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;