Skip to main content

mtp_sdk/
lib.rs

1//! # mtp-sdk
2//!
3//! Make any Clap-based CLI tool LLM-discoverable with `--mtp-describe`.
4//!
5//! This crate reads type information, help strings, defaults, and possible
6//! values directly from Clap's `Command` and `Arg` metadata, so you get
7//! `--mtp-describe` with zero extra annotation in most cases.
8//!
9//! ## Quick start
10//!
11//! ```rust,no_run
12//! use clap::Parser;
13//! use mtp_sdk::Describable;
14//!
15//! #[derive(Parser)]
16//! #[command(name = "mytool", version = "1.0.0", about = "Does things")]
17//! struct Cli {
18//!     /// Input file
19//!     input: String,
20//! }
21//!
22//! fn main() {
23//!     let cli = Cli::describable();
24//! }
25//! ```
26
27use clap::{Command, CommandFactory, Parser, ValueHint};
28use serde::{Deserialize, Serialize};
29use std::collections::HashMap;
30use std::process;
31
32/// The MTP specification version this SDK implements.
33pub const MTP_SPEC_VERSION: &str = "2026-02-07";
34
35// ── Schema types ─────────────────────────────────────────────────────
36
37/// Top-level tool schema returned by `--mtp-describe`.
38#[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/// A single (leaf) command in the tool.
51#[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/// Describes a single argument (positional or flag).
68#[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/// Describes stdin or stdout for a command.
83#[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/// An example invocation.
95#[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/// Tool-level auth configuration.
105#[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/// A supported authentication provider.
115#[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/// Per-command auth overrides.
138#[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// ── Annotations for the builder / describe options ───────────────────
147
148/// Annotations for a single command (examples, IO, auth).
149#[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/// Options passed to `describe()` for attaching metadata.
158#[derive(Debug, Clone, Default)]
159pub struct DescribeOptions {
160    pub commands: HashMap<String, CommandAnnotation>,
161    pub auth: Option<AuthConfig>,
162}
163
164// ── Clap type inference ──────────────────────────────────────────────
165
166/// Infer the MTP arg type from a Clap `Arg`.
167pub fn infer_arg_type(arg: &clap::Arg) -> String {
168    // Boolean actions take priority (Clap derive sets possible_values
169    // ["true","false"] for bool fields, so we must check action first)
170    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    // Possible values -> enum
184    if !arg.get_possible_values().is_empty() {
185        return "enum".to_string();
186    }
187
188    // Value hint -> path
189    match arg.get_value_hint() {
190        ValueHint::FilePath | ValueHint::DirPath | ValueHint::AnyPath => {
191            return "path".to_string();
192        }
193        _ => {}
194    }
195
196    // Multi-value detection via num_args
197    if let Some(range) = arg.get_num_args() {
198        if range.max_values() > 1 {
199            return "array".to_string();
200        }
201    }
202
203    // Value parser type ID checks for numeric types
204    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
222// ── Arg extraction ───────────────────────────────────────────────────
223
224/// Extract an `ArgDescriptor` from a Clap `Arg`. Returns `None` for
225/// hidden args.
226pub 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    // Boolean flags with SetTrue action default to false
279    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
297// ── Command walking ──────────────────────────────────────────────────
298
299fn is_filtered_subcommand(name: &str) -> bool {
300    name == "help" || name == "mtp-describe"
301}
302
303/// Recursively walk a `Command` tree, returning leaf commands with
304/// space-separated names (e.g. "auth login"). Commands with no visible
305/// subcommands are leaves.
306pub 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        // Leaf command
318        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        // If this command has its own args (beyond help/version), emit
324        // a _root entry so they aren't silently dropped.
325        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        // Recurse into subcommands
334        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
392// ── Schema generation ────────────────────────────────────────────────
393
394/// Extract the full `ToolSchema` from a Clap `Command`.
395///
396/// This is a pure function with no side effects. Use it for
397/// programmatic access or testing.
398pub fn describe<T: CommandFactory>(options: Option<&DescribeOptions>) -> ToolSchema {
399    let cmd = T::command();
400    extract_schema(&cmd, options)
401}
402
403/// Extract the full `ToolSchema` from an already-built `Command`.
404pub 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
428// ── Describable trait ────────────────────────────────────────────────
429
430/// Trait for Clap-derived CLI structs to add `--mtp-describe` support.
431///
432/// Checks `std::env::args()` for `--mtp-describe` before parsing.
433/// If found, prints the JSON schema and exits.
434/// Otherwise, parses normally and returns the parsed value.
435pub 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            // If a subcommand is present in argv, don't intercept; let it handle things
440            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    /// Generate the schema without side effects.
462    fn schema() -> ToolSchema {
463        describe::<Self>(None)
464    }
465}
466
467impl<T: Parser> Describable for T {}
468
469// ── Builder ──────────────────────────────────────────────────────────
470
471/// Builder for attaching examples, IO descriptors, and auth config
472/// to commands before checking `--mtp-describe`.
473pub 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    /// Set tool-level auth configuration.
555    pub fn auth(mut self, config: AuthConfig) -> Self {
556        self.options.auth = Some(config);
557        self
558    }
559
560    /// Set per-command auth overrides.
561    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    /// Check for `--mtp-describe` (printing schema + exiting if present),
567    /// otherwise parse and return the CLI struct.
568    ///
569    /// If a subcommand name appears in argv alongside `--mtp-describe`,
570    /// the flag is NOT intercepted here; the subcommand is expected to
571    /// handle it (e.g. `wrap --mtp-describe` means "describe the wrapped
572    /// server", not "describe this CLI").
573    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    /// Generate schema without side effects. For testing.
598    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// ── Tests ────────────────────────────────────────────────────────────
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614    use clap::{Arg, ArgAction, ValueHint};
615
616    // ── infer_arg_type ───────────────────────────────────────────
617
618    #[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    // ── extract_arg ──────────────────────────────────────────────
707
708    #[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"); // prefers long
725    }
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    // ── walk_commands ────────────────────────────────────────────
768
769    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        // No _root emitted when parent has no visible args
924        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    // ── extract_schema / describe ────────────────────────────────
944
945    #[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    // ── describe() pure function ─────────────────────────────────
993
994    #[derive(Parser)]
995    #[command(name = "testtool", version = "2.0.0", about = "Test tool")]
996    struct TestCli {
997        /// Input file
998        input: String,
999        /// Be verbose
1000        #[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    // ── DescribableBuilder ───────────────────────────────────────
1016
1017    #[derive(Parser)]
1018    #[command(name = "buildertool", version = "1.0.0", about = "Builder test")]
1019    enum BuilderCli {
1020        /// Do stuff
1021        Doit {
1022            /// Input
1023            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    // ── JSON serialization ───────────────────────────────────────
1109
1110    #[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()); // renamed from provider_type
1152    }
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        // Verify specVersion appears in serialized JSON (camelCase)
1164        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}