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