use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
use tracing::{debug, warn};
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ProjectConfig {
#[serde(default)]
pub detectors: HashMap<String, DetectorConfigOverride>,
#[serde(default)]
pub scoring: ScoringConfig,
#[serde(default)]
pub exclude: ExcludeConfig,
#[serde(default)]
pub defaults: CliDefaults,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct DetectorConfigOverride {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub severity: Option<String>,
#[serde(default)]
pub thresholds: HashMap<String, ThresholdValue>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum ThresholdValue {
Integer(i64),
Float(f64),
Boolean(bool),
String(String),
}
impl ThresholdValue {
pub fn as_i64(&self) -> Option<i64> {
match self {
ThresholdValue::Integer(v) => Some(*v),
ThresholdValue::Float(v) => Some(*v as i64),
_ => None,
}
}
pub fn as_f64(&self) -> Option<f64> {
match self {
ThresholdValue::Integer(v) => Some(*v as f64),
ThresholdValue::Float(v) => Some(*v),
_ => None,
}
}
pub fn as_bool(&self) -> Option<bool> {
match self {
ThresholdValue::Boolean(v) => Some(*v),
_ => None,
}
}
pub fn as_str(&self) -> Option<&str> {
match self {
ThresholdValue::String(v) => Some(v.as_str()),
_ => None,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct ScoringConfig {
#[serde(default = "default_security_multiplier")]
pub security_multiplier: f64,
#[serde(default)]
pub pillar_weights: PillarWeights,
}
impl Default for ScoringConfig {
fn default() -> Self {
Self {
security_multiplier: default_security_multiplier(),
pillar_weights: PillarWeights::default(),
}
}
}
fn default_security_multiplier() -> f64 {
3.0
}
#[derive(Debug, Clone, Deserialize)]
pub struct PillarWeights {
#[serde(default = "default_structure_weight")]
pub structure: f64,
#[serde(default = "default_quality_weight")]
pub quality: f64,
#[serde(default = "default_architecture_weight")]
pub architecture: f64,
}
impl Default for PillarWeights {
fn default() -> Self {
Self {
structure: default_structure_weight(),
quality: default_quality_weight(),
architecture: default_architecture_weight(),
}
}
}
fn default_structure_weight() -> f64 {
0.4
}
fn default_quality_weight() -> f64 {
0.3
}
fn default_architecture_weight() -> f64 {
0.3
}
impl PillarWeights {
pub fn is_valid(&self) -> bool {
let sum = self.structure + self.quality + self.architecture;
(sum - 1.0).abs() < 0.001
}
pub fn normalize(&mut self) {
let sum = self.structure + self.quality + self.architecture;
if sum > 0.0 {
self.structure /= sum;
self.quality /= sum;
self.architecture /= sum;
}
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ExcludeConfig {
#[serde(default)]
pub paths: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct CliDefaults {
#[serde(default)]
pub format: Option<String>,
#[serde(default)]
pub severity: Option<String>,
#[serde(default)]
pub workers: Option<usize>,
#[serde(default)]
pub per_page: Option<usize>,
#[serde(default)]
pub skip_detectors: Vec<String>,
#[serde(default)]
pub thorough: Option<bool>,
#[serde(default)]
pub no_git: Option<bool>,
#[serde(default)]
pub no_emoji: Option<bool>,
#[serde(default)]
pub fail_on: Option<String>,
}
pub fn load_project_config(repo_path: &Path) -> ProjectConfig {
let toml_path = repo_path.join("repotoire.toml");
if toml_path.exists() {
match load_toml_config(&toml_path) {
Ok(config) => {
debug!("Loaded project config from {}", toml_path.display());
return config;
}
Err(e) => {
warn!("Failed to load {}: {}", toml_path.display(), e);
}
}
}
let json_path = repo_path.join(".repotoirerc.json");
if json_path.exists() {
match load_json_config(&json_path) {
Ok(config) => {
debug!("Loaded project config from {}", json_path.display());
return config;
}
Err(e) => {
warn!("Failed to load {}: {}", json_path.display(), e);
}
}
}
for yaml_name in &[".repotoire.yaml", ".repotoire.yml"] {
let yaml_path = repo_path.join(yaml_name);
if yaml_path.exists() {
match load_yaml_config(&yaml_path) {
Ok(config) => {
debug!("Loaded project config from {}", yaml_path.display());
return config;
}
Err(e) => {
warn!("Failed to load {}: {}", yaml_path.display(), e);
}
}
}
}
debug!("No project config found, using defaults");
ProjectConfig::default()
}
fn load_toml_config(path: &Path) -> anyhow::Result<ProjectConfig> {
let content = std::fs::read_to_string(path)?;
let config: ProjectConfig = toml::from_str(&content)?;
Ok(config)
}
fn load_json_config(path: &Path) -> anyhow::Result<ProjectConfig> {
let content = std::fs::read_to_string(path)?;
let config: ProjectConfig = serde_json::from_str(&content)?;
Ok(config)
}
fn load_yaml_config(path: &Path) -> anyhow::Result<ProjectConfig> {
let content = std::fs::read_to_string(path)?;
let config: ProjectConfig = serde_json::from_str(&content)
.map_err(|e| anyhow::anyhow!("YAML parsing not fully supported yet (tried JSON fallback): {}", e))?;
Ok(config)
}
impl ProjectConfig {
pub fn is_detector_enabled(&self, name: &str) -> bool {
let normalized = normalize_detector_name(name);
self.detectors
.get(&normalized)
.or_else(|| self.detectors.get(name))
.and_then(|c| c.enabled)
.unwrap_or(true)
}
pub fn get_severity_override(&self, name: &str) -> Option<&str> {
let normalized = normalize_detector_name(name);
self.detectors
.get(&normalized)
.or_else(|| self.detectors.get(name))
.and_then(|c| c.severity.as_deref())
}
pub fn get_threshold(&self, detector_name: &str, threshold_name: &str) -> Option<&ThresholdValue> {
let normalized = normalize_detector_name(detector_name);
self.detectors
.get(&normalized)
.or_else(|| self.detectors.get(detector_name))
.and_then(|c| c.thresholds.get(threshold_name))
}
pub fn get_threshold_i64(&self, detector_name: &str, threshold_name: &str) -> Option<i64> {
self.get_threshold(detector_name, threshold_name)
.and_then(|v| v.as_i64())
}
pub fn get_threshold_f64(&self, detector_name: &str, threshold_name: &str) -> Option<f64> {
self.get_threshold(detector_name, threshold_name)
.and_then(|v| v.as_f64())
}
pub fn should_exclude(&self, path: &Path) -> bool {
let path_str = path.to_string_lossy();
for pattern in &self.exclude.paths {
if glob_match(pattern, &path_str) {
return true;
}
}
false
}
pub fn get_disabled_detectors(&self) -> Vec<String> {
let mut disabled = Vec::new();
for (name, config) in &self.detectors {
if config.enabled == Some(false) {
disabled.push(name.clone());
}
}
disabled.extend(self.defaults.skip_detectors.clone());
disabled
}
}
pub fn normalize_detector_name(name: &str) -> String {
let mut result = String::new();
let chars: Vec<char> = name.chars().collect();
for (i, c) in chars.iter().enumerate() {
if c.is_uppercase() {
let prev_is_lower = i > 0 && chars[i - 1].is_lowercase();
let is_acronym_end = i > 0
&& chars[i - 1].is_uppercase()
&& i + 1 < chars.len()
&& chars[i + 1].is_lowercase();
if prev_is_lower || is_acronym_end {
result.push('-');
}
result.push(c.to_lowercase().next().unwrap());
} else if *c == '_' {
result.push('-');
} else {
result.push(*c);
}
}
result
.trim_end_matches("-detector")
.to_string()
}
fn glob_match(pattern: &str, path: &str) -> bool {
if pattern.starts_with("**/") && pattern.ends_with("/**") {
let middle = pattern.trim_start_matches("**/").trim_end_matches("/**");
return path.contains(&format!("/{}/", middle))
|| path.starts_with(&format!("{}/", middle));
}
if pattern.contains("**") {
let parts: Vec<&str> = pattern.split("**").collect();
if parts.len() == 2 {
let prefix = parts[0].trim_end_matches('/');
let suffix = parts[1].trim_start_matches('/');
if !prefix.is_empty() && !path.starts_with(prefix) {
return false;
}
if !suffix.is_empty() && !path.ends_with(suffix) {
return false;
}
return true;
}
}
if pattern.contains('*') {
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 2 {
let prefix = parts[0];
let suffix = parts[1];
return path.starts_with(prefix) && path.ends_with(suffix);
}
}
path.starts_with(pattern) || path == pattern
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_detector_name() {
assert_eq!(normalize_detector_name("GodClassDetector"), "god-class");
assert_eq!(normalize_detector_name("god_class"), "god-class");
assert_eq!(normalize_detector_name("god-class"), "god-class");
assert_eq!(normalize_detector_name("SQLInjectionDetector"), "sql-injection");
assert_eq!(normalize_detector_name("NPlusOneDetector"), "n-plus-one");
}
#[test]
fn test_glob_match() {
assert!(glob_match("**/vendor/**", "src/vendor/lib/foo.py"));
assert!(glob_match("generated/", "generated/model.py"));
assert!(glob_match("*.test.ts", "foo.test.ts"));
assert!(glob_match("vendor/", "vendor/lib/foo.py"));
assert!(!glob_match("vendor/", "src/vendor/foo.py"));
}
#[test]
fn test_pillar_weights_validation() {
let valid = PillarWeights {
structure: 0.4,
quality: 0.3,
architecture: 0.3,
};
assert!(valid.is_valid());
let invalid = PillarWeights {
structure: 0.5,
quality: 0.5,
architecture: 0.5,
};
assert!(!invalid.is_valid());
}
#[test]
fn test_pillar_weights_normalize() {
let mut weights = PillarWeights {
structure: 2.0,
quality: 1.0,
architecture: 1.0,
};
weights.normalize();
assert!((weights.structure - 0.5).abs() < 0.001);
assert!((weights.quality - 0.25).abs() < 0.001);
assert!((weights.architecture - 0.25).abs() < 0.001);
}
#[test]
fn test_threshold_value() {
let int_val = ThresholdValue::Integer(42);
assert_eq!(int_val.as_i64(), Some(42));
assert_eq!(int_val.as_f64(), Some(42.0));
assert_eq!(int_val.as_bool(), None);
let float_val = ThresholdValue::Float(3.14);
assert_eq!(float_val.as_i64(), Some(3));
assert_eq!(float_val.as_f64(), Some(3.14));
let bool_val = ThresholdValue::Boolean(true);
assert_eq!(bool_val.as_bool(), Some(true));
assert_eq!(bool_val.as_i64(), None);
}
#[test]
fn test_default_config() {
let config = ProjectConfig::default();
assert!(config.is_detector_enabled("god-class"));
assert!(config.is_detector_enabled("unknown-detector"));
assert!(config.get_severity_override("god-class").is_none());
assert!((config.scoring.security_multiplier - 3.0).abs() < 0.001);
assert!(config.scoring.pillar_weights.is_valid());
}
#[test]
fn test_parse_toml_config() {
let toml_content = r#"
[detectors.god-class]
enabled = true
thresholds = { method_count = 30, loc = 600 }
[detectors.sql-injection]
severity = "high"
enabled = false
[scoring]
security_multiplier = 5.0
[scoring.pillar_weights]
structure = 0.3
quality = 0.4
architecture = 0.3
[exclude]
paths = ["generated/", "vendor/"]
[defaults]
format = "json"
workers = 4
skip_detectors = ["debug-code"]
"#;
let config: ProjectConfig = toml::from_str(toml_content).unwrap();
assert!(config.is_detector_enabled("god-class"));
assert!(!config.is_detector_enabled("sql-injection"));
assert_eq!(config.get_severity_override("sql-injection"), Some("high"));
assert_eq!(config.get_threshold_i64("god-class", "method_count"), Some(30));
assert_eq!(config.get_threshold_i64("god-class", "loc"), Some(600));
assert!((config.scoring.security_multiplier - 5.0).abs() < 0.001);
assert!((config.scoring.pillar_weights.structure - 0.3).abs() < 0.001);
assert_eq!(config.exclude.paths.len(), 2);
assert!(config.should_exclude(Path::new("generated/foo.py")));
assert_eq!(config.defaults.format, Some("json".to_string()));
assert_eq!(config.defaults.workers, Some(4));
assert!(config.defaults.skip_detectors.contains(&"debug-code".to_string()));
}
}