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}