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 /// Dotted effect ids this tool declares (`fs.remove`, `fs.overwrite`,
262 /// …) — what an embedder reads off `tools --json` to learn a tool's
263 /// destructive effects instead of recognizing tool names. Empty for a
264 /// tool with no destructive effect. A flat tool with several behaviors
265 /// behind one schema (`kaish-trash`'s `list`/`restore`/`config`/`empty`)
266 /// lists every effect any of its behaviors has, not just the ones the
267 /// current invocation will reach — the schema is reflected once, before
268 /// argv says which behavior runs. See [`ToolSchema::with_operations`].
269 #[serde(default, skip_serializing_if = "Vec::is_empty")]
270 pub operations: Vec<String>,
271}
272
273impl ToolSchema {
274 /// Create a new tool schema.
275 pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
276 Self {
277 name: name.into(),
278 description: description.into(),
279 params: Vec::new(),
280 examples: Vec::new(),
281 map_positionals: false,
282 subcommands: Vec::new(),
283 aliases: Vec::new(),
284 owns_output: false,
285 raw_argv: false,
286 glob_passthrough: false,
287 operations: Vec::new(),
288 }
289 }
290
291 /// Declare that this tool wants its argv in source order with types
292 /// preserved (no flag/positional split). See [`ToolSchema::raw_argv`].
293 pub fn with_raw_argv(mut self) -> Self {
294 self.raw_argv = true;
295 self
296 }
297
298 /// Declare that this tool consumes glob patterns as data: the argv binder
299 /// passes bare patterns through as literal text instead of expanding them.
300 /// See [`ToolSchema::glob_passthrough`].
301 pub fn with_glob_passthrough(mut self) -> Self {
302 self.glob_passthrough = true;
303 self
304 }
305
306 /// Declare the dotted effect ids this tool carries. See
307 /// [`ToolSchema::operations`].
308 pub fn with_operations(mut self, operations: impl IntoIterator<Item = impl Into<String>>) -> Self {
309 self.operations = operations.into_iter().map(Into::into).collect();
310 self
311 }
312
313 /// Enable positional->named parameter mapping for MCP/external tools.
314 pub fn with_positional_mapping(mut self) -> Self {
315 self.map_positionals = true;
316 self
317 }
318
319 /// Add a parameter to the schema.
320 pub fn param(mut self, param: ParamSchema) -> Self {
321 self.params.push(param);
322 self
323 }
324
325 /// Add an example to the schema.
326 pub fn example(mut self, description: impl Into<String>, code: impl Into<String>) -> Self {
327 self.examples.push(Example::new(description, code));
328 self
329 }
330
331 /// Add a child schema, making this a subcommand-aware tool.
332 pub fn subcommand(mut self, child: ToolSchema) -> Self {
333 self.subcommands.push(child);
334 self
335 }
336
337 /// Set command-level aliases (e.g. `ls` for a `list` subcommand). These
338 /// name the *command*, not its flags; flag aliases live on each
339 /// [`ParamSchema`].
340 pub fn with_command_aliases(mut self, aliases: impl IntoIterator<Item = impl Into<String>>) -> Self {
341 self.aliases = aliases.into_iter().map(Into::into).collect();
342 self
343 }
344
345 /// True if `word` names this command — its `name` or any of its
346 /// command-level `aliases`. Used when routing a positional to a child.
347 pub fn matches_command(&self, word: &str) -> bool {
348 self.name == word || self.aliases.iter().any(|a| a == word)
349 }
350
351 /// Declare that this tool renders its own output (including `--json`), so
352 /// the kernel won't re-format its result.
353 ///
354 /// Applies to the whole tree: every subcommand is marked too, and a `json`
355 /// param is advertised on each node that doesn't already declare one.
356 /// Reflection skips `json` as the kernel-global output flag, so this
357 /// re-advertises it for tools that genuinely own it — closing the loop so
358 /// `help <tool> <sub>` lists `--json` where the tool actually handles it.
359 pub fn with_owned_output(mut self) -> Self {
360 self.mark_owned_output();
361 self
362 }
363
364 fn mark_owned_output(&mut self) {
365 self.owns_output = true;
366 if !self.params.iter().any(|p| p.name == "json") {
367 self.params.push(
368 ParamSchema::new("json", "bool").with_description("Render output as JSON"),
369 );
370 }
371 for child in &mut self.subcommands {
372 child.mark_owned_output();
373 }
374 }
375}
376
377/// Parsed arguments ready for tool execution.
378#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
379#[non_exhaustive]
380pub struct ToolArgs {
381 /// Positional arguments in order.
382 pub positional: Vec<Value>,
383 /// Named arguments by key.
384 pub named: BTreeMap<String, Value>,
385 /// Boolean flags (e.g., -l, --force).
386 pub flags: HashSet<String>,
387}
388
389impl ToolArgs {
390 /// Create empty args.
391 pub fn new() -> Self {
392 Self::default()
393 }
394
395 /// Get a positional argument by index.
396 pub fn get_positional(&self, index: usize) -> Option<&Value> {
397 self.positional.get(index)
398 }
399
400 /// Get a named argument by key.
401 pub fn get_named(&self, key: &str) -> Option<&Value> {
402 self.named.get(key)
403 }
404
405 /// Get a named argument or positional fallback.
406 ///
407 /// Useful for tools that accept both `cat file.txt` and `cat path=file.txt`.
408 pub fn get(&self, name: &str, positional_index: usize) -> Option<&Value> {
409 self.named.get(name).or_else(|| self.positional.get(positional_index))
410 }
411
412 /// Get a string value from args.
413 pub fn get_string(&self, name: &str, positional_index: usize) -> Option<String> {
414 self.get(name, positional_index).and_then(|v| match v {
415 Value::String(s) => Some(s.clone()),
416 Value::Int(i) => Some(i.to_string()),
417 Value::Float(f) => Some(f.to_string()),
418 Value::Bool(b) => Some(b.to_string()),
419 _ => None,
420 })
421 }
422
423 /// Get a boolean value from args.
424 pub fn get_bool(&self, name: &str, positional_index: usize) -> Option<bool> {
425 self.get(name, positional_index).and_then(|v| match v {
426 Value::Bool(b) => Some(*b),
427 Value::String(s) => match s.as_str() {
428 "true" | "yes" | "1" => Some(true),
429 "false" | "no" | "0" => Some(false),
430 _ => None,
431 },
432 Value::Int(i) => Some(*i != 0),
433 _ => None,
434 })
435 }
436
437 /// Check if a flag is set (in flags set, or named bool).
438 pub fn has_flag(&self, name: &str) -> bool {
439 // Check the flags set first (from -x or --name syntax)
440 if self.flags.contains(name) {
441 return true;
442 }
443 // Fall back to checking named args (from name=true syntax)
444 self.named.get(name).is_some_and(|v| match v {
445 Value::Bool(b) => *b,
446 Value::String(s) => !s.is_empty() && s != "false" && s != "0",
447 _ => true,
448 })
449 }
450
451 /// Move bool entries from `named` into the appropriate set so a downstream
452 /// clap parser (with `#[arg(...)] field: bool`) accepts them.
453 ///
454 /// Tests routinely seed `args.named.insert(K, Value::Bool(true))` for the
455 /// schema-pre-clap path; `to_argv()` would emit those as `--K=true`, which
456 /// clap rejects for `bool` fields. Promote to:
457 /// - `Bool(true)` → presence in `flags` (clap sees `--K`).
458 /// - `Bool(false)` → dropped (clap treats absent flag and explicit false
459 /// the same; preserving it would only resurface as `--K=false` and break
460 /// the same parser).
461 ///
462 /// A `Value::Bool` parked under a key the `schema` declares as a *value-taking*
463 /// flag is the flag's literal value, not a bare bool flag — `spawn --command
464 /// true` binds `command = Bool(true)`. Those keys are left in `named` so
465 /// `to_argv()` renders `--command=true` and clap's `Option<String>` field
466 /// accepts it; collapsing them to a bare `--command` drops the value and
467 /// makes clap error "a value is required".
468 ///
469 /// Idempotent. Non-bool named entries are left alone.
470 pub fn flagify_bool_named(&mut self, schema: &ToolSchema) {
471 // Keys (param names + aliases) the schema declares as non-bool, non-positional
472 // flags — i.e. flags that take a value.
473 let value_keys: HashSet<&str> = schema
474 .params
475 .iter()
476 .filter(|p| !p.positional && !is_bool_param_type(&p.param_type))
477 .flat_map(|p| {
478 std::iter::once(p.name.as_str())
479 .chain(p.aliases.iter().map(|a| a.trim_start_matches('-')))
480 })
481 .collect();
482
483 let bool_keys: Vec<String> = self
484 .named
485 .iter()
486 .filter(|(k, v)| matches!(v, Value::Bool(_)) && !value_keys.contains(k.as_str()))
487 .map(|(k, _)| k.clone())
488 .collect();
489 for k in bool_keys {
490 // Remove unconditionally so Bool(false) doesn't linger and break
491 // a `--K=false` rejection in clap. Only Bool(true) re-enters as a
492 // flag presence.
493 if let Some(Value::Bool(true)) = self.named.remove(&k) {
494 self.flags.insert(k);
495 }
496 }
497 }
498
499 /// Reconstruct a clap-friendly argv vector from already-parsed ToolArgs.
500 ///
501 /// kaish has already done shell parsing (variables expanded, globs expanded,
502 /// `$(...)` substituted, schema-driven flag/value splitting). `to_argv`
503 /// rebuilds a flat token stream suitable for `Parser::parse_from(std::iter::once("<tool>").chain(args.to_argv()?))`.
504 ///
505 /// Layout: flags first (as `--<name>`), then named values (as
506 /// `--<name>=<value>`), then positionals — separated from earlier sections
507 /// by `--` so trailing-passthrough builtins still see them as positionals
508 /// even if a value happens to begin with `-`.
509 ///
510 /// # Errors
511 ///
512 /// Returns [`ToolArgvError`] when a **named/flag** value is
513 /// [`Value::Bytes`] — binary can't cross the argv/text stringification
514 /// boundary (GH #164, closing the root cause behind GH #120's stringified
515 /// `[binary: N bytes]` placeholder). A **positional** `Value::Bytes` does
516 /// NOT error here; see `value_to_argv_token`'s doc comment for why.
517 ///
518 /// See the clap builtin pattern in CLAUDE.md (Contributor conventions).
519 ///
520 /// Equivalent to [`to_argv_excluding`](Self::to_argv_excluding)`(&[])` —
521 /// same rendering path, nothing excluded.
522 pub fn to_argv(&self) -> Result<Vec<String>, ToolArgvError> {
523 self.to_argv_excluding(&[])
524 }
525
526 /// Like [`to_argv`](Self::to_argv), but skips the given **named** keys
527 /// entirely — neither the key's flag token nor its value appears in the
528 /// rendered argv, and (crucially) a `Value::Bytes` under an excluded key
529 /// is never passed to `render_named_value`, so it can never trip
530 /// [`ToolArgvError::BinaryNamedValue`].
531 ///
532 /// Use this when a builtin deliberately reads one of its own named
533 /// parameters raw off `ToolArgs` (e.g. `args.named.get("content")`)
534 /// instead of the clap-parsed field, specifically to preserve a
535 /// typed/binary value that must not cross the argv/text stringification
536 /// boundary — while still wanting the *rest* of its arguments bound
537 /// through the normal clap path. `write`'s `content` param is the
538 /// motivating case (GH #218, a follow-up from the GH #164 / #215
539 /// review): before this helper, the builtin cloned the whole `ToolArgs`
540 /// and called `named.remove("content")` by hand, which silently stops
541 /// covering a *second* Bytes-capable named param the moment one is added.
542 /// Naming the excluded keys here instead makes the exemption a
543 /// greppable, drift-resistant idiom.
544 ///
545 /// Only **named** keys are excludable — not flags or positionals, by
546 /// design. A bool flag carries no value to protect, so there is nothing
547 /// to exempt. A positional's clap-reflected field is already a
548 /// validation-only sink nobody reads (see CLAUDE.md's clap-builtin
549 /// convention), so a positional `Value::Bytes` never needed an
550 /// exemption in the first place — `value_to_argv_token` renders it as
551 /// an inert placeholder rather than erroring. If a future case needs to
552 /// exclude a flag or positional too, that is new design, not an
553 /// extension of this helper.
554 pub fn to_argv_excluding(&self, exclude: &[&str]) -> Result<Vec<String>, ToolArgvError> {
555 let mut argv = Vec::with_capacity(
556 self.flags.len() + self.named.len() * 2 + self.positional.len() + 1,
557 );
558
559 // Flags are unordered (HashSet); sort for deterministic argv so tests
560 // and snapshots stay stable. Single-char keys emit short form (`-n`)
561 // so clap's natural `#[arg(short = 'n', long = "no_newline")]` derive
562 // accepts them without needing visible_alias gymnastics.
563 let mut flags: Vec<&String> = self.flags.iter().collect();
564 flags.sort();
565 for flag in flags {
566 argv.push(flag_token(flag));
567 }
568
569 // Named values: emit `-k=value` for single-char keys and `--key=value`
570 // for multi-char keys. `=` form keeps parsing unambiguous when the
571 // value begins with `-`. Multi-value (`consumes > 1`) params are
572 // stored as Value::Json(Array(Array(...))) — one entry per occurrence.
573 // An excluded key is skipped before its value is ever inspected, so a
574 // Value::Bytes there can't reach render_named_value's Bytes guard.
575 for (key, value) in &self.named {
576 if exclude.contains(&key.as_str()) {
577 continue;
578 }
579 for rendered in render_named_value(key, value)? {
580 argv.push(format!("{}={}", flag_token(key), rendered));
581 }
582 }
583
584 // `--` terminator so clap treats positionals as positionals even if
585 // they begin with `-` (e.g. `echo -- -n` should print `-n`).
586 if !self.positional.is_empty() {
587 argv.push("--".to_string());
588 for value in &self.positional {
589 argv.push(value_to_argv_token(value));
590 }
591 }
592
593 Ok(argv)
594 }
595}
596
597/// Error raised by [`ToolArgs::to_argv`] when a **named or flag** argument
598/// cannot cross the argv/text stringification boundary.
599///
600/// The only offending [`Value`] variant is [`Value::Bytes`] — every other
601/// variant has a lossless text form. Binary crossing this boundary used to
602/// silently render as a `[binary: N bytes]` placeholder text token, which a
603/// downstream clap-parsed field (`parsed.separator`, `parsed.algo`, …) would
604/// then see as if it were the user's real value — GH #120's root cause,
605/// deferred as "Phase 2" at the time and closed here (GH #164).
606///
607/// Deliberately does **not** cover positional `Value::Bytes` — see
608/// `value_to_argv_token`'s doc comment for why a positional binary value
609/// stays safe to render as a placeholder rather than error.
610#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
611#[non_exhaustive]
612pub enum ToolArgvError {
613 /// A named/flag argument held [`Value::Bytes`].
614 #[error(
615 "argument `{key}` holds {byte_len} binary bytes, which cannot cross the argv/text \
616 boundary — read it from the raw ToolArgs value (e.g. `args.get(\"{key}\", ..)`) \
617 instead of the clap-parsed field"
618 )]
619 BinaryNamedValue {
620 /// The named argument's key (the schema name, e.g. `"separator"` —
621 /// not the `-`/`--`-prefixed flag_token form).
622 key: String,
623 /// The number of binary bytes it held. Never the bytes themselves —
624 /// this error message must stay safe to log.
625 byte_len: usize,
626 },
627}
628
629fn flag_token(name: &str) -> String {
630 if name.chars().count() == 1 {
631 format!("-{name}")
632 } else {
633 format!("--{name}")
634 }
635}
636
637/// Whether a `ParamSchema::param_type` names a boolean flag.
638fn is_bool_param_type(param_type: &str) -> bool {
639 param_type.eq_ignore_ascii_case("bool") || param_type.eq_ignore_ascii_case("boolean")
640}
641
642/// Render one named argument's value into its `to_argv()` token(s).
643///
644/// `key` is only used to attribute a [`ToolArgvError`] to the argument that
645/// held it — it does not affect rendering of any other variant.
646fn render_named_value(key: &str, value: &Value) -> Result<Vec<String>, ToolArgvError> {
647 match value {
648 // `consumes > 1` lands as Json(Array(Array(...))) — one inner array per
649 // occurrence. Flatten each inner array into space-joined tokens; clap
650 // can split on `=` further if needed.
651 Value::Json(serde_json::Value::Array(outer)) if outer.iter().all(|v| v.is_array()) => {
652 Ok(outer
653 .iter()
654 .map(|inner| {
655 inner
656 .as_array()
657 .map(|a| a.iter().map(json_value_to_token).collect::<Vec<_>>().join(" "))
658 .unwrap_or_default()
659 })
660 .collect())
661 }
662 // A named/flag value is commonly read straight off the clap-parsed
663 // field (`parsed.separator`, `parsed.algo`, …) rather than the raw
664 // `ToolArgs`, so silently stringifying binary here — as the old
665 // `[binary: N bytes]` placeholder did — hands a builtin's clap struct
666 // a value that looks textual but isn't the user's real data (GH #120's
667 // root cause). Loud instead: see `ToolArgvError`.
668 Value::Bytes(data) => Err(ToolArgvError::BinaryNamedValue {
669 key: key.to_string(),
670 byte_len: data.len(),
671 }),
672 _ => Ok(vec![value_to_argv_token(value)]),
673 }
674}
675
676/// Render one **positional** argument's value into its `to_argv()` token.
677///
678/// `Value::Bytes` renders as a visible placeholder rather than erroring —
679/// unlike the named-value path in [`render_named_value`]. This is safe only
680/// because a clap-reflected positional field is a validation-only sink (see
681/// CLAUDE.md's clap-builtin convention): no builtin reads a positional's
682/// *value* off the parsed clap struct, every one of them reads the typed
683/// `Value` straight off `args.positional` instead (e.g. `push`'s `rest:
684/// Vec<String>` sink, or `write`'s content positional, which accepts real
685/// `Value::Bytes` content byte-for-byte via `args.positional`, never via
686/// `parsed`). A placeholder token here only has to satisfy clap's parse (argv
687/// shape / arity), never carry real data anywhere — so it can never leak
688/// unlike the named case this function's sibling guards against.
689fn value_to_argv_token(value: &Value) -> String {
690 match value {
691 Value::Null => String::new(),
692 Value::Bool(b) => b.to_string(),
693 Value::Int(i) => i.to_string(),
694 Value::Float(f) => f.to_string(),
695 Value::String(s) => s.clone(),
696 Value::Json(j) => j.to_string(),
697 Value::Bytes(data) => format!("[binary: {} bytes]", data.len()),
698 }
699}
700
701fn json_value_to_token(value: &serde_json::Value) -> String {
702 match value {
703 serde_json::Value::Null => String::new(),
704 serde_json::Value::Bool(b) => b.to_string(),
705 serde_json::Value::Number(n) => n.to_string(),
706 serde_json::Value::String(s) => s.clone(),
707 other => other.to_string(),
708 }
709}
710
711#[cfg(test)]
712mod schema_serde_tests {
713 use super::*;
714
715 /// A flat tool (no subcommands/aliases) must serialize byte-identically to
716 /// the pre-subcommand wire format: the two new fields are skipped entirely.
717 #[test]
718 fn flat_schema_omits_new_fields_on_wire() {
719 let schema = ToolSchema::new("cat", "concatenate")
720 .param(ParamSchema::required("path", "string", "file to read").positional());
721 let json = serde_json::to_value(&schema).expect("serialize");
722 let obj = json.as_object().expect("object");
723 assert!(!obj.contains_key("subcommands"), "flat tool leaks subcommands: {json}");
724 assert!(!obj.contains_key("aliases"), "flat tool leaks command aliases: {json}");
725 }
726
727 /// Round-trip the skip: a flat tool serializes *without* the keys, so the
728 /// deserializer must `default` them back to empty. (This is what lets us
729 /// skip-serialize empties without breaking our own flat tools' payloads.)
730 #[test]
731 fn flat_wire_form_deserializes_to_empty() {
732 let flat = serde_json::json!({
733 "name": "cat",
734 "description": "concatenate",
735 "params": [],
736 "examples": [],
737 "map_positionals": false
738 });
739 let schema: ToolSchema = serde_json::from_value(flat).expect("deserialize flat form");
740 assert!(schema.subcommands.is_empty());
741 assert!(schema.aliases.is_empty());
742 }
743
744 /// `with_owned_output` marks the whole tree and advertises `json` on each
745 /// node that didn't already declare it.
746 #[test]
747 fn with_owned_output_marks_tree_and_advertises_json() {
748 let schema = ToolSchema::new("kj", "kaijutsu")
749 .subcommand(
750 ToolSchema::new("context", "ctx")
751 .subcommand(ToolSchema::new("list", "list contexts")),
752 )
753 .with_owned_output();
754
755 assert!(schema.owns_output, "root marked");
756 assert!(schema.params.iter().any(|p| p.name == "json"), "root advertises json");
757 let context = &schema.subcommands[0];
758 assert!(context.owns_output, "child marked");
759 let list = &context.subcommands[0];
760 assert!(list.owns_output, "grandchild marked");
761 assert!(list.params.iter().any(|p| p.name == "json"), "leaf advertises json");
762 }
763
764 /// `with_owned_output` doesn't duplicate an already-declared `json` param.
765 #[test]
766 fn with_owned_output_does_not_double_add_json() {
767 let schema = ToolSchema::new("kj", "kaijutsu")
768 .param(ParamSchema::new("json", "bool"))
769 .with_owned_output();
770 let json_count = schema.params.iter().filter(|p| p.name == "json").count();
771 assert_eq!(json_count, 1, "json should appear exactly once");
772 }
773
774 /// `owns_output` round-trips and is omitted from the wire when false.
775 #[test]
776 fn owns_output_serde() {
777 let flat = ToolSchema::new("ls", "list");
778 let json = serde_json::to_value(&flat).expect("serialize");
779 let obj = json.as_object().expect("object");
780 assert!(!obj.contains_key("owns_output"), "false omitted: {json}");
781
782 let owned = ToolSchema::new("kj", "kaijutsu").with_owned_output();
783 let wire = serde_json::to_string(&owned).expect("serialize");
784 let back: ToolSchema = serde_json::from_str(&wire).expect("deserialize");
785 assert!(back.owns_output);
786 }
787
788 /// A subcommand tree round-trips through serde with names and aliases intact.
789 #[test]
790 fn subcommand_tree_round_trips() {
791 let schema = ToolSchema::new("kj", "kaijutsu")
792 .subcommand(
793 ToolSchema::new("context", "context ops")
794 .with_command_aliases(["ctx"])
795 .subcommand(ToolSchema::new("list", "list contexts").with_command_aliases(["ls"])),
796 );
797 let json = serde_json::to_string(&schema).expect("serialize");
798 let back: ToolSchema = serde_json::from_str(&json).expect("deserialize");
799 assert_eq!(back.subcommands.len(), 1);
800 let context = &back.subcommands[0];
801 assert!(context.matches_command("context"));
802 assert!(context.matches_command("ctx"));
803 assert_eq!(context.subcommands.len(), 1);
804 assert!(context.subcommands[0].matches_command("ls"));
805 }
806}
807
808#[cfg(test)]
809mod to_argv_tests {
810 use super::*;
811
812 #[test]
813 fn empty_args_produce_empty_argv() {
814 assert!(ToolArgs::new().to_argv().unwrap().is_empty());
815 }
816
817 #[test]
818 fn positionals_emitted_after_double_dash() {
819 let mut args = ToolArgs::new();
820 args.positional.push(Value::String("hello".into()));
821 args.positional.push(Value::String("world".into()));
822 assert_eq!(args.to_argv().unwrap(), vec!["--", "hello", "world"]);
823 }
824
825 #[test]
826 fn single_char_flags_emit_short_form() {
827 let mut args = ToolArgs::new();
828 args.flags.insert("n".into());
829 args.flags.insert("verbose".into());
830 // Sorted: "n" then "verbose"
831 assert_eq!(args.to_argv().unwrap(), vec!["-n", "--verbose"]);
832 }
833
834 #[test]
835 fn named_values_use_equals_form() {
836 let mut args = ToolArgs::new();
837 args.named.insert("count".into(), Value::Int(5));
838 args.named.insert("name".into(), Value::String("foo".into()));
839 // BTreeMap iterates in key order, so "count" before "name"
840 assert_eq!(args.to_argv().unwrap(), vec!["--count=5", "--name=foo"]);
841 }
842
843 #[test]
844 fn single_char_named_emits_short_equals() {
845 let mut args = ToolArgs::new();
846 args.named.insert("n".into(), Value::Int(5));
847 assert_eq!(args.to_argv().unwrap(), vec!["-n=5"]);
848 }
849
850 #[test]
851 fn positional_with_leading_dash_survives_double_dash() {
852 let mut args = ToolArgs::new();
853 args.positional.push(Value::String("-n".into()));
854 // `echo -- -n` should round-trip as `-- -n`, not be reparsed as a flag.
855 assert_eq!(args.to_argv().unwrap(), vec!["--", "-n"]);
856 }
857
858 #[test]
859 fn mixed_flags_named_positionals() {
860 let mut args = ToolArgs::new();
861 args.flags.insert("verbose".into());
862 args.named.insert("limit".into(), Value::Int(10));
863 args.positional.push(Value::String("file.txt".into()));
864 assert_eq!(
865 args.to_argv().unwrap(),
866 vec!["--verbose", "--limit=10", "--", "file.txt"]
867 );
868 }
869
870 /// GH #164: a named/flag `Value::Bytes` must error loudly instead of
871 /// silently stringifying to the `[binary: N bytes]` placeholder — that
872 /// placeholder is exactly what a downstream clap-parsed field
873 /// (`parsed.separator`, `parsed.algo`, …) would otherwise see as if it
874 /// were the user's real value (GH #120's root cause).
875 #[test]
876 fn named_bytes_value_errors_loudly() {
877 let mut args = ToolArgs::new();
878 args.named.insert("separator".into(), Value::Bytes(vec![0xff, 0x00, 0xfe]));
879
880 let err = args.to_argv().expect_err("named Bytes must error");
881 // The message must name the key and the byte count.
882 let message = err.to_string();
883 assert!(message.contains("separator"));
884 assert!(message.contains('3'));
885 let ToolArgvError::BinaryNamedValue { key, byte_len } = err;
886 assert_eq!(key, "separator");
887 assert_eq!(byte_len, 3);
888 }
889
890 /// A single-char named key (e.g. `-a`) gets the same loud treatment.
891 #[test]
892 fn single_char_named_bytes_value_errors_loudly() {
893 let mut args = ToolArgs::new();
894 args.named.insert("a".into(), Value::Bytes(vec![1, 2]));
895
896 let err = args.to_argv().expect_err("named Bytes must error");
897 let ToolArgvError::BinaryNamedValue { key, byte_len } = err;
898 assert_eq!(key, "a");
899 assert_eq!(byte_len, 2);
900 }
901
902 /// Positional `Value::Bytes`, by contrast, does NOT error — see
903 /// `value_to_argv_token`'s doc comment. The clap-reflected positional
904 /// field is a validation-only sink; no builtin ever reads its *value* off
905 /// the parsed struct (they read the typed `Value` straight off
906 /// `args.positional`), so a placeholder token here is inert, not
907 /// corruption. This is the load-bearing decision behind builtins like
908 /// `push`/`write` accepting real binary content through positionals.
909 #[test]
910 fn positional_bytes_value_renders_placeholder_not_error() {
911 let mut args = ToolArgs::new();
912 args.positional.push(Value::Bytes(vec![0xff, 0x00, 0xfe]));
913
914 let argv = args.to_argv().expect("positional Bytes must not error");
915 assert_eq!(argv, vec!["--", "[binary: 3 bytes]"]);
916 }
917
918 /// Mixed case: a named Bytes error takes priority even when a positional
919 /// Bytes value is also present (the loud path must not be starved by
920 /// iteration order silently succeeding on the positional half first).
921 #[test]
922 fn named_bytes_errors_even_with_positional_bytes_present() {
923 let mut args = ToolArgs::new();
924 args.named.insert("check".into(), Value::Bytes(vec![9, 9]));
925 args.positional.push(Value::Bytes(vec![1, 2, 3]));
926
927 let err = args.to_argv().expect_err("named Bytes must still error");
928 let ToolArgvError::BinaryNamedValue { key, .. } = err;
929 assert_eq!(key, "check");
930 }
931
932 // ── GH #218: ToolArgs::to_argv_excluding ────────────────────────────
933 //
934 // write.rs reads its own `content` named param raw off `ToolArgs` to
935 // preserve `Value::Bytes`, then needs the *rest* of its args through the
936 // normal clap/to_argv path — these tests pin the helper that replaces the
937 // ad hoc "clone ToolArgs, remove the key, call to_argv()" dance.
938
939 /// A named `Value::Bytes` under an excluded key must not error and must
940 /// not appear anywhere in the rendered argv — this is the whole point:
941 /// `write`'s `content` carries real binary and must never reach argv.
942 #[test]
943 fn to_argv_excluding_skips_excluded_named_bytes_without_error() {
944 let mut args = ToolArgs::new();
945 args.named.insert("content".into(), Value::Bytes(vec![0xff, 0x00, 0xfe]));
946 args.named.insert("path".into(), Value::String("dest.bin".into()));
947
948 let argv = args
949 .to_argv_excluding(&["content"])
950 .expect("excluded named Bytes must not error");
951 assert_eq!(argv, vec!["--path=dest.bin"]);
952 assert!(
953 argv.iter().all(|tok| !tok.contains("content")),
954 "excluded key must not appear in argv at all: {argv:?}"
955 );
956 }
957
958 /// A named `Value::Bytes` under a key that is NOT excluded still errors
959 /// loudly, same as plain `to_argv()` — excluding one key must not blanket
960 /// the whole named map.
961 #[test]
962 fn to_argv_excluding_still_errors_on_non_excluded_named_bytes() {
963 let mut args = ToolArgs::new();
964 args.named.insert("content".into(), Value::Bytes(vec![1, 2, 3]));
965 args.named.insert("separator".into(), Value::Bytes(vec![9, 9]));
966
967 let err = args
968 .to_argv_excluding(&["content"])
969 .expect_err("non-excluded named Bytes must still error");
970 let ToolArgvError::BinaryNamedValue { key, .. } = err;
971 assert_eq!(key, "separator");
972 }
973
974 /// Excluding a key that isn't a `Value::Bytes` at all (the common case —
975 /// most invocations of `write` carry plain string content) still drops it
976 /// from argv. The exclusion is unconditional on the key, not conditional
977 /// on the value being binary.
978 #[test]
979 fn to_argv_excluding_drops_excluded_key_regardless_of_value_type() {
980 let mut args = ToolArgs::new();
981 args.named.insert("content".into(), Value::String("hello".into()));
982 args.named.insert("path".into(), Value::String("dest.txt".into()));
983
984 let argv = args.to_argv_excluding(&["content"]).expect("no error expected");
985 assert_eq!(argv, vec!["--path=dest.txt"]);
986 }
987
988 /// An empty exclude list must behave *exactly* like `to_argv()` — same
989 /// tokens, same order — across flags, named values, and positionals, on
990 /// args that don't touch the Bytes edge case at all. `to_argv()` itself
991 /// delegates to this with an empty slice, so this is also the guard that
992 /// the delegation didn't change plain `to_argv()` behavior.
993 #[test]
994 fn to_argv_excluding_empty_list_matches_to_argv() {
995 let mut args = ToolArgs::new();
996 args.flags.insert("verbose".into());
997 args.flags.insert("n".into());
998 args.named.insert("limit".into(), Value::Int(10));
999 args.named.insert("name".into(), Value::String("foo".into()));
1000 args.positional.push(Value::String("file.txt".into()));
1001 args.positional.push(Value::String("-weird".into()));
1002
1003 assert_eq!(
1004 args.to_argv_excluding(&[]).unwrap(),
1005 args.to_argv().unwrap(),
1006 "empty exclude list must be indistinguishable from to_argv()"
1007 );
1008 }
1009
1010 #[test]
1011 fn flagify_bool_named_promotes_true_to_flag() {
1012 let mut args = ToolArgs::new();
1013 args.named.insert("recursive".into(), Value::Bool(true));
1014 args.named.insert("limit".into(), Value::Int(5));
1015
1016 args.flagify_bool_named(&ToolSchema::new("t", ""));
1017
1018 assert!(args.flags.contains("recursive"));
1019 assert!(!args.named.contains_key("recursive"));
1020 // Non-bool entries are untouched.
1021 assert_eq!(args.named.get("limit"), Some(&Value::Int(5)));
1022 }
1023
1024 #[test]
1025 fn flagify_bool_named_drops_false() {
1026 let mut args = ToolArgs::new();
1027 args.named.insert("recursive".into(), Value::Bool(false));
1028
1029 args.flagify_bool_named(&ToolSchema::new("t", ""));
1030
1031 assert!(!args.flags.contains("recursive"));
1032 assert!(!args.named.contains_key("recursive"));
1033 }
1034
1035 #[test]
1036 fn flagify_bool_named_is_idempotent() {
1037 let mut args = ToolArgs::new();
1038 args.named.insert("recursive".into(), Value::Bool(true));
1039 args.flagify_bool_named(&ToolSchema::new("t", ""));
1040 args.flagify_bool_named(&ToolSchema::new("t", ""));
1041 assert!(args.flags.contains("recursive"));
1042 }
1043
1044 /// Regression guard: argv emitted after flagify must round-trip through
1045 /// a clap parser without `--K=true` showing up.
1046 #[test]
1047 fn flagify_bool_named_round_trips_through_to_argv() {
1048 let mut args = ToolArgs::new();
1049 args.named.insert("R".into(), Value::Bool(true));
1050 args.flagify_bool_named(&ToolSchema::new("t", ""));
1051 let argv = args.to_argv().unwrap();
1052 assert!(argv.contains(&"-R".to_string()), "expected -R, got {:?}", argv);
1053 assert!(!argv.iter().any(|s| s.contains('=')), "no =value should appear, got {:?}", argv);
1054 }
1055
1056 /// A `Bool(true)` parked under a schema-declared value-taking flag is the
1057 /// flag's literal value (`spawn --command true`), not a bare bool flag — it
1058 /// stays in `named` and renders as `--K=true`, not a value-less `--K`.
1059 #[test]
1060 fn flagify_bool_named_keeps_value_flag_value() {
1061 let mut schema = ToolSchema::new("spawn", "");
1062 schema.params.push(ParamSchema::new("command", "string"));
1063
1064 let mut args = ToolArgs::new();
1065 args.named.insert("command".into(), Value::Bool(true));
1066 args.flagify_bool_named(&schema);
1067
1068 assert!(!args.flags.contains("command"), "value flag must not collapse to a bare flag");
1069 assert_eq!(args.named.get("command"), Some(&Value::Bool(true)));
1070 let argv = args.to_argv().unwrap();
1071 assert!(
1072 argv.iter().any(|s| s == "--command=true"),
1073 "expected --command=true, got {:?}",
1074 argv
1075 );
1076 }
1077
1078 /// One schema carrying both a bool flag and a value-taking flag: the bool
1079 /// flag still flagifies, the value flag keeps its value. Proves
1080 /// `is_bool_param_type` actually distinguishes the two (an empty-schema test
1081 /// can't — it flagifies everything regardless).
1082 #[test]
1083 fn flagify_bool_named_distinguishes_bool_from_value_param() {
1084 let mut schema = ToolSchema::new("t", "");
1085 schema.params.push(ParamSchema::new("verbose", "bool"));
1086 schema.params.push(ParamSchema::new("command", "string"));
1087
1088 let mut args = ToolArgs::new();
1089 args.named.insert("verbose".into(), Value::Bool(true));
1090 args.named.insert("command".into(), Value::Bool(true));
1091 args.flagify_bool_named(&schema);
1092
1093 // Bool flag → promoted to a bare flag.
1094 assert!(args.flags.contains("verbose"));
1095 assert!(!args.named.contains_key("verbose"));
1096 // Value flag → value retained.
1097 assert!(!args.flags.contains("command"));
1098 assert_eq!(args.named.get("command"), Some(&Value::Bool(true)));
1099 }
1100}