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