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/// How the kernel's argv binder hands a tool its arguments.
195///
196/// The default split into [`ToolArgs::positional`], [`ToolArgs::named`] and
197/// [`ToolArgs::flags`] is set-shaped, which suits most tools but cannot serve a
198/// subcommand tree: `kj block list --limit 5` renders back as
199/// `--limit=5 -- block list`, which clap rejects because `--limit` belongs to
200/// `list`, not the root. Order and multiplicity are gone by then.
201///
202/// A third binding is plausible, so this enum is `#[non_exhaustive]` from the
203/// day it ships: adding the attribute later is itself a breaking change. Match
204/// with a wildcard arm that fails loudly, never a silent default.
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
206#[serde(rename_all = "lowercase")]
207#[non_exhaustive]
208pub enum ArgBinding {
209    /// Decompose into `positional`/`named`/`flags`. The default; every tool
210    /// that does not ask for something else gets this.
211    #[default]
212    Typed,
213    /// Hand the tool every word after its name, in source order, as
214    /// [`ToolArgs::words`]. See [`ToolSchema::with_verbatim_argv`].
215    Verbatim,
216}
217
218impl ArgBinding {
219    /// True for the default binding. `skip_serializing_if` reads this so a
220    /// typed tool's schema carries no `"arg_binding"` key at all.
221    pub fn is_typed(&self) -> bool {
222        matches!(self, ArgBinding::Typed)
223    }
224}
225
226/// Schema describing a tool's interface.
227#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
228#[non_exhaustive]
229pub struct ToolSchema {
230    /// Tool name.
231    pub name: String,
232    /// Short description.
233    pub description: String,
234    /// Parameter definitions.
235    pub params: Vec<ParamSchema>,
236    /// Whether `$(tool)` binds this tool's `.data` as a typed value rather
237    /// than binding its text. See [`ToolSchema::with_typed_substitution`].
238    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
239    pub typed_substitution: bool,
240    /// Usage examples.
241    pub examples: Vec<Example>,
242    /// Map remaining positional args to named params by schema order.
243    /// Only for MCP/external tools that expect named JSON params.
244    /// Builtins handle their own positionals and should leave this false.
245    pub map_positionals: bool,
246    /// Child schemas for subcommand-aware tools (`kj context list`, …).
247    ///
248    /// Empty for flat tools (`cat`, `grep`, `ls`) — they take the flat binding
249    /// path. When non-empty, the kernel walks leading positionals to pick the
250    /// active leaf and binds flags against *that leaf's* `params` (see
251    /// `select_leaf` in the kernel).
252    ///
253    /// `skip_serializing_if` keeps the wire compact for the many flat tools
254    /// (no `"subcommands":[]` noise); `default` is then required so a flat
255    /// tool's payload (key absent) deserializes back to empty.
256    #[serde(default, skip_serializing_if = "Vec::is_empty")]
257    pub subcommands: Vec<ToolSchema>,
258    /// Command-level aliases (`ls` → `list`, `rm` → `remove`), matched when
259    /// routing a positional to a child. Distinct from [`ParamSchema::aliases`],
260    /// which name *flags*.
261    #[serde(default, skip_serializing_if = "Vec::is_empty")]
262    pub aliases: Vec<String>,
263    /// The tool renders its **own** output, including `--json` — the kernel
264    /// must not re-format its `ExecResult` through `apply_output_format`.
265    ///
266    /// Default false: a tool returns typed [`crate::OutputData`] and the kernel
267    /// renders the requested format uniformly. Set true for tools with bespoke
268    /// JSON envelopes (e.g. an embedder's `kj`): they consume `--json`
269    /// themselves and emit final bytes. See [`ToolSchema::with_owned_output`].
270    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
271    pub owns_output: bool,
272    /// The tool wants its argv **in source order, with types preserved** — the
273    /// binder must NOT split flags into the unordered `flags` set. When true,
274    /// every argument is bound to `positional` in the order written (operators
275    /// like `-f`/`=`/`!` as strings, operands keeping their `Value` type), and
276    /// `named`/`flags` stay empty.
277    ///
278    /// Default false: normal tools get the clap-style order-independent split
279    /// (`-la` == `-al`). Set true for the rare *position-sensitive* command
280    /// whose operands may themselves look like flags — POSIX `test`, where
281    /// `test $x = -n` and `test 0 -gt -5` must see `-n`/`-5` as literal
282    /// operands. See [`ToolSchema::with_raw_argv`].
283    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
284    pub raw_argv: bool,
285    /// How the binder hands this tool its arguments. See [`ArgBinding`].
286    #[serde(default, skip_serializing_if = "ArgBinding::is_typed")]
287    pub arg_binding: ArgBinding,
288    /// The tool consumes glob patterns **as data** — the argv binder must pass
289    /// a bare glob pattern through as literal text instead of expanding it to
290    /// matching paths.
291    ///
292    /// Default false: shell semantics — `cat *.rs` sees matching files and
293    /// zero matches is a bind-time error. Set true for a tool whose input *is*
294    /// the pattern (`glob`), so the natural unquoted spelling
295    /// (`glob **/*.rs`) hands the pattern text to the tool instead of walking
296    /// the tree at bind time and binding the first match as the "pattern".
297    /// See [`ToolSchema::with_glob_passthrough`].
298    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
299    pub glob_passthrough: bool,
300    /// Dotted effect ids this tool declares (`fs.remove`, `fs.overwrite`,
301    /// …) — what an embedder reads off `tools --json` to learn a tool's
302    /// destructive effects instead of recognizing tool names. Empty for a
303    /// tool with no destructive effect. A flat tool with several behaviors
304    /// behind one schema (`kaish-trash`'s `list`/`restore`/`config`/`empty`)
305    /// lists every effect any of its behaviors has, not just the ones the
306    /// current invocation will reach — the schema is reflected once, before
307    /// argv says which behavior runs. See [`ToolSchema::with_operations`].
308    #[serde(default, skip_serializing_if = "Vec::is_empty")]
309    pub operations: Vec<String>,
310}
311
312impl ToolSchema {
313    /// Create a new tool schema.
314    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
315        Self {
316            name: name.into(),
317            description: description.into(),
318            params: Vec::new(),
319            examples: Vec::new(),
320            map_positionals: false,
321            subcommands: Vec::new(),
322            aliases: Vec::new(),
323            owns_output: false,
324            raw_argv: false,
325            arg_binding: ArgBinding::Typed,
326            glob_passthrough: false,
327            typed_substitution: false,
328            operations: Vec::new(),
329        }
330    }
331
332    /// Declare that this tool wants its argv in source order with types
333    /// preserved (no flag/positional split). See [`ToolSchema::raw_argv`].
334    ///
335    /// Setting this and [`ToolSchema::with_verbatim_argv`] together is a
336    /// mistake; verbatim wins.
337    pub fn with_raw_argv(mut self) -> Self {
338        self.raw_argv = true;
339        self
340    }
341
342    /// Declare that this tool parses its own argv: the binder fills
343    /// [`ToolArgs::words`] with every word after the tool name, in source
344    /// order, post-expansion, and leaves the split empty. See [`ArgBinding`]
345    /// for when to use it. [`ToolArgs::to_argv`] cannot put
346    /// them back — a verbatim tool builds its argv from `words` with no
347    /// inversion at all ([`ToolArgs::words_argv`] does the rendering).
348    ///
349    /// The kernel still owns the global flags: `--json` is removed from
350    /// `words` wherever it appears and applied to the output format, so a
351    /// verbatim tool never sees it and cannot get it wrong. The schema is
352    /// unchanged either way — it still supplies help, completion and the
353    /// parameter list.
354    ///
355    /// Distinct from [`ToolSchema::with_raw_argv`], which also keeps source
356    /// order but binds into `positional` and does not lift the global flags.
357    /// Setting both is a mistake; verbatim wins.
358    ///
359    /// With [`ToolSchema::with_owned_output`] the tool keeps `--json` in its
360    /// own words — the kernel renders nothing for such a tool, so lifting the
361    /// flag would leave it handled by no one.
362    pub fn with_verbatim_argv(mut self) -> Self {
363        self.arg_binding = ArgBinding::Verbatim;
364        self
365    }
366
367    /// Declare that this tool's `.data` IS its value, so `$(tool)` binds it
368    /// typed instead of binding the text it printed.
369    ///
370    /// `.data` does three jobs: it feeds `--json`, it is the pipeline's
371    /// structured sideband, and it is what a command substitution binds. Only
372    /// the third is a question of taste, and answering it from "does this tool
373    /// set `.data`" got it wrong: `cut -f2 f` bound `["b"]` while
374    /// `awk '{print $2}' f`, doing the identical job, bound text.
375    ///
376    /// Declare it when the structured thing IS the answer — `fromjson`, `jq`,
377    /// `keys`, `values`. Leave it off when `.data` is a structured VIEW of
378    /// text the tool already printed, which is every tool with a POSIX
379    /// counterpart: those read as their POSIX selves, and a caller who wants
380    /// types asks with `--json`.
381    ///
382    /// Not inferable from the constructor: `jq` and `cut` both build with
383    /// `success_with_data` and belong on opposite sides.
384    pub fn with_typed_substitution(mut self) -> Self {
385        self.typed_substitution = true;
386        self
387    }
388
389    /// Declare that this tool consumes glob patterns as data: the argv binder
390    /// passes bare patterns through as literal text instead of expanding them.
391    /// See [`ToolSchema::glob_passthrough`].
392    pub fn with_glob_passthrough(mut self) -> Self {
393        self.glob_passthrough = true;
394        self
395    }
396
397    /// Declare the dotted effect ids this tool carries. See
398    /// [`ToolSchema::operations`].
399    pub fn with_operations(mut self, operations: impl IntoIterator<Item = impl Into<String>>) -> Self {
400        self.operations = operations.into_iter().map(Into::into).collect();
401        self
402    }
403
404    /// Enable positional->named parameter mapping for MCP/external tools.
405    pub fn with_positional_mapping(mut self) -> Self {
406        self.map_positionals = true;
407        self
408    }
409
410    /// Add a parameter to the schema.
411    pub fn param(mut self, param: ParamSchema) -> Self {
412        self.params.push(param);
413        self
414    }
415
416    /// Add an example to the schema.
417    pub fn example(mut self, description: impl Into<String>, code: impl Into<String>) -> Self {
418        self.examples.push(Example::new(description, code));
419        self
420    }
421
422    /// Add a child schema, making this a subcommand-aware tool.
423    pub fn subcommand(mut self, child: ToolSchema) -> Self {
424        self.subcommands.push(child);
425        self
426    }
427
428    /// Set command-level aliases (e.g. `ls` for a `list` subcommand). These
429    /// name the *command*, not its flags; flag aliases live on each
430    /// [`ParamSchema`].
431    pub fn with_command_aliases(mut self, aliases: impl IntoIterator<Item = impl Into<String>>) -> Self {
432        self.aliases = aliases.into_iter().map(Into::into).collect();
433        self
434    }
435
436    /// True if `word` names this command — its `name` or any of its
437    /// command-level `aliases`. Used when routing a positional to a child.
438    pub fn matches_command(&self, word: &str) -> bool {
439        self.name == word || self.aliases.iter().any(|a| a == word)
440    }
441
442    /// Declare that this tool renders its own output (including `--json`), so
443    /// the kernel won't re-format its result.
444    ///
445    /// Applies to the whole tree: every subcommand is marked too, and a `json`
446    /// param is advertised on each node that doesn't already declare one.
447    /// Reflection skips `json` as the kernel-global output flag, so this
448    /// re-advertises it for tools that genuinely own it — closing the loop so
449    /// `help <tool> <sub>` lists `--json` where the tool actually handles it.
450    pub fn with_owned_output(mut self) -> Self {
451        self.mark_owned_output();
452        self
453    }
454
455    fn mark_owned_output(&mut self) {
456        self.owns_output = true;
457        if !self.params.iter().any(|p| p.name == "json") {
458            self.params.push(
459                ParamSchema::new("json", "bool").with_description("Render output as JSON"),
460            );
461        }
462        for child in &mut self.subcommands {
463            child.mark_owned_output();
464        }
465    }
466}
467
468/// Parsed arguments ready for tool execution.
469#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
470#[non_exhaustive]
471pub struct ToolArgs {
472    /// Positional arguments in order.
473    pub positional: Vec<Value>,
474    /// Verbatim source text for a `positional` entry whose typed `Display`
475    /// would not reproduce it — `-0`, `0.10`, `1.0`. Keyed by index into
476    /// `positional`, and empty for the common case.
477    ///
478    /// Most builtins read a positional straight off `positional`, never off
479    /// the clap-parsed struct, and a plain `Value::Int` cannot carry the
480    /// source text once typed. A builtin echoing a positional's text verbatim
481    /// should call [`positional_text`](Self::positional_text).
482    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
483    pub positional_raw: BTreeMap<usize, String>,
484    /// Named arguments by key.
485    pub named: BTreeMap<String, Value>,
486    /// Same idea as [`positional_raw`](Self::positional_raw), keyed by the
487    /// named key. A named value is normally read off the clap-parsed struct,
488    /// so `to_argv`/`to_argv_excluding` consult this when rendering
489    /// `--key=value` and the source text reaches the clap field that way.
490    /// Only populated for a single, non-repeated value.
491    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
492    pub named_raw: BTreeMap<String, String>,
493    /// Boolean flags (e.g., -l, --force).
494    pub flags: HashSet<String>,
495    /// Every word after the tool name, in source order, post-expansion —
496    /// `Some` only for an [`ArgBinding::Verbatim`] tool, `None` for every
497    /// other tool.
498    ///
499    /// A text word arrives as [`Value::String`]; a heredoc- or pipe-bound word
500    /// keeps its [`Value::Bytes`]. `positional` and `named` are empty when this
501    /// is `Some`; `flags` holds only the global flags the binder lifted out
502    /// (today just `json`), so `has_flag("json")` still answers.
503    ///
504    /// Render it to a clap argv with [`ToolArgs::words_argv`].
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    pub words: Option<Vec<Value>>,
507    /// Same idea as `positional_raw`, but for `words` — keyed by index into
508    /// `words`. Only ever populated when `words` is `Some`.
509    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
510    pub words_raw: BTreeMap<usize, String>,
511}
512
513impl ToolArgs {
514    /// Create empty args.
515    pub fn new() -> Self {
516        Self::default()
517    }
518
519    /// The display text for positional `index`: its
520    /// [`positional_raw`](Self::positional_raw) entry when there is one,
521    /// otherwise `value_to_argv_token` on the typed value. `None` when
522    /// `index` is out of range.
523    ///
524    /// A builtin printing a positional's text as-is should call this rather
525    /// than stringify `positional[index]` itself. A builtin needing the typed
526    /// value should keep reading `positional[index]`, which is unaffected.
527    pub fn positional_text(&self, index: usize) -> Option<String> {
528        if let Some(raw) = self.positional_raw.get(&index) {
529            return Some(raw.clone());
530        }
531        self.positional.get(index).map(value_to_argv_token)
532    }
533
534    /// Render [`words`](Self::words) into argv tokens for a verbatim tool's
535    /// own parser. Empty when the tool is not verbatim.
536    ///
537    /// A [`Value::Bytes`] word renders as an inert placeholder token, as
538    /// [`to_argv`](Self::to_argv) does for a binary positional; the real bytes
539    /// stay at the matching index in `words`. A non-canonical numeral (`-0`,
540    /// `0.10`) renders its `words_raw` entry instead of `value`'s `Display`,
541    /// the same substitution `positional_text` makes for `positional`.
542    pub fn words_argv(&self) -> Vec<String> {
543        self.words
544            .as_deref()
545            .unwrap_or_default()
546            .iter()
547            .enumerate()
548            .map(|(i, value)| {
549                self.words_raw
550                    .get(&i)
551                    .cloned()
552                    .unwrap_or_else(|| value_to_argv_token(value))
553            })
554            .collect()
555    }
556
557    /// Get a positional argument by index.
558    pub fn get_positional(&self, index: usize) -> Option<&Value> {
559        self.positional.get(index)
560    }
561
562    /// Get a named argument by key.
563    pub fn get_named(&self, key: &str) -> Option<&Value> {
564        self.named.get(key)
565    }
566
567    /// Get a named argument or positional fallback.
568    ///
569    /// Useful for tools that accept both `cat file.txt` and `cat path=file.txt`.
570    pub fn get(&self, name: &str, positional_index: usize) -> Option<&Value> {
571        self.named.get(name).or_else(|| self.positional.get(positional_index))
572    }
573
574    /// Get a string value from args.
575    pub fn get_string(&self, name: &str, positional_index: usize) -> Option<String> {
576        self.get(name, positional_index).and_then(|v| match v {
577            Value::String(s) => Some(s.clone()),
578            Value::Int(i) => Some(i.to_string()),
579            Value::Float(f) => Some(f.to_string()),
580            Value::Bool(b) => Some(b.to_string()),
581            _ => None,
582        })
583    }
584
585    /// Get a boolean value from args.
586    pub fn get_bool(&self, name: &str, positional_index: usize) -> Option<bool> {
587        self.get(name, positional_index).and_then(|v| match v {
588            Value::Bool(b) => Some(*b),
589            Value::String(s) => match s.as_str() {
590                "true" | "yes" | "1" => Some(true),
591                "false" | "no" | "0" => Some(false),
592                _ => None,
593            },
594            Value::Int(i) => Some(*i != 0),
595            _ => None,
596        })
597    }
598
599    /// Check if a flag is set (in flags set, or named bool).
600    pub fn has_flag(&self, name: &str) -> bool {
601        // Check the flags set first (from -x or --name syntax)
602        if self.flags.contains(name) {
603            return true;
604        }
605        // Fall back to checking named args (from name=true syntax)
606        self.named.get(name).is_some_and(|v| match v {
607            Value::Bool(b) => *b,
608            Value::String(s) => !s.is_empty() && s != "false" && s != "0",
609            _ => true,
610        })
611    }
612
613    /// Move bool entries from `named` into the appropriate set so a downstream
614    /// clap parser (with `#[arg(...)] field: bool`) accepts them.
615    ///
616    /// Tests routinely seed `args.named.insert(K, Value::Bool(true))` for the
617    /// schema-pre-clap path; `to_argv()` would emit those as `--K=true`, which
618    /// clap rejects for `bool` fields. Promote to:
619    /// - `Bool(true)` → presence in `flags` (clap sees `--K`).
620    /// - `Bool(false)` → dropped (clap treats absent flag and explicit false
621    ///   the same; preserving it would only resurface as `--K=false` and break
622    ///   the same parser).
623    ///
624    /// A `Value::Bool` parked under a key the `schema` declares as a *value-taking*
625    /// flag is the flag's literal value, not a bare bool flag — `spawn --command
626    /// true` binds `command = Bool(true)`. Those keys are left in `named` so
627    /// `to_argv()` renders `--command=true` and clap's `Option<String>` field
628    /// accepts it; collapsing them to a bare `--command` drops the value and
629    /// makes clap error "a value is required".
630    ///
631    /// Idempotent. Non-bool named entries are left alone.
632    pub fn flagify_bool_named(&mut self, schema: &ToolSchema) {
633        // Keys (param names + aliases) the schema declares as non-bool, non-positional
634        // flags — i.e. flags that take a value.
635        let value_keys: HashSet<&str> = schema
636            .params
637            .iter()
638            .filter(|p| !p.positional && !is_bool_param_type(&p.param_type))
639            .flat_map(|p| {
640                std::iter::once(p.name.as_str())
641                    .chain(p.aliases.iter().map(|a| a.trim_start_matches('-')))
642            })
643            .collect();
644
645        let bool_keys: Vec<String> = self
646            .named
647            .iter()
648            .filter(|(k, v)| matches!(v, Value::Bool(_)) && !value_keys.contains(k.as_str()))
649            .map(|(k, _)| k.clone())
650            .collect();
651        for k in bool_keys {
652            // Remove unconditionally so Bool(false) doesn't linger and break
653            // a `--K=false` rejection in clap. Only Bool(true) re-enters as a
654            // flag presence.
655            if let Some(Value::Bool(true)) = self.named.remove(&k) {
656                self.flags.insert(k);
657            }
658        }
659    }
660
661    /// Reconstruct a clap-friendly argv vector from already-parsed ToolArgs.
662    ///
663    /// kaish has already done shell parsing (variables expanded, globs expanded,
664    /// `$(...)` substituted, schema-driven flag/value splitting). `to_argv`
665    /// rebuilds a flat token stream suitable for `Parser::parse_from(std::iter::once("<tool>").chain(args.to_argv()?))`.
666    ///
667    /// Layout: flags first (as `--<name>`), then named values (as
668    /// `--<name>=<value>`), then positionals — separated from earlier sections
669    /// by `--` so trailing-passthrough builtins still see them as positionals
670    /// even if a value happens to begin with `-`.
671    ///
672    /// # Errors
673    ///
674    /// Returns [`ToolArgvError`] when a **named/flag** value is
675    /// [`Value::Bytes`] — binary can't cross the argv/text stringification
676    /// boundary (GH #164, closing the root cause behind GH #120's stringified
677    /// `[binary: N bytes]` placeholder). A **positional** `Value::Bytes` does
678    /// NOT error here; see `value_to_argv_token`'s doc comment for why.
679    ///
680    /// See the clap builtin pattern in CLAUDE.md (Contributor conventions).
681    ///
682    /// Equivalent to [`to_argv_excluding`](Self::to_argv_excluding)`(&[])` —
683    /// same rendering path, nothing excluded.
684    pub fn to_argv(&self) -> Result<Vec<String>, ToolArgvError> {
685        self.to_argv_excluding(&[])
686    }
687
688    /// Like [`to_argv`](Self::to_argv), but skips the given **named** keys
689    /// entirely — neither the key's flag token nor its value appears in the
690    /// rendered argv, and (crucially) a `Value::Bytes` under an excluded key
691    /// is never passed to `render_named_value`, so it can never trip
692    /// [`ToolArgvError::BinaryNamedValue`].
693    ///
694    /// Use this when a builtin deliberately reads one of its own named
695    /// parameters raw off `ToolArgs` (e.g. `args.named.get("content")`)
696    /// instead of the clap-parsed field, specifically to preserve a
697    /// typed/binary value that must not cross the argv/text stringification
698    /// boundary — while still wanting the *rest* of its arguments bound
699    /// through the normal clap path. `write`'s `content` param is the
700    /// motivating case (GH #218, a follow-up from the GH #164 / #215
701    /// review): before this helper, the builtin cloned the whole `ToolArgs`
702    /// and called `named.remove("content")` by hand, which silently stops
703    /// covering a *second* Bytes-capable named param the moment one is added.
704    /// Naming the excluded keys here instead makes the exemption a
705    /// greppable, drift-resistant idiom.
706    ///
707    /// Only **named** keys are excludable — not flags or positionals, by
708    /// design. A bool flag carries no value to protect, so there is nothing
709    /// to exempt. A positional's clap-reflected field is already a
710    /// validation-only sink nobody reads (see CLAUDE.md's clap-builtin
711    /// convention), so a positional `Value::Bytes` never needed an
712    /// exemption in the first place — `value_to_argv_token` renders it as
713    /// an inert placeholder rather than erroring. If a future case needs to
714    /// exclude a flag or positional too, that is new design, not an
715    /// extension of this helper.
716    pub fn to_argv_excluding(&self, exclude: &[&str]) -> Result<Vec<String>, ToolArgvError> {
717        let mut argv = Vec::with_capacity(
718            self.flags.len() + self.named.len() * 2 + self.positional.len() + 1,
719        );
720
721        // Flags are unordered (HashSet); sort for deterministic argv so tests
722        // and snapshots stay stable. Single-char keys emit short form (`-n`)
723        // so clap's natural `#[arg(short = 'n', long = "no_newline")]` derive
724        // accepts them without needing visible_alias gymnastics.
725        let mut flags: Vec<&String> = self.flags.iter().collect();
726        flags.sort();
727        for flag in flags {
728            argv.push(flag_token(flag));
729        }
730
731        // Named values: emit `-k=value` for single-char keys and `--key=value`
732        // for multi-char keys. `=` form keeps parsing unambiguous when the
733        // value begins with `-`. Multi-value (`consumes > 1`) params are
734        // stored as Value::Json(Array(Array(...))) — one entry per occurrence.
735        // An excluded key is skipped before its value is ever inspected, so a
736        // Value::Bytes there can't reach render_named_value's Bytes guard.
737        for (key, value) in &self.named {
738            if exclude.contains(&key.as_str()) {
739                continue;
740            }
741            if let Some(raw) = self.named_raw.get(key) {
742                argv.push(format!("{}={}", flag_token(key), raw));
743                continue;
744            }
745            for rendered in render_named_value(key, value)? {
746                argv.push(format!("{}={}", flag_token(key), rendered));
747            }
748        }
749
750        // `--` terminator so clap treats positionals as positionals even if
751        // they begin with `-` (e.g. `echo -- -n` should print `-n`).
752        if !self.positional.is_empty() {
753            argv.push("--".to_string());
754            for (i, value) in self.positional.iter().enumerate() {
755                argv.push(
756                    self.positional_raw
757                        .get(&i)
758                        .cloned()
759                        .unwrap_or_else(|| value_to_argv_token(value)),
760                );
761            }
762        }
763
764        Ok(argv)
765    }
766}
767
768/// Error raised by [`ToolArgs::to_argv`] when a **named or flag** argument
769/// cannot cross the argv/text stringification boundary.
770///
771/// The only offending [`Value`] variant is [`Value::Bytes`] — every other
772/// variant has a lossless text form. Binary crossing this boundary used to
773/// silently render as a `[binary: N bytes]` placeholder text token, which a
774/// downstream clap-parsed field (`parsed.separator`, `parsed.algo`, …) would
775/// then see as if it were the user's real value — GH #120's root cause,
776/// deferred as "Phase 2" at the time and closed here (GH #164).
777///
778/// Deliberately does **not** cover positional `Value::Bytes` — see
779/// `value_to_argv_token`'s doc comment for why a positional binary value
780/// stays safe to render as a placeholder rather than error.
781#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
782#[non_exhaustive]
783pub enum ToolArgvError {
784    /// A named/flag argument held [`Value::Bytes`].
785    #[error(
786        "argument `{key}` holds {byte_len} binary bytes, which cannot cross the argv/text \
787         boundary — read it from the raw ToolArgs value (e.g. `args.get(\"{key}\", ..)`) \
788         instead of the clap-parsed field"
789    )]
790    BinaryNamedValue {
791        /// The named argument's key (the schema name, e.g. `"separator"` —
792        /// not the `-`/`--`-prefixed flag_token form).
793        key: String,
794        /// The number of binary bytes it held. Never the bytes themselves —
795        /// this error message must stay safe to log.
796        byte_len: usize,
797    },
798}
799
800fn flag_token(name: &str) -> String {
801    if name.chars().count() == 1 {
802        format!("-{name}")
803    } else {
804        format!("--{name}")
805    }
806}
807
808/// Whether a `ParamSchema::param_type` names a boolean flag.
809fn is_bool_param_type(param_type: &str) -> bool {
810    param_type.eq_ignore_ascii_case("bool") || param_type.eq_ignore_ascii_case("boolean")
811}
812
813/// Render one named argument's value into its `to_argv()` token(s).
814///
815/// `key` is only used to attribute a [`ToolArgvError`] to the argument that
816/// held it — it does not affect rendering of any other variant.
817fn render_named_value(key: &str, value: &Value) -> Result<Vec<String>, ToolArgvError> {
818    match value {
819        // `consumes > 1` lands as Json(Array(Array(...))) — one inner array per
820        // occurrence. Flatten each inner array into space-joined tokens; clap
821        // can split on `=` further if needed.
822        Value::Json(serde_json::Value::Array(outer)) if outer.iter().all(|v| v.is_array()) => {
823            Ok(outer
824                .iter()
825                .map(|inner| {
826                    inner
827                        .as_array()
828                        .map(|a| a.iter().map(json_value_to_token).collect::<Vec<_>>().join(" "))
829                        .unwrap_or_default()
830                })
831                .collect())
832        }
833        // A named/flag value is commonly read straight off the clap-parsed
834        // field (`parsed.separator`, `parsed.algo`, …) rather than the raw
835        // `ToolArgs`, so silently stringifying binary here — as the old
836        // `[binary: N bytes]` placeholder did — hands a builtin's clap struct
837        // a value that looks textual but isn't the user's real data (GH #120's
838        // root cause). Loud instead: see `ToolArgvError`.
839        Value::Bytes(data) => Err(ToolArgvError::BinaryNamedValue {
840            key: key.to_string(),
841            byte_len: data.len(),
842        }),
843        _ => Ok(vec![value_to_argv_token(value)]),
844    }
845}
846
847/// Render one **positional** argument's value into its `to_argv()` token.
848///
849/// `Value::Bytes` renders as a visible placeholder rather than erroring —
850/// unlike the named-value path in [`render_named_value`]. This is safe only
851/// because a clap-reflected positional field is a validation-only sink (see
852/// CLAUDE.md's clap-builtin convention): no builtin reads a positional's
853/// *value* off the parsed clap struct, every one of them reads the typed
854/// `Value` straight off `args.positional` instead (e.g. `push`'s `rest:
855/// Vec<String>` sink, or `write`'s content positional, which accepts real
856/// `Value::Bytes` content byte-for-byte via `args.positional`, never via
857/// `parsed`). A placeholder token here only has to satisfy clap's parse (argv
858/// shape / arity), never carry real data anywhere — so it can never leak
859/// unlike the named case this function's sibling guards against.
860fn value_to_argv_token(value: &Value) -> String {
861    match value {
862        Value::Null => String::new(),
863        Value::Bool(b) => b.to_string(),
864        Value::Int(i) => i.to_string(),
865        Value::Float(f) => f.to_string(),
866        Value::String(s) => s.clone(),
867        Value::Json(j) => j.to_string(),
868        Value::Bytes(data) => format!("[binary: {} bytes]", data.len()),
869    }
870}
871
872/// Is a kernel-owned global flag written as `--flag=VALUE` on?
873///
874/// Off for the empty string, `false`, `0`, and a numeric zero; on for
875/// everything else, including a value the kernel could not evaluate. `--json`
876/// is bound by three different binders — typed, `raw_argv`, and `verbatim` —
877/// and each one asks this question. Asking it in one place is what keeps
878/// `--json=0` meaning the same thing on all three.
879///
880/// This is deliberately NOT [`ToolArgs::has_flag`]'s rule. `has_flag` answers
881/// "was this key provided", which is what a value-taking flag needs
882/// (`head -n 0` provided `-n`), so it treats a numeric zero as present. A
883/// global flag has no value of its own to carry, so for it zero means off.
884///
885/// ```
886/// use kaish_types::{global_flag_value_is_truthy, Value};
887///
888/// assert!(global_flag_value_is_truthy(&Value::String("yes".into())));
889/// assert!(!global_flag_value_is_truthy(&Value::Int(0)));
890/// assert!(!global_flag_value_is_truthy(&Value::String("0".into())));
891/// ```
892pub fn global_flag_value_is_truthy(value: &Value) -> bool {
893    match value {
894        Value::Bool(b) => *b,
895        Value::Int(i) => *i != 0,
896        Value::Float(f) => *f != 0.0,
897        Value::String(s) => !s.is_empty() && s != "false" && s != "0",
898        // A collection, JSON payload, or binary blob is not a spelling of
899        // "off" — treat the flag as given rather than silently dropping it.
900        _ => true,
901    }
902}
903
904fn json_value_to_token(value: &serde_json::Value) -> String {
905    match value {
906        serde_json::Value::Null => String::new(),
907        serde_json::Value::Bool(b) => b.to_string(),
908        serde_json::Value::Number(n) => n.to_string(),
909        serde_json::Value::String(s) => s.clone(),
910        other => other.to_string(),
911    }
912}
913
914#[cfg(test)]
915mod schema_serde_tests {
916    use super::*;
917
918    /// A flat tool (no subcommands/aliases) must serialize byte-identically to
919    /// the pre-subcommand wire format: the two new fields are skipped entirely.
920    #[test]
921    fn flat_schema_omits_new_fields_on_wire() {
922        let schema = ToolSchema::new("cat", "concatenate")
923            .param(ParamSchema::required("path", "string", "file to read").positional());
924        let json = serde_json::to_value(&schema).expect("serialize");
925        let obj = json.as_object().expect("object");
926        assert!(!obj.contains_key("subcommands"), "flat tool leaks subcommands: {json}");
927        assert!(!obj.contains_key("aliases"), "flat tool leaks command aliases: {json}");
928    }
929
930    /// Round-trip the skip: a flat tool serializes *without* the keys, so the
931    /// deserializer must `default` them back to empty. (This is what lets us
932    /// skip-serialize empties without breaking our own flat tools' payloads.)
933    #[test]
934    fn flat_wire_form_deserializes_to_empty() {
935        let flat = serde_json::json!({
936            "name": "cat",
937            "description": "concatenate",
938            "params": [],
939            "examples": [],
940            "map_positionals": false
941        });
942        let schema: ToolSchema = serde_json::from_value(flat).expect("deserialize flat form");
943        assert!(schema.subcommands.is_empty());
944        assert!(schema.aliases.is_empty());
945    }
946
947    /// `with_owned_output` marks the whole tree and advertises `json` on each
948    /// node that didn't already declare it.
949    #[test]
950    fn with_owned_output_marks_tree_and_advertises_json() {
951        let schema = ToolSchema::new("kj", "kaijutsu")
952            .subcommand(
953                ToolSchema::new("context", "ctx")
954                    .subcommand(ToolSchema::new("list", "list contexts")),
955            )
956            .with_owned_output();
957
958        assert!(schema.owns_output, "root marked");
959        assert!(schema.params.iter().any(|p| p.name == "json"), "root advertises json");
960        let context = &schema.subcommands[0];
961        assert!(context.owns_output, "child marked");
962        let list = &context.subcommands[0];
963        assert!(list.owns_output, "grandchild marked");
964        assert!(list.params.iter().any(|p| p.name == "json"), "leaf advertises json");
965    }
966
967    /// `with_owned_output` doesn't duplicate an already-declared `json` param.
968    #[test]
969    fn with_owned_output_does_not_double_add_json() {
970        let schema = ToolSchema::new("kj", "kaijutsu")
971            .param(ParamSchema::new("json", "bool"))
972            .with_owned_output();
973        let json_count = schema.params.iter().filter(|p| p.name == "json").count();
974        assert_eq!(json_count, 1, "json should appear exactly once");
975    }
976
977    /// `owns_output` round-trips and is omitted from the wire when false.
978    #[test]
979    fn owns_output_serde() {
980        let flat = ToolSchema::new("ls", "list");
981        let json = serde_json::to_value(&flat).expect("serialize");
982        let obj = json.as_object().expect("object");
983        assert!(!obj.contains_key("owns_output"), "false omitted: {json}");
984
985        let owned = ToolSchema::new("kj", "kaijutsu").with_owned_output();
986        let wire = serde_json::to_string(&owned).expect("serialize");
987        let back: ToolSchema = serde_json::from_str(&wire).expect("deserialize");
988        assert!(back.owns_output);
989    }
990
991    /// A subcommand tree round-trips through serde with names and aliases intact.
992    #[test]
993    fn subcommand_tree_round_trips() {
994        let schema = ToolSchema::new("kj", "kaijutsu")
995            .subcommand(
996                ToolSchema::new("context", "context ops")
997                    .with_command_aliases(["ctx"])
998                    .subcommand(ToolSchema::new("list", "list contexts").with_command_aliases(["ls"])),
999            );
1000        let json = serde_json::to_string(&schema).expect("serialize");
1001        let back: ToolSchema = serde_json::from_str(&json).expect("deserialize");
1002        assert_eq!(back.subcommands.len(), 1);
1003        let context = &back.subcommands[0];
1004        assert!(context.matches_command("context"));
1005        assert!(context.matches_command("ctx"));
1006        assert_eq!(context.subcommands.len(), 1);
1007        assert!(context.subcommands[0].matches_command("ls"));
1008    }
1009}
1010
1011#[cfg(test)]
1012mod to_argv_tests {
1013    use super::*;
1014
1015    #[test]
1016    fn empty_args_produce_empty_argv() {
1017        assert!(ToolArgs::new().to_argv().unwrap().is_empty());
1018    }
1019
1020    #[test]
1021    fn positionals_emitted_after_double_dash() {
1022        let mut args = ToolArgs::new();
1023        args.positional.push(Value::String("hello".into()));
1024        args.positional.push(Value::String("world".into()));
1025        assert_eq!(args.to_argv().unwrap(), vec!["--", "hello", "world"]);
1026    }
1027
1028    // `-0` is correctly typed as `Value::Int(0)`, but that value's `Display`
1029    // cannot get back to `-0`. `*_raw` holds the text `Value` alone lost.
1030
1031    #[test]
1032    fn positional_raw_overrides_the_typed_value_in_to_argv() {
1033        let mut args = ToolArgs::new();
1034        args.positional.push(Value::Int(0));
1035        args.positional_raw.insert(0, "-0".to_string());
1036        assert_eq!(args.to_argv().unwrap(), vec!["--", "-0"]);
1037    }
1038
1039    #[test]
1040    fn positional_text_prefers_raw_and_falls_back_to_the_typed_value() {
1041        let mut args = ToolArgs::new();
1042        args.positional.push(Value::Int(0));
1043        args.positional.push(Value::Int(5));
1044        args.positional_raw.insert(0, "-0".to_string());
1045        assert_eq!(args.positional_text(0).as_deref(), Some("-0"));
1046        assert_eq!(args.positional_text(1).as_deref(), Some("5"));
1047        assert_eq!(args.positional_text(2), None, "out of range");
1048    }
1049
1050    #[test]
1051    fn named_raw_overrides_the_typed_value_in_to_argv() {
1052        let mut args = ToolArgs::new();
1053        args.named.insert("count".into(), Value::Float(0.10));
1054        args.named_raw.insert("count".into(), "0.10".to_string());
1055        assert_eq!(args.to_argv().unwrap(), vec!["--count=0.10"]);
1056    }
1057
1058    #[test]
1059    fn words_raw_overrides_the_typed_value_in_words_argv() {
1060        let mut args = ToolArgs::new();
1061        args.words = Some(vec![Value::String("echo".into()), Value::Float(1.0)]);
1062        args.words_raw.insert(1, "1.0".to_string());
1063        assert_eq!(args.words_argv(), vec!["echo", "1.0"]);
1064    }
1065
1066    #[test]
1067    fn a_canonical_numeral_is_unaffected_by_the_raw_fields() {
1068        let mut args = ToolArgs::new();
1069        args.positional.push(Value::Int(5));
1070        args.named.insert("count".into(), Value::Int(3));
1071        args.words = Some(vec![Value::Int(7)]);
1072        assert!(args.positional_raw.is_empty());
1073        assert!(args.named_raw.is_empty());
1074        assert!(args.words_raw.is_empty());
1075        assert_eq!(args.to_argv().unwrap(), vec!["--count=3", "--", "5"]);
1076        assert_eq!(args.words_argv(), vec!["7"]);
1077    }
1078
1079    #[test]
1080    fn single_char_flags_emit_short_form() {
1081        let mut args = ToolArgs::new();
1082        args.flags.insert("n".into());
1083        args.flags.insert("verbose".into());
1084        // Sorted: "n" then "verbose"
1085        assert_eq!(args.to_argv().unwrap(), vec!["-n", "--verbose"]);
1086    }
1087
1088    #[test]
1089    fn named_values_use_equals_form() {
1090        let mut args = ToolArgs::new();
1091        args.named.insert("count".into(), Value::Int(5));
1092        args.named.insert("name".into(), Value::String("foo".into()));
1093        // BTreeMap iterates in key order, so "count" before "name"
1094        assert_eq!(args.to_argv().unwrap(), vec!["--count=5", "--name=foo"]);
1095    }
1096
1097    #[test]
1098    fn single_char_named_emits_short_equals() {
1099        let mut args = ToolArgs::new();
1100        args.named.insert("n".into(), Value::Int(5));
1101        assert_eq!(args.to_argv().unwrap(), vec!["-n=5"]);
1102    }
1103
1104    #[test]
1105    fn positional_with_leading_dash_survives_double_dash() {
1106        let mut args = ToolArgs::new();
1107        args.positional.push(Value::String("-n".into()));
1108        // `echo -- -n` should round-trip as `-- -n`, not be reparsed as a flag.
1109        assert_eq!(args.to_argv().unwrap(), vec!["--", "-n"]);
1110    }
1111
1112    #[test]
1113    fn mixed_flags_named_positionals() {
1114        let mut args = ToolArgs::new();
1115        args.flags.insert("verbose".into());
1116        args.named.insert("limit".into(), Value::Int(10));
1117        args.positional.push(Value::String("file.txt".into()));
1118        assert_eq!(
1119            args.to_argv().unwrap(),
1120            vec!["--verbose", "--limit=10", "--", "file.txt"]
1121        );
1122    }
1123
1124    /// GH #164: a named/flag `Value::Bytes` must error loudly instead of
1125    /// silently stringifying to the `[binary: N bytes]` placeholder — that
1126    /// placeholder is exactly what a downstream clap-parsed field
1127    /// (`parsed.separator`, `parsed.algo`, …) would otherwise see as if it
1128    /// were the user's real value (GH #120's root cause).
1129    #[test]
1130    fn named_bytes_value_errors_loudly() {
1131        let mut args = ToolArgs::new();
1132        args.named.insert("separator".into(), Value::Bytes(vec![0xff, 0x00, 0xfe]));
1133
1134        let err = args.to_argv().expect_err("named Bytes must error");
1135        // The message must name the key and the byte count.
1136        let message = err.to_string();
1137        assert!(message.contains("separator"));
1138        assert!(message.contains('3'));
1139        let ToolArgvError::BinaryNamedValue { key, byte_len } = err;
1140        assert_eq!(key, "separator");
1141        assert_eq!(byte_len, 3);
1142    }
1143
1144    /// A single-char named key (e.g. `-a`) gets the same loud treatment.
1145    #[test]
1146    fn single_char_named_bytes_value_errors_loudly() {
1147        let mut args = ToolArgs::new();
1148        args.named.insert("a".into(), Value::Bytes(vec![1, 2]));
1149
1150        let err = args.to_argv().expect_err("named Bytes must error");
1151        let ToolArgvError::BinaryNamedValue { key, byte_len } = err;
1152        assert_eq!(key, "a");
1153        assert_eq!(byte_len, 2);
1154    }
1155
1156    /// Positional `Value::Bytes`, by contrast, does NOT error — see
1157    /// `value_to_argv_token`'s doc comment. The clap-reflected positional
1158    /// field is a validation-only sink; no builtin ever reads its *value* off
1159    /// the parsed struct (they read the typed `Value` straight off
1160    /// `args.positional`), so a placeholder token here is inert, not
1161    /// corruption. This is the load-bearing decision behind builtins like
1162    /// `push`/`write` accepting real binary content through positionals.
1163    #[test]
1164    fn positional_bytes_value_renders_placeholder_not_error() {
1165        let mut args = ToolArgs::new();
1166        args.positional.push(Value::Bytes(vec![0xff, 0x00, 0xfe]));
1167
1168        let argv = args.to_argv().expect("positional Bytes must not error");
1169        assert_eq!(argv, vec!["--", "[binary: 3 bytes]"]);
1170    }
1171
1172    /// Mixed case: a named Bytes error takes priority even when a positional
1173    /// Bytes value is also present (the loud path must not be starved by
1174    /// iteration order silently succeeding on the positional half first).
1175    #[test]
1176    fn named_bytes_errors_even_with_positional_bytes_present() {
1177        let mut args = ToolArgs::new();
1178        args.named.insert("check".into(), Value::Bytes(vec![9, 9]));
1179        args.positional.push(Value::Bytes(vec![1, 2, 3]));
1180
1181        let err = args.to_argv().expect_err("named Bytes must still error");
1182        let ToolArgvError::BinaryNamedValue { key, .. } = err;
1183        assert_eq!(key, "check");
1184    }
1185
1186    // ── GH #218: ToolArgs::to_argv_excluding ────────────────────────────
1187    //
1188    // write.rs reads its own `content` named param raw off `ToolArgs` to
1189    // preserve `Value::Bytes`, then needs the *rest* of its args through the
1190    // normal clap/to_argv path — these tests pin the helper that replaces the
1191    // ad hoc "clone ToolArgs, remove the key, call to_argv()" dance.
1192
1193    /// A named `Value::Bytes` under an excluded key must not error and must
1194    /// not appear anywhere in the rendered argv — this is the whole point:
1195    /// `write`'s `content` carries real binary and must never reach argv.
1196    #[test]
1197    fn to_argv_excluding_skips_excluded_named_bytes_without_error() {
1198        let mut args = ToolArgs::new();
1199        args.named.insert("content".into(), Value::Bytes(vec![0xff, 0x00, 0xfe]));
1200        args.named.insert("path".into(), Value::String("dest.bin".into()));
1201
1202        let argv = args
1203            .to_argv_excluding(&["content"])
1204            .expect("excluded named Bytes must not error");
1205        assert_eq!(argv, vec!["--path=dest.bin"]);
1206        assert!(
1207            argv.iter().all(|tok| !tok.contains("content")),
1208            "excluded key must not appear in argv at all: {argv:?}"
1209        );
1210    }
1211
1212    /// A named `Value::Bytes` under a key that is NOT excluded still errors
1213    /// loudly, same as plain `to_argv()` — excluding one key must not blanket
1214    /// the whole named map.
1215    #[test]
1216    fn to_argv_excluding_still_errors_on_non_excluded_named_bytes() {
1217        let mut args = ToolArgs::new();
1218        args.named.insert("content".into(), Value::Bytes(vec![1, 2, 3]));
1219        args.named.insert("separator".into(), Value::Bytes(vec![9, 9]));
1220
1221        let err = args
1222            .to_argv_excluding(&["content"])
1223            .expect_err("non-excluded named Bytes must still error");
1224        let ToolArgvError::BinaryNamedValue { key, .. } = err;
1225        assert_eq!(key, "separator");
1226    }
1227
1228    /// Excluding a key that isn't a `Value::Bytes` at all (the common case —
1229    /// most invocations of `write` carry plain string content) still drops it
1230    /// from argv. The exclusion is unconditional on the key, not conditional
1231    /// on the value being binary.
1232    #[test]
1233    fn to_argv_excluding_drops_excluded_key_regardless_of_value_type() {
1234        let mut args = ToolArgs::new();
1235        args.named.insert("content".into(), Value::String("hello".into()));
1236        args.named.insert("path".into(), Value::String("dest.txt".into()));
1237
1238        let argv = args.to_argv_excluding(&["content"]).expect("no error expected");
1239        assert_eq!(argv, vec!["--path=dest.txt"]);
1240    }
1241
1242    /// An empty exclude list must behave *exactly* like `to_argv()` — same
1243    /// tokens, same order — across flags, named values, and positionals, on
1244    /// args that don't touch the Bytes edge case at all. `to_argv()` itself
1245    /// delegates to this with an empty slice, so this is also the guard that
1246    /// the delegation didn't change plain `to_argv()` behavior.
1247    #[test]
1248    fn to_argv_excluding_empty_list_matches_to_argv() {
1249        let mut args = ToolArgs::new();
1250        args.flags.insert("verbose".into());
1251        args.flags.insert("n".into());
1252        args.named.insert("limit".into(), Value::Int(10));
1253        args.named.insert("name".into(), Value::String("foo".into()));
1254        args.positional.push(Value::String("file.txt".into()));
1255        args.positional.push(Value::String("-weird".into()));
1256
1257        assert_eq!(
1258            args.to_argv_excluding(&[]).unwrap(),
1259            args.to_argv().unwrap(),
1260            "empty exclude list must be indistinguishable from to_argv()"
1261        );
1262    }
1263
1264    #[test]
1265    fn flagify_bool_named_promotes_true_to_flag() {
1266        let mut args = ToolArgs::new();
1267        args.named.insert("recursive".into(), Value::Bool(true));
1268        args.named.insert("limit".into(), Value::Int(5));
1269
1270        args.flagify_bool_named(&ToolSchema::new("t", ""));
1271
1272        assert!(args.flags.contains("recursive"));
1273        assert!(!args.named.contains_key("recursive"));
1274        // Non-bool entries are untouched.
1275        assert_eq!(args.named.get("limit"), Some(&Value::Int(5)));
1276    }
1277
1278    #[test]
1279    fn flagify_bool_named_drops_false() {
1280        let mut args = ToolArgs::new();
1281        args.named.insert("recursive".into(), Value::Bool(false));
1282
1283        args.flagify_bool_named(&ToolSchema::new("t", ""));
1284
1285        assert!(!args.flags.contains("recursive"));
1286        assert!(!args.named.contains_key("recursive"));
1287    }
1288
1289    #[test]
1290    fn flagify_bool_named_is_idempotent() {
1291        let mut args = ToolArgs::new();
1292        args.named.insert("recursive".into(), Value::Bool(true));
1293        args.flagify_bool_named(&ToolSchema::new("t", ""));
1294        args.flagify_bool_named(&ToolSchema::new("t", ""));
1295        assert!(args.flags.contains("recursive"));
1296    }
1297
1298    /// Regression guard: argv emitted after flagify must round-trip through
1299    /// a clap parser without `--K=true` showing up.
1300    #[test]
1301    fn flagify_bool_named_round_trips_through_to_argv() {
1302        let mut args = ToolArgs::new();
1303        args.named.insert("R".into(), Value::Bool(true));
1304        args.flagify_bool_named(&ToolSchema::new("t", ""));
1305        let argv = args.to_argv().unwrap();
1306        assert!(argv.contains(&"-R".to_string()), "expected -R, got {:?}", argv);
1307        assert!(!argv.iter().any(|s| s.contains('=')), "no =value should appear, got {:?}", argv);
1308    }
1309
1310    /// A `Bool(true)` parked under a schema-declared value-taking flag is the
1311    /// flag's literal value (`spawn --command true`), not a bare bool flag — it
1312    /// stays in `named` and renders as `--K=true`, not a value-less `--K`.
1313    #[test]
1314    fn flagify_bool_named_keeps_value_flag_value() {
1315        let mut schema = ToolSchema::new("spawn", "");
1316        schema.params.push(ParamSchema::new("command", "string"));
1317
1318        let mut args = ToolArgs::new();
1319        args.named.insert("command".into(), Value::Bool(true));
1320        args.flagify_bool_named(&schema);
1321
1322        assert!(!args.flags.contains("command"), "value flag must not collapse to a bare flag");
1323        assert_eq!(args.named.get("command"), Some(&Value::Bool(true)));
1324        let argv = args.to_argv().unwrap();
1325        assert!(
1326            argv.iter().any(|s| s == "--command=true"),
1327            "expected --command=true, got {:?}",
1328            argv
1329        );
1330    }
1331
1332    /// One schema carrying both a bool flag and a value-taking flag: the bool
1333    /// flag still flagifies, the value flag keeps its value. Proves
1334    /// `is_bool_param_type` actually distinguishes the two (an empty-schema test
1335    /// can't — it flagifies everything regardless).
1336    #[test]
1337    fn flagify_bool_named_distinguishes_bool_from_value_param() {
1338        let mut schema = ToolSchema::new("t", "");
1339        schema.params.push(ParamSchema::new("verbose", "bool"));
1340        schema.params.push(ParamSchema::new("command", "string"));
1341
1342        let mut args = ToolArgs::new();
1343        args.named.insert("verbose".into(), Value::Bool(true));
1344        args.named.insert("command".into(), Value::Bool(true));
1345        args.flagify_bool_named(&schema);
1346
1347        // Bool flag → promoted to a bare flag.
1348        assert!(args.flags.contains("verbose"));
1349        assert!(!args.named.contains_key("verbose"));
1350        // Value flag → value retained.
1351        assert!(!args.flags.contains("command"));
1352        assert_eq!(args.named.get("command"), Some(&Value::Bool(true)));
1353    }
1354}
1355
1356#[cfg(test)]
1357mod verbatim_words_tests {
1358    use super::*;
1359
1360    /// A typed tool never receives words, so the argv rendering is empty
1361    /// rather than a stringified decomposition.
1362    #[test]
1363    fn typed_args_render_no_words() {
1364        let mut args = ToolArgs::new();
1365        args.positional.push(Value::String("hello".into()));
1366        assert!(args.words.is_none());
1367        assert!(args.words_argv().is_empty());
1368    }
1369
1370    /// Order, multiplicity and non-string types all survive into argv — the
1371    /// three things the typed decomposition drops for a subcommand tree.
1372    #[test]
1373    fn words_render_in_order_with_repeats() {
1374        let mut args = ToolArgs::new();
1375        args.words = Some(vec![
1376            Value::String("block".into()),
1377            Value::String("list".into()),
1378            Value::String("--limit".into()),
1379            Value::Int(5),
1380            Value::String("--include".into()),
1381            Value::String("a".into()),
1382            Value::String("--include".into()),
1383            Value::String("b".into()),
1384        ]);
1385        assert_eq!(
1386            args.words_argv(),
1387            vec!["block", "list", "--limit", "5", "--include", "a", "--include", "b"],
1388        );
1389    }
1390
1391    /// A binary word keeps its bytes in `words` and renders as an inert
1392    /// placeholder token, so the tool's parser sees an argv-shaped stream
1393    /// while the real data stays reachable at the same index.
1394    #[test]
1395    fn binary_word_renders_as_a_placeholder_and_keeps_its_bytes() {
1396        let mut args = ToolArgs::new();
1397        args.words = Some(vec![
1398            Value::String("write".into()),
1399            Value::Bytes(vec![0, 159, 146, 150]),
1400        ]);
1401        let argv = args.words_argv();
1402        assert_eq!(argv[0], "write");
1403        assert_ne!(argv[1], "", "a binary word still needs an argv token");
1404        assert!(
1405            !argv[1].as_bytes().contains(&0),
1406            "the placeholder must be text, not the raw bytes; got {:?}",
1407            argv[1],
1408        );
1409        let words = args.words.as_deref().expect("words");
1410        assert_eq!(words[1], Value::Bytes(vec![0, 159, 146, 150]));
1411    }
1412}