Skip to main content

majordomus_cli/capability/builtin/
scope.rs

1//! The scope capabilities of the `repository` module: the repository scope as data, and
2//! any path judged against it. They are composed in `repository.rs`; the declarative
3//! `scope` kind keeps the `scope` module id for the declaration itself.
4//!
5//! The scope is compiled once at start-up and carried by the index; nothing here reads the
6//! declaration again. `repository.scope` answers the declaration, where it came from and
7//! every tracked file tallied against it; `repository.scope_classify` judges one path by
8//! name, size and content, and names the rule that decided.
9
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12
13use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
14use crate::capability::handler::{CapabilityError, Context};
15use crate::scope::{Classification, Declaration, Origin, Tally};
16
17use super::Empty;
18
19/// The URI under which `repository.scope` is read as an MCP resource.
20pub const SCOPE_URI: &str = "majordomus://scope";
21
22// ---------------------------------------------------------------- repository.scope
23
24/// The scope: the declaration as read, its origin, and every tracked file against it.
25#[derive(Debug, Serialize, Deserialize, JsonSchema)]
26pub struct ScopeReport {
27    /// `repository` when the manifest names a scope section, `distribution` for the default.
28    pub origin: Origin,
29    /// The file the declaration was read from.
30    pub path: String,
31    /// The declaration as read.
32    pub declaration: Declaration,
33    /// Every tracked file judged by name and size, counted; the out ones listed.
34    pub tracked: Tally,
35}
36
37pub(super) fn scope_info(ctx: &Context, _: Empty) -> Result<ScopeReport, CapabilityError> {
38    let scoped = &ctx.index.scoped;
39    Ok(ScopeReport {
40        origin: scoped.scope.origin(),
41        path: scoped.scope.path().to_string(),
42        declaration: scoped.scope.declaration().clone(),
43        tracked: scoped.tally.clone(),
44    })
45}
46
47// ---------------------------------------------------------------- repository.scope_classify
48
49/// Which path to judge.
50#[derive(Debug, Serialize, Deserialize, JsonSchema)]
51#[serde(deny_unknown_fields)]
52pub struct ClassifyInput {
53    /// Repository-relative, forward slashes; `./` is stripped. An absolute path or a
54    /// `..` segment is an invalid input.
55    pub path: String,
56}
57
58impl BenchmarkCases for ClassifyInput {
59    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
60        let mut cases = vec![
61            NamedCase::new(
62                "layer-file",
63                ClassifyInput {
64                    path: ".ai/manifest.yaml".into(),
65                },
66            ),
67            NamedCase::new(
68                "local-half",
69                ClassifyInput {
70                    path: ".ai/local/state/current.yaml".into(),
71                },
72            ),
73            NamedCase::new(
74                "secret",
75                ClassifyInput {
76                    path: "config/.env".into(),
77                },
78            ),
79            NamedCase::new(
80                "undeclared",
81                ClassifyInput {
82                    path: "node_modules/left-pad/index.js".into(),
83                },
84            ),
85        ];
86        if let Some(o) = ctx.index.objects.first() {
87            cases.push(NamedCase::new(
88                "first-object",
89                ClassifyInput {
90                    path: o.provenance.path.clone(),
91                },
92            ));
93        }
94        cases
95    }
96}
97
98/// A repository-relative path as the scope judges it: forward slashes, no `./`, no
99/// trailing slash. Absolute paths and parent segments are refused.
100///
101/// ```
102/// use majordomus_cli::capability::builtin::normalise_path;
103/// assert_eq!(normalise_path("./docs/CLI.md").unwrap(), "docs/CLI.md");
104/// assert_eq!(normalise_path("docs\\CLI.md").unwrap(), "docs/CLI.md");
105/// assert_eq!(normalise_path("docs/").unwrap(), "docs");
106/// assert!(normalise_path("/etc/passwd").is_err());
107/// assert!(normalise_path("../x").is_err());
108/// assert!(normalise_path("").is_err());
109/// ```
110pub fn normalise_path(raw: &str) -> Result<String, CapabilityError> {
111    let mut p = raw.trim().replace('\\', "/");
112    while let Some(rest) = p.strip_prefix("./") {
113        p = rest.to_string();
114    }
115    while p.ends_with('/') && p.len() > 1 {
116        p.pop();
117    }
118    if p.is_empty() || p == "." {
119        return Err(CapabilityError::InvalidInput(
120            "path is empty; name a repository-relative path".into(),
121        ));
122    }
123    if p.starts_with('/') || p.chars().nth(1) == Some(':') {
124        return Err(CapabilityError::InvalidInput(format!(
125            "'{raw}' is absolute; name a repository-relative path"
126        )));
127    }
128    if p.split('/').any(|s| s == "..") {
129        return Err(CapabilityError::InvalidInput(format!(
130            "'{raw}' carries a '..' segment; name a path inside the repository"
131        )));
132    }
133    let collapsed: Vec<&str> = p
134        .split('/')
135        .filter(|s| !s.is_empty() && *s != ".")
136        .collect();
137    Ok(collapsed.join("/"))
138}
139
140pub(super) fn scope_classify(
141    ctx: &Context,
142    input: ClassifyInput,
143) -> Result<Classification, CapabilityError> {
144    let path = normalise_path(&input.path)?;
145    let root = std::path::Path::new(&ctx.index.repository.root);
146    Ok(ctx.index.scoped.scope.classify(root, &path))
147}