Skip to main content

kaish_kernel/scheduler/
pipeline.rs

1//! Pipeline execution for kaish.
2//!
3//! Executes a sequence of commands connected by pipes, where the stdout
4//! of each command becomes the stdin of the next.
5//!
6//! Also handles scatter/gather pipelines for parallel execution.
7
8use std::sync::Arc;
9
10use std::collections::HashMap;
11
12use crate::arithmetic;
13use crate::ast::{Arg, Command, Expr, Redirect, RedirectKind, Value};
14use crate::dispatch::{CommandDispatcher, PipelinePosition};
15use crate::interpreter::{apply_output_format, ExecResult, OutputFormat, PathError};
16use crate::tools::{ExecContext, ToolArgs, ToolRegistry, ToolSchema};
17use tokio::io::AsyncWriteExt;
18
19use super::pipe_stream::pipe_stream_default;
20use super::scatter::{
21    parse_gather_options, parse_scatter_options, ScatterGatherRunner,
22};
23
24/// Whether `--json` appears literally among a command's raw AST args.
25///
26/// Used only for `run_scatter_gather`'s own option-parsing error path (GH
27/// #222): scatter/gather's arguments are pulled out and parsed directly by
28/// the pipeline runner rather than via `Tool::execute()`, so a parse failure
29/// can happen before there's ever a `ToolArgs` to read a `--json` flag off of
30/// through the usual `GlobalFlags::apply_from_args` route every other
31/// builtin's dispatch takes. `--json` is always a bare boolean flag (never
32/// `--json=value`), so it always lexes to `Arg::LongFlag`.
33fn has_json_flag(args: &[Arg]) -> bool {
34    args.iter().any(|a| matches!(a, Arg::LongFlag(name) if name == "json"))
35}
36
37/// Apply `--json` to a `run_scatter_gather` early-return error.
38///
39/// Mirrors the kernel's `finalize_output` seam (kernel.rs::execute_command)
40/// for the one path that bypasses it entirely: scatter/gather's own
41/// option-parsing errors return straight out of `run_scatter_gather` before
42/// either tool's `Tool::execute()` — and thus `finalize_output` — ever runs
43/// (GH #222). Every early return in `run_scatter_gather` funnels through this
44/// one function, so it is the single place the format gets applied — not
45/// three separate copies threaded through each `return` site.
46fn finalize_scatter_gather_error(result: ExecResult, format: Option<OutputFormat>) -> ExecResult {
47    match format {
48        Some(format) => apply_output_format(result, format),
49        None => result,
50    }
51}
52
53/// Apply redirects to an execution result.
54///
55/// Pre-execution redirects (Stdin, HereDoc) should be handled before calling.
56/// Post-execution redirects (stdout/stderr to file, merge) applied here.
57/// Redirects are processed left-to-right per POSIX.
58pub(crate) async fn apply_redirects(
59    mut result: ExecResult,
60    redirects: &[Redirect],
61    ctx: &ExecContext,
62    dispatcher: &dyn CommandDispatcher,
63) -> ExecResult {
64    // Defer materialization of OutputData → result.out to individual redirect
65    // handlers. File redirects (Overwrite/Append) can stream OutputData directly
66    // to disk via write_canonical(), avoiding OOM on large structured output.
67    // Merge redirects and the fallthrough path materialize on demand.
68    for redir in redirects {
69        match redir.kind {
70            RedirectKind::MergeStderr => {
71                // 2>&1 - append stderr to stdout
72                // Ensure output is materialized for merge
73                result.materialize();
74                if !result.err.is_empty() {
75                    let err = std::mem::take(&mut result.err);
76                    result.push_out(&err);
77                }
78            }
79            RedirectKind::MergeStdout => {
80                // 1>&2 or >&2 - append stdout to stderr (a text stream).
81                // Binary stdout can't be folded into text stderr without
82                // corruption — fail loud instead.
83                if result.is_bytes() {
84                    return ExecResult::failure(
85                        1,
86                        "redirect: cannot merge binary stdout into stderr (1>&2) — \
87                         redirect it to a file or pipe through base64/xxd",
88                    );
89                }
90                result.materialize();
91                if !result.text_out().is_empty() {
92                    let out = result.text_out().into_owned();
93                    result.err.push_str(&out);
94                }
95                // `1>&2` is still a stdout redirect: stdout went to stderr, so
96                // drop out/output AND the .data sideband (same as a file
97                // redirect), or a structured result leaks past `x=$(cmd >&2)`
98                // and `cmd >&2 | consumer`. Unconditional so a .data-only,
99                // empty-.out result is cleared too.
100                result.clear_stdout();
101            }
102            RedirectKind::StdoutOverwrite => {
103                let path = match eval_redirect_target(&redir.target, ctx, dispatcher).await {
104                    Ok(p) => p,
105                    Err(e) => return ExecResult::failure(1, format!("redirect: {e}")),
106                };
107                // A binary result writes its raw bytes (no lossy decode).
108                if let Some(bytes) = result.out_bytes() {
109                    if let Err(e) = redirect_write(ctx, &path, bytes).await {
110                        return ExecResult::failure(1, format!("redirect: {e}"));
111                    }
112                } else if let Some(output) = result.take_output_for_stream() {
113                    // Stream OutputData directly to file if available
114                    let mut buf = Vec::new();
115                    if let Err(e) = output.write_canonical(&mut buf, None) {
116                        return ExecResult::failure(1, format!("redirect: {e}"));
117                    }
118                    if let Err(e) = redirect_write(ctx, &path, &buf).await {
119                        return ExecResult::failure(1, format!("redirect: {e}"));
120                    }
121                } else if let Err(e) = redirect_write(ctx, &path, result.text_out().as_bytes()).await {
122                    return ExecResult::failure(1, format!("redirect: {e}"));
123                }
124                // stdout went to the file: drop out/output AND the .data sideband.
125                result.clear_stdout();
126            }
127            RedirectKind::StdoutAppend => {
128                let path = match eval_redirect_target(&redir.target, ctx, dispatcher).await {
129                    Ok(p) => p,
130                    Err(e) => return ExecResult::failure(1, format!("redirect: {e}")),
131                };
132                // A binary result appends its raw bytes (no lossy decode).
133                if let Some(bytes) = result.out_bytes() {
134                    if let Err(e) = redirect_append(ctx, &path, bytes).await {
135                        return ExecResult::failure(1, format!("redirect: {e}"));
136                    }
137                } else if let Some(output) = result.take_output_for_stream() {
138                    // Stream OutputData directly if available
139                    let mut buf = Vec::new();
140                    if let Err(e) = output.write_canonical(&mut buf, None) {
141                        return ExecResult::failure(1, format!("redirect: {e}"));
142                    }
143                    if let Err(e) = redirect_append(ctx, &path, &buf).await {
144                        return ExecResult::failure(1, format!("redirect: {e}"));
145                    }
146                } else if let Err(e) = redirect_append(ctx, &path, result.text_out().as_bytes()).await {
147                    return ExecResult::failure(1, format!("redirect: {e}"));
148                }
149                // stdout went to the file: drop out/output AND the .data sideband.
150                result.clear_stdout();
151            }
152            RedirectKind::Stderr => {
153                let path = match eval_redirect_target(&redir.target, ctx, dispatcher).await {
154                    Ok(p) => p,
155                    Err(e) => return ExecResult::failure(1, format!("redirect: {e}")),
156                };
157                if let Err(e) = redirect_write(ctx, &path, result.err.as_bytes()).await {
158                    return ExecResult::failure(1, format!("redirect: {e}"));
159                }
160                result.err.clear();
161            }
162            RedirectKind::Both => {
163                let path = match eval_redirect_target(&redir.target, ctx, dispatcher).await {
164                    Ok(p) => p,
165                    Err(e) => return ExecResult::failure(1, format!("redirect: {e}")),
166                };
167                // Build the combined bytes: raw binary stdout (no lossy decode),
168                // or structured output streamed straight to a byte buffer via
169                // `take_output_for_stream`/`write_canonical` — same lazy path
170                // `>`/`>>` use above — instead of forcing it through one
171                // `String` first (`text_out()`'s canonical-string fallback).
172                // Falls back to the text form only when neither applies.
173                // Followed by stderr.
174                let mut combined: Vec<u8> = if let Some(b) = result.out_bytes() {
175                    b.to_vec()
176                } else if let Some(output) = result.take_output_for_stream() {
177                    let mut buf = Vec::new();
178                    if let Err(e) = output.write_canonical(&mut buf, None) {
179                        return ExecResult::failure(1, format!("redirect: {e}"));
180                    }
181                    buf
182                } else {
183                    result.text_out().into_owned().into_bytes()
184                };
185                combined.extend_from_slice(result.err.as_bytes());
186                if let Err(e) = redirect_write(ctx, &path, &combined).await {
187                    return ExecResult::failure(1, format!("redirect: {e}"));
188                }
189                // both streams went to the file: drop stdout (incl. .data) + stderr.
190                result.clear_stdout();
191                result.err.clear();
192            }
193            // Pre-execution redirects - already handled before command execution
194            RedirectKind::Stdin | RedirectKind::HereDoc | RedirectKind::HereString => {}
195        }
196    }
197    // Materialize any remaining OutputData into result.out.
198    // Callers (accumulate_result, pipeline piping) expect .out to be populated
199    // after apply_redirects returns. File redirects above consume .output directly
200    // via streaming; this only fires when no redirect consumed it.
201    result.materialize();
202    result
203}
204
205/// Evaluate a redirect target expression to get the file path (or heredoc body).
206///
207/// Takes the `dispatcher` explicitly rather than reading `ctx.dispatcher`, so
208/// command substitution (`$(...)`) in the target runs on the *same* dispatcher
209/// the runner already uses — `cat < $(echo f)`, `echo x > $(echo f)`, and
210/// `$(...)` inside a heredoc body. `ctx.dispatcher` is only populated when the
211/// kernel is Arc-attached (`into_arc`); a bare `Kernel::execute` — every test,
212/// and any embedder holding a `Kernel` by value — left it `None`, so a `$()`
213/// target silently fell back to the sync evaluator that can't run it (GH #90).
214/// The runner always holds a real dispatcher; thread it through so the behavior
215/// no longer depends on how the kernel was constructed.
216async fn eval_redirect_target(
217    expr: &Expr,
218    ctx: &ExecContext,
219    dispatcher: &dyn CommandDispatcher,
220) -> Result<String, String> {
221    let value = dispatcher
222        .eval_expr(expr, ctx)
223        .await
224        .map_err(|e| e.to_string())?;
225    // Decision D: a bare collection can't be a redirect target either — same
226    // process-boundary guard as external argv (see `structured_boundary_error`).
227    if let Some(msg) = crate::interpreter::structured_boundary_error("a redirect target", &value) {
228        return Err(msg);
229    }
230    // Text sink: binary goes loud rather than becoming a file literally named
231    // `[binary: N bytes]` — the same guard external argv and env export use.
232    crate::interpreter::value_to_text_sink_named(&value, "a redirect target").map_err(|e| e.to_string())
233}
234
235/// Write data to a file via the VFS backend.
236///
237/// The redirect target is resolved against `ctx.cwd` (like every other path
238/// operand — see `cat`/`cp`/etc.), so a relative `> f` write and a later
239/// relative read agree on the same `$PWD/f`. Without this the router would
240/// normalize a bare relative path to `/f`, diverging from cwd-resolved reads.
241async fn redirect_write(ctx: &ExecContext, path: &str, data: &[u8]) -> Result<(), String> {
242    use crate::backend::WriteMode;
243    let resolved = ctx.resolve_path(path);
244    ctx.backend.write(&resolved, data, WriteMode::Overwrite).await.map_err(|e| e.to_string())
245}
246
247/// Append data to a file via the VFS backend.
248///
249/// Resolves the target against `ctx.cwd` for the same reason as `redirect_write`.
250async fn redirect_append(ctx: &ExecContext, path: &str, data: &[u8]) -> Result<(), String> {
251    let resolved = ctx.resolve_path(path);
252    ctx.backend.append(&resolved, data).await.map_err(|e| e.to_string())
253}
254
255/// Set up stdin from redirects (< file, <<heredoc).
256/// Called before command execution.
257///
258/// `< file` reads through the VFS backend (not the host filesystem) with the
259/// target resolved against `ctx.cwd`, mirroring how `cat` and the output
260/// redirects resolve their operands. A missing/unreadable file is a hard
261/// error — we never silently feed the command empty stdin. Non-UTF-8 content
262/// is NOT rejected here (GH #176): `ctx.stdin` is bytes-typed, so the raw
263/// bytes flow through to whatever the command actually does with them — a
264/// byte-aware builtin (`wc -c`, `cat`, `cmp`, …) consumes them intact, and a
265/// text-only builtin refuses loudly at the point it asks for text
266/// (`read_stdin_to_text`), not before the command even runs.
267async fn setup_stdin_redirects(
268    cmd: &Command,
269    ctx: &mut ExecContext,
270    dispatcher: &dyn CommandDispatcher,
271) -> Result<(), String> {
272    use std::path::Path;
273    for redir in &cmd.redirects {
274        match &redir.kind {
275            RedirectKind::Stdin => {
276                let path = eval_redirect_target(&redir.target, ctx, dispatcher).await?;
277                let resolved = ctx.resolve_path(&path);
278                let data = ctx
279                    .backend
280                    .read(Path::new(&resolved), None)
281                    .await
282                    .map_err(|e| format!("redirect: {path}: {e}"))?;
283                ctx.set_stdin(data);
284            }
285            RedirectKind::HereDoc => {
286                match &redir.target {
287                    Expr::Literal(Value::String(content)) => {
288                        ctx.set_stdin(content.clone());
289                    }
290                    // Heredoc bodies may contain `$(...)`; route through the
291                    // dispatcher so command substitution runs.
292                    expr => {
293                        let body = eval_redirect_target(expr, ctx, dispatcher).await?;
294                        ctx.set_stdin(body);
295                    }
296                }
297            }
298            RedirectKind::HereString => {
299                // Per bash, here-strings append a trailing newline to the
300                // expanded word so the command receives a terminated line.
301                let mut s = eval_redirect_target(&redir.target, ctx, dispatcher).await?;
302                s.push('\n');
303                ctx.set_stdin(s);
304            }
305            _ => {}
306        }
307    }
308    Ok(())
309}
310
311/// Runs pipelines by spawning tasks and connecting them via channels.
312#[derive(Clone)]
313pub struct PipelineRunner {
314    tools: Arc<ToolRegistry>,
315}
316
317impl PipelineRunner {
318    /// Create a new pipeline runner with the given tool registry.
319    pub fn new(tools: Arc<ToolRegistry>) -> Self {
320        Self { tools }
321    }
322
323    /// Execute a pipeline of commands.
324    ///
325    /// Each command's stdout becomes the next command's stdin.
326    /// If the pipeline contains scatter/gather, delegates to ScatterGatherRunner.
327    /// Returns the result of the last command in the pipeline.
328    ///
329    /// The `dispatcher` handles the full command resolution chain (user tools,
330    /// builtins, scripts, external commands, backend tools). The runner handles
331    /// I/O routing: stdin redirects, piping between commands, and output redirects.
332    pub async fn run(
333        &self,
334        commands: &[Command],
335        ctx: &mut ExecContext,
336        dispatcher: &dyn CommandDispatcher,
337    ) -> ExecResult {
338        if commands.is_empty() {
339            return ExecResult::success("");
340        }
341
342        // Check for scatter/gather pipeline
343        if let Some((scatter_idx, gather_idx)) = find_scatter_gather(commands) {
344            return self.run_scatter_gather(commands, scatter_idx, gather_idx, ctx, dispatcher).await;
345        }
346
347        self.run_sequential(commands, ctx, dispatcher).await
348    }
349
350    /// Execute commands sequentially without scatter/gather detection.
351    ///
352    /// Used by `ScatterGatherRunner` for pre_scatter, post_gather, and parallel
353    /// workers. Breaks the async recursion chain (`run` → scatter → `run`).
354    pub async fn run_sequential(
355        &self,
356        commands: &[Command],
357        ctx: &mut ExecContext,
358        dispatcher: &dyn CommandDispatcher,
359    ) -> ExecResult {
360        if commands.is_empty() {
361            return ExecResult::success("");
362        }
363
364        if commands.len() == 1 {
365            // Single command, no piping needed
366            return self.run_single(&commands[0], ctx, None, dispatcher).await;
367        }
368
369        // Multi-command pipeline
370        self.run_pipeline(commands, ctx, dispatcher).await
371    }
372
373    /// Run a scatter/gather pipeline.
374    async fn run_scatter_gather(
375        &self,
376        commands: &[Command],
377        scatter_idx: usize,
378        gather_idx: usize,
379        ctx: &mut ExecContext,
380        dispatcher: &dyn CommandDispatcher,
381    ) -> ExecResult {
382        // Split pipeline into parts
383        let pre_scatter = &commands[..scatter_idx];
384        let scatter_cmd = &commands[scatter_idx];
385        let parallel = &commands[scatter_idx + 1..gather_idx];
386        let gather_cmd = &commands[gather_idx];
387        let post_gather = &commands[gather_idx + 1..];
388
389        // scatter/gather's own option-parsing below returns `ExecResult`s
390        // directly, bypassing `Tool::execute()` and thus the normal
391        // per-command `finalize_output` seam (kernel.rs::execute_command)
392        // that every other builtin's `--json` goes through. Detect `--json`
393        // up front from the raw AST (not a built `ToolArgs`): the very first
394        // fallible step below (`build_tool_args`) can itself fail, in which
395        // case there is no `ToolArgs` yet to read a flag off of via the usual
396        // `GlobalFlags::apply_from_args` route. Every early return in this
397        // function funnels through `finalize_scatter_gather_error` below so
398        // this is the ONE place the format gets applied (GH #222).
399        let format = (has_json_flag(&scatter_cmd.args) || has_json_flag(&gather_cmd.args))
400            .then_some(OutputFormat::Json);
401
402        // Parse options from scatter and gather commands
403        // These are builtins with simple key=value syntax, no schema-driven parsing needed.
404        // build_tool_args is fallible: a bad/subscripted collection access in a
405        // scatter/gather flag value (`scatter --as ${u[nope]}`) must fail loud here,
406        // not silently coalesce to a dropped flag (the now-closed reduced-sync-path
407        // swallow; its arithmetic half was GH #183). Mirrors run_single's
408        // dispatch-error handling.
409        let scatter_schema = self.tools.get("scatter").map(|t| t.schema());
410        let gather_schema = self.tools.get("gather").map(|t| t.schema());
411        let scatter_args = match build_tool_args(&scatter_cmd.args, ctx, scatter_schema.as_ref()).await {
412            Ok(args) => args,
413            Err(e) => {
414                return finalize_scatter_gather_error(
415                    ExecResult::failure(1, format!("scatter: {e}")),
416                    format,
417                )
418            }
419        };
420        let gather_args = match build_tool_args(&gather_cmd.args, ctx, gather_schema.as_ref()).await {
421            Ok(args) => args,
422            Err(e) => {
423                return finalize_scatter_gather_error(
424                    ExecResult::failure(1, format!("gather: {e}")),
425                    format,
426                )
427            }
428        };
429        let scatter_opts = match parse_scatter_options(&scatter_args) {
430            Ok(opts) => opts,
431            Err(e) => {
432                return finalize_scatter_gather_error(
433                    ExecResult::failure(2, format!("scatter: {e}")),
434                    format,
435                )
436            }
437        };
438        let gather_opts = match parse_gather_options(&gather_args) {
439            Ok(opts) => opts,
440            Err(e) => {
441                return finalize_scatter_gather_error(
442                    ExecResult::failure(2, format!("gather: {e}")),
443                    format,
444                )
445            }
446        };
447
448        // We need an `Arc<dyn CommandDispatcher>` to hand to `ScatterGatherRunner`.
449        // `fork_attached` produces a subkernel whose cancellation token is a
450        // child of the parent's, so a parent timeout/cancel cascades into
451        // the scatter pipeline (and into worker children via further forks).
452        let sequential_dispatcher: Arc<dyn CommandDispatcher> = dispatcher.fork_attached().await;
453
454        let runner = ScatterGatherRunner::new(self.tools.clone(), sequential_dispatcher);
455        runner
456            .run(
457                pre_scatter,
458                scatter_opts,
459                parallel,
460                gather_opts,
461                &gather_cmd.redirects,
462                post_gather,
463                ctx,
464            )
465            .await
466    }
467
468    /// Run a single command with optional stdin.
469    ///
470    /// The dispatcher handles arg parsing, schema lookup, output format, and execution.
471    /// The runner handles stdin setup (redirects + pipeline) and output redirects.
472    async fn run_single(
473        &self,
474        cmd: &Command,
475        ctx: &mut ExecContext,
476        stdin: Option<Vec<u8>>,
477        dispatcher: &dyn CommandDispatcher,
478    ) -> ExecResult {
479        // Set up stdin from redirects (< file, <<heredoc)
480        if let Err(e) = setup_stdin_redirects(cmd, ctx, dispatcher).await {
481            return ExecResult::failure(1, e);
482        }
483
484        // Set stdin from pipeline (overrides redirect stdin)
485        if let Some(input) = stdin {
486            ctx.set_stdin(input);
487        }
488
489        // Set pipeline position for stdio inheritance decisions
490        ctx.pipeline_position = PipelinePosition::Only;
491
492        // Execute via dispatcher (full resolution chain)
493        let result = match dispatcher.dispatch(cmd, ctx).await {
494            Ok(result) => result,
495            Err(e) => ExecResult::failure(1, e.to_string()),
496        };
497
498        // Apply post-execution redirects
499        apply_redirects(result, &cmd.redirects, ctx, dispatcher).await
500    }
501
502    /// Run a multi-command pipeline concurrently.
503    ///
504    /// Each stage runs in its own tokio task, connected by bounded pipe streams
505    /// (64KB ring buffers with backpressure). This provides:
506    /// - Bounded memory usage (no buffering entire outputs)
507    /// - Backpressure (fast producers wait for slow consumers)
508    /// - Early termination (e.g., `seq 1 1000000 | head -n 5`)
509    ///
510    /// Structured data (`stdin_data`) is passed via oneshot channels alongside pipes.
511    async fn run_pipeline(
512        &self,
513        commands: &[Command],
514        ctx: &mut ExecContext,
515        dispatcher: &dyn CommandDispatcher,
516    ) -> ExecResult {
517        let stage_count = commands.len();
518        let last_idx = stage_count - 1;
519
520        // Create N-1 pipe pairs connecting adjacent stages
521        let mut pipe_writers: Vec<Option<super::pipe_stream::PipeWriter>> = Vec::new();
522        let mut pipe_readers: Vec<Option<super::pipe_stream::PipeReader>> = Vec::new();
523
524        for _ in 0..last_idx {
525            let (writer, reader) = pipe_stream_default();
526            pipe_writers.push(Some(writer));
527            pipe_readers.push(Some(reader));
528        }
529
530        // Create N-1 oneshot channels for structured data sideband
531        let mut data_senders: Vec<Option<tokio::sync::oneshot::Sender<Option<Value>>>> = Vec::new();
532        let mut data_receivers: Vec<Option<tokio::sync::oneshot::Receiver<Option<Value>>>> = Vec::new();
533
534        for _ in 0..last_idx {
535            let (tx, rx) = tokio::sync::oneshot::channel();
536            data_senders.push(Some(tx));
537            data_receivers.push(Some(rx));
538        }
539
540        let mut handles: Vec<tokio::task::JoinHandle<(ExecResult, ExecContext)>> = Vec::with_capacity(stage_count);
541
542        for (i, cmd) in commands.iter().enumerate() {
543            let mut stage_ctx = ctx.child_for_pipeline();
544            let cmd = cmd.clone();
545
546            // Fork attached: each concurrent pipeline stage needs independent
547            // mutable state, but cancellation should still cascade from the
548            // parent (so a request timeout kills externals running in any
549            // stage, not just the foreground one).
550            let task_dispatcher: Arc<dyn CommandDispatcher> = dispatcher.fork_attached().await;
551
552            // Set up stdin from redirects on the child context. A failure here
553            // (e.g. `cmd < missing`) fails this stage; surface it from inside
554            // the spawned task so the normal join/collection path reports it.
555            let stdin_setup = setup_stdin_redirects(&cmd, &mut stage_ctx, dispatcher).await;
556
557            // Wire pipe_stdin: stage 0 gets parent stdin (if no redirect), others get pipe reader
558            if i == 0 {
559                // First stage inherits the parent's stdin, but only if redirects didn't
560                // already set stdin (e.g., heredoc). Don't overwrite redirect-provided stdin.
561                if stage_ctx.stdin.is_none() {
562                    stage_ctx.stdin = ctx.stdin.take();
563                }
564                if stage_ctx.stdin_data.is_none() {
565                    stage_ctx.stdin_data = ctx.stdin_data.take();
566                }
567                // Inherit a frontend-seeded lazy stdin pipe (non-Clone, so moved),
568                // unless a redirect already provided stdin — `read_stdin_*` prefers
569                // `pipe_stdin`, and `set_stdin` clears it, so `< file` still wins.
570                if stage_ctx.stdin.is_none() && stage_ctx.pipe_stdin.is_none() {
571                    stage_ctx.pipe_stdin = ctx.pipe_stdin.take();
572                }
573            } else {
574                // Intermediate/last stages read from pipe
575                stage_ctx.pipe_stdin = pipe_readers[i - 1].take();
576                // Structured data received via oneshot (resolved at start of execution)
577            }
578
579            // Wire pipe_stdout: last stage writes to ExecResult, others write to pipe
580            if i < last_idx {
581                stage_ctx.pipe_stdout = pipe_writers[i].take();
582            }
583
584            // Set pipeline position
585            stage_ctx.pipeline_position = match i {
586                0 => PipelinePosition::First,
587                n if n == last_idx => PipelinePosition::Last,
588                _ => PipelinePosition::Middle,
589            };
590
591            let data_sender = if i < last_idx { data_senders[i].take() } else { None };
592            let data_receiver = if i > 0 { data_receivers[i - 1].take() } else { None };
593
594            // Propagate the embedder's trace context across the spawn boundary
595            // so each concurrent stage's spans stay in the same trace.
596            let handle: tokio::task::JoinHandle<(ExecResult, ExecContext)> =
597                tokio::spawn(crate::telemetry::bind_current_context(async move {
598                // A stdin-redirect setup failure short-circuits this stage.
599                if let Err(e) = stdin_setup {
600                    return (ExecResult::failure(1, e), stage_ctx);
601                }
602
603                // Hand the structured-data sideband receiver to the stage; do
604                // NOT pre-read it. A consuming builtin resolves it via
605                // `ctx.resolve_stdin()`, which drains the pipe first (so a
606                // streaming upstream can't deadlock) and only then awaits this —
607                // by which point the producer has sent it. The old `try_recv`
608                // here raced the producer's post-dispatch send and silently
609                // dropped structured data (`seq 1 3 | jq .` → text → parse error).
610                stage_ctx.stdin_data_rx = data_receiver;
611
612                // Execute the command
613                let mut result = match task_dispatcher.dispatch(&cmd, &mut stage_ctx).await {
614                    Ok(result) => result,
615                    Err(e) => ExecResult::failure(1, e.to_string()),
616                };
617
618                // Apply post-execution redirects. Use the stage's own
619                // (forked) dispatcher — the borrowed `dispatcher` can't cross
620                // the spawn boundary, and `stage_ctx.dispatcher` is `None` on a
621                // bare kernel, which is exactly the GH #90 gap.
622                result = apply_redirects(result, &cmd.redirects, &stage_ctx, &*task_dispatcher).await;
623
624                // Flush buffered stderr to the kernel's stderr stream.
625                // This delivers error output from intermediate pipeline stages
626                // in real-time (via the kernel drain) instead of silently discarding it.
627                // Redirects like 2>&1 have already cleared result.err, so merged
628                // stderr goes through the pipe as expected.
629                if !result.err.is_empty() {
630                    if let Some(ref stderr) = stage_ctx.stderr {
631                        stderr.write_str(&result.err);
632                        result.err.clear();
633                    }
634                }
635
636                // Send structured data to the next stage via the oneshot BEFORE
637                // the pipe write. The consumer's `resolve_stdin` drains the pipe
638                // FIRST and only THEN awaits this oneshot, so by the time it
639                // reads the sideband the value is already here — sending before
640                // the (possibly backpressured) pipe write keeps that ordering.
641                if let Some(tx) = data_sender {
642                    let _ = tx.send(result.data.clone());
643                }
644
645                // Write output to pipe for next stage (if not last).
646                // Consumer is now unblocked and can drain concurrently.
647                if let Some(mut pipe_out) = stage_ctx.pipe_stdout.take() {
648                    // A binary result flows through the pipe as raw bytes;
649                    // structured output serializes straight to a byte buffer
650                    // (`write_canonical`) rather than building the full
651                    // canonical `String` first — same lazy path the `>`/`>>`
652                    // file redirects use via `take_output_for_stream`. Either
653                    // way the next stage gets exactly what was produced — no
654                    // lossy round-trip.
655                    let bytes: Vec<u8> = if let Some(b) = result.out_bytes() {
656                        b.to_vec()
657                    } else if let Some(output) = result.take_output_for_stream() {
658                        let mut buf = Vec::new();
659                        // `Vec<u8>`'s `Write` impl is infallible; a serialize
660                        // error here would only come from a future non-memory
661                        // writer, so fall back to the same lossy text form the
662                        // non-streaming branch already uses rather than
663                        // dropping the stage's output outright.
664                        if output.write_canonical(&mut buf, None).is_err() {
665                            buf = output.to_canonical_string().into_bytes();
666                        }
667                        buf
668                    } else {
669                        result.text_out().into_owned().into_bytes()
670                    };
671                    if !bytes.is_empty() {
672                        // Write result to pipe; ignore broken pipe (reader dropped early)
673                        let _ = pipe_out.write_all(&bytes).await;
674                        let _ = pipe_out.shutdown().await;
675                    }
676                    // Drop pipe_out signals EOF to next stage's reader
677                }
678
679                (result, stage_ctx)
680            }));
681
682            handles.push(handle);
683        }
684
685        // Await all stages and return last stage's result.
686        // Sync the last stage's scope back to the parent context so that
687        // variable assignments in the last pipeline stage are visible
688        // (e.g., `echo "Alice" | read NAME`).
689        let mut last_result = ExecResult::success("");
690        let mut panics: Vec<String> = Vec::new();
691
692        for (i, handle) in handles.into_iter().enumerate() {
693            match handle.await {
694                Ok((result, stage_ctx)) => {
695                    if i == last_idx {
696                        last_result = result;
697                        // Sync last stage's scope and cwd changes back
698                        ctx.scope = stage_ctx.scope;
699                        ctx.cwd = stage_ctx.cwd;
700                        ctx.prev_cwd = stage_ctx.prev_cwd;
701                        ctx.aliases = stage_ctx.aliases;
702                    }
703                }
704                Err(e) => {
705                    panics.push(format!("stage {}: {}", i, e));
706                }
707            }
708        }
709
710
711        // ANY stage panicking overrides `last_result`, regardless of which
712        // stage.
713        if !panics.is_empty() {
714            last_result = ExecResult::failure(
715                1,
716                format!("pipeline stage(s) panicked: {}", panics.join("; ")),
717            );
718        }
719
720        last_result
721    }
722}
723
724/// Extract parameter types from a tool schema.
725///
726/// Returns a map from param name → param type (e.g., "verbose" → "bool", "output" → "string").
727/// Build a map from flag name → (canonical param name, param type).
728///
729/// Includes both primary names and aliases (with dashes stripped).
730/// For short flags like `-n` aliased to `lines`, maps `"n"` → `("lines", "int", 1)`.
731/// The third tuple slot is `consumes`: how many positionals the flag pulls
732/// per occurrence (1 for standard `--flag value`, 2 for jq's `--arg NAME VAL`).
733///
734/// Positional params (`positional: true`) are excluded — they're not flags,
735/// and including them would mis-route `cat --paths foo.txt` from positional
736/// to named, regressing builtins that read from `args.positional`.
737/// Walk leading positionals to select the active subcommand leaf of a schema.
738///
739/// A flat tool (`schema.subcommands` empty) returns the root immediately —
740/// today's single-leaf behavior. For a subcommand-aware tool each leading
741/// positional, in order, must name a child (by `name` or a command-level
742/// alias) to descend; the first positional that names no child is the leaf's
743/// own argument, and selection stops there. Multi-level trees fall out by
744/// construction (`block edit insert` → two descents).
745///
746/// Routing is **literal-only**: a subcommand selector must be a bareword or
747/// quoted string (both parse to `Expr::Literal(Value::String)`). A *computed*
748/// positional (`$(…)`, `$VAR`, a glob) sitting where a subcommand is required
749/// is an **error**, not a silent guess — kaish can't see its value at parse
750/// time, so picking a leaf from it would misroute the flags that bind against
751/// the leaf's params. The fix is to spell the subcommand out, or use the
752/// `--flag=value` form (which binds without any schema lookup).
753///
754/// Returned leaf borrows from `schema`, so its `params`/`subcommands` outlive
755/// any `schema_param_lookup` taken from it.
756///
757/// **Global value flags.** A space-form value flag declared on the *root*
758/// (e.g. kj's global `--confirm <token>`) can legitimately precede the
759/// subcommand path. Its value is a positional in the AST, so routing must not
760/// mistake it for a subcommand selector — `select_leaf` skips the value of any
761/// root-declared non-bool flag it sees. Leaf-specific value flags can't precede
762/// their own subcommand by construction, so only the root's flags need this.
763pub fn select_leaf<'a>(schema: &'a ToolSchema, args: &[Arg]) -> anyhow::Result<&'a ToolSchema> {
764    // Names + aliases of root-declared value (non-bool, non-positional) flags,
765    // whose space-form value is a positional we must skip while routing.
766    let root_lookup = schema_param_lookup(schema);
767    let is_root_value_flag = |name: &str| -> bool {
768        root_lookup.get(name).is_some_and(|(_, typ, ..)| !is_bool_type(typ))
769    };
770
771    let mut node = schema;
772    let mut skip_next_positional = false;
773    for arg in args {
774        match arg {
775            // Tokens past `--` are raw data, never subcommand selectors.
776            Arg::DoubleDash => break,
777            // A root value flag in space form consumes the next positional as
778            // its value — don't route on that positional.
779            Arg::LongFlag(name) if is_root_value_flag(name) => skip_next_positional = true,
780            Arg::ShortFlag(name) if is_root_value_flag(name) => skip_next_positional = true,
781            Arg::Positional(expr) => {
782                if skip_next_positional {
783                    skip_next_positional = false;
784                    continue; // this positional is the preceding flag's value
785                }
786                if node.subcommands.is_empty() {
787                    break; // leaf reached — remaining positionals are its args
788                }
789                match classify_subcommand_positional(expr) {
790                    SubcommandWord::Word(word) => {
791                        match node.subcommands.iter().find(|c| c.matches_command(word)) {
792                            Some(child) => node = child, // descend
793                            None => break,               // not a subcommand → leaf's own arg
794                        }
795                    }
796                    // A non-string literal (number/bool) can't be a subcommand
797                    // name but its value *is* known; treat it as the leaf's own
798                    // positional and stop — no misroute risk.
799                    SubcommandWord::OtherLiteral => break,
800                    SubcommandWord::Computed(kind) => anyhow::bail!(
801                        "{}: a subcommand name is required here, but got {kind}. \
802                         Subcommands must be literal words — spell it out \
803                         (e.g. `{} <subcommand> …`) or use the `--flag=value` form.",
804                        node.name,
805                        schema.name
806                    ),
807                }
808            }
809            // Flags are skipped during routing; they bind against the leaf.
810            _ => {}
811        }
812    }
813    Ok(node)
814}
815
816/// How a positional reads when a subcommand selector is expected.
817enum SubcommandWord<'a> {
818    /// A literal word that may name a child.
819    Word(&'a str),
820    /// A literal but non-string value — a known value, never a subcommand.
821    OtherLiteral,
822    /// A value computed at runtime; `kind` describes it for the error.
823    Computed(&'static str),
824}
825
826fn classify_subcommand_positional(expr: &Expr) -> SubcommandWord<'_> {
827    match expr {
828        Expr::Literal(Value::String(s)) => SubcommandWord::Word(s),
829        Expr::Literal(_) => SubcommandWord::OtherLiteral,
830        Expr::CommandSubst(_) | Expr::Command(_) => SubcommandWord::Computed("a command substitution `$(…)`"),
831        Expr::VarRef(_)
832        | Expr::VarWithDefault { .. }
833        | Expr::VarLength(_)
834        | Expr::Positional(_)
835        | Expr::AllArgs
836        | Expr::ArgCount
837        | Expr::CurrentPid
838        | Expr::LastExitCode => SubcommandWord::Computed("a variable reference"),
839        Expr::Interpolated(_) | Expr::HereDocBody { .. } => SubcommandWord::Computed("an interpolated string"),
840        Expr::GlobPattern(_) => SubcommandWord::Computed("a glob pattern"),
841        Expr::Arithmetic(_) => SubcommandWord::Computed("an arithmetic expansion"),
842        _ => SubcommandWord::Computed("a value computed at runtime"),
843    }
844}
845
846pub fn schema_param_lookup(schema: &ToolSchema) -> HashMap<String, (&str, &str, usize, bool)> {
847    let mut map = HashMap::new();
848    for p in schema.params.iter().filter(|p| !p.positional) {
849        map.insert(p.name.clone(), (p.name.as_str(), p.param_type.as_str(), p.consumes, p.repeatable));
850        for alias in &p.aliases {
851            let stripped = alias.trim_start_matches('-');
852            map.insert(stripped.to_string(), (p.name.as_str(), p.param_type.as_str(), p.consumes, p.repeatable));
853        }
854    }
855    map
856}
857
858/// Check if a type is considered boolean.
859pub fn is_bool_type(param_type: &str) -> bool {
860    matches!(param_type.to_lowercase().as_str(), "bool" | "boolean")
861}
862
863/// Reduced [`crate::kernel::ArgValueSource`] for `build_tool_args` below:
864/// evaluates via this module's own synchronous `eval_simple_expr` (no
865/// recursion into the async pipeline, so no command substitution) and never
866/// expands globs or tilde — `build_tool_args`'s historical "reduced sync"
867/// contract (see its doc comment), preserved exactly. Only the STRUCTURAL
868/// flag/positional binding now comes from the one shared
869/// `crate::kernel::bind_tool_args` core (GH #188).
870struct SyncEvalSource<'a> {
871    ctx: &'a ExecContext,
872}
873
874#[async_trait::async_trait]
875impl crate::kernel::ArgValueSource for SyncEvalSource<'_> {
876    async fn eval(&self, expr: &Expr) -> anyhow::Result<Option<Value>> {
877        eval_simple_expr(expr, self.ctx).map_err(|e| anyhow::anyhow!(e))
878    }
879
880    async fn expand_glob(&self, _pattern: &str) -> anyhow::Result<Option<Vec<String>>> {
881        // This reduced context has never expanded globs (bare patterns bind
882        // as literal text via `eval_simple_expr`'s `GlobPattern` arm) —
883        // scatter/gather's own flag values (`--as`, `--limit`, `--timeout`)
884        // are never file globs, so there's nothing to fix here (GH #188
885        // scoped this out; see the PR description).
886        Ok(None)
887    }
888
889    async fn home(&self) -> Option<String> {
890        // No tilde expansion in this reduced context — unchanged from
891        // before GH #188 (scatter/gather's own flag values are never paths).
892        None
893    }
894}
895
896/// Build ToolArgs from AST Args, evaluating expressions — the reduced sync
897/// wrapper around the shared `crate::kernel::bind_tool_args` core. Used by
898/// scatter/gather's own option parsing (`run_scatter_gather`, below —
899/// before any worker forks, so it can't recurse back into
900/// `PipelineRunner::run` for command substitution) and the `#[cfg(test)]`
901/// `BackendDispatcher` (`dispatch.rs`).
902///
903/// GH #188: this used to be a hand-rolled twin of `Kernel::build_args_async`'s
904/// flag/positional-binding logic that could — and did — drift from it (no
905/// undeclared-space-flag guard, no glued-short-flag handling, no
906/// `consumes`/`repeatable` accumulation). Now it's a thin wrapper: the
907/// binding logic itself is shared via `SyncEvalSource`, and only
908/// expression evaluation differs (this context can't run `$(...)`).
909pub async fn build_tool_args(
910    args: &[Arg],
911    ctx: &ExecContext,
912    schema: Option<&ToolSchema>,
913) -> Result<ToolArgs, String> {
914    crate::kernel::bind_tool_args(args, schema, &SyncEvalSource { ctx })
915        .await
916        .map_err(|e| e.to_string())
917}
918
919/// Simple expression evaluation for args (without full scope access).
920///
921/// `Ok(None)` means "not representable in this reduced sync context" (only
922/// binary ops fall here now — everything else this reduced binder can't
923/// evaluate, like command substitution, has its own explicit `Err` arm
924/// below; callers treat `None` the same as before, e.g. falling back to a
925/// bare flag). `Err` means a genuine failure — a [`PathError`]
926/// (undefined-subscripted-root, a missing key, a shape mismatch), a bad
927/// `$((...))` arithmetic expansion, or an unsupported `$(...)`/`$(cmd)` — and
928/// MUST propagate loud, the same as the async `build_args_async`/
929/// `eval_expr_async` (`kernel.rs`) and the sync interpreter (`eval.rs`) treat
930/// it. Before this, every arm here discarded the error via
931/// `.ok()`/`if let Ok(..)`, so a bad subscript OR a bad arithmetic expansion
932/// in a scatter/gather flag value silently dropped the argument instead of
933/// failing (now closed; the arithmetic swallow was GH #183).
934pub(crate) fn eval_simple_expr(expr: &Expr, ctx: &ExecContext) -> Result<Option<Value>, String> {
935    match expr {
936        Expr::Literal(value) => Ok(Some(eval_literal(value, ctx))),
937        Expr::VarRef(path) => match ctx.scope.resolve_path(path) {
938            Ok(v) => Ok(Some(v)),
939            // Unset BARE variable: coalesces (skip-the-arg) — this reduced
940            // context's bash-compatible convention. Bare-only on purpose: an
941            // undefined root under a SUBSCRIPTED path is loud below, the same
942            // split `resolve_length` draws — `scatter --as ${x[key]}` with a
943            // typo'd root must not silently drop the flag (kaibo review
944            // finding, PR #85).
945            Err(PathError::UndefinedRoot(_)) if path.segments.len() <= 1 => Ok(None),
946            Err(PathError::UndefinedRoot(_)) => Err(format!(
947                "{}: undefined variable",
948                crate::interpreter::format_path(path)
949            )),
950            // A loud path error (absence or shape) carries its own actionable
951            // message — never swallowed.
952            Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => Err(msg),
953        },
954        Expr::Interpolated(parts) => Ok(Some(Value::String(eval_string_parts_sync(parts, ctx)?))),
955        // Bare (unquoted whole-token) forms — `scatter --limit ${#tags}`,
956        // `scatter --as ${cfg[name]:-N}` — reuse the same shared path resolver
957        // the async path calls (`eval_expr_async`'s `VarLength`/`VarWithDefault`
958        // arms), so length/default semantics agree between the two paths.
959        Expr::VarLength(path) => {
960            crate::interpreter::resolve_length(&ctx.scope, path).map(|n| Some(Value::Int(n)))
961        }
962        Expr::VarWithDefault { path, default } => {
963            match crate::interpreter::resolve_default(&ctx.scope, path)? {
964                Some(value) => Ok(Some(value)),
965                None => Ok(Some(Value::String(eval_string_parts_sync(default, ctx)?))),
966            }
967        }
968        Expr::GlobPattern(s) => Ok(Some(Value::String(s.clone()))),
969        // Bare arithmetic expansion (`scatter --limit $((1+1))`) — mirrors
970        // the async `eval_expr_async`'s `Expr::Arithmetic` arm (kernel.rs),
971        // which already propagates loud. This used to fall into the
972        // catch-all `_ => Ok(None)` below (silently "not representable
973        // here"), so a bare `$((...))` flag value never bound at all — a
974        // valid `--limit $((1+1))` silently ran unlimited, and a bad
975        // `--limit $((1/0))` silently did too, instead of failing (GH #183).
976        Expr::Arithmetic(expr_str) => arithmetic::eval_arithmetic(expr_str, &ctx.scope)
977            .map(|n| Some(Value::Int(n)))
978            .map_err(|e| format!("arithmetic error: {e}")),
979        Expr::HereDocBody { parts, strip_tabs } => {
980            // Heredoc body materialization for redirect targets. `<<-` tab
981            // stripping applies to the literal source, not to tabs from a
982            // `$var` value — matching the interpreter's eval path.
983            let mut asm = crate::interpreter::HeredocAssembler::new(*strip_tabs);
984            for sp in parts {
985                match &sp.part {
986                    crate::ast::StringPart::Literal(s) => asm.push_literal(s),
987                    other => {
988                        let s = eval_string_parts_sync(std::slice::from_ref(other), ctx)?;
989                        asm.push_interpolated(&s);
990                    }
991                }
992            }
993            Ok(Some(Value::String(asm.into_string())))
994        }
995        // Command substitution can't be evaluated here (this reduced sync
996        // binder runs before any worker forks, so it can't recurse through
997        // the async pipeline) — but that must fail loud, not silently
998        // coalesce to a bare boolean flag/dropped value the way an unset
999        // bare variable does. `scatter --limit $(echo 5)` used to silently
1000        // run at the default limit instead of erroring.
1001        Expr::CommandSubst(_) | Expr::Command(_) => Err(
1002            "command substitution `$(...)` is not supported in a scatter/gather flag value here; \
1003             assign it to a variable first (e.g. `n=$(...); scatter --limit $n`)"
1004                .to_string(),
1005        ),
1006        _ => Ok(None), // Binary ops need more context
1007    }
1008}
1009
1010/// Evaluate a literal value.
1011fn eval_literal(value: &Value, _ctx: &ExecContext) -> Value {
1012    value.clone()
1013}
1014
1015/// Evaluate string parts synchronously (for pipeline context).
1016///
1017/// Command substitutions are skipped as they require async. A [`PathError`]
1018/// (absence or shape) from a subscripted `$var`, `${…:-default}`, or `${#…}`
1019/// propagates loud via `Err` — matching the async `eval_string_part_async`
1020/// (`kernel.rs`) and the sync `Interpreter::eval_interpolated` (`eval.rs`).
1021/// An unset BARE root still expands to empty (bash-compatible), unchanged.
1022fn eval_string_parts_sync(parts: &[crate::ast::StringPart], ctx: &ExecContext) -> Result<String, String> {
1023    let mut result = String::new();
1024    for part in parts {
1025        match part {
1026            crate::ast::StringPart::Literal(s) => result.push_str(s),
1027            crate::ast::StringPart::Var(path) => match ctx.scope.resolve_path(path) {
1028                // Text sink: binary goes loud, never the `[binary: N bytes]`
1029                // placeholder — matches the async `eval_string_part_async`
1030                // (kernel.rs) and sync `eval_interpolated` (eval.rs).
1031                Ok(value) => result.push_str(
1032                    &crate::interpreter::value_to_text_sink(&value).map_err(|e| e.to_string())?,
1033                ),
1034                // Unconditional (even subscripted) on purpose: in STRING
1035                // context both primary sites — async `eval_string_part_async`
1036                // (kernel.rs) and sync `eval_interpolated` (eval.rs) — expand
1037                // an undefined root to empty, bash-compatibly ("a${nope[k]}b"
1038                // → "ab"). The bare-only restriction applies to the
1039                // whole-token `Expr::VarRef` arm above, matching the primary
1040                // sites' loud whole-token behavior.
1041                Err(PathError::UndefinedRoot(_)) => {}
1042                Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => return Err(msg),
1043            },
1044            crate::ast::StringPart::VarWithDefault { path, default } => {
1045                match crate::interpreter::resolve_default(&ctx.scope, path)? {
1046                    Some(value) => result.push_str(
1047                        &crate::interpreter::value_to_text_sink(&value).map_err(|e| e.to_string())?,
1048                    ),
1049                    None => result.push_str(&eval_string_parts_sync(default, ctx)?),
1050                }
1051            }
1052            crate::ast::StringPart::VarLength(path) => {
1053                // Element/key count for collections, byte count for binary;
1054                // unset BARE root → 0 (bash parity). A shape/absence error on a
1055                // SUBSCRIPTED path now propagates loud instead of silently
1056                // omitting the length (the fixed "silent 0" gap).
1057                let len = crate::interpreter::resolve_length(&ctx.scope, path)?;
1058                result.push_str(&len.to_string());
1059            }
1060            crate::ast::StringPart::Positional(n) => {
1061                if let Some(s) = ctx.scope.get_positional(*n) {
1062                    result.push_str(s);
1063                }
1064            }
1065            crate::ast::StringPart::AllArgs => {
1066                result.push_str(&ctx.scope.all_args().join(" "));
1067            }
1068            crate::ast::StringPart::ArgCount => {
1069                result.push_str(&ctx.scope.arg_count().to_string());
1070            }
1071            crate::ast::StringPart::Arithmetic(expr) => {
1072                // Loud on purpose (GH #183): this used to be `if let Ok(..)`,
1073                // silently omitting the digits on error — a quoted
1074                // `--limit "$((1/0))"` used to surface only as scatter's own
1075                // generic int-parse complaint on the resulting "", masking
1076                // the real arithmetic error. Matches the bare
1077                // `Expr::Arithmetic` arm above and the async
1078                // `eval_string_part_async` (kernel.rs).
1079                let value = arithmetic::eval_arithmetic(expr, &ctx.scope)
1080                    .map_err(|e| format!("arithmetic error: {e}"))?;
1081                result.push_str(&value.to_string());
1082            }
1083            crate::ast::StringPart::CommandSubst(_) => {
1084                // Command substitution can't run in this reduced sync
1085                // context (see `eval_simple_expr`'s CommandSubst arm) — fail
1086                // loud instead of silently splicing in nothing.
1087                // `scatter --as "W$(suffix)"` used to bind the plain "W"
1088                // with the substitution silently dropped.
1089                return Err(
1090                    "command substitution `$(...)` is not supported inside a scatter/gather \
1091                     flag's interpolated value here; assign it to a variable first"
1092                        .to_string(),
1093                );
1094            }
1095            crate::ast::StringPart::LastExitCode => {
1096                result.push_str(&ctx.scope.last_result().code.to_string());
1097            }
1098            crate::ast::StringPart::CurrentPid => {
1099                result.push_str(&ctx.scope.pid().to_string());
1100            }
1101        }
1102    }
1103    Ok(result)
1104}
1105
1106/// Find scatter and gather commands in a pipeline.
1107///
1108/// Returns Some((scatter_index, gather_index)) if both are found with scatter before gather.
1109/// Returns None if the pipeline doesn't have a valid scatter/gather pattern.
1110fn find_scatter_gather(commands: &[Command]) -> Option<(usize, usize)> {
1111    let scatter_idx = commands.iter().position(|c| c.name == "scatter")?;
1112    let gather_idx = commands.iter().position(|c| c.name == "gather")?;
1113
1114    // Gather must come after scatter
1115    if gather_idx > scatter_idx {
1116        Some((scatter_idx, gather_idx))
1117    } else {
1118        None
1119    }
1120}
1121
1122#[cfg(test)]
1123mod select_leaf_tests {
1124    use super::*;
1125    use crate::tools::ParamSchema;
1126
1127    /// `kj`-shaped tree: kj → context (alias ctx) → {list (alias ls), create}.
1128    /// Root carries a global `--confirm <token>` value flag and a `--verbose`
1129    /// bool; `create` carries a leaf `--type` value flag — enough to exercise
1130    /// global-flag skipping and leaf binding.
1131    fn kj_schema() -> ToolSchema {
1132        ToolSchema::new("kj", "kaijutsu")
1133            .param(ParamSchema::new("confirm", "string"))
1134            .param(ParamSchema::new("verbose", "bool"))
1135            .subcommand(
1136                ToolSchema::new("context", "context ops")
1137                    .with_command_aliases(["ctx"])
1138                    .subcommand(ToolSchema::new("list", "list").with_command_aliases(["ls"]))
1139                    .subcommand(
1140                        ToolSchema::new("create", "create").param(
1141                            ParamSchema::new("type", "string").with_aliases(["t"]),
1142                        ),
1143                    ),
1144            )
1145    }
1146
1147    fn word(s: &str) -> Arg {
1148        Arg::Positional(Expr::Literal(Value::String(s.to_string())))
1149    }
1150
1151    #[test]
1152    fn flat_tool_returns_root() {
1153        let schema = ToolSchema::new("cat", "concat")
1154            .param(ParamSchema::required("path", "string", "f").positional());
1155        let leaf = select_leaf(&schema, &[word("foo.txt")]).expect("flat ok");
1156        assert_eq!(leaf.name, "cat");
1157    }
1158
1159    #[test]
1160    fn single_hop() {
1161        let schema = kj_schema();
1162        let leaf = select_leaf(&schema, &[word("context")]).expect("ok");
1163        assert_eq!(leaf.name, "context");
1164    }
1165
1166    #[test]
1167    fn two_hops() {
1168        let schema = kj_schema();
1169        let leaf = select_leaf(&schema, &[word("context"), word("create")]).expect("ok");
1170        assert_eq!(leaf.name, "create");
1171        assert!(leaf.params.iter().any(|p| p.name == "type"), "leaf has --type");
1172    }
1173
1174    #[test]
1175    fn alias_hops_route() {
1176        let schema = kj_schema();
1177        // `kj ctx ls` → context.list via command aliases.
1178        let leaf = select_leaf(&schema, &[word("ctx"), word("ls")]).expect("ok");
1179        assert_eq!(leaf.name, "list");
1180    }
1181
1182    #[test]
1183    fn unknown_subcommand_stops_at_current_node() {
1184        let schema = kj_schema();
1185        // `context nonesuch` — `nonesuch` names no child, so context is the leaf
1186        // and `nonesuch` is context's own positional. No error.
1187        let leaf = select_leaf(&schema, &[word("context"), word("nonesuch")]).expect("ok");
1188        assert_eq!(leaf.name, "context");
1189    }
1190
1191    #[test]
1192    fn root_bool_flag_before_path_does_not_disrupt_routing() {
1193        let schema = kj_schema();
1194        // `kj --verbose context create` — a root bool flag is skipped, both
1195        // positionals route to create.
1196        let args = vec![Arg::LongFlag("verbose".into()), word("context"), word("create")];
1197        let leaf = select_leaf(&schema, &args).expect("ok");
1198        assert_eq!(leaf.name, "create");
1199    }
1200
1201    #[test]
1202    fn root_value_flag_space_form_before_path_skips_its_value() {
1203        let schema = kj_schema();
1204        // `kj --confirm token context create` — `token` is --confirm's value,
1205        // NOT a subcommand selector; routing skips it and reaches create.
1206        let args = vec![
1207            Arg::LongFlag("confirm".into()),
1208            word("token"),
1209            word("context"),
1210            word("create"),
1211        ];
1212        let leaf = select_leaf(&schema, &args).expect("ok");
1213        assert_eq!(leaf.name, "create");
1214    }
1215
1216    #[test]
1217    fn leaf_value_flag_after_path_routes_to_leaf() {
1218        let schema = kj_schema();
1219        // `kj context create --type x` — the natural form: path first, leaf flag
1220        // after. Routing reaches create; --type then binds against create.
1221        let args = vec![
1222            word("context"),
1223            word("create"),
1224            Arg::LongFlag("type".into()),
1225            word("x"),
1226        ];
1227        let leaf = select_leaf(&schema, &args).expect("ok");
1228        assert_eq!(leaf.name, "create");
1229        assert!(leaf.params.iter().any(|p| p.name == "type"));
1230    }
1231
1232    #[test]
1233    fn double_dash_stops_routing() {
1234        let schema = kj_schema();
1235        // `kj -- context` — after `--`, `context` is raw data, not a subcommand.
1236        let leaf = select_leaf(&schema, &[Arg::DoubleDash, word("context")]).expect("ok");
1237        assert_eq!(leaf.name, "kj");
1238    }
1239
1240    #[test]
1241    fn computed_subcommand_selector_errors() {
1242        let schema = kj_schema();
1243        // `kj $(echo context)` — a command substitution where a subcommand name
1244        // is required must fail loud, not silently pick a leaf.
1245        let args = vec![Arg::Positional(Expr::CommandSubst(vec![
1246            crate::ast::Stmt::Command(crate::ast::Command {
1247                name: "echo".into(),
1248                args: vec![],
1249                redirects: vec![],
1250            }),
1251        ]))];
1252        let err = select_leaf(&schema, &args).expect_err("must error");
1253        let msg = err.to_string();
1254        assert!(msg.contains("subcommand name is required"), "got: {msg}");
1255        assert!(msg.contains("command substitution"), "names the cause: {msg}");
1256    }
1257
1258    #[test]
1259    fn variable_subcommand_selector_errors() {
1260        let schema = kj_schema();
1261        let args = vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("sub")))];
1262        let err = select_leaf(&schema, &args).expect_err("must error");
1263        assert!(err.to_string().contains("variable reference"), "got: {err}");
1264    }
1265
1266    #[test]
1267    fn computed_positional_after_leaf_is_fine() {
1268        let schema = kj_schema();
1269        // `kj context list $(echo x)` — once at a leaf (list has no children),
1270        // a computed positional is just an argument; routing already stopped.
1271        let args = vec![
1272            word("context"),
1273            word("list"),
1274            Arg::Positional(Expr::CommandSubst(vec![crate::ast::Stmt::Command(
1275                crate::ast::Command { name: "echo".into(), args: vec![], redirects: vec![] },
1276            )])),
1277        ];
1278        let leaf = select_leaf(&schema, &args).expect("ok");
1279        assert_eq!(leaf.name, "list");
1280    }
1281}
1282
1283#[cfg(test)]
1284mod tests {
1285    use super::*;
1286    use crate::dispatch::BackendDispatcher;
1287    use crate::tools::register_builtins;
1288    use crate::vfs::{Filesystem, MemoryFs, VfsRouter};
1289    use std::path::Path;
1290
1291    async fn make_runner_and_ctx() -> (PipelineRunner, ExecContext, BackendDispatcher) {
1292        let mut tools = ToolRegistry::new();
1293        register_builtins(&mut tools);
1294        let tools = Arc::new(tools);
1295        let runner = PipelineRunner::new(tools.clone());
1296        let dispatcher = BackendDispatcher::new(tools.clone());
1297
1298        let mut vfs = VfsRouter::new();
1299        let mem = MemoryFs::new();
1300        mem.write(Path::new("test.txt"), b"hello\nworld\nfoo").await.unwrap();
1301        vfs.mount("/", mem);
1302        let ctx = ExecContext::with_vfs_and_tools(Arc::new(vfs), tools);
1303
1304        (runner, ctx, dispatcher)
1305    }
1306
1307    fn make_cmd(name: &str, args: Vec<&str>) -> Command {
1308        Command {
1309            name: name.to_string(),
1310            args: args.iter().map(|s| Arg::Positional(Expr::Literal(Value::String(s.to_string())))).collect(),
1311            redirects: vec![],
1312        }
1313    }
1314
1315    #[tokio::test]
1316    async fn test_single_command() {
1317        let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1318        let cmd = make_cmd("echo", vec!["hello"]);
1319
1320        let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
1321        assert!(result.ok());
1322        assert_eq!(result.text_out().trim(), "hello");
1323    }
1324
1325    #[tokio::test]
1326    async fn test_pipeline_echo_grep() {
1327        let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1328
1329        // echo "hello\nworld" | grep pattern="world"
1330        let echo_cmd = Command {
1331            name: "echo".to_string(),
1332            args: vec![Arg::Positional(Expr::Literal(Value::String("hello\nworld".to_string())))],
1333            redirects: vec![],
1334        };
1335        let grep_cmd = Command {
1336            name: "grep".to_string(),
1337            args: vec![Arg::Positional(Expr::Literal(Value::String("world".to_string())))],
1338            redirects: vec![],
1339        };
1340
1341        let result = runner.run(&[echo_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1342        assert!(result.ok());
1343        assert_eq!(result.text_out().trim(), "world");
1344    }
1345
1346    #[tokio::test]
1347    async fn test_pipeline_cat_grep() {
1348        let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1349
1350        // cat /test.txt | grep pattern="hello"
1351        let cat_cmd = make_cmd("cat", vec!["/test.txt"]);
1352        let grep_cmd = Command {
1353            name: "grep".to_string(),
1354            args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
1355            redirects: vec![],
1356        };
1357
1358        let result = runner.run(&[cat_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1359        assert!(result.ok());
1360        assert!(result.text_out().contains("hello"));
1361    }
1362
1363    #[tokio::test]
1364    async fn test_command_not_found() {
1365        let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1366        let cmd = make_cmd("nonexistent", vec![]);
1367
1368        let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
1369        assert!(!result.ok());
1370        assert_eq!(result.code, 127);
1371        assert!(result.err.contains("not found"));
1372    }
1373
1374    #[tokio::test]
1375    async fn test_pipeline_continues_on_failure() {
1376        // Standard shell semantics: pipeline runs all commands,
1377        // exit code comes from the last command
1378        let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1379
1380        // cat /nonexistent | grep "hello"
1381        // cat fails but grep still runs (on empty input), grep returns 1 (no match)
1382        let cat_cmd = make_cmd("cat", vec!["/nonexistent"]);
1383        let grep_cmd = Command {
1384            name: "grep".to_string(),
1385            args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
1386            redirects: vec![],
1387        };
1388
1389        let result = runner.run(&[cat_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1390        // Exit code comes from last command (grep), not from cat
1391        assert!(!result.ok());
1392    }
1393
1394    #[tokio::test]
1395    async fn test_pipeline_last_command_exit_code() {
1396        // echo hello | cat — both succeed, pipeline succeeds
1397        let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1398
1399        let echo_cmd = make_cmd("echo", vec!["hello"]);
1400        let cat_cmd = make_cmd("cat", vec![]);
1401
1402        let result = runner.run(&[echo_cmd, cat_cmd], &mut ctx, &dispatcher).await;
1403        assert!(result.ok());
1404        assert!(result.text_out().contains("hello"));
1405    }
1406
1407    #[tokio::test]
1408    async fn test_empty_pipeline() {
1409        let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1410        let result = runner.run(&[], &mut ctx, &dispatcher).await;
1411        assert!(result.ok());
1412    }
1413
1414    // === Scatter/Gather Tests ===
1415
1416    #[test]
1417    fn test_find_scatter_gather_both_present() {
1418        let commands = vec![
1419            make_cmd("echo", vec!["a"]),
1420            make_cmd("scatter", vec![]),
1421            make_cmd("process", vec![]),
1422            make_cmd("gather", vec![]),
1423        ];
1424        let result = find_scatter_gather(&commands);
1425        assert_eq!(result, Some((1, 3)));
1426    }
1427
1428    #[test]
1429    fn test_find_scatter_gather_no_scatter() {
1430        let commands = vec![
1431            make_cmd("echo", vec!["a"]),
1432            make_cmd("gather", vec![]),
1433        ];
1434        let result = find_scatter_gather(&commands);
1435        assert!(result.is_none());
1436    }
1437
1438    #[test]
1439    fn test_find_scatter_gather_no_gather() {
1440        let commands = vec![
1441            make_cmd("echo", vec!["a"]),
1442            make_cmd("scatter", vec![]),
1443        ];
1444        let result = find_scatter_gather(&commands);
1445        assert!(result.is_none());
1446    }
1447
1448    #[test]
1449    fn test_find_scatter_gather_wrong_order() {
1450        let commands = vec![
1451            make_cmd("gather", vec![]),
1452            make_cmd("scatter", vec![]),
1453        ];
1454        let result = find_scatter_gather(&commands);
1455        assert!(result.is_none());
1456    }
1457
1458    #[tokio::test]
1459    async fn test_scatter_gather_simple() {
1460        let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1461
1462        // split "a b c" | scatter | echo ${ITEM} | gather
1463        let split_cmd = Command {
1464            name: "split".to_string(),
1465            args: vec![Arg::Positional(Expr::Literal(Value::String("a b c".to_string())))],
1466            redirects: vec![],
1467        };
1468        let scatter_cmd = make_cmd("scatter", vec![]);
1469        let process_cmd = Command {
1470            name: "echo".to_string(),
1471            args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1472            redirects: vec![],
1473        };
1474        let gather_cmd = make_cmd("gather", vec![]);
1475
1476        let result = runner.run(&[split_cmd, scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1477        assert!(result.ok(), "scatter with structured data should succeed: {}", result.err);
1478        // Each echo should output the item
1479        assert!(result.text_out().contains("a"));
1480        assert!(result.text_out().contains("b"));
1481        assert!(result.text_out().contains("c"));
1482    }
1483
1484    #[tokio::test]
1485    async fn test_scatter_gather_empty_input() {
1486        let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1487
1488        // echo "" | scatter | echo ${ITEM} | gather
1489        let echo_cmd = Command {
1490            name: "echo".to_string(),
1491            args: vec![Arg::Positional(Expr::Literal(Value::String("".to_string())))],
1492            redirects: vec![],
1493        };
1494        let scatter_cmd = make_cmd("scatter", vec![]);
1495        let process_cmd = Command {
1496            name: "echo".to_string(),
1497            args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1498            redirects: vec![],
1499        };
1500        let gather_cmd = make_cmd("gather", vec![]);
1501
1502        let result = runner.run(&[echo_cmd, scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1503        assert!(result.ok());
1504        assert!(result.text_out().trim().is_empty());
1505    }
1506
1507    #[tokio::test]
1508    async fn test_scatter_gather_with_structured_stdin() {
1509        let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1510
1511        // Set structured stdin data (as if piped from split/seq)
1512        let data = Value::Json(serde_json::json!(["x", "y", "z"]));
1513        ctx.set_stdin_with_data("x\ny\nz".to_string(), Some(data));
1514
1515        let scatter_cmd = make_cmd("scatter", vec![]);
1516        let process_cmd = Command {
1517            name: "echo".to_string(),
1518            args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1519            redirects: vec![],
1520        };
1521        let gather_cmd = make_cmd("gather", vec![]);
1522
1523        let result = runner.run(&[scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1524        assert!(result.ok(), "scatter with structured stdin should succeed: {}", result.err);
1525        assert!(result.text_out().contains("x"));
1526        assert!(result.text_out().contains("y"));
1527        assert!(result.text_out().contains("z"));
1528    }
1529
1530    #[tokio::test]
1531    async fn test_scatter_gather_json_input() {
1532        let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1533
1534        // Structured JSON array input (as if from split/seq)
1535        let data = Value::Json(serde_json::json!(["one", "two", "three"]));
1536        ctx.set_stdin_with_data(r#"["one", "two", "three"]"#.to_string(), Some(data));
1537
1538        let scatter_cmd = make_cmd("scatter", vec![]);
1539        let process_cmd = Command {
1540            name: "echo".to_string(),
1541            args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1542            redirects: vec![],
1543        };
1544        let gather_cmd = make_cmd("gather", vec![]);
1545
1546        let result = runner.run(&[scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1547        assert!(result.ok(), "scatter with JSON data should succeed: {}", result.err);
1548        assert!(result.text_out().contains("one"));
1549        assert!(result.text_out().contains("two"));
1550        assert!(result.text_out().contains("three"));
1551    }
1552
1553    #[tokio::test]
1554    async fn test_scatter_gather_with_post_gather() {
1555        let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1556
1557        // split "a b" | scatter | echo ${ITEM} | gather | grep "a"
1558        let split_cmd = Command {
1559            name: "split".to_string(),
1560            args: vec![Arg::Positional(Expr::Literal(Value::String("a b".to_string())))],
1561            redirects: vec![],
1562        };
1563        let scatter_cmd = make_cmd("scatter", vec![]);
1564        let process_cmd = Command {
1565            name: "echo".to_string(),
1566            args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1567            redirects: vec![],
1568        };
1569        let gather_cmd = make_cmd("gather", vec![]);
1570        let grep_cmd = Command {
1571            name: "grep".to_string(),
1572            args: vec![Arg::Positional(Expr::Literal(Value::String("a".to_string())))],
1573            redirects: vec![],
1574        };
1575
1576        let result = runner.run(&[split_cmd, scatter_cmd, process_cmd, gather_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1577        assert!(result.ok(), "scatter with post_gather should succeed: {}", result.err);
1578        assert!(result.text_out().contains("a"));
1579        assert!(!result.text_out().contains("b"));
1580    }
1581
1582    #[tokio::test]
1583    async fn test_scatter_custom_var_name() {
1584        let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1585
1586        // Provide structured data (as if from split/seq)
1587        let data = Value::Json(serde_json::json!(["test1", "test2"]));
1588        ctx.set_stdin_with_data("test1\ntest2".to_string(), Some(data));
1589
1590        // scatter --as URL | echo ${URL} | gather
1591        let scatter_cmd = Command {
1592            name: "scatter".to_string(),
1593            args: vec![Arg::Named {
1594                key: "as".to_string(),
1595                value: Expr::Literal(Value::String("URL".to_string())),
1596            }],
1597            redirects: vec![],
1598        };
1599        let process_cmd = Command {
1600            name: "echo".to_string(),
1601            args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("URL")))],
1602            redirects: vec![],
1603        };
1604        let gather_cmd = make_cmd("gather", vec![]);
1605
1606        let result = runner.run(&[scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1607        assert!(result.ok(), "scatter with custom var should succeed: {}", result.err);
1608        assert!(result.text_out().contains("test1"));
1609        assert!(result.text_out().contains("test2"));
1610    }
1611
1612    // === Backend Routing Tests ===
1613
1614    #[tokio::test]
1615    async fn test_pipeline_routes_through_backend() {
1616        use crate::backend::testing::MockBackend;
1617        use std::sync::atomic::Ordering;
1618
1619        // Create mock backend
1620        let (backend, call_count) = MockBackend::new();
1621        let backend: std::sync::Arc<dyn crate::backend::KernelBackend> = std::sync::Arc::new(backend);
1622
1623        // Create context with mock backend
1624        let mut ctx = crate::tools::ExecContext::with_backend(backend);
1625
1626        // BackendDispatcher routes through backend.call_tool()
1627        let tools = std::sync::Arc::new(ToolRegistry::new());
1628        let runner = PipelineRunner::new(tools.clone());
1629        let dispatcher = BackendDispatcher::new(tools);
1630
1631        // Single command should route through backend
1632        let cmd = make_cmd("test-tool", vec!["arg1"]);
1633        let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
1634
1635        assert!(result.ok(), "Mock backend should return success");
1636        assert_eq!(call_count.load(Ordering::SeqCst), 1, "call_tool should be invoked once");
1637        assert!(result.text_out().contains("mock executed"), "Output should be from mock backend");
1638    }
1639
1640    #[tokio::test]
1641    async fn test_multi_command_pipeline_routes_through_backend() {
1642        use crate::backend::testing::MockBackend;
1643        use std::sync::atomic::Ordering;
1644
1645        let (backend, call_count) = MockBackend::new();
1646        let backend: std::sync::Arc<dyn crate::backend::KernelBackend> = std::sync::Arc::new(backend);
1647        let mut ctx = crate::tools::ExecContext::with_backend(backend);
1648
1649        let tools = std::sync::Arc::new(ToolRegistry::new());
1650        let runner = PipelineRunner::new(tools.clone());
1651        let dispatcher = BackendDispatcher::new(tools);
1652
1653        // Pipeline with 3 commands
1654        let cmd1 = make_cmd("tool1", vec![]);
1655        let cmd2 = make_cmd("tool2", vec![]);
1656        let cmd3 = make_cmd("tool3", vec![]);
1657
1658        let result = runner.run(&[cmd1, cmd2, cmd3], &mut ctx, &dispatcher).await;
1659
1660        assert!(result.ok());
1661        assert_eq!(call_count.load(Ordering::SeqCst), 3, "call_tool should be invoked for each command");
1662    }
1663
1664    /// GH #93 item 4: the test-only `BackendDispatcher` used to hand-roll the
1665    /// `ToolResult` -> `ExecResult` conversion (wrapping `data` unconditionally
1666    /// as `Value::Json`), diverging from the production path in kernel.rs,
1667    /// which goes through `ExecResult::from(tool_result)` and unwraps JSON
1668    /// scalars into native `Value` variants via `json_to_value_no_envelope`.
1669    /// A scalar `data` payload is where the two paths visibly disagreed.
1670    #[tokio::test]
1671    async fn backend_dispatcher_scalar_data_matches_production_unwrap() {
1672        use crate::backend::testing::MockBackend;
1673        use crate::backend::ToolResult;
1674
1675        let (mock, _calls) = MockBackend::new();
1676        let backend = mock.with_tool_result(|_name| Ok(ToolResult::with_data("", serde_json::json!(42))));
1677        let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
1678        let mut ctx = ExecContext::with_backend(backend);
1679
1680        let dispatcher = BackendDispatcher::new(Arc::new(ToolRegistry::new()));
1681        let cmd = make_cmd("embedder_tool", vec![]);
1682
1683        let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
1684        assert_eq!(
1685            result.data,
1686            Some(Value::Int(42)),
1687            "a scalar ToolResult.data must unwrap to a native Value, matching \
1688             the production From<ToolResult> path — not stay Value::Json(42)"
1689        );
1690    }
1691
1692    /// Companion to the scalar test above: an object shaped like the binary
1693    /// byte-envelope must stay a plain structured record (`Value::Json`), not
1694    /// get auto-decoded into `Value::Bytes`. Pins the same guarantee
1695    /// `json_to_value_no_envelope` gives the production path, now that the
1696    /// test dispatcher shares that exact conversion.
1697    #[tokio::test]
1698    async fn backend_dispatcher_envelope_shaped_data_stays_structured() {
1699        use crate::backend::testing::MockBackend;
1700        use crate::backend::ToolResult;
1701
1702        let envelope = kaish_types::bytes_to_envelope(&[1u8, 2, 3]);
1703        let (mock, _calls) = MockBackend::new();
1704        let backend = mock.with_tool_result(move |_name| Ok(ToolResult::with_data("", envelope.clone())));
1705        let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
1706        let mut ctx = ExecContext::with_backend(backend);
1707
1708        let dispatcher = BackendDispatcher::new(Arc::new(ToolRegistry::new()));
1709        let cmd = make_cmd("embedder_tool", vec![]);
1710
1711        let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
1712        assert!(
1713            matches!(result.data, Some(Value::Json(_))),
1714            "envelope-shaped external data must stay structured, not silently \
1715             decode to Value::Bytes: got {:?}",
1716            result.data
1717        );
1718    }
1719
1720    /// GH #93 item 3: `did_spill`/`original_code` must survive the
1721    /// ToolResult <-> ExecResult seam. The old hand-rolled conversion in the
1722    /// test dispatcher never touched either field, so a capped backend-tool
1723    /// result silently looked uncapped by the time it reached the kernel.
1724    #[tokio::test]
1725    async fn backend_dispatcher_preserves_did_spill_and_original_code() {
1726        use crate::backend::testing::MockBackend;
1727        use crate::backend::ToolResult;
1728
1729        let (mock, _calls) = MockBackend::new();
1730        let backend = mock.with_tool_result(|_name| {
1731            Ok(ToolResult::success("truncated...")
1732                .with_did_spill(true)
1733                .with_original_code(Some(0)))
1734        });
1735        let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
1736        let mut ctx = ExecContext::with_backend(backend);
1737
1738        let dispatcher = BackendDispatcher::new(Arc::new(ToolRegistry::new()));
1739        let cmd = make_cmd("embedder_tool", vec![]);
1740
1741        let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
1742        assert!(result.did_spill, "did_spill must survive the backend seam");
1743        assert_eq!(result.original_code, Some(0), "original_code must survive the backend seam");
1744    }
1745
1746    // === Schema-Aware Argument Parsing Tests ===
1747
1748    use crate::tools::{ParamSchema, ToolSchema};
1749
1750    fn make_test_schema() -> ToolSchema {
1751        ToolSchema::new("test-tool", "A test tool for schema-aware parsing")
1752            .param(ParamSchema::required("query", "string", "Search query"))
1753            .param(ParamSchema::optional("limit", "int", Value::Int(10), "Max results"))
1754            .param(ParamSchema::optional("verbose", "bool", Value::Bool(false), "Verbose output"))
1755            .param(ParamSchema::optional("output", "string", Value::String("stdout".into()), "Output destination"))
1756            .with_positional_mapping()
1757    }
1758
1759    fn make_minimal_ctx() -> ExecContext {
1760        let mut vfs = VfsRouter::new();
1761        vfs.mount("/", MemoryFs::new());
1762        ExecContext::new(Arc::new(vfs))
1763    }
1764
1765    /// A throwaway dispatcher for `apply_redirects` in tests that exercise
1766    /// merge redirects (`2>&1`) only — they never evaluate a `$()` target, so
1767    /// an empty-registry backend dispatcher suffices to satisfy the signature.
1768    fn test_dispatcher() -> BackendDispatcher {
1769        BackendDispatcher::new(Arc::new(ToolRegistry::new()))
1770    }
1771
1772    #[tokio::test]
1773    async fn test_schema_aware_string_arg() {
1774        // --query "test" should become named: {"query": "test"}
1775        let args = vec![
1776            Arg::LongFlag("query".to_string()),
1777            Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
1778        ];
1779        let schema = make_test_schema();
1780        let ctx = make_minimal_ctx();
1781
1782        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1783
1784        assert!(tool_args.flags.is_empty(), "No flags should be set");
1785        assert!(tool_args.positional.is_empty(), "No positionals - consumed by --query");
1786        assert_eq!(
1787            tool_args.named.get("query"),
1788            Some(&Value::String("test".to_string())),
1789            "--query should consume 'test' as its value"
1790        );
1791    }
1792
1793    #[tokio::test]
1794    async fn test_schema_aware_bool_flag() {
1795        // --verbose should remain a flag since schema says bool
1796        let args = vec![
1797            Arg::LongFlag("verbose".to_string()),
1798        ];
1799        let schema = make_test_schema();
1800        let ctx = make_minimal_ctx();
1801
1802        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1803
1804        assert!(tool_args.flags.contains("verbose"), "--verbose should be a flag");
1805        assert!(tool_args.named.is_empty(), "No named args");
1806        assert!(tool_args.positional.is_empty(), "No positionals");
1807    }
1808
1809    #[tokio::test]
1810    async fn test_schema_aware_mixed() {
1811        // mcp_tool file.txt --output out.txt --verbose
1812        // file.txt maps to "query" (first unfilled non-bool schema param)
1813        let args = vec![
1814            Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
1815            Arg::LongFlag("output".to_string()),
1816            Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
1817            Arg::LongFlag("verbose".to_string()),
1818        ];
1819        let schema = make_test_schema();
1820        let ctx = make_minimal_ctx();
1821
1822        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1823
1824        assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
1825        assert_eq!(
1826            tool_args.named.get("query"),
1827            Some(&Value::String("file.txt".to_string()))
1828        );
1829        assert_eq!(
1830            tool_args.named.get("output"),
1831            Some(&Value::String("out.txt".to_string()))
1832        );
1833        assert!(tool_args.flags.contains("verbose"));
1834    }
1835
1836    #[tokio::test]
1837    async fn test_schema_aware_multiple_string_args() {
1838        // --query "test" --output "result.json" --verbose --limit 5
1839        let args = vec![
1840            Arg::LongFlag("query".to_string()),
1841            Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
1842            Arg::LongFlag("output".to_string()),
1843            Arg::Positional(Expr::Literal(Value::String("result.json".to_string()))),
1844            Arg::LongFlag("verbose".to_string()),
1845            Arg::LongFlag("limit".to_string()),
1846            Arg::Positional(Expr::Literal(Value::Int(5))),
1847        ];
1848        let schema = make_test_schema();
1849        let ctx = make_minimal_ctx();
1850
1851        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1852
1853        assert!(tool_args.positional.is_empty(), "All positionals consumed");
1854        assert_eq!(
1855            tool_args.named.get("query"),
1856            Some(&Value::String("test".to_string()))
1857        );
1858        assert_eq!(
1859            tool_args.named.get("output"),
1860            Some(&Value::String("result.json".to_string()))
1861        );
1862        assert_eq!(
1863            tool_args.named.get("limit"),
1864            Some(&Value::Int(5))
1865        );
1866        assert!(tool_args.flags.contains("verbose"));
1867    }
1868
1869    #[tokio::test]
1870    async fn test_schema_aware_double_dash() {
1871        // --output out.txt -- --this-is-data
1872        // After --, everything is positional
1873        let args = vec![
1874            Arg::LongFlag("output".to_string()),
1875            Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
1876            Arg::DoubleDash,
1877            Arg::Positional(Expr::Literal(Value::String("--this-is-data".to_string()))),
1878        ];
1879        let schema = make_test_schema();
1880        let ctx = make_minimal_ctx();
1881
1882        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1883
1884        assert_eq!(
1885            tool_args.named.get("output"),
1886            Some(&Value::String("out.txt".to_string()))
1887        );
1888        // After --, the --this-is-data is treated as a positional (it's a Positional in the args)
1889        assert_eq!(
1890            tool_args.positional,
1891            vec![Value::String("--this-is-data".to_string())]
1892        );
1893    }
1894
1895    /// GH #116: the sync twin of kernel.rs's async `build_args_async` WordAssign
1896    /// fallback must also go loud on binary rather than silently reassembling
1897    /// the `[binary: N bytes]` placeholder into `key=value` (e.g. `dd if=$BIN`
1898    /// reached via a scatter/gather flag value, which routes through this sync
1899    /// evaluator instead of the async binder).
1900    #[tokio::test]
1901    async fn word_assign_binary_value_is_loud_not_placeholder() {
1902        let args = vec![Arg::WordAssign {
1903            key: "if".to_string(),
1904            value: Expr::Literal(Value::Bytes(vec![0xff, 0x00, 0xfe])),
1905        }];
1906        let ctx = make_minimal_ctx();
1907
1908        // schema=None ⇒ accepts_word_assign is false ⇒ falls to the
1909        // stringify-to-positional branch under test.
1910        let err = build_tool_args(&args, &ctx, None).await.expect_err("binary WordAssign must error");
1911        assert!(
1912            err.contains("cannot be used as"),
1913            "error should name the binary problem, got {err:?}"
1914        );
1915    }
1916
1917    /// GH #189 item 1: pin the CURRENT (pre-`--`) behavior first — a
1918    /// word-assign-accepting tool (`export`, keyed off the root schema name)
1919    /// binds a bare `key=value` as a named assignment. This is unchanged by
1920    /// the fix below; only the post-`--` case changes.
1921    #[tokio::test]
1922    async fn word_assign_before_double_dash_binds_named_for_export() {
1923        let args = vec![Arg::WordAssign {
1924            key: "A".to_string(),
1925            value: Expr::Literal(Value::String("1".to_string())),
1926        }];
1927        let schema = ToolSchema::new("export", "export");
1928        let ctx = make_minimal_ctx();
1929
1930        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1931        assert_eq!(tool_args.named.get("A"), Some(&Value::String("1".to_string())));
1932        assert!(tool_args.positional.is_empty());
1933    }
1934
1935    /// GH #189 item 1: `export -- A=1` must NOT bind `A=1` as a named
1936    /// assignment — `--` marks everything after it as literal data, and the
1937    /// WordAssign arm used to ignore `past_double_dash` entirely (only the
1938    /// flag arms checked it). Before the fix, this test's ONLY visible
1939    /// difference from the one above was replacing `WordAssign` with
1940    /// `[DoubleDash, WordAssign]` — the fix degrades the value to a
1941    /// stringified `"A=1"` positional instead, matching how every other
1942    /// tool treats a `key=value` after `--`.
1943    #[tokio::test]
1944    async fn word_assign_after_double_dash_is_positional_even_for_export() {
1945        let args = vec![
1946            Arg::DoubleDash,
1947            Arg::WordAssign {
1948                key: "A".to_string(),
1949                value: Expr::Literal(Value::String("1".to_string())),
1950            },
1951        ];
1952        let schema = ToolSchema::new("export", "export");
1953        let ctx = make_minimal_ctx();
1954
1955        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1956        assert!(
1957            tool_args.named.is_empty(),
1958            "past `--`, A=1 must not become a named assignment: {:?}",
1959            tool_args.named
1960        );
1961        assert_eq!(tool_args.positional, vec![Value::String("A=1".to_string())]);
1962    }
1963
1964    /// GH #189 item 3: `--flag=true` on an UNDECLARED flag (this is exactly
1965    /// `--json`'s situation — `clap_schema::is_skipped` deliberately excludes
1966    /// it from every builtin's reflected schema) must flagify at bind time:
1967    /// land in `flags`, not `named` as a literal `Value::Bool` a clap `bool`
1968    /// field's `SetTrue` action rejects (`seq --json=true` used to exit 2).
1969    /// Before this fix, only the ~20 builtins that called
1970    /// `ToolArgs::flagify_bool_named` themselves got this normalization.
1971    #[tokio::test]
1972    async fn named_true_on_undeclared_flag_flagifies() {
1973        let args = vec![Arg::Named {
1974            key: "json".to_string(),
1975            value: Expr::Literal(Value::Bool(true)),
1976        }];
1977        let schema = make_test_schema(); // declares query/limit/verbose/output, not "json"
1978        let ctx = make_minimal_ctx();
1979
1980        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1981        assert!(tool_args.flags.contains("json"), "flags: {:?}", tool_args.flags);
1982        assert!(!tool_args.named.contains_key("json"), "named: {:?}", tool_args.named);
1983    }
1984
1985    /// `--flag=false` on an undeclared flag drops entirely — absence and
1986    /// explicit false are the same thing, matching `ToolArgs::flagify_bool_named`.
1987    #[tokio::test]
1988    async fn named_false_on_undeclared_flag_drops() {
1989        let args = vec![Arg::Named {
1990            key: "json".to_string(),
1991            value: Expr::Literal(Value::Bool(false)),
1992        }];
1993        let schema = make_test_schema();
1994        let ctx = make_minimal_ctx();
1995
1996        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1997        assert!(!tool_args.flags.contains("json"));
1998        assert!(!tool_args.named.contains_key("json"));
1999    }
2000
2001    /// A schema-DECLARED bool param (`verbose`) behaves the same as an
2002    /// undeclared one: `--verbose=true` flagifies instead of landing in
2003    /// `named`.
2004    #[tokio::test]
2005    async fn named_true_on_declared_bool_param_flagifies() {
2006        let args = vec![Arg::Named {
2007            key: "verbose".to_string(),
2008            value: Expr::Literal(Value::Bool(true)),
2009        }];
2010        let schema = make_test_schema();
2011        let ctx = make_minimal_ctx();
2012
2013        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2014        assert!(tool_args.flags.contains("verbose"));
2015        assert!(!tool_args.named.contains_key("verbose"));
2016    }
2017
2018    /// A schema-declared VALUE-taking flag's own `=true` literal
2019    /// (`spawn --command=true`, `output` here — both string-typed in
2020    /// `make_test_schema`) must NOT flagify — `true` is the flag's actual
2021    /// value, not a bool-flag presence marker, and clap's `Option<String>`
2022    /// field for it accepts `--output=true` fine.
2023    #[tokio::test]
2024    async fn named_true_on_declared_value_flag_keeps_value() {
2025        let args = vec![Arg::Named {
2026            key: "output".to_string(),
2027            value: Expr::Literal(Value::Bool(true)),
2028        }];
2029        let schema = make_test_schema();
2030        let ctx = make_minimal_ctx();
2031
2032        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2033        assert_eq!(tool_args.named.get("output"), Some(&Value::Bool(true)));
2034        assert!(!tool_args.flags.contains("output"));
2035    }
2036
2037    /// The fix must not depend on a schema being present at all — a
2038    /// completely schemaless invocation (`schema=None`, e.g. a shell
2039    /// function call) sees an empty `param_lookup`, so `--flag=true` still
2040    /// flagifies rather than landing in `named`.
2041    #[tokio::test]
2042    async fn named_true_with_no_schema_flagifies() {
2043        let args = vec![Arg::Named {
2044            key: "verbose".to_string(),
2045            value: Expr::Literal(Value::Bool(true)),
2046        }];
2047        let ctx = make_minimal_ctx();
2048
2049        let tool_args = build_tool_args(&args, &ctx, None).await.expect("build_tool_args");
2050        assert!(tool_args.flags.contains("verbose"));
2051        assert!(!tool_args.named.contains_key("verbose"));
2052    }
2053
2054    #[tokio::test]
2055    async fn test_no_schema_fallback() {
2056        // Without schema, all --flags are treated as bool flags
2057        let args = vec![
2058            Arg::LongFlag("query".to_string()),
2059            Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
2060        ];
2061        let ctx = make_minimal_ctx();
2062
2063        let tool_args = build_tool_args(&args, &ctx, None).await.expect("build_tool_args");
2064
2065        // Without schema, --query is a flag and "test" is a positional
2066        assert!(tool_args.flags.contains("query"), "--query should be a flag");
2067        assert_eq!(
2068            tool_args.positional,
2069            vec![Value::String("test".to_string())],
2070            "'test' should be a positional"
2071        );
2072    }
2073
2074    /// GH #188: `--unknown value` under a `map_positionals` schema (real
2075    /// MCP/backend tools) is ambiguous — kaish can't tell an undeclared
2076    /// flag's space-form value from a bool flag sitting before a genuine
2077    /// positional. The pre-#188 reduced sync twin silently defaulted
2078    /// `--unknown` to a bool flag and mapped "value" onto the first unfilled
2079    /// param (`query`) instead — exactly the "no undeclared-space-flag
2080    /// guard" divergence from `Kernel::build_args_async`'s real behavior
2081    /// that unifying the two binders closes. Production's ambiguous-value
2082    /// guard (`kernel::bind_tool_args`) now fires here too.
2083    #[tokio::test]
2084    async fn test_unknown_flag_ambiguous_space_value_now_errors_loud() {
2085        let args = vec![
2086            Arg::LongFlag("unknown".to_string()),
2087            Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2088        ];
2089        let schema = make_test_schema();
2090        let ctx = make_minimal_ctx();
2091
2092        let err = build_tool_args(&args, &ctx, Some(&schema))
2093            .await
2094            .expect_err("an undeclared flag immediately before a positional must be ambiguous, not silently bool");
2095        assert!(
2096            err.contains("--unknown is not a declared flag"),
2097            "got: {err}"
2098        );
2099    }
2100
2101    /// The unambiguous half of the same guard: an undeclared flag with
2102    /// nothing after it can't be silently swallowing a positional, so it
2103    /// still defaults to a bare bool flag — unchanged by GH #188.
2104    #[tokio::test]
2105    async fn test_unknown_bool_flag_with_no_following_positional_is_fine() {
2106        let args = vec![
2107            Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2108            Arg::LongFlag("unknown".to_string()),
2109        ];
2110        let schema = make_test_schema();
2111        let ctx = make_minimal_ctx();
2112
2113        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2114
2115        assert!(tool_args.flags.contains("unknown"));
2116        assert!(tool_args.positional.is_empty(), "value consumed as query param");
2117        assert_eq!(
2118            tool_args.named.get("query"),
2119            Some(&Value::String("value".to_string()))
2120        );
2121    }
2122
2123    /// GH #189 item 4: the SAME ambiguity guard as
2124    /// `test_unknown_flag_ambiguous_space_value_now_errors_loud` above, but
2125    /// for an undeclared SHORT flag. Before the fix, an undeclared short
2126    /// flag under a `map_positionals` schema always defaulted to a bare bool
2127    /// (`is_bool = lookup.map(...).unwrap_or(true)`), silently divorcing the
2128    /// following positional's value (`-t explorer` → flag "t" set, "explorer"
2129    /// mapped onto the first unfilled param instead of "t"'s value) — the
2130    /// long-flag half of this was closed by GH #188; this closes the
2131    /// short-flag half.
2132    #[tokio::test]
2133    async fn test_unknown_short_flag_ambiguous_space_value_now_errors_loud() {
2134        let args = vec![
2135            Arg::ShortFlag("t".to_string()),
2136            Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2137        ];
2138        let schema = make_test_schema();
2139        let ctx = make_minimal_ctx();
2140
2141        let err = build_tool_args(&args, &ctx, Some(&schema))
2142            .await
2143            .expect_err("an undeclared short flag immediately before a positional must be ambiguous, not silently bool");
2144        assert!(
2145            err.contains("-t is not a declared flag"),
2146            "got: {err}"
2147        );
2148    }
2149
2150    /// The unambiguous half: an undeclared short flag with nothing after it
2151    /// can't be silently swallowing a positional, so it still defaults to a
2152    /// bare bool flag.
2153    #[tokio::test]
2154    async fn test_unknown_short_bool_flag_with_no_following_positional_is_fine() {
2155        let args = vec![
2156            Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2157            Arg::ShortFlag("t".to_string()),
2158        ];
2159        let schema = make_test_schema();
2160        let ctx = make_minimal_ctx();
2161
2162        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2163
2164        assert!(tool_args.flags.contains("t"));
2165        assert!(tool_args.positional.is_empty(), "value consumed as query param");
2166        assert_eq!(
2167            tool_args.named.get("query"),
2168            Some(&Value::String("value".to_string()))
2169        );
2170    }
2171
2172    /// The guard is specific to `map_positionals` (backend/MCP) schemas — a
2173    /// builtin (no `map_positionals`) keeps the pre-existing behavior of
2174    /// treating an undeclared short flag as bare bool, since a builtin
2175    /// handles its own positionals rather than relying on this ambiguity
2176    /// class at all.
2177    #[tokio::test]
2178    async fn test_unknown_short_flag_not_ambiguous_without_map_positionals() {
2179        let args = vec![
2180            Arg::ShortFlag("t".to_string()),
2181            Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2182        ];
2183        // A builtin-shaped schema: same params as make_test_schema but no
2184        // positional mapping.
2185        let schema = ToolSchema::new("test-tool", "A test tool")
2186            .param(ParamSchema::required("query", "string", "Search query"));
2187        let ctx = make_minimal_ctx();
2188
2189        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2190        assert!(tool_args.flags.contains("t"));
2191        assert_eq!(tool_args.positional, vec![Value::String("value".to_string())]);
2192    }
2193
2194    #[tokio::test]
2195    async fn test_named_args_unchanged() {
2196        // key=value syntax should work regardless of schema
2197        let args = vec![
2198            Arg::Named {
2199                key: "query".to_string(),
2200                value: Expr::Literal(Value::String("test".to_string())),
2201            },
2202            Arg::LongFlag("verbose".to_string()),
2203        ];
2204        let schema = make_test_schema();
2205        let ctx = make_minimal_ctx();
2206
2207        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2208
2209        assert_eq!(
2210            tool_args.named.get("query"),
2211            Some(&Value::String("test".to_string()))
2212        );
2213        assert!(tool_args.flags.contains("verbose"));
2214    }
2215
2216    #[tokio::test]
2217    async fn test_short_flags_unchanged() {
2218        // Short flags -la should expand regardless of schema; file.txt maps to query
2219        let args = vec![
2220            Arg::ShortFlag("la".to_string()),
2221            Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
2222        ];
2223        let schema = make_test_schema();
2224        let ctx = make_minimal_ctx();
2225
2226        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2227
2228        assert!(tool_args.flags.contains("l"));
2229        assert!(tool_args.flags.contains("a"));
2230        assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
2231        assert_eq!(
2232            tool_args.named.get("query"),
2233            Some(&Value::String("file.txt".to_string()))
2234        );
2235    }
2236
2237    #[tokio::test]
2238    async fn test_flag_at_end_no_value() {
2239        // --output at end with no value available - treat as flag (lenient)
2240        // file.txt maps to query (first unfilled non-bool param)
2241        let args = vec![
2242            Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
2243            Arg::LongFlag("output".to_string()),
2244        ];
2245        let schema = make_test_schema();
2246        let ctx = make_minimal_ctx();
2247
2248        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2249
2250        // output expects a value but none available after it, so it becomes a flag
2251        assert!(tool_args.flags.contains("output"));
2252        assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
2253        assert_eq!(
2254            tool_args.named.get("query"),
2255            Some(&Value::String("file.txt".to_string()))
2256        );
2257    }
2258
2259    #[tokio::test]
2260    async fn test_positional_skips_bool_params() {
2261        // Schema: [query: string, verbose: bool, output: string]
2262        // Args: "val1" "val2"
2263        // Expected: query="val1", verbose unset, output="val2"
2264        let schema = ToolSchema::new("test", "")
2265            .param(ParamSchema::required("query", "string", ""))
2266            .param(ParamSchema::optional(
2267                "verbose",
2268                "bool",
2269                Value::Bool(false),
2270                "",
2271            ))
2272            .param(ParamSchema::optional(
2273                "output",
2274                "string",
2275                Value::Null,
2276                "",
2277            ))
2278            .with_positional_mapping();
2279        let args = vec![
2280            Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2281            Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
2282        ];
2283        let ctx = make_minimal_ctx();
2284
2285        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2286
2287        assert_eq!(
2288            tool_args.named.get("query"),
2289            Some(&Value::String("val1".to_string()))
2290        );
2291        assert_eq!(
2292            tool_args.named.get("output"),
2293            Some(&Value::String("val2".to_string()))
2294        );
2295        assert!(!tool_args.flags.contains("verbose"));
2296        assert!(tool_args.positional.is_empty());
2297    }
2298
2299    #[tokio::test]
2300    async fn test_positionals_fill_available_slots() {
2301        // Schema has query (string), limit (int), verbose (bool), output (string).
2302        // Three positionals fill the 3 non-bool slots.
2303        let args = vec![
2304            Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2305            Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
2306            Arg::Positional(Expr::Literal(Value::String("val3".to_string()))),
2307        ];
2308        let schema = make_test_schema(); // query, limit(int), verbose(bool), output
2309        let ctx = make_minimal_ctx();
2310
2311        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2312
2313        // val1 → query, val2 → limit (int param but receives string — tool decides),
2314        // val3 → output
2315        assert_eq!(
2316            tool_args.named.get("query"),
2317            Some(&Value::String("val1".to_string()))
2318        );
2319        assert_eq!(
2320            tool_args.named.get("limit"),
2321            Some(&Value::String("val2".to_string()))
2322        );
2323        assert_eq!(
2324            tool_args.named.get("output"),
2325            Some(&Value::String("val3".to_string()))
2326        );
2327        assert!(tool_args.positional.is_empty());
2328    }
2329
2330    #[tokio::test]
2331    async fn test_truly_excess_positionals() {
2332        // More positionals than non-bool schema params — leftovers stay positional
2333        let schema = ToolSchema::new("test", "")
2334            .param(ParamSchema::required("name", "string", ""))
2335            .with_positional_mapping();
2336        let args = vec![
2337            Arg::Positional(Expr::Literal(Value::String("first".to_string()))),
2338            Arg::Positional(Expr::Literal(Value::String("second".to_string()))),
2339            Arg::Positional(Expr::Literal(Value::String("third".to_string()))),
2340        ];
2341        let ctx = make_minimal_ctx();
2342
2343        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2344
2345        assert_eq!(
2346            tool_args.named.get("name"),
2347            Some(&Value::String("first".to_string()))
2348        );
2349        assert_eq!(
2350            tool_args.positional,
2351            vec![
2352                Value::String("second".to_string()),
2353                Value::String("third".to_string()),
2354            ]
2355        );
2356    }
2357
2358    #[tokio::test]
2359    async fn test_double_dash_positional_not_mapped() {
2360        // `tool val1 -- val2` — val1 maps to query, val2 stays positional (post-dash)
2361        let args = vec![
2362            Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2363            Arg::DoubleDash,
2364            Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
2365        ];
2366        let schema = make_test_schema();
2367        let ctx = make_minimal_ctx();
2368
2369        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2370
2371        assert_eq!(
2372            tool_args.named.get("query"),
2373            Some(&Value::String("val1".to_string()))
2374        );
2375        // val2 is after --, should NOT be mapped even though schema has unfilled params
2376        assert_eq!(
2377            tool_args.positional,
2378            vec![Value::String("val2".to_string())]
2379        );
2380    }
2381
2382    #[tokio::test]
2383    async fn test_all_params_filled_by_flags() {
2384        // All schema params satisfied by explicit flags — no positional mapping needed
2385        let args = vec![
2386            Arg::LongFlag("query".to_string()),
2387            Arg::Positional(Expr::Literal(Value::String("search".to_string()))),
2388            Arg::LongFlag("output".to_string()),
2389            Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
2390            Arg::LongFlag("verbose".to_string()),
2391        ];
2392        let schema = make_test_schema();
2393        let ctx = make_minimal_ctx();
2394
2395        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2396
2397        assert_eq!(
2398            tool_args.named.get("query"),
2399            Some(&Value::String("search".to_string()))
2400        );
2401        assert_eq!(
2402            tool_args.named.get("output"),
2403            Some(&Value::String("out.txt".to_string()))
2404        );
2405        assert!(tool_args.flags.contains("verbose"));
2406        assert!(tool_args.positional.is_empty());
2407    }
2408
2409    #[tokio::test]
2410    async fn test_mixed_flags_and_positional_fill() {
2411        // --output foo val1 — output is explicit, val1 maps to query
2412        let args = vec![
2413            Arg::LongFlag("output".to_string()),
2414            Arg::Positional(Expr::Literal(Value::String("foo".to_string()))),
2415            Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2416        ];
2417        let schema = make_test_schema();
2418        let ctx = make_minimal_ctx();
2419
2420        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2421
2422        assert_eq!(
2423            tool_args.named.get("output"),
2424            Some(&Value::String("foo".to_string()))
2425        );
2426        assert_eq!(
2427            tool_args.named.get("query"),
2428            Some(&Value::String("val1".to_string()))
2429        );
2430        assert!(tool_args.positional.is_empty());
2431    }
2432
2433    #[tokio::test]
2434    async fn test_alias_flag_prevents_mapping_overwrite() {
2435        // -q "search" "out.txt" — -q is alias for query, so out.txt should map to output
2436        let schema = ToolSchema::new("test", "")
2437            .param(ParamSchema::required("query", "string", "").with_aliases(["-q"]))
2438            .param(ParamSchema::required("output", "string", ""))
2439            .with_positional_mapping();
2440        let args = vec![
2441            Arg::ShortFlag("q".to_string()),
2442            Arg::Positional(Expr::Literal(Value::String("search".to_string()))),
2443            Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
2444        ];
2445        let ctx = make_minimal_ctx();
2446
2447        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2448
2449        assert_eq!(
2450            tool_args.named.get("query"),
2451            Some(&Value::String("search".to_string()))
2452        );
2453        assert_eq!(
2454            tool_args.named.get("output"),
2455            Some(&Value::String("out.txt".to_string()))
2456        );
2457        assert!(tool_args.positional.is_empty());
2458    }
2459
2460    #[tokio::test]
2461    async fn test_builtin_schema_no_positional_mapping() {
2462        // Builtins have map_positionals=false — positionals stay positional
2463        let schema = ToolSchema::new("echo", "")
2464            .param(ParamSchema::optional("args", "any", Value::Null, ""))
2465            .param(ParamSchema::optional("no_newline", "bool", Value::Bool(false), ""));
2466        // Note: no .with_positional_mapping() — this is a builtin
2467        let args = vec![
2468            Arg::Positional(Expr::Literal(Value::String("hello".to_string()))),
2469            Arg::Positional(Expr::Literal(Value::String("world".to_string()))),
2470        ];
2471        let ctx = make_minimal_ctx();
2472
2473        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2474
2475        // Positionals should NOT be consumed as named params
2476        assert_eq!(
2477            tool_args.positional,
2478            vec![
2479                Value::String("hello".to_string()),
2480                Value::String("world".to_string()),
2481            ]
2482        );
2483        assert!(!tool_args.named.contains_key("args"));
2484    }
2485
2486    #[tokio::test]
2487    async fn test_short_flag_with_alias_consumes_value() {
2488        // `-n 5` where `-n` is aliased to `lines` (type: int)
2489        // Should produce named: {"lines": 5}, not flags: {"n"} + positional: [5]
2490        let schema = ToolSchema::new("head", "Output first part of files")
2491            .param(ParamSchema::optional("lines", "int", Value::Int(10), "Number of lines")
2492                .with_aliases(["-n"]));
2493        let args = vec![
2494            Arg::ShortFlag("n".to_string()),
2495            Arg::Positional(Expr::Literal(Value::Int(5))),
2496            Arg::Positional(Expr::Literal(Value::String("/tmp/file.txt".to_string()))),
2497        ];
2498        let ctx = make_minimal_ctx();
2499
2500        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2501
2502        assert!(tool_args.flags.is_empty(), "no boolean flags: {:?}", tool_args.flags);
2503        assert_eq!(tool_args.named.get("lines"), Some(&Value::Int(5)), "should resolve alias to canonical name");
2504        assert_eq!(tool_args.positional, vec![Value::String("/tmp/file.txt".to_string())]);
2505    }
2506
2507    // === GH #188: divergences the pre-unification sync twin couldn't handle ===
2508    //
2509    // The old `scheduler::pipeline::build_tool_args` hand-rolled its own
2510    // flag/positional binder that never supported glued short-flag values or
2511    // `consumes`/`repeatable` accumulation (see the removed comment that used
2512    // to sit on the `LongFlag` arm). Scatter/gather's own schemas never
2513    // exercised these (scalar flags only), so the gap was real but
2514    // un-triggerable in production — these tests pin the now-shared
2515    // `kernel::bind_tool_args` behavior through the reduced sync entry point
2516    // so the two binders can't quietly drift apart on it again.
2517
2518    #[tokio::test]
2519    async fn test_glued_short_flag_value_now_binds() {
2520        // `-f1` (`cut -f1`-shaped): before #188 this fell to the "combined
2521        // short flags" arm and produced two bogus bool flags ("f", "1")
2522        // instead of resolving the declared value-flag's glued value.
2523        let schema = ToolSchema::new("cut", "")
2524            .param(ParamSchema::optional("fields", "string", Value::Null, "").with_aliases(["-f"]));
2525        let args = vec![Arg::ShortFlag("f1".to_string())];
2526        let ctx = make_minimal_ctx();
2527
2528        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2529
2530        assert!(tool_args.flags.is_empty(), "no bogus bool flags: {:?}", tool_args.flags);
2531        assert_eq!(tool_args.named.get("fields"), Some(&Value::String("1".to_string())));
2532    }
2533
2534    #[tokio::test]
2535    async fn test_repeatable_flag_now_accumulates() {
2536        // `-e A -e B`: before #188 the sync twin's value-flag path always
2537        // overwrote `named[canonical]`, silently keeping only the last
2538        // occurrence ("B"). The shared core accumulates both, matching
2539        // `Kernel::build_args_async`.
2540        let schema = ToolSchema::new("sed", "")
2541            .param(ParamSchema::optional("expression", "string", Value::Null, "")
2542                .with_aliases(["-e"])
2543                .with_repeatable(true));
2544        let args = vec![
2545            Arg::ShortFlag("e".to_string()),
2546            Arg::Positional(Expr::Literal(Value::String("A".to_string()))),
2547            Arg::ShortFlag("e".to_string()),
2548            Arg::Positional(Expr::Literal(Value::String("B".to_string()))),
2549        ];
2550        let ctx = make_minimal_ctx();
2551
2552        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2553
2554        assert_eq!(
2555            tool_args.named.get("expression"),
2556            Some(&Value::Json(serde_json::json!(["A", "B"]))),
2557            "both occurrences must survive, not just the last: {:?}",
2558            tool_args.named
2559        );
2560    }
2561
2562    #[tokio::test]
2563    async fn test_multi_consume_flag_now_accumulates() {
2564        // `--arg NAME VAL` (jq-shaped, `consumes == 2`): before #188 the sync
2565        // twin only ever consumed a single positional per flag occurrence,
2566        // so a `consumes: 2` param was unsupported. The shared core
2567        // recognizes it and accumulates array-of-arrays occurrences.
2568        let schema = ToolSchema::new("jq", "")
2569            .param(ParamSchema::optional("arg", "any", Value::Null, "").consumes(2));
2570        let args = vec![
2571            Arg::LongFlag("arg".to_string()),
2572            Arg::Positional(Expr::Literal(Value::String("name".to_string()))),
2573            Arg::Positional(Expr::Literal(Value::String("val".to_string()))),
2574        ];
2575        let ctx = make_minimal_ctx();
2576
2577        let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2578
2579        assert_eq!(
2580            tool_args.named.get("arg"),
2581            Some(&Value::Json(serde_json::json!([["name", "val"]]))),
2582            "got: {:?}",
2583            tool_args.named
2584        );
2585        assert!(tool_args.positional.is_empty());
2586    }
2587
2588    // === Redirect Execution Tests ===
2589
2590    #[tokio::test]
2591    async fn test_merge_stderr_redirect() {
2592        // Test that 2>&1 merges stderr into stdout
2593        let result = ExecResult::from_output(0, "stdout content", "stderr content");
2594
2595        let redirects = vec![Redirect {
2596            kind: RedirectKind::MergeStderr,
2597            target: Expr::Literal(Value::Null),
2598        }];
2599
2600        let ctx = make_minimal_ctx();
2601        let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2602
2603        assert_eq!(&*result.text_out(), "stdout contentstderr content");
2604        assert!(result.err.is_empty());
2605    }
2606
2607    #[tokio::test]
2608    async fn test_merge_stderr_with_empty_stderr() {
2609        // Test that 2>&1 handles empty stderr gracefully
2610        let result = ExecResult::from_output(0, "stdout only", "");
2611
2612        let redirects = vec![Redirect {
2613            kind: RedirectKind::MergeStderr,
2614            target: Expr::Literal(Value::Null),
2615        }];
2616
2617        let ctx = make_minimal_ctx();
2618        let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2619
2620        assert_eq!(&*result.text_out(), "stdout only");
2621        assert!(result.err.is_empty());
2622    }
2623
2624    #[tokio::test]
2625    async fn test_merge_stderr_order_matters() {
2626        // Test redirect ordering: 2>&1 > file means:
2627        // 1. First merge stderr into stdout
2628        // 2. Then write stdout to file (leaving both empty for piping)
2629        // This verifies left-to-right processing
2630        let result = ExecResult::from_output(0, "stdout\n", "stderr\n");
2631
2632        // Just 2>&1 - should merge
2633        let redirects = vec![Redirect {
2634            kind: RedirectKind::MergeStderr,
2635            target: Expr::Literal(Value::Null),
2636        }];
2637
2638        let ctx = make_minimal_ctx();
2639        let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2640
2641        assert_eq!(&*result.text_out(), "stdout\nstderr\n");
2642        assert!(result.err.is_empty());
2643    }
2644
2645    #[tokio::test]
2646    async fn test_redirect_with_command_execution() {
2647        let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
2648
2649        // echo "hello" with 2>&1 redirect
2650        let cmd = Command {
2651            name: "echo".to_string(),
2652            args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
2653            redirects: vec![Redirect {
2654                kind: RedirectKind::MergeStderr,
2655                target: Expr::Literal(Value::Null),
2656            }],
2657        };
2658
2659        let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
2660        assert!(result.ok());
2661        // echo produces no stderr, so this just validates the redirect doesn't break anything
2662        assert!(result.text_out().contains("hello"));
2663    }
2664
2665    #[tokio::test]
2666    async fn test_merge_stderr_in_pipeline() {
2667        let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
2668
2669        // echo "output" 2>&1 | grep "output"
2670        // The 2>&1 should be applied to echo's result, then piped to grep
2671        let echo_cmd = Command {
2672            name: "echo".to_string(),
2673            args: vec![Arg::Positional(Expr::Literal(Value::String("output".to_string())))],
2674            redirects: vec![Redirect {
2675                kind: RedirectKind::MergeStderr,
2676                target: Expr::Literal(Value::Null),
2677            }],
2678        };
2679        let grep_cmd = Command {
2680            name: "grep".to_string(),
2681            args: vec![Arg::Positional(Expr::Literal(Value::String("output".to_string())))],
2682            redirects: vec![],
2683        };
2684
2685        let result = runner.run(&[echo_cmd, grep_cmd], &mut ctx, &dispatcher).await;
2686        assert!(result.ok(), "result failed: code={}, err={}", result.code, result.err);
2687        assert!(result.text_out().contains("output"));
2688    }
2689
2690    // === Item 6: `&>` (RedirectKind::Both) streams structured output ===
2691    //
2692    // `>`/`>>` already stream a command's structured `OutputData` straight to
2693    // a byte buffer via `take_output_for_stream`/`write_canonical` instead of
2694    // building the whole `to_canonical_string()` `String` first. `&>` used to
2695    // skip that path entirely (`result.text_out().into_owned().into_bytes()`,
2696    // which forces the full-string materialization). These tests lock in that
2697    // `&>` now takes the same streaming path and — since the file bytes are
2698    // the only thing observable from outside — that it produces byte-for-byte
2699    // the same content the old materialize-first code did.
2700
2701    fn big_table_output(rows: usize) -> crate::interpreter::OutputData {
2702        use crate::interpreter::OutputNode;
2703        let headers = vec!["id".to_string(), "name".to_string()];
2704        let nodes: Vec<OutputNode> = (0..rows)
2705            .map(|i| OutputNode::new(i.to_string()).with_cells(vec![format!("row-{i}")]))
2706            .collect();
2707        crate::interpreter::OutputData::table(headers, nodes)
2708    }
2709
2710    #[tokio::test]
2711    async fn test_both_redirect_streams_structured_output_to_file() {
2712        // A result with structured `.output` and empty `.out` — exactly the
2713        // shape `take_output_for_stream` requires, and the shape a real
2714        // builtin (e.g. `ls`, `find`) hands back before `--json`/materialize
2715        // ever runs.
2716        let output = big_table_output(50);
2717        let expected_stdout = output.to_canonical_string();
2718        let mut result = ExecResult::with_output(output);
2719        result.err = "warning: heads up\n".to_string();
2720
2721        let redirects = vec![Redirect {
2722            kind: RedirectKind::Both,
2723            target: Expr::Literal(Value::String("/out.txt".to_string())),
2724        }];
2725        let ctx = make_minimal_ctx();
2726        let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2727
2728        // Both streams went to the file: stdout (incl. the sideband) and
2729        // stderr are both dropped from the in-memory result.
2730        assert!(result.ok());
2731        assert_eq!(&*result.text_out(), "");
2732        assert!(result.err.is_empty());
2733        assert!(!result.has_output());
2734
2735        let written = ctx.backend.read(Path::new("/out.txt"), None).await.expect("file written");
2736        let written = String::from_utf8(written).expect("valid utf8");
2737        // Byte-for-byte the same as the pre-refactor path would have produced:
2738        // the table's canonical string, followed by stderr, with nothing lost
2739        // or reordered by streaming it through `write_canonical` instead.
2740        assert_eq!(written, format!("{expected_stdout}warning: heads up\n"));
2741    }
2742
2743    #[tokio::test]
2744    async fn test_both_redirect_streams_large_structured_output_intact() {
2745        // A much bigger table than any single test needs to *pass*, but large
2746        // enough that a regression re-introducing a size-limited or
2747        // truncating path (rather than genuinely streaming) would be caught:
2748        // every row must survive the round-trip through `&>`.
2749        let rows = 5_000;
2750        let output = big_table_output(rows);
2751        let expected_stdout = output.to_canonical_string();
2752        let result = ExecResult::with_output(output);
2753
2754        let redirects = vec![Redirect {
2755            kind: RedirectKind::Both,
2756            target: Expr::Literal(Value::String("/big.txt".to_string())),
2757        }];
2758        let ctx = make_minimal_ctx();
2759        let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2760        assert!(result.ok());
2761
2762        let written = ctx.backend.read(Path::new("/big.txt"), None).await.expect("file written");
2763        let written = String::from_utf8(written).expect("valid utf8");
2764        assert_eq!(written, expected_stdout);
2765        assert!(written.contains("row-0"));
2766        assert!(written.contains(&format!("row-{}", rows - 1)));
2767    }
2768
2769    #[tokio::test]
2770    async fn test_both_redirect_still_writes_binary_stdout_raw() {
2771        // Unchanged branch (`out_bytes()`), covered here so the refactor
2772        // can't accidentally regress the binary path while touching the
2773        // structured-output branch next to it.
2774        let result = ExecResult::success_text_or_bytes(vec![0xff, 0x00, 0xfe, b'x']);
2775        let redirects = vec![Redirect {
2776            kind: RedirectKind::Both,
2777            target: Expr::Literal(Value::String("/bin.out".to_string())),
2778        }];
2779        let ctx = make_minimal_ctx();
2780        let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2781        assert!(result.ok());
2782
2783        let written = ctx.backend.read(Path::new("/bin.out"), None).await.expect("file written");
2784        assert_eq!(written, vec![0xff, 0x00, 0xfe, b'x']);
2785    }
2786}