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}