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 PathOutcome::Exec {
92 child: Box::new(child_cfg),
93 child_dir,
94 }
95}
96
97pub enum WalkOutcome {
103 RunScript {
105 command: String,
106 docker: Option<String>,
107 cwd: PathBuf,
108 },
109 ExecCommand {
113 config: Box<CommandConfig>,
114 preset: Option<String>,
115 tail: Vec<String>,
116 cmd_dir: PathBuf,
117 },
118 RefreshSchema {
120 config: Box<CommandConfig>,
121 cmd_dir: PathBuf,
122 cmd_name: String,
123 },
124 PrintCommandHelp {
126 config: Box<CommandConfig>,
127 name: String,
128 },
129 PrintPresetHelp {
131 config: Box<CommandConfig>,
132 parent_label: String,
133 preset_name: String,
134 },
135 PrintRunHelp {
137 name: String,
138 description: Option<String>,
139 run: String,
140 docker: Option<String>,
141 },
142 UnknownCommand { name: String },
145 PresetAtTopLevel { name: String },
148 Error(String),
151}
152
153pub fn walk_commands(
167 cmd_name: &str,
168 tail: &[String],
169 top_commands: &BTreeMap<String, CommandSpec>,
170 project_root: &Path,
171 env: Option<&str>,
172) -> WalkOutcome {
173 let mut commands: BTreeMap<String, CommandSpec> = top_commands.clone();
174 let mut enclosing: Option<CommandConfig> = None;
175 let mut current_dir: PathBuf = project_root.to_path_buf();
176 let mut name: String = cmd_name.to_string();
177 let mut current_tail: Vec<String> = tail.to_vec();
178
179 loop {
180 let spec = match commands.get(&name) {
181 Some(s) => s.clone(),
182 None => return WalkOutcome::UnknownCommand { name },
183 };
184
185 let kind = match spec.kind() {
186 Ok(k) => k,
187 Err(e) => return WalkOutcome::Error(format!("command `{name}`: {e}")),
188 };
189
190 match kind {
191 CommandKind::Run => {
192 let command = spec
193 .run
194 .expect("Run kind guarantees `run` is set");
195 if current_tail.iter().any(|a| a == "--help" || a == "-h") {
196 return WalkOutcome::PrintRunHelp {
197 name,
198 description: spec.description,
199 run: command,
200 docker: spec.docker,
201 };
202 }
203 return WalkOutcome::RunScript {
204 command,
205 docker: spec.docker,
206 cwd: current_dir,
207 };
208 }
209 CommandKind::Path => {
210 match classify_path_step(&spec, &name, ¤t_dir, ¤t_tail, env) {
211 PathOutcome::LoadFailed(msg) => return WalkOutcome::Error(msg),
212 PathOutcome::Descend {
213 child,
214 new_dir,
215 new_name,
216 } => {
217 commands = child.commands.clone();
218 enclosing = Some(*child);
219 current_dir = new_dir;
220 name = new_name;
221 if !current_tail.is_empty() {
225 current_tail.remove(0);
226 }
227 }
228 PathOutcome::ShowHelp { child } => {
229 return WalkOutcome::PrintCommandHelp {
230 config: child,
231 name,
232 };
233 }
234 PathOutcome::RefreshSchema { child, child_dir } => {
235 return WalkOutcome::RefreshSchema {
236 config: child,
237 cmd_dir: child_dir,
238 cmd_name: name,
239 };
240 }
241 PathOutcome::Exec { child, child_dir } => {
242 return WalkOutcome::ExecCommand {
243 config: child,
244 preset: None,
245 tail: current_tail,
246 cmd_dir: child_dir,
247 };
248 }
249 }
250 }
251 CommandKind::Preset => {
252 let Some(encl) = enclosing.take() else {
253 return WalkOutcome::PresetAtTopLevel { name };
254 };
255
256 if current_tail.iter().any(|a| a == "--help" || a == "-h") {
257 let parent_label = current_dir
258 .file_name()
259 .and_then(|n| n.to_str())
260 .unwrap_or("")
261 .to_string();
262 return WalkOutcome::PrintPresetHelp {
263 config: Box::new(encl),
264 parent_label,
265 preset_name: name,
266 };
267 }
268
269 return WalkOutcome::ExecCommand {
270 config: Box::new(encl),
271 preset: Some(name),
272 tail: current_tail,
273 cmd_dir: current_dir,
274 };
275 }
276 }
277 }
278}
279
280#[cfg(test)]
283mod tests {
284 use super::*;
285
286 struct TempDir(PathBuf);
288
289 impl TempDir {
290 fn new() -> Self {
291 let base = std::env::temp_dir();
292 let unique = format!(
293 "flodl-dispatch-{}-{}",
294 std::process::id(),
295 std::time::SystemTime::now()
296 .duration_since(std::time::UNIX_EPOCH)
297 .map(|d| d.as_nanos())
298 .unwrap_or(0)
299 );
300 let dir = base.join(unique);
301 std::fs::create_dir_all(&dir).expect("tempdir creation");
302 Self(dir)
303 }
304 fn path(&self) -> &Path {
305 &self.0
306 }
307 }
308
309 impl Drop for TempDir {
310 fn drop(&mut self) {
311 let _ = std::fs::remove_dir_all(&self.0);
312 }
313 }
314
315 fn mkcmd(base: &Path, sub: &str, body: &str) -> PathBuf {
317 let dir = base.join(sub);
318 std::fs::create_dir_all(&dir).expect("mkcmd dir");
319 std::fs::write(dir.join("fdl.yml"), body).expect("mkcmd write");
320 dir
321 }
322
323 fn path_spec() -> CommandSpec {
324 CommandSpec::default()
326 }
327
328 #[test]
329 fn classify_descends_when_tail_names_nested_command() {
330 let tmp = TempDir::new();
331 mkcmd(
332 tmp.path(),
333 "ddp-bench",
334 "entry: echo\ncommands:\n quick:\n options: { model: linear }\n",
335 );
336 let spec = path_spec();
337 let tail = vec!["quick".to_string()];
338 let out = classify_path_step(&spec, "ddp-bench", tmp.path(), &tail, None);
339 match out {
340 PathOutcome::Descend { new_name, .. } => assert_eq!(new_name, "quick"),
341 _ => panic!("expected Descend, got something else"),
342 }
343 }
344
345 #[test]
346 fn classify_show_help_when_tail_has_flag() {
347 let tmp = TempDir::new();
348 mkcmd(tmp.path(), "sub", "entry: echo\n");
349 let spec = path_spec();
350 let tail = vec!["--help".to_string()];
351 let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
352 assert!(matches!(out, PathOutcome::ShowHelp { .. }));
353 }
354
355 #[test]
356 fn classify_show_help_short_flag() {
357 let tmp = TempDir::new();
358 mkcmd(tmp.path(), "sub", "entry: echo\n");
359 let spec = path_spec();
360 let tail = vec!["-h".to_string()];
361 let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
362 assert!(matches!(out, PathOutcome::ShowHelp { .. }));
363 }
364
365 #[test]
366 fn classify_refresh_schema() {
367 let tmp = TempDir::new();
368 mkcmd(tmp.path(), "sub", "entry: echo\n");
369 let spec = path_spec();
370 let tail = vec!["--refresh-schema".to_string()];
371 let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
372 assert!(matches!(out, PathOutcome::RefreshSchema { .. }));
373 }
374
375 #[test]
376 fn classify_exec_when_tail_has_no_known_token() {
377 let tmp = TempDir::new();
378 mkcmd(tmp.path(), "sub", "entry: echo\n");
379 let spec = path_spec();
380 let tail = vec!["--model".to_string(), "linear".to_string()];
381 let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
382 assert!(matches!(out, PathOutcome::Exec { .. }));
383 }
384
385 #[test]
386 fn classify_exec_when_tail_is_empty() {
387 let tmp = TempDir::new();
388 mkcmd(tmp.path(), "sub", "entry: echo\n");
389 let spec = path_spec();
390 let tail: Vec<String> = vec![];
391 let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
392 assert!(matches!(out, PathOutcome::Exec { .. }));
393 }
394
395 #[test]
396 fn classify_descend_wins_over_help_at_same_level() {
397 let tmp = TempDir::new();
401 mkcmd(
402 tmp.path(),
403 "sub",
404 "entry: echo\ncommands:\n quick:\n options: { x: 1 }\n",
405 );
406 let spec = path_spec();
407 let tail = vec!["quick".to_string(), "--help".to_string()];
408 let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
409 assert!(matches!(out, PathOutcome::Descend { .. }));
410 }
411
412 #[test]
413 fn classify_load_failed_when_no_child_fdl_yml() {
414 let tmp = TempDir::new();
415 let spec = path_spec();
416 let tail: Vec<String> = vec![];
417 let out = classify_path_step(&spec, "missing", tmp.path(), &tail, None);
418 match out {
419 PathOutcome::LoadFailed(msg) => assert!(msg.contains("no fdl.yml")),
420 _ => panic!("expected LoadFailed, got something else"),
421 }
422 }
423
424 #[test]
425 fn classify_uses_explicit_path() {
426 let tmp = TempDir::new();
429 mkcmd(tmp.path(), "actual", "entry: echo\n");
430 let spec = CommandSpec {
431 path: Some("actual".into()),
432 ..Default::default()
433 };
434 let tail: Vec<String> = vec![];
435 let out = classify_path_step(&spec, "label", tmp.path(), &tail, None);
438 assert!(matches!(out, PathOutcome::Exec { .. }));
439 }
440
441 fn top_commands(yaml: &str) -> BTreeMap<String, CommandSpec> {
449 #[derive(serde::Deserialize)]
450 struct Root {
451 #[serde(default)]
452 commands: BTreeMap<String, CommandSpec>,
453 }
454 serde_yaml::from_str::<Root>(yaml)
455 .expect("parse top-level commands")
456 .commands
457 }
458
459 fn args(xs: &[&str]) -> Vec<String> {
460 xs.iter().map(|s| s.to_string()).collect()
461 }
462
463 #[test]
464 fn walk_top_level_run_returns_run_script() {
465 let tmp = TempDir::new();
466 let commands = top_commands("commands:\n greet:\n run: echo hello\n");
467 let out = walk_commands("greet", &[], &commands, tmp.path(), None);
468 match out {
469 WalkOutcome::RunScript { command, docker, cwd } => {
470 assert_eq!(command, "echo hello");
471 assert!(docker.is_none());
472 assert_eq!(cwd, tmp.path());
473 }
474 _ => panic!("expected RunScript"),
475 }
476 }
477
478 #[test]
479 fn walk_top_level_run_with_docker_preserves_service() {
480 let tmp = TempDir::new();
481 let commands = top_commands(
482 "commands:\n dev:\n run: cargo test\n docker: dev\n",
483 );
484 let out = walk_commands("dev", &[], &commands, tmp.path(), None);
485 match out {
486 WalkOutcome::RunScript { docker, .. } => {
487 assert_eq!(docker.as_deref(), Some("dev"));
488 }
489 _ => panic!("expected RunScript with docker"),
490 }
491 }
492
493 #[test]
494 fn walk_run_with_help_prints_help_not_script() {
495 let tmp = TempDir::new();
496 let commands = top_commands(
497 "commands:\n test:\n description: Run all CPU tests\n run: cargo test\n docker: dev\n",
498 );
499 let tail = args(&["--help"]);
500 let out = walk_commands("test", &tail, &commands, tmp.path(), None);
501 match out {
502 WalkOutcome::PrintRunHelp {
503 name,
504 description,
505 run,
506 docker,
507 } => {
508 assert_eq!(name, "test");
509 assert_eq!(description.as_deref(), Some("Run all CPU tests"));
510 assert_eq!(run, "cargo test");
511 assert_eq!(docker.as_deref(), Some("dev"));
512 }
513 _ => panic!("expected PrintRunHelp"),
514 }
515 }
516
517 #[test]
518 fn walk_run_with_short_help_prints_help() {
519 let tmp = TempDir::new();
520 let commands = top_commands("commands:\n test:\n run: cargo test\n");
521 let tail = args(&["-h"]);
522 let out = walk_commands("test", &tail, &commands, tmp.path(), None);
523 assert!(matches!(out, WalkOutcome::PrintRunHelp { .. }));
524 }
525
526 #[test]
527 fn walk_unknown_top_level_returns_unknown() {
528 let tmp = TempDir::new();
529 let commands = top_commands("commands:\n greet:\n run: echo hello\n");
530 let out = walk_commands("nope", &args(&["arg"]), &commands, tmp.path(), None);
531 match out {
532 WalkOutcome::UnknownCommand { name } => assert_eq!(name, "nope"),
533 _ => panic!("expected UnknownCommand"),
534 }
535 }
536
537 #[test]
538 fn walk_top_level_preset_errors_without_enclosing() {
539 let tmp = TempDir::new();
543 let commands = top_commands(
544 "commands:\n orphan:\n options: { model: linear }\n",
545 );
546 let out = walk_commands("orphan", &[], &commands, tmp.path(), None);
547 match out {
548 WalkOutcome::PresetAtTopLevel { name } => assert_eq!(name, "orphan"),
549 _ => panic!("expected PresetAtTopLevel"),
550 }
551 }
552
553 #[test]
554 fn walk_run_and_path_both_set_is_error() {
555 let tmp = TempDir::new();
556 let commands = top_commands(
557 "commands:\n bad:\n run: echo hi\n path: ./sub\n",
558 );
559 let out = walk_commands("bad", &[], &commands, tmp.path(), None);
560 match out {
561 WalkOutcome::Error(msg) => {
562 assert!(msg.contains("bad"), "got: {msg}");
563 assert!(msg.contains("both `run:` and `path:`"), "got: {msg}");
564 }
565 _ => panic!("expected Error"),
566 }
567 }
568
569 #[test]
570 fn walk_path_exec_at_one_level() {
571 let tmp = TempDir::new();
573 mkcmd(tmp.path(), "ddp-bench", "entry: cargo run -p ddp-bench\n");
574 let commands = top_commands("commands:\n ddp-bench: {}\n");
575 let tail = args(&["--seed", "42"]);
576 let out = walk_commands("ddp-bench", &tail, &commands, tmp.path(), None);
577 match out {
578 WalkOutcome::ExecCommand {
579 preset,
580 tail: returned_tail,
581 cmd_dir,
582 ..
583 } => {
584 assert!(preset.is_none());
585 assert_eq!(returned_tail, args(&["--seed", "42"]));
586 assert_eq!(cmd_dir, tmp.path().join("ddp-bench"));
587 }
588 _ => panic!("expected ExecCommand"),
589 }
590 }
591
592 #[test]
593 fn walk_path_then_preset_at_two_levels() {
594 let tmp = TempDir::new();
600 mkcmd(
601 tmp.path(),
602 "ddp-bench",
603 "entry: cargo run -p ddp-bench\n\
604 commands:\n quick:\n options: { model: linear }\n",
605 );
606 let commands = top_commands("commands:\n ddp-bench: {}\n");
607 let tail = args(&["quick", "--epochs", "5"]);
608 let out = walk_commands("ddp-bench", &tail, &commands, tmp.path(), None);
609 match out {
610 WalkOutcome::ExecCommand {
611 preset,
612 tail: returned_tail,
613 cmd_dir,
614 ..
615 } => {
616 assert_eq!(preset.as_deref(), Some("quick"));
617 assert_eq!(returned_tail, args(&["--epochs", "5"]));
618 assert_eq!(cmd_dir, tmp.path().join("ddp-bench"));
619 }
620 _ => panic!("expected ExecCommand with preset"),
621 }
622 }
623
624 #[test]
625 fn walk_path_then_path_then_preset_at_three_levels() {
626 let tmp = TempDir::new();
631 mkcmd(
632 tmp.path(),
633 "a",
634 "entry: echo a\ncommands:\n b: {}\n",
635 );
636 let b_dir = tmp.path().join("a").join("b");
638 std::fs::create_dir_all(&b_dir).unwrap();
639 std::fs::write(
640 b_dir.join("fdl.yml"),
641 "entry: echo b\ncommands:\n quick:\n options: { x: 1 }\n",
642 )
643 .unwrap();
644 let commands = top_commands("commands:\n a: {}\n");
645 let tail = args(&["b", "quick"]);
646 let out = walk_commands("a", &tail, &commands, tmp.path(), None);
647 match out {
648 WalkOutcome::ExecCommand {
649 preset, cmd_dir, ..
650 } => {
651 assert_eq!(preset.as_deref(), Some("quick"));
652 assert_eq!(cmd_dir, b_dir);
653 }
654 _ => panic!("expected ExecCommand with preset at depth 3"),
655 }
656 }
657
658 #[test]
659 fn walk_path_child_missing_returns_error() {
660 let tmp = TempDir::new();
662 let commands = top_commands("commands:\n ghost: {}\n");
663 let out = walk_commands("ghost", &[], &commands, tmp.path(), None);
664 match out {
665 WalkOutcome::Error(msg) => assert!(msg.contains("no fdl.yml"), "got: {msg}"),
666 _ => panic!("expected Error(LoadFailed)"),
667 }
668 }
669
670 #[test]
671 fn walk_path_help_prints_command_help() {
672 let tmp = TempDir::new();
673 mkcmd(tmp.path(), "ddp-bench", "entry: echo\n");
674 let commands = top_commands("commands:\n ddp-bench: {}\n");
675 let tail = args(&["--help"]);
676 let out = walk_commands("ddp-bench", &tail, &commands, tmp.path(), None);
677 match out {
678 WalkOutcome::PrintCommandHelp { name, .. } => assert_eq!(name, "ddp-bench"),
679 _ => panic!("expected PrintCommandHelp"),
680 }
681 }
682
683 #[test]
684 fn walk_preset_help_prints_preset_help() {
685 let tmp = TempDir::new();
689 mkcmd(
690 tmp.path(),
691 "ddp-bench",
692 "entry: echo\ncommands:\n quick:\n options: { x: 1 }\n",
693 );
694 let commands = top_commands("commands:\n ddp-bench: {}\n");
695 let tail = args(&["quick", "--help"]);
696 let out = walk_commands("ddp-bench", &tail, &commands, tmp.path(), None);
697 match out {
698 WalkOutcome::PrintPresetHelp {
699 parent_label,
700 preset_name,
701 ..
702 } => {
703 assert_eq!(preset_name, "quick");
704 assert_eq!(parent_label, "ddp-bench");
705 }
706 _ => panic!("expected PrintPresetHelp"),
707 }
708 }
709
710 #[test]
711 fn walk_path_refresh_schema() {
712 let tmp = TempDir::new();
713 mkcmd(tmp.path(), "ddp-bench", "entry: echo\n");
714 let commands = top_commands("commands:\n ddp-bench: {}\n");
715 let tail = args(&["--refresh-schema"]);
716 let out = walk_commands("ddp-bench", &tail, &commands, tmp.path(), None);
717 match out {
718 WalkOutcome::RefreshSchema { cmd_name, .. } => {
719 assert_eq!(cmd_name, "ddp-bench");
720 }
721 _ => panic!("expected RefreshSchema"),
722 }
723 }
724
725 #[test]
726 fn walk_env_propagates_to_child_overlay() {
727 let tmp = TempDir::new();
731 let child = mkcmd(tmp.path(), "ddp-bench", "entry: echo-base\n");
732 std::fs::write(child.join("fdl.ci.yml"), "entry: echo-ci\n").unwrap();
733 let commands = top_commands("commands:\n ddp-bench: {}\n");
734 let out = walk_commands("ddp-bench", &[], &commands, tmp.path(), Some("ci"));
735 match out {
736 WalkOutcome::ExecCommand { config, .. } => {
737 assert_eq!(config.entry.as_deref(), Some("echo-ci"));
738 }
739 _ => panic!("expected ExecCommand with env-overlaid entry"),
740 }
741 }
742
743 #[test]
744 fn walk_env_none_ignores_overlay() {
745 let tmp = TempDir::new();
747 let child = mkcmd(tmp.path(), "ddp-bench", "entry: echo-base\n");
748 std::fs::write(child.join("fdl.ci.yml"), "entry: echo-ci\n").unwrap();
749 let commands = top_commands("commands:\n ddp-bench: {}\n");
750 let out = walk_commands("ddp-bench", &[], &commands, tmp.path(), None);
751 match out {
752 WalkOutcome::ExecCommand { config, .. } => {
753 assert_eq!(config.entry.as_deref(), Some("echo-base"));
754 }
755 _ => panic!("expected ExecCommand with base entry"),
756 }
757 }
758}