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