flodl_cli/config/command.rs
1//! Sub-command config loading from registered command directories.
2
3use std::path::{Path, PathBuf};
4
5use super::loading::{CONFIG_NAMES, EXAMPLE_SUFFIXES, try_copy_example};
6use super::schema::{validate_schema, CommandConfig};
7
8/// Load a command config from a sub-directory.
9///
10/// Applies the same `.example`/`.dist` fallback as [`super::loading::find_config`]. If a
11/// `schema:` block is present, validates it before returning.
12pub fn load_command(dir: &Path) -> Result<CommandConfig, String> {
13 load_command_with_env(dir, None)
14}
15
16/// Load a sub-command config with an optional environment overlay.
17///
18/// Applies the same `.example`/`.dist` fallback as [`super::loading::find_config`] to locate
19/// the base file, then deep-merges a sibling `fdl.<env>.yml` overlay if one
20/// exists. A *missing* overlay is silently accepted here (different from
21/// [`super::loading::load_project_with_env`]) — envs declared at the project root don't
22/// have to exist for every sub-command.
23pub fn load_command_with_env(dir: &Path, env: Option<&str>) -> Result<CommandConfig, String> {
24 // Resolve the base config path (with .example fallback, same as before).
25 let mut base_path: Option<PathBuf> = None;
26 for name in CONFIG_NAMES {
27 let path = dir.join(name);
28 if path.is_file() {
29 base_path = Some(path);
30 break;
31 }
32 }
33 if base_path.is_none() {
34 for name in CONFIG_NAMES {
35 for suffix in EXAMPLE_SUFFIXES {
36 let example = dir.join(format!("{name}{suffix}"));
37 if example.is_file() {
38 let target = dir.join(name);
39 let src = if try_copy_example(&example, &target) {
40 target
41 } else {
42 example
43 };
44 base_path = Some(src);
45 break;
46 }
47 }
48 if base_path.is_some() {
49 break;
50 }
51 }
52 }
53 let base_path = base_path
54 .ok_or_else(|| format!("no fdl.yml found in {}", dir.display()))?;
55
56 // Layered load: base chain + optional env overlay chain. Both sides
57 // run through `resolve_chain` so `inherit-from:` composes the same
58 // way for nested commands as for the project root.
59 let mut layers = crate::overlay::resolve_chain(&base_path)?;
60 if let Some(name) = env {
61 if let Some(p) = crate::overlay::find_env_file(&base_path, name) {
62 layers.extend(crate::overlay::resolve_chain(&p)?);
63 }
64 }
65 let mut seen = std::collections::HashSet::new();
66 layers.retain(|(path, _)| seen.insert(path.clone()));
67 let merged = crate::overlay::merge_layers(
68 layers.into_iter().map(|(_, v)| v).collect::<Vec<_>>(),
69 );
70 // Re-serialize so `from_str`'s parser tracks line/col through
71 // deserialize (`from_value` discards positional info). With
72 // `deny_unknown_fields` on the config structs, unknown-key errors
73 // carry a location this way. Positions refer to the merged
74 // document, not any single source file, when overlays are in play.
75 let merged_str = serde_yaml_ng::to_string(&merged).map_err(|e| {
76 format!(
77 "{}: failed to re-serialize merged YAML for diagnostics: {e}",
78 base_path.display()
79 )
80 })?;
81 let mut cfg: CommandConfig = serde_yaml_ng::from_str(&merged_str)
82 .map_err(|e| format!("{}: {}", base_path.display(), e))?;
83
84 if let Some(schema) = &cfg.schema {
85 validate_schema(schema)
86 .map_err(|e| format!("schema error in {}/fdl.yml: {e}", dir.display()))?;
87 // Preset validation (choice values + strict unknown-key rejection)
88 // is intentionally deferred to the exec path. Load-time validation
89 // would block `fdl <cmd> --help` whenever ANY preset in the config
90 // has a typo — worse UX than letting help render and erroring only
91 // when the broken preset is actually invoked.
92 }
93
94 // Cache precedence: a valid, fresh cached schema (written by `fdl <cmd>
95 // --refresh-schema` or auto-probed below) wins over the inline YAML
96 // schema. This lets a binary become the source of truth for its own
97 // surface once it opts into the `--fdl-schema` contract. A cache that
98 // is older than the command's fdl.yml is treated as stale and skipped
99 // — the inline schema (if any) reasserts until a refresh happens.
100 let cmd_name = dir
101 .file_name()
102 .and_then(|n| n.to_str())
103 .unwrap_or("_");
104 let cache = crate::schema_cache::cache_path(dir, cmd_name);
105 // Reference mtimes: config files that, when edited, might invalidate
106 // the cached schema (e.g. changing `entry:` to point somewhere else).
107 let refs: Vec<std::path::PathBuf> = CONFIG_NAMES
108 .iter()
109 .map(|n| dir.join(n))
110 .filter(|p| p.exists())
111 .collect();
112 if !crate::schema_cache::is_stale(&cache, &refs) {
113 if let Some(cached) = crate::schema_cache::read_cache(&cache) {
114 cfg.schema = Some(cached);
115 }
116 } else if let Some(entry) = cfg.entry.as_deref() {
117 // Auto-probe non-cargo entries when the cache is stale or missing.
118 // Cargo entries are skipped by default — `cargo run --fdl-schema`
119 // triggers a full compile which is unacceptable latency for `-h`
120 // — unless the yml explicitly opts in via `compile: true`.
121 // Scripts and pre-built binaries are expected to handle the flag
122 // cheaply (emit JSON and exit), so probing them on demand is safe.
123 // Probe failures are swallowed: an entry that doesn't implement
124 // `--fdl-schema` simply falls through to the inline schema (or no
125 // schema) — help still renders.
126 let opts_into_compile = cfg.compile.unwrap_or(false);
127 let should_probe =
128 !crate::schema_cache::is_cargo_entry(entry) || opts_into_compile;
129 if should_probe {
130 if let Ok(probed) =
131 crate::schema_cache::probe(entry, dir, cfg.docker.as_deref())
132 {
133 // Best-effort cache write: if the dir is read-only, the
134 // schema still applies to this invocation, we just re-probe
135 // next time. Non-fatal.
136 let _ = crate::schema_cache::write_cache(&cache, &probed);
137 cfg.schema = Some(probed);
138 }
139 }
140 }
141
142 Ok(cfg)
143}
144
145// ── Strict-mode validation ──────────────────────────────────────────────
146