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