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
10use crate::model::CustomRule;
11
12#[derive(Debug, Clone, Default, Deserialize)]
14#[serde(default, deny_unknown_fields)]
15pub struct Config {
16 pub scan: ScanConfig,
18 pub clean: CleanConfig,
20 pub watch: WatchConfig,
22 pub rules: Vec<CustomRule>,
24}
25
26#[derive(Debug, Clone, Deserialize)]
28#[serde(default, deny_unknown_fields)]
29pub struct WatchConfig {
30 pub threshold: String,
32 pub interval: String,
34}
35
36impl Default for WatchConfig {
37 fn default() -> Self {
38 Self {
39 threshold: "5GiB".to_owned(),
40 interval: "1h".to_owned(),
41 }
42 }
43}
44
45#[derive(Debug, Clone, Default, Deserialize)]
47#[serde(default, deny_unknown_fields)]
48pub struct ScanConfig {
49 pub roots: Vec<PathBuf>,
51 pub exclude: Vec<String>,
53 pub older_than: Option<String>,
55 pub min_size: Option<String>,
57 pub max_depth: Option<usize>,
59}
60
61#[derive(Debug, Clone, Deserialize)]
63#[serde(default, deny_unknown_fields)]
64pub struct CleanConfig {
65 pub protect_git_tracked: bool,
67 pub expensive_caches: bool,
69}
70
71impl Default for CleanConfig {
72 fn default() -> Self {
73 Self {
74 protect_git_tracked: true,
75 expensive_caches: false,
76 }
77 }
78}
79
80#[must_use]
82pub fn config_candidates() -> Vec<PathBuf> {
83 let mut candidates = Vec::new();
84 if let Ok(current) = env::current_dir() {
85 candidates.push(current.join(".devclean.toml"));
86 candidates.push(current.join("devclean.toml"));
87 }
88 if let Some(base) = BaseDirs::new() {
89 candidates.push(base.config_dir().join("devclean/config.toml"));
90 }
91 candidates
92}
93
94pub fn load_config(explicit: Option<&Path>) -> Result<Config> {
100 let selected = if let Some(path) = explicit {
101 Some(path.to_path_buf())
102 } else {
103 config_candidates().into_iter().find(|path| path.is_file())
104 };
105 let Some(path) = selected else {
106 return Ok(Config::default());
107 };
108 let content = fs::read_to_string(&path)
109 .with_context(|| format!("failed to read config {}", path.display()))?;
110 let config: Config =
111 toml::from_str(&content).with_context(|| format!("invalid config {}", path.display()))?;
112 validate_custom_rules(&config.rules)?;
113 Ok(config)
114}
115
116fn validate_custom_rules(rules: &[CustomRule]) -> Result<()> {
117 for rule in rules {
118 anyhow::ensure!(
119 !rule.name.trim().is_empty(),
120 "custom rule name cannot be empty"
121 );
122 anyhow::ensure!(
123 !rule.directory_names.is_empty(),
124 "custom rule `{}` needs at least one directory name",
125 rule.name
126 );
127 anyhow::ensure!(
128 !rule.required_markers.is_empty(),
129 "custom rule `{}` needs at least one direct marker",
130 rule.name
131 );
132 for value in rule.directory_names.iter().chain(&rule.required_markers) {
133 let candidate = Path::new(value);
134 anyhow::ensure!(
135 candidate.components().count() == 1
136 && matches!(
137 candidate.components().next(),
138 Some(std::path::Component::Normal(_))
139 ),
140 "custom rule `{}` contains unsafe name `{value}`",
141 rule.name
142 );
143 }
144 }
145 Ok(())
146}
147
148pub fn parse_age(value: &str) -> Result<Duration> {
154 humantime::parse_duration(value).with_context(|| format!("invalid duration `{value}`"))
155}
156
157pub fn parse_bytes(value: &str) -> Result<u64> {
163 parse_size::parse_size(value).with_context(|| format!("invalid byte size `{value}`"))
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn parse_age_should_accept_days() -> Result<()> {
172 assert_eq!(parse_age("30d")?, Duration::from_secs(30 * 86_400));
173 Ok(())
174 }
175
176 #[test]
177 fn parse_bytes_should_accept_binary_units() -> Result<()> {
178 assert_eq!(parse_bytes("2GiB")?, 2 * 1024 * 1024 * 1024);
179 Ok(())
180 }
181
182 #[test]
183 fn custom_rules_should_require_exact_names_and_direct_markers() {
184 let invalid = CustomRule {
185 name: "unsafe".to_owned(),
186 category: crate::model::Category::BuildOutput,
187 directory_names: vec!["../dist".to_owned()],
188 required_markers: vec!["package.json".to_owned()],
189 reason: "generated output".to_owned(),
190 };
191
192 assert!(validate_custom_rules(&[invalid]).is_err());
193 }
194}