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 ──────────────────────────────────────────