1use 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 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
24pub 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
34pub 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 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 fn check_ref(
71 out: &mut Vec<String>,
72 id: &str,
73 name: &str,
74 val: Option<&str>,
75 expected_stem: &str,
76 re: ®ex::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: ®ex::Regex) {
104 let caps = &cmd.capabilities;
105 let id = cmd.command_id.as_str();
106
107 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
190pub 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
208pub fn build_document(command: &Command, registry: &CommandRegistry) -> IntrospectDocument {
211 let binary = build_binary_info(command);
212 let mut commands = Vec::new();
213
214 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 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
250fn 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 let mut path = parent_path.to_vec();
312 path.push(command.get_name().to_string());
313
314 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
354pub 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
364fn 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 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 #[arg(global = true, long)]
472 quiet: bool,
473
474 #[command(subcommand)]
475 cmd: DemoSub,
476 }
477
478 #[derive(clap::Subcommand)]
479 enum DemoSub {
480 #[command(visible_alias = "b")]
482 Build {
483 #[arg(short, long)]
485 jobs: Option<u32>,
486 },
487 Cache {
489 #[command(subcommand)]
490 cmd: CacheSub,
491 },
492 }
493
494 #[derive(clap::Subcommand)]
495 enum CacheSub {
496 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, ®istry);
541
542 let build = doc.commands.iter().find(|c| c.path.last().unwrap() == "build").unwrap();
543 assert_eq!(build.command_id, "demo.compile");
544 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 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, ®istry);
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 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 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, ®istry);
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, ®istry);
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, ®istry);
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, ®istry);
683 let v = capability_violations(&doc);
684 assert!(v.iter().any(|s| s.contains("does not match")), "got {v:?}");
685 }
686
687 #[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, ®istry);
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, ®istry);
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, ®istry);
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, ®istry);
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, ®istry);
761 let v = capability_violations(&doc);
762 assert!(v.iter().any(|s| s.contains("implies long_running")), "got {v:?}");
763 }
764
765 #[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, ®istry);
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 #[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, ®istry);
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 #[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, ®istry);
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 #[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, ®istry);
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 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 #[arg(global = true, long)]
861 quiet: bool,
862
863 #[arg(long)]
865 port: Option<u16>,
866
867 #[command(subcommand)]
868 cmd: Option<RootedSub>,
869 }
870
871 #[derive(clap::Subcommand)]
872 enum RootedSub {
873 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 assert!(doc.binary.global_args.iter().any(|a| a.name == "quiet"));
884
885 let root = doc
887 .commands
888 .iter()
889 .find(|c| c.path == ["rooted"])
890 .expect("root command_info must be present");
891
892 assert!(root.args.iter().any(|a| a.name == "port"));
894 assert!(!root.args.iter().any(|a| a.name == "quiet"));
896 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, ®istry);
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 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 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}