clean_dev_dirs/config/
file.rs1use std::path::{Path, PathBuf};
38
39use serde::Deserialize;
40
41#[derive(Deserialize, Default, Debug)]
46pub struct FileConfig {
47 pub project_type: Option<String>,
49
50 pub dir: Option<PathBuf>,
52
53 #[serde(default)]
55 pub filtering: FileFilterConfig,
56
57 #[serde(default)]
59 pub scanning: FileScanConfig,
60
61 #[serde(default)]
63 pub execution: FileExecutionConfig,
64}
65
66#[derive(Deserialize, Default, Debug)]
68pub struct FileFilterConfig {
69 pub keep_size: Option<String>,
71
72 pub keep_days: Option<u32>,
74
75 pub sort: Option<String>,
77
78 pub reverse: Option<bool>,
80}
81
82#[derive(Deserialize, Default, Debug)]
84pub struct FileScanConfig {
85 pub threads: Option<usize>,
87
88 pub verbose: Option<bool>,
90
91 pub skip: Option<Vec<PathBuf>>,
93
94 pub ignore: Option<Vec<PathBuf>>,
96}
97
98#[derive(Deserialize, Default, Debug)]
100pub struct FileExecutionConfig {
101 pub keep_executables: Option<bool>,
103
104 pub interactive: Option<bool>,
106
107 pub dry_run: Option<bool>,
109
110 pub use_trash: Option<bool>,
113}
114
115#[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 #[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 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 #[test]
343 fn test_config_path_is_platform_appropriate() {
344 let path = FileConfig::config_path();
345
346 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 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 if let Some(path) = FileConfig::config_path()
382 && let Some(grandparent) = path.parent().and_then(Path::parent)
383 {
384 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 let relative = PathBuf::from("some/relative/path");
408 assert_eq!(expand_tilde(&relative), relative);
409
410 let absolute = PathBuf::from("/usr/local/bin");
412 assert_eq!(expand_tilde(&absolute), absolute);
413
414 #[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 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}