Skip to main content

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}