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::output::numfmt::MessageMetric;
17
18/// Tool identifier carried in the rule-id / source-prefix field of every
19/// CI/IDE output format (Checkstyle `<error source="...">`, Clang/MSVC
20/// warning rule prefix, SARIF `tool.driver.name`). Single source of
21/// truth so a future tool rename is one edit, not three.
22pub const TOOL_ID: &str = "big-code-analysis";
23
24/// `path.to_str()`, or emit a stderr warning and return `None`. Used
25/// by every output format that turns offender paths into UTF-8
26/// identifiers (Checkstyle attribute, SARIF URI, warning-line column,
27/// HTML / CSV cell). Centralizing the warning text keeps the
28/// `format` label consistent across formats.
29pub(crate) fn warn_non_utf8_path<'a>(format: &str, path: &'a Path) -> Option<&'a str> {
30    if let Some(s) = path.to_str() {
31        Some(s)
32    } else {
33        eprintln!(
34            "Warning: skipping non-UTF-8 path in {format} output: {}",
35            path.display()
36        );
37        None
38    }
39}
40
41/// Severity of a metric-threshold violation.
42///
43/// Defaults to [`Severity::Warning`] so producers can opt into
44/// `Error` explicitly for hard-fail gates.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
46#[serde(rename_all = "lowercase")]
47pub enum Severity {
48    /// Soft severity: report the violation but do not fail.
49    #[default]
50    Warning,
51    /// Hard severity: report the violation and fail any gate keyed off it.
52    Error,
53}
54
55impl Severity {
56    /// Lowercase token used by Checkstyle XML and most CI integrations.
57    #[must_use]
58    pub fn as_str(self) -> &'static str {
59        match self {
60            Self::Warning => "warning",
61            Self::Error => "error",
62        }
63    }
64}
65
66/// One metric-threshold violation, language-agnostic and format-agnostic.
67///
68/// Paths are stored as [`PathBuf`] so output writers can decide how to
69/// surface non-UTF-8 components (skip, replace, or fail) rather than
70/// silently lossy-converting.
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct OffenderRecord {
73    /// Source file the violation was reported against.
74    pub path: PathBuf,
75    /// Function or method name; `None` for file-level violations.
76    pub function: Option<String>,
77    /// First line covered by the violation (1-based).
78    pub start_line: u32,
79    /// Last line covered by the violation (1-based, inclusive).
80    pub end_line: u32,
81    /// Optional starting column (1-based).
82    pub start_col: Option<u32>,
83    /// Metric identifier, e.g. `"cyclomatic"`, `"loc.lloc"`,
84    /// `"halstead.volume"`.
85    pub metric: String,
86    /// Observed metric value.
87    pub value: f64,
88    /// Configured threshold the value exceeded.
89    pub limit: f64,
90    /// Severity assigned by the threshold engine.
91    pub severity: Severity,
92}
93
94impl OffenderRecord {
95    /// Default human-readable message used by formats that do not carry
96    /// their own templating. `"<metric> <value> exceeds limit <limit>"`,
97    /// with values formatted via `MessageMetric`: integer fast-path
98    /// for safe integers, six-decimal rounding for non-integer finites,
99    /// `"NaN"` / `"inf"` / `"-inf"` for non-finite values. The Display
100    /// adapter writes directly into the format buffer, so this builds
101    /// one `String` per call rather than three.
102    #[must_use]
103    pub fn default_message(&self) -> String {
104        format!(
105            "{} {} exceeds limit {}",
106            self.metric,
107            MessageMetric(self.value),
108            MessageMetric(self.limit),
109        )
110    }
111}
112
113#[cfg(test)]
114#[allow(
115    clippy::float_cmp,
116    clippy::cast_precision_loss,
117    clippy::cast_possible_truncation,
118    clippy::cast_sign_loss,
119    clippy::similar_names,
120    clippy::doc_markdown,
121    clippy::needless_raw_string_hashes,
122    clippy::too_many_lines
123)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn severity_default_is_warning() {
129        assert_eq!(Severity::default(), Severity::Warning);
130    }
131
132    #[test]
133    fn severity_as_str_lowercase() {
134        assert_eq!(Severity::Warning.as_str(), "warning");
135        assert_eq!(Severity::Error.as_str(), "error");
136    }
137
138    #[test]
139    fn default_message_renders_integral_value() {
140        let r = OffenderRecord {
141            path: PathBuf::from("a.rs"),
142            function: Some("f".into()),
143            start_line: 1,
144            end_line: 2,
145            start_col: None,
146            metric: "cyclomatic".into(),
147            value: 17.0,
148            limit: 15.0,
149            severity: Severity::Warning,
150        };
151        assert_eq!(r.default_message(), "cyclomatic 17 exceeds limit 15");
152    }
153
154    #[test]
155    fn default_message_renders_fractional_value() {
156        let r = OffenderRecord {
157            path: PathBuf::from("a.rs"),
158            function: None,
159            start_line: 1,
160            end_line: 1,
161            start_col: None,
162            metric: "halstead.volume".into(),
163            value: 12.5,
164            limit: 10.0,
165            severity: Severity::Error,
166        };
167        assert_eq!(r.default_message(), "halstead.volume 12.5 exceeds limit 10");
168    }
169
170    #[test]
171    fn default_message_renders_non_finite_values() {
172        let mut r = OffenderRecord {
173            path: PathBuf::from("a.rs"),
174            function: None,
175            start_line: 1,
176            end_line: 1,
177            start_col: None,
178            metric: "halstead.volume".into(),
179            value: f64::NAN,
180            limit: 10.0,
181            severity: Severity::Warning,
182        };
183        assert_eq!(r.default_message(), "halstead.volume NaN exceeds limit 10");
184
185        r.value = f64::INFINITY;
186        assert_eq!(r.default_message(), "halstead.volume inf exceeds limit 10");
187
188        r.value = f64::NEG_INFINITY;
189        assert_eq!(r.default_message(), "halstead.volume -inf exceeds limit 10");
190    }
191}