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