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}