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