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    },
124    /// Path-or-Preset terminal → caller invokes the child's entry. For
125    /// a Preset, `preset` is the preset name inside the enclosing
126    /// `commands:` block; for a Path-Exec it is `None`.
127    ExecCommand {
128        config: Box<CommandConfig>,
129        preset: Option<String>,
130        tail: Vec<String>,
131        cmd_dir: PathBuf,
132    },
133    /// Path terminal with `--refresh-schema` in the tail.
134    RefreshSchema {
135        config: Box<CommandConfig>,
136        cmd_dir: PathBuf,
137        cmd_name: String,
138    },
139    /// Path terminal with `--help` / `-h` in the tail.
140    PrintCommandHelp {
141        config: Box<CommandConfig>,
142        name: String,
143    },
144    /// Preset terminal with `--help` / `-h` in the tail.
145    PrintPresetHelp {
146        config: Box<CommandConfig>,
147        parent_label: String,
148        preset_name: String,
149    },
150    /// Run terminal with `--help` / `-h` in the tail.
151    PrintRunHelp {
152        name: String,
153        description: Option<String>,
154        run: String,
155        append: Option<String>,
156        docker: Option<String>,
157    },
158    /// The top-level or descended-into name doesn't exist in the current
159    /// `commands:` map. Caller prints the project-help banner.
160    UnknownCommand { name: String },
161    /// A Preset-kind command at the top level has nothing to reuse an
162    /// `entry:` from. Caller prints a pointer to the fix.
163    PresetAtTopLevel { name: String },
164    /// Structural error: spec declares both `run:` and `path:`, or a
165    /// child fdl.yml failed to load / parse. String is the diagnostic.
166    Error(String),
167}
168
169/// Walk the command graph from a top-level name and produce a
170/// [`WalkOutcome`]. Every transition is pure: the walker never spawns a
171/// process, prints to stdout, or reads the process cwd. Inputs carry all
172/// the context needed.
173///
174/// - `cmd_name`: the top-level token the user typed (`fdl <cmd_name> ...`).
175/// - `tail`: positional args following `cmd_name` (typically `&args[2..]`).
176/// - `top_commands`: the root `commands:` block (usually
177///   `&project.commands`).
178/// - `project_root`: the directory containing the base `fdl.yml`; acts
179///   as the initial `current_dir` for Path resolution.
180/// - `env`: active overlay name, threaded to each `load_command_with_env`
181///   call so descended configs pick up env-layered fields.
182pub fn walk_commands(
183    cmd_name: &str,
184    tail: &[String],
185    top_commands: &BTreeMap<String, CommandSpec>,
186    project_root: &Path,
187    env: Option<&str>,
188) -> WalkOutcome {
189    let mut commands: BTreeMap<String, CommandSpec> = top_commands.clone();
190    let mut enclosing: Option<CommandConfig> = None;
191    let mut current_dir: PathBuf = project_root.to_path_buf();
192    let mut name: String = cmd_name.to_string();
193    // `qualified` tracks the space-separated path the user typed
194    // (`flodl-hf export`) so help renderers can show the correct
195    // invocation. `name` is always the leaf used for command-map lookup.
196    let mut qualified: String = cmd_name.to_string();
197    let mut current_tail: Vec<String> = tail.to_vec();
198
199    loop {
200        let spec = match commands.get(&name) {
201            Some(s) => s.clone(),
202            None => return WalkOutcome::UnknownCommand { name },
203        };
204
205        let kind = match spec.kind() {
206            Ok(k) => k,
207            Err(e) => return WalkOutcome::Error(format!("command `{name}`: {e}")),
208        };
209
210        match kind {
211            CommandKind::Run => {
212                let command = spec
213                    .run
214                    .expect("Run kind guarantees `run` is set");
215                if current_tail.iter().any(|a| a == "--help" || a == "-h") {
216                    return WalkOutcome::PrintRunHelp {
217                        name: qualified,
218                        description: spec.description,
219                        run: command,
220                        append: spec.append,
221                        docker: spec.docker,
222                    };
223                }
224                // Split tail on the first `--`: anything before is
225                // unexpected (no fdl-side flags exist for run-kind), and
226                // anything after is forwarded to the script. Loud
227                // rejection of stray args mirrors the loud-errors-over-
228                // silent rule.
229                let (before, after) = match current_tail.iter().position(|a| a == "--") {
230                    Some(idx) => {
231                        let after = current_tail[idx + 1..].to_vec();
232                        let before = current_tail[..idx].to_vec();
233                        (before, after)
234                    }
235                    None => (current_tail.clone(), Vec::new()),
236                };
237                if !before.is_empty() {
238                    return WalkOutcome::Error(format!(
239                        "command `{name}` does not accept extra args; \
240                         use `fdl {name} -- {}` to forward them to the script",
241                        before.join(" ")
242                    ));
243                }
244                return WalkOutcome::RunScript {
245                    command,
246                    append: spec.append,
247                    user_args: after,
248                    docker: spec.docker,
249                    cwd: current_dir,
250                };
251            }
252            CommandKind::Path => {
253                match classify_path_step(&spec, &name, &current_dir, &current_tail, env) {
254                    PathOutcome::LoadFailed(msg) => return WalkOutcome::Error(msg),
255                    PathOutcome::Descend {
256                        child,
257                        new_dir,
258                        new_name,
259                    } => {
260                        commands = child.commands.clone();
261                        enclosing = Some(*child);
262                        current_dir = new_dir;
263                        qualified.push(' ');
264                        qualified.push_str(&new_name);
265                        name = new_name;
266                        // classify_path_step returned Descend because
267                        // current_tail[0] named a nested command; consume
268                        // that token before the next iteration.
269                        if !current_tail.is_empty() {
270                            current_tail.remove(0);
271                        }
272                    }
273                    PathOutcome::ShowHelp { child } => {
274                        return WalkOutcome::PrintCommandHelp {
275                            config: child,
276                            name: qualified,
277                        };
278                    }
279                    PathOutcome::RefreshSchema { child, child_dir } => {
280                        return WalkOutcome::RefreshSchema {
281                            config: child,
282                            cmd_dir: child_dir,
283                            cmd_name: qualified,
284                        };
285                    }
286                    PathOutcome::Exec { child, child_dir } => {
287                        return WalkOutcome::ExecCommand {
288                            config: child,
289                            preset: None,
290                            tail: current_tail,
291                            cmd_dir: child_dir,
292                        };
293                    }
294                }
295            }
296            CommandKind::Preset => {
297                let Some(encl) = enclosing.take() else {
298                    return WalkOutcome::PresetAtTopLevel { name };
299                };
300
301                if current_tail.iter().any(|a| a == "--help" || a == "-h") {
302                    let parent_label = current_dir
303                        .file_name()
304                        .and_then(|n| n.to_str())
305                        .unwrap_or("")
306                        .to_string();
307                    return WalkOutcome::PrintPresetHelp {
308                        config: Box::new(encl),
309                        parent_label,
310                        preset_name: name,
311                    };
312                }
313
314                return WalkOutcome::ExecCommand {
315                    config: Box::new(encl),
316                    preset: Some(name),
317                    tail: current_tail,
318                    cmd_dir: current_dir,
319                };
320            }
321        }
322    }
323}
324
325// ── Tests ───────────────────────────────────────────────────────────────
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    /// Minimal tempdir helper — avoids pulling in the `tempfile` crate.
332    struct TempDir(PathBuf);
333
334    impl TempDir {
335        fn new() -> Self {
336            let base = std::env::temp_dir();
337            let unique = format!(
338                "flodl-dispatch-{}-{}",
339                std::process::id(),
340                std::time::SystemTime::now()
341                    .duration_since(std::time::UNIX_EPOCH)
342                    .map(|d| d.as_nanos())
343                    .unwrap_or(0)
344            );
345            let dir = base.join(unique);
346            std::fs::create_dir_all(&dir).expect("tempdir creation");
347            Self(dir)
348        }
349        fn path(&self) -> &Path {
350            &self.0
351        }
352    }
353
354    impl Drop for TempDir {
355        fn drop(&mut self) {
356            let _ = std::fs::remove_dir_all(&self.0);
357        }
358    }
359
360    /// Write a sub-command fdl.yml at `dir/sub/fdl.yml` with the given body.
361    fn mkcmd(base: &Path, sub: &str, body: &str) -> PathBuf {
362        let dir = base.join(sub);
363        std::fs::create_dir_all(&dir).expect("mkcmd dir");
364        std::fs::write(dir.join("fdl.yml"), body).expect("mkcmd write");
365        dir
366    }
367
368    fn path_spec() -> CommandSpec {
369        // Convention-default Path: no fields set, `kind()` returns Path.
370        CommandSpec::default()
371    }
372
373    #[test]
374    fn classify_descends_when_tail_names_nested_command() {
375        let tmp = TempDir::new();
376        mkcmd(
377            tmp.path(),
378            "ddp-bench",
379            "entry: echo\ncommands:\n  quick:\n    options: { model: linear }\n",
380        );
381        let spec = path_spec();
382        let tail = vec!["quick".to_string()];
383        let out = classify_path_step(&spec, "ddp-bench", tmp.path(), &tail, None);
384        match out {
385            PathOutcome::Descend { new_name, .. } => assert_eq!(new_name, "quick"),
386            _ => panic!("expected Descend, got something else"),
387        }
388    }
389
390    #[test]
391    fn classify_show_help_when_tail_has_flag() {
392        let tmp = TempDir::new();
393        mkcmd(tmp.path(), "sub", "entry: echo\n");
394        let spec = path_spec();
395        let tail = vec!["--help".to_string()];
396        let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
397        assert!(matches!(out, PathOutcome::ShowHelp { .. }));
398    }
399
400    #[test]
401    fn classify_show_help_short_flag() {
402        let tmp = TempDir::new();
403        mkcmd(tmp.path(), "sub", "entry: echo\n");
404        let spec = path_spec();
405        let tail = vec!["-h".to_string()];
406        let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
407        assert!(matches!(out, PathOutcome::ShowHelp { .. }));
408    }
409
410    #[test]
411    fn classify_refresh_schema() {
412        let tmp = TempDir::new();
413        mkcmd(tmp.path(), "sub", "entry: echo\n");
414        let spec = path_spec();
415        let tail = vec!["--refresh-schema".to_string()];
416        let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
417        assert!(matches!(out, PathOutcome::RefreshSchema { .. }));
418    }
419
420    #[test]
421    fn classify_exec_when_tail_has_no_known_token() {
422        let tmp = TempDir::new();
423        mkcmd(tmp.path(), "sub", "entry: echo\n");
424        let spec = path_spec();
425        let tail = vec!["--model".to_string(), "linear".to_string()];
426        let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
427        assert!(matches!(out, PathOutcome::Exec { .. }));
428    }
429
430    #[test]
431    fn classify_exec_when_tail_is_empty() {
432        let tmp = TempDir::new();
433        mkcmd(tmp.path(), "sub", "entry: echo\n");
434        let spec = path_spec();
435        let tail: Vec<String> = vec![];
436        let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
437        assert!(matches!(out, PathOutcome::Exec { .. }));
438    }
439
440    #[test]
441    fn classify_descend_wins_over_help_at_same_level() {
442        // `fdl sub quick --help` must render help for `quick` (handled
443        // one level deeper), not for `sub`. Descent wins over help at
444        // the current step.
445        let tmp = TempDir::new();
446        mkcmd(
447            tmp.path(),
448            "sub",
449            "entry: echo\ncommands:\n  quick:\n    options: { x: 1 }\n",
450        );
451        let spec = path_spec();
452        let tail = vec!["quick".to_string(), "--help".to_string()];
453        let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
454        assert!(matches!(out, PathOutcome::Descend { .. }));
455    }
456
457    #[test]
458    fn classify_bare_no_entry_with_subcommands_shows_help() {
459        // Bare `fdl <project>` on a project that defines `commands:` but
460        // no top-level `entry:` should print help, not error with
461        // "no entry point defined". Mirrors the top-level `fdl` UX.
462        let tmp = TempDir::new();
463        mkcmd(
464            tmp.path(),
465            "sub",
466            "commands:\n  foo:\n    run: echo foo\n",
467        );
468        let spec = path_spec();
469        let tail: Vec<String> = vec![];
470        let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
471        assert!(matches!(out, PathOutcome::ShowHelp { .. }));
472    }
473
474    #[test]
475    fn classify_no_entry_no_subcommands_still_falls_through() {
476        // No entry, no sub-commands: keep the existing exec path so the
477        // downstream "no entry point defined" error fires for genuinely
478        // misconfigured projects.
479        let tmp = TempDir::new();
480        mkcmd(tmp.path(), "sub", "description: empty\n");
481        let spec = path_spec();
482        let tail: Vec<String> = vec![];
483        let out = classify_path_step(&spec, "sub", tmp.path(), &tail, None);
484        assert!(matches!(out, PathOutcome::Exec { .. }));
485    }
486
487    #[test]
488    fn classify_load_failed_when_no_child_fdl_yml() {
489        let tmp = TempDir::new();
490        let spec = path_spec();
491        let tail: Vec<String> = vec![];
492        let out = classify_path_step(&spec, "missing", tmp.path(), &tail, None);
493        match out {
494            PathOutcome::LoadFailed(msg) => assert!(msg.contains("no fdl.yml")),
495            _ => panic!("expected LoadFailed, got something else"),
496        }
497    }
498
499    #[test]
500    fn classify_uses_explicit_path() {
501        // Explicit `path:` overrides the convention default. Drop the
502        // child fdl.yml under `actual/` and point `path:` there.
503        let tmp = TempDir::new();
504        mkcmd(tmp.path(), "actual", "entry: echo\n");
505        let spec = CommandSpec {
506            path: Some("actual".into()),
507            ..Default::default()
508        };
509        let tail: Vec<String> = vec![];
510        // `name` here is the command's label, not where we load from —
511        // `actual/` is the real dir courtesy of `path:`.
512        let out = classify_path_step(&spec, "label", tmp.path(), &tail, None);
513        assert!(matches!(out, PathOutcome::Exec { .. }));
514    }
515
516    // ── walk_commands: outer walker ──────────────────────────────────────
517    //
518    // These drive the full walk from top-level down, asserting on the
519    // terminal WalkOutcome variant. No processes are spawned — the walker
520    // is pure, so tests stay fast and hermetic.
521
522    /// Build a top-level `commands:` map by parsing a short YAML snippet.
523    fn top_commands(yaml: &str) -> BTreeMap<String, CommandSpec> {
524        #[derive(serde::Deserialize)]
525        struct Root {
526            #[serde(default)]
527            commands: BTreeMap<String, CommandSpec>,
528        }
529        serde_yaml::from_str::<Root>(yaml)
530            .expect("parse top-level commands")
531            .commands
532    }
533
534    fn args(xs: &[&str]) -> Vec<String> {
535        xs.iter().map(|s| s.to_string()).collect()
536    }
537
538    #[test]
539    fn walk_top_level_run_returns_run_script() {
540        let tmp = TempDir::new();
541        let commands = top_commands("commands:\n  greet:\n    run: echo hello\n");
542        let out = walk_commands("greet", &[], &commands, tmp.path(), None);
543        match out {
544            WalkOutcome::RunScript {
545                command,
546                append,
547                user_args,
548                docker,
549                cwd,
550            } => {
551                assert_eq!(command, "echo hello");
552                assert!(append.is_none());
553                assert!(user_args.is_empty());
554                assert!(docker.is_none());
555                assert_eq!(cwd, tmp.path());
556            }
557            _ => panic!("expected RunScript"),
558        }
559    }
560
561    #[test]
562    fn walk_top_level_run_with_docker_preserves_service() {
563        let tmp = TempDir::new();
564        let commands = top_commands(
565            "commands:\n  dev:\n    run: cargo test\n    docker: dev\n",
566        );
567        let out = walk_commands("dev", &[], &commands, tmp.path(), None);
568        match out {
569            WalkOutcome::RunScript { docker, .. } => {
570                assert_eq!(docker.as_deref(), Some("dev"));
571            }
572            _ => panic!("expected RunScript with docker"),
573        }
574    }
575
576    #[test]
577    fn walk_run_with_help_prints_help_not_script() {
578        let tmp = TempDir::new();
579        let commands = top_commands(
580            "commands:\n  test:\n    description: Run all CPU tests\n    run: cargo test\n    docker: dev\n",
581        );
582        let tail = args(&["--help"]);
583        let out = walk_commands("test", &tail, &commands, tmp.path(), None);
584        match out {
585            WalkOutcome::PrintRunHelp {
586                name,
587                description,
588                run,
589                append,
590                docker,
591            } => {
592                assert_eq!(name, "test");
593                assert_eq!(description.as_deref(), Some("Run all CPU tests"));
594                assert_eq!(run, "cargo test");
595                assert!(append.is_none());
596                assert_eq!(docker.as_deref(), Some("dev"));
597            }
598            _ => panic!("expected PrintRunHelp"),
599        }
600    }
601
602    #[test]
603    fn walk_run_forwards_args_after_double_dash() {
604        let tmp = TempDir::new();
605        let commands = top_commands(
606            "commands:\n  test:\n    run: cargo test live\n    append: -- --nocapture --ignored\n",
607        );
608        let tail = args(&["--", "-p", "flodl-hf"]);
609        let out = walk_commands("test", &tail, &commands, tmp.path(), None);
610        match out {
611            WalkOutcome::RunScript {
612                command,
613                append,
614                user_args,
615                ..
616            } => {
617                assert_eq!(command, "cargo test live");
618                assert_eq!(append.as_deref(), Some("-- --nocapture --ignored"));
619                assert_eq!(user_args, vec!["-p".to_string(), "flodl-hf".to_string()]);
620            }
621            _ => panic!("expected RunScript"),
622        }
623    }
624
625    #[test]
626    fn walk_run_rejects_stray_args_before_double_dash() {
627        let tmp = TempDir::new();
628        let commands = top_commands("commands:\n  test:\n    run: cargo test\n");
629        let tail = args(&["-p", "flodl-hf"]);
630        let out = walk_commands("test", &tail, &commands, tmp.path(), None);
631        match out {
632            WalkOutcome::Error(msg) => {
633                assert!(
634                    msg.contains("does not accept extra args")
635                        && msg.contains("fdl test -- -p flodl-hf"),
636                    "got: {msg}"
637                );
638            }
639            _ => panic!("expected Error"),
640        }
641    }
642
643    #[test]
644    fn walk_run_rejects_stray_args_even_with_double_dash_after() {
645        let tmp = TempDir::new();
646        let commands = top_commands("commands:\n  test:\n    run: cargo test\n");
647        // Stray `-p flodl-hf` BEFORE `--` is rejected even though `--`
648        // appears later. The rule is structural: the tail must be empty
649        // up to `--`.
650        let tail = args(&["-p", "flodl-hf", "--", "extra"]);
651        let out = walk_commands("test", &tail, &commands, tmp.path(), None);
652        assert!(matches!(out, WalkOutcome::Error(_)));
653    }
654
655    #[test]
656    fn walk_run_with_short_help_prints_help() {
657        let tmp = TempDir::new();
658        let commands = top_commands("commands:\n  test:\n    run: cargo test\n");
659        let tail = args(&["-h"]);
660        let out = walk_commands("test", &tail, &commands, tmp.path(), None);
661        assert!(matches!(out, WalkOutcome::PrintRunHelp { .. }));
662    }
663
664    #[test]
665    fn walk_unknown_top_level_returns_unknown() {
666        let tmp = TempDir::new();
667        let commands = top_commands("commands:\n  greet:\n    run: echo hello\n");
668        let out = walk_commands("nope", &args(&["arg"]), &commands, tmp.path(), None);
669        match out {
670            WalkOutcome::UnknownCommand { name } => assert_eq!(name, "nope"),
671            _ => panic!("expected UnknownCommand"),
672        }
673    }
674
675    #[test]
676    fn walk_top_level_preset_errors_without_enclosing() {
677        // A top-level command with preset-shaped fields (`options:`) but
678        // neither `run:` nor `path:` has no enclosing CommandConfig to
679        // borrow an `entry:` from — must error loudly.
680        let tmp = TempDir::new();
681        let commands = top_commands(
682            "commands:\n  orphan:\n    options: { model: linear }\n",
683        );
684        let out = walk_commands("orphan", &[], &commands, tmp.path(), None);
685        match out {
686            WalkOutcome::PresetAtTopLevel { name } => assert_eq!(name, "orphan"),
687            _ => panic!("expected PresetAtTopLevel"),
688        }
689    }
690
691    #[test]
692    fn walk_run_and_path_both_set_is_error() {
693        let tmp = TempDir::new();
694        let commands = top_commands(
695            "commands:\n  bad:\n    run: echo hi\n    path: ./sub\n",
696        );
697        let out = walk_commands("bad", &[], &commands, tmp.path(), None);
698        match out {
699            WalkOutcome::Error(msg) => {
700                assert!(msg.contains("bad"), "got: {msg}");
701                assert!(msg.contains("both `run:` and `path:`"), "got: {msg}");
702            }
703            _ => panic!("expected Error"),
704        }
705    }
706
707    #[test]
708    fn walk_path_exec_at_one_level() {
709        // Top-level `ddp-bench` path-kind → no further descent → Exec.
710        let tmp = TempDir::new();
711        mkcmd(tmp.path(), "ddp-bench", "entry: cargo run -p ddp-bench\n");
712        let commands = top_commands("commands:\n  ddp-bench: {}\n");
713        let tail = args(&["--seed", "42"]);
714        let out = walk_commands("ddp-bench", &tail, &commands, tmp.path(), None);
715        match out {
716            WalkOutcome::ExecCommand {
717                preset,
718                tail: returned_tail,
719                cmd_dir,
720                ..
721            } => {
722                assert!(preset.is_none());
723                assert_eq!(returned_tail, args(&["--seed", "42"]));
724                assert_eq!(cmd_dir, tmp.path().join("ddp-bench"));
725            }
726            _ => panic!("expected ExecCommand"),
727        }
728    }
729
730    #[test]
731    fn walk_path_then_preset_at_two_levels() {
732        // fdl.yml: commands: { ddp-bench: {} }  → path kind, convention
733        // ddp-bench/fdl.yml: commands: { quick: { options: { model: linear } } }
734        // Invocation: `fdl ddp-bench quick --epochs 5`
735        // Expected: descend into ddp-bench, resolve `quick` as preset,
736        // emit ExecCommand with preset=Some("quick"), tail=["--epochs","5"].
737        let tmp = TempDir::new();
738        mkcmd(
739            tmp.path(),
740            "ddp-bench",
741            "entry: cargo run -p ddp-bench\n\
742             commands:\n  quick:\n    options: { model: linear }\n",
743        );
744        let commands = top_commands("commands:\n  ddp-bench: {}\n");
745        let tail = args(&["quick", "--epochs", "5"]);
746        let out = walk_commands("ddp-bench", &tail, &commands, tmp.path(), None);
747        match out {
748            WalkOutcome::ExecCommand {
749                preset,
750                tail: returned_tail,
751                cmd_dir,
752                ..
753            } => {
754                assert_eq!(preset.as_deref(), Some("quick"));
755                assert_eq!(returned_tail, args(&["--epochs", "5"]));
756                assert_eq!(cmd_dir, tmp.path().join("ddp-bench"));
757            }
758            _ => panic!("expected ExecCommand with preset"),
759        }
760    }
761
762    #[test]
763    fn walk_path_then_path_then_preset_at_three_levels() {
764        // Three-level walk: `fdl a b quick`.
765        // tmp/fdl.yml             → commands: { a: {} }
766        // tmp/a/fdl.yml           → commands: { b: {} }   + entry (required for preset parent)
767        // tmp/a/b/fdl.yml         → commands: { quick: { options: { x: 1 } } } + entry
768        let tmp = TempDir::new();
769        mkcmd(
770            tmp.path(),
771            "a",
772            "entry: echo a\ncommands:\n  b: {}\n",
773        );
774        // b is a sibling directory under a/
775        let b_dir = tmp.path().join("a").join("b");
776        std::fs::create_dir_all(&b_dir).unwrap();
777        std::fs::write(
778            b_dir.join("fdl.yml"),
779            "entry: echo b\ncommands:\n  quick:\n    options: { x: 1 }\n",
780        )
781        .unwrap();
782        let commands = top_commands("commands:\n  a: {}\n");
783        let tail = args(&["b", "quick"]);
784        let out = walk_commands("a", &tail, &commands, tmp.path(), None);
785        match out {
786            WalkOutcome::ExecCommand {
787                preset, cmd_dir, ..
788            } => {
789                assert_eq!(preset.as_deref(), Some("quick"));
790                assert_eq!(cmd_dir, b_dir);
791            }
792            _ => panic!("expected ExecCommand with preset at depth 3"),
793        }
794    }
795
796    #[test]
797    fn walk_path_child_missing_returns_error() {
798        // Convention-default Path for `ghost`, but tmp/ghost/fdl.yml doesn't exist.
799        let tmp = TempDir::new();
800        let commands = top_commands("commands:\n  ghost: {}\n");
801        let out = walk_commands("ghost", &[], &commands, tmp.path(), None);
802        match out {
803            WalkOutcome::Error(msg) => assert!(msg.contains("no fdl.yml"), "got: {msg}"),
804            _ => panic!("expected Error(LoadFailed)"),
805        }
806    }
807
808    #[test]
809    fn walk_path_help_prints_command_help() {
810        let tmp = TempDir::new();
811        mkcmd(tmp.path(), "ddp-bench", "entry: echo\n");
812        let commands = top_commands("commands:\n  ddp-bench: {}\n");
813        let tail = args(&["--help"]);
814        let out = walk_commands("ddp-bench", &tail, &commands, tmp.path(), None);
815        match out {
816            WalkOutcome::PrintCommandHelp { name, .. } => assert_eq!(name, "ddp-bench"),
817            _ => panic!("expected PrintCommandHelp"),
818        }
819    }
820
821    #[test]
822    fn walk_preset_help_prints_preset_help() {
823        // `fdl ddp-bench quick --help` — help applies to the preset, not
824        // the enclosing command (descent wins at the classify level, then
825        // Preset-kind with `--help` in the tail emits PrintPresetHelp).
826        let tmp = TempDir::new();
827        mkcmd(
828            tmp.path(),
829            "ddp-bench",
830            "entry: echo\ncommands:\n  quick:\n    options: { x: 1 }\n",
831        );
832        let commands = top_commands("commands:\n  ddp-bench: {}\n");
833        let tail = args(&["quick", "--help"]);
834        let out = walk_commands("ddp-bench", &tail, &commands, tmp.path(), None);
835        match out {
836            WalkOutcome::PrintPresetHelp {
837                parent_label,
838                preset_name,
839                ..
840            } => {
841                assert_eq!(preset_name, "quick");
842                assert_eq!(parent_label, "ddp-bench");
843            }
844            _ => panic!("expected PrintPresetHelp"),
845        }
846    }
847
848    #[test]
849    fn walk_path_refresh_schema() {
850        let tmp = TempDir::new();
851        mkcmd(tmp.path(), "ddp-bench", "entry: echo\n");
852        let commands = top_commands("commands:\n  ddp-bench: {}\n");
853        let tail = args(&["--refresh-schema"]);
854        let out = walk_commands("ddp-bench", &tail, &commands, tmp.path(), None);
855        match out {
856            WalkOutcome::RefreshSchema { cmd_name, .. } => {
857                assert_eq!(cmd_name, "ddp-bench");
858            }
859            _ => panic!("expected RefreshSchema"),
860        }
861    }
862
863    #[test]
864    fn walk_env_propagates_to_child_overlay() {
865        // Base child says entry=echo-base; env overlay fdl.ci.yml
866        // overrides entry=echo-ci. After descent with env=Some("ci"),
867        // the ExecCommand carries the overlaid config.
868        let tmp = TempDir::new();
869        let child = mkcmd(tmp.path(), "ddp-bench", "entry: echo-base\n");
870        std::fs::write(child.join("fdl.ci.yml"), "entry: echo-ci\n").unwrap();
871        let commands = top_commands("commands:\n  ddp-bench: {}\n");
872        let out = walk_commands("ddp-bench", &[], &commands, tmp.path(), Some("ci"));
873        match out {
874            WalkOutcome::ExecCommand { config, .. } => {
875                assert_eq!(config.entry.as_deref(), Some("echo-ci"));
876            }
877            _ => panic!("expected ExecCommand with env-overlaid entry"),
878        }
879    }
880
881    #[test]
882    fn walk_env_none_ignores_overlay() {
883        // Same fixtures as above, but env=None — base must win.
884        let tmp = TempDir::new();
885        let child = mkcmd(tmp.path(), "ddp-bench", "entry: echo-base\n");
886        std::fs::write(child.join("fdl.ci.yml"), "entry: echo-ci\n").unwrap();
887        let commands = top_commands("commands:\n  ddp-bench: {}\n");
888        let out = walk_commands("ddp-bench", &[], &commands, tmp.path(), None);
889        match out {
890            WalkOutcome::ExecCommand { config, .. } => {
891                assert_eq!(config.entry.as_deref(), Some("echo-base"));
892            }
893            _ => panic!("expected ExecCommand with base entry"),
894        }
895    }
896}