majordomus_cli/command_graph/
native.rs1use crate::capability::model::CliExposure;
25use crate::capability::registry::CapabilityRegistry;
26use crate::cli::docs::{self, ArgDoc, CommandDoc};
27
28use super::model::{
29 ArgumentSpec, Availability, CommandId, CommandNode, Diagnostic, Execution, Origin, Projections,
30 Provenance, Requirement, Secrecy, ValueChoice, ValueSource,
31};
32use super::semantics;
33
34pub struct Contribution {
36 pub nodes: Vec<CommandNode>,
38 pub diagnostics: Vec<Diagnostic>,
41}
42
43pub fn contribute(registry: Option<&CapabilityRegistry>) -> Contribution {
49 let tree = docs::tree();
50 let mut nodes = Vec::new();
51 let mut diagnostics = Vec::new();
52 let mut paths: Vec<Vec<String>> = Vec::new();
53
54 for doc in tree.flatten() {
55 if doc.path.len() < 2 {
57 continue;
58 }
59 let path: Vec<String> = doc.path[1..].to_vec();
60 paths.push(path.clone());
61 nodes.push(node(doc, &path, registry));
62 }
63
64 for stale in semantics::unused(&paths) {
65 diagnostics.push(
66 Diagnostic::error(
67 "semantics-names-no-command",
68 format!("the semantics beside the command line name `{stale}`, which the command line does not declare"),
69 Vec::new(),
70 )
71 .with_remedy("remove the entry, or restore the command it names"),
72 );
73 }
74
75 if let Some(registry) = registry {
76 for capability in registry.iter() {
77 let Some(CliExposure { path: words }) = &capability.exposure.cli else {
78 continue;
79 };
80 if !paths.iter().any(|p| p == words) {
81 diagnostics.push(
82 Diagnostic::error(
83 "capability-names-no-command",
84 format!(
85 "capability `{}` is exposed as `majordomus {}`, which the command line does not declare",
86 capability.id,
87 words.join(" ")
88 ),
89 Vec::new(),
90 )
91 .with_remedy("add the clap arm, or drop the cli exposure"),
92 );
93 }
94 }
95 }
96
97 Contribution { nodes, diagnostics }
98}
99
100fn node(doc: &CommandDoc, path: &[String], registry: Option<&CapabilityRegistry>) -> CommandNode {
102 let sem = semantics::of(path);
103 let capability = registry.and_then(|r| r.by_cli(path));
104
105 let provenance = Provenance {
106 declared_in: docs::DECLARATION.into(),
107 read_by: Some("majordomus commands list".into()),
108 capability: capability.map(|c| c.id.clone()),
109 };
110
111 let effect = capability
115 .map(|c| super::model::Effect::of_capability(c.execution))
116 .unwrap_or(sem.effect);
117 let stability = capability
118 .map(|c| c.stability)
119 .unwrap_or(crate::capability::Stability::Implemented);
120
121 let arguments = doc
122 .args
123 .iter()
124 .map(|a| argument(a, path, &doc.usage))
125 .collect();
126
127 CommandNode {
128 id: CommandId::derive(Origin::Executable, path),
129 origin: Origin::Executable,
130 invocation: format!("majordomus {}", path.join(" ")),
131 summary: doc.about.clone(),
132 description: doc.long_about.clone(),
133 runnable: doc.executable,
134 arguments,
135 execution: Execution {
136 origin: Origin::Executable,
137 argv: path.to_vec(),
138 },
139 effect,
140 interactivity: sem.interactivity,
141 stability,
142 availability: Availability {
143 available: true,
144 reason: None,
145 requires: sem.requires.to_vec(),
146 },
147 group: path.first().cloned(),
148 tags: capability.map(|c| c.tags.clone()).unwrap_or_default(),
149 aliases: doc.aliases.clone(),
150 deprecation: None,
151 provenance,
152 projections: Projections::default(),
153 entrypoint: Some(path.len() == 1),
154 path: path.to_vec(),
155 }
156}
157
158fn argument(arg: &ArgDoc, path: &[String], usage: &str) -> ArgumentSpec {
160 let values: Vec<ValueChoice> = arg
161 .possible_values
162 .iter()
163 .map(|v| ValueChoice {
164 value: v.name.clone(),
165 description: v.help.clone(),
166 })
167 .collect();
168
169 let source = infer(arg, path, &values);
170 let secrecy = semantics::secrecy(&arg.name, arg.value_name.as_deref());
171 let source = if secrecy == Secrecy::Secret {
173 ValueSource::Secret
174 } else {
175 source
176 };
177
178 ArgumentSpec {
179 name: arg.name.clone(),
180 long: arg.long.clone(),
181 short: arg.short,
182 positional: arg.positional,
183 takes_value: arg.takes_value,
184 required: arg.required,
185 variadic: arg.positional && usage.contains("..."),
186 global: arg.global,
187 help: arg.help.clone(),
188 value_name: arg.value_name.clone(),
189 values,
190 defaults: arg.defaults.clone(),
191 source,
192 secrecy,
193 }
194}
195
196fn infer(arg: &ArgDoc, path: &[String], values: &[ValueChoice]) -> ValueSource {
204 if !arg.takes_value {
205 return ValueSource::Free;
206 }
207 if !values.is_empty() {
208 return ValueSource::Enumerated;
209 }
210 if let Some(source) = semantics::binding(path, &arg.name) {
211 return source;
212 }
213 let placeholder = arg.value_name.as_deref().unwrap_or("").to_ascii_uppercase();
214 match placeholder.as_str() {
215 "PATH" | "DIR" | "FILE" | "OUT" => ValueSource::Path,
216 "BRANCH" => ValueSource::Branch,
217 "SHELL" => ValueSource::Shell,
218 _ => match arg.name.as_str() {
219 "repo" | "out" | "path" => ValueSource::Path,
220 "branch" | "base" => ValueSource::Branch,
221 "shell" => ValueSource::Shell,
222 _ => ValueSource::Free,
223 },
224 }
225}
226
227pub const NATIVE_REQUIREMENTS: &[Requirement] = &[Requirement::Repository, Requirement::Layer];
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 #[test]
236 fn the_walk_finds_the_command_line() {
237 let c = contribute(None);
238 assert!(
239 c.nodes.len() > 40,
240 "the command line has more commands than {}",
241 c.nodes.len()
242 );
243 let status = c
244 .nodes
245 .iter()
246 .find(|n| n.id.as_str() == "executable.worktree.status")
247 .expect("worktree status is declared");
248 assert_eq!(status.invocation, "majordomus worktree status");
249 assert!(status.runnable);
250 assert_eq!(status.group.as_deref(), Some("worktree"));
251 }
252
253 #[test]
254 fn semantics_reach_the_nodes() {
255 let c = contribute(None);
256 let remove = c
257 .nodes
258 .iter()
259 .find(|n| n.id.as_str() == "executable.worktree.remove")
260 .expect("worktree remove is declared");
261 assert_eq!(remove.effect, super::super::model::Effect::Destructive);
262 let serve = c
263 .nodes
264 .iter()
265 .find(|n| n.id.as_str() == "executable.serve")
266 .expect("serve is declared");
267 assert_eq!(
268 serve.interactivity,
269 super::super::model::Interactivity::LongRunning
270 );
271 }
272
273 #[test]
274 fn every_annotation_names_a_command_that_exists() {
275 let c = contribute(None);
276 let stale: Vec<_> = c
277 .diagnostics
278 .iter()
279 .filter(|d| d.code == "semantics-names-no-command")
280 .collect();
281 assert!(stale.is_empty(), "stale annotations: {stale:?}");
282 }
283
284 #[test]
285 fn value_sources_are_inferred_from_the_declaration() {
286 let c = contribute(None);
287 let path_cmd = c
288 .nodes
289 .iter()
290 .find(|n| n.id.as_str() == "executable.worktree.path")
291 .expect("worktree path is declared");
292 let branch = path_cmd
293 .arguments
294 .iter()
295 .find(|a| a.name == "branch")
296 .expect("it takes a branch");
297 assert_eq!(branch.source, ValueSource::Branch);
298
299 let enumerated = c
301 .nodes
302 .iter()
303 .flat_map(|n| n.arguments.iter())
304 .find(|a| !a.values.is_empty())
305 .expect("some argument is a value enum");
306 assert_eq!(enumerated.source, ValueSource::Enumerated);
307 }
308}