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}