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