majordomus_cli/capability/builtin/
scope.rs1use 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
19pub const SCOPE_URI: &str = "majordomus://scope";
21
22#[derive(Debug, Serialize, Deserialize, JsonSchema)]
26pub struct ScopeReport {
27 pub origin: Origin,
29 pub path: String,
31 pub declaration: Declaration,
33 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#[derive(Debug, Serialize, Deserialize, JsonSchema)]
51#[serde(deny_unknown_fields)]
52pub struct ClassifyInput {
53 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
98pub 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}