Skip to main content

ralph/cli/
spec.rs

1//! Clap CLI introspection utilities for emitting a deterministic CLI spec JSON contract.
2//!
3//! Responsibilities:
4//! - Convert an in-memory `clap::Command` (including hidden/internal commands and args) into the
5//!   versioned `CliSpec` contract model.
6//! - Produce deterministic output by sorting commands/args and by emitting a stable JSON shape.
7//!
8//! Not handled here:
9//! - Adding a user-facing CLI command that prints the spec (it is currently exposed only as the
10//!   hidden/internal `ralph __cli-spec` command).
11//! - File IO, stdout/stderr printing, or schema generation.
12//!
13//! Invariants/assumptions:
14//! - The caller provides the fully constructed clap command (e.g. `Cli::command()`).
15//! - Output ordering is deterministic: args are sorted by `id`, subcommands by `name`.
16
17use anyhow::Result;
18use clap::{Arg, ArgAction, Command};
19use std::any::TypeId;
20
21use crate::contracts::{ArgSpec, CLI_SPEC_VERSION, CliSpec, CommandSpec};
22
23/// Convert a clap command tree into the versioned `CliSpec` model.
24pub fn cli_spec_from_command(command: &Command) -> CliSpec {
25    let root_name = command.get_name().to_owned();
26    CliSpec {
27        version: CLI_SPEC_VERSION,
28        root: command_spec_from_command(command, vec![root_name]),
29    }
30}
31
32/// Convert a clap command tree into deterministic pretty JSON.
33pub fn cli_spec_json_pretty_from_command(command: &Command) -> Result<String> {
34    let spec = cli_spec_from_command(command);
35    Ok(serde_json::to_string_pretty(&spec)?)
36}
37
38fn command_spec_from_command(command: &Command, path: Vec<String>) -> CommandSpec {
39    let name = command.get_name().to_owned();
40
41    let mut args: Vec<ArgSpec> = command.get_arguments().map(arg_spec_from_arg).collect();
42    args.sort_by(|a, b| a.id.cmp(&b.id));
43
44    let mut subcommands: Vec<CommandSpec> = command
45        .get_subcommands()
46        .map(|subcommand| {
47            let mut sub_path = path.clone();
48            sub_path.push(subcommand.get_name().to_owned());
49            command_spec_from_command(subcommand, sub_path)
50        })
51        .collect();
52    subcommands.sort_by(|a, b| a.name.cmp(&b.name));
53
54    CommandSpec {
55        name,
56        path,
57        about: command.get_about().map(ToString::to_string),
58        long_about: command.get_long_about().map(ToString::to_string),
59        after_long_help: command.get_after_long_help().map(ToString::to_string),
60        hidden: command.is_hide_set(),
61        args,
62        subcommands,
63    }
64}
65
66fn arg_spec_from_arg(arg: &Arg) -> ArgSpec {
67    let id = arg.get_id().to_string();
68    let index = arg.get_index();
69
70    let effective_range = arg
71        .get_num_args()
72        .unwrap_or_else(|| match arg.get_action() {
73            ArgAction::SetTrue
74            | ArgAction::SetFalse
75            | ArgAction::Count
76            | ArgAction::Help
77            | ArgAction::Version => 0.into(),
78            ArgAction::Set | ArgAction::Append => 1.into(),
79            &_ => 1.into(),
80        });
81    let num_args_min = effective_range.min_values();
82    let num_args_max = match effective_range.max_values() {
83        usize::MAX => None,
84        max => Some(max),
85    };
86
87    let takes_value = num_args_max != Some(0);
88
89    // For flags (no values), clap may expose internal "possible values" that are not meaningful
90    // for a UI; suppress those to keep the contract intuitive.
91    let default_values: Vec<String> = if takes_value {
92        arg.get_default_values()
93            .iter()
94            .map(|value| value.to_string_lossy().to_string())
95            .collect()
96    } else {
97        Vec::new()
98    };
99
100    let mut possible_values: Vec<String> = if takes_value {
101        arg.get_possible_values()
102            .into_iter()
103            .map(|value| value.get_name().to_string())
104            .collect()
105    } else {
106        Vec::new()
107    };
108    possible_values.sort();
109
110    // Heuristic: clap's `ValueEnum` derives parse into the enum type, while raw
111    // `PossibleValuesParser` parses into `String`. We intentionally do not classify `bool`
112    // as a `ValueEnum` even though it has enumerated possible values.
113    let value_type_id = arg.get_value_parser().type_id();
114    let value_enum = takes_value
115        && !possible_values.is_empty()
116        && value_type_id != TypeId::of::<String>()
117        && value_type_id != TypeId::of::<std::ffi::OsString>()
118        && value_type_id != TypeId::of::<std::path::PathBuf>()
119        && value_type_id != TypeId::of::<bool>();
120
121    ArgSpec {
122        id,
123        long: arg.get_long().map(ToOwned::to_owned),
124        short: arg.get_short(),
125        help: arg.get_help().map(ToString::to_string),
126        long_help: arg.get_long_help().map(ToString::to_string),
127        required: arg.is_required_set(),
128        default_values,
129        possible_values,
130        value_enum,
131        num_args_min,
132        num_args_max,
133        global: arg.is_global_set(),
134        hidden: arg.is_hide_set(),
135        positional: index.is_some(),
136        index,
137        action: format!("{:?}", arg.get_action()),
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::{cli_spec_from_command, cli_spec_json_pretty_from_command};
144    use crate::contracts::CLI_SPEC_VERSION;
145    use crate::contracts::{ArgSpec, CommandSpec};
146    use clap::{Arg, Command};
147
148    fn find_command_by_path<'a>(cmd: &'a CommandSpec, path: &[&str]) -> Option<&'a CommandSpec> {
149        if cmd.path.iter().map(String::as_str).eq(path.iter().copied()) {
150            return Some(cmd);
151        }
152        for sub in &cmd.subcommands {
153            if let Some(found) = find_command_by_path(sub, path) {
154                return Some(found);
155            }
156        }
157        None
158    }
159
160    fn find_arg<'a>(cmd: &'a CommandSpec, id: &str) -> Option<&'a ArgSpec> {
161        cmd.args.iter().find(|a| a.id == id)
162    }
163
164    fn assert_sorted(cmd: &CommandSpec) {
165        let sub_names: Vec<&str> = cmd.subcommands.iter().map(|c| c.name.as_str()).collect();
166        let mut sorted_sub_names = sub_names.clone();
167        sorted_sub_names.sort();
168        assert_eq!(
169            sub_names, sorted_sub_names,
170            "subcommands not sorted for {:?}",
171            cmd.path
172        );
173
174        let arg_ids: Vec<&str> = cmd.args.iter().map(|a| a.id.as_str()).collect();
175        let mut sorted_arg_ids = arg_ids.clone();
176        sorted_arg_ids.sort();
177        assert_eq!(
178            arg_ids, sorted_arg_ids,
179            "args not sorted for {:?}",
180            cmd.path
181        );
182
183        for arg in &cmd.args {
184            let mut sorted_possible_values = arg.possible_values.clone();
185            sorted_possible_values.sort();
186            assert_eq!(
187                arg.possible_values, sorted_possible_values,
188                "possible_values not sorted for arg {:?} in {:?}",
189                arg.id, cmd.path
190            );
191            if let Some(max) = arg.num_args_max {
192                assert!(
193                    arg.num_args_min <= max,
194                    "num_args_min must be <= num_args_max for arg {:?} in {:?}",
195                    arg.id,
196                    cmd.path
197                );
198            }
199        }
200
201        for sub in &cmd.subcommands {
202            assert_sorted(sub);
203        }
204    }
205
206    #[test]
207    fn cli_spec_json_is_deterministic_for_ralph_cli() -> anyhow::Result<()> {
208        use clap::CommandFactory;
209
210        let cmd1 = crate::cli::Cli::command();
211        let json1 = cli_spec_json_pretty_from_command(&cmd1)?;
212
213        let cmd2 = crate::cli::Cli::command();
214        let json2 = cli_spec_json_pretty_from_command(&cmd2)?;
215
216        assert_eq!(json1, json2);
217        Ok(())
218    }
219
220    #[test]
221    fn cli_spec_is_sorted_and_has_required_root_fields() {
222        use clap::CommandFactory;
223
224        let command = crate::cli::Cli::command();
225        let spec = cli_spec_from_command(&command);
226
227        assert_eq!(spec.version, CLI_SPEC_VERSION);
228        assert_eq!(spec.root.name, "ralph");
229        assert_eq!(spec.root.path, vec!["ralph".to_string()]);
230
231        assert_sorted(&spec.root);
232    }
233
234    #[test]
235    fn cli_spec_includes_hidden_internal_command_and_marks_it_hidden() {
236        use clap::CommandFactory;
237
238        let command = crate::cli::Cli::command();
239        let spec = cli_spec_from_command(&command);
240
241        let serve = find_command_by_path(&spec.root, &["ralph", "daemon", "serve"])
242            .expect("expected hidden daemon serve command to exist in spec");
243        assert!(serve.hidden, "expected daemon serve to be marked hidden");
244    }
245
246    #[test]
247    fn cli_spec_includes_hidden_internal_arg_and_marks_it_hidden() {
248        use clap::CommandFactory;
249
250        let command = crate::cli::Cli::command();
251        let spec = cli_spec_from_command(&command);
252
253        let run_one = find_command_by_path(&spec.root, &["ralph", "run", "one"])
254            .expect("expected run one command to exist in spec");
255        let arg = find_arg(run_one, "parallel_worker")
256            .expect("expected parallel_worker arg to exist in spec");
257        assert!(arg.hidden, "expected parallel_worker to be marked hidden");
258    }
259
260    #[test]
261    fn cli_spec_includes_defaults_possible_values_num_args_and_value_enum() {
262        use clap::CommandFactory;
263
264        let command = crate::cli::Cli::command();
265        let spec = cli_spec_from_command(&command);
266
267        let color = find_arg(&spec.root, "color").expect("expected color arg to exist");
268        assert_eq!(color.default_values, vec!["auto".to_string()]);
269        assert_eq!(
270            color.possible_values,
271            vec![
272                "always".to_string(),
273                "auto".to_string(),
274                "never".to_string()
275            ]
276        );
277        assert!(
278            color.value_enum,
279            "expected --color to be detected as a ValueEnum"
280        );
281        assert_eq!(color.num_args_min, 1);
282        assert_eq!(color.num_args_max, Some(1));
283        assert!(!color.required);
284        assert_eq!(color.help.as_deref(), Some("Color output control"));
285
286        let verbose = find_arg(&spec.root, "verbose").expect("expected verbose arg to exist");
287        assert!(verbose.default_values.is_empty());
288        assert!(verbose.possible_values.is_empty());
289        assert!(
290            !verbose.value_enum,
291            "expected --verbose to not be detected as a ValueEnum"
292        );
293        assert_eq!(verbose.num_args_min, 0);
294        assert_eq!(verbose.num_args_max, Some(0));
295        assert!(!verbose.required);
296    }
297
298    #[test]
299    fn cli_spec_reflects_unbounded_num_args_and_non_value_enum_possible_values() {
300        let root = Command::new("root").arg(
301            Arg::new("mode")
302                .long("mode")
303                .value_parser(["a", "b"])
304                .default_value("a"),
305        );
306        let root = root.arg(Arg::new("items").long("item").num_args(1..));
307
308        let spec = cli_spec_from_command(&root);
309        let mode = find_arg(&spec.root, "mode").expect("expected mode arg");
310        assert_eq!(mode.default_values, vec!["a".to_string()]);
311        assert_eq!(mode.possible_values, vec!["a".to_string(), "b".to_string()]);
312        assert!(!mode.value_enum, "expected mode to not be a ValueEnum");
313        assert_eq!(mode.num_args_min, 1);
314        assert_eq!(mode.num_args_max, Some(1));
315
316        let items = find_arg(&spec.root, "items").expect("expected items arg");
317        assert_eq!(items.num_args_min, 1);
318        assert_eq!(items.num_args_max, None);
319    }
320
321    #[test]
322    fn cli_spec_is_deterministic_even_when_builder_insertion_order_differs() -> anyhow::Result<()> {
323        fn build(order: u8) -> Command {
324            let mut root = Command::new("root");
325
326            let a = Arg::new("alpha").long("alpha");
327            let z = Arg::new("zeta").long("zeta");
328
329            let sub_a = Command::new("a").arg(Arg::new("x").long("x"));
330            let sub_b = Command::new("b").arg(Arg::new("y").long("y").hide(true));
331
332            if order == 0 {
333                root = root.arg(z).arg(a);
334                root = root.subcommand(sub_b).subcommand(sub_a);
335            } else {
336                root = root.arg(a).arg(z);
337                root = root.subcommand(sub_a).subcommand(sub_b);
338            }
339
340            root
341        }
342
343        let json1 = cli_spec_json_pretty_from_command(&build(0))?;
344        let json2 = cli_spec_json_pretty_from_command(&build(1))?;
345
346        assert_eq!(json1, json2);
347        Ok(())
348    }
349}