Skip to main content

kaish_types/
tool.rs

1//! Tool schema and argument types.
2
3use std::collections::{BTreeMap, HashSet};
4
5use crate::value::Value;
6
7fn default_consumes() -> usize {
8    1
9}
10
11/// Schema for a tool parameter.
12#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
13#[non_exhaustive]
14pub struct ParamSchema {
15    /// Parameter name.
16    pub name: String,
17    /// Type hint (string, int, bool, array, object, any).
18    pub param_type: String,
19    /// Whether this parameter is required.
20    pub required: bool,
21    /// Default value if not required.
22    pub default: Option<Value>,
23    /// Description for help text.
24    pub description: String,
25    /// Alternative names/flags for this parameter (e.g., "-r", "-R" for "recursive").
26    pub aliases: Vec<String>,
27    /// Number of positional tokens this non-bool flag consumes per occurrence.
28    ///
29    /// Default 1 (standard `--flag value`). Set to 2 for `--flag NAME VALUE`
30    /// patterns such as jq's `--arg` / `--argjson`. When `consumes > 1`, the
31    /// kernel collects each occurrence as an inner array and accumulates
32    /// repeated occurrences under the same `named` key — the tool sees a
33    /// `Value::Json(Array(Array(...)))` listing every (N-tuple) occurrence.
34    #[serde(default = "default_consumes")]
35    pub consumes: usize,
36    /// True when this flag may appear more than once and each occurrence
37    /// should be kept (clap's `ArgAction::Append`, i.e. a `Vec<_>` value flag
38    /// like sed's `-e`). When set, the kernel accumulates every occurrence
39    /// under the same `named` key as a `Value::Json(Array(...))` instead of
40    /// letting the last write win — the "no silent drop" contract for repeated
41    /// flags. Orthogonal to `consumes`: `consumes` is values-per-occurrence,
42    /// `repeatable` is occurrences-per-invocation.
43    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
44    pub repeatable: bool,
45    /// True for positional arguments (`cat foo.txt`), false for flags
46    /// (`grep --ignore-case`). The validator matches positional params
47    /// against `args.positional` by their order *among positionals only*,
48    /// independent of where they sit in the clap struct. Default false so
49    /// hand-built `ParamSchema::required(...)` constructors keep flag
50    /// semantics; clap-reflected positionals set it via
51    /// `arg.get_index().is_some()`.
52    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
53    pub positional: bool,
54}
55
56impl ParamSchema {
57    /// Create a required parameter.
58    pub fn required(name: impl Into<String>, param_type: impl Into<String>, description: impl Into<String>) -> Self {
59        Self {
60            name: name.into(),
61            param_type: param_type.into(),
62            required: true,
63            default: None,
64            description: description.into(),
65            aliases: Vec::new(),
66            consumes: 1,
67            repeatable: false,
68            positional: false,
69        }
70    }
71
72    /// Create an optional parameter with a default value.
73    pub fn optional(name: impl Into<String>, param_type: impl Into<String>, default: Value, description: impl Into<String>) -> Self {
74        Self {
75            name: name.into(),
76            param_type: param_type.into(),
77            required: false,
78            default: Some(default),
79            description: description.into(),
80            aliases: Vec::new(),
81            consumes: 1,
82            repeatable: false,
83            positional: false,
84        }
85    }
86
87    /// Create a minimal parameter (not required, no default, empty
88    /// description, `consumes` 1, flag — not positional). Chain the `with_*`
89    /// setters to fill in fields. Use this when each field is computed
90    /// independently (e.g. reflected from clap) rather than fitting the
91    /// `required`/`optional` shortcuts. Keeps construction working across the
92    /// `#[non_exhaustive]` boundary.
93    pub fn new(name: impl Into<String>, param_type: impl Into<String>) -> Self {
94        Self {
95            name: name.into(),
96            param_type: param_type.into(),
97            required: false,
98            default: None,
99            description: String::new(),
100            aliases: Vec::new(),
101            consumes: 1,
102            repeatable: false,
103            positional: false,
104        }
105    }
106
107    /// Set the human-readable description.
108    pub fn with_description(mut self, description: impl Into<String>) -> Self {
109        self.description = description.into();
110        self
111    }
112
113    /// Set whether the parameter is required.
114    pub fn with_required(mut self, required: bool) -> Self {
115        self.required = required;
116        self
117    }
118
119    /// Set the default value (used when the parameter is omitted).
120    pub fn with_default(mut self, default: Option<Value>) -> Self {
121        self.default = default;
122        self
123    }
124
125    /// Set the positional flag from a computed boolean (the parameterless
126    /// [`positional`](Self::positional) sets it unconditionally to `true`).
127    pub fn with_positional(mut self, positional: bool) -> Self {
128        self.positional = positional;
129        self
130    }
131
132    /// Mark this parameter as positional (matched by argv order rather than
133    /// by name). Used by `params_from_clap` for clap args with an assigned
134    /// index, and by hand-written schemas for positional parameters like
135    /// jq's `filter`.
136    pub fn positional(mut self) -> Self {
137        self.positional = true;
138        self
139    }
140
141    /// Add alternative names/flags for this parameter.
142    ///
143    /// Aliases are used for short flags like `-r`, `-R` that map to `recursive`.
144    pub fn with_aliases(mut self, aliases: impl IntoIterator<Item = impl Into<String>>) -> Self {
145        self.aliases = aliases.into_iter().map(Into::into).collect();
146        self
147    }
148
149    /// Declare how many positional tokens this non-bool flag consumes per
150    /// occurrence (`--flag v1 v2 ...`). Default is 1. Panics on 0 — a flag
151    /// that consumes nothing is a bool flag, not a schema-typed param.
152    pub fn consumes(mut self, n: usize) -> Self {
153        assert!(n >= 1, "ParamSchema::consumes requires n >= 1 (use a bool param for flags that take no value)");
154        self.consumes = n;
155        self
156    }
157
158    /// Mark this flag as repeatable: each occurrence is accumulated rather than
159    /// overwritten (see [`repeatable`](Self::repeatable)). Set from a computed
160    /// boolean so clap reflection can pass `ArgAction::Append` directly.
161    pub fn with_repeatable(mut self, repeatable: bool) -> Self {
162        self.repeatable = repeatable;
163        self
164    }
165
166    /// Check if a flag name matches this parameter or any of its aliases.
167    pub fn matches_flag(&self, flag: &str) -> bool {
168        if self.name == flag {
169            return true;
170        }
171        self.aliases.iter().any(|a| a == flag)
172    }
173}
174
175/// An example showing how to use a tool.
176#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
177pub struct Example {
178    /// Short description of what the example demonstrates.
179    pub description: String,
180    /// The example command/code.
181    pub code: String,
182}
183
184impl Example {
185    /// Create a new example.
186    pub fn new(description: impl Into<String>, code: impl Into<String>) -> Self {
187        Self {
188            description: description.into(),
189            code: code.into(),
190        }
191    }
192}
193
194/// Schema describing a tool's interface.
195#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
196#[non_exhaustive]
197pub struct ToolSchema {
198    /// Tool name.
199    pub name: String,
200    /// Short description.
201    pub description: String,
202    /// Parameter definitions.
203    pub params: Vec<ParamSchema>,
204    /// Usage examples.
205    pub examples: Vec<Example>,
206    /// Map remaining positional args to named params by schema order.
207    /// Only for MCP/external tools that expect named JSON params.
208    /// Builtins handle their own positionals and should leave this false.
209    pub map_positionals: bool,
210    /// Child schemas for subcommand-aware tools (`kj context list`, …).
211    ///
212    /// Empty for flat tools (`cat`, `grep`, `ls`) — they take the flat binding
213    /// path. When non-empty, the kernel walks leading positionals to pick the
214    /// active leaf and binds flags against *that leaf's* `params` (see
215    /// `select_leaf` in the kernel).
216    ///
217    /// `skip_serializing_if` keeps the wire compact for the many flat tools
218    /// (no `"subcommands":[]` noise); `default` is then required so a flat
219    /// tool's payload (key absent) deserializes back to empty.
220    #[serde(default, skip_serializing_if = "Vec::is_empty")]
221    pub subcommands: Vec<ToolSchema>,
222    /// Command-level aliases (`ls` → `list`, `rm` → `remove`), matched when
223    /// routing a positional to a child. Distinct from [`ParamSchema::aliases`],
224    /// which name *flags*.
225    #[serde(default, skip_serializing_if = "Vec::is_empty")]
226    pub aliases: Vec<String>,
227    /// The tool renders its **own** output, including `--json` — the kernel
228    /// must not re-format its `ExecResult` through `apply_output_format`.
229    ///
230    /// Default false: a tool returns typed [`crate::OutputData`] and the kernel
231    /// renders the requested format uniformly. Set true for tools with bespoke
232    /// JSON envelopes (e.g. an embedder's `kj`): they consume `--json`
233    /// themselves and emit final bytes. See [`ToolSchema::with_owned_output`].
234    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
235    pub owns_output: bool,
236}
237
238impl ToolSchema {
239    /// Create a new tool schema.
240    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
241        Self {
242            name: name.into(),
243            description: description.into(),
244            params: Vec::new(),
245            examples: Vec::new(),
246            map_positionals: false,
247            subcommands: Vec::new(),
248            aliases: Vec::new(),
249            owns_output: false,
250        }
251    }
252
253    /// Enable positional->named parameter mapping for MCP/external tools.
254    pub fn with_positional_mapping(mut self) -> Self {
255        self.map_positionals = true;
256        self
257    }
258
259    /// Add a parameter to the schema.
260    pub fn param(mut self, param: ParamSchema) -> Self {
261        self.params.push(param);
262        self
263    }
264
265    /// Add an example to the schema.
266    pub fn example(mut self, description: impl Into<String>, code: impl Into<String>) -> Self {
267        self.examples.push(Example::new(description, code));
268        self
269    }
270
271    /// Add a child schema, making this a subcommand-aware tool.
272    pub fn subcommand(mut self, child: ToolSchema) -> Self {
273        self.subcommands.push(child);
274        self
275    }
276
277    /// Set command-level aliases (e.g. `ls` for a `list` subcommand). These
278    /// name the *command*, not its flags; flag aliases live on each
279    /// [`ParamSchema`].
280    pub fn with_command_aliases(mut self, aliases: impl IntoIterator<Item = impl Into<String>>) -> Self {
281        self.aliases = aliases.into_iter().map(Into::into).collect();
282        self
283    }
284
285    /// True if `word` names this command — its `name` or any of its
286    /// command-level `aliases`. Used when routing a positional to a child.
287    pub fn matches_command(&self, word: &str) -> bool {
288        self.name == word || self.aliases.iter().any(|a| a == word)
289    }
290
291    /// Declare that this tool renders its own output (including `--json`), so
292    /// the kernel won't re-format its result.
293    ///
294    /// Applies to the whole tree: every subcommand is marked too, and a `json`
295    /// param is advertised on each node that doesn't already declare one.
296    /// Reflection skips `json` as the kernel-global output flag, so this
297    /// re-advertises it for tools that genuinely own it — closing the loop so
298    /// `help <tool> <sub>` lists `--json` where the tool actually handles it.
299    pub fn with_owned_output(mut self) -> Self {
300        self.mark_owned_output();
301        self
302    }
303
304    fn mark_owned_output(&mut self) {
305        self.owns_output = true;
306        if !self.params.iter().any(|p| p.name == "json") {
307            self.params.push(
308                ParamSchema::new("json", "bool").with_description("Render output as JSON"),
309            );
310        }
311        for child in &mut self.subcommands {
312            child.mark_owned_output();
313        }
314    }
315}
316
317/// Parsed arguments ready for tool execution.
318#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
319#[non_exhaustive]
320pub struct ToolArgs {
321    /// Positional arguments in order.
322    pub positional: Vec<Value>,
323    /// Named arguments by key.
324    pub named: BTreeMap<String, Value>,
325    /// Boolean flags (e.g., -l, --force).
326    pub flags: HashSet<String>,
327}
328
329impl ToolArgs {
330    /// Create empty args.
331    pub fn new() -> Self {
332        Self::default()
333    }
334
335    /// Get a positional argument by index.
336    pub fn get_positional(&self, index: usize) -> Option<&Value> {
337        self.positional.get(index)
338    }
339
340    /// Get a named argument by key.
341    pub fn get_named(&self, key: &str) -> Option<&Value> {
342        self.named.get(key)
343    }
344
345    /// Get a named argument or positional fallback.
346    ///
347    /// Useful for tools that accept both `cat file.txt` and `cat path=file.txt`.
348    pub fn get(&self, name: &str, positional_index: usize) -> Option<&Value> {
349        self.named.get(name).or_else(|| self.positional.get(positional_index))
350    }
351
352    /// Get a string value from args.
353    pub fn get_string(&self, name: &str, positional_index: usize) -> Option<String> {
354        self.get(name, positional_index).and_then(|v| match v {
355            Value::String(s) => Some(s.clone()),
356            Value::Int(i) => Some(i.to_string()),
357            Value::Float(f) => Some(f.to_string()),
358            Value::Bool(b) => Some(b.to_string()),
359            _ => None,
360        })
361    }
362
363    /// Get a boolean value from args.
364    pub fn get_bool(&self, name: &str, positional_index: usize) -> Option<bool> {
365        self.get(name, positional_index).and_then(|v| match v {
366            Value::Bool(b) => Some(*b),
367            Value::String(s) => match s.as_str() {
368                "true" | "yes" | "1" => Some(true),
369                "false" | "no" | "0" => Some(false),
370                _ => None,
371            },
372            Value::Int(i) => Some(*i != 0),
373            _ => None,
374        })
375    }
376
377    /// Check if a flag is set (in flags set, or named bool).
378    pub fn has_flag(&self, name: &str) -> bool {
379        // Check the flags set first (from -x or --name syntax)
380        if self.flags.contains(name) {
381            return true;
382        }
383        // Fall back to checking named args (from name=true syntax)
384        self.named.get(name).is_some_and(|v| match v {
385            Value::Bool(b) => *b,
386            Value::String(s) => !s.is_empty() && s != "false" && s != "0",
387            _ => true,
388        })
389    }
390
391    /// Move bool entries from `named` into the appropriate set so a downstream
392    /// clap parser (with `#[arg(...)] field: bool`) accepts them.
393    ///
394    /// Tests routinely seed `args.named.insert(K, Value::Bool(true))` for the
395    /// schema-pre-clap path; `to_argv()` would emit those as `--K=true`, which
396    /// clap rejects for `bool` fields. Promote to:
397    /// - `Bool(true)` → presence in `flags` (clap sees `--K`).
398    /// - `Bool(false)` → dropped (clap treats absent flag and explicit false
399    ///   the same; preserving it would only resurface as `--K=false` and break
400    ///   the same parser).
401    ///
402    /// A `Value::Bool` parked under a key the `schema` declares as a *value-taking*
403    /// flag is the flag's literal value, not a bare bool flag — `spawn --command
404    /// true` binds `command = Bool(true)`. Those keys are left in `named` so
405    /// `to_argv()` renders `--command=true` and clap's `Option<String>` field
406    /// accepts it; collapsing them to a bare `--command` drops the value and
407    /// makes clap error "a value is required". (See docs/issues.md.)
408    ///
409    /// Idempotent. Non-bool named entries are left alone.
410    pub fn flagify_bool_named(&mut self, schema: &ToolSchema) {
411        // Keys (param names + aliases) the schema declares as non-bool, non-positional
412        // flags — i.e. flags that take a value.
413        let value_keys: HashSet<&str> = schema
414            .params
415            .iter()
416            .filter(|p| !p.positional && !is_bool_param_type(&p.param_type))
417            .flat_map(|p| {
418                std::iter::once(p.name.as_str())
419                    .chain(p.aliases.iter().map(|a| a.trim_start_matches('-')))
420            })
421            .collect();
422
423        let bool_keys: Vec<String> = self
424            .named
425            .iter()
426            .filter(|(k, v)| matches!(v, Value::Bool(_)) && !value_keys.contains(k.as_str()))
427            .map(|(k, _)| k.clone())
428            .collect();
429        for k in bool_keys {
430            // Remove unconditionally so Bool(false) doesn't linger and break
431            // a `--K=false` rejection in clap. Only Bool(true) re-enters as a
432            // flag presence.
433            if let Some(Value::Bool(true)) = self.named.remove(&k) {
434                self.flags.insert(k);
435            }
436        }
437    }
438
439    /// Reconstruct a clap-friendly argv vector from already-parsed ToolArgs.
440    ///
441    /// kaish has already done shell parsing (variables expanded, globs expanded,
442    /// `$(...)` substituted, schema-driven flag/value splitting). `to_argv`
443    /// rebuilds a flat token stream suitable for `Parser::parse_from(std::iter::once("<tool>").chain(args.to_argv()))`.
444    ///
445    /// Layout: flags first (as `--<name>`), then named values (as
446    /// `--<name>=<value>`), then positionals — separated from earlier sections
447    /// by `--` so trailing-passthrough builtins still see them as positionals
448    /// even if a value happens to begin with `-`.
449    ///
450    /// See docs/clap-migration.md for the full recipe.
451    pub fn to_argv(&self) -> Vec<String> {
452        let mut argv = Vec::with_capacity(
453            self.flags.len() + self.named.len() * 2 + self.positional.len() + 1,
454        );
455
456        // Flags are unordered (HashSet); sort for deterministic argv so tests
457        // and snapshots stay stable. Single-char keys emit short form (`-n`)
458        // so clap's natural `#[arg(short = 'n', long = "no_newline")]` derive
459        // accepts them without needing visible_alias gymnastics.
460        let mut flags: Vec<&String> = self.flags.iter().collect();
461        flags.sort();
462        for flag in flags {
463            argv.push(flag_token(flag));
464        }
465
466        // Named values: emit `-k=value` for single-char keys and `--key=value`
467        // for multi-char keys. `=` form keeps parsing unambiguous when the
468        // value begins with `-`. Multi-value (`consumes > 1`) params are
469        // stored as Value::Json(Array(Array(...))) — one entry per occurrence.
470        for (key, value) in &self.named {
471            for rendered in render_named_value(value) {
472                argv.push(format!("{}={}", flag_token(key), rendered));
473            }
474        }
475
476        // `--` terminator so clap treats positionals as positionals even if
477        // they begin with `-` (e.g. `echo -- -n` should print `-n`).
478        if !self.positional.is_empty() {
479            argv.push("--".to_string());
480            for value in &self.positional {
481                argv.push(value_to_argv_token(value));
482            }
483        }
484
485        argv
486    }
487}
488
489fn flag_token(name: &str) -> String {
490    if name.chars().count() == 1 {
491        format!("-{name}")
492    } else {
493        format!("--{name}")
494    }
495}
496
497/// Whether a `ParamSchema::param_type` names a boolean flag.
498fn is_bool_param_type(param_type: &str) -> bool {
499    param_type.eq_ignore_ascii_case("bool") || param_type.eq_ignore_ascii_case("boolean")
500}
501
502fn render_named_value(value: &Value) -> Vec<String> {
503    match value {
504        // `consumes > 1` lands as Json(Array(Array(...))) — one inner array per
505        // occurrence. Flatten each inner array into space-joined tokens; clap
506        // can split on `=` further if needed.
507        Value::Json(serde_json::Value::Array(outer)) if outer.iter().all(|v| v.is_array()) => {
508            outer
509                .iter()
510                .map(|inner| {
511                    inner
512                        .as_array()
513                        .map(|a| a.iter().map(json_value_to_token).collect::<Vec<_>>().join(" "))
514                        .unwrap_or_default()
515                })
516                .collect()
517        }
518        _ => vec![value_to_argv_token(value)],
519    }
520}
521
522fn value_to_argv_token(value: &Value) -> String {
523    match value {
524        Value::Null => String::new(),
525        Value::Bool(b) => b.to_string(),
526        Value::Int(i) => i.to_string(),
527        Value::Float(f) => f.to_string(),
528        Value::String(s) => s.clone(),
529        Value::Json(j) => j.to_string(),
530        // Splatting binary into argv is a text context; a real loud-error guard
531        // lands with the arg-building rework (Phase 2). For now mark it visibly
532        // rather than emitting raw bytes. See docs/binary-data.md.
533        Value::Bytes(data) => format!("[binary: {} bytes]", data.len()),
534    }
535}
536
537fn json_value_to_token(value: &serde_json::Value) -> String {
538    match value {
539        serde_json::Value::Null => String::new(),
540        serde_json::Value::Bool(b) => b.to_string(),
541        serde_json::Value::Number(n) => n.to_string(),
542        serde_json::Value::String(s) => s.clone(),
543        other => other.to_string(),
544    }
545}
546
547#[cfg(test)]
548mod schema_serde_tests {
549    use super::*;
550
551    /// A flat tool (no subcommands/aliases) must serialize byte-identically to
552    /// the pre-subcommand wire format: the two new fields are skipped entirely.
553    #[test]
554    fn flat_schema_omits_new_fields_on_wire() {
555        let schema = ToolSchema::new("cat", "concatenate")
556            .param(ParamSchema::required("path", "string", "file to read").positional());
557        let json = serde_json::to_value(&schema).expect("serialize");
558        let obj = json.as_object().expect("object");
559        assert!(!obj.contains_key("subcommands"), "flat tool leaks subcommands: {json}");
560        assert!(!obj.contains_key("aliases"), "flat tool leaks command aliases: {json}");
561    }
562
563    /// Round-trip the skip: a flat tool serializes *without* the keys, so the
564    /// deserializer must `default` them back to empty. (This is what lets us
565    /// skip-serialize empties without breaking our own flat tools' payloads.)
566    #[test]
567    fn flat_wire_form_deserializes_to_empty() {
568        let flat = serde_json::json!({
569            "name": "cat",
570            "description": "concatenate",
571            "params": [],
572            "examples": [],
573            "map_positionals": false
574        });
575        let schema: ToolSchema = serde_json::from_value(flat).expect("deserialize flat form");
576        assert!(schema.subcommands.is_empty());
577        assert!(schema.aliases.is_empty());
578    }
579
580    /// `with_owned_output` marks the whole tree and advertises `json` on each
581    /// node that didn't already declare it.
582    #[test]
583    fn with_owned_output_marks_tree_and_advertises_json() {
584        let schema = ToolSchema::new("kj", "kaijutsu")
585            .subcommand(
586                ToolSchema::new("context", "ctx")
587                    .subcommand(ToolSchema::new("list", "list contexts")),
588            )
589            .with_owned_output();
590
591        assert!(schema.owns_output, "root marked");
592        assert!(schema.params.iter().any(|p| p.name == "json"), "root advertises json");
593        let context = &schema.subcommands[0];
594        assert!(context.owns_output, "child marked");
595        let list = &context.subcommands[0];
596        assert!(list.owns_output, "grandchild marked");
597        assert!(list.params.iter().any(|p| p.name == "json"), "leaf advertises json");
598    }
599
600    /// `with_owned_output` doesn't duplicate an already-declared `json` param.
601    #[test]
602    fn with_owned_output_does_not_double_add_json() {
603        let schema = ToolSchema::new("kj", "kaijutsu")
604            .param(ParamSchema::new("json", "bool"))
605            .with_owned_output();
606        let json_count = schema.params.iter().filter(|p| p.name == "json").count();
607        assert_eq!(json_count, 1, "json should appear exactly once");
608    }
609
610    /// `owns_output` round-trips and is omitted from the wire when false.
611    #[test]
612    fn owns_output_serde() {
613        let flat = ToolSchema::new("ls", "list");
614        let json = serde_json::to_value(&flat).expect("serialize");
615        let obj = json.as_object().expect("object");
616        assert!(!obj.contains_key("owns_output"), "false omitted: {json}");
617
618        let owned = ToolSchema::new("kj", "kaijutsu").with_owned_output();
619        let wire = serde_json::to_string(&owned).expect("serialize");
620        let back: ToolSchema = serde_json::from_str(&wire).expect("deserialize");
621        assert!(back.owns_output);
622    }
623
624    /// A subcommand tree round-trips through serde with names and aliases intact.
625    #[test]
626    fn subcommand_tree_round_trips() {
627        let schema = ToolSchema::new("kj", "kaijutsu")
628            .subcommand(
629                ToolSchema::new("context", "context ops")
630                    .with_command_aliases(["ctx"])
631                    .subcommand(ToolSchema::new("list", "list contexts").with_command_aliases(["ls"])),
632            );
633        let json = serde_json::to_string(&schema).expect("serialize");
634        let back: ToolSchema = serde_json::from_str(&json).expect("deserialize");
635        assert_eq!(back.subcommands.len(), 1);
636        let context = &back.subcommands[0];
637        assert!(context.matches_command("context"));
638        assert!(context.matches_command("ctx"));
639        assert_eq!(context.subcommands.len(), 1);
640        assert!(context.subcommands[0].matches_command("ls"));
641    }
642}
643
644#[cfg(test)]
645mod to_argv_tests {
646    use super::*;
647
648    #[test]
649    fn empty_args_produce_empty_argv() {
650        assert!(ToolArgs::new().to_argv().is_empty());
651    }
652
653    #[test]
654    fn positionals_emitted_after_double_dash() {
655        let mut args = ToolArgs::new();
656        args.positional.push(Value::String("hello".into()));
657        args.positional.push(Value::String("world".into()));
658        assert_eq!(args.to_argv(), vec!["--", "hello", "world"]);
659    }
660
661    #[test]
662    fn single_char_flags_emit_short_form() {
663        let mut args = ToolArgs::new();
664        args.flags.insert("n".into());
665        args.flags.insert("verbose".into());
666        // Sorted: "n" then "verbose"
667        assert_eq!(args.to_argv(), vec!["-n", "--verbose"]);
668    }
669
670    #[test]
671    fn named_values_use_equals_form() {
672        let mut args = ToolArgs::new();
673        args.named.insert("count".into(), Value::Int(5));
674        args.named.insert("name".into(), Value::String("foo".into()));
675        // BTreeMap iterates in key order, so "count" before "name"
676        assert_eq!(args.to_argv(), vec!["--count=5", "--name=foo"]);
677    }
678
679    #[test]
680    fn single_char_named_emits_short_equals() {
681        let mut args = ToolArgs::new();
682        args.named.insert("n".into(), Value::Int(5));
683        assert_eq!(args.to_argv(), vec!["-n=5"]);
684    }
685
686    #[test]
687    fn positional_with_leading_dash_survives_double_dash() {
688        let mut args = ToolArgs::new();
689        args.positional.push(Value::String("-n".into()));
690        // `echo -- -n` should round-trip as `-- -n`, not be reparsed as a flag.
691        assert_eq!(args.to_argv(), vec!["--", "-n"]);
692    }
693
694    #[test]
695    fn mixed_flags_named_positionals() {
696        let mut args = ToolArgs::new();
697        args.flags.insert("verbose".into());
698        args.named.insert("limit".into(), Value::Int(10));
699        args.positional.push(Value::String("file.txt".into()));
700        assert_eq!(
701            args.to_argv(),
702            vec!["--verbose", "--limit=10", "--", "file.txt"]
703        );
704    }
705
706    #[test]
707    fn flagify_bool_named_promotes_true_to_flag() {
708        let mut args = ToolArgs::new();
709        args.named.insert("recursive".into(), Value::Bool(true));
710        args.named.insert("limit".into(), Value::Int(5));
711
712        args.flagify_bool_named(&ToolSchema::new("t", ""));
713
714        assert!(args.flags.contains("recursive"));
715        assert!(!args.named.contains_key("recursive"));
716        // Non-bool entries are untouched.
717        assert_eq!(args.named.get("limit"), Some(&Value::Int(5)));
718    }
719
720    #[test]
721    fn flagify_bool_named_drops_false() {
722        let mut args = ToolArgs::new();
723        args.named.insert("recursive".into(), Value::Bool(false));
724
725        args.flagify_bool_named(&ToolSchema::new("t", ""));
726
727        assert!(!args.flags.contains("recursive"));
728        assert!(!args.named.contains_key("recursive"));
729    }
730
731    #[test]
732    fn flagify_bool_named_is_idempotent() {
733        let mut args = ToolArgs::new();
734        args.named.insert("recursive".into(), Value::Bool(true));
735        args.flagify_bool_named(&ToolSchema::new("t", ""));
736        args.flagify_bool_named(&ToolSchema::new("t", ""));
737        assert!(args.flags.contains("recursive"));
738    }
739
740    /// Regression guard: argv emitted after flagify must round-trip through
741    /// a clap parser without `--K=true` showing up.
742    #[test]
743    fn flagify_bool_named_round_trips_through_to_argv() {
744        let mut args = ToolArgs::new();
745        args.named.insert("R".into(), Value::Bool(true));
746        args.flagify_bool_named(&ToolSchema::new("t", ""));
747        let argv = args.to_argv();
748        assert!(argv.contains(&"-R".to_string()), "expected -R, got {:?}", argv);
749        assert!(!argv.iter().any(|s| s.contains('=')), "no =value should appear, got {:?}", argv);
750    }
751
752    /// A `Bool(true)` parked under a schema-declared value-taking flag is the
753    /// flag's literal value (`spawn --command true`), not a bare bool flag — it
754    /// stays in `named` and renders as `--K=true`, not a value-less `--K`.
755    #[test]
756    fn flagify_bool_named_keeps_value_flag_value() {
757        let mut schema = ToolSchema::new("spawn", "");
758        schema.params.push(ParamSchema::new("command", "string"));
759
760        let mut args = ToolArgs::new();
761        args.named.insert("command".into(), Value::Bool(true));
762        args.flagify_bool_named(&schema);
763
764        assert!(!args.flags.contains("command"), "value flag must not collapse to a bare flag");
765        assert_eq!(args.named.get("command"), Some(&Value::Bool(true)));
766        let argv = args.to_argv();
767        assert!(
768            argv.iter().any(|s| s == "--command=true"),
769            "expected --command=true, got {:?}",
770            argv
771        );
772    }
773
774    /// One schema carrying both a bool flag and a value-taking flag: the bool
775    /// flag still flagifies, the value flag keeps its value. Proves
776    /// `is_bool_param_type` actually distinguishes the two (an empty-schema test
777    /// can't — it flagifies everything regardless).
778    #[test]
779    fn flagify_bool_named_distinguishes_bool_from_value_param() {
780        let mut schema = ToolSchema::new("t", "");
781        schema.params.push(ParamSchema::new("verbose", "bool"));
782        schema.params.push(ParamSchema::new("command", "string"));
783
784        let mut args = ToolArgs::new();
785        args.named.insert("verbose".into(), Value::Bool(true));
786        args.named.insert("command".into(), Value::Bool(true));
787        args.flagify_bool_named(&schema);
788
789        // Bool flag → promoted to a bare flag.
790        assert!(args.flags.contains("verbose"));
791        assert!(!args.named.contains_key("verbose"));
792        // Value flag → value retained.
793        assert!(!args.flags.contains("command"));
794        assert_eq!(args.named.get("command"), Some(&Value::Bool(true)));
795    }
796}