1use 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
24async fn apply_redirects(
30 mut result: ExecResult,
31 redirects: &[Redirect],
32 ctx: &ExecContext,
33) -> ExecResult {
34 for redir in redirects {
39 match redir.kind {
40 RedirectKind::MergeStderr => {
41 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 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 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 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 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 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 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 RedirectKind::Stdin | RedirectKind::HereDoc | RedirectKind::HereString => {}
148 }
149 }
150 result.materialize();
155 result
156}
157
158async 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
178async 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
190async 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
198async 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 expr => {
229 let body = eval_redirect_target(expr, ctx).await?;
230 ctx.set_stdin(body);
231 }
232 }
233 }
234 RedirectKind::HereString => {
235 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#[derive(Clone)]
249pub struct PipelineRunner {
250 tools: Arc<ToolRegistry>,
251}
252
253impl PipelineRunner {
254 pub fn new(tools: Arc<ToolRegistry>) -> Self {
256 Self { tools }
257 }
258
259 #[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 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 #[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 return self.run_single(&commands[0], ctx, None, dispatcher).await;
305 }
306
307 self.run_pipeline(commands, ctx, dispatcher).await
309 }
310
311 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 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 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 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 #[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 if let Err(e) = setup_stdin_redirects(cmd, ctx).await {
367 return ExecResult::failure(1, e);
368 }
369
370 if let Some(input) = stdin {
372 ctx.set_stdin(input);
373 }
374
375 ctx.pipeline_position = PipelinePosition::Only;
377
378 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_redirects(result, &cmd.redirects, ctx).await
386 }
387
388 #[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 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 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 let task_dispatcher: Arc<dyn CommandDispatcher> = dispatcher.fork_attached().await;
438
439 let stdin_setup = setup_stdin_redirects(&cmd, &mut stage_ctx).await;
443
444 if i == 0 {
446 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 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 stage_ctx.pipe_stdin = pipe_readers[i - 1].take();
463 }
465
466 if i < last_idx {
468 stage_ctx.pipe_stdout = pipe_writers[i].take();
469 }
470
471 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 let handle: tokio::task::JoinHandle<(ExecResult, ExecContext)> =
484 tokio::spawn(crate::telemetry::bind_current_context(async move {
485 if let Err(e) = stdin_setup {
487 return (ExecResult::failure(1, e), stage_ctx);
488 }
489
490 if let Some(mut rx) = data_receiver {
497 if let Ok(data) = rx.try_recv() {
498 stage_ctx.stdin_data = data;
499 }
500 }
502
503 let mut result = match task_dispatcher.dispatch(&cmd, &mut stage_ctx).await {
505 Ok(result) => result,
506 Err(e) => ExecResult::failure(1, e.to_string()),
507 };
508
509 result = apply_redirects(result, &cmd.redirects, &stage_ctx).await;
511
512 if !result.err.is_empty() {
518 if let Some(ref stderr) = stage_ctx.stderr {
519 stderr.write_str(&result.err);
520 result.err.clear();
521 }
522 }
523
524 if let Some(tx) = data_sender {
530 let _ = tx.send(result.data.clone());
531 }
532
533 if let Some(mut pipe_out) = stage_ctx.pipe_stdout.take() {
536 let bytes: Vec<u8> = match result.out_bytes() {
540 Some(b) => b.to_vec(),
541 None => result.text_out().into_owned().into_bytes(),
542 };
543 if !bytes.is_empty() {
544 let _ = pipe_out.write_all(&bytes).await;
546 let _ = pipe_out.shutdown().await;
547 }
548 }
550
551 (result, stage_ctx)
552 }));
553
554 handles.push(handle);
555 }
556
557 let mut last_result = ExecResult::success("");
562 let mut panics: Vec<String> = Vec::new();
563 for (i, handle) in handles.into_iter().enumerate() {
564 match handle.await {
565 Ok((result, stage_ctx)) => {
566 if i == last_idx {
567 last_result = result;
568 ctx.scope = stage_ctx.scope;
570 ctx.cwd = stage_ctx.cwd;
571 ctx.prev_cwd = stage_ctx.prev_cwd;
572 ctx.aliases = stage_ctx.aliases;
573 }
574 }
575 Err(e) => {
576 panics.push(format!("stage {}: {}", i, e));
577 }
578 }
579 }
580
581 if !panics.is_empty() {
582 last_result = ExecResult::failure(
583 1,
584 format!("pipeline stage(s) panicked: {}", panics.join("; ")),
585 );
586 }
587
588 last_result
589 }
590}
591
592pub fn select_leaf<'a>(schema: &'a ToolSchema, args: &[Arg]) -> anyhow::Result<&'a ToolSchema> {
632 let root_lookup = schema_param_lookup(schema);
635 let is_root_value_flag = |name: &str| -> bool {
636 root_lookup.get(name).is_some_and(|(_, typ, ..)| !is_bool_type(typ))
637 };
638
639 let mut node = schema;
640 let mut skip_next_positional = false;
641 for arg in args {
642 match arg {
643 Arg::DoubleDash => break,
645 Arg::LongFlag(name) if is_root_value_flag(name) => skip_next_positional = true,
648 Arg::ShortFlag(name) if is_root_value_flag(name) => skip_next_positional = true,
649 Arg::Positional(expr) => {
650 if skip_next_positional {
651 skip_next_positional = false;
652 continue; }
654 if node.subcommands.is_empty() {
655 break; }
657 match classify_subcommand_positional(expr) {
658 SubcommandWord::Word(word) => {
659 match node.subcommands.iter().find(|c| c.matches_command(word)) {
660 Some(child) => node = child, None => break, }
663 }
664 SubcommandWord::OtherLiteral => break,
668 SubcommandWord::Computed(kind) => anyhow::bail!(
669 "{}: a subcommand name is required here, but got {kind}. \
670 Subcommands must be literal words — spell it out \
671 (e.g. `{} <subcommand> …`) or use the `--flag=value` form.",
672 node.name,
673 schema.name
674 ),
675 }
676 }
677 _ => {}
679 }
680 }
681 Ok(node)
682}
683
684enum SubcommandWord<'a> {
686 Word(&'a str),
688 OtherLiteral,
690 Computed(&'static str),
692}
693
694fn classify_subcommand_positional(expr: &Expr) -> SubcommandWord<'_> {
695 match expr {
696 Expr::Literal(Value::String(s)) => SubcommandWord::Word(s),
697 Expr::Literal(_) => SubcommandWord::OtherLiteral,
698 Expr::CommandSubst(_) | Expr::Command(_) => SubcommandWord::Computed("a command substitution `$(…)`"),
699 Expr::VarRef(_)
700 | Expr::VarWithDefault { .. }
701 | Expr::VarLength(_)
702 | Expr::Positional(_)
703 | Expr::AllArgs
704 | Expr::ArgCount
705 | Expr::CurrentPid
706 | Expr::LastExitCode => SubcommandWord::Computed("a variable reference"),
707 Expr::Interpolated(_) | Expr::HereDocBody { .. } => SubcommandWord::Computed("an interpolated string"),
708 Expr::GlobPattern(_) => SubcommandWord::Computed("a glob pattern"),
709 Expr::Arithmetic(_) => SubcommandWord::Computed("an arithmetic expansion"),
710 _ => SubcommandWord::Computed("a value computed at runtime"),
711 }
712}
713
714pub fn schema_param_lookup(schema: &ToolSchema) -> HashMap<String, (&str, &str, usize, bool)> {
715 let mut map = HashMap::new();
716 for p in schema.params.iter().filter(|p| !p.positional) {
717 map.insert(p.name.clone(), (p.name.as_str(), p.param_type.as_str(), p.consumes, p.repeatable));
718 for alias in &p.aliases {
719 let stripped = alias.trim_start_matches('-');
720 map.insert(stripped.to_string(), (p.name.as_str(), p.param_type.as_str(), p.consumes, p.repeatable));
721 }
722 }
723 map
724}
725
726pub fn is_bool_type(param_type: &str) -> bool {
728 matches!(param_type.to_lowercase().as_str(), "bool" | "boolean")
729}
730
731pub fn build_tool_args(args: &[Arg], ctx: &ExecContext, schema: Option<&ToolSchema>) -> ToolArgs {
739 let mut tool_args = ToolArgs::new();
740 let param_lookup = schema.map(schema_param_lookup).unwrap_or_default();
741 let accepts_word_assign = schema
742 .map(|s| crate::tools::accepts_word_assign(s.name.as_str()))
743 .unwrap_or(false);
744
745 let mut consumed_positionals: std::collections::HashSet<usize> = std::collections::HashSet::new();
747 let mut past_double_dash = false;
748
749 let mut positional_indices: Vec<(usize, &Expr)> = Vec::new();
751 for (i, arg) in args.iter().enumerate() {
752 if let Arg::Positional(expr) = arg {
753 positional_indices.push((i, expr));
754 }
755 }
756
757 let mut i = 0;
759 while i < args.len() {
760 let arg = &args[i];
761
762 match arg {
763 Arg::DoubleDash => {
764 past_double_dash = true;
765 }
766 Arg::Positional(expr) => {
767 if !consumed_positionals.contains(&i)
769 && let Some(value) = eval_simple_expr(expr, ctx)
770 {
771 tool_args.positional.push(value);
772 }
773 }
774 Arg::Named { key, value } => {
775 if let Some(val) = eval_simple_expr(value, ctx) {
776 tool_args.named.insert(key.clone(), val);
777 }
778 }
779 Arg::WordAssign { key, value } => {
780 if let Some(val) = eval_simple_expr(value, ctx) {
781 if accepts_word_assign {
782 tool_args.named.insert(key.clone(), val);
783 } else {
784 let val_str = crate::interpreter::value_to_string(&val);
785 tool_args.positional.push(Value::String(format!("{key}={val_str}")));
786 }
787 }
788 }
789 Arg::ShortFlag(name) => {
790 if past_double_dash {
791 tool_args.positional.push(Value::String(format!("-{name}")));
792 } else if name.len() == 1 {
793 let flag_name = name.as_str();
796 let lookup = param_lookup.get(flag_name);
797 let is_bool = lookup
798 .map(|(_, typ, ..)| is_bool_type(typ))
799 .unwrap_or(true);
800
801 if is_bool {
802 tool_args.flags.insert(flag_name.to_string());
803 } else {
804 let canonical = lookup.map(|(n, ..)| *n).unwrap_or(flag_name);
806 let next_positional = positional_indices
807 .iter()
808 .find(|(idx, _)| *idx > i && !consumed_positionals.contains(idx));
809
810 if let Some((pos_idx, expr)) = next_positional {
811 if let Some(value) = eval_simple_expr(expr, ctx) {
812 tool_args.named.insert(canonical.to_string(), value);
813 consumed_positionals.insert(*pos_idx);
814 } else {
815 tool_args.flags.insert(flag_name.to_string());
816 }
817 } else {
818 tool_args.flags.insert(flag_name.to_string());
819 }
820 }
821 } else if let Some(&(canonical, typ, ..)) = param_lookup.get(name.as_str()) {
822 if is_bool_type(typ) {
824 tool_args.flags.insert(canonical.to_string());
825 } else {
826 let next_positional = positional_indices
827 .iter()
828 .find(|(idx, _)| *idx > i && !consumed_positionals.contains(idx));
829 if let Some((pos_idx, expr)) = next_positional {
830 if let Some(value) = eval_simple_expr(expr, ctx) {
831 tool_args.named.insert(canonical.to_string(), value);
832 consumed_positionals.insert(*pos_idx);
833 } else {
834 tool_args.flags.insert(name.clone());
835 }
836 } else {
837 tool_args.flags.insert(name.clone());
838 }
839 }
840 } else {
841 for c in name.chars() {
843 tool_args.flags.insert(c.to_string());
844 }
845 }
846 }
847 Arg::LongFlag(name) => {
848 if past_double_dash {
849 tool_args.positional.push(Value::String(format!("--{name}")));
850 } else {
851 let lookup = param_lookup.get(name.as_str());
853 let is_bool = lookup
854 .map(|(_, typ, ..)| is_bool_type(typ))
855 .unwrap_or(true); if is_bool {
858 tool_args.flags.insert(name.clone());
859 } else {
860 let canonical = lookup.map(|(n, ..)| *n).unwrap_or(name.as_str());
869 let next_positional = positional_indices
870 .iter()
871 .find(|(idx, _)| *idx > i && !consumed_positionals.contains(idx));
872
873 if let Some((pos_idx, expr)) = next_positional {
874 if let Some(value) = eval_simple_expr(expr, ctx) {
875 tool_args.named.insert(canonical.to_string(), value);
876 consumed_positionals.insert(*pos_idx);
877 } else {
878 tool_args.flags.insert(name.clone());
879 }
880 } else {
881 tool_args.flags.insert(name.clone());
882 }
883 }
884 }
885 }
886 }
887 i += 1;
888 }
889
890 if let Some(schema) = schema.filter(|s| s.map_positionals) {
895 let pre_dash_count = if past_double_dash {
897 let dash_pos = args.iter().position(|a| matches!(a, Arg::DoubleDash)).unwrap_or(args.len());
899 positional_indices.iter()
901 .filter(|(idx, _)| *idx < dash_pos && !consumed_positionals.contains(idx))
902 .count()
903 } else {
904 tool_args.positional.len()
905 };
906
907 let mut remaining = Vec::new();
908 let mut positional_iter = tool_args.positional.drain(..).enumerate();
909
910 for param in &schema.params {
911 if tool_args.named.contains_key(¶m.name) || tool_args.flags.contains(¶m.name) {
912 continue; }
914 if is_bool_type(¶m.param_type) {
915 continue; }
917 loop {
919 match positional_iter.next() {
920 Some((idx, val)) if idx < pre_dash_count => {
921 tool_args.named.insert(param.name.clone(), val);
922 break;
923 }
924 Some((_, val)) => {
925 remaining.push(val); }
927 None => break,
928 }
929 }
930 }
931
932 remaining.extend(positional_iter.map(|(_, v)| v));
934 tool_args.positional = remaining;
935 }
936
937 tool_args
938}
939
940pub(crate) fn eval_simple_expr(expr: &Expr, ctx: &ExecContext) -> Option<Value> {
942 match expr {
943 Expr::Literal(value) => Some(eval_literal(value, ctx)),
944 Expr::VarRef(path) => ctx.scope.resolve_path(path),
945 Expr::Interpolated(parts) => {
946 let mut result = String::new();
947 for part in parts {
948 match part {
949 crate::ast::StringPart::Literal(s) => result.push_str(s),
950 crate::ast::StringPart::Var(path) => {
951 if let Some(value) = ctx.scope.resolve_path(path) {
952 result.push_str(&value_to_string(&value));
953 }
954 }
955 crate::ast::StringPart::VarWithDefault { name, default } => {
956 match ctx.scope.get(name) {
957 Some(value) => {
958 let s = value_to_string(value);
959 if s.is_empty() {
960 result.push_str(&eval_string_parts_sync(default, ctx));
961 } else {
962 result.push_str(&s);
963 }
964 }
965 None => result.push_str(&eval_string_parts_sync(default, ctx)),
966 }
967 }
968 crate::ast::StringPart::VarLength(name) => {
969 let len = match ctx.scope.get(name) {
970 Some(value) => value_to_string(value).len(),
971 None => 0,
972 };
973 result.push_str(&len.to_string());
974 }
975 crate::ast::StringPart::Positional(n) => {
976 if let Some(s) = ctx.scope.get_positional(*n) {
977 result.push_str(s);
978 }
979 }
980 crate::ast::StringPart::AllArgs => {
981 result.push_str(&ctx.scope.all_args().join(" "));
982 }
983 crate::ast::StringPart::ArgCount => {
984 result.push_str(&ctx.scope.arg_count().to_string());
985 }
986 crate::ast::StringPart::Arithmetic(expr) => {
987 if let Ok(value) = arithmetic::eval_arithmetic(expr, &ctx.scope) {
989 result.push_str(&value.to_string());
990 }
991 }
992 crate::ast::StringPart::CommandSubst(_) => {
993 }
995 crate::ast::StringPart::LastExitCode => {
996 result.push_str(&ctx.scope.last_result().code.to_string());
997 }
998 crate::ast::StringPart::CurrentPid => {
999 result.push_str(&ctx.scope.pid().to_string());
1000 }
1001 }
1002 }
1003 Some(Value::String(result))
1004 }
1005 Expr::GlobPattern(s) => Some(Value::String(s.clone())),
1006 Expr::HereDocBody { parts, strip_tabs } => {
1007 let unwrapped: Vec<crate::ast::StringPart> =
1011 parts.iter().map(|sp| sp.part.clone()).collect();
1012 let raw = eval_string_parts_sync(&unwrapped, ctx);
1013 let body = if *strip_tabs {
1014 crate::interpreter::strip_leading_tabs(&raw)
1015 } else {
1016 raw
1017 };
1018 Some(Value::String(body))
1019 }
1020 _ => None, }
1022}
1023
1024fn eval_literal(value: &Value, _ctx: &ExecContext) -> Value {
1026 value.clone()
1027}
1028
1029fn value_to_string(value: &Value) -> String {
1031 match value {
1032 Value::Null => "".to_string(),
1033 Value::Bool(b) => b.to_string(),
1034 Value::Int(i) => i.to_string(),
1035 Value::Float(f) => f.to_string(),
1036 Value::String(s) => s.clone(),
1037 Value::Json(json) => json.to_string(),
1038 Value::Bytes(b) => format!("[binary: {} bytes]", b.len()),
1039 }
1040}
1041
1042fn eval_string_parts_sync(parts: &[crate::ast::StringPart], ctx: &ExecContext) -> String {
1045 let mut result = String::new();
1046 for part in parts {
1047 match part {
1048 crate::ast::StringPart::Literal(s) => result.push_str(s),
1049 crate::ast::StringPart::Var(path) => {
1050 if let Some(value) = ctx.scope.resolve_path(path) {
1051 result.push_str(&value_to_string(&value));
1052 }
1053 }
1054 crate::ast::StringPart::VarWithDefault { name, default } => {
1055 match ctx.scope.get(name) {
1056 Some(value) => {
1057 let s = value_to_string(value);
1058 if s.is_empty() {
1059 result.push_str(&eval_string_parts_sync(default, ctx));
1060 } else {
1061 result.push_str(&s);
1062 }
1063 }
1064 None => result.push_str(&eval_string_parts_sync(default, ctx)),
1065 }
1066 }
1067 crate::ast::StringPart::VarLength(name) => {
1068 let len = match ctx.scope.get(name) {
1069 Some(value) => value_to_string(value).len(),
1070 None => 0,
1071 };
1072 result.push_str(&len.to_string());
1073 }
1074 crate::ast::StringPart::Positional(n) => {
1075 if let Some(s) = ctx.scope.get_positional(*n) {
1076 result.push_str(s);
1077 }
1078 }
1079 crate::ast::StringPart::AllArgs => {
1080 result.push_str(&ctx.scope.all_args().join(" "));
1081 }
1082 crate::ast::StringPart::ArgCount => {
1083 result.push_str(&ctx.scope.arg_count().to_string());
1084 }
1085 crate::ast::StringPart::Arithmetic(expr) => {
1086 if let Ok(value) = arithmetic::eval_arithmetic(expr, &ctx.scope) {
1087 result.push_str(&value.to_string());
1088 }
1089 }
1090 crate::ast::StringPart::CommandSubst(_) => {
1091 }
1093 crate::ast::StringPart::LastExitCode => {
1094 result.push_str(&ctx.scope.last_result().code.to_string());
1095 }
1096 crate::ast::StringPart::CurrentPid => {
1097 result.push_str(&ctx.scope.pid().to_string());
1098 }
1099 }
1100 }
1101 result
1102}
1103
1104fn find_scatter_gather(commands: &[Command]) -> Option<(usize, usize)> {
1109 let scatter_idx = commands.iter().position(|c| c.name == "scatter")?;
1110 let gather_idx = commands.iter().position(|c| c.name == "gather")?;
1111
1112 if gather_idx > scatter_idx {
1114 Some((scatter_idx, gather_idx))
1115 } else {
1116 None
1117 }
1118}
1119
1120#[cfg(test)]
1121mod select_leaf_tests {
1122 use super::*;
1123 use crate::tools::ParamSchema;
1124
1125 fn kj_schema() -> ToolSchema {
1130 ToolSchema::new("kj", "kaijutsu")
1131 .param(ParamSchema::new("confirm", "string"))
1132 .param(ParamSchema::new("verbose", "bool"))
1133 .subcommand(
1134 ToolSchema::new("context", "context ops")
1135 .with_command_aliases(["ctx"])
1136 .subcommand(ToolSchema::new("list", "list").with_command_aliases(["ls"]))
1137 .subcommand(
1138 ToolSchema::new("create", "create").param(
1139 ParamSchema::new("type", "string").with_aliases(["t"]),
1140 ),
1141 ),
1142 )
1143 }
1144
1145 fn word(s: &str) -> Arg {
1146 Arg::Positional(Expr::Literal(Value::String(s.to_string())))
1147 }
1148
1149 #[test]
1150 fn flat_tool_returns_root() {
1151 let schema = ToolSchema::new("cat", "concat")
1152 .param(ParamSchema::required("path", "string", "f").positional());
1153 let leaf = select_leaf(&schema, &[word("foo.txt")]).expect("flat ok");
1154 assert_eq!(leaf.name, "cat");
1155 }
1156
1157 #[test]
1158 fn single_hop() {
1159 let schema = kj_schema();
1160 let leaf = select_leaf(&schema, &[word("context")]).expect("ok");
1161 assert_eq!(leaf.name, "context");
1162 }
1163
1164 #[test]
1165 fn two_hops() {
1166 let schema = kj_schema();
1167 let leaf = select_leaf(&schema, &[word("context"), word("create")]).expect("ok");
1168 assert_eq!(leaf.name, "create");
1169 assert!(leaf.params.iter().any(|p| p.name == "type"), "leaf has --type");
1170 }
1171
1172 #[test]
1173 fn alias_hops_route() {
1174 let schema = kj_schema();
1175 let leaf = select_leaf(&schema, &[word("ctx"), word("ls")]).expect("ok");
1177 assert_eq!(leaf.name, "list");
1178 }
1179
1180 #[test]
1181 fn unknown_subcommand_stops_at_current_node() {
1182 let schema = kj_schema();
1183 let leaf = select_leaf(&schema, &[word("context"), word("nonesuch")]).expect("ok");
1186 assert_eq!(leaf.name, "context");
1187 }
1188
1189 #[test]
1190 fn root_bool_flag_before_path_does_not_disrupt_routing() {
1191 let schema = kj_schema();
1192 let args = vec![Arg::LongFlag("verbose".into()), word("context"), word("create")];
1195 let leaf = select_leaf(&schema, &args).expect("ok");
1196 assert_eq!(leaf.name, "create");
1197 }
1198
1199 #[test]
1200 fn root_value_flag_space_form_before_path_skips_its_value() {
1201 let schema = kj_schema();
1202 let args = vec![
1205 Arg::LongFlag("confirm".into()),
1206 word("nonce"),
1207 word("context"),
1208 word("create"),
1209 ];
1210 let leaf = select_leaf(&schema, &args).expect("ok");
1211 assert_eq!(leaf.name, "create");
1212 }
1213
1214 #[test]
1215 fn leaf_value_flag_after_path_routes_to_leaf() {
1216 let schema = kj_schema();
1217 let args = vec![
1220 word("context"),
1221 word("create"),
1222 Arg::LongFlag("type".into()),
1223 word("x"),
1224 ];
1225 let leaf = select_leaf(&schema, &args).expect("ok");
1226 assert_eq!(leaf.name, "create");
1227 assert!(leaf.params.iter().any(|p| p.name == "type"));
1228 }
1229
1230 #[test]
1231 fn double_dash_stops_routing() {
1232 let schema = kj_schema();
1233 let leaf = select_leaf(&schema, &[Arg::DoubleDash, word("context")]).expect("ok");
1235 assert_eq!(leaf.name, "kj");
1236 }
1237
1238 #[test]
1239 fn computed_subcommand_selector_errors() {
1240 let schema = kj_schema();
1241 let args = vec![Arg::Positional(Expr::CommandSubst(vec![
1244 crate::ast::Stmt::Command(crate::ast::Command {
1245 name: "echo".into(),
1246 args: vec![],
1247 redirects: vec![],
1248 }),
1249 ]))];
1250 let err = select_leaf(&schema, &args).expect_err("must error");
1251 let msg = err.to_string();
1252 assert!(msg.contains("subcommand name is required"), "got: {msg}");
1253 assert!(msg.contains("command substitution"), "names the cause: {msg}");
1254 }
1255
1256 #[test]
1257 fn variable_subcommand_selector_errors() {
1258 let schema = kj_schema();
1259 let args = vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("sub")))];
1260 let err = select_leaf(&schema, &args).expect_err("must error");
1261 assert!(err.to_string().contains("variable reference"), "got: {err}");
1262 }
1263
1264 #[test]
1265 fn computed_positional_after_leaf_is_fine() {
1266 let schema = kj_schema();
1267 let args = vec![
1270 word("context"),
1271 word("list"),
1272 Arg::Positional(Expr::CommandSubst(vec![crate::ast::Stmt::Command(
1273 crate::ast::Command { name: "echo".into(), args: vec![], redirects: vec![] },
1274 )])),
1275 ];
1276 let leaf = select_leaf(&schema, &args).expect("ok");
1277 assert_eq!(leaf.name, "list");
1278 }
1279}
1280
1281#[cfg(test)]
1282mod tests {
1283 use super::*;
1284 use crate::dispatch::BackendDispatcher;
1285 use crate::tools::register_builtins;
1286 use crate::vfs::{Filesystem, MemoryFs, VfsRouter};
1287 use std::path::Path;
1288
1289 async fn make_runner_and_ctx() -> (PipelineRunner, ExecContext, BackendDispatcher) {
1290 let mut tools = ToolRegistry::new();
1291 register_builtins(&mut tools);
1292 let tools = Arc::new(tools);
1293 let runner = PipelineRunner::new(tools.clone());
1294 let dispatcher = BackendDispatcher::new(tools.clone());
1295
1296 let mut vfs = VfsRouter::new();
1297 let mem = MemoryFs::new();
1298 mem.write(Path::new("test.txt"), b"hello\nworld\nfoo").await.unwrap();
1299 vfs.mount("/", mem);
1300 let ctx = ExecContext::with_vfs_and_tools(Arc::new(vfs), tools);
1301
1302 (runner, ctx, dispatcher)
1303 }
1304
1305 fn make_cmd(name: &str, args: Vec<&str>) -> Command {
1306 Command {
1307 name: name.to_string(),
1308 args: args.iter().map(|s| Arg::Positional(Expr::Literal(Value::String(s.to_string())))).collect(),
1309 redirects: vec![],
1310 }
1311 }
1312
1313 #[tokio::test]
1314 async fn test_single_command() {
1315 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1316 let cmd = make_cmd("echo", vec!["hello"]);
1317
1318 let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
1319 assert!(result.ok());
1320 assert_eq!(result.text_out().trim(), "hello");
1321 }
1322
1323 #[tokio::test]
1324 async fn test_pipeline_echo_grep() {
1325 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1326
1327 let echo_cmd = Command {
1329 name: "echo".to_string(),
1330 args: vec![Arg::Positional(Expr::Literal(Value::String("hello\nworld".to_string())))],
1331 redirects: vec![],
1332 };
1333 let grep_cmd = Command {
1334 name: "grep".to_string(),
1335 args: vec![Arg::Positional(Expr::Literal(Value::String("world".to_string())))],
1336 redirects: vec![],
1337 };
1338
1339 let result = runner.run(&[echo_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1340 assert!(result.ok());
1341 assert_eq!(result.text_out().trim(), "world");
1342 }
1343
1344 #[tokio::test]
1345 async fn test_pipeline_cat_grep() {
1346 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1347
1348 let cat_cmd = make_cmd("cat", vec!["/test.txt"]);
1350 let grep_cmd = Command {
1351 name: "grep".to_string(),
1352 args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
1353 redirects: vec![],
1354 };
1355
1356 let result = runner.run(&[cat_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1357 assert!(result.ok());
1358 assert!(result.text_out().contains("hello"));
1359 }
1360
1361 #[tokio::test]
1362 async fn test_command_not_found() {
1363 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1364 let cmd = make_cmd("nonexistent", vec![]);
1365
1366 let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
1367 assert!(!result.ok());
1368 assert_eq!(result.code, 127);
1369 assert!(result.err.contains("not found"));
1370 }
1371
1372 #[tokio::test]
1373 async fn test_pipeline_continues_on_failure() {
1374 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1377
1378 let cat_cmd = make_cmd("cat", vec!["/nonexistent"]);
1381 let grep_cmd = Command {
1382 name: "grep".to_string(),
1383 args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
1384 redirects: vec![],
1385 };
1386
1387 let result = runner.run(&[cat_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1388 assert!(!result.ok());
1390 }
1391
1392 #[tokio::test]
1393 async fn test_pipeline_last_command_exit_code() {
1394 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1396
1397 let echo_cmd = make_cmd("echo", vec!["hello"]);
1398 let cat_cmd = make_cmd("cat", vec![]);
1399
1400 let result = runner.run(&[echo_cmd, cat_cmd], &mut ctx, &dispatcher).await;
1401 assert!(result.ok());
1402 assert!(result.text_out().contains("hello"));
1403 }
1404
1405 #[tokio::test]
1406 async fn test_empty_pipeline() {
1407 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1408 let result = runner.run(&[], &mut ctx, &dispatcher).await;
1409 assert!(result.ok());
1410 }
1411
1412 #[test]
1415 fn test_find_scatter_gather_both_present() {
1416 let commands = vec![
1417 make_cmd("echo", vec!["a"]),
1418 make_cmd("scatter", vec![]),
1419 make_cmd("process", vec![]),
1420 make_cmd("gather", vec![]),
1421 ];
1422 let result = find_scatter_gather(&commands);
1423 assert_eq!(result, Some((1, 3)));
1424 }
1425
1426 #[test]
1427 fn test_find_scatter_gather_no_scatter() {
1428 let commands = vec![
1429 make_cmd("echo", vec!["a"]),
1430 make_cmd("gather", vec![]),
1431 ];
1432 let result = find_scatter_gather(&commands);
1433 assert!(result.is_none());
1434 }
1435
1436 #[test]
1437 fn test_find_scatter_gather_no_gather() {
1438 let commands = vec![
1439 make_cmd("echo", vec!["a"]),
1440 make_cmd("scatter", vec![]),
1441 ];
1442 let result = find_scatter_gather(&commands);
1443 assert!(result.is_none());
1444 }
1445
1446 #[test]
1447 fn test_find_scatter_gather_wrong_order() {
1448 let commands = vec![
1449 make_cmd("gather", vec![]),
1450 make_cmd("scatter", vec![]),
1451 ];
1452 let result = find_scatter_gather(&commands);
1453 assert!(result.is_none());
1454 }
1455
1456 #[tokio::test]
1457 async fn test_scatter_gather_simple() {
1458 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1459
1460 let split_cmd = Command {
1462 name: "split".to_string(),
1463 args: vec![Arg::Positional(Expr::Literal(Value::String("a b c".to_string())))],
1464 redirects: vec![],
1465 };
1466 let scatter_cmd = make_cmd("scatter", vec![]);
1467 let process_cmd = Command {
1468 name: "echo".to_string(),
1469 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1470 redirects: vec![],
1471 };
1472 let gather_cmd = make_cmd("gather", vec![]);
1473
1474 let result = runner.run(&[split_cmd, scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1475 assert!(result.ok(), "scatter with structured data should succeed: {}", result.err);
1476 assert!(result.text_out().contains("a"));
1478 assert!(result.text_out().contains("b"));
1479 assert!(result.text_out().contains("c"));
1480 }
1481
1482 #[tokio::test]
1483 async fn test_scatter_gather_empty_input() {
1484 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1485
1486 let echo_cmd = Command {
1488 name: "echo".to_string(),
1489 args: vec![Arg::Positional(Expr::Literal(Value::String("".to_string())))],
1490 redirects: vec![],
1491 };
1492 let scatter_cmd = make_cmd("scatter", vec![]);
1493 let process_cmd = Command {
1494 name: "echo".to_string(),
1495 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1496 redirects: vec![],
1497 };
1498 let gather_cmd = make_cmd("gather", vec![]);
1499
1500 let result = runner.run(&[echo_cmd, scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1501 assert!(result.ok());
1502 assert!(result.text_out().trim().is_empty());
1503 }
1504
1505 #[tokio::test]
1506 async fn test_scatter_gather_with_structured_stdin() {
1507 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1508
1509 let data = Value::Json(serde_json::json!(["x", "y", "z"]));
1511 ctx.set_stdin_with_data("x\ny\nz".to_string(), Some(data));
1512
1513 let scatter_cmd = make_cmd("scatter", vec![]);
1514 let process_cmd = Command {
1515 name: "echo".to_string(),
1516 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1517 redirects: vec![],
1518 };
1519 let gather_cmd = make_cmd("gather", vec![]);
1520
1521 let result = runner.run(&[scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1522 assert!(result.ok(), "scatter with structured stdin should succeed: {}", result.err);
1523 assert!(result.text_out().contains("x"));
1524 assert!(result.text_out().contains("y"));
1525 assert!(result.text_out().contains("z"));
1526 }
1527
1528 #[tokio::test]
1529 async fn test_scatter_gather_json_input() {
1530 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1531
1532 let data = Value::Json(serde_json::json!(["one", "two", "three"]));
1534 ctx.set_stdin_with_data(r#"["one", "two", "three"]"#.to_string(), Some(data));
1535
1536 let scatter_cmd = make_cmd("scatter", vec![]);
1537 let process_cmd = Command {
1538 name: "echo".to_string(),
1539 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1540 redirects: vec![],
1541 };
1542 let gather_cmd = make_cmd("gather", vec![]);
1543
1544 let result = runner.run(&[scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1545 assert!(result.ok(), "scatter with JSON data should succeed: {}", result.err);
1546 assert!(result.text_out().contains("one"));
1547 assert!(result.text_out().contains("two"));
1548 assert!(result.text_out().contains("three"));
1549 }
1550
1551 #[tokio::test]
1552 async fn test_scatter_gather_with_post_gather() {
1553 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1554
1555 let split_cmd = Command {
1557 name: "split".to_string(),
1558 args: vec![Arg::Positional(Expr::Literal(Value::String("a b".to_string())))],
1559 redirects: vec![],
1560 };
1561 let scatter_cmd = make_cmd("scatter", vec![]);
1562 let process_cmd = Command {
1563 name: "echo".to_string(),
1564 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1565 redirects: vec![],
1566 };
1567 let gather_cmd = make_cmd("gather", vec![]);
1568 let grep_cmd = Command {
1569 name: "grep".to_string(),
1570 args: vec![Arg::Positional(Expr::Literal(Value::String("a".to_string())))],
1571 redirects: vec![],
1572 };
1573
1574 let result = runner.run(&[split_cmd, scatter_cmd, process_cmd, gather_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1575 assert!(result.ok(), "scatter with post_gather should succeed: {}", result.err);
1576 assert!(result.text_out().contains("a"));
1577 assert!(!result.text_out().contains("b"));
1578 }
1579
1580 #[tokio::test]
1581 async fn test_scatter_custom_var_name() {
1582 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1583
1584 let data = Value::Json(serde_json::json!(["test1", "test2"]));
1586 ctx.set_stdin_with_data("test1\ntest2".to_string(), Some(data));
1587
1588 let scatter_cmd = Command {
1590 name: "scatter".to_string(),
1591 args: vec![Arg::Named {
1592 key: "as".to_string(),
1593 value: Expr::Literal(Value::String("URL".to_string())),
1594 }],
1595 redirects: vec![],
1596 };
1597 let process_cmd = Command {
1598 name: "echo".to_string(),
1599 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("URL")))],
1600 redirects: vec![],
1601 };
1602 let gather_cmd = make_cmd("gather", vec![]);
1603
1604 let result = runner.run(&[scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1605 assert!(result.ok(), "scatter with custom var should succeed: {}", result.err);
1606 assert!(result.text_out().contains("test1"));
1607 assert!(result.text_out().contains("test2"));
1608 }
1609
1610 #[tokio::test]
1613 async fn test_pipeline_routes_through_backend() {
1614 use crate::backend::testing::MockBackend;
1615 use std::sync::atomic::Ordering;
1616
1617 let (backend, call_count) = MockBackend::new();
1619 let backend: std::sync::Arc<dyn crate::backend::KernelBackend> = std::sync::Arc::new(backend);
1620
1621 let mut ctx = crate::tools::ExecContext::with_backend(backend);
1623
1624 let tools = std::sync::Arc::new(ToolRegistry::new());
1626 let runner = PipelineRunner::new(tools.clone());
1627 let dispatcher = BackendDispatcher::new(tools);
1628
1629 let cmd = make_cmd("test-tool", vec!["arg1"]);
1631 let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
1632
1633 assert!(result.ok(), "Mock backend should return success");
1634 assert_eq!(call_count.load(Ordering::SeqCst), 1, "call_tool should be invoked once");
1635 assert!(result.text_out().contains("mock executed"), "Output should be from mock backend");
1636 }
1637
1638 #[tokio::test]
1639 async fn test_multi_command_pipeline_routes_through_backend() {
1640 use crate::backend::testing::MockBackend;
1641 use std::sync::atomic::Ordering;
1642
1643 let (backend, call_count) = MockBackend::new();
1644 let backend: std::sync::Arc<dyn crate::backend::KernelBackend> = std::sync::Arc::new(backend);
1645 let mut ctx = crate::tools::ExecContext::with_backend(backend);
1646
1647 let tools = std::sync::Arc::new(ToolRegistry::new());
1648 let runner = PipelineRunner::new(tools.clone());
1649 let dispatcher = BackendDispatcher::new(tools);
1650
1651 let cmd1 = make_cmd("tool1", vec![]);
1653 let cmd2 = make_cmd("tool2", vec![]);
1654 let cmd3 = make_cmd("tool3", vec![]);
1655
1656 let result = runner.run(&[cmd1, cmd2, cmd3], &mut ctx, &dispatcher).await;
1657
1658 assert!(result.ok());
1659 assert_eq!(call_count.load(Ordering::SeqCst), 3, "call_tool should be invoked for each command");
1660 }
1661
1662 use crate::tools::{ParamSchema, ToolSchema};
1665
1666 fn make_test_schema() -> ToolSchema {
1667 ToolSchema::new("test-tool", "A test tool for schema-aware parsing")
1668 .param(ParamSchema::required("query", "string", "Search query"))
1669 .param(ParamSchema::optional("limit", "int", Value::Int(10), "Max results"))
1670 .param(ParamSchema::optional("verbose", "bool", Value::Bool(false), "Verbose output"))
1671 .param(ParamSchema::optional("output", "string", Value::String("stdout".into()), "Output destination"))
1672 .with_positional_mapping()
1673 }
1674
1675 fn make_minimal_ctx() -> ExecContext {
1676 let mut vfs = VfsRouter::new();
1677 vfs.mount("/", MemoryFs::new());
1678 ExecContext::new(Arc::new(vfs))
1679 }
1680
1681 #[test]
1682 fn test_schema_aware_string_arg() {
1683 let args = vec![
1685 Arg::LongFlag("query".to_string()),
1686 Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
1687 ];
1688 let schema = make_test_schema();
1689 let ctx = make_minimal_ctx();
1690
1691 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
1692
1693 assert!(tool_args.flags.is_empty(), "No flags should be set");
1694 assert!(tool_args.positional.is_empty(), "No positionals - consumed by --query");
1695 assert_eq!(
1696 tool_args.named.get("query"),
1697 Some(&Value::String("test".to_string())),
1698 "--query should consume 'test' as its value"
1699 );
1700 }
1701
1702 #[test]
1703 fn test_schema_aware_bool_flag() {
1704 let args = vec![
1706 Arg::LongFlag("verbose".to_string()),
1707 ];
1708 let schema = make_test_schema();
1709 let ctx = make_minimal_ctx();
1710
1711 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
1712
1713 assert!(tool_args.flags.contains("verbose"), "--verbose should be a flag");
1714 assert!(tool_args.named.is_empty(), "No named args");
1715 assert!(tool_args.positional.is_empty(), "No positionals");
1716 }
1717
1718 #[test]
1719 fn test_schema_aware_mixed() {
1720 let args = vec![
1723 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
1724 Arg::LongFlag("output".to_string()),
1725 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
1726 Arg::LongFlag("verbose".to_string()),
1727 ];
1728 let schema = make_test_schema();
1729 let ctx = make_minimal_ctx();
1730
1731 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
1732
1733 assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
1734 assert_eq!(
1735 tool_args.named.get("query"),
1736 Some(&Value::String("file.txt".to_string()))
1737 );
1738 assert_eq!(
1739 tool_args.named.get("output"),
1740 Some(&Value::String("out.txt".to_string()))
1741 );
1742 assert!(tool_args.flags.contains("verbose"));
1743 }
1744
1745 #[test]
1746 fn test_schema_aware_multiple_string_args() {
1747 let args = vec![
1749 Arg::LongFlag("query".to_string()),
1750 Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
1751 Arg::LongFlag("output".to_string()),
1752 Arg::Positional(Expr::Literal(Value::String("result.json".to_string()))),
1753 Arg::LongFlag("verbose".to_string()),
1754 Arg::LongFlag("limit".to_string()),
1755 Arg::Positional(Expr::Literal(Value::Int(5))),
1756 ];
1757 let schema = make_test_schema();
1758 let ctx = make_minimal_ctx();
1759
1760 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
1761
1762 assert!(tool_args.positional.is_empty(), "All positionals consumed");
1763 assert_eq!(
1764 tool_args.named.get("query"),
1765 Some(&Value::String("test".to_string()))
1766 );
1767 assert_eq!(
1768 tool_args.named.get("output"),
1769 Some(&Value::String("result.json".to_string()))
1770 );
1771 assert_eq!(
1772 tool_args.named.get("limit"),
1773 Some(&Value::Int(5))
1774 );
1775 assert!(tool_args.flags.contains("verbose"));
1776 }
1777
1778 #[test]
1779 fn test_schema_aware_double_dash() {
1780 let args = vec![
1783 Arg::LongFlag("output".to_string()),
1784 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
1785 Arg::DoubleDash,
1786 Arg::Positional(Expr::Literal(Value::String("--this-is-data".to_string()))),
1787 ];
1788 let schema = make_test_schema();
1789 let ctx = make_minimal_ctx();
1790
1791 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
1792
1793 assert_eq!(
1794 tool_args.named.get("output"),
1795 Some(&Value::String("out.txt".to_string()))
1796 );
1797 assert_eq!(
1799 tool_args.positional,
1800 vec![Value::String("--this-is-data".to_string())]
1801 );
1802 }
1803
1804 #[test]
1805 fn test_no_schema_fallback() {
1806 let args = vec![
1808 Arg::LongFlag("query".to_string()),
1809 Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
1810 ];
1811 let ctx = make_minimal_ctx();
1812
1813 let tool_args = build_tool_args(&args, &ctx, None);
1814
1815 assert!(tool_args.flags.contains("query"), "--query should be a flag");
1817 assert_eq!(
1818 tool_args.positional,
1819 vec![Value::String("test".to_string())],
1820 "'test' should be a positional"
1821 );
1822 }
1823
1824 #[test]
1825 fn test_unknown_flag_in_schema() {
1826 let args = vec![
1828 Arg::LongFlag("unknown".to_string()),
1829 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
1830 ];
1831 let schema = make_test_schema();
1832 let ctx = make_minimal_ctx();
1833
1834 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
1835
1836 assert!(tool_args.flags.contains("unknown"));
1837 assert!(tool_args.positional.is_empty(), "value consumed as query param");
1838 assert_eq!(
1839 tool_args.named.get("query"),
1840 Some(&Value::String("value".to_string()))
1841 );
1842 }
1843
1844 #[test]
1845 fn test_named_args_unchanged() {
1846 let args = vec![
1848 Arg::Named {
1849 key: "query".to_string(),
1850 value: Expr::Literal(Value::String("test".to_string())),
1851 },
1852 Arg::LongFlag("verbose".to_string()),
1853 ];
1854 let schema = make_test_schema();
1855 let ctx = make_minimal_ctx();
1856
1857 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
1858
1859 assert_eq!(
1860 tool_args.named.get("query"),
1861 Some(&Value::String("test".to_string()))
1862 );
1863 assert!(tool_args.flags.contains("verbose"));
1864 }
1865
1866 #[test]
1867 fn test_short_flags_unchanged() {
1868 let args = vec![
1870 Arg::ShortFlag("la".to_string()),
1871 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
1872 ];
1873 let schema = make_test_schema();
1874 let ctx = make_minimal_ctx();
1875
1876 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
1877
1878 assert!(tool_args.flags.contains("l"));
1879 assert!(tool_args.flags.contains("a"));
1880 assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
1881 assert_eq!(
1882 tool_args.named.get("query"),
1883 Some(&Value::String("file.txt".to_string()))
1884 );
1885 }
1886
1887 #[test]
1888 fn test_flag_at_end_no_value() {
1889 let args = vec![
1892 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
1893 Arg::LongFlag("output".to_string()),
1894 ];
1895 let schema = make_test_schema();
1896 let ctx = make_minimal_ctx();
1897
1898 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
1899
1900 assert!(tool_args.flags.contains("output"));
1902 assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
1903 assert_eq!(
1904 tool_args.named.get("query"),
1905 Some(&Value::String("file.txt".to_string()))
1906 );
1907 }
1908
1909 #[test]
1910 fn test_positional_skips_bool_params() {
1911 let schema = ToolSchema::new("test", "")
1915 .param(ParamSchema::required("query", "string", ""))
1916 .param(ParamSchema::optional(
1917 "verbose",
1918 "bool",
1919 Value::Bool(false),
1920 "",
1921 ))
1922 .param(ParamSchema::optional(
1923 "output",
1924 "string",
1925 Value::Null,
1926 "",
1927 ))
1928 .with_positional_mapping();
1929 let args = vec![
1930 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
1931 Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
1932 ];
1933 let ctx = make_minimal_ctx();
1934
1935 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
1936
1937 assert_eq!(
1938 tool_args.named.get("query"),
1939 Some(&Value::String("val1".to_string()))
1940 );
1941 assert_eq!(
1942 tool_args.named.get("output"),
1943 Some(&Value::String("val2".to_string()))
1944 );
1945 assert!(!tool_args.flags.contains("verbose"));
1946 assert!(tool_args.positional.is_empty());
1947 }
1948
1949 #[test]
1950 fn test_positionals_fill_available_slots() {
1951 let args = vec![
1954 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
1955 Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
1956 Arg::Positional(Expr::Literal(Value::String("val3".to_string()))),
1957 ];
1958 let schema = make_test_schema(); let ctx = make_minimal_ctx();
1960
1961 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
1962
1963 assert_eq!(
1966 tool_args.named.get("query"),
1967 Some(&Value::String("val1".to_string()))
1968 );
1969 assert_eq!(
1970 tool_args.named.get("limit"),
1971 Some(&Value::String("val2".to_string()))
1972 );
1973 assert_eq!(
1974 tool_args.named.get("output"),
1975 Some(&Value::String("val3".to_string()))
1976 );
1977 assert!(tool_args.positional.is_empty());
1978 }
1979
1980 #[test]
1981 fn test_truly_excess_positionals() {
1982 let schema = ToolSchema::new("test", "")
1984 .param(ParamSchema::required("name", "string", ""))
1985 .with_positional_mapping();
1986 let args = vec![
1987 Arg::Positional(Expr::Literal(Value::String("first".to_string()))),
1988 Arg::Positional(Expr::Literal(Value::String("second".to_string()))),
1989 Arg::Positional(Expr::Literal(Value::String("third".to_string()))),
1990 ];
1991 let ctx = make_minimal_ctx();
1992
1993 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
1994
1995 assert_eq!(
1996 tool_args.named.get("name"),
1997 Some(&Value::String("first".to_string()))
1998 );
1999 assert_eq!(
2000 tool_args.positional,
2001 vec![
2002 Value::String("second".to_string()),
2003 Value::String("third".to_string()),
2004 ]
2005 );
2006 }
2007
2008 #[test]
2009 fn test_double_dash_positional_not_mapped() {
2010 let args = vec![
2012 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2013 Arg::DoubleDash,
2014 Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
2015 ];
2016 let schema = make_test_schema();
2017 let ctx = make_minimal_ctx();
2018
2019 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
2020
2021 assert_eq!(
2022 tool_args.named.get("query"),
2023 Some(&Value::String("val1".to_string()))
2024 );
2025 assert_eq!(
2027 tool_args.positional,
2028 vec![Value::String("val2".to_string())]
2029 );
2030 }
2031
2032 #[test]
2033 fn test_all_params_filled_by_flags() {
2034 let args = vec![
2036 Arg::LongFlag("query".to_string()),
2037 Arg::Positional(Expr::Literal(Value::String("search".to_string()))),
2038 Arg::LongFlag("output".to_string()),
2039 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
2040 Arg::LongFlag("verbose".to_string()),
2041 ];
2042 let schema = make_test_schema();
2043 let ctx = make_minimal_ctx();
2044
2045 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
2046
2047 assert_eq!(
2048 tool_args.named.get("query"),
2049 Some(&Value::String("search".to_string()))
2050 );
2051 assert_eq!(
2052 tool_args.named.get("output"),
2053 Some(&Value::String("out.txt".to_string()))
2054 );
2055 assert!(tool_args.flags.contains("verbose"));
2056 assert!(tool_args.positional.is_empty());
2057 }
2058
2059 #[test]
2060 fn test_mixed_flags_and_positional_fill() {
2061 let args = vec![
2063 Arg::LongFlag("output".to_string()),
2064 Arg::Positional(Expr::Literal(Value::String("foo".to_string()))),
2065 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2066 ];
2067 let schema = make_test_schema();
2068 let ctx = make_minimal_ctx();
2069
2070 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
2071
2072 assert_eq!(
2073 tool_args.named.get("output"),
2074 Some(&Value::String("foo".to_string()))
2075 );
2076 assert_eq!(
2077 tool_args.named.get("query"),
2078 Some(&Value::String("val1".to_string()))
2079 );
2080 assert!(tool_args.positional.is_empty());
2081 }
2082
2083 #[test]
2084 fn test_alias_flag_prevents_mapping_overwrite() {
2085 let schema = ToolSchema::new("test", "")
2087 .param(ParamSchema::required("query", "string", "").with_aliases(["-q"]))
2088 .param(ParamSchema::required("output", "string", ""))
2089 .with_positional_mapping();
2090 let args = vec![
2091 Arg::ShortFlag("q".to_string()),
2092 Arg::Positional(Expr::Literal(Value::String("search".to_string()))),
2093 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
2094 ];
2095 let ctx = make_minimal_ctx();
2096
2097 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
2098
2099 assert_eq!(
2100 tool_args.named.get("query"),
2101 Some(&Value::String("search".to_string()))
2102 );
2103 assert_eq!(
2104 tool_args.named.get("output"),
2105 Some(&Value::String("out.txt".to_string()))
2106 );
2107 assert!(tool_args.positional.is_empty());
2108 }
2109
2110 #[test]
2111 fn test_builtin_schema_no_positional_mapping() {
2112 let schema = ToolSchema::new("echo", "")
2114 .param(ParamSchema::optional("args", "any", Value::Null, ""))
2115 .param(ParamSchema::optional("no_newline", "bool", Value::Bool(false), ""));
2116 let args = vec![
2118 Arg::Positional(Expr::Literal(Value::String("hello".to_string()))),
2119 Arg::Positional(Expr::Literal(Value::String("world".to_string()))),
2120 ];
2121 let ctx = make_minimal_ctx();
2122
2123 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
2124
2125 assert_eq!(
2127 tool_args.positional,
2128 vec![
2129 Value::String("hello".to_string()),
2130 Value::String("world".to_string()),
2131 ]
2132 );
2133 assert!(!tool_args.named.contains_key("args"));
2134 }
2135
2136 #[test]
2137 fn test_short_flag_with_alias_consumes_value() {
2138 let schema = ToolSchema::new("head", "Output first part of files")
2141 .param(ParamSchema::optional("lines", "int", Value::Int(10), "Number of lines")
2142 .with_aliases(["-n"]));
2143 let args = vec![
2144 Arg::ShortFlag("n".to_string()),
2145 Arg::Positional(Expr::Literal(Value::Int(5))),
2146 Arg::Positional(Expr::Literal(Value::String("/tmp/file.txt".to_string()))),
2147 ];
2148 let ctx = make_minimal_ctx();
2149
2150 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
2151
2152 assert!(tool_args.flags.is_empty(), "no boolean flags: {:?}", tool_args.flags);
2153 assert_eq!(tool_args.named.get("lines"), Some(&Value::Int(5)), "should resolve alias to canonical name");
2154 assert_eq!(tool_args.positional, vec![Value::String("/tmp/file.txt".to_string())]);
2155 }
2156
2157 #[tokio::test]
2160 async fn test_merge_stderr_redirect() {
2161 let result = ExecResult::from_output(0, "stdout content", "stderr content");
2163
2164 let redirects = vec![Redirect {
2165 kind: RedirectKind::MergeStderr,
2166 target: Expr::Literal(Value::Null),
2167 }];
2168
2169 let ctx = make_minimal_ctx();
2170 let result = apply_redirects(result, &redirects, &ctx).await;
2171
2172 assert_eq!(&*result.text_out(), "stdout contentstderr content");
2173 assert!(result.err.is_empty());
2174 }
2175
2176 #[tokio::test]
2177 async fn test_merge_stderr_with_empty_stderr() {
2178 let result = ExecResult::from_output(0, "stdout only", "");
2180
2181 let redirects = vec![Redirect {
2182 kind: RedirectKind::MergeStderr,
2183 target: Expr::Literal(Value::Null),
2184 }];
2185
2186 let ctx = make_minimal_ctx();
2187 let result = apply_redirects(result, &redirects, &ctx).await;
2188
2189 assert_eq!(&*result.text_out(), "stdout only");
2190 assert!(result.err.is_empty());
2191 }
2192
2193 #[tokio::test]
2194 async fn test_merge_stderr_order_matters() {
2195 let result = ExecResult::from_output(0, "stdout\n", "stderr\n");
2200
2201 let redirects = vec![Redirect {
2203 kind: RedirectKind::MergeStderr,
2204 target: Expr::Literal(Value::Null),
2205 }];
2206
2207 let ctx = make_minimal_ctx();
2208 let result = apply_redirects(result, &redirects, &ctx).await;
2209
2210 assert_eq!(&*result.text_out(), "stdout\nstderr\n");
2211 assert!(result.err.is_empty());
2212 }
2213
2214 #[tokio::test]
2215 async fn test_redirect_with_command_execution() {
2216 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
2217
2218 let cmd = Command {
2220 name: "echo".to_string(),
2221 args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
2222 redirects: vec![Redirect {
2223 kind: RedirectKind::MergeStderr,
2224 target: Expr::Literal(Value::Null),
2225 }],
2226 };
2227
2228 let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
2229 assert!(result.ok());
2230 assert!(result.text_out().contains("hello"));
2232 }
2233
2234 #[tokio::test]
2235 async fn test_merge_stderr_in_pipeline() {
2236 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
2237
2238 let echo_cmd = Command {
2241 name: "echo".to_string(),
2242 args: vec![Arg::Positional(Expr::Literal(Value::String("output".to_string())))],
2243 redirects: vec![Redirect {
2244 kind: RedirectKind::MergeStderr,
2245 target: Expr::Literal(Value::Null),
2246 }],
2247 };
2248 let grep_cmd = Command {
2249 name: "grep".to_string(),
2250 args: vec![Arg::Positional(Expr::Literal(Value::String("output".to_string())))],
2251 redirects: vec![],
2252 };
2253
2254 let result = runner.run(&[echo_cmd, grep_cmd], &mut ctx, &dispatcher).await;
2255 assert!(result.ok(), "result failed: code={}, err={}", result.code, result.err);
2256 assert!(result.text_out().contains("output"));
2257 }
2258}