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