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() -> anyhow::Result<()> {
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)?;
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        Ok(())
272    }
273
274    #[test]
275    fn test_parse_dirs_field() -> anyhow::Result<()> {
276        let toml_content = r#"dirs = ["~/Projects", "~/work"]"#;
277        let config: FileConfig = toml::from_str(toml_content)?;
278
279        assert_eq!(
280            config.dirs,
281            Some(vec![PathBuf::from("~/Projects"), PathBuf::from("~/work")])
282        );
283        assert!(config.dir.is_none());
284
285        Ok(())
286    }
287
288    #[test]
289    fn test_parse_partial_config() -> anyhow::Result<()> {
290        let toml_content = r#"
291[filtering]
292keep_size = "100MB"
293"#;
294
295        let config: FileConfig = toml::from_str(toml_content)?;
296
297        assert!(config.project_type.is_none());
298        assert!(config.dir.is_none());
299        assert_eq!(config.filtering.keep_size, Some("100MB".to_string()));
300        assert!(config.filtering.keep_days.is_none());
301        assert!(config.filtering.sort.is_none());
302        assert!(config.filtering.reverse.is_none());
303        assert!(config.scanning.threads.is_none());
304
305        Ok(())
306    }
307
308    #[test]
309    fn test_parse_empty_config() -> anyhow::Result<()> {
310        let toml_content = "";
311        let config: FileConfig = toml::from_str(toml_content)?;
312
313        assert!(config.project_type.is_none());
314        assert!(config.dir.is_none());
315
316        Ok(())
317    }
318
319    #[test]
320    fn test_malformed_config_errors() {
321        let toml_content = r#"
322[filtering]
323keep_days = "not_a_number"
324"#;
325        let result = toml::from_str::<FileConfig>(toml_content);
326        assert!(result.is_err());
327    }
328
329    #[test]
330    fn test_config_path_returns_expected_suffix() {
331        let path = FileConfig::config_path();
332        if let Some(p) = path {
333            assert!(p.ends_with("clean-dev-dirs/config.toml"));
334        }
335    }
336
337    #[test]
338    fn test_load_returns_defaults_when_no_file() -> anyhow::Result<()> {
339        let config = FileConfig::load()?;
340        assert!(config.project_type.is_none());
341        assert!(config.dir.is_none());
342
343        Ok(())
344    }
345
346    #[test]
347    fn test_expand_tilde_with_home() {
348        let path = PathBuf::from("~/Projects");
349        let expanded = expand_tilde(&path);
350
351        if let Some(home) = dirs::home_dir() {
352            assert_eq!(expanded, home.join("Projects"));
353        }
354    }
355
356    #[test]
357    fn test_expand_tilde_absolute_path_unchanged() {
358        let path = PathBuf::from("/absolute/path");
359        let expanded = expand_tilde(&path);
360        assert_eq!(expanded, PathBuf::from("/absolute/path"));
361    }
362
363    #[test]
364    fn test_expand_tilde_relative_path_unchanged() {
365        let path = PathBuf::from("relative/path");
366        let expanded = expand_tilde(&path);
367        assert_eq!(expanded, PathBuf::from("relative/path"));
368    }
369
370    #[test]
371    fn test_expand_tilde_bare() {
372        let path = PathBuf::from("~");
373        let expanded = expand_tilde(&path);
374
375        if let Some(home) = dirs::home_dir() {
376            assert_eq!(expanded, home);
377        }
378    }
379
380    // ── Platform-specific config path tests ─────────────────────────────
381
382    #[test]
383    fn test_config_path_is_platform_appropriate() {
384        let path = FileConfig::config_path();
385
386        // config_path might return None in CI environments without a home dir,
387        // but when it does return a path, it must match platform conventions.
388        if let Some(p) = &path {
389            let path_str = p.to_string_lossy();
390
391            #[cfg(target_os = "linux")]
392            assert!(
393                path_str.contains(".config"),
394                "Linux config path should be under $XDG_CONFIG_HOME or ~/.config, got: {path_str}"
395            );
396
397            #[cfg(target_os = "macos")]
398            assert!(
399                path_str.contains("Application Support") || path_str.contains(".config"),
400                "macOS config path should be under Library/Application Support, got: {path_str}"
401            );
402
403            #[cfg(target_os = "windows")]
404            assert!(
405                path_str.contains("AppData"),
406                "Windows config path should be under AppData, got: {path_str}"
407            );
408
409            // Common: always ends with our application config file name
410            assert!(
411                p.ends_with("clean-dev-dirs/config.toml")
412                    || p.ends_with(Path::new("clean-dev-dirs").join("config.toml"))
413            );
414        }
415    }
416
417    #[test]
418    fn test_config_path_parent_exists_or_can_be_created() {
419        // Verify the parent of the config path is a real, accessible directory
420        // (or at least its grandparent exists — the app dir might not exist yet).
421        if let Some(path) = FileConfig::config_path()
422            && let Some(grandparent) = path.parent().and_then(Path::parent)
423        {
424            // The system config directory should exist
425            assert!(
426                grandparent.exists(),
427                "Config grandparent directory should exist: {}",
428                grandparent.display()
429            );
430        }
431    }
432
433    #[test]
434    fn test_expand_tilde_deeply_nested() {
435        let path = PathBuf::from("~/a/b/c/d");
436        let expanded = expand_tilde(&path);
437
438        if let Some(home) = dirs::home_dir() {
439            assert_eq!(expanded, home.join("a").join("b").join("c").join("d"));
440            assert!(!expanded.to_string_lossy().contains('~'));
441        }
442    }
443
444    #[test]
445    fn test_expand_tilde_no_effect_on_non_tilde() {
446        // Relative paths without ~ should be unchanged
447        let relative = PathBuf::from("some/relative/path");
448        assert_eq!(expand_tilde(&relative), relative);
449
450        // Absolute Unix-style paths should be unchanged
451        let absolute = PathBuf::from("/usr/local/bin");
452        assert_eq!(expand_tilde(&absolute), absolute);
453
454        // Windows-style absolute paths should be unchanged
455        #[cfg(windows)]
456        {
457            let win_abs = PathBuf::from(r"C:\Users\user\Documents");
458            assert_eq!(expand_tilde(&win_abs), win_abs);
459        }
460    }
461
462    #[test]
463    fn test_config_toml_parsing_with_platform_paths() -> anyhow::Result<()> {
464        let toml_unix = "dir = \"/home/user/projects\"\n";
465        let config: FileConfig = toml::from_str(toml_unix)?;
466        assert_eq!(config.dir, Some(PathBuf::from("/home/user/projects")));
467
468        let toml_tilde = "dir = \"~/Projects\"\n";
469        let config: FileConfig = toml::from_str(toml_tilde)?;
470        assert_eq!(config.dir, Some(PathBuf::from("~/Projects")));
471
472        let toml_relative = "dir = \"./projects\"\n";
473        let config: FileConfig = toml::from_str(toml_relative)?;
474        assert_eq!(config.dir, Some(PathBuf::from("./projects")));
475
476        Ok(())
477    }
478
479    #[test]
480    fn test_file_config_all_execution_options_parse() -> anyhow::Result<()> {
481        let toml_content = r"
482[execution]
483keep_executables = true
484interactive = false
485dry_run = true
486use_trash = false
487";
488        let config: FileConfig = toml::from_str(toml_content)?;
489
490        assert_eq!(config.execution.keep_executables, Some(true));
491        assert_eq!(config.execution.interactive, Some(false));
492        assert_eq!(config.execution.dry_run, Some(true));
493        assert_eq!(config.execution.use_trash, Some(false));
494
495        Ok(())
496    }
497}