1use clap::{Arg, ArgAction, Command};
4
5use super::{
6 INTROSPECT_SCHEMA_ID, INTROSPECT_SCHEMA_VERSION,
7 document::{
8 ArgInfo, ArgKind, BinaryInfo, Capabilities, CommandInfo, IntrospectDocument, ValueType,
9 },
10 registry::{CommandMeta, CommandRegistry},
11};
12
13impl CommandInfo {
14 fn collect_ids_into(&self, out: &mut Vec<String>) {
16 out.push(self.command_id.clone());
17 for sub in &self.subcommands {
18 sub.collect_ids_into(out);
19 }
20 }
21}
22
23pub fn collect_command_ids(doc: &IntrospectDocument) -> Vec<String> {
26 let mut out = Vec::new();
27 for cmd in &doc.commands {
28 cmd.collect_ids_into(&mut out);
29 }
30 out
31}
32
33pub fn duplicate_command_ids(doc: &IntrospectDocument) -> Vec<String> {
41 let mut seen = std::collections::BTreeSet::new();
42 let mut dups = Vec::new();
43 for id in collect_command_ids(doc) {
44 if !seen.insert(id.clone()) && !dups.contains(&id) {
45 dups.push(id);
46 }
47 }
48 dups
49}
50
51pub fn build_document(command: &Command, registry: &CommandRegistry) -> IntrospectDocument {
54 let binary = build_binary_info(command);
55 let mut commands = Vec::new();
56
57 if let Some(root) = build_root_command_info(command, registry) {
60 commands.push(root);
61 }
62
63 let root_path = vec![command.get_name().to_string()];
64 for sub in command.get_subcommands() {
65 commands.push(build_command_info(sub, &root_path, registry));
66 }
67
68 IntrospectDocument {
69 schema_id: INTROSPECT_SCHEMA_ID.to_string(),
70 schema_version: INTROSPECT_SCHEMA_VERSION,
71 binary,
72 commands,
73 }
74}
75
76fn build_binary_info(command: &Command) -> BinaryInfo {
77 let global_args = command
79 .get_arguments()
80 .filter(|a| a.is_global_set() && !is_help_or_version(a))
81 .map(build_arg_info)
82 .collect();
83
84 BinaryInfo {
85 name: command.get_name().to_string(),
86 version: command.get_version().unwrap_or("").to_string(),
87 long_version: command.get_long_version().map(str::to_string),
88 description: command.get_about().map(|s| s.to_string()),
89 global_args,
90 }
91}
92
93fn build_root_command_info(command: &Command, registry: &CommandRegistry) -> Option<CommandInfo> {
102 if command.is_subcommand_required_set() {
103 return None;
104 }
105
106 let args: Vec<ArgInfo> = command
107 .get_arguments()
108 .filter(|a| !a.is_global_set() && !is_help_or_version(a))
109 .map(build_arg_info)
110 .collect();
111
112 if args.is_empty() {
113 return None;
114 }
115
116 let path = vec![command.get_name().to_string()];
117 let meta = registry.lookup(&[]);
118
119 let command_id = derive_command_id(&path, meta);
120 let command_id_stable = meta.and_then(|m| m.command_id).is_some();
121 let capabilities = meta.map_or_else(Capabilities::default, |m| m.capabilities.clone());
122 let capabilities_declared = meta.is_some_and(|m| m.capabilities_declared);
123 let exit_codes = meta.map_or_else(Vec::new, |m| m.exit_codes.to_vec());
124
125 let aliases = command.get_visible_aliases().map(str::to_string).collect::<Vec<_>>();
126 let summary = command.get_about().map(|s| s.to_string());
127 let description = command
128 .get_long_about()
129 .map(|s| s.to_string())
130 .filter(|d| Some(d.as_str()) != summary.as_deref());
131
132 Some(CommandInfo {
133 command_id,
134 command_id_stable,
135 path,
136 aliases,
137 summary,
138 description,
139 args,
140 subcommands: Vec::new(),
141 capabilities,
142 capabilities_declared,
143 exit_codes,
144 hidden: command.is_hide_set(),
145 })
146}
147
148fn build_command_info(
149 command: &Command,
150 parent_path: &[String],
151 registry: &CommandRegistry,
152) -> CommandInfo {
153 let mut path = parent_path.to_vec();
155 path.push(command.get_name().to_string());
156
157 let lookup_path: Vec<&str> = path.iter().skip(1).map(String::as_str).collect();
159 let meta = registry.lookup(&lookup_path);
160
161 let command_id = derive_command_id(&path, meta);
162 let command_id_stable = meta.and_then(|m| m.command_id).is_some();
163 let capabilities = meta.map_or_else(Capabilities::default, |m| m.capabilities.clone());
164 let capabilities_declared = meta.is_some_and(|m| m.capabilities_declared);
165 let exit_codes = meta.map_or_else(Vec::new, |m| m.exit_codes.to_vec());
166
167 let aliases = command.get_visible_aliases().map(str::to_string).collect::<Vec<_>>();
168
169 let summary = command.get_about().map(|s| s.to_string());
170 let description = command
171 .get_long_about()
172 .map(|s| s.to_string())
173 .filter(|d| Some(d.as_str()) != summary.as_deref());
174
175 let args =
176 command.get_arguments().filter(|a| !is_help_or_version(a)).map(build_arg_info).collect();
177
178 let subcommands =
179 command.get_subcommands().map(|sub| build_command_info(sub, &path, registry)).collect();
180
181 CommandInfo {
182 command_id,
183 command_id_stable,
184 path,
185 aliases,
186 summary,
187 description,
188 args,
189 subcommands,
190 capabilities,
191 capabilities_declared,
192 exit_codes,
193 hidden: command.is_hide_set(),
194 }
195}
196
197pub fn render_introspect_document(command: &Command, registry: &CommandRegistry) -> String {
203 let doc = build_document(command, registry);
204 serde_json::to_string(&doc).expect("introspect document must be serializable")
205}
206
207fn derive_command_id(path: &[String], meta: Option<&CommandMeta>) -> String {
212 if let Some(id) = meta.and_then(|m| m.command_id) {
213 return id.to_string();
214 }
215 path.join(".")
216}
217
218fn build_arg_info(arg: &Arg) -> ArgInfo {
219 let kind = arg_kind(arg);
220 let value_type = arg_value_type(arg);
221
222 let aliases = arg
223 .get_visible_aliases()
224 .map(|a| a.into_iter().map(String::from).collect::<Vec<_>>())
225 .unwrap_or_default();
226
227 let possible_values = arg
228 .get_possible_values()
229 .iter()
230 .filter(|p| !p.is_hide_set())
231 .map(|p| p.get_name().to_string())
232 .collect();
233
234 let conflicts_with: Vec<String> = Vec::new();
238
239 let default = arg.get_default_values().first().map(|v| v.to_string_lossy().into_owned());
240
241 ArgInfo {
242 name: arg.get_id().to_string(),
243 kind,
244 value_type,
245 help: arg.get_help().map(|h| h.to_string()),
246 long: arg.get_long().map(String::from),
247 short: arg.get_short(),
248 aliases,
249 env: arg.get_env().map(|e| e.to_string_lossy().into_owned()),
250 default,
251 possible_values,
252 required: arg.is_required_set(),
253 repeatable: matches!(arg.get_action(), ArgAction::Count | ArgAction::Append),
254 conflicts_with,
255 help_heading: arg.get_help_heading().map(String::from),
256 hidden: arg.is_hide_set(),
257 }
258}
259
260fn arg_kind(arg: &Arg) -> ArgKind {
261 if arg.is_positional() {
262 return ArgKind::Positional;
263 }
264 match arg.get_action() {
265 ArgAction::SetTrue
266 | ArgAction::SetFalse
267 | ArgAction::Count
268 | ArgAction::Help
269 | ArgAction::HelpShort
270 | ArgAction::HelpLong
271 | ArgAction::Version => ArgKind::Flag,
272 _ => ArgKind::Option,
273 }
274}
275
276fn arg_value_type(arg: &Arg) -> ValueType {
277 match arg.get_action() {
278 ArgAction::SetTrue | ArgAction::SetFalse => return ValueType::Bool,
279 ArgAction::Count => return ValueType::Integer,
280 _ => {}
281 }
282
283 let name = arg.get_value_names().and_then(|v| v.first()).map(|s| s.as_str()).unwrap_or("");
284 match name.to_ascii_lowercase().as_str() {
285 "" => ValueType::Other,
286 "path" | "file" | "dir" | "directory" => ValueType::Path,
287 "url" | "rpc_url" | "rpc-url" => ValueType::Url,
288 "address" | "addr" => ValueType::Address,
289 "selector" | "sig" => ValueType::Selector,
290 "hex" | "bytes" | "bytecode" | "calldata" => ValueType::Hex,
291 "json" => ValueType::Json,
292 "int" | "integer" | "u64" | "u256" | "i64" | "i256" | "number" | "n" => ValueType::Integer,
293 "string" | "str" | "name" => ValueType::String,
294 _ => ValueType::Other,
295 }
296}
297
298fn is_help_or_version(arg: &Arg) -> bool {
299 matches!(
300 arg.get_action(),
301 ArgAction::Help | ArgAction::HelpShort | ArgAction::HelpLong | ArgAction::Version
302 )
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308 use clap::Parser;
309
310 #[derive(Parser)]
311 #[command(name = "demo", version = "0.1.0")]
312 struct Demo {
313 #[arg(global = true, long)]
315 quiet: bool,
316
317 #[command(subcommand)]
318 cmd: DemoSub,
319 }
320
321 #[derive(clap::Subcommand)]
322 enum DemoSub {
323 #[command(visible_alias = "b")]
325 Build {
326 #[arg(short, long)]
328 jobs: Option<u32>,
329 },
330 Cache {
332 #[command(subcommand)]
333 cmd: CacheSub,
334 },
335 }
336
337 #[derive(clap::Subcommand)]
338 enum CacheSub {
339 Clean,
341 }
342
343 #[test]
344 fn global_args_land_on_binary_info() {
345 let cmd = <Demo as clap::CommandFactory>::command();
346 let doc = build_document(&cmd, &CommandRegistry::EMPTY);
347
348 assert!(
349 doc.binary.global_args.iter().any(|a| a.name == "quiet"),
350 "global args missing from BinaryInfo: {:?}",
351 doc.binary.global_args,
352 );
353 }
354
355 #[test]
356 fn builds_document_with_default_command_ids() {
357 let cmd = <Demo as clap::CommandFactory>::command();
358 let doc = build_document(&cmd, &CommandRegistry::EMPTY);
359
360 assert_eq!(doc.schema_id, INTROSPECT_SCHEMA_ID);
361 assert_eq!(doc.binary.name, "demo");
362
363 let build = doc.commands.iter().find(|c| c.path.last().unwrap() == "build").unwrap();
364 assert_eq!(build.command_id, "demo.build");
365 assert_eq!(build.aliases, vec!["b".to_string()]);
366 }
367
368 #[test]
369 fn registry_overrides_command_id() {
370 static ENTRIES: &[super::super::registry::RegistryEntry] =
371 &[super::super::registry::RegistryEntry {
372 path: &["build"],
373 meta: CommandMeta {
374 command_id: Some("demo.compile"),
375 capabilities: Capabilities::NONE,
376 capabilities_declared: true,
377 exit_codes: &[],
378 },
379 }];
380 let registry = CommandRegistry::new(ENTRIES);
381
382 let cmd = <Demo as clap::CommandFactory>::command();
383 let doc = build_document(&cmd, ®istry);
384
385 let build = doc.commands.iter().find(|c| c.path.last().unwrap() == "build").unwrap();
386 assert_eq!(build.command_id, "demo.compile");
387 assert!(build.command_id_stable);
389 assert!(build.capabilities_declared);
390 }
391
392 #[test]
393 fn partial_registry_entry_does_not_promote_default_capabilities() {
394 static ENTRIES: &[super::super::registry::RegistryEntry] =
398 &[super::super::registry::RegistryEntry {
399 path: &["build"],
400 meta: CommandMeta {
401 command_id: Some("demo.compile"),
402 capabilities: Capabilities::NONE,
403 capabilities_declared: false,
404 exit_codes: &[],
405 },
406 }];
407 let registry = CommandRegistry::new(ENTRIES);
408
409 let cmd = <Demo as clap::CommandFactory>::command();
410 let doc = build_document(&cmd, ®istry);
411
412 let build = doc.commands.iter().find(|c| c.path.last().unwrap() == "build").unwrap();
413 assert!(build.command_id_stable);
414 assert!(!build.capabilities_declared);
415 }
416
417 #[test]
418 fn unregistered_commands_are_provisional() {
419 let cmd = <Demo as clap::CommandFactory>::command();
422 let doc = build_document(&cmd, &CommandRegistry::EMPTY);
423 let build = doc.commands.iter().find(|c| c.path.last().unwrap() == "build").unwrap();
424 assert!(!build.command_id_stable);
425 assert!(!build.capabilities_declared);
426 }
427
428 #[test]
429 fn nested_subcommands_have_dotted_ids() {
430 let cmd = <Demo as clap::CommandFactory>::command();
431 let doc = build_document(&cmd, &CommandRegistry::EMPTY);
432
433 let cache = doc.commands.iter().find(|c| c.path.last().unwrap() == "cache").unwrap();
434 let clean = cache.subcommands.iter().find(|c| c.path.last().unwrap() == "clean").unwrap();
435 assert_eq!(clean.command_id, "demo.cache.clean");
436 assert_eq!(clean.path, vec!["demo", "cache", "clean"]);
437 }
438
439 #[test]
440 fn args_are_described() {
441 let cmd = <Demo as clap::CommandFactory>::command();
442 let doc = build_document(&cmd, &CommandRegistry::EMPTY);
443 let build = doc.commands.iter().find(|c| c.path.last().unwrap() == "build").unwrap();
444
445 let jobs = build.args.iter().find(|a| a.name == "jobs").unwrap();
446 assert!(matches!(jobs.kind, ArgKind::Option));
447 assert_eq!(jobs.long.as_deref(), Some("jobs"));
448 assert_eq!(jobs.short, Some('j'));
449 assert!(!jobs.required);
450 }
451
452 #[test]
453 fn help_and_version_args_are_excluded() {
454 let cmd = <Demo as clap::CommandFactory>::command();
455 let doc = build_document(&cmd, &CommandRegistry::EMPTY);
456 let build = doc.commands.iter().find(|c| c.path.last().unwrap() == "build").unwrap();
457 for arg in &build.args {
458 assert!(arg.name != "help" && arg.name != "version", "got {arg:?}");
459 }
460 }
461
462 #[test]
463 fn document_round_trips_through_serde() {
464 let cmd = <Demo as clap::CommandFactory>::command();
465 let doc = build_document(&cmd, &CommandRegistry::EMPTY);
466 let json = serde_json::to_string(&doc).unwrap();
467 let parsed: IntrospectDocument = serde_json::from_str(&json).unwrap();
468 assert_eq!(parsed, doc);
469 }
470
471 #[test]
472 fn schema_id_and_version_agree() {
473 let expected = format!("foundry:introspect@v{INTROSPECT_SCHEMA_VERSION}");
475 assert_eq!(INTROSPECT_SCHEMA_ID, expected);
476 }
477
478 #[derive(Parser)]
479 #[command(name = "rooted", version = "0.1.0")]
480 struct Rooted {
481 #[arg(global = true, long)]
483 quiet: bool,
484
485 #[arg(long)]
487 port: Option<u16>,
488
489 #[command(subcommand)]
490 cmd: Option<RootedSub>,
491 }
492
493 #[derive(clap::Subcommand)]
494 enum RootedSub {
495 Serve,
497 }
498
499 #[test]
500 fn root_non_global_args_are_emitted_as_synthetic_root_command() {
501 let cmd = <Rooted as clap::CommandFactory>::command();
502 let doc = build_document(&cmd, &CommandRegistry::EMPTY);
503
504 assert!(doc.binary.global_args.iter().any(|a| a.name == "quiet"));
506
507 let root = doc
509 .commands
510 .iter()
511 .find(|c| c.path == ["rooted"])
512 .expect("root command_info must be present");
513
514 assert!(root.args.iter().any(|a| a.name == "port"));
516 assert!(!root.args.iter().any(|a| a.name == "quiet"));
518 assert!(root.subcommands.is_empty());
520 assert!(doc.commands.iter().any(|c| c.path == ["rooted", "serve"]));
521 }
522
523 #[test]
524 fn root_command_can_be_overridden_by_empty_registry_path() {
525 static ENTRIES: &[super::super::registry::RegistryEntry] =
526 &[super::super::registry::RegistryEntry {
527 path: &[],
528 meta: CommandMeta {
529 command_id: Some("rooted.start"),
530 capabilities: Capabilities::NONE,
531 capabilities_declared: true,
532 exit_codes: &[],
533 },
534 }];
535 let registry = CommandRegistry::new(ENTRIES);
536
537 let cmd = <Rooted as clap::CommandFactory>::command();
538 let doc = build_document(&cmd, ®istry);
539
540 let root = doc.commands.iter().find(|c| c.path == ["rooted"]).unwrap();
541 assert_eq!(root.command_id, "rooted.start");
542 assert!(root.command_id_stable);
543 assert!(root.capabilities_declared);
544 }
545
546 #[test]
547 fn subcommand_required_root_does_not_emit_synthetic_root_command() {
548 let cmd = clap::Command::new("strict")
549 .subcommand_required(true)
550 .arg(clap::Arg::new("port").long("port"))
551 .subcommand(clap::Command::new("run"));
552 let doc = build_document(&cmd, &CommandRegistry::EMPTY);
553
554 assert!(!doc.commands.iter().any(|c| c.path == ["strict"]));
556 assert!(doc.commands.iter().any(|c| c.path == ["strict", "run"]));
557 }
558
559 #[test]
560 fn build_does_not_require_successful_parse() {
561 let cmd = clap::Command::new("strict")
565 .subcommand_required(true)
566 .arg_required_else_help(true)
567 .subcommand(clap::Command::new("run").arg(clap::Arg::new("input").required(true)));
568 let json = render_introspect_document(&cmd, &CommandRegistry::EMPTY);
569 let parsed: IntrospectDocument = serde_json::from_str(&json).expect("valid JSON");
570 assert_eq!(parsed.commands.len(), 1);
571 assert_eq!(parsed.commands[0].command_id, "strict.run");
572 }
573}