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