Skip to main content

clean_dev_dirs/config/
file.rs

1//! Configuration file support for persistent settings.
2//!
3//! This module provides support for loading configuration from a TOML file
4//! located at `~/.config/clean-dev-dirs/config.toml` (or the platform-specific
5//! equivalent). Configuration file values serve as defaults that can be
6//! overridden by CLI arguments.
7//!
8//! # Layering
9//!
10//! The precedence order is: **CLI argument > config file > hardcoded default**.
11//!
12//! # Example config
13//!
14//! ```toml
15//! project_type = "rust"
16//! dir = "~/Projects"
17//!
18//! [filtering]
19//! keep_size = "50MB"
20//! keep_days = 7
21//! sort = "size"
22//! reverse = false
23//!
24//! [scanning]
25//! threads = 4
26//! verbose = true
27//! skip = [".cargo", "vendor"]
28//! ignore = [".git"]
29//! max_depth = 5
30//!
31//! [execution]
32//! keep_executables = true
33//! interactive = false
34//! dry_run = false
35//! use_trash = true    # default; set to false for permanent deletion
36//! ```
37
38use std::path::{Path, PathBuf};
39
40use serde::Deserialize;
41
42/// Top-level configuration file structure.
43///
44/// All fields are `Option<T>` so we can detect which values are present in the
45/// config file and apply layered configuration (CLI > config file > defaults).
46#[derive(Deserialize, Default, Debug)]
47pub struct FileConfig {
48    /// Default project type filter (e.g., `"rust"`, `"node"`, `"all"`)
49    pub project_type: Option<String>,
50
51    /// Default directory to scan
52    pub dir: Option<PathBuf>,
53
54    /// Filtering options
55    #[serde(default)]
56    pub filtering: FileFilterConfig,
57
58    /// Scanning options
59    #[serde(default)]
60    pub scanning: FileScanConfig,
61
62    /// Execution options
63    #[serde(default)]
64    pub execution: FileExecutionConfig,
65}
66
67/// Filtering options from the configuration file.
68#[derive(Deserialize, Default, Debug)]
69pub struct FileFilterConfig {
70    /// Minimum size threshold (e.g., `"50MB"`)
71    pub keep_size: Option<String>,
72
73    /// Minimum age in days
74    pub keep_days: Option<u32>,
75
76    /// Sort criterion for project output (`"size"`, `"age"`, `"name"`, `"type"`)
77    pub sort: Option<String>,
78
79    /// Whether to reverse the sort order
80    pub reverse: Option<bool>,
81}
82
83/// Scanning options from the configuration file.
84#[derive(Deserialize, Default, Debug)]
85pub struct FileScanConfig {
86    /// Number of threads for scanning
87    pub threads: Option<usize>,
88
89    /// Whether to show verbose output
90    pub verbose: Option<bool>,
91
92    /// Directories to skip during scanning
93    pub skip: Option<Vec<PathBuf>>,
94
95    /// Directories to ignore during scanning
96    pub ignore: Option<Vec<PathBuf>>,
97
98    /// Maximum directory depth to scan
99    pub max_depth: Option<usize>,
100}
101
102/// Execution options from the configuration file.
103#[derive(Deserialize, Default, Debug)]
104pub struct FileExecutionConfig {
105    /// Whether to preserve compiled executables
106    pub keep_executables: Option<bool>,
107
108    /// Whether to use interactive selection
109    pub interactive: Option<bool>,
110
111    /// Whether to run in dry-run mode
112    pub dry_run: Option<bool>,
113
114    /// Whether to move directories to the system trash instead of permanently deleting them.
115    /// Defaults to `true` when absent. Set to `false` for permanent deletion.
116    pub use_trash: Option<bool>,
117}
118
119/// Expand a leading `~` in a path to the user's home directory.
120///
121/// Paths that don't start with `~` are returned unchanged.
122///
123/// # Examples
124///
125/// ```
126/// # use std::path::PathBuf;
127/// # use clean_dev_dirs::config::file::expand_tilde;
128/// let absolute = PathBuf::from("/absolute/path");
129/// assert_eq!(expand_tilde(&absolute), PathBuf::from("/absolute/path"));
130/// ```
131#[must_use]
132pub fn expand_tilde(path: &Path) -> PathBuf {
133    if let Ok(rest) = path.strip_prefix("~")
134        && let Some(home) = dirs::home_dir()
135    {
136        return home.join(rest);
137    }
138    path.to_path_buf()
139}
140
141impl FileConfig {
142    /// Returns the path where the configuration file is expected.
143    ///
144    /// The configuration file is located at `<config_dir>/clean-dev-dirs/config.toml`,
145    /// where `<config_dir>` is the platform-specific configuration directory
146    /// (e.g., `~/.config` on Linux/macOS, `%APPDATA%` on Windows).
147    ///
148    /// # Returns
149    ///
150    /// `Some(PathBuf)` with the config file path, or `None` if the config
151    /// directory cannot be determined.
152    #[must_use]
153    pub fn config_path() -> Option<PathBuf> {
154        dirs::config_dir().map(|p| p.join("clean-dev-dirs").join("config.toml"))
155    }
156
157    /// Load configuration from the default config file location.
158    ///
159    /// If the config file doesn't exist, returns a default (empty) configuration.
160    /// If the file exists but is malformed, returns an error.
161    ///
162    /// # Errors
163    ///
164    /// Returns an error if:
165    /// - The config file exists but cannot be read
166    /// - The config file exists but contains invalid TOML or unexpected fields
167    pub fn load() -> anyhow::Result<Self> {
168        let Some(path) = Self::config_path() else {
169            return Ok(Self::default());
170        };
171
172        if !path.exists() {
173            return Ok(Self::default());
174        }
175
176        let content = std::fs::read_to_string(&path).map_err(|e| {
177            anyhow::anyhow!("Failed to read config file at {}: {e}", path.display())
178        })?;
179
180        let config: Self = toml::from_str(&content).map_err(|e| {
181            anyhow::anyhow!("Failed to parse config file at {}: {e}", path.display())
182        })?;
183
184        Ok(config)
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn test_default_file_config() {
194        let config = FileConfig::default();
195
196        assert!(config.project_type.is_none());
197        assert!(config.dir.is_none());
198        assert!(config.filtering.keep_size.is_none());
199        assert!(config.filtering.keep_days.is_none());
200        assert!(config.filtering.sort.is_none());
201        assert!(config.filtering.reverse.is_none());
202        assert!(config.scanning.threads.is_none());
203        assert!(config.scanning.verbose.is_none());
204        assert!(config.scanning.skip.is_none());
205        assert!(config.scanning.ignore.is_none());
206        assert!(config.execution.keep_executables.is_none());
207        assert!(config.execution.interactive.is_none());
208        assert!(config.execution.dry_run.is_none());
209        assert!(config.execution.use_trash.is_none());
210    }
211
212    #[test]
213    fn test_parse_full_config() {
214        let toml_content = r#"
215project_type = "rust"
216dir = "~/Projects"
217
218[filtering]
219keep_size = "50MB"
220keep_days = 7
221sort = "size"
222reverse = true
223
224[scanning]
225threads = 4
226verbose = true
227skip = [".cargo", "vendor"]
228ignore = [".git"]
229
230[execution]
231keep_executables = true
232interactive = false
233dry_run = false
234use_trash = true
235"#;
236
237        let config: FileConfig = toml::from_str(toml_content).unwrap();
238
239        assert_eq!(config.project_type, Some("rust".to_string()));
240        assert_eq!(config.dir, Some(PathBuf::from("~/Projects")));
241        assert_eq!(config.filtering.keep_size, Some("50MB".to_string()));
242        assert_eq!(config.filtering.keep_days, Some(7));
243        assert_eq!(config.filtering.sort, Some("size".to_string()));
244        assert_eq!(config.filtering.reverse, Some(true));
245        assert_eq!(config.scanning.threads, Some(4));
246        assert_eq!(config.scanning.verbose, Some(true));
247        assert_eq!(
248            config.scanning.skip,
249            Some(vec![PathBuf::from(".cargo"), PathBuf::from("vendor")])
250        );
251        assert_eq!(config.scanning.ignore, Some(vec![PathBuf::from(".git")]));
252        assert_eq!(config.execution.keep_executables, Some(true));
253        assert_eq!(config.execution.interactive, Some(false));
254        assert_eq!(config.execution.dry_run, Some(false));
255        assert_eq!(config.execution.use_trash, Some(true));
256    }
257
258    #[test]
259    fn test_parse_partial_config() {
260        let toml_content = r#"
261[filtering]
262keep_size = "100MB"
263"#;
264
265        let config: FileConfig = toml::from_str(toml_content).unwrap();
266
267        assert!(config.project_type.is_none());
268        assert!(config.dir.is_none());
269        assert_eq!(config.filtering.keep_size, Some("100MB".to_string()));
270        assert!(config.filtering.keep_days.is_none());
271        assert!(config.filtering.sort.is_none());
272        assert!(config.filtering.reverse.is_none());
273        assert!(config.scanning.threads.is_none());
274    }
275
276    #[test]
277    fn test_parse_empty_config() {
278        let toml_content = "";
279        let config: FileConfig = toml::from_str(toml_content).unwrap();
280
281        assert!(config.project_type.is_none());
282        assert!(config.dir.is_none());
283    }
284
285    #[test]
286    fn test_malformed_config_errors() {
287        let toml_content = r#"
288[filtering]
289keep_days = "not_a_number"
290"#;
291        let result = toml::from_str::<FileConfig>(toml_content);
292        assert!(result.is_err());
293    }
294
295    #[test]
296    fn test_config_path_returns_expected_suffix() {
297        let path = FileConfig::config_path();
298        if let Some(p) = path {
299            assert!(p.ends_with("clean-dev-dirs/config.toml"));
300        }
301    }
302
303    #[test]
304    fn test_load_returns_defaults_when_no_file() {
305        let config = FileConfig::load().unwrap();
306        assert!(config.project_type.is_none());
307        assert!(config.dir.is_none());
308    }
309
310    #[test]
311    fn test_expand_tilde_with_home() {
312        let path = PathBuf::from("~/Projects");
313        let expanded = expand_tilde(&path);
314
315        if let Some(home) = dirs::home_dir() {
316            assert_eq!(expanded, home.join("Projects"));
317        }
318    }
319
320    #[test]
321    fn test_expand_tilde_absolute_path_unchanged() {
322        let path = PathBuf::from("/absolute/path");
323        let expanded = expand_tilde(&path);
324        assert_eq!(expanded, PathBuf::from("/absolute/path"));
325    }
326
327    #[test]
328    fn test_expand_tilde_relative_path_unchanged() {
329        let path = PathBuf::from("relative/path");
330        let expanded = expand_tilde(&path);
331        assert_eq!(expanded, PathBuf::from("relative/path"));
332    }
333
334    #[test]
335    fn test_expand_tilde_bare() {
336        let path = PathBuf::from("~");
337        let expanded = expand_tilde(&path);
338
339        if let Some(home) = dirs::home_dir() {
340            assert_eq!(expanded, home);
341        }
342    }
343
344    // ── Platform-specific config path tests ─────────────────────────────
345
346    #[test]
347    fn test_config_path_is_platform_appropriate() {
348        let path = FileConfig::config_path();
349
350        // config_path might return None in CI environments without a home dir,
351        // but when it does return a path, it must match platform conventions.
352        if let Some(p) = &path {
353            let path_str = p.to_string_lossy();
354
355            #[cfg(target_os = "linux")]
356            assert!(
357                path_str.contains(".config"),
358                "Linux config path should be under $XDG_CONFIG_HOME or ~/.config, got: {path_str}"
359            );
360
361            #[cfg(target_os = "macos")]
362            assert!(
363                path_str.contains("Application Support") || path_str.contains(".config"),
364                "macOS config path should be under Library/Application Support, got: {path_str}"
365            );
366
367            #[cfg(target_os = "windows")]
368            assert!(
369                path_str.contains("AppData"),
370                "Windows config path should be under AppData, got: {path_str}"
371            );
372
373            // Common: always ends with our application config file name
374            assert!(
375                p.ends_with("clean-dev-dirs/config.toml")
376                    || p.ends_with(Path::new("clean-dev-dirs").join("config.toml"))
377            );
378        }
379    }
380
381    #[test]
382    fn test_config_path_parent_exists_or_can_be_created() {
383        // Verify the parent of the config path is a real, accessible directory
384        // (or at least its grandparent exists — the app dir might not exist yet).
385        if let Some(path) = FileConfig::config_path()
386            && let Some(grandparent) = path.parent().and_then(Path::parent)
387        {
388            // The system config directory should exist
389            assert!(
390                grandparent.exists(),
391                "Config grandparent directory should exist: {}",
392                grandparent.display()
393            );
394        }
395    }
396
397    #[test]
398    fn test_expand_tilde_deeply_nested() {
399        let path = PathBuf::from("~/a/b/c/d");
400        let expanded = expand_tilde(&path);
401
402        if let Some(home) = dirs::home_dir() {
403            assert_eq!(expanded, home.join("a").join("b").join("c").join("d"));
404            assert!(!expanded.to_string_lossy().contains('~'));
405        }
406    }
407
408    #[test]
409    fn test_expand_tilde_no_effect_on_non_tilde() {
410        // Relative paths without ~ should be unchanged
411        let relative = PathBuf::from("some/relative/path");
412        assert_eq!(expand_tilde(&relative), relative);
413
414        // Absolute Unix-style paths should be unchanged
415        let absolute = PathBuf::from("/usr/local/bin");
416        assert_eq!(expand_tilde(&absolute), absolute);
417
418        // Windows-style absolute paths should be unchanged
419        #[cfg(windows)]
420        {
421            let win_abs = PathBuf::from(r"C:\Users\user\Documents");
422            assert_eq!(expand_tilde(&win_abs), win_abs);
423        }
424    }
425
426    #[test]
427    fn test_config_toml_parsing_with_platform_paths() {
428        // Test that TOML parsing handles paths from any platform
429        let toml_unix = "dir = \"/home/user/projects\"\n";
430        let config: FileConfig = toml::from_str(toml_unix).unwrap();
431        assert_eq!(config.dir, Some(PathBuf::from("/home/user/projects")));
432
433        let toml_tilde = "dir = \"~/Projects\"\n";
434        let config: FileConfig = toml::from_str(toml_tilde).unwrap();
435        assert_eq!(config.dir, Some(PathBuf::from("~/Projects")));
436
437        let toml_relative = "dir = \"./projects\"\n";
438        let config: FileConfig = toml::from_str(toml_relative).unwrap();
439        assert_eq!(config.dir, Some(PathBuf::from("./projects")));
440    }
441
442    #[test]
443    fn test_file_config_all_execution_options_parse() {
444        let toml_content = r"
445[execution]
446keep_executables = true
447interactive = false
448dry_run = true
449use_trash = false
450";
451        let config: FileConfig = toml::from_str(toml_content).unwrap();
452
453        assert_eq!(config.execution.keep_executables, Some(true));
454        assert_eq!(config.execution.interactive, Some(false));
455        assert_eq!(config.execution.dry_run, Some(true));
456        assert_eq!(config.execution.use_trash, Some(false));
457    }
458}