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