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