Skip to main content

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}