Skip to main content

kaish_kernel/scheduler/
pipeline.rs

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