1use std::collections::HashSet;
10use std::path::{Path, PathBuf};
11
12use anyhow::{Context, Result};
13
14use super::merge::merge_toml_values;
15
16#[derive(Debug, Clone, PartialEq, Eq, Hash)]
18pub enum ConfigSource {
19 Profile(String),
20 Path(PathBuf),
21}
22
23pub fn resolve_extends_chain(initial_path: &Path, initial_toml: &str) -> Result<toml::Value> {
28 let mut visited: HashSet<ConfigSource> = HashSet::new();
29 let mut chain: Vec<toml::Value> = Vec::new();
30
31 let mut current_val: toml::Value = toml::from_str(initial_toml)
32 .with_context(|| format!("failed to parse TOML at '{}'", initial_path.display()))?;
33 let mut current_dir = initial_path
34 .parent()
35 .unwrap_or_else(|| Path::new("."))
36 .to_path_buf();
37
38 let canon_initial =
40 std::fs::canonicalize(initial_path).unwrap_or_else(|_| initial_path.to_path_buf());
41 visited.insert(ConfigSource::Path(canon_initial));
42 chain.push(current_val.clone());
43
44 while let Some(extends_val) = current_val.get("extends").and_then(|v| v.as_str()) {
45 let trimmed = extends_val.trim();
46 if trimmed.is_empty() {
47 break;
48 }
49 let (source, next_raw, next_dir) = resolve_extends_target(trimmed, ¤t_dir)
50 .with_context(|| {
51 format!(
52 "failed to resolve extends target '{}' from '{}'",
53 trimmed,
54 current_dir.display()
55 )
56 })?;
57
58 if !visited.insert(source.clone()) {
59 anyhow::bail!("Circular dependency detected in 'extends': {source:?}");
60 }
61
62 current_val = toml::from_str(&next_raw)
63 .with_context(|| format!("failed to parse TOML for extends target {source:?}"))?;
64 current_dir = next_dir;
65 chain.push(current_val.clone());
66 }
67
68 let mut merged = chain.pop().expect("chain has at least initial");
70 while let Some(child) = chain.pop() {
71 merge_toml_values(&mut merged, child);
72 }
73
74 if let Some(tbl) = merged.as_table_mut() {
76 tbl.remove("extends");
77 }
78
79 Ok(merged)
80}
81
82fn resolve_extends_target(
83 target: &str,
84 current_dir: &Path,
85) -> Result<(ConfigSource, String, PathBuf)> {
86 if let Some(name) = target.strip_prefix("profile:") {
88 let name = name.trim();
89 if name.is_empty() {
90 anyhow::bail!("extends 'profile:' requires a profile name");
91 }
92 let profile = crate::profiles::find(name)
93 .ok_or_else(|| anyhow::anyhow!("unknown profile '{name}'"))?;
94 let source = ConfigSource::Profile(name.to_string());
95 let next_dir = current_dir.to_path_buf();
96 return Ok((source, profile.toml, next_dir));
97 }
98
99 let is_path_like = target.starts_with("./")
102 || target.starts_with("../")
103 || target.starts_with('/')
104 || std::path::Path::new(target)
105 .extension()
106 .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
107 || target.contains('/');
108 if is_path_like {
109 let candidate = if Path::new(target).is_absolute() {
110 PathBuf::from(target)
111 } else {
112 current_dir.join(target)
113 };
114 let content = std::fs::read_to_string(&candidate)
115 .with_context(|| format!("failed to read extends path '{}'", candidate.display()))?;
116 let canon = std::fs::canonicalize(&candidate).unwrap_or(candidate.clone());
117 let source = ConfigSource::Path(canon);
118 let next_dir = candidate
119 .parent()
120 .map(|p| p.to_path_buf())
121 .unwrap_or_else(|| current_dir.to_path_buf());
122 return Ok((source, content, next_dir));
123 }
124
125 let sibling_path = crate::config::find_config_path(target).ok_or_else(|| {
127 anyhow::anyhow!(
128 "failed to read sibling extends '{}' — no config found at '{}/{{profiles/,}}/{}.toml'",
129 target,
130 crate::config::config_dir().display(),
131 target
132 )
133 })?;
134 let content = std::fs::read_to_string(&sibling_path).with_context(|| {
135 format!(
136 "failed to read sibling extends '{}' at '{}'",
137 target,
138 sibling_path.display()
139 )
140 })?;
141 let canon = std::fs::canonicalize(&sibling_path).unwrap_or(sibling_path.clone());
142 let source = ConfigSource::Path(canon);
143 let next_dir = sibling_path
144 .parent()
145 .map(|p| p.to_path_buf())
146 .unwrap_or_else(|| current_dir.to_path_buf());
147 Ok((source, content, next_dir))
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use std::fs;
154
155 fn write_toml(dir: &Path, name: &str, content: &str) -> PathBuf {
156 let p = dir.join(name);
157 fs::write(&p, content).unwrap();
158 p
159 }
160
161 #[test]
162 fn single_extends_merges() {
163 let tmp = tempfile::tempdir().unwrap();
164 let base = write_toml(
165 tmp.path(),
166 "base.toml",
167 r#"
168 [image]
169 base = "fedora:41"
170 name = "base"
171
172 [container]
173 name = "base"
174 home = "~/containers/base"
175
176 [image.packages]
177 install = ["git"]
178 "#,
179 );
180 let child_path = tmp.path().join("child.toml");
181 let child_toml = r#"
182 extends = "./base.toml"
183 [image]
184 name = "child"
185 [container]
186 name = "child"
187 home = "~/containers/child"
188 [image.packages]
189 install = ["rustup"]
190 "#
191 .to_string();
192 fs::write(&child_path, &child_toml).unwrap();
193 let merged = resolve_extends_chain(&child_path, &child_toml).unwrap();
194 assert_eq!(
196 merged
197 .get("image")
198 .unwrap()
199 .get("name")
200 .unwrap()
201 .as_str()
202 .unwrap(),
203 "child"
204 );
205 let install = merged
206 .get("image")
207 .unwrap()
208 .get("packages")
209 .unwrap()
210 .get("install")
211 .unwrap()
212 .as_array()
213 .unwrap();
214 let strs: Vec<_> = install.iter().map(|v| v.as_str().unwrap()).collect();
215 assert!(strs.contains(&"git"));
216 assert!(strs.contains(&"rustup"));
217 let _ = base;
218 }
219
220 #[test]
221 fn circular_detected() {
222 let tmp = tempfile::tempdir().unwrap();
223 let a_path = tmp.path().join("a.toml");
224 let b_path = tmp.path().join("b.toml");
225 fs::write(
226 &a_path,
227 r#"extends = "./b.toml"
228[image]
229base = "fedora:41"
230name = "a"
231[container]
232name = "a"
233home = "~/a"
234"#,
235 )
236 .unwrap();
237 fs::write(
238 &b_path,
239 r#"extends = "./a.toml"
240[image]
241base = "fedora:41"
242name = "b"
243[container]
244name = "b"
245home = "~/b"
246"#,
247 )
248 .unwrap();
249 let a_content = fs::read_to_string(&a_path).unwrap();
250 let err = resolve_extends_chain(&a_path, &a_content).unwrap_err();
251 assert!(err.to_string().contains("Circular"));
252 }
253
254 #[test]
255 fn profile_extends() {
256 let tmp = tempfile::tempdir().unwrap();
257 let child_path = tmp.path().join("child.toml");
258 let child_toml = r#"
259 extends = "profile:dev"
260 [container]
261 name = "mydev"
262 home = "~/containers/mydev"
263 "#;
264 fs::write(&child_path, child_toml).unwrap();
265 let merged = resolve_extends_chain(&child_path, child_toml).unwrap();
266 assert!(merged.get("image").is_some());
268 assert_eq!(
269 merged
270 .get("container")
271 .unwrap()
272 .get("name")
273 .unwrap()
274 .as_str()
275 .unwrap(),
276 "mydev"
277 );
278 }
279}