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#[cfg(test)]
257mod tests {
258    use super::*;
259    use tempfile::TempDir;
260
261    #[test]
262    fn test_path_strategy_default() {
263        assert_eq!(PathStrategy::default(), PathStrategy::System);
264    }
265
266    #[test]
267    fn test_app_paths_new() {
268        let paths = AppPaths::new("testapp");
269        assert_eq!(paths.app_name, "testapp");
270        assert_eq!(paths.config_strategy, PathStrategy::System);
271        assert_eq!(paths.data_strategy, PathStrategy::System);
272    }
273
274    #[test]
275    fn test_app_paths_builder() {
276        let paths = AppPaths::new("testapp")
277            .config_strategy(PathStrategy::Xdg)
278            .data_strategy(PathStrategy::Xdg);
279
280        assert_eq!(paths.config_strategy, PathStrategy::Xdg);
281        assert_eq!(paths.data_strategy, PathStrategy::Xdg);
282    }
283
284    #[test]
285    fn test_system_strategy_config_dir() {
286        let paths = AppPaths::new("testapp").config_strategy(PathStrategy::System);
287        let config_dir = paths.resolve_config_dir().unwrap();
288
289        // Should end with app name
290        assert!(config_dir.ends_with("testapp"));
291
292        // On Unix-like systems, should be under config dir
293        #[cfg(unix)]
294        {
295            let home = dirs::home_dir().unwrap();
296            // macOS uses Library/Application Support, Linux uses .config
297            assert!(
298                config_dir.starts_with(home.join("Library/Application Support"))
299                    || config_dir.starts_with(home.join(".config"))
300            );
301        }
302    }
303
304    #[test]
305    fn test_xdg_strategy_config_dir() {
306        let paths = AppPaths::new("testapp").config_strategy(PathStrategy::Xdg);
307        let config_dir = paths.resolve_config_dir().unwrap();
308
309        // Should be ~/.config/testapp on all platforms
310        let home = dirs::home_dir().unwrap();
311        assert_eq!(config_dir, home.join(".config/testapp"));
312    }
313
314    #[test]
315    fn test_xdg_strategy_data_dir() {
316        let paths = AppPaths::new("testapp").data_strategy(PathStrategy::Xdg);
317        let data_dir = paths.resolve_data_dir().unwrap();
318
319        // Should be ~/.local/share/testapp on all platforms
320        let home = dirs::home_dir().unwrap();
321        assert_eq!(data_dir, home.join(".local/share/testapp"));
322    }
323
324    #[test]
325    fn test_custom_base_strategy() {
326        let temp_dir = TempDir::new().unwrap();
327        let custom_base = temp_dir.path().to_path_buf();
328
329        let paths = AppPaths::new("testapp")
330            .config_strategy(PathStrategy::CustomBase(custom_base.clone()))
331            .data_strategy(PathStrategy::CustomBase(custom_base.clone()));
332
333        let config_dir = paths.resolve_config_dir().unwrap();
334        let data_dir = paths.resolve_data_dir().unwrap();
335
336        assert_eq!(config_dir, custom_base.join("testapp"));
337        assert_eq!(data_dir, custom_base.join("data/testapp"));
338    }
339
340    #[test]
341    fn test_config_file() {
342        let temp_dir = TempDir::new().unwrap();
343        let custom_base = temp_dir.path().to_path_buf();
344
345        let paths =
346            AppPaths::new("testapp").config_strategy(PathStrategy::CustomBase(custom_base.clone()));
347
348        let config_file = paths.config_file("config.toml").unwrap();
349        assert_eq!(config_file, custom_base.join("testapp/config.toml"));
350
351        // Verify directory was created
352        assert!(custom_base.join("testapp").exists());
353    }
354
355    #[test]
356    fn test_data_file() {
357        let temp_dir = TempDir::new().unwrap();
358        let custom_base = temp_dir.path().to_path_buf();
359
360        let paths =
361            AppPaths::new("testapp").data_strategy(PathStrategy::CustomBase(custom_base.clone()));
362
363        let data_file = paths.data_file("cache.db").unwrap();
364        assert_eq!(data_file, custom_base.join("data/testapp/cache.db"));
365
366        // Verify directory was created
367        assert!(custom_base.join("data/testapp").exists());
368    }
369
370    #[test]
371    fn test_ensure_dir_exists() {
372        let temp_dir = TempDir::new().unwrap();
373        let test_path = temp_dir.path().join("nested/test/path");
374
375        let paths = AppPaths::new("testapp");
376        paths.ensure_dir_exists(&test_path).unwrap();
377
378        assert!(test_path.exists());
379        assert!(test_path.is_dir());
380    }
381
382    #[test]
383    fn test_multiple_calls_idempotent() {
384        let temp_dir = TempDir::new().unwrap();
385        let custom_base = temp_dir.path().to_path_buf();
386
387        let paths =
388            AppPaths::new("testapp").config_strategy(PathStrategy::CustomBase(custom_base.clone()));
389
390        // Call config_dir multiple times
391        let dir1 = paths.config_dir().unwrap();
392        let dir2 = paths.config_dir().unwrap();
393        let dir3 = paths.config_dir().unwrap();
394
395        assert_eq!(dir1, dir2);
396        assert_eq!(dir2, dir3);
397    }
398}