use std::path::{Path, PathBuf};
use serde::Deserialize;
#[derive(Deserialize, Default, Debug)]
pub struct FileConfig {
pub project_type: Option<String>,
pub dirs: Option<Vec<PathBuf>>,
pub dir: Option<PathBuf>,
#[serde(default)]
pub filtering: FileFilterConfig,
#[serde(default)]
pub scanning: FileScanConfig,
#[serde(default)]
pub execution: FileExecutionConfig,
}
#[derive(Deserialize, Default, Debug)]
pub struct FileFilterConfig {
pub keep_size: Option<String>,
pub keep_days: Option<u32>,
pub sort: Option<String>,
pub reverse: Option<bool>,
pub name_pattern: Option<String>,
}
#[derive(Deserialize, Default, Debug)]
pub struct FileScanConfig {
pub threads: Option<usize>,
pub verbose: Option<bool>,
pub skip: Option<Vec<PathBuf>>,
pub ignore: Option<Vec<PathBuf>>,
pub max_depth: Option<usize>,
}
#[derive(Deserialize, Default, Debug)]
pub struct FileExecutionConfig {
pub keep_executables: Option<bool>,
pub interactive: Option<bool>,
pub dry_run: Option<bool>,
pub use_trash: Option<bool>,
}
#[must_use]
pub fn expand_tilde(path: &Path) -> PathBuf {
if let Ok(rest) = path.strip_prefix("~")
&& let Some(home) = dirs::home_dir()
{
return home.join(rest);
}
path.to_path_buf()
}
impl FileConfig {
#[must_use]
pub fn config_path() -> Option<PathBuf> {
dirs::config_dir().map(|p| p.join("clean-dev-dirs").join("config.toml"))
}
pub fn load() -> anyhow::Result<Self> {
let Some(path) = Self::config_path() else {
return Ok(Self::default());
};
if !path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(&path).map_err(|e| {
anyhow::anyhow!("Failed to read config file at {}: {e}", path.display())
})?;
let config: Self = toml::from_str(&content).map_err(|e| {
anyhow::anyhow!("Failed to parse config file at {}: {e}", path.display())
})?;
Ok(config)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_file_config() {
let config = FileConfig::default();
assert!(config.project_type.is_none());
assert!(config.dirs.is_none());
assert!(config.dir.is_none());
assert!(config.filtering.keep_size.is_none());
assert!(config.filtering.keep_days.is_none());
assert!(config.filtering.sort.is_none());
assert!(config.filtering.reverse.is_none());
assert!(config.filtering.name_pattern.is_none());
assert!(config.scanning.threads.is_none());
assert!(config.scanning.verbose.is_none());
assert!(config.scanning.skip.is_none());
assert!(config.scanning.ignore.is_none());
assert!(config.execution.keep_executables.is_none());
assert!(config.execution.interactive.is_none());
assert!(config.execution.dry_run.is_none());
assert!(config.execution.use_trash.is_none());
}
#[test]
fn test_parse_full_config() -> anyhow::Result<()> {
let toml_content = r#"
project_type = "rust"
dir = "~/Projects"
[filtering]
keep_size = "50MB"
keep_days = 7
sort = "size"
reverse = true
name_pattern = "my-*"
[scanning]
threads = 4
verbose = true
skip = [".cargo", "vendor"]
ignore = [".git"]
[execution]
keep_executables = true
interactive = false
dry_run = false
use_trash = true
"#;
let config: FileConfig = toml::from_str(toml_content)?;
assert_eq!(config.project_type, Some("rust".to_string()));
assert_eq!(config.dir, Some(PathBuf::from("~/Projects")));
assert_eq!(config.filtering.keep_size, Some("50MB".to_string()));
assert_eq!(config.filtering.keep_days, Some(7));
assert_eq!(config.filtering.sort, Some("size".to_string()));
assert_eq!(config.filtering.reverse, Some(true));
assert_eq!(config.filtering.name_pattern, Some("my-*".to_string()));
assert_eq!(config.scanning.threads, Some(4));
assert_eq!(config.scanning.verbose, Some(true));
assert_eq!(
config.scanning.skip,
Some(vec![PathBuf::from(".cargo"), PathBuf::from("vendor")])
);
assert_eq!(config.scanning.ignore, Some(vec![PathBuf::from(".git")]));
assert_eq!(config.execution.keep_executables, Some(true));
assert_eq!(config.execution.interactive, Some(false));
assert_eq!(config.execution.dry_run, Some(false));
assert_eq!(config.execution.use_trash, Some(true));
Ok(())
}
#[test]
fn test_parse_dirs_field() -> anyhow::Result<()> {
let toml_content = r#"dirs = ["~/Projects", "~/work"]"#;
let config: FileConfig = toml::from_str(toml_content)?;
assert_eq!(
config.dirs,
Some(vec![PathBuf::from("~/Projects"), PathBuf::from("~/work")])
);
assert!(config.dir.is_none());
Ok(())
}
#[test]
fn test_parse_partial_config() -> anyhow::Result<()> {
let toml_content = r#"
[filtering]
keep_size = "100MB"
"#;
let config: FileConfig = toml::from_str(toml_content)?;
assert!(config.project_type.is_none());
assert!(config.dir.is_none());
assert_eq!(config.filtering.keep_size, Some("100MB".to_string()));
assert!(config.filtering.keep_days.is_none());
assert!(config.filtering.sort.is_none());
assert!(config.filtering.reverse.is_none());
assert!(config.scanning.threads.is_none());
Ok(())
}
#[test]
fn test_parse_empty_config() -> anyhow::Result<()> {
let toml_content = "";
let config: FileConfig = toml::from_str(toml_content)?;
assert!(config.project_type.is_none());
assert!(config.dir.is_none());
Ok(())
}
#[test]
fn test_malformed_config_errors() {
let toml_content = r#"
[filtering]
keep_days = "not_a_number"
"#;
let result = toml::from_str::<FileConfig>(toml_content);
assert!(result.is_err());
}
#[test]
fn test_config_path_returns_expected_suffix() {
let path = FileConfig::config_path();
if let Some(p) = path {
assert!(p.ends_with("clean-dev-dirs/config.toml"));
}
}
#[test]
fn test_load_returns_defaults_when_no_file() -> anyhow::Result<()> {
let config = FileConfig::load()?;
assert!(config.project_type.is_none());
assert!(config.dir.is_none());
Ok(())
}
#[test]
fn test_expand_tilde_with_home() {
let path = PathBuf::from("~/Projects");
let expanded = expand_tilde(&path);
if let Some(home) = dirs::home_dir() {
assert_eq!(expanded, home.join("Projects"));
}
}
#[test]
fn test_expand_tilde_absolute_path_unchanged() {
let path = PathBuf::from("/absolute/path");
let expanded = expand_tilde(&path);
assert_eq!(expanded, PathBuf::from("/absolute/path"));
}
#[test]
fn test_expand_tilde_relative_path_unchanged() {
let path = PathBuf::from("relative/path");
let expanded = expand_tilde(&path);
assert_eq!(expanded, PathBuf::from("relative/path"));
}
#[test]
fn test_expand_tilde_bare() {
let path = PathBuf::from("~");
let expanded = expand_tilde(&path);
if let Some(home) = dirs::home_dir() {
assert_eq!(expanded, home);
}
}
#[test]
fn test_config_path_is_platform_appropriate() {
let path = FileConfig::config_path();
if let Some(p) = &path {
let path_str = p.to_string_lossy();
#[cfg(target_os = "linux")]
assert!(
path_str.contains(".config"),
"Linux config path should be under $XDG_CONFIG_HOME or ~/.config, got: {path_str}"
);
#[cfg(target_os = "macos")]
assert!(
path_str.contains("Application Support") || path_str.contains(".config"),
"macOS config path should be under Library/Application Support, got: {path_str}"
);
#[cfg(target_os = "windows")]
assert!(
path_str.contains("AppData"),
"Windows config path should be under AppData, got: {path_str}"
);
assert!(
p.ends_with("clean-dev-dirs/config.toml")
|| p.ends_with(Path::new("clean-dev-dirs").join("config.toml"))
);
}
}
#[test]
fn test_config_path_parent_exists_or_can_be_created() {
if let Some(path) = FileConfig::config_path()
&& let Some(grandparent) = path.parent().and_then(Path::parent)
{
assert!(
grandparent.exists(),
"Config grandparent directory should exist: {}",
grandparent.display()
);
}
}
#[test]
fn test_expand_tilde_deeply_nested() {
let path = PathBuf::from("~/a/b/c/d");
let expanded = expand_tilde(&path);
if let Some(home) = dirs::home_dir() {
assert_eq!(expanded, home.join("a").join("b").join("c").join("d"));
assert!(!expanded.to_string_lossy().contains('~'));
}
}
#[test]
fn test_expand_tilde_no_effect_on_non_tilde() {
let relative = PathBuf::from("some/relative/path");
assert_eq!(expand_tilde(&relative), relative);
let absolute = PathBuf::from("/usr/local/bin");
assert_eq!(expand_tilde(&absolute), absolute);
#[cfg(windows)]
{
let win_abs = PathBuf::from(r"C:\Users\user\Documents");
assert_eq!(expand_tilde(&win_abs), win_abs);
}
}
#[test]
fn test_config_toml_parsing_with_platform_paths() -> anyhow::Result<()> {
let toml_unix = "dir = \"/home/user/projects\"\n";
let config: FileConfig = toml::from_str(toml_unix)?;
assert_eq!(config.dir, Some(PathBuf::from("/home/user/projects")));
let toml_tilde = "dir = \"~/Projects\"\n";
let config: FileConfig = toml::from_str(toml_tilde)?;
assert_eq!(config.dir, Some(PathBuf::from("~/Projects")));
let toml_relative = "dir = \"./projects\"\n";
let config: FileConfig = toml::from_str(toml_relative)?;
assert_eq!(config.dir, Some(PathBuf::from("./projects")));
Ok(())
}
#[test]
fn test_file_config_all_execution_options_parse() -> anyhow::Result<()> {
let toml_content = r"
[execution]
keep_executables = true
interactive = false
dry_run = true
use_trash = false
";
let config: FileConfig = toml::from_str(toml_content)?;
assert_eq!(config.execution.keep_executables, Some(true));
assert_eq!(config.execution.interactive, Some(false));
assert_eq!(config.execution.dry_run, Some(true));
assert_eq!(config.execution.use_trash, Some(false));
Ok(())
}
}