Skip to main content

majordomus_cli/capability/builtin/
directories.rs

1//! The `directories` module: the layer's directory contracts as a hierarchy, and what
2//! each directory owes.
3//!
4//! A directory of the layer says what it is for in a context document, and that document
5//! composes with the ones above it. Two questions follow from that and neither was
6//! answerable on the wire: what does this repository's contract hierarchy look like, and
7//! which contracts actually apply to a directory once inheritance has been resolved. This
8//! module answers both — the local contract a directory declares, and the effective chain
9//! composed from the root down — so inheritance is inspectable rather than folklore.
10//!
11//! The tree is derived from the index and from nothing else. Every directory here is the
12//! directory of a file the index holds, with its ancestors filled in, so the hierarchy is
13//! the one the registry already knows: adding a directory with tracked content puts it in
14//! this answer, in the API, in the Cockpit and in the generated reference at once, and no
15//! list anywhere names a directory. That also fixes the boundary: a directory holding
16//! nothing tracked is not part of the shared contract and does not appear. The gate that
17//! walks the working tree and refuses a commit is `majordomus context validate`; this is
18//! the projection of the same rule, not a second enforcement of it.
19
20use 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
35/// The URI under which `directories.list` is read as an MCP resource.
36pub const DIRECTORIES_URI: &str = "majordomus://directories";
37
38/// The kind whose objects are directory contracts.
39const CONTEXT_KIND: &str = "context";
40
41/// The layer root every governed directory sits under.
42const LAYER: &str = ".ai";
43
44// ---------------------------------------------------------------- output types
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
47#[serde(rename_all = "lowercase")]
48/// Whether a directory of the layer carries the contract it owes.
49pub enum DirectoryState {
50    /// It carries a context document of its own.
51    Documented,
52    /// It owes none, because a contract above it says so.
53    Exempt,
54    /// It owes one and has none.
55    Owed,
56}
57
58impl DirectoryState {
59    /// The word this state is reported under.
60    ///
61    /// ```
62    /// use majordomus_cli::capability::builtin::DirectoryState;
63    /// assert_eq!(DirectoryState::Owed.as_str(), "owed");
64    /// ```
65    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)]
75/// A directory contract as the document declares it, before anything is inherited.
76pub struct ContractView {
77    /// The document's identity; it survives a move, the file name does not.
78    pub id: String,
79    /// The document's path, repository-relative.
80    pub path: String,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    /// One line naming the directory.
83    pub title: Option<String>,
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    /// One line saying what the directory is for.
86    pub description: Option<String>,
87    /// `active` or `deprecated`; a deprecated document is listed and never applied.
88    pub status: String,
89    /// `directory`, `subtree` or `explicit`: how far the document reaches.
90    pub scope: String,
91    /// `extend`, `replace` or `final`: how it composes with what is above it.
92    pub composition: String,
93    /// Ties within one depth are broken by this, then by path.
94    pub order: i64,
95    /// `*`, or the providers the document is written for.
96    pub providers: Vec<String>,
97    /// Who it addresses; both when it says nothing.
98    pub audience: Vec<String>,
99    #[serde(default, skip_serializing_if = "Vec::is_empty")]
100    /// Pathspecs whose change names this document for review.
101    pub tracks: Vec<String>,
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    /// Whether the directories below owe a contract, when this document says.
104    pub children_require_contract: Option<bool>,
105    #[serde(default, skip_serializing_if = "Vec::is_empty")]
106    /// Subtrees below this one that owe nothing: carried, not authored here.
107    pub children_exempt: Vec<String>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
111/// One contract in a directory's effective chain, in the order it is applied.
112pub struct EffectiveEntry {
113    /// The document's identity.
114    pub id: String,
115    /// The document's path.
116    pub path: String,
117    /// Depth below the layer root; less is less specific and applies first.
118    pub depth: usize,
119    /// The declared order, the second sort key.
120    pub order: i64,
121    /// `extend`, `replace` or `final`.
122    pub composition: String,
123    /// Why it applies to this directory, in words.
124    pub reason: String,
125    /// True for the directory's own document, false for one it inherits.
126    pub local: bool,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
130/// One directory of the layer, what it owes, and what applies to it.
131pub struct DirectoryNode {
132    /// Repository-relative, forward slashes.
133    pub path: String,
134    /// Segments below the layer root; the root itself is 0.
135    pub depth: usize,
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    /// The directory above, when it is inside the layer.
138    pub parent: Option<String>,
139    #[serde(default, skip_serializing_if = "Vec::is_empty")]
140    /// The directories immediately below, sorted.
141    pub children: Vec<String>,
142    /// Whether it carries the contract it owes.
143    pub state: DirectoryState,
144    /// Whether a contract is owed here at all.
145    pub requires_contract: bool,
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    /// The contract that made the requirement explicit, when one did.
148    pub governed_by: Option<String>,
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    /// The contract that released this subtree, when one did.
151    pub exempted_by: Option<String>,
152    /// How many objects of any kind the index holds directly in this directory.
153    pub objects: usize,
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    /// The contract this directory declares, before inheritance.
156    pub contract: Option<ContractView>,
157    #[serde(default, skip_serializing_if = "Vec::is_empty")]
158    /// The contracts that apply here once inheritance is resolved, least specific first.
159    /// Present when the request asked for it, or when it asked about one path.
160    pub effective: Vec<EffectiveEntry>,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
164/// How many directories are in each state.
165pub struct DirectoryTallies {
166    /// Every directory of the layer the index knows.
167    pub directories: usize,
168    /// Carrying a contract of their own.
169    pub documented: usize,
170    /// Released by a contract above them.
171    pub exempt: usize,
172    /// Owing a contract and carrying none.
173    pub owed: usize,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
177/// The layer's directories, what each owes, and the contracts that apply.
178pub struct DirectoryReport {
179    /// The layer root the tree is rooted at.
180    pub root: String,
181    /// The directories in each state.
182    pub tallies: DirectoryTallies,
183    /// Every directory asked for, sorted by path.
184    pub directories: Vec<DirectoryNode>,
185}
186
187// ---------------------------------------------------------------- input
188
189#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
190#[serde(deny_unknown_fields)]
191/// Which directories to answer for, and how much of the hierarchy to resolve.
192pub struct DirectoriesInput {
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    /// One directory, repository-relative and inside the layer. Its effective chain is
195    /// always resolved. Absent means every directory of the layer.
196    pub path: Option<String>,
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    /// Resolve the effective chain for every directory, not only for a named one.
199    pub effective: Option<bool>,
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    /// Only directories in this state: `documented`, `exempt` or `owed`.
202    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
234// ---------------------------------------------------------------- reading the metadata
235
236fn 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
252/// The contract a context object declares. Defaults match the contract's own: a document
253/// with no audience addresses both, and `order` and the rest are required of it.
254fn 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
294/// Is `child` `parent` itself, or below it?
295fn under(parent: &str, child: &str) -> bool {
296    child == parent || child.starts_with(&format!("{parent}/"))
297}
298
299/// Segments below the layer root.
300fn depth_of(dir: &str) -> usize {
301    if dir == LAYER {
302        0
303    } else {
304        dir.split('/').count().saturating_sub(1)
305    }
306}
307
308// ---------------------------------------------------------------- the tree
309
310/// One directory's declared contract, keyed by directory.
311struct 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    /// The contract nearest at or above `dir` that says what its children owe, and what it
325    /// said. Absent everywhere above means a contract is owed.
326    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    /// The contract that released `dir`, when one names it or a subtree above it.
347    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    /// The chain that applies to `dir`: every document whose scope reaches it, least
360    /// specific first — depth, then declared order, then path — with a `replace`
361    /// document's supersessions dropped. This is the composition the resolver applies;
362    /// a deprecated document is discovered and never applied.
363    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        // a replace document stands in for what it supersedes, which leaves the chain
418        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
454/// Every directory of the layer the index knows: the directory of each object it holds,
455/// with the ancestors between it and the layer root filled in.
456fn 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        // every directory between this one and the root is a directory of the layer too
465        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        // the tallies count the whole layer, never the filtered answer
524        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
578// ---------------------------------------------------------------- the module
579
580/// The `directories` module: the layer's contract hierarchy, projected once.
581pub 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}