flodl_cli/config/loading.rs
1//! Manifest discovery + load helpers: walks up from CWD to find the
2//! project manifest, loads YAML/JSON, resolves env-overlay layers,
3//! cluster-dispatch chain resolution.
4
5use std::path::{Path, PathBuf};
6
7
8use super::schema::ProjectConfig;
9
10/// Resolve whether a leaf command should fan out across the cluster, given
11/// the chain of [`super::schema::CommandSpec::cluster`] directives along its command path,
12/// ordered root → leaf.
13///
14/// Walks the chain from leaf back to root; the first `Some` value wins.
15/// This makes deeper overrides take precedence:
16///
17/// - root sets `cluster: true`, leaf unset → `true` (inherits)
18/// - root sets `cluster: true`, leaf sets `cluster: false` → `false` (override)
19/// - root unset, leaf sets `cluster: true` → `true`
20/// - all unset → `false` (no clustering by default)
21///
22/// Intended caller pattern (from a future dispatch in `run.rs`):
23///
24/// ```ignore
25/// let chain: Vec<Option<bool>> = ancestors.iter()
26/// .map(|spec| spec.cluster)
27/// .collect();
28/// if cluster_dispatch_enabled(&project, &chain) {
29/// // fan out across project.cluster.workers
30/// }
31/// ```
32///
33/// This is a pure function on the chain; cluster-block presence is checked
34/// separately by [`cluster_dispatch_enabled`].
35pub fn resolve_cluster_dispatch(chain: &[Option<bool>]) -> bool {
36 chain.iter().rev().find_map(|x| *x).unwrap_or(false)
37}
38
39/// Whether the leaf command's effective `cluster:` value resolves to `true`
40/// AND a `cluster:` topology is declared at the project root.
41///
42/// Without a project-root `cluster:` block, multi-host dispatch is
43/// unavailable regardless of any per-command directives along the chain.
44pub fn cluster_dispatch_enabled(project: &ProjectConfig, chain: &[Option<bool>]) -> bool {
45 project.cluster.is_some() && resolve_cluster_dispatch(chain)
46}
47
48
49// ── Config discovery ────────────────────────────────────────────────────
50
51pub(super) const CONFIG_NAMES: &[&str] = &["fdl.yaml", "fdl.yml", "fdl.json"];
52pub(super) const EXAMPLE_SUFFIXES: &[&str] = &[".example", ".dist"];
53
54/// Walk up from `start` looking for fdl.yaml.
55///
56/// If only an `.example` (or `.dist`) variant exists, offers to copy it
57/// to the real config path. This lets the repo commit `fdl.yaml.example`
58/// while `.gitignore`-ing `fdl.yaml` so users can customize locally.
59pub fn find_config(start: &Path) -> Option<PathBuf> {
60 let mut dir = start.to_path_buf();
61 loop {
62 // First pass: look for the real config.
63 for name in CONFIG_NAMES {
64 let candidate = dir.join(name);
65 if candidate.is_file() {
66 return Some(candidate);
67 }
68 }
69 // Second pass: look for .example/.dist variants.
70 for name in CONFIG_NAMES {
71 for suffix in EXAMPLE_SUFFIXES {
72 let example = dir.join(format!("{name}{suffix}"));
73 if example.is_file() {
74 let target = dir.join(name);
75 if try_copy_example(&example, &target) {
76 return Some(target);
77 }
78 // User declined: use the example directly.
79 return Some(example);
80 }
81 }
82 }
83 if !dir.pop() {
84 return None;
85 }
86 }
87}
88
89/// Locate the project config inside `dir` only — no walk-up, no
90/// example-copy prompt. For resolvers that already hold the project root
91/// and must not block on interactive prompts.
92pub fn find_config_in(dir: &Path) -> Option<PathBuf> {
93 CONFIG_NAMES.iter().map(|n| dir.join(n)).find(|c| c.is_file())
94}
95
96/// Prompt the user to copy an example config to the real path.
97/// Returns true if the copy succeeded.
98pub(super) fn try_copy_example(example: &Path, target: &Path) -> bool {
99 // Never prompt without a terminal: in CI, shell completions, or any
100 // piped context the prompt is invisible and `read_line` hits EOF,
101 // which the Y-default would treat as consent — silently adopting the
102 // example as the live config. Non-interactive callers just use the
103 // example file directly, copying nothing.
104 if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
105 return false;
106 }
107 let example_name = example.file_name().unwrap_or_default().to_string_lossy();
108 let target_name = target.file_name().unwrap_or_default().to_string_lossy();
109 eprintln!(
110 "fdl: found {example_name} but no {target_name}. \
111 Copy it to create your local config? [Y/n] "
112 );
113 let mut input = String::new();
114 if std::io::stdin().read_line(&mut input).is_err() {
115 return false;
116 }
117 let answer = input.trim().to_lowercase();
118 if answer.is_empty() || answer == "y" || answer == "yes" {
119 match std::fs::copy(example, target) {
120 Ok(_) => {
121 eprintln!("fdl: created {target_name} (edit to customize)");
122 true
123 }
124 Err(e) => {
125 eprintln!("fdl: failed to copy: {e}");
126 false
127 }
128 }
129 } else {
130 false
131 }
132}
133
134/// Load a project config from a specific path.
135pub fn load_project(path: &Path) -> Result<ProjectConfig, String> {
136 load_project_with_env(path, None)
137}
138
139/// Load a project config with an optional environment overlay.
140///
141/// When `env` is `Some`, looks for a sibling `fdl.<env>.{yml,yaml,json}` next
142/// to `base_path` and deep-merges it over the base before deserialization.
143/// Missing overlay files are a hard error — the user asked for this env, so
144/// silently ignoring it would be worse than a clear message.
145pub fn load_project_with_env(
146 base_path: &Path,
147 env: Option<&str>,
148) -> Result<ProjectConfig, String> {
149 let layers = resolve_config_layers(base_path, env)?;
150 let merged = crate::overlay::merge_layers(
151 layers.iter().map(|(_, v)| v.clone()).collect::<Vec<_>>(),
152 );
153 // An empty (or comments-only) fdl.yml — and empty overlays — merge to a
154 // null value. That is a valid "nothing configured" state, so return an
155 // all-defaults config instead of letting `from_str::<ProjectConfig>("null")`
156 // fail with a cryptic serde "invalid type: unit value" error.
157 if merged.is_null() {
158 return Ok(ProjectConfig::default());
159 }
160 // Re-serialize so `from_str`'s parser tracks line/col through deserialize.
161 // `from_value` discards positional info, leaving errors location-less.
162 let merged_str = serde_yaml_ng::to_string(&merged).map_err(|e| {
163 format!(
164 "{}: failed to re-serialize merged YAML for diagnostics: {e}",
165 base_path.display()
166 )
167 })?;
168 let cfg = serde_yaml_ng::from_str::<ProjectConfig>(&merged_str).map_err(|e| {
169 let names: Vec<String> = layers
170 .iter()
171 .map(|(p, _)| {
172 p.file_name()
173 .and_then(|n| n.to_str())
174 .unwrap_or("?")
175 .to_string()
176 })
177 .collect();
178 let env_hint = env
179 .map(|n| format!(" {n}"))
180 .unwrap_or_default();
181 let (loc_str, context) = match e.location() {
182 Some(loc) => (
183 format!(" at merged-view line {}, col {}", loc.line(), loc.column()),
184 extract_context(&merged_str, loc.line()),
185 ),
186 None => (String::new(), String::new()),
187 };
188 format!(
189 "{} (layers: {}){}: {}{}\n inspect merged view: fdl{} config show",
190 base_path.display(),
191 names.join(" + "),
192 loc_str,
193 e,
194 context,
195 env_hint
196 )
197 })?;
198 reject_user_ranks(&cfg, base_path)?;
199 Ok(cfg)
200}
201
202/// Reject `ranks:` in user-authored worker blocks. The key looks
203/// load-bearing but never was: rank assignment is computed from probed
204/// device counts (`ClusterConfig::populate_ranks`). The field must stay
205/// deserializable for the canonical-JSON wire round-trip, so serde's
206/// `deny_unknown_fields` cannot catch it; this check runs on the
207/// user-YAML entry path only.
208fn reject_user_ranks(cfg: &ProjectConfig, base_path: &Path) -> Result<(), String> {
209 let Some(cluster) = &cfg.cluster else {
210 return Ok(());
211 };
212 for (i, w) in cluster.workers.iter().enumerate() {
213 if !w.ranks.is_empty() {
214 return Err(format!(
215 "{}: cluster.workers[{i}] ({:?}) declares `ranks:`, which is \
216 not user configuration; ranks are computed from probed device \
217 counts at launch. Remove the key.",
218 base_path.display(),
219 w.host,
220 ));
221 }
222 }
223 Ok(())
224}
225
226/// Extract 3 lines of context around `line_no` (1-based) from the merged
227/// YAML string, formatted as numbered indented lines for inclusion in error
228/// messages. Returns empty if line_no is out of range.
229fn extract_context(text: &str, line_no: usize) -> String {
230 if line_no == 0 {
231 return String::new();
232 }
233 let lines: Vec<&str> = text.lines().collect();
234 if line_no > lines.len() {
235 return String::new();
236 }
237 let start = line_no.saturating_sub(2).max(1);
238 let end = (line_no + 1).min(lines.len());
239 let mut out = String::from("\n");
240 for n in start..=end {
241 let marker = if n == line_no { ">>" } else { " " };
242 out.push_str(&format!(" {marker} {n:>4}: {}\n", lines[n - 1]));
243 }
244 out
245}
246
247/// Load the raw merged [`serde_yaml_ng::Value`] for a config + optional env
248/// overlay. Exposed so callers like `fdl config show` can inspect the
249/// resolved view before it is deserialized into a strongly-typed struct.
250pub fn load_merged_value(
251 base_path: &Path,
252 env: Option<&str>,
253) -> Result<serde_yaml_ng::Value, String> {
254 let layers = resolve_config_layers(base_path, env)?;
255 Ok(crate::overlay::merge_layers(
256 layers.into_iter().map(|(_, v)| v).collect::<Vec<_>>(),
257 ))
258}
259
260/// Resolve every layer contributing to a config, in merge order, with
261/// `inherit-from:` chains expanded. Paired with the base file + optional
262/// env overlay, the result is `[chain(base)..., chain(env_overlay)...]`
263/// de-duplicated by canonical path (kept-first).
264///
265/// Used by `fdl config show` for per-leaf source annotation, and
266/// internally by [`load_merged_value`] / [`super::command::load_command_with_env`] so
267/// every consumer picks up `inherit-from:` uniformly.
268pub fn resolve_config_layers(
269 base_path: &Path,
270 env: Option<&str>,
271) -> Result<Vec<(PathBuf, serde_yaml_ng::Value)>, String> {
272 let mut layers = crate::overlay::resolve_chain(base_path)?;
273 if let Some(name) = env {
274 match crate::overlay::find_env_file(base_path, name) {
275 Some(p) => {
276 let env_chain = crate::overlay::resolve_chain(&p)?;
277 layers.extend(env_chain);
278 }
279 None => {
280 return Err(format!(
281 "environment `{name}` not found (expected fdl.{name}.yml next to {})",
282 base_path.display()
283 ));
284 }
285 }
286 }
287 // Dedup by canonical path, keeping first occurrence. An env overlay
288 // whose chain loops back to a file already in the base chain (same
289 // file via a different inheritance route) collapses cleanly.
290 let mut seen = std::collections::HashSet::new();
291 layers.retain(|(path, _)| seen.insert(path.clone()));
292 Ok(layers)
293}
294
295/// Source path list for a base config + env overlay, in merge order. Used
296/// by `fdl config show` to annotate which layer a value came from.
297pub fn config_layer_sources(base_path: &Path, env: Option<&str>) -> Vec<PathBuf> {
298 resolve_config_layers(base_path, env)
299 .map(|ls| ls.into_iter().map(|(p, _)| p).collect())
300 .unwrap_or_else(|_| vec![base_path.to_path_buf()])
301}