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//!
22//! [scanning]
23//! threads = 4
24//! verbose = true
25//! skip = [".cargo", "vendor"]
26//! ignore = [".git"]
27//!
28//! [execution]
29//! keep_executables = true
30//! interactive = false
31//! dry_run = false
32//! ```
33
34use std::path::{Path, PathBuf};
35
36use serde::Deserialize;
37
38/// Top-level configuration file structure.
39///
40/// All fields are `Option<T>` so we can detect which values are present in the
41/// config file and apply layered configuration (CLI > config file > defaults).
42#[derive(Deserialize, Default, Debug)]
43pub struct FileConfig {
44    /// Default project type filter (e.g., `"rust"`, `"node"`, `"all"`)
45    pub project_type: Option<String>,
46
47    /// Default directory to scan
48    pub dir: Option<PathBuf>,
49
50    /// Filtering options
51    #[serde(default)]
52    pub filtering: FileFilterConfig,
53
54    /// Scanning options
55    #[serde(default)]
56    pub scanning: FileScanConfig,
57
58    /// Execution options
59    #[serde(default)]
60    pub execution: FileExecutionConfig,
61}
62
63/// Filtering options from the configuration file.
64#[derive(Deserialize, Default, Debug)]
65pub struct FileFilterConfig {
66    /// Minimum size threshold (e.g., `"50MB"`)
67    pub keep_size: Option<String>,
68
69    /// Minimum age in days
70    pub keep_days: Option<u32>,
71}
72
73/// Scanning options from the configuration file.
74#[derive(Deserialize, Default, Debug)]
75pub struct FileScanConfig {
76    /// Number of threads for scanning
77    pub threads: Option<usize>,
78
79    /// Whether to show verbose output
80    pub verbose: Option<bool>,
81
82    /// Directories to skip during scanning
83    pub skip: Option<Vec<PathBuf>>,
84
85    /// Directories to ignore during scanning
86    pub ignore: Option<Vec<PathBuf>>,
87}
88
89/// Execution options from the configuration file.
90#[derive(Deserialize, Default, Debug)]
91pub struct FileExecutionConfig {
92    /// Whether to preserve compiled executables
93    pub keep_executables: Option<bool>,
94
95    /// Whether to use interactive selection
96    pub interactive: Option<bool>,
97
98    /// Whether to run in dry-run mode
99    pub dry_run: Option<bool>,
100}
101
102/// Expand a leading `~` in a path to the user's home directory.
103///
104/// Paths that don't start with `~` are returned unchanged.
105///
106/// # Examples
107///
108/// ```
109/// # use std::path::PathBuf;
110/// # use clean_dev_dirs::config::file::expand_tilde;
111/// let absolute = PathBuf::from("/absolute/path");
112/// assert_eq!(expand_tilde(&absolute), PathBuf::from("/absolute/path"));
113/// ```
114#[must_use]
115pub fn expand_tilde(path: &Path) -> PathBuf {
116    if let Ok(rest) = path.strip_prefix("~")
117        && let Some(home) = dirs::home_dir()
118    {
119        return home.join(rest);
120    }
121    path.to_path_buf()
122}
123
124impl FileConfig {
125    /// Returns the path where the configuration file is expected.
126    ///
127    /// The configuration file is located at `<config_dir>/clean-dev-dirs/config.toml`,
128    /// where `<config_dir>` is the platform-specific configuration directory
129    /// (e.g., `~/.config` on Linux/macOS, `%APPDATA%` on Windows).
130    ///
131    /// # Returns
132    ///
133    /// `Some(PathBuf)` with the config file path, or `None` if the config
134    /// directory cannot be determined.
135    #[must_use]
136    pub fn config_path() -> Option<PathBuf> {
137        dirs::config_dir().map(|p| p.join("clean-dev-dirs").join("config.toml"))
138    }
139
140    /// Load configuration from the default config file location.
141    ///
142    /// If the config file doesn't exist, returns a default (empty) configuration.
143    /// If the file exists but is malformed, returns an error.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if:
148    /// - The config file exists but cannot be read
149    /// - The config file exists but contains invalid TOML or unexpected fields
150    pub fn load() -> anyhow::Result<Self> {
151        let Some(path) = Self::config_path() else {
152            return Ok(Self::default());
153        };
154
155        if !path.exists() {
156            return Ok(Self::default());
157        }
158
159        let content = std::fs::read_to_string(&path).map_err(|e| {
160            anyhow::anyhow!("Failed to read config file at {}: {e}", path.display())
161        })?;
162
163        let config: Self = toml::from_str(&content).map_err(|e| {
164            anyhow::anyhow!("Failed to parse config file at {}: {e}", path.display())
165        })?;
166
167        Ok(config)
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn test_default_file_config() {
177        let config = FileConfig::default();
178
179        assert!(config.project_type.is_none());
180        assert!(config.dir.is_none());
181        assert!(config.filtering.keep_size.is_none());
182        assert!(config.filtering.keep_days.is_none());
183        assert!(config.scanning.threads.is_none());
184        assert!(config.scanning.verbose.is_none());
185        assert!(config.scanning.skip.is_none());
186        assert!(config.scanning.ignore.is_none());
187        assert!(config.execution.keep_executables.is_none());
188        assert!(config.execution.interactive.is_none());
189        assert!(config.execution.dry_run.is_none());
190    }
191
192    #[test]
193    fn test_parse_full_config() {
194        let toml_content = r#"
195project_type = "rust"
196dir = "~/Projects"
197
198[filtering]
199keep_size = "50MB"
200keep_days = 7
201
202[scanning]
203threads = 4
204verbose = true
205skip = [".cargo", "vendor"]
206ignore = [".git"]
207
208[execution]
209keep_executables = true
210interactive = false
211dry_run = false
212"#;
213
214        let config: FileConfig = toml::from_str(toml_content).unwrap();
215
216        assert_eq!(config.project_type, Some("rust".to_string()));
217        assert_eq!(config.dir, Some(PathBuf::from("~/Projects")));
218        assert_eq!(config.filtering.keep_size, Some("50MB".to_string()));
219        assert_eq!(config.filtering.keep_days, Some(7));
220        assert_eq!(config.scanning.threads, Some(4));
221        assert_eq!(config.scanning.verbose, Some(true));
222        assert_eq!(
223            config.scanning.skip,
224            Some(vec![PathBuf::from(".cargo"), PathBuf::from("vendor")])
225        );
226        assert_eq!(config.scanning.ignore, Some(vec![PathBuf::from(".git")]));
227        assert_eq!(config.execution.keep_executables, Some(true));
228        assert_eq!(config.execution.interactive, Some(false));
229        assert_eq!(config.execution.dry_run, Some(false));
230    }
231
232    #[test]
233    fn test_parse_partial_config() {
234        let toml_content = r#"
235[filtering]
236keep_size = "100MB"
237"#;
238
239        let config: FileConfig = toml::from_str(toml_content).unwrap();
240
241        assert!(config.project_type.is_none());
242        assert!(config.dir.is_none());
243        assert_eq!(config.filtering.keep_size, Some("100MB".to_string()));
244        assert!(config.filtering.keep_days.is_none());
245        assert!(config.scanning.threads.is_none());
246    }
247
248    #[test]
249    fn test_parse_empty_config() {
250        let toml_content = "";
251        let config: FileConfig = toml::from_str(toml_content).unwrap();
252
253        assert!(config.project_type.is_none());
254        assert!(config.dir.is_none());
255    }
256
257    #[test]
258    fn test_malformed_config_errors() {
259        let toml_content = r#"
260[filtering]
261keep_days = "not_a_number"
262"#;
263        let result = toml::from_str::<FileConfig>(toml_content);
264        assert!(result.is_err());
265    }
266
267    #[test]
268    fn test_config_path_returns_expected_suffix() {
269        let path = FileConfig::config_path();
270        if let Some(p) = path {
271            assert!(p.ends_with("clean-dev-dirs/config.toml"));
272        }
273    }
274
275    #[test]
276    fn test_load_returns_defaults_when_no_file() {
277        let config = FileConfig::load().unwrap();
278        assert!(config.project_type.is_none());
279        assert!(config.dir.is_none());
280    }
281
282    #[test]
283    fn test_expand_tilde_with_home() {
284        let path = PathBuf::from("~/Projects");
285        let expanded = expand_tilde(&path);
286
287        if let Some(home) = dirs::home_dir() {
288            assert_eq!(expanded, home.join("Projects"));
289        }
290    }
291
292    #[test]
293    fn test_expand_tilde_absolute_path_unchanged() {
294        let path = PathBuf::from("/absolute/path");
295        let expanded = expand_tilde(&path);
296        assert_eq!(expanded, PathBuf::from("/absolute/path"));
297    }
298
299    #[test]
300    fn test_expand_tilde_relative_path_unchanged() {
301        let path = PathBuf::from("relative/path");
302        let expanded = expand_tilde(&path);
303        assert_eq!(expanded, PathBuf::from("relative/path"));
304    }
305
306    #[test]
307    fn test_expand_tilde_bare() {
308        let path = PathBuf::from("~");
309        let expanded = expand_tilde(&path);
310
311        if let Some(home) = dirs::home_dir() {
312            assert_eq!(expanded, home);
313        }
314    }
315}