Skip to main content

foundry_cli/introspect/
build.rs

1//! Build an [`IntrospectDocument`] from a `clap::Command` tree.
2
3use clap::{Arg, ArgAction, Command};
4use std::sync::OnceLock;
5
6use super::{
7    INTROSPECT_SCHEMA_ID, INTROSPECT_SCHEMA_VERSION, OutputMode,
8    document::{
9        ArgInfo, ArgKind, BinaryInfo, Capabilities, CommandInfo, IntrospectDocument, ValueType,
10    },
11    registry::{CommandMeta, CommandRegistry},
12};
13
14impl CommandInfo {
15    /// Push this command's id and every descendant id into `out`.
16    fn collect_ids_into(&self, out: &mut Vec<String>) {
17        out.push(self.command_id.clone());
18        for sub in &self.subcommands {
19            sub.collect_ids_into(out);
20        }
21    }
22}
23
24/// Collect every `command_id` (recursively) emitted by an
25/// [`IntrospectDocument`].
26pub fn collect_command_ids(doc: &IntrospectDocument) -> Vec<String> {
27    let mut out = Vec::new();
28    for cmd in &doc.commands {
29        cmd.collect_ids_into(&mut out);
30    }
31    out
32}
33
34/// Assert capability self-consistency for every command in `doc`.
35///
36/// Returns one error message per offending command. Static repo-wide check
37/// that catches commands declaring an output mode without wiring the
38/// supporting schema metadata, or vice versa.
39///
40/// Per-mode rules (see also spec §3, §4, §8):
41///
42/// - [`OutputMode::None`] and [`OutputMode::LegacyJson`] MUST NOT carry schema refs.
43/// - [`OutputMode::Envelope`] requires `result_schema_ref`; MUST NOT carry `event_schema_ref` or
44///   `session_schema_ref`.
45/// - [`OutputMode::Stream`] requires `event_schema_ref`; implies `long_running = true`.
46///   `result_schema_ref` MAY also be set when the stream ends with a terminal envelope.
47/// - [`OutputMode::Session`] requires `session_schema_ref`; implies `stateful = true` and
48///   `long_running = true`.
49///
50/// Per-field stem rules (spec §8): every present ref MUST take the exact
51/// shape `foundry:<stem>@v<N>` where `<stem>` is the emitting command's
52/// `command_id` for `result_schema_ref`, the same id suffixed with
53/// `.event` for `event_schema_ref`, and `.session` for `session_schema_ref`.
54/// This makes payload/event/session schemas mechanically derivable from
55/// `command_id` so agents can pin against them without trusting the
56/// registry to map them.
57pub fn capability_violations(doc: &IntrospectDocument) -> Vec<String> {
58    static SCHEMA_REF_RE: OnceLock<regex::Regex> = OnceLock::new();
59    let schema_re = SCHEMA_REF_RE.get_or_init(|| {
60        // Versions are canonical, start at 1, and have no leading zeros.
61        regex::Regex::new(r"^foundry:([a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*)@v[1-9][0-9]*$")
62            .expect("schema-ref regex compiles")
63    });
64
65    /// Validate format and exact-stem equality.
66    ///
67    /// `expected_stem` is `command_id` (for result), `command_id.event`
68    /// (for event), or `command_id.session` (for session). The ref MUST
69    /// match `foundry:<expected_stem>@v<digits>`.
70    fn check_ref(
71        out: &mut Vec<String>,
72        id: &str,
73        name: &str,
74        val: Option<&str>,
75        expected_stem: &str,
76        re: &regex::Regex,
77    ) {
78        let Some(v) = val else { return };
79        if v.is_empty() {
80            out.push(format!("{id}: capabilities.{name} must not be empty"));
81            return;
82        }
83        let Some(caps) = re.captures(v) else {
84            out.push(format!(
85                "{id}: capabilities.{name} = `{v}` does not match `foundry:<stem>@vN`"
86            ));
87            return;
88        };
89        let stem = &caps[1];
90        if stem != expected_stem {
91            out.push(format!(
92                "{id}: capabilities.{name} = `{v}` stem `{stem}` does not match expected \
93                 `{expected_stem}` (per spec §8: stem must equal `command_id`{suffix})",
94                suffix = match name {
95                    "event_schema_ref" => " + `.event`",
96                    "session_schema_ref" => " + `.session`",
97                    _ => "",
98                },
99            ));
100        }
101    }
102
103    fn walk(cmd: &CommandInfo, out: &mut Vec<String>, re: &regex::Regex) {
104        let caps = &cmd.capabilities;
105        let id = cmd.command_id.as_str();
106
107        // Format + stem check on every present ref, regardless of mode.
108        check_ref(out, id, "result_schema_ref", caps.result_schema_ref.as_deref(), id, re);
109        check_ref(
110            out,
111            id,
112            "event_schema_ref",
113            caps.event_schema_ref.as_deref(),
114            &format!("{id}.event"),
115            re,
116        );
117        check_ref(
118            out,
119            id,
120            "session_schema_ref",
121            caps.session_schema_ref.as_deref(),
122            &format!("{id}.session"),
123            re,
124        );
125
126        match caps.output_mode {
127            OutputMode::None | OutputMode::LegacyJson => {
128                if caps.result_schema_ref.is_some()
129                    || caps.event_schema_ref.is_some()
130                    || caps.session_schema_ref.is_some()
131                {
132                    out.push(format!(
133                        "{id}: output_mode={:?} must not carry any schema refs",
134                        caps.output_mode
135                    ));
136                }
137            }
138            OutputMode::Envelope => {
139                if caps.result_schema_ref.is_none() {
140                    out.push(format!(
141                        "{id}: output_mode=envelope requires capabilities.result_schema_ref"
142                    ));
143                }
144                if caps.event_schema_ref.is_some() {
145                    out.push(format!("{id}: output_mode=envelope must not carry event_schema_ref"));
146                }
147                if caps.session_schema_ref.is_some() {
148                    out.push(format!(
149                        "{id}: output_mode=envelope must not carry session_schema_ref"
150                    ));
151                }
152            }
153            OutputMode::Stream => {
154                if caps.event_schema_ref.is_none() {
155                    out.push(format!(
156                        "{id}: output_mode=stream requires capabilities.event_schema_ref"
157                    ));
158                }
159                if !caps.long_running {
160                    out.push(format!("{id}: output_mode=stream implies long_running = true"));
161                }
162            }
163            OutputMode::Session => {
164                if caps.session_schema_ref.is_none() {
165                    out.push(format!(
166                        "{id}: output_mode=session requires capabilities.session_schema_ref"
167                    ));
168                }
169                if !caps.stateful {
170                    out.push(format!("{id}: output_mode=session implies stateful = true"));
171                }
172                if !caps.long_running {
173                    out.push(format!("{id}: output_mode=session implies long_running = true"));
174                }
175            }
176        }
177
178        for sub in &cmd.subcommands {
179            walk(sub, out, re);
180        }
181    }
182
183    let mut out = Vec::new();
184    for cmd in &doc.commands {
185        walk(cmd, &mut out, schema_re);
186    }
187    out
188}
189
190/// Assert that every `command_id` in `doc` is unique.
191///
192/// Returns the duplicate ids on failure (one entry per duplicate `command_id`,
193/// in the order they were first encountered). On success returns an empty vec.
194///
195/// This is the canonical uniqueness check the agent contract relies on; each
196/// binary calls it from a unit test to enforce the invariant in CI.
197pub fn duplicate_command_ids(doc: &IntrospectDocument) -> Vec<String> {
198    let mut seen = std::collections::BTreeSet::new();
199    let mut dups = Vec::new();
200    for id in collect_command_ids(doc) {
201        if !seen.insert(id.clone()) && !dups.contains(&id) {
202            dups.push(id);
203        }
204    }
205    dups
206}
207
208/// Build an [`IntrospectDocument`] for `command` overlaid with metadata from
209/// `registry`.
210pub fn build_document(command: &Command, registry: &CommandRegistry) -> IntrospectDocument {
211    let binary = build_binary_info(command);
212    let mut commands = Vec::new();
213
214    // Surface the root/default invocation when the binary accepts root-only
215    // (non-global) args without requiring a subcommand.
216    if let Some(root) = build_root_command_info(command, registry) {
217        commands.push(root);
218    }
219
220    let root_path = vec![command.get_name().to_string()];
221    for sub in command.get_subcommands() {
222        commands.push(build_command_info(sub, &root_path, registry));
223    }
224
225    IntrospectDocument {
226        schema_id: INTROSPECT_SCHEMA_ID.to_string(),
227        schema_version: INTROSPECT_SCHEMA_VERSION,
228        binary,
229        commands,
230    }
231}
232
233fn build_binary_info(command: &Command) -> BinaryInfo {
234    // Root `global = true` args; not surfaced by `get_arguments()` on subcommands.
235    let global_args = command
236        .get_arguments()
237        .filter(|a| a.is_global_set() && !is_help_or_version(a))
238        .map(build_arg_info)
239        .collect();
240
241    BinaryInfo {
242        name: command.get_name().to_string(),
243        version: command.get_version().unwrap_or("").to_string(),
244        long_version: command.get_long_version().map(str::to_string),
245        description: command.get_about().map(|s| s.to_string()),
246        global_args,
247    }
248}
249
250/// Build a synthetic `CommandInfo` for the root/default invocation.
251///
252/// Returns `None` when the root requires a subcommand or has no root-only args
253/// (i.e. nothing to invoke without a subcommand). The returned command has an
254/// empty `subcommands` list; named subcommands remain top-level siblings.
255///
256/// Registry lookup uses the empty path (`&[]`), so a binary can pin a stable
257/// id and capabilities for its default invocation (e.g. `anvil.start`).
258fn build_root_command_info(command: &Command, registry: &CommandRegistry) -> Option<CommandInfo> {
259    if command.is_subcommand_required_set() {
260        return None;
261    }
262
263    let args: Vec<ArgInfo> = command
264        .get_arguments()
265        .filter(|a| !a.is_global_set() && !is_help_or_version(a))
266        .map(build_arg_info)
267        .collect();
268
269    if args.is_empty() {
270        return None;
271    }
272
273    let path = vec![command.get_name().to_string()];
274    let meta = registry.lookup(&[]);
275
276    let command_id = derive_command_id(&path, meta);
277    let command_id_stable = meta.and_then(|m| m.command_id).is_some();
278    let capabilities = meta.map_or_else(Capabilities::default, |m| m.capabilities.clone());
279    let capabilities_declared = meta.is_some_and(|m| m.capabilities_declared);
280    let exit_codes = meta.map_or_else(Vec::new, |m| m.exit_codes.to_vec());
281
282    let aliases = command.get_visible_aliases().map(str::to_string).collect::<Vec<_>>();
283    let summary = command.get_about().map(|s| s.to_string());
284    let description = command
285        .get_long_about()
286        .map(|s| s.to_string())
287        .filter(|d| Some(d.as_str()) != summary.as_deref());
288
289    Some(CommandInfo {
290        command_id,
291        command_id_stable,
292        path,
293        aliases,
294        summary,
295        description,
296        args,
297        subcommands: Vec::new(),
298        capabilities,
299        capabilities_declared,
300        exit_codes,
301        hidden: command.is_hide_set(),
302    })
303}
304
305fn build_command_info(
306    command: &Command,
307    parent_path: &[String],
308    registry: &CommandRegistry,
309) -> CommandInfo {
310    // Path components for this command, including the binary name at index 0.
311    let mut path = parent_path.to_vec();
312    path.push(command.get_name().to_string());
313
314    // Registry lookup uses the path **without** the binary name.
315    let lookup_path: Vec<&str> = path.iter().skip(1).map(String::as_str).collect();
316    let meta = registry.lookup(&lookup_path);
317
318    let command_id = derive_command_id(&path, meta);
319    let command_id_stable = meta.and_then(|m| m.command_id).is_some();
320    let capabilities = meta.map_or_else(Capabilities::default, |m| m.capabilities.clone());
321    let capabilities_declared = meta.is_some_and(|m| m.capabilities_declared);
322    let exit_codes = meta.map_or_else(Vec::new, |m| m.exit_codes.to_vec());
323
324    let aliases = command.get_visible_aliases().map(str::to_string).collect::<Vec<_>>();
325
326    let summary = command.get_about().map(|s| s.to_string());
327    let description = command
328        .get_long_about()
329        .map(|s| s.to_string())
330        .filter(|d| Some(d.as_str()) != summary.as_deref());
331
332    let args =
333        command.get_arguments().filter(|a| !is_help_or_version(a)).map(build_arg_info).collect();
334
335    let subcommands =
336        command.get_subcommands().map(|sub| build_command_info(sub, &path, registry)).collect();
337
338    CommandInfo {
339        command_id,
340        command_id_stable,
341        path,
342        aliases,
343        summary,
344        description,
345        args,
346        subcommands,
347        capabilities,
348        capabilities_declared,
349        exit_codes,
350        hidden: command.is_hide_set(),
351    }
352}
353
354/// Serialize an [`IntrospectDocument`] as compact JSON.
355///
356/// This is the pure rendering step `--introspect` performs before exit, split
357/// out so binaries and tests can validate the emitted JSON without spawning
358/// a subprocess.
359pub fn render_introspect_document(command: &Command, registry: &CommandRegistry) -> String {
360    let doc = build_document(command, registry);
361    serde_json::to_string(&doc).expect("introspect document must be serializable")
362}
363
364/// Derive the stable command id.
365///
366/// If the registry pins an explicit `command_id`, use it. Otherwise, derive
367/// the id by joining the path components with `.` (e.g. `forge.build`).
368fn derive_command_id(path: &[String], meta: Option<&CommandMeta>) -> String {
369    if let Some(id) = meta.and_then(|m| m.command_id) {
370        return id.to_string();
371    }
372    path.join(".")
373}
374
375fn build_arg_info(arg: &Arg) -> ArgInfo {
376    let kind = arg_kind(arg);
377    let value_type = arg_value_type(arg);
378
379    let aliases = arg
380        .get_visible_aliases()
381        .map(|a| a.into_iter().map(String::from).collect::<Vec<_>>())
382        .unwrap_or_default();
383
384    let possible_values = arg
385        .get_possible_values()
386        .iter()
387        .filter(|p| !p.is_hide_set())
388        .map(|p| p.get_name().to_string())
389        .collect();
390
391    // Clap does not expose conflict relationships through a public API on `Arg`;
392    // these would have to be threaded through annotations on a per-binary basis.
393    // Reserved here so the schema field is always present and stable.
394    let conflicts_with: Vec<String> = Vec::new();
395
396    let default = arg.get_default_values().first().map(|v| v.to_string_lossy().into_owned());
397
398    ArgInfo {
399        name: arg.get_id().to_string(),
400        kind,
401        value_type,
402        help: arg.get_help().map(|h| h.to_string()),
403        long: arg.get_long().map(String::from),
404        short: arg.get_short(),
405        aliases,
406        env: arg.get_env().map(|e| e.to_string_lossy().into_owned()),
407        default,
408        possible_values,
409        required: arg.is_required_set(),
410        repeatable: matches!(arg.get_action(), ArgAction::Count | ArgAction::Append),
411        conflicts_with,
412        help_heading: arg.get_help_heading().map(String::from),
413        hidden: arg.is_hide_set(),
414    }
415}
416
417fn arg_kind(arg: &Arg) -> ArgKind {
418    if arg.is_positional() {
419        return ArgKind::Positional;
420    }
421    match arg.get_action() {
422        ArgAction::SetTrue
423        | ArgAction::SetFalse
424        | ArgAction::Count
425        | ArgAction::Help
426        | ArgAction::HelpShort
427        | ArgAction::HelpLong
428        | ArgAction::Version => ArgKind::Flag,
429        _ => ArgKind::Option,
430    }
431}
432
433fn arg_value_type(arg: &Arg) -> ValueType {
434    match arg.get_action() {
435        ArgAction::SetTrue | ArgAction::SetFalse => return ValueType::Bool,
436        ArgAction::Count => return ValueType::Integer,
437        _ => {}
438    }
439
440    let name = arg.get_value_names().and_then(|v| v.first()).map(|s| s.as_str()).unwrap_or("");
441    match name.to_ascii_lowercase().as_str() {
442        "" => ValueType::Other,
443        "path" | "file" | "dir" | "directory" => ValueType::Path,
444        "url" | "rpc_url" | "rpc-url" => ValueType::Url,
445        "address" | "addr" => ValueType::Address,
446        "selector" | "sig" => ValueType::Selector,
447        "hex" | "bytes" | "bytecode" | "calldata" => ValueType::Hex,
448        "json" => ValueType::Json,
449        "int" | "integer" | "u64" | "u256" | "i64" | "i256" | "number" | "n" => ValueType::Integer,
450        "string" | "str" | "name" => ValueType::String,
451        _ => ValueType::Other,
452    }
453}
454
455fn is_help_or_version(arg: &Arg) -> bool {
456    matches!(
457        arg.get_action(),
458        ArgAction::Help | ArgAction::HelpShort | ArgAction::HelpLong | ArgAction::Version
459    )
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use clap::Parser;
466
467    #[derive(Parser)]
468    #[command(name = "demo", version = "0.1.0")]
469    struct Demo {
470        /// Global flag, must appear on `BinaryInfo.global_args`.
471        #[arg(global = true, long)]
472        quiet: bool,
473
474        #[command(subcommand)]
475        cmd: DemoSub,
476    }
477
478    #[derive(clap::Subcommand)]
479    enum DemoSub {
480        /// Build the project.
481        #[command(visible_alias = "b")]
482        Build {
483            /// Number of jobs.
484            #[arg(short, long)]
485            jobs: Option<u32>,
486        },
487        /// Group of cache subcommands.
488        Cache {
489            #[command(subcommand)]
490            cmd: CacheSub,
491        },
492    }
493
494    #[derive(clap::Subcommand)]
495    enum CacheSub {
496        /// Clean cache.
497        Clean,
498    }
499
500    #[test]
501    fn global_args_land_on_binary_info() {
502        let cmd = <Demo as clap::CommandFactory>::command();
503        let doc = build_document(&cmd, &CommandRegistry::EMPTY);
504
505        assert!(
506            doc.binary.global_args.iter().any(|a| a.name == "quiet"),
507            "global args missing from BinaryInfo: {:?}",
508            doc.binary.global_args,
509        );
510    }
511
512    #[test]
513    fn builds_document_with_default_command_ids() {
514        let cmd = <Demo as clap::CommandFactory>::command();
515        let doc = build_document(&cmd, &CommandRegistry::EMPTY);
516
517        assert_eq!(doc.schema_id, INTROSPECT_SCHEMA_ID);
518        assert_eq!(doc.binary.name, "demo");
519
520        let build = doc.commands.iter().find(|c| c.path.last().unwrap() == "build").unwrap();
521        assert_eq!(build.command_id, "demo.build");
522        assert_eq!(build.aliases, vec!["b".to_string()]);
523    }
524
525    #[test]
526    fn registry_overrides_command_id() {
527        static ENTRIES: &[super::super::registry::RegistryEntry] =
528            &[super::super::registry::RegistryEntry {
529                path: &["build"],
530                meta: CommandMeta {
531                    command_id: Some("demo.compile"),
532                    capabilities: Capabilities::NONE,
533                    capabilities_declared: true,
534                    exit_codes: &[],
535                },
536            }];
537        let registry = CommandRegistry::new(ENTRIES);
538
539        let cmd = <Demo as clap::CommandFactory>::command();
540        let doc = build_document(&cmd, &registry);
541
542        let build = doc.commands.iter().find(|c| c.path.last().unwrap() == "build").unwrap();
543        assert_eq!(build.command_id, "demo.compile");
544        // Pinned in the registry → stable; declared capabilities → authoritative.
545        assert!(build.command_id_stable);
546        assert!(build.capabilities_declared);
547    }
548
549    #[test]
550    fn partial_registry_entry_does_not_promote_default_capabilities() {
551        // A registry entry that pins only `command_id` MUST NOT flip
552        // `capabilities_declared` to true; the wire field still reflects the
553        // placeholder `Capabilities::NONE` as non-authoritative.
554        static ENTRIES: &[super::super::registry::RegistryEntry] =
555            &[super::super::registry::RegistryEntry {
556                path: &["build"],
557                meta: CommandMeta {
558                    command_id: Some("demo.compile"),
559                    capabilities: Capabilities::NONE,
560                    capabilities_declared: false,
561                    exit_codes: &[],
562                },
563            }];
564        let registry = CommandRegistry::new(ENTRIES);
565
566        let cmd = <Demo as clap::CommandFactory>::command();
567        let doc = build_document(&cmd, &registry);
568
569        let build = doc.commands.iter().find(|c| c.path.last().unwrap() == "build").unwrap();
570        assert!(build.command_id_stable);
571        assert!(!build.capabilities_declared);
572    }
573
574    #[test]
575    fn unregistered_commands_are_provisional() {
576        // Without a registry entry, both provenance bits must be false so
577        // consumers know not to treat the defaults as authoritative.
578        let cmd = <Demo as clap::CommandFactory>::command();
579        let doc = build_document(&cmd, &CommandRegistry::EMPTY);
580        let build = doc.commands.iter().find(|c| c.path.last().unwrap() == "build").unwrap();
581        assert!(!build.command_id_stable);
582        assert!(!build.capabilities_declared);
583    }
584
585    #[test]
586    fn nested_subcommands_have_dotted_ids() {
587        let cmd = <Demo as clap::CommandFactory>::command();
588        let doc = build_document(&cmd, &CommandRegistry::EMPTY);
589
590        let cache = doc.commands.iter().find(|c| c.path.last().unwrap() == "cache").unwrap();
591        let clean = cache.subcommands.iter().find(|c| c.path.last().unwrap() == "clean").unwrap();
592        assert_eq!(clean.command_id, "demo.cache.clean");
593        assert_eq!(clean.path, vec!["demo", "cache", "clean"]);
594    }
595
596    #[test]
597    fn args_are_described() {
598        let cmd = <Demo as clap::CommandFactory>::command();
599        let doc = build_document(&cmd, &CommandRegistry::EMPTY);
600        let build = doc.commands.iter().find(|c| c.path.last().unwrap() == "build").unwrap();
601
602        let jobs = build.args.iter().find(|a| a.name == "jobs").unwrap();
603        assert!(matches!(jobs.kind, ArgKind::Option));
604        assert_eq!(jobs.long.as_deref(), Some("jobs"));
605        assert_eq!(jobs.short, Some('j'));
606        assert!(!jobs.required);
607    }
608
609    #[test]
610    fn help_and_version_args_are_excluded() {
611        let cmd = <Demo as clap::CommandFactory>::command();
612        let doc = build_document(&cmd, &CommandRegistry::EMPTY);
613        let build = doc.commands.iter().find(|c| c.path.last().unwrap() == "build").unwrap();
614        for arg in &build.args {
615            assert!(arg.name != "help" && arg.name != "version", "got {arg:?}");
616        }
617    }
618
619    /// Helper: build a registry with one entry pinned at `["build"]` whose
620    /// capabilities overlay `caps` (with `capabilities_declared = true`).
621    fn registry_with_capabilities(caps: Capabilities) -> CommandRegistry {
622        let entry: &'static super::super::registry::RegistryEntry =
623            Box::leak(Box::new(super::super::registry::RegistryEntry {
624                path: &["build"],
625                meta: CommandMeta {
626                    command_id: None,
627                    capabilities: caps,
628                    capabilities_declared: true,
629                    exit_codes: &[],
630                },
631            }));
632        CommandRegistry::new(std::slice::from_ref(entry))
633    }
634
635    #[test]
636    fn capability_violations_detects_envelope_without_schema_ref() {
637        let registry = registry_with_capabilities(Capabilities {
638            output_mode: OutputMode::Envelope,
639            ..Capabilities::NONE
640        });
641        let cmd = <Demo as clap::CommandFactory>::command();
642        let doc = build_document(&cmd, &registry);
643        let violations = capability_violations(&doc);
644        assert_eq!(violations.len(), 1, "got {violations:?}");
645        assert!(violations[0].contains("envelope requires capabilities.result_schema_ref"));
646    }
647
648    #[test]
649    fn capability_violations_passes_for_well_formed_envelope() {
650        let registry = registry_with_capabilities(Capabilities {
651            output_mode: OutputMode::Envelope,
652            result_schema_ref: Some(std::borrow::Cow::Borrowed("foundry:demo.build@v1")),
653            ..Capabilities::NONE
654        });
655        let cmd = <Demo as clap::CommandFactory>::command();
656        let doc = build_document(&cmd, &registry);
657        let violations = capability_violations(&doc);
658        assert!(violations.is_empty(), "unexpected violations: {violations:?}");
659    }
660
661    #[test]
662    fn capability_violations_rejects_empty_schema_ref() {
663        let registry = registry_with_capabilities(Capabilities {
664            output_mode: OutputMode::Envelope,
665            result_schema_ref: Some(std::borrow::Cow::Borrowed("")),
666            ..Capabilities::NONE
667        });
668        let cmd = <Demo as clap::CommandFactory>::command();
669        let doc = build_document(&cmd, &registry);
670        let v = capability_violations(&doc);
671        assert!(v.iter().any(|s| s.contains("must not be empty")), "got {v:?}");
672    }
673
674    #[test]
675    fn capability_violations_rejects_malformed_schema_ref() {
676        let registry = registry_with_capabilities(Capabilities {
677            output_mode: OutputMode::Envelope,
678            result_schema_ref: Some(std::borrow::Cow::Borrowed("not-a-foundry-ref")),
679            ..Capabilities::NONE
680        });
681        let cmd = <Demo as clap::CommandFactory>::command();
682        let doc = build_document(&cmd, &registry);
683        let v = capability_violations(&doc);
684        assert!(v.iter().any(|s| s.contains("does not match")), "got {v:?}");
685    }
686
687    /// Schema versions are canonical: start at 1, no leading zeros.
688    /// `@v0`, `@v01`, `@v007` must all be rejected.
689    #[test]
690    fn capability_violations_rejects_non_canonical_version() {
691        for ref_str in
692            ["foundry:demo.build@v0", "foundry:demo.build@v01", "foundry:demo.build@v007"]
693        {
694            let registry = registry_with_capabilities(Capabilities {
695                output_mode: OutputMode::Envelope,
696                result_schema_ref: Some(std::borrow::Cow::Owned(ref_str.to_string())),
697                ..Capabilities::NONE
698            });
699            let cmd = <Demo as clap::CommandFactory>::command();
700            let doc = build_document(&cmd, &registry);
701            let v = capability_violations(&doc);
702            assert!(
703                v.iter().any(|s| s.contains("does not match")),
704                "expected rejection of `{ref_str}`, got {v:?}"
705            );
706        }
707    }
708
709    #[test]
710    fn capability_violations_rejects_envelope_with_event_ref() {
711        let registry = registry_with_capabilities(Capabilities {
712            output_mode: OutputMode::Envelope,
713            result_schema_ref: Some(std::borrow::Cow::Borrowed("foundry:demo.build@v1")),
714            event_schema_ref: Some(std::borrow::Cow::Borrowed("foundry:demo.build.event@v1")),
715            ..Capabilities::NONE
716        });
717        let cmd = <Demo as clap::CommandFactory>::command();
718        let doc = build_document(&cmd, &registry);
719        let v = capability_violations(&doc);
720        assert!(v.iter().any(|s| s.contains("must not carry event_schema_ref")), "got {v:?}");
721    }
722
723    #[test]
724    fn capability_violations_rejects_none_with_schema_ref() {
725        let registry = registry_with_capabilities(Capabilities {
726            output_mode: OutputMode::None,
727            result_schema_ref: Some(std::borrow::Cow::Borrowed("foundry:demo.build@v1")),
728            ..Capabilities::NONE
729        });
730        let cmd = <Demo as clap::CommandFactory>::command();
731        let doc = build_document(&cmd, &registry);
732        let v = capability_violations(&doc);
733        assert!(v.iter().any(|s| s.contains("must not carry any schema refs")), "got {v:?}");
734    }
735
736    #[test]
737    fn capability_violations_session_requires_stateful() {
738        let registry = registry_with_capabilities(Capabilities {
739            output_mode: OutputMode::Session,
740            session_schema_ref: Some(std::borrow::Cow::Borrowed("foundry:demo.build.session@v1")),
741            long_running: true,
742            stateful: false,
743            ..Capabilities::NONE
744        });
745        let cmd = <Demo as clap::CommandFactory>::command();
746        let doc = build_document(&cmd, &registry);
747        let v = capability_violations(&doc);
748        assert!(v.iter().any(|s| s.contains("implies stateful")), "got {v:?}");
749    }
750
751    #[test]
752    fn capability_violations_stream_requires_long_running() {
753        let registry = registry_with_capabilities(Capabilities {
754            output_mode: OutputMode::Stream,
755            event_schema_ref: Some(std::borrow::Cow::Borrowed("foundry:demo.build.event@v1")),
756            long_running: false,
757            ..Capabilities::NONE
758        });
759        let cmd = <Demo as clap::CommandFactory>::command();
760        let doc = build_document(&cmd, &registry);
761        let v = capability_violations(&doc);
762        assert!(v.iter().any(|s| s.contains("implies long_running")), "got {v:?}");
763    }
764
765    /// Spec §8: `result_schema_ref` stem MUST equal `command_id`. A ref
766    /// whose stem drifts away from `command_id` (e.g. `foundry:test-result@v1`
767    /// for `demo.build`) breaks discoverability and must be rejected.
768    #[test]
769    fn capability_violations_rejects_result_ref_stem_mismatch() {
770        let registry = registry_with_capabilities(Capabilities {
771            output_mode: OutputMode::Envelope,
772            result_schema_ref: Some(std::borrow::Cow::Borrowed("foundry:test_result@v1")),
773            ..Capabilities::NONE
774        });
775        let cmd = <Demo as clap::CommandFactory>::command();
776        let doc = build_document(&cmd, &registry);
777        let v = capability_violations(&doc);
778        assert!(
779            v.iter().any(|s| s.contains("stem `test_result` does not match expected `demo.build`")),
780            "got {v:?}"
781        );
782    }
783
784    /// Spec §8: `event_schema_ref` stem MUST equal `<command_id>.event`. A
785    /// missing or wrong `.event` suffix must be rejected.
786    #[test]
787    fn capability_violations_rejects_event_ref_without_event_suffix() {
788        let registry = registry_with_capabilities(Capabilities {
789            output_mode: OutputMode::Stream,
790            event_schema_ref: Some(std::borrow::Cow::Borrowed("foundry:demo.build@v1")),
791            long_running: true,
792            ..Capabilities::NONE
793        });
794        let cmd = <Demo as clap::CommandFactory>::command();
795        let doc = build_document(&cmd, &registry);
796        let v = capability_violations(&doc);
797        assert!(
798            v.iter().any(|s| s.contains("does not match expected `demo.build.event`")),
799            "got {v:?}"
800        );
801    }
802
803    /// Spec §8: `session_schema_ref` stem MUST equal `<command_id>.session`.
804    #[test]
805    fn capability_violations_rejects_session_ref_without_session_suffix() {
806        let registry = registry_with_capabilities(Capabilities {
807            output_mode: OutputMode::Session,
808            session_schema_ref: Some(std::borrow::Cow::Borrowed("foundry:demo.build@v1")),
809            long_running: true,
810            stateful: true,
811            ..Capabilities::NONE
812        });
813        let cmd = <Demo as clap::CommandFactory>::command();
814        let doc = build_document(&cmd, &registry);
815        let v = capability_violations(&doc);
816        assert!(
817            v.iter().any(|s| s.contains("does not match expected `demo.build.session`")),
818            "got {v:?}"
819        );
820    }
821
822    /// Suffix on the wrong field must be rejected:
823    /// `result_schema_ref` MUST NOT end in `.event` or `.session`.
824    #[test]
825    fn capability_violations_rejects_result_ref_with_event_suffix() {
826        let registry = registry_with_capabilities(Capabilities {
827            output_mode: OutputMode::Envelope,
828            result_schema_ref: Some(std::borrow::Cow::Borrowed("foundry:demo.build.event@v1")),
829            ..Capabilities::NONE
830        });
831        let cmd = <Demo as clap::CommandFactory>::command();
832        let doc = build_document(&cmd, &registry);
833        let v = capability_violations(&doc);
834        assert!(
835            v.iter().any(|s| s.contains("stem `demo.build.event` does not match expected `demo.build`")),
836            "got {v:?}"
837        );
838    }
839
840    #[test]
841    fn document_round_trips_through_serde() {
842        let cmd = <Demo as clap::CommandFactory>::command();
843        let doc = build_document(&cmd, &CommandRegistry::EMPTY);
844        let json = serde_json::to_string(&doc).unwrap();
845        let parsed: IntrospectDocument = serde_json::from_str(&json).unwrap();
846        assert_eq!(parsed, doc);
847    }
848
849    #[test]
850    fn schema_id_and_version_agree() {
851        // The `@vN` suffix on `schema_id` must match the numeric `schema_version`.
852        let expected = format!("foundry:introspect@v{INTROSPECT_SCHEMA_VERSION}");
853        assert_eq!(INTROSPECT_SCHEMA_ID, expected);
854    }
855
856    #[derive(Parser)]
857    #[command(name = "rooted", version = "0.1.0")]
858    struct Rooted {
859        /// Global flag, must land on `BinaryInfo.global_args`.
860        #[arg(global = true, long)]
861        quiet: bool,
862
863        /// Root-only arg, must land on the synthetic root `CommandInfo`.
864        #[arg(long)]
865        port: Option<u16>,
866
867        #[command(subcommand)]
868        cmd: Option<RootedSub>,
869    }
870
871    #[derive(clap::Subcommand)]
872    enum RootedSub {
873        /// Named subcommand, stays a top-level sibling of the root command.
874        Serve,
875    }
876
877    #[test]
878    fn root_non_global_args_are_emitted_as_synthetic_root_command() {
879        let cmd = <Rooted as clap::CommandFactory>::command();
880        let doc = build_document(&cmd, &CommandRegistry::EMPTY);
881
882        // Global stays on `BinaryInfo.global_args`.
883        assert!(doc.binary.global_args.iter().any(|a| a.name == "quiet"));
884
885        // Synthetic root command exists with `path = [binary_name]`.
886        let root = doc
887            .commands
888            .iter()
889            .find(|c| c.path == ["rooted"])
890            .expect("root command_info must be present");
891
892        // Root-only arg lives on the root command.
893        assert!(root.args.iter().any(|a| a.name == "port"));
894        // Global is not duplicated onto the root command.
895        assert!(!root.args.iter().any(|a| a.name == "quiet"));
896        // Named subcommands are siblings, not nested under the root command.
897        assert!(root.subcommands.is_empty());
898        assert!(doc.commands.iter().any(|c| c.path == ["rooted", "serve"]));
899    }
900
901    #[test]
902    fn root_command_can_be_overridden_by_empty_registry_path() {
903        static ENTRIES: &[super::super::registry::RegistryEntry] =
904            &[super::super::registry::RegistryEntry {
905                path: &[],
906                meta: CommandMeta {
907                    command_id: Some("rooted.start"),
908                    capabilities: Capabilities::NONE,
909                    capabilities_declared: true,
910                    exit_codes: &[],
911                },
912            }];
913        let registry = CommandRegistry::new(ENTRIES);
914
915        let cmd = <Rooted as clap::CommandFactory>::command();
916        let doc = build_document(&cmd, &registry);
917
918        let root = doc.commands.iter().find(|c| c.path == ["rooted"]).unwrap();
919        assert_eq!(root.command_id, "rooted.start");
920        assert!(root.command_id_stable);
921        assert!(root.capabilities_declared);
922    }
923
924    #[test]
925    fn subcommand_required_root_does_not_emit_synthetic_root_command() {
926        let cmd = clap::Command::new("strict")
927            .subcommand_required(true)
928            .arg(clap::Arg::new("port").long("port"))
929            .subcommand(clap::Command::new("run"));
930        let doc = build_document(&cmd, &CommandRegistry::EMPTY);
931
932        // Only the named subcommand is emitted; no synthetic root entry.
933        assert!(!doc.commands.iter().any(|c| c.path == ["strict"]));
934        assert!(doc.commands.iter().any(|c| c.path == ["strict", "run"]));
935    }
936
937    #[test]
938    fn build_does_not_require_successful_parse() {
939        // `--introspect` must work even when required args/subcommands are
940        // missing; `build_document` is the only function called pre-parse, so
941        // it must not invoke clap's parsing path.
942        let cmd = clap::Command::new("strict")
943            .subcommand_required(true)
944            .arg_required_else_help(true)
945            .subcommand(clap::Command::new("run").arg(clap::Arg::new("input").required(true)));
946        let json = render_introspect_document(&cmd, &CommandRegistry::EMPTY);
947        let parsed: IntrospectDocument = serde_json::from_str(&json).expect("valid JSON");
948        assert_eq!(parsed.commands.len(), 1);
949        assert_eq!(parsed.commands[0].command_id, "strict.run");
950    }
951}