Skip to main content

hotspots_core/
config.rs

1//! Configuration file support for Hotspots
2//!
3//! Loads project-specific configuration from JSON files.
4//!
5//! Search order:
6//! 1. Explicit path (--config CLI flag)
7//! 2. `.hotspotsrc.json` in project root
8//! 3. `hotspots.config.json` in project root
9//! 4. `"hotspots"` key in `package.json`
10//!
11//! All fields are optional. CLI flags take precedence over config file values.
12
13use anyhow::{Context, Result};
14use globset::{Glob, GlobSet, GlobSetBuilder};
15use serde::{Deserialize, Serialize};
16use std::path::{Path, PathBuf};
17
18/// Default exclude patterns always applied (merged with any user-specified excludes).
19const DEFAULT_EXCLUDES: &[&str] = &[
20    // Test files
21    "**/*.test.ts",
22    "**/*.test.tsx",
23    "**/*.test.js",
24    "**/*.test.jsx",
25    "**/*.spec.ts",
26    "**/*.spec.tsx",
27    "**/*.spec.js",
28    "**/*.spec.jsx",
29    "**/__tests__/**",
30    "**/__mocks__/**",
31    "**/__snapshots__/**",
32    // Python test conventions
33    "**/test_*.py",
34    "**/*_test.py",
35    // Go test and generated conventions
36    "**/*_test.go",
37    "**/vendor/**",
38    "**/*.pb.go",
39    "**/zz_generated*.go",
40    "**/mock_*.go",
41    // JS/TS build output and generated code
42    "**/node_modules/**",
43    "**/dist/**",
44    "**/build/**",
45    "**/.next/**",
46    "**/.nuxt/**",
47    "**/.output/**",
48    "**/.cache/**",
49    "**/.turbo/**",
50    "**/storybook-static/**",
51    "**/*.min.js",
52    "**/*.min.ts",
53    "**/*.bundle.js",
54    "**/__generated__/**",
55    "**/generated/**",
56    "**/*.generated.ts",
57    "**/*.generated.js",
58    "**/*.pb.ts",
59    "**/*.pb.js",
60    // Python virtualenvs and cache
61    "**/venv/**",
62    "**/.venv/**",
63    "**/__pycache__/**",
64    // Django auto-generated migrations
65    "**/migrations/**",
66    // Java/Kotlin build output
67    "**/target/**",
68    "**/out/**",
69    // C/C++ vendored dependencies (common bundling conventions)
70    "**/deps/**",
71    "**/third_party/**",
72    "**/thirdparty/**",
73    "**/external/**",
74    "**/extern/**",
75    "**/contrib/**",
76];
77
78/// Hotspots configuration loaded from a JSON config file
79#[derive(Debug, Clone, Default, Serialize, Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct HotspotsConfig {
82    /// Glob patterns for files to include (default: all supported extensions)
83    #[serde(default)]
84    pub include: Vec<String>,
85
86    /// Glob patterns for files to exclude (default: test files, node_modules, dist)
87    #[serde(default)]
88    pub exclude: Vec<String>,
89
90    /// Custom risk band thresholds
91    #[serde(default)]
92    pub thresholds: Option<ThresholdConfig>,
93
94    /// Custom metric weights for LRS calculation
95    #[serde(default)]
96    pub weights: Option<WeightConfig>,
97
98    /// Warning thresholds for proactive alerts
99    #[serde(default)]
100    pub warning_thresholds: Option<WarningThresholdConfig>,
101
102    /// Minimum LRS to report (default: 0.0, report all)
103    #[serde(default)]
104    pub min_lrs: Option<f64>,
105
106    /// Maximum number of results to show
107    #[serde(default)]
108    pub top: Option<usize>,
109
110    /// Activity risk scoring weights
111    #[serde(default)]
112    pub scoring: Option<ScoringWeightsConfig>,
113
114    /// Number of days back to look for co-change pairs (default: 90)
115    #[serde(default)]
116    pub co_change_window_days: Option<u64>,
117
118    /// Minimum number of co-changes required to report a pair (default: 3)
119    #[serde(default)]
120    pub co_change_min_count: Option<usize>,
121
122    /// Use per-function git log -L for touch metrics (default: false).
123    /// Warm runs use the on-disk cache and are as fast as file-level.
124    /// Set to true for full precision; the default is hybrid touch mode (see hybrid_touch_threshold).
125    #[serde(default)]
126    pub per_function_touches: Option<bool>,
127
128    /// Enable hybrid touch mode: file-level for all functions, then per-function
129    /// only for functions in files with touch_count_30d >= this threshold.
130    /// Overrides per_function_touches when set. Recommended: 3–10.
131    #[serde(default)]
132    pub hybrid_touch_threshold: Option<usize>,
133
134    /// Percentile threshold for driving dimension detection (1–99, default: 75).
135    /// A function must exceed this percentile in a metric to trigger that driver label.
136    /// Lower values = more functions get specific labels; higher = only extreme outliers.
137    #[serde(default)]
138    pub driver_threshold_percentile: Option<u8>,
139
140    /// Node count above which betweenness centrality switches from exact to approximate
141    /// (default: 2000). Below this threshold the exact O(N²) Brandes algorithm runs;
142    /// above it, k-source pivot sampling is used instead.
143    #[serde(default)]
144    pub betweenness_exact_threshold: Option<usize>,
145
146    /// Number of pivot sources for approximate betweenness centrality (default: 256).
147    /// Higher values improve accuracy at proportional cost. Only used when the graph
148    /// exceeds `betweenness_exact_threshold`. Must be at least 1.
149    #[serde(default)]
150    pub betweenness_approx_k: Option<usize>,
151
152    /// Skip all call graph computation when the repo has more than this many functions
153    /// (default: no limit). Use this on very large repos where graph algorithms dominate
154    /// CPU. When skipped, PageRank, betweenness, SCC, fan-in/fan-out, and dependency
155    /// depth are all omitted from output.
156    #[serde(default)]
157    pub callgraph_skip_above: Option<usize>,
158
159    /// Pattern detection thresholds. Overrides defaults from `docs/patterns.md`.
160    #[serde(default)]
161    pub patterns: Option<PatternThresholdsConfig>,
162
163    /// Per-repo severity overrides for blocking policies.
164    #[serde(default)]
165    pub policy: Option<PolicyConfig>,
166}
167
168/// Severity for a blocking policy, as configured per-repo.
169///
170/// A repo whose baseline LRS naturally runs high (e.g. a research repo with
171/// dense, one-shot scripts) can downgrade a policy from `block` to `warn`, or
172/// disable it with `off`, without changing what counts as Critical (see
173/// `thresholds.critical`) or which findings are reported.
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum PolicyMode {
176    Block,
177    Warn,
178    Off,
179}
180
181impl PolicyMode {
182    fn parse(field: &str, s: &str) -> Result<Self> {
183        match s {
184            "block" => Ok(PolicyMode::Block),
185            "warn" => Ok(PolicyMode::Warn),
186            "off" => Ok(PolicyMode::Off),
187            other => anyhow::bail!(
188                "policy.{} must be one of \"block\", \"warn\", \"off\" (got \"{}\")",
189                field,
190                other
191            ),
192        }
193    }
194}
195
196/// Per-repo severity overrides for blocking policies.
197///
198/// Both `critical-introduction` (a function becomes Critical) and
199/// `excessive-risk-regression` (an existing function jumps by >= 1.0 LRS)
200/// default to `block`. Note: `critical-introduction` fires identically
201/// whether the function is brand-new or an existing function regressed —
202/// a Critical function needs review either way, so there is deliberately
203/// no separate "new vs. regressed" knob here, only an overall severity.
204///
205/// Downgrading either policy below `block` requires a `_reason` string
206/// (mirroring the `// hotspots-ignore: <reason>` convention for per-function
207/// suppression), so a reviewer of the `.hotspotsrc.json` diff sees *why* a
208/// blocking gate was weakened, not just that it was.
209#[derive(Debug, Clone, Serialize, Deserialize)]
210#[serde(deny_unknown_fields)]
211pub struct PolicyConfig {
212    /// Severity for `critical-introduction`: "block" | "warn" | "off" (default: "block")
213    pub critical_introduction: Option<String>,
214    /// Required when `critical_introduction` is not "block"
215    pub critical_introduction_reason: Option<String>,
216    /// Severity for `excessive-risk-regression`: "block" | "warn" | "off" (default: "block")
217    pub excessive_risk_regression: Option<String>,
218    /// Required when `excessive_risk_regression` is not "block"
219    pub excessive_risk_regression_reason: Option<String>,
220}
221
222/// Custom risk band thresholds
223#[derive(Debug, Clone, Serialize, Deserialize)]
224#[serde(deny_unknown_fields)]
225pub struct ThresholdConfig {
226    /// LRS threshold for moderate risk (default: 3.0)
227    pub moderate: Option<f64>,
228    /// LRS threshold for high risk (default: 6.0)
229    pub high: Option<f64>,
230    /// LRS threshold for critical risk (default: 9.0)
231    pub critical: Option<f64>,
232}
233
234/// Custom metric weights for LRS calculation
235#[derive(Debug, Clone, Serialize, Deserialize)]
236#[serde(deny_unknown_fields)]
237pub struct WeightConfig {
238    /// Weight for cyclomatic complexity (default: 1.0)
239    pub cc: Option<f64>,
240    /// Weight for nesting depth (default: 0.8)
241    pub nd: Option<f64>,
242    /// Weight for fan-out (default: 0.6)
243    pub fo: Option<f64>,
244    /// Weight for non-structured exits (default: 0.7)
245    pub ns: Option<f64>,
246}
247
248/// Weights for activity-weighted risk scoring
249#[derive(Debug, Clone, Serialize, Deserialize)]
250#[serde(deny_unknown_fields)]
251pub struct ScoringWeightsConfig {
252    /// Weight for churn factor (default: 0.5)
253    pub churn: Option<f64>,
254    /// Weight for touch frequency factor (default: 0.3)
255    pub touch: Option<f64>,
256    /// Weight for recency factor (default: 0.2)
257    pub recency: Option<f64>,
258    /// Weight for fan-in factor (default: 0.4)
259    pub fan_in: Option<f64>,
260    /// Weight for SCC penalty (default: 0.3)
261    pub scc: Option<f64>,
262    /// Weight for dependency depth penalty (default: 0.1)
263    pub depth: Option<f64>,
264    /// Weight for neighbor churn factor (default: 0.2)
265    pub neighbor_churn: Option<f64>,
266    /// Weight for commit-timing burstiness factor (default: 0.3)
267    pub burst: Option<f64>,
268}
269
270/// Pattern detection thresholds — override defaults from `docs/patterns.md`
271#[derive(Debug, Clone, Serialize, Deserialize)]
272#[serde(deny_unknown_fields)]
273pub struct PatternThresholdsConfig {
274    pub complex_branching_cc: Option<usize>,
275    pub complex_branching_nd: Option<usize>,
276    pub deeply_nested_nd: Option<usize>,
277    pub exit_heavy_ns: Option<usize>,
278    pub god_function_loc: Option<usize>,
279    pub god_function_fo: Option<usize>,
280    pub long_function_loc: Option<usize>,
281    pub churn_magnet_churn: Option<usize>,
282    pub churn_magnet_cc: Option<usize>,
283    pub cyclic_hub_scc: Option<usize>,
284    pub cyclic_hub_fan_in: Option<usize>,
285    pub hub_function_fan_in: Option<usize>,
286    pub hub_function_cc: Option<usize>,
287    pub middle_man_fan_in: Option<usize>,
288    pub middle_man_fo: Option<usize>,
289    pub middle_man_cc_max: Option<usize>,
290    pub neighbor_risk_churn: Option<usize>,
291    pub neighbor_risk_fo: Option<usize>,
292    pub shotgun_target_fan_in: Option<usize>,
293    pub shotgun_target_churn: Option<usize>,
294    pub stale_complex_cc: Option<usize>,
295    pub stale_complex_loc: Option<usize>,
296    pub stale_complex_days: Option<u32>,
297}
298
299/// Warning thresholds for proactive alerts
300#[derive(Debug, Clone, Serialize, Deserialize)]
301#[serde(deny_unknown_fields)]
302pub struct WarningThresholdConfig {
303    /// Watch threshold minimum - approaching moderate (default: 2.5)
304    pub watch_min: Option<f64>,
305    /// Watch threshold maximum - approaching moderate (default: 3.0)
306    pub watch_max: Option<f64>,
307    /// Attention threshold minimum - approaching high (default: 5.5)
308    pub attention_min: Option<f64>,
309    /// Attention threshold maximum - approaching high (default: 6.0)
310    pub attention_max: Option<f64>,
311    /// Rapid growth threshold - percent increase (default: 50.0)
312    pub rapid_growth_percent: Option<f64>,
313}
314
315/// Resolved configuration with compiled glob patterns
316#[derive(Debug)]
317pub struct ResolvedConfig {
318    /// Compiled include patterns (empty means include all)
319    pub include: Option<GlobSet>,
320    /// Compiled exclude patterns
321    pub exclude: GlobSet,
322    /// Risk band thresholds
323    pub moderate_threshold: f64,
324    pub high_threshold: f64,
325    pub critical_threshold: f64,
326    /// LRS weights
327    pub weight_cc: f64,
328    pub weight_nd: f64,
329    pub weight_fo: f64,
330    pub weight_ns: f64,
331    /// Warning thresholds
332    pub watch_min: f64,
333    pub watch_max: f64,
334    pub attention_min: f64,
335    pub attention_max: f64,
336    pub rapid_growth_percent: f64,
337    /// Filters
338    pub min_lrs: Option<f64>,
339    pub top_n: Option<usize>,
340    /// Co-change mining parameters
341    pub co_change_window_days: u64,
342    pub co_change_min_count: usize,
343    /// Whether to use per-function git log -L for touch metrics
344    pub per_function_touches: bool,
345    /// Hybrid touch threshold: Some(n) = file-level first, per-function for files with ≥n touches
346    pub hybrid_touch_threshold: Option<usize>,
347    /// Percentile threshold for driving dimension detection (1–99)
348    pub driver_threshold_percentile: u8,
349    /// Node count above which betweenness switches to approximate algorithm
350    pub betweenness_exact_threshold: usize,
351    /// Number of pivot sources for approximate betweenness
352    pub betweenness_approx_k: usize,
353    /// Skip all call graph computation above this function count (usize::MAX = never skip)
354    pub callgraph_skip_above: usize,
355    /// Activity risk scoring weights
356    pub scoring_weights: crate::scoring::ScoringWeights,
357    /// Pattern detection thresholds
358    pub pattern_thresholds: crate::patterns::Thresholds,
359    /// Severity for the `critical-introduction` policy (default: Block)
360    pub critical_introduction_mode: PolicyMode,
361    /// Reason given for downgrading `critical_introduction_mode` below Block (None if Block)
362    pub critical_introduction_reason: Option<String>,
363    /// Severity for the `excessive-risk-regression` policy (default: Block)
364    pub excessive_risk_regression_mode: PolicyMode,
365    /// Reason given for downgrading `excessive_risk_regression_mode` below Block (None if Block)
366    pub excessive_risk_regression_reason: Option<String>,
367    /// Path the config was loaded from (None if defaults)
368    pub config_path: Option<PathBuf>,
369    /// Whether the loaded config file set an `exclude` key specifically
370    /// (as opposed to `exclude` being active only via built-in defaults)
371    pub exclude_is_custom: bool,
372}
373
374impl HotspotsConfig {
375    /// Validate the configuration for logical errors
376    pub fn validate(&self) -> Result<()> {
377        if let Some(ref t) = self.thresholds {
378            validate_thresholds(t)?;
379        }
380        if let Some(ref w) = self.weights {
381            validate_weights(w)?;
382        }
383        if let Some(ref wt) = self.warning_thresholds {
384            validate_warning_thresholds(wt)?;
385        }
386        if let Some(ref s) = self.scoring {
387            validate_scoring(s)?;
388        }
389        if let Some(ref p) = self.patterns {
390            validate_pattern_thresholds(p)?;
391        }
392        if let Some(ref p) = self.policy {
393            validate_policy_config(p)?;
394        }
395        validate_scalar_fields(self)?;
396        validate_glob_patterns(&self.include, &self.exclude)
397    }
398}
399
400fn validate_scalar_fields(c: &HotspotsConfig) -> Result<()> {
401    if let Some(min) = c.min_lrs {
402        if min < 0.0 {
403            anyhow::bail!("min_lrs must be non-negative (got {})", min);
404        }
405    }
406    if let Some(w) = c.co_change_window_days {
407        if w == 0 {
408            anyhow::bail!("co_change_window_days must be at least 1");
409        }
410    }
411    if let Some(m) = c.co_change_min_count {
412        if m == 0 {
413            anyhow::bail!("co_change_min_count must be at least 1");
414        }
415    }
416    if let Some(p) = c.driver_threshold_percentile {
417        if p == 0 || p >= 100 {
418            anyhow::bail!(
419                "driver_threshold_percentile must be between 1 and 99 (got {})",
420                p
421            );
422        }
423    }
424    if let Some(k) = c.betweenness_approx_k {
425        if k == 0 {
426            anyhow::bail!("betweenness_approx_k must be at least 1");
427        }
428    }
429    Ok(())
430}
431
432fn validate_glob_patterns(include: &[String], exclude: &[String]) -> Result<()> {
433    for pattern in include {
434        Glob::new(pattern).with_context(|| format!("invalid include pattern: {}", pattern))?;
435    }
436    for pattern in exclude {
437        Glob::new(pattern).with_context(|| format!("invalid exclude pattern: {}", pattern))?;
438    }
439    Ok(())
440}
441
442fn validate_thresholds(t: &ThresholdConfig) -> Result<()> {
443    let moderate = t.moderate.unwrap_or(3.0);
444    let high = t.high.unwrap_or(6.0);
445    let critical = t.critical.unwrap_or(9.0);
446    if moderate <= 0.0 {
447        anyhow::bail!("thresholds.moderate must be positive (got {})", moderate);
448    }
449    if high <= 0.0 {
450        anyhow::bail!("thresholds.high must be positive (got {})", high);
451    }
452    if critical <= 0.0 {
453        anyhow::bail!("thresholds.critical must be positive (got {})", critical);
454    }
455    if moderate >= high {
456        anyhow::bail!(
457            "thresholds.moderate ({}) must be less than thresholds.high ({})",
458            moderate,
459            high
460        );
461    }
462    if high >= critical {
463        anyhow::bail!(
464            "thresholds.high ({}) must be less than thresholds.critical ({})",
465            high,
466            critical
467        );
468    }
469    Ok(())
470}
471
472fn validate_policy_config(p: &PolicyConfig) -> Result<()> {
473    if let Some(ref s) = p.critical_introduction {
474        let mode = PolicyMode::parse("critical_introduction", s)?;
475        require_reason_if_downgraded(
476            "critical_introduction",
477            mode,
478            p.critical_introduction_reason.as_deref(),
479        )?;
480    }
481    if let Some(ref s) = p.excessive_risk_regression {
482        let mode = PolicyMode::parse("excessive_risk_regression", s)?;
483        require_reason_if_downgraded(
484            "excessive_risk_regression",
485            mode,
486            p.excessive_risk_regression_reason.as_deref(),
487        )?;
488    }
489    Ok(())
490}
491
492/// A policy downgraded below `block` must carry a non-empty `<field>_reason`,
493/// mirroring the `// hotspots-ignore: <reason>` convention — so a reviewer of
494/// the config diff sees why a blocking gate was weakened.
495fn require_reason_if_downgraded(field: &str, mode: PolicyMode, reason: Option<&str>) -> Result<()> {
496    if mode == PolicyMode::Block {
497        return Ok(());
498    }
499    match reason {
500        Some(r) if !r.trim().is_empty() => Ok(()),
501        _ => anyhow::bail!(
502            "policy.{field}_reason is required when policy.{field} is not \"block\" (explain why this gate is downgraded)",
503            field = field
504        ),
505    }
506}
507
508fn validate_weights(w: &WeightConfig) -> Result<()> {
509    for (name, val) in [("cc", w.cc), ("nd", w.nd), ("fo", w.fo), ("ns", w.ns)] {
510        if let Some(v) = val {
511            if v < 0.0 {
512                anyhow::bail!("weights.{} must be non-negative (got {})", name, v);
513            }
514            if v > 10.0 {
515                anyhow::bail!("weights.{} must be at most 10.0 (got {})", name, v);
516            }
517        }
518    }
519
520    // Joint degeneracy check: bounds-checking each weight individually lets
521    // cc = nd = fo = ns = 0 pass silently, which collapses every function's
522    // LRS score to 0 and disables risk scoring entirely. Resolve unset fields
523    // to their defaults (matching ResolvedConfig::resolve) before checking,
524    // since only an explicit all-zero override is degenerate.
525    let resolved_cc = w.cc.unwrap_or(1.0);
526    let resolved_nd = w.nd.unwrap_or(0.8);
527    let resolved_fo = w.fo.unwrap_or(0.6);
528    let resolved_ns = w.ns.unwrap_or(0.7);
529    if resolved_cc == 0.0 && resolved_nd == 0.0 && resolved_fo == 0.0 && resolved_ns == 0.0 {
530        anyhow::bail!(
531            "weights.cc, weights.nd, weights.fo, and weights.ns cannot all be 0 — this would collapse every function's LRS score to 0 and silently disable risk scoring"
532        );
533    }
534
535    Ok(())
536}
537
538fn validate_warning_thresholds(wt: &WarningThresholdConfig) -> Result<()> {
539    let watch_min = wt.watch_min.unwrap_or(2.5);
540    let watch_max = wt.watch_max.unwrap_or(3.0);
541    let attention_min = wt.attention_min.unwrap_or(5.5);
542    let attention_max = wt.attention_max.unwrap_or(6.0);
543    let rapid_growth = wt.rapid_growth_percent.unwrap_or(50.0);
544    if watch_min <= 0.0 {
545        anyhow::bail!(
546            "warning_thresholds.watch_min must be positive (got {})",
547            watch_min
548        );
549    }
550    if watch_max <= 0.0 {
551        anyhow::bail!(
552            "warning_thresholds.watch_max must be positive (got {})",
553            watch_max
554        );
555    }
556    if attention_min <= 0.0 {
557        anyhow::bail!(
558            "warning_thresholds.attention_min must be positive (got {})",
559            attention_min
560        );
561    }
562    if attention_max <= 0.0 {
563        anyhow::bail!(
564            "warning_thresholds.attention_max must be positive (got {})",
565            attention_max
566        );
567    }
568    if rapid_growth <= 0.0 {
569        anyhow::bail!(
570            "warning_thresholds.rapid_growth_percent must be positive (got {})",
571            rapid_growth
572        );
573    }
574    if watch_min >= watch_max {
575        anyhow::bail!(
576            "warning_thresholds.watch_min ({}) must be less than watch_max ({})",
577            watch_min,
578            watch_max
579        );
580    }
581    if attention_min >= attention_max {
582        anyhow::bail!(
583            "warning_thresholds.attention_min ({}) must be less than attention_max ({})",
584            attention_min,
585            attention_max
586        );
587    }
588    Ok(())
589}
590
591fn validate_scoring(s: &ScoringWeightsConfig) -> Result<()> {
592    for (name, val) in [
593        ("churn", s.churn),
594        ("touch", s.touch),
595        ("recency", s.recency),
596        ("fan_in", s.fan_in),
597        ("scc", s.scc),
598        ("depth", s.depth),
599        ("neighbor_churn", s.neighbor_churn),
600        ("burst", s.burst),
601    ] {
602        if let Some(v) = val {
603            if v < 0.0 {
604                anyhow::bail!("scoring.{} must be non-negative (got {})", name, v);
605            }
606            if v > 10.0 {
607                anyhow::bail!("scoring.{} must be at most 10.0 (got {})", name, v);
608            }
609        }
610    }
611    Ok(())
612}
613
614fn validate_pattern_thresholds(p: &PatternThresholdsConfig) -> Result<()> {
615    // All thresholds must be at least 1 when specified
616    let usize_fields: &[(&str, Option<usize>)] = &[
617        ("complex_branching_cc", p.complex_branching_cc),
618        ("complex_branching_nd", p.complex_branching_nd),
619        ("deeply_nested_nd", p.deeply_nested_nd),
620        ("exit_heavy_ns", p.exit_heavy_ns),
621        ("god_function_loc", p.god_function_loc),
622        ("god_function_fo", p.god_function_fo),
623        ("long_function_loc", p.long_function_loc),
624        ("churn_magnet_churn", p.churn_magnet_churn),
625        ("churn_magnet_cc", p.churn_magnet_cc),
626        ("cyclic_hub_scc", p.cyclic_hub_scc),
627        ("cyclic_hub_fan_in", p.cyclic_hub_fan_in),
628        ("hub_function_fan_in", p.hub_function_fan_in),
629        ("hub_function_cc", p.hub_function_cc),
630        ("middle_man_fan_in", p.middle_man_fan_in),
631        ("middle_man_fo", p.middle_man_fo),
632        ("middle_man_cc_max", p.middle_man_cc_max),
633        ("neighbor_risk_churn", p.neighbor_risk_churn),
634        ("neighbor_risk_fo", p.neighbor_risk_fo),
635        ("shotgun_target_fan_in", p.shotgun_target_fan_in),
636        ("shotgun_target_churn", p.shotgun_target_churn),
637        ("stale_complex_cc", p.stale_complex_cc),
638        ("stale_complex_loc", p.stale_complex_loc),
639    ];
640    for (name, val) in usize_fields {
641        if let Some(v) = val {
642            if *v == 0 {
643                anyhow::bail!("patterns.{} must be at least 1 (got 0)", name);
644            }
645        }
646    }
647    if let Some(v) = p.stale_complex_days {
648        if v == 0 {
649            anyhow::bail!("patterns.stale_complex_days must be at least 1 (got 0)");
650        }
651    }
652    Ok(())
653}
654
655impl HotspotsConfig {
656    /// Resolve config into compiled form ready for use
657    pub fn resolve(&self) -> Result<ResolvedConfig> {
658        self.validate()?;
659
660        // Compile include patterns
661        let include = if self.include.is_empty() {
662            None
663        } else {
664            let mut builder = GlobSetBuilder::new();
665            for pattern in &self.include {
666                builder.add(Glob::new(pattern)?);
667            }
668            Some(builder.build()?)
669        };
670
671        // Compile exclude patterns: defaults always apply; user patterns are additive.
672        let exclude = {
673            let mut builder = GlobSetBuilder::new();
674            for pattern in DEFAULT_EXCLUDES {
675                builder.add(Glob::new(pattern)?);
676            }
677            for pattern in &self.exclude {
678                builder.add(Glob::new(pattern)?);
679            }
680            builder.build()?
681        };
682
683        let (moderate, high, critical) = match &self.thresholds {
684            Some(t) => (
685                t.moderate.unwrap_or(3.0),
686                t.high.unwrap_or(6.0),
687                t.critical.unwrap_or(9.0),
688            ),
689            None => (3.0, 6.0, 9.0),
690        };
691
692        let (w_cc, w_nd, w_fo, w_ns) = match &self.weights {
693            Some(w) => (
694                w.cc.unwrap_or(1.0),
695                w.nd.unwrap_or(0.8),
696                w.fo.unwrap_or(0.6),
697                w.ns.unwrap_or(0.7),
698            ),
699            None => (1.0, 0.8, 0.6, 0.7),
700        };
701
702        let (watch_min, watch_max, attention_min, attention_max, rapid_growth_percent) =
703            match &self.warning_thresholds {
704                Some(wt) => (
705                    wt.watch_min.unwrap_or(2.5),
706                    wt.watch_max.unwrap_or(3.0),
707                    wt.attention_min.unwrap_or(5.5),
708                    wt.attention_max.unwrap_or(6.0),
709                    wt.rapid_growth_percent.unwrap_or(50.0),
710                ),
711                None => (2.5, 3.0, 5.5, 6.0, 50.0),
712            };
713
714        let scoring_weights = match &self.scoring {
715            Some(s) => {
716                let defaults = crate::scoring::ScoringWeights::default();
717                crate::scoring::ScoringWeights {
718                    churn: s.churn.unwrap_or(defaults.churn),
719                    touch: s.touch.unwrap_or(defaults.touch),
720                    recency: s.recency.unwrap_or(defaults.recency),
721                    fan_in: s.fan_in.unwrap_or(defaults.fan_in),
722                    scc: s.scc.unwrap_or(defaults.scc),
723                    depth: s.depth.unwrap_or(defaults.depth),
724                    neighbor_churn: s.neighbor_churn.unwrap_or(defaults.neighbor_churn),
725                    burst: s.burst.unwrap_or(defaults.burst),
726                }
727            }
728            None => crate::scoring::ScoringWeights::default(),
729        };
730
731        let pattern_thresholds = match &self.patterns {
732            Some(p) => {
733                let d = crate::patterns::Thresholds::default();
734                crate::patterns::Thresholds {
735                    complex_branching_cc: p.complex_branching_cc.unwrap_or(d.complex_branching_cc),
736                    complex_branching_nd: p.complex_branching_nd.unwrap_or(d.complex_branching_nd),
737                    deeply_nested_nd: p.deeply_nested_nd.unwrap_or(d.deeply_nested_nd),
738                    exit_heavy_ns: p.exit_heavy_ns.unwrap_or(d.exit_heavy_ns),
739                    god_function_loc: p.god_function_loc.unwrap_or(d.god_function_loc),
740                    god_function_fo: p.god_function_fo.unwrap_or(d.god_function_fo),
741                    long_function_loc: p.long_function_loc.unwrap_or(d.long_function_loc),
742                    churn_magnet_churn: p.churn_magnet_churn.unwrap_or(d.churn_magnet_churn),
743                    churn_magnet_cc: p.churn_magnet_cc.unwrap_or(d.churn_magnet_cc),
744                    cyclic_hub_scc: p.cyclic_hub_scc.unwrap_or(d.cyclic_hub_scc),
745                    cyclic_hub_fan_in: p.cyclic_hub_fan_in.unwrap_or(d.cyclic_hub_fan_in),
746                    hub_function_fan_in: p.hub_function_fan_in.unwrap_or(d.hub_function_fan_in),
747                    hub_function_cc: p.hub_function_cc.unwrap_or(d.hub_function_cc),
748                    middle_man_fan_in: p.middle_man_fan_in.unwrap_or(d.middle_man_fan_in),
749                    middle_man_fo: p.middle_man_fo.unwrap_or(d.middle_man_fo),
750                    middle_man_cc_max: p.middle_man_cc_max.unwrap_or(d.middle_man_cc_max),
751                    neighbor_risk_churn: p.neighbor_risk_churn.unwrap_or(d.neighbor_risk_churn),
752                    neighbor_risk_fo: p.neighbor_risk_fo.unwrap_or(d.neighbor_risk_fo),
753                    shotgun_target_fan_in: p
754                        .shotgun_target_fan_in
755                        .unwrap_or(d.shotgun_target_fan_in),
756                    shotgun_target_churn: p.shotgun_target_churn.unwrap_or(d.shotgun_target_churn),
757                    stale_complex_cc: p.stale_complex_cc.unwrap_or(d.stale_complex_cc),
758                    stale_complex_loc: p.stale_complex_loc.unwrap_or(d.stale_complex_loc),
759                    stale_complex_days: p.stale_complex_days.unwrap_or(d.stale_complex_days),
760                }
761            }
762            None => crate::patterns::Thresholds::default(),
763        };
764
765        let (
766            critical_introduction_mode,
767            critical_introduction_reason,
768            excessive_risk_regression_mode,
769            excessive_risk_regression_reason,
770        ) = match &self.policy {
771            Some(p) => (
772                match &p.critical_introduction {
773                    Some(s) => PolicyMode::parse("critical_introduction", s)?,
774                    None => PolicyMode::Block,
775                },
776                p.critical_introduction_reason.clone(),
777                match &p.excessive_risk_regression {
778                    Some(s) => PolicyMode::parse("excessive_risk_regression", s)?,
779                    None => PolicyMode::Block,
780                },
781                p.excessive_risk_regression_reason.clone(),
782            ),
783            None => (PolicyMode::Block, None, PolicyMode::Block, None),
784        };
785
786        Ok(ResolvedConfig {
787            include,
788            exclude,
789            moderate_threshold: moderate,
790            high_threshold: high,
791            critical_threshold: critical,
792            weight_cc: w_cc,
793            weight_nd: w_nd,
794            weight_fo: w_fo,
795            weight_ns: w_ns,
796            watch_min,
797            watch_max,
798            attention_min,
799            attention_max,
800            rapid_growth_percent,
801            min_lrs: self.min_lrs,
802            top_n: self.top,
803            scoring_weights,
804            pattern_thresholds,
805            critical_introduction_mode,
806            critical_introduction_reason,
807            excessive_risk_regression_mode,
808            excessive_risk_regression_reason,
809            co_change_window_days: self.co_change_window_days.unwrap_or(90),
810            co_change_min_count: self.co_change_min_count.unwrap_or(3),
811            per_function_touches: self.per_function_touches.unwrap_or(false),
812            hybrid_touch_threshold: self.hybrid_touch_threshold,
813            driver_threshold_percentile: self.driver_threshold_percentile.unwrap_or(75),
814            betweenness_exact_threshold: self.betweenness_exact_threshold.unwrap_or(2000),
815            betweenness_approx_k: self.betweenness_approx_k.unwrap_or(256),
816            callgraph_skip_above: self.callgraph_skip_above.unwrap_or(usize::MAX),
817            config_path: None,
818            exclude_is_custom: !self.exclude.is_empty(),
819        })
820    }
821}
822
823impl ResolvedConfig {
824    /// Check if a file path should be included based on include/exclude patterns
825    pub fn should_include(&self, path: &Path) -> bool {
826        let path_str = path.to_string_lossy();
827
828        // Check exclude first
829        if self.exclude.is_match(path_str.as_ref()) {
830            return false;
831        }
832
833        // If include patterns exist, file must match at least one
834        if let Some(ref include) = self.include {
835            return include.is_match(path_str.as_ref());
836        }
837
838        true
839    }
840
841    /// Build a ResolvedConfig with all defaults (no config file)
842    pub fn defaults() -> Result<Self> {
843        HotspotsConfig::default().resolve()
844    }
845}
846
847/// Discover and load a config file from the project root
848///
849/// Search order:
850/// 1. `.hotspotsrc.json`
851/// 2. `hotspots.config.json`
852/// 3. `"hotspots"` key in `package.json`
853///
854/// Returns `None` if no config file is found (use defaults).
855pub fn discover_config(project_root: &Path) -> Result<Option<(HotspotsConfig, PathBuf)>> {
856    // 1. .hotspotsrc.json
857    let rc_path = project_root.join(".hotspotsrc.json");
858    if rc_path.exists() {
859        let config = load_config_file(&rc_path)?;
860        return Ok(Some((config, rc_path)));
861    }
862
863    // 2. hotspots.config.json
864    let config_path = project_root.join("hotspots.config.json");
865    if config_path.exists() {
866        let config = load_config_file(&config_path)?;
867        return Ok(Some((config, config_path)));
868    }
869
870    // 3. package.json "hotspots" key
871    let pkg_path = project_root.join("package.json");
872    if pkg_path.exists() {
873        if let Some(config) = load_from_package_json(&pkg_path)? {
874            return Ok(Some((config, pkg_path)));
875        }
876    }
877
878    Ok(None)
879}
880
881/// Load config from an explicit file path
882pub fn load_config_file(path: &Path) -> Result<HotspotsConfig> {
883    let content = std::fs::read_to_string(path)
884        .with_context(|| format!("failed to read config file: {}", path.display()))?;
885
886    let config: HotspotsConfig = serde_json::from_str(&content)
887        .with_context(|| format!("failed to parse config file: {}", path.display()))?;
888
889    config
890        .validate()
891        .with_context(|| format!("invalid config in: {}", path.display()))?;
892
893    Ok(config)
894}
895
896/// Load hotspots config from the "hotspots" key in package.json
897fn load_from_package_json(path: &Path) -> Result<Option<HotspotsConfig>> {
898    let content = std::fs::read_to_string(path)
899        .with_context(|| format!("failed to read {}", path.display()))?;
900
901    let pkg: serde_json::Value = serde_json::from_str(&content)
902        .with_context(|| format!("failed to parse {}", path.display()))?;
903
904    match pkg.get("hotspots") {
905        Some(hotspots_value) => {
906            let config: HotspotsConfig = serde_json::from_value(hotspots_value.clone())
907                .with_context(|| format!("invalid hotspots config in {}", path.display()))?;
908            config
909                .validate()
910                .with_context(|| format!("invalid hotspots config in {}", path.display()))?;
911            Ok(Some(config))
912        }
913        None => Ok(None),
914    }
915}
916
917/// Load and resolve config for a project
918///
919/// If `config_path` is provided, loads from that file.
920/// Otherwise, discovers config from the project root.
921/// Returns default config if nothing is found.
922pub fn load_and_resolve(project_root: &Path, config_path: Option<&Path>) -> Result<ResolvedConfig> {
923    let (config, source_path) = if let Some(path) = config_path {
924        let config = load_config_file(path)?;
925        (config, Some(path.to_path_buf()))
926    } else {
927        match discover_config(project_root)? {
928            Some((config, path)) => (config, Some(path)),
929            None => (HotspotsConfig::default(), None),
930        }
931    };
932
933    let mut resolved = config.resolve()?;
934    resolved.config_path = source_path;
935    Ok(resolved)
936}
937
938#[cfg(test)]
939mod tests {
940    use super::*;
941    use std::fs;
942
943    #[test]
944    fn test_default_config_is_valid() {
945        let config = HotspotsConfig::default();
946        config.validate().expect("default config should be valid");
947        let resolved = config.resolve().expect("default config should resolve");
948        assert!(resolved.include.is_none());
949        assert_eq!(resolved.weight_cc, 1.0);
950        assert_eq!(resolved.weight_nd, 0.8);
951        assert_eq!(resolved.weight_fo, 0.6);
952        assert_eq!(resolved.weight_ns, 0.7);
953        assert_eq!(resolved.moderate_threshold, 3.0);
954        assert_eq!(resolved.high_threshold, 6.0);
955        assert_eq!(resolved.critical_threshold, 9.0);
956    }
957
958    #[test]
959    fn test_parse_minimal_config() {
960        let json = r#"{}"#;
961        let config: HotspotsConfig = serde_json::from_str(json).unwrap();
962        config.validate().unwrap();
963    }
964
965    #[test]
966    fn test_parse_full_config() {
967        let json = r#"{
968            "include": ["src/**/*.ts", "src/**/*.tsx"],
969            "exclude": ["**/*.test.ts", "**/node_modules/**"],
970            "thresholds": {
971                "moderate": 4.0,
972                "high": 7.0,
973                "critical": 10.0
974            },
975            "weights": {
976                "cc": 1.2,
977                "nd": 0.9,
978                "fo": 0.5,
979                "ns": 0.8
980            },
981            "min_lrs": 2.0,
982            "top": 20
983        }"#;
984        let config: HotspotsConfig = serde_json::from_str(json).unwrap();
985        config.validate().unwrap();
986        let resolved = config.resolve().unwrap();
987        assert!(resolved.include.is_some());
988        assert_eq!(resolved.moderate_threshold, 4.0);
989        assert_eq!(resolved.high_threshold, 7.0);
990        assert_eq!(resolved.critical_threshold, 10.0);
991        assert_eq!(resolved.weight_cc, 1.2);
992        assert_eq!(resolved.min_lrs, Some(2.0));
993        assert_eq!(resolved.top_n, Some(20));
994    }
995
996    #[test]
997    fn test_reject_unknown_fields() {
998        let json = r#"{"unknown_field": true}"#;
999        let result: Result<HotspotsConfig, _> = serde_json::from_str(json);
1000        assert!(result.is_err(), "unknown fields should be rejected");
1001    }
1002
1003    #[test]
1004    fn test_reject_negative_weight() {
1005        let json = r#"{"weights": {"cc": -1.0}}"#;
1006        let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1007        assert!(config.validate().is_err());
1008    }
1009
1010    #[test]
1011    fn test_reject_weight_over_10() {
1012        let json = r#"{"weights": {"cc": 11.0}}"#;
1013        let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1014        assert!(config.validate().is_err());
1015    }
1016
1017    #[test]
1018    fn test_reject_all_zero_weights() {
1019        // Regression test for hotspots#145: each weight individually passes
1020        // bounds checking, but all-zero collapses every LRS score to 0.
1021        let json = r#"{"weights": {"cc": 0.0, "nd": 0.0, "fo": 0.0, "ns": 0.0}}"#;
1022        let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1023        assert!(config.validate().is_err());
1024    }
1025
1026    #[test]
1027    fn test_accept_partial_zero_weights() {
1028        // Only one weight is nonzero — this is unusual but not degenerate,
1029        // since LRS doesn't collapse to a constant 0 for every function.
1030        let json = r#"{"weights": {"cc": 0.0, "nd": 0.0, "fo": 0.0, "ns": 1.0}}"#;
1031        let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1032        assert!(config.validate().is_ok());
1033    }
1034
1035    #[test]
1036    fn test_accept_unset_weights_use_nonzero_defaults() {
1037        // No weights key at all — every field defaults to a nonzero value,
1038        // so this must not be flagged as degenerate.
1039        let json = r#"{}"#;
1040        let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1041        assert!(config.validate().is_ok());
1042    }
1043
1044    #[test]
1045    fn test_reject_negative_threshold() {
1046        let json = r#"{"thresholds": {"moderate": -1.0}}"#;
1047        let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1048        assert!(config.validate().is_err());
1049    }
1050
1051    #[test]
1052    fn test_reject_unordered_thresholds() {
1053        let json = r#"{"thresholds": {"moderate": 6.0, "high": 3.0, "critical": 9.0}}"#;
1054        let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1055        assert!(config.validate().is_err());
1056    }
1057
1058    #[test]
1059    fn test_reject_invalid_glob_pattern() {
1060        let json = r#"{"include": ["[invalid"]}"#;
1061        let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1062        assert!(config.validate().is_err());
1063    }
1064
1065    #[test]
1066    fn test_should_include_default_excludes() {
1067        let resolved = ResolvedConfig::defaults().unwrap();
1068        assert!(!resolved.should_include(Path::new("src/foo.test.ts")));
1069        assert!(!resolved.should_include(Path::new("node_modules/pkg/index.js")));
1070        assert!(!resolved.should_include(Path::new("dist/bundle.js")));
1071        assert!(resolved.should_include(Path::new("src/api.ts")));
1072        assert!(resolved.should_include(Path::new("src/components/Button.tsx")));
1073        // C/C++ vendored directories
1074        assert!(!resolved.should_include(Path::new("deps/jemalloc/src/arena.c")));
1075        assert!(!resolved.should_include(Path::new("third_party/abseil/absl/base/base.cc")));
1076        assert!(!resolved.should_include(Path::new("thirdparty/openssl/ssl/ssl_lib.c")));
1077        assert!(!resolved.should_include(Path::new("external/zlib/deflate.c")));
1078        assert!(!resolved.should_include(Path::new("extern/lua/lvm.c")));
1079        assert!(!resolved.should_include(Path::new("contrib/hiredis/net.c")));
1080        // Own source should still be included
1081        assert!(resolved.should_include(Path::new("src/server.c")));
1082        assert!(resolved.should_include(Path::new("src/networking.c")));
1083    }
1084
1085    #[test]
1086    fn test_should_include_custom_patterns() {
1087        let config: HotspotsConfig = serde_json::from_str(
1088            r#"{
1089            "include": ["src/**/*.ts"],
1090            "exclude": ["src/generated/**"]
1091        }"#,
1092        )
1093        .unwrap();
1094        let resolved = config.resolve().unwrap();
1095        assert!(resolved.should_include(Path::new("src/api.ts")));
1096        assert!(!resolved.should_include(Path::new("lib/util.ts")));
1097        assert!(!resolved.should_include(Path::new("src/generated/types.ts")));
1098    }
1099
1100    #[test]
1101    fn test_discover_hotspotsrc() {
1102        let dir = tempfile::tempdir().unwrap();
1103        let config_path = dir.path().join(".hotspotsrc.json");
1104        fs::write(&config_path, r#"{"min_lrs": 5.0}"#).unwrap();
1105
1106        let result = discover_config(dir.path()).unwrap();
1107        assert!(result.is_some());
1108        let (config, path) = result.unwrap();
1109        assert_eq!(config.min_lrs, Some(5.0));
1110        assert_eq!(path, config_path);
1111    }
1112
1113    #[test]
1114    fn test_discover_hotspots_config_json() {
1115        let dir = tempfile::tempdir().unwrap();
1116        let config_path = dir.path().join("hotspots.config.json");
1117        fs::write(&config_path, r#"{"top": 10}"#).unwrap();
1118
1119        let result = discover_config(dir.path()).unwrap();
1120        assert!(result.is_some());
1121        let (config, _) = result.unwrap();
1122        assert_eq!(config.top, Some(10));
1123    }
1124
1125    #[test]
1126    fn test_discover_package_json() {
1127        let dir = tempfile::tempdir().unwrap();
1128        let pkg_path = dir.path().join("package.json");
1129        fs::write(
1130            &pkg_path,
1131            r#"{
1132            "name": "my-project",
1133            "version": "1.0.0",
1134            "hotspots": {
1135                "exclude": ["**/*.test.ts"],
1136                "min_lrs": 3.0
1137            }
1138        }"#,
1139        )
1140        .unwrap();
1141
1142        let result = discover_config(dir.path()).unwrap();
1143        assert!(result.is_some());
1144        let (config, _) = result.unwrap();
1145        assert_eq!(config.min_lrs, Some(3.0));
1146        assert_eq!(config.exclude, vec!["**/*.test.ts"]);
1147    }
1148
1149    #[test]
1150    fn test_discover_package_json_without_hotspots_key() {
1151        let dir = tempfile::tempdir().unwrap();
1152        let pkg_path = dir.path().join("package.json");
1153        fs::write(&pkg_path, r#"{"name": "my-project", "version": "1.0.0"}"#).unwrap();
1154
1155        let result = discover_config(dir.path()).unwrap();
1156        assert!(result.is_none());
1157    }
1158
1159    #[test]
1160    fn test_discover_priority_order() {
1161        let dir = tempfile::tempdir().unwrap();
1162
1163        // Create both config files - .hotspotsrc.json should win
1164        fs::write(dir.path().join(".hotspotsrc.json"), r#"{"min_lrs": 1.0}"#).unwrap();
1165        fs::write(
1166            dir.path().join("hotspots.config.json"),
1167            r#"{"min_lrs": 2.0}"#,
1168        )
1169        .unwrap();
1170
1171        let result = discover_config(dir.path()).unwrap();
1172        let (config, _) = result.unwrap();
1173        assert_eq!(
1174            config.min_lrs,
1175            Some(1.0),
1176            ".hotspotsrc.json should take priority"
1177        );
1178    }
1179
1180    #[test]
1181    fn test_no_config_returns_none() {
1182        let dir = tempfile::tempdir().unwrap();
1183        let result = discover_config(dir.path()).unwrap();
1184        assert!(result.is_none());
1185    }
1186
1187    #[test]
1188    fn test_load_and_resolve_defaults() {
1189        let dir = tempfile::tempdir().unwrap();
1190        let resolved = load_and_resolve(dir.path(), None).unwrap();
1191        assert!(resolved.config_path.is_none());
1192        assert_eq!(resolved.weight_cc, 1.0);
1193    }
1194
1195    #[test]
1196    fn test_load_and_resolve_explicit_path() {
1197        let dir = tempfile::tempdir().unwrap();
1198        let config_path = dir.path().join("custom.json");
1199        fs::write(&config_path, r#"{"weights": {"cc": 2.0}}"#).unwrap();
1200
1201        let resolved = load_and_resolve(dir.path(), Some(&config_path)).unwrap();
1202        assert_eq!(resolved.weight_cc, 2.0);
1203        assert_eq!(resolved.config_path, Some(config_path));
1204        // Regression test for hotspots#146: a config file that sets only
1205        // `weights` and never touches `exclude` must not report exclude as
1206        // custom just because *a* config file was found.
1207        assert!(!resolved.exclude_is_custom);
1208    }
1209
1210    #[test]
1211    fn test_exclude_is_custom_when_config_sets_exclude() {
1212        let dir = tempfile::tempdir().unwrap();
1213        let config_path = dir.path().join("custom.json");
1214        fs::write(&config_path, r#"{"exclude": ["**/legacy/**"]}"#).unwrap();
1215
1216        let resolved = load_and_resolve(dir.path(), Some(&config_path)).unwrap();
1217        assert!(resolved.exclude_is_custom);
1218    }
1219
1220    #[test]
1221    fn test_exclude_is_not_custom_with_no_config_file() {
1222        let dir = tempfile::tempdir().unwrap();
1223        let resolved = load_and_resolve(dir.path(), None).unwrap();
1224        assert!(!resolved.exclude_is_custom);
1225    }
1226
1227    #[test]
1228    fn test_partial_weights_use_defaults_for_rest() {
1229        let json = r#"{"weights": {"cc": 2.0}}"#;
1230        let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1231        let resolved = config.resolve().unwrap();
1232        assert_eq!(resolved.weight_cc, 2.0);
1233        assert_eq!(resolved.weight_nd, 0.8); // default
1234        assert_eq!(resolved.weight_fo, 0.6); // default
1235        assert_eq!(resolved.weight_ns, 0.7); // default
1236    }
1237
1238    #[test]
1239    fn test_partial_thresholds_use_defaults_for_rest() {
1240        let json = r#"{"thresholds": {"critical": 12.0}}"#;
1241        let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1242        let resolved = config.resolve().unwrap();
1243        assert_eq!(resolved.moderate_threshold, 3.0); // default
1244        assert_eq!(resolved.high_threshold, 6.0); // default
1245        assert_eq!(resolved.critical_threshold, 12.0);
1246    }
1247
1248    #[test]
1249    fn test_default_warning_thresholds() {
1250        let config = HotspotsConfig::default();
1251        let resolved = config.resolve().unwrap();
1252        assert_eq!(resolved.watch_min, 2.5);
1253        assert_eq!(resolved.watch_max, 3.0);
1254        assert_eq!(resolved.attention_min, 5.5);
1255        assert_eq!(resolved.attention_max, 6.0);
1256        assert_eq!(resolved.rapid_growth_percent, 50.0);
1257    }
1258
1259    #[test]
1260    fn test_parse_warning_thresholds() {
1261        let json = r#"{
1262            "warning_thresholds": {
1263                "watch_min": 2.0,
1264                "watch_max": 3.5,
1265                "attention_min": 5.0,
1266                "attention_max": 7.0,
1267                "rapid_growth_percent": 75.0
1268            }
1269        }"#;
1270        let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1271        config.validate().unwrap();
1272        let resolved = config.resolve().unwrap();
1273        assert_eq!(resolved.watch_min, 2.0);
1274        assert_eq!(resolved.watch_max, 3.5);
1275        assert_eq!(resolved.attention_min, 5.0);
1276        assert_eq!(resolved.attention_max, 7.0);
1277        assert_eq!(resolved.rapid_growth_percent, 75.0);
1278    }
1279
1280    #[test]
1281    fn test_partial_warning_thresholds_use_defaults() {
1282        let json = r#"{"warning_thresholds": {"rapid_growth_percent": 100.0}}"#;
1283        let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1284        let resolved = config.resolve().unwrap();
1285        assert_eq!(resolved.watch_min, 2.5); // default
1286        assert_eq!(resolved.watch_max, 3.0); // default
1287        assert_eq!(resolved.attention_min, 5.5); // default
1288        assert_eq!(resolved.attention_max, 6.0); // default
1289        assert_eq!(resolved.rapid_growth_percent, 100.0);
1290    }
1291
1292    #[test]
1293    fn test_reject_negative_watch_min() {
1294        let json = r#"{"warning_thresholds": {"watch_min": -1.0}}"#;
1295        let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1296        assert!(config.validate().is_err());
1297    }
1298
1299    #[test]
1300    fn test_reject_negative_rapid_growth() {
1301        let json = r#"{"warning_thresholds": {"rapid_growth_percent": -10.0}}"#;
1302        let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1303        assert!(config.validate().is_err());
1304    }
1305
1306    #[test]
1307    fn test_reject_unordered_watch_thresholds() {
1308        let json = r#"{"warning_thresholds": {"watch_min": 3.0, "watch_max": 2.0}}"#;
1309        let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1310        assert!(config.validate().is_err());
1311    }
1312
1313    #[test]
1314    fn test_reject_unordered_attention_thresholds() {
1315        let json = r#"{"warning_thresholds": {"attention_min": 7.0, "attention_max": 5.0}}"#;
1316        let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1317        assert!(config.validate().is_err());
1318    }
1319
1320    #[test]
1321    fn test_full_config_with_warnings() {
1322        let json = r#"{
1323            "thresholds": {
1324                "moderate": 3.0,
1325                "high": 6.0,
1326                "critical": 9.0
1327            },
1328            "warning_thresholds": {
1329                "watch_min": 2.5,
1330                "watch_max": 3.0,
1331                "attention_min": 5.5,
1332                "attention_max": 6.0,
1333                "rapid_growth_percent": 50.0
1334            }
1335        }"#;
1336        let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1337        config.validate().unwrap();
1338        let resolved = config.resolve().unwrap();
1339        assert_eq!(resolved.watch_min, 2.5);
1340        assert_eq!(resolved.watch_max, 3.0);
1341        assert_eq!(resolved.attention_min, 5.5);
1342        assert_eq!(resolved.attention_max, 6.0);
1343        assert_eq!(resolved.rapid_growth_percent, 50.0);
1344    }
1345
1346    #[test]
1347    fn test_scoring_weights_defaults() {
1348        let config = HotspotsConfig::default();
1349        let resolved = config.resolve().unwrap();
1350        let defaults = crate::scoring::ScoringWeights::default();
1351        assert_eq!(resolved.scoring_weights.churn, defaults.churn);
1352        assert_eq!(resolved.scoring_weights.touch, defaults.touch);
1353        assert_eq!(resolved.scoring_weights.recency, defaults.recency);
1354        assert_eq!(resolved.scoring_weights.fan_in, defaults.fan_in);
1355        assert_eq!(resolved.scoring_weights.scc, defaults.scc);
1356        assert_eq!(resolved.scoring_weights.depth, defaults.depth);
1357        assert_eq!(
1358            resolved.scoring_weights.neighbor_churn,
1359            defaults.neighbor_churn
1360        );
1361    }
1362
1363    #[test]
1364    fn test_scoring_weights_from_config() {
1365        let json = r#"{
1366            "scoring": {
1367                "churn": 0.8,
1368                "touch": 0.5,
1369                "fan_in": 0.6
1370            }
1371        }"#;
1372        let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1373        config.validate().unwrap();
1374        let resolved = config.resolve().unwrap();
1375        assert_eq!(resolved.scoring_weights.churn, 0.8);
1376        assert_eq!(resolved.scoring_weights.touch, 0.5);
1377        assert_eq!(resolved.scoring_weights.fan_in, 0.6);
1378        // Unspecified fields fall back to defaults
1379        let defaults = crate::scoring::ScoringWeights::default();
1380        assert_eq!(resolved.scoring_weights.recency, defaults.recency);
1381        assert_eq!(resolved.scoring_weights.scc, defaults.scc);
1382    }
1383
1384    #[test]
1385    fn test_reject_negative_scoring_weight() {
1386        let json = r#"{"scoring": {"churn": -0.1}}"#;
1387        let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1388        assert!(config.validate().is_err());
1389    }
1390
1391    #[test]
1392    fn test_reject_scoring_weight_over_10() {
1393        let json = r#"{"scoring": {"fan_in": 11.0}}"#;
1394        let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1395        assert!(config.validate().is_err());
1396    }
1397}