Skip to main content

pacsea/theme/
paths.rs

1use std::env;
2use std::path::{Path, PathBuf};
3
4/// What: Locate the active theme configuration file, considering modern and legacy layouts.
5///
6/// Inputs:
7/// - None (reads environment variables to build candidate paths).
8///
9/// Output:
10/// - `Some(PathBuf)` pointing to the first readable theme file; `None` when nothing exists.
11///
12/// Details:
13/// - Prefers `$HOME/.config/pacsea/theme.conf`, then legacy `pacsea.conf`, and repeats for XDG paths.
14pub fn resolve_theme_config_path() -> Option<PathBuf> {
15    let home = env::var("HOME").ok();
16    let xdg_config = env::var("XDG_CONFIG_HOME").ok();
17    let mut candidates: Vec<PathBuf> = Vec::new();
18    if let Some(h) = home.as_deref() {
19        let base = Path::new(h).join(".config").join("pacsea");
20        candidates.push(base.join("theme.conf"));
21        candidates.push(base.join("pacsea.conf")); // legacy
22    }
23    if let Some(xdg) = xdg_config.as_deref() {
24        let x = Path::new(xdg).join("pacsea");
25        candidates.push(x.join("theme.conf"));
26        candidates.push(x.join("pacsea.conf")); // legacy
27    }
28    candidates.into_iter().find(|p| p.is_file())
29}
30
31/// What: Locate the active settings configuration file, prioritizing the split layout.
32///
33/// Inputs:
34/// - None.
35///
36/// Output:
37/// - `Some(PathBuf)` for the resolved settings file; `None` when no candidate exists.
38///
39/// Details:
40/// - Searches `$HOME` and `XDG_CONFIG_HOME` for `settings.conf`, then falls back to `pacsea.conf`.
41pub(super) fn resolve_settings_config_path() -> Option<PathBuf> {
42    let home = env::var("HOME").ok();
43    let xdg_config = env::var("XDG_CONFIG_HOME").ok();
44    let mut candidates: Vec<PathBuf> = Vec::new();
45    if let Some(h) = home.as_deref() {
46        let base = Path::new(h).join(".config").join("pacsea");
47        candidates.push(base.join("settings.conf"));
48        candidates.push(base.join("pacsea.conf")); // legacy
49    }
50    if let Some(xdg) = xdg_config.as_deref() {
51        let x = Path::new(xdg).join("pacsea");
52        candidates.push(x.join("settings.conf"));
53        candidates.push(x.join("pacsea.conf")); // legacy
54    }
55    candidates.into_iter().find(|p| p.is_file())
56}
57
58/// What: Locate the keybindings configuration file for Pacsea.
59///
60/// Inputs:
61/// - None.
62///
63/// Output:
64/// - `Some(PathBuf)` when a keybinds file is present; `None` otherwise.
65///
66/// Details:
67/// - Checks both `$HOME/.config/pacsea/keybinds.conf` and the legacy `pacsea.conf`, mirrored for XDG.
68pub(super) fn resolve_keybinds_config_path() -> Option<PathBuf> {
69    let home = env::var("HOME").ok();
70    let xdg_config = env::var("XDG_CONFIG_HOME").ok();
71    let mut candidates: Vec<PathBuf> = Vec::new();
72    if let Some(h) = home.as_deref() {
73        let base = Path::new(h).join(".config").join("pacsea");
74        candidates.push(base.join("keybinds.conf"));
75        candidates.push(base.join("pacsea.conf")); // legacy
76    }
77    if let Some(xdg) = xdg_config.as_deref() {
78        let x = Path::new(xdg).join("pacsea");
79        candidates.push(x.join("keybinds.conf"));
80        candidates.push(x.join("pacsea.conf")); // legacy
81    }
82    candidates.into_iter().find(|p| p.is_file())
83}
84
85/// What: Locate the `repos.conf` file for third-party repository definitions (TOML).
86///
87/// Inputs:
88/// - None (reads `HOME` / `XDG_CONFIG_HOME`).
89///
90/// Output:
91/// - `Some(PathBuf)` when a candidate file exists; `None` otherwise.
92///
93/// Details:
94/// - Checks `$HOME/.config/pacsea/repos.conf` then `XDG_CONFIG_HOME/pacsea/repos.conf`.
95/// - Does not fall back to legacy `pacsea.conf` (repos live only in the split layout).
96#[must_use]
97pub fn resolve_repos_config_path() -> Option<PathBuf> {
98    let home = env::var("HOME").ok();
99    let xdg_config = env::var("XDG_CONFIG_HOME").ok();
100    let mut candidates: Vec<PathBuf> = Vec::new();
101    if let Some(h) = home.as_deref() {
102        let base = Path::new(h).join(".config").join("pacsea");
103        candidates.push(base.join("repos.conf"));
104    }
105    if let Some(xdg) = xdg_config.as_deref() {
106        let x = Path::new(xdg).join("pacsea");
107        candidates.push(x.join("repos.conf"));
108    }
109    candidates.into_iter().find(|p| p.is_file())
110}
111
112/// What: Resolve an XDG base directory, falling back to `$HOME` with provided segments.
113///
114/// Inputs:
115/// - `var`: Environment variable name, e.g., `XDG_CONFIG_HOME`.
116/// - `home_default`: Path segments appended to `$HOME` when the variable is unset.
117///
118/// Output:
119/// - `PathBuf` pointing to the derived base directory.
120///
121/// Details:
122/// - Treats empty environment values as unset and gracefully handles missing `$HOME`.
123fn xdg_base_dir(var: &str, home_default: &[&str]) -> PathBuf {
124    if let Ok(p) = env::var(var)
125        && !p.trim().is_empty()
126    {
127        return PathBuf::from(p);
128    }
129    let home = env::var("HOME").unwrap_or_else(|_| ".".to_string());
130    let mut base = PathBuf::from(home);
131    for seg in home_default {
132        base = base.join(seg);
133    }
134    base
135}
136
137/// What: Build `$HOME/.config/pacsea`, ensuring the directory exists when `$HOME` is set.
138///
139/// Inputs:
140/// - None.
141///
142/// Output:
143/// - `Some(PathBuf)` when the directory is accessible; `None` if `$HOME` is missing or creation fails.
144///
145/// Details:
146/// - Serves as the preferred base for other configuration directories.
147/// - On Windows, also checks `APPDATA` and `USERPROFILE` if `HOME` is not set.
148fn home_config_dir() -> Option<PathBuf> {
149    // Try HOME first (works on Unix and Windows if set)
150    if let Ok(home) = env::var("HOME") {
151        let dir = Path::new(&home).join(".config").join("pacsea");
152        if std::fs::create_dir_all(&dir).is_ok() {
153            return Some(dir);
154        }
155    }
156    // Windows fallback: use APPDATA or USERPROFILE
157    #[cfg(windows)]
158    {
159        if let Ok(appdata) = env::var("APPDATA") {
160            let dir = Path::new(&appdata).join("pacsea");
161            if std::fs::create_dir_all(&dir).is_ok() {
162                return Some(dir);
163            }
164        }
165        if let Ok(userprofile) = env::var("USERPROFILE") {
166            let dir = Path::new(&userprofile).join(".config").join("pacsea");
167            if std::fs::create_dir_all(&dir).is_ok() {
168                return Some(dir);
169            }
170        }
171    }
172    None
173}
174
175/// What: Resolve the Pacsea configuration directory, ensuring it exists on disk.
176///
177/// Inputs:
178/// - None.
179///
180/// Output:
181/// - `PathBuf` pointing to the Pacsea config directory.
182///
183/// Details:
184/// - Prefers `$HOME/.config/pacsea`, falling back to `XDG_CONFIG_HOME/pacsea` when necessary.
185#[must_use]
186pub fn config_dir() -> PathBuf {
187    // Prefer HOME ~/.config/pacsea first
188    if let Some(dir) = home_config_dir() {
189        return dir;
190    }
191    // Fallback: use XDG_CONFIG_HOME (or default to ~/.config) and ensure
192    let base = xdg_base_dir("XDG_CONFIG_HOME", &[".config"]);
193    let dir = base.join("pacsea");
194    let _ = std::fs::create_dir_all(&dir);
195    dir
196}
197
198/// What: Obtain the logs subdirectory inside the Pacsea config folder.
199///
200/// Inputs:
201/// - None.
202///
203/// Output:
204/// - `PathBuf` leading to the `logs` directory (created if missing).
205///
206/// Details:
207/// - Builds upon `config_dir()` and ensures a stable location for log files.
208#[must_use]
209pub fn logs_dir() -> PathBuf {
210    let base = config_dir();
211    let dir = base.join("logs");
212    let _ = std::fs::create_dir_all(&dir);
213    dir
214}
215
216/// What: Obtain the lists subdirectory inside the Pacsea config folder.
217///
218/// Inputs:
219/// - None.
220///
221/// Output:
222/// - `PathBuf` leading to the `lists` directory (created if missing).
223///
224/// Details:
225/// - Builds upon `config_dir()` and ensures storage for exported package lists.
226#[must_use]
227pub fn lists_dir() -> PathBuf {
228    let base = config_dir();
229    let dir = base.join("lists");
230    let _ = std::fs::create_dir_all(&dir);
231    dir
232}
233
234#[cfg(test)]
235mod tests {
236    /// What: Manage temporary HOME override for path resolution tests.
237    ///
238    /// Inputs:
239    /// - `base`: Temporary HOME root directory.
240    ///
241    /// Output:
242    /// - Guard that restores `HOME` and `XDG_CONFIG_HOME` and removes temp directory on drop.
243    ///
244    /// Details:
245    /// - Clears `XDG_CONFIG_HOME` while active so `config_dir` and resolvers cannot use the
246    ///   developer's real XDG config tree.
247    /// - Provides panic-safe cleanup for tests mutating process-wide environment.
248    struct HomeTestGuard {
249        orig_home: Option<std::ffi::OsString>,
250        orig_xdg: Option<std::ffi::OsString>,
251        base: std::path::PathBuf,
252    }
253
254    impl HomeTestGuard {
255        /// What: Create a HOME override guard for test isolation.
256        ///
257        /// Inputs:
258        /// - `base`: Temporary path to use as `HOME`.
259        ///
260        /// Output:
261        /// - Initialized `HomeTestGuard`.
262        ///
263        /// Details:
264        /// - Captures original `HOME` and `XDG_CONFIG_HOME`, applies test `HOME`, and unsets XDG.
265        fn new(base: std::path::PathBuf) -> Self {
266            let orig_home = std::env::var_os("HOME");
267            let orig_xdg = std::env::var_os("XDG_CONFIG_HOME");
268            let _ = std::fs::create_dir_all(&base);
269            unsafe {
270                std::env::set_var("HOME", base.display().to_string());
271                std::env::remove_var("XDG_CONFIG_HOME");
272            }
273            Self {
274                orig_home,
275                orig_xdg,
276                base,
277            }
278        }
279    }
280
281    impl Drop for HomeTestGuard {
282        fn drop(&mut self) {
283            unsafe {
284                if let Some(v) = self.orig_home.as_ref() {
285                    std::env::set_var("HOME", v);
286                } else {
287                    std::env::remove_var("HOME");
288                }
289                if let Some(v) = self.orig_xdg.as_ref() {
290                    std::env::set_var("XDG_CONFIG_HOME", v);
291                } else {
292                    std::env::remove_var("XDG_CONFIG_HOME");
293                }
294            }
295            let _ = std::fs::remove_dir_all(&self.base);
296        }
297    }
298
299    #[test]
300    /// What: Verify path helpers resolve under the Pacsea config directory rooted at `HOME`.
301    ///
302    /// Inputs:
303    /// - Temporary `HOME` directory substituted to capture generated paths.
304    ///
305    /// Output:
306    /// - `config_dir`, `logs_dir`, and `lists_dir` end with `pacsea`, `logs`, and `lists` respectively.
307    ///
308    /// Details:
309    /// - Restores the original `HOME` and `XDG_CONFIG_HOME` afterwards to avoid polluting the real
310    ///   configuration tree.
311    fn paths_config_lists_logs_under_home() {
312        let _guard = crate::theme::test_mutex()
313            .lock()
314            .expect("Test mutex poisoned");
315        let base = std::env::temp_dir().join(format!(
316            "pacsea_test_paths_{}_{}",
317            std::process::id(),
318            std::time::SystemTime::now()
319                .duration_since(std::time::UNIX_EPOCH)
320                .expect("System time is before UNIX epoch")
321                .as_nanos()
322        ));
323        let _home_guard = HomeTestGuard::new(base);
324        let cfg = super::config_dir();
325        let logs = super::logs_dir();
326        let lists = super::lists_dir();
327        assert!(cfg.ends_with("pacsea"));
328        assert!(logs.ends_with("logs"));
329        assert!(lists.ends_with("lists"));
330    }
331
332    #[test]
333    /// What: Ensure `config_dir` stays under the test `HOME` when `XDG_CONFIG_HOME` was set in the environment.
334    ///
335    /// Inputs:
336    /// - A bogus `XDG_CONFIG_HOME` set before `HomeTestGuard` (simulates a developer shell).
337    ///
338    /// Output:
339    /// - `config_dir` is a path under the temporary home root.
340    ///
341    /// Details:
342    /// - Guards against regressions where only `HOME` is overridden and the XDG fallback or other
343    ///   helpers could still target the real config tree.
344    fn paths_config_stays_under_temp_home_when_xdg_config_home_was_set() {
345        let _guard = crate::theme::test_mutex()
346            .lock()
347            .expect("Test mutex poisoned");
348        let base = std::env::temp_dir().join(format!(
349            "pacsea_test_paths_xdg_{}_{}",
350            std::process::id(),
351            std::time::SystemTime::now()
352                .duration_since(std::time::UNIX_EPOCH)
353                .expect("System time is before UNIX epoch")
354                .as_nanos()
355        ));
356        let home_root = base.clone();
357        unsafe {
358            std::env::set_var(
359                "XDG_CONFIG_HOME",
360                "/nonexistent/pacsea_test_xdg_decoy_must_not_be_used",
361            );
362        }
363        let _home_guard = HomeTestGuard::new(base);
364        let cfg = super::config_dir();
365        assert!(
366            cfg.starts_with(&home_root),
367            "config_dir should resolve under test HOME, not decoy XDG_CONFIG_HOME; got {cfg:?}"
368        );
369    }
370}