Skip to main content

big_code_analysis/output/
offenders.rs

1//! Offender records consumed by CI/IDE output formats.
2//!
3//! [`OffenderRecord`] is the minimal shape every CI/IDE output format
4//! (Checkstyle, SARIF, JUnit, etc.) renders. Producing offender records
5//! from metric values vs. configured thresholds is the job of the
6//! threshold engine (#96); this module only defines the data shape so
7//! the format implementations can land independently.
8
9#![allow(clippy::doc_markdown)]
10
11use std::path::Path;
12use std::path::PathBuf;
13
14use serde::{Deserialize, Serialize};
15
16use crate::metric_catalog::{Direction, lookup};
17use crate::output::numfmt::MessageMetric;
18
19/// Tool identifier carried in the rule-id / source-prefix field of every
20/// CI/IDE output format (Checkstyle `<error source="...">`, Clang/MSVC
21/// warning rule prefix, SARIF `tool.driver.name`). Single source of
22/// truth so a future tool rename is one edit, not three.
23pub const TOOL_ID: &str = "big-code-analysis";
24
25/// `path.to_str()`, or emit a stderr warning and return `None`. Used
26/// by every output format that turns offender paths into UTF-8
27/// identifiers (Checkstyle attribute, SARIF URI, warning-line column,
28/// HTML / CSV cell). Centralizing the warning text keeps the
29/// `format` label consistent across formats.
30pub(crate) fn warn_non_utf8_path<'a>(format: &str, path: &'a Path) -> Option<&'a str> {
31    if let Some(s) = path.to_str() {
32        Some(s)
33    } else {
34        eprintln!(
35            "Warning: skipping non-UTF-8 path in {format} output: {}",
36            path.display()
37        );
38        None
39    }
40}
41
42/// Severity of a metric-threshold violation.
43///
44/// Defaults to [`Severity::Warning`] so producers can opt into
45/// `Error` explicitly for hard-fail gates.
46///
47/// # Ordering contract
48///
49/// `Severity` is an ordered scale: `Error > Warning`. The derived
50/// [`Ord`]/[`PartialOrd`] follow declaration order, so variants are
51/// declared least-severe-first (`Warning` then `Error`) to make the
52/// derived comparison match the intended severity ranking. Callers can
53/// rely on this to pick the worst severity in a set
54/// (`severities.iter().max()`) or to gate on `>= Severity::Error`. Any
55/// future tier (`Info`/`Note`) must be inserted in the correct severity
56/// position to preserve this scale.
57// An open severity scale: a future `Info` / `Note` tier (or any tier
58// between `Warning` and `Error`) lands as an additive variant rather
59// than a 2.0 break, so it carries `#[non_exhaustive]`. Variants stay in
60// severity order because the derived `Ord` (added in #552) is keyed on
61// declaration order.
62#[derive(
63    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
64)]
65#[serde(rename_all = "lowercase")]
66#[non_exhaustive]
67pub enum Severity {
68    /// Soft severity: report the violation but do not fail.
69    #[default]
70    Warning,
71    /// Hard severity: report the violation and fail any gate keyed off it.
72    Error,
73}
74
75impl Severity {
76    /// Lowercase token used by Checkstyle XML and most CI integrations.
77    #[must_use]
78    pub fn as_str(self) -> &'static str {
79        match self {
80            Self::Warning => "warning",
81            Self::Error => "error",
82        }
83    }
84}
85
86/// One metric-threshold violation, language-agnostic and format-agnostic.
87///
88/// Paths are stored as [`PathBuf`] so output writers can decide how to
89/// surface non-UTF-8 components (skip, replace, or fail) rather than
90/// silently lossy-converting.
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92pub struct OffenderRecord {
93    /// Source file the violation was reported against.
94    pub path: PathBuf,
95    /// Function or method name; `None` for file-level violations.
96    pub function: Option<String>,
97    /// First line covered by the violation (1-based).
98    pub start_line: u32,
99    /// Last line covered by the violation (1-based, inclusive).
100    pub end_line: u32,
101    /// Optional starting column (1-based).
102    pub start_col: Option<u32>,
103    /// Metric identifier, e.g. `"cyclomatic"`, `"loc.lloc"`,
104    /// `"halstead.volume"`.
105    pub metric: String,
106    /// Observed metric value.
107    pub value: f64,
108    /// Configured threshold the value exceeded.
109    pub limit: f64,
110    /// Severity assigned by the threshold engine.
111    pub severity: Severity,
112}
113
114impl OffenderRecord {
115    /// Default human-readable message used by formats that do not carry
116    /// their own templating. Renders `"<metric> <value> exceeds limit
117    /// <limit>"` for higher-is-worse metrics and `"<metric> <value>
118    /// falls below limit <limit>"` for the lower-is-worse `mi.*` family
119    /// (#698) — the breach phrasing must match the direction the
120    /// [`metric_catalog`](crate::metric_catalog) records, or an MI
121    /// offender (value *below* the limit) reads as "exceeds" in
122    /// Checkstyle / Clang / MSVC / SARIF output. An unknown metric id
123    /// falls back to the higher-is-worse phrasing.
124    ///
125    /// Values are formatted via `MessageMetric`: integer fast-path for
126    /// safe integers, six-decimal rounding for non-integer finites,
127    /// `"NaN"` / `"inf"` / `"-inf"` for non-finite values. The Display
128    /// adapter writes directly into the format buffer, so this builds
129    /// one `String` per call rather than three.
130    #[must_use]
131    pub fn default_message(&self) -> String {
132        format!(
133            "{} {} {} {}",
134            self.metric,
135            MessageMetric(self.value),
136            self.breach_phrase(),
137            MessageMetric(self.limit),
138        )
139    }
140
141    /// The direction-appropriate breach phrase for this offender's
142    /// metric: `"exceeds limit"` when a higher value is worse,
143    /// `"falls below limit"` for the lower-is-worse `mi.*` family.
144    /// Unknown metric ids default to `"exceeds limit"` (the common
145    /// case), matching the unknown-id fallback the SARIF / Code Climate
146    /// descriptions already use.
147    fn breach_phrase(&self) -> &'static str {
148        match lookup(&self.metric).map(|i| i.direction) {
149            Some(Direction::LowerIsWorse) => "falls below limit",
150            _ => "exceeds limit",
151        }
152    }
153}
154
155#[cfg(test)]
156#[allow(
157    clippy::float_cmp,
158    clippy::cast_precision_loss,
159    clippy::cast_possible_truncation,
160    clippy::cast_sign_loss,
161    clippy::similar_names,
162    clippy::doc_markdown,
163    clippy::needless_raw_string_hashes,
164    clippy::too_many_lines
165)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn severity_default_is_warning() {
171        assert_eq!(Severity::default(), Severity::Warning);
172    }
173
174    #[test]
175    fn severity_as_str_lowercase() {
176        assert_eq!(Severity::Warning.as_str(), "warning");
177        assert_eq!(Severity::Error.as_str(), "error");
178    }
179
180    /// `Severity` is `#[non_exhaustive]` (#551). The attribute is a
181    /// compile-time forward-compat contract and must not change the
182    /// serialized form: each variant still round-trips through its
183    /// lowercase token.
184    #[test]
185    fn severity_non_exhaustive_serde_roundtrip_unchanged() {
186        for (variant, token) in [
187            (Severity::Warning, "\"warning\""),
188            (Severity::Error, "\"error\""),
189        ] {
190            let json = serde_json::to_string(&variant).unwrap();
191            assert_eq!(json, token);
192            let back: Severity = serde_json::from_str(&json).unwrap();
193            assert_eq!(back, variant);
194        }
195    }
196
197    #[test]
198    fn default_message_renders_integral_value() {
199        let r = OffenderRecord {
200            path: PathBuf::from("a.rs"),
201            function: Some("f".into()),
202            start_line: 1,
203            end_line: 2,
204            start_col: None,
205            metric: "cyclomatic".into(),
206            value: 17.0,
207            limit: 15.0,
208            severity: Severity::Warning,
209        };
210        assert_eq!(r.default_message(), "cyclomatic 17 exceeds limit 15");
211    }
212
213    #[test]
214    fn default_message_renders_fractional_value() {
215        let r = OffenderRecord {
216            path: PathBuf::from("a.rs"),
217            function: None,
218            start_line: 1,
219            end_line: 1,
220            start_col: None,
221            metric: "halstead.volume".into(),
222            value: 12.5,
223            limit: 10.0,
224            severity: Severity::Error,
225        };
226        assert_eq!(r.default_message(), "halstead.volume 12.5 exceeds limit 10");
227    }
228
229    #[test]
230    fn default_message_renders_non_finite_values() {
231        let mut r = OffenderRecord {
232            path: PathBuf::from("a.rs"),
233            function: None,
234            start_line: 1,
235            end_line: 1,
236            start_col: None,
237            metric: "halstead.volume".into(),
238            value: f64::NAN,
239            limit: 10.0,
240            severity: Severity::Warning,
241        };
242        assert_eq!(r.default_message(), "halstead.volume NaN exceeds limit 10");
243
244        r.value = f64::INFINITY;
245        assert_eq!(r.default_message(), "halstead.volume inf exceeds limit 10");
246
247        r.value = f64::NEG_INFINITY;
248        assert_eq!(r.default_message(), "halstead.volume -inf exceeds limit 10");
249    }
250
251    #[test]
252    fn default_message_lower_is_worse_metric_falls_below() {
253        // The `mi.*` Maintainability Index family is lower-is-worse: an
254        // offender's value is *below* the limit, so the message must read
255        // "falls below limit", not "exceeds limit" (#698). A pre-fix
256        // build hardcoded "exceeds limit" for every metric, producing the
257        // nonsensical "mi.original 30 exceeds limit 50" for a value that
258        // is below 50.
259        let r = OffenderRecord {
260            path: PathBuf::from("a.rs"),
261            function: Some("f".into()),
262            start_line: 1,
263            end_line: 2,
264            start_col: None,
265            metric: "mi.original".into(),
266            value: 30.0,
267            limit: 50.0,
268            severity: Severity::Warning,
269        };
270        assert_eq!(r.default_message(), "mi.original 30 falls below limit 50");
271    }
272
273    #[test]
274    fn default_message_higher_is_worse_metric_still_exceeds() {
275        // The direction lookup must keep the higher-is-worse phrasing for
276        // every non-`mi` metric. Guards against an over-broad fix that
277        // flipped the wording for the common case.
278        let r = OffenderRecord {
279            path: PathBuf::from("a.rs"),
280            function: Some("f".into()),
281            start_line: 1,
282            end_line: 2,
283            start_col: None,
284            metric: "cognitive".into(),
285            value: 25.0,
286            limit: 20.0,
287            severity: Severity::Error,
288        };
289        assert_eq!(r.default_message(), "cognitive 25 exceeds limit 20");
290    }
291
292    #[test]
293    fn default_message_unknown_metric_defaults_to_exceeds() {
294        // An id the catalog does not know falls back to the
295        // higher-is-worse "exceeds limit" phrasing.
296        let r = OffenderRecord {
297            path: PathBuf::from("a.rs"),
298            function: None,
299            start_line: 1,
300            end_line: 1,
301            start_col: None,
302            metric: "made.up.metric".into(),
303            value: 5.0,
304            limit: 1.0,
305            severity: Severity::Warning,
306        };
307        assert_eq!(r.default_message(), "made.up.metric 5 exceeds limit 1");
308    }
309}