Skip to main content

flodl_cli/
overlay.rs

1//! Multi-environment configuration overlays.
2//!
3//! An `fdl.yml` project manifest can be layered with per-environment files
4//! (e.g. `fdl.local.yml`, `fdl.ci.yml`, `fdl.cloud.yml`). When an environment
5//! is active, its file is deep-merged on top of the base config before the
6//! strongly-typed [`ProjectConfig`](crate::config::ProjectConfig) /
7//! [`CommandConfig`](crate::config::CommandConfig) deserialization runs.
8//!
9//! # Merge rules
10//!
11//! - **Maps**: deep-merge. Recurse into nested maps; overlay keys win.
12//! - **Scalars**: replace. Overlay value takes over.
13//! - **Lists**: replace entirely. (Order is contentious — append/prepend
14//!   modes cause more debugging pain than they save.)
15//! - **`null` deletes**: a key set to `null` in the overlay removes it from
16//!   the merged map (not "write null"). Useful for "reset to defaults in
17//!   this env."
18//!
19//! # Discovery
20//!
21//! Sibling files matching `fdl.<env>.{yml,yaml,json}` alongside the base
22//! config. The `<env>` token is the first-arg env selector.
23
24use std::path::{Path, PathBuf};
25
26use serde_yaml::{Mapping, Value};
27
28// ── Deep-merge ──────────────────────────────────────────────────────────
29
30/// Deep-merge `over` onto `base`. Maps recurse; scalars and lists replace;
31/// `null` values in a map context delete the key from the result.
32///
33/// Non-Mapping destinations are replaced wholesale when the overlay is a
34/// Mapping too — i.e. no cross-type merging, the newer value wins.
35pub fn deep_merge(base: Value, over: Value) -> Value {
36    match (base, over) {
37        (Value::Mapping(base_map), Value::Mapping(over_map)) => {
38            Value::Mapping(merge_mapping(base_map, over_map))
39        }
40        // Scalar, sequence, or type-change: overlay replaces base.
41        (_, over) => over,
42    }
43}
44
45fn merge_mapping(mut base: Mapping, over: Mapping) -> Mapping {
46    for (k, v) in over {
47        if matches!(v, Value::Null) {
48            base.remove(&k);
49            continue;
50        }
51        match base.remove(&k) {
52            Some(existing) => {
53                base.insert(k, deep_merge(existing, v));
54            }
55            None => {
56                base.insert(k, v);
57            }
58        }
59    }
60    base
61}
62
63/// Merge a chain of layers left-to-right. The first is the base; each
64/// subsequent layer is merged on top of the running result.
65pub fn merge_layers<I>(layers: I) -> Value
66where
67    I: IntoIterator<Item = Value>,
68{
69    layers
70        .into_iter()
71        .reduce(deep_merge)
72        .unwrap_or(Value::Null)
73}
74
75// ── Discovery ───────────────────────────────────────────────────────────
76
77/// Config filename extensions in preference order. Mirrors `config::CONFIG_NAMES`
78/// but exposed here so overlay lookup matches sibling base files.
79const EXTENSIONS: &[&str] = &["yml", "yaml", "json"];
80
81/// Find a sibling overlay for `env` next to `base_config`.
82///
83/// `base_config` should be the resolved path to the base `fdl.yml` (not a
84/// directory). Returns `Some(path)` if `fdl.<env>.<ext>` exists for any
85/// supported extension, `None` otherwise.
86pub fn find_env_file(base_config: &Path, env: &str) -> Option<PathBuf> {
87    let dir = base_config.parent()?;
88    for ext in EXTENSIONS {
89        let candidate = dir.join(format!("fdl.{env}.{ext}"));
90        if candidate.is_file() {
91            return Some(candidate);
92        }
93    }
94    None
95}
96
97/// List every environment overlay discoverable beside the base config.
98///
99/// Returns env names (without `fdl.` prefix or extension), sorted. Duplicate
100/// names across extensions are de-duplicated — the first-found wins, matching
101/// [`find_env_file`] precedence.
102pub fn list_envs(base_config: &Path) -> Vec<String> {
103    let Some(dir) = base_config.parent() else {
104        return Vec::new();
105    };
106    let entries = match std::fs::read_dir(dir) {
107        Ok(r) => r,
108        Err(_) => return Vec::new(),
109    };
110    let mut envs = std::collections::BTreeSet::new();
111    for entry in entries.flatten() {
112        let name = entry.file_name();
113        let Some(name_str) = name.to_str() else {
114            continue;
115        };
116        let Some(stripped) = name_str.strip_prefix("fdl.") else {
117            continue;
118        };
119        // Must have at least one `.` separating env name from extension.
120        let Some((env, ext)) = stripped.rsplit_once('.') else {
121            continue;
122        };
123        if env.is_empty() || !EXTENSIONS.contains(&ext) {
124            continue;
125        }
126        envs.insert(env.to_string());
127    }
128    envs.into_iter().collect()
129}
130
131// ── Provenance-tracking merge ───────────────────────────────────────────
132//
133// [`deep_merge`] is lossy: once values collapse together we lose track of
134// which layer contributed each leaf. For `fdl config show`'s per-line
135// source annotation we need the merged *and* the origin, so we carry a
136// parallel tree that records a layer index at every leaf / sequence /
137// replaced-wholesale value. Maps are recursive: each entry carries its
138// own origin, the map itself has no single source. Sequences are
139// replaced wholesale, so they behave as leaves — the whole list is
140// attributed to whichever layer last wrote it.
141
142/// A merged value plus the layer that produced each leaf.
143///
144/// Layer indices are 0-based and refer to the slice passed to
145/// [`merge_layers_annotated`]: `0` is the base, `1` is the first overlay,
146/// and so on. Callers map indices to display labels (filenames, usually)
147/// at render time.
148#[derive(Debug, Clone)]
149pub enum AnnotatedNode {
150    /// Terminal value: scalar, null, or sequence. `source` is the layer
151    /// that last wrote this value.
152    Leaf { value: Value, source: usize },
153    /// Mapping node. `entries` preserves insertion order matching
154    /// [`deep_merge`]'s re-key-to-end behaviour (overridden keys move to
155    /// the tail of the map, matching the final `serde_yaml` serialisation).
156    Map { entries: Vec<(Value, AnnotatedNode)> },
157}
158
159impl AnnotatedNode {
160    /// Materialise the merged [`Value`] — useful for equality tests
161    /// against [`deep_merge`] output.
162    pub fn to_value(&self) -> Value {
163        match self {
164            AnnotatedNode::Leaf { value, .. } => value.clone(),
165            AnnotatedNode::Map { entries } => {
166                let mut m = Mapping::new();
167                for (k, v) in entries {
168                    m.insert(k.clone(), v.to_value());
169                }
170                Value::Mapping(m)
171            }
172        }
173    }
174}
175
176/// Merge a chain of layers left-to-right with provenance tracking. Mirrors
177/// [`merge_layers`] but returns an [`AnnotatedNode`] instead of a flat
178/// [`Value`]. Layer indices in the result are positions into `layers`.
179pub fn merge_layers_annotated(layers: &[Value]) -> AnnotatedNode {
180    if layers.is_empty() {
181        return AnnotatedNode::Leaf {
182            value: Value::Null,
183            source: 0,
184        };
185    }
186
187    let mut result = to_annotated(&layers[0], 0);
188    for (i, layer) in layers.iter().enumerate().skip(1) {
189        result = deep_merge_annotated(result, layer, i);
190    }
191    result
192}
193
194/// Lift a raw [`Value`] into an [`AnnotatedNode`] tagged with one source.
195fn to_annotated(v: &Value, source: usize) -> AnnotatedNode {
196    match v {
197        Value::Mapping(m) => {
198            let entries = m
199                .iter()
200                .map(|(k, v)| (k.clone(), to_annotated(v, source)))
201                .collect();
202            AnnotatedNode::Map { entries }
203        }
204        other => AnnotatedNode::Leaf {
205            value: other.clone(),
206            source,
207        },
208    }
209}
210
211/// Merge `over` onto `base` with provenance. Mirrors [`deep_merge`] but
212/// carries source indices; `over_source` is the layer index for any
213/// leaves the overlay introduces or replaces.
214fn deep_merge_annotated(
215    base: AnnotatedNode,
216    over: &Value,
217    over_source: usize,
218) -> AnnotatedNode {
219    match (base, over) {
220        (AnnotatedNode::Map { mut entries }, Value::Mapping(over_map)) => {
221            for (k, v) in over_map {
222                if matches!(v, Value::Null) {
223                    entries.retain(|(ek, _)| ek != k);
224                    continue;
225                }
226                let pos = entries.iter().position(|(ek, _)| ek == k);
227                match pos {
228                    Some(p) => {
229                        // Match deep_merge's re-key-to-end behaviour: drop
230                        // the existing entry and re-append under merge.
231                        let (_, existing) = entries.remove(p);
232                        let merged = deep_merge_annotated(existing, v, over_source);
233                        entries.push((k.clone(), merged));
234                    }
235                    None => {
236                        entries.push((k.clone(), to_annotated(v, over_source)));
237                    }
238                }
239            }
240            AnnotatedNode::Map { entries }
241        }
242        // Type change or scalar-over-anything: overlay replaces wholesale.
243        (_, over) => to_annotated(over, over_source),
244    }
245}
246
247// ── Rendering with inline source comments ───────────────────────────────
248
249/// Emit an [`AnnotatedNode`] as YAML with a trailing `# <label>` on each
250/// leaf line, column-aligned for legibility.
251///
252/// `source_labels[i]` is the label shown for layer index `i` (typically a
253/// filename). Sequences are rendered inline when all items are scalars
254/// and the resulting line fits the `INLINE_SEQ_LIMIT` threshold; otherwise
255/// they drop to block style with the source tag on the key line.
256pub fn render_annotated_yaml(node: &AnnotatedNode, source_labels: &[String]) -> String {
257    // Two-pass render so we can align comment columns. First pass emits
258    // lines with `\0` between body and source tag; second pass computes
259    // the target column and pads.
260    let mut raw = String::new();
261    render_node(node, 0, source_labels, &mut raw);
262    align_comments(&raw)
263}
264
265/// Inline-sequence threshold: combined line length beyond which a
266/// scalar-only sequence drops from `[a, b, c]` to block form.
267const INLINE_SEQ_LIMIT: usize = 80;
268
269fn render_node(node: &AnnotatedNode, indent: usize, labels: &[String], out: &mut String) {
270    match node {
271        AnnotatedNode::Leaf { value, source } => {
272            // Top-level leaf (root is a bare scalar). Rare but support it.
273            let tag = label(labels, *source);
274            emit_line(out, indent, &format_scalar(value), Some(&tag));
275        }
276        AnnotatedNode::Map { entries } => {
277            for (k, child) in entries {
278                let key = format_key(k);
279                match child {
280                    AnnotatedNode::Leaf { value, source } => {
281                        let tag = label(labels, *source);
282                        render_leaf_entry(&key, value, &tag, indent, out);
283                    }
284                    AnnotatedNode::Map { .. } => {
285                        // Header line for a nested map: no tag (the map
286                        // itself has no single source).
287                        emit_header(out, indent, &format!("{key}:"));
288                        render_node(child, indent + 2, labels, out);
289                    }
290                }
291            }
292        }
293    }
294}
295
296fn render_leaf_entry(key: &str, value: &Value, tag: &str, indent: usize, out: &mut String) {
297    match value {
298        Value::Sequence(items) if items.iter().all(is_inline_scalar) => {
299            let inline = format!(
300                "{key}: [{}]",
301                items
302                    .iter()
303                    .map(format_scalar)
304                    .collect::<Vec<_>>()
305                    .join(", ")
306            );
307            if indent + inline.len() <= INLINE_SEQ_LIMIT {
308                emit_line(out, indent, &inline, Some(tag));
309            } else {
310                emit_line(out, indent, &format!("{key}:"), Some(tag));
311                for item in items {
312                    emit_header(out, indent + 2, &format!("- {}", format_scalar(item)));
313                }
314            }
315        }
316        Value::Sequence(items) => {
317            emit_line(out, indent, &format!("{key}:"), Some(tag));
318            for item in items {
319                match item {
320                    Value::Mapping(m) => {
321                        // Rare in fdl configs but render sensibly: first
322                        // key on the `-` line, rest indented.
323                        let mut it = m.iter();
324                        if let Some((first_k, first_v)) = it.next() {
325                            let first_key = format_key(first_k);
326                            emit_header(
327                                out,
328                                indent + 2,
329                                &format!("- {first_key}: {}", format_scalar(first_v)),
330                            );
331                            for (k, v) in it {
332                                emit_header(
333                                    out,
334                                    indent + 4,
335                                    &format!("{}: {}", format_key(k), format_scalar(v)),
336                                );
337                            }
338                        }
339                    }
340                    other => {
341                        emit_header(out, indent + 2, &format!("- {}", format_scalar(other)));
342                    }
343                }
344            }
345        }
346        other => {
347            emit_line(out, indent, &format!("{key}: {}", format_scalar(other)), Some(tag));
348        }
349    }
350}
351
352/// Write a line that will participate in column alignment. `body` is the
353/// YAML body (key: value); `tag` is the source label. Body and tag are
354/// separated by a `\0` sentinel so [`align_comments`] can pad precisely.
355fn emit_line(out: &mut String, indent: usize, body: &str, tag: Option<&str>) {
356    for _ in 0..indent {
357        out.push(' ');
358    }
359    out.push_str(body);
360    if let Some(t) = tag {
361        out.push('\0');
362        out.push_str(t);
363    }
364    out.push('\n');
365}
366
367/// Write a header/structural line (no source tag). No `\0` sentinel so
368/// alignment leaves it untouched.
369fn emit_header(out: &mut String, indent: usize, body: &str) {
370    for _ in 0..indent {
371        out.push(' ');
372    }
373    out.push_str(body);
374    out.push('\n');
375}
376
377/// Align `# <tag>` comments across lines that carry the `\0` sentinel.
378/// Lines without the sentinel pass through unchanged. Comment column is
379/// `max(body_width) + 2`, clamped to a minimum for single-line configs.
380fn align_comments(raw: &str) -> String {
381    let lines: Vec<&str> = raw.lines().collect();
382    let mut max_body = 0;
383    for line in &lines {
384        if let Some(idx) = line.find('\0') {
385            max_body = max_body.max(idx);
386        }
387    }
388    // 2-space gutter before the `#`. Minimum column so single-key files
389    // still look deliberate rather than cramped.
390    let col = max_body.max(12) + 2;
391
392    let mut out = String::with_capacity(raw.len() + lines.len() * 4);
393    for line in &lines {
394        match line.find('\0') {
395            Some(idx) => {
396                let (body, rest) = line.split_at(idx);
397                let tag = &rest[1..]; // skip the '\0'
398                out.push_str(body);
399                for _ in body.chars().count()..col {
400                    out.push(' ');
401                }
402                out.push_str("# ");
403                out.push_str(tag);
404            }
405            None => out.push_str(line),
406        }
407        out.push('\n');
408    }
409    out
410}
411
412fn label(labels: &[String], source: usize) -> String {
413    labels
414        .get(source)
415        .cloned()
416        .unwrap_or_else(|| format!("layer[{source}]"))
417}
418
419fn is_inline_scalar(v: &Value) -> bool {
420    matches!(
421        v,
422        Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_)
423    )
424}
425
426/// Format a scalar for display in a YAML line. Strings are quoted only
427/// when they would otherwise parse ambiguously (start with a special
428/// char, contain a `:` followed by space, etc.). Goal: look like the
429/// user's source file when unambiguous, quote only when required.
430fn format_scalar(v: &Value) -> String {
431    match v {
432        Value::Null => "null".to_string(),
433        Value::Bool(b) => b.to_string(),
434        Value::Number(n) => n.to_string(),
435        Value::String(s) => format_string(s),
436        Value::Sequence(_) | Value::Mapping(_) => {
437            // Shouldn't be called with a container — defensive fallback.
438            serde_yaml::to_string(v).unwrap_or_default().trim().to_string()
439        }
440        Value::Tagged(t) => serde_yaml::to_string(&**t)
441            .unwrap_or_default()
442            .trim()
443            .to_string(),
444    }
445}
446
447fn format_key(k: &Value) -> String {
448    match k {
449        Value::String(s) => {
450            // Most config keys are plain identifiers; keep them unquoted.
451            if is_plain_key(s) {
452                s.clone()
453            } else {
454                format_string(s)
455            }
456        }
457        other => format_scalar(other),
458    }
459}
460
461fn is_plain_key(s: &str) -> bool {
462    !s.is_empty()
463        && s.chars()
464            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
465}
466
467fn format_string(s: &str) -> String {
468    // Quote if the raw string would mis-parse as something else, or if
469    // it contains characters that make unquoted YAML ambiguous.
470    let needs_quote = s.is_empty()
471        || s.contains(':')
472        || s.contains('#')
473        || s.contains('\n')
474        || s.contains('"')
475        || s.starts_with(|c: char| c.is_whitespace() || "!&*>|%@`[]{},-?".contains(c))
476        || matches!(s, "true" | "false" | "null" | "yes" | "no" | "~")
477        || s.parse::<f64>().is_ok();
478    if needs_quote {
479        // Double-quoted with JSON-style escapes.
480        let escaped = s
481            .replace('\\', "\\\\")
482            .replace('"', "\\\"")
483            .replace('\n', "\\n")
484            .replace('\t', "\\t");
485        format!("\"{escaped}\"")
486    } else {
487        s.to_string()
488    }
489}
490
491/// Load a YAML/JSON file as a [`Value`]. Extension-based dispatch on the
492/// file suffix (`.yml`, `.yaml`, `.json`).
493pub fn load_value(path: &Path) -> Result<Value, String> {
494    let content = std::fs::read_to_string(path)
495        .map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
496    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("yaml");
497    match ext {
498        "json" => serde_json::from_str::<Value>(&content)
499            .map_err(|e| format!("{}: {}", path.display(), e)),
500        _ => serde_yaml::from_str::<Value>(&content)
501            .map_err(|e| format!("{}: {}", path.display(), e)),
502    }
503}
504
505// ── `inherit-from:` chain resolution ────────────────────────────────────
506//
507// A config file can declare a top-level `inherit-from: <path>` that names
508// a parent to merge under. Chains are linear (single parent) so the
509// effective layer list becomes [deepest-ancestor, ..., direct-parent, this].
510// The `inherit-from` key is stripped from every returned value so it
511// doesn't leak into the deserialised config.
512
513/// YAML key used by [`resolve_chain`] to discover the parent layer.
514const INHERIT_KEY: &str = "inherit-from";
515
516/// Load `path` and every ancestor reachable via `inherit-from:`, returning
517/// them in merge order (deepest ancestor first, `path` itself last). The
518/// `inherit-from` key is removed from every returned [`Value`].
519///
520/// Relative ancestor paths are resolved against the directory of the file
521/// that declared the `inherit-from:`. Cycles (including self-inheritance)
522/// are detected via the recursion stack and surface as an error listing
523/// the full cycle for fast diagnosis.
524pub fn resolve_chain(path: &Path) -> Result<Vec<(PathBuf, Value)>, String> {
525    let mut stack: Vec<PathBuf> = Vec::new();
526    let mut out: Vec<(PathBuf, Value)> = Vec::new();
527    resolve_chain_inner(path, &mut stack, &mut out)?;
528    Ok(out)
529}
530
531fn resolve_chain_inner(
532    path: &Path,
533    stack: &mut Vec<PathBuf>,
534    out: &mut Vec<(PathBuf, Value)>,
535) -> Result<(), String> {
536    let canonical = path.canonicalize().map_err(|e| {
537        format!(
538            "cannot resolve inherit-from target `{}`: {e}",
539            path.display()
540        )
541    })?;
542
543    if stack.contains(&canonical) {
544        let mut chain: Vec<String> = stack.iter().map(|p| p.display().to_string()).collect();
545        chain.push(canonical.display().to_string());
546        return Err(format!("inherit-from cycle detected: {}", chain.join(" -> ")));
547    }
548
549    stack.push(canonical.clone());
550
551    let mut value = load_value(path)?;
552    let parent = extract_inherit_from(&mut value, path)?;
553
554    if let Some(parent_rel) = parent {
555        let parent_abs = if Path::new(&parent_rel).is_absolute() {
556            PathBuf::from(&parent_rel)
557        } else {
558            canonical
559                .parent()
560                .unwrap_or_else(|| Path::new("."))
561                .join(&parent_rel)
562        };
563        resolve_chain_inner(&parent_abs, stack, out)?;
564    }
565
566    stack.pop();
567    out.push((canonical, value));
568    Ok(())
569}
570
571/// Pop the top-level `inherit-from` key from a mapping and return its
572/// string value. A missing or explicitly-null key returns `Ok(None)`.
573/// A non-string value errors with the offending type named.
574fn extract_inherit_from(value: &mut Value, path: &Path) -> Result<Option<String>, String> {
575    let Value::Mapping(m) = value else {
576        return Ok(None);
577    };
578    let key = Value::String(INHERIT_KEY.to_string());
579    match m.remove(&key) {
580        None | Some(Value::Null) => Ok(None),
581        Some(Value::String(s)) if s.is_empty() => Err(format!(
582            "{INHERIT_KEY} in {} must be a non-empty path",
583            path.display()
584        )),
585        Some(Value::String(s)) => Ok(Some(s)),
586        Some(other) => Err(format!(
587            "{INHERIT_KEY} in {} must be a string path, got {}",
588            path.display(),
589            type_name(&other)
590        )),
591    }
592}
593
594fn type_name(v: &Value) -> &'static str {
595    match v {
596        Value::Null => "null",
597        Value::Bool(_) => "bool",
598        Value::Number(_) => "number",
599        Value::String(_) => "string",
600        Value::Sequence(_) => "sequence",
601        Value::Mapping(_) => "mapping",
602        Value::Tagged(_) => "tagged",
603    }
604}
605
606// ── Tests ───────────────────────────────────────────────────────────────
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use std::collections::BTreeMap;
612
613    fn yaml(s: &str) -> Value {
614        serde_yaml::from_str(s).expect("test fixture must parse")
615    }
616
617    /// Build `Vec<String>` from string literals — shorter than repeating
618    /// `.to_string()` in every path assertion.
619    fn p(xs: &[&str]) -> Vec<String> {
620        xs.iter().map(|s| s.to_string()).collect()
621    }
622
623    #[test]
624    fn scalar_over_scalar_replaces() {
625        let base = yaml("42");
626        let over = yaml("99");
627        assert_eq!(deep_merge(base, over), yaml("99"));
628    }
629
630    #[test]
631    fn map_keys_deep_merge() {
632        let base = yaml(
633            r"
634            a: 1
635            nested:
636              x: one
637              y: two
638            ",
639        );
640        let over = yaml(
641            r"
642            nested:
643              y: TWO
644              z: three
645            b: 2
646            ",
647        );
648        let expected = yaml(
649            r"
650            a: 1
651            b: 2
652            nested:
653              x: one
654              y: TWO
655              z: three
656            ",
657        );
658        assert_eq!(deep_merge(base, over), expected);
659    }
660
661    #[test]
662    fn lists_replace_not_append() {
663        let base = yaml(
664            r"
665            items: [a, b, c]
666            ",
667        );
668        let over = yaml(
669            r"
670            items: [x, y]
671            ",
672        );
673        let expected = yaml(
674            r"
675            items: [x, y]
676            ",
677        );
678        assert_eq!(deep_merge(base, over), expected);
679    }
680
681    #[test]
682    fn null_in_overlay_deletes_key() {
683        let base = yaml(
684            r"
685            ddp:
686              policy: cadence
687              anchor: 3
688            training:
689              epochs: 10
690            ",
691        );
692        let over = yaml(
693            r"
694            ddp: ~
695            training:
696              epochs: 20
697            ",
698        );
699        // `ddp: null` removes the whole block; training.epochs updates.
700        let expected = yaml(
701            r"
702            training:
703              epochs: 20
704            ",
705        );
706        assert_eq!(deep_merge(base, over), expected);
707    }
708
709    #[test]
710    fn null_leaf_removes_single_key() {
711        let base = yaml(
712            r"
713            ddp:
714              policy: cadence
715              anchor: 3
716            ",
717        );
718        let over = yaml(
719            r"
720            ddp:
721              anchor: ~
722            ",
723        );
724        let expected = yaml(
725            r"
726            ddp:
727              policy: cadence
728            ",
729        );
730        assert_eq!(deep_merge(base, over), expected);
731    }
732
733    #[test]
734    fn overlay_adds_new_top_level_key() {
735        let base = yaml("a: 1");
736        let over = yaml("b: 2");
737        let expected = yaml(
738            r"
739            a: 1
740            b: 2
741            ",
742        );
743        assert_eq!(deep_merge(base, over), expected);
744    }
745
746    #[test]
747    fn merge_chain_three_layers() {
748        let l1 = yaml("a: 1\nb: 1");
749        let l2 = yaml("b: 2\nc: 2");
750        let l3 = yaml("c: 3");
751        let got = merge_layers(vec![l1, l2, l3]);
752        let expected = yaml(
753            r"
754            a: 1
755            b: 2
756            c: 3
757            ",
758        );
759        assert_eq!(got, expected);
760    }
761
762    #[test]
763    fn type_change_overlay_replaces_wholesale() {
764        let base = yaml(
765            r"
766            ddp:
767              policy: cadence
768            ",
769        );
770        let over = yaml(
771            r"
772            ddp: solo-0
773            ",
774        );
775        let expected = yaml(
776            r"
777            ddp: solo-0
778            ",
779        );
780        assert_eq!(deep_merge(base, over), expected);
781    }
782
783    #[test]
784    fn type_change_scalar_base_mapping_overlay_replaces() {
785        // Symmetry with `type_change_overlay_replaces_wholesale`: when
786        // the base is a scalar and the overlay is a mapping, the mapping
787        // wins wholesale. No attempt at cross-type merging.
788        let base = yaml(
789            r"
790            ddp: solo-0
791            ",
792        );
793        let over = yaml(
794            r"
795            ddp:
796              policy: cadence
797              anchor: 3
798            ",
799        );
800        let expected = yaml(
801            r"
802            ddp:
803              policy: cadence
804              anchor: 3
805            ",
806        );
807        assert_eq!(deep_merge(base, over), expected);
808    }
809
810    #[test]
811    fn list_envs_discovers_sibling_overlays() {
812        let tmp = tempdir();
813        std::fs::write(tmp.path().join("fdl.yml"), "description: base").unwrap();
814        std::fs::write(tmp.path().join("fdl.ci.yml"), "description: ci").unwrap();
815        std::fs::write(tmp.path().join("fdl.cloud.yaml"), "description: cloud").unwrap();
816        std::fs::write(tmp.path().join("fdl.prod.json"), "{}").unwrap();
817        // Decoys — must NOT be listed.
818        std::fs::write(tmp.path().join("fdl.yml.example"), "").unwrap();
819        std::fs::write(tmp.path().join("other.ci.yml"), "").unwrap();
820        std::fs::write(tmp.path().join("fdl.yml.bak"), "").unwrap();
821
822        let envs = list_envs(&tmp.path().join("fdl.yml"));
823        assert_eq!(envs, vec!["ci".to_string(), "cloud".into(), "prod".into()]);
824    }
825
826    #[test]
827    fn find_env_file_respects_extension_precedence() {
828        let tmp = tempdir();
829        std::fs::write(tmp.path().join("fdl.yml"), "").unwrap();
830        std::fs::write(tmp.path().join("fdl.ci.yml"), "# yml wins").unwrap();
831        std::fs::write(tmp.path().join("fdl.ci.yaml"), "# yaml loses").unwrap();
832
833        let got = find_env_file(&tmp.path().join("fdl.yml"), "ci").unwrap();
834        assert_eq!(got.file_name().unwrap().to_str(), Some("fdl.ci.yml"));
835    }
836
837    #[test]
838    fn find_env_file_missing_returns_none() {
839        let tmp = tempdir();
840        std::fs::write(tmp.path().join("fdl.yml"), "").unwrap();
841        assert!(find_env_file(&tmp.path().join("fdl.yml"), "nope").is_none());
842    }
843
844    // ── Annotated merge ──────────────────────────────────────────────────
845
846    /// Collect every leaf's (key-path, source-index) from an AnnotatedNode.
847    /// Key path elements are YAML `Value`s (almost always strings in our
848    /// configs) for parity with [`AnnotatedNode::Map`]'s key type.
849    fn leaves(node: &AnnotatedNode) -> Vec<(Vec<String>, usize)> {
850        fn walk(node: &AnnotatedNode, path: &mut Vec<String>, out: &mut Vec<(Vec<String>, usize)>) {
851            match node {
852                AnnotatedNode::Leaf { source, .. } => out.push((path.clone(), *source)),
853                AnnotatedNode::Map { entries } => {
854                    for (k, v) in entries {
855                        let key = match k {
856                            Value::String(s) => s.clone(),
857                            other => format!("{other:?}"),
858                        };
859                        path.push(key);
860                        walk(v, path, out);
861                        path.pop();
862                    }
863                }
864            }
865        }
866        let mut out = Vec::new();
867        walk(node, &mut Vec::new(), &mut out);
868        out
869    }
870
871    #[test]
872    fn annotated_single_layer_tags_every_leaf_with_zero() {
873        let layers = vec![yaml("ddp:\n  policy: cadence\n  anchor: 3\ntraining:\n  epochs: 10\n")];
874        let node = merge_layers_annotated(&layers);
875        for (path, src) in leaves(&node) {
876            assert_eq!(src, 0, "{path:?} should be tagged with layer 0");
877        }
878    }
879
880    #[test]
881    fn annotated_overlay_replaces_key_source() {
882        let layers = vec![
883            yaml("ddp:\n  policy: cadence\n  anchor: 3\n"),
884            yaml("ddp:\n  anchor: 5\n"),
885        ];
886        let node = merge_layers_annotated(&layers);
887        let by_path: BTreeMap<Vec<String>, usize> = leaves(&node).into_iter().collect();
888        assert_eq!(by_path[&p(&["ddp", "policy"])], 0);
889        assert_eq!(by_path[&p(&["ddp", "anchor"])], 1);
890    }
891
892    #[test]
893    fn annotated_added_key_tagged_with_overlay() {
894        let layers = vec![
895            yaml("ddp:\n  policy: cadence\n"),
896            yaml("training:\n  epochs: 20\n"),
897        ];
898        let node = merge_layers_annotated(&layers);
899        let by_path: BTreeMap<Vec<String>, usize> = leaves(&node).into_iter().collect();
900        assert_eq!(by_path[&p(&["training", "epochs"])], 1);
901    }
902
903    #[test]
904    fn annotated_null_deletes_key_and_removes_leaf() {
905        let layers = vec![
906            yaml("ddp:\n  policy: cadence\n  anchor: 3\n"),
907            yaml("ddp:\n  anchor: ~\n"),
908        ];
909        let node = merge_layers_annotated(&layers);
910        let paths: Vec<Vec<String>> = leaves(&node).into_iter().map(|(path, _)| path).collect();
911        assert!(paths.contains(&p(&["ddp", "policy"])));
912        assert!(!paths.iter().any(|path| path == &p(&["ddp", "anchor"])));
913    }
914
915    #[test]
916    fn annotated_type_change_resets_source_to_overlay() {
917        // Mapping in base → scalar in overlay: the whole subtree collapses
918        // to a Leaf tagged with the overlay's index.
919        let layers = vec![
920            yaml("ddp:\n  policy: cadence\n"),
921            yaml("ddp: solo-0\n"),
922        ];
923        let node = merge_layers_annotated(&layers);
924        let by_path: BTreeMap<Vec<String>, usize> = leaves(&node).into_iter().collect();
925        assert_eq!(by_path[&p(&["ddp"])], 1);
926        assert!(!by_path.contains_key(&p(&["ddp", "policy"])));
927    }
928
929    #[test]
930    fn annotated_list_replaced_wholesale_tagged_with_setter() {
931        // Lists are replace-not-append, so the whole sequence is attributed
932        // to the layer that last wrote it.
933        let layers = vec![
934            yaml("regions: [eu-west]\n"),
935            yaml("regions: [us-east, ap-south]\n"),
936        ];
937        let node = merge_layers_annotated(&layers);
938        let by_path: BTreeMap<Vec<String>, usize> = leaves(&node).into_iter().collect();
939        assert_eq!(by_path[&p(&["regions"])], 1);
940    }
941
942    #[test]
943    fn annotated_three_layer_chain() {
944        let layers = vec![
945            yaml("a: 1\nb: 1\nc: 1\n"),
946            yaml("b: 2\nc: 2\n"),
947            yaml("c: 3\n"),
948        ];
949        let node = merge_layers_annotated(&layers);
950        let by_path: BTreeMap<Vec<String>, usize> = leaves(&node).into_iter().collect();
951        assert_eq!(by_path[&p(&["a"])], 0);
952        assert_eq!(by_path[&p(&["b"])], 1);
953        assert_eq!(by_path[&p(&["c"])], 2);
954    }
955
956    #[test]
957    fn annotated_to_value_matches_deep_merge() {
958        let l1 = yaml("ddp:\n  policy: cadence\n  anchor: 3\ntraining:\n  epochs: 10\n");
959        let l2 = yaml("ddp:\n  anchor: 5\ntraining:\n  seed: 42\n");
960        let annotated = merge_layers_annotated(&[l1.clone(), l2.clone()]);
961        let plain = deep_merge(l1, l2);
962        assert_eq!(annotated.to_value(), plain);
963    }
964
965    // ── Rendering ────────────────────────────────────────────────────────
966
967    fn labels(xs: &[&str]) -> Vec<String> {
968        xs.iter().map(|s| s.to_string()).collect()
969    }
970
971    #[test]
972    fn render_tags_every_leaf_with_filename() {
973        let layers = vec![yaml("ddp:\n  policy: cadence\n  anchor: 3\n")];
974        let node = merge_layers_annotated(&layers);
975        let out = render_annotated_yaml(&node, &labels(&["fdl.yml"]));
976        for line in out.lines() {
977            if line.contains(':') && !line.trim_end().ends_with(':') {
978                assert!(line.contains("# fdl.yml"), "missing tag on: `{line}`");
979            }
980        }
981    }
982
983    #[test]
984    fn render_tags_overlay_keys_with_overlay_filename() {
985        let layers = vec![
986            yaml("ddp:\n  policy: cadence\n  anchor: 3\n"),
987            yaml("ddp:\n  anchor: 5\n"),
988        ];
989        let node = merge_layers_annotated(&layers);
990        let out = render_annotated_yaml(&node, &labels(&["fdl.yml", "fdl.ci.yml"]));
991        // policy unchanged → tagged with base.
992        let policy_line = out.lines().find(|l| l.contains("policy:")).unwrap();
993        assert!(policy_line.contains("# fdl.yml") && !policy_line.contains("# fdl.ci.yml"));
994        // anchor overridden → tagged with overlay.
995        let anchor_line = out.lines().find(|l| l.contains("anchor:")).unwrap();
996        assert!(anchor_line.contains("# fdl.ci.yml"));
997    }
998
999    #[test]
1000    fn render_aligns_comment_column() {
1001        let layers = vec![yaml("a: 1\nbb: 22\nccc: 333\n")];
1002        let node = merge_layers_annotated(&layers);
1003        let out = render_annotated_yaml(&node, &labels(&["fdl.yml"]));
1004        // All `#` symbols must land in the same column.
1005        let cols: Vec<usize> = out
1006            .lines()
1007            .filter_map(|l| l.find('#'))
1008            .collect();
1009        assert!(cols.len() >= 3);
1010        let first = cols[0];
1011        assert!(cols.iter().all(|c| *c == first), "mismatched columns: {cols:?}");
1012    }
1013
1014    #[test]
1015    fn render_inline_short_scalar_list() {
1016        // `serde_yaml::Number::to_string` preserves `1.0` as `1.0`.
1017        let layers = vec![yaml("ratios: [1.5, 1.0]\n")];
1018        let node = merge_layers_annotated(&layers);
1019        let out = render_annotated_yaml(&node, &labels(&["fdl.yml"]));
1020        assert!(out.contains("ratios: [1.5, 1.0]"), "got:\n{out}");
1021        assert!(out.lines().next().unwrap().contains("# fdl.yml"));
1022    }
1023
1024    #[test]
1025    fn render_deleted_key_absent_from_output() {
1026        let layers = vec![
1027            yaml("ddp:\n  policy: cadence\n  anchor: 3\n"),
1028            yaml("ddp:\n  anchor: ~\n"),
1029        ];
1030        let node = merge_layers_annotated(&layers);
1031        let out = render_annotated_yaml(&node, &labels(&["fdl.yml", "fdl.ci.yml"]));
1032        assert!(!out.contains("anchor"), "deleted key leaked: {out}");
1033        assert!(out.contains("policy"));
1034    }
1035
1036    #[test]
1037    fn render_header_lines_have_no_comment() {
1038        // The `ddp:` header line is a nested-map opener — it has no single
1039        // source, so it gets no trailing `# <label>`.
1040        let layers = vec![yaml("ddp:\n  policy: cadence\n")];
1041        let node = merge_layers_annotated(&layers);
1042        let out = render_annotated_yaml(&node, &labels(&["fdl.yml"]));
1043        let header = out.lines().find(|l| l.trim() == "ddp:").unwrap();
1044        assert!(!header.contains('#'));
1045    }
1046
1047    #[test]
1048    fn render_quotes_ambiguous_strings() {
1049        // `true` as a literal string must be quoted so it doesn't
1050        // round-trip as a boolean.
1051        let layers = vec![yaml("flag: \"true\"\n")];
1052        let node = merge_layers_annotated(&layers);
1053        let out = render_annotated_yaml(&node, &labels(&["fdl.yml"]));
1054        assert!(out.contains("flag: \"true\""), "got:\n{out}");
1055    }
1056
1057    #[test]
1058    fn render_long_scalar_list_drops_to_block_form() {
1059        let long: Vec<String> = (0..30).map(|i| format!("item-number-{i}")).collect();
1060        let yaml_src = format!("items: [{}]\n", long.join(", "));
1061        let layers = vec![yaml(&yaml_src)];
1062        let node = merge_layers_annotated(&layers);
1063        let out = render_annotated_yaml(&node, &labels(&["fdl.yml"]));
1064        assert!(out.contains("items:  "), "expected header line with tag");
1065        assert!(out.contains("- item-number-0"));
1066    }
1067
1068    // ── inherit-from chain resolution ────────────────────────────────────
1069
1070    /// Canonicalise a path so tests can compare against `resolve_chain`'s
1071    /// returned paths (which are always canonical).
1072    fn canon(p: &Path) -> PathBuf {
1073        p.canonicalize().expect("canonicalize fixture path")
1074    }
1075
1076    #[test]
1077    fn resolve_chain_single_file_no_inherit() {
1078        let tmp = tempdir();
1079        let f = tmp.path().join("fdl.yml");
1080        std::fs::write(&f, "description: test\nddp:\n  policy: cadence\n").unwrap();
1081        let chain = resolve_chain(&f).unwrap();
1082        assert_eq!(chain.len(), 1);
1083        assert_eq!(chain[0].0, canon(&f));
1084    }
1085
1086    #[test]
1087    fn resolve_chain_strips_inherit_from_key() {
1088        let tmp = tempdir();
1089        let parent = tmp.path().join("fdl.yml");
1090        let child = tmp.path().join("fdl.ci.yml");
1091        std::fs::write(&parent, "a: 1\n").unwrap();
1092        std::fs::write(&child, "inherit-from: fdl.yml\nb: 2\n").unwrap();
1093        let chain = resolve_chain(&child).unwrap();
1094        assert_eq!(chain.len(), 2);
1095        // First layer is the parent (deepest), second is the child.
1096        assert_eq!(chain[0].0, canon(&parent));
1097        assert_eq!(chain[1].0, canon(&child));
1098        // inherit-from must not appear in the returned values.
1099        for (_, v) in &chain {
1100            if let Value::Mapping(m) = v {
1101                assert!(!m.contains_key(Value::String("inherit-from".to_string())));
1102            }
1103        }
1104    }
1105
1106    #[test]
1107    fn resolve_chain_three_level_ordering() {
1108        // c inherits from b, b inherits from a. Merge order must be [a, b, c].
1109        let tmp = tempdir();
1110        let a = tmp.path().join("a.yml");
1111        let b = tmp.path().join("b.yml");
1112        let c = tmp.path().join("c.yml");
1113        std::fs::write(&a, "x: from-a\n").unwrap();
1114        std::fs::write(&b, "inherit-from: a.yml\ny: from-b\n").unwrap();
1115        std::fs::write(&c, "inherit-from: b.yml\nz: from-c\n").unwrap();
1116        let chain = resolve_chain(&c).unwrap();
1117        let paths: Vec<PathBuf> = chain.iter().map(|(p, _)| p.clone()).collect();
1118        assert_eq!(paths, vec![canon(&a), canon(&b), canon(&c)]);
1119    }
1120
1121    #[test]
1122    fn resolve_chain_relative_paths_resolve_from_declaring_file() {
1123        // Declaring file sits one dir down; inherit-from uses `../base.yml`.
1124        let tmp = tempdir();
1125        let base = tmp.path().join("base.yml");
1126        let nested_dir = tmp.path().join("nested");
1127        std::fs::create_dir_all(&nested_dir).unwrap();
1128        let child = nested_dir.join("child.yml");
1129        std::fs::write(&base, "shared: true\n").unwrap();
1130        std::fs::write(&child, "inherit-from: ../base.yml\nlocal: true\n").unwrap();
1131        let chain = resolve_chain(&child).unwrap();
1132        assert_eq!(chain.len(), 2);
1133        assert_eq!(chain[0].0, canon(&base));
1134        assert_eq!(chain[1].0, canon(&child));
1135    }
1136
1137    #[test]
1138    fn resolve_chain_absolute_path_works() {
1139        let tmp = tempdir();
1140        let parent = tmp.path().join("parent.yml");
1141        let child = tmp.path().join("child.yml");
1142        std::fs::write(&parent, "a: 1\n").unwrap();
1143        // Use absolute path in inherit-from.
1144        let abs = canon(&parent);
1145        std::fs::write(
1146            &child,
1147            format!("inherit-from: {}\nb: 2\n", abs.display()),
1148        )
1149        .unwrap();
1150        let chain = resolve_chain(&child).unwrap();
1151        assert_eq!(chain.len(), 2);
1152        assert_eq!(chain[0].0, canon(&parent));
1153    }
1154
1155    #[test]
1156    fn resolve_chain_self_inheritance_errors() {
1157        let tmp = tempdir();
1158        let f = tmp.path().join("fdl.yml");
1159        std::fs::write(&f, "inherit-from: fdl.yml\nx: 1\n").unwrap();
1160        let err = resolve_chain(&f).unwrap_err();
1161        assert!(err.contains("cycle"), "got: {err}");
1162        // Self-loop appears as the same path on both sides of the arrow.
1163        assert!(err.matches("fdl.yml").count() >= 2, "got: {err}");
1164    }
1165
1166    #[test]
1167    fn resolve_chain_two_file_cycle_errors() {
1168        // a inherits from b, b inherits from a — classic cycle.
1169        let tmp = tempdir();
1170        let a = tmp.path().join("a.yml");
1171        let b = tmp.path().join("b.yml");
1172        std::fs::write(&a, "inherit-from: b.yml\nx: 1\n").unwrap();
1173        std::fs::write(&b, "inherit-from: a.yml\ny: 2\n").unwrap();
1174        let err = resolve_chain(&a).unwrap_err();
1175        assert!(err.contains("cycle"), "got: {err}");
1176        assert!(err.contains("a.yml"));
1177        assert!(err.contains("b.yml"));
1178    }
1179
1180    #[test]
1181    fn resolve_chain_missing_parent_errors() {
1182        let tmp = tempdir();
1183        let f = tmp.path().join("fdl.yml");
1184        std::fs::write(&f, "inherit-from: missing.yml\nx: 1\n").unwrap();
1185        let err = resolve_chain(&f).unwrap_err();
1186        assert!(
1187            err.contains("cannot resolve inherit-from target"),
1188            "got: {err}"
1189        );
1190        assert!(err.contains("missing.yml"), "got: {err}");
1191    }
1192
1193    #[test]
1194    fn resolve_chain_non_string_inherit_errors() {
1195        let tmp = tempdir();
1196        let f = tmp.path().join("fdl.yml");
1197        std::fs::write(&f, "inherit-from: 42\nx: 1\n").unwrap();
1198        let err = resolve_chain(&f).unwrap_err();
1199        assert!(err.contains("must be a string path"), "got: {err}");
1200        assert!(err.contains("got number"), "got: {err}");
1201    }
1202
1203    #[test]
1204    fn resolve_chain_empty_string_inherit_errors() {
1205        let tmp = tempdir();
1206        let f = tmp.path().join("fdl.yml");
1207        std::fs::write(&f, "inherit-from: \"\"\nx: 1\n").unwrap();
1208        let err = resolve_chain(&f).unwrap_err();
1209        assert!(err.contains("non-empty"), "got: {err}");
1210    }
1211
1212    #[test]
1213    fn resolve_chain_null_inherit_ignored() {
1214        // Explicit `inherit-from: null` == key absent. No error, no parent.
1215        let tmp = tempdir();
1216        let f = tmp.path().join("fdl.yml");
1217        std::fs::write(&f, "inherit-from: ~\nx: 1\n").unwrap();
1218        let chain = resolve_chain(&f).unwrap();
1219        assert_eq!(chain.len(), 1);
1220    }
1221
1222    // Tiny tempdir helper — standalone so we don't pull in the tempfile crate.
1223    fn tempdir() -> TempDir {
1224        TempDir::new()
1225    }
1226
1227    struct TempDir(PathBuf);
1228
1229    impl TempDir {
1230        fn new() -> Self {
1231            let base = std::env::temp_dir();
1232            let unique = format!(
1233                "flodl-overlay-{}-{}",
1234                std::process::id(),
1235                std::time::SystemTime::now()
1236                    .duration_since(std::time::UNIX_EPOCH)
1237                    .map(|d| d.as_nanos())
1238                    .unwrap_or(0)
1239            );
1240            let dir = base.join(unique);
1241            std::fs::create_dir_all(&dir).expect("tempdir creation");
1242            Self(dir)
1243        }
1244        fn path(&self) -> &Path {
1245            &self.0
1246        }
1247    }
1248
1249    impl Drop for TempDir {
1250        fn drop(&mut self) {
1251            let _ = std::fs::remove_dir_all(&self.0);
1252        }
1253    }
1254}