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