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}