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 let mut stage0_took_session_stdin = false;
544 let mut stage0_took_session_pipe_stdin = false;
550
551 for (i, cmd) in commands.iter().enumerate() {
552 let mut stage_ctx = ctx.child_for_pipeline();
553 let cmd = cmd.clone();
554
555 let task_dispatcher: Arc<dyn CommandDispatcher> = dispatcher.fork_attached().await;
560
561 let stdin_setup = setup_stdin_redirects(&cmd, &mut stage_ctx, dispatcher).await;
565
566 if i == 0 {
568 let redirect_set_stdin = stage_ctx.stdin.is_some();
575 stage0_took_session_stdin = !redirect_set_stdin;
576 if !redirect_set_stdin {
579 stage_ctx.stdin = ctx.stdin.take();
580 }
581 if stage_ctx.stdin_data.is_none() {
582 stage_ctx.stdin_data = ctx.stdin_data.take();
583 }
584 if !redirect_set_stdin && stage_ctx.pipe_stdin.is_none() {
593 stage_ctx.pipe_stdin = ctx.pipe_stdin.take();
594 stage0_took_session_pipe_stdin = true;
595 }
596 } else {
597 stage_ctx.pipe_stdin = pipe_readers[i - 1].take();
599 }
601
602 if i < last_idx {
604 stage_ctx.pipe_stdout = pipe_writers[i].take();
605 }
606
607 stage_ctx.pipeline_position = match i {
609 0 => PipelinePosition::First,
610 n if n == last_idx => PipelinePosition::Last,
611 _ => PipelinePosition::Middle,
612 };
613
614 let data_sender = if i < last_idx { data_senders[i].take() } else { None };
615 let data_receiver = if i > 0 { data_receivers[i - 1].take() } else { None };
616
617 let handle: tokio::task::JoinHandle<(ExecResult, ExecContext)> =
620 tokio::spawn(crate::telemetry::bind_current_context(async move {
621 if let Err(e) = stdin_setup {
623 return (ExecResult::failure(1, e), stage_ctx);
624 }
625
626 stage_ctx.stdin_data_rx = data_receiver;
634
635 let mut result = match task_dispatcher.dispatch(&cmd, &mut stage_ctx).await {
637 Ok(result) => result,
638 Err(e) => ExecResult::failure(1, e.to_string()),
639 };
640
641 result = apply_redirects(result, &cmd.redirects, &stage_ctx, &*task_dispatcher).await;
646
647 if !result.err.is_empty() {
653 if let Some(ref stderr) = stage_ctx.stderr {
654 stderr.write_str(&result.err);
655 result.err.clear();
656 }
657 }
658
659 if let Some(tx) = data_sender {
665 let _ = tx.send(result.data.clone());
666 }
667
668 if let Some(mut pipe_out) = stage_ctx.pipe_stdout.take() {
671 let bytes: Vec<u8> = if let Some(b) = result.out_bytes() {
679 b.to_vec()
680 } else if let Some(output) = result.take_output_for_stream() {
681 let mut buf = Vec::new();
682 if output.write_canonical(&mut buf, None).is_err() {
688 buf = output.to_canonical_string().into_bytes();
689 }
690 buf
691 } else {
692 result.text_out().into_owned().into_bytes()
693 };
694 if !bytes.is_empty() {
695 let _ = pipe_out.write_all(&bytes).await;
697 let _ = pipe_out.shutdown().await;
698 }
699 }
701
702 (result, stage_ctx)
703 }));
704
705 handles.push(handle);
706 }
707
708 let mut last_result = ExecResult::success("");
713 let mut panics: Vec<String> = Vec::new();
714
715 for (i, handle) in handles.into_iter().enumerate() {
716 match handle.await {
717 Ok((result, mut stage_ctx)) => {
718 if i == 0 && stage0_took_session_stdin {
729 ctx.stdin = stage_ctx.stdin.take();
730 }
731 if i == 0 && stage0_took_session_pipe_stdin {
732 ctx.pipe_stdin = stage_ctx.pipe_stdin.take();
733 }
734 if i == last_idx {
735 last_result = result;
736 ctx.scope = stage_ctx.scope;
738 ctx.cwd = stage_ctx.cwd;
739 ctx.prev_cwd = stage_ctx.prev_cwd;
740 ctx.aliases = stage_ctx.aliases;
741 }
742 }
743 Err(e) => {
744 panics.push(format!("stage {}: {}", i, e));
745 }
746 }
747 }
748
749
750 if !panics.is_empty() {
753 last_result = ExecResult::failure(
754 1,
755 format!("pipeline stage(s) panicked: {}", panics.join("; ")),
756 );
757 }
758
759 last_result
760 }
761}
762
763pub fn select_leaf<'a>(schema: &'a ToolSchema, args: &[Arg]) -> anyhow::Result<&'a ToolSchema> {
803 let root_lookup = schema_param_lookup(schema);
806 let is_root_value_flag = |name: &str| -> bool {
807 root_lookup.get(name).is_some_and(|(_, typ, ..)| !is_bool_type(typ))
808 };
809
810 let mut node = schema;
811 let mut skip_next_positional = false;
812 for arg in args {
813 match arg {
814 Arg::DoubleDash => break,
816 Arg::LongFlag(name) if is_root_value_flag(name) => skip_next_positional = true,
819 Arg::ShortFlag(name) if is_root_value_flag(name) => skip_next_positional = true,
820 Arg::Positional(expr) => {
821 if skip_next_positional {
822 skip_next_positional = false;
823 continue; }
825 if node.subcommands.is_empty() {
826 break; }
828 match classify_subcommand_positional(expr) {
829 SubcommandWord::Word(word) => {
830 match node.subcommands.iter().find(|c| c.matches_command(word)) {
831 Some(child) => node = child, None => break, }
834 }
835 SubcommandWord::OtherLiteral => break,
839 SubcommandWord::Computed(kind) => anyhow::bail!(
840 "{}: a subcommand name is required here, but got {kind}. \
841 Subcommands must be literal words — spell it out \
842 (e.g. `{} <subcommand> …`) or use the `--flag=value` form.",
843 node.name,
844 schema.name
845 ),
846 }
847 }
848 _ => {}
850 }
851 }
852 Ok(node)
853}
854
855enum SubcommandWord<'a> {
857 Word(&'a str),
859 OtherLiteral,
861 Computed(&'static str),
863}
864
865fn classify_subcommand_positional(expr: &Expr) -> SubcommandWord<'_> {
866 match expr {
867 Expr::Literal(Value::String(s)) => SubcommandWord::Word(s),
868 Expr::Literal(_) => SubcommandWord::OtherLiteral,
869 Expr::CommandSubst(_) | Expr::Command(_) => SubcommandWord::Computed("a command substitution `$(…)`"),
870 Expr::VarRef(_)
871 | Expr::VarWithDefault { .. }
872 | Expr::VarLength(_)
873 | Expr::Positional(_)
874 | Expr::AllArgs
875 | Expr::ArgCount
876 | Expr::CurrentPid
877 | Expr::LastExitCode => SubcommandWord::Computed("a variable reference"),
878 Expr::Interpolated(_) | Expr::HereDocBody { .. } => SubcommandWord::Computed("an interpolated string"),
879 Expr::GlobPattern(_) => SubcommandWord::Computed("a glob pattern"),
880 Expr::Arithmetic(_) => SubcommandWord::Computed("an arithmetic expansion"),
881 _ => SubcommandWord::Computed("a value computed at runtime"),
882 }
883}
884
885pub fn schema_param_lookup(schema: &ToolSchema) -> HashMap<String, (&str, &str, usize, bool)> {
886 let mut map = HashMap::new();
887 for p in schema.params.iter().filter(|p| !p.positional) {
888 map.insert(p.name.clone(), (p.name.as_str(), p.param_type.as_str(), p.consumes, p.repeatable));
889 for alias in &p.aliases {
890 let stripped = alias.trim_start_matches('-');
891 map.insert(stripped.to_string(), (p.name.as_str(), p.param_type.as_str(), p.consumes, p.repeatable));
892 }
893 }
894 map
895}
896
897pub fn is_bool_type(param_type: &str) -> bool {
899 matches!(param_type.to_lowercase().as_str(), "bool" | "boolean")
900}
901
902struct SyncEvalSource<'a> {
910 ctx: &'a ExecContext,
911}
912
913#[async_trait::async_trait]
914impl crate::kernel::ArgValueSource for SyncEvalSource<'_> {
915 async fn eval(&self, expr: &Expr) -> anyhow::Result<Option<Value>> {
916 eval_simple_expr(expr, self.ctx).map_err(|e| anyhow::anyhow!(e))
917 }
918
919 async fn expand_glob(&self, _pattern: &str) -> anyhow::Result<Option<Vec<String>>> {
920 Ok(None)
926 }
927
928 async fn home(&self) -> Option<String> {
929 None
932 }
933}
934
935pub async fn build_tool_args(
949 args: &[Arg],
950 ctx: &ExecContext,
951 schema: Option<&ToolSchema>,
952) -> Result<ToolArgs, String> {
953 crate::kernel::bind_tool_args(args, schema, &SyncEvalSource { ctx })
954 .await
955 .map_err(|e| e.to_string())
956}
957
958pub(crate) fn eval_simple_expr(expr: &Expr, ctx: &ExecContext) -> Result<Option<Value>, String> {
974 match expr {
975 Expr::Literal(value) => Ok(Some(eval_literal(value, ctx))),
976 Expr::VarRef(path) => match ctx.scope.resolve_path(path) {
977 Ok(v) => Ok(Some(v)),
978 Err(PathError::UndefinedRoot(_)) if path.segments.len() <= 1 => Ok(None),
985 Err(PathError::UndefinedRoot(_)) => Err(format!(
986 "{}: undefined variable",
987 crate::interpreter::format_path(path)
988 )),
989 Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => Err(msg),
992 },
993 Expr::Interpolated(parts) => Ok(Some(Value::String(eval_string_parts_sync(parts, ctx)?))),
994 Expr::VarLength(path) => {
999 crate::interpreter::resolve_length(&ctx.scope, path).map(|n| Some(Value::Int(n)))
1000 }
1001 Expr::VarWithDefault { path, default } => {
1002 match crate::interpreter::resolve_default(&ctx.scope, path)? {
1003 Some(value) => Ok(Some(value)),
1004 None => Ok(Some(Value::String(eval_string_parts_sync(default, ctx)?))),
1005 }
1006 }
1007 Expr::GlobPattern(s) => Ok(Some(Value::String(s.clone()))),
1008 Expr::Arithmetic(expr_str) => arithmetic::eval_arithmetic(expr_str, &ctx.scope)
1016 .map(|n| Some(Value::Int(n)))
1017 .map_err(|e| format!("arithmetic error: {e}")),
1018 Expr::HereDocBody { parts, strip_tabs } => {
1019 let mut asm = crate::interpreter::HeredocAssembler::new(*strip_tabs);
1023 for sp in parts {
1024 match &sp.part {
1025 crate::ast::StringPart::Literal(s) => asm.push_literal(s),
1026 other => {
1027 let s = eval_string_parts_sync(std::slice::from_ref(other), ctx)?;
1028 asm.push_interpolated(&s);
1029 }
1030 }
1031 }
1032 Ok(Some(Value::String(asm.into_string())))
1033 }
1034 Expr::CommandSubst(_) | Expr::Command(_) => Err(
1041 "command substitution `$(...)` is not supported in a scatter/gather flag value here; \
1042 assign it to a variable first (e.g. `n=$(...); scatter --limit $n`)"
1043 .to_string(),
1044 ),
1045 _ => Ok(None), }
1047}
1048
1049fn eval_literal(value: &Value, _ctx: &ExecContext) -> Value {
1051 value.clone()
1052}
1053
1054fn eval_string_parts_sync(parts: &[crate::ast::StringPart], ctx: &ExecContext) -> Result<String, String> {
1062 let mut result = String::new();
1063 for part in parts {
1064 match part {
1065 crate::ast::StringPart::Literal(s) => result.push_str(s),
1066 crate::ast::StringPart::Var(path) => match ctx.scope.resolve_path(path) {
1067 Ok(value) => result.push_str(
1071 &crate::interpreter::value_to_text_sink(&value).map_err(|e| e.to_string())?,
1072 ),
1073 Err(PathError::UndefinedRoot(_)) => {}
1081 Err(PathError::Absence(msg)) | Err(PathError::Shape(msg)) => return Err(msg),
1082 },
1083 crate::ast::StringPart::VarWithDefault { path, default } => {
1084 match crate::interpreter::resolve_default(&ctx.scope, path)? {
1085 Some(value) => result.push_str(
1086 &crate::interpreter::value_to_text_sink(&value).map_err(|e| e.to_string())?,
1087 ),
1088 None => result.push_str(&eval_string_parts_sync(default, ctx)?),
1089 }
1090 }
1091 crate::ast::StringPart::VarLength(path) => {
1092 let len = crate::interpreter::resolve_length(&ctx.scope, path)?;
1097 result.push_str(&len.to_string());
1098 }
1099 crate::ast::StringPart::Positional(n) => {
1100 if let Some(s) = ctx.scope.get_positional(*n) {
1101 result.push_str(s);
1102 }
1103 }
1104 crate::ast::StringPart::AllArgs => {
1105 result.push_str(&ctx.scope.all_args().join(" "));
1106 }
1107 crate::ast::StringPart::ArgCount => {
1108 result.push_str(&ctx.scope.arg_count().to_string());
1109 }
1110 crate::ast::StringPart::Arithmetic(expr) => {
1111 let value = arithmetic::eval_arithmetic(expr, &ctx.scope)
1119 .map_err(|e| format!("arithmetic error: {e}"))?;
1120 result.push_str(&value.to_string());
1121 }
1122 crate::ast::StringPart::CommandSubst(_) => {
1123 return Err(
1129 "command substitution `$(...)` is not supported inside a scatter/gather \
1130 flag's interpolated value here; assign it to a variable first"
1131 .to_string(),
1132 );
1133 }
1134 crate::ast::StringPart::LastExitCode => {
1135 result.push_str(&ctx.scope.last_result().code.to_string());
1136 }
1137 crate::ast::StringPart::CurrentPid => {
1138 result.push_str(&ctx.scope.pid().to_string());
1139 }
1140 }
1141 }
1142 Ok(result)
1143}
1144
1145fn find_scatter_gather(commands: &[Command]) -> Option<(usize, usize)> {
1150 let scatter_idx = commands.iter().position(|c| c.name == "scatter")?;
1151 let gather_idx = commands.iter().position(|c| c.name == "gather")?;
1152
1153 if gather_idx > scatter_idx {
1155 Some((scatter_idx, gather_idx))
1156 } else {
1157 None
1158 }
1159}
1160
1161#[cfg(test)]
1162mod select_leaf_tests {
1163 use super::*;
1164 use crate::tools::ParamSchema;
1165
1166 fn kj_schema() -> ToolSchema {
1171 ToolSchema::new("kj", "kaijutsu")
1172 .param(ParamSchema::new("confirm", "string"))
1173 .param(ParamSchema::new("verbose", "bool"))
1174 .subcommand(
1175 ToolSchema::new("context", "context ops")
1176 .with_command_aliases(["ctx"])
1177 .subcommand(ToolSchema::new("list", "list").with_command_aliases(["ls"]))
1178 .subcommand(
1179 ToolSchema::new("create", "create").param(
1180 ParamSchema::new("type", "string").with_aliases(["t"]),
1181 ),
1182 ),
1183 )
1184 }
1185
1186 fn word(s: &str) -> Arg {
1187 Arg::Positional(Expr::Literal(Value::String(s.to_string())))
1188 }
1189
1190 #[test]
1191 fn flat_tool_returns_root() {
1192 let schema = ToolSchema::new("cat", "concat")
1193 .param(ParamSchema::required("path", "string", "f").positional());
1194 let leaf = select_leaf(&schema, &[word("foo.txt")]).expect("flat ok");
1195 assert_eq!(leaf.name, "cat");
1196 }
1197
1198 #[test]
1199 fn single_hop() {
1200 let schema = kj_schema();
1201 let leaf = select_leaf(&schema, &[word("context")]).expect("ok");
1202 assert_eq!(leaf.name, "context");
1203 }
1204
1205 #[test]
1206 fn two_hops() {
1207 let schema = kj_schema();
1208 let leaf = select_leaf(&schema, &[word("context"), word("create")]).expect("ok");
1209 assert_eq!(leaf.name, "create");
1210 assert!(leaf.params.iter().any(|p| p.name == "type"), "leaf has --type");
1211 }
1212
1213 #[test]
1214 fn alias_hops_route() {
1215 let schema = kj_schema();
1216 let leaf = select_leaf(&schema, &[word("ctx"), word("ls")]).expect("ok");
1218 assert_eq!(leaf.name, "list");
1219 }
1220
1221 #[test]
1222 fn unknown_subcommand_stops_at_current_node() {
1223 let schema = kj_schema();
1224 let leaf = select_leaf(&schema, &[word("context"), word("nonesuch")]).expect("ok");
1227 assert_eq!(leaf.name, "context");
1228 }
1229
1230 #[test]
1231 fn root_bool_flag_before_path_does_not_disrupt_routing() {
1232 let schema = kj_schema();
1233 let args = vec![Arg::LongFlag("verbose".into()), word("context"), word("create")];
1236 let leaf = select_leaf(&schema, &args).expect("ok");
1237 assert_eq!(leaf.name, "create");
1238 }
1239
1240 #[test]
1241 fn root_value_flag_space_form_before_path_skips_its_value() {
1242 let schema = kj_schema();
1243 let args = vec![
1246 Arg::LongFlag("confirm".into()),
1247 word("token"),
1248 word("context"),
1249 word("create"),
1250 ];
1251 let leaf = select_leaf(&schema, &args).expect("ok");
1252 assert_eq!(leaf.name, "create");
1253 }
1254
1255 #[test]
1256 fn leaf_value_flag_after_path_routes_to_leaf() {
1257 let schema = kj_schema();
1258 let args = vec![
1261 word("context"),
1262 word("create"),
1263 Arg::LongFlag("type".into()),
1264 word("x"),
1265 ];
1266 let leaf = select_leaf(&schema, &args).expect("ok");
1267 assert_eq!(leaf.name, "create");
1268 assert!(leaf.params.iter().any(|p| p.name == "type"));
1269 }
1270
1271 #[test]
1272 fn double_dash_stops_routing() {
1273 let schema = kj_schema();
1274 let leaf = select_leaf(&schema, &[Arg::DoubleDash, word("context")]).expect("ok");
1276 assert_eq!(leaf.name, "kj");
1277 }
1278
1279 #[test]
1280 fn computed_subcommand_selector_errors() {
1281 let schema = kj_schema();
1282 let args = vec![Arg::Positional(Expr::CommandSubst(vec![
1285 crate::ast::Stmt::Command(crate::ast::Command {
1286 name: "echo".into(),
1287 args: vec![],
1288 redirects: vec![],
1289 }),
1290 ]))];
1291 let err = select_leaf(&schema, &args).expect_err("must error");
1292 let msg = err.to_string();
1293 assert!(msg.contains("subcommand name is required"), "got: {msg}");
1294 assert!(msg.contains("command substitution"), "names the cause: {msg}");
1295 }
1296
1297 #[test]
1298 fn variable_subcommand_selector_errors() {
1299 let schema = kj_schema();
1300 let args = vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("sub")))];
1301 let err = select_leaf(&schema, &args).expect_err("must error");
1302 assert!(err.to_string().contains("variable reference"), "got: {err}");
1303 }
1304
1305 #[test]
1306 fn computed_positional_after_leaf_is_fine() {
1307 let schema = kj_schema();
1308 let args = vec![
1311 word("context"),
1312 word("list"),
1313 Arg::Positional(Expr::CommandSubst(vec![crate::ast::Stmt::Command(
1314 crate::ast::Command { name: "echo".into(), args: vec![], redirects: vec![] },
1315 )])),
1316 ];
1317 let leaf = select_leaf(&schema, &args).expect("ok");
1318 assert_eq!(leaf.name, "list");
1319 }
1320}
1321
1322#[cfg(test)]
1323mod tests {
1324 use super::*;
1325 use crate::dispatch::BackendDispatcher;
1326 use crate::tools::register_builtins;
1327 use crate::vfs::{Filesystem, MemoryFs, VfsRouter};
1328 use std::path::Path;
1329
1330 async fn make_runner_and_ctx() -> (PipelineRunner, ExecContext, BackendDispatcher) {
1331 let mut tools = ToolRegistry::new();
1332 register_builtins(&mut tools);
1333 let tools = Arc::new(tools);
1334 let runner = PipelineRunner::new(tools.clone());
1335 let dispatcher = BackendDispatcher::new(tools.clone());
1336
1337 let mut vfs = VfsRouter::new();
1338 let mem = MemoryFs::new();
1339 mem.write(Path::new("test.txt"), b"hello\nworld\nfoo").await.unwrap();
1340 vfs.mount("/", mem);
1341 let ctx = ExecContext::with_vfs_and_tools(Arc::new(vfs), tools);
1342
1343 (runner, ctx, dispatcher)
1344 }
1345
1346 fn make_cmd(name: &str, args: Vec<&str>) -> Command {
1347 Command {
1348 name: name.to_string(),
1349 args: args.iter().map(|s| Arg::Positional(Expr::Literal(Value::String(s.to_string())))).collect(),
1350 redirects: vec![],
1351 }
1352 }
1353
1354 #[tokio::test]
1355 async fn test_single_command() {
1356 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1357 let cmd = make_cmd("echo", vec!["hello"]);
1358
1359 let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
1360 assert!(result.ok());
1361 assert_eq!(result.text_out().trim(), "hello");
1362 }
1363
1364 #[tokio::test]
1365 async fn test_pipeline_echo_grep() {
1366 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1367
1368 let echo_cmd = Command {
1370 name: "echo".to_string(),
1371 args: vec![Arg::Positional(Expr::Literal(Value::String("hello\nworld".to_string())))],
1372 redirects: vec![],
1373 };
1374 let grep_cmd = Command {
1375 name: "grep".to_string(),
1376 args: vec![Arg::Positional(Expr::Literal(Value::String("world".to_string())))],
1377 redirects: vec![],
1378 };
1379
1380 let result = runner.run(&[echo_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1381 assert!(result.ok());
1382 assert_eq!(result.text_out().trim(), "world");
1383 }
1384
1385 #[tokio::test]
1386 async fn test_pipeline_cat_grep() {
1387 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1388
1389 let cat_cmd = make_cmd("cat", vec!["/test.txt"]);
1391 let grep_cmd = Command {
1392 name: "grep".to_string(),
1393 args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
1394 redirects: vec![],
1395 };
1396
1397 let result = runner.run(&[cat_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1398 assert!(result.ok());
1399 assert!(result.text_out().contains("hello"));
1400 }
1401
1402 #[tokio::test]
1403 async fn test_command_not_found() {
1404 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1405 let cmd = make_cmd("nonexistent", vec![]);
1406
1407 let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
1408 assert!(!result.ok());
1409 assert_eq!(result.code, 127);
1410 assert!(result.err.contains("not found"));
1411 }
1412
1413 #[tokio::test]
1414 async fn test_pipeline_continues_on_failure() {
1415 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1418
1419 let cat_cmd = make_cmd("cat", vec!["/nonexistent"]);
1422 let grep_cmd = Command {
1423 name: "grep".to_string(),
1424 args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
1425 redirects: vec![],
1426 };
1427
1428 let result = runner.run(&[cat_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1429 assert!(!result.ok());
1431 }
1432
1433 #[tokio::test]
1434 async fn test_pipeline_last_command_exit_code() {
1435 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1437
1438 let echo_cmd = make_cmd("echo", vec!["hello"]);
1439 let cat_cmd = make_cmd("cat", vec![]);
1440
1441 let result = runner.run(&[echo_cmd, cat_cmd], &mut ctx, &dispatcher).await;
1442 assert!(result.ok());
1443 assert!(result.text_out().contains("hello"));
1444 }
1445
1446 #[tokio::test]
1447 async fn test_empty_pipeline() {
1448 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1449 let result = runner.run(&[], &mut ctx, &dispatcher).await;
1450 assert!(result.ok());
1451 }
1452
1453 #[test]
1456 fn test_find_scatter_gather_both_present() {
1457 let commands = vec![
1458 make_cmd("echo", vec!["a"]),
1459 make_cmd("scatter", vec![]),
1460 make_cmd("process", vec![]),
1461 make_cmd("gather", vec![]),
1462 ];
1463 let result = find_scatter_gather(&commands);
1464 assert_eq!(result, Some((1, 3)));
1465 }
1466
1467 #[test]
1468 fn test_find_scatter_gather_no_scatter() {
1469 let commands = vec![
1470 make_cmd("echo", vec!["a"]),
1471 make_cmd("gather", vec![]),
1472 ];
1473 let result = find_scatter_gather(&commands);
1474 assert!(result.is_none());
1475 }
1476
1477 #[test]
1478 fn test_find_scatter_gather_no_gather() {
1479 let commands = vec![
1480 make_cmd("echo", vec!["a"]),
1481 make_cmd("scatter", vec![]),
1482 ];
1483 let result = find_scatter_gather(&commands);
1484 assert!(result.is_none());
1485 }
1486
1487 #[test]
1488 fn test_find_scatter_gather_wrong_order() {
1489 let commands = vec![
1490 make_cmd("gather", vec![]),
1491 make_cmd("scatter", vec![]),
1492 ];
1493 let result = find_scatter_gather(&commands);
1494 assert!(result.is_none());
1495 }
1496
1497 #[tokio::test]
1498 async fn test_scatter_gather_simple() {
1499 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1500
1501 let split_cmd = Command {
1503 name: "split".to_string(),
1504 args: vec![Arg::Positional(Expr::Literal(Value::String("a b c".to_string())))],
1505 redirects: vec![],
1506 };
1507 let scatter_cmd = make_cmd("scatter", vec![]);
1508 let process_cmd = Command {
1509 name: "echo".to_string(),
1510 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1511 redirects: vec![],
1512 };
1513 let gather_cmd = make_cmd("gather", vec![]);
1514
1515 let result = runner.run(&[split_cmd, scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1516 assert!(result.ok(), "scatter with structured data should succeed: {}", result.err);
1517 assert!(result.text_out().contains("a"));
1519 assert!(result.text_out().contains("b"));
1520 assert!(result.text_out().contains("c"));
1521 }
1522
1523 #[tokio::test]
1524 async fn test_scatter_gather_empty_input() {
1525 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1526
1527 let echo_cmd = Command {
1529 name: "echo".to_string(),
1530 args: vec![Arg::Positional(Expr::Literal(Value::String("".to_string())))],
1531 redirects: vec![],
1532 };
1533 let scatter_cmd = make_cmd("scatter", vec![]);
1534 let process_cmd = Command {
1535 name: "echo".to_string(),
1536 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1537 redirects: vec![],
1538 };
1539 let gather_cmd = make_cmd("gather", vec![]);
1540
1541 let result = runner.run(&[echo_cmd, scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1542 assert!(result.ok());
1543 assert!(result.text_out().trim().is_empty());
1544 }
1545
1546 #[tokio::test]
1547 async fn test_scatter_gather_with_structured_stdin() {
1548 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1549
1550 let data = Value::Json(serde_json::json!(["x", "y", "z"]));
1552 ctx.set_stdin_with_data("x\ny\nz".to_string(), Some(data));
1553
1554 let scatter_cmd = make_cmd("scatter", vec![]);
1555 let process_cmd = Command {
1556 name: "echo".to_string(),
1557 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1558 redirects: vec![],
1559 };
1560 let gather_cmd = make_cmd("gather", vec![]);
1561
1562 let result = runner.run(&[scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1563 assert!(result.ok(), "scatter with structured stdin should succeed: {}", result.err);
1564 assert!(result.text_out().contains("x"));
1565 assert!(result.text_out().contains("y"));
1566 assert!(result.text_out().contains("z"));
1567 }
1568
1569 #[tokio::test]
1570 async fn test_scatter_gather_json_input() {
1571 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1572
1573 let data = Value::Json(serde_json::json!(["one", "two", "three"]));
1575 ctx.set_stdin_with_data(r#"["one", "two", "three"]"#.to_string(), Some(data));
1576
1577 let scatter_cmd = make_cmd("scatter", vec![]);
1578 let process_cmd = Command {
1579 name: "echo".to_string(),
1580 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1581 redirects: vec![],
1582 };
1583 let gather_cmd = make_cmd("gather", vec![]);
1584
1585 let result = runner.run(&[scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1586 assert!(result.ok(), "scatter with JSON data should succeed: {}", result.err);
1587 assert!(result.text_out().contains("one"));
1588 assert!(result.text_out().contains("two"));
1589 assert!(result.text_out().contains("three"));
1590 }
1591
1592 #[tokio::test]
1593 async fn test_scatter_gather_with_post_gather() {
1594 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1595
1596 let split_cmd = Command {
1598 name: "split".to_string(),
1599 args: vec![Arg::Positional(Expr::Literal(Value::String("a b".to_string())))],
1600 redirects: vec![],
1601 };
1602 let scatter_cmd = make_cmd("scatter", vec![]);
1603 let process_cmd = Command {
1604 name: "echo".to_string(),
1605 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("ITEM")))],
1606 redirects: vec![],
1607 };
1608 let gather_cmd = make_cmd("gather", vec![]);
1609 let grep_cmd = Command {
1610 name: "grep".to_string(),
1611 args: vec![Arg::Positional(Expr::Literal(Value::String("a".to_string())))],
1612 redirects: vec![],
1613 };
1614
1615 let result = runner.run(&[split_cmd, scatter_cmd, process_cmd, gather_cmd, grep_cmd], &mut ctx, &dispatcher).await;
1616 assert!(result.ok(), "scatter with post_gather should succeed: {}", result.err);
1617 assert!(result.text_out().contains("a"));
1618 assert!(!result.text_out().contains("b"));
1619 }
1620
1621 #[tokio::test]
1622 async fn test_scatter_custom_var_name() {
1623 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
1624
1625 let data = Value::Json(serde_json::json!(["test1", "test2"]));
1627 ctx.set_stdin_with_data("test1\ntest2".to_string(), Some(data));
1628
1629 let scatter_cmd = Command {
1631 name: "scatter".to_string(),
1632 args: vec![Arg::Named {
1633 key: "as".to_string(),
1634 value: Expr::Literal(Value::String("URL".to_string())),
1635 }],
1636 redirects: vec![],
1637 };
1638 let process_cmd = Command {
1639 name: "echo".to_string(),
1640 args: vec![Arg::Positional(Expr::VarRef(crate::ast::VarPath::simple("URL")))],
1641 redirects: vec![],
1642 };
1643 let gather_cmd = make_cmd("gather", vec![]);
1644
1645 let result = runner.run(&[scatter_cmd, process_cmd, gather_cmd], &mut ctx, &dispatcher).await;
1646 assert!(result.ok(), "scatter with custom var should succeed: {}", result.err);
1647 assert!(result.text_out().contains("test1"));
1648 assert!(result.text_out().contains("test2"));
1649 }
1650
1651 #[tokio::test]
1654 async fn test_pipeline_routes_through_backend() {
1655 use crate::backend::testing::MockBackend;
1656 use std::sync::atomic::Ordering;
1657
1658 let (backend, call_count) = MockBackend::new();
1660 let backend: std::sync::Arc<dyn crate::backend::KernelBackend> = std::sync::Arc::new(backend);
1661
1662 let mut ctx = crate::tools::ExecContext::with_backend(backend);
1664
1665 let tools = std::sync::Arc::new(ToolRegistry::new());
1667 let runner = PipelineRunner::new(tools.clone());
1668 let dispatcher = BackendDispatcher::new(tools);
1669
1670 let cmd = make_cmd("test-tool", vec!["arg1"]);
1672 let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
1673
1674 assert!(result.ok(), "Mock backend should return success");
1675 assert_eq!(call_count.load(Ordering::SeqCst), 1, "call_tool should be invoked once");
1676 assert!(result.text_out().contains("mock executed"), "Output should be from mock backend");
1677 }
1678
1679 #[tokio::test]
1680 async fn test_multi_command_pipeline_routes_through_backend() {
1681 use crate::backend::testing::MockBackend;
1682 use std::sync::atomic::Ordering;
1683
1684 let (backend, call_count) = MockBackend::new();
1685 let backend: std::sync::Arc<dyn crate::backend::KernelBackend> = std::sync::Arc::new(backend);
1686 let mut ctx = crate::tools::ExecContext::with_backend(backend);
1687
1688 let tools = std::sync::Arc::new(ToolRegistry::new());
1689 let runner = PipelineRunner::new(tools.clone());
1690 let dispatcher = BackendDispatcher::new(tools);
1691
1692 let cmd1 = make_cmd("tool1", vec![]);
1694 let cmd2 = make_cmd("tool2", vec![]);
1695 let cmd3 = make_cmd("tool3", vec![]);
1696
1697 let result = runner.run(&[cmd1, cmd2, cmd3], &mut ctx, &dispatcher).await;
1698
1699 assert!(result.ok());
1700 assert_eq!(call_count.load(Ordering::SeqCst), 3, "call_tool should be invoked for each command");
1701 }
1702
1703 #[tokio::test]
1710 async fn backend_dispatcher_scalar_data_matches_production_unwrap() {
1711 use crate::backend::testing::MockBackend;
1712 use crate::backend::ToolResult;
1713
1714 let (mock, _calls) = MockBackend::new();
1715 let backend = mock.with_tool_result(|_name| Ok(ToolResult::with_data("", serde_json::json!(42))));
1716 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
1717 let mut ctx = ExecContext::with_backend(backend);
1718
1719 let dispatcher = BackendDispatcher::new(Arc::new(ToolRegistry::new()));
1720 let cmd = make_cmd("embedder_tool", vec![]);
1721
1722 let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
1723 assert_eq!(
1724 result.data,
1725 Some(Value::Int(42)),
1726 "a scalar ToolResult.data must unwrap to a native Value, matching \
1727 the production From<ToolResult> path — not stay Value::Json(42)"
1728 );
1729 }
1730
1731 #[tokio::test]
1737 async fn backend_dispatcher_envelope_shaped_data_stays_structured() {
1738 use crate::backend::testing::MockBackend;
1739 use crate::backend::ToolResult;
1740
1741 let envelope = kaish_types::bytes_to_envelope(&[1u8, 2, 3]);
1742 let (mock, _calls) = MockBackend::new();
1743 let backend = mock.with_tool_result(move |_name| Ok(ToolResult::with_data("", envelope.clone())));
1744 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
1745 let mut ctx = ExecContext::with_backend(backend);
1746
1747 let dispatcher = BackendDispatcher::new(Arc::new(ToolRegistry::new()));
1748 let cmd = make_cmd("embedder_tool", vec![]);
1749
1750 let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
1751 assert!(
1752 matches!(result.data, Some(Value::Json(_))),
1753 "envelope-shaped external data must stay structured, not silently \
1754 decode to Value::Bytes: got {:?}",
1755 result.data
1756 );
1757 }
1758
1759 #[tokio::test]
1764 async fn backend_dispatcher_preserves_did_spill_and_original_code() {
1765 use crate::backend::testing::MockBackend;
1766 use crate::backend::ToolResult;
1767
1768 let (mock, _calls) = MockBackend::new();
1769 let backend = mock.with_tool_result(|_name| {
1770 Ok(ToolResult::success("truncated...")
1771 .with_did_spill(true)
1772 .with_original_code(Some(0)))
1773 });
1774 let backend: Arc<dyn crate::backend::KernelBackend> = Arc::new(backend);
1775 let mut ctx = ExecContext::with_backend(backend);
1776
1777 let dispatcher = BackendDispatcher::new(Arc::new(ToolRegistry::new()));
1778 let cmd = make_cmd("embedder_tool", vec![]);
1779
1780 let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
1781 assert!(result.did_spill, "did_spill must survive the backend seam");
1782 assert_eq!(result.original_code, Some(0), "original_code must survive the backend seam");
1783 }
1784
1785 use crate::tools::{ParamSchema, ToolSchema};
1788
1789 fn make_test_schema() -> ToolSchema {
1790 ToolSchema::new("test-tool", "A test tool for schema-aware parsing")
1791 .param(ParamSchema::required("query", "string", "Search query"))
1792 .param(ParamSchema::optional("limit", "int", Value::Int(10), "Max results"))
1793 .param(ParamSchema::optional("verbose", "bool", Value::Bool(false), "Verbose output"))
1794 .param(ParamSchema::optional("output", "string", Value::String("stdout".into()), "Output destination"))
1795 .with_positional_mapping()
1796 }
1797
1798 fn make_minimal_ctx() -> ExecContext {
1799 let mut vfs = VfsRouter::new();
1800 vfs.mount("/", MemoryFs::new());
1801 ExecContext::new(Arc::new(vfs))
1802 }
1803
1804 fn test_dispatcher() -> BackendDispatcher {
1808 BackendDispatcher::new(Arc::new(ToolRegistry::new()))
1809 }
1810
1811 #[tokio::test]
1812 async fn test_schema_aware_string_arg() {
1813 let args = vec![
1815 Arg::LongFlag("query".to_string()),
1816 Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
1817 ];
1818 let schema = make_test_schema();
1819 let ctx = make_minimal_ctx();
1820
1821 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1822
1823 assert!(tool_args.flags.is_empty(), "No flags should be set");
1824 assert!(tool_args.positional.is_empty(), "No positionals - consumed by --query");
1825 assert_eq!(
1826 tool_args.named.get("query"),
1827 Some(&Value::String("test".to_string())),
1828 "--query should consume 'test' as its value"
1829 );
1830 }
1831
1832 #[tokio::test]
1833 async fn test_schema_aware_bool_flag() {
1834 let args = vec![
1836 Arg::LongFlag("verbose".to_string()),
1837 ];
1838 let schema = make_test_schema();
1839 let ctx = make_minimal_ctx();
1840
1841 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1842
1843 assert!(tool_args.flags.contains("verbose"), "--verbose should be a flag");
1844 assert!(tool_args.named.is_empty(), "No named args");
1845 assert!(tool_args.positional.is_empty(), "No positionals");
1846 }
1847
1848 #[tokio::test]
1849 async fn test_schema_aware_mixed() {
1850 let args = vec![
1853 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
1854 Arg::LongFlag("output".to_string()),
1855 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
1856 Arg::LongFlag("verbose".to_string()),
1857 ];
1858 let schema = make_test_schema();
1859 let ctx = make_minimal_ctx();
1860
1861 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1862
1863 assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
1864 assert_eq!(
1865 tool_args.named.get("query"),
1866 Some(&Value::String("file.txt".to_string()))
1867 );
1868 assert_eq!(
1869 tool_args.named.get("output"),
1870 Some(&Value::String("out.txt".to_string()))
1871 );
1872 assert!(tool_args.flags.contains("verbose"));
1873 }
1874
1875 #[tokio::test]
1876 async fn test_schema_aware_multiple_string_args() {
1877 let args = vec![
1879 Arg::LongFlag("query".to_string()),
1880 Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
1881 Arg::LongFlag("output".to_string()),
1882 Arg::Positional(Expr::Literal(Value::String("result.json".to_string()))),
1883 Arg::LongFlag("verbose".to_string()),
1884 Arg::LongFlag("limit".to_string()),
1885 Arg::Positional(Expr::Literal(Value::Int(5))),
1886 ];
1887 let schema = make_test_schema();
1888 let ctx = make_minimal_ctx();
1889
1890 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1891
1892 assert!(tool_args.positional.is_empty(), "All positionals consumed");
1893 assert_eq!(
1894 tool_args.named.get("query"),
1895 Some(&Value::String("test".to_string()))
1896 );
1897 assert_eq!(
1898 tool_args.named.get("output"),
1899 Some(&Value::String("result.json".to_string()))
1900 );
1901 assert_eq!(
1902 tool_args.named.get("limit"),
1903 Some(&Value::Int(5))
1904 );
1905 assert!(tool_args.flags.contains("verbose"));
1906 }
1907
1908 #[tokio::test]
1909 async fn test_schema_aware_double_dash() {
1910 let args = vec![
1913 Arg::LongFlag("output".to_string()),
1914 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
1915 Arg::DoubleDash,
1916 Arg::Positional(Expr::Literal(Value::String("--this-is-data".to_string()))),
1917 ];
1918 let schema = make_test_schema();
1919 let ctx = make_minimal_ctx();
1920
1921 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1922
1923 assert_eq!(
1924 tool_args.named.get("output"),
1925 Some(&Value::String("out.txt".to_string()))
1926 );
1927 assert_eq!(
1929 tool_args.positional,
1930 vec![Value::String("--this-is-data".to_string())]
1931 );
1932 }
1933
1934 #[tokio::test]
1940 async fn word_assign_binary_value_is_loud_not_placeholder() {
1941 let args = vec![Arg::WordAssign {
1942 key: "if".to_string(),
1943 value: Expr::Literal(Value::Bytes(vec![0xff, 0x00, 0xfe])),
1944 }];
1945 let ctx = make_minimal_ctx();
1946
1947 let err = build_tool_args(&args, &ctx, None).await.expect_err("binary WordAssign must error");
1950 assert!(
1951 err.contains("cannot be used as"),
1952 "error should name the binary problem, got {err:?}"
1953 );
1954 }
1955
1956 #[tokio::test]
1961 async fn word_assign_before_double_dash_binds_named_for_export() {
1962 let args = vec![Arg::WordAssign {
1963 key: "A".to_string(),
1964 value: Expr::Literal(Value::String("1".to_string())),
1965 }];
1966 let schema = ToolSchema::new("export", "export");
1967 let ctx = make_minimal_ctx();
1968
1969 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1970 assert_eq!(tool_args.named.get("A"), Some(&Value::String("1".to_string())));
1971 assert!(tool_args.positional.is_empty());
1972 }
1973
1974 #[tokio::test]
1983 async fn word_assign_after_double_dash_is_positional_even_for_export() {
1984 let args = vec![
1985 Arg::DoubleDash,
1986 Arg::WordAssign {
1987 key: "A".to_string(),
1988 value: Expr::Literal(Value::String("1".to_string())),
1989 },
1990 ];
1991 let schema = ToolSchema::new("export", "export");
1992 let ctx = make_minimal_ctx();
1993
1994 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
1995 assert!(
1996 tool_args.named.is_empty(),
1997 "past `--`, A=1 must not become a named assignment: {:?}",
1998 tool_args.named
1999 );
2000 assert_eq!(tool_args.positional, vec![Value::String("A=1".to_string())]);
2001 }
2002
2003 #[tokio::test]
2011 async fn named_true_on_undeclared_flag_flagifies() {
2012 let args = vec![Arg::Named {
2013 key: "json".to_string(),
2014 value: Expr::Literal(Value::Bool(true)),
2015 }];
2016 let schema = make_test_schema(); let ctx = make_minimal_ctx();
2018
2019 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2020 assert!(tool_args.flags.contains("json"), "flags: {:?}", tool_args.flags);
2021 assert!(!tool_args.named.contains_key("json"), "named: {:?}", tool_args.named);
2022 }
2023
2024 #[tokio::test]
2027 async fn named_false_on_undeclared_flag_drops() {
2028 let args = vec![Arg::Named {
2029 key: "json".to_string(),
2030 value: Expr::Literal(Value::Bool(false)),
2031 }];
2032 let schema = make_test_schema();
2033 let ctx = make_minimal_ctx();
2034
2035 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2036 assert!(!tool_args.flags.contains("json"));
2037 assert!(!tool_args.named.contains_key("json"));
2038 }
2039
2040 #[tokio::test]
2044 async fn named_true_on_declared_bool_param_flagifies() {
2045 let args = vec![Arg::Named {
2046 key: "verbose".to_string(),
2047 value: Expr::Literal(Value::Bool(true)),
2048 }];
2049 let schema = make_test_schema();
2050 let ctx = make_minimal_ctx();
2051
2052 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2053 assert!(tool_args.flags.contains("verbose"));
2054 assert!(!tool_args.named.contains_key("verbose"));
2055 }
2056
2057 #[tokio::test]
2063 async fn named_true_on_declared_value_flag_keeps_value() {
2064 let args = vec![Arg::Named {
2065 key: "output".to_string(),
2066 value: Expr::Literal(Value::Bool(true)),
2067 }];
2068 let schema = make_test_schema();
2069 let ctx = make_minimal_ctx();
2070
2071 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2072 assert_eq!(tool_args.named.get("output"), Some(&Value::Bool(true)));
2073 assert!(!tool_args.flags.contains("output"));
2074 }
2075
2076 #[tokio::test]
2081 async fn named_true_with_no_schema_flagifies() {
2082 let args = vec![Arg::Named {
2083 key: "verbose".to_string(),
2084 value: Expr::Literal(Value::Bool(true)),
2085 }];
2086 let ctx = make_minimal_ctx();
2087
2088 let tool_args = build_tool_args(&args, &ctx, None).await.expect("build_tool_args");
2089 assert!(tool_args.flags.contains("verbose"));
2090 assert!(!tool_args.named.contains_key("verbose"));
2091 }
2092
2093 #[tokio::test]
2094 async fn test_no_schema_fallback() {
2095 let args = vec![
2097 Arg::LongFlag("query".to_string()),
2098 Arg::Positional(Expr::Literal(Value::String("test".to_string()))),
2099 ];
2100 let ctx = make_minimal_ctx();
2101
2102 let tool_args = build_tool_args(&args, &ctx, None).await.expect("build_tool_args");
2103
2104 assert!(tool_args.flags.contains("query"), "--query should be a flag");
2106 assert_eq!(
2107 tool_args.positional,
2108 vec![Value::String("test".to_string())],
2109 "'test' should be a positional"
2110 );
2111 }
2112
2113 #[tokio::test]
2123 async fn test_unknown_flag_ambiguous_space_value_now_errors_loud() {
2124 let args = vec![
2125 Arg::LongFlag("unknown".to_string()),
2126 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2127 ];
2128 let schema = make_test_schema();
2129 let ctx = make_minimal_ctx();
2130
2131 let err = build_tool_args(&args, &ctx, Some(&schema))
2132 .await
2133 .expect_err("an undeclared flag immediately before a positional must be ambiguous, not silently bool");
2134 assert!(
2135 err.contains("--unknown is not a declared flag"),
2136 "got: {err}"
2137 );
2138 }
2139
2140 #[tokio::test]
2144 async fn test_unknown_bool_flag_with_no_following_positional_is_fine() {
2145 let args = vec![
2146 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2147 Arg::LongFlag("unknown".to_string()),
2148 ];
2149 let schema = make_test_schema();
2150 let ctx = make_minimal_ctx();
2151
2152 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2153
2154 assert!(tool_args.flags.contains("unknown"));
2155 assert!(tool_args.positional.is_empty(), "value consumed as query param");
2156 assert_eq!(
2157 tool_args.named.get("query"),
2158 Some(&Value::String("value".to_string()))
2159 );
2160 }
2161
2162 #[tokio::test]
2172 async fn test_unknown_short_flag_ambiguous_space_value_now_errors_loud() {
2173 let args = vec![
2174 Arg::ShortFlag("t".to_string()),
2175 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2176 ];
2177 let schema = make_test_schema();
2178 let ctx = make_minimal_ctx();
2179
2180 let err = build_tool_args(&args, &ctx, Some(&schema))
2181 .await
2182 .expect_err("an undeclared short flag immediately before a positional must be ambiguous, not silently bool");
2183 assert!(
2184 err.contains("-t is not a declared flag"),
2185 "got: {err}"
2186 );
2187 }
2188
2189 #[tokio::test]
2193 async fn test_unknown_short_bool_flag_with_no_following_positional_is_fine() {
2194 let args = vec![
2195 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2196 Arg::ShortFlag("t".to_string()),
2197 ];
2198 let schema = make_test_schema();
2199 let ctx = make_minimal_ctx();
2200
2201 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2202
2203 assert!(tool_args.flags.contains("t"));
2204 assert!(tool_args.positional.is_empty(), "value consumed as query param");
2205 assert_eq!(
2206 tool_args.named.get("query"),
2207 Some(&Value::String("value".to_string()))
2208 );
2209 }
2210
2211 #[tokio::test]
2217 async fn test_unknown_short_flag_not_ambiguous_without_map_positionals() {
2218 let args = vec![
2219 Arg::ShortFlag("t".to_string()),
2220 Arg::Positional(Expr::Literal(Value::String("value".to_string()))),
2221 ];
2222 let schema = ToolSchema::new("test-tool", "A test tool")
2225 .param(ParamSchema::required("query", "string", "Search query"));
2226 let ctx = make_minimal_ctx();
2227
2228 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2229 assert!(tool_args.flags.contains("t"));
2230 assert_eq!(tool_args.positional, vec![Value::String("value".to_string())]);
2231 }
2232
2233 #[tokio::test]
2234 async fn test_named_args_unchanged() {
2235 let args = vec![
2237 Arg::Named {
2238 key: "query".to_string(),
2239 value: Expr::Literal(Value::String("test".to_string())),
2240 },
2241 Arg::LongFlag("verbose".to_string()),
2242 ];
2243 let schema = make_test_schema();
2244 let ctx = make_minimal_ctx();
2245
2246 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2247
2248 assert_eq!(
2249 tool_args.named.get("query"),
2250 Some(&Value::String("test".to_string()))
2251 );
2252 assert!(tool_args.flags.contains("verbose"));
2253 }
2254
2255 #[tokio::test]
2256 async fn test_short_flags_unchanged() {
2257 let args = vec![
2259 Arg::ShortFlag("la".to_string()),
2260 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
2261 ];
2262 let schema = make_test_schema();
2263 let ctx = make_minimal_ctx();
2264
2265 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2266
2267 assert!(tool_args.flags.contains("l"));
2268 assert!(tool_args.flags.contains("a"));
2269 assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
2270 assert_eq!(
2271 tool_args.named.get("query"),
2272 Some(&Value::String("file.txt".to_string()))
2273 );
2274 }
2275
2276 #[tokio::test]
2277 async fn test_flag_at_end_no_value() {
2278 let args = vec![
2281 Arg::Positional(Expr::Literal(Value::String("file.txt".to_string()))),
2282 Arg::LongFlag("output".to_string()),
2283 ];
2284 let schema = make_test_schema();
2285 let ctx = make_minimal_ctx();
2286
2287 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2288
2289 assert!(tool_args.flags.contains("output"));
2291 assert!(tool_args.positional.is_empty(), "file.txt consumed as query param");
2292 assert_eq!(
2293 tool_args.named.get("query"),
2294 Some(&Value::String("file.txt".to_string()))
2295 );
2296 }
2297
2298 #[tokio::test]
2299 async fn test_positional_skips_bool_params() {
2300 let schema = ToolSchema::new("test", "")
2304 .param(ParamSchema::required("query", "string", ""))
2305 .param(ParamSchema::optional(
2306 "verbose",
2307 "bool",
2308 Value::Bool(false),
2309 "",
2310 ))
2311 .param(ParamSchema::optional(
2312 "output",
2313 "string",
2314 Value::Null,
2315 "",
2316 ))
2317 .with_positional_mapping();
2318 let args = vec![
2319 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2320 Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
2321 ];
2322 let ctx = make_minimal_ctx();
2323
2324 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2325
2326 assert_eq!(
2327 tool_args.named.get("query"),
2328 Some(&Value::String("val1".to_string()))
2329 );
2330 assert_eq!(
2331 tool_args.named.get("output"),
2332 Some(&Value::String("val2".to_string()))
2333 );
2334 assert!(!tool_args.flags.contains("verbose"));
2335 assert!(tool_args.positional.is_empty());
2336 }
2337
2338 #[tokio::test]
2339 async fn test_positionals_fill_available_slots() {
2340 let args = vec![
2343 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2344 Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
2345 Arg::Positional(Expr::Literal(Value::String("val3".to_string()))),
2346 ];
2347 let schema = make_test_schema(); let ctx = make_minimal_ctx();
2349
2350 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2351
2352 assert_eq!(
2355 tool_args.named.get("query"),
2356 Some(&Value::String("val1".to_string()))
2357 );
2358 assert_eq!(
2359 tool_args.named.get("limit"),
2360 Some(&Value::String("val2".to_string()))
2361 );
2362 assert_eq!(
2363 tool_args.named.get("output"),
2364 Some(&Value::String("val3".to_string()))
2365 );
2366 assert!(tool_args.positional.is_empty());
2367 }
2368
2369 #[tokio::test]
2370 async fn test_truly_excess_positionals() {
2371 let schema = ToolSchema::new("test", "")
2373 .param(ParamSchema::required("name", "string", ""))
2374 .with_positional_mapping();
2375 let args = vec![
2376 Arg::Positional(Expr::Literal(Value::String("first".to_string()))),
2377 Arg::Positional(Expr::Literal(Value::String("second".to_string()))),
2378 Arg::Positional(Expr::Literal(Value::String("third".to_string()))),
2379 ];
2380 let ctx = make_minimal_ctx();
2381
2382 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2383
2384 assert_eq!(
2385 tool_args.named.get("name"),
2386 Some(&Value::String("first".to_string()))
2387 );
2388 assert_eq!(
2389 tool_args.positional,
2390 vec![
2391 Value::String("second".to_string()),
2392 Value::String("third".to_string()),
2393 ]
2394 );
2395 }
2396
2397 #[tokio::test]
2398 async fn test_double_dash_positional_not_mapped() {
2399 let args = vec![
2401 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2402 Arg::DoubleDash,
2403 Arg::Positional(Expr::Literal(Value::String("val2".to_string()))),
2404 ];
2405 let schema = make_test_schema();
2406 let ctx = make_minimal_ctx();
2407
2408 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2409
2410 assert_eq!(
2411 tool_args.named.get("query"),
2412 Some(&Value::String("val1".to_string()))
2413 );
2414 assert_eq!(
2416 tool_args.positional,
2417 vec![Value::String("val2".to_string())]
2418 );
2419 }
2420
2421 #[tokio::test]
2422 async fn test_all_params_filled_by_flags() {
2423 let args = vec![
2425 Arg::LongFlag("query".to_string()),
2426 Arg::Positional(Expr::Literal(Value::String("search".to_string()))),
2427 Arg::LongFlag("output".to_string()),
2428 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
2429 Arg::LongFlag("verbose".to_string()),
2430 ];
2431 let schema = make_test_schema();
2432 let ctx = make_minimal_ctx();
2433
2434 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2435
2436 assert_eq!(
2437 tool_args.named.get("query"),
2438 Some(&Value::String("search".to_string()))
2439 );
2440 assert_eq!(
2441 tool_args.named.get("output"),
2442 Some(&Value::String("out.txt".to_string()))
2443 );
2444 assert!(tool_args.flags.contains("verbose"));
2445 assert!(tool_args.positional.is_empty());
2446 }
2447
2448 #[tokio::test]
2449 async fn test_mixed_flags_and_positional_fill() {
2450 let args = vec![
2452 Arg::LongFlag("output".to_string()),
2453 Arg::Positional(Expr::Literal(Value::String("foo".to_string()))),
2454 Arg::Positional(Expr::Literal(Value::String("val1".to_string()))),
2455 ];
2456 let schema = make_test_schema();
2457 let ctx = make_minimal_ctx();
2458
2459 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2460
2461 assert_eq!(
2462 tool_args.named.get("output"),
2463 Some(&Value::String("foo".to_string()))
2464 );
2465 assert_eq!(
2466 tool_args.named.get("query"),
2467 Some(&Value::String("val1".to_string()))
2468 );
2469 assert!(tool_args.positional.is_empty());
2470 }
2471
2472 #[tokio::test]
2473 async fn test_alias_flag_prevents_mapping_overwrite() {
2474 let schema = ToolSchema::new("test", "")
2476 .param(ParamSchema::required("query", "string", "").with_aliases(["-q"]))
2477 .param(ParamSchema::required("output", "string", ""))
2478 .with_positional_mapping();
2479 let args = vec![
2480 Arg::ShortFlag("q".to_string()),
2481 Arg::Positional(Expr::Literal(Value::String("search".to_string()))),
2482 Arg::Positional(Expr::Literal(Value::String("out.txt".to_string()))),
2483 ];
2484 let ctx = make_minimal_ctx();
2485
2486 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2487
2488 assert_eq!(
2489 tool_args.named.get("query"),
2490 Some(&Value::String("search".to_string()))
2491 );
2492 assert_eq!(
2493 tool_args.named.get("output"),
2494 Some(&Value::String("out.txt".to_string()))
2495 );
2496 assert!(tool_args.positional.is_empty());
2497 }
2498
2499 #[tokio::test]
2500 async fn test_builtin_schema_no_positional_mapping() {
2501 let schema = ToolSchema::new("echo", "")
2503 .param(ParamSchema::optional("args", "any", Value::Null, ""))
2504 .param(ParamSchema::optional("no_newline", "bool", Value::Bool(false), ""));
2505 let args = vec![
2507 Arg::Positional(Expr::Literal(Value::String("hello".to_string()))),
2508 Arg::Positional(Expr::Literal(Value::String("world".to_string()))),
2509 ];
2510 let ctx = make_minimal_ctx();
2511
2512 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2513
2514 assert_eq!(
2516 tool_args.positional,
2517 vec![
2518 Value::String("hello".to_string()),
2519 Value::String("world".to_string()),
2520 ]
2521 );
2522 assert!(!tool_args.named.contains_key("args"));
2523 }
2524
2525 #[tokio::test]
2526 async fn test_short_flag_with_alias_consumes_value() {
2527 let schema = ToolSchema::new("head", "Output first part of files")
2530 .param(ParamSchema::optional("lines", "int", Value::Int(10), "Number of lines")
2531 .with_aliases(["-n"]));
2532 let args = vec![
2533 Arg::ShortFlag("n".to_string()),
2534 Arg::Positional(Expr::Literal(Value::Int(5))),
2535 Arg::Positional(Expr::Literal(Value::String("/tmp/file.txt".to_string()))),
2536 ];
2537 let ctx = make_minimal_ctx();
2538
2539 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2540
2541 assert!(tool_args.flags.is_empty(), "no boolean flags: {:?}", tool_args.flags);
2542 assert_eq!(tool_args.named.get("lines"), Some(&Value::Int(5)), "should resolve alias to canonical name");
2543 assert_eq!(tool_args.positional, vec![Value::String("/tmp/file.txt".to_string())]);
2544 }
2545
2546 #[tokio::test]
2558 async fn test_glued_short_flag_value_now_binds() {
2559 let schema = ToolSchema::new("cut", "")
2563 .param(ParamSchema::optional("fields", "string", Value::Null, "").with_aliases(["-f"]));
2564 let args = vec![Arg::ShortFlag("f1".to_string())];
2565 let ctx = make_minimal_ctx();
2566
2567 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2568
2569 assert!(tool_args.flags.is_empty(), "no bogus bool flags: {:?}", tool_args.flags);
2570 assert_eq!(tool_args.named.get("fields"), Some(&Value::String("1".to_string())));
2571 }
2572
2573 #[tokio::test]
2574 async fn test_repeatable_flag_now_accumulates() {
2575 let schema = ToolSchema::new("sed", "")
2580 .param(ParamSchema::optional("expression", "string", Value::Null, "")
2581 .with_aliases(["-e"])
2582 .with_repeatable(true));
2583 let args = vec![
2584 Arg::ShortFlag("e".to_string()),
2585 Arg::Positional(Expr::Literal(Value::String("A".to_string()))),
2586 Arg::ShortFlag("e".to_string()),
2587 Arg::Positional(Expr::Literal(Value::String("B".to_string()))),
2588 ];
2589 let ctx = make_minimal_ctx();
2590
2591 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2592
2593 assert_eq!(
2594 tool_args.named.get("expression"),
2595 Some(&Value::Json(serde_json::json!(["A", "B"]))),
2596 "both occurrences must survive, not just the last: {:?}",
2597 tool_args.named
2598 );
2599 }
2600
2601 #[tokio::test]
2602 async fn test_multi_consume_flag_now_accumulates() {
2603 let schema = ToolSchema::new("jq", "")
2608 .param(ParamSchema::optional("arg", "any", Value::Null, "").consumes(2));
2609 let args = vec![
2610 Arg::LongFlag("arg".to_string()),
2611 Arg::Positional(Expr::Literal(Value::String("name".to_string()))),
2612 Arg::Positional(Expr::Literal(Value::String("val".to_string()))),
2613 ];
2614 let ctx = make_minimal_ctx();
2615
2616 let tool_args = build_tool_args(&args, &ctx, Some(&schema)).await.expect("build_tool_args");
2617
2618 assert_eq!(
2619 tool_args.named.get("arg"),
2620 Some(&Value::Json(serde_json::json!([["name", "val"]]))),
2621 "got: {:?}",
2622 tool_args.named
2623 );
2624 assert!(tool_args.positional.is_empty());
2625 }
2626
2627 #[tokio::test]
2630 async fn test_merge_stderr_redirect() {
2631 let result = ExecResult::from_output(0, "stdout content", "stderr content");
2633
2634 let redirects = vec![Redirect {
2635 kind: RedirectKind::MergeStderr,
2636 target: Expr::Literal(Value::Null),
2637 }];
2638
2639 let ctx = make_minimal_ctx();
2640 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2641
2642 assert_eq!(&*result.text_out(), "stdout contentstderr content");
2643 assert!(result.err.is_empty());
2644 }
2645
2646 #[tokio::test]
2647 async fn test_merge_stderr_with_empty_stderr() {
2648 let result = ExecResult::from_output(0, "stdout only", "");
2650
2651 let redirects = vec![Redirect {
2652 kind: RedirectKind::MergeStderr,
2653 target: Expr::Literal(Value::Null),
2654 }];
2655
2656 let ctx = make_minimal_ctx();
2657 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2658
2659 assert_eq!(&*result.text_out(), "stdout only");
2660 assert!(result.err.is_empty());
2661 }
2662
2663 #[tokio::test]
2664 async fn test_merge_stderr_order_matters() {
2665 let result = ExecResult::from_output(0, "stdout\n", "stderr\n");
2670
2671 let redirects = vec![Redirect {
2673 kind: RedirectKind::MergeStderr,
2674 target: Expr::Literal(Value::Null),
2675 }];
2676
2677 let ctx = make_minimal_ctx();
2678 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2679
2680 assert_eq!(&*result.text_out(), "stdout\nstderr\n");
2681 assert!(result.err.is_empty());
2682 }
2683
2684 #[tokio::test]
2685 async fn test_redirect_with_command_execution() {
2686 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
2687
2688 let cmd = Command {
2690 name: "echo".to_string(),
2691 args: vec![Arg::Positional(Expr::Literal(Value::String("hello".to_string())))],
2692 redirects: vec![Redirect {
2693 kind: RedirectKind::MergeStderr,
2694 target: Expr::Literal(Value::Null),
2695 }],
2696 };
2697
2698 let result = runner.run(&[cmd], &mut ctx, &dispatcher).await;
2699 assert!(result.ok());
2700 assert!(result.text_out().contains("hello"));
2702 }
2703
2704 #[tokio::test]
2705 async fn test_merge_stderr_in_pipeline() {
2706 let (runner, mut ctx, dispatcher) = make_runner_and_ctx().await;
2707
2708 let echo_cmd = Command {
2711 name: "echo".to_string(),
2712 args: vec![Arg::Positional(Expr::Literal(Value::String("output".to_string())))],
2713 redirects: vec![Redirect {
2714 kind: RedirectKind::MergeStderr,
2715 target: Expr::Literal(Value::Null),
2716 }],
2717 };
2718 let grep_cmd = Command {
2719 name: "grep".to_string(),
2720 args: vec![Arg::Positional(Expr::Literal(Value::String("output".to_string())))],
2721 redirects: vec![],
2722 };
2723
2724 let result = runner.run(&[echo_cmd, grep_cmd], &mut ctx, &dispatcher).await;
2725 assert!(result.ok(), "result failed: code={}, err={}", result.code, result.err);
2726 assert!(result.text_out().contains("output"));
2727 }
2728
2729 fn big_table_output(rows: usize) -> crate::interpreter::OutputData {
2741 use crate::interpreter::OutputNode;
2742 let headers = vec!["id".to_string(), "name".to_string()];
2743 let nodes: Vec<OutputNode> = (0..rows)
2744 .map(|i| OutputNode::new(i.to_string()).with_cells(vec![format!("row-{i}")]))
2745 .collect();
2746 crate::interpreter::OutputData::table(headers, nodes)
2747 }
2748
2749 #[tokio::test]
2750 async fn test_both_redirect_streams_structured_output_to_file() {
2751 let output = big_table_output(50);
2756 let expected_stdout = output.to_canonical_string();
2757 let mut result = ExecResult::with_output(output);
2758 result.err = "warning: heads up\n".to_string();
2759
2760 let redirects = vec![Redirect {
2761 kind: RedirectKind::Both,
2762 target: Expr::Literal(Value::String("/out.txt".to_string())),
2763 }];
2764 let ctx = make_minimal_ctx();
2765 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2766
2767 assert!(result.ok());
2770 assert_eq!(&*result.text_out(), "");
2771 assert!(result.err.is_empty());
2772 assert!(!result.has_output());
2773
2774 let written = ctx.backend.read(Path::new("/out.txt"), None).await.expect("file written");
2775 let written = String::from_utf8(written).expect("valid utf8");
2776 assert_eq!(written, format!("{expected_stdout}warning: heads up\n"));
2780 }
2781
2782 #[tokio::test]
2783 async fn test_both_redirect_streams_large_structured_output_intact() {
2784 let rows = 5_000;
2789 let output = big_table_output(rows);
2790 let expected_stdout = output.to_canonical_string();
2791 let result = ExecResult::with_output(output);
2792
2793 let redirects = vec![Redirect {
2794 kind: RedirectKind::Both,
2795 target: Expr::Literal(Value::String("/big.txt".to_string())),
2796 }];
2797 let ctx = make_minimal_ctx();
2798 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2799 assert!(result.ok());
2800
2801 let written = ctx.backend.read(Path::new("/big.txt"), None).await.expect("file written");
2802 let written = String::from_utf8(written).expect("valid utf8");
2803 assert_eq!(written, expected_stdout);
2804 assert!(written.contains("row-0"));
2805 assert!(written.contains(&format!("row-{}", rows - 1)));
2806 }
2807
2808 #[tokio::test]
2809 async fn test_both_redirect_still_writes_binary_stdout_raw() {
2810 let result = ExecResult::success_text_or_bytes(vec![0xff, 0x00, 0xfe, b'x']);
2814 let redirects = vec![Redirect {
2815 kind: RedirectKind::Both,
2816 target: Expr::Literal(Value::String("/bin.out".to_string())),
2817 }];
2818 let ctx = make_minimal_ctx();
2819 let result = apply_redirects(result, &redirects, &ctx, &test_dispatcher()).await;
2820 assert!(result.ok());
2821
2822 let written = ctx.backend.read(Path::new("/bin.out"), None).await.expect("file written");
2823 assert_eq!(written, vec![0xff, 0x00, 0xfe, b'x']);
2824 }
2825}