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::{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
311#[derive(Clone)]
313pub struct PipelineRunner {
314 tools: Arc<ToolRegistry>,
315}
316
317impl PipelineRunner {
318 pub fn new(tools: Arc<ToolRegistry>) -> Self {
320 Self { tools }
321 }
322
323 pub async fn run(
333 &self,
334 commands: &[Command],
335 ctx: &mut ExecContext,
336 dispatcher: &dyn CommandDispatcher,
337 ) -> ExecResult {
338 if commands.is_empty() {
339 return ExecResult::success("");
340 }
341
342 if let Some((scatter_idx, gather_idx)) = find_scatter_gather(commands) {
344 return self.run_scatter_gather(commands, scatter_idx, gather_idx, ctx, dispatcher).await;
345 }
346
347 self.run_sequential(commands, ctx, dispatcher).await
348 }
349
350 pub async fn run_sequential(
355 &self,
356 commands: &[Command],
357 ctx: &mut ExecContext,
358 dispatcher: &dyn CommandDispatcher,
359 ) -> ExecResult {
360 if commands.is_empty() {
361 return ExecResult::success("");
362 }
363
364 if commands.len() == 1 {
365 return self.run_single(&commands[0], ctx, None, dispatcher).await;
367 }
368
369 self.run_pipeline(commands, ctx, dispatcher).await
371 }
372
373 async fn run_scatter_gather(
375 &self,
376 commands: &[Command],
377 scatter_idx: usize,
378 gather_idx: usize,
379 ctx: &mut ExecContext,
380 dispatcher: &dyn CommandDispatcher,
381 ) -> ExecResult {
382 let pre_scatter = &commands[..scatter_idx];
384 let scatter_cmd = &commands[scatter_idx];
385 let parallel = &commands[scatter_idx + 1..gather_idx];
386 let gather_cmd = &commands[gather_idx];
387 let post_gather = &commands[gather_idx + 1..];
388
389 let format = (has_json_flag(&scatter_cmd.args) || has_json_flag(&gather_cmd.args))
400 .then_some(OutputFormat::Json);
401
402 let scatter_schema = self.tools.get("scatter").map(|t| t.schema());
410 let gather_schema = self.tools.get("gather").map(|t| t.schema());
411 let scatter_args = match build_tool_args(&scatter_cmd.args, ctx, scatter_schema.as_ref()).await {
412 Ok(args) => args,
413 Err(e) => {
414 return finalize_scatter_gather_error(
415 ExecResult::failure(1, format!("scatter: {e}")),
416 format,
417 )
418 }
419 };
420 let gather_args = match build_tool_args(&gather_cmd.args, ctx, gather_schema.as_ref()).await {
421 Ok(args) => args,
422 Err(e) => {
423 return finalize_scatter_gather_error(
424 ExecResult::failure(1, format!("gather: {e}")),
425 format,
426 )
427 }
428 };
429 let scatter_opts = match parse_scatter_options(&scatter_args) {
430 Ok(opts) => opts,
431 Err(e) => {
432 return finalize_scatter_gather_error(
433 ExecResult::failure(2, format!("scatter: {e}")),
434 format,
435 )
436 }
437 };
438 let gather_opts = match parse_gather_options(&gather_args) {
439 Ok(opts) => opts,
440 Err(e) => {
441 return finalize_scatter_gather_error(
442 ExecResult::failure(2, format!("gather: {e}")),
443 format,
444 )
445 }
446 };
447
448 let sequential_dispatcher: Arc<dyn CommandDispatcher> = dispatcher.fork_attached().await;
453
454 let runner = ScatterGatherRunner::new(self.tools.clone(), sequential_dispatcher);
455 runner
456 .run(
457 pre_scatter,
458 scatter_opts,
459 parallel,
460 gather_opts,
461 &gather_cmd.redirects,
462 post_gather,
463 ctx,
464 )
465 .await
466 }
467
468 async fn run_single(
473 &self,
474 cmd: &Command,
475 ctx: &mut ExecContext,
476 stdin: Option<Vec<u8>>,
477 dispatcher: &dyn CommandDispatcher,
478 ) -> ExecResult {
479 if let Err(e) = setup_stdin_redirects(cmd, ctx, dispatcher).await {
481 return ExecResult::failure(1, e);
482 }
483
484 if let Some(input) = stdin {
486 ctx.set_stdin(input);
487 }
488
489 ctx.pipeline_position = PipelinePosition::Only;
491
492 let result = match dispatcher.dispatch(cmd, ctx).await {
494 Ok(result) => result,
495 Err(e) => ExecResult::failure(1, e.to_string()),
496 };
497
498 apply_redirects(result, &cmd.redirects, ctx, dispatcher).await
500 }
501
502 async fn run_pipeline(
512 &self,
513 commands: &[Command],
514 ctx: &mut ExecContext,
515 dispatcher: &dyn CommandDispatcher,
516 ) -> ExecResult {
517 let stage_count = commands.len();
518 let last_idx = stage_count - 1;
519
520 let mut pipe_writers: Vec<Option<super::pipe_stream::PipeWriter>> = Vec::new();
522 let mut pipe_readers: Vec<Option<super::pipe_stream::PipeReader>> = Vec::new();
523
524 for _ in 0..last_idx {
525 let (writer, reader) = pipe_stream_default();
526 pipe_writers.push(Some(writer));
527 pipe_readers.push(Some(reader));
528 }
529
530 let mut data_senders: Vec<Option<tokio::sync::oneshot::Sender<Option<Value>>>> = Vec::new();
532 let mut data_receivers: Vec<Option<tokio::sync::oneshot::Receiver<Option<Value>>>> = Vec::new();
533
534 for _ in 0..last_idx {
535 let (tx, rx) = tokio::sync::oneshot::channel();
536 data_senders.push(Some(tx));
537 data_receivers.push(Some(rx));
538 }
539
540 let mut handles: Vec<tokio::task::JoinHandle<(ExecResult, ExecContext)>> = Vec::with_capacity(stage_count);
541
542 for (i, cmd) in commands.iter().enumerate() {
543 let mut stage_ctx = ctx.child_for_pipeline();
544 let cmd = cmd.clone();
545
546 let task_dispatcher: Arc<dyn CommandDispatcher> = dispatcher.fork_attached().await;
551
552 let stdin_setup = setup_stdin_redirects(&cmd, &mut stage_ctx, dispatcher).await;
556
557 if i == 0 {
559 if stage_ctx.stdin.is_none() {
562 stage_ctx.stdin = ctx.stdin.take();
563 }
564 if stage_ctx.stdin_data.is_none() {
565 stage_ctx.stdin_data = ctx.stdin_data.take();
566 }
567 if stage_ctx.stdin.is_none() && stage_ctx.pipe_stdin.is_none() {
571 stage_ctx.pipe_stdin = ctx.pipe_stdin.take();
572 }
573 } else {
574 stage_ctx.pipe_stdin = pipe_readers[i - 1].take();
576 }
578
579 if i < last_idx {
581 stage_ctx.pipe_stdout = pipe_writers[i].take();
582 }
583
584 stage_ctx.pipeline_position = match i {
586 0 => PipelinePosition::First,
587 n if n == last_idx => PipelinePosition::Last,
588 _ => PipelinePosition::Middle,
589 };
590
591 let data_sender = if i < last_idx { data_senders[i].take() } else { None };
592 let data_receiver = if i > 0 { data_receivers[i - 1].take() } else { None };
593
594 let handle: tokio::task::JoinHandle<(ExecResult, ExecContext)> =
597 tokio::spawn(crate::telemetry::bind_current_context(async move {
598 if let Err(e) = stdin_setup {
600 return (ExecResult::failure(1, e), stage_ctx);
601 }
602
603 stage_ctx.stdin_data_rx = data_receiver;
611
612 let mut result = match task_dispatcher.dispatch(&cmd, &mut stage_ctx).await {
614 Ok(result) => result,
615 Err(e) => ExecResult::failure(1, e.to_string()),
616 };
617
618 result = apply_redirects(result, &cmd.redirects, &stage_ctx, &*task_dispatcher).await;
623
624 if !result.err.is_empty() {
630 if let Some(ref stderr) = stage_ctx.stderr {
631 stderr.write_str(&result.err);
632 result.err.clear();
633 }
634 }
635
636 if let Some(tx) = data_sender {
642 let _ = tx.send(result.data.clone());
643 }
644
645 if let Some(mut pipe_out) = stage_ctx.pipe_stdout.take() {
648 let bytes: Vec<u8> = if let Some(b) = result.out_bytes() {
656 b.to_vec()
657 } else if let Some(output) = result.take_output_for_stream() {
658 let mut buf = Vec::new();
659 if output.write_canonical(&mut buf, None).is_err() {
665 buf = output.to_canonical_string().into_bytes();
666 }
667 buf
668 } else {
669 result.text_out().into_owned().into_bytes()
670 };
671 if !bytes.is_empty() {
672 let _ = pipe_out.write_all(&bytes).await;
674 let _ = pipe_out.shutdown().await;
675 }
676 }
678
679 (result, stage_ctx)
680 }));
681
682 handles.push(handle);
683 }
684
685 let mut last_result = ExecResult::success("");
690 let mut panics: Vec<String> = Vec::new();
691
692 for (i, handle) in handles.into_iter().enumerate() {
693 match handle.await {
694 Ok((result, stage_ctx)) => {
695 if i == last_idx {
696 last_result = result;
697 ctx.scope = stage_ctx.scope;
699 ctx.cwd = stage_ctx.cwd;
700 ctx.prev_cwd = stage_ctx.prev_cwd;
701 ctx.aliases = stage_ctx.aliases;
702 }
703 }
704 Err(e) => {
705 panics.push(format!("stage {}: {}", i, e));
706 }
707 }
708 }
709
710
711 if !panics.is_empty() {
714 last_result = ExecResult::failure(
715 1,
716 format!("pipeline stage(s) panicked: {}", panics.join("; ")),
717 );
718 }
719
720 last_result
721 }
722}
723
724pub fn select_leaf<'a>(schema: &'a ToolSchema, args: &[Arg]) -> anyhow::Result<&'a ToolSchema> {
764 let root_lookup = schema_param_lookup(schema);
767 let is_root_value_flag = |name: &str| -> bool {
768 root_lookup.get(name).is_some_and(|(_, typ, ..)| !is_bool_type(typ))
769 };
770
771 let mut node = schema;
772 let mut skip_next_positional = false;
773 for arg in args {
774 match arg {
775 Arg::DoubleDash => break,
777 Arg::LongFlag(name) if is_root_value_flag(name) => skip_next_positional = true,
780 Arg::ShortFlag(name) if is_root_value_flag(name) => skip_next_positional = true,
781 Arg::Positional(expr) => {
782 if skip_next_positional {
783 skip_next_positional = false;
784 continue; }
786 if node.subcommands.is_empty() {
787 break; }
789 match classify_subcommand_positional(expr) {
790 SubcommandWord::Word(word) => {
791 match node.subcommands.iter().find(|c| c.matches_command(word)) {
792 Some(child) => node = child, None => break, }
795 }
796 SubcommandWord::OtherLiteral => break,
800 SubcommandWord::Computed(kind) => anyhow::bail!(
801 "{}: a subcommand name is required here, but got {kind}. \
802 Subcommands must be literal words — spell it out \
803 (e.g. `{} <subcommand> …`) or use the `--flag=value` form.",
804 node.name,
805 schema.name
806 ),
807 }
808 }
809 _ => {}
811 }
812 }
813 Ok(node)
814}
815
816enum SubcommandWord<'a> {
818 Word(&'a str),
820 OtherLiteral,
822 Computed(&'static str),
824}
825
826fn classify_subcommand_positional(expr: &Expr) -> SubcommandWord<'_> {
827 match expr {
828 Expr::Literal(Value::String(s)) => SubcommandWord::Word(s),
829 Expr::Literal(_) => SubcommandWord::OtherLiteral,
830 Expr::CommandSubst(_) | Expr::Command(_) => SubcommandWord::Computed("a command substitution `$(…)`"),
831 Expr::VarRef(_)
832 | Expr::VarWithDefault { .. }
833 | Expr::VarLength(_)
834 | Expr::Positional(_)
835 | Expr::AllArgs
836 | Expr::ArgCount
837 | Expr::CurrentPid
838 | Expr::LastExitCode => SubcommandWord::Computed("a variable reference"),
839 Expr::Interpolated(_) | Expr::HereDocBody { .. } => SubcommandWord::Computed("an interpolated string"),
840 Expr::GlobPattern(_) => SubcommandWord::Computed("a glob pattern"),
841 Expr::Arithmetic(_) => SubcommandWord::Computed("an arithmetic expansion"),
842 _ => SubcommandWord::Computed("a value computed at runtime"),
843 }
844}
845
846pub fn schema_param_lookup(schema: &ToolSchema) -> HashMap<String, (&str, &str, usize, bool)> {
847 let mut map = HashMap::new();
848 for p in schema.params.iter().filter(|p| !p.positional) {
849 map.insert(p.name.clone(), (p.name.as_str(), p.param_type.as_str(), p.consumes, p.repeatable));
850 for alias in &p.aliases {
851 let stripped = alias.trim_start_matches('-');
852 map.insert(stripped.to_string(), (p.name.as_str(), p.param_type.as_str(), p.consumes, p.repeatable));
853 }
854 }
855 map
856}
857
858pub fn is_bool_type(param_type: &str) -> bool {
860 matches!(param_type.to_lowercase().as_str(), "bool" | "boolean")
861}
862
863struct SyncEvalSource<'a> {
871 ctx: &'a ExecContext,
872}
873
874#[async_trait::async_trait]
875impl crate::kernel::ArgValueSource for SyncEvalSource<'_> {
876 async fn eval(&self, expr: &Expr) -> anyhow::Result<Option<Value>> {
877 eval_simple_expr(expr, self.ctx).map_err(|e| anyhow::anyhow!(e))
878 }
879
880 async fn expand_glob(&self, _pattern: &str) -> anyhow::Result<Option<Vec<String>>> {
881 Ok(None)
887 }
888
889 async fn home(&self) -> Option<String> {
890 None
893 }
894}
895
896pub async fn build_tool_args(
910 args: &[Arg],
911 ctx: &ExecContext,
912 schema: Option<&ToolSchema>,
913) -> Result<ToolArgs, String> {
914 crate::kernel::bind_tool_args(args, schema, &SyncEvalSource { ctx })
915 .await
916 .map_err(|e| e.to_string())
917}
918
919pub(crate) fn eval_simple_expr(expr: &Expr, ctx: &ExecContext) -> Result<Option<Value>, String> {
935 match expr {
936 Expr::Literal(value) => Ok(Some(eval_literal(value, ctx))),
937 Expr::VarRef(path) => match ctx.scope.resolve_path(path) {
938 Ok(v) => Ok(Some(v)),
939 Err(PathError::UndefinedRoot(_)) if path.segments.len() <= 1 => Ok(None),
946 Err(PathError::UndefinedRoot(_)) => Err(format!(
947 "{}: undefined variable",
948 crate::interpreter::format_path(path)
949 )),
950 Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => Err(msg),
953 },
954 Expr::Interpolated(parts) => Ok(Some(Value::String(eval_string_parts_sync(parts, ctx)?))),
955 Expr::VarLength(path) => {
960 crate::interpreter::resolve_length(&ctx.scope, path).map(|n| Some(Value::Int(n)))
961 }
962 Expr::VarWithDefault { path, default } => {
963 match crate::interpreter::resolve_default(&ctx.scope, path)? {
964 Some(value) => Ok(Some(value)),
965 None => Ok(Some(Value::String(eval_string_parts_sync(default, ctx)?))),
966 }
967 }
968 Expr::GlobPattern(s) => Ok(Some(Value::String(s.clone()))),
969 Expr::Arithmetic(expr_str) => arithmetic::eval_arithmetic(expr_str, &ctx.scope)
977 .map(|n| Some(Value::Int(n)))
978 .map_err(|e| format!("arithmetic error: {e}")),
979 Expr::HereDocBody { parts, strip_tabs } => {
980 let mut asm = crate::interpreter::HeredocAssembler::new(*strip_tabs);
984 for sp in parts {
985 match &sp.part {
986 crate::ast::StringPart::Literal(s) => asm.push_literal(s),
987 other => {
988 let s = eval_string_parts_sync(std::slice::from_ref(other), ctx)?;
989 asm.push_interpolated(&s);
990 }
991 }
992 }
993 Ok(Some(Value::String(asm.into_string())))
994 }
995 Expr::CommandSubst(_) | Expr::Command(_) => Err(
1002 "command substitution `$(...)` is not supported in a scatter/gather flag value here; \
1003 assign it to a variable first (e.g. `n=$(...); scatter --limit $n`)"
1004 .to_string(),
1005 ),
1006 _ => Ok(None), }
1008}
1009
1010fn eval_literal(value: &Value, _ctx: &ExecContext) -> Value {
1012 value.clone()
1013}
1014
1015fn eval_string_parts_sync(parts: &[crate::ast::StringPart], ctx: &ExecContext) -> Result<String, String> {
1023 let mut result = String::new();
1024 for part in parts {
1025 match part {
1026 crate::ast::StringPart::Literal(s) => result.push_str(s),
1027 crate::ast::StringPart::Var(path) => match ctx.scope.resolve_path(path) {
1028 Ok(value) => result.push_str(
1032 &crate::interpreter::value_to_text_sink(&value).map_err(|e| e.to_string())?,
1033 ),
1034 Err(PathError::UndefinedRoot(_)) => {}
1042 Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => return Err(msg),
1043 },
1044 crate::ast::StringPart::VarWithDefault { path, default } => {
1045 match crate::interpreter::resolve_default(&ctx.scope, path)? {
1046 Some(value) => result.push_str(
1047 &crate::interpreter::value_to_text_sink(&value).map_err(|e| e.to_string())?,
1048 ),
1049 None => result.push_str(&eval_string_parts_sync(default, ctx)?),
1050 }
1051 }
1052 crate::ast::StringPart::VarLength(path) => {
1053 let len = crate::interpreter::resolve_length(&ctx.scope, path)?;
1058 result.push_str(&len.to_string());
1059 }
1060 crate::ast::StringPart::Positional(n) => {
1061 if let Some(s) = ctx.scope.get_positional(*n) {
1062 result.push_str(s);
1063 }
1064 }
1065 crate::ast::StringPart::AllArgs => {
1066 result.push_str(&ctx.scope.all_args().join(" "));
1067 }
1068 crate::ast::StringPart::ArgCount => {
1069 result.push_str(&ctx.scope.arg_count().to_string());
1070 }
1071 crate::ast::StringPart::Arithmetic(expr) => {
1072 let value = arithmetic::eval_arithmetic(expr, &ctx.scope)
1080 .map_err(|e| format!("arithmetic error: {e}"))?;
1081 result.push_str(&value.to_string());
1082 }
1083 crate::ast::StringPart::CommandSubst(_) => {
1084 return Err(
1090 "command substitution `$(...)` is not supported inside a scatter/gather \
1091 flag's interpolated value here; assign it to a variable first"
1092 .to_string(),
1093 );
1094 }
1095 crate::ast::StringPart::LastExitCode => {
1096 result.push_str(&ctx.scope.last_result().code.to_string());
1097 }
1098 crate::ast::StringPart::CurrentPid => {
1099 result.push_str(&ctx.scope.pid().to_string());
1100 }
1101 }
1102 }
1103 Ok(result)
1104}
1105
1106fn find_scatter_gather(commands: &[Command]) -> Option<(usize, usize)> {
1111 let scatter_idx = commands.iter().position(|c| c.name == "scatter")?;
1112 let gather_idx = commands.iter().position(|c| c.name == "gather")?;
1113
1114 if gather_idx > scatter_idx {
1116 Some((scatter_idx, gather_idx))
1117 } else {
1118 None
1119 }
1120}
1121
1122#[cfg(test)]
1123mod select_leaf_tests {
1124 use super::*;
1125 use crate::tools::ParamSchema;
1126
1127 fn kj_schema() -> ToolSchema {
1132 ToolSchema::new("kj", "kaijutsu")
1133 .param(ParamSchema::new("confirm", "string"))
1134 .param(ParamSchema::new("verbose", "bool"))
1135 .subcommand(
1136 ToolSchema::new("context", "context ops")
1137 .with_command_aliases(["ctx"])
1138 .subcommand(ToolSchema::new("list", "list").with_command_aliases(["ls"]))
1139 .subcommand(
1140 ToolSchema::new("create", "create").param(
1141 ParamSchema::new("type", "string").with_aliases(["t"]),
1142 ),
1143 ),
1144 )
1145 }
1146
1147 fn word(s: &str) -> Arg {
1148 Arg::Positional(Expr::Literal(Value::String(s.to_string())))
1149 }
1150
1151 #[test]
1152 fn flat_tool_returns_root() {
1153 let schema = ToolSchema::new("cat", "concat")
1154 .param(ParamSchema::required("path", "string", "f").positional());
1155 let leaf = select_leaf(&schema, &[word("foo.txt")]).expect("flat ok");
1156 assert_eq!(leaf.name, "cat");
1157 }
1158
1159 #[test]
1160 fn single_hop() {
1161 let schema = kj_schema();
1162 let leaf = select_leaf(&schema, &[word("context")]).expect("ok");
1163 assert_eq!(leaf.name, "context");
1164 }
1165
1166 #[test]
1167 fn two_hops() {
1168 let schema = kj_schema();
1169 let leaf = select_leaf(&schema, &[word("context"), word("create")]).expect("ok");
1170 assert_eq!(leaf.name, "create");
1171 assert!(leaf.params.iter().any(|p| p.name == "type"), "leaf has --type");
1172 }
1173
1174 #[test]
1175 fn alias_hops_route() {
1176 let schema = kj_schema();
1177 let leaf = select_leaf(&schema, &[word("ctx"), word("ls")]).expect("ok");
1179 assert_eq!(leaf.name, "list");
1180 }
1181
1182 #[test]
1183 fn unknown_subcommand_stops_at_current_node() {
1184 let schema = kj_schema();
1185 let leaf = select_leaf(&schema, &[word("context"), word("nonesuch")]).expect("ok");
1188 assert_eq!(leaf.name, "context");
1189 }
1190
1191 #[test]
1192 fn root_bool_flag_before_path_does_not_disrupt_routing() {
1193 let schema = kj_schema();
1194 let args = vec![Arg::LongFlag("verbose".into()), word("context"), word("create")];
1197 let leaf = select_leaf(&schema, &args).expect("ok");
1198 assert_eq!(leaf.name, "create");
1199 }
1200
1201 #[test]
1202 fn root_value_flag_space_form_before_path_skips_its_value() {
1203 let schema = kj_schema();
1204 let args = vec![
1207 Arg::LongFlag("confirm".into()),
1208 word("token"),
1209 word("context"),
1210 word("create"),
1211 ];
1212 let leaf = select_leaf(&schema, &args).expect("ok");
1213 assert_eq!(leaf.name, "create");
1214 }
1215
1216 #[test]
1217 fn leaf_value_flag_after_path_routes_to_leaf() {
1218 let schema = kj_schema();
1219 let args = vec![
1222 word("context"),
1223 word("create"),
1224 Arg::LongFlag("type".into()),
1225 word("x"),
1226 ];
1227 let leaf = select_leaf(&schema, &args).expect("ok");
1228 assert_eq!(leaf.name, "create");
1229 assert!(leaf.params.iter().any(|p| p.name == "type"));
1230 }
1231
1232 #[test]
1233 fn double_dash_stops_routing() {
1234 let schema = kj_schema();
1235 let leaf = select_leaf(&schema, &[Arg::DoubleDash, word("context")]).expect("ok");
1237 assert_eq!(leaf.name, "kj");
1238 }
1239
1240 #[test]
1241 fn computed_subcommand_selector_errors() {
1242 let schema = kj_schema();
1243 let args = vec![Arg::Positional(Expr::CommandSubst(vec![
1246 crate::ast::Stmt::Command(crate::ast::Command {
1247 name: "echo".into(),
1248 args: vec![],
1249 redirects: vec![],
1250 }),
1251 ]))];
1252 let err = select_leaf(&schema, &args).expect_err("must error");
1253 let msg = err.to_string();
1254 assert!(msg.contains("subcommand name is required"), "got: {msg}");
1255 assert!(msg.contains("command substitution"), "names the cause: {msg}");
1256 }
1257
1258 #[test]
1259 fn variable_subcommand_selector_errors() {
1260 let schema = kj_schema();
1261 let args = vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("sub")))];
1262 let err = select_leaf(&schema, &args).expect_err("must error");
1263 assert!(err.to_string().contains("variable reference"), "got: {err}");
1264 }
1265
1266 #[test]
1267 fn computed_positional_after_leaf_is_fine() {
1268 let schema = kj_schema();
1269 let args = vec![
1272 word("context"),
1273 word("list"),
1274 Arg::Positional(Expr::CommandSubst(vec![crate::ast::Stmt::Command(
1275 crate::ast::Command { name: "echo".into(), args: vec![], redirects: vec![] },
1276 )])),
1277 ];
1278 let leaf = select_leaf(&schema, &args).expect("ok");
1279 assert_eq!(leaf.name, "list");
1280 }
1281}
1282
1283#[cfg(test)]
1284mod tests {
1285 use super::*;
1286 use crate::dispatch::BackendDispatcher;
1287 use crate::tools::register_builtins;
1288 use crate::vfs::{Filesystem, MemoryFs, VfsRouter};
1289 use std::path::Path;
1290
1291 async fn make_runner_and_ctx() -> (PipelineRunner, ExecContext, BackendDispatcher) {
1292 let mut tools = ToolRegistry::new();
1293 register_builtins(&mut tools);
1294 let tools = Arc::new(tools);
1295 let runner = PipelineRunner::new(tools.clone());
1296 let dispatcher = BackendDispatcher::new(tools.clone());
1297
1298 let mut vfs = VfsRouter::new();
1299 let mem = MemoryFs::new();
1300 mem.write(Path::new("test.txt"), b"hello\nworld\nfoo").await.unwrap();
1301 vfs.mount("/", mem);
1302 let ctx = ExecContext::with_vfs_and_tools(Arc::new(vfs), tools);
1303
1304 (runner, ctx, dispatcher)
1305 }
1306
1307 fn make_cmd(name: &str, args: Vec<&str>) -> Command {
1308 Command {
1309 name: name.to_string(),
1310 args: args.iter().map(|s| Arg::Positional(Expr::Literal(Value::String(s.to_string())))).collect(),
1311 redirects: vec![],
1312 }
1313 }
1314
1315 #[tokio::test]
1316 async fn test_single_command() {
1317 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1318 let cmd = make_cmd("echo", vec!["hello"]);
1319
1320 let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
1321 assert!(result.ok());
1322 assert_eq!(result.text_out().trim(), "hello");
1323 }
1324
1325 #[tokio::test]
1326 async fn test_pipeline_echo_grep() {
1327 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1328
1329 let echo_cmd = Command {
1331 name: "echo".to_string(),
1332 args: vec![Arg::Positional(Expr::Literal(Value::String("hello\nworld".to_string())))],
1333 redirects: vec![],
1334 };
1335 let grep_cmd = Command {
1336 name: "grep".to_string(),
1337 args: vec![Arg::Positional(Expr::Literal(Value::String("world".to_string())))],
1338 redirects: vec![],
1339 };
1340
1341 let result = runner.run(&[echo_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1342 assert!(result.ok());
1343 assert_eq!(result.text_out().trim(), "world");
1344 }
1345
1346 #[tokio::test]
1347 async fn test_pipeline_cat_grep() {
1348 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1349
1350 let cat_cmd = make_cmd("cat", vec!["/test.txt"]);
1352 let grep_cmd = Command {
1353 name: "grep".to_string(),
1354 args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
1355 redirects: vec![],
1356 };
1357
1358 let result = runner.run(&[cat_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1359 assert!(result.ok());
1360 assert!(result.text_out().contains("hello"));
1361 }
1362
1363 #[tokio::test]
1364 async fn test_command_not_found() {
1365 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1366 let cmd = make_cmd("nonexistent", vec![]);
1367
1368 let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
1369 assert!(!result.ok());
1370 assert_eq!(result.code, 127);
1371 assert!(result.err.contains("not found"));
1372 }
1373
1374 #[tokio::test]
1375 async fn test_pipeline_continues_on_failure() {
1376 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1379
1380 let cat_cmd = make_cmd("cat", vec!["/nonexistent"]);
1383 let grep_cmd = Command {
1384 name: "grep".to_string(),
1385 args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
1386 redirects: vec![],
1387 };
1388
1389 let result = runner.run(&[cat_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1390 assert!(!result.ok());
1392 }
1393
1394 #[tokio::test]
1395 async fn test_pipeline_last_command_exit_code() {
1396 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1398
1399 let echo_cmd = make_cmd("echo", vec!["hello"]);
1400 let cat_cmd = make_cmd("cat", vec![]);
1401
1402 let result = runner.run(&[echo_cmd, cat_cmd], &mut ctx, &dispatcher).await;
1403 assert!(result.ok());
1404 assert!(result.text_out().contains("hello"));
1405 }
1406
1407 #[tokio::test]
1408 async fn test_empty_pipeline() {
1409 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1410 let result = runner.run(&[], &mut ctx, &dispatcher).await;
1411 assert!(result.ok());
1412 }
1413
1414 #[test]
1417 fn test_find_scatter_gather_both_present() {
1418 let commands = vec![
1419 make_cmd("echo", vec!["a"]),
1420 make_cmd("scatter", vec![]),
1421 make_cmd("process", vec![]),
1422 make_cmd("gather", vec![]),
1423 ];
1424 let result = find_scatter_gather(&commands);
1425 assert_eq!(result, Some((1, 3)));
1426 }
1427
1428 #[test]
1429 fn test_find_scatter_gather_no_scatter() {
1430 let commands = vec![
1431 make_cmd("echo", vec!["a"]),
1432 make_cmd("gather", vec![]),
1433 ];
1434 let result = find_scatter_gather(&commands);
1435 assert!(result.is_none());
1436 }
1437
1438 #[test]
1439 fn test_find_scatter_gather_no_gather() {
1440 let commands = vec![
1441 make_cmd("echo", vec!["a"]),
1442 make_cmd("scatter", vec![]),
1443 ];
1444 let result = find_scatter_gather(&commands);
1445 assert!(result.is_none());
1446 }
1447
1448 #[test]
1449 fn test_find_scatter_gather_wrong_order() {
1450 let commands = vec![
1451 make_cmd("gather", vec![]),
1452 make_cmd("scatter", vec![]),
1453 ];
1454 let result = find_scatter_gather(&commands);
1455 assert!(result.is_none());
1456 }
1457
1458 #[tokio::test]
1459 async fn test_scatter_gather_simple() {
1460 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1461
1462 let split_cmd = Command {
1464 name: "split".to_string(),
1465 args: vec![Arg::Positional(Expr::Literal(Value::String("a b c".to_string())))],
1466 redirects: vec![],
1467 };
1468 let scatter_cmd = make_cmd("scatter", vec![]);
1469 let process_cmd = Command {
1470 name: "echo".to_string(),
1471 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1472 redirects: vec![],
1473 };
1474 let gather_cmd = make_cmd("gather", vec![]);
1475
1476 let result = runner.run(&[split_cmd, scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1477 assert!(result.ok(), "scatter with structured data should succeed: {}", result.err);
1478 assert!(result.text_out().contains("a"));
1480 assert!(result.text_out().contains("b"));
1481 assert!(result.text_out().contains("c"));
1482 }
1483
1484 #[tokio::test]
1485 async fn test_scatter_gather_empty_input() {
1486 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1487
1488 let echo_cmd = Command {
1490 name: "echo".to_string(),
1491 args: vec![Arg::Positional(Expr::Literal(Value::String("".to_string())))],
1492 redirects: vec![],
1493 };
1494 let scatter_cmd = make_cmd("scatter", vec![]);
1495 let process_cmd = Command {
1496 name: "echo".to_string(),
1497 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1498 redirects: vec![],
1499 };
1500 let gather_cmd = make_cmd("gather", vec![]);
1501
1502 let result = runner.run(&[echo_cmd, scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1503 assert!(result.ok());
1504 assert!(result.text_out().trim().is_empty());
1505 }
1506
1507 #[tokio::test]
1508 async fn test_scatter_gather_with_structured_stdin() {
1509 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1510
1511 let data = Value::Json(serde_json::json!(["x", "y", "z"]));
1513 ctx.set_stdin_with_data("x\ny\nz".to_string(), Some(data));
1514
1515 let scatter_cmd = make_cmd("scatter", vec![]);
1516 let process_cmd = Command {
1517 name: "echo".to_string(),
1518 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1519 redirects: vec![],
1520 };
1521 let gather_cmd = make_cmd("gather", vec![]);
1522
1523 let result = runner.run(&[scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1524 assert!(result.ok(), "scatter with structured stdin should succeed: {}", result.err);
1525 assert!(result.text_out().contains("x"));
1526 assert!(result.text_out().contains("y"));
1527 assert!(result.text_out().contains("z"));
1528 }
1529
1530 #[tokio::test]
1531 async fn test_scatter_gather_json_input() {
1532 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1533
1534 let data = Value::Json(serde_json::json!(["one", "two", "three"]));
1536 ctx.set_stdin_with_data(r#"["one", "two", "three"]"#.to_string(), Some(data));
1537
1538 let scatter_cmd = make_cmd("scatter", vec![]);
1539 let process_cmd = Command {
1540 name: "echo".to_string(),
1541 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1542 redirects: vec![],
1543 };
1544 let gather_cmd = make_cmd("gather", vec![]);
1545
1546 let result = runner.run(&[scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1547 assert!(result.ok(), "scatter with JSON data should succeed: {}", result.err);
1548 assert!(result.text_out().contains("one"));
1549 assert!(result.text_out().contains("two"));
1550 assert!(result.text_out().contains("three"));
1551 }
1552
1553 #[tokio::test]
1554 async fn test_scatter_gather_with_post_gather() {
1555 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1556
1557 let split_cmd = Command {
1559 name: "split".to_string(),
1560 args: vec![Arg::Positional(Expr::Literal(Value::String("a b".to_string())))],
1561 redirects: vec![],
1562 };
1563 let scatter_cmd = make_cmd("scatter", vec![]);
1564 let process_cmd = Command {
1565 name: "echo".to_string(),
1566 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1567 redirects: vec![],
1568 };
1569 let gather_cmd = make_cmd("gather", vec![]);
1570 let grep_cmd = Command {
1571 name: "grep".to_string(),
1572 args: vec![Arg::Positional(Expr::Literal(Value::String("a".to_string())))],
1573 redirects: vec![],
1574 };
1575
1576 let result = runner.run(&[split_cmd, scatter_cmd, process_cmd, gather_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1577 assert!(result.ok(), "scatter with post_gather should succeed: {}", result.err);
1578 assert!(result.text_out().contains("a"));
1579 assert!(!result.text_out().contains("b"));
1580 }
1581
1582 #[tokio::test]
1583 async fn test_scatter_custom_var_name() {
1584 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1585
1586 let data = Value::Json(serde_json::json!(["test1", "test2"]));
1588 ctx.set_stdin_with_data("test1\ntest2".to_string(), Some(data));
1589
1590 let scatter_cmd = Command {
1592 name: "scatter".to_string(),
1593 args: vec![Arg::Named {
1594 key: "as".to_string(),
1595 value: Expr::Literal(Value::String("URL".to_string())),
1596 }],
1597 redirects: vec![],
1598 };
1599 let process_cmd = Command {
1600 name: "echo".to_string(),
1601 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("URL")))],
1602 redirects: vec![],
1603 };
1604 let gather_cmd = make_cmd("gather", vec![]);
1605
1606 let result = runner.run(&[scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1607 assert!(result.ok(), "scatter with custom var should succeed: {}", result.err);
1608 assert!(result.text_out().contains("test1"));
1609 assert!(result.text_out().contains("test2"));
1610 }
1611
1612 #[tokio::test]
1615 async fn test_pipeline_routes_through_backend() {
1616 use crate::backend::testing::MockBackend;
1617 use std::sync::atomic::Ordering;
1618
1619 let (backend, call_count) = MockBackend::new();
1621 let backend: std::sync::Arc<dyn crate::backend::KernelBackend> = std::sync::Arc::new(backend);
1622
1623 let mut ctx = crate::tools::ExecContext::with_backend(backend);
1625
1626 let tools = std::sync::Arc::new(ToolRegistry::new());
1628 let runner = PipelineRunner::new(tools.clone());
1629 let dispatcher = BackendDispatcher::new(tools);
1630
1631 let cmd = make_cmd("test-tool", vec!["arg1"]);
1633 let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
1634
1635 assert!(result.ok(), "Mock backend should return success");
1636 assert_eq!(call_count.load(Ordering::SeqCst), 1, "call_tool should be invoked once");
1637 assert!(result.text_out().contains("mock executed"), "Output should be from mock backend");
1638 }
1639
1640 #[tokio::test]
1641 async fn test_multi_command_pipeline_routes_through_backend() {
1642 use crate::backend::testing::MockBackend;
1643 use std::sync::atomic::Ordering;
1644
1645 let (backend, call_count) = MockBackend::new();
1646 let backend: std::sync::Arc<dyn crate::backend::KernelBackend> = std::sync::Arc::new(backend);
1647 let mut ctx = crate::tools::ExecContext::with_backend(backend);
1648
1649 let tools = std::sync::Arc::new(ToolRegistry::new());
1650 let runner = PipelineRunner::new(tools.clone());
1651 let dispatcher = BackendDispatcher::new(tools);
1652
1653 let cmd1 = make_cmd("tool1", vec![]);
1655 let cmd2 = make_cmd("tool2", vec![]);
1656 let cmd3 = make_cmd("tool3", vec![]);
1657
1658 let result = runner.run(&[cmd1, cmd2, cmd3], &mut ctx, &dispatcher).await;
1659
1660 assert!(result.ok());
1661 assert_eq!(call_count.load(Ordering::SeqCst), 3, "call_tool should be invoked for each command");
1662 }
1663
1664 #[tokio::test]
1671 async fn backend_dispatcher_scalar_data_matches_production_unwrap() {
1672 use crate::backend::testing::MockBackend;
1673 use crate::backend::ToolResult;
1674
1675 let (mock, _calls) = MockBackend::new();
1676 let backend = mock.with_tool_result(|_name| Ok(ToolResult::with_data("", serde_json::json!(42))));
1677 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
1678 let mut ctx = ExecContext::with_backend(backend);
1679
1680 let dispatcher = BackendDispatcher::new(Arc::new(ToolRegistry::new()));
1681 let cmd = make_cmd("embedder_tool", vec![]);
1682
1683 let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
1684 assert_eq!(
1685 result.data,
1686 Some(Value::Int(42)),
1687 "a scalar ToolResult.data must unwrap to a native Value, matching \
1688 the production From<ToolResult> path — not stay Value::Json(42)"
1689 );
1690 }
1691
1692 #[tokio::test]
1698 async fn backend_dispatcher_envelope_shaped_data_stays_structured() {
1699 use crate::backend::testing::MockBackend;
1700 use crate::backend::ToolResult;
1701
1702 let envelope = kaish_types::bytes_to_envelope(&[1u8, 2, 3]);
1703 let (mock, _calls) = MockBackend::new();
1704 let backend = mock.with_tool_result(move |_name| Ok(ToolResult::with_data("", envelope.clone())));
1705 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
1706 let mut ctx = ExecContext::with_backend(backend);
1707
1708 let dispatcher = BackendDispatcher::new(Arc::new(ToolRegistry::new()));
1709 let cmd = make_cmd("embedder_tool", vec![]);
1710
1711 let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
1712 assert!(
1713 matches!(result.data, Some(Value::Json(_))),
1714 "envelope-shaped external data must stay structured, not silently \
1715 decode to Value::Bytes: got {:?}",
1716 result.data
1717 );
1718 }
1719
1720 #[tokio::test]
1725 async fn backend_dispatcher_preserves_did_spill_and_original_code() {
1726 use crate::backend::testing::MockBackend;
1727 use crate::backend::ToolResult;
1728
1729 let (mock, _calls) = MockBackend::new();
1730 let backend = mock.with_tool_result(|_name| {
1731 Ok(ToolResult::success("truncated...")
1732 .with_did_spill(true)
1733 .with_original_code(Some(0)))
1734 });
1735 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
1736 let mut ctx = ExecContext::with_backend(backend);
1737
1738 let dispatcher = BackendDispatcher::new(Arc::new(ToolRegistry::new()));
1739 let cmd = make_cmd("embedder_tool", vec![]);
1740
1741 let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
1742 assert!(result.did_spill, "did_spill must survive the backend seam");
1743 assert_eq!(result.original_code, Some(0), "original_code must survive the backend seam");
1744 }
1745
1746 use crate::tools::{ParamSchema, ToolSchema};
1749
1750 fn make_test_schema() -> ToolSchema {
1751 ToolSchema::new("test-tool", "A test tool for schema-aware parsing")
1752 .param(ParamSchema::required("query", "string", "Search query"))
1753 .param(ParamSchema::optional("limit", "int", Value::Int(10), "Max results"))
1754 .param(ParamSchema::optional("verbose", "bool", Value::Bool(false), "Verbose output"))
1755 .param(ParamSchema::optional("output", "string", Value::String("stdout".into()), "Output destination"))
1756 .with_positional_mapping()
1757 }
1758
1759 fn make_minimal_ctx() -> ExecContext {
1760 let mut vfs = VfsRouter::new();
1761 vfs.mount("/", MemoryFs::new());
1762 ExecContext::new(Arc::new(vfs))
1763 }
1764
1765 fn test_dispatcher() -> BackendDispatcher {
1769 BackendDispatcher::new(Arc::new(ToolRegistry::new()))
1770 }
1771
1772 #[tokio::test]
1773 async fn test_schema_aware_string_arg() {
1774 let args = vec![
1776 Arg::LongFlag("query".to_string()),
1777 Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
1778 ];
1779 let schema = make_test_schema();
1780 let ctx = make_minimal_ctx();
1781
1782 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1783
1784 assert!(tool_args.flags.is_empty(), "No flags should be set");
1785 assert!(tool_args.positional.is_empty(), "No positionals - consumed by --query");
1786 assert_eq!(
1787 tool_args.named.get("query"),
1788 Some(&Value::String("test".to_string())),
1789 "--query should consume 'test' as its value"
1790 );
1791 }
1792
1793 #[tokio::test]
1794 async fn test_schema_aware_bool_flag() {
1795 let args = vec![
1797 Arg::LongFlag("verbose".to_string()),
1798 ];
1799 let schema = make_test_schema();
1800 let ctx = make_minimal_ctx();
1801
1802 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1803
1804 assert!(tool_args.flags.contains("verbose"), "--verbose should be a flag");
1805 assert!(tool_args.named.is_empty(), "No named args");
1806 assert!(tool_args.positional.is_empty(), "No positionals");
1807 }
1808
1809 #[tokio::test]
1810 async fn test_schema_aware_mixed() {
1811 let args = vec![
1814 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
1815 Arg::LongFlag("output".to_string()),
1816 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
1817 Arg::LongFlag("verbose".to_string()),
1818 ];
1819 let schema = make_test_schema();
1820 let ctx = make_minimal_ctx();
1821
1822 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1823
1824 assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
1825 assert_eq!(
1826 tool_args.named.get("query"),
1827 Some(&Value::String("file.txt".to_string()))
1828 );
1829 assert_eq!(
1830 tool_args.named.get("output"),
1831 Some(&Value::String("out.txt".to_string()))
1832 );
1833 assert!(tool_args.flags.contains("verbose"));
1834 }
1835
1836 #[tokio::test]
1837 async fn test_schema_aware_multiple_string_args() {
1838 let args = vec![
1840 Arg::LongFlag("query".to_string()),
1841 Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
1842 Arg::LongFlag("output".to_string()),
1843 Arg::Positional(Expr::Literal(Value::String("result.json".to_string()))),
1844 Arg::LongFlag("verbose".to_string()),
1845 Arg::LongFlag("limit".to_string()),
1846 Arg::Positional(Expr::Literal(Value::Int(5))),
1847 ];
1848 let schema = make_test_schema();
1849 let ctx = make_minimal_ctx();
1850
1851 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1852
1853 assert!(tool_args.positional.is_empty(), "All positionals consumed");
1854 assert_eq!(
1855 tool_args.named.get("query"),
1856 Some(&Value::String("test".to_string()))
1857 );
1858 assert_eq!(
1859 tool_args.named.get("output"),
1860 Some(&Value::String("result.json".to_string()))
1861 );
1862 assert_eq!(
1863 tool_args.named.get("limit"),
1864 Some(&Value::Int(5))
1865 );
1866 assert!(tool_args.flags.contains("verbose"));
1867 }
1868
1869 #[tokio::test]
1870 async fn test_schema_aware_double_dash() {
1871 let args = vec![
1874 Arg::LongFlag("output".to_string()),
1875 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
1876 Arg::DoubleDash,
1877 Arg::Positional(Expr::Literal(Value::String("--this-is-data".to_string()))),
1878 ];
1879 let schema = make_test_schema();
1880 let ctx = make_minimal_ctx();
1881
1882 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1883
1884 assert_eq!(
1885 tool_args.named.get("output"),
1886 Some(&Value::String("out.txt".to_string()))
1887 );
1888 assert_eq!(
1890 tool_args.positional,
1891 vec![Value::String("--this-is-data".to_string())]
1892 );
1893 }
1894
1895 #[tokio::test]
1901 async fn word_assign_binary_value_is_loud_not_placeholder() {
1902 let args = vec![Arg::WordAssign {
1903 key: "if".to_string(),
1904 value: Expr::Literal(Value::Bytes(vec![0xff, 0x00, 0xfe])),
1905 }];
1906 let ctx = make_minimal_ctx();
1907
1908 let err = build_tool_args(&args, &ctx, None).await.expect_err("binary WordAssign must error");
1911 assert!(
1912 err.contains("cannot be used as"),
1913 "error should name the binary problem, got {err:?}"
1914 );
1915 }
1916
1917 #[tokio::test]
1922 async fn word_assign_before_double_dash_binds_named_for_export() {
1923 let args = vec![Arg::WordAssign {
1924 key: "A".to_string(),
1925 value: Expr::Literal(Value::String("1".to_string())),
1926 }];
1927 let schema = ToolSchema::new("export", "export");
1928 let ctx = make_minimal_ctx();
1929
1930 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1931 assert_eq!(tool_args.named.get("A"), Some(&Value::String("1".to_string())));
1932 assert!(tool_args.positional.is_empty());
1933 }
1934
1935 #[tokio::test]
1944 async fn word_assign_after_double_dash_is_positional_even_for_export() {
1945 let args = vec![
1946 Arg::DoubleDash,
1947 Arg::WordAssign {
1948 key: "A".to_string(),
1949 value: Expr::Literal(Value::String("1".to_string())),
1950 },
1951 ];
1952 let schema = ToolSchema::new("export", "export");
1953 let ctx = make_minimal_ctx();
1954
1955 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1956 assert!(
1957 tool_args.named.is_empty(),
1958 "past `--`, A=1 must not become a named assignment: {:?}",
1959 tool_args.named
1960 );
1961 assert_eq!(tool_args.positional, vec![Value::String("A=1".to_string())]);
1962 }
1963
1964 #[tokio::test]
1972 async fn named_true_on_undeclared_flag_flagifies() {
1973 let args = vec![Arg::Named {
1974 key: "json".to_string(),
1975 value: Expr::Literal(Value::Bool(true)),
1976 }];
1977 let schema = make_test_schema(); let ctx = make_minimal_ctx();
1979
1980 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1981 assert!(tool_args.flags.contains("json"), "flags: {:?}", tool_args.flags);
1982 assert!(!tool_args.named.contains_key("json"), "named: {:?}", tool_args.named);
1983 }
1984
1985 #[tokio::test]
1988 async fn named_false_on_undeclared_flag_drops() {
1989 let args = vec![Arg::Named {
1990 key: "json".to_string(),
1991 value: Expr::Literal(Value::Bool(false)),
1992 }];
1993 let schema = make_test_schema();
1994 let ctx = make_minimal_ctx();
1995
1996 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1997 assert!(!tool_args.flags.contains("json"));
1998 assert!(!tool_args.named.contains_key("json"));
1999 }
2000
2001 #[tokio::test]
2005 async fn named_true_on_declared_bool_param_flagifies() {
2006 let args = vec![Arg::Named {
2007 key: "verbose".to_string(),
2008 value: Expr::Literal(Value::Bool(true)),
2009 }];
2010 let schema = make_test_schema();
2011 let ctx = make_minimal_ctx();
2012
2013 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2014 assert!(tool_args.flags.contains("verbose"));
2015 assert!(!tool_args.named.contains_key("verbose"));
2016 }
2017
2018 #[tokio::test]
2024 async fn named_true_on_declared_value_flag_keeps_value() {
2025 let args = vec![Arg::Named {
2026 key: "output".to_string(),
2027 value: Expr::Literal(Value::Bool(true)),
2028 }];
2029 let schema = make_test_schema();
2030 let ctx = make_minimal_ctx();
2031
2032 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2033 assert_eq!(tool_args.named.get("output"), Some(&Value::Bool(true)));
2034 assert!(!tool_args.flags.contains("output"));
2035 }
2036
2037 #[tokio::test]
2042 async fn named_true_with_no_schema_flagifies() {
2043 let args = vec![Arg::Named {
2044 key: "verbose".to_string(),
2045 value: Expr::Literal(Value::Bool(true)),
2046 }];
2047 let ctx = make_minimal_ctx();
2048
2049 let tool_args = build_tool_args(&args, &ctx, None).await.expect("build_tool_args");
2050 assert!(tool_args.flags.contains("verbose"));
2051 assert!(!tool_args.named.contains_key("verbose"));
2052 }
2053
2054 #[tokio::test]
2055 async fn test_no_schema_fallback() {
2056 let args = vec![
2058 Arg::LongFlag("query".to_string()),
2059 Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
2060 ];
2061 let ctx = make_minimal_ctx();
2062
2063 let tool_args = build_tool_args(&args, &ctx, None).await.expect("build_tool_args");
2064
2065 assert!(tool_args.flags.contains("query"), "--query should be a flag");
2067 assert_eq!(
2068 tool_args.positional,
2069 vec![Value::String("test".to_string())],
2070 "'test' should be a positional"
2071 );
2072 }
2073
2074 #[tokio::test]
2084 async fn test_unknown_flag_ambiguous_space_value_now_errors_loud() {
2085 let args = vec![
2086 Arg::LongFlag("unknown".to_string()),
2087 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2088 ];
2089 let schema = make_test_schema();
2090 let ctx = make_minimal_ctx();
2091
2092 let err = build_tool_args(&args, &ctx, Some(&schema))
2093 .await
2094 .expect_err("an undeclared flag immediately before a positional must be ambiguous, not silently bool");
2095 assert!(
2096 err.contains("--unknown is not a declared flag"),
2097 "got: {err}"
2098 );
2099 }
2100
2101 #[tokio::test]
2105 async fn test_unknown_bool_flag_with_no_following_positional_is_fine() {
2106 let args = vec![
2107 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2108 Arg::LongFlag("unknown".to_string()),
2109 ];
2110 let schema = make_test_schema();
2111 let ctx = make_minimal_ctx();
2112
2113 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2114
2115 assert!(tool_args.flags.contains("unknown"));
2116 assert!(tool_args.positional.is_empty(), "value consumed as query param");
2117 assert_eq!(
2118 tool_args.named.get("query"),
2119 Some(&Value::String("value".to_string()))
2120 );
2121 }
2122
2123 #[tokio::test]
2133 async fn test_unknown_short_flag_ambiguous_space_value_now_errors_loud() {
2134 let args = vec![
2135 Arg::ShortFlag("t".to_string()),
2136 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2137 ];
2138 let schema = make_test_schema();
2139 let ctx = make_minimal_ctx();
2140
2141 let err = build_tool_args(&args, &ctx, Some(&schema))
2142 .await
2143 .expect_err("an undeclared short flag immediately before a positional must be ambiguous, not silently bool");
2144 assert!(
2145 err.contains("-t is not a declared flag"),
2146 "got: {err}"
2147 );
2148 }
2149
2150 #[tokio::test]
2154 async fn test_unknown_short_bool_flag_with_no_following_positional_is_fine() {
2155 let args = vec![
2156 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2157 Arg::ShortFlag("t".to_string()),
2158 ];
2159 let schema = make_test_schema();
2160 let ctx = make_minimal_ctx();
2161
2162 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2163
2164 assert!(tool_args.flags.contains("t"));
2165 assert!(tool_args.positional.is_empty(), "value consumed as query param");
2166 assert_eq!(
2167 tool_args.named.get("query"),
2168 Some(&Value::String("value".to_string()))
2169 );
2170 }
2171
2172 #[tokio::test]
2178 async fn test_unknown_short_flag_not_ambiguous_without_map_positionals() {
2179 let args = vec![
2180 Arg::ShortFlag("t".to_string()),
2181 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2182 ];
2183 let schema = ToolSchema::new("test-tool", "A test tool")
2186 .param(ParamSchema::required("query", "string", "Search query"));
2187 let ctx = make_minimal_ctx();
2188
2189 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2190 assert!(tool_args.flags.contains("t"));
2191 assert_eq!(tool_args.positional, vec![Value::String("value".to_string())]);
2192 }
2193
2194 #[tokio::test]
2195 async fn test_named_args_unchanged() {
2196 let args = vec![
2198 Arg::Named {
2199 key: "query".to_string(),
2200 value: Expr::Literal(Value::String("test".to_string())),
2201 },
2202 Arg::LongFlag("verbose".to_string()),
2203 ];
2204 let schema = make_test_schema();
2205 let ctx = make_minimal_ctx();
2206
2207 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2208
2209 assert_eq!(
2210 tool_args.named.get("query"),
2211 Some(&Value::String("test".to_string()))
2212 );
2213 assert!(tool_args.flags.contains("verbose"));
2214 }
2215
2216 #[tokio::test]
2217 async fn test_short_flags_unchanged() {
2218 let args = vec![
2220 Arg::ShortFlag("la".to_string()),
2221 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
2222 ];
2223 let schema = make_test_schema();
2224 let ctx = make_minimal_ctx();
2225
2226 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2227
2228 assert!(tool_args.flags.contains("l"));
2229 assert!(tool_args.flags.contains("a"));
2230 assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
2231 assert_eq!(
2232 tool_args.named.get("query"),
2233 Some(&Value::String("file.txt".to_string()))
2234 );
2235 }
2236
2237 #[tokio::test]
2238 async fn test_flag_at_end_no_value() {
2239 let args = vec![
2242 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
2243 Arg::LongFlag("output".to_string()),
2244 ];
2245 let schema = make_test_schema();
2246 let ctx = make_minimal_ctx();
2247
2248 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2249
2250 assert!(tool_args.flags.contains("output"));
2252 assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
2253 assert_eq!(
2254 tool_args.named.get("query"),
2255 Some(&Value::String("file.txt".to_string()))
2256 );
2257 }
2258
2259 #[tokio::test]
2260 async fn test_positional_skips_bool_params() {
2261 let schema = ToolSchema::new("test", "")
2265 .param(ParamSchema::required("query", "string", ""))
2266 .param(ParamSchema::optional(
2267 "verbose",
2268 "bool",
2269 Value::Bool(false),
2270 "",
2271 ))
2272 .param(ParamSchema::optional(
2273 "output",
2274 "string",
2275 Value::Null,
2276 "",
2277 ))
2278 .with_positional_mapping();
2279 let args = vec![
2280 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2281 Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
2282 ];
2283 let ctx = make_minimal_ctx();
2284
2285 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2286
2287 assert_eq!(
2288 tool_args.named.get("query"),
2289 Some(&Value::String("val1".to_string()))
2290 );
2291 assert_eq!(
2292 tool_args.named.get("output"),
2293 Some(&Value::String("val2".to_string()))
2294 );
2295 assert!(!tool_args.flags.contains("verbose"));
2296 assert!(tool_args.positional.is_empty());
2297 }
2298
2299 #[tokio::test]
2300 async fn test_positionals_fill_available_slots() {
2301 let args = vec![
2304 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2305 Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
2306 Arg::Positional(Expr::Literal(Value::String("val3".to_string()))),
2307 ];
2308 let schema = make_test_schema(); let ctx = make_minimal_ctx();
2310
2311 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2312
2313 assert_eq!(
2316 tool_args.named.get("query"),
2317 Some(&Value::String("val1".to_string()))
2318 );
2319 assert_eq!(
2320 tool_args.named.get("limit"),
2321 Some(&Value::String("val2".to_string()))
2322 );
2323 assert_eq!(
2324 tool_args.named.get("output"),
2325 Some(&Value::String("val3".to_string()))
2326 );
2327 assert!(tool_args.positional.is_empty());
2328 }
2329
2330 #[tokio::test]
2331 async fn test_truly_excess_positionals() {
2332 let schema = ToolSchema::new("test", "")
2334 .param(ParamSchema::required("name", "string", ""))
2335 .with_positional_mapping();
2336 let args = vec![
2337 Arg::Positional(Expr::Literal(Value::String("first".to_string()))),
2338 Arg::Positional(Expr::Literal(Value::String("second".to_string()))),
2339 Arg::Positional(Expr::Literal(Value::String("third".to_string()))),
2340 ];
2341 let ctx = make_minimal_ctx();
2342
2343 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2344
2345 assert_eq!(
2346 tool_args.named.get("name"),
2347 Some(&Value::String("first".to_string()))
2348 );
2349 assert_eq!(
2350 tool_args.positional,
2351 vec![
2352 Value::String("second".to_string()),
2353 Value::String("third".to_string()),
2354 ]
2355 );
2356 }
2357
2358 #[tokio::test]
2359 async fn test_double_dash_positional_not_mapped() {
2360 let args = vec![
2362 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2363 Arg::DoubleDash,
2364 Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
2365 ];
2366 let schema = make_test_schema();
2367 let ctx = make_minimal_ctx();
2368
2369 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2370
2371 assert_eq!(
2372 tool_args.named.get("query"),
2373 Some(&Value::String("val1".to_string()))
2374 );
2375 assert_eq!(
2377 tool_args.positional,
2378 vec![Value::String("val2".to_string())]
2379 );
2380 }
2381
2382 #[tokio::test]
2383 async fn test_all_params_filled_by_flags() {
2384 let args = vec![
2386 Arg::LongFlag("query".to_string()),
2387 Arg::Positional(Expr::Literal(Value::String("search".to_string()))),
2388 Arg::LongFlag("output".to_string()),
2389 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
2390 Arg::LongFlag("verbose".to_string()),
2391 ];
2392 let schema = make_test_schema();
2393 let ctx = make_minimal_ctx();
2394
2395 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2396
2397 assert_eq!(
2398 tool_args.named.get("query"),
2399 Some(&Value::String("search".to_string()))
2400 );
2401 assert_eq!(
2402 tool_args.named.get("output"),
2403 Some(&Value::String("out.txt".to_string()))
2404 );
2405 assert!(tool_args.flags.contains("verbose"));
2406 assert!(tool_args.positional.is_empty());
2407 }
2408
2409 #[tokio::test]
2410 async fn test_mixed_flags_and_positional_fill() {
2411 let args = vec![
2413 Arg::LongFlag("output".to_string()),
2414 Arg::Positional(Expr::Literal(Value::String("foo".to_string()))),
2415 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2416 ];
2417 let schema = make_test_schema();
2418 let ctx = make_minimal_ctx();
2419
2420 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2421
2422 assert_eq!(
2423 tool_args.named.get("output"),
2424 Some(&Value::String("foo".to_string()))
2425 );
2426 assert_eq!(
2427 tool_args.named.get("query"),
2428 Some(&Value::String("val1".to_string()))
2429 );
2430 assert!(tool_args.positional.is_empty());
2431 }
2432
2433 #[tokio::test]
2434 async fn test_alias_flag_prevents_mapping_overwrite() {
2435 let schema = ToolSchema::new("test", "")
2437 .param(ParamSchema::required("query", "string", "").with_aliases(["-q"]))
2438 .param(ParamSchema::required("output", "string", ""))
2439 .with_positional_mapping();
2440 let args = vec![
2441 Arg::ShortFlag("q".to_string()),
2442 Arg::Positional(Expr::Literal(Value::String("search".to_string()))),
2443 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
2444 ];
2445 let ctx = make_minimal_ctx();
2446
2447 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2448
2449 assert_eq!(
2450 tool_args.named.get("query"),
2451 Some(&Value::String("search".to_string()))
2452 );
2453 assert_eq!(
2454 tool_args.named.get("output"),
2455 Some(&Value::String("out.txt".to_string()))
2456 );
2457 assert!(tool_args.positional.is_empty());
2458 }
2459
2460 #[tokio::test]
2461 async fn test_builtin_schema_no_positional_mapping() {
2462 let schema = ToolSchema::new("echo", "")
2464 .param(ParamSchema::optional("args", "any", Value::Null, ""))
2465 .param(ParamSchema::optional("no_newline", "bool", Value::Bool(false), ""));
2466 let args = vec![
2468 Arg::Positional(Expr::Literal(Value::String("hello".to_string()))),
2469 Arg::Positional(Expr::Literal(Value::String("world".to_string()))),
2470 ];
2471 let ctx = make_minimal_ctx();
2472
2473 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2474
2475 assert_eq!(
2477 tool_args.positional,
2478 vec![
2479 Value::String("hello".to_string()),
2480 Value::String("world".to_string()),
2481 ]
2482 );
2483 assert!(!tool_args.named.contains_key("args"));
2484 }
2485
2486 #[tokio::test]
2487 async fn test_short_flag_with_alias_consumes_value() {
2488 let schema = ToolSchema::new("head", "Output first part of files")
2491 .param(ParamSchema::optional("lines", "int", Value::Int(10), "Number of lines")
2492 .with_aliases(["-n"]));
2493 let args = vec![
2494 Arg::ShortFlag("n".to_string()),
2495 Arg::Positional(Expr::Literal(Value::Int(5))),
2496 Arg::Positional(Expr::Literal(Value::String("/tmp/file.txt".to_string()))),
2497 ];
2498 let ctx = make_minimal_ctx();
2499
2500 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2501
2502 assert!(tool_args.flags.is_empty(), "no boolean flags: {:?}", tool_args.flags);
2503 assert_eq!(tool_args.named.get("lines"), Some(&Value::Int(5)), "should resolve alias to canonical name");
2504 assert_eq!(tool_args.positional, vec![Value::String("/tmp/file.txt".to_string())]);
2505 }
2506
2507 #[tokio::test]
2519 async fn test_glued_short_flag_value_now_binds() {
2520 let schema = ToolSchema::new("cut", "")
2524 .param(ParamSchema::optional("fields", "string", Value::Null, "").with_aliases(["-f"]));
2525 let args = vec![Arg::ShortFlag("f1".to_string())];
2526 let ctx = make_minimal_ctx();
2527
2528 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2529
2530 assert!(tool_args.flags.is_empty(), "no bogus bool flags: {:?}", tool_args.flags);
2531 assert_eq!(tool_args.named.get("fields"), Some(&Value::String("1".to_string())));
2532 }
2533
2534 #[tokio::test]
2535 async fn test_repeatable_flag_now_accumulates() {
2536 let schema = ToolSchema::new("sed", "")
2541 .param(ParamSchema::optional("expression", "string", Value::Null, "")
2542 .with_aliases(["-e"])
2543 .with_repeatable(true));
2544 let args = vec![
2545 Arg::ShortFlag("e".to_string()),
2546 Arg::Positional(Expr::Literal(Value::String("A".to_string()))),
2547 Arg::ShortFlag("e".to_string()),
2548 Arg::Positional(Expr::Literal(Value::String("B".to_string()))),
2549 ];
2550 let ctx = make_minimal_ctx();
2551
2552 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2553
2554 assert_eq!(
2555 tool_args.named.get("expression"),
2556 Some(&Value::Json(serde_json::json!(["A", "B"]))),
2557 "both occurrences must survive, not just the last: {:?}",
2558 tool_args.named
2559 );
2560 }
2561
2562 #[tokio::test]
2563 async fn test_multi_consume_flag_now_accumulates() {
2564 let schema = ToolSchema::new("jq", "")
2569 .param(ParamSchema::optional("arg", "any", Value::Null, "").consumes(2));
2570 let args = vec![
2571 Arg::LongFlag("arg".to_string()),
2572 Arg::Positional(Expr::Literal(Value::String("name".to_string()))),
2573 Arg::Positional(Expr::Literal(Value::String("val".to_string()))),
2574 ];
2575 let ctx = make_minimal_ctx();
2576
2577 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2578
2579 assert_eq!(
2580 tool_args.named.get("arg"),
2581 Some(&Value::Json(serde_json::json!([["name", "val"]]))),
2582 "got: {:?}",
2583 tool_args.named
2584 );
2585 assert!(tool_args.positional.is_empty());
2586 }
2587
2588 #[tokio::test]
2591 async fn test_merge_stderr_redirect() {
2592 let result = ExecResult::from_output(0, "stdout content", "stderr content");
2594
2595 let redirects = vec![Redirect {
2596 kind: RedirectKind::MergeStderr,
2597 target: Expr::Literal(Value::Null),
2598 }];
2599
2600 let ctx = make_minimal_ctx();
2601 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2602
2603 assert_eq!(&*result.text_out(), "stdout contentstderr content");
2604 assert!(result.err.is_empty());
2605 }
2606
2607 #[tokio::test]
2608 async fn test_merge_stderr_with_empty_stderr() {
2609 let result = ExecResult::from_output(0, "stdout only", "");
2611
2612 let redirects = vec![Redirect {
2613 kind: RedirectKind::MergeStderr,
2614 target: Expr::Literal(Value::Null),
2615 }];
2616
2617 let ctx = make_minimal_ctx();
2618 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2619
2620 assert_eq!(&*result.text_out(), "stdout only");
2621 assert!(result.err.is_empty());
2622 }
2623
2624 #[tokio::test]
2625 async fn test_merge_stderr_order_matters() {
2626 let result = ExecResult::from_output(0, "stdout\n", "stderr\n");
2631
2632 let redirects = vec![Redirect {
2634 kind: RedirectKind::MergeStderr,
2635 target: Expr::Literal(Value::Null),
2636 }];
2637
2638 let ctx = make_minimal_ctx();
2639 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2640
2641 assert_eq!(&*result.text_out(), "stdout\nstderr\n");
2642 assert!(result.err.is_empty());
2643 }
2644
2645 #[tokio::test]
2646 async fn test_redirect_with_command_execution() {
2647 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
2648
2649 let cmd = Command {
2651 name: "echo".to_string(),
2652 args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
2653 redirects: vec![Redirect {
2654 kind: RedirectKind::MergeStderr,
2655 target: Expr::Literal(Value::Null),
2656 }],
2657 };
2658
2659 let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
2660 assert!(result.ok());
2661 assert!(result.text_out().contains("hello"));
2663 }
2664
2665 #[tokio::test]
2666 async fn test_merge_stderr_in_pipeline() {
2667 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
2668
2669 let echo_cmd = Command {
2672 name: "echo".to_string(),
2673 args: vec![Arg::Positional(Expr::Literal(Value::String("output".to_string())))],
2674 redirects: vec![Redirect {
2675 kind: RedirectKind::MergeStderr,
2676 target: Expr::Literal(Value::Null),
2677 }],
2678 };
2679 let grep_cmd = Command {
2680 name: "grep".to_string(),
2681 args: vec![Arg::Positional(Expr::Literal(Value::String("output".to_string())))],
2682 redirects: vec![],
2683 };
2684
2685 let result = runner.run(&[echo_cmd, grep_cmd], &mut ctx, &dispatcher).await;
2686 assert!(result.ok(), "result failed: code={}, err={}", result.code, result.err);
2687 assert!(result.text_out().contains("output"));
2688 }
2689
2690 fn big_table_output(rows: usize) -> crate::interpreter::OutputData {
2702 use crate::interpreter::OutputNode;
2703 let headers = vec!["id".to_string(), "name".to_string()];
2704 let nodes: Vec<OutputNode> = (0..rows)
2705 .map(|i| OutputNode::new(i.to_string()).with_cells(vec![format!("row-{i}")]))
2706 .collect();
2707 crate::interpreter::OutputData::table(headers, nodes)
2708 }
2709
2710 #[tokio::test]
2711 async fn test_both_redirect_streams_structured_output_to_file() {
2712 let output = big_table_output(50);
2717 let expected_stdout = output.to_canonical_string();
2718 let mut result = ExecResult::with_output(output);
2719 result.err = "warning: heads up\n".to_string();
2720
2721 let redirects = vec![Redirect {
2722 kind: RedirectKind::Both,
2723 target: Expr::Literal(Value::String("/out.txt".to_string())),
2724 }];
2725 let ctx = make_minimal_ctx();
2726 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2727
2728 assert!(result.ok());
2731 assert_eq!(&*result.text_out(), "");
2732 assert!(result.err.is_empty());
2733 assert!(!result.has_output());
2734
2735 let written = ctx.backend.read(Path::new("/out.txt"), None).await.expect("file written");
2736 let written = String::from_utf8(written).expect("valid utf8");
2737 assert_eq!(written, format!("{expected_stdout}warning: heads up\n"));
2741 }
2742
2743 #[tokio::test]
2744 async fn test_both_redirect_streams_large_structured_output_intact() {
2745 let rows = 5_000;
2750 let output = big_table_output(rows);
2751 let expected_stdout = output.to_canonical_string();
2752 let result = ExecResult::with_output(output);
2753
2754 let redirects = vec![Redirect {
2755 kind: RedirectKind::Both,
2756 target: Expr::Literal(Value::String("/big.txt".to_string())),
2757 }];
2758 let ctx = make_minimal_ctx();
2759 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2760 assert!(result.ok());
2761
2762 let written = ctx.backend.read(Path::new("/big.txt"), None).await.expect("file written");
2763 let written = String::from_utf8(written).expect("valid utf8");
2764 assert_eq!(written, expected_stdout);
2765 assert!(written.contains("row-0"));
2766 assert!(written.contains(&format!("row-{}", rows - 1)));
2767 }
2768
2769 #[tokio::test]
2770 async fn test_both_redirect_still_writes_binary_stdout_raw() {
2771 let result = ExecResult::success_text_or_bytes(vec![0xff, 0x00, 0xfe, b'x']);
2775 let redirects = vec![Redirect {
2776 kind: RedirectKind::Both,
2777 target: Expr::Literal(Value::String("/bin.out".to_string())),
2778 }];
2779 let ctx = make_minimal_ctx();
2780 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2781 assert!(result.ok());
2782
2783 let written = ctx.backend.read(Path::new("/bin.out"), None).await.expect("file written");
2784 assert_eq!(written, vec![0xff, 0x00, 0xfe, b'x']);
2785 }
2786}