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