mod thresholds;
pub use thresholds::*;
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::path::Path;
use crate::cache::storage::CACHE_DIR;
const CONFIG_FILE: &str = "barad-dur.toml";
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OutputFormat {
#[default]
Cli,
Html,
Json,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CategoryWeights {
#[serde(default = "default_health_weight")]
pub health: u32,
#[serde(default = "default_team_weight")]
pub team: u32,
#[serde(default = "default_evolution_weight")]
pub evolution: u32,
#[serde(default = "default_hygiene_weight")]
pub hygiene: u32,
#[serde(default = "default_coupling_weight")]
pub coupling: u32,
#[serde(default = "default_deps_weight")]
pub deps: u32,
}
fn default_health_weight() -> u32 {
35
}
fn default_team_weight() -> u32 {
10
}
fn default_evolution_weight() -> u32 {
20
}
fn default_hygiene_weight() -> u32 {
15
}
fn default_coupling_weight() -> u32 {
20
}
fn default_deps_weight() -> u32 {
0
}
impl Default for CategoryWeights {
fn default() -> Self {
Self {
health: 35,
team: 10,
evolution: 20,
hygiene: 15,
coupling: 20,
deps: 0,
}
}
}
impl CategoryWeights {
pub fn sum(&self) -> u32 {
self.health + self.team + self.evolution + self.hygiene + self.coupling + self.deps
}
pub fn as_weight_pairs(&self) -> Vec<(&'static str, f64)> {
let s = self.sum() as f64;
let mut pairs = vec![
("Health", self.health as f64 / s),
("Team", self.team as f64 / s),
("Evolution", self.evolution as f64 / s),
("Git Hygiene", self.hygiene as f64 / s),
("Coupling", self.coupling as f64 / s),
];
if self.deps > 0 {
pairs.push(("Dependencies", self.deps as f64 / s));
}
pairs
}
}
#[derive(Debug, Clone, Deserialize, Default)]
struct TomlConfig {
#[serde(default)]
analysis: TomlAnalysis,
#[serde(default)]
exclude: TomlExclude,
#[serde(default)]
weights: CategoryWeights,
#[serde(default)]
thresholds: Thresholds,
#[serde(default)]
output: TomlOutput,
#[serde(default)]
backfill: BackfillConfig,
}
#[derive(Debug, Clone, Deserialize, Default)]
struct TomlAnalysis {
since: Option<String>,
#[serde(default)]
skip_blame: bool,
}
#[derive(Debug, Clone, Deserialize)]
struct TomlExclude {
#[serde(default = "default_true")]
use_defaults: bool,
#[serde(default)]
patterns: Vec<String>,
#[serde(default)]
extensions: Vec<String>,
}
fn default_true() -> bool {
true
}
impl Default for TomlExclude {
fn default() -> Self {
Self {
use_defaults: true,
patterns: Vec::new(),
extensions: Vec::new(),
}
}
}
#[derive(Debug, Clone, Deserialize, Default)]
struct TomlOutput {
#[serde(default)]
format: OutputFormat,
#[serde(default)]
auto_open: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct BackfillConfig {
#[serde(default = "default_sample_count")]
pub sample_count: u32,
}
fn default_sample_count() -> u32 {
10
}
impl Default for BackfillConfig {
fn default() -> Self {
Self {
sample_count: default_sample_count(),
}
}
}
#[derive(Debug, Clone)]
pub struct RepoConfig {
pub since: Option<String>,
pub skip_blame: bool,
pub exclude_use_defaults: bool,
pub exclude_patterns: Vec<String>,
pub exclude_extensions: Vec<String>,
pub weights: CategoryWeights,
pub thresholds: Thresholds,
pub output_format: OutputFormat,
pub auto_open: bool,
pub backfill: BackfillConfig,
}
impl Default for RepoConfig {
fn default() -> Self {
Self {
since: None,
skip_blame: false,
exclude_use_defaults: true,
exclude_patterns: Vec::new(),
exclude_extensions: Vec::new(),
weights: CategoryWeights::default(),
thresholds: Thresholds::default(),
output_format: OutputFormat::Cli,
auto_open: false,
backfill: BackfillConfig::default(),
}
}
}
pub fn load(repo_root: &Path) -> Result<RepoConfig> {
let config_path = repo_root.join(CACHE_DIR).join(CONFIG_FILE);
if !config_path.exists() {
return Ok(RepoConfig::default());
}
let content = std::fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read {}", config_path.display()))?;
warn_unknown_keys(&content, &config_path);
let toml_cfg: TomlConfig = toml::from_str(&content)
.with_context(|| format!("Failed to parse {}", config_path.display()))?;
Ok(RepoConfig {
since: toml_cfg.analysis.since,
skip_blame: toml_cfg.analysis.skip_blame,
exclude_use_defaults: toml_cfg.exclude.use_defaults,
exclude_patterns: toml_cfg.exclude.patterns,
exclude_extensions: toml_cfg.exclude.extensions,
weights: toml_cfg.weights,
thresholds: toml_cfg.thresholds,
output_format: toml_cfg.output.format,
auto_open: toml_cfg.output.auto_open,
backfill: toml_cfg.backfill,
})
}
fn warn_unknown_keys(content: &str, path: &Path) {
let known_sections = [
"analysis",
"exclude",
"weights",
"thresholds",
"output",
"backfill",
];
if let Ok(value) = content.parse::<toml::Value>() {
if let Some(table) = value.as_table() {
for key in table.keys() {
if !known_sections.contains(&key.as_str()) {
eprintln!(
"Warning: Unknown config key '{}' in {}",
key,
path.display()
);
}
}
}
}
}
pub fn validate(config: &RepoConfig) -> Result<()> {
let sum = config.weights.sum();
if sum != 100 {
bail!(
"Category weights must sum to 100, got {} (health={}, team={}, evolution={}, hygiene={}, coupling={})",
sum,
config.weights.health,
config.weights.team,
config.weights.evolution,
config.weights.hygiene,
config.weights.coupling,
);
}
if config.thresholds.coupling.component_depth == 0 {
bail!("thresholds.coupling.component_depth must be >= 1, got 0");
}
let ratio = config.thresholds.coupling.change_coupling_min_ratio;
if !(0.0..=1.0).contains(&ratio) {
bail!(
"thresholds.coupling.change_coupling_min_ratio must be in [0.0, 1.0], got {}",
ratio
);
}
Ok(())
}
pub fn merge_since(toml_val: Option<String>, cli_val: Option<String>) -> Option<String> {
cli_val.or(toml_val)
}
pub fn merge_bool(toml_val: bool, cli_val: Option<bool>) -> bool {
cli_val.unwrap_or(toml_val)
}
pub fn merge_exclude_patterns(
mut toml_patterns: Vec<String>,
cli_patterns: &[String],
) -> Vec<String> {
toml_patterns.extend(cli_patterns.iter().cloned());
toml_patterns
}
pub fn merge_with_cli(config: RepoConfig, args: &crate::cli::AnalyzeArgs) -> RepoConfig {
RepoConfig {
since: merge_since(config.since, args.since.clone()),
skip_blame: merge_bool(config.skip_blame, args.skip_blame),
exclude_use_defaults: merge_bool(
config.exclude_use_defaults,
args.no_default_excludes.map(|v| !v),
),
exclude_patterns: merge_exclude_patterns(config.exclude_patterns, &args.exclude),
exclude_extensions: merge_exclude_patterns(config.exclude_extensions, &args.exclude_ext),
weights: config.weights,
thresholds: config.thresholds,
output_format: if args.json {
OutputFormat::Json
} else if args.html {
OutputFormat::Html
} else {
config.output_format
},
auto_open: if args.open { true } else { config.auto_open },
backfill: config.backfill,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn default_config_values() {
let cfg = RepoConfig::default();
assert_eq!(cfg.since, None);
assert!(!cfg.skip_blame);
assert!(cfg.exclude_use_defaults);
assert!(cfg.exclude_patterns.is_empty());
assert_eq!(cfg.weights.sum(), 100);
assert_eq!(cfg.output_format, OutputFormat::Cli);
assert!(!cfg.auto_open);
}
#[test]
fn load_missing_file_returns_defaults() {
let dir = TempDir::new().unwrap();
let cfg = load(dir.path()).unwrap();
assert_eq!(cfg.weights.health, 35);
assert!(cfg.exclude_use_defaults);
}
#[test]
fn load_minimal_toml() {
let dir = TempDir::new().unwrap();
let cache_dir = dir.path().join(".repository-analysis");
fs::create_dir_all(&cache_dir).unwrap();
fs::write(
cache_dir.join("barad-dur.toml"),
"[analysis]\nsince = \"3months\"\n",
)
.unwrap();
let cfg = load(dir.path()).unwrap();
assert_eq!(cfg.since, Some("3months".to_string()));
assert_eq!(cfg.weights.health, 35);
}
#[test]
fn load_custom_weights() {
let dir = TempDir::new().unwrap();
let cache_dir = dir.path().join(".repository-analysis");
fs::create_dir_all(&cache_dir).unwrap();
fs::write(
cache_dir.join("barad-dur.toml"),
"[weights]\nhealth = 40\nteam = 30\nevolution = 20\nhygiene = 10\n",
)
.unwrap();
let cfg = load(dir.path()).unwrap();
assert_eq!(cfg.weights.health, 40);
assert_eq!(cfg.weights.hygiene, 10);
assert_eq!(cfg.weights.coupling, 20); }
#[test]
fn load_exclude_patterns() {
let dir = TempDir::new().unwrap();
let cache_dir = dir.path().join(".repository-analysis");
fs::create_dir_all(&cache_dir).unwrap();
fs::write(
cache_dir.join("barad-dur.toml"),
"[exclude]\nuse_defaults = false\npatterns = [\"*.resx\", \"**/i18n/**\"]\n",
)
.unwrap();
let cfg = load(dir.path()).unwrap();
assert!(!cfg.exclude_use_defaults);
assert_eq!(cfg.exclude_patterns, vec!["*.resx", "**/i18n/**"]);
}
#[test]
fn load_exclude_extensions_from_toml() {
let dir = TempDir::new().unwrap();
let cache_dir = dir.path().join(".repository-analysis");
fs::create_dir_all(&cache_dir).unwrap();
fs::write(
cache_dir.join("barad-dur.toml"),
"[exclude]\nextensions = [\"jar\", \"min.js\"]\n",
)
.unwrap();
let cfg = load(dir.path()).unwrap();
assert_eq!(cfg.exclude_extensions, vec!["jar", "min.js"]);
}
#[test]
fn load_exclude_extensions_defaults_to_empty() {
let dir = TempDir::new().unwrap();
let cache_dir = dir.path().join(".repository-analysis");
fs::create_dir_all(&cache_dir).unwrap();
fs::write(cache_dir.join("barad-dur.toml"), "[exclude]\n").unwrap();
let cfg = load(dir.path()).unwrap();
assert!(cfg.exclude_extensions.is_empty());
}
#[test]
fn load_output_section() {
let dir = TempDir::new().unwrap();
let cache_dir = dir.path().join(".repository-analysis");
fs::create_dir_all(&cache_dir).unwrap();
fs::write(
cache_dir.join("barad-dur.toml"),
"[output]\nformat = \"html\"\nauto_open = true\n",
)
.unwrap();
let cfg = load(dir.path()).unwrap();
assert_eq!(cfg.output_format, OutputFormat::Html);
assert!(cfg.auto_open);
}
#[test]
fn load_thresholds() {
let dir = TempDir::new().unwrap();
let cache_dir = dir.path().join(".repository-analysis");
fs::create_dir_all(&cache_dir).unwrap();
fs::write(
cache_dir.join("barad-dur.toml"),
"[thresholds.health]\nmax_complexity = 30\n",
)
.unwrap();
let cfg = load(dir.path()).unwrap();
assert_eq!(cfg.thresholds.health.max_complexity, 30);
assert_eq!(cfg.thresholds.team.silo_max_owners, 1);
}
#[test]
fn load_bad_toml_returns_error() {
let dir = TempDir::new().unwrap();
let cache_dir = dir.path().join(".repository-analysis");
fs::create_dir_all(&cache_dir).unwrap();
fs::write(cache_dir.join("barad-dur.toml"), "not valid toml [[[").unwrap();
assert!(load(dir.path()).is_err());
}
#[test]
fn validate_weights_sum_100() {
let cfg = RepoConfig::default();
assert!(validate(&cfg).is_ok());
}
#[test]
fn validate_weights_bad_sum() {
let mut cfg = RepoConfig::default();
cfg.weights.health = 50;
let err = validate(&cfg).unwrap_err();
assert!(err.to_string().contains("must sum to 100"));
}
#[test]
fn merge_cli_since_overrides_toml() {
let merged = merge_since(Some("6months".into()), Some("3months".into()));
assert_eq!(merged, Some("3months".to_string()));
}
#[test]
fn merge_cli_since_none_keeps_toml() {
let merged = merge_since(Some("6months".into()), None);
assert_eq!(merged, Some("6months".to_string()));
}
#[test]
fn merge_exclude_appends() {
let toml_patterns = vec!["*.resx".into()];
let cli_patterns = vec!["**/vendor/**".into()];
let merged = merge_exclude_patterns(toml_patterns, &cli_patterns);
assert_eq!(merged, vec!["*.resx", "**/vendor/**"]);
}
#[test]
fn merge_exclude_extensions_appends_cli_to_toml() {
let toml_exts = vec!["jar".into()];
let cli_exts = vec!["min.js".into()];
let merged = merge_exclude_patterns(toml_exts, &cli_exts);
assert_eq!(merged, vec!["jar", "min.js"]);
}
#[test]
fn merge_with_cli_wires_exclude_ext() {
use crate::cli::{Cli, Commands};
use clap::Parser;
let cli = Cli::parse_from(["barad-dur", "analyze", ".", "--exclude-ext", "jar"]);
let args = match cli.command {
Commands::Analyze(a) => a,
_ => panic!("expected Analyze"),
};
let toml_cfg = RepoConfig {
exclude_extensions: vec!["min.js".into()],
..RepoConfig::default()
};
let merged = merge_with_cli(toml_cfg, &args);
assert_eq!(merged.exclude_extensions, vec!["min.js", "jar"]);
}
#[test]
fn merge_skip_blame_cli_overrides() {
let merged = merge_bool(false, Some(true));
assert!(merged);
}
#[test]
fn merge_skip_blame_cli_absent_keeps_toml() {
let merged = merge_bool(true, None);
assert!(merged);
}
#[test]
fn load_coupling_thresholds_defaults() {
let dir = TempDir::new().unwrap();
let cfg = load(dir.path()).unwrap();
assert_eq!(cfg.thresholds.coupling.component_depth, 2);
assert!((cfg.thresholds.coupling.change_coupling_min_ratio - 0.30).abs() < f64::EPSILON);
}
#[test]
fn load_coupling_thresholds_from_toml() {
let dir = TempDir::new().unwrap();
let cache_dir = dir.path().join(".repository-analysis");
fs::create_dir_all(&cache_dir).unwrap();
fs::write(
cache_dir.join("barad-dur.toml"),
"[thresholds.coupling]\ncomponent_depth = 3\nchange_coupling_min_ratio = 0.50\n",
)
.unwrap();
let cfg = load(dir.path()).unwrap();
assert_eq!(cfg.thresholds.coupling.component_depth, 3);
assert!((cfg.thresholds.coupling.change_coupling_min_ratio - 0.50).abs() < f64::EPSILON);
}
#[test]
fn validate_coupling_depth_zero_errors() {
let mut cfg = RepoConfig::default();
cfg.thresholds.coupling.component_depth = 0;
let err = validate(&cfg).unwrap_err();
assert!(err.to_string().contains("component_depth"));
}
#[test]
fn validate_coupling_ratio_out_of_range_errors() {
let mut cfg = RepoConfig::default();
cfg.thresholds.coupling.change_coupling_min_ratio = 1.5;
let err = validate(&cfg).unwrap_err();
assert!(err.to_string().contains("change_coupling_min_ratio"));
}
}