1use std::collections::BTreeMap;
19use std::path::{Path, PathBuf};
20
21use crate::config::{self, CommandConfig, CommandKind, CommandSpec};
22
23pub enum PathOutcome {
26 LoadFailed(String),
29 Descend {
31 child: Box<CommandConfig>,
32 new_dir: PathBuf,
33 new_name: String,
34 },
35 ShowHelp { child: Box<CommandConfig> },
37 RefreshSchema {
39 child: Box<CommandConfig>,
40 child_dir: PathBuf,
41 },
42 Exec {
44 child: Box<CommandConfig>,
45 child_dir: PathBuf,
46 },
47}
48
49pub fn classify_path_step(
53 spec: &CommandSpec,
54 name: &str,
55 current_dir: &Path,
56 tail: &[String],
57 env: Option<&str>,
58) -> PathOutcome {
59 let child_dir = spec.resolve_path(name, current_dir);
60 let child_cfg = match config::load_command_with_env(&child_dir, env) {
61 Ok(c) => c,
62 Err(e) => return PathOutcome::LoadFailed(e),
63 };
64
65 if let Some(next) = tail.first() {
69 if child_cfg.commands.contains_key(next) {
70 return PathOutcome::Descend {
71 child: Box::new(child_cfg),
72 new_dir: child_dir,
73 new_name: next.clone(),
74 };
75 }
76 }
77
78 if tail.iter().any(|a| a == "--help" || a == "-h") {
79 return PathOutcome::ShowHelp {
80 child: Box::new(child_cfg),
81 };
82 }
83
84 if tail.iter().any(|a| a == "--refresh-schema") {
85 return PathOutcome::RefreshSchema {
86 child: Box::new(child_cfg),
87 child_dir,
88 };
89 }
90
91 if tail.is_empty() && child_cfg.entry.is_none() && !child_cfg.commands.is_empty() {
96 return PathOutcome::ShowHelp {
97 child: Box::new(child_cfg),
98 };
99 }
100
101 PathOutcome::Exec {
102 child: Box::new(child_cfg),
103 child_dir,
104 }
105}
106
107pub enum WalkOutcome {
113 RunScript {
118 command: String,
119 append: Option<String>,
120 user_args: Vec<String>,
121 docker: Option<String>,
122 cwd: PathBuf,
123 },
124 ExecCommand {
128 config: Box<CommandConfig>,
129 preset: Option<String>,
130 tail: Vec<String>,
131 cmd_dir: PathBuf,
132 },
133 RefreshSchema {
135 config: Box<CommandConfig>,
136 cmd_dir: PathBuf,
137 cmd_name: String,
138 },
139 PrintCommandHelp {
141 config: Box<CommandConfig>,
142 name: String,
143 },
144 PrintPresetHelp {
146 config: Box<CommandConfig>,
147 parent_label: String,
148 preset_name: String,
149 },
150 PrintRunHelp {
152 name: String,
153 description: Option<String>,
154 run: String,
155 append: Option<String>,
156 docker: Option<String>,
157 },
158 UnknownCommand { name: String },
161 PresetAtTopLevel { name: String },
164 Error(String),
167}
168
169pub fn walk_commands(
183 cmd_name: &str,
184 tail: &[String],
185 top_commands: &BTreeMap<String, CommandSpec>,
186 project_root: &Path,
187 env: Option<&str>,
188) -> WalkOutcome {
189 let mut commands: BTreeMap<String, CommandSpec> = top_commands.clone();
190 let mut enclosing: Option<CommandConfig> = None;
191 let mut current_dir: PathBuf = project_root.to_path_buf();
192 let mut name: String = cmd_name.to_string();
193 let mut qualified: String = cmd_name.to_string();
197 let mut current_tail: Vec<String> = tail.to_vec();
198
199 loop {
200 let spec = match commands.get(&name) {
201 Some(s) => s.clone(),
202 None => return WalkOutcome::UnknownCommand { name },
203 };
204
205 let kind = match spec.kind() {
206 Ok(k) => k,
207 Err(e) => return WalkOutcome::Error(format!("command `{name}`: {e}")),
208 };
209
210 match kind {
211 CommandKind::Run => {
212 let command = spec
213 .run
214 .expect("Run kind guarantees `run` is set");
215 if current_tail.iter().any(|a| a == "--help" || a == "-h") {
216 return WalkOutcome::PrintRunHelp {
217 name: qualified,
218 description: spec.description,
219 run: command,
220 append: spec.append,
221 docker: spec.docker,
222 };
223 }
224 let (before, after) = match current_tail.iter().position(|a| a == "--") {
230 Some(idx) => {
231 let after = current_tail[idx + 1..].to_vec();
232 let before = current_tail[..idx].to_vec();
233 (before, after)
234 }
235 None => (current_tail.clone(), Vec::new()),
236 };
237 if !before.is_empty() {
238 return WalkOutcome::Error(format!(
239 "command `{name}` does not accept extra args; \
240 use `fdl {name} -- {}` to forward them to the script",
241 before.join(" ")
242 ));
243 }
244 return WalkOutcome::RunScript {
245 command,
246 append: spec.append,
247 user_args: after,
248 docker: spec.docker,
249 cwd: current_dir,
250 };
251 }
252 CommandKind::Path => {
253 match classify_path_step(&spec, &name, ¤t_dir, ¤t_tail, env) {
254 PathOutcome::LoadFailed(msg) => return WalkOutcome::Error(msg),
255 PathOutcome::Descend {
256 child,
257 new_dir,
258 new_name,
259 } => {
260 commands = child.commands.clone();
261 enclosing = Some(*child);
262 current_dir = new_dir;
263 qualified.push(' ');
264 qualified.push_str(&new_name);
265 name = new_name;
266 if !current_tail.is_empty() {
270 current_tail.remove(0);
271 }
272 }
273 PathOutcome::ShowHelp { child } => {
274 return WalkOutcome::PrintCommandHelp {
275 config: child,
276 name: qualified,
277 };
278 }
279 PathOutcome::RefreshSchema { child, child_dir } => {
280 return WalkOutcome::RefreshSchema {
281 config: child,
282 cmd_dir: child_dir,
283 cmd_name: qualified,
284 };
285 }
286 PathOutcome::Exec { child, child_dir } => {
287 return WalkOutcome::ExecCommand {
288 config: child,
289 preset: None,
290 tail: current_tail,
291 cmd_dir: child_dir,
292 };
293 }
294 }
295 }
296 CommandKind::Preset => {
297 let Some(encl) = enclosing.take() else {
298 return WalkOutcome::PresetAtTopLevel { name };
299 };
300
301 if current_tail.iter().any(|a| a == "--help" || a == "-h") {
302 let parent_label = current_dir
303 .file_name()
304 .and_then(|n| n.to_str())
305 .unwrap_or("")
306 .to_string();
307 return WalkOutcome::PrintPresetHelp {
308 config: Box::new(encl),
309 parent_label,
310 preset_name: name,
311 };
312 }
313
314 return WalkOutcome::ExecCommand {
315 config: Box::new(encl),
316 preset: Some(name),
317 tail: current_tail,
318 cmd_dir: current_dir,
319 };
320 }
321 }
322 }
323}
324
325#[cfg(test)]
328mod tests {
329 use super::*;
330
331 struct TempDir(PathBuf);
333
334 impl TempDir {
335 fn new() -> Self {
336 let base = std::env::temp_dir();
337 let unique = format!(
338 "flodl-dispatch-{}-{}",
339 std::process::id(),
340 std::time::SystemTime::now()
341 .duration_since(std::time::UNIX_EPOCH)
342 .map(|d| d.as_nanos())
343 .unwrap_or(0)
344 );
345 let dir = base.join(unique);
346 std::fs::create_dir_all(&dir).expect("tempdir creation");
347 Self(dir)
348 }
349 fn path(&self) -> &Path {
350 &self.0
351 }
352 }
353
354 impl Drop for TempDir {
355 fn drop(&mut self) {
356 let _ = std::fs::remove_dir_all(&self.0);
357 }
358 }
359
360 fn mkcmd(base: &Path, sub: &str, body: &str) -> PathBuf {
362 let dir = base.join(sub);
363 std::fs::create_dir_all(&dir).expect("mkcmd dir");
364 std::fs::write(dir.join("fdl.yml"), body).expect("mkcmd write");
365 dir
366 }
367
368 fn path_spec() -> CommandSpec {
369 CommandSpec::default()
371 }
372
373 #[test]
374 fn classify_descends_when_tail_names_nested_command() {
375 let tmp = TempDir::new();
376 mkcmd(
377 tmp.path(),
378 "ddp-bench",
379 "entry: echo\ncommands:\n quick:\n options: { model: linear }\n",
380 );
381 let spec = path_spec();
382 let tail = vec!["quick".to_string()];
383 let out = classify_path_step(&spec, "ddp-bench", tmp.path(), &tail, None);
384 match out {
385 PathOutcome::Descend { new_name, .. } => assert_eq!(new_name, "quick"),
386 _ => panic!("expected Descend, got something else"),
387 }
388 }
389
390 #[test]
391 fn classify_show_help_when_tail_has_flag() {
392 let tmp = TempDir::new();
393 mkcmd(tmp.path(), "sub", "entry: echo\n");
394 let spec = path_spec();
395 let tail = vec!["--help".to_string()];
396 let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
397 assert!(matches!(out, PathOutcome::ShowHelp { .. }));
398 }
399
400 #[test]
401 fn classify_show_help_short_flag() {
402 let tmp = TempDir::new();
403 mkcmd(tmp.path(), "sub", "entry: echo\n");
404 let spec = path_spec();
405 let tail = vec!["-h".to_string()];
406 let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
407 assert!(matches!(out, PathOutcome::ShowHelp { .. }));
408 }
409
410 #[test]
411 fn classify_refresh_schema() {
412 let tmp = TempDir::new();
413 mkcmd(tmp.path(), "sub", "entry: echo\n");
414 let spec = path_spec();
415 let tail = vec!["--refresh-schema".to_string()];
416 let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
417 assert!(matches!(out, PathOutcome::RefreshSchema { .. }));
418 }
419
420 #[test]
421 fn classify_exec_when_tail_has_no_known_token() {
422 let tmp = TempDir::new();
423 mkcmd(tmp.path(), "sub", "entry: echo\n");
424 let spec = path_spec();
425 let tail = vec!["--model".to_string(), "linear".to_string()];
426 let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
427 assert!(matches!(out, PathOutcome::Exec { .. }));
428 }
429
430 #[test]
431 fn classify_exec_when_tail_is_empty() {
432 let tmp = TempDir::new();
433 mkcmd(tmp.path(), "sub", "entry: echo\n");
434 let spec = path_spec();
435 let tail: Vec<String> = vec![];
436 let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
437 assert!(matches!(out, PathOutcome::Exec { .. }));
438 }
439
440 #[test]
441 fn classify_descend_wins_over_help_at_same_level() {
442 let tmp = TempDir::new();
446 mkcmd(
447 tmp.path(),
448 "sub",
449 "entry: echo\ncommands:\n quick:\n options: { x: 1 }\n",
450 );
451 let spec = path_spec();
452 let tail = vec!["quick".to_string(), "--help".to_string()];
453 let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
454 assert!(matches!(out, PathOutcome::Descend { .. }));
455 }
456
457 #[test]
458 fn classify_bare_no_entry_with_subcommands_shows_help() {
459 let tmp = TempDir::new();
463 mkcmd(
464 tmp.path(),
465 "sub",
466 "commands:\n foo:\n run: echo foo\n",
467 );
468 let spec = path_spec();
469 let tail: Vec<String> = vec![];
470 let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
471 assert!(matches!(out, PathOutcome::ShowHelp { .. }));
472 }
473
474 #[test]
475 fn classify_no_entry_no_subcommands_still_falls_through() {
476 let tmp = TempDir::new();
480 mkcmd(tmp.path(), "sub", "description: empty\n");
481 let spec = path_spec();
482 let tail: Vec<String> = vec![];
483 let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
484 assert!(matches!(out, PathOutcome::Exec { .. }));
485 }
486
487 #[test]
488 fn classify_load_failed_when_no_child_fdl_yml() {
489 let tmp = TempDir::new();
490 let spec = path_spec();
491 let tail: Vec<String> = vec![];
492 let out = classify_path_step(&spec, "missing", tmp.path(), &tail, None);
493 match out {
494 PathOutcome::LoadFailed(msg) => assert!(msg.contains("no fdl.yml")),
495 _ => panic!("expected LoadFailed, got something else"),
496 }
497 }
498
499 #[test]
500 fn classify_uses_explicit_path() {
501 let tmp = TempDir::new();
504 mkcmd(tmp.path(), "actual", "entry: echo\n");
505 let spec = CommandSpec {
506 path: Some("actual".into()),
507 ..Default::default()
508 };
509 let tail: Vec<String> = vec![];
510 let out = classify_path_step(&spec, "label", tmp.path(), &tail, None);
513 assert!(matches!(out, PathOutcome::Exec { .. }));
514 }
515
516 fn top_commands(yaml: &str) -> BTreeMap<String, CommandSpec> {
524 #[derive(serde::Deserialize)]
525 struct Root {
526 #[serde(default)]
527 commands: BTreeMap<String, CommandSpec>,
528 }
529 serde_yaml::from_str::<Root>(yaml)
530 .expect("parse top-level commands")
531 .commands
532 }
533
534 fn args(xs: &[&str]) -> Vec<String> {
535 xs.iter().map(|s| s.to_string()).collect()
536 }
537
538 #[test]
539 fn walk_top_level_run_returns_run_script() {
540 let tmp = TempDir::new();
541 let commands = top_commands("commands:\n greet:\n run: echo hello\n");
542 let out = walk_commands("greet", &[], &commands, tmp.path(), None);
543 match out {
544 WalkOutcome::RunScript {
545 command,
546 append,
547 user_args,
548 docker,
549 cwd,
550 } => {
551 assert_eq!(command, "echo hello");
552 assert!(append.is_none());
553 assert!(user_args.is_empty());
554 assert!(docker.is_none());
555 assert_eq!(cwd, tmp.path());
556 }
557 _ => panic!("expected RunScript"),
558 }
559 }
560
561 #[test]
562 fn walk_top_level_run_with_docker_preserves_service() {
563 let tmp = TempDir::new();
564 let commands = top_commands(
565 "commands:\n dev:\n run: cargo test\n docker: dev\n",
566 );
567 let out = walk_commands("dev", &[], &commands, tmp.path(), None);
568 match out {
569 WalkOutcome::RunScript { docker, .. } => {
570 assert_eq!(docker.as_deref(), Some("dev"));
571 }
572 _ => panic!("expected RunScript with docker"),
573 }
574 }
575
576 #[test]
577 fn walk_run_with_help_prints_help_not_script() {
578 let tmp = TempDir::new();
579 let commands = top_commands(
580 "commands:\n test:\n description: Run all CPU tests\n run: cargo test\n docker: dev\n",
581 );
582 let tail = args(&["--help"]);
583 let out = walk_commands("test", &tail, &commands, tmp.path(), None);
584 match out {
585 WalkOutcome::PrintRunHelp {
586 name,
587 description,
588 run,
589 append,
590 docker,
591 } => {
592 assert_eq!(name, "test");
593 assert_eq!(description.as_deref(), Some("Run all CPU tests"));
594 assert_eq!(run, "cargo test");
595 assert!(append.is_none());
596 assert_eq!(docker.as_deref(), Some("dev"));
597 }
598 _ => panic!("expected PrintRunHelp"),
599 }
600 }
601
602 #[test]
603 fn walk_run_forwards_args_after_double_dash() {
604 let tmp = TempDir::new();
605 let commands = top_commands(
606 "commands:\n test:\n run: cargo test live\n append: -- --nocapture --ignored\n",
607 );
608 let tail = args(&["--", "-p", "flodl-hf"]);
609 let out = walk_commands("test", &tail, &commands, tmp.path(), None);
610 match out {
611 WalkOutcome::RunScript {
612 command,
613 append,
614 user_args,
615 ..
616 } => {
617 assert_eq!(command, "cargo test live");
618 assert_eq!(append.as_deref(), Some("-- --nocapture --ignored"));
619 assert_eq!(user_args, vec!["-p".to_string(), "flodl-hf".to_string()]);
620 }
621 _ => panic!("expected RunScript"),
622 }
623 }
624
625 #[test]
626 fn walk_run_rejects_stray_args_before_double_dash() {
627 let tmp = TempDir::new();
628 let commands = top_commands("commands:\n test:\n run: cargo test\n");
629 let tail = args(&["-p", "flodl-hf"]);
630 let out = walk_commands("test", &tail, &commands, tmp.path(), None);
631 match out {
632 WalkOutcome::Error(msg) => {
633 assert!(
634 msg.contains("does not accept extra args")
635 && msg.contains("fdl test -- -p flodl-hf"),
636 "got: {msg}"
637 );
638 }
639 _ => panic!("expected Error"),
640 }
641 }
642
643 #[test]
644 fn walk_run_rejects_stray_args_even_with_double_dash_after() {
645 let tmp = TempDir::new();
646 let commands = top_commands("commands:\n test:\n run: cargo test\n");
647 let tail = args(&["-p", "flodl-hf", "--", "extra"]);
651 let out = walk_commands("test", &tail, &commands, tmp.path(), None);
652 assert!(matches!(out, WalkOutcome::Error(_)));
653 }
654
655 #[test]
656 fn walk_run_with_short_help_prints_help() {
657 let tmp = TempDir::new();
658 let commands = top_commands("commands:\n test:\n run: cargo test\n");
659 let tail = args(&["-h"]);
660 let out = walk_commands("test", &tail, &commands, tmp.path(), None);
661 assert!(matches!(out, WalkOutcome::PrintRunHelp { .. }));
662 }
663
664 #[test]
665 fn walk_unknown_top_level_returns_unknown() {
666 let tmp = TempDir::new();
667 let commands = top_commands("commands:\n greet:\n run: echo hello\n");
668 let out = walk_commands("nope", &args(&["arg"]), &commands, tmp.path(), None);
669 match out {
670 WalkOutcome::UnknownCommand { name } => assert_eq!(name, "nope"),
671 _ => panic!("expected UnknownCommand"),
672 }
673 }
674
675 #[test]
676 fn walk_top_level_preset_errors_without_enclosing() {
677 let tmp = TempDir::new();
681 let commands = top_commands(
682 "commands:\n orphan:\n options: { model: linear }\n",
683 );
684 let out = walk_commands("orphan", &[], &commands, tmp.path(), None);
685 match out {
686 WalkOutcome::PresetAtTopLevel { name } => assert_eq!(name, "orphan"),
687 _ => panic!("expected PresetAtTopLevel"),
688 }
689 }
690
691 #[test]
692 fn walk_run_and_path_both_set_is_error() {
693 let tmp = TempDir::new();
694 let commands = top_commands(
695 "commands:\n bad:\n run: echo hi\n path: ./sub\n",
696 );
697 let out = walk_commands("bad", &[], &commands, tmp.path(), None);
698 match out {
699 WalkOutcome::Error(msg) => {
700 assert!(msg.contains("bad"), "got: {msg}");
701 assert!(msg.contains("both `run:` and `path:`"), "got: {msg}");
702 }
703 _ => panic!("expected Error"),
704 }
705 }
706
707 #[test]
708 fn walk_path_exec_at_one_level() {
709 let tmp = TempDir::new();
711 mkcmd(tmp.path(), "ddp-bench", "entry: cargo run -p ddp-bench\n");
712 let commands = top_commands("commands:\n ddp-bench: {}\n");
713 let tail = args(&["--seed", "42"]);
714 let out = walk_commands("ddp-bench", &tail, &commands, tmp.path(), None);
715 match out {
716 WalkOutcome::ExecCommand {
717 preset,
718 tail: returned_tail,
719 cmd_dir,
720 ..
721 } => {
722 assert!(preset.is_none());
723 assert_eq!(returned_tail, args(&["--seed", "42"]));
724 assert_eq!(cmd_dir, tmp.path().join("ddp-bench"));
725 }
726 _ => panic!("expected ExecCommand"),
727 }
728 }
729
730 #[test]
731 fn walk_path_then_preset_at_two_levels() {
732 let tmp = TempDir::new();
738 mkcmd(
739 tmp.path(),
740 "ddp-bench",
741 "entry: cargo run -p ddp-bench\n\
742 commands:\n quick:\n options: { model: linear }\n",
743 );
744 let commands = top_commands("commands:\n ddp-bench: {}\n");
745 let tail = args(&["quick", "--epochs", "5"]);
746 let out = walk_commands("ddp-bench", &tail, &commands, tmp.path(), None);
747 match out {
748 WalkOutcome::ExecCommand {
749 preset,
750 tail: returned_tail,
751 cmd_dir,
752 ..
753 } => {
754 assert_eq!(preset.as_deref(), Some("quick"));
755 assert_eq!(returned_tail, args(&["--epochs", "5"]));
756 assert_eq!(cmd_dir, tmp.path().join("ddp-bench"));
757 }
758 _ => panic!("expected ExecCommand with preset"),
759 }
760 }
761
762 #[test]
763 fn walk_path_then_path_then_preset_at_three_levels() {
764 let tmp = TempDir::new();
769 mkcmd(
770 tmp.path(),
771 "a",
772 "entry: echo a\ncommands:\n b: {}\n",
773 );
774 let b_dir = tmp.path().join("a").join("b");
776 std::fs::create_dir_all(&b_dir).unwrap();
777 std::fs::write(
778 b_dir.join("fdl.yml"),
779 "entry: echo b\ncommands:\n quick:\n options: { x: 1 }\n",
780 )
781 .unwrap();
782 let commands = top_commands("commands:\n a: {}\n");
783 let tail = args(&["b", "quick"]);
784 let out = walk_commands("a", &tail, &commands, tmp.path(), None);
785 match out {
786 WalkOutcome::ExecCommand {
787 preset, cmd_dir, ..
788 } => {
789 assert_eq!(preset.as_deref(), Some("quick"));
790 assert_eq!(cmd_dir, b_dir);
791 }
792 _ => panic!("expected ExecCommand with preset at depth 3"),
793 }
794 }
795
796 #[test]
797 fn walk_path_child_missing_returns_error() {
798 let tmp = TempDir::new();
800 let commands = top_commands("commands:\n ghost: {}\n");
801 let out = walk_commands("ghost", &[], &commands, tmp.path(), None);
802 match out {
803 WalkOutcome::Error(msg) => assert!(msg.contains("no fdl.yml"), "got: {msg}"),
804 _ => panic!("expected Error(LoadFailed)"),
805 }
806 }
807
808 #[test]
809 fn walk_path_help_prints_command_help() {
810 let tmp = TempDir::new();
811 mkcmd(tmp.path(), "ddp-bench", "entry: echo\n");
812 let commands = top_commands("commands:\n ddp-bench: {}\n");
813 let tail = args(&["--help"]);
814 let out = walk_commands("ddp-bench", &tail, &commands, tmp.path(), None);
815 match out {
816 WalkOutcome::PrintCommandHelp { name, .. } => assert_eq!(name, "ddp-bench"),
817 _ => panic!("expected PrintCommandHelp"),
818 }
819 }
820
821 #[test]
822 fn walk_preset_help_prints_preset_help() {
823 let tmp = TempDir::new();
827 mkcmd(
828 tmp.path(),
829 "ddp-bench",
830 "entry: echo\ncommands:\n quick:\n options: { x: 1 }\n",
831 );
832 let commands = top_commands("commands:\n ddp-bench: {}\n");
833 let tail = args(&["quick", "--help"]);
834 let out = walk_commands("ddp-bench", &tail, &commands, tmp.path(), None);
835 match out {
836 WalkOutcome::PrintPresetHelp {
837 parent_label,
838 preset_name,
839 ..
840 } => {
841 assert_eq!(preset_name, "quick");
842 assert_eq!(parent_label, "ddp-bench");
843 }
844 _ => panic!("expected PrintPresetHelp"),
845 }
846 }
847
848 #[test]
849 fn walk_path_refresh_schema() {
850 let tmp = TempDir::new();
851 mkcmd(tmp.path(), "ddp-bench", "entry: echo\n");
852 let commands = top_commands("commands:\n ddp-bench: {}\n");
853 let tail = args(&["--refresh-schema"]);
854 let out = walk_commands("ddp-bench", &tail, &commands, tmp.path(), None);
855 match out {
856 WalkOutcome::RefreshSchema { cmd_name, .. } => {
857 assert_eq!(cmd_name, "ddp-bench");
858 }
859 _ => panic!("expected RefreshSchema"),
860 }
861 }
862
863 #[test]
864 fn walk_env_propagates_to_child_overlay() {
865 let tmp = TempDir::new();
869 let child = mkcmd(tmp.path(), "ddp-bench", "entry: echo-base\n");
870 std::fs::write(child.join("fdl.ci.yml"), "entry: echo-ci\n").unwrap();
871 let commands = top_commands("commands:\n ddp-bench: {}\n");
872 let out = walk_commands("ddp-bench", &[], &commands, tmp.path(), Some("ci"));
873 match out {
874 WalkOutcome::ExecCommand { config, .. } => {
875 assert_eq!(config.entry.as_deref(), Some("echo-ci"));
876 }
877 _ => panic!("expected ExecCommand with env-overlaid entry"),
878 }
879 }
880
881 #[test]
882 fn walk_env_none_ignores_overlay() {
883 let tmp = TempDir::new();
885 let child = mkcmd(tmp.path(), "ddp-bench", "entry: echo-base\n");
886 std::fs::write(child.join("fdl.ci.yml"), "entry: echo-ci\n").unwrap();
887 let commands = top_commands("commands:\n ddp-bench: {}\n");
888 let out = walk_commands("ddp-bench", &[], &commands, tmp.path(), None);
889 match out {
890 WalkOutcome::ExecCommand { config, .. } => {
891 assert_eq!(config.entry.as_deref(), Some("echo-base"));
892 }
893 _ => panic!("expected ExecCommand with base entry"),
894 }
895 }
896}