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