use std::fs;
use std::path::Path;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ProjectConfig {
#[serde(default)]
pub project_type: Option<ProjectType>,
#[serde(default)]
pub whitelists: Whitelists,
#[serde(default)]
pub rules: RulesConfig,
#[serde(default)]
pub overrides: Vec<OverrideConfig>,
}
impl ProjectConfig {
pub fn load_from_file(path: &Path) -> Option<Self> {
if !path.exists() {
return None;
}
let content = fs::read_to_string(path).ok()?;
toml::from_str(&content).ok()
}
pub fn discover(start_dir: &Path) -> Self {
let config_name = ".garbage-code-hunter.toml";
let mut current = Some(start_dir);
while let Some(dir) = current {
let config_path = dir.join(config_name);
if let Some(config) = Self::load_from_file(&config_path) {
return config;
}
current = dir.parent();
}
Self::default()
}
pub fn get_override_for_path(&self, path: &Path) -> Option<&OverrideConfig> {
let path_str = path.to_string_lossy();
self.overrides.iter().find(|override_config| {
path_str.contains(&override_config.pattern)
|| path_str.starts_with(&override_config.pattern)
})
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ProjectType {
CliTool,
Library,
WebService,
Game,
Embedded,
Wasm,
Other(String),
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(rename_all = "kebab-case")]
pub struct Whitelists {
#[serde(default)]
pub magic_numbers: Vec<i64>,
#[serde(default)]
pub variable_names: Vec<String>,
#[serde(default)]
pub directories: Vec<String>,
#[serde(default)]
pub exclude_patterns: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(rename_all = "kebab-case")]
pub struct RulesConfig {
#[serde(default)]
pub naming: NamingRuleConfig,
#[serde(default)]
pub unwrap: UnwrapRuleConfig,
#[serde(default)]
pub magic_number: MagicNumberRuleConfig,
#[serde(default)]
pub println: PrintlnRuleConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct NamingRuleConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default = "default_severity_mild")]
pub severity: SeverityOverride,
#[serde(default)]
pub allowed_names: Vec<String>,
}
impl Default for NamingRuleConfig {
fn default() -> Self {
Self {
enabled: true,
severity: SeverityOverride::Mild,
allowed_names: Vec::new(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct UnwrapRuleConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default = "default_unwrap_threshold")]
pub threshold: usize,
#[serde(default = "default_nuclear_threshold")]
pub nuclear_threshold: usize,
}
impl Default for UnwrapRuleConfig {
fn default() -> Self {
Self {
enabled: true,
threshold: 1,
nuclear_threshold: 15,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct MagicNumberRuleConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default)]
pub allowed_numbers: Vec<i64>,
#[serde(default = "default_ui_numbers")]
pub ui_layout_numbers: Vec<i64>,
}
impl Default for MagicNumberRuleConfig {
fn default() -> Self {
Self {
enabled: true,
allowed_numbers: Vec::new(),
ui_layout_numbers: default_ui_numbers(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct PrintlnRuleConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default = "default_true")]
pub allow_in_main_files: bool,
#[serde(default = "default_println_threshold")]
pub threshold: usize,
}
impl Default for PrintlnRuleConfig {
fn default() -> Self {
Self {
enabled: true,
allow_in_main_files: true,
threshold: 3,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct OverrideConfig {
pub pattern: String,
#[serde(default)]
pub context: Option<FileContextType>,
#[serde(default = "default_one")]
pub weight_multiplier: f64,
#[serde(default)]
pub disabled_rules: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum FileContextType {
Business,
Example,
Test,
Benchmark,
Documentation,
Config,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SeverityOverride {
Mild,
Spicy,
Nuclear,
}
fn default_enabled() -> bool {
true
}
fn default_severity_mild() -> SeverityOverride {
SeverityOverride::Mild
}
fn default_unwrap_threshold() -> usize {
1
}
fn default_nuclear_threshold() -> usize {
15
}
fn default_ui_numbers() -> Vec<i64> {
vec![20, 25, 30, 33, 40, 50, 60, 66, 70, 75, 80, 90, 100]
}
fn default_true() -> bool {
true
}
fn default_println_threshold() -> usize {
3
}
fn default_one() -> f64 {
1.0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = ProjectConfig::default();
assert!(config.rules.naming.enabled);
assert_eq!(config.rules.unwrap.threshold, 1);
assert!(config.rules.println.allow_in_main_files);
}
#[test]
fn test_load_nonexistent_file() {
let config = ProjectConfig::load_from_file(Path::new("/nonexistent/config.toml"));
assert!(config.is_none());
}
#[test]
fn test_discover_without_config() {
let temp_dir = tempfile::tempdir().unwrap();
let config = ProjectConfig::discover(temp_dir.path());
assert!(config.project_type.is_none());
assert!(config.whitelists.magic_numbers.is_empty());
}
#[test]
fn test_load_valid_config() {
let toml_content = r#"
[whitelists]
magic-numbers = [800, 1000, 2000]
variable-names = ["data", "info", "ctx"]
[rules.naming]
enabled = true
severity = "mild"
allowed-names = ["e", "ctx"]
[rules.unwrap]
threshold = 3
nuclear-threshold = 20
[rules.println]
allow-in-main-files = true
threshold = 5
"#;
let config: ProjectConfig = toml::from_str(toml_content).expect("Failed to parse config");
assert_eq!(config.whitelists.magic_numbers.len(), 3);
assert_eq!(config.whitelists.variable_names.len(), 3);
assert_eq!(config.rules.unwrap.threshold, 3);
assert_eq!(config.rules.unwrap.nuclear_threshold, 20);
assert_eq!(config.rules.println.threshold, 5);
}
}