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