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