use std::path::{Path, PathBuf};
use serde_yaml::{Mapping, Value};
pub fn deep_merge(base: Value, over: Value) -> Value {
match (base, over) {
(Value::Mapping(base_map), Value::Mapping(over_map)) => {
Value::Mapping(merge_mapping(base_map, over_map))
}
(_, over) => over,
}
}
fn merge_mapping(mut base: Mapping, over: Mapping) -> Mapping {
for (k, v) in over {
if matches!(v, Value::Null) {
base.remove(&k);
continue;
}
match base.remove(&k) {
Some(existing) => {
base.insert(k, deep_merge(existing, v));
}
None => {
base.insert(k, v);
}
}
}
base
}
pub fn merge_layers<I>(layers: I) -> Value
where
I: IntoIterator<Item = Value>,
{
layers
.into_iter()
.reduce(deep_merge)
.unwrap_or(Value::Null)
}
const EXTENSIONS: &[&str] = &["yml", "yaml", "json"];
pub fn find_env_file(base_config: &Path, env: &str) -> Option<PathBuf> {
let dir = base_config.parent()?;
for ext in EXTENSIONS {
let candidate = dir.join(format!("fdl.{env}.{ext}"));
if candidate.is_file() {
return Some(candidate);
}
}
None
}
pub fn list_envs(base_config: &Path) -> Vec<String> {
let Some(dir) = base_config.parent() else {
return Vec::new();
};
let entries = match std::fs::read_dir(dir) {
Ok(r) => r,
Err(_) => return Vec::new(),
};
let mut envs = std::collections::BTreeSet::new();
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name_str) = name.to_str() else {
continue;
};
let Some(stripped) = name_str.strip_prefix("fdl.") else {
continue;
};
let Some((env, ext)) = stripped.rsplit_once('.') else {
continue;
};
if env.is_empty() || !EXTENSIONS.contains(&ext) {
continue;
}
envs.insert(env.to_string());
}
envs.into_iter().collect()
}
#[derive(Debug, Clone)]
pub enum AnnotatedNode {
Leaf { value: Value, source: usize },
Map { entries: Vec<(Value, AnnotatedNode)> },
}
impl AnnotatedNode {
pub fn to_value(&self) -> Value {
match self {
AnnotatedNode::Leaf { value, .. } => value.clone(),
AnnotatedNode::Map { entries } => {
let mut m = Mapping::new();
for (k, v) in entries {
m.insert(k.clone(), v.to_value());
}
Value::Mapping(m)
}
}
}
}
pub fn merge_layers_annotated(layers: &[Value]) -> AnnotatedNode {
if layers.is_empty() {
return AnnotatedNode::Leaf {
value: Value::Null,
source: 0,
};
}
let mut result = to_annotated(&layers[0], 0);
for (i, layer) in layers.iter().enumerate().skip(1) {
result = deep_merge_annotated(result, layer, i);
}
result
}
fn to_annotated(v: &Value, source: usize) -> AnnotatedNode {
match v {
Value::Mapping(m) => {
let entries = m
.iter()
.map(|(k, v)| (k.clone(), to_annotated(v, source)))
.collect();
AnnotatedNode::Map { entries }
}
other => AnnotatedNode::Leaf {
value: other.clone(),
source,
},
}
}
fn deep_merge_annotated(
base: AnnotatedNode,
over: &Value,
over_source: usize,
) -> AnnotatedNode {
match (base, over) {
(AnnotatedNode::Map { mut entries }, Value::Mapping(over_map)) => {
for (k, v) in over_map {
if matches!(v, Value::Null) {
entries.retain(|(ek, _)| ek != k);
continue;
}
let pos = entries.iter().position(|(ek, _)| ek == k);
match pos {
Some(p) => {
let (_, existing) = entries.remove(p);
let merged = deep_merge_annotated(existing, v, over_source);
entries.push((k.clone(), merged));
}
None => {
entries.push((k.clone(), to_annotated(v, over_source)));
}
}
}
AnnotatedNode::Map { entries }
}
(_, over) => to_annotated(over, over_source),
}
}
pub fn render_annotated_yaml(node: &AnnotatedNode, source_labels: &[String]) -> String {
let mut raw = String::new();
render_node(node, 0, source_labels, &mut raw);
align_comments(&raw)
}
const INLINE_SEQ_LIMIT: usize = 80;
fn render_node(node: &AnnotatedNode, indent: usize, labels: &[String], out: &mut String) {
match node {
AnnotatedNode::Leaf { value, source } => {
let tag = label(labels, *source);
emit_line(out, indent, &format_scalar(value), Some(&tag));
}
AnnotatedNode::Map { entries } => {
for (k, child) in entries {
let key = format_key(k);
match child {
AnnotatedNode::Leaf { value, source } => {
let tag = label(labels, *source);
render_leaf_entry(&key, value, &tag, indent, out);
}
AnnotatedNode::Map { .. } => {
emit_header(out, indent, &format!("{key}:"));
render_node(child, indent + 2, labels, out);
}
}
}
}
}
}
fn render_leaf_entry(key: &str, value: &Value, tag: &str, indent: usize, out: &mut String) {
match value {
Value::Sequence(items) if items.iter().all(is_inline_scalar) => {
let inline = format!(
"{key}: [{}]",
items
.iter()
.map(format_scalar)
.collect::<Vec<_>>()
.join(", ")
);
if indent + inline.len() <= INLINE_SEQ_LIMIT {
emit_line(out, indent, &inline, Some(tag));
} else {
emit_line(out, indent, &format!("{key}:"), Some(tag));
for item in items {
emit_header(out, indent + 2, &format!("- {}", format_scalar(item)));
}
}
}
Value::Sequence(items) => {
emit_line(out, indent, &format!("{key}:"), Some(tag));
for item in items {
match item {
Value::Mapping(m) => {
let mut it = m.iter();
if let Some((first_k, first_v)) = it.next() {
let first_key = format_key(first_k);
emit_header(
out,
indent + 2,
&format!("- {first_key}: {}", format_scalar(first_v)),
);
for (k, v) in it {
emit_header(
out,
indent + 4,
&format!("{}: {}", format_key(k), format_scalar(v)),
);
}
}
}
other => {
emit_header(out, indent + 2, &format!("- {}", format_scalar(other)));
}
}
}
}
other => {
emit_line(out, indent, &format!("{key}: {}", format_scalar(other)), Some(tag));
}
}
}
fn emit_line(out: &mut String, indent: usize, body: &str, tag: Option<&str>) {
for _ in 0..indent {
out.push(' ');
}
out.push_str(body);
if let Some(t) = tag {
out.push('\0');
out.push_str(t);
}
out.push('\n');
}
fn emit_header(out: &mut String, indent: usize, body: &str) {
for _ in 0..indent {
out.push(' ');
}
out.push_str(body);
out.push('\n');
}
fn align_comments(raw: &str) -> String {
let lines: Vec<&str> = raw.lines().collect();
let mut max_body = 0;
for line in &lines {
if let Some(idx) = line.find('\0') {
max_body = max_body.max(idx);
}
}
let col = max_body.max(12) + 2;
let mut out = String::with_capacity(raw.len() + lines.len() * 4);
for line in &lines {
match line.find('\0') {
Some(idx) => {
let (body, rest) = line.split_at(idx);
let tag = &rest[1..]; out.push_str(body);
for _ in body.chars().count()..col {
out.push(' ');
}
out.push_str("# ");
out.push_str(tag);
}
None => out.push_str(line),
}
out.push('\n');
}
out
}
fn label(labels: &[String], source: usize) -> String {
labels
.get(source)
.cloned()
.unwrap_or_else(|| format!("layer[{source}]"))
}
fn is_inline_scalar(v: &Value) -> bool {
matches!(
v,
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_)
)
}
fn format_scalar(v: &Value) -> String {
match v {
Value::Null => "null".to_string(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::String(s) => format_string(s),
Value::Sequence(_) | Value::Mapping(_) => {
serde_yaml::to_string(v).unwrap_or_default().trim().to_string()
}
Value::Tagged(t) => serde_yaml::to_string(&**t)
.unwrap_or_default()
.trim()
.to_string(),
}
}
fn format_key(k: &Value) -> String {
match k {
Value::String(s) => {
if is_plain_key(s) {
s.clone()
} else {
format_string(s)
}
}
other => format_scalar(other),
}
}
fn is_plain_key(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
fn format_string(s: &str) -> String {
let needs_quote = s.is_empty()
|| s.contains(':')
|| s.contains('#')
|| s.contains('\n')
|| s.contains('"')
|| s.starts_with(|c: char| c.is_whitespace() || "!&*>|%@`[]{},-?".contains(c))
|| matches!(s, "true" | "false" | "null" | "yes" | "no" | "~")
|| s.parse::<f64>().is_ok();
if needs_quote {
let escaped = s
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\t', "\\t");
format!("\"{escaped}\"")
} else {
s.to_string()
}
}
pub fn load_value(path: &Path) -> Result<Value, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("yaml");
match ext {
"json" => serde_json::from_str::<Value>(&content)
.map_err(|e| format!("{}: {}", path.display(), e)),
_ => serde_yaml::from_str::<Value>(&content)
.map_err(|e| format!("{}: {}", path.display(), e)),
}
}
const INHERIT_KEY: &str = "inherit-from";
pub fn resolve_chain(path: &Path) -> Result<Vec<(PathBuf, Value)>, String> {
let mut stack: Vec<PathBuf> = Vec::new();
let mut out: Vec<(PathBuf, Value)> = Vec::new();
resolve_chain_inner(path, &mut stack, &mut out)?;
Ok(out)
}
fn resolve_chain_inner(
path: &Path,
stack: &mut Vec<PathBuf>,
out: &mut Vec<(PathBuf, Value)>,
) -> Result<(), String> {
let canonical = path.canonicalize().map_err(|e| {
format!(
"cannot resolve inherit-from target `{}`: {e}",
path.display()
)
})?;
if stack.contains(&canonical) {
let mut chain: Vec<String> = stack.iter().map(|p| p.display().to_string()).collect();
chain.push(canonical.display().to_string());
return Err(format!("inherit-from cycle detected: {}", chain.join(" -> ")));
}
stack.push(canonical.clone());
let mut value = load_value(path)?;
let parent = extract_inherit_from(&mut value, path)?;
if let Some(parent_rel) = parent {
let parent_abs = if Path::new(&parent_rel).is_absolute() {
PathBuf::from(&parent_rel)
} else {
canonical
.parent()
.unwrap_or_else(|| Path::new("."))
.join(&parent_rel)
};
resolve_chain_inner(&parent_abs, stack, out)?;
}
stack.pop();
out.push((canonical, value));
Ok(())
}
fn extract_inherit_from(value: &mut Value, path: &Path) -> Result<Option<String>, String> {
let Value::Mapping(m) = value else {
return Ok(None);
};
let key = Value::String(INHERIT_KEY.to_string());
match m.remove(&key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) if s.is_empty() => Err(format!(
"{INHERIT_KEY} in {} must be a non-empty path",
path.display()
)),
Some(Value::String(s)) => Ok(Some(s)),
Some(other) => Err(format!(
"{INHERIT_KEY} in {} must be a string path, got {}",
path.display(),
type_name(&other)
)),
}
}
fn type_name(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Sequence(_) => "sequence",
Value::Mapping(_) => "mapping",
Value::Tagged(_) => "tagged",
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn yaml(s: &str) -> Value {
serde_yaml::from_str(s).expect("test fixture must parse")
}
fn p(xs: &[&str]) -> Vec<String> {
xs.iter().map(|s| s.to_string()).collect()
}
#[test]
fn scalar_over_scalar_replaces() {
let base = yaml("42");
let over = yaml("99");
assert_eq!(deep_merge(base, over), yaml("99"));
}
#[test]
fn map_keys_deep_merge() {
let base = yaml(
r"
a: 1
nested:
x: one
y: two
",
);
let over = yaml(
r"
nested:
y: TWO
z: three
b: 2
",
);
let expected = yaml(
r"
a: 1
b: 2
nested:
x: one
y: TWO
z: three
",
);
assert_eq!(deep_merge(base, over), expected);
}
#[test]
fn lists_replace_not_append() {
let base = yaml(
r"
items: [a, b, c]
",
);
let over = yaml(
r"
items: [x, y]
",
);
let expected = yaml(
r"
items: [x, y]
",
);
assert_eq!(deep_merge(base, over), expected);
}
#[test]
fn null_in_overlay_deletes_key() {
let base = yaml(
r"
ddp:
policy: cadence
anchor: 3
training:
epochs: 10
",
);
let over = yaml(
r"
ddp: ~
training:
epochs: 20
",
);
let expected = yaml(
r"
training:
epochs: 20
",
);
assert_eq!(deep_merge(base, over), expected);
}
#[test]
fn null_leaf_removes_single_key() {
let base = yaml(
r"
ddp:
policy: cadence
anchor: 3
",
);
let over = yaml(
r"
ddp:
anchor: ~
",
);
let expected = yaml(
r"
ddp:
policy: cadence
",
);
assert_eq!(deep_merge(base, over), expected);
}
#[test]
fn overlay_adds_new_top_level_key() {
let base = yaml("a: 1");
let over = yaml("b: 2");
let expected = yaml(
r"
a: 1
b: 2
",
);
assert_eq!(deep_merge(base, over), expected);
}
#[test]
fn merge_chain_three_layers() {
let l1 = yaml("a: 1\nb: 1");
let l2 = yaml("b: 2\nc: 2");
let l3 = yaml("c: 3");
let got = merge_layers(vec![l1, l2, l3]);
let expected = yaml(
r"
a: 1
b: 2
c: 3
",
);
assert_eq!(got, expected);
}
#[test]
fn type_change_overlay_replaces_wholesale() {
let base = yaml(
r"
ddp:
policy: cadence
",
);
let over = yaml(
r"
ddp: solo-0
",
);
let expected = yaml(
r"
ddp: solo-0
",
);
assert_eq!(deep_merge(base, over), expected);
}
#[test]
fn type_change_scalar_base_mapping_overlay_replaces() {
let base = yaml(
r"
ddp: solo-0
",
);
let over = yaml(
r"
ddp:
policy: cadence
anchor: 3
",
);
let expected = yaml(
r"
ddp:
policy: cadence
anchor: 3
",
);
assert_eq!(deep_merge(base, over), expected);
}
#[test]
fn list_envs_discovers_sibling_overlays() {
let tmp = tempdir();
std::fs::write(tmp.path().join("fdl.yml"), "description: base").unwrap();
std::fs::write(tmp.path().join("fdl.ci.yml"), "description: ci").unwrap();
std::fs::write(tmp.path().join("fdl.cloud.yaml"), "description: cloud").unwrap();
std::fs::write(tmp.path().join("fdl.prod.json"), "{}").unwrap();
std::fs::write(tmp.path().join("fdl.yml.example"), "").unwrap();
std::fs::write(tmp.path().join("other.ci.yml"), "").unwrap();
std::fs::write(tmp.path().join("fdl.yml.bak"), "").unwrap();
let envs = list_envs(&tmp.path().join("fdl.yml"));
assert_eq!(envs, vec!["ci".to_string(), "cloud".into(), "prod".into()]);
}
#[test]
fn find_env_file_respects_extension_precedence() {
let tmp = tempdir();
std::fs::write(tmp.path().join("fdl.yml"), "").unwrap();
std::fs::write(tmp.path().join("fdl.ci.yml"), "# yml wins").unwrap();
std::fs::write(tmp.path().join("fdl.ci.yaml"), "# yaml loses").unwrap();
let got = find_env_file(&tmp.path().join("fdl.yml"), "ci").unwrap();
assert_eq!(got.file_name().unwrap().to_str(), Some("fdl.ci.yml"));
}
#[test]
fn find_env_file_missing_returns_none() {
let tmp = tempdir();
std::fs::write(tmp.path().join("fdl.yml"), "").unwrap();
assert!(find_env_file(&tmp.path().join("fdl.yml"), "nope").is_none());
}
fn leaves(node: &AnnotatedNode) -> Vec<(Vec<String>, usize)> {
fn walk(node: &AnnotatedNode, path: &mut Vec<String>, out: &mut Vec<(Vec<String>, usize)>) {
match node {
AnnotatedNode::Leaf { source, .. } => out.push((path.clone(), *source)),
AnnotatedNode::Map { entries } => {
for (k, v) in entries {
let key = match k {
Value::String(s) => s.clone(),
other => format!("{other:?}"),
};
path.push(key);
walk(v, path, out);
path.pop();
}
}
}
}
let mut out = Vec::new();
walk(node, &mut Vec::new(), &mut out);
out
}
#[test]
fn annotated_single_layer_tags_every_leaf_with_zero() {
let layers = vec![yaml("ddp:\n policy: cadence\n anchor: 3\ntraining:\n epochs: 10\n")];
let node = merge_layers_annotated(&layers);
for (path, src) in leaves(&node) {
assert_eq!(src, 0, "{path:?} should be tagged with layer 0");
}
}
#[test]
fn annotated_overlay_replaces_key_source() {
let layers = vec![
yaml("ddp:\n policy: cadence\n anchor: 3\n"),
yaml("ddp:\n anchor: 5\n"),
];
let node = merge_layers_annotated(&layers);
let by_path: BTreeMap<Vec<String>, usize> = leaves(&node).into_iter().collect();
assert_eq!(by_path[&p(&["ddp", "policy"])], 0);
assert_eq!(by_path[&p(&["ddp", "anchor"])], 1);
}
#[test]
fn annotated_added_key_tagged_with_overlay() {
let layers = vec![
yaml("ddp:\n policy: cadence\n"),
yaml("training:\n epochs: 20\n"),
];
let node = merge_layers_annotated(&layers);
let by_path: BTreeMap<Vec<String>, usize> = leaves(&node).into_iter().collect();
assert_eq!(by_path[&p(&["training", "epochs"])], 1);
}
#[test]
fn annotated_null_deletes_key_and_removes_leaf() {
let layers = vec![
yaml("ddp:\n policy: cadence\n anchor: 3\n"),
yaml("ddp:\n anchor: ~\n"),
];
let node = merge_layers_annotated(&layers);
let paths: Vec<Vec<String>> = leaves(&node).into_iter().map(|(path, _)| path).collect();
assert!(paths.contains(&p(&["ddp", "policy"])));
assert!(!paths.iter().any(|path| path == &p(&["ddp", "anchor"])));
}
#[test]
fn annotated_type_change_resets_source_to_overlay() {
let layers = vec![
yaml("ddp:\n policy: cadence\n"),
yaml("ddp: solo-0\n"),
];
let node = merge_layers_annotated(&layers);
let by_path: BTreeMap<Vec<String>, usize> = leaves(&node).into_iter().collect();
assert_eq!(by_path[&p(&["ddp"])], 1);
assert!(!by_path.contains_key(&p(&["ddp", "policy"])));
}
#[test]
fn annotated_list_replaced_wholesale_tagged_with_setter() {
let layers = vec![
yaml("regions: [eu-west]\n"),
yaml("regions: [us-east, ap-south]\n"),
];
let node = merge_layers_annotated(&layers);
let by_path: BTreeMap<Vec<String>, usize> = leaves(&node).into_iter().collect();
assert_eq!(by_path[&p(&["regions"])], 1);
}
#[test]
fn annotated_three_layer_chain() {
let layers = vec![
yaml("a: 1\nb: 1\nc: 1\n"),
yaml("b: 2\nc: 2\n"),
yaml("c: 3\n"),
];
let node = merge_layers_annotated(&layers);
let by_path: BTreeMap<Vec<String>, usize> = leaves(&node).into_iter().collect();
assert_eq!(by_path[&p(&["a"])], 0);
assert_eq!(by_path[&p(&["b"])], 1);
assert_eq!(by_path[&p(&["c"])], 2);
}
#[test]
fn annotated_to_value_matches_deep_merge() {
let l1 = yaml("ddp:\n policy: cadence\n anchor: 3\ntraining:\n epochs: 10\n");
let l2 = yaml("ddp:\n anchor: 5\ntraining:\n seed: 42\n");
let annotated = merge_layers_annotated(&[l1.clone(), l2.clone()]);
let plain = deep_merge(l1, l2);
assert_eq!(annotated.to_value(), plain);
}
fn labels(xs: &[&str]) -> Vec<String> {
xs.iter().map(|s| s.to_string()).collect()
}
#[test]
fn render_tags_every_leaf_with_filename() {
let layers = vec![yaml("ddp:\n policy: cadence\n anchor: 3\n")];
let node = merge_layers_annotated(&layers);
let out = render_annotated_yaml(&node, &labels(&["fdl.yml"]));
for line in out.lines() {
if line.contains(':') && !line.trim_end().ends_with(':') {
assert!(line.contains("# fdl.yml"), "missing tag on: `{line}`");
}
}
}
#[test]
fn render_tags_overlay_keys_with_overlay_filename() {
let layers = vec![
yaml("ddp:\n policy: cadence\n anchor: 3\n"),
yaml("ddp:\n anchor: 5\n"),
];
let node = merge_layers_annotated(&layers);
let out = render_annotated_yaml(&node, &labels(&["fdl.yml", "fdl.ci.yml"]));
let policy_line = out.lines().find(|l| l.contains("policy:")).unwrap();
assert!(policy_line.contains("# fdl.yml") && !policy_line.contains("# fdl.ci.yml"));
let anchor_line = out.lines().find(|l| l.contains("anchor:")).unwrap();
assert!(anchor_line.contains("# fdl.ci.yml"));
}
#[test]
fn render_aligns_comment_column() {
let layers = vec![yaml("a: 1\nbb: 22\nccc: 333\n")];
let node = merge_layers_annotated(&layers);
let out = render_annotated_yaml(&node, &labels(&["fdl.yml"]));
let cols: Vec<usize> = out
.lines()
.filter_map(|l| l.find('#'))
.collect();
assert!(cols.len() >= 3);
let first = cols[0];
assert!(cols.iter().all(|c| *c == first), "mismatched columns: {cols:?}");
}
#[test]
fn render_inline_short_scalar_list() {
let layers = vec![yaml("ratios: [1.5, 1.0]\n")];
let node = merge_layers_annotated(&layers);
let out = render_annotated_yaml(&node, &labels(&["fdl.yml"]));
assert!(out.contains("ratios: [1.5, 1.0]"), "got:\n{out}");
assert!(out.lines().next().unwrap().contains("# fdl.yml"));
}
#[test]
fn render_deleted_key_absent_from_output() {
let layers = vec![
yaml("ddp:\n policy: cadence\n anchor: 3\n"),
yaml("ddp:\n anchor: ~\n"),
];
let node = merge_layers_annotated(&layers);
let out = render_annotated_yaml(&node, &labels(&["fdl.yml", "fdl.ci.yml"]));
assert!(!out.contains("anchor"), "deleted key leaked: {out}");
assert!(out.contains("policy"));
}
#[test]
fn render_header_lines_have_no_comment() {
let layers = vec![yaml("ddp:\n policy: cadence\n")];
let node = merge_layers_annotated(&layers);
let out = render_annotated_yaml(&node, &labels(&["fdl.yml"]));
let header = out.lines().find(|l| l.trim() == "ddp:").unwrap();
assert!(!header.contains('#'));
}
#[test]
fn render_quotes_ambiguous_strings() {
let layers = vec![yaml("flag: \"true\"\n")];
let node = merge_layers_annotated(&layers);
let out = render_annotated_yaml(&node, &labels(&["fdl.yml"]));
assert!(out.contains("flag: \"true\""), "got:\n{out}");
}
#[test]
fn render_long_scalar_list_drops_to_block_form() {
let long: Vec<String> = (0..30).map(|i| format!("item-number-{i}")).collect();
let yaml_src = format!("items: [{}]\n", long.join(", "));
let layers = vec![yaml(&yaml_src)];
let node = merge_layers_annotated(&layers);
let out = render_annotated_yaml(&node, &labels(&["fdl.yml"]));
assert!(out.contains("items: "), "expected header line with tag");
assert!(out.contains("- item-number-0"));
}
fn canon(p: &Path) -> PathBuf {
p.canonicalize().expect("canonicalize fixture path")
}
#[test]
fn resolve_chain_single_file_no_inherit() {
let tmp = tempdir();
let f = tmp.path().join("fdl.yml");
std::fs::write(&f, "description: test\nddp:\n policy: cadence\n").unwrap();
let chain = resolve_chain(&f).unwrap();
assert_eq!(chain.len(), 1);
assert_eq!(chain[0].0, canon(&f));
}
#[test]
fn resolve_chain_strips_inherit_from_key() {
let tmp = tempdir();
let parent = tmp.path().join("fdl.yml");
let child = tmp.path().join("fdl.ci.yml");
std::fs::write(&parent, "a: 1\n").unwrap();
std::fs::write(&child, "inherit-from: fdl.yml\nb: 2\n").unwrap();
let chain = resolve_chain(&child).unwrap();
assert_eq!(chain.len(), 2);
assert_eq!(chain[0].0, canon(&parent));
assert_eq!(chain[1].0, canon(&child));
for (_, v) in &chain {
if let Value::Mapping(m) = v {
assert!(!m.contains_key(Value::String("inherit-from".to_string())));
}
}
}
#[test]
fn resolve_chain_three_level_ordering() {
let tmp = tempdir();
let a = tmp.path().join("a.yml");
let b = tmp.path().join("b.yml");
let c = tmp.path().join("c.yml");
std::fs::write(&a, "x: from-a\n").unwrap();
std::fs::write(&b, "inherit-from: a.yml\ny: from-b\n").unwrap();
std::fs::write(&c, "inherit-from: b.yml\nz: from-c\n").unwrap();
let chain = resolve_chain(&c).unwrap();
let paths: Vec<PathBuf> = chain.iter().map(|(p, _)| p.clone()).collect();
assert_eq!(paths, vec![canon(&a), canon(&b), canon(&c)]);
}
#[test]
fn resolve_chain_relative_paths_resolve_from_declaring_file() {
let tmp = tempdir();
let base = tmp.path().join("base.yml");
let nested_dir = tmp.path().join("nested");
std::fs::create_dir_all(&nested_dir).unwrap();
let child = nested_dir.join("child.yml");
std::fs::write(&base, "shared: true\n").unwrap();
std::fs::write(&child, "inherit-from: ../base.yml\nlocal: true\n").unwrap();
let chain = resolve_chain(&child).unwrap();
assert_eq!(chain.len(), 2);
assert_eq!(chain[0].0, canon(&base));
assert_eq!(chain[1].0, canon(&child));
}
#[test]
fn resolve_chain_absolute_path_works() {
let tmp = tempdir();
let parent = tmp.path().join("parent.yml");
let child = tmp.path().join("child.yml");
std::fs::write(&parent, "a: 1\n").unwrap();
let abs = canon(&parent);
std::fs::write(
&child,
format!("inherit-from: {}\nb: 2\n", abs.display()),
)
.unwrap();
let chain = resolve_chain(&child).unwrap();
assert_eq!(chain.len(), 2);
assert_eq!(chain[0].0, canon(&parent));
}
#[test]
fn resolve_chain_self_inheritance_errors() {
let tmp = tempdir();
let f = tmp.path().join("fdl.yml");
std::fs::write(&f, "inherit-from: fdl.yml\nx: 1\n").unwrap();
let err = resolve_chain(&f).unwrap_err();
assert!(err.contains("cycle"), "got: {err}");
assert!(err.matches("fdl.yml").count() >= 2, "got: {err}");
}
#[test]
fn resolve_chain_two_file_cycle_errors() {
let tmp = tempdir();
let a = tmp.path().join("a.yml");
let b = tmp.path().join("b.yml");
std::fs::write(&a, "inherit-from: b.yml\nx: 1\n").unwrap();
std::fs::write(&b, "inherit-from: a.yml\ny: 2\n").unwrap();
let err = resolve_chain(&a).unwrap_err();
assert!(err.contains("cycle"), "got: {err}");
assert!(err.contains("a.yml"));
assert!(err.contains("b.yml"));
}
#[test]
fn resolve_chain_missing_parent_errors() {
let tmp = tempdir();
let f = tmp.path().join("fdl.yml");
std::fs::write(&f, "inherit-from: missing.yml\nx: 1\n").unwrap();
let err = resolve_chain(&f).unwrap_err();
assert!(
err.contains("cannot resolve inherit-from target"),
"got: {err}"
);
assert!(err.contains("missing.yml"), "got: {err}");
}
#[test]
fn resolve_chain_non_string_inherit_errors() {
let tmp = tempdir();
let f = tmp.path().join("fdl.yml");
std::fs::write(&f, "inherit-from: 42\nx: 1\n").unwrap();
let err = resolve_chain(&f).unwrap_err();
assert!(err.contains("must be a string path"), "got: {err}");
assert!(err.contains("got number"), "got: {err}");
}
#[test]
fn resolve_chain_empty_string_inherit_errors() {
let tmp = tempdir();
let f = tmp.path().join("fdl.yml");
std::fs::write(&f, "inherit-from: \"\"\nx: 1\n").unwrap();
let err = resolve_chain(&f).unwrap_err();
assert!(err.contains("non-empty"), "got: {err}");
}
#[test]
fn resolve_chain_null_inherit_ignored() {
let tmp = tempdir();
let f = tmp.path().join("fdl.yml");
std::fs::write(&f, "inherit-from: ~\nx: 1\n").unwrap();
let chain = resolve_chain(&f).unwrap();
assert_eq!(chain.len(), 1);
}
fn tempdir() -> TempDir {
TempDir::new()
}
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let base = std::env::temp_dir();
let unique = format!(
"flodl-overlay-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
let dir = base.join(unique);
std::fs::create_dir_all(&dir).expect("tempdir creation");
Self(dir)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
}