Skip to main content

fallow_output/
audit_worsened.rs

1//! Audit-mode attribution classes of complexity findings, and the measured
2//! metrics that grew against the matched base finding.
3
4/// Audit-mode class of a complexity finding against the base snapshot.
5#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
6#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
7#[serde(rename_all = "kebab-case")]
8pub enum AuditFindingAttribution {
9    /// No base finding matches, or a grown metric now exceeds a threshold
10    /// that the base finding did not exceed, or the growth of an exceeded
11    /// metric moved the finding to a higher severity tier. A CRAP score from
12    /// estimated coverage does not count as growth.
13    Introduced,
14    /// A base finding matches and a measured metric grew, but the finding is
15    /// not introduced. Growth of an exceeded metric in the same severity tier
16    /// is worsened.
17    Worsened,
18    /// A base finding matches and no measured metric grew.
19    Inherited,
20}
21
22/// Audit-mode reason why a complexity finding does not gate the verdict.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
24#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
25#[serde(rename_all = "kebab-case")]
26pub enum AuditGateExemption {
27    /// The finding is in a test file: a spec, a test directory, an end-to-end
28    /// suite, a mock or a fixture. Its gate severity is `warn`.
29    TestFile,
30}
31
32impl AuditGateExemption {
33    /// The JSON value, also used in compact output and SARIF properties.
34    #[must_use]
35    pub const fn token(self) -> &'static str {
36        match self {
37            Self::TestFile => "test-file",
38        }
39    }
40}
41
42/// A measured metric of a complexity finding.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
44#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
45#[serde(rename_all = "snake_case")]
46pub enum WorsenedMetricKind {
47    /// Cyclomatic complexity.
48    Cyclomatic,
49    /// Cognitive complexity.
50    Cognitive,
51    /// CRAP score. Compared only when both sides have coverage data.
52    Crap,
53    /// Lines of code in the function body.
54    LineCount,
55}
56
57impl WorsenedMetricKind {
58    /// Short label for human output.
59    #[must_use]
60    pub const fn label(self) -> &'static str {
61        match self {
62            Self::Cyclomatic => "cyclomatic",
63            Self::Cognitive => "cognitive",
64            Self::Crap => "CRAP",
65            Self::LineCount => "lines",
66        }
67    }
68}
69
70/// One measured metric that is higher at head than in the matched base
71/// finding.
72#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
73#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
74pub struct WorsenedMetric {
75    /// The metric.
76    pub metric: WorsenedMetricKind,
77    /// Value in the matched base finding.
78    #[serde(serialize_with = "serialize_metric_value")]
79    pub base: f64,
80    /// Value in the head finding. Always higher than `base`.
81    #[serde(serialize_with = "serialize_metric_value")]
82    pub head: f64,
83}
84
85impl WorsenedMetric {
86    /// Human form, for example `cyclomatic 15 -> 40`.
87    #[must_use]
88    pub fn describe(&self) -> String {
89        format!(
90            "{} {} -> {}",
91            self.metric.label(),
92            format_metric_value(self.base),
93            format_metric_value(self.head)
94        )
95    }
96}
97
98/// The human form of a list of grown metrics, joined with `, `.
99#[must_use]
100pub fn describe_worsened_metrics(metrics: &[WorsenedMetric]) -> String {
101    metrics
102        .iter()
103        .map(WorsenedMetric::describe)
104        .collect::<Vec<_>>()
105        .join(", ")
106}
107
108/// Largest magnitude that an `f64` holds as an exact whole number.
109const MAX_EXACT_WHOLE: f64 = 9_007_199_254_740_992.0;
110
111fn is_whole(value: f64) -> bool {
112    value.fract() == 0.0 && value.abs() < MAX_EXACT_WHOLE
113}
114
115/// Format a metric value: whole numbers without a fraction, CRAP scores with
116/// one decimal.
117#[must_use]
118pub fn format_metric_value(value: f64) -> String {
119    if is_whole(value) {
120        format!("{value:.0}")
121    } else {
122        format!("{value:.1}")
123    }
124}
125
126/// Write whole metric values as JSON integers, so `15` does not read `15.0`.
127#[expect(
128    clippy::trivially_copy_pass_by_ref,
129    reason = "serde serialize_with passes the field by reference"
130)]
131fn serialize_metric_value<S: serde::Serializer>(
132    value: &f64,
133    serializer: S,
134) -> Result<S::Ok, S::Error> {
135    if is_whole(*value) {
136        #[expect(
137            clippy::cast_possible_truncation,
138            reason = "is_whole keeps the value whole and inside the exact i64 range"
139        )]
140        serializer.serialize_i64(*value as i64)
141    } else {
142        serializer.serialize_f64(*value)
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn whole_values_serialize_as_integers() {
152        let metric = WorsenedMetric {
153            metric: WorsenedMetricKind::LineCount,
154            base: 80.0,
155            head: 300.0,
156        };
157        let json = serde_json::to_value(metric).expect("serialize");
158        assert_eq!(
159            json,
160            serde_json::json!({"metric": "line_count", "base": 80, "head": 300})
161        );
162    }
163
164    #[test]
165    fn crap_values_keep_their_fraction() {
166        let metric = WorsenedMetric {
167            metric: WorsenedMetricKind::Crap,
168            base: 31.5,
169            head: 42.25,
170        };
171        let json = serde_json::to_value(metric).expect("serialize");
172        assert_eq!(json["base"], 31.5);
173        assert_eq!(metric.describe(), "CRAP 31.5 -> 42.2");
174    }
175
176    #[test]
177    fn describe_joins_metrics() {
178        let metrics = [
179            WorsenedMetric {
180                metric: WorsenedMetricKind::Cyclomatic,
181                base: 15.0,
182                head: 40.0,
183            },
184            WorsenedMetric {
185                metric: WorsenedMetricKind::LineCount,
186                base: 80.0,
187                head: 300.0,
188            },
189        ];
190        assert_eq!(
191            describe_worsened_metrics(&metrics),
192            "cyclomatic 15 -> 40, lines 80 -> 300"
193        );
194    }
195}