use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use tracing::{debug, warn};
pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
"**/vendor/**",
"**/node_modules/**",
"**/third_party/**",
"**/third-party/**",
"**/bower_components/**",
"**/dist/**",
"**/*.min.js",
"**/*.min.css",
"**/*.bundle.js",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, 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_key(|f| std::cmp::Reverse(f.1));
if scores[0].1 < 2 {
return ProjectType::Library;
}
scores[0].0
}
}
use super::project_type_scoring::{
score_cli_markers, score_compiler_markers, score_datascience_markers, score_framework_markers,
score_game_markers, score_interpreter_markers, score_kernel_markers, score_library_markers,
score_mobile_markers, score_web_markers,
};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CoChangeConfigToml {
#[serde(default)]
pub half_life_days: Option<f64>,
#[serde(default)]
pub min_weight: Option<f32>,
#[serde(default)]
pub max_files_per_commit: Option<usize>,
#[serde(default)]
pub max_commits: Option<usize>,
}
impl CoChangeConfigToml {
pub fn to_runtime(&self) -> crate::git::co_change::CoChangeConfig {
let defaults = crate::git::co_change::CoChangeConfig::default();
crate::git::co_change::CoChangeConfig {
half_life_days: self.half_life_days.unwrap_or(defaults.half_life_days),
min_weight: self.min_weight.unwrap_or(defaults.min_weight),
max_files_per_commit: self
.max_files_per_commit
.unwrap_or(defaults.max_files_per_commit),
max_commits: self.max_commits.unwrap_or(defaults.max_commits),
min_decay: defaults.min_decay,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OwnershipConfigToml {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub half_life_days: Option<f64>,
#[serde(default)]
pub author_threshold: Option<f64>,
#[serde(default)]
pub inactive_months: Option<u32>,
#[serde(default)]
pub min_file_loc: Option<usize>,
}
fn default_true() -> bool {
true
}
impl Default for OwnershipConfigToml {
fn default() -> Self {
Self {
enabled: true,
half_life_days: None,
author_threshold: None,
inactive_months: None,
min_file_loc: None,
}
}
}
impl OwnershipConfigToml {
pub fn to_runtime(&self) -> crate::git::ownership::OwnershipConfig {
let defaults = crate::git::ownership::OwnershipConfig::default();
crate::git::ownership::OwnershipConfig {
half_life_days: self.half_life_days.unwrap_or(defaults.half_life_days),
author_threshold: self.author_threshold.unwrap_or(defaults.author_threshold),
inactive_months: self.inactive_months.unwrap_or(defaults.inactive_months),
min_file_loc: self.min_file_loc.unwrap_or(defaults.min_file_loc),
}
}
}
#[derive(Debug, Clone, Serialize, 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 per_file_ignores: HashMap<String, Vec<String>>,
#[serde(default)]
pub defaults: CliDefaults,
#[serde(default)]
pub co_change: CoChangeConfigToml,
#[serde(default)]
pub ownership: OwnershipConfigToml,
#[serde(default)]
pub dual_branch: crate::config::DualBranchConfig,
#[serde(skip)]
#[allow(dead_code)] detected_type: Option<ProjectType>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DetectorConfigOverride {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub severity: Option<crate::models::Severity>,
#[serde(default)]
pub tier: Option<crate::models::Tier>,
#[serde(default)]
pub thresholds: HashMap<String, ThresholdValue>,
#[serde(default)]
pub confidence_threshold: Option<crate::models::Confidence>,
}
#[derive(Debug, Clone, Serialize, 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, Serialize, 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, Serialize, 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, Serialize, Deserialize, Default)]
pub struct ExcludeConfig {
#[serde(default)]
pub paths: Vec<String>,
#[serde(default)]
pub skip_defaults: bool,
}
impl ExcludeConfig {
pub fn effective_patterns(&self) -> Vec<String> {
let mut patterns = Vec::new();
if !self.skip_defaults {
patterns.extend(DEFAULT_EXCLUDE_PATTERNS.iter().map(|s| s.to_string()));
}
for p in &self.paths {
if !patterns.contains(p) {
patterns.push(p.clone());
}
}
patterns
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CliDefaults {
#[serde(default)]
pub format: Option<crate::reporters::OutputFormat>,
#[serde(default)]
pub severity: Option<crate::models::Severity>,
#[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<crate::models::Severity>,
#[serde(default)]
pub min_confidence: Option<f64>,
}
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() {
continue;
}
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 = basic_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 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.project_type(repo_path).coupling_multiplier()
}
pub fn complexity_multiplier(&self, repo_path: &Path) -> f64 {
self.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 severity_override(&self, name: &str) -> Option<crate::models::Severity> {
let normalized = normalize_detector_name(name);
self.detectors
.get(&normalized)
.or_else(|| self.detectors.get(name))
.and_then(|c| c.severity)
}
pub fn detector_tier_cap(&self, name: &str) -> Option<crate::models::Tier> {
let normalized = normalize_detector_name(name);
self.detectors
.get(&normalized)
.or_else(|| self.detectors.get(name))
.and_then(|c| c.tier)
}
pub fn 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 threshold_i64(&self, detector_name: &str, threshold_name: &str) -> Option<i64> {
self.threshold(detector_name, threshold_name)
.and_then(|v| v.as_i64())
}
pub fn threshold_f64(&self, detector_name: &str, threshold_name: &str) -> Option<f64> {
self.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.effective_patterns() {
if glob_match(pattern, &path_str) {
return true;
}
}
false
}
pub fn is_per_file_ignored(&self, path: &Path, detector_name: &str) -> bool {
if self.per_file_ignores.is_empty() {
return false;
}
let path_str = path.to_string_lossy();
let det_norm = normalize_detector_name(detector_name);
for (pattern, detectors) in &self.per_file_ignores {
if !glob_match(pattern, &path_str) {
continue;
}
for d in detectors {
if d == "*" || normalize_detector_name(d) == det_norm {
return true;
}
}
}
false
}
pub fn 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_or(*c));
} else if *c == '_' {
result.push('-');
} else {
result.push(*c);
}
}
result.trim_end_matches("-detector").to_string()
}
pub 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("**") {
return glob_match_doublestar(pattern, path);
}
if pattern.contains('*') {
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 2 {
return path.starts_with(parts[0]) && path.ends_with(parts[1]);
}
}
path.starts_with(pattern) || path == pattern
}
fn glob_match_doublestar(pattern: &str, path: &str) -> bool {
let parts: Vec<&str> = pattern.split("**").collect();
if parts.len() != 2 {
return false;
}
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() && !suffix.contains('*') && !path.ends_with(suffix) {
return false;
}
if !suffix.is_empty() && suffix.contains('*') && !suffix_wildcard_matches(suffix, path) {
return false;
}
true
}
fn suffix_wildcard_matches(suffix: &str, path: &str) -> bool {
let star_parts: Vec<&str> = suffix.split('*').collect();
if star_parts.len() != 2 {
return true; }
let before = star_parts[0];
let after = star_parts[1];
if before.is_empty() {
path.ends_with(after)
} else {
path.contains(before) && path.ends_with(after)
}
}
#[cfg(test)]
mod tests;