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}