Skip to main content

devclean/
config.rs

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/// User configuration loaded from `devclean.toml`.
13#[derive(Debug, Clone, Default, Deserialize)]
14#[serde(default, deny_unknown_fields)]
15pub struct Config {
16    /// Read-only discovery settings.
17    pub scan: ScanConfig,
18    /// Destructive cleanup settings.
19    pub clean: CleanConfig,
20    /// Read-only filesystem watcher settings.
21    pub watch: WatchConfig,
22    /// Declarative project-local scanner extensions.
23    pub rules: Vec<CustomRule>,
24}
25
26/// Settings for the read-only filesystem watcher.
27#[derive(Debug, Clone, Deserialize)]
28#[serde(default, deny_unknown_fields)]
29pub struct WatchConfig {
30    /// Notify when reclaimable artifacts reach this size.
31    pub threshold: String,
32    /// Minimum duration between event-triggered scans.
33    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/// Settings that affect candidate discovery.
46#[derive(Debug, Clone, Default, Deserialize)]
47#[serde(default, deny_unknown_fields)]
48pub struct ScanConfig {
49    /// Default roots when the CLI does not supply roots.
50    pub roots: Vec<PathBuf>,
51    /// Glob patterns excluded from traversal and cleanup.
52    pub exclude: Vec<String>,
53    /// Only include artifacts older than this duration, for example `30d`.
54    pub older_than: Option<String>,
55    /// Only include artifacts at least this large, for example `1GiB`.
56    pub min_size: Option<String>,
57    /// Maximum traversal depth.
58    pub max_depth: Option<usize>,
59}
60
61/// Settings that affect destructive cleanup.
62#[derive(Debug, Clone, Deserialize)]
63#[serde(default, deny_unknown_fields)]
64pub struct CleanConfig {
65    /// Refuse candidates that contain Git-tracked files.
66    pub protect_git_tracked: bool,
67    /// Include large model and runtime caches that are expensive to restore.
68    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/// Returns configuration locations in precedence order.
81#[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
94/// Loads an explicit configuration file or the first discovered default.
95///
96/// # Errors
97///
98/// Returns an error when an explicit file is missing, unreadable, or invalid.
99pub 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
148/// Parses a human duration such as `12h`, `30d`, or `3weeks`.
149///
150/// # Errors
151///
152/// Returns an error for an invalid duration.
153pub fn parse_age(value: &str) -> Result<Duration> {
154    humantime::parse_duration(value).with_context(|| format!("invalid duration `{value}`"))
155}
156
157/// Parses a human byte size such as `500MB` or `2GiB`.
158///
159/// # Errors
160///
161/// Returns an error for an invalid byte size.
162pub 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}