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}