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 mut asm = crate::interpreter::HeredocAssembler::new(*strip_tabs);
1007 for sp in parts {
1008 match &sp.part {
1009 crate::ast::StringPart::Literal(s) => asm.push_literal(s),
1010 other => asm.push_interpolated(&eval_string_parts_sync(
1011 std::slice::from_ref(other),
1012 ctx,
1013 )),
1014 }
1015 }
1016 Some(Value::String(asm.into_string()))
1017 }
1018 _ => None, }
1020}
1021
1022fn eval_literal(value: &Value, _ctx: &ExecContext) -> Value {
1024 value.clone()
1025}
1026
1027fn value_to_string(value: &Value) -> String {
1029 match value {
1030 Value::Null => "".to_string(),
1031 Value::Bool(b) => b.to_string(),
1032 Value::Int(i) => i.to_string(),
1033 Value::Float(f) => f.to_string(),
1034 Value::String(s) => s.clone(),
1035 Value::Json(json) => json.to_string(),
1036 Value::Bytes(b) => format!("[binary: {} bytes]", b.len()),
1037 }
1038}
1039
1040fn eval_string_parts_sync(parts: &[crate::ast::StringPart], ctx: &ExecContext) -> String {
1043 let mut result = String::new();
1044 for part in parts {
1045 match part {
1046 crate::ast::StringPart::Literal(s) => result.push_str(s),
1047 crate::ast::StringPart::Var(path) => {
1048 if let Some(value) = ctx.scope.resolve_path(path) {
1049 result.push_str(&value_to_string(&value));
1050 }
1051 }
1052 crate::ast::StringPart::VarWithDefault { name, default } => {
1053 match ctx.scope.get(name) {
1054 Some(value) => {
1055 let s = value_to_string(value);
1056 if s.is_empty() {
1057 result.push_str(&eval_string_parts_sync(default, ctx));
1058 } else {
1059 result.push_str(&s);
1060 }
1061 }
1062 None => result.push_str(&eval_string_parts_sync(default, ctx)),
1063 }
1064 }
1065 crate::ast::StringPart::VarLength(name) => {
1066 let len = match ctx.scope.get(name) {
1067 Some(value) => value_to_string(value).len(),
1068 None => 0,
1069 };
1070 result.push_str(&len.to_string());
1071 }
1072 crate::ast::StringPart::Positional(n) => {
1073 if let Some(s) = ctx.scope.get_positional(*n) {
1074 result.push_str(s);
1075 }
1076 }
1077 crate::ast::StringPart::AllArgs => {
1078 result.push_str(&ctx.scope.all_args().join(" "));
1079 }
1080 crate::ast::StringPart::ArgCount => {
1081 result.push_str(&ctx.scope.arg_count().to_string());
1082 }
1083 crate::ast::StringPart::Arithmetic(expr) => {
1084 if let Ok(value) = arithmetic::eval_arithmetic(expr, &ctx.scope) {
1085 result.push_str(&value.to_string());
1086 }
1087 }
1088 crate::ast::StringPart::CommandSubst(_) => {
1089 }
1091 crate::ast::StringPart::LastExitCode => {
1092 result.push_str(&ctx.scope.last_result().code.to_string());
1093 }
1094 crate::ast::StringPart::CurrentPid => {
1095 result.push_str(&ctx.scope.pid().to_string());
1096 }
1097 }
1098 }
1099 result
1100}
1101
1102fn find_scatter_gather(commands: &[Command]) -> Option<(usize, usize)> {
1107 let scatter_idx = commands.iter().position(|c| c.name == "scatter")?;
1108 let gather_idx = commands.iter().position(|c| c.name == "gather")?;
1109
1110 if gather_idx > scatter_idx {
1112 Some((scatter_idx, gather_idx))
1113 } else {
1114 None
1115 }
1116}
1117
1118#[cfg(test)]
1119mod select_leaf_tests {
1120 use super::*;
1121 use crate::tools::ParamSchema;
1122
1123 fn kj_schema() -> ToolSchema {
1128 ToolSchema::new("kj", "kaijutsu")
1129 .param(ParamSchema::new("confirm", "string"))
1130 .param(ParamSchema::new("verbose", "bool"))
1131 .subcommand(
1132 ToolSchema::new("context", "context ops")
1133 .with_command_aliases(["ctx"])
1134 .subcommand(ToolSchema::new("list", "list").with_command_aliases(["ls"]))
1135 .subcommand(
1136 ToolSchema::new("create", "create").param(
1137 ParamSchema::new("type", "string").with_aliases(["t"]),
1138 ),
1139 ),
1140 )
1141 }
1142
1143 fn word(s: &str) -> Arg {
1144 Arg::Positional(Expr::Literal(Value::String(s.to_string())))
1145 }
1146
1147 #[test]
1148 fn flat_tool_returns_root() {
1149 let schema = ToolSchema::new("cat", "concat")
1150 .param(ParamSchema::required("path", "string", "f").positional());
1151 let leaf = select_leaf(&schema, &[word("foo.txt")]).expect("flat ok");
1152 assert_eq!(leaf.name, "cat");
1153 }
1154
1155 #[test]
1156 fn single_hop() {
1157 let schema = kj_schema();
1158 let leaf = select_leaf(&schema, &[word("context")]).expect("ok");
1159 assert_eq!(leaf.name, "context");
1160 }
1161
1162 #[test]
1163 fn two_hops() {
1164 let schema = kj_schema();
1165 let leaf = select_leaf(&schema, &[word("context"), word("create")]).expect("ok");
1166 assert_eq!(leaf.name, "create");
1167 assert!(leaf.params.iter().any(|p| p.name == "type"), "leaf has --type");
1168 }
1169
1170 #[test]
1171 fn alias_hops_route() {
1172 let schema = kj_schema();
1173 let leaf = select_leaf(&schema, &[word("ctx"), word("ls")]).expect("ok");
1175 assert_eq!(leaf.name, "list");
1176 }
1177
1178 #[test]
1179 fn unknown_subcommand_stops_at_current_node() {
1180 let schema = kj_schema();
1181 let leaf = select_leaf(&schema, &[word("context"), word("nonesuch")]).expect("ok");
1184 assert_eq!(leaf.name, "context");
1185 }
1186
1187 #[test]
1188 fn root_bool_flag_before_path_does_not_disrupt_routing() {
1189 let schema = kj_schema();
1190 let args = vec![Arg::LongFlag("verbose".into()), word("context"), word("create")];
1193 let leaf = select_leaf(&schema, &args).expect("ok");
1194 assert_eq!(leaf.name, "create");
1195 }
1196
1197 #[test]
1198 fn root_value_flag_space_form_before_path_skips_its_value() {
1199 let schema = kj_schema();
1200 let args = vec![
1203 Arg::LongFlag("confirm".into()),
1204 word("nonce"),
1205 word("context"),
1206 word("create"),
1207 ];
1208 let leaf = select_leaf(&schema, &args).expect("ok");
1209 assert_eq!(leaf.name, "create");
1210 }
1211
1212 #[test]
1213 fn leaf_value_flag_after_path_routes_to_leaf() {
1214 let schema = kj_schema();
1215 let args = vec![
1218 word("context"),
1219 word("create"),
1220 Arg::LongFlag("type".into()),
1221 word("x"),
1222 ];
1223 let leaf = select_leaf(&schema, &args).expect("ok");
1224 assert_eq!(leaf.name, "create");
1225 assert!(leaf.params.iter().any(|p| p.name == "type"));
1226 }
1227
1228 #[test]
1229 fn double_dash_stops_routing() {
1230 let schema = kj_schema();
1231 let leaf = select_leaf(&schema, &[Arg::DoubleDash, word("context")]).expect("ok");
1233 assert_eq!(leaf.name, "kj");
1234 }
1235
1236 #[test]
1237 fn computed_subcommand_selector_errors() {
1238 let schema = kj_schema();
1239 let args = vec![Arg::Positional(Expr::CommandSubst(vec![
1242 crate::ast::Stmt::Command(crate::ast::Command {
1243 name: "echo".into(),
1244 args: vec![],
1245 redirects: vec![],
1246 }),
1247 ]))];
1248 let err = select_leaf(&schema, &args).expect_err("must error");
1249 let msg = err.to_string();
1250 assert!(msg.contains("subcommand name is required"), "got: {msg}");
1251 assert!(msg.contains("command substitution"), "names the cause: {msg}");
1252 }
1253
1254 #[test]
1255 fn variable_subcommand_selector_errors() {
1256 let schema = kj_schema();
1257 let args = vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("sub")))];
1258 let err = select_leaf(&schema, &args).expect_err("must error");
1259 assert!(err.to_string().contains("variable reference"), "got: {err}");
1260 }
1261
1262 #[test]
1263 fn computed_positional_after_leaf_is_fine() {
1264 let schema = kj_schema();
1265 let args = vec![
1268 word("context"),
1269 word("list"),
1270 Arg::Positional(Expr::CommandSubst(vec![crate::ast::Stmt::Command(
1271 crate::ast::Command { name: "echo".into(), args: vec![], redirects: vec![] },
1272 )])),
1273 ];
1274 let leaf = select_leaf(&schema, &args).expect("ok");
1275 assert_eq!(leaf.name, "list");
1276 }
1277}
1278
1279#[cfg(test)]
1280mod tests {
1281 use super::*;
1282 use crate::dispatch::BackendDispatcher;
1283 use crate::tools::register_builtins;
1284 use crate::vfs::{Filesystem, MemoryFs, VfsRouter};
1285 use std::path::Path;
1286
1287 async fn make_runner_and_ctx() -> (PipelineRunner, ExecContext, BackendDispatcher) {
1288 let mut tools = ToolRegistry::new();
1289 register_builtins(&mut tools);
1290 let tools = Arc::new(tools);
1291 let runner = PipelineRunner::new(tools.clone());
1292 let dispatcher = BackendDispatcher::new(tools.clone());
1293
1294 let mut vfs = VfsRouter::new();
1295 let mem = MemoryFs::new();
1296 mem.write(Path::new("test.txt"), b"hello\nworld\nfoo").await.unwrap();
1297 vfs.mount("/", mem);
1298 let ctx = ExecContext::with_vfs_and_tools(Arc::new(vfs), tools);
1299
1300 (runner, ctx, dispatcher)
1301 }
1302
1303 fn make_cmd(name: &str, args: Vec<&str>) -> Command {
1304 Command {
1305 name: name.to_string(),
1306 args: args.iter().map(|s| Arg::Positional(Expr::Literal(Value::String(s.to_string())))).collect(),
1307 redirects: vec![],
1308 }
1309 }
1310
1311 #[tokio::test]
1312 async fn test_single_command() {
1313 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1314 let cmd = make_cmd("echo", vec!["hello"]);
1315
1316 let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
1317 assert!(result.ok());
1318 assert_eq!(result.text_out().trim(), "hello");
1319 }
1320
1321 #[tokio::test]
1322 async fn test_pipeline_echo_grep() {
1323 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1324
1325 let echo_cmd = Command {
1327 name: "echo".to_string(),
1328 args: vec![Arg::Positional(Expr::Literal(Value::String("hello\nworld".to_string())))],
1329 redirects: vec![],
1330 };
1331 let grep_cmd = Command {
1332 name: "grep".to_string(),
1333 args: vec![Arg::Positional(Expr::Literal(Value::String("world".to_string())))],
1334 redirects: vec![],
1335 };
1336
1337 let result = runner.run(&[echo_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1338 assert!(result.ok());
1339 assert_eq!(result.text_out().trim(), "world");
1340 }
1341
1342 #[tokio::test]
1343 async fn test_pipeline_cat_grep() {
1344 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1345
1346 let cat_cmd = make_cmd("cat", vec!["/test.txt"]);
1348 let grep_cmd = Command {
1349 name: "grep".to_string(),
1350 args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
1351 redirects: vec![],
1352 };
1353
1354 let result = runner.run(&[cat_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1355 assert!(result.ok());
1356 assert!(result.text_out().contains("hello"));
1357 }
1358
1359 #[tokio::test]
1360 async fn test_command_not_found() {
1361 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1362 let cmd = make_cmd("nonexistent", vec![]);
1363
1364 let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
1365 assert!(!result.ok());
1366 assert_eq!(result.code, 127);
1367 assert!(result.err.contains("not found"));
1368 }
1369
1370 #[tokio::test]
1371 async fn test_pipeline_continues_on_failure() {
1372 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1375
1376 let cat_cmd = make_cmd("cat", vec!["/nonexistent"]);
1379 let grep_cmd = Command {
1380 name: "grep".to_string(),
1381 args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
1382 redirects: vec![],
1383 };
1384
1385 let result = runner.run(&[cat_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1386 assert!(!result.ok());
1388 }
1389
1390 #[tokio::test]
1391 async fn test_pipeline_last_command_exit_code() {
1392 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1394
1395 let echo_cmd = make_cmd("echo", vec!["hello"]);
1396 let cat_cmd = make_cmd("cat", vec![]);
1397
1398 let result = runner.run(&[echo_cmd, cat_cmd], &mut ctx, &dispatcher).await;
1399 assert!(result.ok());
1400 assert!(result.text_out().contains("hello"));
1401 }
1402
1403 #[tokio::test]
1404 async fn test_empty_pipeline() {
1405 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1406 let result = runner.run(&[], &mut ctx, &dispatcher).await;
1407 assert!(result.ok());
1408 }
1409
1410 #[test]
1413 fn test_find_scatter_gather_both_present() {
1414 let commands = vec![
1415 make_cmd("echo", vec!["a"]),
1416 make_cmd("scatter", vec![]),
1417 make_cmd("process", vec![]),
1418 make_cmd("gather", vec![]),
1419 ];
1420 let result = find_scatter_gather(&commands);
1421 assert_eq!(result, Some((1, 3)));
1422 }
1423
1424 #[test]
1425 fn test_find_scatter_gather_no_scatter() {
1426 let commands = vec![
1427 make_cmd("echo", vec!["a"]),
1428 make_cmd("gather", vec![]),
1429 ];
1430 let result = find_scatter_gather(&commands);
1431 assert!(result.is_none());
1432 }
1433
1434 #[test]
1435 fn test_find_scatter_gather_no_gather() {
1436 let commands = vec![
1437 make_cmd("echo", vec!["a"]),
1438 make_cmd("scatter", vec![]),
1439 ];
1440 let result = find_scatter_gather(&commands);
1441 assert!(result.is_none());
1442 }
1443
1444 #[test]
1445 fn test_find_scatter_gather_wrong_order() {
1446 let commands = vec![
1447 make_cmd("gather", vec![]),
1448 make_cmd("scatter", vec![]),
1449 ];
1450 let result = find_scatter_gather(&commands);
1451 assert!(result.is_none());
1452 }
1453
1454 #[tokio::test]
1455 async fn test_scatter_gather_simple() {
1456 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1457
1458 let split_cmd = Command {
1460 name: "split".to_string(),
1461 args: vec![Arg::Positional(Expr::Literal(Value::String("a b c".to_string())))],
1462 redirects: vec![],
1463 };
1464 let scatter_cmd = make_cmd("scatter", vec![]);
1465 let process_cmd = Command {
1466 name: "echo".to_string(),
1467 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1468 redirects: vec![],
1469 };
1470 let gather_cmd = make_cmd("gather", vec![]);
1471
1472 let result = runner.run(&[split_cmd, scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1473 assert!(result.ok(), "scatter with structured data should succeed: {}", result.err);
1474 assert!(result.text_out().contains("a"));
1476 assert!(result.text_out().contains("b"));
1477 assert!(result.text_out().contains("c"));
1478 }
1479
1480 #[tokio::test]
1481 async fn test_scatter_gather_empty_input() {
1482 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1483
1484 let echo_cmd = Command {
1486 name: "echo".to_string(),
1487 args: vec![Arg::Positional(Expr::Literal(Value::String("".to_string())))],
1488 redirects: vec![],
1489 };
1490 let scatter_cmd = make_cmd("scatter", vec![]);
1491 let process_cmd = Command {
1492 name: "echo".to_string(),
1493 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1494 redirects: vec![],
1495 };
1496 let gather_cmd = make_cmd("gather", vec![]);
1497
1498 let result = runner.run(&[echo_cmd, scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1499 assert!(result.ok());
1500 assert!(result.text_out().trim().is_empty());
1501 }
1502
1503 #[tokio::test]
1504 async fn test_scatter_gather_with_structured_stdin() {
1505 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1506
1507 let data = Value::Json(serde_json::json!(["x", "y", "z"]));
1509 ctx.set_stdin_with_data("x\ny\nz".to_string(), Some(data));
1510
1511 let scatter_cmd = make_cmd("scatter", vec![]);
1512 let process_cmd = Command {
1513 name: "echo".to_string(),
1514 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1515 redirects: vec![],
1516 };
1517 let gather_cmd = make_cmd("gather", vec![]);
1518
1519 let result = runner.run(&[scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1520 assert!(result.ok(), "scatter with structured stdin should succeed: {}", result.err);
1521 assert!(result.text_out().contains("x"));
1522 assert!(result.text_out().contains("y"));
1523 assert!(result.text_out().contains("z"));
1524 }
1525
1526 #[tokio::test]
1527 async fn test_scatter_gather_json_input() {
1528 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1529
1530 let data = Value::Json(serde_json::json!(["one", "two", "three"]));
1532 ctx.set_stdin_with_data(r#"["one", "two", "three"]"#.to_string(), Some(data));
1533
1534 let scatter_cmd = make_cmd("scatter", vec![]);
1535 let process_cmd = Command {
1536 name: "echo".to_string(),
1537 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1538 redirects: vec![],
1539 };
1540 let gather_cmd = make_cmd("gather", vec![]);
1541
1542 let result = runner.run(&[scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1543 assert!(result.ok(), "scatter with JSON data should succeed: {}", result.err);
1544 assert!(result.text_out().contains("one"));
1545 assert!(result.text_out().contains("two"));
1546 assert!(result.text_out().contains("three"));
1547 }
1548
1549 #[tokio::test]
1550 async fn test_scatter_gather_with_post_gather() {
1551 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1552
1553 let split_cmd = Command {
1555 name: "split".to_string(),
1556 args: vec![Arg::Positional(Expr::Literal(Value::String("a b".to_string())))],
1557 redirects: vec![],
1558 };
1559 let scatter_cmd = make_cmd("scatter", vec![]);
1560 let process_cmd = Command {
1561 name: "echo".to_string(),
1562 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1563 redirects: vec![],
1564 };
1565 let gather_cmd = make_cmd("gather", vec![]);
1566 let grep_cmd = Command {
1567 name: "grep".to_string(),
1568 args: vec![Arg::Positional(Expr::Literal(Value::String("a".to_string())))],
1569 redirects: vec![],
1570 };
1571
1572 let result = runner.run(&[split_cmd, scatter_cmd, process_cmd, gather_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1573 assert!(result.ok(), "scatter with post_gather should succeed: {}", result.err);
1574 assert!(result.text_out().contains("a"));
1575 assert!(!result.text_out().contains("b"));
1576 }
1577
1578 #[tokio::test]
1579 async fn test_scatter_custom_var_name() {
1580 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1581
1582 let data = Value::Json(serde_json::json!(["test1", "test2"]));
1584 ctx.set_stdin_with_data("test1\ntest2".to_string(), Some(data));
1585
1586 let scatter_cmd = Command {
1588 name: "scatter".to_string(),
1589 args: vec![Arg::Named {
1590 key: "as".to_string(),
1591 value: Expr::Literal(Value::String("URL".to_string())),
1592 }],
1593 redirects: vec![],
1594 };
1595 let process_cmd = Command {
1596 name: "echo".to_string(),
1597 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("URL")))],
1598 redirects: vec![],
1599 };
1600 let gather_cmd = make_cmd("gather", vec![]);
1601
1602 let result = runner.run(&[scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1603 assert!(result.ok(), "scatter with custom var should succeed: {}", result.err);
1604 assert!(result.text_out().contains("test1"));
1605 assert!(result.text_out().contains("test2"));
1606 }
1607
1608 #[tokio::test]
1611 async fn test_pipeline_routes_through_backend() {
1612 use crate::backend::testing::MockBackend;
1613 use std::sync::atomic::Ordering;
1614
1615 let (backend, call_count) = MockBackend::new();
1617 let backend: std::sync::Arc<dyn crate::backend::KernelBackend> = std::sync::Arc::new(backend);
1618
1619 let mut ctx = crate::tools::ExecContext::with_backend(backend);
1621
1622 let tools = std::sync::Arc::new(ToolRegistry::new());
1624 let runner = PipelineRunner::new(tools.clone());
1625 let dispatcher = BackendDispatcher::new(tools);
1626
1627 let cmd = make_cmd("test-tool", vec!["arg1"]);
1629 let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
1630
1631 assert!(result.ok(), "Mock backend should return success");
1632 assert_eq!(call_count.load(Ordering::SeqCst), 1, "call_tool should be invoked once");
1633 assert!(result.text_out().contains("mock executed"), "Output should be from mock backend");
1634 }
1635
1636 #[tokio::test]
1637 async fn test_multi_command_pipeline_routes_through_backend() {
1638 use crate::backend::testing::MockBackend;
1639 use std::sync::atomic::Ordering;
1640
1641 let (backend, call_count) = MockBackend::new();
1642 let backend: std::sync::Arc<dyn crate::backend::KernelBackend> = std::sync::Arc::new(backend);
1643 let mut ctx = crate::tools::ExecContext::with_backend(backend);
1644
1645 let tools = std::sync::Arc::new(ToolRegistry::new());
1646 let runner = PipelineRunner::new(tools.clone());
1647 let dispatcher = BackendDispatcher::new(tools);
1648
1649 let cmd1 = make_cmd("tool1", vec![]);
1651 let cmd2 = make_cmd("tool2", vec![]);
1652 let cmd3 = make_cmd("tool3", vec![]);
1653
1654 let result = runner.run(&[cmd1, cmd2, cmd3], &mut ctx, &dispatcher).await;
1655
1656 assert!(result.ok());
1657 assert_eq!(call_count.load(Ordering::SeqCst), 3, "call_tool should be invoked for each command");
1658 }
1659
1660 use crate::tools::{ParamSchema, ToolSchema};
1663
1664 fn make_test_schema() -> ToolSchema {
1665 ToolSchema::new("test-tool", "A test tool for schema-aware parsing")
1666 .param(ParamSchema::required("query", "string", "Search query"))
1667 .param(ParamSchema::optional("limit", "int", Value::Int(10), "Max results"))
1668 .param(ParamSchema::optional("verbose", "bool", Value::Bool(false), "Verbose output"))
1669 .param(ParamSchema::optional("output", "string", Value::String("stdout".into()), "Output destination"))
1670 .with_positional_mapping()
1671 }
1672
1673 fn make_minimal_ctx() -> ExecContext {
1674 let mut vfs = VfsRouter::new();
1675 vfs.mount("/", MemoryFs::new());
1676 ExecContext::new(Arc::new(vfs))
1677 }
1678
1679 #[test]
1680 fn test_schema_aware_string_arg() {
1681 let args = vec![
1683 Arg::LongFlag("query".to_string()),
1684 Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
1685 ];
1686 let schema = make_test_schema();
1687 let ctx = make_minimal_ctx();
1688
1689 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
1690
1691 assert!(tool_args.flags.is_empty(), "No flags should be set");
1692 assert!(tool_args.positional.is_empty(), "No positionals - consumed by --query");
1693 assert_eq!(
1694 tool_args.named.get("query"),
1695 Some(&Value::String("test".to_string())),
1696 "--query should consume 'test' as its value"
1697 );
1698 }
1699
1700 #[test]
1701 fn test_schema_aware_bool_flag() {
1702 let args = vec![
1704 Arg::LongFlag("verbose".to_string()),
1705 ];
1706 let schema = make_test_schema();
1707 let ctx = make_minimal_ctx();
1708
1709 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
1710
1711 assert!(tool_args.flags.contains("verbose"), "--verbose should be a flag");
1712 assert!(tool_args.named.is_empty(), "No named args");
1713 assert!(tool_args.positional.is_empty(), "No positionals");
1714 }
1715
1716 #[test]
1717 fn test_schema_aware_mixed() {
1718 let args = vec![
1721 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
1722 Arg::LongFlag("output".to_string()),
1723 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
1724 Arg::LongFlag("verbose".to_string()),
1725 ];
1726 let schema = make_test_schema();
1727 let ctx = make_minimal_ctx();
1728
1729 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
1730
1731 assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
1732 assert_eq!(
1733 tool_args.named.get("query"),
1734 Some(&Value::String("file.txt".to_string()))
1735 );
1736 assert_eq!(
1737 tool_args.named.get("output"),
1738 Some(&Value::String("out.txt".to_string()))
1739 );
1740 assert!(tool_args.flags.contains("verbose"));
1741 }
1742
1743 #[test]
1744 fn test_schema_aware_multiple_string_args() {
1745 let args = vec![
1747 Arg::LongFlag("query".to_string()),
1748 Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
1749 Arg::LongFlag("output".to_string()),
1750 Arg::Positional(Expr::Literal(Value::String("result.json".to_string()))),
1751 Arg::LongFlag("verbose".to_string()),
1752 Arg::LongFlag("limit".to_string()),
1753 Arg::Positional(Expr::Literal(Value::Int(5))),
1754 ];
1755 let schema = make_test_schema();
1756 let ctx = make_minimal_ctx();
1757
1758 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
1759
1760 assert!(tool_args.positional.is_empty(), "All positionals consumed");
1761 assert_eq!(
1762 tool_args.named.get("query"),
1763 Some(&Value::String("test".to_string()))
1764 );
1765 assert_eq!(
1766 tool_args.named.get("output"),
1767 Some(&Value::String("result.json".to_string()))
1768 );
1769 assert_eq!(
1770 tool_args.named.get("limit"),
1771 Some(&Value::Int(5))
1772 );
1773 assert!(tool_args.flags.contains("verbose"));
1774 }
1775
1776 #[test]
1777 fn test_schema_aware_double_dash() {
1778 let args = vec![
1781 Arg::LongFlag("output".to_string()),
1782 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
1783 Arg::DoubleDash,
1784 Arg::Positional(Expr::Literal(Value::String("--this-is-data".to_string()))),
1785 ];
1786 let schema = make_test_schema();
1787 let ctx = make_minimal_ctx();
1788
1789 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
1790
1791 assert_eq!(
1792 tool_args.named.get("output"),
1793 Some(&Value::String("out.txt".to_string()))
1794 );
1795 assert_eq!(
1797 tool_args.positional,
1798 vec![Value::String("--this-is-data".to_string())]
1799 );
1800 }
1801
1802 #[test]
1803 fn test_no_schema_fallback() {
1804 let args = vec![
1806 Arg::LongFlag("query".to_string()),
1807 Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
1808 ];
1809 let ctx = make_minimal_ctx();
1810
1811 let tool_args = build_tool_args(&args, &ctx, None);
1812
1813 assert!(tool_args.flags.contains("query"), "--query should be a flag");
1815 assert_eq!(
1816 tool_args.positional,
1817 vec![Value::String("test".to_string())],
1818 "'test' should be a positional"
1819 );
1820 }
1821
1822 #[test]
1823 fn test_unknown_flag_in_schema() {
1824 let args = vec![
1826 Arg::LongFlag("unknown".to_string()),
1827 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
1828 ];
1829 let schema = make_test_schema();
1830 let ctx = make_minimal_ctx();
1831
1832 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
1833
1834 assert!(tool_args.flags.contains("unknown"));
1835 assert!(tool_args.positional.is_empty(), "value consumed as query param");
1836 assert_eq!(
1837 tool_args.named.get("query"),
1838 Some(&Value::String("value".to_string()))
1839 );
1840 }
1841
1842 #[test]
1843 fn test_named_args_unchanged() {
1844 let args = vec![
1846 Arg::Named {
1847 key: "query".to_string(),
1848 value: Expr::Literal(Value::String("test".to_string())),
1849 },
1850 Arg::LongFlag("verbose".to_string()),
1851 ];
1852 let schema = make_test_schema();
1853 let ctx = make_minimal_ctx();
1854
1855 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
1856
1857 assert_eq!(
1858 tool_args.named.get("query"),
1859 Some(&Value::String("test".to_string()))
1860 );
1861 assert!(tool_args.flags.contains("verbose"));
1862 }
1863
1864 #[test]
1865 fn test_short_flags_unchanged() {
1866 let args = vec![
1868 Arg::ShortFlag("la".to_string()),
1869 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
1870 ];
1871 let schema = make_test_schema();
1872 let ctx = make_minimal_ctx();
1873
1874 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
1875
1876 assert!(tool_args.flags.contains("l"));
1877 assert!(tool_args.flags.contains("a"));
1878 assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
1879 assert_eq!(
1880 tool_args.named.get("query"),
1881 Some(&Value::String("file.txt".to_string()))
1882 );
1883 }
1884
1885 #[test]
1886 fn test_flag_at_end_no_value() {
1887 let args = vec![
1890 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
1891 Arg::LongFlag("output".to_string()),
1892 ];
1893 let schema = make_test_schema();
1894 let ctx = make_minimal_ctx();
1895
1896 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
1897
1898 assert!(tool_args.flags.contains("output"));
1900 assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
1901 assert_eq!(
1902 tool_args.named.get("query"),
1903 Some(&Value::String("file.txt".to_string()))
1904 );
1905 }
1906
1907 #[test]
1908 fn test_positional_skips_bool_params() {
1909 let schema = ToolSchema::new("test", "")
1913 .param(ParamSchema::required("query", "string", ""))
1914 .param(ParamSchema::optional(
1915 "verbose",
1916 "bool",
1917 Value::Bool(false),
1918 "",
1919 ))
1920 .param(ParamSchema::optional(
1921 "output",
1922 "string",
1923 Value::Null,
1924 "",
1925 ))
1926 .with_positional_mapping();
1927 let args = vec![
1928 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
1929 Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
1930 ];
1931 let ctx = make_minimal_ctx();
1932
1933 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
1934
1935 assert_eq!(
1936 tool_args.named.get("query"),
1937 Some(&Value::String("val1".to_string()))
1938 );
1939 assert_eq!(
1940 tool_args.named.get("output"),
1941 Some(&Value::String("val2".to_string()))
1942 );
1943 assert!(!tool_args.flags.contains("verbose"));
1944 assert!(tool_args.positional.is_empty());
1945 }
1946
1947 #[test]
1948 fn test_positionals_fill_available_slots() {
1949 let args = vec![
1952 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
1953 Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
1954 Arg::Positional(Expr::Literal(Value::String("val3".to_string()))),
1955 ];
1956 let schema = make_test_schema(); let ctx = make_minimal_ctx();
1958
1959 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
1960
1961 assert_eq!(
1964 tool_args.named.get("query"),
1965 Some(&Value::String("val1".to_string()))
1966 );
1967 assert_eq!(
1968 tool_args.named.get("limit"),
1969 Some(&Value::String("val2".to_string()))
1970 );
1971 assert_eq!(
1972 tool_args.named.get("output"),
1973 Some(&Value::String("val3".to_string()))
1974 );
1975 assert!(tool_args.positional.is_empty());
1976 }
1977
1978 #[test]
1979 fn test_truly_excess_positionals() {
1980 let schema = ToolSchema::new("test", "")
1982 .param(ParamSchema::required("name", "string", ""))
1983 .with_positional_mapping();
1984 let args = vec![
1985 Arg::Positional(Expr::Literal(Value::String("first".to_string()))),
1986 Arg::Positional(Expr::Literal(Value::String("second".to_string()))),
1987 Arg::Positional(Expr::Literal(Value::String("third".to_string()))),
1988 ];
1989 let ctx = make_minimal_ctx();
1990
1991 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
1992
1993 assert_eq!(
1994 tool_args.named.get("name"),
1995 Some(&Value::String("first".to_string()))
1996 );
1997 assert_eq!(
1998 tool_args.positional,
1999 vec![
2000 Value::String("second".to_string()),
2001 Value::String("third".to_string()),
2002 ]
2003 );
2004 }
2005
2006 #[test]
2007 fn test_double_dash_positional_not_mapped() {
2008 let args = vec![
2010 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2011 Arg::DoubleDash,
2012 Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
2013 ];
2014 let schema = make_test_schema();
2015 let ctx = make_minimal_ctx();
2016
2017 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
2018
2019 assert_eq!(
2020 tool_args.named.get("query"),
2021 Some(&Value::String("val1".to_string()))
2022 );
2023 assert_eq!(
2025 tool_args.positional,
2026 vec![Value::String("val2".to_string())]
2027 );
2028 }
2029
2030 #[test]
2031 fn test_all_params_filled_by_flags() {
2032 let args = vec![
2034 Arg::LongFlag("query".to_string()),
2035 Arg::Positional(Expr::Literal(Value::String("search".to_string()))),
2036 Arg::LongFlag("output".to_string()),
2037 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
2038 Arg::LongFlag("verbose".to_string()),
2039 ];
2040 let schema = make_test_schema();
2041 let ctx = make_minimal_ctx();
2042
2043 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
2044
2045 assert_eq!(
2046 tool_args.named.get("query"),
2047 Some(&Value::String("search".to_string()))
2048 );
2049 assert_eq!(
2050 tool_args.named.get("output"),
2051 Some(&Value::String("out.txt".to_string()))
2052 );
2053 assert!(tool_args.flags.contains("verbose"));
2054 assert!(tool_args.positional.is_empty());
2055 }
2056
2057 #[test]
2058 fn test_mixed_flags_and_positional_fill() {
2059 let args = vec![
2061 Arg::LongFlag("output".to_string()),
2062 Arg::Positional(Expr::Literal(Value::String("foo".to_string()))),
2063 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2064 ];
2065 let schema = make_test_schema();
2066 let ctx = make_minimal_ctx();
2067
2068 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
2069
2070 assert_eq!(
2071 tool_args.named.get("output"),
2072 Some(&Value::String("foo".to_string()))
2073 );
2074 assert_eq!(
2075 tool_args.named.get("query"),
2076 Some(&Value::String("val1".to_string()))
2077 );
2078 assert!(tool_args.positional.is_empty());
2079 }
2080
2081 #[test]
2082 fn test_alias_flag_prevents_mapping_overwrite() {
2083 let schema = ToolSchema::new("test", "")
2085 .param(ParamSchema::required("query", "string", "").with_aliases(["-q"]))
2086 .param(ParamSchema::required("output", "string", ""))
2087 .with_positional_mapping();
2088 let args = vec![
2089 Arg::ShortFlag("q".to_string()),
2090 Arg::Positional(Expr::Literal(Value::String("search".to_string()))),
2091 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
2092 ];
2093 let ctx = make_minimal_ctx();
2094
2095 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
2096
2097 assert_eq!(
2098 tool_args.named.get("query"),
2099 Some(&Value::String("search".to_string()))
2100 );
2101 assert_eq!(
2102 tool_args.named.get("output"),
2103 Some(&Value::String("out.txt".to_string()))
2104 );
2105 assert!(tool_args.positional.is_empty());
2106 }
2107
2108 #[test]
2109 fn test_builtin_schema_no_positional_mapping() {
2110 let schema = ToolSchema::new("echo", "")
2112 .param(ParamSchema::optional("args", "any", Value::Null, ""))
2113 .param(ParamSchema::optional("no_newline", "bool", Value::Bool(false), ""));
2114 let args = vec![
2116 Arg::Positional(Expr::Literal(Value::String("hello".to_string()))),
2117 Arg::Positional(Expr::Literal(Value::String("world".to_string()))),
2118 ];
2119 let ctx = make_minimal_ctx();
2120
2121 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
2122
2123 assert_eq!(
2125 tool_args.positional,
2126 vec![
2127 Value::String("hello".to_string()),
2128 Value::String("world".to_string()),
2129 ]
2130 );
2131 assert!(!tool_args.named.contains_key("args"));
2132 }
2133
2134 #[test]
2135 fn test_short_flag_with_alias_consumes_value() {
2136 let schema = ToolSchema::new("head", "Output first part of files")
2139 .param(ParamSchema::optional("lines", "int", Value::Int(10), "Number of lines")
2140 .with_aliases(["-n"]));
2141 let args = vec![
2142 Arg::ShortFlag("n".to_string()),
2143 Arg::Positional(Expr::Literal(Value::Int(5))),
2144 Arg::Positional(Expr::Literal(Value::String("/tmp/file.txt".to_string()))),
2145 ];
2146 let ctx = make_minimal_ctx();
2147
2148 let tool_args = build_tool_args(&args, &ctx, Some(&schema));
2149
2150 assert!(tool_args.flags.is_empty(), "no boolean flags: {:?}", tool_args.flags);
2151 assert_eq!(tool_args.named.get("lines"), Some(&Value::Int(5)), "should resolve alias to canonical name");
2152 assert_eq!(tool_args.positional, vec![Value::String("/tmp/file.txt".to_string())]);
2153 }
2154
2155 #[tokio::test]
2158 async fn test_merge_stderr_redirect() {
2159 let result = ExecResult::from_output(0, "stdout content", "stderr content");
2161
2162 let redirects = vec![Redirect {
2163 kind: RedirectKind::MergeStderr,
2164 target: Expr::Literal(Value::Null),
2165 }];
2166
2167 let ctx = make_minimal_ctx();
2168 let result = apply_redirects(result, &redirects, &ctx).await;
2169
2170 assert_eq!(&*result.text_out(), "stdout contentstderr content");
2171 assert!(result.err.is_empty());
2172 }
2173
2174 #[tokio::test]
2175 async fn test_merge_stderr_with_empty_stderr() {
2176 let result = ExecResult::from_output(0, "stdout only", "");
2178
2179 let redirects = vec![Redirect {
2180 kind: RedirectKind::MergeStderr,
2181 target: Expr::Literal(Value::Null),
2182 }];
2183
2184 let ctx = make_minimal_ctx();
2185 let result = apply_redirects(result, &redirects, &ctx).await;
2186
2187 assert_eq!(&*result.text_out(), "stdout only");
2188 assert!(result.err.is_empty());
2189 }
2190
2191 #[tokio::test]
2192 async fn test_merge_stderr_order_matters() {
2193 let result = ExecResult::from_output(0, "stdout\n", "stderr\n");
2198
2199 let redirects = vec![Redirect {
2201 kind: RedirectKind::MergeStderr,
2202 target: Expr::Literal(Value::Null),
2203 }];
2204
2205 let ctx = make_minimal_ctx();
2206 let result = apply_redirects(result, &redirects, &ctx).await;
2207
2208 assert_eq!(&*result.text_out(), "stdout\nstderr\n");
2209 assert!(result.err.is_empty());
2210 }
2211
2212 #[tokio::test]
2213 async fn test_redirect_with_command_execution() {
2214 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
2215
2216 let cmd = Command {
2218 name: "echo".to_string(),
2219 args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
2220 redirects: vec![Redirect {
2221 kind: RedirectKind::MergeStderr,
2222 target: Expr::Literal(Value::Null),
2223 }],
2224 };
2225
2226 let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
2227 assert!(result.ok());
2228 assert!(result.text_out().contains("hello"));
2230 }
2231
2232 #[tokio::test]
2233 async fn test_merge_stderr_in_pipeline() {
2234 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
2235
2236 let echo_cmd = Command {
2239 name: "echo".to_string(),
2240 args: vec![Arg::Positional(Expr::Literal(Value::String("output".to_string())))],
2241 redirects: vec![Redirect {
2242 kind: RedirectKind::MergeStderr,
2243 target: Expr::Literal(Value::Null),
2244 }],
2245 };
2246 let grep_cmd = Command {
2247 name: "grep".to_string(),
2248 args: vec![Arg::Positional(Expr::Literal(Value::String("output".to_string())))],
2249 redirects: vec![],
2250 };
2251
2252 let result = runner.run(&[echo_cmd, grep_cmd], &mut ctx, &dispatcher).await;
2253 assert!(result.ok(), "result failed: code={}, err={}", result.code, result.err);
2254 assert!(result.text_out().contains("output"));
2255 }
2256}