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