1use clap::{Command, CommandFactory, Parser, ValueHint};
28use serde::{Deserialize, Serialize};
29use std::collections::HashMap;
30use std::process;
31
32pub const MTP_SPEC_VERSION: &str = "2026-02-07";
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct ToolSchema {
40 #[serde(rename = "specVersion")]
41 pub spec_version: String,
42 pub name: String,
43 pub version: String,
44 pub description: String,
45 pub commands: Vec<CommandDescriptor>,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub auth: Option<AuthConfig>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct CommandDescriptor {
53 pub name: String,
54 pub description: String,
55 #[serde(default, skip_serializing_if = "Vec::is_empty")]
56 pub args: Vec<ArgDescriptor>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub stdin: Option<IODescriptor>,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub stdout: Option<IODescriptor>,
61 #[serde(default, skip_serializing_if = "Vec::is_empty")]
62 pub examples: Vec<Example>,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub auth: Option<CommandAuth>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct ArgDescriptor {
70 pub name: String,
71 #[serde(rename = "type")]
72 pub arg_type: String,
73 #[serde(skip_serializing_if = "Option::is_none")]
74 pub description: Option<String>,
75 pub required: bool,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub default: Option<serde_json::Value>,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub values: Option<Vec<String>>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct IODescriptor {
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub content_type: Option<String>,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub description: Option<String>,
90 #[serde(skip_serializing_if = "Option::is_none")]
91 pub schema: Option<serde_json::Value>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct Example {
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub description: Option<String>,
99 pub command: String,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 pub output: Option<String>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
106#[serde(rename_all = "camelCase")]
107pub struct AuthConfig {
108 #[serde(skip_serializing_if = "Option::is_none")]
109 pub required: Option<bool>,
110 pub env_var: String,
111 pub providers: Vec<AuthProvider>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116#[serde(rename_all = "camelCase")]
117pub struct AuthProvider {
118 pub id: String,
119 #[serde(rename = "type")]
120 pub provider_type: String,
121 #[serde(skip_serializing_if = "Option::is_none")]
122 pub display_name: Option<String>,
123 #[serde(skip_serializing_if = "Option::is_none")]
124 pub authorization_url: Option<String>,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub token_url: Option<String>,
127 #[serde(skip_serializing_if = "Option::is_none")]
128 pub scopes: Option<Vec<String>>,
129 #[serde(skip_serializing_if = "Option::is_none")]
130 pub client_id: Option<String>,
131 #[serde(skip_serializing_if = "Option::is_none")]
132 pub registration_url: Option<String>,
133 #[serde(skip_serializing_if = "Option::is_none")]
134 pub instructions: Option<String>,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct CommandAuth {
140 #[serde(skip_serializing_if = "Option::is_none")]
141 pub required: Option<bool>,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub scopes: Option<Vec<String>>,
144}
145
146#[derive(Debug, Clone, Default)]
150pub struct CommandAnnotation {
151 pub stdin: Option<IODescriptor>,
152 pub stdout: Option<IODescriptor>,
153 pub examples: Vec<Example>,
154 pub auth: Option<CommandAuth>,
155}
156
157#[derive(Debug, Clone, Default)]
159pub struct DescribeOptions {
160 pub commands: HashMap<String, CommandAnnotation>,
161 pub auth: Option<AuthConfig>,
162}
163
164pub fn infer_arg_type(arg: &clap::Arg) -> String {
168 match arg.get_action() {
171 clap::ArgAction::SetTrue | clap::ArgAction::SetFalse => {
172 return "boolean".to_string();
173 }
174 clap::ArgAction::Count => {
175 return "integer".to_string();
176 }
177 clap::ArgAction::Append => {
178 return "array".to_string();
179 }
180 _ => {}
181 }
182
183 if !arg.get_possible_values().is_empty() {
185 return "enum".to_string();
186 }
187
188 match arg.get_value_hint() {
190 ValueHint::FilePath | ValueHint::DirPath | ValueHint::AnyPath => {
191 return "path".to_string();
192 }
193 _ => {}
194 }
195
196 if let Some(range) = arg.get_num_args() {
198 if range.max_values() > 1 {
199 return "array".to_string();
200 }
201 }
202
203 let type_id = arg.get_value_parser().type_id();
205 if type_id == std::any::TypeId::of::<i32>()
206 || type_id == std::any::TypeId::of::<i64>()
207 || type_id == std::any::TypeId::of::<u32>()
208 || type_id == std::any::TypeId::of::<u64>()
209 || type_id == std::any::TypeId::of::<usize>()
210 {
211 return "integer".to_string();
212 }
213 if type_id == std::any::TypeId::of::<f32>()
214 || type_id == std::any::TypeId::of::<f64>()
215 {
216 return "number".to_string();
217 }
218
219 "string".to_string()
220}
221
222pub fn extract_arg(arg: &clap::Arg) -> Option<ArgDescriptor> {
227 if arg.is_hide_set() {
228 return None;
229 }
230
231 let is_positional = arg.get_long().is_none() && arg.get_short().is_none();
232
233 let name = if is_positional {
234 arg.get_id().to_string()
235 } else if let Some(long) = arg.get_long() {
236 format!("--{}", long)
237 } else if let Some(short) = arg.get_short() {
238 format!("-{}", short)
239 } else {
240 arg.get_id().to_string()
241 };
242
243 let arg_type = infer_arg_type(arg);
244
245 let values = if arg_type == "enum" {
246 Some(
247 arg.get_possible_values()
248 .iter()
249 .filter_map(|v| {
250 if v.is_hide_set() {
251 None
252 } else {
253 v.get_name_and_aliases().next().map(|s| s.to_string())
254 }
255 })
256 .collect(),
257 )
258 } else {
259 None
260 };
261
262 let default = arg.get_default_values().first().map(|v| {
263 let s = v.to_string_lossy();
264 match arg_type.as_str() {
265 "boolean" => serde_json::Value::Bool(s == "true"),
266 "integer" => s
267 .parse::<i64>()
268 .map(serde_json::Value::from)
269 .unwrap_or(serde_json::Value::String(s.to_string())),
270 "number" => s
271 .parse::<f64>()
272 .map(|f| serde_json::json!(f))
273 .unwrap_or(serde_json::Value::String(s.to_string())),
274 _ => serde_json::Value::String(s.to_string()),
275 }
276 });
277
278 let default = if arg_type == "boolean" && default.is_none() {
280 Some(serde_json::Value::Bool(false))
281 } else {
282 default
283 };
284
285 let description = arg.get_help().map(|h| h.to_string());
286
287 Some(ArgDescriptor {
288 name,
289 arg_type,
290 description,
291 required: arg.is_required_set(),
292 default,
293 values,
294 })
295}
296
297fn is_filtered_subcommand(name: &str) -> bool {
300 name == "help" || name == "mtp-describe"
301}
302
303pub fn walk_commands(
307 cmd: &Command,
308 annotations: &HashMap<String, CommandAnnotation>,
309 parent_path: Option<&str>,
310) -> Vec<CommandDescriptor> {
311 let visible_subs: Vec<&Command> = cmd
312 .get_subcommands()
313 .filter(|sub| !sub.is_hide_set() && !is_filtered_subcommand(sub.get_name()))
314 .collect();
315
316 if visible_subs.is_empty() {
317 let name = parent_path.unwrap_or("_root").to_string();
319 vec![build_command_with_annotations(cmd, &name, annotations)]
320 } else {
321 let mut result = Vec::new();
322
323 let has_own_args = cmd.get_arguments().any(|a| {
326 let id = a.get_id().as_str();
327 id != "help" && id != "version" && id != "mtp-describe" && !a.is_hide_set()
328 });
329 if has_own_args && parent_path.is_none() {
330 result.push(build_command_with_annotations(cmd, "_root", annotations));
331 }
332
333 for sub in visible_subs {
335 let path = match parent_path {
336 Some(p) => format!("{} {}", p, sub.get_name()),
337 None => sub.get_name().to_string(),
338 };
339 result.extend(walk_commands(sub, annotations, Some(&path)));
340 }
341 result
342 }
343}
344
345fn build_command_with_annotations(
346 cmd: &Command,
347 name: &str,
348 annotations: &HashMap<String, CommandAnnotation>,
349) -> CommandDescriptor {
350 let mut desc = build_command(cmd, name);
351 if let Some(ann) = annotations.get(name) {
352 if let Some(ref stdin) = ann.stdin {
353 desc.stdin = Some(stdin.clone());
354 }
355 if let Some(ref stdout) = ann.stdout {
356 desc.stdout = Some(stdout.clone());
357 }
358 if !ann.examples.is_empty() {
359 desc.examples = ann.examples.clone();
360 }
361 if let Some(ref auth) = ann.auth {
362 desc.auth = Some(auth.clone());
363 }
364 }
365 desc
366}
367
368fn build_command(cmd: &Command, name: &str) -> CommandDescriptor {
369 let args: Vec<ArgDescriptor> = cmd
370 .get_arguments()
371 .filter(|a| {
372 let id = a.get_id().as_str();
373 id != "help" && id != "version" && id != "mtp-describe"
374 })
375 .filter_map(extract_arg)
376 .collect();
377
378 CommandDescriptor {
379 name: name.to_string(),
380 description: cmd
381 .get_about()
382 .map(|s| s.to_string())
383 .unwrap_or_default(),
384 args,
385 stdin: None,
386 stdout: None,
387 examples: vec![],
388 auth: None,
389 }
390}
391
392pub fn describe<T: CommandFactory>(options: Option<&DescribeOptions>) -> ToolSchema {
399 let cmd = T::command();
400 extract_schema(&cmd, options)
401}
402
403pub fn extract_schema(cmd: &Command, options: Option<&DescribeOptions>) -> ToolSchema {
405 let annotations = options
406 .map(|o| &o.commands)
407 .cloned()
408 .unwrap_or_default();
409
410 let commands = walk_commands(cmd, &annotations, None);
411
412 ToolSchema {
413 spec_version: MTP_SPEC_VERSION.to_string(),
414 name: cmd.get_name().to_string(),
415 version: cmd
416 .get_version()
417 .map(|s| s.to_string())
418 .unwrap_or_default(),
419 description: cmd
420 .get_about()
421 .map(|s| s.to_string())
422 .unwrap_or_default(),
423 commands,
424 auth: options.and_then(|o| o.auth.clone()),
425 }
426}
427
428pub trait Describable: Parser {
436 fn describable() -> Self {
437 let args: Vec<String> = std::env::args().collect();
438 if args.iter().any(|a| a == "--mtp-describe") {
439 let cmd = <Self as CommandFactory>::command();
441 let sub_names: std::collections::HashSet<String> = cmd
442 .get_subcommands()
443 .flat_map(|s| {
444 let mut names = vec![s.get_name().to_string()];
445 names.extend(s.get_all_aliases().map(String::from));
446 names
447 })
448 .collect();
449 let has_subcommand = args.iter().skip(1).any(|a| sub_names.contains(a));
450 if !has_subcommand {
451 let schema = describe::<Self>(None);
452 let json =
453 serde_json::to_string(&schema).expect("failed to serialize schema");
454 println!("{}", json);
455 process::exit(0);
456 }
457 }
458 <Self as Parser>::parse()
459 }
460
461 fn schema() -> ToolSchema {
463 describe::<Self>(None)
464 }
465}
466
467impl<T: Parser> Describable for T {}
468
469pub struct DescribableBuilder {
474 options: DescribeOptions,
475}
476
477impl DescribableBuilder {
478 pub fn new() -> Self {
479 Self {
480 options: DescribeOptions::default(),
481 }
482 }
483
484 fn annotation(&mut self, cmd: &str) -> &mut CommandAnnotation {
485 self.options
486 .commands
487 .entry(cmd.to_string())
488 .or_default()
489 }
490
491 pub fn example(
492 mut self,
493 command_name: &str,
494 description: &str,
495 invocation: &str,
496 output: Option<&str>,
497 ) -> Self {
498 self.annotation(command_name).examples.push(Example {
499 description: Some(description.to_string()),
500 command: invocation.to_string(),
501 output: output.map(|s| s.to_string()),
502 });
503 self
504 }
505
506 pub fn stdin(mut self, command_name: &str, content_type: &str, description: &str) -> Self {
507 self.annotation(command_name).stdin = Some(IODescriptor {
508 content_type: Some(content_type.to_string()),
509 description: Some(description.to_string()),
510 schema: None,
511 });
512 self
513 }
514
515 pub fn stdout(mut self, command_name: &str, content_type: &str, description: &str) -> Self {
516 self.annotation(command_name).stdout = Some(IODescriptor {
517 content_type: Some(content_type.to_string()),
518 description: Some(description.to_string()),
519 schema: None,
520 });
521 self
522 }
523
524 pub fn stdin_with_schema(
525 mut self,
526 command_name: &str,
527 content_type: &str,
528 description: &str,
529 schema: serde_json::Value,
530 ) -> Self {
531 self.annotation(command_name).stdin = Some(IODescriptor {
532 content_type: Some(content_type.to_string()),
533 description: Some(description.to_string()),
534 schema: Some(schema),
535 });
536 self
537 }
538
539 pub fn stdout_with_schema(
540 mut self,
541 command_name: &str,
542 content_type: &str,
543 description: &str,
544 schema: serde_json::Value,
545 ) -> Self {
546 self.annotation(command_name).stdout = Some(IODescriptor {
547 content_type: Some(content_type.to_string()),
548 description: Some(description.to_string()),
549 schema: Some(schema),
550 });
551 self
552 }
553
554 pub fn auth(mut self, config: AuthConfig) -> Self {
556 self.options.auth = Some(config);
557 self
558 }
559
560 pub fn command_auth(mut self, command_name: &str, auth: CommandAuth) -> Self {
562 self.annotation(command_name).auth = Some(auth);
563 self
564 }
565
566 pub fn parse<T: Parser>(self) -> T {
574 let args: Vec<String> = std::env::args().collect();
575 if args.iter().any(|a| a == "--mtp-describe") {
576 let cmd = <T as CommandFactory>::command();
577 let sub_names: std::collections::HashSet<String> = cmd
578 .get_subcommands()
579 .flat_map(|s| {
580 let mut names = vec![s.get_name().to_string()];
581 names.extend(s.get_all_aliases().map(String::from));
582 names
583 })
584 .collect();
585 let has_subcommand = args.iter().skip(1).any(|a| sub_names.contains(a));
586 if !has_subcommand {
587 let schema = describe::<T>(Some(&self.options));
588 let json =
589 serde_json::to_string(&schema).expect("failed to serialize schema");
590 println!("{}", json);
591 process::exit(0);
592 }
593 }
594 <T as Parser>::parse()
595 }
596
597 pub fn schema<T: CommandFactory>(&self) -> ToolSchema {
599 describe::<T>(Some(&self.options))
600 }
601}
602
603impl Default for DescribableBuilder {
604 fn default() -> Self {
605 Self::new()
606 }
607}
608
609#[cfg(test)]
612mod tests {
613 use super::*;
614 use clap::{Arg, ArgAction, ValueHint};
615
616 #[test]
619 fn infer_type_boolean() {
620 let arg = Arg::new("verbose").long("verbose").action(ArgAction::SetTrue);
621 assert_eq!(infer_arg_type(&arg), "boolean");
622 }
623
624 #[test]
625 fn infer_type_boolean_set_false() {
626 let arg = Arg::new("no-color")
627 .long("no-color")
628 .action(ArgAction::SetFalse);
629 assert_eq!(infer_arg_type(&arg), "boolean");
630 }
631
632 #[test]
633 fn infer_type_enum() {
634 let arg = Arg::new("format")
635 .long("format")
636 .value_parser(["json", "csv", "yaml"]);
637 assert_eq!(infer_arg_type(&arg), "enum");
638 }
639
640 #[test]
641 fn infer_type_path() {
642 let arg = Arg::new("file")
643 .long("file")
644 .value_hint(ValueHint::FilePath);
645 assert_eq!(infer_arg_type(&arg), "path");
646 }
647
648 #[test]
649 fn infer_type_dir_path() {
650 let arg = Arg::new("dir")
651 .long("dir")
652 .value_hint(ValueHint::DirPath);
653 assert_eq!(infer_arg_type(&arg), "path");
654 }
655
656 #[test]
657 fn infer_type_count_is_integer() {
658 let arg = Arg::new("verbose").short('v').action(ArgAction::Count);
659 assert_eq!(infer_arg_type(&arg), "integer");
660 }
661
662 #[test]
663 fn infer_type_append_is_array() {
664 let arg = Arg::new("file").long("file").action(ArgAction::Append);
665 assert_eq!(infer_arg_type(&arg), "array");
666 }
667
668 #[test]
669 fn infer_type_multi_value_is_array() {
670 let arg = Arg::new("files")
671 .long("files")
672 .num_args(1..=10);
673 assert_eq!(infer_arg_type(&arg), "array");
674 }
675
676 #[test]
677 fn infer_type_integer_i64() {
678 let arg = Arg::new("port")
679 .long("port")
680 .value_parser(clap::value_parser!(i64));
681 assert_eq!(infer_arg_type(&arg), "integer");
682 }
683
684 #[test]
685 fn infer_type_integer_u32() {
686 let arg = Arg::new("count")
687 .long("count")
688 .value_parser(clap::value_parser!(u32));
689 assert_eq!(infer_arg_type(&arg), "integer");
690 }
691
692 #[test]
693 fn infer_type_number_f64() {
694 let arg = Arg::new("threshold")
695 .long("threshold")
696 .value_parser(clap::value_parser!(f64));
697 assert_eq!(infer_arg_type(&arg), "number");
698 }
699
700 #[test]
701 fn infer_type_default_string() {
702 let arg = Arg::new("name").long("name");
703 assert_eq!(infer_arg_type(&arg), "string");
704 }
705
706 #[test]
709 fn extract_positional_arg() {
710 let arg = Arg::new("input").required(true).help("Input file");
711 let desc = extract_arg(&arg).unwrap();
712 assert_eq!(desc.name, "input");
713 assert!(desc.required);
714 assert_eq!(desc.description.as_deref(), Some("Input file"));
715 }
716
717 #[test]
718 fn extract_long_flag() {
719 let arg = Arg::new("format")
720 .long("format")
721 .short('f')
722 .help("Output format");
723 let desc = extract_arg(&arg).unwrap();
724 assert_eq!(desc.name, "--format"); }
726
727 #[test]
728 fn extract_short_only_flag() {
729 let arg = Arg::new("verbose").short('v').action(ArgAction::SetTrue);
730 let desc = extract_arg(&arg).unwrap();
731 assert_eq!(desc.name, "-v");
732 assert_eq!(desc.arg_type, "boolean");
733 }
734
735 #[test]
736 fn extract_default_value() {
737 let arg = Arg::new("format")
738 .long("format")
739 .default_value("json")
740 .value_parser(["json", "csv"]);
741 let desc = extract_arg(&arg).unwrap();
742 assert_eq!(desc.default, Some(serde_json::Value::String("json".to_string())));
743 assert_eq!(desc.arg_type, "enum");
744 assert_eq!(desc.values, Some(vec!["json".to_string(), "csv".to_string()]));
745 }
746
747 #[test]
748 fn extract_boolean_default_false() {
749 let arg = Arg::new("pretty").long("pretty").action(ArgAction::SetTrue);
750 let desc = extract_arg(&arg).unwrap();
751 assert_eq!(desc.default, Some(serde_json::Value::Bool(false)));
752 }
753
754 #[test]
755 fn extract_hidden_arg_returns_none() {
756 let arg = Arg::new("secret").long("secret").hide(true);
757 assert!(extract_arg(&arg).is_none());
758 }
759
760 #[test]
761 fn extract_required_flag() {
762 let arg = Arg::new("token").long("token").required(true);
763 let desc = extract_arg(&arg).unwrap();
764 assert!(desc.required);
765 }
766
767 fn simple_cmd() -> Command {
770 Command::new("mytool")
771 .version("1.0.0")
772 .about("A tool")
773 .arg(Arg::new("input").required(true).help("Input file"))
774 }
775
776 fn multi_cmd() -> Command {
777 Command::new("filetool")
778 .version("1.2.0")
779 .about("File operations")
780 .subcommand(
781 Command::new("convert")
782 .about("Convert files")
783 .arg(Arg::new("input").required(true))
784 .arg(
785 Arg::new("format")
786 .long("format")
787 .default_value("json")
788 .value_parser(["json", "csv"]),
789 ),
790 )
791 .subcommand(
792 Command::new("validate")
793 .about("Validate files")
794 .arg(Arg::new("file").required(true)),
795 )
796 }
797
798 #[test]
799 fn walk_single_command_produces_root() {
800 let cmd = simple_cmd();
801 let result = walk_commands(&cmd, &HashMap::new(), None);
802 assert_eq!(result.len(), 1);
803 assert_eq!(result[0].name, "_root");
804 assert_eq!(result[0].args.len(), 1);
805 assert_eq!(result[0].args[0].name, "input");
806 }
807
808 #[test]
809 fn walk_multi_command_produces_subcommand_names() {
810 let cmd = multi_cmd();
811 let result = walk_commands(&cmd, &HashMap::new(), None);
812 assert_eq!(result.len(), 2);
813 let names: Vec<&str> = result.iter().map(|c| c.name.as_str()).collect();
814 assert!(names.contains(&"convert"));
815 assert!(names.contains(&"validate"));
816 }
817
818 #[test]
819 fn walk_nested_commands_space_separated() {
820 let cmd = Command::new("tool")
821 .subcommand(
822 Command::new("auth")
823 .about("Auth commands")
824 .subcommand(Command::new("login").about("Log in"))
825 .subcommand(Command::new("logout").about("Log out")),
826 );
827 let result = walk_commands(&cmd, &HashMap::new(), None);
828 assert_eq!(result.len(), 2);
829 let names: Vec<&str> = result.iter().map(|c| c.name.as_str()).collect();
830 assert!(names.contains(&"auth login"));
831 assert!(names.contains(&"auth logout"));
832 }
833
834 #[test]
835 fn walk_deeply_nested_commands() {
836 let cmd = Command::new("tool")
837 .subcommand(
838 Command::new("a")
839 .subcommand(
840 Command::new("b")
841 .subcommand(Command::new("c").about("Deep")),
842 ),
843 );
844 let result = walk_commands(&cmd, &HashMap::new(), None);
845 assert_eq!(result.len(), 1);
846 assert_eq!(result[0].name, "a b c");
847 }
848
849 #[test]
850 fn walk_filters_hidden_subcommands() {
851 let cmd = Command::new("tool")
852 .subcommand(Command::new("visible").about("Visible"))
853 .subcommand(Command::new("hidden").about("Hidden").hide(true));
854 let result = walk_commands(&cmd, &HashMap::new(), None);
855 assert_eq!(result.len(), 1);
856 assert_eq!(result[0].name, "visible");
857 }
858
859 #[test]
860 fn walk_merges_annotations() {
861 let mut annotations = HashMap::new();
862 annotations.insert(
863 "convert".to_string(),
864 CommandAnnotation {
865 stdin: Some(IODescriptor {
866 content_type: Some("text/plain".to_string()),
867 description: Some("Raw input".to_string()),
868 schema: None,
869 }),
870 stdout: None,
871 examples: vec![Example {
872 description: Some("Convert CSV".to_string()),
873 command: "filetool convert data.csv".to_string(),
874 output: None,
875 }],
876 auth: Some(CommandAuth {
877 required: Some(true),
878 scopes: Some(vec!["read".to_string()]),
879 }),
880 },
881 );
882
883 let cmd = multi_cmd();
884 let result = walk_commands(&cmd, &annotations, None);
885 let convert = result.iter().find(|c| c.name == "convert").unwrap();
886 assert!(convert.stdin.is_some());
887 assert_eq!(convert.examples.len(), 1);
888 assert!(convert.auth.is_some());
889 assert_eq!(convert.auth.as_ref().unwrap().required, Some(true));
890 }
891
892 #[test]
893 fn walk_filters_hidden_args() {
894 let cmd = Command::new("tool")
895 .arg(Arg::new("visible").long("visible"))
896 .arg(Arg::new("hidden").long("hidden").hide(true));
897 let result = walk_commands(&cmd, &HashMap::new(), None);
898 assert_eq!(result.len(), 1);
899 let arg_names: Vec<&str> = result[0].args.iter().map(|a| a.name.as_str()).collect();
900 assert!(arg_names.contains(&"--visible"));
901 assert!(!arg_names.contains(&"--hidden"));
902 }
903
904 #[test]
905 fn walk_root_args_with_subcommands() {
906 let cmd = Command::new("tool")
907 .arg(Arg::new("debug").long("debug").action(ArgAction::SetTrue))
908 .subcommand(Command::new("run").about("Run it"))
909 .subcommand(Command::new("build").about("Build it"));
910 let result = walk_commands(&cmd, &HashMap::new(), None);
911 assert_eq!(result.len(), 3);
912 let names: Vec<&str> = result.iter().map(|c| c.name.as_str()).collect();
913 assert!(names.contains(&"_root"));
914 assert!(names.contains(&"run"));
915 assert!(names.contains(&"build"));
916 let root = result.iter().find(|c| c.name == "_root").unwrap();
917 assert_eq!(root.args.len(), 1);
918 assert_eq!(root.args[0].name, "--debug");
919 }
920
921 #[test]
922 fn walk_no_root_args_with_subcommands() {
923 let cmd = Command::new("tool")
925 .subcommand(Command::new("run").about("Run it"));
926 let result = walk_commands(&cmd, &HashMap::new(), None);
927 assert_eq!(result.len(), 1);
928 assert_eq!(result[0].name, "run");
929 }
930
931 #[test]
932 fn example_description_is_optional() {
933 let ex = Example {
934 description: None,
935 command: "mytool run".to_string(),
936 output: None,
937 };
938 let json = serde_json::to_value(&ex).unwrap();
939 assert!(json.get("description").is_none());
940 assert_eq!(json.get("command").unwrap(), "mytool run");
941 }
942
943 #[test]
946 fn extract_schema_basic() {
947 let cmd = multi_cmd();
948 let schema = extract_schema(&cmd, None);
949 assert_eq!(schema.spec_version, MTP_SPEC_VERSION);
950 assert_eq!(schema.name, "filetool");
951 assert_eq!(schema.version, "1.2.0");
952 assert_eq!(schema.commands.len(), 2);
953 assert!(schema.auth.is_none());
954 }
955
956 #[test]
957 fn extract_schema_with_auth() {
958 let cmd = simple_cmd();
959 let opts = DescribeOptions {
960 commands: HashMap::new(),
961 auth: Some(AuthConfig {
962 required: Some(true),
963 env_var: "MY_TOKEN".to_string(),
964 providers: vec![AuthProvider {
965 id: "api-key".to_string(),
966 provider_type: "api-key".to_string(),
967 display_name: None,
968 authorization_url: None,
969 token_url: None,
970 scopes: None,
971 client_id: None,
972 registration_url: Some("https://example.com/keys".to_string()),
973 instructions: Some("Get a key".to_string()),
974 }],
975 }),
976 };
977 let schema = extract_schema(&cmd, Some(&opts));
978 assert!(schema.auth.is_some());
979 let auth = schema.auth.unwrap();
980 assert_eq!(auth.env_var, "MY_TOKEN");
981 assert_eq!(auth.providers.len(), 1);
982 assert_eq!(auth.providers[0].provider_type, "api-key");
983 }
984
985 #[test]
986 fn extract_schema_no_version_defaults_empty() {
987 let cmd = Command::new("bare").about("No version");
988 let schema = extract_schema(&cmd, None);
989 assert_eq!(schema.version, "");
990 }
991
992 #[derive(Parser)]
995 #[command(name = "testtool", version = "2.0.0", about = "Test tool")]
996 struct TestCli {
997 input: String,
999 #[arg(long)]
1001 verbose: bool,
1002 }
1003
1004 #[test]
1005 fn describe_pure_function() {
1006 let schema = describe::<TestCli>(None);
1007 assert_eq!(schema.spec_version, MTP_SPEC_VERSION);
1008 assert_eq!(schema.name, "testtool");
1009 assert_eq!(schema.version, "2.0.0");
1010 assert_eq!(schema.commands.len(), 1);
1011 assert_eq!(schema.commands[0].name, "_root");
1012 assert_eq!(schema.commands[0].args.len(), 2);
1013 }
1014
1015 #[derive(Parser)]
1018 #[command(name = "buildertool", version = "1.0.0", about = "Builder test")]
1019 enum BuilderCli {
1020 Doit {
1022 input: String,
1024 },
1025 }
1026
1027 #[test]
1028 fn builder_attaches_examples() {
1029 let builder = DescribableBuilder::new().example(
1030 "doit",
1031 "Run it",
1032 "buildertool doit foo.txt",
1033 Some("done"),
1034 );
1035 let schema = builder.schema::<BuilderCli>();
1036 let cmd = &schema.commands[0];
1037 assert_eq!(cmd.examples.len(), 1);
1038 assert_eq!(cmd.examples[0].description, Some("Run it".to_string()));
1039 assert_eq!(cmd.examples[0].output, Some("done".to_string()));
1040 }
1041
1042 #[test]
1043 fn builder_attaches_io() {
1044 let builder = DescribableBuilder::new()
1045 .stdin("doit", "text/plain", "Raw text")
1046 .stdout("doit", "application/json", "JSON output");
1047 let schema = builder.schema::<BuilderCli>();
1048 let cmd = &schema.commands[0];
1049 assert!(cmd.stdin.is_some());
1050 assert_eq!(
1051 cmd.stdin.as_ref().unwrap().content_type,
1052 Some("text/plain".to_string())
1053 );
1054 assert!(cmd.stdout.is_some());
1055 }
1056
1057 #[test]
1058 fn builder_attaches_io_with_schema() {
1059 let json_schema = serde_json::json!({"type": "object"});
1060 let builder = DescribableBuilder::new().stdin_with_schema(
1061 "doit",
1062 "application/json",
1063 "JSON input",
1064 json_schema.clone(),
1065 );
1066 let schema = builder.schema::<BuilderCli>();
1067 let cmd = &schema.commands[0];
1068 assert_eq!(cmd.stdin.as_ref().unwrap().schema, Some(json_schema));
1069 }
1070
1071 #[test]
1072 fn builder_attaches_auth() {
1073 let builder = DescribableBuilder::new()
1074 .auth(AuthConfig {
1075 required: Some(true),
1076 env_var: "TOKEN".to_string(),
1077 providers: vec![AuthProvider {
1078 id: "gh".to_string(),
1079 provider_type: "oauth2".to_string(),
1080 display_name: Some("GitHub".to_string()),
1081 authorization_url: Some("https://github.com/login/oauth/authorize".to_string()),
1082 token_url: Some("https://github.com/login/oauth/access_token".to_string()),
1083 scopes: Some(vec!["repo".to_string()]),
1084 client_id: Some("abc123".to_string()),
1085 registration_url: None,
1086 instructions: None,
1087 }],
1088 })
1089 .command_auth(
1090 "doit",
1091 CommandAuth {
1092 required: Some(true),
1093 scopes: Some(vec!["write".to_string()]),
1094 },
1095 );
1096 let schema = builder.schema::<BuilderCli>();
1097 assert!(schema.auth.is_some());
1098 assert_eq!(schema.auth.as_ref().unwrap().env_var, "TOKEN");
1099
1100 let cmd = &schema.commands[0];
1101 assert!(cmd.auth.is_some());
1102 assert_eq!(
1103 cmd.auth.as_ref().unwrap().scopes,
1104 Some(vec!["write".to_string()])
1105 );
1106 }
1107
1108 #[test]
1111 fn io_descriptor_serializes_camel_case() {
1112 let io = IODescriptor {
1113 content_type: Some("application/json".to_string()),
1114 description: Some("JSON data".to_string()),
1115 schema: None,
1116 };
1117 let json = serde_json::to_value(&io).unwrap();
1118 assert!(json.get("contentType").is_some());
1119 assert!(json.get("content_type").is_none());
1120 }
1121
1122 #[test]
1123 fn auth_config_serializes_camel_case() {
1124 let auth = AuthConfig {
1125 required: Some(true),
1126 env_var: "TOKEN".to_string(),
1127 providers: vec![],
1128 };
1129 let json = serde_json::to_value(&auth).unwrap();
1130 assert!(json.get("envVar").is_some());
1131 assert!(json.get("env_var").is_none());
1132 }
1133
1134 #[test]
1135 fn auth_provider_serializes_camel_case() {
1136 let provider = AuthProvider {
1137 id: "gh".to_string(),
1138 provider_type: "oauth2".to_string(),
1139 display_name: Some("GitHub".to_string()),
1140 authorization_url: Some("https://example.com/auth".to_string()),
1141 token_url: Some("https://example.com/token".to_string()),
1142 scopes: None,
1143 client_id: None,
1144 registration_url: None,
1145 instructions: None,
1146 };
1147 let json = serde_json::to_value(&provider).unwrap();
1148 assert!(json.get("displayName").is_some());
1149 assert!(json.get("authorizationUrl").is_some());
1150 assert!(json.get("tokenUrl").is_some());
1151 assert!(json.get("type").is_some()); }
1153
1154 #[test]
1155 fn full_schema_json_roundtrip() {
1156 let schema = describe::<TestCli>(None);
1157 let json_str = serde_json::to_string(&schema).unwrap();
1158 let parsed: ToolSchema = serde_json::from_str(&json_str).unwrap();
1159 assert_eq!(parsed.spec_version, MTP_SPEC_VERSION);
1160 assert_eq!(parsed.name, "testtool");
1161 assert_eq!(parsed.commands.len(), 1);
1162
1163 let json_val: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1165 assert!(json_val.get("specVersion").is_some());
1166 assert!(json_val.get("spec_version").is_none());
1167 }
1168}