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