Skip to main content

crap_core/domain/
threshold.rs

1use super::types::ComplexityMetric;
2
3// ── Threshold calibration table ──────────────────────────────────────
4//
5// CRAP thresholds are aligned with the intrinsic risk classification
6// in `crap.rs::classify_risk`: every preset fires at the next
7// risk-tier boundary up.
8//
9//                strict  default  lenient
10//   cyclomatic      8       15       25
11//   cognitive       8       15       25
12//
13// Both metric columns currently hold the same values. The dual-column
14// infrastructure is preserved because cognitive and cyclomatic scores
15// can diverge in magnitude for the same code (cognitive is
16// nesting-weighted; cyclomatic counts decision points), and a future
17// recalibration may want to widen one column without touching the
18// other. Today the columns are flat-equal — the simplest defensible
19// position until corpus evidence motivates a split.
20//
21// `ThresholdPreset::threshold(metric)` is the single lookup that keys
22// a tier to a column. Every preset / `--strict` / `--lenient` /
23// no-flag-default resolution routes through it, so a future per-metric
24// divergence is a one-line constant change with no API churn.
25
26/// Strict CRAP cutoff for the **cognitive** metric — gates at the
27/// Low → Acceptable risk boundary; for high-quality or safety-critical
28/// code. Cyclomatic equivalent: [`STRICT_THRESHOLD_CYCLOMATIC`].
29pub const STRICT_THRESHOLD: f64 = 8.0;
30
31/// Default CRAP cutoff for the **cognitive** metric — gates at the
32/// Acceptable → Moderate risk boundary; the balanced tier for typical
33/// codebases. Cyclomatic equivalent: [`DEFAULT_THRESHOLD_CYCLOMATIC`].
34pub const DEFAULT_THRESHOLD: f64 = 15.0;
35
36/// Lenient CRAP cutoff for the **cognitive** metric — gates at the
37/// Moderate → High risk boundary; for legacy or transitional code.
38/// Cyclomatic equivalent: [`LENIENT_THRESHOLD_CYCLOMATIC`].
39pub const LENIENT_THRESHOLD: f64 = 25.0;
40
41/// Strict CRAP cutoff for the **cyclomatic** metric. Currently flat-
42/// equal to the cognitive strict value; the constant is retained so a
43/// future per-metric recalibration is a one-line change.
44pub const STRICT_THRESHOLD_CYCLOMATIC: f64 = 8.0;
45
46/// Default CRAP cutoff for the **cyclomatic** metric. Currently flat-
47/// equal to the cognitive default; see column note above.
48pub const DEFAULT_THRESHOLD_CYCLOMATIC: f64 = 15.0;
49
50/// Lenient CRAP cutoff for the **cyclomatic** metric. Currently flat-
51/// equal to the cognitive lenient; see column note above.
52pub const LENIENT_THRESHOLD_CYCLOMATIC: f64 = 25.0;
53
54/// Named threshold preset — a calibration *tier*, independent of
55/// metric. The concrete f64 cutoff is resolved per metric via
56/// [`ThresholdPreset::threshold`] (the same tier maps to a different
57/// number for cyclomatic vs cognitive scores).
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum ThresholdPreset {
60    /// High-quality libraries, safety-critical code. Gates at the
61    /// Low → Acceptable risk boundary (cognitive 8, cyclomatic 8).
62    Strict,
63    /// Typical projects (balanced) — the tier used when no preset or
64    /// explicit threshold is given. Gates at the Acceptable → Moderate
65    /// risk boundary (cognitive 15, cyclomatic 15).
66    Default,
67    /// Legacy or transitional code. Gates at the Moderate → High risk
68    /// boundary (cognitive 25, cyclomatic 25).
69    Lenient,
70}
71
72impl ThresholdPreset {
73    /// Resolve this tier to its concrete f64 CRAP cutoff for `metric`.
74    /// This is the single place tier→number is keyed on the metric:
75    /// `--strict` / `--lenient` / config `preset` / the no-flag
76    /// default all route through it, so a cutoff calibrated for one
77    /// metric is never silently applied to the other metric's
78    /// (different-magnitude) scores.
79    pub fn threshold(self, metric: ComplexityMetric) -> f64 {
80        match (metric, self) {
81            (ComplexityMetric::Cognitive, Self::Strict) => STRICT_THRESHOLD,
82            (ComplexityMetric::Cognitive, Self::Default) => DEFAULT_THRESHOLD,
83            (ComplexityMetric::Cognitive, Self::Lenient) => LENIENT_THRESHOLD,
84            (ComplexityMetric::Cyclomatic, Self::Strict) => STRICT_THRESHOLD_CYCLOMATIC,
85            (ComplexityMetric::Cyclomatic, Self::Default) => DEFAULT_THRESHOLD_CYCLOMATIC,
86            (ComplexityMetric::Cyclomatic, Self::Lenient) => LENIENT_THRESHOLD_CYCLOMATIC,
87        }
88    }
89}
90
91/// Returns true if the value is a valid CRAP threshold (finite and positive).
92pub fn is_valid_threshold(value: f64) -> bool {
93    value.is_finite() && value > 0.0
94}
95
96/// A glob-based threshold override for a specific path pattern.
97#[derive(Debug, Clone, PartialEq)]
98pub struct ThresholdOverride {
99    /// Glob pattern matched against project-relative file paths (e.g. `domain/**`).
100    pub pattern: String,
101    /// CRAP threshold for functions in files matching this pattern.
102    pub threshold: f64,
103}
104
105/// Threshold configuration with optional per-path overrides.
106///
107/// When overrides are present, each function's file path is tested against
108/// the override patterns in declaration order. The last matching override
109/// wins. If no override matches, the global threshold applies.
110#[derive(Debug, Clone, PartialEq)]
111pub struct ThresholdConfig {
112    /// Global CRAP threshold (used when no override matches).
113    pub global: f64,
114    /// Per-path overrides, evaluated in order (last match wins).
115    pub overrides: Vec<ThresholdOverride>,
116}
117
118/// Returns the cognitive-metric default cutoff. `Default` cannot take
119/// a metric argument, so it cannot be metric-correct on its own — it
120/// is the cognitive baseline only.
121///
122/// The CLI never relies on this: the analysis path resolves the
123/// metric-correct `global` through `merge_threshold` (CLI > config >
124/// adapter default, keyed on the effective metric) and constructs
125/// `ThresholdConfig` directly, bypassing `Default`. The remaining
126/// `Default` use is a struct-field initializer that is overwritten
127/// before any analysis runs.
128///
129/// A library embedder that needs a metric-correct config should build
130/// it explicitly rather than via `Default`:
131///
132/// ```
133/// # use crap_core::domain::threshold::{ThresholdConfig, ThresholdPreset};
134/// # use crap_core::domain::types::ComplexityMetric;
135/// let metric = ComplexityMetric::Cyclomatic;
136/// let config = ThresholdConfig {
137///     global: ThresholdPreset::Default.threshold(metric),
138///     overrides: Vec::new(),
139/// };
140/// ```
141impl Default for ThresholdConfig {
142    fn default() -> Self {
143        Self {
144            global: DEFAULT_THRESHOLD,
145            overrides: Vec::new(),
146        }
147    }
148}
149
150impl ThresholdConfig {
151    /// Returns true if any per-path overrides are configured.
152    pub fn has_overrides(&self) -> bool {
153        !self.overrides.is_empty()
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn threshold_constants() {
163        // Aligned with classify_risk boundaries (Low ≤ 8, Acceptable ≤ 15,
164        // Moderate ≤ 25). Both metric columns flat-equal today; see the
165        // calibration-table comment at the top of this module for why
166        // the dual-column infrastructure is preserved.
167        assert_eq!(STRICT_THRESHOLD, 8.0);
168        assert_eq!(DEFAULT_THRESHOLD, 15.0);
169        assert_eq!(LENIENT_THRESHOLD, 25.0);
170        assert_eq!(STRICT_THRESHOLD_CYCLOMATIC, 8.0);
171        assert_eq!(DEFAULT_THRESHOLD_CYCLOMATIC, 15.0);
172        assert_eq!(LENIENT_THRESHOLD_CYCLOMATIC, 25.0);
173    }
174
175    #[test]
176    fn preset_to_threshold_is_metric_keyed() {
177        use ComplexityMetric::{Cognitive, Cyclomatic};
178        // Cognitive column (crap4rs default).
179        assert_eq!(ThresholdPreset::Strict.threshold(Cognitive), 8.0);
180        assert_eq!(ThresholdPreset::Default.threshold(Cognitive), 15.0);
181        assert_eq!(ThresholdPreset::Lenient.threshold(Cognitive), 25.0);
182        // Cyclomatic column (crap4ts / crap4rs --metric cyclomatic).
183        // Routing stays metric-keyed even with flat-equal values so a
184        // future per-metric recalibration is a one-line change.
185        assert_eq!(ThresholdPreset::Strict.threshold(Cyclomatic), 8.0);
186        assert_eq!(ThresholdPreset::Default.threshold(Cyclomatic), 15.0);
187        assert_eq!(ThresholdPreset::Lenient.threshold(Cyclomatic), 25.0);
188    }
189
190    #[test]
191    fn default_config_uses_default_threshold() {
192        let config = ThresholdConfig::default();
193        assert_eq!(config.global, DEFAULT_THRESHOLD);
194        assert!(config.overrides.is_empty());
195    }
196
197    #[test]
198    fn has_overrides_false_when_empty() {
199        let config = ThresholdConfig::default();
200        assert!(!config.has_overrides());
201    }
202
203    #[test]
204    fn has_overrides_true_when_present() {
205        let config = ThresholdConfig {
206            global: DEFAULT_THRESHOLD,
207            overrides: vec![ThresholdOverride {
208                pattern: "domain/**".to_string(),
209                threshold: 5.0,
210            }],
211        };
212        assert!(config.has_overrides());
213    }
214
215    #[test]
216    fn is_valid_threshold_accepts_positive_finite() {
217        assert!(is_valid_threshold(1.0));
218        assert!(is_valid_threshold(0.001));
219        assert!(is_valid_threshold(DEFAULT_THRESHOLD));
220        assert!(is_valid_threshold(100.0));
221    }
222
223    #[test]
224    fn is_valid_threshold_rejects_invalid() {
225        assert!(!is_valid_threshold(0.0));
226        assert!(!is_valid_threshold(-1.0));
227        assert!(!is_valid_threshold(f64::NAN));
228        assert!(!is_valid_threshold(f64::INFINITY));
229        assert!(!is_valid_threshold(f64::NEG_INFINITY));
230    }
231
232    #[test]
233    fn threshold_override_equality() {
234        let a = ThresholdOverride {
235            pattern: "src/**".to_string(),
236            threshold: 10.0,
237        };
238        let b = ThresholdOverride {
239            pattern: "src/**".to_string(),
240            threshold: 10.0,
241        };
242        assert_eq!(a, b);
243    }
244}