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}