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, PathError};
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
24pub(crate) async 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 }
65 result.clear_stdout();
72 }
73 RedirectKind::StdoutOverwrite => {
74 let path = match eval_redirect_target(&redir.target, ctx).await {
75 Ok(p) => p,
76 Err(e) => return ExecResult::failure(1, format!("redirect: {e}")),
77 };
78 if let Some(bytes) = result.out_bytes() {
80 if let Err(e) = redirect_write(ctx, &path, bytes).await {
81 return ExecResult::failure(1, format!("redirect: {e}"));
82 }
83 } else if let Some(output) = result.take_output_for_stream() {
84 let mut buf = Vec::new();
86 if let Err(e) = output.write_canonical(&mut buf, None) {
87 return ExecResult::failure(1, format!("redirect: {e}"));
88 }
89 if let Err(e) = redirect_write(ctx, &path, &buf).await {
90 return ExecResult::failure(1, format!("redirect: {e}"));
91 }
92 } else if let Err(e) = redirect_write(ctx, &path, result.text_out().as_bytes()).await {
93 return ExecResult::failure(1, format!("redirect: {e}"));
94 }
95 result.clear_stdout();
97 }
98 RedirectKind::StdoutAppend => {
99 let path = match eval_redirect_target(&redir.target, ctx).await {
100 Ok(p) => p,
101 Err(e) => return ExecResult::failure(1, format!("redirect: {e}")),
102 };
103 if let Some(bytes) = result.out_bytes() {
105 if let Err(e) = redirect_append(ctx, &path, bytes).await {
106 return ExecResult::failure(1, format!("redirect: {e}"));
107 }
108 } else if let Some(output) = result.take_output_for_stream() {
109 let mut buf = Vec::new();
111 if let Err(e) = output.write_canonical(&mut buf, None) {
112 return ExecResult::failure(1, format!("redirect: {e}"));
113 }
114 if let Err(e) = redirect_append(ctx, &path, &buf).await {
115 return ExecResult::failure(1, format!("redirect: {e}"));
116 }
117 } else if let Err(e) = redirect_append(ctx, &path, result.text_out().as_bytes()).await {
118 return ExecResult::failure(1, format!("redirect: {e}"));
119 }
120 result.clear_stdout();
122 }
123 RedirectKind::Stderr => {
124 let path = match eval_redirect_target(&redir.target, ctx).await {
125 Ok(p) => p,
126 Err(e) => return ExecResult::failure(1, format!("redirect: {e}")),
127 };
128 if let Err(e) = redirect_write(ctx, &path, result.err.as_bytes()).await {
129 return ExecResult::failure(1, format!("redirect: {e}"));
130 }
131 result.err.clear();
132 }
133 RedirectKind::Both => {
134 let path = match eval_redirect_target(&redir.target, ctx).await {
135 Ok(p) => p,
136 Err(e) => return ExecResult::failure(1, format!("redirect: {e}")),
137 };
138 let mut combined: Vec<u8> = match result.out_bytes() {
141 Some(b) => b.to_vec(),
142 None => result.text_out().into_owned().into_bytes(),
143 };
144 combined.extend_from_slice(result.err.as_bytes());
145 if let Err(e) = redirect_write(ctx, &path, &combined).await {
146 return ExecResult::failure(1, format!("redirect: {e}"));
147 }
148 result.clear_stdout();
150 result.err.clear();
151 }
152 RedirectKind::Stdin | RedirectKind::HereDoc | RedirectKind::HereString => {}
154 }
155 }
156 result.materialize();
161 result
162}
163
164async fn eval_redirect_target(expr: &Expr, ctx: &ExecContext) -> Result<String, String> {
171 let value = if let Some(dispatcher) = &ctx.dispatcher {
172 dispatcher.eval_expr(expr, ctx).await.map_err(|e| e.to_string())?
173 } else {
174 eval_simple_expr(expr, ctx)?
175 .ok_or_else(|| "could not evaluate redirect target".to_string())?
176 };
177 if let Some(msg) = crate::interpreter::structured_boundary_error("a redirect target", &value) {
180 return Err(msg);
181 }
182 Ok(value_to_string(&value))
183}
184
185async fn redirect_write(ctx: &ExecContext, path: &str, data: &[u8]) -> Result<(), String> {
192 use crate::backend::WriteMode;
193 let resolved = ctx.resolve_path(path);
194 ctx.backend.write(&resolved, data, WriteMode::Overwrite).await.map_err(|e| e.to_string())
195}
196
197async fn redirect_append(ctx: &ExecContext, path: &str, data: &[u8]) -> Result<(), String> {
201 let resolved = ctx.resolve_path(path);
202 ctx.backend.append(&resolved, data).await.map_err(|e| e.to_string())
203}
204
205async fn setup_stdin_redirects(cmd: &Command, ctx: &mut ExecContext) -> Result<(), String> {
213 use std::path::Path;
214 for redir in &cmd.redirects {
215 match &redir.kind {
216 RedirectKind::Stdin => {
217 let path = eval_redirect_target(&redir.target, ctx).await?;
218 let resolved = ctx.resolve_path(&path);
219 let data = ctx
220 .backend
221 .read(Path::new(&resolved), None)
222 .await
223 .map_err(|e| format!("redirect: {path}: {e}"))?;
224 let content = String::from_utf8(data)
225 .map_err(|_| format!("redirect: {path}: invalid UTF-8"))?;
226 ctx.set_stdin(content);
227 }
228 RedirectKind::HereDoc => {
229 match &redir.target {
230 Expr::Literal(Value::String(content)) => {
231 ctx.set_stdin(content.clone());
232 }
233 expr => {
236 let body = eval_redirect_target(expr, ctx).await?;
237 ctx.set_stdin(body);
238 }
239 }
240 }
241 RedirectKind::HereString => {
242 let mut s = eval_redirect_target(&redir.target, ctx).await?;
245 s.push('\n');
246 ctx.set_stdin(s);
247 }
248 _ => {}
249 }
250 }
251 Ok(())
252}
253
254#[derive(Clone)]
256pub struct PipelineRunner {
257 tools: Arc<ToolRegistry>,
258}
259
260impl PipelineRunner {
261 pub fn new(tools: Arc<ToolRegistry>) -> Self {
263 Self { tools }
264 }
265
266 #[tracing::instrument(level = "debug", skip(self, commands, ctx, dispatcher), fields(command_count = commands.len()))]
276 pub async fn run(
277 &self,
278 commands: &[Command],
279 ctx: &mut ExecContext,
280 dispatcher: &dyn CommandDispatcher,
281 ) -> ExecResult {
282 if commands.is_empty() {
283 return ExecResult::success("");
284 }
285
286 if let Some((scatter_idx, gather_idx)) = find_scatter_gather(commands) {
288 return self.run_scatter_gather(commands, scatter_idx, gather_idx, ctx, dispatcher).await;
289 }
290
291 self.run_sequential(commands, ctx, dispatcher).await
292 }
293
294 #[tracing::instrument(level = "debug", skip(self, commands, ctx, dispatcher), fields(command_count = commands.len()))]
299 pub async fn run_sequential(
300 &self,
301 commands: &[Command],
302 ctx: &mut ExecContext,
303 dispatcher: &dyn CommandDispatcher,
304 ) -> ExecResult {
305 if commands.is_empty() {
306 return ExecResult::success("");
307 }
308
309 if commands.len() == 1 {
310 return self.run_single(&commands[0], ctx, None, dispatcher).await;
312 }
313
314 self.run_pipeline(commands, ctx, dispatcher).await
316 }
317
318 async fn run_scatter_gather(
320 &self,
321 commands: &[Command],
322 scatter_idx: usize,
323 gather_idx: usize,
324 ctx: &mut ExecContext,
325 dispatcher: &dyn CommandDispatcher,
326 ) -> ExecResult {
327 let pre_scatter = &commands[..scatter_idx];
329 let scatter_cmd = &commands[scatter_idx];
330 let parallel = &commands[scatter_idx + 1..gather_idx];
331 let gather_cmd = &commands[gather_idx];
332 let post_gather = &commands[gather_idx + 1..];
333
334 let scatter_schema = self.tools.get("scatter").map(|t| t.schema());
341 let gather_schema = self.tools.get("gather").map(|t| t.schema());
342 let scatter_args = match build_tool_args(&scatter_cmd.args, ctx, scatter_schema.as_ref()) {
343 Ok(args) => args,
344 Err(e) => return ExecResult::failure(1, format!("scatter: {e}")),
345 };
346 let gather_args = match build_tool_args(&gather_cmd.args, ctx, gather_schema.as_ref()) {
347 Ok(args) => args,
348 Err(e) => return ExecResult::failure(1, format!("gather: {e}")),
349 };
350 let scatter_opts = match parse_scatter_options(&scatter_args) {
351 Ok(opts) => opts,
352 Err(e) => return ExecResult::failure(2, format!("scatter: {e}")),
353 };
354 let gather_opts = match parse_gather_options(&gather_args) {
355 Ok(opts) => opts,
356 Err(e) => return ExecResult::failure(2, format!("gather: {e}")),
357 };
358
359 let sequential_dispatcher: Arc<dyn CommandDispatcher> = dispatcher.fork_attached().await;
364
365 let runner = ScatterGatherRunner::new(self.tools.clone(), sequential_dispatcher);
366 runner
367 .run(
368 pre_scatter,
369 scatter_opts,
370 parallel,
371 gather_opts,
372 &gather_cmd.redirects,
373 post_gather,
374 ctx,
375 )
376 .await
377 }
378
379 #[tracing::instrument(level = "debug", skip(self, cmd, ctx, stdin, dispatcher), fields(command = %cmd.name))]
384 async fn run_single(
385 &self,
386 cmd: &Command,
387 ctx: &mut ExecContext,
388 stdin: Option<String>,
389 dispatcher: &dyn CommandDispatcher,
390 ) -> ExecResult {
391 if let Err(e) = setup_stdin_redirects(cmd, ctx).await {
393 return ExecResult::failure(1, e);
394 }
395
396 if let Some(input) = stdin {
398 ctx.set_stdin(input);
399 }
400
401 ctx.pipeline_position = PipelinePosition::Only;
403
404 let result = match dispatcher.dispatch(cmd, ctx).await {
406 Ok(result) => result,
407 Err(e) => ExecResult::failure(1, e.to_string()),
408 };
409
410 apply_redirects(result, &cmd.redirects, ctx).await
412 }
413
414 #[tracing::instrument(level = "debug", skip(self, commands, ctx, dispatcher), fields(stage_count = commands.len()))]
424 async fn run_pipeline(
425 &self,
426 commands: &[Command],
427 ctx: &mut ExecContext,
428 dispatcher: &dyn CommandDispatcher,
429 ) -> ExecResult {
430 let stage_count = commands.len();
431 let last_idx = stage_count - 1;
432
433 let mut pipe_writers: Vec<Option<super::pipe_stream::PipeWriter>> = Vec::new();
435 let mut pipe_readers: Vec<Option<super::pipe_stream::PipeReader>> = Vec::new();
436
437 for _ in 0..last_idx {
438 let (writer, reader) = pipe_stream_default();
439 pipe_writers.push(Some(writer));
440 pipe_readers.push(Some(reader));
441 }
442
443 let mut data_senders: Vec<Option<tokio::sync::oneshot::Sender<Option<Value>>>> = Vec::new();
445 let mut data_receivers: Vec<Option<tokio::sync::oneshot::Receiver<Option<Value>>>> = Vec::new();
446
447 for _ in 0..last_idx {
448 let (tx, rx) = tokio::sync::oneshot::channel();
449 data_senders.push(Some(tx));
450 data_receivers.push(Some(rx));
451 }
452
453 let mut handles: Vec<tokio::task::JoinHandle<(ExecResult, ExecContext)>> = Vec::with_capacity(stage_count);
454
455 for (i, cmd) in commands.iter().enumerate() {
456 let mut stage_ctx = ctx.child_for_pipeline();
457 let cmd = cmd.clone();
458
459 let task_dispatcher: Arc<dyn CommandDispatcher> = dispatcher.fork_attached().await;
464
465 let stdin_setup = setup_stdin_redirects(&cmd, &mut stage_ctx).await;
469
470 if i == 0 {
472 if stage_ctx.stdin.is_none() {
475 stage_ctx.stdin = ctx.stdin.take();
476 }
477 if stage_ctx.stdin_data.is_none() {
478 stage_ctx.stdin_data = ctx.stdin_data.take();
479 }
480 if stage_ctx.stdin.is_none() && stage_ctx.pipe_stdin.is_none() {
484 stage_ctx.pipe_stdin = ctx.pipe_stdin.take();
485 }
486 } else {
487 stage_ctx.pipe_stdin = pipe_readers[i - 1].take();
489 }
491
492 if i < last_idx {
494 stage_ctx.pipe_stdout = pipe_writers[i].take();
495 }
496
497 stage_ctx.pipeline_position = match i {
499 0 => PipelinePosition::First,
500 n if n == last_idx => PipelinePosition::Last,
501 _ => PipelinePosition::Middle,
502 };
503
504 let data_sender = if i < last_idx { data_senders[i].take() } else { None };
505 let data_receiver = if i > 0 { data_receivers[i - 1].take() } else { None };
506
507 let handle: tokio::task::JoinHandle<(ExecResult, ExecContext)> =
510 tokio::spawn(crate::telemetry::bind_current_context(async move {
511 if let Err(e) = stdin_setup {
513 return (ExecResult::failure(1, e), stage_ctx);
514 }
515
516 stage_ctx.stdin_data_rx = data_receiver;
524
525 let mut result = match task_dispatcher.dispatch(&cmd, &mut stage_ctx).await {
527 Ok(result) => result,
528 Err(e) => ExecResult::failure(1, e.to_string()),
529 };
530
531 result = apply_redirects(result, &cmd.redirects, &stage_ctx).await;
533
534 if !result.err.is_empty() {
540 if let Some(ref stderr) = stage_ctx.stderr {
541 stderr.write_str(&result.err);
542 result.err.clear();
543 }
544 }
545
546 if let Some(tx) = data_sender {
552 let _ = tx.send(result.data.clone());
553 }
554
555 if let Some(mut pipe_out) = stage_ctx.pipe_stdout.take() {
558 let bytes: Vec<u8> = match result.out_bytes() {
562 Some(b) => b.to_vec(),
563 None => result.text_out().into_owned().into_bytes(),
564 };
565 if !bytes.is_empty() {
566 let _ = pipe_out.write_all(&bytes).await;
568 let _ = pipe_out.shutdown().await;
569 }
570 }
572
573 (result, stage_ctx)
574 }));
575
576 handles.push(handle);
577 }
578
579 let mut last_result = ExecResult::success("");
584 let mut panics: Vec<String> = Vec::new();
585 for (i, handle) in handles.into_iter().enumerate() {
586 match handle.await {
587 Ok((result, stage_ctx)) => {
588 if i == last_idx {
589 last_result = result;
590 ctx.scope = stage_ctx.scope;
592 ctx.cwd = stage_ctx.cwd;
593 ctx.prev_cwd = stage_ctx.prev_cwd;
594 ctx.aliases = stage_ctx.aliases;
595 }
596 }
597 Err(e) => {
598 panics.push(format!("stage {}: {}", i, e));
599 }
600 }
601 }
602
603 if !panics.is_empty() {
604 last_result = ExecResult::failure(
605 1,
606 format!("pipeline stage(s) panicked: {}", panics.join("; ")),
607 );
608 }
609
610 last_result
611 }
612}
613
614pub fn select_leaf<'a>(schema: &'a ToolSchema, args: &[Arg]) -> anyhow::Result<&'a ToolSchema> {
654 let root_lookup = schema_param_lookup(schema);
657 let is_root_value_flag = |name: &str| -> bool {
658 root_lookup.get(name).is_some_and(|(_, typ, ..)| !is_bool_type(typ))
659 };
660
661 let mut node = schema;
662 let mut skip_next_positional = false;
663 for arg in args {
664 match arg {
665 Arg::DoubleDash => break,
667 Arg::LongFlag(name) if is_root_value_flag(name) => skip_next_positional = true,
670 Arg::ShortFlag(name) if is_root_value_flag(name) => skip_next_positional = true,
671 Arg::Positional(expr) => {
672 if skip_next_positional {
673 skip_next_positional = false;
674 continue; }
676 if node.subcommands.is_empty() {
677 break; }
679 match classify_subcommand_positional(expr) {
680 SubcommandWord::Word(word) => {
681 match node.subcommands.iter().find(|c| c.matches_command(word)) {
682 Some(child) => node = child, None => break, }
685 }
686 SubcommandWord::OtherLiteral => break,
690 SubcommandWord::Computed(kind) => anyhow::bail!(
691 "{}: a subcommand name is required here, but got {kind}. \
692 Subcommands must be literal words — spell it out \
693 (e.g. `{} <subcommand> …`) or use the `--flag=value` form.",
694 node.name,
695 schema.name
696 ),
697 }
698 }
699 _ => {}
701 }
702 }
703 Ok(node)
704}
705
706enum SubcommandWord<'a> {
708 Word(&'a str),
710 OtherLiteral,
712 Computed(&'static str),
714}
715
716fn classify_subcommand_positional(expr: &Expr) -> SubcommandWord<'_> {
717 match expr {
718 Expr::Literal(Value::String(s)) => SubcommandWord::Word(s),
719 Expr::Literal(_) => SubcommandWord::OtherLiteral,
720 Expr::CommandSubst(_) | Expr::Command(_) => SubcommandWord::Computed("a command substitution `$(…)`"),
721 Expr::VarRef(_)
722 | Expr::VarWithDefault { .. }
723 | Expr::VarLength(_)
724 | Expr::Positional(_)
725 | Expr::AllArgs
726 | Expr::ArgCount
727 | Expr::CurrentPid
728 | Expr::LastExitCode => SubcommandWord::Computed("a variable reference"),
729 Expr::Interpolated(_) | Expr::HereDocBody { .. } => SubcommandWord::Computed("an interpolated string"),
730 Expr::GlobPattern(_) => SubcommandWord::Computed("a glob pattern"),
731 Expr::Arithmetic(_) => SubcommandWord::Computed("an arithmetic expansion"),
732 _ => SubcommandWord::Computed("a value computed at runtime"),
733 }
734}
735
736pub fn schema_param_lookup(schema: &ToolSchema) -> HashMap<String, (&str, &str, usize, bool)> {
737 let mut map = HashMap::new();
738 for p in schema.params.iter().filter(|p| !p.positional) {
739 map.insert(p.name.clone(), (p.name.as_str(), p.param_type.as_str(), p.consumes, p.repeatable));
740 for alias in &p.aliases {
741 let stripped = alias.trim_start_matches('-');
742 map.insert(stripped.to_string(), (p.name.as_str(), p.param_type.as_str(), p.consumes, p.repeatable));
743 }
744 }
745 map
746}
747
748pub fn is_bool_type(param_type: &str) -> bool {
750 matches!(param_type.to_lowercase().as_str(), "bool" | "boolean")
751}
752
753pub fn build_tool_args(
766 args: &[Arg],
767 ctx: &ExecContext,
768 schema: Option<&ToolSchema>,
769) -> Result<ToolArgs, String> {
770 let mut tool_args = ToolArgs::new();
771 let param_lookup = schema.map(schema_param_lookup).unwrap_or_default();
772 let accepts_word_assign = schema
773 .map(|s| crate::tools::accepts_word_assign(s.name.as_str()))
774 .unwrap_or(false);
775
776 let mut consumed_positionals: std::collections::HashSet<usize> = std::collections::HashSet::new();
778 let mut past_double_dash = false;
779
780 let mut positional_indices: Vec<(usize, &Expr)> = Vec::new();
782 for (i, arg) in args.iter().enumerate() {
783 if let Arg::Positional(expr) = arg {
784 positional_indices.push((i, expr));
785 }
786 }
787
788 let mut i = 0;
790 while i < args.len() {
791 let arg = &args[i];
792
793 match arg {
794 Arg::DoubleDash => {
795 past_double_dash = true;
796 }
797 Arg::Positional(expr) => {
798 if !consumed_positionals.contains(&i)
800 && let Some(value) = eval_simple_expr(expr, ctx)?
801 {
802 tool_args.positional.push(value);
803 }
804 }
805 Arg::Named { key, value } => {
806 if let Some(val) = eval_simple_expr(value, ctx)? {
807 tool_args.named.insert(key.clone(), val);
808 }
809 }
810 Arg::WordAssign { key, value } => {
811 if let Some(val) = eval_simple_expr(value, ctx)? {
812 if accepts_word_assign {
813 tool_args.named.insert(key.clone(), val);
814 } else {
815 let val_str = crate::interpreter::value_to_string(&val);
816 tool_args.positional.push(Value::String(format!("{key}={val_str}")));
817 }
818 }
819 }
820 Arg::ShortFlag(name) => {
821 if past_double_dash {
822 tool_args.positional.push(Value::String(format!("-{name}")));
823 } else if name.len() == 1 {
824 let flag_name = name.as_str();
827 let lookup = param_lookup.get(flag_name);
828 let is_bool = lookup
829 .map(|(_, typ, ..)| is_bool_type(typ))
830 .unwrap_or(true);
831
832 if is_bool {
833 tool_args.flags.insert(flag_name.to_string());
834 } else {
835 let canonical = lookup.map(|(n, ..)| *n).unwrap_or(flag_name);
837 let next_positional = positional_indices
838 .iter()
839 .find(|(idx, _)| *idx > i && !consumed_positionals.contains(idx));
840
841 if let Some((pos_idx, expr)) = next_positional {
842 if let Some(value) = eval_simple_expr(expr, ctx)? {
843 tool_args.named.insert(canonical.to_string(), value);
844 consumed_positionals.insert(*pos_idx);
845 } else {
846 tool_args.flags.insert(flag_name.to_string());
847 }
848 } else {
849 tool_args.flags.insert(flag_name.to_string());
850 }
851 }
852 } else if let Some(&(canonical, typ, ..)) = param_lookup.get(name.as_str()) {
853 if is_bool_type(typ) {
855 tool_args.flags.insert(canonical.to_string());
856 } else {
857 let next_positional = positional_indices
858 .iter()
859 .find(|(idx, _)| *idx > i && !consumed_positionals.contains(idx));
860 if let Some((pos_idx, expr)) = next_positional {
861 if let Some(value) = eval_simple_expr(expr, ctx)? {
862 tool_args.named.insert(canonical.to_string(), value);
863 consumed_positionals.insert(*pos_idx);
864 } else {
865 tool_args.flags.insert(name.clone());
866 }
867 } else {
868 tool_args.flags.insert(name.clone());
869 }
870 }
871 } else {
872 for c in name.chars() {
874 tool_args.flags.insert(c.to_string());
875 }
876 }
877 }
878 Arg::LongFlag(name) => {
879 if past_double_dash {
880 tool_args.positional.push(Value::String(format!("--{name}")));
881 } else {
882 let lookup = param_lookup.get(name.as_str());
884 let is_bool = lookup
885 .map(|(_, typ, ..)| is_bool_type(typ))
886 .unwrap_or(true); if is_bool {
889 tool_args.flags.insert(name.clone());
890 } else {
891 let canonical = lookup.map(|(n, ..)| *n).unwrap_or(name.as_str());
900 let next_positional = positional_indices
901 .iter()
902 .find(|(idx, _)| *idx > i && !consumed_positionals.contains(idx));
903
904 if let Some((pos_idx, expr)) = next_positional {
905 if let Some(value) = eval_simple_expr(expr, ctx)? {
906 tool_args.named.insert(canonical.to_string(), value);
907 consumed_positionals.insert(*pos_idx);
908 } else {
909 tool_args.flags.insert(name.clone());
910 }
911 } else {
912 tool_args.flags.insert(name.clone());
913 }
914 }
915 }
916 }
917 }
918 i += 1;
919 }
920
921 if let Some(schema) = schema.filter(|s| s.map_positionals) {
926 let pre_dash_count = if past_double_dash {
928 let dash_pos = args.iter().position(|a| matches!(a, Arg::DoubleDash)).unwrap_or(args.len());
930 positional_indices.iter()
932 .filter(|(idx, _)| *idx < dash_pos && !consumed_positionals.contains(idx))
933 .count()
934 } else {
935 tool_args.positional.len()
936 };
937
938 let mut remaining = Vec::new();
939 let mut positional_iter = tool_args.positional.drain(..).enumerate();
940
941 for param in &schema.params {
942 if tool_args.named.contains_key(¶m.name) || tool_args.flags.contains(¶m.name) {
943 continue; }
945 if is_bool_type(¶m.param_type) {
946 continue; }
948 loop {
950 match positional_iter.next() {
951 Some((idx, val)) if idx < pre_dash_count => {
952 tool_args.named.insert(param.name.clone(), val);
953 break;
954 }
955 Some((_, val)) => {
956 remaining.push(val); }
958 None => break,
959 }
960 }
961 }
962
963 remaining.extend(positional_iter.map(|(_, v)| v));
965 tool_args.positional = remaining;
966 }
967
968 Ok(tool_args)
969}
970
971pub(crate) fn eval_simple_expr(expr: &Expr, ctx: &ExecContext) -> Result<Option<Value>, String> {
984 match expr {
985 Expr::Literal(value) => Ok(Some(eval_literal(value, ctx))),
986 Expr::VarRef(path) => match ctx.scope.resolve_path(path) {
987 Ok(v) => Ok(Some(v)),
988 Err(PathError::UndefinedRoot(_)) if path.segments.len() <= 1 => Ok(None),
995 Err(PathError::UndefinedRoot(_)) => Err(format!(
996 "{}: undefined variable",
997 crate::interpreter::format_path(path)
998 )),
999 Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => Err(msg),
1002 },
1003 Expr::Interpolated(parts) => Ok(Some(Value::String(eval_string_parts_sync(parts, ctx)?))),
1004 Expr::VarLength(path) => {
1009 crate::interpreter::resolve_length(&ctx.scope, path).map(|n| Some(Value::Int(n)))
1010 }
1011 Expr::VarWithDefault { path, default } => {
1012 match crate::interpreter::resolve_default(&ctx.scope, path)? {
1013 Some(value) => Ok(Some(value)),
1014 None => Ok(Some(Value::String(eval_string_parts_sync(default, ctx)?))),
1015 }
1016 }
1017 Expr::GlobPattern(s) => Ok(Some(Value::String(s.clone()))),
1018 Expr::HereDocBody { parts, strip_tabs } => {
1019 let mut asm = crate::interpreter::HeredocAssembler::new(*strip_tabs);
1023 for sp in parts {
1024 match &sp.part {
1025 crate::ast::StringPart::Literal(s) => asm.push_literal(s),
1026 other => {
1027 let s = eval_string_parts_sync(std::slice::from_ref(other), ctx)?;
1028 asm.push_interpolated(&s);
1029 }
1030 }
1031 }
1032 Ok(Some(Value::String(asm.into_string())))
1033 }
1034 Expr::CommandSubst(_) | Expr::Command(_) => Err(
1041 "command substitution `$(...)` is not supported in a scatter/gather flag value here; \
1042 assign it to a variable first (e.g. `n=$(...); scatter --limit $n`)"
1043 .to_string(),
1044 ),
1045 _ => Ok(None), }
1047}
1048
1049fn eval_literal(value: &Value, _ctx: &ExecContext) -> Value {
1051 value.clone()
1052}
1053
1054fn value_to_string(value: &Value) -> String {
1056 match value {
1057 Value::Null => "".to_string(),
1058 Value::Bool(b) => b.to_string(),
1059 Value::Int(i) => i.to_string(),
1060 Value::Float(f) => f.to_string(),
1061 Value::String(s) => s.clone(),
1062 Value::Json(json) => json.to_string(),
1063 Value::Bytes(b) => format!("[binary: {} bytes]", b.len()),
1064 }
1065}
1066
1067fn eval_string_parts_sync(parts: &[crate::ast::StringPart], ctx: &ExecContext) -> Result<String, String> {
1075 let mut result = String::new();
1076 for part in parts {
1077 match part {
1078 crate::ast::StringPart::Literal(s) => result.push_str(s),
1079 crate::ast::StringPart::Var(path) => match ctx.scope.resolve_path(path) {
1080 Ok(value) => result.push_str(
1084 &crate::interpreter::value_to_text_sink(&value).map_err(|e| e.to_string())?,
1085 ),
1086 Err(PathError::UndefinedRoot(_)) => {}
1094 Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => return Err(msg),
1095 },
1096 crate::ast::StringPart::VarWithDefault { path, default } => {
1097 match crate::interpreter::resolve_default(&ctx.scope, path)? {
1098 Some(value) => result.push_str(
1099 &crate::interpreter::value_to_text_sink(&value).map_err(|e| e.to_string())?,
1100 ),
1101 None => result.push_str(&eval_string_parts_sync(default, ctx)?),
1102 }
1103 }
1104 crate::ast::StringPart::VarLength(path) => {
1105 let len = crate::interpreter::resolve_length(&ctx.scope, path)?;
1110 result.push_str(&len.to_string());
1111 }
1112 crate::ast::StringPart::Positional(n) => {
1113 if let Some(s) = ctx.scope.get_positional(*n) {
1114 result.push_str(s);
1115 }
1116 }
1117 crate::ast::StringPart::AllArgs => {
1118 result.push_str(&ctx.scope.all_args().join(" "));
1119 }
1120 crate::ast::StringPart::ArgCount => {
1121 result.push_str(&ctx.scope.arg_count().to_string());
1122 }
1123 crate::ast::StringPart::Arithmetic(expr) => {
1124 if let Ok(value) = arithmetic::eval_arithmetic(expr, &ctx.scope) {
1128 result.push_str(&value.to_string());
1129 }
1130 }
1131 crate::ast::StringPart::CommandSubst(_) => {
1132 return Err(
1138 "command substitution `$(...)` is not supported inside a scatter/gather \
1139 flag's interpolated value here; assign it to a variable first"
1140 .to_string(),
1141 );
1142 }
1143 crate::ast::StringPart::LastExitCode => {
1144 result.push_str(&ctx.scope.last_result().code.to_string());
1145 }
1146 crate::ast::StringPart::CurrentPid => {
1147 result.push_str(&ctx.scope.pid().to_string());
1148 }
1149 }
1150 }
1151 Ok(result)
1152}
1153
1154fn find_scatter_gather(commands: &[Command]) -> Option<(usize, usize)> {
1159 let scatter_idx = commands.iter().position(|c| c.name == "scatter")?;
1160 let gather_idx = commands.iter().position(|c| c.name == "gather")?;
1161
1162 if gather_idx > scatter_idx {
1164 Some((scatter_idx, gather_idx))
1165 } else {
1166 None
1167 }
1168}
1169
1170#[cfg(test)]
1171mod select_leaf_tests {
1172 use super::*;
1173 use crate::tools::ParamSchema;
1174
1175 fn kj_schema() -> ToolSchema {
1180 ToolSchema::new("kj", "kaijutsu")
1181 .param(ParamSchema::new("confirm", "string"))
1182 .param(ParamSchema::new("verbose", "bool"))
1183 .subcommand(
1184 ToolSchema::new("context", "context ops")
1185 .with_command_aliases(["ctx"])
1186 .subcommand(ToolSchema::new("list", "list").with_command_aliases(["ls"]))
1187 .subcommand(
1188 ToolSchema::new("create", "create").param(
1189 ParamSchema::new("type", "string").with_aliases(["t"]),
1190 ),
1191 ),
1192 )
1193 }
1194
1195 fn word(s: &str) -> Arg {
1196 Arg::Positional(Expr::Literal(Value::String(s.to_string())))
1197 }
1198
1199 #[test]
1200 fn flat_tool_returns_root() {
1201 let schema = ToolSchema::new("cat", "concat")
1202 .param(ParamSchema::required("path", "string", "f").positional());
1203 let leaf = select_leaf(&schema, &[word("foo.txt")]).expect("flat ok");
1204 assert_eq!(leaf.name, "cat");
1205 }
1206
1207 #[test]
1208 fn single_hop() {
1209 let schema = kj_schema();
1210 let leaf = select_leaf(&schema, &[word("context")]).expect("ok");
1211 assert_eq!(leaf.name, "context");
1212 }
1213
1214 #[test]
1215 fn two_hops() {
1216 let schema = kj_schema();
1217 let leaf = select_leaf(&schema, &[word("context"), word("create")]).expect("ok");
1218 assert_eq!(leaf.name, "create");
1219 assert!(leaf.params.iter().any(|p| p.name == "type"), "leaf has --type");
1220 }
1221
1222 #[test]
1223 fn alias_hops_route() {
1224 let schema = kj_schema();
1225 let leaf = select_leaf(&schema, &[word("ctx"), word("ls")]).expect("ok");
1227 assert_eq!(leaf.name, "list");
1228 }
1229
1230 #[test]
1231 fn unknown_subcommand_stops_at_current_node() {
1232 let schema = kj_schema();
1233 let leaf = select_leaf(&schema, &[word("context"), word("nonesuch")]).expect("ok");
1236 assert_eq!(leaf.name, "context");
1237 }
1238
1239 #[test]
1240 fn root_bool_flag_before_path_does_not_disrupt_routing() {
1241 let schema = kj_schema();
1242 let args = vec![Arg::LongFlag("verbose".into()), word("context"), word("create")];
1245 let leaf = select_leaf(&schema, &args).expect("ok");
1246 assert_eq!(leaf.name, "create");
1247 }
1248
1249 #[test]
1250 fn root_value_flag_space_form_before_path_skips_its_value() {
1251 let schema = kj_schema();
1252 let args = vec![
1255 Arg::LongFlag("confirm".into()),
1256 word("nonce"),
1257 word("context"),
1258 word("create"),
1259 ];
1260 let leaf = select_leaf(&schema, &args).expect("ok");
1261 assert_eq!(leaf.name, "create");
1262 }
1263
1264 #[test]
1265 fn leaf_value_flag_after_path_routes_to_leaf() {
1266 let schema = kj_schema();
1267 let args = vec![
1270 word("context"),
1271 word("create"),
1272 Arg::LongFlag("type".into()),
1273 word("x"),
1274 ];
1275 let leaf = select_leaf(&schema, &args).expect("ok");
1276 assert_eq!(leaf.name, "create");
1277 assert!(leaf.params.iter().any(|p| p.name == "type"));
1278 }
1279
1280 #[test]
1281 fn double_dash_stops_routing() {
1282 let schema = kj_schema();
1283 let leaf = select_leaf(&schema, &[Arg::DoubleDash, word("context")]).expect("ok");
1285 assert_eq!(leaf.name, "kj");
1286 }
1287
1288 #[test]
1289 fn computed_subcommand_selector_errors() {
1290 let schema = kj_schema();
1291 let args = vec![Arg::Positional(Expr::CommandSubst(vec![
1294 crate::ast::Stmt::Command(crate::ast::Command {
1295 name: "echo".into(),
1296 args: vec![],
1297 redirects: vec![],
1298 }),
1299 ]))];
1300 let err = select_leaf(&schema, &args).expect_err("must error");
1301 let msg = err.to_string();
1302 assert!(msg.contains("subcommand name is required"), "got: {msg}");
1303 assert!(msg.contains("command substitution"), "names the cause: {msg}");
1304 }
1305
1306 #[test]
1307 fn variable_subcommand_selector_errors() {
1308 let schema = kj_schema();
1309 let args = vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("sub")))];
1310 let err = select_leaf(&schema, &args).expect_err("must error");
1311 assert!(err.to_string().contains("variable reference"), "got: {err}");
1312 }
1313
1314 #[test]
1315 fn computed_positional_after_leaf_is_fine() {
1316 let schema = kj_schema();
1317 let args = vec![
1320 word("context"),
1321 word("list"),
1322 Arg::Positional(Expr::CommandSubst(vec![crate::ast::Stmt::Command(
1323 crate::ast::Command { name: "echo".into(), args: vec![], redirects: vec![] },
1324 )])),
1325 ];
1326 let leaf = select_leaf(&schema, &args).expect("ok");
1327 assert_eq!(leaf.name, "list");
1328 }
1329}
1330
1331#[cfg(test)]
1332mod tests {
1333 use super::*;
1334 use crate::dispatch::BackendDispatcher;
1335 use crate::tools::register_builtins;
1336 use crate::vfs::{Filesystem, MemoryFs, VfsRouter};
1337 use std::path::Path;
1338
1339 async fn make_runner_and_ctx() -> (PipelineRunner, ExecContext, BackendDispatcher) {
1340 let mut tools = ToolRegistry::new();
1341 register_builtins(&mut tools);
1342 let tools = Arc::new(tools);
1343 let runner = PipelineRunner::new(tools.clone());
1344 let dispatcher = BackendDispatcher::new(tools.clone());
1345
1346 let mut vfs = VfsRouter::new();
1347 let mem = MemoryFs::new();
1348 mem.write(Path::new("test.txt"), b"hello\nworld\nfoo").await.unwrap();
1349 vfs.mount("/", mem);
1350 let ctx = ExecContext::with_vfs_and_tools(Arc::new(vfs), tools);
1351
1352 (runner, ctx, dispatcher)
1353 }
1354
1355 fn make_cmd(name: &str, args: Vec<&str>) -> Command {
1356 Command {
1357 name: name.to_string(),
1358 args: args.iter().map(|s| Arg::Positional(Expr::Literal(Value::String(s.to_string())))).collect(),
1359 redirects: vec![],
1360 }
1361 }
1362
1363 #[tokio::test]
1364 async fn test_single_command() {
1365 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1366 let cmd = make_cmd("echo", vec!["hello"]);
1367
1368 let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
1369 assert!(result.ok());
1370 assert_eq!(result.text_out().trim(), "hello");
1371 }
1372
1373 #[tokio::test]
1374 async fn test_pipeline_echo_grep() {
1375 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1376
1377 let echo_cmd = Command {
1379 name: "echo".to_string(),
1380 args: vec![Arg::Positional(Expr::Literal(Value::String("hello\nworld".to_string())))],
1381 redirects: vec![],
1382 };
1383 let grep_cmd = Command {
1384 name: "grep".to_string(),
1385 args: vec![Arg::Positional(Expr::Literal(Value::String("world".to_string())))],
1386 redirects: vec![],
1387 };
1388
1389 let result = runner.run(&[echo_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1390 assert!(result.ok());
1391 assert_eq!(result.text_out().trim(), "world");
1392 }
1393
1394 #[tokio::test]
1395 async fn test_pipeline_cat_grep() {
1396 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1397
1398 let cat_cmd = make_cmd("cat", vec!["/test.txt"]);
1400 let grep_cmd = Command {
1401 name: "grep".to_string(),
1402 args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
1403 redirects: vec![],
1404 };
1405
1406 let result = runner.run(&[cat_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1407 assert!(result.ok());
1408 assert!(result.text_out().contains("hello"));
1409 }
1410
1411 #[tokio::test]
1412 async fn test_command_not_found() {
1413 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1414 let cmd = make_cmd("nonexistent", vec![]);
1415
1416 let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
1417 assert!(!result.ok());
1418 assert_eq!(result.code, 127);
1419 assert!(result.err.contains("not found"));
1420 }
1421
1422 #[tokio::test]
1423 async fn test_pipeline_continues_on_failure() {
1424 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1427
1428 let cat_cmd = make_cmd("cat", vec!["/nonexistent"]);
1431 let grep_cmd = Command {
1432 name: "grep".to_string(),
1433 args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
1434 redirects: vec![],
1435 };
1436
1437 let result = runner.run(&[cat_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1438 assert!(!result.ok());
1440 }
1441
1442 #[tokio::test]
1443 async fn test_pipeline_last_command_exit_code() {
1444 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1446
1447 let echo_cmd = make_cmd("echo", vec!["hello"]);
1448 let cat_cmd = make_cmd("cat", vec![]);
1449
1450 let result = runner.run(&[echo_cmd, cat_cmd], &mut ctx, &dispatcher).await;
1451 assert!(result.ok());
1452 assert!(result.text_out().contains("hello"));
1453 }
1454
1455 #[tokio::test]
1456 async fn test_empty_pipeline() {
1457 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1458 let result = runner.run(&[], &mut ctx, &dispatcher).await;
1459 assert!(result.ok());
1460 }
1461
1462 #[test]
1465 fn test_find_scatter_gather_both_present() {
1466 let commands = vec![
1467 make_cmd("echo", vec!["a"]),
1468 make_cmd("scatter", vec![]),
1469 make_cmd("process", vec![]),
1470 make_cmd("gather", vec![]),
1471 ];
1472 let result = find_scatter_gather(&commands);
1473 assert_eq!(result, Some((1, 3)));
1474 }
1475
1476 #[test]
1477 fn test_find_scatter_gather_no_scatter() {
1478 let commands = vec![
1479 make_cmd("echo", vec!["a"]),
1480 make_cmd("gather", vec![]),
1481 ];
1482 let result = find_scatter_gather(&commands);
1483 assert!(result.is_none());
1484 }
1485
1486 #[test]
1487 fn test_find_scatter_gather_no_gather() {
1488 let commands = vec![
1489 make_cmd("echo", vec!["a"]),
1490 make_cmd("scatter", vec![]),
1491 ];
1492 let result = find_scatter_gather(&commands);
1493 assert!(result.is_none());
1494 }
1495
1496 #[test]
1497 fn test_find_scatter_gather_wrong_order() {
1498 let commands = vec![
1499 make_cmd("gather", vec![]),
1500 make_cmd("scatter", vec![]),
1501 ];
1502 let result = find_scatter_gather(&commands);
1503 assert!(result.is_none());
1504 }
1505
1506 #[tokio::test]
1507 async fn test_scatter_gather_simple() {
1508 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1509
1510 let split_cmd = Command {
1512 name: "split".to_string(),
1513 args: vec![Arg::Positional(Expr::Literal(Value::String("a b c".to_string())))],
1514 redirects: vec![],
1515 };
1516 let scatter_cmd = make_cmd("scatter", vec![]);
1517 let process_cmd = Command {
1518 name: "echo".to_string(),
1519 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1520 redirects: vec![],
1521 };
1522 let gather_cmd = make_cmd("gather", vec![]);
1523
1524 let result = runner.run(&[split_cmd, scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1525 assert!(result.ok(), "scatter with structured data should succeed: {}", result.err);
1526 assert!(result.text_out().contains("a"));
1528 assert!(result.text_out().contains("b"));
1529 assert!(result.text_out().contains("c"));
1530 }
1531
1532 #[tokio::test]
1533 async fn test_scatter_gather_empty_input() {
1534 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1535
1536 let echo_cmd = Command {
1538 name: "echo".to_string(),
1539 args: vec![Arg::Positional(Expr::Literal(Value::String("".to_string())))],
1540 redirects: vec![],
1541 };
1542 let scatter_cmd = make_cmd("scatter", vec![]);
1543 let process_cmd = Command {
1544 name: "echo".to_string(),
1545 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1546 redirects: vec![],
1547 };
1548 let gather_cmd = make_cmd("gather", vec![]);
1549
1550 let result = runner.run(&[echo_cmd, scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1551 assert!(result.ok());
1552 assert!(result.text_out().trim().is_empty());
1553 }
1554
1555 #[tokio::test]
1556 async fn test_scatter_gather_with_structured_stdin() {
1557 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1558
1559 let data = Value::Json(serde_json::json!(["x", "y", "z"]));
1561 ctx.set_stdin_with_data("x\ny\nz".to_string(), Some(data));
1562
1563 let scatter_cmd = make_cmd("scatter", vec![]);
1564 let process_cmd = Command {
1565 name: "echo".to_string(),
1566 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1567 redirects: vec![],
1568 };
1569 let gather_cmd = make_cmd("gather", vec![]);
1570
1571 let result = runner.run(&[scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1572 assert!(result.ok(), "scatter with structured stdin should succeed: {}", result.err);
1573 assert!(result.text_out().contains("x"));
1574 assert!(result.text_out().contains("y"));
1575 assert!(result.text_out().contains("z"));
1576 }
1577
1578 #[tokio::test]
1579 async fn test_scatter_gather_json_input() {
1580 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1581
1582 let data = Value::Json(serde_json::json!(["one", "two", "three"]));
1584 ctx.set_stdin_with_data(r#"["one", "two", "three"]"#.to_string(), Some(data));
1585
1586 let scatter_cmd = make_cmd("scatter", vec![]);
1587 let process_cmd = Command {
1588 name: "echo".to_string(),
1589 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1590 redirects: vec![],
1591 };
1592 let gather_cmd = make_cmd("gather", vec![]);
1593
1594 let result = runner.run(&[scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1595 assert!(result.ok(), "scatter with JSON data should succeed: {}", result.err);
1596 assert!(result.text_out().contains("one"));
1597 assert!(result.text_out().contains("two"));
1598 assert!(result.text_out().contains("three"));
1599 }
1600
1601 #[tokio::test]
1602 async fn test_scatter_gather_with_post_gather() {
1603 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1604
1605 let split_cmd = Command {
1607 name: "split".to_string(),
1608 args: vec![Arg::Positional(Expr::Literal(Value::String("a b".to_string())))],
1609 redirects: vec![],
1610 };
1611 let scatter_cmd = make_cmd("scatter", vec![]);
1612 let process_cmd = Command {
1613 name: "echo".to_string(),
1614 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1615 redirects: vec![],
1616 };
1617 let gather_cmd = make_cmd("gather", vec![]);
1618 let grep_cmd = Command {
1619 name: "grep".to_string(),
1620 args: vec![Arg::Positional(Expr::Literal(Value::String("a".to_string())))],
1621 redirects: vec![],
1622 };
1623
1624 let result = runner.run(&[split_cmd, scatter_cmd, process_cmd, gather_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1625 assert!(result.ok(), "scatter with post_gather should succeed: {}", result.err);
1626 assert!(result.text_out().contains("a"));
1627 assert!(!result.text_out().contains("b"));
1628 }
1629
1630 #[tokio::test]
1631 async fn test_scatter_custom_var_name() {
1632 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1633
1634 let data = Value::Json(serde_json::json!(["test1", "test2"]));
1636 ctx.set_stdin_with_data("test1\ntest2".to_string(), Some(data));
1637
1638 let scatter_cmd = Command {
1640 name: "scatter".to_string(),
1641 args: vec![Arg::Named {
1642 key: "as".to_string(),
1643 value: Expr::Literal(Value::String("URL".to_string())),
1644 }],
1645 redirects: vec![],
1646 };
1647 let process_cmd = Command {
1648 name: "echo".to_string(),
1649 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("URL")))],
1650 redirects: vec![],
1651 };
1652 let gather_cmd = make_cmd("gather", vec![]);
1653
1654 let result = runner.run(&[scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1655 assert!(result.ok(), "scatter with custom var should succeed: {}", result.err);
1656 assert!(result.text_out().contains("test1"));
1657 assert!(result.text_out().contains("test2"));
1658 }
1659
1660 #[tokio::test]
1663 async fn test_pipeline_routes_through_backend() {
1664 use crate::backend::testing::MockBackend;
1665 use std::sync::atomic::Ordering;
1666
1667 let (backend, call_count) = MockBackend::new();
1669 let backend: std::sync::Arc<dyn crate::backend::KernelBackend> = std::sync::Arc::new(backend);
1670
1671 let mut ctx = crate::tools::ExecContext::with_backend(backend);
1673
1674 let tools = std::sync::Arc::new(ToolRegistry::new());
1676 let runner = PipelineRunner::new(tools.clone());
1677 let dispatcher = BackendDispatcher::new(tools);
1678
1679 let cmd = make_cmd("test-tool", vec!["arg1"]);
1681 let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
1682
1683 assert!(result.ok(), "Mock backend should return success");
1684 assert_eq!(call_count.load(Ordering::SeqCst), 1, "call_tool should be invoked once");
1685 assert!(result.text_out().contains("mock executed"), "Output should be from mock backend");
1686 }
1687
1688 #[tokio::test]
1689 async fn test_multi_command_pipeline_routes_through_backend() {
1690 use crate::backend::testing::MockBackend;
1691 use std::sync::atomic::Ordering;
1692
1693 let (backend, call_count) = MockBackend::new();
1694 let backend: std::sync::Arc<dyn crate::backend::KernelBackend> = std::sync::Arc::new(backend);
1695 let mut ctx = crate::tools::ExecContext::with_backend(backend);
1696
1697 let tools = std::sync::Arc::new(ToolRegistry::new());
1698 let runner = PipelineRunner::new(tools.clone());
1699 let dispatcher = BackendDispatcher::new(tools);
1700
1701 let cmd1 = make_cmd("tool1", vec![]);
1703 let cmd2 = make_cmd("tool2", vec![]);
1704 let cmd3 = make_cmd("tool3", vec![]);
1705
1706 let result = runner.run(&[cmd1, cmd2, cmd3], &mut ctx, &dispatcher).await;
1707
1708 assert!(result.ok());
1709 assert_eq!(call_count.load(Ordering::SeqCst), 3, "call_tool should be invoked for each command");
1710 }
1711
1712 use crate::tools::{ParamSchema, ToolSchema};
1715
1716 fn make_test_schema() -> ToolSchema {
1717 ToolSchema::new("test-tool", "A test tool for schema-aware parsing")
1718 .param(ParamSchema::required("query", "string", "Search query"))
1719 .param(ParamSchema::optional("limit", "int", Value::Int(10), "Max results"))
1720 .param(ParamSchema::optional("verbose", "bool", Value::Bool(false), "Verbose output"))
1721 .param(ParamSchema::optional("output", "string", Value::String("stdout".into()), "Output destination"))
1722 .with_positional_mapping()
1723 }
1724
1725 fn make_minimal_ctx() -> ExecContext {
1726 let mut vfs = VfsRouter::new();
1727 vfs.mount("/", MemoryFs::new());
1728 ExecContext::new(Arc::new(vfs))
1729 }
1730
1731 #[test]
1732 fn test_schema_aware_string_arg() {
1733 let args = vec![
1735 Arg::LongFlag("query".to_string()),
1736 Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
1737 ];
1738 let schema = make_test_schema();
1739 let ctx = make_minimal_ctx();
1740
1741 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).expect("build_tool_args");
1742
1743 assert!(tool_args.flags.is_empty(), "No flags should be set");
1744 assert!(tool_args.positional.is_empty(), "No positionals - consumed by --query");
1745 assert_eq!(
1746 tool_args.named.get("query"),
1747 Some(&Value::String("test".to_string())),
1748 "--query should consume 'test' as its value"
1749 );
1750 }
1751
1752 #[test]
1753 fn test_schema_aware_bool_flag() {
1754 let args = vec![
1756 Arg::LongFlag("verbose".to_string()),
1757 ];
1758 let schema = make_test_schema();
1759 let ctx = make_minimal_ctx();
1760
1761 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).expect("build_tool_args");
1762
1763 assert!(tool_args.flags.contains("verbose"), "--verbose should be a flag");
1764 assert!(tool_args.named.is_empty(), "No named args");
1765 assert!(tool_args.positional.is_empty(), "No positionals");
1766 }
1767
1768 #[test]
1769 fn test_schema_aware_mixed() {
1770 let args = vec![
1773 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
1774 Arg::LongFlag("output".to_string()),
1775 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
1776 Arg::LongFlag("verbose".to_string()),
1777 ];
1778 let schema = make_test_schema();
1779 let ctx = make_minimal_ctx();
1780
1781 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).expect("build_tool_args");
1782
1783 assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
1784 assert_eq!(
1785 tool_args.named.get("query"),
1786 Some(&Value::String("file.txt".to_string()))
1787 );
1788 assert_eq!(
1789 tool_args.named.get("output"),
1790 Some(&Value::String("out.txt".to_string()))
1791 );
1792 assert!(tool_args.flags.contains("verbose"));
1793 }
1794
1795 #[test]
1796 fn test_schema_aware_multiple_string_args() {
1797 let args = vec![
1799 Arg::LongFlag("query".to_string()),
1800 Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
1801 Arg::LongFlag("output".to_string()),
1802 Arg::Positional(Expr::Literal(Value::String("result.json".to_string()))),
1803 Arg::LongFlag("verbose".to_string()),
1804 Arg::LongFlag("limit".to_string()),
1805 Arg::Positional(Expr::Literal(Value::Int(5))),
1806 ];
1807 let schema = make_test_schema();
1808 let ctx = make_minimal_ctx();
1809
1810 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).expect("build_tool_args");
1811
1812 assert!(tool_args.positional.is_empty(), "All positionals consumed");
1813 assert_eq!(
1814 tool_args.named.get("query"),
1815 Some(&Value::String("test".to_string()))
1816 );
1817 assert_eq!(
1818 tool_args.named.get("output"),
1819 Some(&Value::String("result.json".to_string()))
1820 );
1821 assert_eq!(
1822 tool_args.named.get("limit"),
1823 Some(&Value::Int(5))
1824 );
1825 assert!(tool_args.flags.contains("verbose"));
1826 }
1827
1828 #[test]
1829 fn test_schema_aware_double_dash() {
1830 let args = vec![
1833 Arg::LongFlag("output".to_string()),
1834 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
1835 Arg::DoubleDash,
1836 Arg::Positional(Expr::Literal(Value::String("--this-is-data".to_string()))),
1837 ];
1838 let schema = make_test_schema();
1839 let ctx = make_minimal_ctx();
1840
1841 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).expect("build_tool_args");
1842
1843 assert_eq!(
1844 tool_args.named.get("output"),
1845 Some(&Value::String("out.txt".to_string()))
1846 );
1847 assert_eq!(
1849 tool_args.positional,
1850 vec![Value::String("--this-is-data".to_string())]
1851 );
1852 }
1853
1854 #[test]
1855 fn test_no_schema_fallback() {
1856 let args = vec![
1858 Arg::LongFlag("query".to_string()),
1859 Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
1860 ];
1861 let ctx = make_minimal_ctx();
1862
1863 let tool_args = build_tool_args(&args, &ctx, None).expect("build_tool_args");
1864
1865 assert!(tool_args.flags.contains("query"), "--query should be a flag");
1867 assert_eq!(
1868 tool_args.positional,
1869 vec![Value::String("test".to_string())],
1870 "'test' should be a positional"
1871 );
1872 }
1873
1874 #[test]
1875 fn test_unknown_flag_in_schema() {
1876 let args = vec![
1878 Arg::LongFlag("unknown".to_string()),
1879 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
1880 ];
1881 let schema = make_test_schema();
1882 let ctx = make_minimal_ctx();
1883
1884 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).expect("build_tool_args");
1885
1886 assert!(tool_args.flags.contains("unknown"));
1887 assert!(tool_args.positional.is_empty(), "value consumed as query param");
1888 assert_eq!(
1889 tool_args.named.get("query"),
1890 Some(&Value::String("value".to_string()))
1891 );
1892 }
1893
1894 #[test]
1895 fn test_named_args_unchanged() {
1896 let args = vec![
1898 Arg::Named {
1899 key: "query".to_string(),
1900 value: Expr::Literal(Value::String("test".to_string())),
1901 },
1902 Arg::LongFlag("verbose".to_string()),
1903 ];
1904 let schema = make_test_schema();
1905 let ctx = make_minimal_ctx();
1906
1907 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).expect("build_tool_args");
1908
1909 assert_eq!(
1910 tool_args.named.get("query"),
1911 Some(&Value::String("test".to_string()))
1912 );
1913 assert!(tool_args.flags.contains("verbose"));
1914 }
1915
1916 #[test]
1917 fn test_short_flags_unchanged() {
1918 let args = vec![
1920 Arg::ShortFlag("la".to_string()),
1921 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
1922 ];
1923 let schema = make_test_schema();
1924 let ctx = make_minimal_ctx();
1925
1926 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).expect("build_tool_args");
1927
1928 assert!(tool_args.flags.contains("l"));
1929 assert!(tool_args.flags.contains("a"));
1930 assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
1931 assert_eq!(
1932 tool_args.named.get("query"),
1933 Some(&Value::String("file.txt".to_string()))
1934 );
1935 }
1936
1937 #[test]
1938 fn test_flag_at_end_no_value() {
1939 let args = vec![
1942 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
1943 Arg::LongFlag("output".to_string()),
1944 ];
1945 let schema = make_test_schema();
1946 let ctx = make_minimal_ctx();
1947
1948 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).expect("build_tool_args");
1949
1950 assert!(tool_args.flags.contains("output"));
1952 assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
1953 assert_eq!(
1954 tool_args.named.get("query"),
1955 Some(&Value::String("file.txt".to_string()))
1956 );
1957 }
1958
1959 #[test]
1960 fn test_positional_skips_bool_params() {
1961 let schema = ToolSchema::new("test", "")
1965 .param(ParamSchema::required("query", "string", ""))
1966 .param(ParamSchema::optional(
1967 "verbose",
1968 "bool",
1969 Value::Bool(false),
1970 "",
1971 ))
1972 .param(ParamSchema::optional(
1973 "output",
1974 "string",
1975 Value::Null,
1976 "",
1977 ))
1978 .with_positional_mapping();
1979 let args = vec![
1980 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
1981 Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
1982 ];
1983 let ctx = make_minimal_ctx();
1984
1985 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).expect("build_tool_args");
1986
1987 assert_eq!(
1988 tool_args.named.get("query"),
1989 Some(&Value::String("val1".to_string()))
1990 );
1991 assert_eq!(
1992 tool_args.named.get("output"),
1993 Some(&Value::String("val2".to_string()))
1994 );
1995 assert!(!tool_args.flags.contains("verbose"));
1996 assert!(tool_args.positional.is_empty());
1997 }
1998
1999 #[test]
2000 fn test_positionals_fill_available_slots() {
2001 let args = vec![
2004 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2005 Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
2006 Arg::Positional(Expr::Literal(Value::String("val3".to_string()))),
2007 ];
2008 let schema = make_test_schema(); let ctx = make_minimal_ctx();
2010
2011 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).expect("build_tool_args");
2012
2013 assert_eq!(
2016 tool_args.named.get("query"),
2017 Some(&Value::String("val1".to_string()))
2018 );
2019 assert_eq!(
2020 tool_args.named.get("limit"),
2021 Some(&Value::String("val2".to_string()))
2022 );
2023 assert_eq!(
2024 tool_args.named.get("output"),
2025 Some(&Value::String("val3".to_string()))
2026 );
2027 assert!(tool_args.positional.is_empty());
2028 }
2029
2030 #[test]
2031 fn test_truly_excess_positionals() {
2032 let schema = ToolSchema::new("test", "")
2034 .param(ParamSchema::required("name", "string", ""))
2035 .with_positional_mapping();
2036 let args = vec![
2037 Arg::Positional(Expr::Literal(Value::String("first".to_string()))),
2038 Arg::Positional(Expr::Literal(Value::String("second".to_string()))),
2039 Arg::Positional(Expr::Literal(Value::String("third".to_string()))),
2040 ];
2041 let ctx = make_minimal_ctx();
2042
2043 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).expect("build_tool_args");
2044
2045 assert_eq!(
2046 tool_args.named.get("name"),
2047 Some(&Value::String("first".to_string()))
2048 );
2049 assert_eq!(
2050 tool_args.positional,
2051 vec![
2052 Value::String("second".to_string()),
2053 Value::String("third".to_string()),
2054 ]
2055 );
2056 }
2057
2058 #[test]
2059 fn test_double_dash_positional_not_mapped() {
2060 let args = vec![
2062 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2063 Arg::DoubleDash,
2064 Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
2065 ];
2066 let schema = make_test_schema();
2067 let ctx = make_minimal_ctx();
2068
2069 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).expect("build_tool_args");
2070
2071 assert_eq!(
2072 tool_args.named.get("query"),
2073 Some(&Value::String("val1".to_string()))
2074 );
2075 assert_eq!(
2077 tool_args.positional,
2078 vec![Value::String("val2".to_string())]
2079 );
2080 }
2081
2082 #[test]
2083 fn test_all_params_filled_by_flags() {
2084 let args = vec![
2086 Arg::LongFlag("query".to_string()),
2087 Arg::Positional(Expr::Literal(Value::String("search".to_string()))),
2088 Arg::LongFlag("output".to_string()),
2089 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
2090 Arg::LongFlag("verbose".to_string()),
2091 ];
2092 let schema = make_test_schema();
2093 let ctx = make_minimal_ctx();
2094
2095 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).expect("build_tool_args");
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.flags.contains("verbose"));
2106 assert!(tool_args.positional.is_empty());
2107 }
2108
2109 #[test]
2110 fn test_mixed_flags_and_positional_fill() {
2111 let args = vec![
2113 Arg::LongFlag("output".to_string()),
2114 Arg::Positional(Expr::Literal(Value::String("foo".to_string()))),
2115 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2116 ];
2117 let schema = make_test_schema();
2118 let ctx = make_minimal_ctx();
2119
2120 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).expect("build_tool_args");
2121
2122 assert_eq!(
2123 tool_args.named.get("output"),
2124 Some(&Value::String("foo".to_string()))
2125 );
2126 assert_eq!(
2127 tool_args.named.get("query"),
2128 Some(&Value::String("val1".to_string()))
2129 );
2130 assert!(tool_args.positional.is_empty());
2131 }
2132
2133 #[test]
2134 fn test_alias_flag_prevents_mapping_overwrite() {
2135 let schema = ToolSchema::new("test", "")
2137 .param(ParamSchema::required("query", "string", "").with_aliases(["-q"]))
2138 .param(ParamSchema::required("output", "string", ""))
2139 .with_positional_mapping();
2140 let args = vec![
2141 Arg::ShortFlag("q".to_string()),
2142 Arg::Positional(Expr::Literal(Value::String("search".to_string()))),
2143 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
2144 ];
2145 let ctx = make_minimal_ctx();
2146
2147 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).expect("build_tool_args");
2148
2149 assert_eq!(
2150 tool_args.named.get("query"),
2151 Some(&Value::String("search".to_string()))
2152 );
2153 assert_eq!(
2154 tool_args.named.get("output"),
2155 Some(&Value::String("out.txt".to_string()))
2156 );
2157 assert!(tool_args.positional.is_empty());
2158 }
2159
2160 #[test]
2161 fn test_builtin_schema_no_positional_mapping() {
2162 let schema = ToolSchema::new("echo", "")
2164 .param(ParamSchema::optional("args", "any", Value::Null, ""))
2165 .param(ParamSchema::optional("no_newline", "bool", Value::Bool(false), ""));
2166 let args = vec![
2168 Arg::Positional(Expr::Literal(Value::String("hello".to_string()))),
2169 Arg::Positional(Expr::Literal(Value::String("world".to_string()))),
2170 ];
2171 let ctx = make_minimal_ctx();
2172
2173 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).expect("build_tool_args");
2174
2175 assert_eq!(
2177 tool_args.positional,
2178 vec![
2179 Value::String("hello".to_string()),
2180 Value::String("world".to_string()),
2181 ]
2182 );
2183 assert!(!tool_args.named.contains_key("args"));
2184 }
2185
2186 #[test]
2187 fn test_short_flag_with_alias_consumes_value() {
2188 let schema = ToolSchema::new("head", "Output first part of files")
2191 .param(ParamSchema::optional("lines", "int", Value::Int(10), "Number of lines")
2192 .with_aliases(["-n"]));
2193 let args = vec![
2194 Arg::ShortFlag("n".to_string()),
2195 Arg::Positional(Expr::Literal(Value::Int(5))),
2196 Arg::Positional(Expr::Literal(Value::String("/tmp/file.txt".to_string()))),
2197 ];
2198 let ctx = make_minimal_ctx();
2199
2200 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).expect("build_tool_args");
2201
2202 assert!(tool_args.flags.is_empty(), "no boolean flags: {:?}", tool_args.flags);
2203 assert_eq!(tool_args.named.get("lines"), Some(&Value::Int(5)), "should resolve alias to canonical name");
2204 assert_eq!(tool_args.positional, vec![Value::String("/tmp/file.txt".to_string())]);
2205 }
2206
2207 #[tokio::test]
2210 async fn test_merge_stderr_redirect() {
2211 let result = ExecResult::from_output(0, "stdout content", "stderr content");
2213
2214 let redirects = vec![Redirect {
2215 kind: RedirectKind::MergeStderr,
2216 target: Expr::Literal(Value::Null),
2217 }];
2218
2219 let ctx = make_minimal_ctx();
2220 let result = apply_redirects(result, &redirects, &ctx).await;
2221
2222 assert_eq!(&*result.text_out(), "stdout contentstderr content");
2223 assert!(result.err.is_empty());
2224 }
2225
2226 #[tokio::test]
2227 async fn test_merge_stderr_with_empty_stderr() {
2228 let result = ExecResult::from_output(0, "stdout only", "");
2230
2231 let redirects = vec![Redirect {
2232 kind: RedirectKind::MergeStderr,
2233 target: Expr::Literal(Value::Null),
2234 }];
2235
2236 let ctx = make_minimal_ctx();
2237 let result = apply_redirects(result, &redirects, &ctx).await;
2238
2239 assert_eq!(&*result.text_out(), "stdout only");
2240 assert!(result.err.is_empty());
2241 }
2242
2243 #[tokio::test]
2244 async fn test_merge_stderr_order_matters() {
2245 let result = ExecResult::from_output(0, "stdout\n", "stderr\n");
2250
2251 let redirects = vec![Redirect {
2253 kind: RedirectKind::MergeStderr,
2254 target: Expr::Literal(Value::Null),
2255 }];
2256
2257 let ctx = make_minimal_ctx();
2258 let result = apply_redirects(result, &redirects, &ctx).await;
2259
2260 assert_eq!(&*result.text_out(), "stdout\nstderr\n");
2261 assert!(result.err.is_empty());
2262 }
2263
2264 #[tokio::test]
2265 async fn test_redirect_with_command_execution() {
2266 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
2267
2268 let cmd = Command {
2270 name: "echo".to_string(),
2271 args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
2272 redirects: vec![Redirect {
2273 kind: RedirectKind::MergeStderr,
2274 target: Expr::Literal(Value::Null),
2275 }],
2276 };
2277
2278 let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
2279 assert!(result.ok());
2280 assert!(result.text_out().contains("hello"));
2282 }
2283
2284 #[tokio::test]
2285 async fn test_merge_stderr_in_pipeline() {
2286 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
2287
2288 let echo_cmd = Command {
2291 name: "echo".to_string(),
2292 args: vec![Arg::Positional(Expr::Literal(Value::String("output".to_string())))],
2293 redirects: vec![Redirect {
2294 kind: RedirectKind::MergeStderr,
2295 target: Expr::Literal(Value::Null),
2296 }],
2297 };
2298 let grep_cmd = Command {
2299 name: "grep".to_string(),
2300 args: vec![Arg::Positional(Expr::Literal(Value::String("output".to_string())))],
2301 redirects: vec![],
2302 };
2303
2304 let result = runner.run(&[echo_cmd, grep_cmd], &mut ctx, &dispatcher).await;
2305 assert!(result.ok(), "result failed: code={}, err={}", result.code, result.err);
2306 assert!(result.text_out().contains("output"));
2307 }
2308}