Skip to main content

flodl_cli/
config.rs

1//! fdl.yaml configuration loading and discovery.
2//!
3//! Walks up from CWD to find the project manifest, parses YAML/JSON,
4//! and loads sub-command configs from registered command directories.
5
6use std::collections::BTreeMap;
7use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10
11// ── Root project config ─────────────────────────────────────────────────
12
13/// Root fdl.yaml at project root.
14#[derive(Debug, Default, Deserialize)]
15pub struct ProjectConfig {
16    #[serde(default)]
17    pub description: Option<String>,
18    /// Commands defined at this level. Each value is a [`CommandSpec`] that
19    /// encodes the kind of command (inline `run` script, `path` pointer to
20    /// a child fdl.yml, or inline preset reusing the parent entry).
21    #[serde(default)]
22    pub commands: BTreeMap<String, CommandSpec>,
23}
24
25// ── Sub-command config ──────────────────────────────────────────────────
26
27/// Sub-command fdl.yaml (e.g., ddp-bench/fdl.yaml).
28///
29/// Identical shape to [`ProjectConfig`] but with an executable `entry:`
30/// and optional structured config sections (ddp/training/output) that
31/// inline preset commands can override.
32#[derive(Debug, Default, Deserialize)]
33pub struct CommandConfig {
34    #[serde(default)]
35    pub description: Option<String>,
36    #[serde(default)]
37    pub entry: Option<String>,
38    /// Docker compose service name. When set, entry is wrapped in
39    /// `docker compose run --rm <service> bash -c "cd <workdir> && <entry> <args>"`.
40    #[serde(default)]
41    pub docker: Option<String>,
42    #[serde(default)]
43    pub ddp: Option<DdpConfig>,
44    #[serde(default)]
45    pub training: Option<TrainingConfig>,
46    #[serde(default)]
47    pub output: Option<OutputConfig>,
48    /// Nested commands — inline presets of this config's entry, standalone
49    /// `run` scripts, or `path` pointers to child fdl.yml files.
50    #[serde(default)]
51    pub commands: BTreeMap<String, CommandSpec>,
52    /// Help-only placeholder name for the first-positional slot when
53    /// `commands:` holds presets. Defaults to "preset". Pure UX — it
54    /// does not affect dispatch (presets are always looked up by name).
55    /// Useful to match domain vocabulary, e.g. `arg-name: recipe` or
56    /// `arg-name: target`.
57    #[serde(default, rename = "arg-name")]
58    pub arg_name: Option<String>,
59    /// Inline interim schema (before `<entry> --fdl-schema` is implemented).
60    /// Drives help rendering, validation, and completions.
61    #[serde(default)]
62    pub schema: Option<Schema>,
63    /// Opt-in flag for cargo-entry schema probing. Cargo entries are
64    /// auto-skipped from probing because `cargo run --fdl-schema` triggers
65    /// a full compile (unacceptable latency for `-h`). Setting `compile:
66    /// true` declares "I'm fine with the first-run compile cost — probe
67    /// my binary for its real schema." Subsequent invocations use the
68    /// mtime-keyed cache and pay no compile cost. Absent or `false` keeps
69    /// the default skip behavior, so the inline yml schema (if any)
70    /// stays the source of truth.
71    #[serde(default)]
72    pub compile: Option<bool>,
73}
74
75// ── Unified command specification ───────────────────────────────────────
76
77/// A command at any nesting level. Three mutually-exclusive kinds are
78/// recognised at resolve time:
79///
80/// - **Path** (`path` set, or by default when the map is empty/null): the
81///   command is a pointer to a child `fdl.yml`. By convention the path is
82///   `./<command-name>/` when omitted.
83/// - **Run** (`run` set): the command is a self-contained shell script
84///   that is executed as-is. Optional `docker:` service routes it through
85///   `docker compose`.
86/// - **Preset**: neither `path` nor `run` is set. The command merges its
87///   `ddp` / `training` / `output` / `options` fields over the enclosing
88///   `CommandConfig` defaults and invokes that config's `entry:`.
89#[derive(Debug, Default, Clone)]
90pub struct CommandSpec {
91    pub description: Option<String>,
92    /// Inline shell command. Mutex with `path`.
93    pub run: Option<String>,
94    /// Default trailing tokens for a `run:` command. Split on its own
95    /// first `--` into pre/post halves; user args after fdl's first
96    /// `--` are split similarly, then everything merges as
97    /// `[append-pre] [user-pre] -- [append-post] [user-post]`. Append
98    /// seeds defaults; user args last-win on each side. The legacy
99    /// `append: -- --nocapture` shape (empty pre, libtest tokens post)
100    /// keeps working as a degenerate case. Drop entirely with the
101    /// global `--no-append` flag.
102    pub append: Option<String>,
103    /// Pointer to a child directory containing its own `fdl.yml`. Absolute
104    /// or relative to the declaring config's directory. Mutex with `run`.
105    /// `None` + no other fields = "use the convention path
106    /// `./<command-name>/`".
107    pub path: Option<String>,
108    /// Docker compose service for `run`-kind commands.
109    pub docker: Option<String>,
110    /// Preset overrides. Only consulted when neither `run` nor `path` is set.
111    pub ddp: Option<DdpConfig>,
112    pub training: Option<TrainingConfig>,
113    pub output: Option<OutputConfig>,
114    pub options: BTreeMap<String, serde_json::Value>,
115}
116
117/// What kind of command is this, resolved from a [`CommandSpec`].
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum CommandKind {
120    /// `run: "…"` — execute the inline shell command (optionally in Docker).
121    Run,
122    /// `path: "…"` or convention default — load `<path>/fdl.yml` and
123    /// recurse.
124    Path,
125    /// Neither `run` nor `path`. Merges preset fields onto the enclosing
126    /// `CommandConfig` defaults and invokes that config's `entry:`.
127    Preset,
128}
129
130impl CommandSpec {
131    /// Classify this command. Returns an error when both `run` and `path`
132    /// are declared — always a mistake, caught loudly rather than silently
133    /// picking one. Also rejects `docker:` without `run:`: the docker
134    /// service wraps the inline run-script, so pairing it with a `path:`
135    /// pointer or a preset entry is always silent-noop territory.
136    pub fn kind(&self) -> Result<CommandKind, String> {
137        if self.docker.is_some() && self.run.is_none() {
138            return Err(
139                "command declares `docker:` without `run:`; \
140                 `docker:` only wraps inline run-scripts"
141                    .to_string(),
142            );
143        }
144        if self.append.is_some() && self.run.is_none() {
145            return Err(
146                "command declares `append:` without `run:`; \
147                 `append:` only forwards trailing tokens for inline run-scripts"
148                    .to_string(),
149            );
150        }
151        match (self.run.as_deref(), self.path.as_deref()) {
152            (Some(_), Some(_)) => Err(
153                "command declares both `run:` and `path:`; \
154                 only one is allowed"
155                    .to_string(),
156            ),
157            (Some(_), None) => Ok(CommandKind::Run),
158            (None, Some(_)) => Ok(CommandKind::Path),
159            (None, None) => {
160                // No kind-selecting field. If preset fields are present,
161                // treat as Preset; otherwise, fall through to Path (the
162                // convention-default: `./<name>/fdl.yml`).
163                if self.ddp.is_some()
164                    || self.training.is_some()
165                    || self.output.is_some()
166                    || !self.options.is_empty()
167                {
168                    Ok(CommandKind::Preset)
169                } else {
170                    Ok(CommandKind::Path)
171                }
172            }
173        }
174    }
175
176    /// Resolve the effective directory for a `Path`-kind command declared
177    /// in `parent_dir`. Applies the `./<name>/` convention when `path` is
178    /// unset.
179    pub fn resolve_path(&self, name: &str, parent_dir: &Path) -> PathBuf {
180        match &self.path {
181            Some(p) => parent_dir.join(p),
182            None => parent_dir.join(name),
183        }
184    }
185}
186
187// Custom Deserialize so that `commands: { name: ~ }` (YAML null) and
188// `commands: { name: }` (empty value) both deserialize to a default
189// `CommandSpec`. Without this, serde_yaml errors on null because a
190// struct expects a map.
191impl<'de> Deserialize<'de> for CommandSpec {
192    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
193    where
194        D: serde::Deserializer<'de>,
195    {
196        #[derive(Deserialize)]
197        struct Inner {
198            #[serde(default)]
199            description: Option<String>,
200            #[serde(default)]
201            run: Option<String>,
202            #[serde(default)]
203            append: Option<String>,
204            #[serde(default)]
205            path: Option<String>,
206            #[serde(default)]
207            docker: Option<String>,
208            #[serde(default)]
209            ddp: Option<DdpConfig>,
210            #[serde(default)]
211            training: Option<TrainingConfig>,
212            #[serde(default)]
213            output: Option<OutputConfig>,
214            #[serde(default)]
215            options: BTreeMap<String, serde_json::Value>,
216        }
217
218        let raw = serde_yaml::Value::deserialize(deserializer)?;
219        if matches!(raw, serde_yaml::Value::Null) {
220            return Ok(Self::default());
221        }
222        let inner: Inner =
223            serde_yaml::from_value(raw).map_err(serde::de::Error::custom)?;
224        Ok(Self {
225            description: inner.description,
226            run: inner.run,
227            append: inner.append,
228            path: inner.path,
229            docker: inner.docker,
230            ddp: inner.ddp,
231            training: inner.training,
232            output: inner.output,
233            options: inner.options,
234        })
235    }
236}
237
238// ── Schema (interim hand-written, future `<entry> --fdl-schema`) ────────
239
240/// The schema declared inline in a sub-command's fdl.yaml. Maps 1:1 to
241/// what `<entry> --fdl-schema` will later emit as JSON.
242#[derive(Debug, Clone, Default, Deserialize, Serialize)]
243pub struct Schema {
244    #[serde(default, skip_serializing_if = "Vec::is_empty")]
245    pub args: Vec<ArgSpec>,
246    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
247    pub options: BTreeMap<String, OptionSpec>,
248    /// When true, the fdl layer rejects options not declared in the
249    /// schema before the sub-command's entry ever runs. Two validation
250    /// points:
251    ///
252    /// 1. *Load time* — preset `options:` maps are checked against the
253    ///    enclosing `schema.options` (see [`validate_presets_strict`]).
254    ///    A typo like `options: { batchsize: 32 }` when the schema
255    ///    declares `batch-size` is a loud load error.
256    /// 2. *Dispatch time* — the user's extra argv tail is tokenized
257    ///    against the schema (see [`validate_tail`]). Unknown flags
258    ///    error out with a "did you mean" suggestion instead of being
259    ///    silently forwarded.
260    ///
261    /// **Validation NOT gated by `strict`** — always-on for declared
262    /// items, so positive assertions from the schema always hold:
263    /// - `choices:` on options: the user's value and any preset YAML
264    ///   value must be in the list.
265    /// - `choices:` on positional args: the user's value must be in
266    ///   the list (when strict is off, this may mis-fire if unknown
267    ///   flags push orphan values into positional slots — opt into
268    ///   strict for clean positional handling).
269    ///
270    /// `strict` is purely about **unknown** options/args, not about
271    /// validating declared contracts.
272    #[serde(default, skip_serializing_if = "is_false")]
273    pub strict: bool,
274}
275
276/// A flag option, `--name` / `-x`.
277#[derive(Debug, Clone, Deserialize, Serialize)]
278pub struct OptionSpec {
279    #[serde(rename = "type")]
280    pub ty: String,
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub description: Option<String>,
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub default: Option<serde_json::Value>,
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub choices: Option<Vec<serde_json::Value>>,
287    /// Single-letter short alias.
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub short: Option<String>,
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub env: Option<String>,
292    /// Shell snippet producing completion values.
293    /// Consumed by `fdl completions <shell>` (follow-up rollout task).
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    #[allow(dead_code)]
296    pub completer: Option<String>,
297}
298
299/// A positional argument.
300#[derive(Debug, Clone, Deserialize, Serialize)]
301pub struct ArgSpec {
302    pub name: String,
303    #[serde(rename = "type")]
304    pub ty: String,
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub description: Option<String>,
307    #[serde(default = "default_required")]
308    pub required: bool,
309    #[serde(default, skip_serializing_if = "is_false")]
310    pub variadic: bool,
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub default: Option<serde_json::Value>,
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub choices: Option<Vec<serde_json::Value>>,
315    /// Shell snippet producing completion values.
316    /// Consumed by `fdl completions <shell>` (follow-up rollout task).
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    #[allow(dead_code)]
319    pub completer: Option<String>,
320}
321
322fn is_false(b: &bool) -> bool {
323    !*b
324}
325
326fn default_required() -> bool {
327    true
328}
329
330/// Flags reserved at the fdl level — no sub-command option may shadow them.
331/// Kept in sync with main.rs dispatch.
332const RESERVED_LONGS: &[&str] = &[
333    "help", "version", "quiet", "env",
334];
335const RESERVED_SHORTS: &[&str] = &[
336    "h", "V", "q", "v", "e",
337];
338const VALID_TYPES: &[&str] = &[
339    "string", "int", "float", "bool", "path",
340    "list[string]", "list[int]", "list[float]", "list[path]",
341];
342
343/// Check a schema for collisions and structural issues.
344///
345/// Loud-at-load-time: ambiguity caught here is cheaper to fix than mysterious
346/// pass-through behavior at runtime.
347pub fn validate_schema(schema: &Schema) -> Result<(), String> {
348    // Options: check types, shorts, reserved flags.
349    let mut short_seen: BTreeMap<String, String> = BTreeMap::new();
350    for (long, spec) in &schema.options {
351        if !VALID_TYPES.contains(&spec.ty.as_str()) {
352            return Err(format!(
353                "option --{}: unknown type '{}' (valid: {})",
354                long,
355                spec.ty,
356                VALID_TYPES.join(", ")
357            ));
358        }
359        if RESERVED_LONGS.contains(&long.as_str()) {
360            return Err(format!(
361                "option --{long} shadows a reserved fdl-level flag"
362            ));
363        }
364        if let Some(s) = &spec.short {
365            if s.chars().count() != 1 {
366                return Err(format!(
367                    "option --{long}: `short: \"{s}\"` must be a single character"
368                ));
369            }
370            if RESERVED_SHORTS.contains(&s.as_str()) {
371                return Err(format!(
372                    "option --{long}: short -{s} shadows a reserved fdl-level flag"
373                ));
374            }
375            if let Some(prev) = short_seen.insert(s.clone(), long.clone()) {
376                return Err(format!(
377                    "options --{prev} and --{long} both declare short -{s}"
378                ));
379            }
380        }
381    }
382
383    // Args: check types, variadic-only-at-end, no-required-after-optional.
384    let mut seen_optional = false;
385    let mut name_seen: BTreeMap<String, ()> = BTreeMap::new();
386    for (i, arg) in schema.args.iter().enumerate() {
387        if !VALID_TYPES.contains(&arg.ty.as_str()) {
388            return Err(format!(
389                "arg <{}>: unknown type '{}' (valid: {})",
390                arg.name,
391                arg.ty,
392                VALID_TYPES.join(", ")
393            ));
394        }
395        if name_seen.insert(arg.name.clone(), ()).is_some() {
396            return Err(format!("duplicate positional name <{}>", arg.name));
397        }
398        if arg.variadic && i != schema.args.len() - 1 {
399            return Err(format!(
400                "arg <{}>: variadic positional must be the last one",
401                arg.name
402            ));
403        }
404        let is_optional = !arg.required || arg.default.is_some();
405        if arg.required && arg.default.is_some() {
406            return Err(format!(
407                "arg <{}>: `required: true` with a default is a contradiction",
408                arg.name
409            ));
410        }
411        if seen_optional && arg.required && arg.default.is_none() {
412            return Err(format!(
413                "arg <{}>: required positional cannot follow an optional one",
414                arg.name
415            ));
416        }
417        if is_optional {
418            seen_optional = true;
419        }
420    }
421
422    Ok(())
423}
424
425// ── Structured config sections ──────────────────────────────────────────
426
427/// DDP configuration. Maps 1:1 to flodl DdpConfig / DdpRunConfig.
428#[derive(Debug, Clone, Default, Deserialize)]
429pub struct DdpConfig {
430    pub mode: Option<String>,
431    pub policy: Option<String>,
432    pub backend: Option<String>,
433    /// "auto" or integer.
434    pub anchor: Option<serde_json::Value>,
435    pub max_anchor: Option<u32>,
436    pub overhead_target: Option<f64>,
437    pub divergence_threshold: Option<f64>,
438    /// null (unlimited) or integer.
439    pub max_batch_diff: Option<serde_json::Value>,
440    pub speed_hint: Option<SpeedHint>,
441    pub partition_ratios: Option<Vec<f64>>,
442    /// "auto" or bool.
443    pub progressive: Option<serde_json::Value>,
444    pub max_grad_norm: Option<f64>,
445    pub lr_scale_ratio: Option<f64>,
446    pub snapshot_timeout: Option<u32>,
447    pub checkpoint_every: Option<u32>,
448    pub timeline: Option<bool>,
449}
450
451#[derive(Debug, Clone, Default, Deserialize)]
452pub struct SpeedHint {
453    pub slow_rank: usize,
454    pub ratio: f64,
455}
456
457/// Training scalars.
458#[derive(Debug, Clone, Default, Deserialize)]
459pub struct TrainingConfig {
460    pub epochs: Option<u32>,
461    pub batch_size: Option<u32>,
462    pub batches_per_epoch: Option<u32>,
463    pub lr: Option<f64>,
464    pub seed: Option<u64>,
465}
466
467/// Output settings.
468#[derive(Debug, Clone, Default, Deserialize)]
469pub struct OutputConfig {
470    pub dir: Option<String>,
471    pub timeline: Option<bool>,
472    pub monitor: Option<u16>,
473}
474
475
476// ── Config discovery ────────────────────────────────────────────────────
477
478const CONFIG_NAMES: &[&str] = &["fdl.yaml", "fdl.yml", "fdl.json"];
479const EXAMPLE_SUFFIXES: &[&str] = &[".example", ".dist"];
480
481/// Walk up from `start` looking for fdl.yaml.
482///
483/// If only an `.example` (or `.dist`) variant exists, offers to copy it
484/// to the real config path. This lets the repo commit `fdl.yaml.example`
485/// while `.gitignore`-ing `fdl.yaml` so users can customize locally.
486pub fn find_config(start: &Path) -> Option<PathBuf> {
487    let mut dir = start.to_path_buf();
488    loop {
489        // First pass: look for the real config.
490        for name in CONFIG_NAMES {
491            let candidate = dir.join(name);
492            if candidate.is_file() {
493                return Some(candidate);
494            }
495        }
496        // Second pass: look for .example/.dist variants.
497        for name in CONFIG_NAMES {
498            for suffix in EXAMPLE_SUFFIXES {
499                let example = dir.join(format!("{name}{suffix}"));
500                if example.is_file() {
501                    let target = dir.join(name);
502                    if try_copy_example(&example, &target) {
503                        return Some(target);
504                    }
505                    // User declined: use the example directly.
506                    return Some(example);
507                }
508            }
509        }
510        if !dir.pop() {
511            return None;
512        }
513    }
514}
515
516/// Prompt the user to copy an example config to the real path.
517/// Returns true if the copy succeeded.
518fn try_copy_example(example: &Path, target: &Path) -> bool {
519    let example_name = example.file_name().unwrap_or_default().to_string_lossy();
520    let target_name = target.file_name().unwrap_or_default().to_string_lossy();
521    eprintln!(
522        "fdl: found {example_name} but no {target_name}. \
523         Copy it to create your local config? [Y/n] "
524    );
525    let mut input = String::new();
526    if std::io::stdin().read_line(&mut input).is_err() {
527        return false;
528    }
529    let answer = input.trim().to_lowercase();
530    if answer.is_empty() || answer == "y" || answer == "yes" {
531        match std::fs::copy(example, target) {
532            Ok(_) => {
533                eprintln!("fdl: created {target_name} (edit to customize)");
534                true
535            }
536            Err(e) => {
537                eprintln!("fdl: failed to copy: {e}");
538                false
539            }
540        }
541    } else {
542        false
543    }
544}
545
546/// Load a project config from a specific path.
547pub fn load_project(path: &Path) -> Result<ProjectConfig, String> {
548    load_project_with_env(path, None)
549}
550
551/// Load a project config with an optional environment overlay.
552///
553/// When `env` is `Some`, looks for a sibling `fdl.<env>.{yml,yaml,json}` next
554/// to `base_path` and deep-merges it over the base before deserialization.
555/// Missing overlay files are a hard error — the user asked for this env, so
556/// silently ignoring it would be worse than a clear message.
557pub fn load_project_with_env(
558    base_path: &Path,
559    env: Option<&str>,
560) -> Result<ProjectConfig, String> {
561    let merged = load_merged_value(base_path, env)?;
562    serde_yaml::from_value::<ProjectConfig>(merged)
563        .map_err(|e| format!("{}: {}", base_path.display(), e))
564}
565
566/// Load the raw merged [`serde_yaml::Value`] for a config + optional env
567/// overlay. Exposed so callers like `fdl config show` can inspect the
568/// resolved view before it is deserialized into a strongly-typed struct.
569pub fn load_merged_value(
570    base_path: &Path,
571    env: Option<&str>,
572) -> Result<serde_yaml::Value, String> {
573    let layers = resolve_config_layers(base_path, env)?;
574    Ok(crate::overlay::merge_layers(
575        layers.into_iter().map(|(_, v)| v).collect::<Vec<_>>(),
576    ))
577}
578
579/// Resolve every layer contributing to a config, in merge order, with
580/// `inherit-from:` chains expanded. Paired with the base file + optional
581/// env overlay, the result is `[chain(base)..., chain(env_overlay)...]`
582/// de-duplicated by canonical path (kept-first).
583///
584/// Used by `fdl config show` for per-leaf source annotation, and
585/// internally by [`load_merged_value`] / [`load_command_with_env`] so
586/// every consumer picks up `inherit-from:` uniformly.
587pub fn resolve_config_layers(
588    base_path: &Path,
589    env: Option<&str>,
590) -> Result<Vec<(PathBuf, serde_yaml::Value)>, String> {
591    let mut layers = crate::overlay::resolve_chain(base_path)?;
592    if let Some(name) = env {
593        match crate::overlay::find_env_file(base_path, name) {
594            Some(p) => {
595                let env_chain = crate::overlay::resolve_chain(&p)?;
596                layers.extend(env_chain);
597            }
598            None => {
599                return Err(format!(
600                    "environment `{name}` not found (expected fdl.{name}.yml next to {})",
601                    base_path.display()
602                ));
603            }
604        }
605    }
606    // Dedup by canonical path, keeping first occurrence. An env overlay
607    // whose chain loops back to a file already in the base chain (same
608    // file via a different inheritance route) collapses cleanly.
609    let mut seen = std::collections::HashSet::new();
610    layers.retain(|(path, _)| seen.insert(path.clone()));
611    Ok(layers)
612}
613
614/// Source path list for a base config + env overlay, in merge order. Used
615/// by `fdl config show` to annotate which layer a value came from.
616pub fn config_layer_sources(base_path: &Path, env: Option<&str>) -> Vec<PathBuf> {
617    resolve_config_layers(base_path, env)
618        .map(|ls| ls.into_iter().map(|(p, _)| p).collect())
619        .unwrap_or_else(|_| vec![base_path.to_path_buf()])
620}
621
622/// Load a command config from a sub-directory.
623///
624/// Applies the same `.example`/`.dist` fallback as [`find_config`]. If a
625/// `schema:` block is present, validates it before returning.
626pub fn load_command(dir: &Path) -> Result<CommandConfig, String> {
627    load_command_with_env(dir, None)
628}
629
630/// Load a sub-command config with an optional environment overlay.
631///
632/// Applies the same `.example`/`.dist` fallback as [`find_config`] to locate
633/// the base file, then deep-merges a sibling `fdl.<env>.yml` overlay if one
634/// exists. A *missing* overlay is silently accepted here (different from
635/// [`load_project_with_env`]) — envs declared at the project root don't
636/// have to exist for every sub-command.
637pub fn load_command_with_env(dir: &Path, env: Option<&str>) -> Result<CommandConfig, String> {
638    // Resolve the base config path (with .example fallback, same as before).
639    let mut base_path: Option<PathBuf> = None;
640    for name in CONFIG_NAMES {
641        let path = dir.join(name);
642        if path.is_file() {
643            base_path = Some(path);
644            break;
645        }
646    }
647    if base_path.is_none() {
648        for name in CONFIG_NAMES {
649            for suffix in EXAMPLE_SUFFIXES {
650                let example = dir.join(format!("{name}{suffix}"));
651                if example.is_file() {
652                    let target = dir.join(name);
653                    let src = if try_copy_example(&example, &target) {
654                        target
655                    } else {
656                        example
657                    };
658                    base_path = Some(src);
659                    break;
660                }
661            }
662            if base_path.is_some() {
663                break;
664            }
665        }
666    }
667    let base_path = base_path
668        .ok_or_else(|| format!("no fdl.yml found in {}", dir.display()))?;
669
670    // Layered load: base chain + optional env overlay chain. Both sides
671    // run through `resolve_chain` so `inherit-from:` composes the same
672    // way for nested commands as for the project root.
673    let mut layers = crate::overlay::resolve_chain(&base_path)?;
674    if let Some(name) = env {
675        if let Some(p) = crate::overlay::find_env_file(&base_path, name) {
676            layers.extend(crate::overlay::resolve_chain(&p)?);
677        }
678    }
679    let mut seen = std::collections::HashSet::new();
680    layers.retain(|(path, _)| seen.insert(path.clone()));
681    let merged = crate::overlay::merge_layers(
682        layers.into_iter().map(|(_, v)| v).collect::<Vec<_>>(),
683    );
684    let mut cfg: CommandConfig = serde_yaml::from_value(merged)
685        .map_err(|e| format!("{}: {}", base_path.display(), e))?;
686
687    if let Some(schema) = &cfg.schema {
688        validate_schema(schema)
689            .map_err(|e| format!("schema error in {}/fdl.yml: {e}", dir.display()))?;
690        // Preset validation (choice values + strict unknown-key rejection)
691        // is intentionally deferred to the exec path. Load-time validation
692        // would block `fdl <cmd> --help` whenever ANY preset in the config
693        // has a typo — worse UX than letting help render and erroring only
694        // when the broken preset is actually invoked.
695    }
696
697    // Cache precedence: a valid, fresh cached schema (written by `fdl <cmd>
698    // --refresh-schema` or auto-probed below) wins over the inline YAML
699    // schema. This lets a binary become the source of truth for its own
700    // surface once it opts into the `--fdl-schema` contract. A cache that
701    // is older than the command's fdl.yml is treated as stale and skipped
702    // — the inline schema (if any) reasserts until a refresh happens.
703    let cmd_name = dir
704        .file_name()
705        .and_then(|n| n.to_str())
706        .unwrap_or("_");
707    let cache = crate::schema_cache::cache_path(dir, cmd_name);
708    // Reference mtimes: config files that, when edited, might invalidate
709    // the cached schema (e.g. changing `entry:` to point somewhere else).
710    let refs: Vec<std::path::PathBuf> = CONFIG_NAMES
711        .iter()
712        .map(|n| dir.join(n))
713        .filter(|p| p.exists())
714        .collect();
715    if !crate::schema_cache::is_stale(&cache, &refs) {
716        if let Some(cached) = crate::schema_cache::read_cache(&cache) {
717            cfg.schema = Some(cached);
718        }
719    } else if let Some(entry) = cfg.entry.as_deref() {
720        // Auto-probe non-cargo entries when the cache is stale or missing.
721        // Cargo entries are skipped by default — `cargo run --fdl-schema`
722        // triggers a full compile which is unacceptable latency for `-h`
723        // — unless the yml explicitly opts in via `compile: true`.
724        // Scripts and pre-built binaries are expected to handle the flag
725        // cheaply (emit JSON and exit), so probing them on demand is safe.
726        // Probe failures are swallowed: an entry that doesn't implement
727        // `--fdl-schema` simply falls through to the inline schema (or no
728        // schema) — help still renders.
729        let opts_into_compile = cfg.compile.unwrap_or(false);
730        let should_probe =
731            !crate::schema_cache::is_cargo_entry(entry) || opts_into_compile;
732        if should_probe {
733            if let Ok(probed) =
734                crate::schema_cache::probe(entry, dir, cfg.docker.as_deref())
735            {
736                // Best-effort cache write: if the dir is read-only, the
737                // schema still applies to this invocation, we just re-probe
738                // next time. Non-fatal.
739                let _ = crate::schema_cache::write_cache(&cache, &probed);
740                cfg.schema = Some(probed);
741            }
742        }
743    }
744
745    Ok(cfg)
746}
747
748// ── Strict-mode validation ──────────────────────────────────────────────
749
750/// Reserved flags that strict mode always tolerates in the user's tail.
751/// These are fdl-level universals (help/version) or opt-ins every
752/// FdlArgs-derived binary exposes (--fdl-schema) — keeping them out of
753/// the `schema.options` map means strict mode has to allowlist them
754/// separately or spuriously reject legal invocations.
755const STRICT_UNIVERSAL_LONGS: &[(&str, Option<char>, bool)] = &[
756    // (long, short, takes_value)
757    ("help", Some('h'), false),
758    ("version", Some('V'), false),
759    ("fdl-schema", None, false),
760    ("refresh-schema", None, false),
761];
762
763/// Convert a [`Schema`] into an [`ArgsSpec`](crate::args::parser::ArgsSpec) suitable for strict-mode
764/// tail validation. Positional `required` flags are intentionally
765/// dropped: the binary itself will enforce them after parsing, and
766/// treating them as required here would turn "missing positional" into
767/// a double-errored mess.
768pub fn schema_to_args_spec(schema: &Schema) -> crate::args::parser::ArgsSpec {
769    use crate::args::parser::{ArgsSpec, OptionDecl, PositionalDecl};
770
771    let mut options: Vec<OptionDecl> = schema
772        .options
773        .iter()
774        .map(|(long, spec)| OptionDecl {
775            long: long.clone(),
776            short: spec
777                .short
778                .as_deref()
779                .and_then(|s| s.chars().next()),
780            takes_value: spec.ty != "bool",
781            // Every value-taking option is allowed to appear bare in
782            // strict mode. fdl does not second-guess whether the binary
783            // would accept a bare `--foo`; that stays in the binary's
784            // court.
785            allows_bare: true,
786            repeatable: spec.ty.starts_with("list["),
787            choices: spec
788                .choices
789                .as_ref()
790                .map(|cs| strict_choices_to_strings(cs)),
791        })
792        .collect();
793
794    // Always-allowed universals — help/version/fdl-schema/refresh-schema
795    // are not in the user's schema but must not trigger "unknown flag".
796    for (long, short, takes_value) in STRICT_UNIVERSAL_LONGS {
797        options.push(OptionDecl {
798            long: (*long).to_string(),
799            short: *short,
800            takes_value: *takes_value,
801            allows_bare: true,
802            repeatable: false,
803            choices: None,
804        });
805    }
806
807    // Positionals: drop the `required` bit. Strict mode is scoped to
808    // option names/values only; arity is the binary's concern.
809    let positionals: Vec<PositionalDecl> = schema
810        .args
811        .iter()
812        .map(|a| PositionalDecl {
813            name: a.name.clone(),
814            required: false,
815            variadic: a.variadic,
816            choices: a
817                .choices
818                .as_ref()
819                .map(|cs| strict_choices_to_strings(cs)),
820        })
821        .collect();
822
823    ArgsSpec {
824        options,
825        positionals,
826        // Non-strict schemas accept user-forwarded flags the author
827        // didn't declare — the binary re-parses the tail anyway.
828        // Strict schemas reject anything not declared.
829        lenient_unknowns: !schema.strict,
830    }
831}
832
833fn strict_choices_to_strings(cs: &[serde_json::Value]) -> Vec<String> {
834    cs.iter()
835        .map(|v| match v {
836            serde_json::Value::String(s) => s.clone(),
837            other => other.to_string(),
838        })
839        .collect()
840}
841
842/// Validate the user's extra argv tail against a schema. Always called
843/// before `run::exec_command` — the parser's lenient-unknowns mode is
844/// keyed off `schema.strict` so choice validation on declared flags
845/// fires regardless, while unknown-flag rejection stays opt-in.
846///
847/// The tokenizer from [`crate::args::parser`] is reused so "did you
848/// mean" suggestions, cluster, and equals handling come for free.
849pub fn validate_tail(tail: &[String], schema: &Schema) -> Result<(), String> {
850    let spec = schema_to_args_spec(schema);
851    let mut argv = Vec::with_capacity(tail.len() + 1);
852    argv.push("fdl".to_string());
853    argv.extend(tail.iter().cloned());
854    crate::args::parser::parse(&spec, &argv).map(|_| ())
855}
856
857/// Validate a single preset that's about to be invoked. Combines the
858/// always-on `choices:` check and, if `schema.strict`, the unknown-key
859/// rejection — scoped to just this preset, not the whole `commands:`
860/// map. Called from the exec path so typos in a sibling preset don't
861/// block `--help` for a correct one.
862pub fn validate_preset_for_exec(
863    preset_name: &str,
864    spec: &CommandSpec,
865    schema: &Schema,
866) -> Result<(), String> {
867    for (key, value) in &spec.options {
868        let Some(opt) = schema.options.get(key) else {
869            if schema.strict {
870                return Err(format!(
871                    "preset `{preset_name}` pins option `{key}` which is not declared in schema.options"
872                ));
873            }
874            continue;
875        };
876        let Some(choices) = &opt.choices else {
877            continue;
878        };
879        if !choices.iter().any(|c| values_equal(c, value)) {
880            let allowed: Vec<String> = choices
881                .iter()
882                .map(|c| match c {
883                    serde_json::Value::String(s) => s.clone(),
884                    other => other.to_string(),
885                })
886                .collect();
887            return Err(format!(
888                "preset `{preset_name}` sets option `{key}` to `{}` -- allowed: {}",
889                display_json(value),
890                allowed.join(", "),
891            ));
892        }
893    }
894    Ok(())
895}
896
897/// Always-on: validate preset YAML `options:` values against declared
898/// `choices:` in the schema. An option YAML value whose key matches a
899/// declared option with a `choices:` list must be one of those choices.
900/// Keys not declared in the schema are ignored here — those are the
901/// concern of [`validate_presets_strict`] (opt-in).
902///
903/// Used for whole-map validation (e.g. from a future `fdl config lint`
904/// subcommand). The dispatch path uses [`validate_preset_for_exec`] so
905/// sibling-preset typos don't block correct invocations.
906pub fn validate_preset_values(
907    commands: &BTreeMap<String, CommandSpec>,
908    schema: &Schema,
909) -> Result<(), String> {
910    for (preset_name, spec) in commands {
911        match spec.kind() {
912            Ok(CommandKind::Preset) => {}
913            _ => continue,
914        }
915        for (key, value) in &spec.options {
916            let Some(opt) = schema.options.get(key) else {
917                continue; // unknown key — strict's problem, not ours
918            };
919            let Some(choices) = &opt.choices else {
920                continue; // no choices declared — anything goes
921            };
922            if !choices.iter().any(|c| values_equal(c, value)) {
923                let allowed: Vec<String> = choices
924                    .iter()
925                    .map(|c| match c {
926                        serde_json::Value::String(s) => s.clone(),
927                        other => other.to_string(),
928                    })
929                    .collect();
930                return Err(format!(
931                    "preset `{preset_name}` sets option `{key}` to `{}` -- allowed: {}",
932                    display_json(value),
933                    allowed.join(", "),
934                ));
935            }
936        }
937    }
938    Ok(())
939}
940
941/// Compare two JSON values for equality, treating YAML's loose-typed
942/// representation (a preset might write `batch-size: 32` as an int
943/// while the schema's choices list contains `"32"` as a string).
944fn values_equal(a: &serde_json::Value, b: &serde_json::Value) -> bool {
945    if a == b {
946        return true;
947    }
948    // Cross-type string ↔ number comparison for YAML-friendly matching.
949    match (a, b) {
950        (serde_json::Value::String(s), other) | (other, serde_json::Value::String(s)) => {
951            s == &other.to_string()
952        }
953        _ => false,
954    }
955}
956
957fn display_json(v: &serde_json::Value) -> String {
958    match v {
959        serde_json::Value::String(s) => s.clone(),
960        other => other.to_string(),
961    }
962}
963
964/// At load time, reject preset `options:` keys that are not declared in
965/// the enclosing schema. Runs only when `schema.strict == true`, and
966/// only against entries resolved to [`CommandKind::Preset`] — `run:` and
967/// `path:` kinds don't share the parent schema.
968pub fn validate_presets_strict(
969    commands: &BTreeMap<String, CommandSpec>,
970    schema: &Schema,
971) -> Result<(), String> {
972    for (preset_name, spec) in commands {
973        match spec.kind() {
974            Ok(CommandKind::Preset) => {}
975            _ => continue,
976        }
977        for key in spec.options.keys() {
978            if !schema.options.contains_key(key) {
979                return Err(format!(
980                    "preset `{preset_name}` pins option `{key}` which is not declared in schema.options"
981                ));
982            }
983        }
984    }
985    Ok(())
986}
987
988// ── Merge ───────────────────────────────────────────────────────────────
989
990/// Merge the enclosing `CommandConfig` defaults with a named preset's
991/// overrides. Preset values win. Used when dispatching an inline preset
992/// command (neither `run` nor `path`).
993pub fn merge_preset(root: &CommandConfig, preset: &CommandSpec) -> ResolvedConfig {
994    ResolvedConfig {
995        ddp: merge_ddp(&root.ddp, &preset.ddp),
996        training: merge_training(&root.training, &preset.training),
997        output: merge_output(&root.output, &preset.output),
998        options: preset.options.clone(),
999    }
1000}
1001
1002/// Resolved config from root defaults only (no job).
1003pub fn defaults_only(root: &CommandConfig) -> ResolvedConfig {
1004    ResolvedConfig {
1005        ddp: root.ddp.clone().unwrap_or_default(),
1006        training: root.training.clone().unwrap_or_default(),
1007        output: root.output.clone().unwrap_or_default(),
1008        options: BTreeMap::new(),
1009    }
1010}
1011
1012/// Fully resolved configuration ready for arg translation.
1013pub struct ResolvedConfig {
1014    pub ddp: DdpConfig,
1015    pub training: TrainingConfig,
1016    pub output: OutputConfig,
1017    pub options: BTreeMap<String, serde_json::Value>,
1018}
1019
1020macro_rules! merge_field {
1021    ($base:expr, $over:expr, $field:ident) => {
1022        $over
1023            .as_ref()
1024            .and_then(|o| o.$field.clone())
1025            .or_else(|| $base.as_ref().and_then(|b| b.$field.clone()))
1026    };
1027}
1028
1029fn merge_ddp(base: &Option<DdpConfig>, over: &Option<DdpConfig>) -> DdpConfig {
1030    DdpConfig {
1031        mode: merge_field!(base, over, mode),
1032        policy: merge_field!(base, over, policy),
1033        backend: merge_field!(base, over, backend),
1034        anchor: merge_field!(base, over, anchor),
1035        max_anchor: merge_field!(base, over, max_anchor),
1036        overhead_target: merge_field!(base, over, overhead_target),
1037        divergence_threshold: merge_field!(base, over, divergence_threshold),
1038        max_batch_diff: merge_field!(base, over, max_batch_diff),
1039        speed_hint: merge_field!(base, over, speed_hint),
1040        partition_ratios: merge_field!(base, over, partition_ratios),
1041        progressive: merge_field!(base, over, progressive),
1042        max_grad_norm: merge_field!(base, over, max_grad_norm),
1043        lr_scale_ratio: merge_field!(base, over, lr_scale_ratio),
1044        snapshot_timeout: merge_field!(base, over, snapshot_timeout),
1045        checkpoint_every: merge_field!(base, over, checkpoint_every),
1046        timeline: merge_field!(base, over, timeline),
1047    }
1048}
1049
1050fn merge_training(base: &Option<TrainingConfig>, over: &Option<TrainingConfig>) -> TrainingConfig {
1051    TrainingConfig {
1052        epochs: merge_field!(base, over, epochs),
1053        batch_size: merge_field!(base, over, batch_size),
1054        batches_per_epoch: merge_field!(base, over, batches_per_epoch),
1055        lr: merge_field!(base, over, lr),
1056        seed: merge_field!(base, over, seed),
1057    }
1058}
1059
1060fn merge_output(base: &Option<OutputConfig>, over: &Option<OutputConfig>) -> OutputConfig {
1061    OutputConfig {
1062        dir: merge_field!(base, over, dir),
1063        timeline: merge_field!(base, over, timeline),
1064        monitor: merge_field!(base, over, monitor),
1065    }
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070    use super::*;
1071
1072    /// Resolve the project root (where fdl.yml / fdl.yml.example live) starting
1073    /// from CARGO_MANIFEST_DIR. The CLI crate sits one level down.
1074    fn project_root() -> PathBuf {
1075        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1076            .parent()
1077            .expect("flodl-cli parent must be project root")
1078            .to_path_buf()
1079    }
1080
1081    fn load_example() -> ProjectConfig {
1082        let path = project_root().join("fdl.yml.example");
1083        assert!(
1084            path.is_file(),
1085            "fdl.yml.example missing at {} -- the CLI depends on it as the canonical config template",
1086            path.display()
1087        );
1088        load_project(&path).expect("fdl.yml.example must parse as a valid ProjectConfig")
1089    }
1090
1091    fn opt(ty: &str) -> OptionSpec {
1092        OptionSpec {
1093            ty: ty.into(),
1094            description: None,
1095            default: None,
1096            choices: None,
1097            short: None,
1098            env: None,
1099            completer: None,
1100        }
1101    }
1102
1103    fn arg(name: &str, ty: &str) -> ArgSpec {
1104        ArgSpec {
1105            name: name.into(),
1106            ty: ty.into(),
1107            description: None,
1108            required: true,
1109            variadic: false,
1110            default: None,
1111            choices: None,
1112            completer: None,
1113        }
1114    }
1115
1116    #[test]
1117    fn validate_schema_accepts_minimal_valid() {
1118        let mut s = Schema::default();
1119        s.options.insert("model".into(), opt("string"));
1120        s.options.insert("epochs".into(), opt("int"));
1121        s.args.push(arg("run-id", "string"));
1122        validate_schema(&s).expect("minimal valid schema must pass");
1123    }
1124
1125    #[test]
1126    fn validate_schema_rejects_unknown_option_type() {
1127        let mut s = Schema::default();
1128        s.options.insert("bad".into(), opt("integer"));
1129        let err = validate_schema(&s).expect_err("unknown type should fail");
1130        assert!(err.contains("unknown type"), "err was: {err}");
1131    }
1132
1133    #[test]
1134    fn validate_schema_rejects_reserved_long() {
1135        let mut s = Schema::default();
1136        s.options.insert("help".into(), opt("bool"));
1137        let err = validate_schema(&s).expect_err("reserved --help must fail");
1138        assert!(err.contains("reserved"), "err was: {err}");
1139    }
1140
1141    #[test]
1142    fn validate_schema_rejects_reserved_short() {
1143        let mut s = Schema::default();
1144        let mut o = opt("string");
1145        o.short = Some("h".into());
1146        s.options.insert("host".into(), o);
1147        let err = validate_schema(&s).expect_err("short -h must fail");
1148        assert!(err.contains("reserved"), "err was: {err}");
1149    }
1150
1151    #[test]
1152    fn validate_schema_rejects_duplicate_short() {
1153        let mut s = Schema::default();
1154        let mut a = opt("string");
1155        a.short = Some("m".into());
1156        let mut b = opt("string");
1157        b.short = Some("m".into());
1158        s.options.insert("model".into(), a);
1159        s.options.insert("mode".into(), b);
1160        let err = validate_schema(&s).expect_err("duplicate -m must fail");
1161        assert!(err.contains("both declare short"), "err was: {err}");
1162    }
1163
1164    #[test]
1165    fn validate_schema_rejects_non_last_variadic() {
1166        let mut s = Schema::default();
1167        let mut first = arg("files", "string");
1168        first.variadic = true;
1169        s.args.push(first);
1170        s.args.push(arg("trailer", "string"));
1171        let err = validate_schema(&s).expect_err("variadic-not-last must fail");
1172        assert!(err.contains("variadic"), "err was: {err}");
1173    }
1174
1175    #[test]
1176    fn validate_schema_rejects_required_after_optional() {
1177        let mut s = Schema::default();
1178        let mut first = arg("maybe", "string");
1179        first.required = false;
1180        s.args.push(first);
1181        s.args.push(arg("need", "string"));
1182        let err = validate_schema(&s).expect_err("required-after-optional must fail");
1183        assert!(err.contains("cannot follow"), "err was: {err}");
1184    }
1185
1186    // ── Tail validation (always-on) + strict unknown-rejection ─────
1187
1188    fn schema_with_model_option(strict: bool) -> Schema {
1189        let mut s = Schema {
1190            strict,
1191            ..Schema::default()
1192        };
1193        let mut model = opt("string");
1194        model.short = Some("m".into());
1195        model.choices = Some(vec![
1196            serde_json::json!("mlp"),
1197            serde_json::json!("resnet"),
1198        ]);
1199        s.options.insert("model".into(), model);
1200        s.options.insert("epochs".into(), opt("int"));
1201        // A bool flag, no value.
1202        s.options.insert("validate".into(), opt("bool"));
1203        s
1204    }
1205
1206    fn strict_schema_with_model_option() -> Schema {
1207        schema_with_model_option(true)
1208    }
1209
1210    #[test]
1211    fn validate_tail_accepts_known_long_flag() {
1212        let schema = strict_schema_with_model_option();
1213        let tail = vec!["--epochs".into(), "3".into()];
1214        validate_tail(&tail, &schema).expect("known flag must pass");
1215    }
1216
1217    #[test]
1218    fn validate_tail_accepts_known_short_flag() {
1219        let schema = strict_schema_with_model_option();
1220        let tail = vec!["-m".into(), "mlp".into()];
1221        validate_tail(&tail, &schema).expect("known short must pass");
1222    }
1223
1224    #[test]
1225    fn validate_tail_accepts_bool_flag() {
1226        let schema = strict_schema_with_model_option();
1227        let tail = vec!["--validate".into()];
1228        validate_tail(&tail, &schema).expect("bool flag must pass");
1229    }
1230
1231    #[test]
1232    fn validate_tail_strict_rejects_unknown_long_flag() {
1233        let schema = strict_schema_with_model_option();
1234        let tail = vec!["--nope".into()];
1235        let err = validate_tail(&tail, &schema)
1236            .expect_err("unknown long flag must error in strict mode");
1237        assert!(err.contains("--nope"), "err was: {err}");
1238    }
1239
1240    #[test]
1241    fn validate_tail_strict_suggests_did_you_mean() {
1242        // "--epoch" is one char off "--epochs" — edit distance ≤ 2.
1243        let schema = strict_schema_with_model_option();
1244        let tail = vec!["--epoch".into(), "3".into()];
1245        let err = validate_tail(&tail, &schema).expect_err("typo must error");
1246        assert!(err.contains("did you mean"), "err was: {err}");
1247        assert!(err.contains("--epochs"), "suggestion missing: {err}");
1248    }
1249
1250    #[test]
1251    fn validate_tail_strict_rejects_unknown_short_flag() {
1252        let schema = strict_schema_with_model_option();
1253        let tail = vec!["-z".into()];
1254        let err = validate_tail(&tail, &schema)
1255            .expect_err("unknown short must error in strict mode");
1256        assert!(err.contains("-z"), "err was: {err}");
1257    }
1258
1259    #[test]
1260    fn validate_tail_rejects_bad_choice_always_strict() {
1261        let schema = strict_schema_with_model_option();
1262        let tail = vec!["--model".into(), "lenet".into()];
1263        let err = validate_tail(&tail, &schema)
1264            .expect_err("out-of-set choice must error");
1265        assert!(err.contains("lenet"), "err was: {err}");
1266        assert!(err.contains("allowed"), "err should list allowed values: {err}");
1267    }
1268
1269    #[test]
1270    fn validate_tail_rejects_bad_choice_even_when_not_strict() {
1271        // The main change in this rollout: `choices:` is a positive
1272        // assertion by the author, so it must be enforced regardless
1273        // of `schema.strict`. Only *unknown* flags relax without
1274        // strict.
1275        let schema = schema_with_model_option(false);
1276        let tail = vec!["--model".into(), "lenet".into()];
1277        let err = validate_tail(&tail, &schema)
1278            .expect_err("out-of-set choice must error without strict");
1279        assert!(err.contains("lenet"), "err was: {err}");
1280        assert!(err.contains("allowed"), "err should list allowed values: {err}");
1281    }
1282
1283    #[test]
1284    fn validate_tail_non_strict_tolerates_unknown_flag() {
1285        // Without strict, unknown flags are legitimate pass-through
1286        // candidates (the binary handles them itself).
1287        let schema = schema_with_model_option(false);
1288        let tail = vec!["--fancy-passthrough".into(), "value".into()];
1289        validate_tail(&tail, &schema)
1290            .expect("unknown flag must be tolerated when strict is off");
1291    }
1292
1293    #[test]
1294    fn validate_tail_non_strict_still_checks_known_short_choices() {
1295        // The declared short `-m` has choices; a bad value fails even
1296        // when strict is off. Unknown options would be tolerated, but
1297        // once the user reaches a declared option, its contract holds.
1298        let schema = schema_with_model_option(false);
1299        let tail = vec!["-m".into(), "lenet".into()];
1300        let err = validate_tail(&tail, &schema)
1301            .expect_err("out-of-set choice via short must error");
1302        assert!(err.contains("lenet"), "err was: {err}");
1303    }
1304
1305    #[test]
1306    fn validate_tail_allows_reserved_help() {
1307        // Reserved universal flags must pass even though they are not
1308        // declared in the schema. Defense-in-depth against edge cases
1309        // where `--help` somehow reaches dispatch.
1310        let schema = strict_schema_with_model_option();
1311        let tail = vec!["--help".into()];
1312        validate_tail(&tail, &schema).expect("--help must be allowed");
1313    }
1314
1315    #[test]
1316    fn validate_tail_allows_reserved_fdl_schema() {
1317        // `fdl ddp-bench --fdl-schema` is forwarded to the binary.
1318        let schema = strict_schema_with_model_option();
1319        let tail = vec!["--fdl-schema".into()];
1320        validate_tail(&tail, &schema).expect("--fdl-schema must be allowed");
1321    }
1322
1323    #[test]
1324    fn validate_tail_passthrough_after_double_dash() {
1325        // `--` terminates flag parsing. Tokens after it are positionals
1326        // and must never trigger "unknown flag" errors.
1327        let schema = strict_schema_with_model_option();
1328        let tail = vec!["--".into(), "--arbitrary".into(), "anything".into()];
1329        validate_tail(&tail, &schema).expect("passthrough must work");
1330    }
1331
1332    #[test]
1333    fn validate_presets_strict_rejects_unknown_option() {
1334        let schema = strict_schema_with_model_option();
1335        let mut commands = BTreeMap::new();
1336        let mut bad_options = BTreeMap::new();
1337        bad_options.insert("batchsize".into(), serde_json::json!(32));
1338        commands.insert(
1339            "quick".into(),
1340            CommandSpec {
1341                options: bad_options,
1342                ..Default::default()
1343            },
1344        );
1345        let err = validate_presets_strict(&commands, &schema)
1346            .expect_err("preset pinning undeclared option must error");
1347        assert!(err.contains("quick"), "err should name the preset: {err}");
1348        assert!(err.contains("batchsize"), "err should name the key: {err}");
1349    }
1350
1351    #[test]
1352    fn validate_presets_strict_accepts_known_options() {
1353        let schema = strict_schema_with_model_option();
1354        let mut commands = BTreeMap::new();
1355        let mut good_options = BTreeMap::new();
1356        good_options.insert("model".into(), serde_json::json!("mlp"));
1357        good_options.insert("epochs".into(), serde_json::json!(5));
1358        commands.insert(
1359            "quick".into(),
1360            CommandSpec {
1361                options: good_options,
1362                ..Default::default()
1363            },
1364        );
1365        validate_presets_strict(&commands, &schema)
1366            .expect("presets with declared options must pass");
1367    }
1368
1369    #[test]
1370    fn validate_presets_strict_ignores_run_and_path_kinds() {
1371        // Only Preset-kind entries share the parent schema. Run/Path
1372        // siblings are independent, so strict must not touch them.
1373        let schema = strict_schema_with_model_option();
1374        let mut commands = BTreeMap::new();
1375        commands.insert(
1376            "helper".into(),
1377            CommandSpec {
1378                run: Some("echo hi".into()),
1379                ..Default::default()
1380            },
1381        );
1382        commands.insert(
1383            "nested".into(),
1384            CommandSpec {
1385                path: Some("./nested/".into()),
1386                ..Default::default()
1387            },
1388        );
1389        validate_presets_strict(&commands, &schema)
1390            .expect("run/path siblings must be ignored by preset strict check");
1391    }
1392
1393    // ── Preset value validation (always-on `choices:`) ──────────────
1394
1395    #[test]
1396    fn validate_preset_values_rejects_bad_choice_even_without_strict() {
1397        // Schema has `choices:` on model; a preset pinning model to
1398        // something outside the list must fail at load, strict or not.
1399        let schema = schema_with_model_option(false);
1400        let mut commands = BTreeMap::new();
1401        let mut opts = BTreeMap::new();
1402        opts.insert("model".into(), serde_json::json!("lenet"));
1403        commands.insert(
1404            "quick".into(),
1405            CommandSpec {
1406                options: opts,
1407                ..Default::default()
1408            },
1409        );
1410        let err = validate_preset_values(&commands, &schema)
1411            .expect_err("out-of-choices preset must error");
1412        assert!(err.contains("quick"), "preset name missing: {err}");
1413        assert!(err.contains("model"), "option name missing: {err}");
1414        assert!(err.contains("lenet"), "bad value missing: {err}");
1415        assert!(err.contains("allowed"), "allowed list missing: {err}");
1416    }
1417
1418    #[test]
1419    fn validate_preset_values_accepts_in_choices_preset() {
1420        let schema = schema_with_model_option(false);
1421        let mut commands = BTreeMap::new();
1422        let mut opts = BTreeMap::new();
1423        opts.insert("model".into(), serde_json::json!("mlp"));
1424        commands.insert(
1425            "quick".into(),
1426            CommandSpec {
1427                options: opts,
1428                ..Default::default()
1429            },
1430        );
1431        validate_preset_values(&commands, &schema)
1432            .expect("in-choices preset must pass");
1433    }
1434
1435    #[test]
1436    fn validate_preset_values_ignores_undeclared_keys() {
1437        // Unknown keys aren't our concern here — that's for
1438        // `validate_presets_strict`, which only runs under strict.
1439        let schema = schema_with_model_option(false);
1440        let mut commands = BTreeMap::new();
1441        let mut opts = BTreeMap::new();
1442        opts.insert("extra".into(), serde_json::json!("whatever"));
1443        commands.insert(
1444            "quick".into(),
1445            CommandSpec {
1446                options: opts,
1447                ..Default::default()
1448            },
1449        );
1450        validate_preset_values(&commands, &schema)
1451            .expect("undeclared key must be ignored by value validator");
1452    }
1453
1454    #[test]
1455    fn validate_preset_values_ignores_options_without_choices() {
1456        // `epochs` is declared as int with no `choices:`, so any value
1457        // passes the choice check (type validation is a separate pass).
1458        let schema = schema_with_model_option(false);
1459        let mut commands = BTreeMap::new();
1460        let mut opts = BTreeMap::new();
1461        opts.insert("epochs".into(), serde_json::json!(999));
1462        commands.insert(
1463            "quick".into(),
1464            CommandSpec {
1465                options: opts,
1466                ..Default::default()
1467            },
1468        );
1469        validate_preset_values(&commands, &schema)
1470            .expect("no-choices option must accept any value");
1471    }
1472
1473    #[test]
1474    fn validate_schema_rejects_required_with_default() {
1475        let mut s = Schema::default();
1476        let mut a = arg("x", "string");
1477        a.default = Some(serde_json::json!("foo"));
1478        s.args.push(a);
1479        let err = validate_schema(&s).expect_err("required+default must fail");
1480        assert!(err.contains("contradiction"), "err was: {err}");
1481    }
1482
1483    /// Regression guard: fdl.yml.example must keep a working `doc` command.
1484    /// The fdl.doc pipeline (api-ref for the port skill, rustdoc warning
1485    /// enforcement in CI) depends on this entry existing and producing output.
1486    #[test]
1487    fn fdl_yml_example_has_doc_script() {
1488        let cfg = load_example();
1489        let doc = cfg.commands.get("doc").unwrap_or_else(|| {
1490            panic!(
1491                "fdl.yml.example is missing a `doc` command; the rustdoc pipeline \
1492                 depends on `fdl doc` being defined"
1493            )
1494        });
1495        let cmd = doc
1496            .run
1497            .as_deref()
1498            .expect("fdl.yml.example `doc` command must be a `run:` entry");
1499        assert!(
1500            !cmd.trim().is_empty(),
1501            "fdl.yml.example `doc` command has an empty `run:` command"
1502        );
1503        assert!(
1504            cmd.contains("cargo doc"),
1505            "fdl.yml.example `doc` command must invoke `cargo doc`, got: {cmd}"
1506        );
1507        // Must assert some output was produced -- otherwise rustdoc can
1508        // silently succeed without writing anything useful (e.g. when the
1509        // target crate fails to resolve). Keeping the exact check liberal:
1510        // any mention of target/doc as a produced artifact counts.
1511        assert!(
1512            cmd.contains("target/doc"),
1513            "fdl.yml.example `doc` command must verify output was produced \
1514             (expected a `test -f target/doc/...` check), got: {cmd}"
1515        );
1516    }
1517
1518    #[test]
1519    fn command_spec_kind_mutex_run_and_path() {
1520        let spec = CommandSpec {
1521            run: Some("echo".into()),
1522            path: Some("x/".into()),
1523            ..Default::default()
1524        };
1525        let err = spec.kind().expect_err("run + path must fail");
1526        assert!(err.contains("both"), "err was: {err}");
1527    }
1528
1529    #[test]
1530    fn command_spec_kind_path_convention() {
1531        let spec = CommandSpec::default();
1532        assert_eq!(spec.kind().unwrap(), CommandKind::Path);
1533    }
1534
1535    #[test]
1536    fn command_spec_kind_preset_when_preset_fields_set() {
1537        let spec = CommandSpec {
1538            training: Some(TrainingConfig {
1539                epochs: Some(1),
1540                ..Default::default()
1541            }),
1542            ..Default::default()
1543        };
1544        assert_eq!(spec.kind().unwrap(), CommandKind::Preset);
1545    }
1546
1547    #[test]
1548    fn command_spec_kind_preset_when_only_options_set() {
1549        // `options:` alone is enough to make a preset — not every preset
1550        // overrides the structured ddp/training/output blocks.
1551        let mut options = BTreeMap::new();
1552        options.insert("model".into(), serde_json::json!("linear"));
1553        let spec = CommandSpec {
1554            options,
1555            ..Default::default()
1556        };
1557        assert_eq!(spec.kind().unwrap(), CommandKind::Preset);
1558    }
1559
1560    #[test]
1561    fn command_spec_kind_path_explicit() {
1562        // Explicit `path:` is a Path even if preset fields are also set;
1563        // the presence of `path:` is the kind-selecting field.
1564        let spec = CommandSpec {
1565            path: Some("./sub/".into()),
1566            ..Default::default()
1567        };
1568        assert_eq!(spec.kind().unwrap(), CommandKind::Path);
1569    }
1570
1571    #[test]
1572    fn command_spec_kind_rejects_docker_without_run() {
1573        // `docker:` is meaningful only as a wrapper around an inline
1574        // `run:` script. Pairing it with path/preset is a silent noop
1575        // at dispatch time, so we reject at load.
1576        let spec = CommandSpec {
1577            docker: Some("cuda".into()),
1578            ..Default::default()
1579        };
1580        let err = spec
1581            .kind()
1582            .expect_err("docker without run must fail");
1583        assert!(err.contains("docker"), "err was: {err}");
1584    }
1585
1586    #[test]
1587    fn command_spec_kind_allows_docker_with_run() {
1588        let spec = CommandSpec {
1589            run: Some("cargo test".into()),
1590            docker: Some("dev".into()),
1591            ..Default::default()
1592        };
1593        assert_eq!(spec.kind().unwrap(), CommandKind::Run);
1594    }
1595
1596    #[test]
1597    fn command_spec_deserialize_from_null() {
1598        let yaml = "cmd: ~";
1599        let map: BTreeMap<String, CommandSpec> =
1600            serde_yaml::from_str(yaml).expect("null must deserialize to default");
1601        let spec = map.get("cmd").expect("cmd missing");
1602        assert!(spec.run.is_none() && spec.path.is_none());
1603        assert_eq!(spec.kind().unwrap(), CommandKind::Path);
1604    }
1605
1606    #[test]
1607    fn command_config_arg_name_deserializes_kebab_case() {
1608        // YAML uses `arg-name:`, Rust field is `arg_name`.
1609        let yaml = "arg-name: recipe\nentry: echo\n";
1610        let cfg: CommandConfig =
1611            serde_yaml::from_str(yaml).expect("arg-name must parse");
1612        assert_eq!(cfg.arg_name.as_deref(), Some("recipe"));
1613    }
1614
1615    #[test]
1616    fn command_config_arg_name_defaults_to_none() {
1617        let cfg: CommandConfig =
1618            serde_yaml::from_str("entry: echo\n").expect("minimal cfg must parse");
1619        assert!(cfg.arg_name.is_none());
1620    }
1621
1622    // ── resolve_config_layers: inherit-from + env composition ────────────
1623    //
1624    // Integration coverage for how `inherit-from:` chains compose with env
1625    // overlays at the config-module boundary. The overlay module already
1626    // tests `resolve_chain` in isolation; here we verify the concat+dedup
1627    // behaviour that config.rs layers on top.
1628
1629    /// Minimal tempdir helper — matches the pattern used across the crate.
1630    struct TempDir(PathBuf);
1631    impl TempDir {
1632        fn new() -> Self {
1633            use std::sync::atomic::{AtomicU64, Ordering};
1634            static N: AtomicU64 = AtomicU64::new(0);
1635            let dir = std::env::temp_dir().join(format!(
1636                "fdl-cfg-test-{}-{}",
1637                std::process::id(),
1638                N.fetch_add(1, Ordering::Relaxed)
1639            ));
1640            std::fs::create_dir_all(&dir).unwrap();
1641            Self(dir)
1642        }
1643    }
1644    impl Drop for TempDir {
1645        fn drop(&mut self) {
1646            let _ = std::fs::remove_dir_all(&self.0);
1647        }
1648    }
1649
1650    fn filenames(layers: &[(PathBuf, serde_yaml::Value)]) -> Vec<String> {
1651        layers
1652            .iter()
1653            .map(|(p, _)| {
1654                p.file_name()
1655                    .and_then(|n| n.to_str())
1656                    .unwrap_or("?")
1657                    .to_string()
1658            })
1659            .collect()
1660    }
1661
1662    #[test]
1663    fn resolve_config_layers_base_only() {
1664        let tmp = TempDir::new();
1665        let base = tmp.0.join("fdl.yml");
1666        std::fs::write(&base, "a: 1\n").unwrap();
1667        let layers = resolve_config_layers(&base, None).unwrap();
1668        assert_eq!(filenames(&layers), vec!["fdl.yml"]);
1669    }
1670
1671    #[test]
1672    fn resolve_config_layers_base_with_env_overlay() {
1673        let tmp = TempDir::new();
1674        let base = tmp.0.join("fdl.yml");
1675        let env = tmp.0.join("fdl.ci.yml");
1676        std::fs::write(&base, "a: 1\n").unwrap();
1677        std::fs::write(&env, "b: 2\n").unwrap();
1678        let layers = resolve_config_layers(&base, Some("ci")).unwrap();
1679        assert_eq!(filenames(&layers), vec!["fdl.yml", "fdl.ci.yml"]);
1680    }
1681
1682    #[test]
1683    fn resolve_config_layers_env_inherits_from_mixin() {
1684        // fdl.ci.yml inherits from fdl.cloud.yml (standalone mix-in, not
1685        // derived from base). Combined chain: [base, cloud, ci].
1686        let tmp = TempDir::new();
1687        let base = tmp.0.join("fdl.yml");
1688        let cloud = tmp.0.join("fdl.cloud.yml");
1689        let ci = tmp.0.join("fdl.ci.yml");
1690        std::fs::write(&base, "a: 1\n").unwrap();
1691        std::fs::write(&cloud, "b: 2\n").unwrap();
1692        std::fs::write(&ci, "inherit-from: fdl.cloud.yml\nc: 3\n").unwrap();
1693        let layers = resolve_config_layers(&base, Some("ci")).unwrap();
1694        assert_eq!(
1695            filenames(&layers),
1696            vec!["fdl.yml", "fdl.cloud.yml", "fdl.ci.yml"]
1697        );
1698    }
1699
1700    #[test]
1701    fn resolve_config_layers_dedups_when_env_inherits_from_base() {
1702        // fdl.ci.yml inherits from fdl.yml directly. Base is already in
1703        // the layer list, so env's chain collapses into it — the final
1704        // list must not have fdl.yml twice.
1705        let tmp = TempDir::new();
1706        let base = tmp.0.join("fdl.yml");
1707        let ci = tmp.0.join("fdl.ci.yml");
1708        std::fs::write(&base, "a: 1\n").unwrap();
1709        std::fs::write(&ci, "inherit-from: fdl.yml\nb: 2\n").unwrap();
1710        let layers = resolve_config_layers(&base, Some("ci")).unwrap();
1711        assert_eq!(filenames(&layers), vec!["fdl.yml", "fdl.ci.yml"]);
1712    }
1713
1714    #[test]
1715    fn resolve_config_layers_merged_value_matches_chain() {
1716        // End-to-end: the merge result should reflect the chain order
1717        // (base < cloud < ci), with each subsequent layer overriding.
1718        let tmp = TempDir::new();
1719        let base = tmp.0.join("fdl.yml");
1720        let cloud = tmp.0.join("fdl.cloud.yml");
1721        let ci = tmp.0.join("fdl.ci.yml");
1722        std::fs::write(&base, "value: base\nkeep_base: yes\n").unwrap();
1723        std::fs::write(&cloud, "value: cloud\nkeep_cloud: yes\n").unwrap();
1724        std::fs::write(
1725            &ci,
1726            "inherit-from: fdl.cloud.yml\nvalue: ci\nkeep_ci: yes\n",
1727        )
1728        .unwrap();
1729        let merged = load_merged_value(&base, Some("ci")).unwrap();
1730        let m = merged.as_mapping().unwrap();
1731        // Last writer wins on `value`.
1732        assert_eq!(
1733            m.get(serde_yaml::Value::String("value".into())).unwrap(),
1734            &serde_yaml::Value::String("ci".into())
1735        );
1736        // Each layer's unique key survives.
1737        assert!(m.contains_key(serde_yaml::Value::String("keep_base".into())));
1738        assert!(m.contains_key(serde_yaml::Value::String("keep_cloud".into())));
1739        assert!(m.contains_key(serde_yaml::Value::String("keep_ci".into())));
1740    }
1741
1742    #[test]
1743    fn resolve_config_layers_missing_env_errors() {
1744        let tmp = TempDir::new();
1745        let base = tmp.0.join("fdl.yml");
1746        std::fs::write(&base, "a: 1\n").unwrap();
1747        let err = resolve_config_layers(&base, Some("nope")).unwrap_err();
1748        assert!(err.contains("nope"));
1749        assert!(err.contains("not found"));
1750    }
1751
1752    #[test]
1753    fn resolve_config_layers_base_inherit_from_chain() {
1754        // Base itself uses inherit-from: shared-defaults.yml. The
1755        // defaults live in a sibling file and are merged UNDER the base.
1756        let tmp = TempDir::new();
1757        let defaults = tmp.0.join("shared.yml");
1758        let base = tmp.0.join("fdl.yml");
1759        std::fs::write(&defaults, "policy: default\n").unwrap();
1760        std::fs::write(&base, "inherit-from: shared.yml\npolicy: override\n").unwrap();
1761        let layers = resolve_config_layers(&base, None).unwrap();
1762        assert_eq!(filenames(&layers), vec!["shared.yml", "fdl.yml"]);
1763    }
1764
1765    #[test]
1766    fn load_command_auto_probes_non_cargo_entry_and_writes_cache() {
1767        // Script-kind entry + missing cache: load_command should invoke
1768        // `<entry> --fdl-schema`, apply the result to cfg.schema, and
1769        // write it to .fdl/schema-cache/<name>.json for next time.
1770        let tmp = TempDir::new();
1771        let cmd_dir = tmp.0.join("mybench");
1772        std::fs::create_dir_all(&cmd_dir).unwrap();
1773
1774        let script = cmd_dir.join("emit.sh");
1775        let body = "#!/bin/sh\n\
1776                    if [ \"$1\" = \"--fdl-schema\" ]; then\n\
1777                      cat <<'JSON'\n\
1778                    { \"options\": { \"rounds\": { \"type\": \"int\", \"description\": \"N\" } } }\n\
1779                    JSON\n\
1780                      exit 0\n\
1781                    fi\n";
1782        std::fs::write(&script, body).unwrap();
1783        #[cfg(unix)]
1784        {
1785            use std::os::unix::fs::PermissionsExt;
1786            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1787        }
1788
1789        std::fs::write(cmd_dir.join("fdl.yml"), "entry: sh emit.sh\n").unwrap();
1790
1791        let cfg = load_command(&cmd_dir).expect("load ok");
1792        let schema = cfg.schema.expect("auto-probe must populate schema");
1793        assert!(schema.options.contains_key("rounds"));
1794
1795        // Second load reads the freshly-written cache (same content).
1796        let cached_path = crate::schema_cache::cache_path(&cmd_dir, "mybench");
1797        assert!(cached_path.is_file(), "cache file should exist");
1798    }
1799
1800    #[test]
1801    fn load_command_skips_auto_probe_for_cargo_entries() {
1802        // Cargo entries are deliberately not probed — a `cargo run
1803        // --fdl-schema` would compile the whole crate before help
1804        // renders. Missing cache + cargo entry ⇒ no schema, help
1805        // still renders (just without options).
1806        let tmp = TempDir::new();
1807        let cmd_dir = tmp.0.join("cargo-cmd");
1808        std::fs::create_dir_all(&cmd_dir).unwrap();
1809        std::fs::write(cmd_dir.join("fdl.yml"), "entry: cargo run --\n").unwrap();
1810
1811        let cfg = load_command(&cmd_dir).expect("load ok");
1812        assert!(
1813            cfg.schema.is_none(),
1814            "cargo entry must not be auto-probed (compile latency would ruin --help)"
1815        );
1816        let cached = crate::schema_cache::cache_path(&cmd_dir, "cargo-cmd");
1817        assert!(!cached.exists(), "no cache should be written for cargo entries");
1818    }
1819
1820    #[test]
1821    fn load_command_compile_true_overrides_cargo_skip() {
1822        // `compile: true` opts a cargo-entry command in to schema probing.
1823        // The probe still fails on `cargo run` here (no real binary to
1824        // produce JSON), but the load path must reach `probe()` rather
1825        // than short-circuiting on the cargo-skip heuristic. We assert
1826        // by observing that probing was attempted: when the skip kicks
1827        // in, the test for `is_cargo_entry` returns early; with the
1828        // override, control flows into probe() which fails silently.
1829        // Lacking a way to spy on the probe call itself, we rely on the
1830        // sibling test (`load_command_auto_probe_failure_falls_through_silently`)
1831        // to demonstrate the silent-fail path, and assert here that
1832        // `compile: false` still skips. See the explicit-opt-out test
1833        // below.
1834        let tmp = TempDir::new();
1835        let cmd_dir = tmp.0.join("cargo-compile-true");
1836        std::fs::create_dir_all(&cmd_dir).unwrap();
1837        std::fs::write(
1838            cmd_dir.join("fdl.yml"),
1839            "entry: cargo run --\ncompile: true\n",
1840        )
1841        .unwrap();
1842
1843        // Load must succeed; schema stays None because the bogus cargo
1844        // entry can't actually emit JSON, but the probe path was
1845        // exercised (no panic, no early-skip).
1846        let cfg = load_command(&cmd_dir).expect("load ok");
1847        assert_eq!(cfg.compile, Some(true), "compile field round-trips");
1848        assert!(cfg.schema.is_none());
1849    }
1850
1851    #[test]
1852    fn load_command_compile_false_keeps_cargo_skip() {
1853        // Explicit `compile: false` is the same as absent — cargo skip
1854        // stays in place.
1855        let tmp = TempDir::new();
1856        let cmd_dir = tmp.0.join("cargo-compile-false");
1857        std::fs::create_dir_all(&cmd_dir).unwrap();
1858        std::fs::write(
1859            cmd_dir.join("fdl.yml"),
1860            "entry: cargo run --\ncompile: false\n",
1861        )
1862        .unwrap();
1863
1864        let cfg = load_command(&cmd_dir).expect("load ok");
1865        assert_eq!(cfg.compile, Some(false));
1866        assert!(cfg.schema.is_none(), "cargo skip honored when compile: false");
1867        let cached = crate::schema_cache::cache_path(&cmd_dir, "cargo-compile-false");
1868        assert!(!cached.exists());
1869    }
1870
1871    #[test]
1872    fn load_command_auto_probe_failure_falls_through_silently() {
1873        // An entry that ignores --fdl-schema (or errors) must not break
1874        // help rendering. cfg.schema stays None, no cache written.
1875        let tmp = TempDir::new();
1876        let cmd_dir = tmp.0.join("silent");
1877        std::fs::create_dir_all(&cmd_dir).unwrap();
1878        // `/bin/true` ignores any args and exits 0 with empty stdout; probe
1879        // will reject "no JSON object" and Err — we want that swallowed.
1880        // Quoted so YAML doesn't parse the bareword `true` as a boolean.
1881        std::fs::write(cmd_dir.join("fdl.yml"), "entry: \"/bin/true\"\n").unwrap();
1882
1883        let cfg = load_command(&cmd_dir).expect("load must succeed despite probe error");
1884        assert!(cfg.schema.is_none());
1885    }
1886}