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
10/// User configuration loaded from `devclean.toml`.
11#[derive(Debug, Clone, Default, Deserialize)]
12#[serde(default, deny_unknown_fields)]
13pub struct Config {
14    /// Read-only discovery settings.
15    pub scan: ScanConfig,
16    /// Destructive cleanup settings.
17    pub clean: CleanConfig,
18}
19
20/// Settings that affect candidate discovery.
21#[derive(Debug, Clone, Default, Deserialize)]
22#[serde(default, deny_unknown_fields)]
23pub struct ScanConfig {
24    /// Default roots when the CLI does not supply roots.
25    pub roots: Vec<PathBuf>,
26    /// Glob patterns excluded from traversal and cleanup.
27    pub exclude: Vec<String>,
28    /// Only include artifacts older than this duration, for example `30d`.
29    pub older_than: Option<String>,
30    /// Only include artifacts at least this large, for example `1GiB`.
31    pub min_size: Option<String>,
32    /// Maximum traversal depth.
33    pub max_depth: Option<usize>,
34}
35
36/// Settings that affect destructive cleanup.
37#[derive(Debug, Clone, Deserialize)]
38#[serde(default, deny_unknown_fields)]
39pub struct CleanConfig {
40    /// Refuse candidates that contain Git-tracked files.
41    pub protect_git_tracked: bool,
42    /// Include large model and runtime caches that are expensive to restore.
43    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/// Returns configuration locations in precedence order.
56#[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
68/// Loads an explicit configuration file or the first discovered default.
69///
70/// # Errors
71///
72/// Returns an error when an explicit file is missing, unreadable, or invalid.
73pub 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
87/// Parses a human duration such as `12h`, `30d`, or `3weeks`.
88///
89/// # Errors
90///
91/// Returns an error for an invalid duration.
92pub fn parse_age(value: &str) -> Result<Duration> {
93    humantime::parse_duration(value).with_context(|| format!("invalid duration `{value}`"))
94}
95
96/// Parses a human byte size such as `500MB` or `2GiB`.
97///
98/// # Errors
99///
100/// Returns an error for an invalid byte size.
101pub 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}