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