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