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. `<env>` is selected via the `@<env>` token, `--env <env>`, or
23//! `FDL_ENV=<env>`.
24
25use std::path::{Path, PathBuf};
26
27use serde_yaml_ng::{Mapping, Value};
28
29// ── Deep-merge ──────────────────────────────────────────────────────────
30
31/// Deep-merge `over` onto `base`. Maps recurse; scalars and lists replace;
32/// `null` values in a map context delete the key from the result.
33///
34/// Non-Mapping destinations are replaced wholesale when the overlay is a
35/// Mapping too — i.e. no cross-type merging, the newer value wins.
36pub fn deep_merge(base: Value, over: Value) -> Value {
37    match (base, over) {
38        (Value::Mapping(base_map), Value::Mapping(over_map)) => {
39            Value::Mapping(merge_mapping(base_map, over_map))
40        }
41        // Scalar, sequence, or type-change: overlay replaces base.
42        (_, over) => over,
43    }
44}
45
46fn merge_mapping(mut base: Mapping, over: Mapping) -> Mapping {
47    for (k, v) in over {
48        if matches!(v, Value::Null) {
49            base.remove(&k);
50            continue;
51        }
52        match base.remove(&k) {
53            Some(existing) => {
54                base.insert(k, deep_merge(existing, v));
55            }
56            None => {
57                base.insert(k, v);
58            }
59        }
60    }
61    base
62}
63
64/// Merge a chain of layers left-to-right. The first is the base; each
65/// subsequent layer is merged on top of the running result.
66pub fn merge_layers<I>(layers: I) -> Value
67where
68    I: IntoIterator<Item = Value>,
69{
70    layers
71        .into_iter()
72        .reduce(deep_merge)
73        .unwrap_or(Value::Null)
74}
75
76// ── Discovery ───────────────────────────────────────────────────────────
77
78/// Config filename extensions in preference order. Matches the order of
79/// `config::CONFIG_NAMES` (`fdl.yaml` before `fdl.yml`) so overlay resolution
80/// picks the same extension the base file would when both exist.
81const EXTENSIONS: &[&str] = &["yaml", "yml", "json"];
82
83/// Find a sibling overlay for `env` next to `base_config`.
84///
85/// `base_config` should be the resolved path to the base `fdl.yml` (not a
86/// directory). Returns `Some(path)` if `fdl.<env>.<ext>` exists for any
87/// supported extension, `None` otherwise.
88pub fn find_env_file(base_config: &Path, env: &str) -> Option<PathBuf> {
89    let dir = base_config.parent()?;
90    for ext in EXTENSIONS {
91        let candidate = dir.join(format!("fdl.{env}.{ext}"));
92        if candidate.is_file() {
93            return Some(candidate);
94        }
95    }
96    None
97}
98
99/// List every environment overlay discoverable beside the base config.
100///
101/// Returns env names (without `fdl.` prefix or extension), sorted. Duplicate
102/// names across extensions are de-duplicated — the first-found wins, matching
103/// [`find_env_file`] precedence.
104pub fn list_envs(base_config: &Path) -> Vec<String> {
105    let Some(dir) = base_config.parent() else {
106        return Vec::new();
107    };
108    let entries = match std::fs::read_dir(dir) {
109        Ok(r) => r,
110        Err(_) => return Vec::new(),
111    };
112    let mut envs = std::collections::BTreeSet::new();
113    for entry in entries.flatten() {
114        let name = entry.file_name();
115        let Some(name_str) = name.to_str() else {
116            continue;
117        };
118        let Some(stripped) = name_str.strip_prefix("fdl.") else {
119            continue;
120        };
121        // Must have at least one `.` separating env name from extension.
122        let Some((env, ext)) = stripped.rsplit_once('.') else {
123            continue;
124        };
125        if env.is_empty() || !EXTENSIONS.contains(&ext) {
126            continue;
127        }
128        envs.insert(env.to_string());
129    }
130    envs.into_iter().collect()
131}
132
133// ── Provenance-tracking merge ───────────────────────────────────────────
134//
135// [`deep_merge`] is lossy: once values collapse together we lose track of
136// which layer contributed each leaf. For `fdl config show`'s per-line
137// source annotation we need the merged *and* the origin, so we carry a
138// parallel tree that records a layer index at every leaf / sequence /
139// replaced-wholesale value. Maps are recursive: each entry carries its
140// own origin, the map itself has no single source. Sequences are
141// replaced wholesale, so they behave as leaves — the whole list is
142// attributed to whichever layer last wrote it.
143
144/// A merged value plus the layer that produced each leaf.
145///
146/// Layer indices are 0-based and refer to the slice passed to
147/// [`merge_layers_annotated`]: `0` is the base, `1` is the first overlay,
148/// and so on. Callers map indices to display labels (filenames, usually)
149/// at render time.
150#[derive(Debug, Clone)]
151pub enum AnnotatedNode {
152    /// Terminal value: scalar, null, or sequence. `source` is the layer
153    /// that last wrote this value.
154    Leaf { value: Value, source: usize },
155    /// Mapping node. `entries` preserves insertion order matching
156    /// [`deep_merge`]'s re-key-to-end behaviour (overridden keys move to
157    /// the tail of the map, matching the final `serde_yaml_ng` serialisation).
158    Map { entries: Vec<(Value, AnnotatedNode)> },
159}
160
161impl AnnotatedNode {
162    /// Materialise the merged [`Value`] — useful for equality tests
163    /// against [`deep_merge`] output.
164    pub fn to_value(&self) -> Value {
165        match self {
166            AnnotatedNode::Leaf { value, .. } => value.clone(),
167            AnnotatedNode::Map { entries } => {
168                let mut m = Mapping::new();
169                for (k, v) in entries {
170                    m.insert(k.clone(), v.to_value());
171                }
172                Value::Mapping(m)
173            }
174        }
175    }
176}
177
178/// Merge a chain of layers left-to-right with provenance tracking. Mirrors
179/// [`merge_layers`] but returns an [`AnnotatedNode`] instead of a flat
180/// [`Value`]. Layer indices in the result are positions into `layers`.
181pub fn merge_layers_annotated(layers: &[Value]) -> AnnotatedNode {
182    if layers.is_empty() {
183        return AnnotatedNode::Leaf {
184            value: Value::Null,
185            source: 0,
186        };
187    }
188
189    let mut result = to_annotated(&layers[0], 0);
190    for (i, layer) in layers.iter().enumerate().skip(1) {
191        result = deep_merge_annotated(result, layer, i);
192    }
193    result
194}
195
196/// Lift a raw [`Value`] into an [`AnnotatedNode`] tagged with one source.
197fn to_annotated(v: &Value, source: usize) -> AnnotatedNode {
198    match v {
199        Value::Mapping(m) => {
200            let entries = m
201                .iter()
202                .map(|(k, v)| (k.clone(), to_annotated(v, source)))
203                .collect();
204            AnnotatedNode::Map { entries }
205        }
206        other => AnnotatedNode::Leaf {
207            value: other.clone(),
208            source,
209        },
210    }
211}
212
213/// Merge `over` onto `base` with provenance. Mirrors [`deep_merge`] but
214/// carries source indices; `over_source` is the layer index for any
215/// leaves the overlay introduces or replaces.
216fn deep_merge_annotated(
217    base: AnnotatedNode,
218    over: &Value,
219    over_source: usize,
220) -> AnnotatedNode {
221    match (base, over) {
222        (AnnotatedNode::Map { mut entries }, Value::Mapping(over_map)) => {
223            for (k, v) in over_map {
224                if matches!(v, Value::Null) {
225                    entries.retain(|(ek, _)| ek != k);
226                    continue;
227                }
228                let pos = entries.iter().position(|(ek, _)| ek == k);
229                match pos {
230                    Some(p) => {
231                        // Match deep_merge's re-key-to-end behaviour: drop
232                        // the existing entry and re-append under merge.
233                        let (_, existing) = entries.remove(p);
234                        let merged = deep_merge_annotated(existing, v, over_source);
235                        entries.push((k.clone(), merged));
236                    }
237                    None => {
238                        entries.push((k.clone(), to_annotated(v, over_source)));
239                    }
240                }
241            }
242            AnnotatedNode::Map { entries }
243        }
244        // Type change or scalar-over-anything: overlay replaces wholesale.
245        (_, over) => to_annotated(over, over_source),
246    }
247}
248
249// ── Rendering with inline source comments ───────────────────────────────
250
251/// Emit an [`AnnotatedNode`] as YAML with a trailing `# <label>` on each
252/// leaf line, column-aligned for legibility.
253///
254/// `source_labels[i]` is the label shown for layer index `i` (typically a
255/// filename). Sequences are rendered inline when all items are scalars
256/// and the resulting line fits the `INLINE_SEQ_LIMIT` threshold; otherwise
257/// they drop to block style with the source tag on the key line.
258pub fn render_annotated_yaml(node: &AnnotatedNode, source_labels: &[String]) -> String {
259    // Three-pass render:
260    // 1. Emit raw lines with `\0` between body and source tag.
261    // 2. Pad bodies so `# tag` comments align.
262    // 3. Colorize: green keys + dim-gray tags (no-op if color disabled).
263    //
264    // Color happens AFTER alignment so the ANSI escape bytes don't get
265    // counted as body width.
266    let mut raw = String::new();
267    render_node(node, 0, source_labels, &mut raw);
268    let aligned = align_comments(&raw);
269    colorize_keys(&aligned)
270}
271
272/// Inline-sequence threshold: combined line length beyond which a
273/// scalar-only sequence drops from `[a, b, c]` to block form.
274const INLINE_SEQ_LIMIT: usize = 80;
275
276fn render_node(node: &AnnotatedNode, indent: usize, labels: &[String], out: &mut String) {
277    match node {
278        AnnotatedNode::Leaf { value, source } => {
279            // Top-level leaf (root is a bare scalar). Rare but support it.
280            let tag = label(labels, *source);
281            emit_line(out, indent, &format_scalar(value), Some(&tag));
282        }
283        AnnotatedNode::Map { entries } => {
284            for (k, child) in entries {
285                let key = format_key(k);
286                match child {
287                    AnnotatedNode::Leaf { value, source } => {
288                        let tag = label(labels, *source);
289                        render_leaf_entry(&key, value, &tag, indent, out);
290                    }
291                    AnnotatedNode::Map { .. } => {
292                        // Header line for a nested map: no tag (the map
293                        // itself has no single source).
294                        emit_header(out, indent, &format!("{key}:"));
295                        render_node(child, indent + 2, labels, out);
296                    }
297                }
298            }
299        }
300    }
301}
302
303fn render_leaf_entry(key: &str, value: &Value, tag: &str, indent: usize, out: &mut String) {
304    match value {
305        Value::Sequence(items) if items.iter().all(is_inline_scalar) => {
306            let inline = format!(
307                "{key}: [{}]",
308                items
309                    .iter()
310                    .map(format_scalar)
311                    .collect::<Vec<_>>()
312                    .join(", ")
313            );
314            if indent + inline.len() <= INLINE_SEQ_LIMIT {
315                emit_line(out, indent, &inline, Some(tag));
316            } else {
317                emit_line(out, indent, &format!("{key}:"), Some(tag));
318                for item in items {
319                    emit_header(out, indent + 2, &format!("- {}", format_scalar(item)));
320                }
321            }
322        }
323        Value::Sequence(items) => {
324            emit_line(out, indent, &format!("{key}:"), Some(tag));
325            for item in items {
326                match item {
327                    Value::Mapping(m) => {
328                        // First entry on the `-` line, rest indented at the
329                        // same column. Each entry recurses through
330                        // `render_mapping_field` so nested sequences render
331                        // correctly (was `ranks: - 0` from format_scalar's
332                        // defensive fallback).
333                        let mut it = m.iter();
334                        if let Some((first_k, first_v)) = it.next() {
335                            render_mapping_field(
336                                first_k, first_v, indent + 2, Some("- "), out,
337                            );
338                            for (k, v) in it {
339                                render_mapping_field(k, v, indent + 4, None, out);
340                            }
341                        }
342                    }
343                    other => {
344                        emit_header(out, indent + 2, &format!("- {}", format_scalar(other)));
345                    }
346                }
347            }
348        }
349        other => {
350            emit_line(out, indent, &format!("{key}: {}", format_scalar(other)), Some(tag));
351        }
352    }
353}
354
355/// Render one `key: value` field inside a mapping that is itself a list
356/// item. Same logic as [`render_leaf_entry`] but emits header lines (no
357/// source tag) since the containing list already carried the source.
358///
359/// `prefix` is `Some("- ")` for the first key of a list item (printed
360/// flush with the dash) and `None` for subsequent keys (printed at the
361/// indent column for alignment with the first key).
362fn render_mapping_field(
363    k: &Value,
364    v: &Value,
365    indent: usize,
366    prefix: Option<&str>,
367    out: &mut String,
368) {
369    let key = format_key(k);
370    let head = format!("{}{key}", prefix.unwrap_or(""));
371    match v {
372        Value::Sequence(items) if items.iter().all(is_inline_scalar) => {
373            let inline = format!(
374                "{head}: [{}]",
375                items
376                    .iter()
377                    .map(format_scalar)
378                    .collect::<Vec<_>>()
379                    .join(", ")
380            );
381            if indent + inline.len() <= INLINE_SEQ_LIMIT {
382                emit_header(out, indent, &inline);
383            } else {
384                emit_header(out, indent, &format!("{head}:"));
385                for item in items {
386                    emit_header(out, indent + 2, &format!("- {}", format_scalar(item)));
387                }
388            }
389        }
390        Value::Sequence(items) => {
391            emit_header(out, indent, &format!("{head}:"));
392            for item in items {
393                emit_header(out, indent + 2, &format!("- {}", format_scalar(item)));
394            }
395        }
396        Value::Mapping(_) => {
397            emit_header(out, indent, &format!("{head}:"));
398            // Mapping values inside list items: walk recursively.
399            if let Value::Mapping(m) = v {
400                for (k2, v2) in m {
401                    render_mapping_field(k2, v2, indent + 2, None, out);
402                }
403            }
404        }
405        other => {
406            emit_header(out, indent, &format!("{head}: {}", format_scalar(other)));
407        }
408    }
409}
410
411/// Write a line that will participate in column alignment. `body` is the
412/// YAML body (key: value); `tag` is the source label. Body and tag are
413/// separated by a `\0` sentinel so [`align_comments`] can pad precisely.
414fn emit_line(out: &mut String, indent: usize, body: &str, tag: Option<&str>) {
415    for _ in 0..indent {
416        out.push(' ');
417    }
418    out.push_str(body);
419    if let Some(t) = tag {
420        out.push('\0');
421        out.push_str(t);
422    }
423    out.push('\n');
424}
425
426/// Write a header/structural line (no source tag). No `\0` sentinel so
427/// alignment leaves it untouched.
428fn emit_header(out: &mut String, indent: usize, body: &str) {
429    for _ in 0..indent {
430        out.push(' ');
431    }
432    out.push_str(body);
433    out.push('\n');
434}
435
436/// Align `# <tag>` comments across lines that carry the `\0` sentinel.
437/// Lines without the sentinel pass through unchanged. Comment column is
438/// `max(body_width) + 2`, clamped to a minimum for single-line configs.
439/// Maximum body width to track for comment alignment. Beyond this, a long
440/// line (e.g. a multi-flag shell command) breaks alignment for that line
441/// only -- its comment falls right after with a 2-space gutter. This stops
442/// one 90-char clippy command from pushing every comment past the terminal
443/// edge and triggering wrap.
444const ALIGN_CAP: usize = 50;
445
446fn align_comments(raw: &str) -> String {
447    let lines: Vec<&str> = raw.lines().collect();
448    let mut max_body = 0;
449    for line in &lines {
450        if let Some(idx) = line.find('\0') {
451            // Only count lines that fit under the cap; outliers don't
452            // drag everyone else's column rightward.
453            if idx <= ALIGN_CAP {
454                max_body = max_body.max(idx);
455            }
456        }
457    }
458    // 2-space gutter before the `#`. Minimum column so single-key files
459    // still look deliberate rather than cramped.
460    let col = max_body.max(12) + 2;
461
462    let mut out = String::with_capacity(raw.len() + lines.len() * 4);
463    for line in &lines {
464        match line.find('\0') {
465            Some(idx) => {
466                let (body, rest) = line.split_at(idx);
467                let tag = &rest[1..]; // skip the '\0'
468                out.push_str(body);
469                let body_width = body.chars().count();
470                // If the body is too wide to align cleanly, fall back to a
471                // 2-space gutter for that single line.
472                let target_col = if body_width > ALIGN_CAP { body_width + 2 } else { col };
473                for _ in body_width..target_col {
474                    out.push(' ');
475                }
476                // Preserve a `\0` sentinel between padding and `# tag` so
477                // the next pass (colorize_keys) can split unambiguously.
478                // colorize_keys is mandatory and always strips it.
479                out.push('\0');
480                out.push_str("# ");
481                out.push_str(tag);
482            }
483            None => out.push_str(line),
484        }
485        out.push('\n');
486    }
487    out
488}
489
490/// Final render pass: colorize keys (green) and source tags (dark-gray),
491/// and strip the `\0` body/tag sentinel emitted by [`align_comments`].
492///
493/// When color is disabled the function still runs (to remove `\0`) but
494/// emits no ANSI escapes. `\x1b[32m` (green) matches `fdl -h`'s
495/// `-h, --help` style for option names. `\x1b[90m` (bright-black) is the
496/// most reliable "dim" effect across terminal themes; `\x1b[2m` actual-dim
497/// is unimplemented or near-invisible in many setups.
498fn colorize_keys(text: &str) -> String {
499    let color = crate::style::color_enabled();
500    let key_open = if color { "\x1b[32m" } else { "" };
501    let key_close = if color { "\x1b[0m" } else { "" };
502    let tag_open = if color { "\x1b[90m" } else { "" };
503    let tag_close = if color { "\x1b[0m" } else { "" };
504
505    let mut out = String::with_capacity(text.len() + text.lines().count() * 16);
506    for line in text.lines() {
507        // The `\0` sentinel marks the body / tag boundary (emitted by
508        // align_comments). Unambiguous -- can't appear in user content.
509        let (body, comment) = match line.find('\0') {
510            Some(i) => (&line[..i], Some(&line[i + 1..])),
511            None => (line, None),
512        };
513
514        // Key colorization on the body part.
515        match find_key_segment(body) {
516            Some((key_start, key_end)) => {
517                out.push_str(&body[..key_start]);
518                out.push_str(key_open);
519                out.push_str(&body[key_start..key_end]);
520                out.push_str(key_close);
521                out.push_str(&body[key_end..]);
522            }
523            None => out.push_str(body),
524        }
525
526        if let Some(c) = comment {
527            out.push_str(tag_open);
528            out.push_str(c);
529            out.push_str(tag_close);
530        }
531        out.push('\n');
532    }
533    out
534}
535
536/// Locate the `(start, end)` byte range of the YAML key on this line, or
537/// None if there is no key (blank, list-scalar, etc.). Handles list-item
538/// prefix `- ` and arbitrary indent.
539fn find_key_segment(line: &str) -> Option<(usize, usize)> {
540    let bytes = line.as_bytes();
541    let mut i = 0;
542    while i < bytes.len() && bytes[i] == b' ' {
543        i += 1;
544    }
545    // Optional list-item dash.
546    if i + 1 < bytes.len() && bytes[i] == b'-' && bytes[i + 1] == b' ' {
547        i += 2;
548    }
549    let key_start = i;
550    // Scan for the first `:` followed by space / end / newline.
551    while i < bytes.len() {
552        if bytes[i] == b':' {
553            let next = bytes.get(i + 1).copied();
554            match next {
555                None | Some(b' ') | Some(b'\n') => {
556                    if i > key_start {
557                        return Some((key_start, i));
558                    }
559                    return None;
560                }
561                _ => {}
562            }
563        }
564        i += 1;
565    }
566    None
567}
568
569fn label(labels: &[String], source: usize) -> String {
570    labels
571        .get(source)
572        .cloned()
573        .unwrap_or_else(|| format!("layer[{source}]"))
574}
575
576fn is_inline_scalar(v: &Value) -> bool {
577    matches!(
578        v,
579        Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_)
580    )
581}
582
583/// Format a scalar for display in a YAML line. Strings are quoted only
584/// when they would otherwise parse ambiguously (start with a special
585/// char, contain a `:` followed by space, etc.). Goal: look like the
586/// user's source file when unambiguous, quote only when required.
587fn format_scalar(v: &Value) -> String {
588    match v {
589        Value::Null => "null".to_string(),
590        Value::Bool(b) => b.to_string(),
591        Value::Number(n) => n.to_string(),
592        Value::String(s) => format_string(s),
593        Value::Sequence(_) | Value::Mapping(_) => {
594            // Shouldn't be called with a container — defensive fallback.
595            serde_yaml_ng::to_string(v).unwrap_or_default().trim().to_string()
596        }
597        Value::Tagged(t) => serde_yaml_ng::to_string(&**t)
598            .unwrap_or_default()
599            .trim()
600            .to_string(),
601    }
602}
603
604fn format_key(k: &Value) -> String {
605    match k {
606        Value::String(s) => {
607            // Most config keys are plain identifiers; keep them unquoted.
608            if is_plain_key(s) {
609                s.clone()
610            } else {
611                format_string(s)
612            }
613        }
614        other => format_scalar(other),
615    }
616}
617
618fn is_plain_key(s: &str) -> bool {
619    !s.is_empty()
620        && s.chars()
621            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
622}
623
624fn format_string(s: &str) -> String {
625    // Quote if the raw string would mis-parse as something else, or if
626    // it contains characters that make unquoted YAML ambiguous.
627    let needs_quote = s.is_empty()
628        || s.contains(':')
629        || s.contains('#')
630        || s.contains('\n')
631        || s.contains('"')
632        || s.starts_with(|c: char| c.is_whitespace() || "!&*>|%@`[]{},-?".contains(c))
633        || matches!(s, "true" | "false" | "null" | "yes" | "no" | "~")
634        || s.parse::<f64>().is_ok();
635    if needs_quote {
636        // Double-quoted with JSON-style escapes.
637        let escaped = s
638            .replace('\\', "\\\\")
639            .replace('"', "\\\"")
640            .replace('\n', "\\n")
641            .replace('\t', "\\t");
642        format!("\"{escaped}\"")
643    } else {
644        s.to_string()
645    }
646}
647
648/// Load a YAML/JSON file as a [`Value`]. Extension-based dispatch on the
649/// file suffix (`.yml`, `.yaml`, `.json`).
650pub fn load_value(path: &Path) -> Result<Value, String> {
651    let content = std::fs::read_to_string(path)
652        .map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
653    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("yaml");
654    match ext {
655        "json" => serde_json::from_str::<Value>(&content)
656            .map_err(|e| format!("{}: {}", path.display(), e)),
657        _ => serde_yaml_ng::from_str::<Value>(&content)
658            .map_err(|e| format!("{}: {}", path.display(), e)),
659    }
660}
661
662// ── `inherit-from:` chain resolution ────────────────────────────────────
663//
664// A config file can declare a top-level `inherit-from: <path>` that names
665// a parent to merge under. Chains are linear (single parent) so the
666// effective layer list becomes [deepest-ancestor, ..., direct-parent, this].
667// The `inherit-from` key is stripped from every returned value so it
668// doesn't leak into the deserialised config.
669
670/// YAML key used by [`resolve_chain`] to discover the parent layer.
671const INHERIT_KEY: &str = "inherit-from";
672
673/// Load `path` and every ancestor reachable via `inherit-from:`, returning
674/// them in merge order (deepest ancestor first, `path` itself last). The
675/// `inherit-from` key is removed from every returned [`Value`].
676///
677/// Relative ancestor paths are resolved against the directory of the file
678/// that declared the `inherit-from:`. Cycles (including self-inheritance)
679/// are detected via the recursion stack and surface as an error listing
680/// the full cycle for fast diagnosis.
681pub fn resolve_chain(path: &Path) -> Result<Vec<(PathBuf, Value)>, String> {
682    let mut stack: Vec<PathBuf> = Vec::new();
683    let mut out: Vec<(PathBuf, Value)> = Vec::new();
684    resolve_chain_inner(path, &mut stack, &mut out)?;
685    Ok(out)
686}
687
688fn resolve_chain_inner(
689    path: &Path,
690    stack: &mut Vec<PathBuf>,
691    out: &mut Vec<(PathBuf, Value)>,
692) -> Result<(), String> {
693    let canonical = path.canonicalize().map_err(|e| {
694        format!(
695            "cannot resolve inherit-from target `{}`: {e}",
696            path.display()
697        )
698    })?;
699
700    if stack.contains(&canonical) {
701        let mut chain: Vec<String> = stack.iter().map(|p| p.display().to_string()).collect();
702        chain.push(canonical.display().to_string());
703        return Err(format!("inherit-from cycle detected: {}", chain.join(" -> ")));
704    }
705
706    stack.push(canonical.clone());
707
708    let mut value = load_value(path)?;
709    let parent = extract_inherit_from(&mut value, path)?;
710
711    if let Some(parent_rel) = parent {
712        let parent_abs = if Path::new(&parent_rel).is_absolute() {
713            PathBuf::from(&parent_rel)
714        } else {
715            canonical
716                .parent()
717                .unwrap_or_else(|| Path::new("."))
718                .join(&parent_rel)
719        };
720        resolve_chain_inner(&parent_abs, stack, out)?;
721    }
722
723    stack.pop();
724    out.push((canonical, value));
725    Ok(())
726}
727
728/// Pop the top-level `inherit-from` key from a mapping and return its
729/// string value. A missing or explicitly-null key returns `Ok(None)`.
730/// A non-string value errors with the offending type named.
731fn extract_inherit_from(value: &mut Value, path: &Path) -> Result<Option<String>, String> {
732    let Value::Mapping(m) = value else {
733        return Ok(None);
734    };
735    let key = Value::String(INHERIT_KEY.to_string());
736    match m.remove(&key) {
737        None | Some(Value::Null) => Ok(None),
738        Some(Value::String(s)) if s.is_empty() => Err(format!(
739            "{INHERIT_KEY} in {} must be a non-empty path",
740            path.display()
741        )),
742        Some(Value::String(s)) => Ok(Some(s)),
743        Some(other) => Err(format!(
744            "{INHERIT_KEY} in {} must be a string path, got {}",
745            path.display(),
746            type_name(&other)
747        )),
748    }
749}
750
751fn type_name(v: &Value) -> &'static str {
752    match v {
753        Value::Null => "null",
754        Value::Bool(_) => "bool",
755        Value::Number(_) => "number",
756        Value::String(_) => "string",
757        Value::Sequence(_) => "sequence",
758        Value::Mapping(_) => "mapping",
759        Value::Tagged(_) => "tagged",
760    }
761}
762
763// ── Tests ───────────────────────────────────────────────────────────────
764
765#[cfg(test)]
766#[path = "overlay_tests.rs"]
767mod tests;