1use std::sync::Arc;
9
10use std::collections::HashMap;
11
12use crate::arithmetic;
13use crate::ast::{Arg, Command, Expr, PipelineStage, Redirect, RedirectKind, Value};
14use crate::dispatch::{CommandDispatcher, PipelinePosition};
15use crate::interpreter::{apply_output_format, ExecResult, OutputFormat, PathError};
16use crate::tools::{global_flag_value_is_truthy, ExecContext, ToolArgs, ToolRegistry, ToolSchema};
17use tokio::io::AsyncWriteExt;
18
19use super::pipe_stream::pipe_stream_default;
20use super::scatter::{
21 parse_gather_options, parse_scatter_options, ScatterGatherRunner,
22};
23
24fn has_json_flag(args: &[Arg]) -> bool {
40 let mut past_double_dash = false;
41 for arg in args {
42 match arg {
43 Arg::DoubleDash => past_double_dash = true,
44 _ if past_double_dash => {}
45 Arg::LongFlag(name) if name == "json" => return true,
46 Arg::Named { key, value } if key == "json" => {
47 let on = match value {
48 Expr::Literal(v) => global_flag_value_is_truthy(v),
49 _ => true,
50 };
51 if on {
52 return true;
53 }
54 }
55 _ => {}
56 }
57 }
58 false
59}
60
61fn finalize_scatter_gather_error(result: ExecResult, format: Option<OutputFormat>) -> ExecResult {
71 match format {
72 Some(format) => apply_output_format(result, format),
73 None => result,
74 }
75}
76
77pub(crate) async fn apply_redirects(
83 mut result: ExecResult,
84 redirects: &[Redirect],
85 ctx: &ExecContext,
86 dispatcher: &dyn CommandDispatcher,
87) -> ExecResult {
88 for redir in redirects {
93 match redir.kind {
94 RedirectKind::MergeStderr => {
95 result.materialize();
98 if !result.err.is_empty() {
99 let err = std::mem::take(&mut result.err);
100 result.push_out(&err);
101 }
102 }
103 RedirectKind::MergeStdout => {
104 if result.is_bytes() {
108 return ExecResult::failure(
109 1,
110 "redirect: cannot merge binary stdout into stderr (1>&2) — \
111 redirect it to a file or pipe through base64/xxd",
112 );
113 }
114 result.materialize();
115 if !result.text_out().is_empty() {
116 let out = result.text_out().into_owned();
117 result.err.push_str(&out);
118 }
119 result.clear_stdout();
125 }
126 RedirectKind::StdoutOverwrite => {
127 let path = match eval_redirect_target(&redir.target, ctx, dispatcher).await {
128 Ok(p) => p,
129 Err(e) => return ExecResult::failure(1, format!("redirect: {e}")),
130 };
131 if let Some(bytes) = result.out_bytes() {
133 if let Err(e) = redirect_write(ctx, &path, bytes).await {
134 return ExecResult::failure(1, format!("redirect: {e}"));
135 }
136 } else if let Some(output) = result.take_output_for_stream() {
137 let mut buf = Vec::new();
139 if let Err(e) = output.write_canonical(&mut buf, None) {
140 return ExecResult::failure(1, format!("redirect: {e}"));
141 }
142 if let Err(e) = redirect_write(ctx, &path, &buf).await {
143 return ExecResult::failure(1, format!("redirect: {e}"));
144 }
145 } else if let Err(e) = redirect_write(ctx, &path, result.text_out().as_bytes()).await {
146 return ExecResult::failure(1, format!("redirect: {e}"));
147 }
148 result.clear_stdout();
150 }
151 RedirectKind::StdoutAppend => {
152 let path = match eval_redirect_target(&redir.target, ctx, dispatcher).await {
153 Ok(p) => p,
154 Err(e) => return ExecResult::failure(1, format!("redirect: {e}")),
155 };
156 if let Some(bytes) = result.out_bytes() {
158 if let Err(e) = redirect_append(ctx, &path, bytes).await {
159 return ExecResult::failure(1, format!("redirect: {e}"));
160 }
161 } else if let Some(output) = result.take_output_for_stream() {
162 let mut buf = Vec::new();
164 if let Err(e) = output.write_canonical(&mut buf, None) {
165 return ExecResult::failure(1, format!("redirect: {e}"));
166 }
167 if let Err(e) = redirect_append(ctx, &path, &buf).await {
168 return ExecResult::failure(1, format!("redirect: {e}"));
169 }
170 } else if let Err(e) = redirect_append(ctx, &path, result.text_out().as_bytes()).await {
171 return ExecResult::failure(1, format!("redirect: {e}"));
172 }
173 result.clear_stdout();
175 }
176 RedirectKind::Stderr => {
177 let path = match eval_redirect_target(&redir.target, ctx, dispatcher).await {
178 Ok(p) => p,
179 Err(e) => return ExecResult::failure(1, format!("redirect: {e}")),
180 };
181 if let Err(e) = redirect_write(ctx, &path, result.err.as_bytes()).await {
182 return ExecResult::failure(1, format!("redirect: {e}"));
183 }
184 result.err.clear();
185 }
186 RedirectKind::Both => {
187 let path = match eval_redirect_target(&redir.target, ctx, dispatcher).await {
188 Ok(p) => p,
189 Err(e) => return ExecResult::failure(1, format!("redirect: {e}")),
190 };
191 let mut combined: Vec<u8> = if let Some(b) = result.out_bytes() {
199 b.to_vec()
200 } else if let Some(output) = result.take_output_for_stream() {
201 let mut buf = Vec::new();
202 if let Err(e) = output.write_canonical(&mut buf, None) {
203 return ExecResult::failure(1, format!("redirect: {e}"));
204 }
205 buf
206 } else {
207 result.text_out().into_owned().into_bytes()
208 };
209 combined.extend_from_slice(result.err.as_bytes());
210 if let Err(e) = redirect_write(ctx, &path, &combined).await {
211 return ExecResult::failure(1, format!("redirect: {e}"));
212 }
213 result.clear_stdout();
215 result.err.clear();
216 }
217 RedirectKind::Stdin | RedirectKind::HereDoc(_) | RedirectKind::HereString => {}
219 }
220 }
221 result
230}
231
232async fn eval_redirect_target(
244 expr: &Expr,
245 ctx: &ExecContext,
246 dispatcher: &dyn CommandDispatcher,
247) -> Result<String, String> {
248 if let Expr::NumericLiteral { raw, .. } = expr {
254 return Ok(raw.clone());
255 }
256 let value = dispatcher
257 .eval_expr(expr, ctx)
258 .await
259 .map_err(|e| e.to_string())?;
260 if let Some(msg) = crate::interpreter::structured_boundary_error("a redirect target", &value) {
263 return Err(msg);
264 }
265 crate::interpreter::value_to_text_sink_named(&value, "a redirect target").map_err(|e| e.to_string())
268}
269
270async fn redirect_write(ctx: &ExecContext, path: &str, data: &[u8]) -> Result<(), String> {
277 use crate::backend::WriteMode;
278 let resolved = ctx.resolve_path(path);
279 ctx.backend.write(&resolved, data, WriteMode::Overwrite).await.map_err(|e| e.to_string())
280}
281
282async fn redirect_append(ctx: &ExecContext, path: &str, data: &[u8]) -> Result<(), String> {
286 let resolved = ctx.resolve_path(path);
287 ctx.backend.append(&resolved, data).await.map_err(|e| e.to_string())
288}
289
290async fn setup_stdin_redirects(
303 cmd: &Command,
304 ctx: &mut ExecContext,
305 dispatcher: &dyn CommandDispatcher,
306) -> Result<(), String> {
307 use std::path::Path;
308 for redir in &cmd.redirects {
309 match &redir.kind {
310 RedirectKind::Stdin => {
311 let path = eval_redirect_target(&redir.target, ctx, dispatcher).await?;
312 let resolved = ctx.resolve_path(&path);
313 let data = ctx
314 .backend
315 .read(Path::new(&resolved), None)
316 .await
317 .map_err(|e| format!("redirect: {path}: {e}"))?;
318 ctx.set_stdin(data);
319 }
320 RedirectKind::HereDoc(_) => {
321 match &redir.target {
322 Expr::Literal(Value::String(content)) => {
323 ctx.set_stdin(content.clone());
324 }
325 expr => {
328 let body = eval_redirect_target(expr, ctx, dispatcher).await?;
329 ctx.set_stdin(body);
330 }
331 }
332 }
333 RedirectKind::HereString => {
334 let mut s = eval_redirect_target(&redir.target, ctx, dispatcher).await?;
337 s.push('\n');
338 ctx.set_stdin(s);
339 }
340 _ => {}
341 }
342 }
343 Ok(())
344}
345
346async fn setup_stdin_redirects_for(
350 stage: &PipelineStage,
351 ctx: &mut ExecContext,
352 dispatcher: &dyn CommandDispatcher,
353) -> Result<(), String> {
354 match stage {
355 PipelineStage::Command(cmd) => setup_stdin_redirects(cmd, ctx, dispatcher).await,
356 PipelineStage::Compound(_) => Ok(()),
357 }
358}
359
360async fn dispatch_stage(
362 stage: &PipelineStage,
363 ctx: &mut ExecContext,
364 dispatcher: &dyn CommandDispatcher,
365) -> anyhow::Result<ExecResult> {
366 match stage {
367 PipelineStage::Command(cmd) => dispatcher.dispatch(cmd, ctx).await,
368 PipelineStage::Compound(stmt) => dispatcher.dispatch_stmt(stmt, ctx).await,
369 }
370}
371
372#[derive(Clone)]
374pub struct PipelineRunner {
375 tools: Arc<ToolRegistry>,
376}
377
378impl PipelineRunner {
379 pub fn new(tools: Arc<ToolRegistry>) -> Self {
381 Self { tools }
382 }
383
384 pub async fn run(
394 &self,
395 stages: &[PipelineStage],
396 ctx: &mut ExecContext,
397 dispatcher: &dyn CommandDispatcher,
398 ) -> ExecResult {
399 if stages.is_empty() {
400 return ExecResult::success("");
401 }
402
403 if let Some((scatter_idx, gather_idx)) = find_scatter_gather(stages) {
408 let commands: Vec<Command> = match stages
409 .iter()
410 .map(|s| s.as_command().cloned())
411 .collect::<Option<Vec<_>>>()
412 {
413 Some(commands) => commands,
414 None => {
415 return ExecResult::failure(
416 2,
417 "scatter/gather cannot share a pipeline with an if/for/while/case \
418 stage. Run the compound on its own and pipe its output in.",
419 )
420 }
421 };
422 return self
423 .run_scatter_gather(&commands, scatter_idx, gather_idx, ctx, dispatcher)
424 .await;
425 }
426
427 self.run_stage_sequence(stages, ctx, dispatcher).await
428 }
429
430 pub async fn run_sequential(
435 &self,
436 commands: &[Command],
437 ctx: &mut ExecContext,
438 dispatcher: &dyn CommandDispatcher,
439 ) -> ExecResult {
440 let stages: Vec<PipelineStage> = commands
441 .iter()
442 .cloned()
443 .map(PipelineStage::Command)
444 .collect();
445 self.run_stage_sequence(&stages, ctx, dispatcher).await
446 }
447
448 async fn run_stage_sequence(
450 &self,
451 stages: &[PipelineStage],
452 ctx: &mut ExecContext,
453 dispatcher: &dyn CommandDispatcher,
454 ) -> ExecResult {
455 if stages.is_empty() {
456 return ExecResult::success("");
457 }
458
459 if stages.len() == 1 {
460 let result = self.run_single(&stages[0], ctx, None, dispatcher).await;
462 ctx.scope.set_pipestatus(&[result.code]);
467 return result;
468 }
469
470 self.run_pipeline(stages, ctx, dispatcher).await
472 }
473
474 async fn run_scatter_gather(
476 &self,
477 commands: &[Command],
478 scatter_idx: usize,
479 gather_idx: usize,
480 ctx: &mut ExecContext,
481 dispatcher: &dyn CommandDispatcher,
482 ) -> ExecResult {
483 let pre_scatter = &commands[..scatter_idx];
485 let scatter_cmd = &commands[scatter_idx];
486 let parallel = &commands[scatter_idx + 1..gather_idx];
487 let gather_cmd = &commands[gather_idx];
488 let post_gather = &commands[gather_idx + 1..];
489
490 let format = (has_json_flag(&scatter_cmd.args) || has_json_flag(&gather_cmd.args))
501 .then_some(OutputFormat::Json);
502
503 let scatter_schema = self.tools.get("scatter").map(|t| t.schema());
511 let gather_schema = self.tools.get("gather").map(|t| t.schema());
512 let scatter_args = match build_tool_args(&scatter_cmd.args, ctx, scatter_schema.as_ref()).await {
513 Ok(args) => args,
514 Err(e) => {
515 return finalize_scatter_gather_error(
516 ExecResult::failure(1, format!("scatter: {e}")),
517 format,
518 )
519 }
520 };
521 let gather_args = match build_tool_args(&gather_cmd.args, ctx, gather_schema.as_ref()).await {
522 Ok(args) => args,
523 Err(e) => {
524 return finalize_scatter_gather_error(
525 ExecResult::failure(1, format!("gather: {e}")),
526 format,
527 )
528 }
529 };
530 let scatter_opts = match parse_scatter_options(&scatter_args) {
531 Ok(opts) => opts,
532 Err(e) => {
533 return finalize_scatter_gather_error(
534 ExecResult::failure(2, format!("scatter: {e}")),
535 format,
536 )
537 }
538 };
539 let gather_opts = match parse_gather_options(&gather_args) {
540 Ok(opts) => opts,
541 Err(e) => {
542 return finalize_scatter_gather_error(
543 ExecResult::failure(2, format!("gather: {e}")),
544 format,
545 )
546 }
547 };
548
549 let sequential_dispatcher: Arc<dyn CommandDispatcher> = dispatcher.fork_attached().await;
554
555 let runner = ScatterGatherRunner::new(self.tools.clone(), sequential_dispatcher);
556 runner
557 .run(
558 pre_scatter,
559 scatter_opts,
560 parallel,
561 gather_opts,
562 &gather_cmd.redirects,
563 post_gather,
564 ctx,
565 )
566 .await
567 }
568
569 async fn run_single(
574 &self,
575 stage: &PipelineStage,
576 ctx: &mut ExecContext,
577 stdin: Option<Vec<u8>>,
578 dispatcher: &dyn CommandDispatcher,
579 ) -> ExecResult {
580 if let Err(e) = setup_stdin_redirects_for(stage, ctx, dispatcher).await {
582 return ExecResult::failure(1, e);
583 }
584
585 if let Some(input) = stdin {
587 ctx.set_stdin(input);
588 }
589
590 ctx.pipeline_position = PipelinePosition::Only;
592
593 let result = match dispatch_stage(stage, ctx, dispatcher).await {
595 Ok(result) => result,
596 Err(e) => ExecResult::failure(1, e.to_string()),
597 };
598
599 apply_redirects(result, stage.redirects(), ctx, dispatcher).await
601 }
602
603 async fn run_pipeline(
620 &self,
621 stages: &[PipelineStage],
622 ctx: &mut ExecContext,
623 dispatcher: &dyn CommandDispatcher,
624 ) -> ExecResult {
625 let stage_count = stages.len();
626 let last_idx = stage_count - 1;
627
628 let mut pipe_writers: Vec<Option<super::pipe_stream::PipeWriter>> = Vec::new();
630 let mut pipe_readers: Vec<Option<super::pipe_stream::PipeReader>> = Vec::new();
631
632 for _ in 0..last_idx {
633 let (writer, reader) = pipe_stream_default();
634 pipe_writers.push(Some(writer));
635 pipe_readers.push(Some(reader));
636 }
637
638 let mut data_senders: Vec<Option<tokio::sync::oneshot::Sender<Option<Value>>>> = Vec::new();
640 let mut data_receivers: Vec<Option<tokio::sync::oneshot::Receiver<Option<Value>>>> = Vec::new();
641
642 for _ in 0..last_idx {
643 let (tx, rx) = tokio::sync::oneshot::channel();
644 data_senders.push(Some(tx));
645 data_receivers.push(Some(rx));
646 }
647
648 let mut handles: Vec<tokio::task::JoinHandle<(ExecResult, ExecContext)>> = Vec::with_capacity(stage_count);
649 let mut stage0_took_session_stdin = false;
652 let mut stage0_took_session_pipe_stdin = false;
658
659 for (i, stage) in stages.iter().enumerate() {
660 let mut stage_ctx = ctx.child_for_pipeline();
661 let stage = stage.clone();
662
663 let task_dispatcher: Arc<dyn CommandDispatcher> = dispatcher.fork_attached().await;
668
669 let stdin_setup = setup_stdin_redirects_for(&stage, &mut stage_ctx, dispatcher).await;
673
674 if i == 0 {
676 let redirect_set_stdin = stage_ctx.stdin.is_some();
683 stage0_took_session_stdin = !redirect_set_stdin;
684 if !redirect_set_stdin {
687 stage_ctx.stdin = ctx.stdin.take();
688 }
689 if stage_ctx.stdin_data.is_none() {
690 stage_ctx.stdin_data = ctx.stdin_data.take();
691 }
692 if !redirect_set_stdin && stage_ctx.pipe_stdin.is_none() {
701 stage_ctx.pipe_stdin = ctx.pipe_stdin.take();
702 stage0_took_session_pipe_stdin = true;
703 }
704 } else {
705 stage_ctx.pipe_stdin = pipe_readers[i - 1].take();
707 }
709
710 if i < last_idx {
712 stage_ctx.pipe_stdout = pipe_writers[i].take();
713 }
714
715 stage_ctx.pipeline_position = match i {
717 0 => PipelinePosition::First,
718 n if n == last_idx => PipelinePosition::Last,
719 _ => PipelinePosition::Middle,
720 };
721
722 let data_sender = if i < last_idx { data_senders[i].take() } else { None };
723 let data_receiver = if i > 0 { data_receivers[i - 1].take() } else { None };
724
725 let handle: tokio::task::JoinHandle<(ExecResult, ExecContext)> =
728 tokio::spawn(crate::telemetry::bind_current_context(async move {
729 if let Err(e) = stdin_setup {
731 return (ExecResult::failure(1, e), stage_ctx);
732 }
733
734 stage_ctx.stdin_data_rx = data_receiver;
742
743 let mut result = match dispatch_stage(&stage, &mut stage_ctx, &*task_dispatcher).await {
745 Ok(result) => result,
746 Err(e) => ExecResult::failure(1, e.to_string()),
747 };
748
749 result = apply_redirects(result, stage.redirects(), &stage_ctx, &*task_dispatcher).await;
754
755 if !result.err.is_empty() {
761 if let Some(ref stderr) = stage_ctx.stderr {
762 stderr.write_str(&result.err);
763 result.err.clear();
764 }
765 }
766
767 if let Some(tx) = data_sender {
773 let _ = tx.send(result.data.clone());
774 }
775
776 if let Some(mut pipe_out) = stage_ctx.pipe_stdout.take() {
779 let bytes: Vec<u8> = if let Some(b) = result.out_bytes() {
787 b.to_vec()
788 } else if let Some(output) = result.take_output_for_stream() {
789 let mut buf = Vec::new();
790 if output.write_canonical(&mut buf, None).is_err() {
796 buf = output.to_canonical_string().into_bytes();
797 }
798 buf
799 } else {
800 result.text_out().into_owned().into_bytes()
801 };
802 if !bytes.is_empty() {
803 let _ = pipe_out.write_all(&bytes).await;
805 let _ = pipe_out.shutdown().await;
806 }
807 }
809
810 (result, stage_ctx)
811 }));
812
813 handles.push(handle);
814 }
815
816 let mut last_result = ExecResult::success("");
821 let mut panics: Vec<String> = Vec::new();
822 let mut codes: Vec<i64> = vec![1; handles.len()];
827
828 for (i, handle) in handles.into_iter().enumerate() {
829 match handle.await {
830 Ok((result, mut stage_ctx)) => {
831 codes[i] = result.code;
832 if i == 0 && stage0_took_session_stdin {
843 ctx.stdin = stage_ctx.stdin.take();
844 }
845 if i == 0 && stage0_took_session_pipe_stdin {
846 ctx.pipe_stdin = stage_ctx.pipe_stdin.take();
847 }
848 if i == last_idx {
849 last_result = result;
850 ctx.scope = stage_ctx.scope;
852 ctx.cwd = stage_ctx.cwd;
853 ctx.prev_cwd = stage_ctx.prev_cwd;
854 ctx.aliases = stage_ctx.aliases;
855 }
856 }
857 Err(e) => {
858 panics.push(format!("stage {}: {}", i, e));
859 }
860 }
861 }
862
863
864 ctx.scope.set_pipestatus(&codes);
867
868 if !panics.is_empty() {
871 last_result = ExecResult::failure(
872 1,
873 format!("pipeline stage(s) panicked: {}", panics.join("; ")),
874 );
875 }
876
877 last_result
878 }
879}
880
881pub fn select_leaf<'a>(schema: &'a ToolSchema, args: &[Arg]) -> anyhow::Result<&'a ToolSchema> {
921 let root_lookup = schema_param_lookup(schema);
924 let is_root_value_flag = |name: &str| -> bool {
925 root_lookup.get(name).is_some_and(|(_, typ, ..)| !is_bool_type(typ))
926 };
927
928 let mut node = schema;
929 let mut skip_next_positional = false;
930 for arg in args {
931 match arg {
932 Arg::DoubleDash => break,
934 Arg::LongFlag(name) if is_root_value_flag(name) => skip_next_positional = true,
937 Arg::ShortFlag(name) if is_root_value_flag(name) => skip_next_positional = true,
938 Arg::Positional(expr) => {
939 if skip_next_positional {
940 skip_next_positional = false;
941 continue; }
943 if node.subcommands.is_empty() {
944 break; }
946 match classify_subcommand_positional(expr) {
947 SubcommandWord::Word(word) => {
948 match node.subcommands.iter().find(|c| c.matches_command(word)) {
949 Some(child) => node = child, None => break, }
952 }
953 SubcommandWord::OtherLiteral => break,
957 SubcommandWord::Computed(kind) => anyhow::bail!(
958 "{}: a subcommand name is required here, but got {kind}. \
959 Subcommands must be literal words — spell it out \
960 (e.g. `{} <subcommand> …`) or use the `--flag=value` form.",
961 node.name,
962 schema.name
963 ),
964 }
965 }
966 _ => {}
968 }
969 }
970 Ok(node)
971}
972
973enum SubcommandWord<'a> {
975 Word(&'a str),
977 OtherLiteral,
979 Computed(&'static str),
981}
982
983fn classify_subcommand_positional(expr: &Expr) -> SubcommandWord<'_> {
984 match expr {
985 Expr::Literal(Value::String(s)) => SubcommandWord::Word(s),
986 Expr::Literal(_) => SubcommandWord::OtherLiteral,
987 Expr::CommandSubst(_) | Expr::Command(_) => SubcommandWord::Computed("a command substitution `$(…)`"),
988 Expr::VarRef(_)
989 | Expr::VarWithDefault { .. }
990 | Expr::VarLength(_)
991 | Expr::Positional(_)
992 | Expr::AllArgs
993 | Expr::ArgCount
994 | Expr::CurrentPid
995 | Expr::LastExitCode => SubcommandWord::Computed("a variable reference"),
996 Expr::Interpolated(_) | Expr::HereDocBody { .. } => SubcommandWord::Computed("an interpolated string"),
997 Expr::GlobPattern(_) => SubcommandWord::Computed("a glob pattern"),
998 Expr::Arithmetic(_) => SubcommandWord::Computed("an arithmetic expansion"),
999 _ => SubcommandWord::Computed("a value computed at runtime"),
1000 }
1001}
1002
1003pub fn schema_param_lookup(schema: &ToolSchema) -> HashMap<String, (&str, &str, usize, bool)> {
1004 let mut map = HashMap::new();
1005 for p in schema.params.iter().filter(|p| !p.positional) {
1006 map.insert(p.name.clone(), (p.name.as_str(), p.param_type.as_str(), p.consumes, p.repeatable));
1007 for alias in &p.aliases {
1008 let stripped = alias.trim_start_matches('-');
1009 map.insert(stripped.to_string(), (p.name.as_str(), p.param_type.as_str(), p.consumes, p.repeatable));
1010 }
1011 }
1012 map
1013}
1014
1015pub fn is_bool_type(param_type: &str) -> bool {
1017 matches!(param_type.to_lowercase().as_str(), "bool" | "boolean")
1018}
1019
1020struct SyncEvalSource<'a> {
1028 ctx: &'a ExecContext,
1029}
1030
1031#[async_trait::async_trait]
1032impl crate::kernel::ArgValueSource for SyncEvalSource<'_> {
1033 async fn eval(&self, expr: &Expr) -> anyhow::Result<Option<Value>> {
1034 eval_simple_expr(expr, self.ctx).map_err(|e| anyhow::anyhow!(e))
1035 }
1036
1037 async fn expand_glob(&self, _pattern: &str) -> anyhow::Result<Option<Vec<String>>> {
1038 Ok(None)
1044 }
1045
1046 async fn home(&self) -> Option<String> {
1047 None
1050 }
1051}
1052
1053pub async fn build_tool_args(
1067 args: &[Arg],
1068 ctx: &ExecContext,
1069 schema: Option<&ToolSchema>,
1070) -> Result<ToolArgs, String> {
1071 crate::kernel::bind_tool_args(args, schema, &SyncEvalSource { ctx })
1072 .await
1073 .map_err(|e| e.to_string())
1074}
1075
1076fn command_subst_flag_value_message() -> String {
1080 "command substitution `$(...)` is not supported in a scatter/gather flag value here; \
1081 assign it to a variable first (e.g. `n=$(...); scatter --limit $n`)"
1082 .to_string()
1083}
1084
1085fn command_subst_interpolated_value_message() -> String {
1088 "command substitution `$(...)` is not supported inside a scatter/gather \
1089 flag's interpolated value here; assign it to a variable first"
1090 .to_string()
1091}
1092
1093pub(crate) fn eval_simple_expr(expr: &Expr, ctx: &ExecContext) -> Result<Option<Value>, String> {
1109 match expr {
1110 Expr::Literal(value) => Ok(Some(eval_literal(value, ctx))),
1111 Expr::VarRef(path) => match ctx.scope.resolve_path(path) {
1112 Ok(v) => Ok(Some(v)),
1113 Err(PathError::UndefinedRoot(_)) if path.segments.len() <= 1 => Ok(None),
1120 Err(PathError::UndefinedRoot(_)) => Err(format!(
1121 "{}: undefined variable",
1122 crate::interpreter::format_path(path)
1123 )),
1124 Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => Err(msg),
1127 },
1128 Expr::Interpolated(parts) => Ok(Some(Value::String(eval_string_parts_sync(parts, ctx)?))),
1129 Expr::VarLength(path) => {
1134 crate::interpreter::resolve_length(&ctx.scope, path).map(|n| Some(Value::Int(n)))
1135 }
1136 Expr::VarWithDefault { path, default } => {
1137 match crate::interpreter::resolve_default(&ctx.scope, path)? {
1138 Some(value) => Ok(Some(value)),
1139 None => Ok(Some(Value::String(eval_string_parts_sync(default, ctx)?))),
1140 }
1141 }
1142 Expr::GlobPattern(s) => Ok(Some(Value::String(s.clone()))),
1143 Expr::Arithmetic(expr_str) => {
1156 let parsed = arithmetic::parse(expr_str).map_err(|e| format!("arithmetic error: {e}"))?;
1157 if parsed.contains_command_subst() {
1158 Err(command_subst_flag_value_message())
1159 } else {
1160 arithmetic::eval_sync(&parsed, &ctx.scope)
1161 .map(|n| Some(Value::Int(n)))
1162 .map_err(|e| format!("arithmetic error: {e}"))
1163 }
1164 }
1165 Expr::HereDocBody { parts, strip_tabs } => {
1166 let mut asm = crate::interpreter::HeredocAssembler::new(*strip_tabs);
1170 for sp in parts {
1171 match &sp.part {
1172 crate::ast::StringPart::Literal(s) => asm.push_literal(s),
1173 other => {
1174 let s = eval_string_parts_sync(std::slice::from_ref(other), ctx)?;
1175 asm.push_interpolated(&s);
1176 }
1177 }
1178 }
1179 Ok(Some(Value::String(asm.into_string())))
1180 }
1181 Expr::CommandSubst(_) | Expr::Command(_) => Err(command_subst_flag_value_message()),
1188 _ => Ok(None), }
1190}
1191
1192fn eval_literal(value: &Value, _ctx: &ExecContext) -> Value {
1194 value.clone()
1195}
1196
1197fn eval_string_parts_sync(parts: &[crate::ast::StringPart], ctx: &ExecContext) -> Result<String, String> {
1205 let mut result = String::new();
1206 for part in parts {
1207 match part {
1208 crate::ast::StringPart::Literal(s) => result.push_str(s),
1209 crate::ast::StringPart::Var(path) => match ctx.scope.resolve_path(path) {
1210 Ok(value) => result.push_str(
1214 &crate::interpreter::value_to_text_sink(&value).map_err(|e| e.to_string())?,
1215 ),
1216 Err(PathError::UndefinedRoot(_)) => {}
1224 Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => return Err(msg),
1225 },
1226 crate::ast::StringPart::VarWithDefault { path, default } => {
1227 match crate::interpreter::resolve_default(&ctx.scope, path)? {
1228 Some(value) => result.push_str(
1229 &crate::interpreter::value_to_text_sink(&value).map_err(|e| e.to_string())?,
1230 ),
1231 None => result.push_str(&eval_string_parts_sync(default, ctx)?),
1232 }
1233 }
1234 crate::ast::StringPart::VarLength(path) => {
1235 let len = crate::interpreter::resolve_length(&ctx.scope, path)?;
1240 result.push_str(&len.to_string());
1241 }
1242 crate::ast::StringPart::Positional(n) => {
1243 if let Some(s) = ctx.scope.get_positional(*n) {
1244 result.push_str(s);
1245 }
1246 }
1247 crate::ast::StringPart::AllArgs => {
1248 result.push_str(&ctx.scope.all_args().join(" "));
1249 }
1250 crate::ast::StringPart::ArgCount => {
1251 result.push_str(&ctx.scope.arg_count().to_string());
1252 }
1253 crate::ast::StringPart::Arithmetic(expr) => {
1254 let parsed = arithmetic::parse(expr).map_err(|e| format!("arithmetic error: {e}"))?;
1268 if parsed.contains_command_subst() {
1269 return Err(command_subst_interpolated_value_message());
1270 }
1271 let value = arithmetic::eval_sync(&parsed, &ctx.scope)
1272 .map_err(|e| format!("arithmetic error: {e}"))?;
1273 result.push_str(&value.to_string());
1274 }
1275 crate::ast::StringPart::CommandSubst(_) => {
1276 return Err(command_subst_interpolated_value_message());
1282 }
1283 crate::ast::StringPart::LastExitCode => {
1284 result.push_str(&ctx.scope.last_result().code.to_string());
1285 }
1286 crate::ast::StringPart::CurrentPid => {
1287 result.push_str(&ctx.scope.pid().to_string());
1288 }
1289 }
1290 }
1291 Ok(result)
1292}
1293
1294fn find_scatter_gather(stages: &[PipelineStage]) -> Option<(usize, usize)> {
1299 let named = |name: &str| {
1300 stages
1301 .iter()
1302 .position(|s| s.as_command().is_some_and(|c| c.name == name))
1303 };
1304 let scatter_idx = named("scatter")?;
1305 let gather_idx = named("gather")?;
1306
1307 if gather_idx > scatter_idx {
1309 Some((scatter_idx, gather_idx))
1310 } else {
1311 None
1312 }
1313}
1314
1315#[cfg(test)]
1316mod select_leaf_tests {
1317 use super::*;
1318 use crate::tools::ParamSchema;
1319
1320 fn kj_schema() -> ToolSchema {
1325 ToolSchema::new("kj", "kaijutsu")
1326 .param(ParamSchema::new("confirm", "string"))
1327 .param(ParamSchema::new("verbose", "bool"))
1328 .subcommand(
1329 ToolSchema::new("context", "context ops")
1330 .with_command_aliases(["ctx"])
1331 .subcommand(ToolSchema::new("list", "list").with_command_aliases(["ls"]))
1332 .subcommand(
1333 ToolSchema::new("create", "create").param(
1334 ParamSchema::new("type", "string").with_aliases(["t"]),
1335 ),
1336 ),
1337 )
1338 }
1339
1340 fn word(s: &str) -> Arg {
1341 Arg::Positional(Expr::Literal(Value::String(s.to_string())))
1342 }
1343
1344 #[test]
1345 fn flat_tool_returns_root() {
1346 let schema = ToolSchema::new("cat", "concat")
1347 .param(ParamSchema::required("path", "string", "f").positional());
1348 let leaf = select_leaf(&schema, &[word("foo.txt")]).expect("flat ok");
1349 assert_eq!(leaf.name, "cat");
1350 }
1351
1352 #[test]
1353 fn single_hop() {
1354 let schema = kj_schema();
1355 let leaf = select_leaf(&schema, &[word("context")]).expect("ok");
1356 assert_eq!(leaf.name, "context");
1357 }
1358
1359 #[test]
1360 fn two_hops() {
1361 let schema = kj_schema();
1362 let leaf = select_leaf(&schema, &[word("context"), word("create")]).expect("ok");
1363 assert_eq!(leaf.name, "create");
1364 assert!(leaf.params.iter().any(|p| p.name == "type"), "leaf has --type");
1365 }
1366
1367 #[test]
1368 fn alias_hops_route() {
1369 let schema = kj_schema();
1370 let leaf = select_leaf(&schema, &[word("ctx"), word("ls")]).expect("ok");
1372 assert_eq!(leaf.name, "list");
1373 }
1374
1375 #[test]
1376 fn unknown_subcommand_stops_at_current_node() {
1377 let schema = kj_schema();
1378 let leaf = select_leaf(&schema, &[word("context"), word("nonesuch")]).expect("ok");
1381 assert_eq!(leaf.name, "context");
1382 }
1383
1384 #[test]
1385 fn root_bool_flag_before_path_does_not_disrupt_routing() {
1386 let schema = kj_schema();
1387 let args = vec![Arg::LongFlag("verbose".into()), word("context"), word("create")];
1390 let leaf = select_leaf(&schema, &args).expect("ok");
1391 assert_eq!(leaf.name, "create");
1392 }
1393
1394 #[test]
1395 fn root_value_flag_space_form_before_path_skips_its_value() {
1396 let schema = kj_schema();
1397 let args = vec![
1400 Arg::LongFlag("confirm".into()),
1401 word("token"),
1402 word("context"),
1403 word("create"),
1404 ];
1405 let leaf = select_leaf(&schema, &args).expect("ok");
1406 assert_eq!(leaf.name, "create");
1407 }
1408
1409 #[test]
1410 fn leaf_value_flag_after_path_routes_to_leaf() {
1411 let schema = kj_schema();
1412 let args = vec![
1415 word("context"),
1416 word("create"),
1417 Arg::LongFlag("type".into()),
1418 word("x"),
1419 ];
1420 let leaf = select_leaf(&schema, &args).expect("ok");
1421 assert_eq!(leaf.name, "create");
1422 assert!(leaf.params.iter().any(|p| p.name == "type"));
1423 }
1424
1425 #[test]
1426 fn double_dash_stops_routing() {
1427 let schema = kj_schema();
1428 let leaf = select_leaf(&schema, &[Arg::DoubleDash, word("context")]).expect("ok");
1430 assert_eq!(leaf.name, "kj");
1431 }
1432
1433 #[test]
1434 fn computed_subcommand_selector_errors() {
1435 let schema = kj_schema();
1436 let args = vec![Arg::Positional(Expr::CommandSubst(vec![
1439 crate::ast::Stmt::Command(crate::ast::Command {
1440 name: "echo".into(),
1441 args: vec![],
1442 redirects: vec![],
1443 }),
1444 ]))];
1445 let err = select_leaf(&schema, &args).expect_err("must error");
1446 let msg = err.to_string();
1447 assert!(msg.contains("subcommand name is required"), "got: {msg}");
1448 assert!(msg.contains("command substitution"), "names the cause: {msg}");
1449 }
1450
1451 #[test]
1452 fn variable_subcommand_selector_errors() {
1453 let schema = kj_schema();
1454 let args = vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("sub")))];
1455 let err = select_leaf(&schema, &args).expect_err("must error");
1456 assert!(err.to_string().contains("variable reference"), "got: {err}");
1457 }
1458
1459 #[test]
1460 fn computed_positional_after_leaf_is_fine() {
1461 let schema = kj_schema();
1462 let args = vec![
1465 word("context"),
1466 word("list"),
1467 Arg::Positional(Expr::CommandSubst(vec![crate::ast::Stmt::Command(
1468 crate::ast::Command { name: "echo".into(), args: vec![], redirects: vec![] },
1469 )])),
1470 ];
1471 let leaf = select_leaf(&schema, &args).expect("ok");
1472 assert_eq!(leaf.name, "list");
1473 }
1474}
1475
1476#[cfg(test)]
1477mod tests {
1478 use super::*;
1479 use crate::dispatch::BackendDispatcher;
1480 use crate::tools::register_builtins;
1481 use crate::vfs::{Filesystem, MemoryFs, VfsRouter};
1482 use std::path::Path;
1483
1484 async fn make_runner_and_ctx() -> (PipelineRunner, ExecContext, BackendDispatcher) {
1485 let mut tools = ToolRegistry::new();
1486 register_builtins(&mut tools);
1487 let tools = Arc::new(tools);
1488 let runner = PipelineRunner::new(tools.clone());
1489 let dispatcher = BackendDispatcher::new(tools.clone());
1490
1491 let mut vfs = VfsRouter::new();
1492 let mem = MemoryFs::new();
1493 mem.write(Path::new("test.txt"), b"hello\nworld\nfoo").await.unwrap();
1494 vfs.mount("/", mem);
1495 let ctx = ExecContext::with_vfs_and_tools(Arc::new(vfs), tools);
1496
1497 (runner, ctx, dispatcher)
1498 }
1499
1500 fn stages(commands: impl IntoIterator<Item = Command>) -> Vec<PipelineStage> {
1502 commands.into_iter().map(PipelineStage::Command).collect()
1503 }
1504
1505 fn make_cmd(name: &str, args: Vec<&str>) -> Command {
1506 Command {
1507 name: name.to_string(),
1508 args: args.iter().map(|s| Arg::Positional(Expr::Literal(Value::String(s.to_string())))).collect(),
1509 redirects: vec![],
1510 }
1511 }
1512
1513 #[tokio::test]
1514 async fn test_single_command() {
1515 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1516 let cmd = make_cmd("echo", vec!["hello"]);
1517
1518 let result = runner.run(&stages([cmd]), &mut ctx, &dispatcher).await;
1519 assert!(result.ok());
1520 assert_eq!(result.text_out().trim(), "hello");
1521 }
1522
1523 #[tokio::test]
1524 async fn test_pipeline_echo_grep() {
1525 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1526
1527 let echo_cmd = Command {
1529 name: "echo".to_string(),
1530 args: vec![Arg::Positional(Expr::Literal(Value::String("hello\nworld".to_string())))],
1531 redirects: vec![],
1532 };
1533 let grep_cmd = Command {
1534 name: "grep".to_string(),
1535 args: vec![Arg::Positional(Expr::Literal(Value::String("world".to_string())))],
1536 redirects: vec![],
1537 };
1538
1539 let result = runner.run(&stages([echo_cmd, grep_cmd]), &mut ctx, &dispatcher).await;
1540 assert!(result.ok());
1541 assert_eq!(result.text_out().trim(), "world");
1542 }
1543
1544 #[tokio::test]
1545 async fn test_pipeline_cat_grep() {
1546 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1547
1548 let cat_cmd = make_cmd("cat", vec!["/test.txt"]);
1550 let grep_cmd = Command {
1551 name: "grep".to_string(),
1552 args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
1553 redirects: vec![],
1554 };
1555
1556 let result = runner.run(&stages([cat_cmd, grep_cmd]), &mut ctx, &dispatcher).await;
1557 assert!(result.ok());
1558 assert!(result.text_out().contains("hello"));
1559 }
1560
1561 #[tokio::test]
1562 async fn test_command_not_found() {
1563 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1564 let cmd = make_cmd("nonexistent", vec![]);
1565
1566 let result = runner.run(&stages([cmd]), &mut ctx, &dispatcher).await;
1567 assert!(!result.ok());
1568 assert_eq!(result.code, 127);
1569 #[cfg(feature = "subprocess")]
1574 assert!(
1575 result.err.contains("not found"),
1576 "expected a not-found report, got {:?}",
1577 result.err
1578 );
1579 #[cfg(not(feature = "subprocess"))]
1580 assert!(
1581 result.err.contains("external commands are"),
1582 "expected the unavailable-externals report, got {:?}",
1583 result.err
1584 );
1585 }
1586
1587 #[tokio::test]
1588 async fn test_pipeline_continues_on_failure() {
1589 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1592
1593 let cat_cmd = make_cmd("cat", vec!["/nonexistent"]);
1596 let grep_cmd = Command {
1597 name: "grep".to_string(),
1598 args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
1599 redirects: vec![],
1600 };
1601
1602 let result = runner.run(&stages([cat_cmd, grep_cmd]), &mut ctx, &dispatcher).await;
1603 assert!(!result.ok());
1605 }
1606
1607 #[tokio::test]
1608 async fn test_pipeline_last_command_exit_code() {
1609 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1611
1612 let echo_cmd = make_cmd("echo", vec!["hello"]);
1613 let cat_cmd = make_cmd("cat", vec![]);
1614
1615 let result = runner.run(&stages([echo_cmd, cat_cmd]), &mut ctx, &dispatcher).await;
1616 assert!(result.ok());
1617 assert!(result.text_out().contains("hello"));
1618 }
1619
1620 #[tokio::test]
1621 async fn test_empty_pipeline() {
1622 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1623 let result = runner.run(&stages([]), &mut ctx, &dispatcher).await;
1624 assert!(result.ok());
1625 }
1626
1627 #[test]
1630 fn test_find_scatter_gather_both_present() {
1631 let commands = vec![
1632 make_cmd("echo", vec!["a"]),
1633 make_cmd("scatter", vec![]),
1634 make_cmd("process", vec![]),
1635 make_cmd("gather", vec![]),
1636 ];
1637 let result = find_scatter_gather(&stages(commands));
1638 assert_eq!(result, Some((1, 3)));
1639 }
1640
1641 #[test]
1642 fn test_find_scatter_gather_no_scatter() {
1643 let commands = vec![
1644 make_cmd("echo", vec!["a"]),
1645 make_cmd("gather", vec![]),
1646 ];
1647 let result = find_scatter_gather(&stages(commands));
1648 assert!(result.is_none());
1649 }
1650
1651 #[test]
1652 fn test_find_scatter_gather_no_gather() {
1653 let commands = vec![
1654 make_cmd("echo", vec!["a"]),
1655 make_cmd("scatter", vec![]),
1656 ];
1657 let result = find_scatter_gather(&stages(commands));
1658 assert!(result.is_none());
1659 }
1660
1661 #[test]
1662 fn test_find_scatter_gather_wrong_order() {
1663 let commands = vec![
1664 make_cmd("gather", vec![]),
1665 make_cmd("scatter", vec![]),
1666 ];
1667 let result = find_scatter_gather(&stages(commands));
1668 assert!(result.is_none());
1669 }
1670
1671 #[tokio::test]
1672 async fn test_scatter_gather_simple() {
1673 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1674
1675 let split_cmd = Command {
1677 name: "split".to_string(),
1678 args: vec![Arg::Positional(Expr::Literal(Value::String("a b c".to_string())))],
1679 redirects: vec![],
1680 };
1681 let scatter_cmd = make_cmd("scatter", vec![]);
1682 let process_cmd = Command {
1683 name: "echo".to_string(),
1684 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1685 redirects: vec![],
1686 };
1687 let gather_cmd = make_cmd("gather", vec![]);
1688
1689 let result = runner.run(&stages([split_cmd, scatter_cmd, process_cmd, gather_cmd]), &mut ctx, &dispatcher).await;
1690 assert!(result.ok(), "scatter with structured data should succeed: {}", result.err);
1691 assert!(result.text_out().contains("a"));
1693 assert!(result.text_out().contains("b"));
1694 assert!(result.text_out().contains("c"));
1695 }
1696
1697 #[tokio::test]
1698 async fn test_scatter_gather_empty_input() {
1699 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1700
1701 let echo_cmd = Command {
1703 name: "echo".to_string(),
1704 args: vec![Arg::Positional(Expr::Literal(Value::String("".to_string())))],
1705 redirects: vec![],
1706 };
1707 let scatter_cmd = make_cmd("scatter", vec![]);
1708 let process_cmd = Command {
1709 name: "echo".to_string(),
1710 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1711 redirects: vec![],
1712 };
1713 let gather_cmd = make_cmd("gather", vec![]);
1714
1715 let result = runner.run(&stages([echo_cmd, scatter_cmd, process_cmd, gather_cmd]), &mut ctx, &dispatcher).await;
1716 assert!(result.ok());
1717 assert!(result.text_out().trim().is_empty());
1718 }
1719
1720 #[tokio::test]
1721 async fn test_scatter_gather_with_structured_stdin() {
1722 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1723
1724 let data = Value::Json(serde_json::json!(["x", "y", "z"]));
1726 ctx.set_stdin_with_data("x\ny\nz".to_string(), Some(data));
1727
1728 let scatter_cmd = make_cmd("scatter", vec![]);
1729 let process_cmd = Command {
1730 name: "echo".to_string(),
1731 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1732 redirects: vec![],
1733 };
1734 let gather_cmd = make_cmd("gather", vec![]);
1735
1736 let result = runner.run(&stages([scatter_cmd, process_cmd, gather_cmd]), &mut ctx, &dispatcher).await;
1737 assert!(result.ok(), "scatter with structured stdin should succeed: {}", result.err);
1738 assert!(result.text_out().contains("x"));
1739 assert!(result.text_out().contains("y"));
1740 assert!(result.text_out().contains("z"));
1741 }
1742
1743 #[tokio::test]
1744 async fn test_scatter_gather_json_input() {
1745 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1746
1747 let data = Value::Json(serde_json::json!(["one", "two", "three"]));
1749 ctx.set_stdin_with_data(r#"["one", "two", "three"]"#.to_string(), Some(data));
1750
1751 let scatter_cmd = make_cmd("scatter", vec![]);
1752 let process_cmd = Command {
1753 name: "echo".to_string(),
1754 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1755 redirects: vec![],
1756 };
1757 let gather_cmd = make_cmd("gather", vec![]);
1758
1759 let result = runner.run(&stages([scatter_cmd, process_cmd, gather_cmd]), &mut ctx, &dispatcher).await;
1760 assert!(result.ok(), "scatter with JSON data should succeed: {}", result.err);
1761 assert!(result.text_out().contains("one"));
1762 assert!(result.text_out().contains("two"));
1763 assert!(result.text_out().contains("three"));
1764 }
1765
1766 #[tokio::test]
1767 async fn test_scatter_gather_with_post_gather() {
1768 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1769
1770 let split_cmd = Command {
1772 name: "split".to_string(),
1773 args: vec![Arg::Positional(Expr::Literal(Value::String("a b".to_string())))],
1774 redirects: vec![],
1775 };
1776 let scatter_cmd = make_cmd("scatter", vec![]);
1777 let process_cmd = Command {
1778 name: "echo".to_string(),
1779 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1780 redirects: vec![],
1781 };
1782 let gather_cmd = make_cmd("gather", vec![]);
1783 let grep_cmd = Command {
1784 name: "grep".to_string(),
1785 args: vec![Arg::Positional(Expr::Literal(Value::String("a".to_string())))],
1786 redirects: vec![],
1787 };
1788
1789 let result = runner.run(&stages([split_cmd, scatter_cmd, process_cmd, gather_cmd, grep_cmd]), &mut ctx, &dispatcher).await;
1790 assert!(result.ok(), "scatter with post_gather should succeed: {}", result.err);
1791 assert!(result.text_out().contains("a"));
1792 assert!(!result.text_out().contains("b"));
1793 }
1794
1795 #[tokio::test]
1796 async fn test_scatter_custom_var_name() {
1797 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1798
1799 let data = Value::Json(serde_json::json!(["test1", "test2"]));
1801 ctx.set_stdin_with_data("test1\ntest2".to_string(), Some(data));
1802
1803 let scatter_cmd = Command {
1805 name: "scatter".to_string(),
1806 args: vec![Arg::Named {
1807 key: "as".to_string(),
1808 value: Expr::Literal(Value::String("URL".to_string())),
1809 }],
1810 redirects: vec![],
1811 };
1812 let process_cmd = Command {
1813 name: "echo".to_string(),
1814 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("URL")))],
1815 redirects: vec![],
1816 };
1817 let gather_cmd = make_cmd("gather", vec![]);
1818
1819 let result = runner.run(&stages([scatter_cmd, process_cmd, gather_cmd]), &mut ctx, &dispatcher).await;
1820 assert!(result.ok(), "scatter with custom var should succeed: {}", result.err);
1821 assert!(result.text_out().contains("test1"));
1822 assert!(result.text_out().contains("test2"));
1823 }
1824
1825 #[tokio::test]
1828 async fn test_pipeline_routes_through_backend() {
1829 use crate::backend::testing::MockBackend;
1830 use std::sync::atomic::Ordering;
1831
1832 let (backend, call_count) = MockBackend::new();
1834 let backend: std::sync::Arc<dyn crate::backend::KernelBackend> = std::sync::Arc::new(backend);
1835
1836 let mut ctx = crate::tools::ExecContext::with_backend(backend);
1838
1839 let tools = std::sync::Arc::new(ToolRegistry::new());
1841 let runner = PipelineRunner::new(tools.clone());
1842 let dispatcher = BackendDispatcher::new(tools);
1843
1844 let cmd = make_cmd("test-tool", vec!["arg1"]);
1846 let result = runner.run(&stages([cmd]), &mut ctx, &dispatcher).await;
1847
1848 assert!(result.ok(), "Mock backend should return success");
1849 assert_eq!(call_count.load(Ordering::SeqCst), 1, "call_tool should be invoked once");
1850 assert!(result.text_out().contains("mock executed"), "Output should be from mock backend");
1851 }
1852
1853 #[tokio::test]
1854 async fn test_multi_command_pipeline_routes_through_backend() {
1855 use crate::backend::testing::MockBackend;
1856 use std::sync::atomic::Ordering;
1857
1858 let (backend, call_count) = MockBackend::new();
1859 let backend: std::sync::Arc<dyn crate::backend::KernelBackend> = std::sync::Arc::new(backend);
1860 let mut ctx = crate::tools::ExecContext::with_backend(backend);
1861
1862 let tools = std::sync::Arc::new(ToolRegistry::new());
1863 let runner = PipelineRunner::new(tools.clone());
1864 let dispatcher = BackendDispatcher::new(tools);
1865
1866 let cmd1 = make_cmd("tool1", vec![]);
1868 let cmd2 = make_cmd("tool2", vec![]);
1869 let cmd3 = make_cmd("tool3", vec![]);
1870
1871 let result = runner.run(&stages([cmd1, cmd2, cmd3]), &mut ctx, &dispatcher).await;
1872
1873 assert!(result.ok());
1874 assert_eq!(call_count.load(Ordering::SeqCst), 3, "call_tool should be invoked for each command");
1875 }
1876
1877 #[tokio::test]
1884 async fn backend_dispatcher_scalar_data_matches_production_unwrap() {
1885 use crate::backend::testing::MockBackend;
1886 use crate::backend::ToolResult;
1887
1888 let (mock, _calls) = MockBackend::new();
1889 let backend = mock.with_tool_result(|_name| Ok(ToolResult::with_data("", serde_json::json!(42))));
1890 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
1891 let mut ctx = ExecContext::with_backend(backend);
1892
1893 let dispatcher = BackendDispatcher::new(Arc::new(ToolRegistry::new()));
1894 let cmd = make_cmd("embedder_tool", vec![]);
1895
1896 let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
1897 assert_eq!(
1898 result.data,
1899 Some(Value::Int(42)),
1900 "a scalar ToolResult.data must unwrap to a native Value, matching \
1901 the production From<ToolResult> path — not stay Value::Json(42)"
1902 );
1903 }
1904
1905 #[tokio::test]
1911 async fn backend_dispatcher_envelope_shaped_data_stays_structured() {
1912 use crate::backend::testing::MockBackend;
1913 use crate::backend::ToolResult;
1914
1915 let envelope = kaish_types::bytes_to_envelope(&[1u8, 2, 3]);
1916 let (mock, _calls) = MockBackend::new();
1917 let backend = mock.with_tool_result(move |_name| Ok(ToolResult::with_data("", envelope.clone())));
1918 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
1919 let mut ctx = ExecContext::with_backend(backend);
1920
1921 let dispatcher = BackendDispatcher::new(Arc::new(ToolRegistry::new()));
1922 let cmd = make_cmd("embedder_tool", vec![]);
1923
1924 let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
1925 assert!(
1926 matches!(result.data, Some(Value::Json(_))),
1927 "envelope-shaped external data must stay structured, not silently \
1928 decode to Value::Bytes: got {:?}",
1929 result.data
1930 );
1931 }
1932
1933 #[tokio::test]
1938 async fn backend_dispatcher_preserves_did_spill_and_original_code() {
1939 use crate::backend::testing::MockBackend;
1940 use crate::backend::ToolResult;
1941
1942 let (mock, _calls) = MockBackend::new();
1943 let backend = mock.with_tool_result(|_name| {
1944 Ok(ToolResult::success("truncated...")
1945 .with_did_spill(true)
1946 .with_original_code(Some(0)))
1947 });
1948 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
1949 let mut ctx = ExecContext::with_backend(backend);
1950
1951 let dispatcher = BackendDispatcher::new(Arc::new(ToolRegistry::new()));
1952 let cmd = make_cmd("embedder_tool", vec![]);
1953
1954 let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
1955 assert!(result.did_spill, "did_spill must survive the backend seam");
1956 assert_eq!(result.original_code, Some(0), "original_code must survive the backend seam");
1957 }
1958
1959 use crate::tools::{ParamSchema, ToolSchema};
1962
1963 fn make_test_schema() -> ToolSchema {
1964 ToolSchema::new("test-tool", "A test tool for schema-aware parsing")
1965 .param(ParamSchema::required("query", "string", "Search query"))
1966 .param(ParamSchema::optional("limit", "int", Value::Int(10), "Max results"))
1967 .param(ParamSchema::optional("verbose", "bool", Value::Bool(false), "Verbose output"))
1968 .param(ParamSchema::optional("output", "string", Value::String("stdout".into()), "Output destination"))
1969 .with_positional_mapping()
1970 }
1971
1972 fn make_minimal_ctx() -> ExecContext {
1973 let mut vfs = VfsRouter::new();
1974 vfs.mount("/", MemoryFs::new());
1975 ExecContext::new(Arc::new(vfs))
1976 }
1977
1978 fn test_dispatcher() -> BackendDispatcher {
1982 BackendDispatcher::new(Arc::new(ToolRegistry::new()))
1983 }
1984
1985 #[tokio::test]
1986 async fn test_schema_aware_string_arg() {
1987 let args = vec![
1989 Arg::LongFlag("query".to_string()),
1990 Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
1991 ];
1992 let schema = make_test_schema();
1993 let ctx = make_minimal_ctx();
1994
1995 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1996
1997 assert!(tool_args.flags.is_empty(), "No flags should be set");
1998 assert!(tool_args.positional.is_empty(), "No positionals - consumed by --query");
1999 assert_eq!(
2000 tool_args.named.get("query"),
2001 Some(&Value::String("test".to_string())),
2002 "--query should consume 'test' as its value"
2003 );
2004 }
2005
2006 #[tokio::test]
2007 async fn test_schema_aware_bool_flag() {
2008 let args = vec![
2010 Arg::LongFlag("verbose".to_string()),
2011 ];
2012 let schema = make_test_schema();
2013 let ctx = make_minimal_ctx();
2014
2015 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2016
2017 assert!(tool_args.flags.contains("verbose"), "--verbose should be a flag");
2018 assert!(tool_args.named.is_empty(), "No named args");
2019 assert!(tool_args.positional.is_empty(), "No positionals");
2020 }
2021
2022 #[tokio::test]
2023 async fn test_schema_aware_mixed() {
2024 let args = vec![
2027 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
2028 Arg::LongFlag("output".to_string()),
2029 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
2030 Arg::LongFlag("verbose".to_string()),
2031 ];
2032 let schema = make_test_schema();
2033 let ctx = make_minimal_ctx();
2034
2035 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2036
2037 assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
2038 assert_eq!(
2039 tool_args.named.get("query"),
2040 Some(&Value::String("file.txt".to_string()))
2041 );
2042 assert_eq!(
2043 tool_args.named.get("output"),
2044 Some(&Value::String("out.txt".to_string()))
2045 );
2046 assert!(tool_args.flags.contains("verbose"));
2047 }
2048
2049 #[tokio::test]
2050 async fn test_schema_aware_multiple_string_args() {
2051 let args = vec![
2053 Arg::LongFlag("query".to_string()),
2054 Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
2055 Arg::LongFlag("output".to_string()),
2056 Arg::Positional(Expr::Literal(Value::String("result.json".to_string()))),
2057 Arg::LongFlag("verbose".to_string()),
2058 Arg::LongFlag("limit".to_string()),
2059 Arg::Positional(Expr::Literal(Value::Int(5))),
2060 ];
2061 let schema = make_test_schema();
2062 let ctx = make_minimal_ctx();
2063
2064 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2065
2066 assert!(tool_args.positional.is_empty(), "All positionals consumed");
2067 assert_eq!(
2068 tool_args.named.get("query"),
2069 Some(&Value::String("test".to_string()))
2070 );
2071 assert_eq!(
2072 tool_args.named.get("output"),
2073 Some(&Value::String("result.json".to_string()))
2074 );
2075 assert_eq!(
2076 tool_args.named.get("limit"),
2077 Some(&Value::Int(5))
2078 );
2079 assert!(tool_args.flags.contains("verbose"));
2080 }
2081
2082 #[tokio::test]
2083 async fn test_schema_aware_double_dash() {
2084 let args = vec![
2087 Arg::LongFlag("output".to_string()),
2088 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
2089 Arg::DoubleDash,
2090 Arg::Positional(Expr::Literal(Value::String("--this-is-data".to_string()))),
2091 ];
2092 let schema = make_test_schema();
2093 let ctx = make_minimal_ctx();
2094
2095 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2096
2097 assert_eq!(
2098 tool_args.named.get("output"),
2099 Some(&Value::String("out.txt".to_string()))
2100 );
2101 assert_eq!(
2103 tool_args.positional,
2104 vec![Value::String("--this-is-data".to_string())]
2105 );
2106 }
2107
2108 #[tokio::test]
2114 async fn word_assign_binary_value_is_loud_not_placeholder() {
2115 let args = vec![Arg::WordAssign {
2116 key: "if".to_string(),
2117 value: Expr::Literal(Value::Bytes(vec![0xff, 0x00, 0xfe])),
2118 }];
2119 let ctx = make_minimal_ctx();
2120
2121 let err = build_tool_args(&args, &ctx, None).await.expect_err("binary WordAssign must error");
2124 assert!(
2125 err.contains("cannot be used as"),
2126 "error should name the binary problem, got {err:?}"
2127 );
2128 }
2129
2130 #[tokio::test]
2135 async fn word_assign_before_double_dash_binds_named_for_export() {
2136 let args = vec![Arg::WordAssign {
2137 key: "A".to_string(),
2138 value: Expr::Literal(Value::String("1".to_string())),
2139 }];
2140 let schema = ToolSchema::new("export", "export");
2141 let ctx = make_minimal_ctx();
2142
2143 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2144 assert_eq!(tool_args.named.get("A"), Some(&Value::String("1".to_string())));
2145 assert!(tool_args.positional.is_empty());
2146 }
2147
2148 #[tokio::test]
2157 async fn word_assign_after_double_dash_is_positional_even_for_export() {
2158 let args = vec![
2159 Arg::DoubleDash,
2160 Arg::WordAssign {
2161 key: "A".to_string(),
2162 value: Expr::Literal(Value::String("1".to_string())),
2163 },
2164 ];
2165 let schema = ToolSchema::new("export", "export");
2166 let ctx = make_minimal_ctx();
2167
2168 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2169 assert!(
2170 tool_args.named.is_empty(),
2171 "past `--`, A=1 must not become a named assignment: {:?}",
2172 tool_args.named
2173 );
2174 assert_eq!(tool_args.positional, vec![Value::String("A=1".to_string())]);
2175 }
2176
2177 #[tokio::test]
2185 async fn named_true_on_undeclared_flag_flagifies() {
2186 let args = vec![Arg::Named {
2187 key: "json".to_string(),
2188 value: Expr::Literal(Value::Bool(true)),
2189 }];
2190 let schema = make_test_schema(); let ctx = make_minimal_ctx();
2192
2193 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2194 assert!(tool_args.flags.contains("json"), "flags: {:?}", tool_args.flags);
2195 assert!(!tool_args.named.contains_key("json"), "named: {:?}", tool_args.named);
2196 }
2197
2198 #[tokio::test]
2201 async fn named_false_on_undeclared_flag_drops() {
2202 let args = vec![Arg::Named {
2203 key: "json".to_string(),
2204 value: Expr::Literal(Value::Bool(false)),
2205 }];
2206 let schema = make_test_schema();
2207 let ctx = make_minimal_ctx();
2208
2209 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2210 assert!(!tool_args.flags.contains("json"));
2211 assert!(!tool_args.named.contains_key("json"));
2212 }
2213
2214 #[tokio::test]
2218 async fn named_true_on_declared_bool_param_flagifies() {
2219 let args = vec![Arg::Named {
2220 key: "verbose".to_string(),
2221 value: Expr::Literal(Value::Bool(true)),
2222 }];
2223 let schema = make_test_schema();
2224 let ctx = make_minimal_ctx();
2225
2226 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2227 assert!(tool_args.flags.contains("verbose"));
2228 assert!(!tool_args.named.contains_key("verbose"));
2229 }
2230
2231 #[tokio::test]
2237 async fn named_true_on_declared_value_flag_keeps_value() {
2238 let args = vec![Arg::Named {
2239 key: "output".to_string(),
2240 value: Expr::Literal(Value::Bool(true)),
2241 }];
2242 let schema = make_test_schema();
2243 let ctx = make_minimal_ctx();
2244
2245 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2246 assert_eq!(tool_args.named.get("output"), Some(&Value::Bool(true)));
2247 assert!(!tool_args.flags.contains("output"));
2248 }
2249
2250 #[tokio::test]
2255 async fn named_true_with_no_schema_flagifies() {
2256 let args = vec![Arg::Named {
2257 key: "verbose".to_string(),
2258 value: Expr::Literal(Value::Bool(true)),
2259 }];
2260 let ctx = make_minimal_ctx();
2261
2262 let tool_args = build_tool_args(&args, &ctx, None).await.expect("build_tool_args");
2263 assert!(tool_args.flags.contains("verbose"));
2264 assert!(!tool_args.named.contains_key("verbose"));
2265 }
2266
2267 #[tokio::test]
2268 async fn test_no_schema_fallback() {
2269 let args = vec![
2271 Arg::LongFlag("query".to_string()),
2272 Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
2273 ];
2274 let ctx = make_minimal_ctx();
2275
2276 let tool_args = build_tool_args(&args, &ctx, None).await.expect("build_tool_args");
2277
2278 assert!(tool_args.flags.contains("query"), "--query should be a flag");
2280 assert_eq!(
2281 tool_args.positional,
2282 vec![Value::String("test".to_string())],
2283 "'test' should be a positional"
2284 );
2285 }
2286
2287 #[tokio::test]
2297 async fn test_unknown_flag_ambiguous_space_value_now_errors_loud() {
2298 let args = vec![
2299 Arg::LongFlag("unknown".to_string()),
2300 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2301 ];
2302 let schema = make_test_schema();
2303 let ctx = make_minimal_ctx();
2304
2305 let err = build_tool_args(&args, &ctx, Some(&schema))
2306 .await
2307 .expect_err("an undeclared flag immediately before a positional must be ambiguous, not silently bool");
2308 assert!(
2309 err.contains("--unknown is not a declared flag"),
2310 "got: {err}"
2311 );
2312 }
2313
2314 #[tokio::test]
2318 async fn test_unknown_bool_flag_with_no_following_positional_is_fine() {
2319 let args = vec![
2320 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2321 Arg::LongFlag("unknown".to_string()),
2322 ];
2323 let schema = make_test_schema();
2324 let ctx = make_minimal_ctx();
2325
2326 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2327
2328 assert!(tool_args.flags.contains("unknown"));
2329 assert!(tool_args.positional.is_empty(), "value consumed as query param");
2330 assert_eq!(
2331 tool_args.named.get("query"),
2332 Some(&Value::String("value".to_string()))
2333 );
2334 }
2335
2336 #[tokio::test]
2346 async fn test_unknown_short_flag_ambiguous_space_value_now_errors_loud() {
2347 let args = vec![
2348 Arg::ShortFlag("t".to_string()),
2349 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2350 ];
2351 let schema = make_test_schema();
2352 let ctx = make_minimal_ctx();
2353
2354 let err = build_tool_args(&args, &ctx, Some(&schema))
2355 .await
2356 .expect_err("an undeclared short flag immediately before a positional must be ambiguous, not silently bool");
2357 assert!(
2358 err.contains("-t is not a declared flag"),
2359 "got: {err}"
2360 );
2361 }
2362
2363 #[tokio::test]
2367 async fn test_unknown_short_bool_flag_with_no_following_positional_is_fine() {
2368 let args = vec![
2369 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2370 Arg::ShortFlag("t".to_string()),
2371 ];
2372 let schema = make_test_schema();
2373 let ctx = make_minimal_ctx();
2374
2375 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2376
2377 assert!(tool_args.flags.contains("t"));
2378 assert!(tool_args.positional.is_empty(), "value consumed as query param");
2379 assert_eq!(
2380 tool_args.named.get("query"),
2381 Some(&Value::String("value".to_string()))
2382 );
2383 }
2384
2385 #[tokio::test]
2391 async fn test_unknown_short_flag_not_ambiguous_without_map_positionals() {
2392 let args = vec![
2393 Arg::ShortFlag("t".to_string()),
2394 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2395 ];
2396 let schema = ToolSchema::new("test-tool", "A test tool")
2399 .param(ParamSchema::required("query", "string", "Search query"));
2400 let ctx = make_minimal_ctx();
2401
2402 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2403 assert!(tool_args.flags.contains("t"));
2404 assert_eq!(tool_args.positional, vec![Value::String("value".to_string())]);
2405 }
2406
2407 #[tokio::test]
2408 async fn test_named_args_unchanged() {
2409 let args = vec![
2411 Arg::Named {
2412 key: "query".to_string(),
2413 value: Expr::Literal(Value::String("test".to_string())),
2414 },
2415 Arg::LongFlag("verbose".to_string()),
2416 ];
2417 let schema = make_test_schema();
2418 let ctx = make_minimal_ctx();
2419
2420 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2421
2422 assert_eq!(
2423 tool_args.named.get("query"),
2424 Some(&Value::String("test".to_string()))
2425 );
2426 assert!(tool_args.flags.contains("verbose"));
2427 }
2428
2429 #[tokio::test]
2430 async fn test_short_flags_unchanged() {
2431 let args = vec![
2433 Arg::ShortFlag("la".to_string()),
2434 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
2435 ];
2436 let schema = make_test_schema();
2437 let ctx = make_minimal_ctx();
2438
2439 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2440
2441 assert!(tool_args.flags.contains("l"));
2442 assert!(tool_args.flags.contains("a"));
2443 assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
2444 assert_eq!(
2445 tool_args.named.get("query"),
2446 Some(&Value::String("file.txt".to_string()))
2447 );
2448 }
2449
2450 #[tokio::test]
2451 async fn test_flag_at_end_no_value() {
2452 let args = vec![
2455 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
2456 Arg::LongFlag("output".to_string()),
2457 ];
2458 let schema = make_test_schema();
2459 let ctx = make_minimal_ctx();
2460
2461 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2462
2463 assert!(tool_args.flags.contains("output"));
2465 assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
2466 assert_eq!(
2467 tool_args.named.get("query"),
2468 Some(&Value::String("file.txt".to_string()))
2469 );
2470 }
2471
2472 #[tokio::test]
2473 async fn test_positional_skips_bool_params() {
2474 let schema = ToolSchema::new("test", "")
2478 .param(ParamSchema::required("query", "string", ""))
2479 .param(ParamSchema::optional(
2480 "verbose",
2481 "bool",
2482 Value::Bool(false),
2483 "",
2484 ))
2485 .param(ParamSchema::optional(
2486 "output",
2487 "string",
2488 Value::Null,
2489 "",
2490 ))
2491 .with_positional_mapping();
2492 let args = vec![
2493 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2494 Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
2495 ];
2496 let ctx = make_minimal_ctx();
2497
2498 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2499
2500 assert_eq!(
2501 tool_args.named.get("query"),
2502 Some(&Value::String("val1".to_string()))
2503 );
2504 assert_eq!(
2505 tool_args.named.get("output"),
2506 Some(&Value::String("val2".to_string()))
2507 );
2508 assert!(!tool_args.flags.contains("verbose"));
2509 assert!(tool_args.positional.is_empty());
2510 }
2511
2512 #[tokio::test]
2513 async fn test_positionals_fill_available_slots() {
2514 let args = vec![
2517 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2518 Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
2519 Arg::Positional(Expr::Literal(Value::String("val3".to_string()))),
2520 ];
2521 let schema = make_test_schema(); let ctx = make_minimal_ctx();
2523
2524 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2525
2526 assert_eq!(
2529 tool_args.named.get("query"),
2530 Some(&Value::String("val1".to_string()))
2531 );
2532 assert_eq!(
2533 tool_args.named.get("limit"),
2534 Some(&Value::String("val2".to_string()))
2535 );
2536 assert_eq!(
2537 tool_args.named.get("output"),
2538 Some(&Value::String("val3".to_string()))
2539 );
2540 assert!(tool_args.positional.is_empty());
2541 }
2542
2543 #[tokio::test]
2544 async fn test_truly_excess_positionals() {
2545 let schema = ToolSchema::new("test", "")
2547 .param(ParamSchema::required("name", "string", ""))
2548 .with_positional_mapping();
2549 let args = vec![
2550 Arg::Positional(Expr::Literal(Value::String("first".to_string()))),
2551 Arg::Positional(Expr::Literal(Value::String("second".to_string()))),
2552 Arg::Positional(Expr::Literal(Value::String("third".to_string()))),
2553 ];
2554 let ctx = make_minimal_ctx();
2555
2556 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2557
2558 assert_eq!(
2559 tool_args.named.get("name"),
2560 Some(&Value::String("first".to_string()))
2561 );
2562 assert_eq!(
2563 tool_args.positional,
2564 vec![
2565 Value::String("second".to_string()),
2566 Value::String("third".to_string()),
2567 ]
2568 );
2569 }
2570
2571 #[tokio::test]
2572 async fn test_double_dash_positional_not_mapped() {
2573 let args = vec![
2575 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2576 Arg::DoubleDash,
2577 Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
2578 ];
2579 let schema = make_test_schema();
2580 let ctx = make_minimal_ctx();
2581
2582 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2583
2584 assert_eq!(
2585 tool_args.named.get("query"),
2586 Some(&Value::String("val1".to_string()))
2587 );
2588 assert_eq!(
2590 tool_args.positional,
2591 vec![Value::String("val2".to_string())]
2592 );
2593 }
2594
2595 #[tokio::test]
2596 async fn test_all_params_filled_by_flags() {
2597 let args = vec![
2599 Arg::LongFlag("query".to_string()),
2600 Arg::Positional(Expr::Literal(Value::String("search".to_string()))),
2601 Arg::LongFlag("output".to_string()),
2602 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
2603 Arg::LongFlag("verbose".to_string()),
2604 ];
2605 let schema = make_test_schema();
2606 let ctx = make_minimal_ctx();
2607
2608 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2609
2610 assert_eq!(
2611 tool_args.named.get("query"),
2612 Some(&Value::String("search".to_string()))
2613 );
2614 assert_eq!(
2615 tool_args.named.get("output"),
2616 Some(&Value::String("out.txt".to_string()))
2617 );
2618 assert!(tool_args.flags.contains("verbose"));
2619 assert!(tool_args.positional.is_empty());
2620 }
2621
2622 #[tokio::test]
2623 async fn test_mixed_flags_and_positional_fill() {
2624 let args = vec![
2626 Arg::LongFlag("output".to_string()),
2627 Arg::Positional(Expr::Literal(Value::String("foo".to_string()))),
2628 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2629 ];
2630 let schema = make_test_schema();
2631 let ctx = make_minimal_ctx();
2632
2633 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2634
2635 assert_eq!(
2636 tool_args.named.get("output"),
2637 Some(&Value::String("foo".to_string()))
2638 );
2639 assert_eq!(
2640 tool_args.named.get("query"),
2641 Some(&Value::String("val1".to_string()))
2642 );
2643 assert!(tool_args.positional.is_empty());
2644 }
2645
2646 #[tokio::test]
2647 async fn test_alias_flag_prevents_mapping_overwrite() {
2648 let schema = ToolSchema::new("test", "")
2650 .param(ParamSchema::required("query", "string", "").with_aliases(["-q"]))
2651 .param(ParamSchema::required("output", "string", ""))
2652 .with_positional_mapping();
2653 let args = vec![
2654 Arg::ShortFlag("q".to_string()),
2655 Arg::Positional(Expr::Literal(Value::String("search".to_string()))),
2656 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
2657 ];
2658 let ctx = make_minimal_ctx();
2659
2660 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2661
2662 assert_eq!(
2663 tool_args.named.get("query"),
2664 Some(&Value::String("search".to_string()))
2665 );
2666 assert_eq!(
2667 tool_args.named.get("output"),
2668 Some(&Value::String("out.txt".to_string()))
2669 );
2670 assert!(tool_args.positional.is_empty());
2671 }
2672
2673 #[tokio::test]
2674 async fn test_builtin_schema_no_positional_mapping() {
2675 let schema = ToolSchema::new("echo", "")
2677 .param(ParamSchema::optional("args", "any", Value::Null, ""))
2678 .param(ParamSchema::optional("no_newline", "bool", Value::Bool(false), ""));
2679 let args = vec![
2681 Arg::Positional(Expr::Literal(Value::String("hello".to_string()))),
2682 Arg::Positional(Expr::Literal(Value::String("world".to_string()))),
2683 ];
2684 let ctx = make_minimal_ctx();
2685
2686 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2687
2688 assert_eq!(
2690 tool_args.positional,
2691 vec![
2692 Value::String("hello".to_string()),
2693 Value::String("world".to_string()),
2694 ]
2695 );
2696 assert!(!tool_args.named.contains_key("args"));
2697 }
2698
2699 #[tokio::test]
2700 async fn test_short_flag_with_alias_consumes_value() {
2701 let schema = ToolSchema::new("head", "Output first part of files")
2704 .param(ParamSchema::optional("lines", "int", Value::Int(10), "Number of lines")
2705 .with_aliases(["-n"]));
2706 let args = vec![
2707 Arg::ShortFlag("n".to_string()),
2708 Arg::Positional(Expr::Literal(Value::Int(5))),
2709 Arg::Positional(Expr::Literal(Value::String("/tmp/file.txt".to_string()))),
2710 ];
2711 let ctx = make_minimal_ctx();
2712
2713 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2714
2715 assert!(tool_args.flags.is_empty(), "no boolean flags: {:?}", tool_args.flags);
2716 assert_eq!(tool_args.named.get("lines"), Some(&Value::Int(5)), "should resolve alias to canonical name");
2717 assert_eq!(tool_args.positional, vec![Value::String("/tmp/file.txt".to_string())]);
2718 }
2719
2720 #[tokio::test]
2732 async fn test_glued_short_flag_value_now_binds() {
2733 let schema = ToolSchema::new("cut", "")
2737 .param(ParamSchema::optional("fields", "string", Value::Null, "").with_aliases(["-f"]));
2738 let args = vec![Arg::ShortFlag("f1".to_string())];
2739 let ctx = make_minimal_ctx();
2740
2741 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2742
2743 assert!(tool_args.flags.is_empty(), "no bogus bool flags: {:?}", tool_args.flags);
2744 assert_eq!(tool_args.named.get("fields"), Some(&Value::String("1".to_string())));
2745 }
2746
2747 #[tokio::test]
2748 async fn test_repeatable_flag_now_accumulates() {
2749 let schema = ToolSchema::new("sed", "")
2754 .param(ParamSchema::optional("expression", "string", Value::Null, "")
2755 .with_aliases(["-e"])
2756 .with_repeatable(true));
2757 let args = vec![
2758 Arg::ShortFlag("e".to_string()),
2759 Arg::Positional(Expr::Literal(Value::String("A".to_string()))),
2760 Arg::ShortFlag("e".to_string()),
2761 Arg::Positional(Expr::Literal(Value::String("B".to_string()))),
2762 ];
2763 let ctx = make_minimal_ctx();
2764
2765 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2766
2767 assert_eq!(
2768 tool_args.named.get("expression"),
2769 Some(&Value::Json(serde_json::json!(["A", "B"]))),
2770 "both occurrences must survive, not just the last: {:?}",
2771 tool_args.named
2772 );
2773 }
2774
2775 #[tokio::test]
2776 async fn test_multi_consume_flag_now_accumulates() {
2777 let schema = ToolSchema::new("jq", "")
2782 .param(ParamSchema::optional("arg", "any", Value::Null, "").consumes(2));
2783 let args = vec![
2784 Arg::LongFlag("arg".to_string()),
2785 Arg::Positional(Expr::Literal(Value::String("name".to_string()))),
2786 Arg::Positional(Expr::Literal(Value::String("val".to_string()))),
2787 ];
2788 let ctx = make_minimal_ctx();
2789
2790 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2791
2792 assert_eq!(
2793 tool_args.named.get("arg"),
2794 Some(&Value::Json(serde_json::json!([["name", "val"]]))),
2795 "got: {:?}",
2796 tool_args.named
2797 );
2798 assert!(tool_args.positional.is_empty());
2799 }
2800
2801 #[tokio::test]
2804 async fn test_merge_stderr_redirect() {
2805 let result = ExecResult::from_output(0, "stdout content", "stderr content");
2807
2808 let redirects = vec![Redirect {
2809 kind: RedirectKind::MergeStderr,
2810 target: Expr::Literal(Value::Null),
2811 }];
2812
2813 let ctx = make_minimal_ctx();
2814 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2815
2816 assert_eq!(&*result.text_out(), "stdout contentstderr content");
2817 assert!(result.err.is_empty());
2818 }
2819
2820 #[tokio::test]
2821 async fn test_merge_stderr_with_empty_stderr() {
2822 let result = ExecResult::from_output(0, "stdout only", "");
2824
2825 let redirects = vec![Redirect {
2826 kind: RedirectKind::MergeStderr,
2827 target: Expr::Literal(Value::Null),
2828 }];
2829
2830 let ctx = make_minimal_ctx();
2831 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2832
2833 assert_eq!(&*result.text_out(), "stdout only");
2834 assert!(result.err.is_empty());
2835 }
2836
2837 #[tokio::test]
2838 async fn test_merge_stderr_order_matters() {
2839 let result = ExecResult::from_output(0, "stdout\n", "stderr\n");
2844
2845 let redirects = vec![Redirect {
2847 kind: RedirectKind::MergeStderr,
2848 target: Expr::Literal(Value::Null),
2849 }];
2850
2851 let ctx = make_minimal_ctx();
2852 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2853
2854 assert_eq!(&*result.text_out(), "stdout\nstderr\n");
2855 assert!(result.err.is_empty());
2856 }
2857
2858 #[tokio::test]
2859 async fn test_redirect_with_command_execution() {
2860 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
2861
2862 let cmd = Command {
2864 name: "echo".to_string(),
2865 args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
2866 redirects: vec![Redirect {
2867 kind: RedirectKind::MergeStderr,
2868 target: Expr::Literal(Value::Null),
2869 }],
2870 };
2871
2872 let result = runner.run(&stages([cmd]), &mut ctx, &dispatcher).await;
2873 assert!(result.ok());
2874 assert!(result.text_out().contains("hello"));
2876 }
2877
2878 #[tokio::test]
2879 async fn test_merge_stderr_in_pipeline() {
2880 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
2881
2882 let echo_cmd = Command {
2885 name: "echo".to_string(),
2886 args: vec![Arg::Positional(Expr::Literal(Value::String("output".to_string())))],
2887 redirects: vec![Redirect {
2888 kind: RedirectKind::MergeStderr,
2889 target: Expr::Literal(Value::Null),
2890 }],
2891 };
2892 let grep_cmd = Command {
2893 name: "grep".to_string(),
2894 args: vec![Arg::Positional(Expr::Literal(Value::String("output".to_string())))],
2895 redirects: vec![],
2896 };
2897
2898 let result = runner.run(&stages([echo_cmd, grep_cmd]), &mut ctx, &dispatcher).await;
2899 assert!(result.ok(), "result failed: code={}, err={}", result.code, result.err);
2900 assert!(result.text_out().contains("output"));
2901 }
2902
2903 fn big_table_output(rows: usize) -> crate::interpreter::OutputData {
2915 use crate::interpreter::OutputNode;
2916 let headers = vec!["id".to_string(), "name".to_string()];
2917 let nodes: Vec<OutputNode> = (0..rows)
2918 .map(|i| OutputNode::new(i.to_string()).with_cells(vec![format!("row-{i}")]))
2919 .collect();
2920 crate::interpreter::OutputData::table(headers, nodes)
2921 }
2922
2923 #[tokio::test]
2924 async fn test_both_redirect_streams_structured_output_to_file() {
2925 let output = big_table_output(50);
2930 let expected_stdout = output.to_canonical_string();
2931 let mut result = ExecResult::with_output(output);
2932 result.err = "warning: heads up\n".to_string();
2933
2934 let redirects = vec![Redirect {
2935 kind: RedirectKind::Both,
2936 target: Expr::Literal(Value::String("/out.txt".to_string())),
2937 }];
2938 let ctx = make_minimal_ctx();
2939 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2940
2941 assert!(result.ok());
2944 assert_eq!(&*result.text_out(), "");
2945 assert!(result.err.is_empty());
2946 assert!(!result.has_output());
2947
2948 let written = ctx.backend.read(Path::new("/out.txt"), None).await.expect("file written");
2949 let written = String::from_utf8(written).expect("valid utf8");
2950 assert_eq!(written, format!("{expected_stdout}warning: heads up\n"));
2954 }
2955
2956 #[tokio::test]
2957 async fn test_both_redirect_streams_large_structured_output_intact() {
2958 let rows = 5_000;
2963 let output = big_table_output(rows);
2964 let expected_stdout = output.to_canonical_string();
2965 let result = ExecResult::with_output(output);
2966
2967 let redirects = vec![Redirect {
2968 kind: RedirectKind::Both,
2969 target: Expr::Literal(Value::String("/big.txt".to_string())),
2970 }];
2971 let ctx = make_minimal_ctx();
2972 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2973 assert!(result.ok());
2974
2975 let written = ctx.backend.read(Path::new("/big.txt"), None).await.expect("file written");
2976 let written = String::from_utf8(written).expect("valid utf8");
2977 assert_eq!(written, expected_stdout);
2978 assert!(written.contains("row-0"));
2979 assert!(written.contains(&format!("row-{}", rows - 1)));
2980 }
2981
2982 #[tokio::test]
2983 async fn test_both_redirect_still_writes_binary_stdout_raw() {
2984 let result = ExecResult::success_text_or_bytes(vec![0xff, 0x00, 0xfe, b'x']);
2988 let redirects = vec![Redirect {
2989 kind: RedirectKind::Both,
2990 target: Expr::Literal(Value::String("/bin.out".to_string())),
2991 }];
2992 let ctx = make_minimal_ctx();
2993 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2994 assert!(result.ok());
2995
2996 let written = ctx.backend.read(Path::new("/bin.out"), None).await.expect("file written");
2997 assert_eq!(written, vec![0xff, 0x00, 0xfe, b'x']);
2998 }
2999}