use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::fs;
use crate::cli::{SortBy, Color};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub search: SearchConfig,
#[serde(default)]
pub output: OutputConfig,
#[serde(default)]
pub behavior: BehaviorConfig,
#[serde(default)]
pub safety: SafetyConfig,
#[serde(default)]
pub performance: PerformanceConfig,
#[serde(default)]
pub filters: FiltersConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchConfig {
pub directory: Option<PathBuf>,
pub full: Option<bool>,
pub target: Option<String>,
pub exclude: Option<Vec<String>>,
pub exclude_hidden: Option<bool>,
pub include_cargo_cache: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputConfig {
pub sort: Option<SortBy>,
pub gb: Option<bool>,
pub color: Option<Color>,
pub hide_errors: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BehaviorConfig {
pub dry_run: Option<bool>,
pub delete_all: Option<bool>,
pub list_only: Option<bool>,
pub no_check_update: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SafetyConfig {
pub confirm_large_deletions: Option<bool>,
pub large_deletion_threshold: Option<String>,
pub backup_before_delete: Option<bool>,
pub max_concurrent_deletions: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PerformanceConfig {
pub enable_parallel_scanning: Option<bool>,
pub max_parallel_threads: Option<u32>,
pub chunk_size: Option<u32>,
pub enable_parallel_cleanup: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FiltersConfig {
pub min_age_days: Option<u32>,
pub max_age_days: Option<u32>,
pub min_size: Option<String>,
pub ignore_active_projects: Option<bool>,
pub active_threshold_hours: Option<u32>,
}
impl Default for SearchConfig {
fn default() -> Self {
Self {
directory: None,
full: None,
target: None,
exclude: None,
exclude_hidden: None,
include_cargo_cache: None,
}
}
}
impl Default for OutputConfig {
fn default() -> Self {
Self {
sort: None,
gb: None,
color: None,
hide_errors: None,
}
}
}
impl Default for BehaviorConfig {
fn default() -> Self {
Self {
dry_run: None,
delete_all: None,
list_only: None,
no_check_update: None,
}
}
}
impl Default for SafetyConfig {
fn default() -> Self {
Self {
confirm_large_deletions: None,
large_deletion_threshold: None,
backup_before_delete: None,
max_concurrent_deletions: None,
}
}
}
impl Default for PerformanceConfig {
fn default() -> Self {
Self {
enable_parallel_scanning: None,
max_parallel_threads: None,
chunk_size: None,
enable_parallel_cleanup: None,
}
}
}
impl Default for FiltersConfig {
fn default() -> Self {
Self {
min_age_days: None,
max_age_days: None,
min_size: None,
ignore_active_projects: None,
active_threshold_hours: None,
}
}
}
pub struct ConfigLoader {
pub search_paths: Vec<PathBuf>,
}
impl ConfigLoader {
pub fn new() -> Self {
let mut search_paths = Vec::new();
search_paths.push(PathBuf::from("./rskill.toml"));
search_paths.push(PathBuf::from("./.rskillrc"));
if let Some(config_dir) = dirs::config_dir() {
search_paths.push(config_dir.join("rskiller").join("rskill.toml"));
}
if let Some(home_dir) = home::home_dir() {
search_paths.push(home_dir.join("rskill.toml"));
search_paths.push(home_dir.join(".rskillrc"));
}
Self { search_paths }
}
pub fn with_paths(paths: Vec<PathBuf>) -> Self {
Self { search_paths: paths }
}
pub fn load_config(&self) -> Result<Config> {
for path in &self.search_paths {
if path.exists() {
return self.load_from_file(path)
.with_context(|| format!("Failed to load config from {}", path.display()));
}
}
Ok(Config::default())
}
pub fn load_from_file(&self, path: &Path) -> Result<Config> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
match path.extension().and_then(|ext| ext.to_str()) {
Some("toml") => {
toml::from_str(&content)
.with_context(|| format!("Failed to parse TOML config: {}", path.display()))
}
_ => {
serde_json::from_str(&content)
.with_context(|| format!("Failed to parse JSON config: {}", path.display()))
}
}
}
pub fn load_merged_config(&self) -> Result<Config> {
let mut final_config = Config::default();
for path in self.search_paths.iter().rev() {
if path.exists() {
let config = self.load_from_file(path)?;
final_config = self.merge_configs(final_config, config);
}
}
Ok(final_config)
}
pub fn merge_configs(&self, base: Config, override_config: Config) -> Config {
Config {
search: SearchConfig {
directory: override_config.search.directory.or(base.search.directory),
full: override_config.search.full.or(base.search.full),
target: override_config.search.target.or(base.search.target),
exclude: override_config.search.exclude.or(base.search.exclude),
exclude_hidden: override_config.search.exclude_hidden.or(base.search.exclude_hidden),
include_cargo_cache: override_config.search.include_cargo_cache.or(base.search.include_cargo_cache),
},
output: OutputConfig {
sort: override_config.output.sort.or(base.output.sort),
gb: override_config.output.gb.or(base.output.gb),
color: override_config.output.color.or(base.output.color),
hide_errors: override_config.output.hide_errors.or(base.output.hide_errors),
},
behavior: BehaviorConfig {
dry_run: override_config.behavior.dry_run.or(base.behavior.dry_run),
delete_all: override_config.behavior.delete_all.or(base.behavior.delete_all),
list_only: override_config.behavior.list_only.or(base.behavior.list_only),
no_check_update: override_config.behavior.no_check_update.or(base.behavior.no_check_update),
},
safety: SafetyConfig {
confirm_large_deletions: override_config.safety.confirm_large_deletions.or(base.safety.confirm_large_deletions),
large_deletion_threshold: override_config.safety.large_deletion_threshold.or(base.safety.large_deletion_threshold),
backup_before_delete: override_config.safety.backup_before_delete.or(base.safety.backup_before_delete),
max_concurrent_deletions: override_config.safety.max_concurrent_deletions.or(base.safety.max_concurrent_deletions),
},
performance: PerformanceConfig {
enable_parallel_scanning: override_config.performance.enable_parallel_scanning.or(base.performance.enable_parallel_scanning),
max_parallel_threads: override_config.performance.max_parallel_threads.or(base.performance.max_parallel_threads),
chunk_size: override_config.performance.chunk_size.or(base.performance.chunk_size),
enable_parallel_cleanup: override_config.performance.enable_parallel_cleanup.or(base.performance.enable_parallel_cleanup),
},
filters: FiltersConfig {
min_age_days: override_config.filters.min_age_days.or(base.filters.min_age_days),
max_age_days: override_config.filters.max_age_days.or(base.filters.max_age_days),
min_size: override_config.filters.min_size.or(base.filters.min_size),
ignore_active_projects: override_config.filters.ignore_active_projects.or(base.filters.ignore_active_projects),
active_threshold_hours: override_config.filters.active_threshold_hours.or(base.filters.active_threshold_hours),
},
}
}
pub fn generate_sample_config(format: ConfigFormat) -> Result<String> {
let sample_config = Config {
search: SearchConfig {
directory: Some(PathBuf::from("~/")),
full: Some(false),
target: Some("target".to_string()),
exclude: Some(vec!["node_modules".to_string(), "vendor".to_string(), ".git".to_string()]),
exclude_hidden: Some(true),
include_cargo_cache: Some(false),
},
output: OutputConfig {
sort: Some(SortBy::Size),
gb: Some(false),
color: Some(Color::Blue),
hide_errors: Some(false),
},
behavior: BehaviorConfig {
dry_run: Some(false),
delete_all: Some(false),
list_only: Some(false),
no_check_update: Some(false),
},
safety: SafetyConfig {
confirm_large_deletions: Some(true),
large_deletion_threshold: Some("1GB".to_string()),
backup_before_delete: Some(false),
max_concurrent_deletions: Some(3),
},
performance: PerformanceConfig {
enable_parallel_scanning: Some(true),
max_parallel_threads: Some(0), chunk_size: Some(1000),
enable_parallel_cleanup: Some(true),
},
filters: FiltersConfig {
min_age_days: Some(0),
max_age_days: Some(365),
min_size: Some("1MB".to_string()),
ignore_active_projects: Some(true),
active_threshold_hours: Some(24),
},
};
match format {
ConfigFormat::Toml => {
toml::to_string_pretty(&sample_config)
.context("Failed to serialize sample TOML config")
}
ConfigFormat::Json => {
serde_json::to_string_pretty(&sample_config)
.context("Failed to serialize sample JSON config")
}
}
}
}
impl Default for ConfigLoader {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy)]
pub enum ConfigFormat {
Toml,
Json,
}
impl std::str::FromStr for ConfigFormat {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self> {
match s.to_lowercase().as_str() {
"toml" => Ok(ConfigFormat::Toml),
"json" => Ok(ConfigFormat::Json),
_ => Err(anyhow::anyhow!("Invalid config format. Supported formats: toml, json")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
use std::fs::write;
#[test]
fn test_load_toml_config() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("rskill.toml");
let toml_content = r#"
[search]
directory = "/test/path"
full = true
target = "build"
[output]
sort = "Path"
gb = true
"#;
write(&config_path, toml_content).unwrap();
let loader = ConfigLoader::with_paths(vec![config_path]);
let config = loader.load_config().unwrap();
assert_eq!(config.search.directory, Some(PathBuf::from("/test/path")));
assert_eq!(config.search.full, Some(true));
assert_eq!(config.search.target, Some("build".to_string()));
assert_eq!(config.output.sort, Some(SortBy::Path));
assert_eq!(config.output.gb, Some(true));
}
#[test]
fn test_load_json_config() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rskillrc");
let json_content = r#"
{
"search": {
"directory": "/test/path",
"full": false,
"exclude": ["node_modules", "target"]
},
"output": {
"color": "Red"
}
}
"#;
write(&config_path, json_content).unwrap();
let loader = ConfigLoader::with_paths(vec![config_path]);
let config = loader.load_config().unwrap();
assert_eq!(config.search.directory, Some(PathBuf::from("/test/path")));
assert_eq!(config.search.full, Some(false));
assert_eq!(config.search.exclude, Some(vec!["node_modules".to_string(), "target".to_string()]));
assert_eq!(config.output.color, Some(Color::Red));
}
#[test]
fn test_config_merging() {
let loader = ConfigLoader::new();
let base_config = Config {
search: SearchConfig {
directory: Some(PathBuf::from("/base")),
full: Some(false),
target: Some("target".to_string()),
exclude: None,
exclude_hidden: None,
include_cargo_cache: None,
},
output: OutputConfig {
sort: Some(SortBy::Size),
gb: Some(false),
color: Some(Color::Blue),
hide_errors: None,
},
..Default::default()
};
let override_config = Config {
search: SearchConfig {
directory: Some(PathBuf::from("/override")),
full: Some(true),
target: None, exclude: Some(vec!["test".to_string()]),
exclude_hidden: None,
include_cargo_cache: None,
},
output: OutputConfig {
sort: None, gb: Some(true),
color: None, hide_errors: Some(true),
},
..Default::default()
};
let merged = loader.merge_configs(base_config, override_config);
assert_eq!(merged.search.directory, Some(PathBuf::from("/override")));
assert_eq!(merged.search.full, Some(true));
assert_eq!(merged.search.exclude, Some(vec!["test".to_string()]));
assert_eq!(merged.output.gb, Some(true));
assert_eq!(merged.output.hide_errors, Some(true));
assert_eq!(merged.search.target, Some("target".to_string()));
assert_eq!(merged.output.sort, Some(SortBy::Size));
assert_eq!(merged.output.color, Some(Color::Blue));
}
#[test]
fn test_default_config() {
let loader = ConfigLoader::with_paths(vec![]);
let config = loader.load_config().unwrap();
assert_eq!(config.search.directory, None);
assert_eq!(config.search.full, None);
assert_eq!(config.output.sort, None);
}
#[test]
fn test_sample_config_generation() {
let toml_config = ConfigLoader::generate_sample_config(ConfigFormat::Toml).unwrap();
let json_config = ConfigLoader::generate_sample_config(ConfigFormat::Json).unwrap();
assert!(toml_config.contains("[search]"));
assert!(toml_config.contains("directory = \"~/\""));
assert!(json_config.contains("\"search\""));
assert!(json_config.contains("\"directory\": \"~/\""));
}
}