Skip to main content

foundry_cli/introspect/
build.rs

1//! Build an [`IntrospectDocument`] from a `clap::Command` tree.
2
3use 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    /// Push this command's id and every descendant id into `out`.
15    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
23/// Collect every `command_id` (recursively) emitted by an
24/// [`IntrospectDocument`].
25pub 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
33/// Assert that every `command_id` in `doc` is unique.
34///
35/// Returns the duplicate ids on failure (one entry per duplicate `command_id`,
36/// in the order they were first encountered). On success returns an empty vec.
37///
38/// This is the canonical uniqueness check the agent contract relies on; each
39/// binary calls it from a unit test to enforce the invariant in CI.
40pub 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
51/// Build an [`IntrospectDocument`] for `command` overlaid with metadata from
52/// `registry`.
53pub fn build_document(command: &Command, registry: &CommandRegistry) -> IntrospectDocument {
54    let binary = build_binary_info(command);
55    let mut commands = Vec::new();
56
57    // Surface the root/default invocation when the binary accepts root-only
58    // (non-global) args without requiring a subcommand.
59    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    // Root `global = true` args; not surfaced by `get_arguments()` on subcommands.
78    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
93/// Build a synthetic `CommandInfo` for the root/default invocation.
94///
95/// Returns `None` when the root requires a subcommand or has no root-only args
96/// (i.e. nothing to invoke without a subcommand). The returned command has an
97/// empty `subcommands` list; named subcommands remain top-level siblings.
98///
99/// Registry lookup uses the empty path (`&[]`), so a binary can pin a stable
100/// id and capabilities for its default invocation (e.g. `anvil.start`).
101fn 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    // Path components for this command, including the binary name at index 0.
154    let mut path = parent_path.to_vec();
155    path.push(command.get_name().to_string());
156
157    // Registry lookup uses the path **without** the binary name.
158    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
197/// Serialize an [`IntrospectDocument`] as compact JSON.
198///
199/// This is the pure rendering step `--introspect` performs before exit, split
200/// out so binaries and tests can validate the emitted JSON without spawning
201/// a subprocess.
202pub 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
207/// Derive the stable command id.
208///
209/// If the registry pins an explicit `command_id`, use it. Otherwise, derive
210/// the id by joining the path components with `.` (e.g. `forge.build`).
211fn 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    // Clap does not expose conflict relationships through a public API on `Arg`;
235    // these would have to be threaded through annotations on a per-binary basis.
236    // Reserved here so the schema field is always present and stable.
237    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        /// Global flag, must appear on `BinaryInfo.global_args`.
314        #[arg(global = true, long)]
315        quiet: bool,
316
317        #[command(subcommand)]
318        cmd: DemoSub,
319    }
320
321    #[derive(clap::Subcommand)]
322    enum DemoSub {
323        /// Build the project.
324        #[command(visible_alias = "b")]
325        Build {
326            /// Number of jobs.
327            #[arg(short, long)]
328            jobs: Option<u32>,
329        },
330        /// Group of cache subcommands.
331        Cache {
332            #[command(subcommand)]
333            cmd: CacheSub,
334        },
335    }
336
337    #[derive(clap::Subcommand)]
338    enum CacheSub {
339        /// Clean cache.
340        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, &registry);
384
385        let build = doc.commands.iter().find(|c| c.path.last().unwrap() == "build").unwrap();
386        assert_eq!(build.command_id, "demo.compile");
387        // Pinned in the registry → stable; declared capabilities → authoritative.
388        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        // A registry entry that pins only `command_id` MUST NOT flip
395        // `capabilities_declared` to true; the wire field still reflects the
396        // placeholder `Capabilities::NONE` as non-authoritative.
397        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, &registry);
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        // Without a registry entry, both provenance bits must be false so
420        // consumers know not to treat the defaults as authoritative.
421        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        // The `@vN` suffix on `schema_id` must match the numeric `schema_version`.
474        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        /// Global flag, must land on `BinaryInfo.global_args`.
482        #[arg(global = true, long)]
483        quiet: bool,
484
485        /// Root-only arg, must land on the synthetic root `CommandInfo`.
486        #[arg(long)]
487        port: Option<u16>,
488
489        #[command(subcommand)]
490        cmd: Option<RootedSub>,
491    }
492
493    #[derive(clap::Subcommand)]
494    enum RootedSub {
495        /// Named subcommand, stays a top-level sibling of the root command.
496        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        // Global stays on `BinaryInfo.global_args`.
505        assert!(doc.binary.global_args.iter().any(|a| a.name == "quiet"));
506
507        // Synthetic root command exists with `path = [binary_name]`.
508        let root = doc
509            .commands
510            .iter()
511            .find(|c| c.path == ["rooted"])
512            .expect("root command_info must be present");
513
514        // Root-only arg lives on the root command.
515        assert!(root.args.iter().any(|a| a.name == "port"));
516        // Global is not duplicated onto the root command.
517        assert!(!root.args.iter().any(|a| a.name == "quiet"));
518        // Named subcommands are siblings, not nested under the root command.
519        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, &registry);
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        // Only the named subcommand is emitted; no synthetic root entry.
555        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        // `--introspect` must work even when required args/subcommands are
562        // missing; `build_document` is the only function called pre-parse, so
563        // it must not invoke clap's parsing path.
564        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}