use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
use tracing::{debug, warn};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ProjectType {
#[default]
Web,
Interpreter,
Compiler,
Library,
Framework,
Cli,
Kernel,
Game,
DataScience,
Mobile,
}
impl ProjectType {
pub fn coupling_multiplier(&self) -> f64 {
match self {
ProjectType::Web => 1.0, ProjectType::Interpreter => 2.5, ProjectType::Compiler => 3.0, ProjectType::Library => 1.5, ProjectType::Framework => 3.0, ProjectType::Cli => 1.3, ProjectType::Kernel => 3.0, ProjectType::Game => 2.0, ProjectType::DataScience => 2.0, ProjectType::Mobile => 1.5, }
}
pub fn complexity_multiplier(&self) -> f64 {
match self {
ProjectType::Web => 1.0,
ProjectType::Interpreter => 1.8, ProjectType::Compiler => 1.5, ProjectType::Library => 1.2,
ProjectType::Framework => 1.5, ProjectType::Cli => 1.1,
ProjectType::Kernel => 2.0, ProjectType::Game => 1.5, ProjectType::DataScience => 1.8, ProjectType::Mobile => 1.3, }
}
pub fn lenient_dead_code(&self) -> bool {
matches!(
self,
ProjectType::Interpreter
| ProjectType::Kernel
| ProjectType::Game
| ProjectType::Framework
| ProjectType::DataScience
)
}
pub fn detect(repo_path: &Path) -> ProjectType {
let mut scores: Vec<(ProjectType, u32)> = vec![
(
ProjectType::Interpreter,
score_interpreter_markers(repo_path),
),
(ProjectType::Compiler, score_compiler_markers(repo_path)),
(ProjectType::Framework, score_framework_markers(repo_path)),
(ProjectType::Kernel, score_kernel_markers(repo_path)),
(ProjectType::Game, score_game_markers(repo_path)),
(
ProjectType::DataScience,
score_datascience_markers(repo_path),
),
(ProjectType::Mobile, score_mobile_markers(repo_path)),
(ProjectType::Cli, score_cli_markers(repo_path)),
(ProjectType::Library, score_library_markers(repo_path)),
(ProjectType::Web, score_web_markers(repo_path)),
];
scores.sort_by(|a, b| b.1.cmp(&a.1));
if scores[0].1 < 2 {
return ProjectType::Library;
}
scores[0].0
}
}
use super::project_type_scoring::{score_framework_markers, score_interpreter_markers, score_compiler_markers, score_kernel_markers, score_game_markers, score_cli_markers, score_library_markers, score_web_markers, score_datascience_markers, score_mobile_markers};
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ProjectConfig {
#[serde(default)]
pub project_type: Option<ProjectType>,
#[serde(default)]
pub detectors: HashMap<String, DetectorConfigOverride>,
#[serde(default)]
pub scoring: ScoringConfig,
#[serde(default)]
pub exclude: ExcludeConfig,
#[serde(default)]
pub defaults: CliDefaults,
#[serde(skip)]
detected_type: Option<ProjectType>,
}
#[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)?;
if let Ok(config) = serde_json::from_str::<ProjectConfig>(&content) {
return Ok(config);
}
anyhow::bail!(
"YAML config files with non-JSON syntax are not yet supported.\n\
Please convert {} to TOML format (repotoire.toml) or use JSON syntax.\n\
See: https://repotoire.com/docs/cli/config",
path.display()
)
}
impl ProjectConfig {
pub fn get_project_type(&self, repo_path: &Path) -> ProjectType {
if let Some(explicit) = self.project_type {
debug!("Using explicit project type: {:?}", explicit);
return explicit;
}
let detected = ProjectType::detect(repo_path);
debug!(
"Auto-detected project type: {:?} (coupling multiplier: {})",
detected,
detected.coupling_multiplier()
);
detected
}
pub fn coupling_multiplier(&self, repo_path: &Path) -> f64 {
self.get_project_type(repo_path).coupling_multiplier()
}
pub fn complexity_multiplier(&self, repo_path: &Path) -> f64 {
self.get_project_type(repo_path).complexity_multiplier()
}
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(2.5);
assert_eq!(float_val.as_i64(), Some(2));
assert_eq!(float_val.as_f64(), Some(2.5));
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()));
}
}