use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result};
use directories::BaseDirs;
use serde::Deserialize;
use crate::model::CustomRule;
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub scan: ScanConfig,
pub clean: CleanConfig,
pub watch: WatchConfig,
pub rules: Vec<CustomRule>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct WatchConfig {
pub threshold: String,
pub interval: String,
}
impl Default for WatchConfig {
fn default() -> Self {
Self {
threshold: "5GiB".to_owned(),
interval: "1h".to_owned(),
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ScanConfig {
pub roots: Vec<PathBuf>,
pub exclude: Vec<String>,
pub older_than: Option<String>,
pub min_size: Option<String>,
pub max_depth: Option<usize>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CleanConfig {
pub protect_git_tracked: bool,
pub expensive_caches: bool,
}
impl Default for CleanConfig {
fn default() -> Self {
Self {
protect_git_tracked: true,
expensive_caches: false,
}
}
}
#[must_use]
pub fn config_candidates() -> Vec<PathBuf> {
let mut candidates = Vec::new();
if let Ok(current) = env::current_dir() {
candidates.push(current.join(".devclean.toml"));
candidates.push(current.join("devclean.toml"));
}
if let Some(base) = BaseDirs::new() {
candidates.push(base.config_dir().join("devclean/config.toml"));
}
candidates
}
pub fn load_config(explicit: Option<&Path>) -> Result<Config> {
let selected = if let Some(path) = explicit {
Some(path.to_path_buf())
} else {
config_candidates().into_iter().find(|path| path.is_file())
};
let Some(path) = selected else {
return Ok(Config::default());
};
let content = fs::read_to_string(&path)
.with_context(|| format!("failed to read config {}", path.display()))?;
let config: Config =
toml::from_str(&content).with_context(|| format!("invalid config {}", path.display()))?;
validate_custom_rules(&config.rules)?;
Ok(config)
}
fn validate_custom_rules(rules: &[CustomRule]) -> Result<()> {
for rule in rules {
anyhow::ensure!(
!rule.name.trim().is_empty(),
"custom rule name cannot be empty"
);
anyhow::ensure!(
!rule.directory_names.is_empty(),
"custom rule `{}` needs at least one directory name",
rule.name
);
anyhow::ensure!(
!rule.required_markers.is_empty(),
"custom rule `{}` needs at least one direct marker",
rule.name
);
for value in rule.directory_names.iter().chain(&rule.required_markers) {
let candidate = Path::new(value);
anyhow::ensure!(
candidate.components().count() == 1
&& matches!(
candidate.components().next(),
Some(std::path::Component::Normal(_))
),
"custom rule `{}` contains unsafe name `{value}`",
rule.name
);
}
}
Ok(())
}
pub fn parse_age(value: &str) -> Result<Duration> {
humantime::parse_duration(value).with_context(|| format!("invalid duration `{value}`"))
}
pub fn parse_bytes(value: &str) -> Result<u64> {
parse_size::parse_size(value).with_context(|| format!("invalid byte size `{value}`"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_age_should_accept_days() -> Result<()> {
assert_eq!(parse_age("30d")?, Duration::from_secs(30 * 86_400));
Ok(())
}
#[test]
fn parse_bytes_should_accept_binary_units() -> Result<()> {
assert_eq!(parse_bytes("2GiB")?, 2 * 1024 * 1024 * 1024);
Ok(())
}
#[test]
fn custom_rules_should_require_exact_names_and_direct_markers() {
let invalid = CustomRule {
name: "unsafe".to_owned(),
category: crate::model::Category::BuildOutput,
directory_names: vec!["../dist".to_owned()],
required_markers: vec!["package.json".to_owned()],
reason: "generated output".to_owned(),
};
assert!(validate_custom_rules(&[invalid]).is_err());
}
}