Skip to main content

podbox/config/
fs.rs

1use std::path::PathBuf;
2
3use anyhow::Result;
4
5pub fn expand_tilde(path: &str) -> PathBuf {
6    if let Some(stripped) = path.strip_prefix("~/") {
7        if let Some(home) = dirs::home_dir() {
8            return home.join(stripped);
9        }
10    }
11    if path == "~" {
12        if let Some(home) = dirs::home_dir() {
13            return home;
14        }
15    }
16    PathBuf::from(path)
17}
18
19pub fn config_dir() -> PathBuf {
20    dirs::config_dir()
21        .map(|d| d.join("podbox"))
22        .unwrap_or_else(|| PathBuf::from("~/.config/podbox"))
23}
24
25/// Canonical profiles directory: ~/.config/podbox/profiles/
26pub fn profiles_dir() -> PathBuf {
27    config_dir().join("profiles")
28}
29
30/// Resolve a container configuration path by name.
31///
32/// Resolution order:
33/// 1. Canonical path: ~/.config/podbox/profiles/<name>.toml
34/// 2. Legacy root path: ~/.config/podbox/<name>.toml (deprecated)
35pub fn find_config_path(name: &str) -> Option<PathBuf> {
36    let canonical = profiles_dir().join(format!("{name}.toml"));
37    if canonical.is_file() {
38        return Some(canonical);
39    }
40
41    let legacy = config_dir().join(format!("{name}.toml"));
42    if legacy.is_file() {
43        tracing::debug!(
44            "Using legacy config path '{}'. Run `podbox migrate` to move to profiles/.",
45            legacy.display()
46        );
47        return Some(legacy);
48    }
49
50    None
51}
52
53/// List legacy root-level configs (those not yet migrated to profiles/).
54pub fn find_legacy_root_configs() -> Vec<PathBuf> {
55    let root = config_dir();
56    let profiles = profiles_dir();
57    let Ok(entries) = std::fs::read_dir(&root) else {
58        return vec![];
59    };
60    let mut out = Vec::new();
61    for entry in entries.flatten() {
62        let path = entry.path();
63        if !path.is_file() {
64            continue;
65        }
66        if path.extension().is_some_and(|ext| ext == "toml") {
67            // Skip files that also exist in profiles/ (canonical wins)
68            if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
69                if profiles.join(format!("{stem}.toml")).is_file() {
70                    continue;
71                }
72                out.push(path);
73            }
74        }
75    }
76    out.sort();
77    out
78}
79
80pub fn find_definition() -> Option<PathBuf> {
81    let new_local = PathBuf::from(".podbox.toml");
82    if new_local.exists() {
83        return Some(new_local);
84    }
85
86    let old_local = PathBuf::from(".podmgr.toml");
87    if old_local.exists() {
88        eprintln!(
89            "Warning: '.podmgr.toml' found. Rename it to '.podbox.toml' to silence this warning."
90        );
91        return Some(old_local);
92    }
93
94    // Fall back to any config in config_dir or profiles_dir
95    let configs = list_configs();
96    if !configs.is_empty() {
97        if configs.len() > 1 {
98            eprintln!(
99                "Warning: multiple configuration files found in {}. Selecting '{}' alphabetically. Use --config to specify a different file.",
100                config_dir().display(),
101                configs[0].display()
102            );
103        }
104        return Some(configs.into_iter().next().unwrap());
105    }
106
107    None
108}
109
110pub fn list_configs() -> Vec<PathBuf> {
111    use std::collections::BTreeMap;
112    let mut map: BTreeMap<String, PathBuf> = BTreeMap::new();
113
114    // 1. Scan legacy root (lower priority)
115    let root = config_dir();
116    if let Ok(entries) = std::fs::read_dir(&root) {
117        for entry in entries.flatten() {
118            let path = entry.path();
119            if path.is_file() && path.extension().is_some_and(|ext| ext == "toml") {
120                if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
121                    map.insert(stem.to_string(), path);
122                }
123            }
124        }
125    }
126
127    // 2. Scan canonical profiles/ directory (overwrites legacy on collision)
128    let pdir = profiles_dir();
129    if let Ok(entries) = std::fs::read_dir(&pdir) {
130        for entry in entries.flatten() {
131            let path = entry.path();
132            if path.is_file() && path.extension().is_some_and(|ext| ext == "toml") {
133                if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
134                    map.insert(stem.to_string(), path);
135                }
136            }
137        }
138    }
139
140    let mut out: Vec<PathBuf> = map.into_values().collect();
141    out.sort();
142    out
143}
144
145pub fn active_context_path() -> PathBuf {
146    config_dir().join(".active")
147}
148
149pub fn read_active_context() -> Option<String> {
150    let path = active_context_path();
151    let content = std::fs::read_to_string(&path).ok()?;
152    let name = content.trim().to_string();
153    if name.is_empty() {
154        let _ = std::fs::remove_file(&path);
155        return None;
156    }
157    if find_config_path(&name).is_some() {
158        Some(name)
159    } else {
160        let _ = std::fs::remove_file(&path);
161        None
162    }
163}
164
165pub fn write_active_context(name: &str) -> Result<()> {
166    let path = active_context_path();
167    std::fs::create_dir_all(path.parent().unwrap())?;
168    std::fs::write(&path, name)?;
169    Ok(())
170}
171
172pub fn clear_active_context() -> Result<()> {
173    let path = active_context_path();
174    if path.exists() {
175        std::fs::remove_file(&path)?;
176    }
177    Ok(())
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn test_expand_tilde() {
186        let home = dirs::home_dir().unwrap();
187        assert_eq!(expand_tilde("~/foo"), home.join("foo"));
188        assert_eq!(expand_tilde("~"), home.clone());
189        assert_eq!(expand_tilde("/foo/bar"), PathBuf::from("/foo/bar"));
190    }
191}