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