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