Skip to main content

flodl_cli/config/
schema.rs

1//! fdl.yaml schema types: ProjectConfig, CommandConfig, CommandSpec,
2//! CommandKind, Schema, OptionSpec, ArgSpec, plus `validate_schema`.
3
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8
9use super::cluster::{ClusterConfig, DdpConfig, OutputConfig, TrainingConfig};
10
11/// Root fdl.yaml at project root.
12///
13/// `deny_unknown_fields`: a mistyped key (e.g. `comands:`) errors at
14/// load, naming the field and listing the valid ones, instead of
15/// silently configuring nothing. Same rigor as the CLI's unknown-flag
16/// rejection; applies to every user-facing config struct below.
17#[derive(Debug, Default, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct ProjectConfig {
20    #[serde(default)]
21    pub description: Option<String>,
22    /// Commands defined at this level. Each value is a [`CommandSpec`] that
23    /// encodes the kind of command (inline `run` script, `path` pointer to
24    /// a child fdl.yml, or inline preset reusing the parent entry).
25    #[serde(default)]
26    pub commands: BTreeMap<String, CommandSpec>,
27    /// Multi-host cluster topology. When present, commands marked
28    /// `cluster: true` are dispatched across every worker in
29    /// [`ClusterConfig::workers`]. Lives at the project root because the
30    /// topology is shared across all sub-command fdl.yml files; the
31    /// canonical author pattern is to put the cluster block in a
32    /// `fdl.<env>.yml` overlay (e.g. `fdl.vm.yml`) that deep-merges over
33    /// the base `fdl.yml`.
34    #[serde(default)]
35    pub cluster: Option<ClusterConfig>,
36}
37
38// ── Sub-command config ──────────────────────────────────────────────────
39
40/// Sub-command fdl.yaml (e.g., ddp-bench/fdl.yaml).
41///
42/// Identical shape to [`ProjectConfig`] but with an executable `entry:`
43/// and optional structured config sections (ddp/training/output) that
44/// inline preset commands can override.
45#[derive(Debug, Default, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct CommandConfig {
48    #[serde(default)]
49    pub description: Option<String>,
50    #[serde(default)]
51    pub entry: Option<String>,
52    /// Docker compose service name. When set, entry is wrapped in
53    /// `docker compose run --rm <service> bash -c "cd <workdir> && <entry> <args>"`.
54    #[serde(default)]
55    pub docker: Option<String>,
56    #[serde(default)]
57    pub ddp: Option<DdpConfig>,
58    #[serde(default)]
59    pub training: Option<TrainingConfig>,
60    #[serde(default)]
61    pub output: Option<OutputConfig>,
62    /// Nested commands — inline presets of this config's entry, standalone
63    /// `run` scripts, or `path` pointers to child fdl.yml files.
64    #[serde(default)]
65    pub commands: BTreeMap<String, CommandSpec>,
66    /// Help-only placeholder name for the first-positional slot when
67    /// `commands:` holds presets. Defaults to "preset". Pure UX — it
68    /// does not affect dispatch (presets are always looked up by name).
69    /// Useful to match domain vocabulary, e.g. `arg-name: recipe` or
70    /// `arg-name: target`.
71    #[serde(default, rename = "arg-name")]
72    pub arg_name: Option<String>,
73    /// Inline interim schema (before `<entry> --fdl-schema` is implemented).
74    /// Drives help rendering, validation, and completions.
75    #[serde(default)]
76    pub schema: Option<Schema>,
77    /// Opt-in flag for cargo-entry schema probing. Cargo entries are
78    /// auto-skipped from probing because `cargo run --fdl-schema` triggers
79    /// a full compile (unacceptable latency for `-h`). Setting `compile:
80    /// true` declares "I'm fine with the first-run compile cost — probe
81    /// my binary for its real schema." Subsequent invocations use the
82    /// mtime-keyed cache and pay no compile cost. Absent or `false` keeps
83    /// the default skip behavior, so the inline yml schema (if any)
84    /// stays the source of truth.
85    #[serde(default)]
86    pub compile: Option<bool>,
87}
88
89// ── Unified command specification ───────────────────────────────────────
90
91/// A command at any nesting level. Three mutually-exclusive kinds are
92/// recognised at resolve time:
93///
94/// - **Path** (`path` set, or by default when the map is empty/null): the
95///   command is a pointer to a child `fdl.yml`. By convention the path is
96///   `./<command-name>/` when omitted.
97/// - **Run** (`run` set): the command is a self-contained shell script
98///   that is executed as-is. Optional `docker:` service routes it through
99///   `docker compose`.
100/// - **Preset**: neither `path` nor `run` is set. The command merges its
101///   `ddp` / `training` / `output` / `options` fields over the enclosing
102///   `CommandConfig` defaults and invokes that config's `entry:`.
103#[derive(Debug, Default, Clone)]
104pub struct CommandSpec {
105    pub description: Option<String>,
106    /// Inline shell command. Mutex with `path`.
107    pub run: Option<String>,
108    /// Default trailing tokens for a `run:` command. Split on its own
109    /// first `--` into pre/post halves; user args after fdl's first
110    /// `--` are split similarly, then everything merges as
111    /// `[append-pre] [user-pre] -- [append-post] [user-post]`. Append
112    /// seeds defaults; user args last-win on each side. The legacy
113    /// `append: -- --nocapture` shape (empty pre, libtest tokens post)
114    /// keeps working as a degenerate case. Drop entirely with the
115    /// global `--no-append` flag.
116    pub append: Option<String>,
117    /// Pointer to a child directory containing its own `fdl.yml`. Absolute
118    /// or relative to the declaring config's directory. Mutex with `run`.
119    /// `None` + no other fields = "use the convention path
120    /// `./<command-name>/`".
121    pub path: Option<String>,
122    /// Docker compose service for `run`-kind commands.
123    pub docker: Option<String>,
124    /// Preset overrides. Only consulted when neither `run` nor `path` is set.
125    pub ddp: Option<DdpConfig>,
126    pub training: Option<TrainingConfig>,
127    pub output: Option<OutputConfig>,
128    pub options: BTreeMap<String, serde_json::Value>,
129    /// Dispatch this command across every host in
130    /// [`ProjectConfig::cluster`]'s `hosts` list. Set to `true` to opt the
131    /// command into multi-host execution. Default `None` means single-host
132    /// (today's behavior). Has no effect when no `cluster:` block is
133    /// declared at the project root.
134    pub cluster: Option<bool>,
135    /// Per-entry parse failure, captured instead of failing the whole
136    /// `commands:` map. Unknown keys (`deny_unknown_fields`) and type
137    /// errors inside ONE command's block must not block `--help` or
138    /// sibling commands — validation stays scoped to the single thing
139    /// invoked. Surfaced through [`Self::kind`], which every dispatch
140    /// path consults, so the error fires exactly when this command is
141    /// used.
142    pub load_error: Option<String>,
143}
144
145/// What kind of command is this, resolved from a [`CommandSpec`].
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum CommandKind {
148    /// `run: "…"` — execute the inline shell command (optionally in Docker).
149    Run,
150    /// `path: "…"` or convention default — load `<path>/fdl.yml` and
151    /// recurse.
152    Path,
153    /// Neither `run` nor `path`. Merges preset fields onto the enclosing
154    /// `CommandConfig` defaults and invokes that config's `entry:`.
155    Preset,
156}
157
158impl CommandSpec {
159    /// Classify this command. Returns an error when both `run` and `path`
160    /// are declared — always a mistake, caught loudly rather than silently
161    /// picking one. Also rejects `docker:` without `run:`: the docker
162    /// service wraps the inline run-script, so pairing it with a `path:`
163    /// pointer or a preset entry is always silent-noop territory.
164    pub fn kind(&self) -> Result<CommandKind, String> {
165        if let Some(e) = &self.load_error {
166            return Err(e.clone());
167        }
168        if self.docker.is_some() && self.run.is_none() {
169            return Err(
170                "command declares `docker:` without `run:`; \
171                 `docker:` only wraps inline run-scripts"
172                    .to_string(),
173            );
174        }
175        if self.append.is_some() && self.run.is_none() {
176            return Err(
177                "command declares `append:` without `run:`; \
178                 `append:` only forwards trailing tokens for inline run-scripts"
179                    .to_string(),
180            );
181        }
182        match (self.run.as_deref(), self.path.as_deref()) {
183            (Some(_), Some(_)) => Err(
184                "command declares both `run:` and `path:`; \
185                 only one is allowed"
186                    .to_string(),
187            ),
188            (Some(_), None) => Ok(CommandKind::Run),
189            (None, Some(_)) => Ok(CommandKind::Path),
190            (None, None) => {
191                // No kind-selecting field. If preset fields are present,
192                // treat as Preset; otherwise, fall through to Path (the
193                // convention-default: `./<name>/fdl.yml`).
194                if self.ddp.is_some()
195                    || self.training.is_some()
196                    || self.output.is_some()
197                    || !self.options.is_empty()
198                {
199                    Ok(CommandKind::Preset)
200                } else {
201                    Ok(CommandKind::Path)
202                }
203            }
204        }
205    }
206
207    /// Resolve the effective directory for a `Path`-kind command declared
208    /// in `parent_dir`. Applies the `./<name>/` convention when `path` is
209    /// unset.
210    pub fn resolve_path(&self, name: &str, parent_dir: &Path) -> PathBuf {
211        match &self.path {
212            Some(p) => parent_dir.join(p),
213            None => parent_dir.join(name),
214        }
215    }
216}
217
218// Custom Deserialize so that `commands: { name: ~ }` (YAML null) and
219// `commands: { name: }` (empty value) both deserialize to a default
220// `CommandSpec`. Without this, serde_yaml_ng errors on null because a
221// struct expects a map.
222impl<'de> Deserialize<'de> for CommandSpec {
223    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
224    where
225        D: serde::Deserializer<'de>,
226    {
227        #[derive(Deserialize)]
228        #[serde(deny_unknown_fields)]
229        struct Inner {
230            #[serde(default)]
231            description: Option<String>,
232            #[serde(default)]
233            run: Option<String>,
234            #[serde(default)]
235            append: Option<String>,
236            #[serde(default)]
237            path: Option<String>,
238            #[serde(default)]
239            docker: Option<String>,
240            #[serde(default)]
241            ddp: Option<DdpConfig>,
242            #[serde(default)]
243            training: Option<TrainingConfig>,
244            #[serde(default)]
245            output: Option<OutputConfig>,
246            #[serde(default)]
247            options: BTreeMap<String, serde_json::Value>,
248            #[serde(default)]
249            cluster: Option<bool>,
250        }
251
252        let raw = serde_yaml_ng::Value::deserialize(deserializer)?;
253        if matches!(raw, serde_yaml_ng::Value::Null) {
254            return Ok(Self::default());
255        }
256        // A bad entry (unknown key via deny_unknown_fields, wrong type)
257        // is captured as `load_error` instead of failing the enclosing
258        // `commands:` map: help and sibling commands keep working, and
259        // `kind()` raises the error when THIS command is invoked.
260        let inner: Inner = match serde_yaml_ng::from_value(raw) {
261            Ok(inner) => inner,
262            Err(e) => {
263                return Ok(Self {
264                    load_error: Some(e.to_string()),
265                    ..Self::default()
266                });
267            }
268        };
269        Ok(Self {
270            description: inner.description,
271            run: inner.run,
272            append: inner.append,
273            path: inner.path,
274            docker: inner.docker,
275            ddp: inner.ddp,
276            training: inner.training,
277            output: inner.output,
278            options: inner.options,
279            cluster: inner.cluster,
280            load_error: None,
281        })
282    }
283}
284
285// ── Schema (interim hand-written, future `<entry> --fdl-schema`) ────────
286
287/// The schema declared inline in a sub-command's fdl.yaml. Maps 1:1 to
288/// what `<entry> --fdl-schema` will later emit as JSON.
289/// `deny_unknown_fields` also applies to the `--fdl-schema` probe JSON:
290/// a schema emitted by a NEWER flodl-cli-macros than this fdl knows
291/// fails to parse rather than silently dropping the unknown field, and
292/// the probe layer falls back to the inline yml schema (or none) —
293/// help always renders (see `schema_cache`).
294#[derive(Debug, Clone, Default, Deserialize, Serialize)]
295#[serde(deny_unknown_fields)]
296pub struct Schema {
297    #[serde(default, skip_serializing_if = "Vec::is_empty")]
298    pub args: Vec<ArgSpec>,
299    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
300    pub options: BTreeMap<String, OptionSpec>,
301    /// When true, the fdl layer rejects options not declared in the
302    /// schema before the sub-command's entry ever runs. Two validation
303    /// points:
304    ///
305    /// 1. *Load time* — preset `options:` maps are checked against the
306    ///    enclosing `schema.options` (see [`super::validation::validate_presets_strict`]).
307    ///    A typo like `options: { batchsize: 32 }` when the schema
308    ///    declares `batch-size` is a loud load error.
309    /// 2. *Dispatch time* — the user's extra argv tail is tokenized
310    ///    against the schema (see [`super::validation::validate_tail`]). Unknown flags
311    ///    error out with a "did you mean" suggestion instead of being
312    ///    silently forwarded.
313    ///
314    /// **Validation NOT gated by `strict`** — always-on for declared
315    /// items, so positive assertions from the schema always hold:
316    /// - `choices:` on options: the user's value and any preset YAML
317    ///   value must be in the list.
318    /// - `choices:` on positional args: the user's value must be in
319    ///   the list (when strict is off, this may mis-fire if unknown
320    ///   flags push orphan values into positional slots — opt into
321    ///   strict for clean positional handling).
322    ///
323    /// `strict` is purely about **unknown** options/args, not about
324    /// validating declared contracts.
325    #[serde(default, skip_serializing_if = "is_false")]
326    pub strict: bool,
327    /// One-line human description of this node. Usually unset for the root
328    /// of a flat schema (help banners come from the binary's struct doc);
329    /// for a child under [`Self::commands`] it carries the subcommand's
330    /// summary (the enum variant's doc-comment), rendered in the parent
331    /// `--help` COMMANDS list.
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub description: Option<String>,
334    /// Sub-command tree. Empty for a leaf — the common case: a single
335    /// `#[derive(FdlArgs)]` struct. Non-empty for a variant-shaped CLI
336    /// (`#[derive(FdlArgs)]` on an enum of newtype variants), where each key
337    /// is a subcommand name and each value is that subcommand's own schema.
338    ///
339    /// A node is either a **leaf** (`args` / `options`) or a **branch**
340    /// (`commands`), never both — enforced by [`validate_schema`]. The shape
341    /// mirrors the recursive `commands:` map already used by
342    /// [`CommandConfig`]/[`super::ProjectConfig`] at the yaml layer.
343    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
344    pub commands: BTreeMap<String, Schema>,
345}
346
347/// A flag option, `--name` / `-x`.
348#[derive(Debug, Clone, Deserialize, Serialize)]
349#[serde(deny_unknown_fields)]
350pub struct OptionSpec {
351    #[serde(rename = "type")]
352    pub ty: String,
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub description: Option<String>,
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub default: Option<serde_json::Value>,
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub choices: Option<Vec<serde_json::Value>>,
359    /// Single-letter short alias.
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub short: Option<String>,
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub env: Option<String>,
364    /// Shell snippet producing completion values.
365    /// Consumed by `fdl completions <shell>`.
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    #[allow(dead_code)]
368    pub completer: Option<String>,
369}
370
371/// A positional argument.
372#[derive(Debug, Clone, Deserialize, Serialize)]
373#[serde(deny_unknown_fields)]
374pub struct ArgSpec {
375    pub name: String,
376    #[serde(rename = "type")]
377    pub ty: String,
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub description: Option<String>,
380    #[serde(default = "default_required")]
381    pub required: bool,
382    #[serde(default, skip_serializing_if = "is_false")]
383    pub variadic: bool,
384    #[serde(default, skip_serializing_if = "Option::is_none")]
385    pub default: Option<serde_json::Value>,
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub choices: Option<Vec<serde_json::Value>>,
388    /// Shell snippet producing completion values.
389    /// Consumed by `fdl completions <shell>`.
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    #[allow(dead_code)]
392    pub completer: Option<String>,
393}
394
395fn is_false(b: &bool) -> bool {
396    !*b
397}
398
399fn default_required() -> bool {
400    true
401}
402
403/// Flags reserved at the fdl level — no sub-command option may shadow them.
404/// Kept in sync with main.rs dispatch.
405const RESERVED_LONGS: &[&str] = &[
406    "help", "version", "quiet", "env",
407];
408const RESERVED_SHORTS: &[&str] = &[
409    "h", "V", "q", "v", "e",
410];
411const VALID_TYPES: &[&str] = &[
412    "string", "int", "float", "bool", "path",
413    "list[string]", "list[int]", "list[float]", "list[path]",
414];
415
416/// Check a schema for collisions and structural issues.
417///
418/// Loud-at-load-time: ambiguity caught here is cheaper to fix than mysterious
419/// pass-through behavior at runtime.
420pub fn validate_schema(schema: &Schema) -> Result<(), String> {
421    // Branch node (variant-shaped CLI): a subcommand tree. A node is
422    // either a leaf (args/options) or a branch (commands), never both —
423    // the enum derive emits branches with empty args/options, and a
424    // hand-authored yaml tree must keep its flags on the leaves.
425    if !schema.commands.is_empty() {
426        if !schema.args.is_empty() || !schema.options.is_empty() {
427            return Err(
428                "schema declares both `commands` (a subcommand tree) and \
429                 top-level `args`/`options`; a node is either a leaf or a \
430                 branch, not both — move the flags onto the subcommands"
431                    .to_string(),
432            );
433        }
434        for (name, child) in &schema.commands {
435            if name.trim().is_empty() {
436                return Err("schema `commands` has an empty subcommand name".to_string());
437            }
438            validate_schema(child).map_err(|e| format!("subcommand `{name}`: {e}"))?;
439        }
440        return Ok(());
441    }
442
443    // Options: check types, shorts, reserved flags.
444    let mut short_seen: BTreeMap<String, String> = BTreeMap::new();
445    for (long, spec) in &schema.options {
446        if !VALID_TYPES.contains(&spec.ty.as_str()) {
447            return Err(format!(
448                "option --{}: unknown type '{}' (valid: {})",
449                long,
450                spec.ty,
451                VALID_TYPES.join(", ")
452            ));
453        }
454        if RESERVED_LONGS.contains(&long.as_str()) {
455            return Err(format!(
456                "option --{long} shadows a reserved fdl-level flag"
457            ));
458        }
459        if let Some(s) = &spec.short {
460            if s.chars().count() != 1 {
461                return Err(format!(
462                    "option --{long}: `short: \"{s}\"` must be a single character"
463                ));
464            }
465            if RESERVED_SHORTS.contains(&s.as_str()) {
466                return Err(format!(
467                    "option --{long}: short -{s} shadows a reserved fdl-level flag"
468                ));
469            }
470            if let Some(prev) = short_seen.insert(s.clone(), long.clone()) {
471                return Err(format!(
472                    "options --{prev} and --{long} both declare short -{s}"
473                ));
474            }
475        }
476    }
477
478    // Args: check types, variadic-only-at-end, no-required-after-optional.
479    let mut seen_optional = false;
480    let mut name_seen: BTreeMap<String, ()> = BTreeMap::new();
481    for (i, arg) in schema.args.iter().enumerate() {
482        if !VALID_TYPES.contains(&arg.ty.as_str()) {
483            return Err(format!(
484                "arg <{}>: unknown type '{}' (valid: {})",
485                arg.name,
486                arg.ty,
487                VALID_TYPES.join(", ")
488            ));
489        }
490        if name_seen.insert(arg.name.clone(), ()).is_some() {
491            return Err(format!("duplicate positional name <{}>", arg.name));
492        }
493        if arg.variadic && i != schema.args.len() - 1 {
494            return Err(format!(
495                "arg <{}>: variadic positional must be the last one",
496                arg.name
497            ));
498        }
499        let is_optional = !arg.required || arg.default.is_some();
500        if arg.required && arg.default.is_some() {
501            return Err(format!(
502                "arg <{}>: `required: true` with a default is a contradiction",
503                arg.name
504            ));
505        }
506        if seen_optional && arg.required && arg.default.is_none() {
507            return Err(format!(
508                "arg <{}>: required positional cannot follow an optional one",
509                arg.name
510            ));
511        }
512        if is_optional {
513            seen_optional = true;
514        }
515    }
516
517    Ok(())
518}
519
520// ── Structured config sections ──────────────────────────────────────────