use anyhow::{Context, Result};
use globset::{Glob, GlobSet, GlobSetBuilder};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
const DEFAULT_EXCLUDES: &[&str] = &[
"**/*.test.ts",
"**/*.test.tsx",
"**/*.test.js",
"**/*.test.jsx",
"**/*.spec.ts",
"**/*.spec.tsx",
"**/*.spec.js",
"**/*.spec.jsx",
"**/__tests__/**",
"**/__mocks__/**",
"**/__snapshots__/**",
"**/test_*.py",
"**/*_test.py",
"**/*_test.go",
"**/vendor/**",
"**/*.pb.go",
"**/zz_generated*.go",
"**/mock_*.go",
"**/node_modules/**",
"**/dist/**",
"**/build/**",
"**/.next/**",
"**/.nuxt/**",
"**/.output/**",
"**/.cache/**",
"**/.turbo/**",
"**/storybook-static/**",
"**/*.min.js",
"**/*.min.ts",
"**/*.bundle.js",
"**/__generated__/**",
"**/generated/**",
"**/*.generated.ts",
"**/*.generated.js",
"**/*.pb.ts",
"**/*.pb.js",
"**/venv/**",
"**/.venv/**",
"**/__pycache__/**",
"**/migrations/**",
"**/target/**",
"**/out/**",
];
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HotspotsConfig {
#[serde(default)]
pub include: Vec<String>,
#[serde(default)]
pub exclude: Vec<String>,
#[serde(default)]
pub thresholds: Option<ThresholdConfig>,
#[serde(default)]
pub weights: Option<WeightConfig>,
#[serde(default)]
pub warning_thresholds: Option<WarningThresholdConfig>,
#[serde(default)]
pub min_lrs: Option<f64>,
#[serde(default)]
pub top: Option<usize>,
#[serde(default)]
pub scoring: Option<ScoringWeightsConfig>,
#[serde(default)]
pub co_change_window_days: Option<u64>,
#[serde(default)]
pub co_change_min_count: Option<usize>,
#[serde(default)]
pub per_function_touches: Option<bool>,
#[serde(default)]
pub hybrid_touch_threshold: Option<usize>,
#[serde(default)]
pub driver_threshold_percentile: Option<u8>,
#[serde(default)]
pub betweenness_exact_threshold: Option<usize>,
#[serde(default)]
pub betweenness_approx_k: Option<usize>,
#[serde(default)]
pub callgraph_skip_above: Option<usize>,
#[serde(default)]
pub patterns: Option<PatternThresholdsConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ThresholdConfig {
pub moderate: Option<f64>,
pub high: Option<f64>,
pub critical: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WeightConfig {
pub cc: Option<f64>,
pub nd: Option<f64>,
pub fo: Option<f64>,
pub ns: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScoringWeightsConfig {
pub churn: Option<f64>,
pub touch: Option<f64>,
pub recency: Option<f64>,
pub fan_in: Option<f64>,
pub scc: Option<f64>,
pub depth: Option<f64>,
pub neighbor_churn: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PatternThresholdsConfig {
pub complex_branching_cc: Option<usize>,
pub complex_branching_nd: Option<usize>,
pub deeply_nested_nd: Option<usize>,
pub exit_heavy_ns: Option<usize>,
pub god_function_loc: Option<usize>,
pub god_function_fo: Option<usize>,
pub long_function_loc: Option<usize>,
pub churn_magnet_churn: Option<usize>,
pub churn_magnet_cc: Option<usize>,
pub cyclic_hub_scc: Option<usize>,
pub cyclic_hub_fan_in: Option<usize>,
pub hub_function_fan_in: Option<usize>,
pub hub_function_cc: Option<usize>,
pub middle_man_fan_in: Option<usize>,
pub middle_man_fo: Option<usize>,
pub middle_man_cc_max: Option<usize>,
pub neighbor_risk_churn: Option<usize>,
pub neighbor_risk_fo: Option<usize>,
pub shotgun_target_fan_in: Option<usize>,
pub shotgun_target_churn: Option<usize>,
pub stale_complex_cc: Option<usize>,
pub stale_complex_loc: Option<usize>,
pub stale_complex_days: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WarningThresholdConfig {
pub watch_min: Option<f64>,
pub watch_max: Option<f64>,
pub attention_min: Option<f64>,
pub attention_max: Option<f64>,
pub rapid_growth_percent: Option<f64>,
}
#[derive(Debug)]
pub struct ResolvedConfig {
pub include: Option<GlobSet>,
pub exclude: GlobSet,
pub moderate_threshold: f64,
pub high_threshold: f64,
pub critical_threshold: f64,
pub weight_cc: f64,
pub weight_nd: f64,
pub weight_fo: f64,
pub weight_ns: f64,
pub watch_min: f64,
pub watch_max: f64,
pub attention_min: f64,
pub attention_max: f64,
pub rapid_growth_percent: f64,
pub min_lrs: Option<f64>,
pub top_n: Option<usize>,
pub co_change_window_days: u64,
pub co_change_min_count: usize,
pub per_function_touches: bool,
pub hybrid_touch_threshold: Option<usize>,
pub driver_threshold_percentile: u8,
pub betweenness_exact_threshold: usize,
pub betweenness_approx_k: usize,
pub callgraph_skip_above: usize,
pub scoring_weights: crate::scoring::ScoringWeights,
pub pattern_thresholds: crate::patterns::Thresholds,
pub config_path: Option<PathBuf>,
}
impl HotspotsConfig {
pub fn validate(&self) -> Result<()> {
if let Some(ref t) = self.thresholds {
validate_thresholds(t)?;
}
if let Some(ref w) = self.weights {
validate_weights(w)?;
}
if let Some(ref wt) = self.warning_thresholds {
validate_warning_thresholds(wt)?;
}
if let Some(ref s) = self.scoring {
validate_scoring(s)?;
}
if let Some(ref p) = self.patterns {
validate_pattern_thresholds(p)?;
}
validate_scalar_fields(self)?;
validate_glob_patterns(&self.include, &self.exclude)
}
}
fn validate_scalar_fields(c: &HotspotsConfig) -> Result<()> {
if let Some(min) = c.min_lrs {
if min < 0.0 {
anyhow::bail!("min_lrs must be non-negative (got {})", min);
}
}
if let Some(w) = c.co_change_window_days {
if w == 0 {
anyhow::bail!("co_change_window_days must be at least 1");
}
}
if let Some(m) = c.co_change_min_count {
if m == 0 {
anyhow::bail!("co_change_min_count must be at least 1");
}
}
if let Some(p) = c.driver_threshold_percentile {
if p == 0 || p >= 100 {
anyhow::bail!(
"driver_threshold_percentile must be between 1 and 99 (got {})",
p
);
}
}
if let Some(k) = c.betweenness_approx_k {
if k == 0 {
anyhow::bail!("betweenness_approx_k must be at least 1");
}
}
Ok(())
}
fn validate_glob_patterns(include: &[String], exclude: &[String]) -> Result<()> {
for pattern in include {
Glob::new(pattern).with_context(|| format!("invalid include pattern: {}", pattern))?;
}
for pattern in exclude {
Glob::new(pattern).with_context(|| format!("invalid exclude pattern: {}", pattern))?;
}
Ok(())
}
fn validate_thresholds(t: &ThresholdConfig) -> Result<()> {
let moderate = t.moderate.unwrap_or(3.0);
let high = t.high.unwrap_or(6.0);
let critical = t.critical.unwrap_or(9.0);
if moderate <= 0.0 {
anyhow::bail!("thresholds.moderate must be positive (got {})", moderate);
}
if high <= 0.0 {
anyhow::bail!("thresholds.high must be positive (got {})", high);
}
if critical <= 0.0 {
anyhow::bail!("thresholds.critical must be positive (got {})", critical);
}
if moderate >= high {
anyhow::bail!(
"thresholds.moderate ({}) must be less than thresholds.high ({})",
moderate,
high
);
}
if high >= critical {
anyhow::bail!(
"thresholds.high ({}) must be less than thresholds.critical ({})",
high,
critical
);
}
Ok(())
}
fn validate_weights(w: &WeightConfig) -> Result<()> {
for (name, val) in [("cc", w.cc), ("nd", w.nd), ("fo", w.fo), ("ns", w.ns)] {
if let Some(v) = val {
if v < 0.0 {
anyhow::bail!("weights.{} must be non-negative (got {})", name, v);
}
if v > 10.0 {
anyhow::bail!("weights.{} must be at most 10.0 (got {})", name, v);
}
}
}
Ok(())
}
fn validate_warning_thresholds(wt: &WarningThresholdConfig) -> Result<()> {
let watch_min = wt.watch_min.unwrap_or(2.5);
let watch_max = wt.watch_max.unwrap_or(3.0);
let attention_min = wt.attention_min.unwrap_or(5.5);
let attention_max = wt.attention_max.unwrap_or(6.0);
let rapid_growth = wt.rapid_growth_percent.unwrap_or(50.0);
if watch_min <= 0.0 {
anyhow::bail!(
"warning_thresholds.watch_min must be positive (got {})",
watch_min
);
}
if watch_max <= 0.0 {
anyhow::bail!(
"warning_thresholds.watch_max must be positive (got {})",
watch_max
);
}
if attention_min <= 0.0 {
anyhow::bail!(
"warning_thresholds.attention_min must be positive (got {})",
attention_min
);
}
if attention_max <= 0.0 {
anyhow::bail!(
"warning_thresholds.attention_max must be positive (got {})",
attention_max
);
}
if rapid_growth <= 0.0 {
anyhow::bail!(
"warning_thresholds.rapid_growth_percent must be positive (got {})",
rapid_growth
);
}
if watch_min >= watch_max {
anyhow::bail!(
"warning_thresholds.watch_min ({}) must be less than watch_max ({})",
watch_min,
watch_max
);
}
if attention_min >= attention_max {
anyhow::bail!(
"warning_thresholds.attention_min ({}) must be less than attention_max ({})",
attention_min,
attention_max
);
}
Ok(())
}
fn validate_scoring(s: &ScoringWeightsConfig) -> Result<()> {
for (name, val) in [
("churn", s.churn),
("touch", s.touch),
("recency", s.recency),
("fan_in", s.fan_in),
("scc", s.scc),
("depth", s.depth),
("neighbor_churn", s.neighbor_churn),
] {
if let Some(v) = val {
if v < 0.0 {
anyhow::bail!("scoring.{} must be non-negative (got {})", name, v);
}
if v > 10.0 {
anyhow::bail!("scoring.{} must be at most 10.0 (got {})", name, v);
}
}
}
Ok(())
}
fn validate_pattern_thresholds(p: &PatternThresholdsConfig) -> Result<()> {
let usize_fields: &[(&str, Option<usize>)] = &[
("complex_branching_cc", p.complex_branching_cc),
("complex_branching_nd", p.complex_branching_nd),
("deeply_nested_nd", p.deeply_nested_nd),
("exit_heavy_ns", p.exit_heavy_ns),
("god_function_loc", p.god_function_loc),
("god_function_fo", p.god_function_fo),
("long_function_loc", p.long_function_loc),
("churn_magnet_churn", p.churn_magnet_churn),
("churn_magnet_cc", p.churn_magnet_cc),
("cyclic_hub_scc", p.cyclic_hub_scc),
("cyclic_hub_fan_in", p.cyclic_hub_fan_in),
("hub_function_fan_in", p.hub_function_fan_in),
("hub_function_cc", p.hub_function_cc),
("middle_man_fan_in", p.middle_man_fan_in),
("middle_man_fo", p.middle_man_fo),
("middle_man_cc_max", p.middle_man_cc_max),
("neighbor_risk_churn", p.neighbor_risk_churn),
("neighbor_risk_fo", p.neighbor_risk_fo),
("shotgun_target_fan_in", p.shotgun_target_fan_in),
("shotgun_target_churn", p.shotgun_target_churn),
("stale_complex_cc", p.stale_complex_cc),
("stale_complex_loc", p.stale_complex_loc),
];
for (name, val) in usize_fields {
if let Some(v) = val {
if *v == 0 {
anyhow::bail!("patterns.{} must be at least 1 (got 0)", name);
}
}
}
if let Some(v) = p.stale_complex_days {
if v == 0 {
anyhow::bail!("patterns.stale_complex_days must be at least 1 (got 0)");
}
}
Ok(())
}
impl HotspotsConfig {
pub fn resolve(&self) -> Result<ResolvedConfig> {
self.validate()?;
let include = if self.include.is_empty() {
None
} else {
let mut builder = GlobSetBuilder::new();
for pattern in &self.include {
builder.add(Glob::new(pattern)?);
}
Some(builder.build()?)
};
let exclude = {
let mut builder = GlobSetBuilder::new();
for pattern in DEFAULT_EXCLUDES {
builder.add(Glob::new(pattern)?);
}
for pattern in &self.exclude {
builder.add(Glob::new(pattern)?);
}
builder.build()?
};
let (moderate, high, critical) = match &self.thresholds {
Some(t) => (
t.moderate.unwrap_or(3.0),
t.high.unwrap_or(6.0),
t.critical.unwrap_or(9.0),
),
None => (3.0, 6.0, 9.0),
};
let (w_cc, w_nd, w_fo, w_ns) = match &self.weights {
Some(w) => (
w.cc.unwrap_or(1.0),
w.nd.unwrap_or(0.8),
w.fo.unwrap_or(0.6),
w.ns.unwrap_or(0.7),
),
None => (1.0, 0.8, 0.6, 0.7),
};
let (watch_min, watch_max, attention_min, attention_max, rapid_growth_percent) =
match &self.warning_thresholds {
Some(wt) => (
wt.watch_min.unwrap_or(2.5),
wt.watch_max.unwrap_or(3.0),
wt.attention_min.unwrap_or(5.5),
wt.attention_max.unwrap_or(6.0),
wt.rapid_growth_percent.unwrap_or(50.0),
),
None => (2.5, 3.0, 5.5, 6.0, 50.0),
};
let scoring_weights = match &self.scoring {
Some(s) => {
let defaults = crate::scoring::ScoringWeights::default();
crate::scoring::ScoringWeights {
churn: s.churn.unwrap_or(defaults.churn),
touch: s.touch.unwrap_or(defaults.touch),
recency: s.recency.unwrap_or(defaults.recency),
fan_in: s.fan_in.unwrap_or(defaults.fan_in),
scc: s.scc.unwrap_or(defaults.scc),
depth: s.depth.unwrap_or(defaults.depth),
neighbor_churn: s.neighbor_churn.unwrap_or(defaults.neighbor_churn),
}
}
None => crate::scoring::ScoringWeights::default(),
};
let pattern_thresholds = match &self.patterns {
Some(p) => {
let d = crate::patterns::Thresholds::default();
crate::patterns::Thresholds {
complex_branching_cc: p.complex_branching_cc.unwrap_or(d.complex_branching_cc),
complex_branching_nd: p.complex_branching_nd.unwrap_or(d.complex_branching_nd),
deeply_nested_nd: p.deeply_nested_nd.unwrap_or(d.deeply_nested_nd),
exit_heavy_ns: p.exit_heavy_ns.unwrap_or(d.exit_heavy_ns),
god_function_loc: p.god_function_loc.unwrap_or(d.god_function_loc),
god_function_fo: p.god_function_fo.unwrap_or(d.god_function_fo),
long_function_loc: p.long_function_loc.unwrap_or(d.long_function_loc),
churn_magnet_churn: p.churn_magnet_churn.unwrap_or(d.churn_magnet_churn),
churn_magnet_cc: p.churn_magnet_cc.unwrap_or(d.churn_magnet_cc),
cyclic_hub_scc: p.cyclic_hub_scc.unwrap_or(d.cyclic_hub_scc),
cyclic_hub_fan_in: p.cyclic_hub_fan_in.unwrap_or(d.cyclic_hub_fan_in),
hub_function_fan_in: p.hub_function_fan_in.unwrap_or(d.hub_function_fan_in),
hub_function_cc: p.hub_function_cc.unwrap_or(d.hub_function_cc),
middle_man_fan_in: p.middle_man_fan_in.unwrap_or(d.middle_man_fan_in),
middle_man_fo: p.middle_man_fo.unwrap_or(d.middle_man_fo),
middle_man_cc_max: p.middle_man_cc_max.unwrap_or(d.middle_man_cc_max),
neighbor_risk_churn: p.neighbor_risk_churn.unwrap_or(d.neighbor_risk_churn),
neighbor_risk_fo: p.neighbor_risk_fo.unwrap_or(d.neighbor_risk_fo),
shotgun_target_fan_in: p
.shotgun_target_fan_in
.unwrap_or(d.shotgun_target_fan_in),
shotgun_target_churn: p.shotgun_target_churn.unwrap_or(d.shotgun_target_churn),
stale_complex_cc: p.stale_complex_cc.unwrap_or(d.stale_complex_cc),
stale_complex_loc: p.stale_complex_loc.unwrap_or(d.stale_complex_loc),
stale_complex_days: p.stale_complex_days.unwrap_or(d.stale_complex_days),
}
}
None => crate::patterns::Thresholds::default(),
};
Ok(ResolvedConfig {
include,
exclude,
moderate_threshold: moderate,
high_threshold: high,
critical_threshold: critical,
weight_cc: w_cc,
weight_nd: w_nd,
weight_fo: w_fo,
weight_ns: w_ns,
watch_min,
watch_max,
attention_min,
attention_max,
rapid_growth_percent,
min_lrs: self.min_lrs,
top_n: self.top,
scoring_weights,
pattern_thresholds,
co_change_window_days: self.co_change_window_days.unwrap_or(90),
co_change_min_count: self.co_change_min_count.unwrap_or(3),
per_function_touches: self.per_function_touches.unwrap_or(false),
hybrid_touch_threshold: self.hybrid_touch_threshold,
driver_threshold_percentile: self.driver_threshold_percentile.unwrap_or(75),
betweenness_exact_threshold: self.betweenness_exact_threshold.unwrap_or(2000),
betweenness_approx_k: self.betweenness_approx_k.unwrap_or(256),
callgraph_skip_above: self.callgraph_skip_above.unwrap_or(usize::MAX),
config_path: None,
})
}
}
impl ResolvedConfig {
pub fn should_include(&self, path: &Path) -> bool {
let path_str = path.to_string_lossy();
if self.exclude.is_match(path_str.as_ref()) {
return false;
}
if let Some(ref include) = self.include {
return include.is_match(path_str.as_ref());
}
true
}
pub fn defaults() -> Result<Self> {
HotspotsConfig::default().resolve()
}
}
pub fn discover_config(project_root: &Path) -> Result<Option<(HotspotsConfig, PathBuf)>> {
let rc_path = project_root.join(".hotspotsrc.json");
if rc_path.exists() {
let config = load_config_file(&rc_path)?;
return Ok(Some((config, rc_path)));
}
let config_path = project_root.join("hotspots.config.json");
if config_path.exists() {
let config = load_config_file(&config_path)?;
return Ok(Some((config, config_path)));
}
let pkg_path = project_root.join("package.json");
if pkg_path.exists() {
if let Some(config) = load_from_package_json(&pkg_path)? {
return Ok(Some((config, pkg_path)));
}
}
Ok(None)
}
pub fn load_config_file(path: &Path) -> Result<HotspotsConfig> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read config file: {}", path.display()))?;
let config: HotspotsConfig = serde_json::from_str(&content)
.with_context(|| format!("failed to parse config file: {}", path.display()))?;
config
.validate()
.with_context(|| format!("invalid config in: {}", path.display()))?;
Ok(config)
}
fn load_from_package_json(path: &Path) -> Result<Option<HotspotsConfig>> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let pkg: serde_json::Value = serde_json::from_str(&content)
.with_context(|| format!("failed to parse {}", path.display()))?;
match pkg.get("hotspots") {
Some(hotspots_value) => {
let config: HotspotsConfig = serde_json::from_value(hotspots_value.clone())
.with_context(|| format!("invalid hotspots config in {}", path.display()))?;
config
.validate()
.with_context(|| format!("invalid hotspots config in {}", path.display()))?;
Ok(Some(config))
}
None => Ok(None),
}
}
pub fn load_and_resolve(project_root: &Path, config_path: Option<&Path>) -> Result<ResolvedConfig> {
let (config, source_path) = if let Some(path) = config_path {
let config = load_config_file(path)?;
(config, Some(path.to_path_buf()))
} else {
match discover_config(project_root)? {
Some((config, path)) => (config, Some(path)),
None => (HotspotsConfig::default(), None),
}
};
let mut resolved = config.resolve()?;
resolved.config_path = source_path;
Ok(resolved)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_default_config_is_valid() {
let config = HotspotsConfig::default();
config.validate().expect("default config should be valid");
let resolved = config.resolve().expect("default config should resolve");
assert!(resolved.include.is_none());
assert_eq!(resolved.weight_cc, 1.0);
assert_eq!(resolved.weight_nd, 0.8);
assert_eq!(resolved.weight_fo, 0.6);
assert_eq!(resolved.weight_ns, 0.7);
assert_eq!(resolved.moderate_threshold, 3.0);
assert_eq!(resolved.high_threshold, 6.0);
assert_eq!(resolved.critical_threshold, 9.0);
}
#[test]
fn test_parse_minimal_config() {
let json = r#"{}"#;
let config: HotspotsConfig = serde_json::from_str(json).unwrap();
config.validate().unwrap();
}
#[test]
fn test_parse_full_config() {
let json = r#"{
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["**/*.test.ts", "**/node_modules/**"],
"thresholds": {
"moderate": 4.0,
"high": 7.0,
"critical": 10.0
},
"weights": {
"cc": 1.2,
"nd": 0.9,
"fo": 0.5,
"ns": 0.8
},
"min_lrs": 2.0,
"top": 20
}"#;
let config: HotspotsConfig = serde_json::from_str(json).unwrap();
config.validate().unwrap();
let resolved = config.resolve().unwrap();
assert!(resolved.include.is_some());
assert_eq!(resolved.moderate_threshold, 4.0);
assert_eq!(resolved.high_threshold, 7.0);
assert_eq!(resolved.critical_threshold, 10.0);
assert_eq!(resolved.weight_cc, 1.2);
assert_eq!(resolved.min_lrs, Some(2.0));
assert_eq!(resolved.top_n, Some(20));
}
#[test]
fn test_reject_unknown_fields() {
let json = r#"{"unknown_field": true}"#;
let result: Result<HotspotsConfig, _> = serde_json::from_str(json);
assert!(result.is_err(), "unknown fields should be rejected");
}
#[test]
fn test_reject_negative_weight() {
let json = r#"{"weights": {"cc": -1.0}}"#;
let config: HotspotsConfig = serde_json::from_str(json).unwrap();
assert!(config.validate().is_err());
}
#[test]
fn test_reject_weight_over_10() {
let json = r#"{"weights": {"cc": 11.0}}"#;
let config: HotspotsConfig = serde_json::from_str(json).unwrap();
assert!(config.validate().is_err());
}
#[test]
fn test_reject_negative_threshold() {
let json = r#"{"thresholds": {"moderate": -1.0}}"#;
let config: HotspotsConfig = serde_json::from_str(json).unwrap();
assert!(config.validate().is_err());
}
#[test]
fn test_reject_unordered_thresholds() {
let json = r#"{"thresholds": {"moderate": 6.0, "high": 3.0, "critical": 9.0}}"#;
let config: HotspotsConfig = serde_json::from_str(json).unwrap();
assert!(config.validate().is_err());
}
#[test]
fn test_reject_invalid_glob_pattern() {
let json = r#"{"include": ["[invalid"]}"#;
let config: HotspotsConfig = serde_json::from_str(json).unwrap();
assert!(config.validate().is_err());
}
#[test]
fn test_should_include_default_excludes() {
let resolved = ResolvedConfig::defaults().unwrap();
assert!(!resolved.should_include(Path::new("src/foo.test.ts")));
assert!(!resolved.should_include(Path::new("node_modules/pkg/index.js")));
assert!(!resolved.should_include(Path::new("dist/bundle.js")));
assert!(resolved.should_include(Path::new("src/api.ts")));
assert!(resolved.should_include(Path::new("src/components/Button.tsx")));
}
#[test]
fn test_should_include_custom_patterns() {
let config: HotspotsConfig = serde_json::from_str(
r#"{
"include": ["src/**/*.ts"],
"exclude": ["src/generated/**"]
}"#,
)
.unwrap();
let resolved = config.resolve().unwrap();
assert!(resolved.should_include(Path::new("src/api.ts")));
assert!(!resolved.should_include(Path::new("lib/util.ts")));
assert!(!resolved.should_include(Path::new("src/generated/types.ts")));
}
#[test]
fn test_discover_hotspotsrc() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join(".hotspotsrc.json");
fs::write(&config_path, r#"{"min_lrs": 5.0}"#).unwrap();
let result = discover_config(dir.path()).unwrap();
assert!(result.is_some());
let (config, path) = result.unwrap();
assert_eq!(config.min_lrs, Some(5.0));
assert_eq!(path, config_path);
}
#[test]
fn test_discover_hotspots_config_json() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("hotspots.config.json");
fs::write(&config_path, r#"{"top": 10}"#).unwrap();
let result = discover_config(dir.path()).unwrap();
assert!(result.is_some());
let (config, _) = result.unwrap();
assert_eq!(config.top, Some(10));
}
#[test]
fn test_discover_package_json() {
let dir = tempfile::tempdir().unwrap();
let pkg_path = dir.path().join("package.json");
fs::write(
&pkg_path,
r#"{
"name": "my-project",
"version": "1.0.0",
"hotspots": {
"exclude": ["**/*.test.ts"],
"min_lrs": 3.0
}
}"#,
)
.unwrap();
let result = discover_config(dir.path()).unwrap();
assert!(result.is_some());
let (config, _) = result.unwrap();
assert_eq!(config.min_lrs, Some(3.0));
assert_eq!(config.exclude, vec!["**/*.test.ts"]);
}
#[test]
fn test_discover_package_json_without_hotspots_key() {
let dir = tempfile::tempdir().unwrap();
let pkg_path = dir.path().join("package.json");
fs::write(&pkg_path, r#"{"name": "my-project", "version": "1.0.0"}"#).unwrap();
let result = discover_config(dir.path()).unwrap();
assert!(result.is_none());
}
#[test]
fn test_discover_priority_order() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(".hotspotsrc.json"), r#"{"min_lrs": 1.0}"#).unwrap();
fs::write(
dir.path().join("hotspots.config.json"),
r#"{"min_lrs": 2.0}"#,
)
.unwrap();
let result = discover_config(dir.path()).unwrap();
let (config, _) = result.unwrap();
assert_eq!(
config.min_lrs,
Some(1.0),
".hotspotsrc.json should take priority"
);
}
#[test]
fn test_no_config_returns_none() {
let dir = tempfile::tempdir().unwrap();
let result = discover_config(dir.path()).unwrap();
assert!(result.is_none());
}
#[test]
fn test_load_and_resolve_defaults() {
let dir = tempfile::tempdir().unwrap();
let resolved = load_and_resolve(dir.path(), None).unwrap();
assert!(resolved.config_path.is_none());
assert_eq!(resolved.weight_cc, 1.0);
}
#[test]
fn test_load_and_resolve_explicit_path() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("custom.json");
fs::write(&config_path, r#"{"weights": {"cc": 2.0}}"#).unwrap();
let resolved = load_and_resolve(dir.path(), Some(&config_path)).unwrap();
assert_eq!(resolved.weight_cc, 2.0);
assert_eq!(resolved.config_path, Some(config_path));
}
#[test]
fn test_partial_weights_use_defaults_for_rest() {
let json = r#"{"weights": {"cc": 2.0}}"#;
let config: HotspotsConfig = serde_json::from_str(json).unwrap();
let resolved = config.resolve().unwrap();
assert_eq!(resolved.weight_cc, 2.0);
assert_eq!(resolved.weight_nd, 0.8); assert_eq!(resolved.weight_fo, 0.6); assert_eq!(resolved.weight_ns, 0.7); }
#[test]
fn test_partial_thresholds_use_defaults_for_rest() {
let json = r#"{"thresholds": {"critical": 12.0}}"#;
let config: HotspotsConfig = serde_json::from_str(json).unwrap();
let resolved = config.resolve().unwrap();
assert_eq!(resolved.moderate_threshold, 3.0); assert_eq!(resolved.high_threshold, 6.0); assert_eq!(resolved.critical_threshold, 12.0);
}
#[test]
fn test_default_warning_thresholds() {
let config = HotspotsConfig::default();
let resolved = config.resolve().unwrap();
assert_eq!(resolved.watch_min, 2.5);
assert_eq!(resolved.watch_max, 3.0);
assert_eq!(resolved.attention_min, 5.5);
assert_eq!(resolved.attention_max, 6.0);
assert_eq!(resolved.rapid_growth_percent, 50.0);
}
#[test]
fn test_parse_warning_thresholds() {
let json = r#"{
"warning_thresholds": {
"watch_min": 2.0,
"watch_max": 3.5,
"attention_min": 5.0,
"attention_max": 7.0,
"rapid_growth_percent": 75.0
}
}"#;
let config: HotspotsConfig = serde_json::from_str(json).unwrap();
config.validate().unwrap();
let resolved = config.resolve().unwrap();
assert_eq!(resolved.watch_min, 2.0);
assert_eq!(resolved.watch_max, 3.5);
assert_eq!(resolved.attention_min, 5.0);
assert_eq!(resolved.attention_max, 7.0);
assert_eq!(resolved.rapid_growth_percent, 75.0);
}
#[test]
fn test_partial_warning_thresholds_use_defaults() {
let json = r#"{"warning_thresholds": {"rapid_growth_percent": 100.0}}"#;
let config: HotspotsConfig = serde_json::from_str(json).unwrap();
let resolved = config.resolve().unwrap();
assert_eq!(resolved.watch_min, 2.5); assert_eq!(resolved.watch_max, 3.0); assert_eq!(resolved.attention_min, 5.5); assert_eq!(resolved.attention_max, 6.0); assert_eq!(resolved.rapid_growth_percent, 100.0);
}
#[test]
fn test_reject_negative_watch_min() {
let json = r#"{"warning_thresholds": {"watch_min": -1.0}}"#;
let config: HotspotsConfig = serde_json::from_str(json).unwrap();
assert!(config.validate().is_err());
}
#[test]
fn test_reject_negative_rapid_growth() {
let json = r#"{"warning_thresholds": {"rapid_growth_percent": -10.0}}"#;
let config: HotspotsConfig = serde_json::from_str(json).unwrap();
assert!(config.validate().is_err());
}
#[test]
fn test_reject_unordered_watch_thresholds() {
let json = r#"{"warning_thresholds": {"watch_min": 3.0, "watch_max": 2.0}}"#;
let config: HotspotsConfig = serde_json::from_str(json).unwrap();
assert!(config.validate().is_err());
}
#[test]
fn test_reject_unordered_attention_thresholds() {
let json = r#"{"warning_thresholds": {"attention_min": 7.0, "attention_max": 5.0}}"#;
let config: HotspotsConfig = serde_json::from_str(json).unwrap();
assert!(config.validate().is_err());
}
#[test]
fn test_full_config_with_warnings() {
let json = r#"{
"thresholds": {
"moderate": 3.0,
"high": 6.0,
"critical": 9.0
},
"warning_thresholds": {
"watch_min": 2.5,
"watch_max": 3.0,
"attention_min": 5.5,
"attention_max": 6.0,
"rapid_growth_percent": 50.0
}
}"#;
let config: HotspotsConfig = serde_json::from_str(json).unwrap();
config.validate().unwrap();
let resolved = config.resolve().unwrap();
assert_eq!(resolved.watch_min, 2.5);
assert_eq!(resolved.watch_max, 3.0);
assert_eq!(resolved.attention_min, 5.5);
assert_eq!(resolved.attention_max, 6.0);
assert_eq!(resolved.rapid_growth_percent, 50.0);
}
#[test]
fn test_scoring_weights_defaults() {
let config = HotspotsConfig::default();
let resolved = config.resolve().unwrap();
let defaults = crate::scoring::ScoringWeights::default();
assert_eq!(resolved.scoring_weights.churn, defaults.churn);
assert_eq!(resolved.scoring_weights.touch, defaults.touch);
assert_eq!(resolved.scoring_weights.recency, defaults.recency);
assert_eq!(resolved.scoring_weights.fan_in, defaults.fan_in);
assert_eq!(resolved.scoring_weights.scc, defaults.scc);
assert_eq!(resolved.scoring_weights.depth, defaults.depth);
assert_eq!(
resolved.scoring_weights.neighbor_churn,
defaults.neighbor_churn
);
}
#[test]
fn test_scoring_weights_from_config() {
let json = r#"{
"scoring": {
"churn": 0.8,
"touch": 0.5,
"fan_in": 0.6
}
}"#;
let config: HotspotsConfig = serde_json::from_str(json).unwrap();
config.validate().unwrap();
let resolved = config.resolve().unwrap();
assert_eq!(resolved.scoring_weights.churn, 0.8);
assert_eq!(resolved.scoring_weights.touch, 0.5);
assert_eq!(resolved.scoring_weights.fan_in, 0.6);
let defaults = crate::scoring::ScoringWeights::default();
assert_eq!(resolved.scoring_weights.recency, defaults.recency);
assert_eq!(resolved.scoring_weights.scc, defaults.scc);
}
#[test]
fn test_reject_negative_scoring_weight() {
let json = r#"{"scoring": {"churn": -0.1}}"#;
let config: HotspotsConfig = serde_json::from_str(json).unwrap();
assert!(config.validate().is_err());
}
#[test]
fn test_reject_scoring_weight_over_10() {
let json = r#"{"scoring": {"fan_in": 11.0}}"#;
let config: HotspotsConfig = serde_json::from_str(json).unwrap();
assert!(config.validate().is_err());
}
}