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        && 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    // 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.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                // Split tail on the first `--`: anything before is
237                // unexpected (no fdl-side flags exist for run-kind), and
238                // anything after is forwarded to the script. Loud
239                // rejection of stray args mirrors the loud-errors-over-
240                // silent rule.
241                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, &current_dir, &current_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                        // classify_path_step returned Descend because
280                        // current_tail[0] named a nested command; consume
281                        // that token before the next iteration.
282                        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// ── Tests ───────────────────────────────────────────────────────────────
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    /// Minimal tempdir helper — avoids pulling in the `tempfile` crate.
347    struct TempDir(PathBuf);
348
349    impl TempDir {
350        fn new() -> Self {
351            // Process-wide counter, NOT a timestamp: concurrent test
352            // threads can construct TempDirs within one SystemTime tick,
353            // and create_dir_all on the colliding path succeeds silently —
354            // two tests then share (and Drop-delete) one directory.
355            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    /// Write a sub-command fdl.yml at `dir/sub/fdl.yml` with the given body.
377    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        // Convention-default Path: no fields set, `kind()` returns Path.
386        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        // `fdl sub quick --help` must render help for `quick` (handled
459        // one level deeper), not for `sub`. Descent wins over help at
460        // the current step.
461        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        // Bare `fdl <project>` on a project that defines `commands:` but
476        // no top-level `entry:` should print help, not error with
477        // "no entry point defined". Mirrors the top-level `fdl` UX.
478        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        // No entry, no sub-commands: keep the existing exec path so the
489        // downstream "no entry point defined" error fires for genuinely
490        // misconfigured projects.
491        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        // Explicit `path:` overrides the convention default. Drop the
514        // child fdl.yml under `actual/` and point `path:` there.
515        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        // `name` here is the command's label, not where we load from —
523        // `actual/` is the real dir courtesy of `path:`.
524        let out = classify_path_step(&spec, "label", tmp.path(), &tail, None);
525        assert!(matches!(out, PathOutcome::Exec { .. }));
526    }
527
528    // ── walk_commands: outer walker ──────────────────────────────────────
529    //
530    // These drive the full walk from top-level down, asserting on the
531    // terminal WalkOutcome variant. No processes are spawned — the walker
532    // is pure, so tests stay fast and hermetic.
533
534    /// Build a top-level `commands:` map by parsing a short YAML snippet.
535    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                // No cluster: directive anywhere → chain has one entry: None.
570                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        // Stray `-p flodl-hf` BEFORE `--` is rejected even though `--`
661        // appears later. The rule is structural: the tail must be empty
662        // up to `--`.
663        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        // A top-level command with preset-shaped fields (`options:`) but
691        // neither `run:` nor `path:` has no enclosing CommandConfig to
692        // borrow an `entry:` from — must error loudly.
693        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        // Top-level `ddp-bench` path-kind → no further descent → Exec.
719        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        // fdl.yml: commands: { ddp-bench: {} }  → path kind, convention
742        // ddp-bench/fdl.yml: commands: { quick: { options: { model: linear } } }
743        // Invocation: `fdl ddp-bench quick --epochs 5`
744        // Expected: descend into ddp-bench, resolve `quick` as preset,
745        // emit ExecCommand with preset=Some("quick"), tail=["--epochs","5"].
746        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        // Three-level walk: `fdl a b quick`.
774        // tmp/fdl.yml             → commands: { a: {} }
775        // tmp/a/fdl.yml           → commands: { b: {} }   + entry (required for preset parent)
776        // tmp/a/b/fdl.yml         → commands: { quick: { options: { x: 1 } } } + entry
777        let tmp = TempDir::new();
778        mkcmd(tmp.path(), "a", "entry: echo a\ncommands:\n  b: {}\n");
779        // b is a sibling directory under a/
780        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        // Convention-default Path for `ghost`, but tmp/ghost/fdl.yml doesn't exist.
804        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        // `fdl ddp-bench quick --help` — help applies to the preset, not
829        // the enclosing command (descent wins at the classify level, then
830        // Preset-kind with `--help` in the tail emits PrintPresetHelp).
831        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        // Base child says entry=echo-base; env overlay fdl.ci.yml
871        // overrides entry=echo-ci. After descent with env=Some("ci"),
872        // the ExecCommand carries the overlaid config.
873        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        // Same fixtures as above, but env=None — base must win.
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(), 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    // ── cluster_chain accumulation along the walk ───────────────────
903
904    #[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        // Root marks ddp-bench: cluster: true. Sub-fdl.yml's leaf command
920        // leaves it unset → chain = [Some(true), None]. Per the resolver,
921        // this would inherit cluster: true at dispatch.
922        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        // Root: ddp-bench (Path, cluster: true). ddp-bench/fdl.yml: quick
937        // preset with cluster: false → chain = [Some(true), Some(false)].
938        // Per the resolver, leaf override wins → effective false (stays
939        // local despite ancestor saying cluster).
940        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        // Plain `fdl ddp-bench` with no cluster directives anywhere →
966        // chain has one None entry per walked level. The resolver returns
967        // false (no dispatch) for any all-None chain.
968        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}