1use std::sync::Arc;
9
10use std::collections::HashMap;
11
12use crate::arithmetic;
13use crate::ast::{Arg, Command, Expr, PipelineStage, Redirect, RedirectKind, Value};
14use crate::dispatch::{CommandDispatcher, PipelinePosition};
15use crate::interpreter::{apply_output_format, ExecResult, OutputFormat, PathError};
16use crate::tools::{global_flag_value_is_truthy, ExecContext, ToolArgs, ToolRegistry, ToolSchema};
17use tokio::io::AsyncWriteExt;
18
19use super::pipe_stream::pipe_stream_default;
20use super::scatter::{
21 parse_gather_options, parse_scatter_options, ScatterGatherRunner,
22};
23
24fn has_json_flag(args: &[Arg]) -> bool {
40 let mut past_double_dash = false;
41 for arg in args {
42 match arg {
43 Arg::DoubleDash => past_double_dash = true,
44 _ if past_double_dash => {}
45 Arg::LongFlag(name) if name == "json" => return true,
46 Arg::Named { key, value } if key == "json" => {
47 let on = match value {
48 Expr::Literal(v) => global_flag_value_is_truthy(v),
49 _ => true,
50 };
51 if on {
52 return true;
53 }
54 }
55 _ => {}
56 }
57 }
58 false
59}
60
61fn finalize_scatter_gather_error(result: ExecResult, format: Option<OutputFormat>) -> ExecResult {
71 match format {
72 Some(format) => apply_output_format(result, format),
73 None => result,
74 }
75}
76
77pub(crate) async fn apply_redirects(
83 mut result: ExecResult,
84 redirects: &[Redirect],
85 ctx: &ExecContext,
86 dispatcher: &dyn CommandDispatcher,
87) -> ExecResult {
88 for redir in redirects {
93 match redir.kind {
94 RedirectKind::MergeStderr => {
95 result.materialize();
98 if !result.err.is_empty() {
99 let err = std::mem::take(&mut result.err);
100 result.push_out(&err);
101 }
102 }
103 RedirectKind::MergeStdout => {
104 if result.is_bytes() {
108 return ExecResult::failure(
109 1,
110 "redirect: cannot merge binary stdout into stderr (1>&2) — \
111 redirect it to a file or pipe through base64/xxd",
112 );
113 }
114 result.materialize();
115 if !result.text_out().is_empty() {
116 let out = result.text_out().into_owned();
117 result.err.push_str(&out);
118 }
119 result.clear_stdout();
125 }
126 RedirectKind::StdoutOverwrite => {
127 let path = match eval_redirect_target(&redir.target, ctx, dispatcher).await {
128 Ok(p) => p,
129 Err(e) => return ExecResult::failure(1, format!("redirect: {e}")),
130 };
131 if let Some(bytes) = result.out_bytes() {
133 if let Err(e) = redirect_write(ctx, &path, bytes).await {
134 return ExecResult::failure(1, format!("redirect: {e}"));
135 }
136 } else if let Some(output) = result.take_output_for_stream() {
137 let mut buf = Vec::new();
139 if let Err(e) = output.write_canonical(&mut buf, None) {
140 return ExecResult::failure(1, format!("redirect: {e}"));
141 }
142 if let Err(e) = redirect_write(ctx, &path, &buf).await {
143 return ExecResult::failure(1, format!("redirect: {e}"));
144 }
145 } else if let Err(e) = redirect_write(ctx, &path, result.text_out().as_bytes()).await {
146 return ExecResult::failure(1, format!("redirect: {e}"));
147 }
148 result.clear_stdout();
150 }
151 RedirectKind::StdoutAppend => {
152 let path = match eval_redirect_target(&redir.target, ctx, dispatcher).await {
153 Ok(p) => p,
154 Err(e) => return ExecResult::failure(1, format!("redirect: {e}")),
155 };
156 if let Some(bytes) = result.out_bytes() {
158 if let Err(e) = redirect_append(ctx, &path, bytes).await {
159 return ExecResult::failure(1, format!("redirect: {e}"));
160 }
161 } else if let Some(output) = result.take_output_for_stream() {
162 let mut buf = Vec::new();
164 if let Err(e) = output.write_canonical(&mut buf, None) {
165 return ExecResult::failure(1, format!("redirect: {e}"));
166 }
167 if let Err(e) = redirect_append(ctx, &path, &buf).await {
168 return ExecResult::failure(1, format!("redirect: {e}"));
169 }
170 } else if let Err(e) = redirect_append(ctx, &path, result.text_out().as_bytes()).await {
171 return ExecResult::failure(1, format!("redirect: {e}"));
172 }
173 result.clear_stdout();
175 }
176 RedirectKind::Stderr => {
177 let path = match eval_redirect_target(&redir.target, ctx, dispatcher).await {
178 Ok(p) => p,
179 Err(e) => return ExecResult::failure(1, format!("redirect: {e}")),
180 };
181 if let Err(e) = redirect_write(ctx, &path, result.err.as_bytes()).await {
182 return ExecResult::failure(1, format!("redirect: {e}"));
183 }
184 result.err.clear();
185 }
186 RedirectKind::Both => {
187 let path = match eval_redirect_target(&redir.target, ctx, dispatcher).await {
188 Ok(p) => p,
189 Err(e) => return ExecResult::failure(1, format!("redirect: {e}")),
190 };
191 let mut combined: Vec<u8> = if let Some(b) = result.out_bytes() {
199 b.to_vec()
200 } else if let Some(output) = result.take_output_for_stream() {
201 let mut buf = Vec::new();
202 if let Err(e) = output.write_canonical(&mut buf, None) {
203 return ExecResult::failure(1, format!("redirect: {e}"));
204 }
205 buf
206 } else {
207 result.text_out().into_owned().into_bytes()
208 };
209 combined.extend_from_slice(result.err.as_bytes());
210 if let Err(e) = redirect_write(ctx, &path, &combined).await {
211 return ExecResult::failure(1, format!("redirect: {e}"));
212 }
213 result.clear_stdout();
215 result.err.clear();
216 }
217 RedirectKind::Stdin | RedirectKind::HereDoc(_) | RedirectKind::HereString => {}
219 }
220 }
221 result
230}
231
232async fn eval_redirect_target(
244 expr: &Expr,
245 ctx: &ExecContext,
246 dispatcher: &dyn CommandDispatcher,
247) -> Result<String, String> {
248 let value = dispatcher
249 .eval_expr(expr, ctx)
250 .await
251 .map_err(|e| e.to_string())?;
252 if let Some(msg) = crate::interpreter::structured_boundary_error("a redirect target", &value) {
255 return Err(msg);
256 }
257 crate::interpreter::value_to_text_sink_named(&value, "a redirect target").map_err(|e| e.to_string())
260}
261
262async fn redirect_write(ctx: &ExecContext, path: &str, data: &[u8]) -> Result<(), String> {
269 use crate::backend::WriteMode;
270 let resolved = ctx.resolve_path(path);
271 ctx.backend.write(&resolved, data, WriteMode::Overwrite).await.map_err(|e| e.to_string())
272}
273
274async fn redirect_append(ctx: &ExecContext, path: &str, data: &[u8]) -> Result<(), String> {
278 let resolved = ctx.resolve_path(path);
279 ctx.backend.append(&resolved, data).await.map_err(|e| e.to_string())
280}
281
282async fn setup_stdin_redirects(
295 cmd: &Command,
296 ctx: &mut ExecContext,
297 dispatcher: &dyn CommandDispatcher,
298) -> Result<(), String> {
299 use std::path::Path;
300 for redir in &cmd.redirects {
301 match &redir.kind {
302 RedirectKind::Stdin => {
303 let path = eval_redirect_target(&redir.target, ctx, dispatcher).await?;
304 let resolved = ctx.resolve_path(&path);
305 let data = ctx
306 .backend
307 .read(Path::new(&resolved), None)
308 .await
309 .map_err(|e| format!("redirect: {path}: {e}"))?;
310 ctx.set_stdin(data);
311 }
312 RedirectKind::HereDoc(_) => {
313 match &redir.target {
314 Expr::Literal(Value::String(content)) => {
315 ctx.set_stdin(content.clone());
316 }
317 expr => {
320 let body = eval_redirect_target(expr, ctx, dispatcher).await?;
321 ctx.set_stdin(body);
322 }
323 }
324 }
325 RedirectKind::HereString => {
326 let mut s = eval_redirect_target(&redir.target, ctx, dispatcher).await?;
329 s.push('\n');
330 ctx.set_stdin(s);
331 }
332 _ => {}
333 }
334 }
335 Ok(())
336}
337
338async fn setup_stdin_redirects_for(
342 stage: &PipelineStage,
343 ctx: &mut ExecContext,
344 dispatcher: &dyn CommandDispatcher,
345) -> Result<(), String> {
346 match stage {
347 PipelineStage::Command(cmd) => setup_stdin_redirects(cmd, ctx, dispatcher).await,
348 PipelineStage::Compound(_) => Ok(()),
349 }
350}
351
352async fn dispatch_stage(
354 stage: &PipelineStage,
355 ctx: &mut ExecContext,
356 dispatcher: &dyn CommandDispatcher,
357) -> anyhow::Result<ExecResult> {
358 match stage {
359 PipelineStage::Command(cmd) => dispatcher.dispatch(cmd, ctx).await,
360 PipelineStage::Compound(stmt) => dispatcher.dispatch_stmt(stmt, ctx).await,
361 }
362}
363
364#[derive(Clone)]
366pub struct PipelineRunner {
367 tools: Arc<ToolRegistry>,
368}
369
370impl PipelineRunner {
371 pub fn new(tools: Arc<ToolRegistry>) -> Self {
373 Self { tools }
374 }
375
376 pub async fn run(
386 &self,
387 stages: &[PipelineStage],
388 ctx: &mut ExecContext,
389 dispatcher: &dyn CommandDispatcher,
390 ) -> ExecResult {
391 if stages.is_empty() {
392 return ExecResult::success("");
393 }
394
395 if let Some((scatter_idx, gather_idx)) = find_scatter_gather(stages) {
400 let commands: Vec<Command> = match stages
401 .iter()
402 .map(|s| s.as_command().cloned())
403 .collect::<Option<Vec<_>>>()
404 {
405 Some(commands) => commands,
406 None => {
407 return ExecResult::failure(
408 2,
409 "scatter/gather cannot share a pipeline with an if/for/while/case \
410 stage. Run the compound on its own and pipe its output in.",
411 )
412 }
413 };
414 return self
415 .run_scatter_gather(&commands, scatter_idx, gather_idx, ctx, dispatcher)
416 .await;
417 }
418
419 self.run_stage_sequence(stages, ctx, dispatcher).await
420 }
421
422 pub async fn run_sequential(
427 &self,
428 commands: &[Command],
429 ctx: &mut ExecContext,
430 dispatcher: &dyn CommandDispatcher,
431 ) -> ExecResult {
432 let stages: Vec<PipelineStage> = commands
433 .iter()
434 .cloned()
435 .map(PipelineStage::Command)
436 .collect();
437 self.run_stage_sequence(&stages, ctx, dispatcher).await
438 }
439
440 async fn run_stage_sequence(
442 &self,
443 stages: &[PipelineStage],
444 ctx: &mut ExecContext,
445 dispatcher: &dyn CommandDispatcher,
446 ) -> ExecResult {
447 if stages.is_empty() {
448 return ExecResult::success("");
449 }
450
451 if stages.len() == 1 {
452 let result = self.run_single(&stages[0], ctx, None, dispatcher).await;
454 ctx.scope.set_pipestatus(&[result.code]);
459 return result;
460 }
461
462 self.run_pipeline(stages, ctx, dispatcher).await
464 }
465
466 async fn run_scatter_gather(
468 &self,
469 commands: &[Command],
470 scatter_idx: usize,
471 gather_idx: usize,
472 ctx: &mut ExecContext,
473 dispatcher: &dyn CommandDispatcher,
474 ) -> ExecResult {
475 let pre_scatter = &commands[..scatter_idx];
477 let scatter_cmd = &commands[scatter_idx];
478 let parallel = &commands[scatter_idx + 1..gather_idx];
479 let gather_cmd = &commands[gather_idx];
480 let post_gather = &commands[gather_idx + 1..];
481
482 let format = (has_json_flag(&scatter_cmd.args) || has_json_flag(&gather_cmd.args))
493 .then_some(OutputFormat::Json);
494
495 let scatter_schema = self.tools.get("scatter").map(|t| t.schema());
503 let gather_schema = self.tools.get("gather").map(|t| t.schema());
504 let scatter_args = match build_tool_args(&scatter_cmd.args, ctx, scatter_schema.as_ref()).await {
505 Ok(args) => args,
506 Err(e) => {
507 return finalize_scatter_gather_error(
508 ExecResult::failure(1, format!("scatter: {e}")),
509 format,
510 )
511 }
512 };
513 let gather_args = match build_tool_args(&gather_cmd.args, ctx, gather_schema.as_ref()).await {
514 Ok(args) => args,
515 Err(e) => {
516 return finalize_scatter_gather_error(
517 ExecResult::failure(1, format!("gather: {e}")),
518 format,
519 )
520 }
521 };
522 let scatter_opts = match parse_scatter_options(&scatter_args) {
523 Ok(opts) => opts,
524 Err(e) => {
525 return finalize_scatter_gather_error(
526 ExecResult::failure(2, format!("scatter: {e}")),
527 format,
528 )
529 }
530 };
531 let gather_opts = match parse_gather_options(&gather_args) {
532 Ok(opts) => opts,
533 Err(e) => {
534 return finalize_scatter_gather_error(
535 ExecResult::failure(2, format!("gather: {e}")),
536 format,
537 )
538 }
539 };
540
541 let sequential_dispatcher: Arc<dyn CommandDispatcher> = dispatcher.fork_attached().await;
546
547 let runner = ScatterGatherRunner::new(self.tools.clone(), sequential_dispatcher);
548 runner
549 .run(
550 pre_scatter,
551 scatter_opts,
552 parallel,
553 gather_opts,
554 &gather_cmd.redirects,
555 post_gather,
556 ctx,
557 )
558 .await
559 }
560
561 async fn run_single(
566 &self,
567 stage: &PipelineStage,
568 ctx: &mut ExecContext,
569 stdin: Option<Vec<u8>>,
570 dispatcher: &dyn CommandDispatcher,
571 ) -> ExecResult {
572 if let Err(e) = setup_stdin_redirects_for(stage, ctx, dispatcher).await {
574 return ExecResult::failure(1, e);
575 }
576
577 if let Some(input) = stdin {
579 ctx.set_stdin(input);
580 }
581
582 ctx.pipeline_position = PipelinePosition::Only;
584
585 let result = match dispatch_stage(stage, ctx, dispatcher).await {
587 Ok(result) => result,
588 Err(e) => ExecResult::failure(1, e.to_string()),
589 };
590
591 apply_redirects(result, stage.redirects(), ctx, dispatcher).await
593 }
594
595 async fn run_pipeline(
612 &self,
613 stages: &[PipelineStage],
614 ctx: &mut ExecContext,
615 dispatcher: &dyn CommandDispatcher,
616 ) -> ExecResult {
617 let stage_count = stages.len();
618 let last_idx = stage_count - 1;
619
620 let mut pipe_writers: Vec<Option<super::pipe_stream::PipeWriter>> = Vec::new();
622 let mut pipe_readers: Vec<Option<super::pipe_stream::PipeReader>> = Vec::new();
623
624 for _ in 0..last_idx {
625 let (writer, reader) = pipe_stream_default();
626 pipe_writers.push(Some(writer));
627 pipe_readers.push(Some(reader));
628 }
629
630 let mut data_senders: Vec<Option<tokio::sync::oneshot::Sender<Option<Value>>>> = Vec::new();
632 let mut data_receivers: Vec<Option<tokio::sync::oneshot::Receiver<Option<Value>>>> = Vec::new();
633
634 for _ in 0..last_idx {
635 let (tx, rx) = tokio::sync::oneshot::channel();
636 data_senders.push(Some(tx));
637 data_receivers.push(Some(rx));
638 }
639
640 let mut handles: Vec<tokio::task::JoinHandle<(ExecResult, ExecContext)>> = Vec::with_capacity(stage_count);
641 let mut stage0_took_session_stdin = false;
644 let mut stage0_took_session_pipe_stdin = false;
650
651 for (i, stage) in stages.iter().enumerate() {
652 let mut stage_ctx = ctx.child_for_pipeline();
653 let stage = stage.clone();
654
655 let task_dispatcher: Arc<dyn CommandDispatcher> = dispatcher.fork_attached().await;
660
661 let stdin_setup = setup_stdin_redirects_for(&stage, &mut stage_ctx, dispatcher).await;
665
666 if i == 0 {
668 let redirect_set_stdin = stage_ctx.stdin.is_some();
675 stage0_took_session_stdin = !redirect_set_stdin;
676 if !redirect_set_stdin {
679 stage_ctx.stdin = ctx.stdin.take();
680 }
681 if stage_ctx.stdin_data.is_none() {
682 stage_ctx.stdin_data = ctx.stdin_data.take();
683 }
684 if !redirect_set_stdin && stage_ctx.pipe_stdin.is_none() {
693 stage_ctx.pipe_stdin = ctx.pipe_stdin.take();
694 stage0_took_session_pipe_stdin = true;
695 }
696 } else {
697 stage_ctx.pipe_stdin = pipe_readers[i - 1].take();
699 }
701
702 if i < last_idx {
704 stage_ctx.pipe_stdout = pipe_writers[i].take();
705 }
706
707 stage_ctx.pipeline_position = match i {
709 0 => PipelinePosition::First,
710 n if n == last_idx => PipelinePosition::Last,
711 _ => PipelinePosition::Middle,
712 };
713
714 let data_sender = if i < last_idx { data_senders[i].take() } else { None };
715 let data_receiver = if i > 0 { data_receivers[i - 1].take() } else { None };
716
717 let handle: tokio::task::JoinHandle<(ExecResult, ExecContext)> =
720 tokio::spawn(crate::telemetry::bind_current_context(async move {
721 if let Err(e) = stdin_setup {
723 return (ExecResult::failure(1, e), stage_ctx);
724 }
725
726 stage_ctx.stdin_data_rx = data_receiver;
734
735 let mut result = match dispatch_stage(&stage, &mut stage_ctx, &*task_dispatcher).await {
737 Ok(result) => result,
738 Err(e) => ExecResult::failure(1, e.to_string()),
739 };
740
741 result = apply_redirects(result, stage.redirects(), &stage_ctx, &*task_dispatcher).await;
746
747 if !result.err.is_empty() {
753 if let Some(ref stderr) = stage_ctx.stderr {
754 stderr.write_str(&result.err);
755 result.err.clear();
756 }
757 }
758
759 if let Some(tx) = data_sender {
765 let _ = tx.send(result.data.clone());
766 }
767
768 if let Some(mut pipe_out) = stage_ctx.pipe_stdout.take() {
771 let bytes: Vec<u8> = if let Some(b) = result.out_bytes() {
779 b.to_vec()
780 } else if let Some(output) = result.take_output_for_stream() {
781 let mut buf = Vec::new();
782 if output.write_canonical(&mut buf, None).is_err() {
788 buf = output.to_canonical_string().into_bytes();
789 }
790 buf
791 } else {
792 result.text_out().into_owned().into_bytes()
793 };
794 if !bytes.is_empty() {
795 let _ = pipe_out.write_all(&bytes).await;
797 let _ = pipe_out.shutdown().await;
798 }
799 }
801
802 (result, stage_ctx)
803 }));
804
805 handles.push(handle);
806 }
807
808 let mut last_result = ExecResult::success("");
813 let mut panics: Vec<String> = Vec::new();
814 let mut codes: Vec<i64> = vec![1; handles.len()];
819
820 for (i, handle) in handles.into_iter().enumerate() {
821 match handle.await {
822 Ok((result, mut stage_ctx)) => {
823 codes[i] = result.code;
824 if i == 0 && stage0_took_session_stdin {
835 ctx.stdin = stage_ctx.stdin.take();
836 }
837 if i == 0 && stage0_took_session_pipe_stdin {
838 ctx.pipe_stdin = stage_ctx.pipe_stdin.take();
839 }
840 if i == last_idx {
841 last_result = result;
842 ctx.scope = stage_ctx.scope;
844 ctx.cwd = stage_ctx.cwd;
845 ctx.prev_cwd = stage_ctx.prev_cwd;
846 ctx.aliases = stage_ctx.aliases;
847 }
848 }
849 Err(e) => {
850 panics.push(format!("stage {}: {}", i, e));
851 }
852 }
853 }
854
855
856 ctx.scope.set_pipestatus(&codes);
859
860 if !panics.is_empty() {
863 last_result = ExecResult::failure(
864 1,
865 format!("pipeline stage(s) panicked: {}", panics.join("; ")),
866 );
867 }
868
869 last_result
870 }
871}
872
873pub fn select_leaf<'a>(schema: &'a ToolSchema, args: &[Arg]) -> anyhow::Result<&'a ToolSchema> {
913 let root_lookup = schema_param_lookup(schema);
916 let is_root_value_flag = |name: &str| -> bool {
917 root_lookup.get(name).is_some_and(|(_, typ, ..)| !is_bool_type(typ))
918 };
919
920 let mut node = schema;
921 let mut skip_next_positional = false;
922 for arg in args {
923 match arg {
924 Arg::DoubleDash => break,
926 Arg::LongFlag(name) if is_root_value_flag(name) => skip_next_positional = true,
929 Arg::ShortFlag(name) if is_root_value_flag(name) => skip_next_positional = true,
930 Arg::Positional(expr) => {
931 if skip_next_positional {
932 skip_next_positional = false;
933 continue; }
935 if node.subcommands.is_empty() {
936 break; }
938 match classify_subcommand_positional(expr) {
939 SubcommandWord::Word(word) => {
940 match node.subcommands.iter().find(|c| c.matches_command(word)) {
941 Some(child) => node = child, None => break, }
944 }
945 SubcommandWord::OtherLiteral => break,
949 SubcommandWord::Computed(kind) => anyhow::bail!(
950 "{}: a subcommand name is required here, but got {kind}. \
951 Subcommands must be literal words — spell it out \
952 (e.g. `{} <subcommand> …`) or use the `--flag=value` form.",
953 node.name,
954 schema.name
955 ),
956 }
957 }
958 _ => {}
960 }
961 }
962 Ok(node)
963}
964
965enum SubcommandWord<'a> {
967 Word(&'a str),
969 OtherLiteral,
971 Computed(&'static str),
973}
974
975fn classify_subcommand_positional(expr: &Expr) -> SubcommandWord<'_> {
976 match expr {
977 Expr::Literal(Value::String(s)) => SubcommandWord::Word(s),
978 Expr::Literal(_) => SubcommandWord::OtherLiteral,
979 Expr::CommandSubst(_) | Expr::Command(_) => SubcommandWord::Computed("a command substitution `$(…)`"),
980 Expr::VarRef(_)
981 | Expr::VarWithDefault { .. }
982 | Expr::VarLength(_)
983 | Expr::Positional(_)
984 | Expr::AllArgs
985 | Expr::ArgCount
986 | Expr::CurrentPid
987 | Expr::LastExitCode => SubcommandWord::Computed("a variable reference"),
988 Expr::Interpolated(_) | Expr::HereDocBody { .. } => SubcommandWord::Computed("an interpolated string"),
989 Expr::GlobPattern(_) => SubcommandWord::Computed("a glob pattern"),
990 Expr::Arithmetic(_) => SubcommandWord::Computed("an arithmetic expansion"),
991 _ => SubcommandWord::Computed("a value computed at runtime"),
992 }
993}
994
995pub fn schema_param_lookup(schema: &ToolSchema) -> HashMap<String, (&str, &str, usize, bool)> {
996 let mut map = HashMap::new();
997 for p in schema.params.iter().filter(|p| !p.positional) {
998 map.insert(p.name.clone(), (p.name.as_str(), p.param_type.as_str(), p.consumes, p.repeatable));
999 for alias in &p.aliases {
1000 let stripped = alias.trim_start_matches('-');
1001 map.insert(stripped.to_string(), (p.name.as_str(), p.param_type.as_str(), p.consumes, p.repeatable));
1002 }
1003 }
1004 map
1005}
1006
1007pub fn is_bool_type(param_type: &str) -> bool {
1009 matches!(param_type.to_lowercase().as_str(), "bool" | "boolean")
1010}
1011
1012struct SyncEvalSource<'a> {
1020 ctx: &'a ExecContext,
1021}
1022
1023#[async_trait::async_trait]
1024impl crate::kernel::ArgValueSource for SyncEvalSource<'_> {
1025 async fn eval(&self, expr: &Expr) -> anyhow::Result<Option<Value>> {
1026 eval_simple_expr(expr, self.ctx).map_err(|e| anyhow::anyhow!(e))
1027 }
1028
1029 async fn expand_glob(&self, _pattern: &str) -> anyhow::Result<Option<Vec<String>>> {
1030 Ok(None)
1036 }
1037
1038 async fn home(&self) -> Option<String> {
1039 None
1042 }
1043}
1044
1045pub async fn build_tool_args(
1059 args: &[Arg],
1060 ctx: &ExecContext,
1061 schema: Option<&ToolSchema>,
1062) -> Result<ToolArgs, String> {
1063 crate::kernel::bind_tool_args(args, schema, &SyncEvalSource { ctx })
1064 .await
1065 .map_err(|e| e.to_string())
1066}
1067
1068pub(crate) fn eval_simple_expr(expr: &Expr, ctx: &ExecContext) -> Result<Option<Value>, String> {
1084 match expr {
1085 Expr::Literal(value) => Ok(Some(eval_literal(value, ctx))),
1086 Expr::VarRef(path) => match ctx.scope.resolve_path(path) {
1087 Ok(v) => Ok(Some(v)),
1088 Err(PathError::UndefinedRoot(_)) if path.segments.len() <= 1 => Ok(None),
1095 Err(PathError::UndefinedRoot(_)) => Err(format!(
1096 "{}: undefined variable",
1097 crate::interpreter::format_path(path)
1098 )),
1099 Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => Err(msg),
1102 },
1103 Expr::Interpolated(parts) => Ok(Some(Value::String(eval_string_parts_sync(parts, ctx)?))),
1104 Expr::VarLength(path) => {
1109 crate::interpreter::resolve_length(&ctx.scope, path).map(|n| Some(Value::Int(n)))
1110 }
1111 Expr::VarWithDefault { path, default } => {
1112 match crate::interpreter::resolve_default(&ctx.scope, path)? {
1113 Some(value) => Ok(Some(value)),
1114 None => Ok(Some(Value::String(eval_string_parts_sync(default, ctx)?))),
1115 }
1116 }
1117 Expr::GlobPattern(s) => Ok(Some(Value::String(s.clone()))),
1118 Expr::Arithmetic(expr_str) => arithmetic::eval_arithmetic(expr_str, &ctx.scope)
1126 .map(|n| Some(Value::Int(n)))
1127 .map_err(|e| format!("arithmetic error: {e}")),
1128 Expr::HereDocBody { parts, strip_tabs } => {
1129 let mut asm = crate::interpreter::HeredocAssembler::new(*strip_tabs);
1133 for sp in parts {
1134 match &sp.part {
1135 crate::ast::StringPart::Literal(s) => asm.push_literal(s),
1136 other => {
1137 let s = eval_string_parts_sync(std::slice::from_ref(other), ctx)?;
1138 asm.push_interpolated(&s);
1139 }
1140 }
1141 }
1142 Ok(Some(Value::String(asm.into_string())))
1143 }
1144 Expr::CommandSubst(_) | Expr::Command(_) => Err(
1151 "command substitution `$(...)` is not supported in a scatter/gather flag value here; \
1152 assign it to a variable first (e.g. `n=$(...); scatter --limit $n`)"
1153 .to_string(),
1154 ),
1155 _ => Ok(None), }
1157}
1158
1159fn eval_literal(value: &Value, _ctx: &ExecContext) -> Value {
1161 value.clone()
1162}
1163
1164fn eval_string_parts_sync(parts: &[crate::ast::StringPart], ctx: &ExecContext) -> Result<String, String> {
1172 let mut result = String::new();
1173 for part in parts {
1174 match part {
1175 crate::ast::StringPart::Literal(s) => result.push_str(s),
1176 crate::ast::StringPart::Var(path) => match ctx.scope.resolve_path(path) {
1177 Ok(value) => result.push_str(
1181 &crate::interpreter::value_to_text_sink(&value).map_err(|e| e.to_string())?,
1182 ),
1183 Err(PathError::UndefinedRoot(_)) => {}
1191 Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => return Err(msg),
1192 },
1193 crate::ast::StringPart::VarWithDefault { path, default } => {
1194 match crate::interpreter::resolve_default(&ctx.scope, path)? {
1195 Some(value) => result.push_str(
1196 &crate::interpreter::value_to_text_sink(&value).map_err(|e| e.to_string())?,
1197 ),
1198 None => result.push_str(&eval_string_parts_sync(default, ctx)?),
1199 }
1200 }
1201 crate::ast::StringPart::VarLength(path) => {
1202 let len = crate::interpreter::resolve_length(&ctx.scope, path)?;
1207 result.push_str(&len.to_string());
1208 }
1209 crate::ast::StringPart::Positional(n) => {
1210 if let Some(s) = ctx.scope.get_positional(*n) {
1211 result.push_str(s);
1212 }
1213 }
1214 crate::ast::StringPart::AllArgs => {
1215 result.push_str(&ctx.scope.all_args().join(" "));
1216 }
1217 crate::ast::StringPart::ArgCount => {
1218 result.push_str(&ctx.scope.arg_count().to_string());
1219 }
1220 crate::ast::StringPart::Arithmetic(expr) => {
1221 let value = arithmetic::eval_arithmetic(expr, &ctx.scope)
1229 .map_err(|e| format!("arithmetic error: {e}"))?;
1230 result.push_str(&value.to_string());
1231 }
1232 crate::ast::StringPart::CommandSubst(_) => {
1233 return Err(
1239 "command substitution `$(...)` is not supported inside a scatter/gather \
1240 flag's interpolated value here; assign it to a variable first"
1241 .to_string(),
1242 );
1243 }
1244 crate::ast::StringPart::LastExitCode => {
1245 result.push_str(&ctx.scope.last_result().code.to_string());
1246 }
1247 crate::ast::StringPart::CurrentPid => {
1248 result.push_str(&ctx.scope.pid().to_string());
1249 }
1250 }
1251 }
1252 Ok(result)
1253}
1254
1255fn find_scatter_gather(stages: &[PipelineStage]) -> Option<(usize, usize)> {
1260 let named = |name: &str| {
1261 stages
1262 .iter()
1263 .position(|s| s.as_command().is_some_and(|c| c.name == name))
1264 };
1265 let scatter_idx = named("scatter")?;
1266 let gather_idx = named("gather")?;
1267
1268 if gather_idx > scatter_idx {
1270 Some((scatter_idx, gather_idx))
1271 } else {
1272 None
1273 }
1274}
1275
1276#[cfg(test)]
1277mod select_leaf_tests {
1278 use super::*;
1279 use crate::tools::ParamSchema;
1280
1281 fn kj_schema() -> ToolSchema {
1286 ToolSchema::new("kj", "kaijutsu")
1287 .param(ParamSchema::new("confirm", "string"))
1288 .param(ParamSchema::new("verbose", "bool"))
1289 .subcommand(
1290 ToolSchema::new("context", "context ops")
1291 .with_command_aliases(["ctx"])
1292 .subcommand(ToolSchema::new("list", "list").with_command_aliases(["ls"]))
1293 .subcommand(
1294 ToolSchema::new("create", "create").param(
1295 ParamSchema::new("type", "string").with_aliases(["t"]),
1296 ),
1297 ),
1298 )
1299 }
1300
1301 fn word(s: &str) -> Arg {
1302 Arg::Positional(Expr::Literal(Value::String(s.to_string())))
1303 }
1304
1305 #[test]
1306 fn flat_tool_returns_root() {
1307 let schema = ToolSchema::new("cat", "concat")
1308 .param(ParamSchema::required("path", "string", "f").positional());
1309 let leaf = select_leaf(&schema, &[word("foo.txt")]).expect("flat ok");
1310 assert_eq!(leaf.name, "cat");
1311 }
1312
1313 #[test]
1314 fn single_hop() {
1315 let schema = kj_schema();
1316 let leaf = select_leaf(&schema, &[word("context")]).expect("ok");
1317 assert_eq!(leaf.name, "context");
1318 }
1319
1320 #[test]
1321 fn two_hops() {
1322 let schema = kj_schema();
1323 let leaf = select_leaf(&schema, &[word("context"), word("create")]).expect("ok");
1324 assert_eq!(leaf.name, "create");
1325 assert!(leaf.params.iter().any(|p| p.name == "type"), "leaf has --type");
1326 }
1327
1328 #[test]
1329 fn alias_hops_route() {
1330 let schema = kj_schema();
1331 let leaf = select_leaf(&schema, &[word("ctx"), word("ls")]).expect("ok");
1333 assert_eq!(leaf.name, "list");
1334 }
1335
1336 #[test]
1337 fn unknown_subcommand_stops_at_current_node() {
1338 let schema = kj_schema();
1339 let leaf = select_leaf(&schema, &[word("context"), word("nonesuch")]).expect("ok");
1342 assert_eq!(leaf.name, "context");
1343 }
1344
1345 #[test]
1346 fn root_bool_flag_before_path_does_not_disrupt_routing() {
1347 let schema = kj_schema();
1348 let args = vec![Arg::LongFlag("verbose".into()), word("context"), word("create")];
1351 let leaf = select_leaf(&schema, &args).expect("ok");
1352 assert_eq!(leaf.name, "create");
1353 }
1354
1355 #[test]
1356 fn root_value_flag_space_form_before_path_skips_its_value() {
1357 let schema = kj_schema();
1358 let args = vec![
1361 Arg::LongFlag("confirm".into()),
1362 word("token"),
1363 word("context"),
1364 word("create"),
1365 ];
1366 let leaf = select_leaf(&schema, &args).expect("ok");
1367 assert_eq!(leaf.name, "create");
1368 }
1369
1370 #[test]
1371 fn leaf_value_flag_after_path_routes_to_leaf() {
1372 let schema = kj_schema();
1373 let args = vec![
1376 word("context"),
1377 word("create"),
1378 Arg::LongFlag("type".into()),
1379 word("x"),
1380 ];
1381 let leaf = select_leaf(&schema, &args).expect("ok");
1382 assert_eq!(leaf.name, "create");
1383 assert!(leaf.params.iter().any(|p| p.name == "type"));
1384 }
1385
1386 #[test]
1387 fn double_dash_stops_routing() {
1388 let schema = kj_schema();
1389 let leaf = select_leaf(&schema, &[Arg::DoubleDash, word("context")]).expect("ok");
1391 assert_eq!(leaf.name, "kj");
1392 }
1393
1394 #[test]
1395 fn computed_subcommand_selector_errors() {
1396 let schema = kj_schema();
1397 let args = vec![Arg::Positional(Expr::CommandSubst(vec![
1400 crate::ast::Stmt::Command(crate::ast::Command {
1401 name: "echo".into(),
1402 args: vec![],
1403 redirects: vec![],
1404 }),
1405 ]))];
1406 let err = select_leaf(&schema, &args).expect_err("must error");
1407 let msg = err.to_string();
1408 assert!(msg.contains("subcommand name is required"), "got: {msg}");
1409 assert!(msg.contains("command substitution"), "names the cause: {msg}");
1410 }
1411
1412 #[test]
1413 fn variable_subcommand_selector_errors() {
1414 let schema = kj_schema();
1415 let args = vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("sub")))];
1416 let err = select_leaf(&schema, &args).expect_err("must error");
1417 assert!(err.to_string().contains("variable reference"), "got: {err}");
1418 }
1419
1420 #[test]
1421 fn computed_positional_after_leaf_is_fine() {
1422 let schema = kj_schema();
1423 let args = vec![
1426 word("context"),
1427 word("list"),
1428 Arg::Positional(Expr::CommandSubst(vec![crate::ast::Stmt::Command(
1429 crate::ast::Command { name: "echo".into(), args: vec![], redirects: vec![] },
1430 )])),
1431 ];
1432 let leaf = select_leaf(&schema, &args).expect("ok");
1433 assert_eq!(leaf.name, "list");
1434 }
1435}
1436
1437#[cfg(test)]
1438mod tests {
1439 use super::*;
1440 use crate::dispatch::BackendDispatcher;
1441 use crate::tools::register_builtins;
1442 use crate::vfs::{Filesystem, MemoryFs, VfsRouter};
1443 use std::path::Path;
1444
1445 async fn make_runner_and_ctx() -> (PipelineRunner, ExecContext, BackendDispatcher) {
1446 let mut tools = ToolRegistry::new();
1447 register_builtins(&mut tools);
1448 let tools = Arc::new(tools);
1449 let runner = PipelineRunner::new(tools.clone());
1450 let dispatcher = BackendDispatcher::new(tools.clone());
1451
1452 let mut vfs = VfsRouter::new();
1453 let mem = MemoryFs::new();
1454 mem.write(Path::new("test.txt"), b"hello\nworld\nfoo").await.unwrap();
1455 vfs.mount("/", mem);
1456 let ctx = ExecContext::with_vfs_and_tools(Arc::new(vfs), tools);
1457
1458 (runner, ctx, dispatcher)
1459 }
1460
1461 fn stages(commands: impl IntoIterator<Item = Command>) -> Vec<PipelineStage> {
1463 commands.into_iter().map(PipelineStage::Command).collect()
1464 }
1465
1466 fn make_cmd(name: &str, args: Vec<&str>) -> Command {
1467 Command {
1468 name: name.to_string(),
1469 args: args.iter().map(|s| Arg::Positional(Expr::Literal(Value::String(s.to_string())))).collect(),
1470 redirects: vec![],
1471 }
1472 }
1473
1474 #[tokio::test]
1475 async fn test_single_command() {
1476 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1477 let cmd = make_cmd("echo", vec!["hello"]);
1478
1479 let result = runner.run(&stages([cmd]), &mut ctx, &dispatcher).await;
1480 assert!(result.ok());
1481 assert_eq!(result.text_out().trim(), "hello");
1482 }
1483
1484 #[tokio::test]
1485 async fn test_pipeline_echo_grep() {
1486 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1487
1488 let echo_cmd = Command {
1490 name: "echo".to_string(),
1491 args: vec![Arg::Positional(Expr::Literal(Value::String("hello\nworld".to_string())))],
1492 redirects: vec![],
1493 };
1494 let grep_cmd = Command {
1495 name: "grep".to_string(),
1496 args: vec![Arg::Positional(Expr::Literal(Value::String("world".to_string())))],
1497 redirects: vec![],
1498 };
1499
1500 let result = runner.run(&stages([echo_cmd, grep_cmd]), &mut ctx, &dispatcher).await;
1501 assert!(result.ok());
1502 assert_eq!(result.text_out().trim(), "world");
1503 }
1504
1505 #[tokio::test]
1506 async fn test_pipeline_cat_grep() {
1507 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1508
1509 let cat_cmd = make_cmd("cat", vec!["/test.txt"]);
1511 let grep_cmd = Command {
1512 name: "grep".to_string(),
1513 args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
1514 redirects: vec![],
1515 };
1516
1517 let result = runner.run(&stages([cat_cmd, grep_cmd]), &mut ctx, &dispatcher).await;
1518 assert!(result.ok());
1519 assert!(result.text_out().contains("hello"));
1520 }
1521
1522 #[tokio::test]
1523 async fn test_command_not_found() {
1524 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1525 let cmd = make_cmd("nonexistent", vec![]);
1526
1527 let result = runner.run(&stages([cmd]), &mut ctx, &dispatcher).await;
1528 assert!(!result.ok());
1529 assert_eq!(result.code, 127);
1530 #[cfg(feature = "subprocess")]
1535 assert!(
1536 result.err.contains("not found"),
1537 "expected a not-found report, got {:?}",
1538 result.err
1539 );
1540 #[cfg(not(feature = "subprocess"))]
1541 assert!(
1542 result.err.contains("external commands are"),
1543 "expected the unavailable-externals report, got {:?}",
1544 result.err
1545 );
1546 }
1547
1548 #[tokio::test]
1549 async fn test_pipeline_continues_on_failure() {
1550 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1553
1554 let cat_cmd = make_cmd("cat", vec!["/nonexistent"]);
1557 let grep_cmd = Command {
1558 name: "grep".to_string(),
1559 args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
1560 redirects: vec![],
1561 };
1562
1563 let result = runner.run(&stages([cat_cmd, grep_cmd]), &mut ctx, &dispatcher).await;
1564 assert!(!result.ok());
1566 }
1567
1568 #[tokio::test]
1569 async fn test_pipeline_last_command_exit_code() {
1570 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1572
1573 let echo_cmd = make_cmd("echo", vec!["hello"]);
1574 let cat_cmd = make_cmd("cat", vec![]);
1575
1576 let result = runner.run(&stages([echo_cmd, cat_cmd]), &mut ctx, &dispatcher).await;
1577 assert!(result.ok());
1578 assert!(result.text_out().contains("hello"));
1579 }
1580
1581 #[tokio::test]
1582 async fn test_empty_pipeline() {
1583 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1584 let result = runner.run(&stages([]), &mut ctx, &dispatcher).await;
1585 assert!(result.ok());
1586 }
1587
1588 #[test]
1591 fn test_find_scatter_gather_both_present() {
1592 let commands = vec![
1593 make_cmd("echo", vec!["a"]),
1594 make_cmd("scatter", vec![]),
1595 make_cmd("process", vec![]),
1596 make_cmd("gather", vec![]),
1597 ];
1598 let result = find_scatter_gather(&stages(commands));
1599 assert_eq!(result, Some((1, 3)));
1600 }
1601
1602 #[test]
1603 fn test_find_scatter_gather_no_scatter() {
1604 let commands = vec![
1605 make_cmd("echo", vec!["a"]),
1606 make_cmd("gather", vec![]),
1607 ];
1608 let result = find_scatter_gather(&stages(commands));
1609 assert!(result.is_none());
1610 }
1611
1612 #[test]
1613 fn test_find_scatter_gather_no_gather() {
1614 let commands = vec![
1615 make_cmd("echo", vec!["a"]),
1616 make_cmd("scatter", vec![]),
1617 ];
1618 let result = find_scatter_gather(&stages(commands));
1619 assert!(result.is_none());
1620 }
1621
1622 #[test]
1623 fn test_find_scatter_gather_wrong_order() {
1624 let commands = vec![
1625 make_cmd("gather", vec![]),
1626 make_cmd("scatter", vec![]),
1627 ];
1628 let result = find_scatter_gather(&stages(commands));
1629 assert!(result.is_none());
1630 }
1631
1632 #[tokio::test]
1633 async fn test_scatter_gather_simple() {
1634 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1635
1636 let split_cmd = Command {
1638 name: "split".to_string(),
1639 args: vec![Arg::Positional(Expr::Literal(Value::String("a b c".to_string())))],
1640 redirects: vec![],
1641 };
1642 let scatter_cmd = make_cmd("scatter", vec![]);
1643 let process_cmd = Command {
1644 name: "echo".to_string(),
1645 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1646 redirects: vec![],
1647 };
1648 let gather_cmd = make_cmd("gather", vec![]);
1649
1650 let result = runner.run(&stages([split_cmd, scatter_cmd, process_cmd, gather_cmd]), &mut ctx, &dispatcher).await;
1651 assert!(result.ok(), "scatter with structured data should succeed: {}", result.err);
1652 assert!(result.text_out().contains("a"));
1654 assert!(result.text_out().contains("b"));
1655 assert!(result.text_out().contains("c"));
1656 }
1657
1658 #[tokio::test]
1659 async fn test_scatter_gather_empty_input() {
1660 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1661
1662 let echo_cmd = Command {
1664 name: "echo".to_string(),
1665 args: vec![Arg::Positional(Expr::Literal(Value::String("".to_string())))],
1666 redirects: vec![],
1667 };
1668 let scatter_cmd = make_cmd("scatter", vec![]);
1669 let process_cmd = Command {
1670 name: "echo".to_string(),
1671 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1672 redirects: vec![],
1673 };
1674 let gather_cmd = make_cmd("gather", vec![]);
1675
1676 let result = runner.run(&stages([echo_cmd, scatter_cmd, process_cmd, gather_cmd]), &mut ctx, &dispatcher).await;
1677 assert!(result.ok());
1678 assert!(result.text_out().trim().is_empty());
1679 }
1680
1681 #[tokio::test]
1682 async fn test_scatter_gather_with_structured_stdin() {
1683 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1684
1685 let data = Value::Json(serde_json::json!(["x", "y", "z"]));
1687 ctx.set_stdin_with_data("x\ny\nz".to_string(), Some(data));
1688
1689 let scatter_cmd = make_cmd("scatter", vec![]);
1690 let process_cmd = Command {
1691 name: "echo".to_string(),
1692 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1693 redirects: vec![],
1694 };
1695 let gather_cmd = make_cmd("gather", vec![]);
1696
1697 let result = runner.run(&stages([scatter_cmd, process_cmd, gather_cmd]), &mut ctx, &dispatcher).await;
1698 assert!(result.ok(), "scatter with structured stdin should succeed: {}", result.err);
1699 assert!(result.text_out().contains("x"));
1700 assert!(result.text_out().contains("y"));
1701 assert!(result.text_out().contains("z"));
1702 }
1703
1704 #[tokio::test]
1705 async fn test_scatter_gather_json_input() {
1706 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1707
1708 let data = Value::Json(serde_json::json!(["one", "two", "three"]));
1710 ctx.set_stdin_with_data(r#"["one", "two", "three"]"#.to_string(), Some(data));
1711
1712 let scatter_cmd = make_cmd("scatter", vec![]);
1713 let process_cmd = Command {
1714 name: "echo".to_string(),
1715 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1716 redirects: vec![],
1717 };
1718 let gather_cmd = make_cmd("gather", vec![]);
1719
1720 let result = runner.run(&stages([scatter_cmd, process_cmd, gather_cmd]), &mut ctx, &dispatcher).await;
1721 assert!(result.ok(), "scatter with JSON data should succeed: {}", result.err);
1722 assert!(result.text_out().contains("one"));
1723 assert!(result.text_out().contains("two"));
1724 assert!(result.text_out().contains("three"));
1725 }
1726
1727 #[tokio::test]
1728 async fn test_scatter_gather_with_post_gather() {
1729 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1730
1731 let split_cmd = Command {
1733 name: "split".to_string(),
1734 args: vec![Arg::Positional(Expr::Literal(Value::String("a b".to_string())))],
1735 redirects: vec![],
1736 };
1737 let scatter_cmd = make_cmd("scatter", vec![]);
1738 let process_cmd = Command {
1739 name: "echo".to_string(),
1740 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1741 redirects: vec![],
1742 };
1743 let gather_cmd = make_cmd("gather", vec![]);
1744 let grep_cmd = Command {
1745 name: "grep".to_string(),
1746 args: vec![Arg::Positional(Expr::Literal(Value::String("a".to_string())))],
1747 redirects: vec![],
1748 };
1749
1750 let result = runner.run(&stages([split_cmd, scatter_cmd, process_cmd, gather_cmd, grep_cmd]), &mut ctx, &dispatcher).await;
1751 assert!(result.ok(), "scatter with post_gather should succeed: {}", result.err);
1752 assert!(result.text_out().contains("a"));
1753 assert!(!result.text_out().contains("b"));
1754 }
1755
1756 #[tokio::test]
1757 async fn test_scatter_custom_var_name() {
1758 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1759
1760 let data = Value::Json(serde_json::json!(["test1", "test2"]));
1762 ctx.set_stdin_with_data("test1\ntest2".to_string(), Some(data));
1763
1764 let scatter_cmd = Command {
1766 name: "scatter".to_string(),
1767 args: vec![Arg::Named {
1768 key: "as".to_string(),
1769 value: Expr::Literal(Value::String("URL".to_string())),
1770 }],
1771 redirects: vec![],
1772 };
1773 let process_cmd = Command {
1774 name: "echo".to_string(),
1775 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("URL")))],
1776 redirects: vec![],
1777 };
1778 let gather_cmd = make_cmd("gather", vec![]);
1779
1780 let result = runner.run(&stages([scatter_cmd, process_cmd, gather_cmd]), &mut ctx, &dispatcher).await;
1781 assert!(result.ok(), "scatter with custom var should succeed: {}", result.err);
1782 assert!(result.text_out().contains("test1"));
1783 assert!(result.text_out().contains("test2"));
1784 }
1785
1786 #[tokio::test]
1789 async fn test_pipeline_routes_through_backend() {
1790 use crate::backend::testing::MockBackend;
1791 use std::sync::atomic::Ordering;
1792
1793 let (backend, call_count) = MockBackend::new();
1795 let backend: std::sync::Arc<dyn crate::backend::KernelBackend> = std::sync::Arc::new(backend);
1796
1797 let mut ctx = crate::tools::ExecContext::with_backend(backend);
1799
1800 let tools = std::sync::Arc::new(ToolRegistry::new());
1802 let runner = PipelineRunner::new(tools.clone());
1803 let dispatcher = BackendDispatcher::new(tools);
1804
1805 let cmd = make_cmd("test-tool", vec!["arg1"]);
1807 let result = runner.run(&stages([cmd]), &mut ctx, &dispatcher).await;
1808
1809 assert!(result.ok(), "Mock backend should return success");
1810 assert_eq!(call_count.load(Ordering::SeqCst), 1, "call_tool should be invoked once");
1811 assert!(result.text_out().contains("mock executed"), "Output should be from mock backend");
1812 }
1813
1814 #[tokio::test]
1815 async fn test_multi_command_pipeline_routes_through_backend() {
1816 use crate::backend::testing::MockBackend;
1817 use std::sync::atomic::Ordering;
1818
1819 let (backend, call_count) = MockBackend::new();
1820 let backend: std::sync::Arc<dyn crate::backend::KernelBackend> = std::sync::Arc::new(backend);
1821 let mut ctx = crate::tools::ExecContext::with_backend(backend);
1822
1823 let tools = std::sync::Arc::new(ToolRegistry::new());
1824 let runner = PipelineRunner::new(tools.clone());
1825 let dispatcher = BackendDispatcher::new(tools);
1826
1827 let cmd1 = make_cmd("tool1", vec![]);
1829 let cmd2 = make_cmd("tool2", vec![]);
1830 let cmd3 = make_cmd("tool3", vec![]);
1831
1832 let result = runner.run(&stages([cmd1, cmd2, cmd3]), &mut ctx, &dispatcher).await;
1833
1834 assert!(result.ok());
1835 assert_eq!(call_count.load(Ordering::SeqCst), 3, "call_tool should be invoked for each command");
1836 }
1837
1838 #[tokio::test]
1845 async fn backend_dispatcher_scalar_data_matches_production_unwrap() {
1846 use crate::backend::testing::MockBackend;
1847 use crate::backend::ToolResult;
1848
1849 let (mock, _calls) = MockBackend::new();
1850 let backend = mock.with_tool_result(|_name| Ok(ToolResult::with_data("", serde_json::json!(42))));
1851 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
1852 let mut ctx = ExecContext::with_backend(backend);
1853
1854 let dispatcher = BackendDispatcher::new(Arc::new(ToolRegistry::new()));
1855 let cmd = make_cmd("embedder_tool", vec![]);
1856
1857 let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
1858 assert_eq!(
1859 result.data,
1860 Some(Value::Int(42)),
1861 "a scalar ToolResult.data must unwrap to a native Value, matching \
1862 the production From<ToolResult> path — not stay Value::Json(42)"
1863 );
1864 }
1865
1866 #[tokio::test]
1872 async fn backend_dispatcher_envelope_shaped_data_stays_structured() {
1873 use crate::backend::testing::MockBackend;
1874 use crate::backend::ToolResult;
1875
1876 let envelope = kaish_types::bytes_to_envelope(&[1u8, 2, 3]);
1877 let (mock, _calls) = MockBackend::new();
1878 let backend = mock.with_tool_result(move |_name| Ok(ToolResult::with_data("", envelope.clone())));
1879 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
1880 let mut ctx = ExecContext::with_backend(backend);
1881
1882 let dispatcher = BackendDispatcher::new(Arc::new(ToolRegistry::new()));
1883 let cmd = make_cmd("embedder_tool", vec![]);
1884
1885 let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
1886 assert!(
1887 matches!(result.data, Some(Value::Json(_))),
1888 "envelope-shaped external data must stay structured, not silently \
1889 decode to Value::Bytes: got {:?}",
1890 result.data
1891 );
1892 }
1893
1894 #[tokio::test]
1899 async fn backend_dispatcher_preserves_did_spill_and_original_code() {
1900 use crate::backend::testing::MockBackend;
1901 use crate::backend::ToolResult;
1902
1903 let (mock, _calls) = MockBackend::new();
1904 let backend = mock.with_tool_result(|_name| {
1905 Ok(ToolResult::success("truncated...")
1906 .with_did_spill(true)
1907 .with_original_code(Some(0)))
1908 });
1909 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
1910 let mut ctx = ExecContext::with_backend(backend);
1911
1912 let dispatcher = BackendDispatcher::new(Arc::new(ToolRegistry::new()));
1913 let cmd = make_cmd("embedder_tool", vec![]);
1914
1915 let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
1916 assert!(result.did_spill, "did_spill must survive the backend seam");
1917 assert_eq!(result.original_code, Some(0), "original_code must survive the backend seam");
1918 }
1919
1920 use crate::tools::{ParamSchema, ToolSchema};
1923
1924 fn make_test_schema() -> ToolSchema {
1925 ToolSchema::new("test-tool", "A test tool for schema-aware parsing")
1926 .param(ParamSchema::required("query", "string", "Search query"))
1927 .param(ParamSchema::optional("limit", "int", Value::Int(10), "Max results"))
1928 .param(ParamSchema::optional("verbose", "bool", Value::Bool(false), "Verbose output"))
1929 .param(ParamSchema::optional("output", "string", Value::String("stdout".into()), "Output destination"))
1930 .with_positional_mapping()
1931 }
1932
1933 fn make_minimal_ctx() -> ExecContext {
1934 let mut vfs = VfsRouter::new();
1935 vfs.mount("/", MemoryFs::new());
1936 ExecContext::new(Arc::new(vfs))
1937 }
1938
1939 fn test_dispatcher() -> BackendDispatcher {
1943 BackendDispatcher::new(Arc::new(ToolRegistry::new()))
1944 }
1945
1946 #[tokio::test]
1947 async fn test_schema_aware_string_arg() {
1948 let args = vec![
1950 Arg::LongFlag("query".to_string()),
1951 Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
1952 ];
1953 let schema = make_test_schema();
1954 let ctx = make_minimal_ctx();
1955
1956 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1957
1958 assert!(tool_args.flags.is_empty(), "No flags should be set");
1959 assert!(tool_args.positional.is_empty(), "No positionals - consumed by --query");
1960 assert_eq!(
1961 tool_args.named.get("query"),
1962 Some(&Value::String("test".to_string())),
1963 "--query should consume 'test' as its value"
1964 );
1965 }
1966
1967 #[tokio::test]
1968 async fn test_schema_aware_bool_flag() {
1969 let args = vec![
1971 Arg::LongFlag("verbose".to_string()),
1972 ];
1973 let schema = make_test_schema();
1974 let ctx = make_minimal_ctx();
1975
1976 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1977
1978 assert!(tool_args.flags.contains("verbose"), "--verbose should be a flag");
1979 assert!(tool_args.named.is_empty(), "No named args");
1980 assert!(tool_args.positional.is_empty(), "No positionals");
1981 }
1982
1983 #[tokio::test]
1984 async fn test_schema_aware_mixed() {
1985 let args = vec![
1988 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
1989 Arg::LongFlag("output".to_string()),
1990 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
1991 Arg::LongFlag("verbose".to_string()),
1992 ];
1993 let schema = make_test_schema();
1994 let ctx = make_minimal_ctx();
1995
1996 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1997
1998 assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
1999 assert_eq!(
2000 tool_args.named.get("query"),
2001 Some(&Value::String("file.txt".to_string()))
2002 );
2003 assert_eq!(
2004 tool_args.named.get("output"),
2005 Some(&Value::String("out.txt".to_string()))
2006 );
2007 assert!(tool_args.flags.contains("verbose"));
2008 }
2009
2010 #[tokio::test]
2011 async fn test_schema_aware_multiple_string_args() {
2012 let args = vec![
2014 Arg::LongFlag("query".to_string()),
2015 Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
2016 Arg::LongFlag("output".to_string()),
2017 Arg::Positional(Expr::Literal(Value::String("result.json".to_string()))),
2018 Arg::LongFlag("verbose".to_string()),
2019 Arg::LongFlag("limit".to_string()),
2020 Arg::Positional(Expr::Literal(Value::Int(5))),
2021 ];
2022 let schema = make_test_schema();
2023 let ctx = make_minimal_ctx();
2024
2025 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2026
2027 assert!(tool_args.positional.is_empty(), "All positionals consumed");
2028 assert_eq!(
2029 tool_args.named.get("query"),
2030 Some(&Value::String("test".to_string()))
2031 );
2032 assert_eq!(
2033 tool_args.named.get("output"),
2034 Some(&Value::String("result.json".to_string()))
2035 );
2036 assert_eq!(
2037 tool_args.named.get("limit"),
2038 Some(&Value::Int(5))
2039 );
2040 assert!(tool_args.flags.contains("verbose"));
2041 }
2042
2043 #[tokio::test]
2044 async fn test_schema_aware_double_dash() {
2045 let args = vec![
2048 Arg::LongFlag("output".to_string()),
2049 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
2050 Arg::DoubleDash,
2051 Arg::Positional(Expr::Literal(Value::String("--this-is-data".to_string()))),
2052 ];
2053 let schema = make_test_schema();
2054 let ctx = make_minimal_ctx();
2055
2056 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2057
2058 assert_eq!(
2059 tool_args.named.get("output"),
2060 Some(&Value::String("out.txt".to_string()))
2061 );
2062 assert_eq!(
2064 tool_args.positional,
2065 vec![Value::String("--this-is-data".to_string())]
2066 );
2067 }
2068
2069 #[tokio::test]
2075 async fn word_assign_binary_value_is_loud_not_placeholder() {
2076 let args = vec![Arg::WordAssign {
2077 key: "if".to_string(),
2078 value: Expr::Literal(Value::Bytes(vec![0xff, 0x00, 0xfe])),
2079 }];
2080 let ctx = make_minimal_ctx();
2081
2082 let err = build_tool_args(&args, &ctx, None).await.expect_err("binary WordAssign must error");
2085 assert!(
2086 err.contains("cannot be used as"),
2087 "error should name the binary problem, got {err:?}"
2088 );
2089 }
2090
2091 #[tokio::test]
2096 async fn word_assign_before_double_dash_binds_named_for_export() {
2097 let args = vec![Arg::WordAssign {
2098 key: "A".to_string(),
2099 value: Expr::Literal(Value::String("1".to_string())),
2100 }];
2101 let schema = ToolSchema::new("export", "export");
2102 let ctx = make_minimal_ctx();
2103
2104 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2105 assert_eq!(tool_args.named.get("A"), Some(&Value::String("1".to_string())));
2106 assert!(tool_args.positional.is_empty());
2107 }
2108
2109 #[tokio::test]
2118 async fn word_assign_after_double_dash_is_positional_even_for_export() {
2119 let args = vec![
2120 Arg::DoubleDash,
2121 Arg::WordAssign {
2122 key: "A".to_string(),
2123 value: Expr::Literal(Value::String("1".to_string())),
2124 },
2125 ];
2126 let schema = ToolSchema::new("export", "export");
2127 let ctx = make_minimal_ctx();
2128
2129 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2130 assert!(
2131 tool_args.named.is_empty(),
2132 "past `--`, A=1 must not become a named assignment: {:?}",
2133 tool_args.named
2134 );
2135 assert_eq!(tool_args.positional, vec![Value::String("A=1".to_string())]);
2136 }
2137
2138 #[tokio::test]
2146 async fn named_true_on_undeclared_flag_flagifies() {
2147 let args = vec![Arg::Named {
2148 key: "json".to_string(),
2149 value: Expr::Literal(Value::Bool(true)),
2150 }];
2151 let schema = make_test_schema(); let ctx = make_minimal_ctx();
2153
2154 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2155 assert!(tool_args.flags.contains("json"), "flags: {:?}", tool_args.flags);
2156 assert!(!tool_args.named.contains_key("json"), "named: {:?}", tool_args.named);
2157 }
2158
2159 #[tokio::test]
2162 async fn named_false_on_undeclared_flag_drops() {
2163 let args = vec![Arg::Named {
2164 key: "json".to_string(),
2165 value: Expr::Literal(Value::Bool(false)),
2166 }];
2167 let schema = make_test_schema();
2168 let ctx = make_minimal_ctx();
2169
2170 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2171 assert!(!tool_args.flags.contains("json"));
2172 assert!(!tool_args.named.contains_key("json"));
2173 }
2174
2175 #[tokio::test]
2179 async fn named_true_on_declared_bool_param_flagifies() {
2180 let args = vec![Arg::Named {
2181 key: "verbose".to_string(),
2182 value: Expr::Literal(Value::Bool(true)),
2183 }];
2184 let schema = make_test_schema();
2185 let ctx = make_minimal_ctx();
2186
2187 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2188 assert!(tool_args.flags.contains("verbose"));
2189 assert!(!tool_args.named.contains_key("verbose"));
2190 }
2191
2192 #[tokio::test]
2198 async fn named_true_on_declared_value_flag_keeps_value() {
2199 let args = vec![Arg::Named {
2200 key: "output".to_string(),
2201 value: Expr::Literal(Value::Bool(true)),
2202 }];
2203 let schema = make_test_schema();
2204 let ctx = make_minimal_ctx();
2205
2206 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2207 assert_eq!(tool_args.named.get("output"), Some(&Value::Bool(true)));
2208 assert!(!tool_args.flags.contains("output"));
2209 }
2210
2211 #[tokio::test]
2216 async fn named_true_with_no_schema_flagifies() {
2217 let args = vec![Arg::Named {
2218 key: "verbose".to_string(),
2219 value: Expr::Literal(Value::Bool(true)),
2220 }];
2221 let ctx = make_minimal_ctx();
2222
2223 let tool_args = build_tool_args(&args, &ctx, None).await.expect("build_tool_args");
2224 assert!(tool_args.flags.contains("verbose"));
2225 assert!(!tool_args.named.contains_key("verbose"));
2226 }
2227
2228 #[tokio::test]
2229 async fn test_no_schema_fallback() {
2230 let args = vec![
2232 Arg::LongFlag("query".to_string()),
2233 Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
2234 ];
2235 let ctx = make_minimal_ctx();
2236
2237 let tool_args = build_tool_args(&args, &ctx, None).await.expect("build_tool_args");
2238
2239 assert!(tool_args.flags.contains("query"), "--query should be a flag");
2241 assert_eq!(
2242 tool_args.positional,
2243 vec![Value::String("test".to_string())],
2244 "'test' should be a positional"
2245 );
2246 }
2247
2248 #[tokio::test]
2258 async fn test_unknown_flag_ambiguous_space_value_now_errors_loud() {
2259 let args = vec![
2260 Arg::LongFlag("unknown".to_string()),
2261 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2262 ];
2263 let schema = make_test_schema();
2264 let ctx = make_minimal_ctx();
2265
2266 let err = build_tool_args(&args, &ctx, Some(&schema))
2267 .await
2268 .expect_err("an undeclared flag immediately before a positional must be ambiguous, not silently bool");
2269 assert!(
2270 err.contains("--unknown is not a declared flag"),
2271 "got: {err}"
2272 );
2273 }
2274
2275 #[tokio::test]
2279 async fn test_unknown_bool_flag_with_no_following_positional_is_fine() {
2280 let args = vec![
2281 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2282 Arg::LongFlag("unknown".to_string()),
2283 ];
2284 let schema = make_test_schema();
2285 let ctx = make_minimal_ctx();
2286
2287 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2288
2289 assert!(tool_args.flags.contains("unknown"));
2290 assert!(tool_args.positional.is_empty(), "value consumed as query param");
2291 assert_eq!(
2292 tool_args.named.get("query"),
2293 Some(&Value::String("value".to_string()))
2294 );
2295 }
2296
2297 #[tokio::test]
2307 async fn test_unknown_short_flag_ambiguous_space_value_now_errors_loud() {
2308 let args = vec![
2309 Arg::ShortFlag("t".to_string()),
2310 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2311 ];
2312 let schema = make_test_schema();
2313 let ctx = make_minimal_ctx();
2314
2315 let err = build_tool_args(&args, &ctx, Some(&schema))
2316 .await
2317 .expect_err("an undeclared short flag immediately before a positional must be ambiguous, not silently bool");
2318 assert!(
2319 err.contains("-t is not a declared flag"),
2320 "got: {err}"
2321 );
2322 }
2323
2324 #[tokio::test]
2328 async fn test_unknown_short_bool_flag_with_no_following_positional_is_fine() {
2329 let args = vec![
2330 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2331 Arg::ShortFlag("t".to_string()),
2332 ];
2333 let schema = make_test_schema();
2334 let ctx = make_minimal_ctx();
2335
2336 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2337
2338 assert!(tool_args.flags.contains("t"));
2339 assert!(tool_args.positional.is_empty(), "value consumed as query param");
2340 assert_eq!(
2341 tool_args.named.get("query"),
2342 Some(&Value::String("value".to_string()))
2343 );
2344 }
2345
2346 #[tokio::test]
2352 async fn test_unknown_short_flag_not_ambiguous_without_map_positionals() {
2353 let args = vec![
2354 Arg::ShortFlag("t".to_string()),
2355 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2356 ];
2357 let schema = ToolSchema::new("test-tool", "A test tool")
2360 .param(ParamSchema::required("query", "string", "Search query"));
2361 let ctx = make_minimal_ctx();
2362
2363 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2364 assert!(tool_args.flags.contains("t"));
2365 assert_eq!(tool_args.positional, vec![Value::String("value".to_string())]);
2366 }
2367
2368 #[tokio::test]
2369 async fn test_named_args_unchanged() {
2370 let args = vec![
2372 Arg::Named {
2373 key: "query".to_string(),
2374 value: Expr::Literal(Value::String("test".to_string())),
2375 },
2376 Arg::LongFlag("verbose".to_string()),
2377 ];
2378 let schema = make_test_schema();
2379 let ctx = make_minimal_ctx();
2380
2381 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2382
2383 assert_eq!(
2384 tool_args.named.get("query"),
2385 Some(&Value::String("test".to_string()))
2386 );
2387 assert!(tool_args.flags.contains("verbose"));
2388 }
2389
2390 #[tokio::test]
2391 async fn test_short_flags_unchanged() {
2392 let args = vec![
2394 Arg::ShortFlag("la".to_string()),
2395 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
2396 ];
2397 let schema = make_test_schema();
2398 let ctx = make_minimal_ctx();
2399
2400 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2401
2402 assert!(tool_args.flags.contains("l"));
2403 assert!(tool_args.flags.contains("a"));
2404 assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
2405 assert_eq!(
2406 tool_args.named.get("query"),
2407 Some(&Value::String("file.txt".to_string()))
2408 );
2409 }
2410
2411 #[tokio::test]
2412 async fn test_flag_at_end_no_value() {
2413 let args = vec![
2416 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
2417 Arg::LongFlag("output".to_string()),
2418 ];
2419 let schema = make_test_schema();
2420 let ctx = make_minimal_ctx();
2421
2422 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2423
2424 assert!(tool_args.flags.contains("output"));
2426 assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
2427 assert_eq!(
2428 tool_args.named.get("query"),
2429 Some(&Value::String("file.txt".to_string()))
2430 );
2431 }
2432
2433 #[tokio::test]
2434 async fn test_positional_skips_bool_params() {
2435 let schema = ToolSchema::new("test", "")
2439 .param(ParamSchema::required("query", "string", ""))
2440 .param(ParamSchema::optional(
2441 "verbose",
2442 "bool",
2443 Value::Bool(false),
2444 "",
2445 ))
2446 .param(ParamSchema::optional(
2447 "output",
2448 "string",
2449 Value::Null,
2450 "",
2451 ))
2452 .with_positional_mapping();
2453 let args = vec![
2454 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2455 Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
2456 ];
2457 let ctx = make_minimal_ctx();
2458
2459 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2460
2461 assert_eq!(
2462 tool_args.named.get("query"),
2463 Some(&Value::String("val1".to_string()))
2464 );
2465 assert_eq!(
2466 tool_args.named.get("output"),
2467 Some(&Value::String("val2".to_string()))
2468 );
2469 assert!(!tool_args.flags.contains("verbose"));
2470 assert!(tool_args.positional.is_empty());
2471 }
2472
2473 #[tokio::test]
2474 async fn test_positionals_fill_available_slots() {
2475 let args = vec![
2478 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2479 Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
2480 Arg::Positional(Expr::Literal(Value::String("val3".to_string()))),
2481 ];
2482 let schema = make_test_schema(); let ctx = make_minimal_ctx();
2484
2485 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2486
2487 assert_eq!(
2490 tool_args.named.get("query"),
2491 Some(&Value::String("val1".to_string()))
2492 );
2493 assert_eq!(
2494 tool_args.named.get("limit"),
2495 Some(&Value::String("val2".to_string()))
2496 );
2497 assert_eq!(
2498 tool_args.named.get("output"),
2499 Some(&Value::String("val3".to_string()))
2500 );
2501 assert!(tool_args.positional.is_empty());
2502 }
2503
2504 #[tokio::test]
2505 async fn test_truly_excess_positionals() {
2506 let schema = ToolSchema::new("test", "")
2508 .param(ParamSchema::required("name", "string", ""))
2509 .with_positional_mapping();
2510 let args = vec![
2511 Arg::Positional(Expr::Literal(Value::String("first".to_string()))),
2512 Arg::Positional(Expr::Literal(Value::String("second".to_string()))),
2513 Arg::Positional(Expr::Literal(Value::String("third".to_string()))),
2514 ];
2515 let ctx = make_minimal_ctx();
2516
2517 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2518
2519 assert_eq!(
2520 tool_args.named.get("name"),
2521 Some(&Value::String("first".to_string()))
2522 );
2523 assert_eq!(
2524 tool_args.positional,
2525 vec![
2526 Value::String("second".to_string()),
2527 Value::String("third".to_string()),
2528 ]
2529 );
2530 }
2531
2532 #[tokio::test]
2533 async fn test_double_dash_positional_not_mapped() {
2534 let args = vec![
2536 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2537 Arg::DoubleDash,
2538 Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
2539 ];
2540 let schema = make_test_schema();
2541 let ctx = make_minimal_ctx();
2542
2543 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2544
2545 assert_eq!(
2546 tool_args.named.get("query"),
2547 Some(&Value::String("val1".to_string()))
2548 );
2549 assert_eq!(
2551 tool_args.positional,
2552 vec![Value::String("val2".to_string())]
2553 );
2554 }
2555
2556 #[tokio::test]
2557 async fn test_all_params_filled_by_flags() {
2558 let args = vec![
2560 Arg::LongFlag("query".to_string()),
2561 Arg::Positional(Expr::Literal(Value::String("search".to_string()))),
2562 Arg::LongFlag("output".to_string()),
2563 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
2564 Arg::LongFlag("verbose".to_string()),
2565 ];
2566 let schema = make_test_schema();
2567 let ctx = make_minimal_ctx();
2568
2569 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2570
2571 assert_eq!(
2572 tool_args.named.get("query"),
2573 Some(&Value::String("search".to_string()))
2574 );
2575 assert_eq!(
2576 tool_args.named.get("output"),
2577 Some(&Value::String("out.txt".to_string()))
2578 );
2579 assert!(tool_args.flags.contains("verbose"));
2580 assert!(tool_args.positional.is_empty());
2581 }
2582
2583 #[tokio::test]
2584 async fn test_mixed_flags_and_positional_fill() {
2585 let args = vec![
2587 Arg::LongFlag("output".to_string()),
2588 Arg::Positional(Expr::Literal(Value::String("foo".to_string()))),
2589 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2590 ];
2591 let schema = make_test_schema();
2592 let ctx = make_minimal_ctx();
2593
2594 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2595
2596 assert_eq!(
2597 tool_args.named.get("output"),
2598 Some(&Value::String("foo".to_string()))
2599 );
2600 assert_eq!(
2601 tool_args.named.get("query"),
2602 Some(&Value::String("val1".to_string()))
2603 );
2604 assert!(tool_args.positional.is_empty());
2605 }
2606
2607 #[tokio::test]
2608 async fn test_alias_flag_prevents_mapping_overwrite() {
2609 let schema = ToolSchema::new("test", "")
2611 .param(ParamSchema::required("query", "string", "").with_aliases(["-q"]))
2612 .param(ParamSchema::required("output", "string", ""))
2613 .with_positional_mapping();
2614 let args = vec![
2615 Arg::ShortFlag("q".to_string()),
2616 Arg::Positional(Expr::Literal(Value::String("search".to_string()))),
2617 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
2618 ];
2619 let ctx = make_minimal_ctx();
2620
2621 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2622
2623 assert_eq!(
2624 tool_args.named.get("query"),
2625 Some(&Value::String("search".to_string()))
2626 );
2627 assert_eq!(
2628 tool_args.named.get("output"),
2629 Some(&Value::String("out.txt".to_string()))
2630 );
2631 assert!(tool_args.positional.is_empty());
2632 }
2633
2634 #[tokio::test]
2635 async fn test_builtin_schema_no_positional_mapping() {
2636 let schema = ToolSchema::new("echo", "")
2638 .param(ParamSchema::optional("args", "any", Value::Null, ""))
2639 .param(ParamSchema::optional("no_newline", "bool", Value::Bool(false), ""));
2640 let args = vec![
2642 Arg::Positional(Expr::Literal(Value::String("hello".to_string()))),
2643 Arg::Positional(Expr::Literal(Value::String("world".to_string()))),
2644 ];
2645 let ctx = make_minimal_ctx();
2646
2647 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2648
2649 assert_eq!(
2651 tool_args.positional,
2652 vec![
2653 Value::String("hello".to_string()),
2654 Value::String("world".to_string()),
2655 ]
2656 );
2657 assert!(!tool_args.named.contains_key("args"));
2658 }
2659
2660 #[tokio::test]
2661 async fn test_short_flag_with_alias_consumes_value() {
2662 let schema = ToolSchema::new("head", "Output first part of files")
2665 .param(ParamSchema::optional("lines", "int", Value::Int(10), "Number of lines")
2666 .with_aliases(["-n"]));
2667 let args = vec![
2668 Arg::ShortFlag("n".to_string()),
2669 Arg::Positional(Expr::Literal(Value::Int(5))),
2670 Arg::Positional(Expr::Literal(Value::String("/tmp/file.txt".to_string()))),
2671 ];
2672 let ctx = make_minimal_ctx();
2673
2674 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2675
2676 assert!(tool_args.flags.is_empty(), "no boolean flags: {:?}", tool_args.flags);
2677 assert_eq!(tool_args.named.get("lines"), Some(&Value::Int(5)), "should resolve alias to canonical name");
2678 assert_eq!(tool_args.positional, vec![Value::String("/tmp/file.txt".to_string())]);
2679 }
2680
2681 #[tokio::test]
2693 async fn test_glued_short_flag_value_now_binds() {
2694 let schema = ToolSchema::new("cut", "")
2698 .param(ParamSchema::optional("fields", "string", Value::Null, "").with_aliases(["-f"]));
2699 let args = vec![Arg::ShortFlag("f1".to_string())];
2700 let ctx = make_minimal_ctx();
2701
2702 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2703
2704 assert!(tool_args.flags.is_empty(), "no bogus bool flags: {:?}", tool_args.flags);
2705 assert_eq!(tool_args.named.get("fields"), Some(&Value::String("1".to_string())));
2706 }
2707
2708 #[tokio::test]
2709 async fn test_repeatable_flag_now_accumulates() {
2710 let schema = ToolSchema::new("sed", "")
2715 .param(ParamSchema::optional("expression", "string", Value::Null, "")
2716 .with_aliases(["-e"])
2717 .with_repeatable(true));
2718 let args = vec![
2719 Arg::ShortFlag("e".to_string()),
2720 Arg::Positional(Expr::Literal(Value::String("A".to_string()))),
2721 Arg::ShortFlag("e".to_string()),
2722 Arg::Positional(Expr::Literal(Value::String("B".to_string()))),
2723 ];
2724 let ctx = make_minimal_ctx();
2725
2726 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2727
2728 assert_eq!(
2729 tool_args.named.get("expression"),
2730 Some(&Value::Json(serde_json::json!(["A", "B"]))),
2731 "both occurrences must survive, not just the last: {:?}",
2732 tool_args.named
2733 );
2734 }
2735
2736 #[tokio::test]
2737 async fn test_multi_consume_flag_now_accumulates() {
2738 let schema = ToolSchema::new("jq", "")
2743 .param(ParamSchema::optional("arg", "any", Value::Null, "").consumes(2));
2744 let args = vec![
2745 Arg::LongFlag("arg".to_string()),
2746 Arg::Positional(Expr::Literal(Value::String("name".to_string()))),
2747 Arg::Positional(Expr::Literal(Value::String("val".to_string()))),
2748 ];
2749 let ctx = make_minimal_ctx();
2750
2751 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2752
2753 assert_eq!(
2754 tool_args.named.get("arg"),
2755 Some(&Value::Json(serde_json::json!([["name", "val"]]))),
2756 "got: {:?}",
2757 tool_args.named
2758 );
2759 assert!(tool_args.positional.is_empty());
2760 }
2761
2762 #[tokio::test]
2765 async fn test_merge_stderr_redirect() {
2766 let result = ExecResult::from_output(0, "stdout content", "stderr content");
2768
2769 let redirects = vec![Redirect {
2770 kind: RedirectKind::MergeStderr,
2771 target: Expr::Literal(Value::Null),
2772 }];
2773
2774 let ctx = make_minimal_ctx();
2775 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2776
2777 assert_eq!(&*result.text_out(), "stdout contentstderr content");
2778 assert!(result.err.is_empty());
2779 }
2780
2781 #[tokio::test]
2782 async fn test_merge_stderr_with_empty_stderr() {
2783 let result = ExecResult::from_output(0, "stdout only", "");
2785
2786 let redirects = vec![Redirect {
2787 kind: RedirectKind::MergeStderr,
2788 target: Expr::Literal(Value::Null),
2789 }];
2790
2791 let ctx = make_minimal_ctx();
2792 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2793
2794 assert_eq!(&*result.text_out(), "stdout only");
2795 assert!(result.err.is_empty());
2796 }
2797
2798 #[tokio::test]
2799 async fn test_merge_stderr_order_matters() {
2800 let result = ExecResult::from_output(0, "stdout\n", "stderr\n");
2805
2806 let redirects = vec![Redirect {
2808 kind: RedirectKind::MergeStderr,
2809 target: Expr::Literal(Value::Null),
2810 }];
2811
2812 let ctx = make_minimal_ctx();
2813 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2814
2815 assert_eq!(&*result.text_out(), "stdout\nstderr\n");
2816 assert!(result.err.is_empty());
2817 }
2818
2819 #[tokio::test]
2820 async fn test_redirect_with_command_execution() {
2821 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
2822
2823 let cmd = Command {
2825 name: "echo".to_string(),
2826 args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
2827 redirects: vec![Redirect {
2828 kind: RedirectKind::MergeStderr,
2829 target: Expr::Literal(Value::Null),
2830 }],
2831 };
2832
2833 let result = runner.run(&stages([cmd]), &mut ctx, &dispatcher).await;
2834 assert!(result.ok());
2835 assert!(result.text_out().contains("hello"));
2837 }
2838
2839 #[tokio::test]
2840 async fn test_merge_stderr_in_pipeline() {
2841 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
2842
2843 let echo_cmd = Command {
2846 name: "echo".to_string(),
2847 args: vec![Arg::Positional(Expr::Literal(Value::String("output".to_string())))],
2848 redirects: vec![Redirect {
2849 kind: RedirectKind::MergeStderr,
2850 target: Expr::Literal(Value::Null),
2851 }],
2852 };
2853 let grep_cmd = Command {
2854 name: "grep".to_string(),
2855 args: vec![Arg::Positional(Expr::Literal(Value::String("output".to_string())))],
2856 redirects: vec![],
2857 };
2858
2859 let result = runner.run(&stages([echo_cmd, grep_cmd]), &mut ctx, &dispatcher).await;
2860 assert!(result.ok(), "result failed: code={}, err={}", result.code, result.err);
2861 assert!(result.text_out().contains("output"));
2862 }
2863
2864 fn big_table_output(rows: usize) -> crate::interpreter::OutputData {
2876 use crate::interpreter::OutputNode;
2877 let headers = vec!["id".to_string(), "name".to_string()];
2878 let nodes: Vec<OutputNode> = (0..rows)
2879 .map(|i| OutputNode::new(i.to_string()).with_cells(vec![format!("row-{i}")]))
2880 .collect();
2881 crate::interpreter::OutputData::table(headers, nodes)
2882 }
2883
2884 #[tokio::test]
2885 async fn test_both_redirect_streams_structured_output_to_file() {
2886 let output = big_table_output(50);
2891 let expected_stdout = output.to_canonical_string();
2892 let mut result = ExecResult::with_output(output);
2893 result.err = "warning: heads up\n".to_string();
2894
2895 let redirects = vec![Redirect {
2896 kind: RedirectKind::Both,
2897 target: Expr::Literal(Value::String("/out.txt".to_string())),
2898 }];
2899 let ctx = make_minimal_ctx();
2900 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2901
2902 assert!(result.ok());
2905 assert_eq!(&*result.text_out(), "");
2906 assert!(result.err.is_empty());
2907 assert!(!result.has_output());
2908
2909 let written = ctx.backend.read(Path::new("/out.txt"), None).await.expect("file written");
2910 let written = String::from_utf8(written).expect("valid utf8");
2911 assert_eq!(written, format!("{expected_stdout}warning: heads up\n"));
2915 }
2916
2917 #[tokio::test]
2918 async fn test_both_redirect_streams_large_structured_output_intact() {
2919 let rows = 5_000;
2924 let output = big_table_output(rows);
2925 let expected_stdout = output.to_canonical_string();
2926 let result = ExecResult::with_output(output);
2927
2928 let redirects = vec![Redirect {
2929 kind: RedirectKind::Both,
2930 target: Expr::Literal(Value::String("/big.txt".to_string())),
2931 }];
2932 let ctx = make_minimal_ctx();
2933 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2934 assert!(result.ok());
2935
2936 let written = ctx.backend.read(Path::new("/big.txt"), None).await.expect("file written");
2937 let written = String::from_utf8(written).expect("valid utf8");
2938 assert_eq!(written, expected_stdout);
2939 assert!(written.contains("row-0"));
2940 assert!(written.contains(&format!("row-{}", rows - 1)));
2941 }
2942
2943 #[tokio::test]
2944 async fn test_both_redirect_still_writes_binary_stdout_raw() {
2945 let result = ExecResult::success_text_or_bytes(vec![0xff, 0x00, 0xfe, b'x']);
2949 let redirects = vec![Redirect {
2950 kind: RedirectKind::Both,
2951 target: Expr::Literal(Value::String("/bin.out".to_string())),
2952 }];
2953 let ctx = make_minimal_ctx();
2954 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2955 assert!(result.ok());
2956
2957 let written = ctx.backend.read(Path::new("/bin.out"), None).await.expect("file written");
2958 assert_eq!(written, vec![0xff, 0x00, 0xfe, b'x']);
2959 }
2960}