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}