majordomus_cli/command_graph/
tool.rs1use std::path::Path;
26
27use serde::Deserialize;
28
29use crate::metadata::yaml;
30
31use super::model::{
32 Availability, CommandId, CommandNode, Diagnostic, Effect, Execution, Interactivity, Origin,
33 Projections, Provenance, Requirement,
34};
35
36pub const REGISTRY: &str = "commands.yaml";
38
39pub const DECLARATION: &str = "share/commands.yaml";
41
42pub const DISPATCH: &str = "bin/majordomus";
45
46#[derive(Debug, Deserialize)]
48struct Registry {
49 version: u32,
50 #[serde(default)]
51 commands: Vec<Entry>,
52}
53
54#[derive(Debug, Deserialize)]
56struct Entry {
57 id: String,
58 #[serde(default)]
59 summary: String,
60 #[serde(default)]
61 category: Option<String>,
62 #[serde(default)]
63 visibility: Option<String>,
64 #[serde(default)]
65 class: Option<String>,
66 #[serde(default)]
67 requires_repository: Option<bool>,
68 #[serde(default)]
69 requires_task: Option<String>,
70 #[serde(default)]
71 syntax: Option<String>,
72 #[serde(default)]
73 note: Option<String>,
74}
75
76pub struct Contribution {
78 pub nodes: Vec<CommandNode>,
80 pub diagnostics: Vec<Diagnostic>,
82}
83
84pub fn contribute(share: &Path) -> Contribution {
90 let path = share.join(REGISTRY);
91 let Ok(text) = std::fs::read_to_string(&path) else {
92 return Contribution {
93 nodes: Vec::new(),
94 diagnostics: Vec::new(),
95 };
96 };
97 let registry: Registry = match yaml::parse_into(&text) {
98 Ok(r) => r,
99 Err(e) => {
100 return Contribution {
101 nodes: Vec::new(),
102 diagnostics: vec![Diagnostic::error(
103 "tool-registry-unreadable",
104 format!("{DECLARATION} could not be read: {e}"),
105 Vec::new(),
106 )],
107 }
108 }
109 };
110 let mut diagnostics = Vec::new();
111 if registry.version != 1 {
112 diagnostics.push(Diagnostic::warning(
113 "tool-registry-version",
114 format!(
115 "{DECLARATION} declares version {}, and this reader knows version 1",
116 registry.version
117 ),
118 Vec::new(),
119 ));
120 }
121
122 let nodes = registry
123 .commands
124 .iter()
125 .filter(|e| e.visibility.as_deref() != Some("internal"))
128 .map(node)
129 .collect();
130
131 Contribution { nodes, diagnostics }
132}
133
134fn node(entry: &Entry) -> CommandNode {
136 let path = vec![entry.id.clone()];
137 let effect = effect_of(entry.class.as_deref());
138 let interactivity = interactivity_of(entry.syntax.as_deref());
139
140 let mut requires = Vec::new();
141 if entry.requires_repository.unwrap_or(true) {
142 requires.push(Requirement::Repository);
143 }
144 requires.push(Requirement::Layer);
145 if entry.requires_task.as_deref() == Some("required") {
146 requires.push(Requirement::Task);
147 }
148
149 CommandNode {
150 id: CommandId::derive(Origin::Tool, &path),
151 origin: Origin::Tool,
152 invocation: format!("majordomus {}", entry.id),
153 summary: entry.summary.clone(),
154 description: entry.note.clone(),
155 runnable: true,
156 arguments: Vec::new(),
161 execution: Execution {
162 origin: Origin::Tool,
163 argv: path.clone(),
164 },
165 effect,
166 interactivity,
167 stability: crate::capability::Stability::BehaviorallyVerified,
168 availability: Availability {
169 available: true,
170 reason: None,
171 requires,
172 },
173 group: entry.category.clone(),
174 tags: Vec::new(),
175 aliases: Vec::new(),
176 deprecation: None,
177 provenance: Provenance {
178 declared_in: DECLARATION.into(),
179 read_by: Some(format!(
180 "{DISPATCH} (the dispatch table it is reconciled against)"
181 )),
182 capability: None,
183 },
184 projections: Projections::default(),
185 entrypoint: Some(true),
186 path,
187 }
188}
189
190fn effect_of(class: Option<&str>) -> Effect {
196 match class {
197 Some("read-only") => Effect::ReadOnly,
198 Some("state-mutating") => Effect::LocalMutation,
199 Some("generated-output-mutating") => Effect::RepositoryMutation,
200 _ => Effect::RepositoryMutation,
201 }
202}
203
204fn interactivity_of(syntax: Option<&str>) -> Interactivity {
206 match syntax {
207 Some(s) if s.contains('<') && !s.contains("<--") => Interactivity::Interactive,
208 _ => Interactivity::NonInteractive,
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
217 fn classes_translate_to_effects() {
218 assert_eq!(effect_of(Some("read-only")), Effect::ReadOnly);
219 assert_eq!(effect_of(Some("state-mutating")), Effect::LocalMutation);
220 assert_eq!(
221 effect_of(Some("generated-output-mutating")),
222 Effect::RepositoryMutation
223 );
224 assert_eq!(effect_of(Some("something-new")), Effect::RepositoryMutation);
227 assert_eq!(effect_of(None), Effect::RepositoryMutation);
228 }
229
230 #[test]
231 fn a_body_on_standard_input_is_interactive() {
232 assert_eq!(
233 interactivity_of(Some("majordomus checkpoint [--derive] < body.md")),
234 Interactivity::Interactive
235 );
236 assert_eq!(
237 interactivity_of(Some("majordomus check [--json]")),
238 Interactivity::NonInteractive
239 );
240 }
241
242 #[test]
243 fn the_shipped_registry_reads() {
244 let share = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
245 .parent()
246 .and_then(|p| p.parent())
247 .map(|root| root.join("share"))
248 .expect("the repository root is two levels above the crate");
249 if !share.join(REGISTRY).exists() {
250 return; }
252 let c = contribute(&share);
253 assert!(c.nodes.len() > 20, "read {} commands", c.nodes.len());
254 let check = c
255 .nodes
256 .iter()
257 .find(|n| n.id.as_str() == "tool.check")
258 .expect("check is a public command of the shell tool");
259 assert_eq!(check.invocation, "majordomus check");
260 assert!(c.diagnostics.is_empty(), "{:?}", c.diagnostics);
261 }
262}