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 && child_cfg.commands.contains_key(next)
70 {
71 return PathOutcome::Descend {
72 child: Box::new(child_cfg),
73 new_dir: child_dir,
74 new_name: next.clone(),
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 cluster_chain: Vec<Option<bool>>,
128 },
129 ExecCommand {
133 config: Box<CommandConfig>,
134 preset: Option<String>,
135 tail: Vec<String>,
136 cmd_dir: PathBuf,
137 cluster_chain: Vec<Option<bool>>,
139 },
140 RefreshSchema {
142 config: Box<CommandConfig>,
143 cmd_dir: PathBuf,
144 cmd_name: String,
145 },
146 PrintCommandHelp {
148 config: Box<CommandConfig>,
149 name: String,
150 },
151 PrintPresetHelp {
153 config: Box<CommandConfig>,
154 parent_label: String,
155 preset_name: String,
156 },
157 PrintRunHelp {
159 name: String,
160 description: Option<String>,
161 run: String,
162 append: Option<String>,
163 docker: Option<String>,
164 },
165 UnknownCommand { name: String },
168 PresetAtTopLevel { name: String },
171 Error(String),
174}
175
176pub fn walk_commands(
190 cmd_name: &str,
191 tail: &[String],
192 top_commands: &BTreeMap<String, CommandSpec>,
193 project_root: &Path,
194 env: Option<&str>,
195) -> WalkOutcome {
196 let mut commands: BTreeMap<String, CommandSpec> = top_commands.clone();
197 let mut enclosing: Option<CommandConfig> = None;
198 let mut current_dir: PathBuf = project_root.to_path_buf();
199 let mut name: String = cmd_name.to_string();
200 let mut qualified: String = cmd_name.to_string();
204 let mut cluster_chain: Vec<Option<bool>> = Vec::new();
210 let mut current_tail: Vec<String> = tail.to_vec();
211
212 loop {
213 let spec = match commands.get(&name) {
214 Some(s) => s.clone(),
215 None => return WalkOutcome::UnknownCommand { name },
216 };
217 cluster_chain.push(spec.cluster);
218
219 let kind = match spec.kind() {
220 Ok(k) => k,
221 Err(e) => return WalkOutcome::Error(format!("command `{name}`: {e}")),
222 };
223
224 match kind {
225 CommandKind::Run => {
226 let command = spec.run.expect("Run kind guarantees `run` is set");
227 if current_tail.iter().any(|a| a == "--help" || a == "-h") {
228 return WalkOutcome::PrintRunHelp {
229 name: qualified,
230 description: spec.description,
231 run: command,
232 append: spec.append,
233 docker: spec.docker,
234 };
235 }
236 let (before, after) = match current_tail.iter().position(|a| a == "--") {
242 Some(idx) => {
243 let after = current_tail[idx + 1..].to_vec();
244 let before = current_tail[..idx].to_vec();
245 (before, after)
246 }
247 None => (current_tail.clone(), Vec::new()),
248 };
249 if !before.is_empty() {
250 return WalkOutcome::Error(format!(
251 "command `{name}` does not accept extra args; \
252 use `fdl {name} -- {}` to forward them to the script",
253 before.join(" ")
254 ));
255 }
256 return WalkOutcome::RunScript {
257 command,
258 append: spec.append,
259 user_args: after,
260 docker: spec.docker,
261 cwd: current_dir,
262 cluster_chain,
263 };
264 }
265 CommandKind::Path => {
266 match classify_path_step(&spec, &name, ¤t_dir, ¤t_tail, env) {
267 PathOutcome::LoadFailed(msg) => return WalkOutcome::Error(msg),
268 PathOutcome::Descend {
269 child,
270 new_dir,
271 new_name,
272 } => {
273 commands = child.commands.clone();
274 enclosing = Some(*child);
275 current_dir = new_dir;
276 qualified.push(' ');
277 qualified.push_str(&new_name);
278 name = new_name;
279 if !current_tail.is_empty() {
283 current_tail.remove(0);
284 }
285 }
286 PathOutcome::ShowHelp { child } => {
287 return WalkOutcome::PrintCommandHelp {
288 config: child,
289 name: qualified,
290 };
291 }
292 PathOutcome::RefreshSchema { child, child_dir } => {
293 return WalkOutcome::RefreshSchema {
294 config: child,
295 cmd_dir: child_dir,
296 cmd_name: qualified,
297 };
298 }
299 PathOutcome::Exec { child, child_dir } => {
300 return WalkOutcome::ExecCommand {
301 config: child,
302 preset: None,
303 tail: current_tail,
304 cmd_dir: child_dir,
305 cluster_chain,
306 };
307 }
308 }
309 }
310 CommandKind::Preset => {
311 let Some(encl) = enclosing.take() else {
312 return WalkOutcome::PresetAtTopLevel { name };
313 };
314
315 if current_tail.iter().any(|a| a == "--help" || a == "-h") {
316 let parent_label = current_dir
317 .file_name()
318 .and_then(|n| n.to_str())
319 .unwrap_or("")
320 .to_string();
321 return WalkOutcome::PrintPresetHelp {
322 config: Box::new(encl),
323 parent_label,
324 preset_name: name,
325 };
326 }
327
328 return WalkOutcome::ExecCommand {
329 config: Box::new(encl),
330 preset: Some(name),
331 tail: current_tail,
332 cmd_dir: current_dir,
333 cluster_chain,
334 };
335 }
336 }
337 }
338}
339
340#[cfg(test)]
343mod tests {
344 use super::*;
345
346 struct TempDir(PathBuf);
348
349 impl TempDir {
350 fn new() -> Self {
351 use std::sync::atomic::{AtomicU64, Ordering};
356 static N: AtomicU64 = AtomicU64::new(0);
357 let dir = std::env::temp_dir().join(format!(
358 "flodl-dispatch-{}-{}",
359 std::process::id(),
360 N.fetch_add(1, Ordering::Relaxed)
361 ));
362 std::fs::create_dir_all(&dir).expect("tempdir creation");
363 Self(dir)
364 }
365 fn path(&self) -> &Path {
366 &self.0
367 }
368 }
369
370 impl Drop for TempDir {
371 fn drop(&mut self) {
372 let _ = std::fs::remove_dir_all(&self.0);
373 }
374 }
375
376 fn mkcmd(base: &Path, sub: &str, body: &str) -> PathBuf {
378 let dir = base.join(sub);
379 std::fs::create_dir_all(&dir).expect("mkcmd dir");
380 std::fs::write(dir.join("fdl.yml"), body).expect("mkcmd write");
381 dir
382 }
383
384 fn path_spec() -> CommandSpec {
385 CommandSpec::default()
387 }
388
389 #[test]
390 fn classify_descends_when_tail_names_nested_command() {
391 let tmp = TempDir::new();
392 mkcmd(
393 tmp.path(),
394 "ddp-bench",
395 "entry: echo\ncommands:\n quick:\n options: { model: linear }\n",
396 );
397 let spec = path_spec();
398 let tail = vec!["quick".to_string()];
399 let out = classify_path_step(&spec, "ddp-bench", tmp.path(), &tail, None);
400 match out {
401 PathOutcome::Descend { new_name, .. } => assert_eq!(new_name, "quick"),
402 _ => panic!("expected Descend, got something else"),
403 }
404 }
405
406 #[test]
407 fn classify_show_help_when_tail_has_flag() {
408 let tmp = TempDir::new();
409 mkcmd(tmp.path(), "sub", "entry: echo\n");
410 let spec = path_spec();
411 let tail = vec!["--help".to_string()];
412 let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
413 assert!(matches!(out, PathOutcome::ShowHelp { .. }));
414 }
415
416 #[test]
417 fn classify_show_help_short_flag() {
418 let tmp = TempDir::new();
419 mkcmd(tmp.path(), "sub", "entry: echo\n");
420 let spec = path_spec();
421 let tail = vec!["-h".to_string()];
422 let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
423 assert!(matches!(out, PathOutcome::ShowHelp { .. }));
424 }
425
426 #[test]
427 fn classify_refresh_schema() {
428 let tmp = TempDir::new();
429 mkcmd(tmp.path(), "sub", "entry: echo\n");
430 let spec = path_spec();
431 let tail = vec!["--refresh-schema".to_string()];
432 let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
433 assert!(matches!(out, PathOutcome::RefreshSchema { .. }));
434 }
435
436 #[test]
437 fn classify_exec_when_tail_has_no_known_token() {
438 let tmp = TempDir::new();
439 mkcmd(tmp.path(), "sub", "entry: echo\n");
440 let spec = path_spec();
441 let tail = vec!["--model".to_string(), "linear".to_string()];
442 let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
443 assert!(matches!(out, PathOutcome::Exec { .. }));
444 }
445
446 #[test]
447 fn classify_exec_when_tail_is_empty() {
448 let tmp = TempDir::new();
449 mkcmd(tmp.path(), "sub", "entry: echo\n");
450 let spec = path_spec();
451 let tail: Vec<String> = vec![];
452 let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
453 assert!(matches!(out, PathOutcome::Exec { .. }));
454 }
455
456 #[test]
457 fn classify_descend_wins_over_help_at_same_level() {
458 let tmp = TempDir::new();
462 mkcmd(
463 tmp.path(),
464 "sub",
465 "entry: echo\ncommands:\n quick:\n options: { x: 1 }\n",
466 );
467 let spec = path_spec();
468 let tail = vec!["quick".to_string(), "--help".to_string()];
469 let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
470 assert!(matches!(out, PathOutcome::Descend { .. }));
471 }
472
473 #[test]
474 fn classify_bare_no_entry_with_subcommands_shows_help() {
475 let tmp = TempDir::new();
479 mkcmd(tmp.path(), "sub", "commands:\n foo:\n run: echo foo\n");
480 let spec = path_spec();
481 let tail: Vec<String> = vec![];
482 let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
483 assert!(matches!(out, PathOutcome::ShowHelp { .. }));
484 }
485
486 #[test]
487 fn classify_no_entry_no_subcommands_still_falls_through() {
488 let tmp = TempDir::new();
492 mkcmd(tmp.path(), "sub", "description: empty\n");
493 let spec = path_spec();
494 let tail: Vec<String> = vec![];
495 let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
496 assert!(matches!(out, PathOutcome::Exec { .. }));
497 }
498
499 #[test]
500 fn classify_load_failed_when_no_child_fdl_yml() {
501 let tmp = TempDir::new();
502 let spec = path_spec();
503 let tail: Vec<String> = vec![];
504 let out = classify_path_step(&spec, "missing", tmp.path(), &tail, None);
505 match out {
506 PathOutcome::LoadFailed(msg) => assert!(msg.contains("no fdl.yml")),
507 _ => panic!("expected LoadFailed, got something else"),
508 }
509 }
510
511 #[test]
512 fn classify_uses_explicit_path() {
513 let tmp = TempDir::new();
516 mkcmd(tmp.path(), "actual", "entry: echo\n");
517 let spec = CommandSpec {
518 path: Some("actual".into()),
519 ..Default::default()
520 };
521 let tail: Vec<String> = vec![];
522 let out = classify_path_step(&spec, "label", tmp.path(), &tail, None);
525 assert!(matches!(out, PathOutcome::Exec { .. }));
526 }
527
528 fn top_commands(yaml: &str) -> BTreeMap<String, CommandSpec> {
536 #[derive(serde::Deserialize)]
537 struct Root {
538 #[serde(default)]
539 commands: BTreeMap<String, CommandSpec>,
540 }
541 serde_yaml_ng::from_str::<Root>(yaml)
542 .expect("parse top-level commands")
543 .commands
544 }
545
546 fn args(xs: &[&str]) -> Vec<String> {
547 xs.iter().map(|s| s.to_string()).collect()
548 }
549
550 #[test]
551 fn walk_top_level_run_returns_run_script() {
552 let tmp = TempDir::new();
553 let commands = top_commands("commands:\n greet:\n run: echo hello\n");
554 let out = walk_commands("greet", &[], &commands, tmp.path(), None);
555 match out {
556 WalkOutcome::RunScript {
557 command,
558 append,
559 user_args,
560 docker,
561 cwd,
562 cluster_chain,
563 } => {
564 assert_eq!(command, "echo hello");
565 assert!(append.is_none());
566 assert!(user_args.is_empty());
567 assert!(docker.is_none());
568 assert_eq!(cwd, tmp.path());
569 assert_eq!(cluster_chain, vec![None]);
571 }
572 _ => panic!("expected RunScript"),
573 }
574 }
575
576 #[test]
577 fn walk_top_level_run_with_docker_preserves_service() {
578 let tmp = TempDir::new();
579 let commands = top_commands("commands:\n dev:\n run: cargo test\n docker: dev\n");
580 let out = walk_commands("dev", &[], &commands, tmp.path(), None);
581 match out {
582 WalkOutcome::RunScript { docker, .. } => {
583 assert_eq!(docker.as_deref(), Some("dev"));
584 }
585 _ => panic!("expected RunScript with docker"),
586 }
587 }
588
589 #[test]
590 fn walk_run_with_help_prints_help_not_script() {
591 let tmp = TempDir::new();
592 let commands = top_commands(
593 "commands:\n test:\n description: Run all CPU tests\n run: cargo test\n docker: dev\n",
594 );
595 let tail = args(&["--help"]);
596 let out = walk_commands("test", &tail, &commands, tmp.path(), None);
597 match out {
598 WalkOutcome::PrintRunHelp {
599 name,
600 description,
601 run,
602 append,
603 docker,
604 } => {
605 assert_eq!(name, "test");
606 assert_eq!(description.as_deref(), Some("Run all CPU tests"));
607 assert_eq!(run, "cargo test");
608 assert!(append.is_none());
609 assert_eq!(docker.as_deref(), Some("dev"));
610 }
611 _ => panic!("expected PrintRunHelp"),
612 }
613 }
614
615 #[test]
616 fn walk_run_forwards_args_after_double_dash() {
617 let tmp = TempDir::new();
618 let commands = top_commands(
619 "commands:\n test:\n run: cargo test live\n append: -- --nocapture --ignored\n",
620 );
621 let tail = args(&["--", "-p", "flodl-hf"]);
622 let out = walk_commands("test", &tail, &commands, tmp.path(), None);
623 match out {
624 WalkOutcome::RunScript {
625 command,
626 append,
627 user_args,
628 ..
629 } => {
630 assert_eq!(command, "cargo test live");
631 assert_eq!(append.as_deref(), Some("-- --nocapture --ignored"));
632 assert_eq!(user_args, vec!["-p".to_string(), "flodl-hf".to_string()]);
633 }
634 _ => panic!("expected RunScript"),
635 }
636 }
637
638 #[test]
639 fn walk_run_rejects_stray_args_before_double_dash() {
640 let tmp = TempDir::new();
641 let commands = top_commands("commands:\n test:\n run: cargo test\n");
642 let tail = args(&["-p", "flodl-hf"]);
643 let out = walk_commands("test", &tail, &commands, tmp.path(), None);
644 match out {
645 WalkOutcome::Error(msg) => {
646 assert!(
647 msg.contains("does not accept extra args")
648 && msg.contains("fdl test -- -p flodl-hf"),
649 "got: {msg}"
650 );
651 }
652 _ => panic!("expected Error"),
653 }
654 }
655
656 #[test]
657 fn walk_run_rejects_stray_args_even_with_double_dash_after() {
658 let tmp = TempDir::new();
659 let commands = top_commands("commands:\n test:\n run: cargo test\n");
660 let tail = args(&["-p", "flodl-hf", "--", "extra"]);
664 let out = walk_commands("test", &tail, &commands, tmp.path(), None);
665 assert!(matches!(out, WalkOutcome::Error(_)));
666 }
667
668 #[test]
669 fn walk_run_with_short_help_prints_help() {
670 let tmp = TempDir::new();
671 let commands = top_commands("commands:\n test:\n run: cargo test\n");
672 let tail = args(&["-h"]);
673 let out = walk_commands("test", &tail, &commands, tmp.path(), None);
674 assert!(matches!(out, WalkOutcome::PrintRunHelp { .. }));
675 }
676
677 #[test]
678 fn walk_unknown_top_level_returns_unknown() {
679 let tmp = TempDir::new();
680 let commands = top_commands("commands:\n greet:\n run: echo hello\n");
681 let out = walk_commands("nope", &args(&["arg"]), &commands, tmp.path(), None);
682 match out {
683 WalkOutcome::UnknownCommand { name } => assert_eq!(name, "nope"),
684 _ => panic!("expected UnknownCommand"),
685 }
686 }
687
688 #[test]
689 fn walk_top_level_preset_errors_without_enclosing() {
690 let tmp = TempDir::new();
694 let commands = top_commands("commands:\n orphan:\n options: { model: linear }\n");
695 let out = walk_commands("orphan", &[], &commands, tmp.path(), None);
696 match out {
697 WalkOutcome::PresetAtTopLevel { name } => assert_eq!(name, "orphan"),
698 _ => panic!("expected PresetAtTopLevel"),
699 }
700 }
701
702 #[test]
703 fn walk_run_and_path_both_set_is_error() {
704 let tmp = TempDir::new();
705 let commands = top_commands("commands:\n bad:\n run: echo hi\n path: ./sub\n");
706 let out = walk_commands("bad", &[], &commands, tmp.path(), None);
707 match out {
708 WalkOutcome::Error(msg) => {
709 assert!(msg.contains("bad"), "got: {msg}");
710 assert!(msg.contains("both `run:` and `path:`"), "got: {msg}");
711 }
712 _ => panic!("expected Error"),
713 }
714 }
715
716 #[test]
717 fn walk_path_exec_at_one_level() {
718 let tmp = TempDir::new();
720 mkcmd(tmp.path(), "ddp-bench", "entry: cargo run -p ddp-bench\n");
721 let commands = top_commands("commands:\n ddp-bench: {}\n");
722 let tail = args(&["--seed", "42"]);
723 let out = walk_commands("ddp-bench", &tail, &commands, tmp.path(), None);
724 match out {
725 WalkOutcome::ExecCommand {
726 preset,
727 tail: returned_tail,
728 cmd_dir,
729 ..
730 } => {
731 assert!(preset.is_none());
732 assert_eq!(returned_tail, args(&["--seed", "42"]));
733 assert_eq!(cmd_dir, tmp.path().join("ddp-bench"));
734 }
735 _ => panic!("expected ExecCommand"),
736 }
737 }
738
739 #[test]
740 fn walk_path_then_preset_at_two_levels() {
741 let tmp = TempDir::new();
747 mkcmd(
748 tmp.path(),
749 "ddp-bench",
750 "entry: cargo run -p ddp-bench\n\
751 commands:\n quick:\n options: { model: linear }\n",
752 );
753 let commands = top_commands("commands:\n ddp-bench: {}\n");
754 let tail = args(&["quick", "--epochs", "5"]);
755 let out = walk_commands("ddp-bench", &tail, &commands, tmp.path(), None);
756 match out {
757 WalkOutcome::ExecCommand {
758 preset,
759 tail: returned_tail,
760 cmd_dir,
761 ..
762 } => {
763 assert_eq!(preset.as_deref(), Some("quick"));
764 assert_eq!(returned_tail, args(&["--epochs", "5"]));
765 assert_eq!(cmd_dir, tmp.path().join("ddp-bench"));
766 }
767 _ => panic!("expected ExecCommand with preset"),
768 }
769 }
770
771 #[test]
772 fn walk_path_then_path_then_preset_at_three_levels() {
773 let tmp = TempDir::new();
778 mkcmd(tmp.path(), "a", "entry: echo a\ncommands:\n b: {}\n");
779 let b_dir = tmp.path().join("a").join("b");
781 std::fs::create_dir_all(&b_dir).unwrap();
782 std::fs::write(
783 b_dir.join("fdl.yml"),
784 "entry: echo b\ncommands:\n quick:\n options: { x: 1 }\n",
785 )
786 .unwrap();
787 let commands = top_commands("commands:\n a: {}\n");
788 let tail = args(&["b", "quick"]);
789 let out = walk_commands("a", &tail, &commands, tmp.path(), None);
790 match out {
791 WalkOutcome::ExecCommand {
792 preset, cmd_dir, ..
793 } => {
794 assert_eq!(preset.as_deref(), Some("quick"));
795 assert_eq!(cmd_dir, b_dir);
796 }
797 _ => panic!("expected ExecCommand with preset at depth 3"),
798 }
799 }
800
801 #[test]
802 fn walk_path_child_missing_returns_error() {
803 let tmp = TempDir::new();
805 let commands = top_commands("commands:\n ghost: {}\n");
806 let out = walk_commands("ghost", &[], &commands, tmp.path(), None);
807 match out {
808 WalkOutcome::Error(msg) => assert!(msg.contains("no fdl.yml"), "got: {msg}"),
809 _ => panic!("expected Error(LoadFailed)"),
810 }
811 }
812
813 #[test]
814 fn walk_path_help_prints_command_help() {
815 let tmp = TempDir::new();
816 mkcmd(tmp.path(), "ddp-bench", "entry: echo\n");
817 let commands = top_commands("commands:\n ddp-bench: {}\n");
818 let tail = args(&["--help"]);
819 let out = walk_commands("ddp-bench", &tail, &commands, tmp.path(), None);
820 match out {
821 WalkOutcome::PrintCommandHelp { name, .. } => assert_eq!(name, "ddp-bench"),
822 _ => panic!("expected PrintCommandHelp"),
823 }
824 }
825
826 #[test]
827 fn walk_preset_help_prints_preset_help() {
828 let tmp = TempDir::new();
832 mkcmd(
833 tmp.path(),
834 "ddp-bench",
835 "entry: echo\ncommands:\n quick:\n options: { x: 1 }\n",
836 );
837 let commands = top_commands("commands:\n ddp-bench: {}\n");
838 let tail = args(&["quick", "--help"]);
839 let out = walk_commands("ddp-bench", &tail, &commands, tmp.path(), None);
840 match out {
841 WalkOutcome::PrintPresetHelp {
842 parent_label,
843 preset_name,
844 ..
845 } => {
846 assert_eq!(preset_name, "quick");
847 assert_eq!(parent_label, "ddp-bench");
848 }
849 _ => panic!("expected PrintPresetHelp"),
850 }
851 }
852
853 #[test]
854 fn walk_path_refresh_schema() {
855 let tmp = TempDir::new();
856 mkcmd(tmp.path(), "ddp-bench", "entry: echo\n");
857 let commands = top_commands("commands:\n ddp-bench: {}\n");
858 let tail = args(&["--refresh-schema"]);
859 let out = walk_commands("ddp-bench", &tail, &commands, tmp.path(), None);
860 match out {
861 WalkOutcome::RefreshSchema { cmd_name, .. } => {
862 assert_eq!(cmd_name, "ddp-bench");
863 }
864 _ => panic!("expected RefreshSchema"),
865 }
866 }
867
868 #[test]
869 fn walk_env_propagates_to_child_overlay() {
870 let tmp = TempDir::new();
874 let child = mkcmd(tmp.path(), "ddp-bench", "entry: echo-base\n");
875 std::fs::write(child.join("fdl.ci.yml"), "entry: echo-ci\n").unwrap();
876 let commands = top_commands("commands:\n ddp-bench: {}\n");
877 let out = walk_commands("ddp-bench", &[], &commands, tmp.path(), Some("ci"));
878 match out {
879 WalkOutcome::ExecCommand { config, .. } => {
880 assert_eq!(config.entry.as_deref(), Some("echo-ci"));
881 }
882 _ => panic!("expected ExecCommand with env-overlaid entry"),
883 }
884 }
885
886 #[test]
887 fn walk_env_none_ignores_overlay() {
888 let tmp = TempDir::new();
890 let child = mkcmd(tmp.path(), "ddp-bench", "entry: echo-base\n");
891 std::fs::write(child.join("fdl.ci.yml"), "entry: echo-ci\n").unwrap();
892 let commands = top_commands("commands:\n ddp-bench: {}\n");
893 let out = walk_commands("ddp-bench", &[], &commands, tmp.path(), None);
894 match out {
895 WalkOutcome::ExecCommand { config, .. } => {
896 assert_eq!(config.entry.as_deref(), Some("echo-base"));
897 }
898 _ => panic!("expected ExecCommand with base entry"),
899 }
900 }
901
902 #[test]
905 fn walk_run_with_cluster_true_carries_single_entry_chain() {
906 let tmp = TempDir::new();
907 let commands = top_commands("commands:\n train:\n cluster: true\n run: cargo run\n");
908 let out = walk_commands("train", &[], &commands, tmp.path(), None);
909 match out {
910 WalkOutcome::RunScript { cluster_chain, .. } => {
911 assert_eq!(cluster_chain, vec![Some(true)]);
912 }
913 _ => panic!("expected RunScript"),
914 }
915 }
916
917 #[test]
918 fn walk_path_carries_ancestor_cluster_into_chain() {
919 let tmp = TempDir::new();
923 mkcmd(tmp.path(), "ddp-bench", "entry: cargo run -p ddp-bench\n");
924 let commands = top_commands("commands:\n ddp-bench:\n cluster: true\n");
925 let out = walk_commands("ddp-bench", &[], &commands, tmp.path(), None);
926 match out {
927 WalkOutcome::ExecCommand { cluster_chain, .. } => {
928 assert_eq!(cluster_chain, vec![Some(true)]);
929 }
930 _ => panic!("expected ExecCommand"),
931 }
932 }
933
934 #[test]
935 fn walk_path_preset_chain_includes_both_levels() {
936 let tmp = TempDir::new();
941 mkcmd(
942 tmp.path(),
943 "ddp-bench",
944 "entry: cargo run -p ddp-bench\n\
945 commands:\n quick:\n cluster: false\n options: { model: linear }\n",
946 );
947 let commands = top_commands("commands:\n ddp-bench:\n cluster: true\n");
948 let tail = args(&["quick"]);
949 let out = walk_commands("ddp-bench", &tail, &commands, tmp.path(), None);
950 match out {
951 WalkOutcome::ExecCommand {
952 preset,
953 cluster_chain,
954 ..
955 } => {
956 assert_eq!(preset.as_deref(), Some("quick"));
957 assert_eq!(cluster_chain, vec![Some(true), Some(false)]);
958 }
959 _ => panic!("expected ExecCommand with preset"),
960 }
961 }
962
963 #[test]
964 fn walk_no_cluster_anywhere_yields_all_none_chain() {
965 let tmp = TempDir::new();
969 mkcmd(tmp.path(), "ddp-bench", "entry: cargo run -p ddp-bench\n");
970 let commands = top_commands("commands:\n ddp-bench: {}\n");
971 let out = walk_commands("ddp-bench", &[], &commands, tmp.path(), None);
972 match out {
973 WalkOutcome::ExecCommand { cluster_chain, .. } => {
974 assert_eq!(cluster_chain, vec![None]);
975 }
976 _ => panic!("expected ExecCommand"),
977 }
978 }
979}