Skip to main content

podbox/config/
extends.rs

1//! `extends` inheritance resolution — chain building + cycle detection.
2//!
3//! Resolves `extends = "<target>"` where target is:
4//! - `profile:<name>` → bundled or user-defined profile TOML
5//! - `./` / `../` / absolute path → filesystem TOML relative to current file
6//! - bare name (`fedora`) → `~/.config/podbox/profiles/<name>.toml` (canonical)
7//!   with fallback to `~/.config/podbox/<name>.toml` (legacy)
8
9use std::collections::HashSet;
10use std::path::{Path, PathBuf};
11
12use anyhow::{Context, Result};
13
14use super::merge::merge_toml_values;
15
16/// Identity of a config source for cycle detection.
17#[derive(Debug, Clone, PartialEq, Eq, Hash)]
18pub enum ConfigSource {
19    Profile(String),
20    Path(PathBuf),
21}
22
23/// Resolve a chain of `extends` starting at `initial_path`/`initial_toml`.
24///
25/// Returns a single merged `toml::Value` where every `extends` has been
26/// resolved and deep-merged (parent → child order, `extends` key dropped).
27pub 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    // Canonicalize initial path when possible; fallback to absolute-ish.
44    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, &current_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    // Merge from base (last in chain) down to leaf child (first).
75    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    // Ensure extends key is stripped from final merged value
81    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    // 1. profile:<name>
93    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    // 2. filesystem path: ./, ../, /, or contains '/' or ends with .toml
106    // Heuristic: if it looks like a path, treat as path.
107    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    // 3. bare sibling name → profiles/<name>.toml (canonical) or legacy root
131    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        // child overrides name, arrays union
201        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        // dev profile has image.base; merged should have it if not overridden
261        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}