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