1use std::collections::{BTreeMap, BTreeSet};
21
22use schemars::JsonSchema;
23use serde::{Deserialize, Serialize};
24use serde_json::Value;
25
26use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
27use crate::capability::handler::{CapabilityError, Context};
28use crate::capability::model::{CachePolicy, Exposure, McpExposure, McpResource, Stability};
29use crate::capability::module::ModuleDescriptor;
30use crate::model::Object;
31use crate::{capability, module};
32
33use super::{get, normalise_path};
34
35pub const DIRECTORIES_URI: &str = "majordomus://directories";
37
38const CONTEXT_KIND: &str = "context";
40
41const LAYER: &str = ".ai";
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
47#[serde(rename_all = "lowercase")]
48pub enum DirectoryState {
50 Documented,
52 Exempt,
54 Owed,
56}
57
58impl DirectoryState {
59 pub fn as_str(self) -> &'static str {
66 match self {
67 DirectoryState::Documented => "documented",
68 DirectoryState::Exempt => "exempt",
69 DirectoryState::Owed => "owed",
70 }
71 }
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
75pub struct ContractView {
77 pub id: String,
79 pub path: String,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub title: Option<String>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub description: Option<String>,
87 pub status: String,
89 pub scope: String,
91 pub composition: String,
93 pub order: i64,
95 pub providers: Vec<String>,
97 pub audience: Vec<String>,
99 #[serde(default, skip_serializing_if = "Vec::is_empty")]
100 pub tracks: Vec<String>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub children_require_contract: Option<bool>,
105 #[serde(default, skip_serializing_if = "Vec::is_empty")]
106 pub children_exempt: Vec<String>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
111pub struct EffectiveEntry {
113 pub id: String,
115 pub path: String,
117 pub depth: usize,
119 pub order: i64,
121 pub composition: String,
123 pub reason: String,
125 pub local: bool,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
130pub struct DirectoryNode {
132 pub path: String,
134 pub depth: usize,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub parent: Option<String>,
139 #[serde(default, skip_serializing_if = "Vec::is_empty")]
140 pub children: Vec<String>,
142 pub state: DirectoryState,
144 pub requires_contract: bool,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub governed_by: Option<String>,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub exempted_by: Option<String>,
152 pub objects: usize,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub contract: Option<ContractView>,
157 #[serde(default, skip_serializing_if = "Vec::is_empty")]
158 pub effective: Vec<EffectiveEntry>,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
164pub struct DirectoryTallies {
166 pub directories: usize,
168 pub documented: usize,
170 pub exempt: usize,
172 pub owed: usize,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
177pub struct DirectoryReport {
179 pub root: String,
181 pub tallies: DirectoryTallies,
183 pub directories: Vec<DirectoryNode>,
185}
186
187#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
190#[serde(deny_unknown_fields)]
191pub struct DirectoriesInput {
193 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub path: Option<String>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub effective: Option<bool>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub state: Option<DirectoryState>,
203}
204
205impl BenchmarkCases for DirectoriesInput {
206 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
207 vec![
208 NamedCase::new("whole-tree", DirectoriesInput::default()),
209 NamedCase::new(
210 "one-directory",
211 DirectoriesInput {
212 path: Some(".ai/repo/rules".into()),
213 ..Default::default()
214 },
215 ),
216 NamedCase::new(
217 "effective-everywhere",
218 DirectoriesInput {
219 effective: Some(true),
220 ..Default::default()
221 },
222 ),
223 NamedCase::new(
224 "owed",
225 DirectoriesInput {
226 state: Some(DirectoryState::Owed),
227 ..Default::default()
228 },
229 ),
230 ]
231 }
232}
233
234fn text(v: &Value, key: &str) -> Option<String> {
237 v.get(key).and_then(Value::as_str).map(str::to_string)
238}
239
240fn list(v: &Value, key: &str) -> Vec<String> {
241 v.get(key)
242 .and_then(Value::as_array)
243 .map(|a| {
244 a.iter()
245 .filter_map(Value::as_str)
246 .map(str::to_string)
247 .collect()
248 })
249 .unwrap_or_default()
250}
251
252fn contract_of(o: &Object) -> ContractView {
255 let m = &o.metadata;
256 let audience = {
257 let a = list(m, "audience");
258 if a.is_empty() {
259 vec!["human".to_string(), "agent".to_string()]
260 } else {
261 a
262 }
263 };
264 ContractView {
265 id: o.identity.clone(),
266 path: o.provenance.path.clone(),
267 title: o.title.clone(),
268 description: o.description.clone(),
269 status: text(m, "status").unwrap_or_else(|| "active".into()),
270 scope: text(m, "scope").unwrap_or_else(|| "directory".into()),
271 composition: text(m, "composition").unwrap_or_else(|| "extend".into()),
272 order: m.get("order").and_then(Value::as_i64).unwrap_or(0),
273 providers: list(m, "providers"),
274 audience,
275 tracks: list(m, "tracks"),
276 children_require_contract: m
277 .get("children")
278 .and_then(|c| c.get("require_contract"))
279 .and_then(Value::as_bool),
280 children_exempt: m
281 .get("children")
282 .and_then(|c| c.get("exempt"))
283 .and_then(Value::as_array)
284 .map(|a| {
285 a.iter()
286 .filter_map(Value::as_str)
287 .map(str::to_string)
288 .collect()
289 })
290 .unwrap_or_default(),
291 }
292}
293
294fn under(parent: &str, child: &str) -> bool {
296 child == parent || child.starts_with(&format!("{parent}/"))
297}
298
299fn depth_of(dir: &str) -> usize {
301 if dir == LAYER {
302 0
303 } else {
304 dir.split('/').count().saturating_sub(1)
305 }
306}
307
308struct Contracts {
312 by_dir: BTreeMap<String, ContractView>,
313}
314
315impl Contracts {
316 fn build(index: &crate::index::Index) -> Self {
317 let mut by_dir = BTreeMap::new();
318 for o in index.objects.iter().filter(|o| o.kind == CONTEXT_KIND) {
319 by_dir.insert(o.provenance.directory.clone(), contract_of(o));
320 }
321 Contracts { by_dir }
322 }
323
324 fn requirement(&self, dir: &str) -> (bool, Option<String>) {
327 let mut best: Option<(usize, bool, String)> = None;
328 for (d, c) in &self.by_dir {
329 let Some(v) = c.children_require_contract else {
330 continue;
331 };
332 if c.scope != "subtree" || !under(d, dir) {
333 continue;
334 }
335 let depth = depth_of(d);
336 if best.as_ref().is_none_or(|(b, _, _)| depth > *b) {
337 best = Some((depth, v, c.path.clone()));
338 }
339 }
340 match best {
341 Some((_, v, path)) => (v, Some(path)),
342 None => (true, None),
343 }
344 }
345
346 fn exemption(&self, dir: &str) -> Option<String> {
348 for (d, c) in &self.by_dir {
349 if c.scope != "subtree" || !under(d, dir) {
350 continue;
351 }
352 if c.children_exempt.iter().any(|e| under(e, dir)) {
353 return Some(c.path.clone());
354 }
355 }
356 None
357 }
358
359 fn effective(&self, dir: &str, index: &crate::index::Index) -> Vec<EffectiveEntry> {
364 let mut rows: Vec<(usize, i64, String, EffectiveEntry)> = Vec::new();
365 for (d, c) in &self.by_dir {
366 if c.status == "deprecated" {
367 continue;
368 }
369 let (reason, depth) = match c.scope.as_str() {
370 "directory" if d == dir => ("the target directory (scope directory)", depth_of(d)),
371 "subtree" if d == dir => ("the target directory (scope subtree)", depth_of(d)),
372 "subtree" if under(d, dir) => ("an ancestor (scope subtree)", depth_of(d)),
373 "explicit" => {
374 let Some(p) = self
375 .paths_of(index, &c.id)
376 .into_iter()
377 .find(|p| under(p, dir))
378 else {
379 continue;
380 };
381 let depth = depth_of(&p);
382 rows.push((
383 depth,
384 c.order,
385 c.path.clone(),
386 EffectiveEntry {
387 id: c.id.clone(),
388 path: c.path.clone(),
389 depth,
390 order: c.order,
391 composition: c.composition.clone(),
392 reason: "a declared path (scope explicit)".into(),
393 local: d == dir,
394 },
395 ));
396 continue;
397 }
398 _ => continue,
399 };
400 rows.push((
401 depth,
402 c.order,
403 c.path.clone(),
404 EffectiveEntry {
405 id: c.id.clone(),
406 path: c.path.clone(),
407 depth,
408 order: c.order,
409 composition: c.composition.clone(),
410 reason: reason.into(),
411 local: d == dir,
412 },
413 ));
414 }
415 rows.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2)));
416
417 let applicable: BTreeSet<String> = rows.iter().map(|r| r.3.id.clone()).collect();
419 let mut superseded: BTreeSet<String> = BTreeSet::new();
420 for r in &rows {
421 if r.3.composition != "replace" {
422 continue;
423 }
424 for s in self.supersedes_of(index, &r.3.id) {
425 if applicable.contains(&s) {
426 superseded.insert(s);
427 }
428 }
429 }
430 rows.into_iter()
431 .map(|r| r.3)
432 .filter(|e| !superseded.contains(&e.id))
433 .collect()
434 }
435
436 fn paths_of(&self, index: &crate::index::Index, id: &str) -> Vec<String> {
437 Self::list_field(index, id, "paths")
438 }
439
440 fn supersedes_of(&self, index: &crate::index::Index, id: &str) -> Vec<String> {
441 Self::list_field(index, id, "supersedes")
442 }
443
444 fn list_field(index: &crate::index::Index, id: &str, key: &str) -> Vec<String> {
445 index
446 .objects
447 .iter()
448 .find(|o| o.kind == CONTEXT_KIND && o.identity == id)
449 .map(|o| list(&o.metadata, key))
450 .unwrap_or_default()
451 }
452}
453
454fn directories_of(index: &crate::index::Index) -> BTreeMap<String, usize> {
457 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
458 for o in &index.objects {
459 let dir = o.provenance.directory.as_str();
460 if !under(LAYER, dir) {
461 continue;
462 }
463 *counts.entry(dir.to_string()).or_insert(0) += 1;
464 let mut cur = dir;
466 while let Some((parent, _)) = cur.rsplit_once('/') {
467 if !under(LAYER, parent) {
468 break;
469 }
470 counts.entry(parent.to_string()).or_insert(0);
471 cur = parent;
472 }
473 }
474 counts
475}
476
477fn directories_list(
478 ctx: &Context,
479 input: DirectoriesInput,
480) -> Result<DirectoryReport, CapabilityError> {
481 let index = ctx.index.as_ref();
482 let contracts = Contracts::build(index);
483 let counts = directories_of(index);
484
485 let target = match input.path.as_deref() {
486 Some(raw) => {
487 let p = normalise_path(raw)?;
488 if !under(LAYER, &p) {
489 return Err(CapabilityError::InvalidInput(format!(
490 "'{p}' is outside {LAYER}/; the directory contracts are the layer's"
491 )));
492 }
493 if !counts.contains_key(&p) {
494 return Err(CapabilityError::NotFound(format!(
495 "no directory '{p}' in the layer; the index holds nothing under it"
496 )));
497 }
498 Some(p)
499 }
500 None => None,
501 };
502 let want_effective = target.is_some() || input.effective.unwrap_or(false);
503
504 let mut tallies = DirectoryTallies {
505 directories: 0,
506 documented: 0,
507 exempt: 0,
508 owed: 0,
509 };
510 let mut out = Vec::new();
511 for (dir, objects) in &counts {
512 let contract = contracts.by_dir.get(dir).cloned();
513 let (requires, governed_by) = contracts.requirement(dir);
514 let exempted_by = contracts.exemption(dir);
515 let state = if contract.is_some() {
516 DirectoryState::Documented
517 } else if exempted_by.is_some() || !requires {
518 DirectoryState::Exempt
519 } else {
520 DirectoryState::Owed
521 };
522
523 tallies.directories += 1;
525 match state {
526 DirectoryState::Documented => tallies.documented += 1,
527 DirectoryState::Exempt => tallies.exempt += 1,
528 DirectoryState::Owed => tallies.owed += 1,
529 }
530
531 if let Some(t) = &target {
532 if dir != t {
533 continue;
534 }
535 }
536 if let Some(want) = input.state {
537 if want != state {
538 continue;
539 }
540 }
541
542 let parent = dir
543 .rsplit_once('/')
544 .map(|(p, _)| p.to_string())
545 .filter(|p| under(LAYER, p) && counts.contains_key(p));
546 let children: Vec<String> = counts
547 .keys()
548 .filter(|c| c.rsplit_once('/').map(|(p, _)| p) == Some(dir.as_str()))
549 .cloned()
550 .collect();
551
552 out.push(DirectoryNode {
553 path: dir.clone(),
554 depth: depth_of(dir),
555 parent,
556 children,
557 state,
558 requires_contract: requires,
559 governed_by,
560 exempted_by,
561 objects: *objects,
562 contract,
563 effective: if want_effective {
564 contracts.effective(dir, index)
565 } else {
566 Vec::new()
567 },
568 });
569 }
570
571 Ok(DirectoryReport {
572 root: LAYER.to_string(),
573 tallies,
574 directories: out,
575 })
576}
577
578pub fn module() -> ModuleDescriptor {
582 module! {
583 id: "directories",
584 title: "Directory contracts",
585 description: "The layer's directories as a hierarchy: the contract each one declares, what it owes and which contract said so, and the chain that applies to it once inheritance is resolved.",
586 stability: Stability::BehaviorallyVerified,
587 capabilities: [
588 capability! {
589 id: "directories.list",
590 title: "The layer's directory contracts",
591 description: "Every directory of the layer the index knows, with the contract it declares, whether it owes one and which contract decided, and — for a named path, or when asked for everywhere — the effective chain composed from the root down, least specific first.",
592 input: DirectoriesInput,
593 output: DirectoryReport,
594 stability: Stability::BehaviorallyVerified,
595 exposure: Exposure {
596 mcp: Some(McpExposure {
597 tool: Some("majordomus_directories".into()),
598 resource: Some(McpResource { uri: DIRECTORIES_URI.into(), name: "directories".into() }),
599 }),
600 http: get("/api/v1/directories"),
601 cli: None,
602 },
603 tags: ["directories", "context", "introspection"],
604 cache: CachePolicy::Process { max_entries: 8, ttl_seconds: Some(5) },
605 handler: directories_list,
606 },
607 ],
608 }
609}