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 = 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    // Canonicalize initial path when possible; fallback to absolute-ish.
39    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, &current_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    // Merge from base (last in chain) down to leaf child (first).
69    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    // Ensure extends key is stripped from final merged value
75    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    // 1. profile:<name>
87    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    // 2. filesystem path: ./, ../, /, or contains '/' or ends with .toml
100    // Heuristic: if it looks like a path, treat as path.
101    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    // 3. bare sibling name → profiles/<name>.toml (canonical) or legacy root
126    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        // child overrides name, arrays union
195        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        // dev profile has image.base; merged should have it if not overridden
267        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}