1use std::sync::Arc;
9
10use std::collections::HashMap;
11
12use crate::arithmetic;
13use crate::ast::{Arg, Command, Expr, Redirect, RedirectKind, Value};
14use crate::dispatch::{CommandDispatcher, PipelinePosition};
15use crate::interpreter::{ExecResult, PathError};
16use crate::tools::{ExecContext, ToolArgs, ToolRegistry, ToolSchema};
17use tokio::io::AsyncWriteExt;
18
19use super::pipe_stream::pipe_stream_default;
20use super::scatter::{
21 parse_gather_options, parse_scatter_options, ScatterGatherRunner,
22};
23
24pub(crate) async fn apply_redirects(
30 mut result: ExecResult,
31 redirects: &[Redirect],
32 ctx: &ExecContext,
33 dispatcher: &dyn CommandDispatcher,
34) -> ExecResult {
35 for redir in redirects {
40 match redir.kind {
41 RedirectKind::MergeStderr => {
42 result.materialize();
45 if !result.err.is_empty() {
46 let err = std::mem::take(&mut result.err);
47 result.push_out(&err);
48 }
49 }
50 RedirectKind::MergeStdout => {
51 if result.is_bytes() {
55 return ExecResult::failure(
56 1,
57 "redirect: cannot merge binary stdout into stderr (1>&2) — \
58 redirect it to a file or pipe through base64/xxd",
59 );
60 }
61 result.materialize();
62 if !result.text_out().is_empty() {
63 let out = result.text_out().into_owned();
64 result.err.push_str(&out);
65 }
66 result.clear_stdout();
73 }
74 RedirectKind::StdoutOverwrite => {
75 let path = match eval_redirect_target(&redir.target, ctx, dispatcher).await {
76 Ok(p) => p,
77 Err(e) => return ExecResult::failure(1, format!("redirect: {e}")),
78 };
79 if let Some(bytes) = result.out_bytes() {
81 if let Err(e) = redirect_write(ctx, &path, bytes).await {
82 return ExecResult::failure(1, format!("redirect: {e}"));
83 }
84 } else if let Some(output) = result.take_output_for_stream() {
85 let mut buf = Vec::new();
87 if let Err(e) = output.write_canonical(&mut buf, None) {
88 return ExecResult::failure(1, format!("redirect: {e}"));
89 }
90 if let Err(e) = redirect_write(ctx, &path, &buf).await {
91 return ExecResult::failure(1, format!("redirect: {e}"));
92 }
93 } else if let Err(e) = redirect_write(ctx, &path, result.text_out().as_bytes()).await {
94 return ExecResult::failure(1, format!("redirect: {e}"));
95 }
96 result.clear_stdout();
98 }
99 RedirectKind::StdoutAppend => {
100 let path = match eval_redirect_target(&redir.target, ctx, dispatcher).await {
101 Ok(p) => p,
102 Err(e) => return ExecResult::failure(1, format!("redirect: {e}")),
103 };
104 if let Some(bytes) = result.out_bytes() {
106 if let Err(e) = redirect_append(ctx, &path, bytes).await {
107 return ExecResult::failure(1, format!("redirect: {e}"));
108 }
109 } else if let Some(output) = result.take_output_for_stream() {
110 let mut buf = Vec::new();
112 if let Err(e) = output.write_canonical(&mut buf, None) {
113 return ExecResult::failure(1, format!("redirect: {e}"));
114 }
115 if let Err(e) = redirect_append(ctx, &path, &buf).await {
116 return ExecResult::failure(1, format!("redirect: {e}"));
117 }
118 } else if let Err(e) = redirect_append(ctx, &path, result.text_out().as_bytes()).await {
119 return ExecResult::failure(1, format!("redirect: {e}"));
120 }
121 result.clear_stdout();
123 }
124 RedirectKind::Stderr => {
125 let path = match eval_redirect_target(&redir.target, ctx, dispatcher).await {
126 Ok(p) => p,
127 Err(e) => return ExecResult::failure(1, format!("redirect: {e}")),
128 };
129 if let Err(e) = redirect_write(ctx, &path, result.err.as_bytes()).await {
130 return ExecResult::failure(1, format!("redirect: {e}"));
131 }
132 result.err.clear();
133 }
134 RedirectKind::Both => {
135 let path = match eval_redirect_target(&redir.target, ctx, dispatcher).await {
136 Ok(p) => p,
137 Err(e) => return ExecResult::failure(1, format!("redirect: {e}")),
138 };
139 let mut combined: Vec<u8> = if let Some(b) = result.out_bytes() {
147 b.to_vec()
148 } else if let Some(output) = result.take_output_for_stream() {
149 let mut buf = Vec::new();
150 if let Err(e) = output.write_canonical(&mut buf, None) {
151 return ExecResult::failure(1, format!("redirect: {e}"));
152 }
153 buf
154 } else {
155 result.text_out().into_owned().into_bytes()
156 };
157 combined.extend_from_slice(result.err.as_bytes());
158 if let Err(e) = redirect_write(ctx, &path, &combined).await {
159 return ExecResult::failure(1, format!("redirect: {e}"));
160 }
161 result.clear_stdout();
163 result.err.clear();
164 }
165 RedirectKind::Stdin | RedirectKind::HereDoc | RedirectKind::HereString => {}
167 }
168 }
169 result.materialize();
174 result
175}
176
177async fn eval_redirect_target(
189 expr: &Expr,
190 ctx: &ExecContext,
191 dispatcher: &dyn CommandDispatcher,
192) -> Result<String, String> {
193 let value = dispatcher
194 .eval_expr(expr, ctx)
195 .await
196 .map_err(|e| e.to_string())?;
197 if let Some(msg) = crate::interpreter::structured_boundary_error("a redirect target", &value) {
200 return Err(msg);
201 }
202 crate::interpreter::value_to_text_sink_named(&value, "a redirect target").map_err(|e| e.to_string())
205}
206
207async fn redirect_write(ctx: &ExecContext, path: &str, data: &[u8]) -> Result<(), String> {
214 use crate::backend::WriteMode;
215 let resolved = ctx.resolve_path(path);
216 ctx.backend.write(&resolved, data, WriteMode::Overwrite).await.map_err(|e| e.to_string())
217}
218
219async fn redirect_append(ctx: &ExecContext, path: &str, data: &[u8]) -> Result<(), String> {
223 let resolved = ctx.resolve_path(path);
224 ctx.backend.append(&resolved, data).await.map_err(|e| e.to_string())
225}
226
227async fn setup_stdin_redirects(
240 cmd: &Command,
241 ctx: &mut ExecContext,
242 dispatcher: &dyn CommandDispatcher,
243) -> Result<(), String> {
244 use std::path::Path;
245 for redir in &cmd.redirects {
246 match &redir.kind {
247 RedirectKind::Stdin => {
248 let path = eval_redirect_target(&redir.target, ctx, dispatcher).await?;
249 let resolved = ctx.resolve_path(&path);
250 let data = ctx
251 .backend
252 .read(Path::new(&resolved), None)
253 .await
254 .map_err(|e| format!("redirect: {path}: {e}"))?;
255 ctx.set_stdin(data);
256 }
257 RedirectKind::HereDoc => {
258 match &redir.target {
259 Expr::Literal(Value::String(content)) => {
260 ctx.set_stdin(content.clone());
261 }
262 expr => {
265 let body = eval_redirect_target(expr, ctx, dispatcher).await?;
266 ctx.set_stdin(body);
267 }
268 }
269 }
270 RedirectKind::HereString => {
271 let mut s = eval_redirect_target(&redir.target, ctx, dispatcher).await?;
274 s.push('\n');
275 ctx.set_stdin(s);
276 }
277 _ => {}
278 }
279 }
280 Ok(())
281}
282
283#[derive(Clone)]
285pub struct PipelineRunner {
286 tools: Arc<ToolRegistry>,
287}
288
289impl PipelineRunner {
290 pub fn new(tools: Arc<ToolRegistry>) -> Self {
292 Self { tools }
293 }
294
295 pub async fn run(
305 &self,
306 commands: &[Command],
307 ctx: &mut ExecContext,
308 dispatcher: &dyn CommandDispatcher,
309 ) -> ExecResult {
310 if commands.is_empty() {
311 return ExecResult::success("");
312 }
313
314 if let Some((scatter_idx, gather_idx)) = find_scatter_gather(commands) {
316 return self.run_scatter_gather(commands, scatter_idx, gather_idx, ctx, dispatcher).await;
317 }
318
319 self.run_sequential(commands, ctx, dispatcher).await
320 }
321
322 pub async fn run_sequential(
327 &self,
328 commands: &[Command],
329 ctx: &mut ExecContext,
330 dispatcher: &dyn CommandDispatcher,
331 ) -> ExecResult {
332 if commands.is_empty() {
333 return ExecResult::success("");
334 }
335
336 if commands.len() == 1 {
337 return self.run_single(&commands[0], ctx, None, dispatcher).await;
339 }
340
341 self.run_pipeline(commands, ctx, dispatcher).await
343 }
344
345 async fn run_scatter_gather(
347 &self,
348 commands: &[Command],
349 scatter_idx: usize,
350 gather_idx: usize,
351 ctx: &mut ExecContext,
352 dispatcher: &dyn CommandDispatcher,
353 ) -> ExecResult {
354 let pre_scatter = &commands[..scatter_idx];
356 let scatter_cmd = &commands[scatter_idx];
357 let parallel = &commands[scatter_idx + 1..gather_idx];
358 let gather_cmd = &commands[gather_idx];
359 let post_gather = &commands[gather_idx + 1..];
360
361 let scatter_schema = self.tools.get("scatter").map(|t| t.schema());
368 let gather_schema = self.tools.get("gather").map(|t| t.schema());
369 let scatter_args = match build_tool_args(&scatter_cmd.args, ctx, scatter_schema.as_ref()).await {
370 Ok(args) => args,
371 Err(e) => return ExecResult::failure(1, format!("scatter: {e}")),
372 };
373 let gather_args = match build_tool_args(&gather_cmd.args, ctx, gather_schema.as_ref()).await {
374 Ok(args) => args,
375 Err(e) => return ExecResult::failure(1, format!("gather: {e}")),
376 };
377 let scatter_opts = match parse_scatter_options(&scatter_args) {
378 Ok(opts) => opts,
379 Err(e) => return ExecResult::failure(2, format!("scatter: {e}")),
380 };
381 let gather_opts = match parse_gather_options(&gather_args) {
382 Ok(opts) => opts,
383 Err(e) => return ExecResult::failure(2, format!("gather: {e}")),
384 };
385
386 let sequential_dispatcher: Arc<dyn CommandDispatcher> = dispatcher.fork_attached().await;
391
392 let runner = ScatterGatherRunner::new(self.tools.clone(), sequential_dispatcher);
393 runner
394 .run(
395 pre_scatter,
396 scatter_opts,
397 parallel,
398 gather_opts,
399 &gather_cmd.redirects,
400 post_gather,
401 ctx,
402 )
403 .await
404 }
405
406 async fn run_single(
411 &self,
412 cmd: &Command,
413 ctx: &mut ExecContext,
414 stdin: Option<Vec<u8>>,
415 dispatcher: &dyn CommandDispatcher,
416 ) -> ExecResult {
417 if let Err(e) = setup_stdin_redirects(cmd, ctx, dispatcher).await {
419 return ExecResult::failure(1, e);
420 }
421
422 if let Some(input) = stdin {
424 ctx.set_stdin(input);
425 }
426
427 ctx.pipeline_position = PipelinePosition::Only;
429
430 let result = match dispatcher.dispatch(cmd, ctx).await {
432 Ok(result) => result,
433 Err(e) => ExecResult::failure(1, e.to_string()),
434 };
435
436 apply_redirects(result, &cmd.redirects, ctx, dispatcher).await
438 }
439
440 async fn run_pipeline(
450 &self,
451 commands: &[Command],
452 ctx: &mut ExecContext,
453 dispatcher: &dyn CommandDispatcher,
454 ) -> ExecResult {
455 let stage_count = commands.len();
456 let last_idx = stage_count - 1;
457
458 let mut pipe_writers: Vec<Option<super::pipe_stream::PipeWriter>> = Vec::new();
460 let mut pipe_readers: Vec<Option<super::pipe_stream::PipeReader>> = Vec::new();
461
462 for _ in 0..last_idx {
463 let (writer, reader) = pipe_stream_default();
464 pipe_writers.push(Some(writer));
465 pipe_readers.push(Some(reader));
466 }
467
468 let mut data_senders: Vec<Option<tokio::sync::oneshot::Sender<Option<Value>>>> = Vec::new();
470 let mut data_receivers: Vec<Option<tokio::sync::oneshot::Receiver<Option<Value>>>> = Vec::new();
471
472 for _ in 0..last_idx {
473 let (tx, rx) = tokio::sync::oneshot::channel();
474 data_senders.push(Some(tx));
475 data_receivers.push(Some(rx));
476 }
477
478 let mut handles: Vec<tokio::task::JoinHandle<(ExecResult, ExecContext)>> = Vec::with_capacity(stage_count);
479
480 for (i, cmd) in commands.iter().enumerate() {
481 let mut stage_ctx = ctx.child_for_pipeline();
482 let cmd = cmd.clone();
483
484 let task_dispatcher: Arc<dyn CommandDispatcher> = dispatcher.fork_attached().await;
489
490 let stdin_setup = setup_stdin_redirects(&cmd, &mut stage_ctx, dispatcher).await;
494
495 if i == 0 {
497 if stage_ctx.stdin.is_none() {
500 stage_ctx.stdin = ctx.stdin.take();
501 }
502 if stage_ctx.stdin_data.is_none() {
503 stage_ctx.stdin_data = ctx.stdin_data.take();
504 }
505 if stage_ctx.stdin.is_none() && stage_ctx.pipe_stdin.is_none() {
509 stage_ctx.pipe_stdin = ctx.pipe_stdin.take();
510 }
511 } else {
512 stage_ctx.pipe_stdin = pipe_readers[i - 1].take();
514 }
516
517 if i < last_idx {
519 stage_ctx.pipe_stdout = pipe_writers[i].take();
520 }
521
522 stage_ctx.pipeline_position = match i {
524 0 => PipelinePosition::First,
525 n if n == last_idx => PipelinePosition::Last,
526 _ => PipelinePosition::Middle,
527 };
528
529 let data_sender = if i < last_idx { data_senders[i].take() } else { None };
530 let data_receiver = if i > 0 { data_receivers[i - 1].take() } else { None };
531
532 let handle: tokio::task::JoinHandle<(ExecResult, ExecContext)> =
535 tokio::spawn(crate::telemetry::bind_current_context(async move {
536 if let Err(e) = stdin_setup {
538 return (ExecResult::failure(1, e), stage_ctx);
539 }
540
541 stage_ctx.stdin_data_rx = data_receiver;
549
550 let mut result = match task_dispatcher.dispatch(&cmd, &mut stage_ctx).await {
552 Ok(result) => result,
553 Err(e) => ExecResult::failure(1, e.to_string()),
554 };
555
556 result = apply_redirects(result, &cmd.redirects, &stage_ctx, &*task_dispatcher).await;
561
562 if !result.err.is_empty() {
568 if let Some(ref stderr) = stage_ctx.stderr {
569 stderr.write_str(&result.err);
570 result.err.clear();
571 }
572 }
573
574 if let Some(tx) = data_sender {
580 let _ = tx.send(result.data.clone());
581 }
582
583 if let Some(mut pipe_out) = stage_ctx.pipe_stdout.take() {
586 let bytes: Vec<u8> = if let Some(b) = result.out_bytes() {
594 b.to_vec()
595 } else if let Some(output) = result.take_output_for_stream() {
596 let mut buf = Vec::new();
597 if output.write_canonical(&mut buf, None).is_err() {
603 buf = output.to_canonical_string().into_bytes();
604 }
605 buf
606 } else {
607 result.text_out().into_owned().into_bytes()
608 };
609 if !bytes.is_empty() {
610 let _ = pipe_out.write_all(&bytes).await;
612 let _ = pipe_out.shutdown().await;
613 }
614 }
616
617 (result, stage_ctx)
618 }));
619
620 handles.push(handle);
621 }
622
623 let mut last_result = ExecResult::success("");
628 let mut panics: Vec<String> = Vec::new();
629 let mut gated: Option<ExecResult> = None;
637 for (i, handle) in handles.into_iter().enumerate() {
638 match handle.await {
639 Ok((result, stage_ctx)) => {
640 if result.latch.is_some() {
641 gated.get_or_insert_with(|| result.clone());
642 }
643 if i == last_idx {
644 last_result = result;
645 ctx.scope = stage_ctx.scope;
647 ctx.cwd = stage_ctx.cwd;
648 ctx.prev_cwd = stage_ctx.prev_cwd;
649 ctx.aliases = stage_ctx.aliases;
650 }
651 }
652 Err(e) => {
653 panics.push(format!("stage {}: {}", i, e));
654 }
655 }
656 }
657
658 if let Some(gate) = gated {
662 last_result = gate;
663 }
664
665 if !panics.is_empty() {
671 last_result = ExecResult::failure(
672 1,
673 format!("pipeline stage(s) panicked: {}", panics.join("; ")),
674 );
675 }
676
677 last_result
678 }
679}
680
681pub fn select_leaf<'a>(schema: &'a ToolSchema, args: &[Arg]) -> anyhow::Result<&'a ToolSchema> {
721 let root_lookup = schema_param_lookup(schema);
724 let is_root_value_flag = |name: &str| -> bool {
725 root_lookup.get(name).is_some_and(|(_, typ, ..)| !is_bool_type(typ))
726 };
727
728 let mut node = schema;
729 let mut skip_next_positional = false;
730 for arg in args {
731 match arg {
732 Arg::DoubleDash => break,
734 Arg::LongFlag(name) if is_root_value_flag(name) => skip_next_positional = true,
737 Arg::ShortFlag(name) if is_root_value_flag(name) => skip_next_positional = true,
738 Arg::Positional(expr) => {
739 if skip_next_positional {
740 skip_next_positional = false;
741 continue; }
743 if node.subcommands.is_empty() {
744 break; }
746 match classify_subcommand_positional(expr) {
747 SubcommandWord::Word(word) => {
748 match node.subcommands.iter().find(|c| c.matches_command(word)) {
749 Some(child) => node = child, None => break, }
752 }
753 SubcommandWord::OtherLiteral => break,
757 SubcommandWord::Computed(kind) => anyhow::bail!(
758 "{}: a subcommand name is required here, but got {kind}. \
759 Subcommands must be literal words — spell it out \
760 (e.g. `{} <subcommand> …`) or use the `--flag=value` form.",
761 node.name,
762 schema.name
763 ),
764 }
765 }
766 _ => {}
768 }
769 }
770 Ok(node)
771}
772
773enum SubcommandWord<'a> {
775 Word(&'a str),
777 OtherLiteral,
779 Computed(&'static str),
781}
782
783fn classify_subcommand_positional(expr: &Expr) -> SubcommandWord<'_> {
784 match expr {
785 Expr::Literal(Value::String(s)) => SubcommandWord::Word(s),
786 Expr::Literal(_) => SubcommandWord::OtherLiteral,
787 Expr::CommandSubst(_) | Expr::Command(_) => SubcommandWord::Computed("a command substitution `$(…)`"),
788 Expr::VarRef(_)
789 | Expr::VarWithDefault { .. }
790 | Expr::VarLength(_)
791 | Expr::Positional(_)
792 | Expr::AllArgs
793 | Expr::ArgCount
794 | Expr::CurrentPid
795 | Expr::LastExitCode => SubcommandWord::Computed("a variable reference"),
796 Expr::Interpolated(_) | Expr::HereDocBody { .. } => SubcommandWord::Computed("an interpolated string"),
797 Expr::GlobPattern(_) => SubcommandWord::Computed("a glob pattern"),
798 Expr::Arithmetic(_) => SubcommandWord::Computed("an arithmetic expansion"),
799 _ => SubcommandWord::Computed("a value computed at runtime"),
800 }
801}
802
803pub fn schema_param_lookup(schema: &ToolSchema) -> HashMap<String, (&str, &str, usize, bool)> {
804 let mut map = HashMap::new();
805 for p in schema.params.iter().filter(|p| !p.positional) {
806 map.insert(p.name.clone(), (p.name.as_str(), p.param_type.as_str(), p.consumes, p.repeatable));
807 for alias in &p.aliases {
808 let stripped = alias.trim_start_matches('-');
809 map.insert(stripped.to_string(), (p.name.as_str(), p.param_type.as_str(), p.consumes, p.repeatable));
810 }
811 }
812 map
813}
814
815pub fn is_bool_type(param_type: &str) -> bool {
817 matches!(param_type.to_lowercase().as_str(), "bool" | "boolean")
818}
819
820struct SyncEvalSource<'a> {
828 ctx: &'a ExecContext,
829}
830
831#[async_trait::async_trait]
832impl crate::kernel::ArgValueSource for SyncEvalSource<'_> {
833 async fn eval(&self, expr: &Expr) -> anyhow::Result<Option<Value>> {
834 eval_simple_expr(expr, self.ctx).map_err(|e| anyhow::anyhow!(e))
835 }
836
837 async fn expand_glob(&self, _pattern: &str) -> anyhow::Result<Option<Vec<String>>> {
838 Ok(None)
844 }
845
846 async fn home(&self) -> Option<String> {
847 None
850 }
851}
852
853pub async fn build_tool_args(
867 args: &[Arg],
868 ctx: &ExecContext,
869 schema: Option<&ToolSchema>,
870) -> Result<ToolArgs, String> {
871 crate::kernel::bind_tool_args(args, schema, &SyncEvalSource { ctx })
872 .await
873 .map_err(|e| e.to_string())
874}
875
876pub(crate) fn eval_simple_expr(expr: &Expr, ctx: &ExecContext) -> Result<Option<Value>, String> {
892 match expr {
893 Expr::Literal(value) => Ok(Some(eval_literal(value, ctx))),
894 Expr::VarRef(path) => match ctx.scope.resolve_path(path) {
895 Ok(v) => Ok(Some(v)),
896 Err(PathError::UndefinedRoot(_)) if path.segments.len() <= 1 => Ok(None),
903 Err(PathError::UndefinedRoot(_)) => Err(format!(
904 "{}: undefined variable",
905 crate::interpreter::format_path(path)
906 )),
907 Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => Err(msg),
910 },
911 Expr::Interpolated(parts) => Ok(Some(Value::String(eval_string_parts_sync(parts, ctx)?))),
912 Expr::VarLength(path) => {
917 crate::interpreter::resolve_length(&ctx.scope, path).map(|n| Some(Value::Int(n)))
918 }
919 Expr::VarWithDefault { path, default } => {
920 match crate::interpreter::resolve_default(&ctx.scope, path)? {
921 Some(value) => Ok(Some(value)),
922 None => Ok(Some(Value::String(eval_string_parts_sync(default, ctx)?))),
923 }
924 }
925 Expr::GlobPattern(s) => Ok(Some(Value::String(s.clone()))),
926 Expr::Arithmetic(expr_str) => arithmetic::eval_arithmetic(expr_str, &ctx.scope)
934 .map(|n| Some(Value::Int(n)))
935 .map_err(|e| format!("arithmetic error: {e}")),
936 Expr::HereDocBody { parts, strip_tabs } => {
937 let mut asm = crate::interpreter::HeredocAssembler::new(*strip_tabs);
941 for sp in parts {
942 match &sp.part {
943 crate::ast::StringPart::Literal(s) => asm.push_literal(s),
944 other => {
945 let s = eval_string_parts_sync(std::slice::from_ref(other), ctx)?;
946 asm.push_interpolated(&s);
947 }
948 }
949 }
950 Ok(Some(Value::String(asm.into_string())))
951 }
952 Expr::CommandSubst(_) | Expr::Command(_) => Err(
959 "command substitution `$(...)` is not supported in a scatter/gather flag value here; \
960 assign it to a variable first (e.g. `n=$(...); scatter --limit $n`)"
961 .to_string(),
962 ),
963 _ => Ok(None), }
965}
966
967fn eval_literal(value: &Value, _ctx: &ExecContext) -> Value {
969 value.clone()
970}
971
972fn eval_string_parts_sync(parts: &[crate::ast::StringPart], ctx: &ExecContext) -> Result<String, String> {
980 let mut result = String::new();
981 for part in parts {
982 match part {
983 crate::ast::StringPart::Literal(s) => result.push_str(s),
984 crate::ast::StringPart::Var(path) => match ctx.scope.resolve_path(path) {
985 Ok(value) => result.push_str(
989 &crate::interpreter::value_to_text_sink(&value).map_err(|e| e.to_string())?,
990 ),
991 Err(PathError::UndefinedRoot(_)) => {}
999 Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => return Err(msg),
1000 },
1001 crate::ast::StringPart::VarWithDefault { path, default } => {
1002 match crate::interpreter::resolve_default(&ctx.scope, path)? {
1003 Some(value) => result.push_str(
1004 &crate::interpreter::value_to_text_sink(&value).map_err(|e| e.to_string())?,
1005 ),
1006 None => result.push_str(&eval_string_parts_sync(default, ctx)?),
1007 }
1008 }
1009 crate::ast::StringPart::VarLength(path) => {
1010 let len = crate::interpreter::resolve_length(&ctx.scope, path)?;
1015 result.push_str(&len.to_string());
1016 }
1017 crate::ast::StringPart::Positional(n) => {
1018 if let Some(s) = ctx.scope.get_positional(*n) {
1019 result.push_str(s);
1020 }
1021 }
1022 crate::ast::StringPart::AllArgs => {
1023 result.push_str(&ctx.scope.all_args().join(" "));
1024 }
1025 crate::ast::StringPart::ArgCount => {
1026 result.push_str(&ctx.scope.arg_count().to_string());
1027 }
1028 crate::ast::StringPart::Arithmetic(expr) => {
1029 let value = arithmetic::eval_arithmetic(expr, &ctx.scope)
1037 .map_err(|e| format!("arithmetic error: {e}"))?;
1038 result.push_str(&value.to_string());
1039 }
1040 crate::ast::StringPart::CommandSubst(_) => {
1041 return Err(
1047 "command substitution `$(...)` is not supported inside a scatter/gather \
1048 flag's interpolated value here; assign it to a variable first"
1049 .to_string(),
1050 );
1051 }
1052 crate::ast::StringPart::LastExitCode => {
1053 result.push_str(&ctx.scope.last_result().code.to_string());
1054 }
1055 crate::ast::StringPart::CurrentPid => {
1056 result.push_str(&ctx.scope.pid().to_string());
1057 }
1058 }
1059 }
1060 Ok(result)
1061}
1062
1063fn find_scatter_gather(commands: &[Command]) -> Option<(usize, usize)> {
1068 let scatter_idx = commands.iter().position(|c| c.name == "scatter")?;
1069 let gather_idx = commands.iter().position(|c| c.name == "gather")?;
1070
1071 if gather_idx > scatter_idx {
1073 Some((scatter_idx, gather_idx))
1074 } else {
1075 None
1076 }
1077}
1078
1079#[cfg(test)]
1080mod select_leaf_tests {
1081 use super::*;
1082 use crate::tools::ParamSchema;
1083
1084 fn kj_schema() -> ToolSchema {
1089 ToolSchema::new("kj", "kaijutsu")
1090 .param(ParamSchema::new("confirm", "string"))
1091 .param(ParamSchema::new("verbose", "bool"))
1092 .subcommand(
1093 ToolSchema::new("context", "context ops")
1094 .with_command_aliases(["ctx"])
1095 .subcommand(ToolSchema::new("list", "list").with_command_aliases(["ls"]))
1096 .subcommand(
1097 ToolSchema::new("create", "create").param(
1098 ParamSchema::new("type", "string").with_aliases(["t"]),
1099 ),
1100 ),
1101 )
1102 }
1103
1104 fn word(s: &str) -> Arg {
1105 Arg::Positional(Expr::Literal(Value::String(s.to_string())))
1106 }
1107
1108 #[test]
1109 fn flat_tool_returns_root() {
1110 let schema = ToolSchema::new("cat", "concat")
1111 .param(ParamSchema::required("path", "string", "f").positional());
1112 let leaf = select_leaf(&schema, &[word("foo.txt")]).expect("flat ok");
1113 assert_eq!(leaf.name, "cat");
1114 }
1115
1116 #[test]
1117 fn single_hop() {
1118 let schema = kj_schema();
1119 let leaf = select_leaf(&schema, &[word("context")]).expect("ok");
1120 assert_eq!(leaf.name, "context");
1121 }
1122
1123 #[test]
1124 fn two_hops() {
1125 let schema = kj_schema();
1126 let leaf = select_leaf(&schema, &[word("context"), word("create")]).expect("ok");
1127 assert_eq!(leaf.name, "create");
1128 assert!(leaf.params.iter().any(|p| p.name == "type"), "leaf has --type");
1129 }
1130
1131 #[test]
1132 fn alias_hops_route() {
1133 let schema = kj_schema();
1134 let leaf = select_leaf(&schema, &[word("ctx"), word("ls")]).expect("ok");
1136 assert_eq!(leaf.name, "list");
1137 }
1138
1139 #[test]
1140 fn unknown_subcommand_stops_at_current_node() {
1141 let schema = kj_schema();
1142 let leaf = select_leaf(&schema, &[word("context"), word("nonesuch")]).expect("ok");
1145 assert_eq!(leaf.name, "context");
1146 }
1147
1148 #[test]
1149 fn root_bool_flag_before_path_does_not_disrupt_routing() {
1150 let schema = kj_schema();
1151 let args = vec![Arg::LongFlag("verbose".into()), word("context"), word("create")];
1154 let leaf = select_leaf(&schema, &args).expect("ok");
1155 assert_eq!(leaf.name, "create");
1156 }
1157
1158 #[test]
1159 fn root_value_flag_space_form_before_path_skips_its_value() {
1160 let schema = kj_schema();
1161 let args = vec![
1164 Arg::LongFlag("confirm".into()),
1165 word("nonce"),
1166 word("context"),
1167 word("create"),
1168 ];
1169 let leaf = select_leaf(&schema, &args).expect("ok");
1170 assert_eq!(leaf.name, "create");
1171 }
1172
1173 #[test]
1174 fn leaf_value_flag_after_path_routes_to_leaf() {
1175 let schema = kj_schema();
1176 let args = vec![
1179 word("context"),
1180 word("create"),
1181 Arg::LongFlag("type".into()),
1182 word("x"),
1183 ];
1184 let leaf = select_leaf(&schema, &args).expect("ok");
1185 assert_eq!(leaf.name, "create");
1186 assert!(leaf.params.iter().any(|p| p.name == "type"));
1187 }
1188
1189 #[test]
1190 fn double_dash_stops_routing() {
1191 let schema = kj_schema();
1192 let leaf = select_leaf(&schema, &[Arg::DoubleDash, word("context")]).expect("ok");
1194 assert_eq!(leaf.name, "kj");
1195 }
1196
1197 #[test]
1198 fn computed_subcommand_selector_errors() {
1199 let schema = kj_schema();
1200 let args = vec![Arg::Positional(Expr::CommandSubst(vec![
1203 crate::ast::Stmt::Command(crate::ast::Command {
1204 name: "echo".into(),
1205 args: vec![],
1206 redirects: vec![],
1207 }),
1208 ]))];
1209 let err = select_leaf(&schema, &args).expect_err("must error");
1210 let msg = err.to_string();
1211 assert!(msg.contains("subcommand name is required"), "got: {msg}");
1212 assert!(msg.contains("command substitution"), "names the cause: {msg}");
1213 }
1214
1215 #[test]
1216 fn variable_subcommand_selector_errors() {
1217 let schema = kj_schema();
1218 let args = vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("sub")))];
1219 let err = select_leaf(&schema, &args).expect_err("must error");
1220 assert!(err.to_string().contains("variable reference"), "got: {err}");
1221 }
1222
1223 #[test]
1224 fn computed_positional_after_leaf_is_fine() {
1225 let schema = kj_schema();
1226 let args = vec![
1229 word("context"),
1230 word("list"),
1231 Arg::Positional(Expr::CommandSubst(vec![crate::ast::Stmt::Command(
1232 crate::ast::Command { name: "echo".into(), args: vec![], redirects: vec![] },
1233 )])),
1234 ];
1235 let leaf = select_leaf(&schema, &args).expect("ok");
1236 assert_eq!(leaf.name, "list");
1237 }
1238}
1239
1240#[cfg(test)]
1241mod tests {
1242 use super::*;
1243 use crate::dispatch::BackendDispatcher;
1244 use crate::tools::register_builtins;
1245 use crate::vfs::{Filesystem, MemoryFs, VfsRouter};
1246 use std::path::Path;
1247
1248 async fn make_runner_and_ctx() -> (PipelineRunner, ExecContext, BackendDispatcher) {
1249 let mut tools = ToolRegistry::new();
1250 register_builtins(&mut tools);
1251 let tools = Arc::new(tools);
1252 let runner = PipelineRunner::new(tools.clone());
1253 let dispatcher = BackendDispatcher::new(tools.clone());
1254
1255 let mut vfs = VfsRouter::new();
1256 let mem = MemoryFs::new();
1257 mem.write(Path::new("test.txt"), b"hello\nworld\nfoo").await.unwrap();
1258 vfs.mount("/", mem);
1259 let ctx = ExecContext::with_vfs_and_tools(Arc::new(vfs), tools);
1260
1261 (runner, ctx, dispatcher)
1262 }
1263
1264 fn make_cmd(name: &str, args: Vec<&str>) -> Command {
1265 Command {
1266 name: name.to_string(),
1267 args: args.iter().map(|s| Arg::Positional(Expr::Literal(Value::String(s.to_string())))).collect(),
1268 redirects: vec![],
1269 }
1270 }
1271
1272 #[tokio::test]
1273 async fn test_single_command() {
1274 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1275 let cmd = make_cmd("echo", vec!["hello"]);
1276
1277 let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
1278 assert!(result.ok());
1279 assert_eq!(result.text_out().trim(), "hello");
1280 }
1281
1282 #[tokio::test]
1283 async fn test_pipeline_echo_grep() {
1284 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1285
1286 let echo_cmd = Command {
1288 name: "echo".to_string(),
1289 args: vec![Arg::Positional(Expr::Literal(Value::String("hello\nworld".to_string())))],
1290 redirects: vec![],
1291 };
1292 let grep_cmd = Command {
1293 name: "grep".to_string(),
1294 args: vec![Arg::Positional(Expr::Literal(Value::String("world".to_string())))],
1295 redirects: vec![],
1296 };
1297
1298 let result = runner.run(&[echo_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1299 assert!(result.ok());
1300 assert_eq!(result.text_out().trim(), "world");
1301 }
1302
1303 #[tokio::test]
1304 async fn test_pipeline_cat_grep() {
1305 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1306
1307 let cat_cmd = make_cmd("cat", vec!["/test.txt"]);
1309 let grep_cmd = Command {
1310 name: "grep".to_string(),
1311 args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
1312 redirects: vec![],
1313 };
1314
1315 let result = runner.run(&[cat_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1316 assert!(result.ok());
1317 assert!(result.text_out().contains("hello"));
1318 }
1319
1320 #[tokio::test]
1321 async fn test_command_not_found() {
1322 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1323 let cmd = make_cmd("nonexistent", vec![]);
1324
1325 let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
1326 assert!(!result.ok());
1327 assert_eq!(result.code, 127);
1328 assert!(result.err.contains("not found"));
1329 }
1330
1331 #[tokio::test]
1332 async fn test_pipeline_continues_on_failure() {
1333 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1336
1337 let cat_cmd = make_cmd("cat", vec!["/nonexistent"]);
1340 let grep_cmd = Command {
1341 name: "grep".to_string(),
1342 args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
1343 redirects: vec![],
1344 };
1345
1346 let result = runner.run(&[cat_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1347 assert!(!result.ok());
1349 }
1350
1351 #[tokio::test]
1352 async fn test_pipeline_last_command_exit_code() {
1353 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1355
1356 let echo_cmd = make_cmd("echo", vec!["hello"]);
1357 let cat_cmd = make_cmd("cat", vec![]);
1358
1359 let result = runner.run(&[echo_cmd, cat_cmd], &mut ctx, &dispatcher).await;
1360 assert!(result.ok());
1361 assert!(result.text_out().contains("hello"));
1362 }
1363
1364 #[tokio::test]
1365 async fn test_empty_pipeline() {
1366 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1367 let result = runner.run(&[], &mut ctx, &dispatcher).await;
1368 assert!(result.ok());
1369 }
1370
1371 #[test]
1374 fn test_find_scatter_gather_both_present() {
1375 let commands = vec![
1376 make_cmd("echo", vec!["a"]),
1377 make_cmd("scatter", vec![]),
1378 make_cmd("process", vec![]),
1379 make_cmd("gather", vec![]),
1380 ];
1381 let result = find_scatter_gather(&commands);
1382 assert_eq!(result, Some((1, 3)));
1383 }
1384
1385 #[test]
1386 fn test_find_scatter_gather_no_scatter() {
1387 let commands = vec![
1388 make_cmd("echo", vec!["a"]),
1389 make_cmd("gather", vec![]),
1390 ];
1391 let result = find_scatter_gather(&commands);
1392 assert!(result.is_none());
1393 }
1394
1395 #[test]
1396 fn test_find_scatter_gather_no_gather() {
1397 let commands = vec![
1398 make_cmd("echo", vec!["a"]),
1399 make_cmd("scatter", vec![]),
1400 ];
1401 let result = find_scatter_gather(&commands);
1402 assert!(result.is_none());
1403 }
1404
1405 #[test]
1406 fn test_find_scatter_gather_wrong_order() {
1407 let commands = vec![
1408 make_cmd("gather", vec![]),
1409 make_cmd("scatter", vec![]),
1410 ];
1411 let result = find_scatter_gather(&commands);
1412 assert!(result.is_none());
1413 }
1414
1415 #[tokio::test]
1416 async fn test_scatter_gather_simple() {
1417 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1418
1419 let split_cmd = Command {
1421 name: "split".to_string(),
1422 args: vec![Arg::Positional(Expr::Literal(Value::String("a b c".to_string())))],
1423 redirects: vec![],
1424 };
1425 let scatter_cmd = make_cmd("scatter", vec![]);
1426 let process_cmd = Command {
1427 name: "echo".to_string(),
1428 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1429 redirects: vec![],
1430 };
1431 let gather_cmd = make_cmd("gather", vec![]);
1432
1433 let result = runner.run(&[split_cmd, scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1434 assert!(result.ok(), "scatter with structured data should succeed: {}", result.err);
1435 assert!(result.text_out().contains("a"));
1437 assert!(result.text_out().contains("b"));
1438 assert!(result.text_out().contains("c"));
1439 }
1440
1441 #[tokio::test]
1442 async fn test_scatter_gather_empty_input() {
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("".to_string())))],
1449 redirects: vec![],
1450 };
1451 let scatter_cmd = make_cmd("scatter", vec![]);
1452 let process_cmd = Command {
1453 name: "echo".to_string(),
1454 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1455 redirects: vec![],
1456 };
1457 let gather_cmd = make_cmd("gather", vec![]);
1458
1459 let result = runner.run(&[echo_cmd, scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1460 assert!(result.ok());
1461 assert!(result.text_out().trim().is_empty());
1462 }
1463
1464 #[tokio::test]
1465 async fn test_scatter_gather_with_structured_stdin() {
1466 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1467
1468 let data = Value::Json(serde_json::json!(["x", "y", "z"]));
1470 ctx.set_stdin_with_data("x\ny\nz".to_string(), Some(data));
1471
1472 let scatter_cmd = make_cmd("scatter", vec![]);
1473 let process_cmd = Command {
1474 name: "echo".to_string(),
1475 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1476 redirects: vec![],
1477 };
1478 let gather_cmd = make_cmd("gather", vec![]);
1479
1480 let result = runner.run(&[scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1481 assert!(result.ok(), "scatter with structured stdin should succeed: {}", result.err);
1482 assert!(result.text_out().contains("x"));
1483 assert!(result.text_out().contains("y"));
1484 assert!(result.text_out().contains("z"));
1485 }
1486
1487 #[tokio::test]
1488 async fn test_scatter_gather_json_input() {
1489 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1490
1491 let data = Value::Json(serde_json::json!(["one", "two", "three"]));
1493 ctx.set_stdin_with_data(r#"["one", "two", "three"]"#.to_string(), Some(data));
1494
1495 let scatter_cmd = make_cmd("scatter", vec![]);
1496 let process_cmd = Command {
1497 name: "echo".to_string(),
1498 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1499 redirects: vec![],
1500 };
1501 let gather_cmd = make_cmd("gather", vec![]);
1502
1503 let result = runner.run(&[scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1504 assert!(result.ok(), "scatter with JSON data should succeed: {}", result.err);
1505 assert!(result.text_out().contains("one"));
1506 assert!(result.text_out().contains("two"));
1507 assert!(result.text_out().contains("three"));
1508 }
1509
1510 #[tokio::test]
1511 async fn test_scatter_gather_with_post_gather() {
1512 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1513
1514 let split_cmd = Command {
1516 name: "split".to_string(),
1517 args: vec![Arg::Positional(Expr::Literal(Value::String("a b".to_string())))],
1518 redirects: vec![],
1519 };
1520 let scatter_cmd = make_cmd("scatter", vec![]);
1521 let process_cmd = Command {
1522 name: "echo".to_string(),
1523 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1524 redirects: vec![],
1525 };
1526 let gather_cmd = make_cmd("gather", vec![]);
1527 let grep_cmd = Command {
1528 name: "grep".to_string(),
1529 args: vec![Arg::Positional(Expr::Literal(Value::String("a".to_string())))],
1530 redirects: vec![],
1531 };
1532
1533 let result = runner.run(&[split_cmd, scatter_cmd, process_cmd, gather_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1534 assert!(result.ok(), "scatter with post_gather should succeed: {}", result.err);
1535 assert!(result.text_out().contains("a"));
1536 assert!(!result.text_out().contains("b"));
1537 }
1538
1539 #[tokio::test]
1540 async fn test_scatter_custom_var_name() {
1541 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1542
1543 let data = Value::Json(serde_json::json!(["test1", "test2"]));
1545 ctx.set_stdin_with_data("test1\ntest2".to_string(), Some(data));
1546
1547 let scatter_cmd = Command {
1549 name: "scatter".to_string(),
1550 args: vec![Arg::Named {
1551 key: "as".to_string(),
1552 value: Expr::Literal(Value::String("URL".to_string())),
1553 }],
1554 redirects: vec![],
1555 };
1556 let process_cmd = Command {
1557 name: "echo".to_string(),
1558 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("URL")))],
1559 redirects: vec![],
1560 };
1561 let gather_cmd = make_cmd("gather", vec![]);
1562
1563 let result = runner.run(&[scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1564 assert!(result.ok(), "scatter with custom var should succeed: {}", result.err);
1565 assert!(result.text_out().contains("test1"));
1566 assert!(result.text_out().contains("test2"));
1567 }
1568
1569 #[tokio::test]
1572 async fn test_pipeline_routes_through_backend() {
1573 use crate::backend::testing::MockBackend;
1574 use std::sync::atomic::Ordering;
1575
1576 let (backend, call_count) = MockBackend::new();
1578 let backend: std::sync::Arc<dyn crate::backend::KernelBackend> = std::sync::Arc::new(backend);
1579
1580 let mut ctx = crate::tools::ExecContext::with_backend(backend);
1582
1583 let tools = std::sync::Arc::new(ToolRegistry::new());
1585 let runner = PipelineRunner::new(tools.clone());
1586 let dispatcher = BackendDispatcher::new(tools);
1587
1588 let cmd = make_cmd("test-tool", vec!["arg1"]);
1590 let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
1591
1592 assert!(result.ok(), "Mock backend should return success");
1593 assert_eq!(call_count.load(Ordering::SeqCst), 1, "call_tool should be invoked once");
1594 assert!(result.text_out().contains("mock executed"), "Output should be from mock backend");
1595 }
1596
1597 #[tokio::test]
1598 async fn test_multi_command_pipeline_routes_through_backend() {
1599 use crate::backend::testing::MockBackend;
1600 use std::sync::atomic::Ordering;
1601
1602 let (backend, call_count) = MockBackend::new();
1603 let backend: std::sync::Arc<dyn crate::backend::KernelBackend> = std::sync::Arc::new(backend);
1604 let mut ctx = crate::tools::ExecContext::with_backend(backend);
1605
1606 let tools = std::sync::Arc::new(ToolRegistry::new());
1607 let runner = PipelineRunner::new(tools.clone());
1608 let dispatcher = BackendDispatcher::new(tools);
1609
1610 let cmd1 = make_cmd("tool1", vec![]);
1612 let cmd2 = make_cmd("tool2", vec![]);
1613 let cmd3 = make_cmd("tool3", vec![]);
1614
1615 let result = runner.run(&[cmd1, cmd2, cmd3], &mut ctx, &dispatcher).await;
1616
1617 assert!(result.ok());
1618 assert_eq!(call_count.load(Ordering::SeqCst), 3, "call_tool should be invoked for each command");
1619 }
1620
1621 #[tokio::test]
1628 async fn backend_dispatcher_scalar_data_matches_production_unwrap() {
1629 use crate::backend::testing::MockBackend;
1630 use crate::backend::ToolResult;
1631
1632 let (mock, _calls) = MockBackend::new();
1633 let backend = mock.with_tool_result(|_name| Ok(ToolResult::with_data("", serde_json::json!(42))));
1634 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
1635 let mut ctx = ExecContext::with_backend(backend);
1636
1637 let dispatcher = BackendDispatcher::new(Arc::new(ToolRegistry::new()));
1638 let cmd = make_cmd("embedder_tool", vec![]);
1639
1640 let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
1641 assert_eq!(
1642 result.data,
1643 Some(Value::Int(42)),
1644 "a scalar ToolResult.data must unwrap to a native Value, matching \
1645 the production From<ToolResult> path — not stay Value::Json(42)"
1646 );
1647 }
1648
1649 #[tokio::test]
1655 async fn backend_dispatcher_envelope_shaped_data_stays_structured() {
1656 use crate::backend::testing::MockBackend;
1657 use crate::backend::ToolResult;
1658
1659 let envelope = kaish_types::bytes_to_envelope(&[1u8, 2, 3]);
1660 let (mock, _calls) = MockBackend::new();
1661 let backend = mock.with_tool_result(move |_name| Ok(ToolResult::with_data("", envelope.clone())));
1662 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
1663 let mut ctx = ExecContext::with_backend(backend);
1664
1665 let dispatcher = BackendDispatcher::new(Arc::new(ToolRegistry::new()));
1666 let cmd = make_cmd("embedder_tool", vec![]);
1667
1668 let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
1669 assert!(
1670 matches!(result.data, Some(Value::Json(_))),
1671 "envelope-shaped external data must stay structured, not silently \
1672 decode to Value::Bytes: got {:?}",
1673 result.data
1674 );
1675 }
1676
1677 #[tokio::test]
1682 async fn backend_dispatcher_preserves_did_spill_and_original_code() {
1683 use crate::backend::testing::MockBackend;
1684 use crate::backend::ToolResult;
1685
1686 let (mock, _calls) = MockBackend::new();
1687 let backend = mock.with_tool_result(|_name| {
1688 Ok(ToolResult::success("truncated...")
1689 .with_did_spill(true)
1690 .with_original_code(Some(0)))
1691 });
1692 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
1693 let mut ctx = ExecContext::with_backend(backend);
1694
1695 let dispatcher = BackendDispatcher::new(Arc::new(ToolRegistry::new()));
1696 let cmd = make_cmd("embedder_tool", vec![]);
1697
1698 let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
1699 assert!(result.did_spill, "did_spill must survive the backend seam");
1700 assert_eq!(result.original_code, Some(0), "original_code must survive the backend seam");
1701 }
1702
1703 use crate::tools::{ParamSchema, ToolSchema};
1706
1707 fn make_test_schema() -> ToolSchema {
1708 ToolSchema::new("test-tool", "A test tool for schema-aware parsing")
1709 .param(ParamSchema::required("query", "string", "Search query"))
1710 .param(ParamSchema::optional("limit", "int", Value::Int(10), "Max results"))
1711 .param(ParamSchema::optional("verbose", "bool", Value::Bool(false), "Verbose output"))
1712 .param(ParamSchema::optional("output", "string", Value::String("stdout".into()), "Output destination"))
1713 .with_positional_mapping()
1714 }
1715
1716 fn make_minimal_ctx() -> ExecContext {
1717 let mut vfs = VfsRouter::new();
1718 vfs.mount("/", MemoryFs::new());
1719 ExecContext::new(Arc::new(vfs))
1720 }
1721
1722 fn test_dispatcher() -> BackendDispatcher {
1726 BackendDispatcher::new(Arc::new(ToolRegistry::new()))
1727 }
1728
1729 #[tokio::test]
1730 async fn test_schema_aware_string_arg() {
1731 let args = vec![
1733 Arg::LongFlag("query".to_string()),
1734 Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
1735 ];
1736 let schema = make_test_schema();
1737 let ctx = make_minimal_ctx();
1738
1739 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1740
1741 assert!(tool_args.flags.is_empty(), "No flags should be set");
1742 assert!(tool_args.positional.is_empty(), "No positionals - consumed by --query");
1743 assert_eq!(
1744 tool_args.named.get("query"),
1745 Some(&Value::String("test".to_string())),
1746 "--query should consume 'test' as its value"
1747 );
1748 }
1749
1750 #[tokio::test]
1751 async fn test_schema_aware_bool_flag() {
1752 let args = vec![
1754 Arg::LongFlag("verbose".to_string()),
1755 ];
1756 let schema = make_test_schema();
1757 let ctx = make_minimal_ctx();
1758
1759 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1760
1761 assert!(tool_args.flags.contains("verbose"), "--verbose should be a flag");
1762 assert!(tool_args.named.is_empty(), "No named args");
1763 assert!(tool_args.positional.is_empty(), "No positionals");
1764 }
1765
1766 #[tokio::test]
1767 async fn test_schema_aware_mixed() {
1768 let args = vec![
1771 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
1772 Arg::LongFlag("output".to_string()),
1773 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
1774 Arg::LongFlag("verbose".to_string()),
1775 ];
1776 let schema = make_test_schema();
1777 let ctx = make_minimal_ctx();
1778
1779 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1780
1781 assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
1782 assert_eq!(
1783 tool_args.named.get("query"),
1784 Some(&Value::String("file.txt".to_string()))
1785 );
1786 assert_eq!(
1787 tool_args.named.get("output"),
1788 Some(&Value::String("out.txt".to_string()))
1789 );
1790 assert!(tool_args.flags.contains("verbose"));
1791 }
1792
1793 #[tokio::test]
1794 async fn test_schema_aware_multiple_string_args() {
1795 let args = vec![
1797 Arg::LongFlag("query".to_string()),
1798 Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
1799 Arg::LongFlag("output".to_string()),
1800 Arg::Positional(Expr::Literal(Value::String("result.json".to_string()))),
1801 Arg::LongFlag("verbose".to_string()),
1802 Arg::LongFlag("limit".to_string()),
1803 Arg::Positional(Expr::Literal(Value::Int(5))),
1804 ];
1805 let schema = make_test_schema();
1806 let ctx = make_minimal_ctx();
1807
1808 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1809
1810 assert!(tool_args.positional.is_empty(), "All positionals consumed");
1811 assert_eq!(
1812 tool_args.named.get("query"),
1813 Some(&Value::String("test".to_string()))
1814 );
1815 assert_eq!(
1816 tool_args.named.get("output"),
1817 Some(&Value::String("result.json".to_string()))
1818 );
1819 assert_eq!(
1820 tool_args.named.get("limit"),
1821 Some(&Value::Int(5))
1822 );
1823 assert!(tool_args.flags.contains("verbose"));
1824 }
1825
1826 #[tokio::test]
1827 async fn test_schema_aware_double_dash() {
1828 let args = vec![
1831 Arg::LongFlag("output".to_string()),
1832 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
1833 Arg::DoubleDash,
1834 Arg::Positional(Expr::Literal(Value::String("--this-is-data".to_string()))),
1835 ];
1836 let schema = make_test_schema();
1837 let ctx = make_minimal_ctx();
1838
1839 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1840
1841 assert_eq!(
1842 tool_args.named.get("output"),
1843 Some(&Value::String("out.txt".to_string()))
1844 );
1845 assert_eq!(
1847 tool_args.positional,
1848 vec![Value::String("--this-is-data".to_string())]
1849 );
1850 }
1851
1852 #[tokio::test]
1858 async fn word_assign_binary_value_is_loud_not_placeholder() {
1859 let args = vec![Arg::WordAssign {
1860 key: "if".to_string(),
1861 value: Expr::Literal(Value::Bytes(vec![0xff, 0x00, 0xfe])),
1862 }];
1863 let ctx = make_minimal_ctx();
1864
1865 let err = build_tool_args(&args, &ctx, None).await.expect_err("binary WordAssign must error");
1868 assert!(
1869 err.contains("cannot be used as"),
1870 "error should name the binary problem, got {err:?}"
1871 );
1872 }
1873
1874 #[tokio::test]
1879 async fn word_assign_before_double_dash_binds_named_for_export() {
1880 let args = vec![Arg::WordAssign {
1881 key: "A".to_string(),
1882 value: Expr::Literal(Value::String("1".to_string())),
1883 }];
1884 let schema = ToolSchema::new("export", "export");
1885 let ctx = make_minimal_ctx();
1886
1887 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1888 assert_eq!(tool_args.named.get("A"), Some(&Value::String("1".to_string())));
1889 assert!(tool_args.positional.is_empty());
1890 }
1891
1892 #[tokio::test]
1901 async fn word_assign_after_double_dash_is_positional_even_for_export() {
1902 let args = vec![
1903 Arg::DoubleDash,
1904 Arg::WordAssign {
1905 key: "A".to_string(),
1906 value: Expr::Literal(Value::String("1".to_string())),
1907 },
1908 ];
1909 let schema = ToolSchema::new("export", "export");
1910 let ctx = make_minimal_ctx();
1911
1912 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1913 assert!(
1914 tool_args.named.is_empty(),
1915 "past `--`, A=1 must not become a named assignment: {:?}",
1916 tool_args.named
1917 );
1918 assert_eq!(tool_args.positional, vec![Value::String("A=1".to_string())]);
1919 }
1920
1921 #[tokio::test]
1929 async fn named_true_on_undeclared_flag_flagifies() {
1930 let args = vec![Arg::Named {
1931 key: "json".to_string(),
1932 value: Expr::Literal(Value::Bool(true)),
1933 }];
1934 let schema = make_test_schema(); let ctx = make_minimal_ctx();
1936
1937 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1938 assert!(tool_args.flags.contains("json"), "flags: {:?}", tool_args.flags);
1939 assert!(!tool_args.named.contains_key("json"), "named: {:?}", tool_args.named);
1940 }
1941
1942 #[tokio::test]
1945 async fn named_false_on_undeclared_flag_drops() {
1946 let args = vec![Arg::Named {
1947 key: "json".to_string(),
1948 value: Expr::Literal(Value::Bool(false)),
1949 }];
1950 let schema = make_test_schema();
1951 let ctx = make_minimal_ctx();
1952
1953 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1954 assert!(!tool_args.flags.contains("json"));
1955 assert!(!tool_args.named.contains_key("json"));
1956 }
1957
1958 #[tokio::test]
1962 async fn named_true_on_declared_bool_param_flagifies() {
1963 let args = vec![Arg::Named {
1964 key: "verbose".to_string(),
1965 value: Expr::Literal(Value::Bool(true)),
1966 }];
1967 let schema = make_test_schema();
1968 let ctx = make_minimal_ctx();
1969
1970 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1971 assert!(tool_args.flags.contains("verbose"));
1972 assert!(!tool_args.named.contains_key("verbose"));
1973 }
1974
1975 #[tokio::test]
1981 async fn named_true_on_declared_value_flag_keeps_value() {
1982 let args = vec![Arg::Named {
1983 key: "output".to_string(),
1984 value: Expr::Literal(Value::Bool(true)),
1985 }];
1986 let schema = make_test_schema();
1987 let ctx = make_minimal_ctx();
1988
1989 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1990 assert_eq!(tool_args.named.get("output"), Some(&Value::Bool(true)));
1991 assert!(!tool_args.flags.contains("output"));
1992 }
1993
1994 #[tokio::test]
1999 async fn named_true_with_no_schema_flagifies() {
2000 let args = vec![Arg::Named {
2001 key: "verbose".to_string(),
2002 value: Expr::Literal(Value::Bool(true)),
2003 }];
2004 let ctx = make_minimal_ctx();
2005
2006 let tool_args = build_tool_args(&args, &ctx, None).await.expect("build_tool_args");
2007 assert!(tool_args.flags.contains("verbose"));
2008 assert!(!tool_args.named.contains_key("verbose"));
2009 }
2010
2011 #[tokio::test]
2012 async fn test_no_schema_fallback() {
2013 let args = vec![
2015 Arg::LongFlag("query".to_string()),
2016 Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
2017 ];
2018 let ctx = make_minimal_ctx();
2019
2020 let tool_args = build_tool_args(&args, &ctx, None).await.expect("build_tool_args");
2021
2022 assert!(tool_args.flags.contains("query"), "--query should be a flag");
2024 assert_eq!(
2025 tool_args.positional,
2026 vec![Value::String("test".to_string())],
2027 "'test' should be a positional"
2028 );
2029 }
2030
2031 #[tokio::test]
2041 async fn test_unknown_flag_ambiguous_space_value_now_errors_loud() {
2042 let args = vec![
2043 Arg::LongFlag("unknown".to_string()),
2044 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2045 ];
2046 let schema = make_test_schema();
2047 let ctx = make_minimal_ctx();
2048
2049 let err = build_tool_args(&args, &ctx, Some(&schema))
2050 .await
2051 .expect_err("an undeclared flag immediately before a positional must be ambiguous, not silently bool");
2052 assert!(
2053 err.contains("--unknown is not a declared flag"),
2054 "got: {err}"
2055 );
2056 }
2057
2058 #[tokio::test]
2062 async fn test_unknown_bool_flag_with_no_following_positional_is_fine() {
2063 let args = vec![
2064 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2065 Arg::LongFlag("unknown".to_string()),
2066 ];
2067 let schema = make_test_schema();
2068 let ctx = make_minimal_ctx();
2069
2070 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2071
2072 assert!(tool_args.flags.contains("unknown"));
2073 assert!(tool_args.positional.is_empty(), "value consumed as query param");
2074 assert_eq!(
2075 tool_args.named.get("query"),
2076 Some(&Value::String("value".to_string()))
2077 );
2078 }
2079
2080 #[tokio::test]
2090 async fn test_unknown_short_flag_ambiguous_space_value_now_errors_loud() {
2091 let args = vec![
2092 Arg::ShortFlag("t".to_string()),
2093 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2094 ];
2095 let schema = make_test_schema();
2096 let ctx = make_minimal_ctx();
2097
2098 let err = build_tool_args(&args, &ctx, Some(&schema))
2099 .await
2100 .expect_err("an undeclared short flag immediately before a positional must be ambiguous, not silently bool");
2101 assert!(
2102 err.contains("-t is not a declared flag"),
2103 "got: {err}"
2104 );
2105 }
2106
2107 #[tokio::test]
2111 async fn test_unknown_short_bool_flag_with_no_following_positional_is_fine() {
2112 let args = vec![
2113 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2114 Arg::ShortFlag("t".to_string()),
2115 ];
2116 let schema = make_test_schema();
2117 let ctx = make_minimal_ctx();
2118
2119 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2120
2121 assert!(tool_args.flags.contains("t"));
2122 assert!(tool_args.positional.is_empty(), "value consumed as query param");
2123 assert_eq!(
2124 tool_args.named.get("query"),
2125 Some(&Value::String("value".to_string()))
2126 );
2127 }
2128
2129 #[tokio::test]
2135 async fn test_unknown_short_flag_not_ambiguous_without_map_positionals() {
2136 let args = vec![
2137 Arg::ShortFlag("t".to_string()),
2138 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2139 ];
2140 let schema = ToolSchema::new("test-tool", "A test tool")
2143 .param(ParamSchema::required("query", "string", "Search query"));
2144 let ctx = make_minimal_ctx();
2145
2146 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2147 assert!(tool_args.flags.contains("t"));
2148 assert_eq!(tool_args.positional, vec![Value::String("value".to_string())]);
2149 }
2150
2151 #[tokio::test]
2152 async fn test_named_args_unchanged() {
2153 let args = vec![
2155 Arg::Named {
2156 key: "query".to_string(),
2157 value: Expr::Literal(Value::String("test".to_string())),
2158 },
2159 Arg::LongFlag("verbose".to_string()),
2160 ];
2161 let schema = make_test_schema();
2162 let ctx = make_minimal_ctx();
2163
2164 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2165
2166 assert_eq!(
2167 tool_args.named.get("query"),
2168 Some(&Value::String("test".to_string()))
2169 );
2170 assert!(tool_args.flags.contains("verbose"));
2171 }
2172
2173 #[tokio::test]
2174 async fn test_short_flags_unchanged() {
2175 let args = vec![
2177 Arg::ShortFlag("la".to_string()),
2178 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
2179 ];
2180 let schema = make_test_schema();
2181 let ctx = make_minimal_ctx();
2182
2183 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2184
2185 assert!(tool_args.flags.contains("l"));
2186 assert!(tool_args.flags.contains("a"));
2187 assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
2188 assert_eq!(
2189 tool_args.named.get("query"),
2190 Some(&Value::String("file.txt".to_string()))
2191 );
2192 }
2193
2194 #[tokio::test]
2195 async fn test_flag_at_end_no_value() {
2196 let args = vec![
2199 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
2200 Arg::LongFlag("output".to_string()),
2201 ];
2202 let schema = make_test_schema();
2203 let ctx = make_minimal_ctx();
2204
2205 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2206
2207 assert!(tool_args.flags.contains("output"));
2209 assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
2210 assert_eq!(
2211 tool_args.named.get("query"),
2212 Some(&Value::String("file.txt".to_string()))
2213 );
2214 }
2215
2216 #[tokio::test]
2217 async fn test_positional_skips_bool_params() {
2218 let schema = ToolSchema::new("test", "")
2222 .param(ParamSchema::required("query", "string", ""))
2223 .param(ParamSchema::optional(
2224 "verbose",
2225 "bool",
2226 Value::Bool(false),
2227 "",
2228 ))
2229 .param(ParamSchema::optional(
2230 "output",
2231 "string",
2232 Value::Null,
2233 "",
2234 ))
2235 .with_positional_mapping();
2236 let args = vec![
2237 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2238 Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
2239 ];
2240 let ctx = make_minimal_ctx();
2241
2242 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2243
2244 assert_eq!(
2245 tool_args.named.get("query"),
2246 Some(&Value::String("val1".to_string()))
2247 );
2248 assert_eq!(
2249 tool_args.named.get("output"),
2250 Some(&Value::String("val2".to_string()))
2251 );
2252 assert!(!tool_args.flags.contains("verbose"));
2253 assert!(tool_args.positional.is_empty());
2254 }
2255
2256 #[tokio::test]
2257 async fn test_positionals_fill_available_slots() {
2258 let args = vec![
2261 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2262 Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
2263 Arg::Positional(Expr::Literal(Value::String("val3".to_string()))),
2264 ];
2265 let schema = make_test_schema(); let ctx = make_minimal_ctx();
2267
2268 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2269
2270 assert_eq!(
2273 tool_args.named.get("query"),
2274 Some(&Value::String("val1".to_string()))
2275 );
2276 assert_eq!(
2277 tool_args.named.get("limit"),
2278 Some(&Value::String("val2".to_string()))
2279 );
2280 assert_eq!(
2281 tool_args.named.get("output"),
2282 Some(&Value::String("val3".to_string()))
2283 );
2284 assert!(tool_args.positional.is_empty());
2285 }
2286
2287 #[tokio::test]
2288 async fn test_truly_excess_positionals() {
2289 let schema = ToolSchema::new("test", "")
2291 .param(ParamSchema::required("name", "string", ""))
2292 .with_positional_mapping();
2293 let args = vec![
2294 Arg::Positional(Expr::Literal(Value::String("first".to_string()))),
2295 Arg::Positional(Expr::Literal(Value::String("second".to_string()))),
2296 Arg::Positional(Expr::Literal(Value::String("third".to_string()))),
2297 ];
2298 let ctx = make_minimal_ctx();
2299
2300 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2301
2302 assert_eq!(
2303 tool_args.named.get("name"),
2304 Some(&Value::String("first".to_string()))
2305 );
2306 assert_eq!(
2307 tool_args.positional,
2308 vec![
2309 Value::String("second".to_string()),
2310 Value::String("third".to_string()),
2311 ]
2312 );
2313 }
2314
2315 #[tokio::test]
2316 async fn test_double_dash_positional_not_mapped() {
2317 let args = vec![
2319 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2320 Arg::DoubleDash,
2321 Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
2322 ];
2323 let schema = make_test_schema();
2324 let ctx = make_minimal_ctx();
2325
2326 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2327
2328 assert_eq!(
2329 tool_args.named.get("query"),
2330 Some(&Value::String("val1".to_string()))
2331 );
2332 assert_eq!(
2334 tool_args.positional,
2335 vec![Value::String("val2".to_string())]
2336 );
2337 }
2338
2339 #[tokio::test]
2340 async fn test_all_params_filled_by_flags() {
2341 let args = vec![
2343 Arg::LongFlag("query".to_string()),
2344 Arg::Positional(Expr::Literal(Value::String("search".to_string()))),
2345 Arg::LongFlag("output".to_string()),
2346 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
2347 Arg::LongFlag("verbose".to_string()),
2348 ];
2349 let schema = make_test_schema();
2350 let ctx = make_minimal_ctx();
2351
2352 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2353
2354 assert_eq!(
2355 tool_args.named.get("query"),
2356 Some(&Value::String("search".to_string()))
2357 );
2358 assert_eq!(
2359 tool_args.named.get("output"),
2360 Some(&Value::String("out.txt".to_string()))
2361 );
2362 assert!(tool_args.flags.contains("verbose"));
2363 assert!(tool_args.positional.is_empty());
2364 }
2365
2366 #[tokio::test]
2367 async fn test_mixed_flags_and_positional_fill() {
2368 let args = vec![
2370 Arg::LongFlag("output".to_string()),
2371 Arg::Positional(Expr::Literal(Value::String("foo".to_string()))),
2372 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2373 ];
2374 let schema = make_test_schema();
2375 let ctx = make_minimal_ctx();
2376
2377 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2378
2379 assert_eq!(
2380 tool_args.named.get("output"),
2381 Some(&Value::String("foo".to_string()))
2382 );
2383 assert_eq!(
2384 tool_args.named.get("query"),
2385 Some(&Value::String("val1".to_string()))
2386 );
2387 assert!(tool_args.positional.is_empty());
2388 }
2389
2390 #[tokio::test]
2391 async fn test_alias_flag_prevents_mapping_overwrite() {
2392 let schema = ToolSchema::new("test", "")
2394 .param(ParamSchema::required("query", "string", "").with_aliases(["-q"]))
2395 .param(ParamSchema::required("output", "string", ""))
2396 .with_positional_mapping();
2397 let args = vec![
2398 Arg::ShortFlag("q".to_string()),
2399 Arg::Positional(Expr::Literal(Value::String("search".to_string()))),
2400 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
2401 ];
2402 let ctx = make_minimal_ctx();
2403
2404 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2405
2406 assert_eq!(
2407 tool_args.named.get("query"),
2408 Some(&Value::String("search".to_string()))
2409 );
2410 assert_eq!(
2411 tool_args.named.get("output"),
2412 Some(&Value::String("out.txt".to_string()))
2413 );
2414 assert!(tool_args.positional.is_empty());
2415 }
2416
2417 #[tokio::test]
2418 async fn test_builtin_schema_no_positional_mapping() {
2419 let schema = ToolSchema::new("echo", "")
2421 .param(ParamSchema::optional("args", "any", Value::Null, ""))
2422 .param(ParamSchema::optional("no_newline", "bool", Value::Bool(false), ""));
2423 let args = vec![
2425 Arg::Positional(Expr::Literal(Value::String("hello".to_string()))),
2426 Arg::Positional(Expr::Literal(Value::String("world".to_string()))),
2427 ];
2428 let ctx = make_minimal_ctx();
2429
2430 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2431
2432 assert_eq!(
2434 tool_args.positional,
2435 vec![
2436 Value::String("hello".to_string()),
2437 Value::String("world".to_string()),
2438 ]
2439 );
2440 assert!(!tool_args.named.contains_key("args"));
2441 }
2442
2443 #[tokio::test]
2444 async fn test_short_flag_with_alias_consumes_value() {
2445 let schema = ToolSchema::new("head", "Output first part of files")
2448 .param(ParamSchema::optional("lines", "int", Value::Int(10), "Number of lines")
2449 .with_aliases(["-n"]));
2450 let args = vec![
2451 Arg::ShortFlag("n".to_string()),
2452 Arg::Positional(Expr::Literal(Value::Int(5))),
2453 Arg::Positional(Expr::Literal(Value::String("/tmp/file.txt".to_string()))),
2454 ];
2455 let ctx = make_minimal_ctx();
2456
2457 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2458
2459 assert!(tool_args.flags.is_empty(), "no boolean flags: {:?}", tool_args.flags);
2460 assert_eq!(tool_args.named.get("lines"), Some(&Value::Int(5)), "should resolve alias to canonical name");
2461 assert_eq!(tool_args.positional, vec![Value::String("/tmp/file.txt".to_string())]);
2462 }
2463
2464 #[tokio::test]
2476 async fn test_glued_short_flag_value_now_binds() {
2477 let schema = ToolSchema::new("cut", "")
2481 .param(ParamSchema::optional("fields", "string", Value::Null, "").with_aliases(["-f"]));
2482 let args = vec![Arg::ShortFlag("f1".to_string())];
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!(tool_args.flags.is_empty(), "no bogus bool flags: {:?}", tool_args.flags);
2488 assert_eq!(tool_args.named.get("fields"), Some(&Value::String("1".to_string())));
2489 }
2490
2491 #[tokio::test]
2492 async fn test_repeatable_flag_now_accumulates() {
2493 let schema = ToolSchema::new("sed", "")
2498 .param(ParamSchema::optional("expression", "string", Value::Null, "")
2499 .with_aliases(["-e"])
2500 .with_repeatable(true));
2501 let args = vec![
2502 Arg::ShortFlag("e".to_string()),
2503 Arg::Positional(Expr::Literal(Value::String("A".to_string()))),
2504 Arg::ShortFlag("e".to_string()),
2505 Arg::Positional(Expr::Literal(Value::String("B".to_string()))),
2506 ];
2507 let ctx = make_minimal_ctx();
2508
2509 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2510
2511 assert_eq!(
2512 tool_args.named.get("expression"),
2513 Some(&Value::Json(serde_json::json!(["A", "B"]))),
2514 "both occurrences must survive, not just the last: {:?}",
2515 tool_args.named
2516 );
2517 }
2518
2519 #[tokio::test]
2520 async fn test_multi_consume_flag_now_accumulates() {
2521 let schema = ToolSchema::new("jq", "")
2526 .param(ParamSchema::optional("arg", "any", Value::Null, "").consumes(2));
2527 let args = vec![
2528 Arg::LongFlag("arg".to_string()),
2529 Arg::Positional(Expr::Literal(Value::String("name".to_string()))),
2530 Arg::Positional(Expr::Literal(Value::String("val".to_string()))),
2531 ];
2532 let ctx = make_minimal_ctx();
2533
2534 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2535
2536 assert_eq!(
2537 tool_args.named.get("arg"),
2538 Some(&Value::Json(serde_json::json!([["name", "val"]]))),
2539 "got: {:?}",
2540 tool_args.named
2541 );
2542 assert!(tool_args.positional.is_empty());
2543 }
2544
2545 #[tokio::test]
2548 async fn test_merge_stderr_redirect() {
2549 let result = ExecResult::from_output(0, "stdout content", "stderr content");
2551
2552 let redirects = vec![Redirect {
2553 kind: RedirectKind::MergeStderr,
2554 target: Expr::Literal(Value::Null),
2555 }];
2556
2557 let ctx = make_minimal_ctx();
2558 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2559
2560 assert_eq!(&*result.text_out(), "stdout contentstderr content");
2561 assert!(result.err.is_empty());
2562 }
2563
2564 #[tokio::test]
2565 async fn test_merge_stderr_with_empty_stderr() {
2566 let result = ExecResult::from_output(0, "stdout only", "");
2568
2569 let redirects = vec![Redirect {
2570 kind: RedirectKind::MergeStderr,
2571 target: Expr::Literal(Value::Null),
2572 }];
2573
2574 let ctx = make_minimal_ctx();
2575 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2576
2577 assert_eq!(&*result.text_out(), "stdout only");
2578 assert!(result.err.is_empty());
2579 }
2580
2581 #[tokio::test]
2582 async fn test_merge_stderr_order_matters() {
2583 let result = ExecResult::from_output(0, "stdout\n", "stderr\n");
2588
2589 let redirects = vec![Redirect {
2591 kind: RedirectKind::MergeStderr,
2592 target: Expr::Literal(Value::Null),
2593 }];
2594
2595 let ctx = make_minimal_ctx();
2596 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2597
2598 assert_eq!(&*result.text_out(), "stdout\nstderr\n");
2599 assert!(result.err.is_empty());
2600 }
2601
2602 #[tokio::test]
2603 async fn test_redirect_with_command_execution() {
2604 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
2605
2606 let cmd = Command {
2608 name: "echo".to_string(),
2609 args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
2610 redirects: vec![Redirect {
2611 kind: RedirectKind::MergeStderr,
2612 target: Expr::Literal(Value::Null),
2613 }],
2614 };
2615
2616 let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
2617 assert!(result.ok());
2618 assert!(result.text_out().contains("hello"));
2620 }
2621
2622 #[tokio::test]
2623 async fn test_merge_stderr_in_pipeline() {
2624 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
2625
2626 let echo_cmd = Command {
2629 name: "echo".to_string(),
2630 args: vec![Arg::Positional(Expr::Literal(Value::String("output".to_string())))],
2631 redirects: vec![Redirect {
2632 kind: RedirectKind::MergeStderr,
2633 target: Expr::Literal(Value::Null),
2634 }],
2635 };
2636 let grep_cmd = Command {
2637 name: "grep".to_string(),
2638 args: vec![Arg::Positional(Expr::Literal(Value::String("output".to_string())))],
2639 redirects: vec![],
2640 };
2641
2642 let result = runner.run(&[echo_cmd, grep_cmd], &mut ctx, &dispatcher).await;
2643 assert!(result.ok(), "result failed: code={}, err={}", result.code, result.err);
2644 assert!(result.text_out().contains("output"));
2645 }
2646
2647 fn big_table_output(rows: usize) -> crate::interpreter::OutputData {
2659 use crate::interpreter::OutputNode;
2660 let headers = vec!["id".to_string(), "name".to_string()];
2661 let nodes: Vec<OutputNode> = (0..rows)
2662 .map(|i| OutputNode::new(i.to_string()).with_cells(vec![format!("row-{i}")]))
2663 .collect();
2664 crate::interpreter::OutputData::table(headers, nodes)
2665 }
2666
2667 #[tokio::test]
2668 async fn test_both_redirect_streams_structured_output_to_file() {
2669 let output = big_table_output(50);
2674 let expected_stdout = output.to_canonical_string();
2675 let mut result = ExecResult::with_output(output);
2676 result.err = "warning: heads up\n".to_string();
2677
2678 let redirects = vec![Redirect {
2679 kind: RedirectKind::Both,
2680 target: Expr::Literal(Value::String("/out.txt".to_string())),
2681 }];
2682 let ctx = make_minimal_ctx();
2683 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2684
2685 assert!(result.ok());
2688 assert_eq!(&*result.text_out(), "");
2689 assert!(result.err.is_empty());
2690 assert!(!result.has_output());
2691
2692 let written = ctx.backend.read(Path::new("/out.txt"), None).await.expect("file written");
2693 let written = String::from_utf8(written).expect("valid utf8");
2694 assert_eq!(written, format!("{expected_stdout}warning: heads up\n"));
2698 }
2699
2700 #[tokio::test]
2701 async fn test_both_redirect_streams_large_structured_output_intact() {
2702 let rows = 5_000;
2707 let output = big_table_output(rows);
2708 let expected_stdout = output.to_canonical_string();
2709 let result = ExecResult::with_output(output);
2710
2711 let redirects = vec![Redirect {
2712 kind: RedirectKind::Both,
2713 target: Expr::Literal(Value::String("/big.txt".to_string())),
2714 }];
2715 let ctx = make_minimal_ctx();
2716 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2717 assert!(result.ok());
2718
2719 let written = ctx.backend.read(Path::new("/big.txt"), None).await.expect("file written");
2720 let written = String::from_utf8(written).expect("valid utf8");
2721 assert_eq!(written, expected_stdout);
2722 assert!(written.contains("row-0"));
2723 assert!(written.contains(&format!("row-{}", rows - 1)));
2724 }
2725
2726 #[tokio::test]
2727 async fn test_both_redirect_still_writes_binary_stdout_raw() {
2728 let result = ExecResult::success_text_or_bytes(vec![0xff, 0x00, 0xfe, b'x']);
2732 let redirects = vec![Redirect {
2733 kind: RedirectKind::Both,
2734 target: Expr::Literal(Value::String("/bin.out".to_string())),
2735 }];
2736 let ctx = make_minimal_ctx();
2737 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2738 assert!(result.ok());
2739
2740 let written = ctx.backend.read(Path::new("/bin.out"), None).await.expect("file written");
2741 assert_eq!(written, vec![0xff, 0x00, 0xfe, b'x']);
2742 }
2743}