1use anyhow::Result;
18use clap::{Arg, ArgAction, Command};
19use std::any::TypeId;
20
21use crate::contracts::{ArgSpec, CLI_SPEC_VERSION, CliSpec, CommandSpec};
22
23pub 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
32pub 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 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 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}