use anyhow::{Context, Result};
use carwash_core::clean::DeleteMode;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub scan: ScanConfig,
pub clean: CleanConfig,
pub updates: UpdatesConfig,
pub ui: UiConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct UpdatesConfig {
pub cache_hours: u64,
pub vulnerabilities: bool,
}
impl Default for UpdatesConfig {
fn default() -> Self {
Self {
cache_hours: 6,
vulnerabilities: true,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct ScanConfig {
pub exclude: Vec<String>,
pub default_excludes: bool,
pub threads: usize,
pub include_hidden: bool,
pub same_filesystem: bool,
pub max_depth: Option<usize>,
}
impl Default for ScanConfig {
fn default() -> Self {
Self {
exclude: Vec::new(),
default_excludes: true,
threads: 0,
include_hidden: false,
same_filesystem: true,
max_depth: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct CleanConfig {
pub mode: DeleteMode,
pub recent_days: u64,
}
impl Default for CleanConfig {
fn default() -> Self {
Self {
mode: DeleteMode::Permanent,
recent_days: 7,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct UiConfig {
pub theme: String,
pub icons: String,
}
impl Default for UiConfig {
fn default() -> Self {
Self {
theme: "gestalt".into(),
icons: "unicode".into(),
}
}
}
const HOME_EXCLUDES: &[&str] = &[
"Library",
"Applications",
"Pictures",
"Music",
"Movies",
"go/pkg/mod",
"snap",
];
impl Config {
pub fn load(path: &Path) -> Result<Self> {
match std::fs::read_to_string(path) {
Ok(text) if is_legacy(&text) => {
anstream::eprintln!(
"warning: ignoring {}: it was written by carwash 0.3. Delete it, or see the README for the new format.",
path.display()
);
Ok(Self::default())
}
Ok(text) => toml::from_str(&text)
.with_context(|| format!("invalid configuration in {}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(e).with_context(|| format!("cannot read {}", path.display())),
}
}
pub fn excludes(&self) -> Vec<PathBuf> {
let home = carwash_core::paths::home();
let mut out: Vec<PathBuf> = self
.scan
.exclude
.iter()
.map(|raw| expand_home(raw, home.as_deref()))
.collect();
if self.scan.default_excludes
&& let Some(home) = &home
{
out.extend(HOME_EXCLUDES.iter().map(|rel| home.join(rel)));
}
out
}
}
fn is_legacy(text: &str) -> bool {
const LEGACY: &[&str] = &["theme", "layout", "keybindings", "progress", "app"];
const CURRENT: &[&str] = &["scan", "clean", "updates", "ui"];
let Ok(table) = text.parse::<toml::Table>() else {
return false;
};
table.keys().any(|k| LEGACY.contains(&k.as_str()))
&& !table.keys().any(|k| CURRENT.contains(&k.as_str()))
}
pub fn expand_home(raw: &str, home: Option<&Path>) -> PathBuf {
match (raw.strip_prefix("~/").or((raw == "~").then_some("")), home) {
(Some(rest), Some(home)) => home.join(rest),
_ => PathBuf::from(raw),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_and_partial_files() {
let config: Config = toml::from_str("[clean]\nrecent_days = 3\n").unwrap();
assert_eq!(config.clean.recent_days, 3);
assert_eq!(config.clean.mode, DeleteMode::Permanent);
assert!(config.scan.same_filesystem);
}
#[test]
fn unknown_keys_are_errors() {
assert!(toml::from_str::<Config>("[scan]\nexclud = []\n").is_err());
}
#[test]
fn version_0_3_files_are_recognised() {
let legacy = "[theme]\ncurrent = \"dark\"\n[layout]\nleft_pane_percent = 30\n[app]\ncache_ttl_minutes = 5\n";
assert!(is_legacy(legacy));
assert!(!is_legacy("[ui]\ntheme = \"nord\"\n"));
assert!(!is_legacy("[layout]\nx = 1\n[scan]\nexclude = []\n"));
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, legacy).unwrap();
assert_eq!(Config::load(&path).unwrap().clean.recent_days, 7);
}
#[test]
fn home_expansion() {
let home = Path::new("/home/me");
assert_eq!(expand_home("~/x", Some(home)), PathBuf::from("/home/me/x"));
assert_eq!(expand_home("~", Some(home)), PathBuf::from("/home/me"));
assert_eq!(expand_home("/abs", Some(home)), PathBuf::from("/abs"));
}
}