Skip to main content

pacsea/theme/
paths.rs

1use std::env;
2use std::path::{Path, PathBuf};
3use std::sync::RwLock;
4
5/// Process-wide override for the Pacsea configuration root, set by the CLI `--config-dir` flag.
6///
7/// Details: When set, all config, cache (lists), and log paths resolve under this directory
8/// instead of `$HOME/.config/pacsea` / `$XDG_CONFIG_HOME/pacsea`.
9static CONFIG_DIR_OVERRIDE: RwLock<Option<PathBuf>> = RwLock::new(None);
10
11/// What: Set or clear the process-wide configuration directory override.
12///
13/// Inputs:
14/// - `path`: `Some(dir)` to use `dir` as the config root; `None` to restore default resolution.
15///
16/// Output:
17/// - None (side effect: subsequent path helpers resolve under the override).
18///
19/// Details:
20/// - Called once at startup when `--config-dir` is passed, before logging initializes.
21/// - Also used by tests to isolate path resolution; ignores a poisoned lock by recovering it.
22pub fn set_config_dir_override(path: Option<PathBuf>) {
23    let mut guard = CONFIG_DIR_OVERRIDE
24        .write()
25        .unwrap_or_else(std::sync::PoisonError::into_inner);
26    *guard = path;
27}
28
29/// What: Read the current configuration directory override, if any.
30///
31/// Inputs:
32/// - None.
33///
34/// Output:
35/// - `Some(PathBuf)` when `--config-dir` is active; `None` otherwise.
36///
37/// Details:
38/// - Recovers from a poisoned lock instead of panicking.
39pub fn config_dir_override() -> Option<PathBuf> {
40    CONFIG_DIR_OVERRIDE
41        .read()
42        .unwrap_or_else(std::sync::PoisonError::into_inner)
43        .clone()
44}
45
46/// What: Locate the active theme configuration file, considering modern and legacy layouts.
47///
48/// Inputs:
49/// - None (reads environment variables to build candidate paths).
50///
51/// Output:
52/// - `Some(PathBuf)` pointing to the first readable theme file; `None` when nothing exists.
53///
54/// Details:
55/// - Prefers `$HOME/.config/pacsea/theme.conf`, then legacy `pacsea.conf`, and repeats for XDG paths.
56/// - When `--config-dir` is active, only the override directory is searched.
57pub fn resolve_theme_config_path() -> Option<PathBuf> {
58    if let Some(root) = config_dir_override() {
59        return [root.join("theme.conf"), root.join("pacsea.conf")]
60            .into_iter()
61            .find(|p| p.is_file());
62    }
63    let home = env::var("HOME").ok();
64    let xdg_config = env::var("XDG_CONFIG_HOME").ok();
65    let mut candidates: Vec<PathBuf> = Vec::new();
66    if let Some(h) = home.as_deref() {
67        let base = Path::new(h).join(".config").join("pacsea");
68        candidates.push(base.join("theme.conf"));
69        candidates.push(base.join("pacsea.conf")); // legacy
70    }
71    if let Some(xdg) = xdg_config.as_deref() {
72        let x = Path::new(xdg).join("pacsea");
73        candidates.push(x.join("theme.conf"));
74        candidates.push(x.join("pacsea.conf")); // legacy
75    }
76    candidates.into_iter().find(|p| p.is_file())
77}
78
79/// What: Locate the active settings configuration file, prioritizing the split layout.
80///
81/// Inputs:
82/// - None.
83///
84/// Output:
85/// - `Some(PathBuf)` for the resolved settings file; `None` when no candidate exists.
86///
87/// Details:
88/// - Searches `$HOME` and `XDG_CONFIG_HOME` for `settings.conf`, then falls back to `pacsea.conf`.
89/// - When `--config-dir` is active, only the override directory is searched.
90pub(super) fn resolve_settings_config_path() -> Option<PathBuf> {
91    if let Some(root) = config_dir_override() {
92        return [root.join("settings.conf"), root.join("pacsea.conf")]
93            .into_iter()
94            .find(|p| p.is_file());
95    }
96    let home = env::var("HOME").ok();
97    let xdg_config = env::var("XDG_CONFIG_HOME").ok();
98    let mut candidates: Vec<PathBuf> = Vec::new();
99    if let Some(h) = home.as_deref() {
100        let base = Path::new(h).join(".config").join("pacsea");
101        candidates.push(base.join("settings.conf"));
102        candidates.push(base.join("pacsea.conf")); // legacy
103    }
104    if let Some(xdg) = xdg_config.as_deref() {
105        let x = Path::new(xdg).join("pacsea");
106        candidates.push(x.join("settings.conf"));
107        candidates.push(x.join("pacsea.conf")); // legacy
108    }
109    candidates.into_iter().find(|p| p.is_file())
110}
111
112/// What: Locate the keybindings configuration file for Pacsea.
113///
114/// Inputs:
115/// - None.
116///
117/// Output:
118/// - `Some(PathBuf)` when a keybinds file is present; `None` otherwise.
119///
120/// Details:
121/// - Checks both `$HOME/.config/pacsea/keybinds.conf` and the legacy `pacsea.conf`, mirrored for XDG.
122/// - When `--config-dir` is active, only the override directory is searched.
123pub(super) fn resolve_keybinds_config_path() -> Option<PathBuf> {
124    if let Some(root) = config_dir_override() {
125        return [root.join("keybinds.conf"), root.join("pacsea.conf")]
126            .into_iter()
127            .find(|p| p.is_file());
128    }
129    let home = env::var("HOME").ok();
130    let xdg_config = env::var("XDG_CONFIG_HOME").ok();
131    let mut candidates: Vec<PathBuf> = Vec::new();
132    if let Some(h) = home.as_deref() {
133        let base = Path::new(h).join(".config").join("pacsea");
134        candidates.push(base.join("keybinds.conf"));
135        candidates.push(base.join("pacsea.conf")); // legacy
136    }
137    if let Some(xdg) = xdg_config.as_deref() {
138        let x = Path::new(xdg).join("pacsea");
139        candidates.push(x.join("keybinds.conf"));
140        candidates.push(x.join("pacsea.conf")); // legacy
141    }
142    candidates.into_iter().find(|p| p.is_file())
143}
144
145/// What: Locate the `repos.conf` file for third-party repository definitions (TOML).
146///
147/// Inputs:
148/// - None (reads `HOME` / `XDG_CONFIG_HOME`).
149///
150/// Output:
151/// - `Some(PathBuf)` when a candidate file exists; `None` otherwise.
152///
153/// Details:
154/// - Checks `$HOME/.config/pacsea/repos.conf` then `XDG_CONFIG_HOME/pacsea/repos.conf`.
155/// - Does not fall back to legacy `pacsea.conf` (repos live only in the split layout).
156/// - When `--config-dir` is active, only the override directory is searched.
157#[must_use]
158pub fn resolve_repos_config_path() -> Option<PathBuf> {
159    if let Some(root) = config_dir_override() {
160        let candidate = root.join("repos.conf");
161        return candidate.is_file().then_some(candidate);
162    }
163    let home = env::var("HOME").ok();
164    let xdg_config = env::var("XDG_CONFIG_HOME").ok();
165    let mut candidates: Vec<PathBuf> = Vec::new();
166    if let Some(h) = home.as_deref() {
167        let base = Path::new(h).join(".config").join("pacsea");
168        candidates.push(base.join("repos.conf"));
169    }
170    if let Some(xdg) = xdg_config.as_deref() {
171        let x = Path::new(xdg).join("pacsea");
172        candidates.push(x.join("repos.conf"));
173    }
174    candidates.into_iter().find(|p| p.is_file())
175}
176
177/// What: Resolve an XDG base directory, falling back to `$HOME` with provided segments.
178///
179/// Inputs:
180/// - `var`: Environment variable name, e.g., `XDG_CONFIG_HOME`.
181/// - `home_default`: Path segments appended to `$HOME` when the variable is unset.
182///
183/// Output:
184/// - `PathBuf` pointing to the derived base directory.
185///
186/// Details:
187/// - Treats empty environment values as unset and gracefully handles missing `$HOME`.
188fn xdg_base_dir(var: &str, home_default: &[&str]) -> PathBuf {
189    if let Ok(p) = env::var(var)
190        && !p.trim().is_empty()
191    {
192        return PathBuf::from(p);
193    }
194    let home = env::var("HOME").unwrap_or_else(|_| ".".to_string());
195    let mut base = PathBuf::from(home);
196    for seg in home_default {
197        base = base.join(seg);
198    }
199    base
200}
201
202/// What: Build `$HOME/.config/pacsea`, ensuring the directory exists when `$HOME` is set.
203///
204/// Inputs:
205/// - None.
206///
207/// Output:
208/// - `Some(PathBuf)` when the directory is accessible; `None` if `$HOME` is missing or creation fails.
209///
210/// Details:
211/// - Serves as the preferred base for other configuration directories.
212/// - On Windows, also checks `APPDATA` and `USERPROFILE` if `HOME` is not set.
213fn home_config_dir() -> Option<PathBuf> {
214    // Try HOME first (works on Unix and Windows if set)
215    if let Ok(home) = env::var("HOME") {
216        let dir = Path::new(&home).join(".config").join("pacsea");
217        if std::fs::create_dir_all(&dir).is_ok() {
218            return Some(dir);
219        }
220    }
221    // Windows fallback: use APPDATA or USERPROFILE
222    #[cfg(windows)]
223    {
224        if let Ok(appdata) = env::var("APPDATA") {
225            let dir = Path::new(&appdata).join("pacsea");
226            if std::fs::create_dir_all(&dir).is_ok() {
227                return Some(dir);
228            }
229        }
230        if let Ok(userprofile) = env::var("USERPROFILE") {
231            let dir = Path::new(&userprofile).join(".config").join("pacsea");
232            if std::fs::create_dir_all(&dir).is_ok() {
233                return Some(dir);
234            }
235        }
236    }
237    None
238}
239
240/// What: Resolve the Pacsea configuration directory, ensuring it exists on disk.
241///
242/// Inputs:
243/// - None.
244///
245/// Output:
246/// - `PathBuf` pointing to the Pacsea config directory.
247///
248/// Details:
249/// - Prefers `$HOME/.config/pacsea`, falling back to `XDG_CONFIG_HOME/pacsea` when necessary.
250/// - When `--config-dir` is active, returns the override directory (created if missing).
251#[must_use]
252pub fn config_dir() -> PathBuf {
253    if let Some(dir) = config_dir_override() {
254        let _ = std::fs::create_dir_all(&dir);
255        return dir;
256    }
257    // Prefer HOME ~/.config/pacsea first
258    if let Some(dir) = home_config_dir() {
259        return dir;
260    }
261    // Fallback: use XDG_CONFIG_HOME (or default to ~/.config) and ensure
262    let base = xdg_base_dir("XDG_CONFIG_HOME", &[".config"]);
263    let dir = base.join("pacsea");
264    let _ = std::fs::create_dir_all(&dir);
265    dir
266}
267
268/// What: Obtain the logs subdirectory inside the Pacsea config folder.
269///
270/// Inputs:
271/// - None.
272///
273/// Output:
274/// - `PathBuf` leading to the `logs` directory (created if missing).
275///
276/// Details:
277/// - Builds upon `config_dir()` and ensures a stable location for log files.
278#[must_use]
279pub fn logs_dir() -> PathBuf {
280    let base = config_dir();
281    let dir = base.join("logs");
282    let _ = std::fs::create_dir_all(&dir);
283    dir
284}
285
286/// What: Obtain the lists subdirectory inside the Pacsea config folder.
287///
288/// Inputs:
289/// - None.
290///
291/// Output:
292/// - `PathBuf` leading to the `lists` directory (created if missing).
293///
294/// Details:
295/// - Builds upon `config_dir()` and ensures storage for exported package lists.
296#[must_use]
297pub fn lists_dir() -> PathBuf {
298    let base = config_dir();
299    let dir = base.join("lists");
300    let _ = std::fs::create_dir_all(&dir);
301    dir
302}
303
304#[cfg(test)]
305mod tests {
306    /// What: Manage temporary HOME override for path resolution tests.
307    ///
308    /// Inputs:
309    /// - `base`: Temporary HOME root directory.
310    ///
311    /// Output:
312    /// - Guard that restores `HOME` and `XDG_CONFIG_HOME` and removes temp directory on drop.
313    ///
314    /// Details:
315    /// - Clears `XDG_CONFIG_HOME` while active so `config_dir` and resolvers cannot use the
316    ///   developer's real XDG config tree.
317    /// - Provides panic-safe cleanup for tests mutating process-wide environment.
318    struct HomeTestGuard {
319        orig_home: Option<std::ffi::OsString>,
320        orig_xdg: Option<std::ffi::OsString>,
321        base: std::path::PathBuf,
322    }
323
324    impl HomeTestGuard {
325        /// What: Create a HOME override guard for test isolation.
326        ///
327        /// Inputs:
328        /// - `base`: Temporary path to use as `HOME`.
329        ///
330        /// Output:
331        /// - Initialized `HomeTestGuard`.
332        ///
333        /// Details:
334        /// - Captures original `HOME` and `XDG_CONFIG_HOME`, applies test `HOME`, and unsets XDG.
335        fn new(base: std::path::PathBuf) -> Self {
336            let orig_home = std::env::var_os("HOME");
337            let orig_xdg = std::env::var_os("XDG_CONFIG_HOME");
338            let _ = std::fs::create_dir_all(&base);
339            unsafe {
340                std::env::set_var("HOME", base.display().to_string());
341                std::env::remove_var("XDG_CONFIG_HOME");
342            }
343            Self {
344                orig_home,
345                orig_xdg,
346                base,
347            }
348        }
349    }
350
351    impl Drop for HomeTestGuard {
352        fn drop(&mut self) {
353            unsafe {
354                if let Some(v) = self.orig_home.as_ref() {
355                    std::env::set_var("HOME", v);
356                } else {
357                    std::env::remove_var("HOME");
358                }
359                if let Some(v) = self.orig_xdg.as_ref() {
360                    std::env::set_var("XDG_CONFIG_HOME", v);
361                } else {
362                    std::env::remove_var("XDG_CONFIG_HOME");
363                }
364            }
365            let _ = std::fs::remove_dir_all(&self.base);
366        }
367    }
368
369    #[test]
370    /// What: Verify path helpers resolve under the Pacsea config directory rooted at `HOME`.
371    ///
372    /// Inputs:
373    /// - Temporary `HOME` directory substituted to capture generated paths.
374    ///
375    /// Output:
376    /// - `config_dir`, `logs_dir`, and `lists_dir` end with `pacsea`, `logs`, and `lists` respectively.
377    ///
378    /// Details:
379    /// - Restores the original `HOME` and `XDG_CONFIG_HOME` afterwards to avoid polluting the real
380    ///   configuration tree.
381    fn paths_config_lists_logs_under_home() {
382        let _guard = crate::theme::test_mutex()
383            .lock()
384            .expect("Test mutex poisoned");
385        let base = std::env::temp_dir().join(format!(
386            "pacsea_test_paths_{}_{}",
387            std::process::id(),
388            std::time::SystemTime::now()
389                .duration_since(std::time::UNIX_EPOCH)
390                .expect("System time is before UNIX epoch")
391                .as_nanos()
392        ));
393        let _home_guard = HomeTestGuard::new(base);
394        let cfg = super::config_dir();
395        let logs = super::logs_dir();
396        let lists = super::lists_dir();
397        assert!(cfg.ends_with("pacsea"));
398        assert!(logs.ends_with("logs"));
399        assert!(lists.ends_with("lists"));
400    }
401
402    /// What: Clear the config-dir override when a test finishes or panics.
403    ///
404    /// Inputs:
405    /// - None.
406    ///
407    /// Output:
408    /// - Restores default path resolution on drop.
409    ///
410    /// Details:
411    /// - Keeps the process-wide override from leaking into other tests.
412    struct OverrideGuard;
413
414    impl Drop for OverrideGuard {
415        fn drop(&mut self) {
416            super::set_config_dir_override(None);
417        }
418    }
419
420    #[test]
421    /// What: Verify all path helpers and resolvers honor the `--config-dir` override.
422    ///
423    /// Inputs:
424    /// - Temporary directory installed via `set_config_dir_override`.
425    ///
426    /// Output:
427    /// - `config_dir`/`logs_dir`/`lists_dir` resolve under the override; resolvers only find
428    ///   files placed inside it.
429    ///
430    /// Details:
431    /// - Clears the override afterwards via `OverrideGuard` so other tests see defaults.
432    fn paths_honor_config_dir_override() {
433        let _guard = crate::theme::test_mutex()
434            .lock()
435            .expect("Test mutex poisoned");
436        let base = std::env::temp_dir().join(format!(
437            "pacsea_test_paths_override_{}_{}",
438            std::process::id(),
439            std::time::SystemTime::now()
440                .duration_since(std::time::UNIX_EPOCH)
441                .expect("System time is before UNIX epoch")
442                .as_nanos()
443        ));
444        super::set_config_dir_override(Some(base.clone()));
445        let _override_guard = OverrideGuard;
446
447        assert_eq!(super::config_dir(), base);
448        assert!(super::logs_dir().starts_with(&base));
449        assert!(super::lists_dir().starts_with(&base));
450
451        // Resolvers search only the override directory.
452        assert!(super::resolve_theme_config_path().is_none());
453        std::fs::write(base.join("settings.conf"), "").expect("write settings.conf");
454        assert_eq!(
455            super::resolve_settings_config_path(),
456            Some(base.join("settings.conf"))
457        );
458        std::fs::write(base.join("repos.conf"), "").expect("write repos.conf");
459        assert_eq!(
460            super::resolve_repos_config_path(),
461            Some(base.join("repos.conf"))
462        );
463
464        let _ = std::fs::remove_dir_all(&base);
465    }
466
467    #[test]
468    /// What: Ensure `config_dir` stays under the test `HOME` when `XDG_CONFIG_HOME` was set in the environment.
469    ///
470    /// Inputs:
471    /// - A bogus `XDG_CONFIG_HOME` set before `HomeTestGuard` (simulates a developer shell).
472    ///
473    /// Output:
474    /// - `config_dir` is a path under the temporary home root.
475    ///
476    /// Details:
477    /// - Guards against regressions where only `HOME` is overridden and the XDG fallback or other
478    ///   helpers could still target the real config tree.
479    fn paths_config_stays_under_temp_home_when_xdg_config_home_was_set() {
480        let _guard = crate::theme::test_mutex()
481            .lock()
482            .expect("Test mutex poisoned");
483        let base = std::env::temp_dir().join(format!(
484            "pacsea_test_paths_xdg_{}_{}",
485            std::process::id(),
486            std::time::SystemTime::now()
487                .duration_since(std::time::UNIX_EPOCH)
488                .expect("System time is before UNIX epoch")
489                .as_nanos()
490        ));
491        let home_root = base.clone();
492        unsafe {
493            std::env::set_var(
494                "XDG_CONFIG_HOME",
495                "/nonexistent/pacsea_test_xdg_decoy_must_not_be_used",
496            );
497        }
498        let _home_guard = HomeTestGuard::new(base);
499        let cfg = super::config_dir();
500        assert!(
501            cfg.starts_with(&home_root),
502            "config_dir should resolve under test HOME, not decoy XDG_CONFIG_HOME; got {cfg:?}"
503        );
504    }
505}