use std::path::{Path, PathBuf};
use serde_yaml_ng::{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] = &["yaml", "yml", "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);
let aligned = align_comments(&raw);
colorize_keys(&aligned)
}
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() {
render_mapping_field(
first_k, first_v, indent + 2, Some("- "), out,
);
for (k, v) in it {
render_mapping_field(k, v, indent + 4, None, out);
}
}
}
other => {
emit_header(out, indent + 2, &format!("- {}", format_scalar(other)));
}
}
}
}
other => {
emit_line(out, indent, &format!("{key}: {}", format_scalar(other)), Some(tag));
}
}
}
fn render_mapping_field(
k: &Value,
v: &Value,
indent: usize,
prefix: Option<&str>,
out: &mut String,
) {
let key = format_key(k);
let head = format!("{}{key}", prefix.unwrap_or(""));
match v {
Value::Sequence(items) if items.iter().all(is_inline_scalar) => {
let inline = format!(
"{head}: [{}]",
items
.iter()
.map(format_scalar)
.collect::<Vec<_>>()
.join(", ")
);
if indent + inline.len() <= INLINE_SEQ_LIMIT {
emit_header(out, indent, &inline);
} else {
emit_header(out, indent, &format!("{head}:"));
for item in items {
emit_header(out, indent + 2, &format!("- {}", format_scalar(item)));
}
}
}
Value::Sequence(items) => {
emit_header(out, indent, &format!("{head}:"));
for item in items {
emit_header(out, indent + 2, &format!("- {}", format_scalar(item)));
}
}
Value::Mapping(_) => {
emit_header(out, indent, &format!("{head}:"));
if let Value::Mapping(m) = v {
for (k2, v2) in m {
render_mapping_field(k2, v2, indent + 2, None, out);
}
}
}
other => {
emit_header(out, indent, &format!("{head}: {}", format_scalar(other)));
}
}
}
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');
}
const ALIGN_CAP: usize = 50;
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') {
if idx <= ALIGN_CAP {
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);
let body_width = body.chars().count();
let target_col = if body_width > ALIGN_CAP { body_width + 2 } else { col };
for _ in body_width..target_col {
out.push(' ');
}
out.push('\0');
out.push_str("# ");
out.push_str(tag);
}
None => out.push_str(line),
}
out.push('\n');
}
out
}
fn colorize_keys(text: &str) -> String {
let color = crate::style::color_enabled();
let key_open = if color { "\x1b[32m" } else { "" };
let key_close = if color { "\x1b[0m" } else { "" };
let tag_open = if color { "\x1b[90m" } else { "" };
let tag_close = if color { "\x1b[0m" } else { "" };
let mut out = String::with_capacity(text.len() + text.lines().count() * 16);
for line in text.lines() {
let (body, comment) = match line.find('\0') {
Some(i) => (&line[..i], Some(&line[i + 1..])),
None => (line, None),
};
match find_key_segment(body) {
Some((key_start, key_end)) => {
out.push_str(&body[..key_start]);
out.push_str(key_open);
out.push_str(&body[key_start..key_end]);
out.push_str(key_close);
out.push_str(&body[key_end..]);
}
None => out.push_str(body),
}
if let Some(c) = comment {
out.push_str(tag_open);
out.push_str(c);
out.push_str(tag_close);
}
out.push('\n');
}
out
}
fn find_key_segment(line: &str) -> Option<(usize, usize)> {
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() && bytes[i] == b' ' {
i += 1;
}
if i + 1 < bytes.len() && bytes[i] == b'-' && bytes[i + 1] == b' ' {
i += 2;
}
let key_start = i;
while i < bytes.len() {
if bytes[i] == b':' {
let next = bytes.get(i + 1).copied();
match next {
None | Some(b' ') | Some(b'\n') => {
if i > key_start {
return Some((key_start, i));
}
return None;
}
_ => {}
}
}
i += 1;
}
None
}
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_ng::to_string(v).unwrap_or_default().trim().to_string()
}
Value::Tagged(t) => serde_yaml_ng::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_ng::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)]
#[path = "overlay_tests.rs"]
mod tests;