use std::collections::HashSet;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use super::merge::merge_toml_values;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ConfigSource {
Profile(String),
Path(PathBuf),
}
pub fn resolve_extends_chain(initial_path: &Path, initial_toml: &str) -> Result<toml::Value> {
let mut visited: HashSet<ConfigSource> = HashSet::new();
let mut chain: Vec<toml::Value> = Vec::new();
let mut current_val: toml::Value = toml::from_str(initial_toml)
.with_context(|| format!("failed to parse TOML at '{}'", initial_path.display()))?;
let mut current_dir = initial_path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
let canon_initial =
std::fs::canonicalize(initial_path).unwrap_or_else(|_| initial_path.to_path_buf());
visited.insert(ConfigSource::Path(canon_initial));
chain.push(current_val.clone());
while let Some(extends_val) = current_val.get("extends").and_then(|v| v.as_str()) {
let trimmed = extends_val.trim();
if trimmed.is_empty() {
break;
}
let (source, next_raw, next_dir) = resolve_extends_target(trimmed, ¤t_dir)
.with_context(|| {
format!(
"failed to resolve extends target '{}' from '{}'",
trimmed,
current_dir.display()
)
})?;
if !visited.insert(source.clone()) {
anyhow::bail!("Circular dependency detected in 'extends': {source:?}");
}
current_val = toml::from_str(&next_raw)
.with_context(|| format!("failed to parse TOML for extends target {source:?}"))?;
current_dir = next_dir;
chain.push(current_val.clone());
}
let mut merged = chain.pop().expect("chain has at least initial");
while let Some(child) = chain.pop() {
merge_toml_values(&mut merged, child);
}
if let Some(tbl) = merged.as_table_mut() {
tbl.remove("extends");
}
Ok(merged)
}
fn resolve_extends_target(
target: &str,
current_dir: &Path,
) -> Result<(ConfigSource, String, PathBuf)> {
if let Some(name) = target.strip_prefix("profile:") {
let name = name.trim();
if name.is_empty() {
anyhow::bail!("extends 'profile:' requires a profile name");
}
let profile = crate::profiles::find(name)
.ok_or_else(|| anyhow::anyhow!("unknown profile '{name}'"))?;
let source = ConfigSource::Profile(name.to_string());
let next_dir = current_dir.to_path_buf();
return Ok((source, profile.toml, next_dir));
}
let is_path_like = target.starts_with("./")
|| target.starts_with("../")
|| target.starts_with('/')
|| std::path::Path::new(target)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
|| target.contains('/');
if is_path_like {
let candidate = if Path::new(target).is_absolute() {
PathBuf::from(target)
} else {
current_dir.join(target)
};
let content = std::fs::read_to_string(&candidate)
.with_context(|| format!("failed to read extends path '{}'", candidate.display()))?;
let canon = std::fs::canonicalize(&candidate).unwrap_or(candidate.clone());
let source = ConfigSource::Path(canon);
let next_dir = candidate
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| current_dir.to_path_buf());
return Ok((source, content, next_dir));
}
let sibling_path = crate::config::find_config_path(target).ok_or_else(|| {
anyhow::anyhow!(
"failed to read sibling extends '{}' — no config found at '{}/{{profiles/,}}/{}.toml'",
target,
crate::config::config_dir().display(),
target
)
})?;
let content = std::fs::read_to_string(&sibling_path).with_context(|| {
format!(
"failed to read sibling extends '{}' at '{}'",
target,
sibling_path.display()
)
})?;
let canon = std::fs::canonicalize(&sibling_path).unwrap_or(sibling_path.clone());
let source = ConfigSource::Path(canon);
let next_dir = sibling_path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| current_dir.to_path_buf());
Ok((source, content, next_dir))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn write_toml(dir: &Path, name: &str, content: &str) -> PathBuf {
let p = dir.join(name);
fs::write(&p, content).unwrap();
p
}
#[test]
fn single_extends_merges() {
let tmp = tempfile::tempdir().unwrap();
let base = write_toml(
tmp.path(),
"base.toml",
r#"
[image]
base = "fedora:41"
name = "base"
[container]
name = "base"
home = "~/containers/base"
[image.packages]
install = ["git"]
"#,
);
let child_path = tmp.path().join("child.toml");
let child_toml = r#"
extends = "./base.toml"
[image]
name = "child"
[container]
name = "child"
home = "~/containers/child"
[image.packages]
install = ["rustup"]
"#
.to_string();
fs::write(&child_path, &child_toml).unwrap();
let merged = resolve_extends_chain(&child_path, &child_toml).unwrap();
assert_eq!(
merged
.get("image")
.unwrap()
.get("name")
.unwrap()
.as_str()
.unwrap(),
"child"
);
let install = merged
.get("image")
.unwrap()
.get("packages")
.unwrap()
.get("install")
.unwrap()
.as_array()
.unwrap();
let strs: Vec<_> = install.iter().map(|v| v.as_str().unwrap()).collect();
assert!(strs.contains(&"git"));
assert!(strs.contains(&"rustup"));
let _ = base;
}
#[test]
fn circular_detected() {
let tmp = tempfile::tempdir().unwrap();
let a_path = tmp.path().join("a.toml");
let b_path = tmp.path().join("b.toml");
fs::write(
&a_path,
r#"extends = "./b.toml"
[image]
base = "fedora:41"
name = "a"
[container]
name = "a"
home = "~/a"
"#,
)
.unwrap();
fs::write(
&b_path,
r#"extends = "./a.toml"
[image]
base = "fedora:41"
name = "b"
[container]
name = "b"
home = "~/b"
"#,
)
.unwrap();
let a_content = fs::read_to_string(&a_path).unwrap();
let err = resolve_extends_chain(&a_path, &a_content).unwrap_err();
assert!(err.to_string().contains("Circular"));
}
#[test]
fn profile_extends() {
let tmp = tempfile::tempdir().unwrap();
let child_path = tmp.path().join("child.toml");
let child_toml = r#"
extends = "profile:dev"
[container]
name = "mydev"
home = "~/containers/mydev"
"#;
fs::write(&child_path, child_toml).unwrap();
let merged = resolve_extends_chain(&child_path, child_toml).unwrap();
assert!(merged.get("image").is_some());
assert_eq!(
merged
.get("container")
.unwrap()
.get("name")
.unwrap()
.as_str()
.unwrap(),
"mydev"
);
}
}