Skip to main content

kaish_types/
tool.rs

1//! Tool schema and argument types.
2
3use std::collections::{BTreeMap, HashSet};
4
5use crate::value::Value;
6
7fn default_consumes() -> usize {
8    1
9}
10
11/// Schema for a tool parameter.
12#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
13#[non_exhaustive]
14pub struct ParamSchema {
15    /// Parameter name.
16    pub name: String,
17    /// Type hint (string, int, bool, array, object, any).
18    pub param_type: String,
19    /// Whether this parameter is required.
20    pub required: bool,
21    /// Default value if not required.
22    pub default: Option<Value>,
23    /// Description for help text.
24    pub description: String,
25    /// Alternative names/flags for this parameter (e.g., "-r", "-R" for "recursive").
26    pub aliases: Vec<String>,
27    /// Number of positional tokens this non-bool flag consumes per occurrence.
28    ///
29    /// Default 1 (standard `--flag value`). Set to 2 for `--flag NAME VALUE`
30    /// patterns such as jq's `--arg` / `--argjson`. When `consumes > 1`, the
31    /// kernel collects each occurrence as an inner array and accumulates
32    /// repeated occurrences under the same `named` key — the tool sees a
33    /// `Value::Json(Array(Array(...)))` listing every (N-tuple) occurrence.
34    #[serde(default = "default_consumes")]
35    pub consumes: usize,
36    /// True when this flag may appear more than once and each occurrence
37    /// should be kept (clap's `ArgAction::Append`, i.e. a `Vec<_>` value flag
38    /// like sed's `-e`). When set, the kernel accumulates every occurrence
39    /// under the same `named` key as a `Value::Json(Array(...))` instead of
40    /// letting the last write win — the "no silent drop" contract for repeated
41    /// flags. Orthogonal to `consumes`: `consumes` is values-per-occurrence,
42    /// `repeatable` is occurrences-per-invocation.
43    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
44    pub repeatable: bool,
45    /// True for positional arguments (`cat foo.txt`), false for flags
46    /// (`grep --ignore-case`). The validator matches positional params
47    /// against `args.positional` by their order *among positionals only*,
48    /// independent of where they sit in the clap struct. Default false so
49    /// hand-built `ParamSchema::required(...)` constructors keep flag
50    /// semantics; clap-reflected positionals set it via
51    /// `arg.get_index().is_some()`.
52    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
53    pub positional: bool,
54}
55
56impl ParamSchema {
57    /// Create a required parameter.
58    pub fn required(name: impl Into<String>, param_type: impl Into<String>, description: impl Into<String>) -> Self {
59        Self {
60            name: name.into(),
61            param_type: param_type.into(),
62            required: true,
63            default: None,
64            description: description.into(),
65            aliases: Vec::new(),
66            consumes: 1,
67            repeatable: false,
68            positional: false,
69        }
70    }
71
72    /// Create an optional parameter with a default value.
73    pub fn optional(name: impl Into<String>, param_type: impl Into<String>, default: Value, description: impl Into<String>) -> Self {
74        Self {
75            name: name.into(),
76            param_type: param_type.into(),
77            required: false,
78            default: Some(default),
79            description: description.into(),
80            aliases: Vec::new(),
81            consumes: 1,
82            repeatable: false,
83            positional: false,
84        }
85    }
86
87    /// Create a minimal parameter (not required, no default, empty
88    /// description, `consumes` 1, flag — not positional). Chain the `with_*`
89    /// setters to fill in fields. Use this when each field is computed
90    /// independently (e.g. reflected from clap) rather than fitting the
91    /// `required`/`optional` shortcuts. Keeps construction working across the
92    /// `#[non_exhaustive]` boundary.
93    pub fn new(name: impl Into<String>, param_type: impl Into<String>) -> Self {
94        Self {
95            name: name.into(),
96            param_type: param_type.into(),
97            required: false,
98            default: None,
99            description: String::new(),
100            aliases: Vec::new(),
101            consumes: 1,
102            repeatable: false,
103            positional: false,
104        }
105    }
106
107    /// Set the human-readable description.
108    pub fn with_description(mut self, description: impl Into<String>) -> Self {
109        self.description = description.into();
110        self
111    }
112
113    /// Set whether the parameter is required.
114    pub fn with_required(mut self, required: bool) -> Self {
115        self.required = required;
116        self
117    }
118
119    /// Set the default value (used when the parameter is omitted).
120    pub fn with_default(mut self, default: Option<Value>) -> Self {
121        self.default = default;
122        self
123    }
124
125    /// Set the positional flag from a computed boolean (the parameterless
126    /// [`positional`](Self::positional) sets it unconditionally to `true`).
127    pub fn with_positional(mut self, positional: bool) -> Self {
128        self.positional = positional;
129        self
130    }
131
132    /// Mark this parameter as positional (matched by argv order rather than
133    /// by name). Used by `params_from_clap` for clap args with an assigned
134    /// index, and by hand-written schemas for positional parameters like
135    /// jq's `filter`.
136    pub fn positional(mut self) -> Self {
137        self.positional = true;
138        self
139    }
140
141    /// Add alternative names/flags for this parameter.
142    ///
143    /// Aliases are used for short flags like `-r`, `-R` that map to `recursive`.
144    pub fn with_aliases(mut self, aliases: impl IntoIterator<Item = impl Into<String>>) -> Self {
145        self.aliases = aliases.into_iter().map(Into::into).collect();
146        self
147    }
148
149    /// Declare how many positional tokens this non-bool flag consumes per
150    /// occurrence (`--flag v1 v2 ...`). Default is 1. Panics on 0 — a flag
151    /// that consumes nothing is a bool flag, not a schema-typed param.
152    pub fn consumes(mut self, n: usize) -> Self {
153        assert!(n >= 1, "ParamSchema::consumes requires n >= 1 (use a bool param for flags that take no value)");
154        self.consumes = n;
155        self
156    }
157
158    /// Mark this flag as repeatable: each occurrence is accumulated rather than
159    /// overwritten (see [`repeatable`](Self::repeatable)). Set from a computed
160    /// boolean so clap reflection can pass `ArgAction::Append` directly.
161    pub fn with_repeatable(mut self, repeatable: bool) -> Self {
162        self.repeatable = repeatable;
163        self
164    }
165
166    /// Check if a flag name matches this parameter or any of its aliases.
167    pub fn matches_flag(&self, flag: &str) -> bool {
168        if self.name == flag {
169            return true;
170        }
171        self.aliases.iter().any(|a| a == flag)
172    }
173}
174
175/// An example showing how to use a tool.
176#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
177pub struct Example {
178    /// Short description of what the example demonstrates.
179    pub description: String,
180    /// The example command/code.
181    pub code: String,
182}
183
184impl Example {
185    /// Create a new example.
186    pub fn new(description: impl Into<String>, code: impl Into<String>) -> Self {
187        Self {
188            description: description.into(),
189            code: code.into(),
190        }
191    }
192}
193
194/// Schema describing a tool's interface.
195#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
196#[non_exhaustive]
197pub struct ToolSchema {
198    /// Tool name.
199    pub name: String,
200    /// Short description.
201    pub description: String,
202    /// Parameter definitions.
203    pub params: Vec<ParamSchema>,
204    /// Usage examples.
205    pub examples: Vec<Example>,
206    /// Map remaining positional args to named params by schema order.
207    /// Only for MCP/external tools that expect named JSON params.
208    /// Builtins handle their own positionals and should leave this false.
209    pub map_positionals: bool,
210    /// Child schemas for subcommand-aware tools (`kj context list`, …).
211    ///
212    /// Empty for flat tools (`cat`, `grep`, `ls`) — they take the flat binding
213    /// path. When non-empty, the kernel walks leading positionals to pick the
214    /// active leaf and binds flags against *that leaf's* `params` (see
215    /// `select_leaf` in the kernel).
216    ///
217    /// `skip_serializing_if` keeps the wire compact for the many flat tools
218    /// (no `"subcommands":[]` noise); `default` is then required so a flat
219    /// tool's payload (key absent) deserializes back to empty.
220    #[serde(default, skip_serializing_if = "Vec::is_empty")]
221    pub subcommands: Vec<ToolSchema>,
222    /// Command-level aliases (`ls` → `list`, `rm` → `remove`), matched when
223    /// routing a positional to a child. Distinct from [`ParamSchema::aliases`],
224    /// which name *flags*.
225    #[serde(default, skip_serializing_if = "Vec::is_empty")]
226    pub aliases: Vec<String>,
227    /// The tool renders its **own** output, including `--json` — the kernel
228    /// must not re-format its `ExecResult` through `apply_output_format`.
229    ///
230    /// Default false: a tool returns typed [`crate::OutputData`] and the kernel
231    /// renders the requested format uniformly. Set true for tools with bespoke
232    /// JSON envelopes (e.g. an embedder's `kj`): they consume `--json`
233    /// themselves and emit final bytes. See [`ToolSchema::with_owned_output`].
234    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
235    pub owns_output: bool,
236    /// The tool wants its argv **in source order, with types preserved** — the
237    /// binder must NOT split flags into the unordered `flags` set. When true,
238    /// every argument is bound to `positional` in the order written (operators
239    /// like `-f`/`=`/`!` as strings, operands keeping their `Value` type), and
240    /// `named`/`flags` stay empty.
241    ///
242    /// Default false: normal tools get the clap-style order-independent split
243    /// (`-la` == `-al`). Set true for the rare *position-sensitive* command
244    /// whose operands may themselves look like flags — POSIX `test`, where
245    /// `test $x = -n` and `test 0 -gt -5` must see `-n`/`-5` as literal
246    /// operands. See [`ToolSchema::with_raw_argv`].
247    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
248    pub raw_argv: bool,
249    /// The tool consumes glob patterns **as data** — the argv binder must pass
250    /// a bare glob pattern through as literal text instead of expanding it to
251    /// matching paths.
252    ///
253    /// Default false: shell semantics — `cat *.rs` sees matching files and
254    /// zero matches is a bind-time error. Set true for a tool whose input *is*
255    /// the pattern (`glob`), so the natural unquoted spelling
256    /// (`glob **/*.rs`) hands the pattern text to the tool instead of walking
257    /// the tree at bind time and binding the first match as the "pattern".
258    /// See [`ToolSchema::with_glob_passthrough`].
259    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
260    pub glob_passthrough: bool,
261}
262
263impl ToolSchema {
264    /// Create a new tool schema.
265    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
266        Self {
267            name: name.into(),
268            description: description.into(),
269            params: Vec::new(),
270            examples: Vec::new(),
271            map_positionals: false,
272            subcommands: Vec::new(),
273            aliases: Vec::new(),
274            owns_output: false,
275            raw_argv: false,
276            glob_passthrough: false,
277        }
278    }
279
280    /// Declare that this tool wants its argv in source order with types
281    /// preserved (no flag/positional split). See [`ToolSchema::raw_argv`].
282    pub fn with_raw_argv(mut self) -> Self {
283        self.raw_argv = true;
284        self
285    }
286
287    /// Declare that this tool consumes glob patterns as data: the argv binder
288    /// passes bare patterns through as literal text instead of expanding them.
289    /// See [`ToolSchema::glob_passthrough`].
290    pub fn with_glob_passthrough(mut self) -> Self {
291        self.glob_passthrough = true;
292        self
293    }
294
295    /// Enable positional->named parameter mapping for MCP/external tools.
296    pub fn with_positional_mapping(mut self) -> Self {
297        self.map_positionals = true;
298        self
299    }
300
301    /// Add a parameter to the schema.
302    pub fn param(mut self, param: ParamSchema) -> Self {
303        self.params.push(param);
304        self
305    }
306
307    /// Add an example to the schema.
308    pub fn example(mut self, description: impl Into<String>, code: impl Into<String>) -> Self {
309        self.examples.push(Example::new(description, code));
310        self
311    }
312
313    /// Add a child schema, making this a subcommand-aware tool.
314    pub fn subcommand(mut self, child: ToolSchema) -> Self {
315        self.subcommands.push(child);
316        self
317    }
318
319    /// Set command-level aliases (e.g. `ls` for a `list` subcommand). These
320    /// name the *command*, not its flags; flag aliases live on each
321    /// [`ParamSchema`].
322    pub fn with_command_aliases(mut self, aliases: impl IntoIterator<Item = impl Into<String>>) -> Self {
323        self.aliases = aliases.into_iter().map(Into::into).collect();
324        self
325    }
326
327    /// True if `word` names this command — its `name` or any of its
328    /// command-level `aliases`. Used when routing a positional to a child.
329    pub fn matches_command(&self, word: &str) -> bool {
330        self.name == word || self.aliases.iter().any(|a| a == word)
331    }
332
333    /// Declare that this tool renders its own output (including `--json`), so
334    /// the kernel won't re-format its result.
335    ///
336    /// Applies to the whole tree: every subcommand is marked too, and a `json`
337    /// param is advertised on each node that doesn't already declare one.
338    /// Reflection skips `json` as the kernel-global output flag, so this
339    /// re-advertises it for tools that genuinely own it — closing the loop so
340    /// `help <tool> <sub>` lists `--json` where the tool actually handles it.
341    pub fn with_owned_output(mut self) -> Self {
342        self.mark_owned_output();
343        self
344    }
345
346    fn mark_owned_output(&mut self) {
347        self.owns_output = true;
348        if !self.params.iter().any(|p| p.name == "json") {
349            self.params.push(
350                ParamSchema::new("json", "bool").with_description("Render output as JSON"),
351            );
352        }
353        for child in &mut self.subcommands {
354            child.mark_owned_output();
355        }
356    }
357}
358
359/// Parsed arguments ready for tool execution.
360#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
361#[non_exhaustive]
362pub struct ToolArgs {
363    /// Positional arguments in order.
364    pub positional: Vec<Value>,
365    /// Named arguments by key.
366    pub named: BTreeMap<String, Value>,
367    /// Boolean flags (e.g., -l, --force).
368    pub flags: HashSet<String>,
369}
370
371impl ToolArgs {
372    /// Create empty args.
373    pub fn new() -> Self {
374        Self::default()
375    }
376
377    /// Get a positional argument by index.
378    pub fn get_positional(&self, index: usize) -> Option<&Value> {
379        self.positional.get(index)
380    }
381
382    /// Get a named argument by key.
383    pub fn get_named(&self, key: &str) -> Option<&Value> {
384        self.named.get(key)
385    }
386
387    /// Get a named argument or positional fallback.
388    ///
389    /// Useful for tools that accept both `cat file.txt` and `cat path=file.txt`.
390    pub fn get(&self, name: &str, positional_index: usize) -> Option<&Value> {
391        self.named.get(name).or_else(|| self.positional.get(positional_index))
392    }
393
394    /// Get a string value from args.
395    pub fn get_string(&self, name: &str, positional_index: usize) -> Option<String> {
396        self.get(name, positional_index).and_then(|v| match v {
397            Value::String(s) => Some(s.clone()),
398            Value::Int(i) => Some(i.to_string()),
399            Value::Float(f) => Some(f.to_string()),
400            Value::Bool(b) => Some(b.to_string()),
401            _ => None,
402        })
403    }
404
405    /// Get a boolean value from args.
406    pub fn get_bool(&self, name: &str, positional_index: usize) -> Option<bool> {
407        self.get(name, positional_index).and_then(|v| match v {
408            Value::Bool(b) => Some(*b),
409            Value::String(s) => match s.as_str() {
410                "true" | "yes" | "1" => Some(true),
411                "false" | "no" | "0" => Some(false),
412                _ => None,
413            },
414            Value::Int(i) => Some(*i != 0),
415            _ => None,
416        })
417    }
418
419    /// Check if a flag is set (in flags set, or named bool).
420    pub fn has_flag(&self, name: &str) -> bool {
421        // Check the flags set first (from -x or --name syntax)
422        if self.flags.contains(name) {
423            return true;
424        }
425        // Fall back to checking named args (from name=true syntax)
426        self.named.get(name).is_some_and(|v| match v {
427            Value::Bool(b) => *b,
428            Value::String(s) => !s.is_empty() && s != "false" && s != "0",
429            _ => true,
430        })
431    }
432
433    /// Move bool entries from `named` into the appropriate set so a downstream
434    /// clap parser (with `#[arg(...)] field: bool`) accepts them.
435    ///
436    /// Tests routinely seed `args.named.insert(K, Value::Bool(true))` for the
437    /// schema-pre-clap path; `to_argv()` would emit those as `--K=true`, which
438    /// clap rejects for `bool` fields. Promote to:
439    /// - `Bool(true)` → presence in `flags` (clap sees `--K`).
440    /// - `Bool(false)` → dropped (clap treats absent flag and explicit false
441    ///   the same; preserving it would only resurface as `--K=false` and break
442    ///   the same parser).
443    ///
444    /// A `Value::Bool` parked under a key the `schema` declares as a *value-taking*
445    /// flag is the flag's literal value, not a bare bool flag — `spawn --command
446    /// true` binds `command = Bool(true)`. Those keys are left in `named` so
447    /// `to_argv()` renders `--command=true` and clap's `Option<String>` field
448    /// accepts it; collapsing them to a bare `--command` drops the value and
449    /// makes clap error "a value is required". (See docs/issues.md.)
450    ///
451    /// Idempotent. Non-bool named entries are left alone.
452    pub fn flagify_bool_named(&mut self, schema: &ToolSchema) {
453        // Keys (param names + aliases) the schema declares as non-bool, non-positional
454        // flags — i.e. flags that take a value.
455        let value_keys: HashSet<&str> = schema
456            .params
457            .iter()
458            .filter(|p| !p.positional && !is_bool_param_type(&p.param_type))
459            .flat_map(|p| {
460                std::iter::once(p.name.as_str())
461                    .chain(p.aliases.iter().map(|a| a.trim_start_matches('-')))
462            })
463            .collect();
464
465        let bool_keys: Vec<String> = self
466            .named
467            .iter()
468            .filter(|(k, v)| matches!(v, Value::Bool(_)) && !value_keys.contains(k.as_str()))
469            .map(|(k, _)| k.clone())
470            .collect();
471        for k in bool_keys {
472            // Remove unconditionally so Bool(false) doesn't linger and break
473            // a `--K=false` rejection in clap. Only Bool(true) re-enters as a
474            // flag presence.
475            if let Some(Value::Bool(true)) = self.named.remove(&k) {
476                self.flags.insert(k);
477            }
478        }
479    }
480
481    /// Reconstruct a clap-friendly argv vector from already-parsed ToolArgs.
482    ///
483    /// kaish has already done shell parsing (variables expanded, globs expanded,
484    /// `$(...)` substituted, schema-driven flag/value splitting). `to_argv`
485    /// rebuilds a flat token stream suitable for `Parser::parse_from(std::iter::once("<tool>").chain(args.to_argv()?))`.
486    ///
487    /// Layout: flags first (as `--<name>`), then named values (as
488    /// `--<name>=<value>`), then positionals — separated from earlier sections
489    /// by `--` so trailing-passthrough builtins still see them as positionals
490    /// even if a value happens to begin with `-`.
491    ///
492    /// # Errors
493    ///
494    /// Returns [`ToolArgvError`] when a **named/flag** value is
495    /// [`Value::Bytes`] — binary can't cross the argv/text stringification
496    /// boundary (GH #164, closing the root cause behind GH #120's stringified
497    /// `[binary: N bytes]` placeholder). A **positional** `Value::Bytes` does
498    /// NOT error here; see [`value_to_argv_token`]'s doc comment for why.
499    ///
500    /// See the clap builtin pattern in CLAUDE.md (Contributor conventions).
501    ///
502    /// Equivalent to [`to_argv_excluding`](Self::to_argv_excluding)`(&[])` —
503    /// same rendering path, nothing excluded.
504    pub fn to_argv(&self) -> Result<Vec<String>, ToolArgvError> {
505        self.to_argv_excluding(&[])
506    }
507
508    /// Like [`to_argv`](Self::to_argv), but skips the given **named** keys
509    /// entirely — neither the key's flag token nor its value appears in the
510    /// rendered argv, and (crucially) a `Value::Bytes` under an excluded key
511    /// is never passed to [`render_named_value`], so it can never trip
512    /// [`ToolArgvError::BinaryNamedValue`].
513    ///
514    /// Use this when a builtin deliberately reads one of its own named
515    /// parameters raw off `ToolArgs` (e.g. `args.named.get("content")`)
516    /// instead of the clap-parsed field, specifically to preserve a
517    /// typed/binary value that must not cross the argv/text stringification
518    /// boundary — while still wanting the *rest* of its arguments bound
519    /// through the normal clap path. `write`'s `content` param is the
520    /// motivating case (GH #218, a follow-up from the GH #164 / #215
521    /// review): before this helper, the builtin cloned the whole `ToolArgs`
522    /// and called `named.remove("content")` by hand, which silently stops
523    /// covering a *second* Bytes-capable named param the moment one is added.
524    /// Naming the excluded keys here instead makes the exemption a
525    /// greppable, drift-resistant idiom.
526    ///
527    /// Only **named** keys are excludable — not flags or positionals, by
528    /// design. A bool flag carries no value to protect, so there is nothing
529    /// to exempt. A positional's clap-reflected field is already a
530    /// validation-only sink nobody reads (see CLAUDE.md's clap-builtin
531    /// convention), so a positional `Value::Bytes` never needed an
532    /// exemption in the first place — [`value_to_argv_token`] renders it as
533    /// an inert placeholder rather than erroring. If a future case needs to
534    /// exclude a flag or positional too, that is new design, not an
535    /// extension of this helper.
536    pub fn to_argv_excluding(&self, exclude: &[&str]) -> Result<Vec<String>, ToolArgvError> {
537        let mut argv = Vec::with_capacity(
538            self.flags.len() + self.named.len() * 2 + self.positional.len() + 1,
539        );
540
541        // Flags are unordered (HashSet); sort for deterministic argv so tests
542        // and snapshots stay stable. Single-char keys emit short form (`-n`)
543        // so clap's natural `#[arg(short = 'n', long = "no_newline")]` derive
544        // accepts them without needing visible_alias gymnastics.
545        let mut flags: Vec<&String> = self.flags.iter().collect();
546        flags.sort();
547        for flag in flags {
548            argv.push(flag_token(flag));
549        }
550
551        // Named values: emit `-k=value` for single-char keys and `--key=value`
552        // for multi-char keys. `=` form keeps parsing unambiguous when the
553        // value begins with `-`. Multi-value (`consumes > 1`) params are
554        // stored as Value::Json(Array(Array(...))) — one entry per occurrence.
555        // An excluded key is skipped before its value is ever inspected, so a
556        // Value::Bytes there can't reach render_named_value's Bytes guard.
557        for (key, value) in &self.named {
558            if exclude.contains(&key.as_str()) {
559                continue;
560            }
561            for rendered in render_named_value(key, value)? {
562                argv.push(format!("{}={}", flag_token(key), rendered));
563            }
564        }
565
566        // `--` terminator so clap treats positionals as positionals even if
567        // they begin with `-` (e.g. `echo -- -n` should print `-n`).
568        if !self.positional.is_empty() {
569            argv.push("--".to_string());
570            for value in &self.positional {
571                argv.push(value_to_argv_token(value));
572            }
573        }
574
575        Ok(argv)
576    }
577}
578
579/// Error raised by [`ToolArgs::to_argv`] when a **named or flag** argument
580/// cannot cross the argv/text stringification boundary.
581///
582/// The only offending [`Value`] variant is [`Value::Bytes`] — every other
583/// variant has a lossless text form. Binary crossing this boundary used to
584/// silently render as a `[binary: N bytes]` placeholder text token, which a
585/// downstream clap-parsed field (`parsed.separator`, `parsed.algo`, …) would
586/// then see as if it were the user's real value — GH #120's root cause,
587/// deferred as "Phase 2" at the time and closed here (GH #164).
588///
589/// Deliberately does **not** cover positional `Value::Bytes` — see
590/// [`value_to_argv_token`]'s doc comment for why a positional binary value
591/// stays safe to render as a placeholder rather than error.
592#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
593#[non_exhaustive]
594pub enum ToolArgvError {
595    /// A named/flag argument held [`Value::Bytes`].
596    #[error(
597        "argument `{key}` holds {byte_len} binary bytes, which cannot cross the argv/text \
598         boundary — read it from the raw ToolArgs value (e.g. `args.get(\"{key}\", ..)`) \
599         instead of the clap-parsed field"
600    )]
601    BinaryNamedValue {
602        /// The named argument's key (the schema name, e.g. `"separator"` —
603        /// not the `-`/`--`-prefixed flag_token form).
604        key: String,
605        /// The number of binary bytes it held. Never the bytes themselves —
606        /// this error message must stay safe to log.
607        byte_len: usize,
608    },
609}
610
611fn flag_token(name: &str) -> String {
612    if name.chars().count() == 1 {
613        format!("-{name}")
614    } else {
615        format!("--{name}")
616    }
617}
618
619/// Whether a `ParamSchema::param_type` names a boolean flag.
620fn is_bool_param_type(param_type: &str) -> bool {
621    param_type.eq_ignore_ascii_case("bool") || param_type.eq_ignore_ascii_case("boolean")
622}
623
624/// Render one named argument's value into its `to_argv()` token(s).
625///
626/// `key` is only used to attribute a [`ToolArgvError`] to the argument that
627/// held it — it does not affect rendering of any other variant.
628fn render_named_value(key: &str, value: &Value) -> Result<Vec<String>, ToolArgvError> {
629    match value {
630        // `consumes > 1` lands as Json(Array(Array(...))) — one inner array per
631        // occurrence. Flatten each inner array into space-joined tokens; clap
632        // can split on `=` further if needed.
633        Value::Json(serde_json::Value::Array(outer)) if outer.iter().all(|v| v.is_array()) => {
634            Ok(outer
635                .iter()
636                .map(|inner| {
637                    inner
638                        .as_array()
639                        .map(|a| a.iter().map(json_value_to_token).collect::<Vec<_>>().join(" "))
640                        .unwrap_or_default()
641                })
642                .collect())
643        }
644        // A named/flag value is commonly read straight off the clap-parsed
645        // field (`parsed.separator`, `parsed.algo`, …) rather than the raw
646        // `ToolArgs`, so silently stringifying binary here — as the old
647        // `[binary: N bytes]` placeholder did — hands a builtin's clap struct
648        // a value that looks textual but isn't the user's real data (GH #120's
649        // root cause). Loud instead: see `ToolArgvError`.
650        Value::Bytes(data) => Err(ToolArgvError::BinaryNamedValue {
651            key: key.to_string(),
652            byte_len: data.len(),
653        }),
654        _ => Ok(vec![value_to_argv_token(value)]),
655    }
656}
657
658/// Render one **positional** argument's value into its `to_argv()` token.
659///
660/// `Value::Bytes` renders as a visible placeholder rather than erroring —
661/// unlike the named-value path in [`render_named_value`]. This is safe only
662/// because a clap-reflected positional field is a validation-only sink (see
663/// CLAUDE.md's clap-builtin convention): no builtin reads a positional's
664/// *value* off the parsed clap struct, every one of them reads the typed
665/// `Value` straight off `args.positional` instead (e.g. `push`'s `rest:
666/// Vec<String>` sink, or `write`'s content positional, which accepts real
667/// `Value::Bytes` content byte-for-byte via `args.positional`, never via
668/// `parsed`). A placeholder token here only has to satisfy clap's parse (argv
669/// shape / arity), never carry real data anywhere — so it can never leak
670/// unlike the named case this function's sibling guards against.
671fn value_to_argv_token(value: &Value) -> String {
672    match value {
673        Value::Null => String::new(),
674        Value::Bool(b) => b.to_string(),
675        Value::Int(i) => i.to_string(),
676        Value::Float(f) => f.to_string(),
677        Value::String(s) => s.clone(),
678        Value::Json(j) => j.to_string(),
679        Value::Bytes(data) => format!("[binary: {} bytes]", data.len()),
680    }
681}
682
683fn json_value_to_token(value: &serde_json::Value) -> String {
684    match value {
685        serde_json::Value::Null => String::new(),
686        serde_json::Value::Bool(b) => b.to_string(),
687        serde_json::Value::Number(n) => n.to_string(),
688        serde_json::Value::String(s) => s.clone(),
689        other => other.to_string(),
690    }
691}
692
693#[cfg(test)]
694mod schema_serde_tests {
695    use super::*;
696
697    /// A flat tool (no subcommands/aliases) must serialize byte-identically to
698    /// the pre-subcommand wire format: the two new fields are skipped entirely.
699    #[test]
700    fn flat_schema_omits_new_fields_on_wire() {
701        let schema = ToolSchema::new("cat", "concatenate")
702            .param(ParamSchema::required("path", "string", "file to read").positional());
703        let json = serde_json::to_value(&schema).expect("serialize");
704        let obj = json.as_object().expect("object");
705        assert!(!obj.contains_key("subcommands"), "flat tool leaks subcommands: {json}");
706        assert!(!obj.contains_key("aliases"), "flat tool leaks command aliases: {json}");
707    }
708
709    /// Round-trip the skip: a flat tool serializes *without* the keys, so the
710    /// deserializer must `default` them back to empty. (This is what lets us
711    /// skip-serialize empties without breaking our own flat tools' payloads.)
712    #[test]
713    fn flat_wire_form_deserializes_to_empty() {
714        let flat = serde_json::json!({
715            "name": "cat",
716            "description": "concatenate",
717            "params": [],
718            "examples": [],
719            "map_positionals": false
720        });
721        let schema: ToolSchema = serde_json::from_value(flat).expect("deserialize flat form");
722        assert!(schema.subcommands.is_empty());
723        assert!(schema.aliases.is_empty());
724    }
725
726    /// `with_owned_output` marks the whole tree and advertises `json` on each
727    /// node that didn't already declare it.
728    #[test]
729    fn with_owned_output_marks_tree_and_advertises_json() {
730        let schema = ToolSchema::new("kj", "kaijutsu")
731            .subcommand(
732                ToolSchema::new("context", "ctx")
733                    .subcommand(ToolSchema::new("list", "list contexts")),
734            )
735            .with_owned_output();
736
737        assert!(schema.owns_output, "root marked");
738        assert!(schema.params.iter().any(|p| p.name == "json"), "root advertises json");
739        let context = &schema.subcommands[0];
740        assert!(context.owns_output, "child marked");
741        let list = &context.subcommands[0];
742        assert!(list.owns_output, "grandchild marked");
743        assert!(list.params.iter().any(|p| p.name == "json"), "leaf advertises json");
744    }
745
746    /// `with_owned_output` doesn't duplicate an already-declared `json` param.
747    #[test]
748    fn with_owned_output_does_not_double_add_json() {
749        let schema = ToolSchema::new("kj", "kaijutsu")
750            .param(ParamSchema::new("json", "bool"))
751            .with_owned_output();
752        let json_count = schema.params.iter().filter(|p| p.name == "json").count();
753        assert_eq!(json_count, 1, "json should appear exactly once");
754    }
755
756    /// `owns_output` round-trips and is omitted from the wire when false.
757    #[test]
758    fn owns_output_serde() {
759        let flat = ToolSchema::new("ls", "list");
760        let json = serde_json::to_value(&flat).expect("serialize");
761        let obj = json.as_object().expect("object");
762        assert!(!obj.contains_key("owns_output"), "false omitted: {json}");
763
764        let owned = ToolSchema::new("kj", "kaijutsu").with_owned_output();
765        let wire = serde_json::to_string(&owned).expect("serialize");
766        let back: ToolSchema = serde_json::from_str(&wire).expect("deserialize");
767        assert!(back.owns_output);
768    }
769
770    /// A subcommand tree round-trips through serde with names and aliases intact.
771    #[test]
772    fn subcommand_tree_round_trips() {
773        let schema = ToolSchema::new("kj", "kaijutsu")
774            .subcommand(
775                ToolSchema::new("context", "context ops")
776                    .with_command_aliases(["ctx"])
777                    .subcommand(ToolSchema::new("list", "list contexts").with_command_aliases(["ls"])),
778            );
779        let json = serde_json::to_string(&schema).expect("serialize");
780        let back: ToolSchema = serde_json::from_str(&json).expect("deserialize");
781        assert_eq!(back.subcommands.len(), 1);
782        let context = &back.subcommands[0];
783        assert!(context.matches_command("context"));
784        assert!(context.matches_command("ctx"));
785        assert_eq!(context.subcommands.len(), 1);
786        assert!(context.subcommands[0].matches_command("ls"));
787    }
788}
789
790#[cfg(test)]
791mod to_argv_tests {
792    use super::*;
793
794    #[test]
795    fn empty_args_produce_empty_argv() {
796        assert!(ToolArgs::new().to_argv().unwrap().is_empty());
797    }
798
799    #[test]
800    fn positionals_emitted_after_double_dash() {
801        let mut args = ToolArgs::new();
802        args.positional.push(Value::String("hello".into()));
803        args.positional.push(Value::String("world".into()));
804        assert_eq!(args.to_argv().unwrap(), vec!["--", "hello", "world"]);
805    }
806
807    #[test]
808    fn single_char_flags_emit_short_form() {
809        let mut args = ToolArgs::new();
810        args.flags.insert("n".into());
811        args.flags.insert("verbose".into());
812        // Sorted: "n" then "verbose"
813        assert_eq!(args.to_argv().unwrap(), vec!["-n", "--verbose"]);
814    }
815
816    #[test]
817    fn named_values_use_equals_form() {
818        let mut args = ToolArgs::new();
819        args.named.insert("count".into(), Value::Int(5));
820        args.named.insert("name".into(), Value::String("foo".into()));
821        // BTreeMap iterates in key order, so "count" before "name"
822        assert_eq!(args.to_argv().unwrap(), vec!["--count=5", "--name=foo"]);
823    }
824
825    #[test]
826    fn single_char_named_emits_short_equals() {
827        let mut args = ToolArgs::new();
828        args.named.insert("n".into(), Value::Int(5));
829        assert_eq!(args.to_argv().unwrap(), vec!["-n=5"]);
830    }
831
832    #[test]
833    fn positional_with_leading_dash_survives_double_dash() {
834        let mut args = ToolArgs::new();
835        args.positional.push(Value::String("-n".into()));
836        // `echo -- -n` should round-trip as `-- -n`, not be reparsed as a flag.
837        assert_eq!(args.to_argv().unwrap(), vec!["--", "-n"]);
838    }
839
840    #[test]
841    fn mixed_flags_named_positionals() {
842        let mut args = ToolArgs::new();
843        args.flags.insert("verbose".into());
844        args.named.insert("limit".into(), Value::Int(10));
845        args.positional.push(Value::String("file.txt".into()));
846        assert_eq!(
847            args.to_argv().unwrap(),
848            vec!["--verbose", "--limit=10", "--", "file.txt"]
849        );
850    }
851
852    /// GH #164: a named/flag `Value::Bytes` must error loudly instead of
853    /// silently stringifying to the `[binary: N bytes]` placeholder — that
854    /// placeholder is exactly what a downstream clap-parsed field
855    /// (`parsed.separator`, `parsed.algo`, …) would otherwise see as if it
856    /// were the user's real value (GH #120's root cause).
857    #[test]
858    fn named_bytes_value_errors_loudly() {
859        let mut args = ToolArgs::new();
860        args.named.insert("separator".into(), Value::Bytes(vec![0xff, 0x00, 0xfe]));
861
862        let err = args.to_argv().expect_err("named Bytes must error");
863        // The message must name the key and the byte count.
864        let message = err.to_string();
865        assert!(message.contains("separator"));
866        assert!(message.contains('3'));
867        let ToolArgvError::BinaryNamedValue { key, byte_len } = err;
868        assert_eq!(key, "separator");
869        assert_eq!(byte_len, 3);
870    }
871
872    /// A single-char named key (e.g. `-a`) gets the same loud treatment.
873    #[test]
874    fn single_char_named_bytes_value_errors_loudly() {
875        let mut args = ToolArgs::new();
876        args.named.insert("a".into(), Value::Bytes(vec![1, 2]));
877
878        let err = args.to_argv().expect_err("named Bytes must error");
879        let ToolArgvError::BinaryNamedValue { key, byte_len } = err;
880        assert_eq!(key, "a");
881        assert_eq!(byte_len, 2);
882    }
883
884    /// Positional `Value::Bytes`, by contrast, does NOT error — see
885    /// `value_to_argv_token`'s doc comment. The clap-reflected positional
886    /// field is a validation-only sink; no builtin ever reads its *value* off
887    /// the parsed struct (they read the typed `Value` straight off
888    /// `args.positional`), so a placeholder token here is inert, not
889    /// corruption. This is the load-bearing decision behind builtins like
890    /// `push`/`write` accepting real binary content through positionals.
891    #[test]
892    fn positional_bytes_value_renders_placeholder_not_error() {
893        let mut args = ToolArgs::new();
894        args.positional.push(Value::Bytes(vec![0xff, 0x00, 0xfe]));
895
896        let argv = args.to_argv().expect("positional Bytes must not error");
897        assert_eq!(argv, vec!["--", "[binary: 3 bytes]"]);
898    }
899
900    /// Mixed case: a named Bytes error takes priority even when a positional
901    /// Bytes value is also present (the loud path must not be starved by
902    /// iteration order silently succeeding on the positional half first).
903    #[test]
904    fn named_bytes_errors_even_with_positional_bytes_present() {
905        let mut args = ToolArgs::new();
906        args.named.insert("check".into(), Value::Bytes(vec![9, 9]));
907        args.positional.push(Value::Bytes(vec![1, 2, 3]));
908
909        let err = args.to_argv().expect_err("named Bytes must still error");
910        let ToolArgvError::BinaryNamedValue { key, .. } = err;
911        assert_eq!(key, "check");
912    }
913
914    // ── GH #218: ToolArgs::to_argv_excluding ────────────────────────────
915    //
916    // write.rs reads its own `content` named param raw off `ToolArgs` to
917    // preserve `Value::Bytes`, then needs the *rest* of its args through the
918    // normal clap/to_argv path — these tests pin the helper that replaces the
919    // ad hoc "clone ToolArgs, remove the key, call to_argv()" dance.
920
921    /// A named `Value::Bytes` under an excluded key must not error and must
922    /// not appear anywhere in the rendered argv — this is the whole point:
923    /// `write`'s `content` carries real binary and must never reach argv.
924    #[test]
925    fn to_argv_excluding_skips_excluded_named_bytes_without_error() {
926        let mut args = ToolArgs::new();
927        args.named.insert("content".into(), Value::Bytes(vec![0xff, 0x00, 0xfe]));
928        args.named.insert("path".into(), Value::String("dest.bin".into()));
929
930        let argv = args
931            .to_argv_excluding(&["content"])
932            .expect("excluded named Bytes must not error");
933        assert_eq!(argv, vec!["--path=dest.bin"]);
934        assert!(
935            argv.iter().all(|tok| !tok.contains("content")),
936            "excluded key must not appear in argv at all: {argv:?}"
937        );
938    }
939
940    /// A named `Value::Bytes` under a key that is NOT excluded still errors
941    /// loudly, same as plain `to_argv()` — excluding one key must not blanket
942    /// the whole named map.
943    #[test]
944    fn to_argv_excluding_still_errors_on_non_excluded_named_bytes() {
945        let mut args = ToolArgs::new();
946        args.named.insert("content".into(), Value::Bytes(vec![1, 2, 3]));
947        args.named.insert("separator".into(), Value::Bytes(vec![9, 9]));
948
949        let err = args
950            .to_argv_excluding(&["content"])
951            .expect_err("non-excluded named Bytes must still error");
952        let ToolArgvError::BinaryNamedValue { key, .. } = err;
953        assert_eq!(key, "separator");
954    }
955
956    /// Excluding a key that isn't a `Value::Bytes` at all (the common case —
957    /// most invocations of `write` carry plain string content) still drops it
958    /// from argv. The exclusion is unconditional on the key, not conditional
959    /// on the value being binary.
960    #[test]
961    fn to_argv_excluding_drops_excluded_key_regardless_of_value_type() {
962        let mut args = ToolArgs::new();
963        args.named.insert("content".into(), Value::String("hello".into()));
964        args.named.insert("path".into(), Value::String("dest.txt".into()));
965
966        let argv = args.to_argv_excluding(&["content"]).expect("no error expected");
967        assert_eq!(argv, vec!["--path=dest.txt"]);
968    }
969
970    /// An empty exclude list must behave *exactly* like `to_argv()` — same
971    /// tokens, same order — across flags, named values, and positionals, on
972    /// args that don't touch the Bytes edge case at all. `to_argv()` itself
973    /// delegates to this with an empty slice, so this is also the guard that
974    /// the delegation didn't change plain `to_argv()` behavior.
975    #[test]
976    fn to_argv_excluding_empty_list_matches_to_argv() {
977        let mut args = ToolArgs::new();
978        args.flags.insert("verbose".into());
979        args.flags.insert("n".into());
980        args.named.insert("limit".into(), Value::Int(10));
981        args.named.insert("name".into(), Value::String("foo".into()));
982        args.positional.push(Value::String("file.txt".into()));
983        args.positional.push(Value::String("-weird".into()));
984
985        assert_eq!(
986            args.to_argv_excluding(&[]).unwrap(),
987            args.to_argv().unwrap(),
988            "empty exclude list must be indistinguishable from to_argv()"
989        );
990    }
991
992    #[test]
993    fn flagify_bool_named_promotes_true_to_flag() {
994        let mut args = ToolArgs::new();
995        args.named.insert("recursive".into(), Value::Bool(true));
996        args.named.insert("limit".into(), Value::Int(5));
997
998        args.flagify_bool_named(&ToolSchema::new("t", ""));
999
1000        assert!(args.flags.contains("recursive"));
1001        assert!(!args.named.contains_key("recursive"));
1002        // Non-bool entries are untouched.
1003        assert_eq!(args.named.get("limit"), Some(&Value::Int(5)));
1004    }
1005
1006    #[test]
1007    fn flagify_bool_named_drops_false() {
1008        let mut args = ToolArgs::new();
1009        args.named.insert("recursive".into(), Value::Bool(false));
1010
1011        args.flagify_bool_named(&ToolSchema::new("t", ""));
1012
1013        assert!(!args.flags.contains("recursive"));
1014        assert!(!args.named.contains_key("recursive"));
1015    }
1016
1017    #[test]
1018    fn flagify_bool_named_is_idempotent() {
1019        let mut args = ToolArgs::new();
1020        args.named.insert("recursive".into(), Value::Bool(true));
1021        args.flagify_bool_named(&ToolSchema::new("t", ""));
1022        args.flagify_bool_named(&ToolSchema::new("t", ""));
1023        assert!(args.flags.contains("recursive"));
1024    }
1025
1026    /// Regression guard: argv emitted after flagify must round-trip through
1027    /// a clap parser without `--K=true` showing up.
1028    #[test]
1029    fn flagify_bool_named_round_trips_through_to_argv() {
1030        let mut args = ToolArgs::new();
1031        args.named.insert("R".into(), Value::Bool(true));
1032        args.flagify_bool_named(&ToolSchema::new("t", ""));
1033        let argv = args.to_argv().unwrap();
1034        assert!(argv.contains(&"-R".to_string()), "expected -R, got {:?}", argv);
1035        assert!(!argv.iter().any(|s| s.contains('=')), "no =value should appear, got {:?}", argv);
1036    }
1037
1038    /// A `Bool(true)` parked under a schema-declared value-taking flag is the
1039    /// flag's literal value (`spawn --command true`), not a bare bool flag — it
1040    /// stays in `named` and renders as `--K=true`, not a value-less `--K`.
1041    #[test]
1042    fn flagify_bool_named_keeps_value_flag_value() {
1043        let mut schema = ToolSchema::new("spawn", "");
1044        schema.params.push(ParamSchema::new("command", "string"));
1045
1046        let mut args = ToolArgs::new();
1047        args.named.insert("command".into(), Value::Bool(true));
1048        args.flagify_bool_named(&schema);
1049
1050        assert!(!args.flags.contains("command"), "value flag must not collapse to a bare flag");
1051        assert_eq!(args.named.get("command"), Some(&Value::Bool(true)));
1052        let argv = args.to_argv().unwrap();
1053        assert!(
1054            argv.iter().any(|s| s == "--command=true"),
1055            "expected --command=true, got {:?}",
1056            argv
1057        );
1058    }
1059
1060    /// One schema carrying both a bool flag and a value-taking flag: the bool
1061    /// flag still flagifies, the value flag keeps its value. Proves
1062    /// `is_bool_param_type` actually distinguishes the two (an empty-schema test
1063    /// can't — it flagifies everything regardless).
1064    #[test]
1065    fn flagify_bool_named_distinguishes_bool_from_value_param() {
1066        let mut schema = ToolSchema::new("t", "");
1067        schema.params.push(ParamSchema::new("verbose", "bool"));
1068        schema.params.push(ParamSchema::new("command", "string"));
1069
1070        let mut args = ToolArgs::new();
1071        args.named.insert("verbose".into(), Value::Bool(true));
1072        args.named.insert("command".into(), Value::Bool(true));
1073        args.flagify_bool_named(&schema);
1074
1075        // Bool flag → promoted to a bare flag.
1076        assert!(args.flags.contains("verbose"));
1077        assert!(!args.named.contains_key("verbose"));
1078        // Value flag → value retained.
1079        assert!(!args.flags.contains("command"));
1080        assert_eq!(args.named.get("command"), Some(&Value::Bool(true)));
1081    }
1082}