clean_dev_dirs/config/
file.rs1use std::path::{Path, PathBuf};
43
44use serde::Deserialize;
45
46#[derive(Deserialize, Default, Debug)]
51pub struct FileConfig {
52 pub project_type: Option<String>,
54
55 pub dirs: Option<Vec<PathBuf>>,
57
58 pub dir: Option<PathBuf>,
60
61 #[serde(default)]
63 pub filtering: FileFilterConfig,
64
65 #[serde(default)]
67 pub scanning: FileScanConfig,
68
69 #[serde(default)]
71 pub execution: FileExecutionConfig,
72}
73
74#[derive(Deserialize, Default, Debug)]
76pub struct FileFilterConfig {
77 pub keep_size: Option<String>,
79
80 pub keep_days: Option<u32>,
82
83 pub sort: Option<String>,
85
86 pub reverse: Option<bool>,
88
89 pub name_pattern: Option<String>,
91}
92
93#[derive(Deserialize, Default, Debug)]
95pub struct FileScanConfig {
96 pub threads: Option<usize>,
98
99 pub verbose: Option<bool>,
101
102 pub skip: Option<Vec<PathBuf>>,
104
105 pub ignore: Option<Vec<PathBuf>>,
107
108 pub max_depth: Option<usize>,
110}
111
112#[derive(Deserialize, Default, Debug)]
114pub struct FileExecutionConfig {
115 pub keep_executables: Option<bool>,
117
118 pub interactive: Option<bool>,
120
121 pub dry_run: Option<bool>,
123
124 pub use_trash: Option<bool>,
127}
128
129#[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 #[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 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 #[test]
383 fn test_config_path_is_platform_appropriate() {
384 let path = FileConfig::config_path();
385
386 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 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 if let Some(path) = FileConfig::config_path()
422 && let Some(grandparent) = path.parent().and_then(Path::parent)
423 {
424 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 let relative = PathBuf::from("some/relative/path");
448 assert_eq!(expand_tilde(&relative), relative);
449
450 let absolute = PathBuf::from("/usr/local/bin");
452 assert_eq!(expand_tilde(&absolute), absolute);
453
454 #[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}