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::{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 {
34 args.iter().any(|a| matches!(a, Arg::LongFlag(name) if name == "json"))
35}
36
37fn finalize_scatter_gather_error(result: ExecResult, format: Option<OutputFormat>) -> ExecResult {
47 match format {
48 Some(format) => apply_output_format(result, format),
49 None => result,
50 }
51}
52
53pub(crate) async fn apply_redirects(
59 mut result: ExecResult,
60 redirects: &[Redirect],
61 ctx: &ExecContext,
62 dispatcher: &dyn CommandDispatcher,
63) -> ExecResult {
64 for redir in redirects {
69 match redir.kind {
70 RedirectKind::MergeStderr => {
71 result.materialize();
74 if !result.err.is_empty() {
75 let err = std::mem::take(&mut result.err);
76 result.push_out(&err);
77 }
78 }
79 RedirectKind::MergeStdout => {
80 if result.is_bytes() {
84 return ExecResult::failure(
85 1,
86 "redirect: cannot merge binary stdout into stderr (1>&2) — \
87 redirect it to a file or pipe through base64/xxd",
88 );
89 }
90 result.materialize();
91 if !result.text_out().is_empty() {
92 let out = result.text_out().into_owned();
93 result.err.push_str(&out);
94 }
95 result.clear_stdout();
101 }
102 RedirectKind::StdoutOverwrite => {
103 let path = match eval_redirect_target(&redir.target, ctx, dispatcher).await {
104 Ok(p) => p,
105 Err(e) => return ExecResult::failure(1, format!("redirect: {e}")),
106 };
107 if let Some(bytes) = result.out_bytes() {
109 if let Err(e) = redirect_write(ctx, &path, bytes).await {
110 return ExecResult::failure(1, format!("redirect: {e}"));
111 }
112 } else if let Some(output) = result.take_output_for_stream() {
113 let mut buf = Vec::new();
115 if let Err(e) = output.write_canonical(&mut buf, None) {
116 return ExecResult::failure(1, format!("redirect: {e}"));
117 }
118 if let Err(e) = redirect_write(ctx, &path, &buf).await {
119 return ExecResult::failure(1, format!("redirect: {e}"));
120 }
121 } else if let Err(e) = redirect_write(ctx, &path, result.text_out().as_bytes()).await {
122 return ExecResult::failure(1, format!("redirect: {e}"));
123 }
124 result.clear_stdout();
126 }
127 RedirectKind::StdoutAppend => {
128 let path = match eval_redirect_target(&redir.target, ctx, dispatcher).await {
129 Ok(p) => p,
130 Err(e) => return ExecResult::failure(1, format!("redirect: {e}")),
131 };
132 if let Some(bytes) = result.out_bytes() {
134 if let Err(e) = redirect_append(ctx, &path, bytes).await {
135 return ExecResult::failure(1, format!("redirect: {e}"));
136 }
137 } else if let Some(output) = result.take_output_for_stream() {
138 let mut buf = Vec::new();
140 if let Err(e) = output.write_canonical(&mut buf, None) {
141 return ExecResult::failure(1, format!("redirect: {e}"));
142 }
143 if let Err(e) = redirect_append(ctx, &path, &buf).await {
144 return ExecResult::failure(1, format!("redirect: {e}"));
145 }
146 } else if let Err(e) = redirect_append(ctx, &path, result.text_out().as_bytes()).await {
147 return ExecResult::failure(1, format!("redirect: {e}"));
148 }
149 result.clear_stdout();
151 }
152 RedirectKind::Stderr => {
153 let path = match eval_redirect_target(&redir.target, ctx, dispatcher).await {
154 Ok(p) => p,
155 Err(e) => return ExecResult::failure(1, format!("redirect: {e}")),
156 };
157 if let Err(e) = redirect_write(ctx, &path, result.err.as_bytes()).await {
158 return ExecResult::failure(1, format!("redirect: {e}"));
159 }
160 result.err.clear();
161 }
162 RedirectKind::Both => {
163 let path = match eval_redirect_target(&redir.target, ctx, dispatcher).await {
164 Ok(p) => p,
165 Err(e) => return ExecResult::failure(1, format!("redirect: {e}")),
166 };
167 let mut combined: Vec<u8> = if let Some(b) = result.out_bytes() {
175 b.to_vec()
176 } else if let Some(output) = result.take_output_for_stream() {
177 let mut buf = Vec::new();
178 if let Err(e) = output.write_canonical(&mut buf, None) {
179 return ExecResult::failure(1, format!("redirect: {e}"));
180 }
181 buf
182 } else {
183 result.text_out().into_owned().into_bytes()
184 };
185 combined.extend_from_slice(result.err.as_bytes());
186 if let Err(e) = redirect_write(ctx, &path, &combined).await {
187 return ExecResult::failure(1, format!("redirect: {e}"));
188 }
189 result.clear_stdout();
191 result.err.clear();
192 }
193 RedirectKind::Stdin | RedirectKind::HereDoc(_) | RedirectKind::HereString => {}
195 }
196 }
197 result.materialize();
202 result
203}
204
205async fn eval_redirect_target(
217 expr: &Expr,
218 ctx: &ExecContext,
219 dispatcher: &dyn CommandDispatcher,
220) -> Result<String, String> {
221 let value = dispatcher
222 .eval_expr(expr, ctx)
223 .await
224 .map_err(|e| e.to_string())?;
225 if let Some(msg) = crate::interpreter::structured_boundary_error("a redirect target", &value) {
228 return Err(msg);
229 }
230 crate::interpreter::value_to_text_sink_named(&value, "a redirect target").map_err(|e| e.to_string())
233}
234
235async fn redirect_write(ctx: &ExecContext, path: &str, data: &[u8]) -> Result<(), String> {
242 use crate::backend::WriteMode;
243 let resolved = ctx.resolve_path(path);
244 ctx.backend.write(&resolved, data, WriteMode::Overwrite).await.map_err(|e| e.to_string())
245}
246
247async fn redirect_append(ctx: &ExecContext, path: &str, data: &[u8]) -> Result<(), String> {
251 let resolved = ctx.resolve_path(path);
252 ctx.backend.append(&resolved, data).await.map_err(|e| e.to_string())
253}
254
255async fn setup_stdin_redirects(
268 cmd: &Command,
269 ctx: &mut ExecContext,
270 dispatcher: &dyn CommandDispatcher,
271) -> Result<(), String> {
272 use std::path::Path;
273 for redir in &cmd.redirects {
274 match &redir.kind {
275 RedirectKind::Stdin => {
276 let path = eval_redirect_target(&redir.target, ctx, dispatcher).await?;
277 let resolved = ctx.resolve_path(&path);
278 let data = ctx
279 .backend
280 .read(Path::new(&resolved), None)
281 .await
282 .map_err(|e| format!("redirect: {path}: {e}"))?;
283 ctx.set_stdin(data);
284 }
285 RedirectKind::HereDoc(_) => {
286 match &redir.target {
287 Expr::Literal(Value::String(content)) => {
288 ctx.set_stdin(content.clone());
289 }
290 expr => {
293 let body = eval_redirect_target(expr, ctx, dispatcher).await?;
294 ctx.set_stdin(body);
295 }
296 }
297 }
298 RedirectKind::HereString => {
299 let mut s = eval_redirect_target(&redir.target, ctx, dispatcher).await?;
302 s.push('\n');
303 ctx.set_stdin(s);
304 }
305 _ => {}
306 }
307 }
308 Ok(())
309}
310
311async fn setup_stdin_redirects_for(
315 stage: &PipelineStage,
316 ctx: &mut ExecContext,
317 dispatcher: &dyn CommandDispatcher,
318) -> Result<(), String> {
319 match stage {
320 PipelineStage::Command(cmd) => setup_stdin_redirects(cmd, ctx, dispatcher).await,
321 PipelineStage::Compound(_) => Ok(()),
322 }
323}
324
325async fn dispatch_stage(
327 stage: &PipelineStage,
328 ctx: &mut ExecContext,
329 dispatcher: &dyn CommandDispatcher,
330) -> anyhow::Result<ExecResult> {
331 match stage {
332 PipelineStage::Command(cmd) => dispatcher.dispatch(cmd, ctx).await,
333 PipelineStage::Compound(stmt) => dispatcher.dispatch_stmt(stmt, ctx).await,
334 }
335}
336
337#[derive(Clone)]
339pub struct PipelineRunner {
340 tools: Arc<ToolRegistry>,
341}
342
343impl PipelineRunner {
344 pub fn new(tools: Arc<ToolRegistry>) -> Self {
346 Self { tools }
347 }
348
349 pub async fn run(
359 &self,
360 stages: &[PipelineStage],
361 ctx: &mut ExecContext,
362 dispatcher: &dyn CommandDispatcher,
363 ) -> ExecResult {
364 if stages.is_empty() {
365 return ExecResult::success("");
366 }
367
368 if let Some((scatter_idx, gather_idx)) = find_scatter_gather(stages) {
373 let commands: Vec<Command> = match stages
374 .iter()
375 .map(|s| s.as_command().cloned())
376 .collect::<Option<Vec<_>>>()
377 {
378 Some(commands) => commands,
379 None => {
380 return ExecResult::failure(
381 2,
382 "scatter/gather cannot share a pipeline with an if/for/while/case \
383 stage. Run the compound on its own and pipe its output in.",
384 )
385 }
386 };
387 return self
388 .run_scatter_gather(&commands, scatter_idx, gather_idx, ctx, dispatcher)
389 .await;
390 }
391
392 self.run_stage_sequence(stages, ctx, dispatcher).await
393 }
394
395 pub async fn run_sequential(
400 &self,
401 commands: &[Command],
402 ctx: &mut ExecContext,
403 dispatcher: &dyn CommandDispatcher,
404 ) -> ExecResult {
405 let stages: Vec<PipelineStage> = commands
406 .iter()
407 .cloned()
408 .map(PipelineStage::Command)
409 .collect();
410 self.run_stage_sequence(&stages, ctx, dispatcher).await
411 }
412
413 async fn run_stage_sequence(
415 &self,
416 stages: &[PipelineStage],
417 ctx: &mut ExecContext,
418 dispatcher: &dyn CommandDispatcher,
419 ) -> ExecResult {
420 if stages.is_empty() {
421 return ExecResult::success("");
422 }
423
424 if stages.len() == 1 {
425 return self.run_single(&stages[0], ctx, None, dispatcher).await;
427 }
428
429 self.run_pipeline(stages, ctx, dispatcher).await
431 }
432
433 async fn run_scatter_gather(
435 &self,
436 commands: &[Command],
437 scatter_idx: usize,
438 gather_idx: usize,
439 ctx: &mut ExecContext,
440 dispatcher: &dyn CommandDispatcher,
441 ) -> ExecResult {
442 let pre_scatter = &commands[..scatter_idx];
444 let scatter_cmd = &commands[scatter_idx];
445 let parallel = &commands[scatter_idx + 1..gather_idx];
446 let gather_cmd = &commands[gather_idx];
447 let post_gather = &commands[gather_idx + 1..];
448
449 let format = (has_json_flag(&scatter_cmd.args) || has_json_flag(&gather_cmd.args))
460 .then_some(OutputFormat::Json);
461
462 let scatter_schema = self.tools.get("scatter").map(|t| t.schema());
470 let gather_schema = self.tools.get("gather").map(|t| t.schema());
471 let scatter_args = match build_tool_args(&scatter_cmd.args, ctx, scatter_schema.as_ref()).await {
472 Ok(args) => args,
473 Err(e) => {
474 return finalize_scatter_gather_error(
475 ExecResult::failure(1, format!("scatter: {e}")),
476 format,
477 )
478 }
479 };
480 let gather_args = match build_tool_args(&gather_cmd.args, ctx, gather_schema.as_ref()).await {
481 Ok(args) => args,
482 Err(e) => {
483 return finalize_scatter_gather_error(
484 ExecResult::failure(1, format!("gather: {e}")),
485 format,
486 )
487 }
488 };
489 let scatter_opts = match parse_scatter_options(&scatter_args) {
490 Ok(opts) => opts,
491 Err(e) => {
492 return finalize_scatter_gather_error(
493 ExecResult::failure(2, format!("scatter: {e}")),
494 format,
495 )
496 }
497 };
498 let gather_opts = match parse_gather_options(&gather_args) {
499 Ok(opts) => opts,
500 Err(e) => {
501 return finalize_scatter_gather_error(
502 ExecResult::failure(2, format!("gather: {e}")),
503 format,
504 )
505 }
506 };
507
508 let sequential_dispatcher: Arc<dyn CommandDispatcher> = dispatcher.fork_attached().await;
513
514 let runner = ScatterGatherRunner::new(self.tools.clone(), sequential_dispatcher);
515 runner
516 .run(
517 pre_scatter,
518 scatter_opts,
519 parallel,
520 gather_opts,
521 &gather_cmd.redirects,
522 post_gather,
523 ctx,
524 )
525 .await
526 }
527
528 async fn run_single(
533 &self,
534 stage: &PipelineStage,
535 ctx: &mut ExecContext,
536 stdin: Option<Vec<u8>>,
537 dispatcher: &dyn CommandDispatcher,
538 ) -> ExecResult {
539 if let Err(e) = setup_stdin_redirects_for(stage, ctx, dispatcher).await {
541 return ExecResult::failure(1, e);
542 }
543
544 if let Some(input) = stdin {
546 ctx.set_stdin(input);
547 }
548
549 ctx.pipeline_position = PipelinePosition::Only;
551
552 let result = match dispatch_stage(stage, ctx, dispatcher).await {
554 Ok(result) => result,
555 Err(e) => ExecResult::failure(1, e.to_string()),
556 };
557
558 apply_redirects(result, stage.redirects(), ctx, dispatcher).await
560 }
561
562 async fn run_pipeline(
579 &self,
580 stages: &[PipelineStage],
581 ctx: &mut ExecContext,
582 dispatcher: &dyn CommandDispatcher,
583 ) -> ExecResult {
584 let stage_count = stages.len();
585 let last_idx = stage_count - 1;
586
587 let mut pipe_writers: Vec<Option<super::pipe_stream::PipeWriter>> = Vec::new();
589 let mut pipe_readers: Vec<Option<super::pipe_stream::PipeReader>> = Vec::new();
590
591 for _ in 0..last_idx {
592 let (writer, reader) = pipe_stream_default();
593 pipe_writers.push(Some(writer));
594 pipe_readers.push(Some(reader));
595 }
596
597 let mut data_senders: Vec<Option<tokio::sync::oneshot::Sender<Option<Value>>>> = Vec::new();
599 let mut data_receivers: Vec<Option<tokio::sync::oneshot::Receiver<Option<Value>>>> = Vec::new();
600
601 for _ in 0..last_idx {
602 let (tx, rx) = tokio::sync::oneshot::channel();
603 data_senders.push(Some(tx));
604 data_receivers.push(Some(rx));
605 }
606
607 let mut handles: Vec<tokio::task::JoinHandle<(ExecResult, ExecContext)>> = Vec::with_capacity(stage_count);
608 let mut stage0_took_session_stdin = false;
611 let mut stage0_took_session_pipe_stdin = false;
617
618 for (i, stage) in stages.iter().enumerate() {
619 let mut stage_ctx = ctx.child_for_pipeline();
620 let stage = stage.clone();
621
622 let task_dispatcher: Arc<dyn CommandDispatcher> = dispatcher.fork_attached().await;
627
628 let stdin_setup = setup_stdin_redirects_for(&stage, &mut stage_ctx, dispatcher).await;
632
633 if i == 0 {
635 let redirect_set_stdin = stage_ctx.stdin.is_some();
642 stage0_took_session_stdin = !redirect_set_stdin;
643 if !redirect_set_stdin {
646 stage_ctx.stdin = ctx.stdin.take();
647 }
648 if stage_ctx.stdin_data.is_none() {
649 stage_ctx.stdin_data = ctx.stdin_data.take();
650 }
651 if !redirect_set_stdin && stage_ctx.pipe_stdin.is_none() {
660 stage_ctx.pipe_stdin = ctx.pipe_stdin.take();
661 stage0_took_session_pipe_stdin = true;
662 }
663 } else {
664 stage_ctx.pipe_stdin = pipe_readers[i - 1].take();
666 }
668
669 if i < last_idx {
671 stage_ctx.pipe_stdout = pipe_writers[i].take();
672 }
673
674 stage_ctx.pipeline_position = match i {
676 0 => PipelinePosition::First,
677 n if n == last_idx => PipelinePosition::Last,
678 _ => PipelinePosition::Middle,
679 };
680
681 let data_sender = if i < last_idx { data_senders[i].take() } else { None };
682 let data_receiver = if i > 0 { data_receivers[i - 1].take() } else { None };
683
684 let handle: tokio::task::JoinHandle<(ExecResult, ExecContext)> =
687 tokio::spawn(crate::telemetry::bind_current_context(async move {
688 if let Err(e) = stdin_setup {
690 return (ExecResult::failure(1, e), stage_ctx);
691 }
692
693 stage_ctx.stdin_data_rx = data_receiver;
701
702 let mut result = match dispatch_stage(&stage, &mut stage_ctx, &*task_dispatcher).await {
704 Ok(result) => result,
705 Err(e) => ExecResult::failure(1, e.to_string()),
706 };
707
708 result = apply_redirects(result, stage.redirects(), &stage_ctx, &*task_dispatcher).await;
713
714 if !result.err.is_empty() {
720 if let Some(ref stderr) = stage_ctx.stderr {
721 stderr.write_str(&result.err);
722 result.err.clear();
723 }
724 }
725
726 if let Some(tx) = data_sender {
732 let _ = tx.send(result.data.clone());
733 }
734
735 if let Some(mut pipe_out) = stage_ctx.pipe_stdout.take() {
738 let bytes: Vec<u8> = if let Some(b) = result.out_bytes() {
746 b.to_vec()
747 } else if let Some(output) = result.take_output_for_stream() {
748 let mut buf = Vec::new();
749 if output.write_canonical(&mut buf, None).is_err() {
755 buf = output.to_canonical_string().into_bytes();
756 }
757 buf
758 } else {
759 result.text_out().into_owned().into_bytes()
760 };
761 if !bytes.is_empty() {
762 let _ = pipe_out.write_all(&bytes).await;
764 let _ = pipe_out.shutdown().await;
765 }
766 }
768
769 (result, stage_ctx)
770 }));
771
772 handles.push(handle);
773 }
774
775 let mut last_result = ExecResult::success("");
780 let mut panics: Vec<String> = Vec::new();
781
782 for (i, handle) in handles.into_iter().enumerate() {
783 match handle.await {
784 Ok((result, mut stage_ctx)) => {
785 if i == 0 && stage0_took_session_stdin {
796 ctx.stdin = stage_ctx.stdin.take();
797 }
798 if i == 0 && stage0_took_session_pipe_stdin {
799 ctx.pipe_stdin = stage_ctx.pipe_stdin.take();
800 }
801 if i == last_idx {
802 last_result = result;
803 ctx.scope = stage_ctx.scope;
805 ctx.cwd = stage_ctx.cwd;
806 ctx.prev_cwd = stage_ctx.prev_cwd;
807 ctx.aliases = stage_ctx.aliases;
808 }
809 }
810 Err(e) => {
811 panics.push(format!("stage {}: {}", i, e));
812 }
813 }
814 }
815
816
817 if !panics.is_empty() {
820 last_result = ExecResult::failure(
821 1,
822 format!("pipeline stage(s) panicked: {}", panics.join("; ")),
823 );
824 }
825
826 last_result
827 }
828}
829
830pub fn select_leaf<'a>(schema: &'a ToolSchema, args: &[Arg]) -> anyhow::Result<&'a ToolSchema> {
870 let root_lookup = schema_param_lookup(schema);
873 let is_root_value_flag = |name: &str| -> bool {
874 root_lookup.get(name).is_some_and(|(_, typ, ..)| !is_bool_type(typ))
875 };
876
877 let mut node = schema;
878 let mut skip_next_positional = false;
879 for arg in args {
880 match arg {
881 Arg::DoubleDash => break,
883 Arg::LongFlag(name) if is_root_value_flag(name) => skip_next_positional = true,
886 Arg::ShortFlag(name) if is_root_value_flag(name) => skip_next_positional = true,
887 Arg::Positional(expr) => {
888 if skip_next_positional {
889 skip_next_positional = false;
890 continue; }
892 if node.subcommands.is_empty() {
893 break; }
895 match classify_subcommand_positional(expr) {
896 SubcommandWord::Word(word) => {
897 match node.subcommands.iter().find(|c| c.matches_command(word)) {
898 Some(child) => node = child, None => break, }
901 }
902 SubcommandWord::OtherLiteral => break,
906 SubcommandWord::Computed(kind) => anyhow::bail!(
907 "{}: a subcommand name is required here, but got {kind}. \
908 Subcommands must be literal words — spell it out \
909 (e.g. `{} <subcommand> …`) or use the `--flag=value` form.",
910 node.name,
911 schema.name
912 ),
913 }
914 }
915 _ => {}
917 }
918 }
919 Ok(node)
920}
921
922enum SubcommandWord<'a> {
924 Word(&'a str),
926 OtherLiteral,
928 Computed(&'static str),
930}
931
932fn classify_subcommand_positional(expr: &Expr) -> SubcommandWord<'_> {
933 match expr {
934 Expr::Literal(Value::String(s)) => SubcommandWord::Word(s),
935 Expr::Literal(_) => SubcommandWord::OtherLiteral,
936 Expr::CommandSubst(_) | Expr::Command(_) => SubcommandWord::Computed("a command substitution `$(…)`"),
937 Expr::VarRef(_)
938 | Expr::VarWithDefault { .. }
939 | Expr::VarLength(_)
940 | Expr::Positional(_)
941 | Expr::AllArgs
942 | Expr::ArgCount
943 | Expr::CurrentPid
944 | Expr::LastExitCode => SubcommandWord::Computed("a variable reference"),
945 Expr::Interpolated(_) | Expr::HereDocBody { .. } => SubcommandWord::Computed("an interpolated string"),
946 Expr::GlobPattern(_) => SubcommandWord::Computed("a glob pattern"),
947 Expr::Arithmetic(_) => SubcommandWord::Computed("an arithmetic expansion"),
948 _ => SubcommandWord::Computed("a value computed at runtime"),
949 }
950}
951
952pub fn schema_param_lookup(schema: &ToolSchema) -> HashMap<String, (&str, &str, usize, bool)> {
953 let mut map = HashMap::new();
954 for p in schema.params.iter().filter(|p| !p.positional) {
955 map.insert(p.name.clone(), (p.name.as_str(), p.param_type.as_str(), p.consumes, p.repeatable));
956 for alias in &p.aliases {
957 let stripped = alias.trim_start_matches('-');
958 map.insert(stripped.to_string(), (p.name.as_str(), p.param_type.as_str(), p.consumes, p.repeatable));
959 }
960 }
961 map
962}
963
964pub fn is_bool_type(param_type: &str) -> bool {
966 matches!(param_type.to_lowercase().as_str(), "bool" | "boolean")
967}
968
969struct SyncEvalSource<'a> {
977 ctx: &'a ExecContext,
978}
979
980#[async_trait::async_trait]
981impl crate::kernel::ArgValueSource for SyncEvalSource<'_> {
982 async fn eval(&self, expr: &Expr) -> anyhow::Result<Option<Value>> {
983 eval_simple_expr(expr, self.ctx).map_err(|e| anyhow::anyhow!(e))
984 }
985
986 async fn expand_glob(&self, _pattern: &str) -> anyhow::Result<Option<Vec<String>>> {
987 Ok(None)
993 }
994
995 async fn home(&self) -> Option<String> {
996 None
999 }
1000}
1001
1002pub async fn build_tool_args(
1016 args: &[Arg],
1017 ctx: &ExecContext,
1018 schema: Option<&ToolSchema>,
1019) -> Result<ToolArgs, String> {
1020 crate::kernel::bind_tool_args(args, schema, &SyncEvalSource { ctx })
1021 .await
1022 .map_err(|e| e.to_string())
1023}
1024
1025pub(crate) fn eval_simple_expr(expr: &Expr, ctx: &ExecContext) -> Result<Option<Value>, String> {
1041 match expr {
1042 Expr::Literal(value) => Ok(Some(eval_literal(value, ctx))),
1043 Expr::VarRef(path) => match ctx.scope.resolve_path(path) {
1044 Ok(v) => Ok(Some(v)),
1045 Err(PathError::UndefinedRoot(_)) if path.segments.len() <= 1 => Ok(None),
1052 Err(PathError::UndefinedRoot(_)) => Err(format!(
1053 "{}: undefined variable",
1054 crate::interpreter::format_path(path)
1055 )),
1056 Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => Err(msg),
1059 },
1060 Expr::Interpolated(parts) => Ok(Some(Value::String(eval_string_parts_sync(parts, ctx)?))),
1061 Expr::VarLength(path) => {
1066 crate::interpreter::resolve_length(&ctx.scope, path).map(|n| Some(Value::Int(n)))
1067 }
1068 Expr::VarWithDefault { path, default } => {
1069 match crate::interpreter::resolve_default(&ctx.scope, path)? {
1070 Some(value) => Ok(Some(value)),
1071 None => Ok(Some(Value::String(eval_string_parts_sync(default, ctx)?))),
1072 }
1073 }
1074 Expr::GlobPattern(s) => Ok(Some(Value::String(s.clone()))),
1075 Expr::Arithmetic(expr_str) => arithmetic::eval_arithmetic(expr_str, &ctx.scope)
1083 .map(|n| Some(Value::Int(n)))
1084 .map_err(|e| format!("arithmetic error: {e}")),
1085 Expr::HereDocBody { parts, strip_tabs } => {
1086 let mut asm = crate::interpreter::HeredocAssembler::new(*strip_tabs);
1090 for sp in parts {
1091 match &sp.part {
1092 crate::ast::StringPart::Literal(s) => asm.push_literal(s),
1093 other => {
1094 let s = eval_string_parts_sync(std::slice::from_ref(other), ctx)?;
1095 asm.push_interpolated(&s);
1096 }
1097 }
1098 }
1099 Ok(Some(Value::String(asm.into_string())))
1100 }
1101 Expr::CommandSubst(_) | Expr::Command(_) => Err(
1108 "command substitution `$(...)` is not supported in a scatter/gather flag value here; \
1109 assign it to a variable first (e.g. `n=$(...); scatter --limit $n`)"
1110 .to_string(),
1111 ),
1112 _ => Ok(None), }
1114}
1115
1116fn eval_literal(value: &Value, _ctx: &ExecContext) -> Value {
1118 value.clone()
1119}
1120
1121fn eval_string_parts_sync(parts: &[crate::ast::StringPart], ctx: &ExecContext) -> Result<String, String> {
1129 let mut result = String::new();
1130 for part in parts {
1131 match part {
1132 crate::ast::StringPart::Literal(s) => result.push_str(s),
1133 crate::ast::StringPart::Var(path) => match ctx.scope.resolve_path(path) {
1134 Ok(value) => result.push_str(
1138 &crate::interpreter::value_to_text_sink(&value).map_err(|e| e.to_string())?,
1139 ),
1140 Err(PathError::UndefinedRoot(_)) => {}
1148 Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => return Err(msg),
1149 },
1150 crate::ast::StringPart::VarWithDefault { path, default } => {
1151 match crate::interpreter::resolve_default(&ctx.scope, path)? {
1152 Some(value) => result.push_str(
1153 &crate::interpreter::value_to_text_sink(&value).map_err(|e| e.to_string())?,
1154 ),
1155 None => result.push_str(&eval_string_parts_sync(default, ctx)?),
1156 }
1157 }
1158 crate::ast::StringPart::VarLength(path) => {
1159 let len = crate::interpreter::resolve_length(&ctx.scope, path)?;
1164 result.push_str(&len.to_string());
1165 }
1166 crate::ast::StringPart::Positional(n) => {
1167 if let Some(s) = ctx.scope.get_positional(*n) {
1168 result.push_str(s);
1169 }
1170 }
1171 crate::ast::StringPart::AllArgs => {
1172 result.push_str(&ctx.scope.all_args().join(" "));
1173 }
1174 crate::ast::StringPart::ArgCount => {
1175 result.push_str(&ctx.scope.arg_count().to_string());
1176 }
1177 crate::ast::StringPart::Arithmetic(expr) => {
1178 let value = arithmetic::eval_arithmetic(expr, &ctx.scope)
1186 .map_err(|e| format!("arithmetic error: {e}"))?;
1187 result.push_str(&value.to_string());
1188 }
1189 crate::ast::StringPart::CommandSubst(_) => {
1190 return Err(
1196 "command substitution `$(...)` is not supported inside a scatter/gather \
1197 flag's interpolated value here; assign it to a variable first"
1198 .to_string(),
1199 );
1200 }
1201 crate::ast::StringPart::LastExitCode => {
1202 result.push_str(&ctx.scope.last_result().code.to_string());
1203 }
1204 crate::ast::StringPart::CurrentPid => {
1205 result.push_str(&ctx.scope.pid().to_string());
1206 }
1207 }
1208 }
1209 Ok(result)
1210}
1211
1212fn find_scatter_gather(stages: &[PipelineStage]) -> Option<(usize, usize)> {
1217 let named = |name: &str| {
1218 stages
1219 .iter()
1220 .position(|s| s.as_command().is_some_and(|c| c.name == name))
1221 };
1222 let scatter_idx = named("scatter")?;
1223 let gather_idx = named("gather")?;
1224
1225 if gather_idx > scatter_idx {
1227 Some((scatter_idx, gather_idx))
1228 } else {
1229 None
1230 }
1231}
1232
1233#[cfg(test)]
1234mod select_leaf_tests {
1235 use super::*;
1236 use crate::tools::ParamSchema;
1237
1238 fn kj_schema() -> ToolSchema {
1243 ToolSchema::new("kj", "kaijutsu")
1244 .param(ParamSchema::new("confirm", "string"))
1245 .param(ParamSchema::new("verbose", "bool"))
1246 .subcommand(
1247 ToolSchema::new("context", "context ops")
1248 .with_command_aliases(["ctx"])
1249 .subcommand(ToolSchema::new("list", "list").with_command_aliases(["ls"]))
1250 .subcommand(
1251 ToolSchema::new("create", "create").param(
1252 ParamSchema::new("type", "string").with_aliases(["t"]),
1253 ),
1254 ),
1255 )
1256 }
1257
1258 fn word(s: &str) -> Arg {
1259 Arg::Positional(Expr::Literal(Value::String(s.to_string())))
1260 }
1261
1262 #[test]
1263 fn flat_tool_returns_root() {
1264 let schema = ToolSchema::new("cat", "concat")
1265 .param(ParamSchema::required("path", "string", "f").positional());
1266 let leaf = select_leaf(&schema, &[word("foo.txt")]).expect("flat ok");
1267 assert_eq!(leaf.name, "cat");
1268 }
1269
1270 #[test]
1271 fn single_hop() {
1272 let schema = kj_schema();
1273 let leaf = select_leaf(&schema, &[word("context")]).expect("ok");
1274 assert_eq!(leaf.name, "context");
1275 }
1276
1277 #[test]
1278 fn two_hops() {
1279 let schema = kj_schema();
1280 let leaf = select_leaf(&schema, &[word("context"), word("create")]).expect("ok");
1281 assert_eq!(leaf.name, "create");
1282 assert!(leaf.params.iter().any(|p| p.name == "type"), "leaf has --type");
1283 }
1284
1285 #[test]
1286 fn alias_hops_route() {
1287 let schema = kj_schema();
1288 let leaf = select_leaf(&schema, &[word("ctx"), word("ls")]).expect("ok");
1290 assert_eq!(leaf.name, "list");
1291 }
1292
1293 #[test]
1294 fn unknown_subcommand_stops_at_current_node() {
1295 let schema = kj_schema();
1296 let leaf = select_leaf(&schema, &[word("context"), word("nonesuch")]).expect("ok");
1299 assert_eq!(leaf.name, "context");
1300 }
1301
1302 #[test]
1303 fn root_bool_flag_before_path_does_not_disrupt_routing() {
1304 let schema = kj_schema();
1305 let args = vec![Arg::LongFlag("verbose".into()), word("context"), word("create")];
1308 let leaf = select_leaf(&schema, &args).expect("ok");
1309 assert_eq!(leaf.name, "create");
1310 }
1311
1312 #[test]
1313 fn root_value_flag_space_form_before_path_skips_its_value() {
1314 let schema = kj_schema();
1315 let args = vec![
1318 Arg::LongFlag("confirm".into()),
1319 word("token"),
1320 word("context"),
1321 word("create"),
1322 ];
1323 let leaf = select_leaf(&schema, &args).expect("ok");
1324 assert_eq!(leaf.name, "create");
1325 }
1326
1327 #[test]
1328 fn leaf_value_flag_after_path_routes_to_leaf() {
1329 let schema = kj_schema();
1330 let args = vec![
1333 word("context"),
1334 word("create"),
1335 Arg::LongFlag("type".into()),
1336 word("x"),
1337 ];
1338 let leaf = select_leaf(&schema, &args).expect("ok");
1339 assert_eq!(leaf.name, "create");
1340 assert!(leaf.params.iter().any(|p| p.name == "type"));
1341 }
1342
1343 #[test]
1344 fn double_dash_stops_routing() {
1345 let schema = kj_schema();
1346 let leaf = select_leaf(&schema, &[Arg::DoubleDash, word("context")]).expect("ok");
1348 assert_eq!(leaf.name, "kj");
1349 }
1350
1351 #[test]
1352 fn computed_subcommand_selector_errors() {
1353 let schema = kj_schema();
1354 let args = vec![Arg::Positional(Expr::CommandSubst(vec![
1357 crate::ast::Stmt::Command(crate::ast::Command {
1358 name: "echo".into(),
1359 args: vec![],
1360 redirects: vec![],
1361 }),
1362 ]))];
1363 let err = select_leaf(&schema, &args).expect_err("must error");
1364 let msg = err.to_string();
1365 assert!(msg.contains("subcommand name is required"), "got: {msg}");
1366 assert!(msg.contains("command substitution"), "names the cause: {msg}");
1367 }
1368
1369 #[test]
1370 fn variable_subcommand_selector_errors() {
1371 let schema = kj_schema();
1372 let args = vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("sub")))];
1373 let err = select_leaf(&schema, &args).expect_err("must error");
1374 assert!(err.to_string().contains("variable reference"), "got: {err}");
1375 }
1376
1377 #[test]
1378 fn computed_positional_after_leaf_is_fine() {
1379 let schema = kj_schema();
1380 let args = vec![
1383 word("context"),
1384 word("list"),
1385 Arg::Positional(Expr::CommandSubst(vec![crate::ast::Stmt::Command(
1386 crate::ast::Command { name: "echo".into(), args: vec![], redirects: vec![] },
1387 )])),
1388 ];
1389 let leaf = select_leaf(&schema, &args).expect("ok");
1390 assert_eq!(leaf.name, "list");
1391 }
1392}
1393
1394#[cfg(test)]
1395mod tests {
1396 use super::*;
1397 use crate::dispatch::BackendDispatcher;
1398 use crate::tools::register_builtins;
1399 use crate::vfs::{Filesystem, MemoryFs, VfsRouter};
1400 use std::path::Path;
1401
1402 async fn make_runner_and_ctx() -> (PipelineRunner, ExecContext, BackendDispatcher) {
1403 let mut tools = ToolRegistry::new();
1404 register_builtins(&mut tools);
1405 let tools = Arc::new(tools);
1406 let runner = PipelineRunner::new(tools.clone());
1407 let dispatcher = BackendDispatcher::new(tools.clone());
1408
1409 let mut vfs = VfsRouter::new();
1410 let mem = MemoryFs::new();
1411 mem.write(Path::new("test.txt"), b"hello\nworld\nfoo").await.unwrap();
1412 vfs.mount("/", mem);
1413 let ctx = ExecContext::with_vfs_and_tools(Arc::new(vfs), tools);
1414
1415 (runner, ctx, dispatcher)
1416 }
1417
1418 fn stages(commands: impl IntoIterator<Item = Command>) -> Vec<PipelineStage> {
1420 commands.into_iter().map(PipelineStage::Command).collect()
1421 }
1422
1423 fn make_cmd(name: &str, args: Vec<&str>) -> Command {
1424 Command {
1425 name: name.to_string(),
1426 args: args.iter().map(|s| Arg::Positional(Expr::Literal(Value::String(s.to_string())))).collect(),
1427 redirects: vec![],
1428 }
1429 }
1430
1431 #[tokio::test]
1432 async fn test_single_command() {
1433 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1434 let cmd = make_cmd("echo", vec!["hello"]);
1435
1436 let result = runner.run(&stages([cmd]), &mut ctx, &dispatcher).await;
1437 assert!(result.ok());
1438 assert_eq!(result.text_out().trim(), "hello");
1439 }
1440
1441 #[tokio::test]
1442 async fn test_pipeline_echo_grep() {
1443 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1444
1445 let echo_cmd = Command {
1447 name: "echo".to_string(),
1448 args: vec![Arg::Positional(Expr::Literal(Value::String("hello\nworld".to_string())))],
1449 redirects: vec![],
1450 };
1451 let grep_cmd = Command {
1452 name: "grep".to_string(),
1453 args: vec![Arg::Positional(Expr::Literal(Value::String("world".to_string())))],
1454 redirects: vec![],
1455 };
1456
1457 let result = runner.run(&stages([echo_cmd, grep_cmd]), &mut ctx, &dispatcher).await;
1458 assert!(result.ok());
1459 assert_eq!(result.text_out().trim(), "world");
1460 }
1461
1462 #[tokio::test]
1463 async fn test_pipeline_cat_grep() {
1464 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1465
1466 let cat_cmd = make_cmd("cat", vec!["/test.txt"]);
1468 let grep_cmd = Command {
1469 name: "grep".to_string(),
1470 args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
1471 redirects: vec![],
1472 };
1473
1474 let result = runner.run(&stages([cat_cmd, grep_cmd]), &mut ctx, &dispatcher).await;
1475 assert!(result.ok());
1476 assert!(result.text_out().contains("hello"));
1477 }
1478
1479 #[tokio::test]
1480 async fn test_command_not_found() {
1481 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1482 let cmd = make_cmd("nonexistent", vec![]);
1483
1484 let result = runner.run(&stages([cmd]), &mut ctx, &dispatcher).await;
1485 assert!(!result.ok());
1486 assert_eq!(result.code, 127);
1487 assert!(result.err.contains("not found"));
1488 }
1489
1490 #[tokio::test]
1491 async fn test_pipeline_continues_on_failure() {
1492 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1495
1496 let cat_cmd = make_cmd("cat", vec!["/nonexistent"]);
1499 let grep_cmd = Command {
1500 name: "grep".to_string(),
1501 args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
1502 redirects: vec![],
1503 };
1504
1505 let result = runner.run(&stages([cat_cmd, grep_cmd]), &mut ctx, &dispatcher).await;
1506 assert!(!result.ok());
1508 }
1509
1510 #[tokio::test]
1511 async fn test_pipeline_last_command_exit_code() {
1512 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1514
1515 let echo_cmd = make_cmd("echo", vec!["hello"]);
1516 let cat_cmd = make_cmd("cat", vec![]);
1517
1518 let result = runner.run(&stages([echo_cmd, cat_cmd]), &mut ctx, &dispatcher).await;
1519 assert!(result.ok());
1520 assert!(result.text_out().contains("hello"));
1521 }
1522
1523 #[tokio::test]
1524 async fn test_empty_pipeline() {
1525 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1526 let result = runner.run(&stages([]), &mut ctx, &dispatcher).await;
1527 assert!(result.ok());
1528 }
1529
1530 #[test]
1533 fn test_find_scatter_gather_both_present() {
1534 let commands = vec![
1535 make_cmd("echo", vec!["a"]),
1536 make_cmd("scatter", vec![]),
1537 make_cmd("process", vec![]),
1538 make_cmd("gather", vec![]),
1539 ];
1540 let result = find_scatter_gather(&stages(commands));
1541 assert_eq!(result, Some((1, 3)));
1542 }
1543
1544 #[test]
1545 fn test_find_scatter_gather_no_scatter() {
1546 let commands = vec![
1547 make_cmd("echo", vec!["a"]),
1548 make_cmd("gather", vec![]),
1549 ];
1550 let result = find_scatter_gather(&stages(commands));
1551 assert!(result.is_none());
1552 }
1553
1554 #[test]
1555 fn test_find_scatter_gather_no_gather() {
1556 let commands = vec![
1557 make_cmd("echo", vec!["a"]),
1558 make_cmd("scatter", vec![]),
1559 ];
1560 let result = find_scatter_gather(&stages(commands));
1561 assert!(result.is_none());
1562 }
1563
1564 #[test]
1565 fn test_find_scatter_gather_wrong_order() {
1566 let commands = vec![
1567 make_cmd("gather", vec![]),
1568 make_cmd("scatter", vec![]),
1569 ];
1570 let result = find_scatter_gather(&stages(commands));
1571 assert!(result.is_none());
1572 }
1573
1574 #[tokio::test]
1575 async fn test_scatter_gather_simple() {
1576 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1577
1578 let split_cmd = Command {
1580 name: "split".to_string(),
1581 args: vec![Arg::Positional(Expr::Literal(Value::String("a b c".to_string())))],
1582 redirects: vec![],
1583 };
1584 let scatter_cmd = make_cmd("scatter", vec![]);
1585 let process_cmd = Command {
1586 name: "echo".to_string(),
1587 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1588 redirects: vec![],
1589 };
1590 let gather_cmd = make_cmd("gather", vec![]);
1591
1592 let result = runner.run(&stages([split_cmd, scatter_cmd, process_cmd, gather_cmd]), &mut ctx, &dispatcher).await;
1593 assert!(result.ok(), "scatter with structured data should succeed: {}", result.err);
1594 assert!(result.text_out().contains("a"));
1596 assert!(result.text_out().contains("b"));
1597 assert!(result.text_out().contains("c"));
1598 }
1599
1600 #[tokio::test]
1601 async fn test_scatter_gather_empty_input() {
1602 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1603
1604 let echo_cmd = Command {
1606 name: "echo".to_string(),
1607 args: vec![Arg::Positional(Expr::Literal(Value::String("".to_string())))],
1608 redirects: vec![],
1609 };
1610 let scatter_cmd = make_cmd("scatter", vec![]);
1611 let process_cmd = Command {
1612 name: "echo".to_string(),
1613 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1614 redirects: vec![],
1615 };
1616 let gather_cmd = make_cmd("gather", vec![]);
1617
1618 let result = runner.run(&stages([echo_cmd, scatter_cmd, process_cmd, gather_cmd]), &mut ctx, &dispatcher).await;
1619 assert!(result.ok());
1620 assert!(result.text_out().trim().is_empty());
1621 }
1622
1623 #[tokio::test]
1624 async fn test_scatter_gather_with_structured_stdin() {
1625 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1626
1627 let data = Value::Json(serde_json::json!(["x", "y", "z"]));
1629 ctx.set_stdin_with_data("x\ny\nz".to_string(), Some(data));
1630
1631 let scatter_cmd = make_cmd("scatter", vec![]);
1632 let process_cmd = Command {
1633 name: "echo".to_string(),
1634 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1635 redirects: vec![],
1636 };
1637 let gather_cmd = make_cmd("gather", vec![]);
1638
1639 let result = runner.run(&stages([scatter_cmd, process_cmd, gather_cmd]), &mut ctx, &dispatcher).await;
1640 assert!(result.ok(), "scatter with structured stdin should succeed: {}", result.err);
1641 assert!(result.text_out().contains("x"));
1642 assert!(result.text_out().contains("y"));
1643 assert!(result.text_out().contains("z"));
1644 }
1645
1646 #[tokio::test]
1647 async fn test_scatter_gather_json_input() {
1648 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1649
1650 let data = Value::Json(serde_json::json!(["one", "two", "three"]));
1652 ctx.set_stdin_with_data(r#"["one", "two", "three"]"#.to_string(), Some(data));
1653
1654 let scatter_cmd = make_cmd("scatter", vec![]);
1655 let process_cmd = Command {
1656 name: "echo".to_string(),
1657 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1658 redirects: vec![],
1659 };
1660 let gather_cmd = make_cmd("gather", vec![]);
1661
1662 let result = runner.run(&stages([scatter_cmd, process_cmd, gather_cmd]), &mut ctx, &dispatcher).await;
1663 assert!(result.ok(), "scatter with JSON data should succeed: {}", result.err);
1664 assert!(result.text_out().contains("one"));
1665 assert!(result.text_out().contains("two"));
1666 assert!(result.text_out().contains("three"));
1667 }
1668
1669 #[tokio::test]
1670 async fn test_scatter_gather_with_post_gather() {
1671 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1672
1673 let split_cmd = Command {
1675 name: "split".to_string(),
1676 args: vec![Arg::Positional(Expr::Literal(Value::String("a b".to_string())))],
1677 redirects: vec![],
1678 };
1679 let scatter_cmd = make_cmd("scatter", vec![]);
1680 let process_cmd = Command {
1681 name: "echo".to_string(),
1682 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1683 redirects: vec![],
1684 };
1685 let gather_cmd = make_cmd("gather", vec![]);
1686 let grep_cmd = Command {
1687 name: "grep".to_string(),
1688 args: vec![Arg::Positional(Expr::Literal(Value::String("a".to_string())))],
1689 redirects: vec![],
1690 };
1691
1692 let result = runner.run(&stages([split_cmd, scatter_cmd, process_cmd, gather_cmd, grep_cmd]), &mut ctx, &dispatcher).await;
1693 assert!(result.ok(), "scatter with post_gather should succeed: {}", result.err);
1694 assert!(result.text_out().contains("a"));
1695 assert!(!result.text_out().contains("b"));
1696 }
1697
1698 #[tokio::test]
1699 async fn test_scatter_custom_var_name() {
1700 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1701
1702 let data = Value::Json(serde_json::json!(["test1", "test2"]));
1704 ctx.set_stdin_with_data("test1\ntest2".to_string(), Some(data));
1705
1706 let scatter_cmd = Command {
1708 name: "scatter".to_string(),
1709 args: vec![Arg::Named {
1710 key: "as".to_string(),
1711 value: Expr::Literal(Value::String("URL".to_string())),
1712 }],
1713 redirects: vec![],
1714 };
1715 let process_cmd = Command {
1716 name: "echo".to_string(),
1717 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("URL")))],
1718 redirects: vec![],
1719 };
1720 let gather_cmd = make_cmd("gather", vec![]);
1721
1722 let result = runner.run(&stages([scatter_cmd, process_cmd, gather_cmd]), &mut ctx, &dispatcher).await;
1723 assert!(result.ok(), "scatter with custom var should succeed: {}", result.err);
1724 assert!(result.text_out().contains("test1"));
1725 assert!(result.text_out().contains("test2"));
1726 }
1727
1728 #[tokio::test]
1731 async fn test_pipeline_routes_through_backend() {
1732 use crate::backend::testing::MockBackend;
1733 use std::sync::atomic::Ordering;
1734
1735 let (backend, call_count) = MockBackend::new();
1737 let backend: std::sync::Arc<dyn crate::backend::KernelBackend> = std::sync::Arc::new(backend);
1738
1739 let mut ctx = crate::tools::ExecContext::with_backend(backend);
1741
1742 let tools = std::sync::Arc::new(ToolRegistry::new());
1744 let runner = PipelineRunner::new(tools.clone());
1745 let dispatcher = BackendDispatcher::new(tools);
1746
1747 let cmd = make_cmd("test-tool", vec!["arg1"]);
1749 let result = runner.run(&stages([cmd]), &mut ctx, &dispatcher).await;
1750
1751 assert!(result.ok(), "Mock backend should return success");
1752 assert_eq!(call_count.load(Ordering::SeqCst), 1, "call_tool should be invoked once");
1753 assert!(result.text_out().contains("mock executed"), "Output should be from mock backend");
1754 }
1755
1756 #[tokio::test]
1757 async fn test_multi_command_pipeline_routes_through_backend() {
1758 use crate::backend::testing::MockBackend;
1759 use std::sync::atomic::Ordering;
1760
1761 let (backend, call_count) = MockBackend::new();
1762 let backend: std::sync::Arc<dyn crate::backend::KernelBackend> = std::sync::Arc::new(backend);
1763 let mut ctx = crate::tools::ExecContext::with_backend(backend);
1764
1765 let tools = std::sync::Arc::new(ToolRegistry::new());
1766 let runner = PipelineRunner::new(tools.clone());
1767 let dispatcher = BackendDispatcher::new(tools);
1768
1769 let cmd1 = make_cmd("tool1", vec![]);
1771 let cmd2 = make_cmd("tool2", vec![]);
1772 let cmd3 = make_cmd("tool3", vec![]);
1773
1774 let result = runner.run(&stages([cmd1, cmd2, cmd3]), &mut ctx, &dispatcher).await;
1775
1776 assert!(result.ok());
1777 assert_eq!(call_count.load(Ordering::SeqCst), 3, "call_tool should be invoked for each command");
1778 }
1779
1780 #[tokio::test]
1787 async fn backend_dispatcher_scalar_data_matches_production_unwrap() {
1788 use crate::backend::testing::MockBackend;
1789 use crate::backend::ToolResult;
1790
1791 let (mock, _calls) = MockBackend::new();
1792 let backend = mock.with_tool_result(|_name| Ok(ToolResult::with_data("", serde_json::json!(42))));
1793 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
1794 let mut ctx = ExecContext::with_backend(backend);
1795
1796 let dispatcher = BackendDispatcher::new(Arc::new(ToolRegistry::new()));
1797 let cmd = make_cmd("embedder_tool", vec![]);
1798
1799 let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
1800 assert_eq!(
1801 result.data,
1802 Some(Value::Int(42)),
1803 "a scalar ToolResult.data must unwrap to a native Value, matching \
1804 the production From<ToolResult> path — not stay Value::Json(42)"
1805 );
1806 }
1807
1808 #[tokio::test]
1814 async fn backend_dispatcher_envelope_shaped_data_stays_structured() {
1815 use crate::backend::testing::MockBackend;
1816 use crate::backend::ToolResult;
1817
1818 let envelope = kaish_types::bytes_to_envelope(&[1u8, 2, 3]);
1819 let (mock, _calls) = MockBackend::new();
1820 let backend = mock.with_tool_result(move |_name| Ok(ToolResult::with_data("", envelope.clone())));
1821 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
1822 let mut ctx = ExecContext::with_backend(backend);
1823
1824 let dispatcher = BackendDispatcher::new(Arc::new(ToolRegistry::new()));
1825 let cmd = make_cmd("embedder_tool", vec![]);
1826
1827 let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
1828 assert!(
1829 matches!(result.data, Some(Value::Json(_))),
1830 "envelope-shaped external data must stay structured, not silently \
1831 decode to Value::Bytes: got {:?}",
1832 result.data
1833 );
1834 }
1835
1836 #[tokio::test]
1841 async fn backend_dispatcher_preserves_did_spill_and_original_code() {
1842 use crate::backend::testing::MockBackend;
1843 use crate::backend::ToolResult;
1844
1845 let (mock, _calls) = MockBackend::new();
1846 let backend = mock.with_tool_result(|_name| {
1847 Ok(ToolResult::success("truncated...")
1848 .with_did_spill(true)
1849 .with_original_code(Some(0)))
1850 });
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!(result.did_spill, "did_spill must survive the backend seam");
1859 assert_eq!(result.original_code, Some(0), "original_code must survive the backend seam");
1860 }
1861
1862 use crate::tools::{ParamSchema, ToolSchema};
1865
1866 fn make_test_schema() -> ToolSchema {
1867 ToolSchema::new("test-tool", "A test tool for schema-aware parsing")
1868 .param(ParamSchema::required("query", "string", "Search query"))
1869 .param(ParamSchema::optional("limit", "int", Value::Int(10), "Max results"))
1870 .param(ParamSchema::optional("verbose", "bool", Value::Bool(false), "Verbose output"))
1871 .param(ParamSchema::optional("output", "string", Value::String("stdout".into()), "Output destination"))
1872 .with_positional_mapping()
1873 }
1874
1875 fn make_minimal_ctx() -> ExecContext {
1876 let mut vfs = VfsRouter::new();
1877 vfs.mount("/", MemoryFs::new());
1878 ExecContext::new(Arc::new(vfs))
1879 }
1880
1881 fn test_dispatcher() -> BackendDispatcher {
1885 BackendDispatcher::new(Arc::new(ToolRegistry::new()))
1886 }
1887
1888 #[tokio::test]
1889 async fn test_schema_aware_string_arg() {
1890 let args = vec![
1892 Arg::LongFlag("query".to_string()),
1893 Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
1894 ];
1895 let schema = make_test_schema();
1896 let ctx = make_minimal_ctx();
1897
1898 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1899
1900 assert!(tool_args.flags.is_empty(), "No flags should be set");
1901 assert!(tool_args.positional.is_empty(), "No positionals - consumed by --query");
1902 assert_eq!(
1903 tool_args.named.get("query"),
1904 Some(&Value::String("test".to_string())),
1905 "--query should consume 'test' as its value"
1906 );
1907 }
1908
1909 #[tokio::test]
1910 async fn test_schema_aware_bool_flag() {
1911 let args = vec![
1913 Arg::LongFlag("verbose".to_string()),
1914 ];
1915 let schema = make_test_schema();
1916 let ctx = make_minimal_ctx();
1917
1918 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1919
1920 assert!(tool_args.flags.contains("verbose"), "--verbose should be a flag");
1921 assert!(tool_args.named.is_empty(), "No named args");
1922 assert!(tool_args.positional.is_empty(), "No positionals");
1923 }
1924
1925 #[tokio::test]
1926 async fn test_schema_aware_mixed() {
1927 let args = vec![
1930 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
1931 Arg::LongFlag("output".to_string()),
1932 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
1933 Arg::LongFlag("verbose".to_string()),
1934 ];
1935 let schema = make_test_schema();
1936 let ctx = make_minimal_ctx();
1937
1938 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1939
1940 assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
1941 assert_eq!(
1942 tool_args.named.get("query"),
1943 Some(&Value::String("file.txt".to_string()))
1944 );
1945 assert_eq!(
1946 tool_args.named.get("output"),
1947 Some(&Value::String("out.txt".to_string()))
1948 );
1949 assert!(tool_args.flags.contains("verbose"));
1950 }
1951
1952 #[tokio::test]
1953 async fn test_schema_aware_multiple_string_args() {
1954 let args = vec![
1956 Arg::LongFlag("query".to_string()),
1957 Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
1958 Arg::LongFlag("output".to_string()),
1959 Arg::Positional(Expr::Literal(Value::String("result.json".to_string()))),
1960 Arg::LongFlag("verbose".to_string()),
1961 Arg::LongFlag("limit".to_string()),
1962 Arg::Positional(Expr::Literal(Value::Int(5))),
1963 ];
1964 let schema = make_test_schema();
1965 let ctx = make_minimal_ctx();
1966
1967 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1968
1969 assert!(tool_args.positional.is_empty(), "All positionals consumed");
1970 assert_eq!(
1971 tool_args.named.get("query"),
1972 Some(&Value::String("test".to_string()))
1973 );
1974 assert_eq!(
1975 tool_args.named.get("output"),
1976 Some(&Value::String("result.json".to_string()))
1977 );
1978 assert_eq!(
1979 tool_args.named.get("limit"),
1980 Some(&Value::Int(5))
1981 );
1982 assert!(tool_args.flags.contains("verbose"));
1983 }
1984
1985 #[tokio::test]
1986 async fn test_schema_aware_double_dash() {
1987 let args = vec![
1990 Arg::LongFlag("output".to_string()),
1991 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
1992 Arg::DoubleDash,
1993 Arg::Positional(Expr::Literal(Value::String("--this-is-data".to_string()))),
1994 ];
1995 let schema = make_test_schema();
1996 let ctx = make_minimal_ctx();
1997
1998 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1999
2000 assert_eq!(
2001 tool_args.named.get("output"),
2002 Some(&Value::String("out.txt".to_string()))
2003 );
2004 assert_eq!(
2006 tool_args.positional,
2007 vec![Value::String("--this-is-data".to_string())]
2008 );
2009 }
2010
2011 #[tokio::test]
2017 async fn word_assign_binary_value_is_loud_not_placeholder() {
2018 let args = vec![Arg::WordAssign {
2019 key: "if".to_string(),
2020 value: Expr::Literal(Value::Bytes(vec![0xff, 0x00, 0xfe])),
2021 }];
2022 let ctx = make_minimal_ctx();
2023
2024 let err = build_tool_args(&args, &ctx, None).await.expect_err("binary WordAssign must error");
2027 assert!(
2028 err.contains("cannot be used as"),
2029 "error should name the binary problem, got {err:?}"
2030 );
2031 }
2032
2033 #[tokio::test]
2038 async fn word_assign_before_double_dash_binds_named_for_export() {
2039 let args = vec![Arg::WordAssign {
2040 key: "A".to_string(),
2041 value: Expr::Literal(Value::String("1".to_string())),
2042 }];
2043 let schema = ToolSchema::new("export", "export");
2044 let ctx = make_minimal_ctx();
2045
2046 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2047 assert_eq!(tool_args.named.get("A"), Some(&Value::String("1".to_string())));
2048 assert!(tool_args.positional.is_empty());
2049 }
2050
2051 #[tokio::test]
2060 async fn word_assign_after_double_dash_is_positional_even_for_export() {
2061 let args = vec![
2062 Arg::DoubleDash,
2063 Arg::WordAssign {
2064 key: "A".to_string(),
2065 value: Expr::Literal(Value::String("1".to_string())),
2066 },
2067 ];
2068 let schema = ToolSchema::new("export", "export");
2069 let ctx = make_minimal_ctx();
2070
2071 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2072 assert!(
2073 tool_args.named.is_empty(),
2074 "past `--`, A=1 must not become a named assignment: {:?}",
2075 tool_args.named
2076 );
2077 assert_eq!(tool_args.positional, vec![Value::String("A=1".to_string())]);
2078 }
2079
2080 #[tokio::test]
2088 async fn named_true_on_undeclared_flag_flagifies() {
2089 let args = vec![Arg::Named {
2090 key: "json".to_string(),
2091 value: Expr::Literal(Value::Bool(true)),
2092 }];
2093 let schema = make_test_schema(); let ctx = make_minimal_ctx();
2095
2096 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2097 assert!(tool_args.flags.contains("json"), "flags: {:?}", tool_args.flags);
2098 assert!(!tool_args.named.contains_key("json"), "named: {:?}", tool_args.named);
2099 }
2100
2101 #[tokio::test]
2104 async fn named_false_on_undeclared_flag_drops() {
2105 let args = vec![Arg::Named {
2106 key: "json".to_string(),
2107 value: Expr::Literal(Value::Bool(false)),
2108 }];
2109 let schema = make_test_schema();
2110 let ctx = make_minimal_ctx();
2111
2112 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2113 assert!(!tool_args.flags.contains("json"));
2114 assert!(!tool_args.named.contains_key("json"));
2115 }
2116
2117 #[tokio::test]
2121 async fn named_true_on_declared_bool_param_flagifies() {
2122 let args = vec![Arg::Named {
2123 key: "verbose".to_string(),
2124 value: Expr::Literal(Value::Bool(true)),
2125 }];
2126 let schema = make_test_schema();
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!(tool_args.flags.contains("verbose"));
2131 assert!(!tool_args.named.contains_key("verbose"));
2132 }
2133
2134 #[tokio::test]
2140 async fn named_true_on_declared_value_flag_keeps_value() {
2141 let args = vec![Arg::Named {
2142 key: "output".to_string(),
2143 value: Expr::Literal(Value::Bool(true)),
2144 }];
2145 let schema = make_test_schema();
2146 let ctx = make_minimal_ctx();
2147
2148 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2149 assert_eq!(tool_args.named.get("output"), Some(&Value::Bool(true)));
2150 assert!(!tool_args.flags.contains("output"));
2151 }
2152
2153 #[tokio::test]
2158 async fn named_true_with_no_schema_flagifies() {
2159 let args = vec![Arg::Named {
2160 key: "verbose".to_string(),
2161 value: Expr::Literal(Value::Bool(true)),
2162 }];
2163 let ctx = make_minimal_ctx();
2164
2165 let tool_args = build_tool_args(&args, &ctx, None).await.expect("build_tool_args");
2166 assert!(tool_args.flags.contains("verbose"));
2167 assert!(!tool_args.named.contains_key("verbose"));
2168 }
2169
2170 #[tokio::test]
2171 async fn test_no_schema_fallback() {
2172 let args = vec![
2174 Arg::LongFlag("query".to_string()),
2175 Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
2176 ];
2177 let ctx = make_minimal_ctx();
2178
2179 let tool_args = build_tool_args(&args, &ctx, None).await.expect("build_tool_args");
2180
2181 assert!(tool_args.flags.contains("query"), "--query should be a flag");
2183 assert_eq!(
2184 tool_args.positional,
2185 vec![Value::String("test".to_string())],
2186 "'test' should be a positional"
2187 );
2188 }
2189
2190 #[tokio::test]
2200 async fn test_unknown_flag_ambiguous_space_value_now_errors_loud() {
2201 let args = vec![
2202 Arg::LongFlag("unknown".to_string()),
2203 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2204 ];
2205 let schema = make_test_schema();
2206 let ctx = make_minimal_ctx();
2207
2208 let err = build_tool_args(&args, &ctx, Some(&schema))
2209 .await
2210 .expect_err("an undeclared flag immediately before a positional must be ambiguous, not silently bool");
2211 assert!(
2212 err.contains("--unknown is not a declared flag"),
2213 "got: {err}"
2214 );
2215 }
2216
2217 #[tokio::test]
2221 async fn test_unknown_bool_flag_with_no_following_positional_is_fine() {
2222 let args = vec![
2223 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2224 Arg::LongFlag("unknown".to_string()),
2225 ];
2226 let schema = make_test_schema();
2227 let ctx = make_minimal_ctx();
2228
2229 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2230
2231 assert!(tool_args.flags.contains("unknown"));
2232 assert!(tool_args.positional.is_empty(), "value consumed as query param");
2233 assert_eq!(
2234 tool_args.named.get("query"),
2235 Some(&Value::String("value".to_string()))
2236 );
2237 }
2238
2239 #[tokio::test]
2249 async fn test_unknown_short_flag_ambiguous_space_value_now_errors_loud() {
2250 let args = vec![
2251 Arg::ShortFlag("t".to_string()),
2252 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2253 ];
2254 let schema = make_test_schema();
2255 let ctx = make_minimal_ctx();
2256
2257 let err = build_tool_args(&args, &ctx, Some(&schema))
2258 .await
2259 .expect_err("an undeclared short flag immediately before a positional must be ambiguous, not silently bool");
2260 assert!(
2261 err.contains("-t is not a declared flag"),
2262 "got: {err}"
2263 );
2264 }
2265
2266 #[tokio::test]
2270 async fn test_unknown_short_bool_flag_with_no_following_positional_is_fine() {
2271 let args = vec![
2272 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2273 Arg::ShortFlag("t".to_string()),
2274 ];
2275 let schema = make_test_schema();
2276 let ctx = make_minimal_ctx();
2277
2278 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2279
2280 assert!(tool_args.flags.contains("t"));
2281 assert!(tool_args.positional.is_empty(), "value consumed as query param");
2282 assert_eq!(
2283 tool_args.named.get("query"),
2284 Some(&Value::String("value".to_string()))
2285 );
2286 }
2287
2288 #[tokio::test]
2294 async fn test_unknown_short_flag_not_ambiguous_without_map_positionals() {
2295 let args = vec![
2296 Arg::ShortFlag("t".to_string()),
2297 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2298 ];
2299 let schema = ToolSchema::new("test-tool", "A test tool")
2302 .param(ParamSchema::required("query", "string", "Search query"));
2303 let ctx = make_minimal_ctx();
2304
2305 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2306 assert!(tool_args.flags.contains("t"));
2307 assert_eq!(tool_args.positional, vec![Value::String("value".to_string())]);
2308 }
2309
2310 #[tokio::test]
2311 async fn test_named_args_unchanged() {
2312 let args = vec![
2314 Arg::Named {
2315 key: "query".to_string(),
2316 value: Expr::Literal(Value::String("test".to_string())),
2317 },
2318 Arg::LongFlag("verbose".to_string()),
2319 ];
2320 let schema = make_test_schema();
2321 let ctx = make_minimal_ctx();
2322
2323 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2324
2325 assert_eq!(
2326 tool_args.named.get("query"),
2327 Some(&Value::String("test".to_string()))
2328 );
2329 assert!(tool_args.flags.contains("verbose"));
2330 }
2331
2332 #[tokio::test]
2333 async fn test_short_flags_unchanged() {
2334 let args = vec![
2336 Arg::ShortFlag("la".to_string()),
2337 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
2338 ];
2339 let schema = make_test_schema();
2340 let ctx = make_minimal_ctx();
2341
2342 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2343
2344 assert!(tool_args.flags.contains("l"));
2345 assert!(tool_args.flags.contains("a"));
2346 assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
2347 assert_eq!(
2348 tool_args.named.get("query"),
2349 Some(&Value::String("file.txt".to_string()))
2350 );
2351 }
2352
2353 #[tokio::test]
2354 async fn test_flag_at_end_no_value() {
2355 let args = vec![
2358 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
2359 Arg::LongFlag("output".to_string()),
2360 ];
2361 let schema = make_test_schema();
2362 let ctx = make_minimal_ctx();
2363
2364 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2365
2366 assert!(tool_args.flags.contains("output"));
2368 assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
2369 assert_eq!(
2370 tool_args.named.get("query"),
2371 Some(&Value::String("file.txt".to_string()))
2372 );
2373 }
2374
2375 #[tokio::test]
2376 async fn test_positional_skips_bool_params() {
2377 let schema = ToolSchema::new("test", "")
2381 .param(ParamSchema::required("query", "string", ""))
2382 .param(ParamSchema::optional(
2383 "verbose",
2384 "bool",
2385 Value::Bool(false),
2386 "",
2387 ))
2388 .param(ParamSchema::optional(
2389 "output",
2390 "string",
2391 Value::Null,
2392 "",
2393 ))
2394 .with_positional_mapping();
2395 let args = vec![
2396 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2397 Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
2398 ];
2399 let ctx = make_minimal_ctx();
2400
2401 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2402
2403 assert_eq!(
2404 tool_args.named.get("query"),
2405 Some(&Value::String("val1".to_string()))
2406 );
2407 assert_eq!(
2408 tool_args.named.get("output"),
2409 Some(&Value::String("val2".to_string()))
2410 );
2411 assert!(!tool_args.flags.contains("verbose"));
2412 assert!(tool_args.positional.is_empty());
2413 }
2414
2415 #[tokio::test]
2416 async fn test_positionals_fill_available_slots() {
2417 let args = vec![
2420 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2421 Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
2422 Arg::Positional(Expr::Literal(Value::String("val3".to_string()))),
2423 ];
2424 let schema = make_test_schema(); let ctx = make_minimal_ctx();
2426
2427 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2428
2429 assert_eq!(
2432 tool_args.named.get("query"),
2433 Some(&Value::String("val1".to_string()))
2434 );
2435 assert_eq!(
2436 tool_args.named.get("limit"),
2437 Some(&Value::String("val2".to_string()))
2438 );
2439 assert_eq!(
2440 tool_args.named.get("output"),
2441 Some(&Value::String("val3".to_string()))
2442 );
2443 assert!(tool_args.positional.is_empty());
2444 }
2445
2446 #[tokio::test]
2447 async fn test_truly_excess_positionals() {
2448 let schema = ToolSchema::new("test", "")
2450 .param(ParamSchema::required("name", "string", ""))
2451 .with_positional_mapping();
2452 let args = vec![
2453 Arg::Positional(Expr::Literal(Value::String("first".to_string()))),
2454 Arg::Positional(Expr::Literal(Value::String("second".to_string()))),
2455 Arg::Positional(Expr::Literal(Value::String("third".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("name"),
2463 Some(&Value::String("first".to_string()))
2464 );
2465 assert_eq!(
2466 tool_args.positional,
2467 vec![
2468 Value::String("second".to_string()),
2469 Value::String("third".to_string()),
2470 ]
2471 );
2472 }
2473
2474 #[tokio::test]
2475 async fn test_double_dash_positional_not_mapped() {
2476 let args = vec![
2478 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2479 Arg::DoubleDash,
2480 Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
2481 ];
2482 let schema = make_test_schema();
2483 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!(
2488 tool_args.named.get("query"),
2489 Some(&Value::String("val1".to_string()))
2490 );
2491 assert_eq!(
2493 tool_args.positional,
2494 vec![Value::String("val2".to_string())]
2495 );
2496 }
2497
2498 #[tokio::test]
2499 async fn test_all_params_filled_by_flags() {
2500 let args = vec![
2502 Arg::LongFlag("query".to_string()),
2503 Arg::Positional(Expr::Literal(Value::String("search".to_string()))),
2504 Arg::LongFlag("output".to_string()),
2505 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
2506 Arg::LongFlag("verbose".to_string()),
2507 ];
2508 let schema = make_test_schema();
2509 let ctx = make_minimal_ctx();
2510
2511 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2512
2513 assert_eq!(
2514 tool_args.named.get("query"),
2515 Some(&Value::String("search".to_string()))
2516 );
2517 assert_eq!(
2518 tool_args.named.get("output"),
2519 Some(&Value::String("out.txt".to_string()))
2520 );
2521 assert!(tool_args.flags.contains("verbose"));
2522 assert!(tool_args.positional.is_empty());
2523 }
2524
2525 #[tokio::test]
2526 async fn test_mixed_flags_and_positional_fill() {
2527 let args = vec![
2529 Arg::LongFlag("output".to_string()),
2530 Arg::Positional(Expr::Literal(Value::String("foo".to_string()))),
2531 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2532 ];
2533 let schema = make_test_schema();
2534 let ctx = make_minimal_ctx();
2535
2536 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2537
2538 assert_eq!(
2539 tool_args.named.get("output"),
2540 Some(&Value::String("foo".to_string()))
2541 );
2542 assert_eq!(
2543 tool_args.named.get("query"),
2544 Some(&Value::String("val1".to_string()))
2545 );
2546 assert!(tool_args.positional.is_empty());
2547 }
2548
2549 #[tokio::test]
2550 async fn test_alias_flag_prevents_mapping_overwrite() {
2551 let schema = ToolSchema::new("test", "")
2553 .param(ParamSchema::required("query", "string", "").with_aliases(["-q"]))
2554 .param(ParamSchema::required("output", "string", ""))
2555 .with_positional_mapping();
2556 let args = vec![
2557 Arg::ShortFlag("q".to_string()),
2558 Arg::Positional(Expr::Literal(Value::String("search".to_string()))),
2559 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
2560 ];
2561 let ctx = make_minimal_ctx();
2562
2563 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2564
2565 assert_eq!(
2566 tool_args.named.get("query"),
2567 Some(&Value::String("search".to_string()))
2568 );
2569 assert_eq!(
2570 tool_args.named.get("output"),
2571 Some(&Value::String("out.txt".to_string()))
2572 );
2573 assert!(tool_args.positional.is_empty());
2574 }
2575
2576 #[tokio::test]
2577 async fn test_builtin_schema_no_positional_mapping() {
2578 let schema = ToolSchema::new("echo", "")
2580 .param(ParamSchema::optional("args", "any", Value::Null, ""))
2581 .param(ParamSchema::optional("no_newline", "bool", Value::Bool(false), ""));
2582 let args = vec![
2584 Arg::Positional(Expr::Literal(Value::String("hello".to_string()))),
2585 Arg::Positional(Expr::Literal(Value::String("world".to_string()))),
2586 ];
2587 let ctx = make_minimal_ctx();
2588
2589 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2590
2591 assert_eq!(
2593 tool_args.positional,
2594 vec![
2595 Value::String("hello".to_string()),
2596 Value::String("world".to_string()),
2597 ]
2598 );
2599 assert!(!tool_args.named.contains_key("args"));
2600 }
2601
2602 #[tokio::test]
2603 async fn test_short_flag_with_alias_consumes_value() {
2604 let schema = ToolSchema::new("head", "Output first part of files")
2607 .param(ParamSchema::optional("lines", "int", Value::Int(10), "Number of lines")
2608 .with_aliases(["-n"]));
2609 let args = vec![
2610 Arg::ShortFlag("n".to_string()),
2611 Arg::Positional(Expr::Literal(Value::Int(5))),
2612 Arg::Positional(Expr::Literal(Value::String("/tmp/file.txt".to_string()))),
2613 ];
2614 let ctx = make_minimal_ctx();
2615
2616 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2617
2618 assert!(tool_args.flags.is_empty(), "no boolean flags: {:?}", tool_args.flags);
2619 assert_eq!(tool_args.named.get("lines"), Some(&Value::Int(5)), "should resolve alias to canonical name");
2620 assert_eq!(tool_args.positional, vec![Value::String("/tmp/file.txt".to_string())]);
2621 }
2622
2623 #[tokio::test]
2635 async fn test_glued_short_flag_value_now_binds() {
2636 let schema = ToolSchema::new("cut", "")
2640 .param(ParamSchema::optional("fields", "string", Value::Null, "").with_aliases(["-f"]));
2641 let args = vec![Arg::ShortFlag("f1".to_string())];
2642 let ctx = make_minimal_ctx();
2643
2644 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2645
2646 assert!(tool_args.flags.is_empty(), "no bogus bool flags: {:?}", tool_args.flags);
2647 assert_eq!(tool_args.named.get("fields"), Some(&Value::String("1".to_string())));
2648 }
2649
2650 #[tokio::test]
2651 async fn test_repeatable_flag_now_accumulates() {
2652 let schema = ToolSchema::new("sed", "")
2657 .param(ParamSchema::optional("expression", "string", Value::Null, "")
2658 .with_aliases(["-e"])
2659 .with_repeatable(true));
2660 let args = vec![
2661 Arg::ShortFlag("e".to_string()),
2662 Arg::Positional(Expr::Literal(Value::String("A".to_string()))),
2663 Arg::ShortFlag("e".to_string()),
2664 Arg::Positional(Expr::Literal(Value::String("B".to_string()))),
2665 ];
2666 let ctx = make_minimal_ctx();
2667
2668 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2669
2670 assert_eq!(
2671 tool_args.named.get("expression"),
2672 Some(&Value::Json(serde_json::json!(["A", "B"]))),
2673 "both occurrences must survive, not just the last: {:?}",
2674 tool_args.named
2675 );
2676 }
2677
2678 #[tokio::test]
2679 async fn test_multi_consume_flag_now_accumulates() {
2680 let schema = ToolSchema::new("jq", "")
2685 .param(ParamSchema::optional("arg", "any", Value::Null, "").consumes(2));
2686 let args = vec![
2687 Arg::LongFlag("arg".to_string()),
2688 Arg::Positional(Expr::Literal(Value::String("name".to_string()))),
2689 Arg::Positional(Expr::Literal(Value::String("val".to_string()))),
2690 ];
2691 let ctx = make_minimal_ctx();
2692
2693 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2694
2695 assert_eq!(
2696 tool_args.named.get("arg"),
2697 Some(&Value::Json(serde_json::json!([["name", "val"]]))),
2698 "got: {:?}",
2699 tool_args.named
2700 );
2701 assert!(tool_args.positional.is_empty());
2702 }
2703
2704 #[tokio::test]
2707 async fn test_merge_stderr_redirect() {
2708 let result = ExecResult::from_output(0, "stdout content", "stderr content");
2710
2711 let redirects = vec![Redirect {
2712 kind: RedirectKind::MergeStderr,
2713 target: Expr::Literal(Value::Null),
2714 }];
2715
2716 let ctx = make_minimal_ctx();
2717 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2718
2719 assert_eq!(&*result.text_out(), "stdout contentstderr content");
2720 assert!(result.err.is_empty());
2721 }
2722
2723 #[tokio::test]
2724 async fn test_merge_stderr_with_empty_stderr() {
2725 let result = ExecResult::from_output(0, "stdout only", "");
2727
2728 let redirects = vec![Redirect {
2729 kind: RedirectKind::MergeStderr,
2730 target: Expr::Literal(Value::Null),
2731 }];
2732
2733 let ctx = make_minimal_ctx();
2734 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2735
2736 assert_eq!(&*result.text_out(), "stdout only");
2737 assert!(result.err.is_empty());
2738 }
2739
2740 #[tokio::test]
2741 async fn test_merge_stderr_order_matters() {
2742 let result = ExecResult::from_output(0, "stdout\n", "stderr\n");
2747
2748 let redirects = vec![Redirect {
2750 kind: RedirectKind::MergeStderr,
2751 target: Expr::Literal(Value::Null),
2752 }];
2753
2754 let ctx = make_minimal_ctx();
2755 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2756
2757 assert_eq!(&*result.text_out(), "stdout\nstderr\n");
2758 assert!(result.err.is_empty());
2759 }
2760
2761 #[tokio::test]
2762 async fn test_redirect_with_command_execution() {
2763 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
2764
2765 let cmd = Command {
2767 name: "echo".to_string(),
2768 args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
2769 redirects: vec![Redirect {
2770 kind: RedirectKind::MergeStderr,
2771 target: Expr::Literal(Value::Null),
2772 }],
2773 };
2774
2775 let result = runner.run(&stages([cmd]), &mut ctx, &dispatcher).await;
2776 assert!(result.ok());
2777 assert!(result.text_out().contains("hello"));
2779 }
2780
2781 #[tokio::test]
2782 async fn test_merge_stderr_in_pipeline() {
2783 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
2784
2785 let echo_cmd = Command {
2788 name: "echo".to_string(),
2789 args: vec![Arg::Positional(Expr::Literal(Value::String("output".to_string())))],
2790 redirects: vec![Redirect {
2791 kind: RedirectKind::MergeStderr,
2792 target: Expr::Literal(Value::Null),
2793 }],
2794 };
2795 let grep_cmd = Command {
2796 name: "grep".to_string(),
2797 args: vec![Arg::Positional(Expr::Literal(Value::String("output".to_string())))],
2798 redirects: vec![],
2799 };
2800
2801 let result = runner.run(&stages([echo_cmd, grep_cmd]), &mut ctx, &dispatcher).await;
2802 assert!(result.ok(), "result failed: code={}, err={}", result.code, result.err);
2803 assert!(result.text_out().contains("output"));
2804 }
2805
2806 fn big_table_output(rows: usize) -> crate::interpreter::OutputData {
2818 use crate::interpreter::OutputNode;
2819 let headers = vec!["id".to_string(), "name".to_string()];
2820 let nodes: Vec<OutputNode> = (0..rows)
2821 .map(|i| OutputNode::new(i.to_string()).with_cells(vec![format!("row-{i}")]))
2822 .collect();
2823 crate::interpreter::OutputData::table(headers, nodes)
2824 }
2825
2826 #[tokio::test]
2827 async fn test_both_redirect_streams_structured_output_to_file() {
2828 let output = big_table_output(50);
2833 let expected_stdout = output.to_canonical_string();
2834 let mut result = ExecResult::with_output(output);
2835 result.err = "warning: heads up\n".to_string();
2836
2837 let redirects = vec![Redirect {
2838 kind: RedirectKind::Both,
2839 target: Expr::Literal(Value::String("/out.txt".to_string())),
2840 }];
2841 let ctx = make_minimal_ctx();
2842 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2843
2844 assert!(result.ok());
2847 assert_eq!(&*result.text_out(), "");
2848 assert!(result.err.is_empty());
2849 assert!(!result.has_output());
2850
2851 let written = ctx.backend.read(Path::new("/out.txt"), None).await.expect("file written");
2852 let written = String::from_utf8(written).expect("valid utf8");
2853 assert_eq!(written, format!("{expected_stdout}warning: heads up\n"));
2857 }
2858
2859 #[tokio::test]
2860 async fn test_both_redirect_streams_large_structured_output_intact() {
2861 let rows = 5_000;
2866 let output = big_table_output(rows);
2867 let expected_stdout = output.to_canonical_string();
2868 let result = ExecResult::with_output(output);
2869
2870 let redirects = vec![Redirect {
2871 kind: RedirectKind::Both,
2872 target: Expr::Literal(Value::String("/big.txt".to_string())),
2873 }];
2874 let ctx = make_minimal_ctx();
2875 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2876 assert!(result.ok());
2877
2878 let written = ctx.backend.read(Path::new("/big.txt"), None).await.expect("file written");
2879 let written = String::from_utf8(written).expect("valid utf8");
2880 assert_eq!(written, expected_stdout);
2881 assert!(written.contains("row-0"));
2882 assert!(written.contains(&format!("row-{}", rows - 1)));
2883 }
2884
2885 #[tokio::test]
2886 async fn test_both_redirect_still_writes_binary_stdout_raw() {
2887 let result = ExecResult::success_text_or_bytes(vec![0xff, 0x00, 0xfe, b'x']);
2891 let redirects = vec![Redirect {
2892 kind: RedirectKind::Both,
2893 target: Expr::Literal(Value::String("/bin.out".to_string())),
2894 }];
2895 let ctx = make_minimal_ctx();
2896 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2897 assert!(result.ok());
2898
2899 let written = ctx.backend.read(Path::new("/bin.out"), None).await.expect("file written");
2900 assert_eq!(written, vec![0xff, 0x00, 0xfe, b'x']);
2901 }
2902}