Skip to main content

flodl_cli/
dispatch.rs

1//! Pure command-graph dispatch.
2//!
3//! `walk_commands` is the outer walker: it chases an arbitrarily nested
4//! `commands:` graph starting from a top-level name + tail, and returns a
5//! `WalkOutcome` describing what the caller should do (run a script,
6//! spawn an entry, print help, error out, ...). The walker performs no
7//! IO of its own: no process spawning, no stdout writes, no cwd reads.
8//!
9//! `classify_path_step` is the inner classifier used by `walk_commands`
10//! for the `Path` arm: loads the child fdl.yml and inspects the tail to
11//! decide whether to descend, render help, refresh the schema cache, or
12//! forward to the entry.
13//!
14//! Keeping all impure actions (printing, spawning) in the caller makes
15//! both functions straight-line and unit-testable against tempdir
16//! fixtures.
17
18use std::collections::BTreeMap;
19use std::path::{Path, PathBuf};
20
21use crate::config::{self, CommandConfig, CommandKind, CommandSpec};
22
23/// What a single `Path`-kind step resolved to. Every variant holds the
24/// loaded `child` config when applicable, so the caller doesn't re-load.
25pub enum PathOutcome {
26    /// Failed to load the child `fdl.yml`. The string is the
27    /// underlying error message.
28    LoadFailed(String),
29    /// Next tail token is a known sub-command of the child — descend.
30    Descend {
31        child: Box<CommandConfig>,
32        new_dir: PathBuf,
33        new_name: String,
34    },
35    /// Tail carries `--help` / `-h` at this level.
36    ShowHelp { child: Box<CommandConfig> },
37    /// Tail carries `--refresh-schema`.
38    RefreshSchema {
39        child: Box<CommandConfig>,
40        child_dir: PathBuf,
41    },
42    /// Forward the tail to the child's entry.
43    Exec {
44        child: Box<CommandConfig>,
45        child_dir: PathBuf,
46    },
47}
48
49/// Classify a `Path`-kind step. Pure: loads the child config, inspects
50/// the tail, and returns the matching [`PathOutcome`]. The caller owns
51/// every side effect (printing, spawning).
52pub 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    // Descent check runs first: `--help` / `--refresh-schema` apply to
66    // the level the user is asking about, not to the parent. If the
67    // next token names a nested entry, we descend before reading flags.
68    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    // Bare project invocation with no entry but available sub-commands:
92    // mirror the top-level `fdl` UX and print help instead of erroring on
93    // a missing entry point. Only kicks in when tail is empty — any extra
94    // tokens still flow through to the existing exec/error path.
95    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
107// ── Outer walker ────────────────────────────────────────────────────────
108
109/// What the outer walker resolved a user invocation to. The caller owns
110/// every impure action (spawning, printing, exit code); the walker just
111/// returns the terminal state.
112pub enum WalkOutcome {
113    /// Top-level or nested `Run` — caller runs the inline script,
114    /// composing `command` + `user_args` (POSIX-quoted) + `append`.
115    /// `user_args` carries everything the caller typed after `--` on
116    /// the CLI (or the empty slice when `--` was absent).
117    RunScript {
118        command: String,
119        append: Option<String>,
120        user_args: Vec<String>,
121        docker: Option<String>,
122        cwd: PathBuf,
123        /// `CommandSpec.cluster` values walked from root → leaf. Caller
124        /// pairs with `ProjectConfig.cluster` and
125        /// [`config::cluster_dispatch_enabled`] to decide whether to
126        /// fan out across hosts instead of running locally.
127        cluster_chain: Vec<Option<bool>>,
128    },
129    /// Path-or-Preset terminal → caller invokes the child's entry. For
130    /// a Preset, `preset` is the preset name inside the enclosing
131    /// `commands:` block; for a Path-Exec it is `None`.
132    ExecCommand {
133        config: Box<CommandConfig>,
134        preset: Option<String>,
135        tail: Vec<String>,
136        cmd_dir: PathBuf,
137        /// See [`WalkOutcome::RunScript::cluster_chain`].
138        cluster_chain: Vec<Option<bool>>,
139    },
140    /// Path terminal with `--refresh-schema` in the tail.
141    RefreshSchema {
142        config: Box<CommandConfig>,
143        cmd_dir: PathBuf,
144        cmd_name: String,
145    },
146    /// Path terminal with `--help` / `-h` in the tail.
147    PrintCommandHelp {
148        config: Box<CommandConfig>,
149        name: String,
150    },
151    /// Preset terminal with `--help` / `-h` in the tail.
152    PrintPresetHelp {
153        config: Box<CommandConfig>,
154        parent_label: String,
155        preset_name: String,
156    },
157    /// Run terminal with `--help` / `-h` in the tail.
158    PrintRunHelp {
159        name: String,
160        description: Option<String>,
161        run: String,
162        append: Option<String>,
163        docker: Option<String>,
164    },
165    /// The top-level or descended-into name doesn't exist in the current
166    /// `commands:` map. Caller prints the project-help banner.
167    UnknownCommand { name: String },
168    /// A Preset-kind command at the top level has nothing to reuse an
169    /// `entry:` from. Caller prints a pointer to the fix.
170    PresetAtTopLevel { name: String },
171    /// Structural error: spec declares both `run:` and `path:`, or a
172    /// child fdl.yml failed to load / parse. String is the diagnostic.
173    Error(String),
174}
175
176/// Walk the command graph from a top-level name and produce a
177/// [`WalkOutcome`]. Every transition is pure: the walker never spawns a
178/// process, prints to stdout, or reads the process cwd. Inputs carry all
179/// the context needed.
180///
181/// - `cmd_name`: the top-level token the user typed (`fdl <cmd_name> ...`).
182/// - `tail`: positional args following `cmd_name` (typically `&args[2..]`).
183/// - `top_commands`: the root `commands:` block (usually
184///   `&project.commands`).
185/// - `project_root`: the directory containing the base `fdl.yml`; acts
186///   as the initial `current_dir` for Path resolution.
187/// - `env`: active overlay name, threaded to each `load_command_with_env`
188///   call so descended configs pick up env-layered fields.
189pub 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    // `qualified` tracks the space-separated path the user typed
201    // (`flodl-hf export`) so help renderers can show the correct
202    // invocation. `name` is always the leaf used for command-map lookup.
203    let mut qualified: String = cmd_name.to_string();
204    // Accumulates `CommandSpec.cluster` at every step (root → leaf). The
205    // caller pairs this with `ProjectConfig.cluster` to decide whether the
206    // resolved terminal command should fan out across the cluster. Per-
207    // command `cluster: false` deeper in the chain overrides an ancestor's
208    // `cluster: true`; see [`config::resolve_cluster_dispatch`].
209    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                // Split tail on the first `--`: anything before is
239                // unexpected (no fdl-side flags exist for run-kind), and
240                // anything after is forwarded to the script. Loud
241                // rejection of stray args mirrors the loud-errors-over-
242                // silent rule.
243                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, &current_dir, &current_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                        // classify_path_step returned Descend because
282                        // current_tail[0] named a nested command; consume
283                        // that token before the next iteration.
284                        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// ── Tests ───────────────────────────────────────────────────────────────
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    /// Minimal tempdir helper — avoids pulling in the `tempfile` crate.
349    struct TempDir(PathBuf);
350
351    impl TempDir {
352        fn new() -> Self {
353            // Process-wide counter, NOT a timestamp: concurrent test
354            // threads can construct TempDirs within one SystemTime tick,
355            // and create_dir_all on the colliding path succeeds silently —
356            // two tests then share (and Drop-delete) one directory.
357            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    /// Write a sub-command fdl.yml at `dir/sub/fdl.yml` with the given body.
379    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        // Convention-default Path: no fields set, `kind()` returns Path.
388        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        // `fdl sub quick --help` must render help for `quick` (handled
461        // one level deeper), not for `sub`. Descent wins over help at
462        // the current step.
463        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        // Bare `fdl <project>` on a project that defines `commands:` but
478        // no top-level `entry:` should print help, not error with
479        // "no entry point defined". Mirrors the top-level `fdl` UX.
480        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        // No entry, no sub-commands: keep the existing exec path so the
495        // downstream "no entry point defined" error fires for genuinely
496        // misconfigured projects.
497        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        // Explicit `path:` overrides the convention default. Drop the
520        // child fdl.yml under `actual/` and point `path:` there.
521        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        // `name` here is the command's label, not where we load from —
529        // `actual/` is the real dir courtesy of `path:`.
530        let out = classify_path_step(&spec, "label", tmp.path(), &tail, None);
531        assert!(matches!(out, PathOutcome::Exec { .. }));
532    }
533
534    // ── walk_commands: outer walker ──────────────────────────────────────
535    //
536    // These drive the full walk from top-level down, asserting on the
537    // terminal WalkOutcome variant. No processes are spawned — the walker
538    // is pure, so tests stay fast and hermetic.
539
540    /// Build a top-level `commands:` map by parsing a short YAML snippet.
541    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                // No cluster: directive anywhere → chain has one entry: None.
576                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        // Stray `-p flodl-hf` BEFORE `--` is rejected even though `--`
669        // appears later. The rule is structural: the tail must be empty
670        // up to `--`.
671        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        // A top-level command with preset-shaped fields (`options:`) but
699        // neither `run:` nor `path:` has no enclosing CommandConfig to
700        // borrow an `entry:` from — must error loudly.
701        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        // Top-level `ddp-bench` path-kind → no further descent → Exec.
731        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        // fdl.yml: commands: { ddp-bench: {} }  → path kind, convention
754        // ddp-bench/fdl.yml: commands: { quick: { options: { model: linear } } }
755        // Invocation: `fdl ddp-bench quick --epochs 5`
756        // Expected: descend into ddp-bench, resolve `quick` as preset,
757        // emit ExecCommand with preset=Some("quick"), tail=["--epochs","5"].
758        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        // Three-level walk: `fdl a b quick`.
786        // tmp/fdl.yml             → commands: { a: {} }
787        // tmp/a/fdl.yml           → commands: { b: {} }   + entry (required for preset parent)
788        // tmp/a/b/fdl.yml         → commands: { quick: { options: { x: 1 } } } + entry
789        let tmp = TempDir::new();
790        mkcmd(
791            tmp.path(),
792            "a",
793            "entry: echo a\ncommands:\n  b: {}\n",
794        );
795        // b is a sibling directory under a/
796        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        // Convention-default Path for `ghost`, but tmp/ghost/fdl.yml doesn't exist.
820        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        // `fdl ddp-bench quick --help` — help applies to the preset, not
845        // the enclosing command (descent wins at the classify level, then
846        // Preset-kind with `--help` in the tail emits PrintPresetHelp).
847        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        // Base child says entry=echo-base; env overlay fdl.ci.yml
887        // overrides entry=echo-ci. After descent with env=Some("ci"),
888        // the ExecCommand carries the overlaid config.
889        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        // Same fixtures as above, but env=None — base must win.
905        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    // ── cluster_chain accumulation along the walk ───────────────────
919
920    #[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        // Root marks ddp-bench: cluster: true. Sub-fdl.yml's leaf command
938        // leaves it unset → chain = [Some(true), None]. Per the resolver,
939        // this would inherit cluster: true at dispatch.
940        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        // Root: ddp-bench (Path, cluster: true). ddp-bench/fdl.yml: quick
957        // preset with cluster: false → chain = [Some(true), Some(false)].
958        // Per the resolver, leaf override wins → effective false (stays
959        // local despite ancestor saying cluster).
960        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        // Plain `fdl ddp-bench` with no cluster directives anywhere →
988        // chain has one None entry per walked level. The resolver returns
989        // false (no dispatch) for any all-None chain.
990        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}