local_store/paths.rs
1//! Platform-agnostic path management for application configuration and data.
2//!
3//! Provides unified path resolution strategies across different platforms.
4
5use crate::errors::{IoOperationKind, StoreError};
6use std::path::PathBuf;
7
8/// Path resolution strategy.
9///
10/// Determines how configuration and data directories are resolved.
11#[derive(Debug, Clone, PartialEq, Eq, Default)]
12#[non_exhaustive]
13pub enum PathStrategy {
14 /// Use OS-standard directories (default).
15 ///
16 /// - Linux: `~/.config/` (XDG_CONFIG_HOME)
17 /// - macOS: `~/Library/Application Support/`
18 /// - Windows: `%APPDATA%`
19 #[default]
20 System,
21
22 /// Force XDG Base Directory layout on all platforms.
23 ///
24 /// Respects `XDG_CONFIG_HOME` / `XDG_DATA_HOME` when set to an absolute
25 /// path (relative or empty values are ignored, per the XDG Base Directory
26 /// specification); otherwise falls back to `~/.config/` for config and
27 /// `~/.local/share/` for data on all platforms (Linux, macOS, Windows).
28 ///
29 /// This is useful for applications that want consistent paths
30 /// across platforms (e.g., VSCode, Neovim, orcs).
31 Xdg,
32
33 /// Use a custom base directory.
34 ///
35 /// Config resolves to `<base>/<app_name>`, while data resolves to
36 /// `<base>/data/<app_name>` (the extra `data` segment keeps config and
37 /// data separated under a single base).
38 CustomBase(PathBuf),
39}
40
41/// Resolve an XDG base directory.
42///
43/// Uses the environment variable when it is set to an absolute path (per the
44/// XDG Base Directory spec, relative or empty values are ignored); otherwise
45/// falls back to `$HOME/<fallback>`.
46fn xdg_base_dir(env_var: &str, fallback: &str) -> Result<PathBuf, StoreError> {
47 if let Some(value) = std::env::var_os(env_var) {
48 let path = PathBuf::from(value);
49 if path.is_absolute() {
50 return Ok(path);
51 }
52 }
53 let home = dirs::home_dir().ok_or(StoreError::HomeDirNotFound)?;
54 Ok(home.join(fallback))
55}
56
57/// Application path manager with configurable resolution strategies.
58///
59/// Provides platform-agnostic path resolution for configuration and data directories.
60///
61/// # Example
62///
63/// ```ignore
64/// use local_store::{AppPaths, PathStrategy};
65///
66/// // Use OS-standard directories (default)
67/// let paths = AppPaths::new("myapp");
68/// let config_path = paths.config_file("config.toml")?;
69///
70/// // Force XDG on all platforms
71/// let paths = AppPaths::new("myapp")
72/// .config_strategy(PathStrategy::Xdg);
73/// let config_path = paths.config_file("config.toml")?;
74///
75/// // Use custom base directory
76/// let paths = AppPaths::new("myapp")
77/// .config_strategy(PathStrategy::CustomBase("/opt/myapp".into()));
78/// ```
79#[derive(Debug, Clone)]
80pub struct AppPaths {
81 app_name: String,
82 config_strategy: PathStrategy,
83 data_strategy: PathStrategy,
84}
85
86impl AppPaths {
87 /// Create a new path manager for the given application name.
88 ///
89 /// Uses `System` strategy by default for both config and data.
90 ///
91 /// # Arguments
92 ///
93 /// * `app_name` - Application name (used as subdirectory name)
94 ///
95 /// # Example
96 ///
97 /// ```ignore
98 /// let paths = AppPaths::new("myapp");
99 /// ```
100 pub fn new(app_name: impl Into<String>) -> Self {
101 Self {
102 app_name: app_name.into(),
103 config_strategy: PathStrategy::default(),
104 data_strategy: PathStrategy::default(),
105 }
106 }
107
108 /// Set the configuration directory resolution strategy.
109 ///
110 /// # Example
111 ///
112 /// ```ignore
113 /// let paths = AppPaths::new("myapp")
114 /// .config_strategy(PathStrategy::Xdg);
115 /// ```
116 pub fn config_strategy(mut self, strategy: PathStrategy) -> Self {
117 self.config_strategy = strategy;
118 self
119 }
120
121 /// Set the data directory resolution strategy.
122 ///
123 /// # Example
124 ///
125 /// ```ignore
126 /// let paths = AppPaths::new("myapp")
127 /// .data_strategy(PathStrategy::Xdg);
128 /// ```
129 pub fn data_strategy(mut self, strategy: PathStrategy) -> Self {
130 self.data_strategy = strategy;
131 self
132 }
133
134 /// Get the configuration directory path.
135 ///
136 /// Creates the directory if it doesn't exist.
137 ///
138 /// # Returns
139 ///
140 /// The resolved configuration directory path.
141 ///
142 /// # Errors
143 ///
144 /// Returns `StoreError::HomeDirNotFound` if the home directory cannot be determined.
145 /// Returns `StoreError::IoError` if directory creation fails.
146 ///
147 /// # Example
148 ///
149 /// ```ignore
150 /// let config_dir = paths.config_dir()?;
151 /// // On Linux with System strategy: ~/.config/myapp
152 /// // On macOS with System strategy: ~/Library/Application Support/myapp
153 /// ```
154 pub fn config_dir(&self) -> Result<PathBuf, StoreError> {
155 let dir = self.resolve_config_dir()?;
156 self.ensure_dir_exists(&dir)?;
157 Ok(dir)
158 }
159
160 /// Get the data directory path.
161 ///
162 /// Creates the directory if it doesn't exist.
163 ///
164 /// # Returns
165 ///
166 /// The resolved data directory path.
167 ///
168 /// # Errors
169 ///
170 /// Returns `StoreError::HomeDirNotFound` if the home directory cannot be determined.
171 /// Returns `StoreError::IoError` if directory creation fails.
172 ///
173 /// # Example
174 ///
175 /// ```ignore
176 /// let data_dir = paths.data_dir()?;
177 /// // On Linux with System strategy: ~/.local/share/myapp
178 /// // On macOS with System strategy: ~/Library/Application Support/myapp
179 /// ```
180 pub fn data_dir(&self) -> Result<PathBuf, StoreError> {
181 let dir = self.resolve_data_dir()?;
182 self.ensure_dir_exists(&dir)?;
183 Ok(dir)
184 }
185
186 /// Get a configuration file path.
187 ///
188 /// This is a convenience method that joins the filename to the config directory.
189 /// Creates the parent directory if it doesn't exist.
190 ///
191 /// # Arguments
192 ///
193 /// * `filename` - The configuration file name
194 ///
195 /// # Example
196 ///
197 /// ```ignore
198 /// let config_file = paths.config_file("config.toml")?;
199 /// // On Linux with System strategy: ~/.config/myapp/config.toml
200 /// ```
201 pub fn config_file(&self, filename: &str) -> Result<PathBuf, StoreError> {
202 Ok(self.config_dir()?.join(filename))
203 }
204
205 /// Get a data file path.
206 ///
207 /// This is a convenience method that joins the filename to the data directory.
208 /// Creates the parent directory if it doesn't exist.
209 ///
210 /// # Arguments
211 ///
212 /// * `filename` - The data file name
213 ///
214 /// # Example
215 ///
216 /// ```ignore
217 /// let data_file = paths.data_file("cache.db")?;
218 /// // On Linux with System strategy: ~/.local/share/myapp/cache.db
219 /// ```
220 pub fn data_file(&self, filename: &str) -> Result<PathBuf, StoreError> {
221 Ok(self.data_dir()?.join(filename))
222 }
223
224 /// Resolve the configuration directory path based on the strategy.
225 fn resolve_config_dir(&self) -> Result<PathBuf, StoreError> {
226 match &self.config_strategy {
227 PathStrategy::System => {
228 // Use OS-standard config directory
229 let base = dirs::config_dir().ok_or(StoreError::HomeDirNotFound)?;
230 Ok(base.join(&self.app_name))
231 }
232 PathStrategy::Xdg => {
233 // Force XDG layout on all platforms (env override respected).
234 Ok(xdg_base_dir("XDG_CONFIG_HOME", ".config")?.join(&self.app_name))
235 }
236 PathStrategy::CustomBase(base) => Ok(base.join(&self.app_name)),
237 }
238 }
239
240 /// Resolve the data directory path based on the strategy.
241 fn resolve_data_dir(&self) -> Result<PathBuf, StoreError> {
242 match &self.data_strategy {
243 PathStrategy::System => {
244 // Use OS-standard data directory
245 let base = dirs::data_dir().ok_or(StoreError::HomeDirNotFound)?;
246 Ok(base.join(&self.app_name))
247 }
248 PathStrategy::Xdg => {
249 // Force XDG layout on all platforms (env override respected).
250 Ok(xdg_base_dir("XDG_DATA_HOME", ".local/share")?.join(&self.app_name))
251 }
252 PathStrategy::CustomBase(base) => Ok(base.join("data").join(&self.app_name)),
253 }
254 }
255
256 /// Ensure a directory exists, creating it if necessary.
257 fn ensure_dir_exists(&self, path: &PathBuf) -> Result<(), StoreError> {
258 if !path.exists() {
259 std::fs::create_dir_all(path).map_err(|e| StoreError::IoError {
260 operation: IoOperationKind::CreateDir,
261 path: path.display().to_string(),
262 context: None,
263 error: e.to_string(),
264 })?;
265 }
266 Ok(())
267 }
268}
269
270/// Preference path manager for OS-recommended preference/configuration directories.
271///
272/// Unlike `AppPaths`, `PrefPath` strictly follows OS-specific conventions:
273/// - macOS: `~/Library/Preferences/`
274/// - Linux: `~/.config/` (XDG_CONFIG_HOME)
275/// - Windows: `%APPDATA%`
276///
277/// # Example
278///
279/// ```ignore
280/// use local_store::PrefPath;
281///
282/// let pref = PrefPath::new("com.example.myapp");
283/// let pref_file = pref.pref_file("settings.plist")?;
284/// // On macOS: ~/Library/Preferences/com.example.myapp/settings.plist
285/// // On Linux: ~/.config/com.example.myapp/settings.plist
286/// ```
287#[derive(Debug, Clone)]
288pub struct PrefPath {
289 app_name: String,
290}
291
292impl PrefPath {
293 /// Create a new preference path manager.
294 ///
295 /// # Arguments
296 ///
297 /// * `app_name` - Application identifier (e.g., "com.example.myapp" for macOS bundle ID style)
298 ///
299 /// # Example
300 ///
301 /// ```ignore
302 /// let pref = PrefPath::new("com.example.myapp");
303 /// ```
304 pub fn new(app_name: impl Into<String>) -> Self {
305 Self {
306 app_name: app_name.into(),
307 }
308 }
309
310 /// Get the preference directory path.
311 ///
312 /// Creates the directory if it doesn't exist.
313 ///
314 /// # Returns
315 ///
316 /// The resolved preference directory path:
317 /// - macOS: `~/Library/Preferences/{app_name}`
318 /// - Linux: `~/.config/{app_name}`
319 /// - Windows: `%APPDATA%\{app_name}`
320 ///
321 /// # Errors
322 ///
323 /// Returns `StoreError::HomeDirNotFound` if the home directory cannot be determined.
324 /// Returns `StoreError::IoError` if directory creation fails.
325 ///
326 /// # Example
327 ///
328 /// ```ignore
329 /// let pref_dir = pref.pref_dir()?;
330 /// // On macOS: ~/Library/Preferences/com.example.myapp
331 /// ```
332 pub fn pref_dir(&self) -> Result<PathBuf, StoreError> {
333 let dir = self.resolve_pref_dir()?;
334 self.ensure_dir_exists(&dir)?;
335 Ok(dir)
336 }
337
338 /// Get a preference file path.
339 ///
340 /// This is a convenience method that joins the filename to the preference directory.
341 /// Creates the parent directory if it doesn't exist.
342 ///
343 /// # Arguments
344 ///
345 /// * `filename` - The preference file name (e.g., "settings.plist", "config.json")
346 ///
347 /// # Example
348 ///
349 /// ```ignore
350 /// let pref_file = pref.pref_file("settings.plist")?;
351 /// // On macOS: ~/Library/Preferences/com.example.myapp/settings.plist
352 /// ```
353 pub fn pref_file(&self, filename: &str) -> Result<PathBuf, StoreError> {
354 Ok(self.pref_dir()?.join(filename))
355 }
356
357 /// Resolve the preference directory path based on OS.
358 fn resolve_pref_dir(&self) -> Result<PathBuf, StoreError> {
359 #[cfg(target_os = "macos")]
360 {
361 // macOS: ~/Library/Preferences
362 let home = dirs::home_dir().ok_or(StoreError::HomeDirNotFound)?;
363 Ok(home.join("Library/Preferences").join(&self.app_name))
364 }
365
366 #[cfg(not(target_os = "macos"))]
367 {
368 // Linux/Windows: Use OS-standard config directory
369 let base = dirs::config_dir().ok_or(StoreError::HomeDirNotFound)?;
370 Ok(base.join(&self.app_name))
371 }
372 }
373
374 /// Ensure a directory exists, creating it if necessary.
375 fn ensure_dir_exists(&self, path: &PathBuf) -> Result<(), StoreError> {
376 if !path.exists() {
377 std::fs::create_dir_all(path).map_err(|e| StoreError::IoError {
378 operation: IoOperationKind::CreateDir,
379 path: path.display().to_string(),
380 context: None,
381 error: e.to_string(),
382 })?;
383 }
384 Ok(())
385 }
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use tempfile::TempDir;
392
393 #[test]
394 fn test_path_strategy_default() {
395 assert_eq!(PathStrategy::default(), PathStrategy::System);
396 }
397
398 #[test]
399 fn test_app_paths_new() {
400 let paths = AppPaths::new("testapp");
401 assert_eq!(paths.app_name, "testapp");
402 assert_eq!(paths.config_strategy, PathStrategy::System);
403 assert_eq!(paths.data_strategy, PathStrategy::System);
404 }
405
406 #[test]
407 fn test_app_paths_builder() {
408 let paths = AppPaths::new("testapp")
409 .config_strategy(PathStrategy::Xdg)
410 .data_strategy(PathStrategy::Xdg);
411
412 assert_eq!(paths.config_strategy, PathStrategy::Xdg);
413 assert_eq!(paths.data_strategy, PathStrategy::Xdg);
414 }
415
416 #[test]
417 fn test_system_strategy_config_dir() {
418 let paths = AppPaths::new("testapp").config_strategy(PathStrategy::System);
419 let config_dir = paths.resolve_config_dir().unwrap();
420
421 // Should end with app name
422 assert!(config_dir.ends_with("testapp"));
423
424 // On Unix-like systems, should be under config dir
425 #[cfg(unix)]
426 {
427 let home = dirs::home_dir().unwrap();
428 // macOS uses Library/Application Support, Linux uses .config
429 assert!(
430 config_dir.starts_with(home.join("Library/Application Support"))
431 || config_dir.starts_with(home.join(".config"))
432 );
433 }
434 }
435
436 /// Expected XDG base for tests: env var when set to an absolute path,
437 /// otherwise `$HOME/<fallback>` (mirrors `xdg_base_dir`).
438 fn expected_xdg_base(env_var: &str, fallback: &str) -> std::path::PathBuf {
439 std::env::var_os(env_var)
440 .map(std::path::PathBuf::from)
441 .filter(|p| p.is_absolute())
442 .unwrap_or_else(|| dirs::home_dir().unwrap().join(fallback))
443 }
444
445 #[test]
446 fn test_xdg_strategy_config_dir() {
447 let paths = AppPaths::new("testapp").config_strategy(PathStrategy::Xdg);
448 let config_dir = paths.resolve_config_dir().unwrap();
449
450 let expected = expected_xdg_base("XDG_CONFIG_HOME", ".config").join("testapp");
451 assert_eq!(config_dir, expected);
452 }
453
454 #[test]
455 fn test_xdg_strategy_data_dir() {
456 let paths = AppPaths::new("testapp").data_strategy(PathStrategy::Xdg);
457 let data_dir = paths.resolve_data_dir().unwrap();
458
459 let expected = expected_xdg_base("XDG_DATA_HOME", ".local/share").join("testapp");
460 assert_eq!(data_dir, expected);
461 }
462
463 #[test]
464 fn test_custom_base_strategy() {
465 let temp_dir = TempDir::new().unwrap();
466 let custom_base = temp_dir.path().to_path_buf();
467
468 let paths = AppPaths::new("testapp")
469 .config_strategy(PathStrategy::CustomBase(custom_base.clone()))
470 .data_strategy(PathStrategy::CustomBase(custom_base.clone()));
471
472 let config_dir = paths.resolve_config_dir().unwrap();
473 let data_dir = paths.resolve_data_dir().unwrap();
474
475 assert_eq!(config_dir, custom_base.join("testapp"));
476 assert_eq!(data_dir, custom_base.join("data/testapp"));
477 }
478
479 #[test]
480 fn test_config_file() {
481 let temp_dir = TempDir::new().unwrap();
482 let custom_base = temp_dir.path().to_path_buf();
483
484 let paths =
485 AppPaths::new("testapp").config_strategy(PathStrategy::CustomBase(custom_base.clone()));
486
487 let config_file = paths.config_file("config.toml").unwrap();
488 assert_eq!(config_file, custom_base.join("testapp/config.toml"));
489
490 // Verify directory was created
491 assert!(custom_base.join("testapp").exists());
492 }
493
494 #[test]
495 fn test_data_file() {
496 let temp_dir = TempDir::new().unwrap();
497 let custom_base = temp_dir.path().to_path_buf();
498
499 let paths =
500 AppPaths::new("testapp").data_strategy(PathStrategy::CustomBase(custom_base.clone()));
501
502 let data_file = paths.data_file("cache.db").unwrap();
503 assert_eq!(data_file, custom_base.join("data/testapp/cache.db"));
504
505 // Verify directory was created
506 assert!(custom_base.join("data/testapp").exists());
507 }
508
509 #[test]
510 fn test_ensure_dir_exists() {
511 let temp_dir = TempDir::new().unwrap();
512 let test_path = temp_dir.path().join("nested/test/path");
513
514 let paths = AppPaths::new("testapp");
515 paths.ensure_dir_exists(&test_path).unwrap();
516
517 assert!(test_path.exists());
518 assert!(test_path.is_dir());
519 }
520
521 #[test]
522 fn test_multiple_calls_idempotent() {
523 let temp_dir = TempDir::new().unwrap();
524 let custom_base = temp_dir.path().to_path_buf();
525
526 let paths =
527 AppPaths::new("testapp").config_strategy(PathStrategy::CustomBase(custom_base.clone()));
528
529 // Call config_dir multiple times
530 let dir1 = paths.config_dir().unwrap();
531 let dir2 = paths.config_dir().unwrap();
532 let dir3 = paths.config_dir().unwrap();
533
534 assert_eq!(dir1, dir2);
535 assert_eq!(dir2, dir3);
536 }
537
538 // PrefPath tests
539 #[test]
540 fn test_pref_path_new() {
541 let pref = PrefPath::new("com.example.testapp");
542 assert_eq!(pref.app_name, "com.example.testapp");
543 }
544
545 #[test]
546 fn test_pref_path_resolve_dir() {
547 let pref = PrefPath::new("com.example.testapp");
548 let pref_dir = pref.resolve_pref_dir().unwrap();
549
550 // Should end with app name
551 assert!(pref_dir.ends_with("com.example.testapp"));
552
553 // Platform-specific checks
554 #[cfg(target_os = "macos")]
555 {
556 let home = dirs::home_dir().unwrap();
557 assert_eq!(
558 pref_dir,
559 home.join("Library/Preferences/com.example.testapp")
560 );
561 }
562
563 #[cfg(all(unix, not(target_os = "macos")))]
564 {
565 let home = dirs::home_dir().unwrap();
566 assert_eq!(pref_dir, home.join(".config/com.example.testapp"));
567 }
568
569 #[cfg(target_os = "windows")]
570 {
571 // On Windows, should use APPDATA
572 assert!(pref_dir.to_string_lossy().contains("AppData"));
573 }
574 }
575
576 #[test]
577 fn test_pref_file() {
578 let pref = PrefPath::new("com.example.testapp");
579 let pref_file = pref.pref_file("settings.plist").unwrap();
580
581 // Should end with the filename
582 assert!(pref_file.ends_with("settings.plist"));
583
584 // Should contain app name
585 assert!(pref_file.to_string_lossy().contains("com.example.testapp"));
586
587 #[cfg(target_os = "macos")]
588 {
589 let home = dirs::home_dir().unwrap();
590 assert_eq!(
591 pref_file,
592 home.join("Library/Preferences/com.example.testapp/settings.plist")
593 );
594 }
595 }
596
597 #[test]
598 fn test_pref_dir_creates_directory() {
599 // This test would require mocking or a temp directory
600 // For now, we just verify it doesn't panic with the real home dir
601 let pref = PrefPath::new("test_version_migrate_pref");
602 let pref_dir = pref.pref_dir().unwrap();
603
604 // Clean up
605 if pref_dir.exists() {
606 let _ = std::fs::remove_dir_all(&pref_dir);
607 }
608 }
609
610 #[test]
611 fn test_pref_path_multiple_calls_idempotent() {
612 let pref = PrefPath::new("test_version_migrate_pref2");
613
614 let dir1 = pref.pref_dir().unwrap();
615 let dir2 = pref.pref_dir().unwrap();
616 let dir3 = pref.pref_dir().unwrap();
617
618 assert_eq!(dir1, dir2);
619 assert_eq!(dir2, dir3);
620
621 // Clean up
622 if dir1.exists() {
623 let _ = std::fs::remove_dir_all(&dir1);
624 }
625 }
626}