1use std::env;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6use anyhow::{Context, Result};
7use directories::BaseDirs;
8use serde::Deserialize;
9
10#[derive(Debug, Clone, Default, Deserialize)]
12#[serde(default, deny_unknown_fields)]
13pub struct Config {
14 pub scan: ScanConfig,
16 pub clean: CleanConfig,
18}
19
20#[derive(Debug, Clone, Default, Deserialize)]
22#[serde(default, deny_unknown_fields)]
23pub struct ScanConfig {
24 pub roots: Vec<PathBuf>,
26 pub exclude: Vec<String>,
28 pub older_than: Option<String>,
30 pub min_size: Option<String>,
32 pub max_depth: Option<usize>,
34}
35
36#[derive(Debug, Clone, Deserialize)]
38#[serde(default, deny_unknown_fields)]
39pub struct CleanConfig {
40 pub protect_git_tracked: bool,
42 pub expensive_caches: bool,
44}
45
46impl Default for CleanConfig {
47 fn default() -> Self {
48 Self {
49 protect_git_tracked: true,
50 expensive_caches: false,
51 }
52 }
53}
54
55#[must_use]
57pub fn config_candidates() -> Vec<PathBuf> {
58 let mut candidates = Vec::new();
59 if let Ok(current) = env::current_dir() {
60 candidates.push(current.join("devclean.toml"));
61 }
62 if let Some(base) = BaseDirs::new() {
63 candidates.push(base.config_dir().join("devclean/config.toml"));
64 }
65 candidates
66}
67
68pub fn load_config(explicit: Option<&Path>) -> Result<Config> {
74 let selected = if let Some(path) = explicit {
75 Some(path.to_path_buf())
76 } else {
77 config_candidates().into_iter().find(|path| path.is_file())
78 };
79 let Some(path) = selected else {
80 return Ok(Config::default());
81 };
82 let content = fs::read_to_string(&path)
83 .with_context(|| format!("failed to read config {}", path.display()))?;
84 toml::from_str(&content).with_context(|| format!("invalid config {}", path.display()))
85}
86
87pub fn parse_age(value: &str) -> Result<Duration> {
93 humantime::parse_duration(value).with_context(|| format!("invalid duration `{value}`"))
94}
95
96pub fn parse_bytes(value: &str) -> Result<u64> {
102 parse_size::parse_size(value).with_context(|| format!("invalid byte size `{value}`"))
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 #[test]
110 fn parse_age_should_accept_days() -> Result<()> {
111 assert_eq!(parse_age("30d")?, Duration::from_secs(30 * 86_400));
112 Ok(())
113 }
114
115 #[test]
116 fn parse_bytes_should_accept_binary_units() -> Result<()> {
117 assert_eq!(parse_bytes("2GiB")?, 2 * 1024 * 1024 * 1024);
118 Ok(())
119 }
120}