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