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;