1use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct Finding {
29 pub check_id: String,
31 pub code: String,
33 pub severity: FindingSeverity,
35 pub title: String,
37 pub message: String,
39 #[serde(skip_serializing_if = "Option::is_none")]
41 pub location: Option<FindingLocation>,
42 #[serde(skip_serializing_if = "Option::is_none")]
44 pub evidence: Option<serde_json::Value>,
45 #[serde(skip_serializing_if = "Option::is_none")]
47 pub docs_url: Option<String>,
48 #[serde(skip_serializing_if = "Option::is_none")]
51 pub fingerprint: Option<String>,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "lowercase")]
57pub enum FindingSeverity {
58 Error,
60 Warn,
62 Info,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct FindingLocation {
88 pub path: String,
90 #[serde(skip_serializing_if = "Option::is_none")]
92 pub line: Option<u32>,
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub column: Option<u32>,
96}
97
98impl Finding {
99 pub fn new(
101 check_id: impl Into<String>,
102 code: impl Into<String>,
103 severity: FindingSeverity,
104 title: impl Into<String>,
105 message: impl Into<String>,
106 ) -> Self {
107 Self {
108 check_id: check_id.into(),
109 code: code.into(),
110 severity,
111 title: title.into(),
112 message: message.into(),
113 location: None,
114 evidence: None,
115 docs_url: None,
116 fingerprint: None,
117 }
118 }
119
120 pub fn with_location(mut self, location: FindingLocation) -> Self {
122 self.location = Some(location);
123 self
124 }
125
126 pub fn with_evidence(mut self, evidence: serde_json::Value) -> Self {
128 self.evidence = Some(evidence);
129 self
130 }
131
132 pub fn with_docs_url(mut self, url: impl Into<String>) -> Self {
134 self.docs_url = Some(url.into());
135 self
136 }
137
138 pub fn compute_fingerprint(&self, tool_name: &str) -> String {
158 let path = self
159 .location
160 .as_ref()
161 .map(|l| l.path.as_str())
162 .unwrap_or("");
163 let identity = format!("{}\0{}\0{}\0{}", tool_name, self.check_id, self.code, path);
164 let hash = blake3::hash(identity.as_bytes());
165 let hex = hash.to_hex();
166 hex[..32].to_string()
167 }
168
169 pub fn with_fingerprint(mut self, tool_name: &str) -> Self {
171 self.fingerprint = Some(self.compute_fingerprint(tool_name));
172 self
173 }
174}
175
176impl FindingLocation {
177 pub fn path(path: impl Into<String>) -> Self {
179 Self {
180 path: path.into(),
181 line: None,
182 column: None,
183 }
184 }
185
186 pub fn path_line(path: impl Into<String>, line: u32) -> Self {
188 Self {
189 path: path.into(),
190 line: Some(line),
191 column: None,
192 }
193 }
194
195 pub fn path_line_column(path: impl Into<String>, line: u32, column: u32) -> Self {
197 Self {
198 path: path.into(),
199 line: Some(line),
200 column: Some(column),
201 }
202 }
203}
204
205impl std::fmt::Display for FindingSeverity {
206 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207 match self {
208 FindingSeverity::Error => write!(f, "error"),
209 FindingSeverity::Warn => write!(f, "warn"),
210 FindingSeverity::Info => write!(f, "info"),
211 }
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::{Finding, FindingLocation, FindingSeverity};
218 use crate::findings;
219
220 #[test]
221 fn finding_severity_display_matches_serde() {
222 for (variant, expected) in [
223 (FindingSeverity::Error, "error"),
224 (FindingSeverity::Warn, "warn"),
225 (FindingSeverity::Info, "info"),
226 ] {
227 assert_eq!(variant.to_string(), expected);
228 let json = serde_json::to_value(variant).unwrap();
229 assert_eq!(json.as_str().unwrap(), expected);
230 }
231 }
232
233 #[test]
234 fn finding_builders_and_fingerprint() {
235 let location = FindingLocation::path_line_column("src/lib.rs", 10, 2);
236 let finding = Finding::new(
237 findings::risk::CHECK_ID,
238 findings::risk::COUPLING,
239 FindingSeverity::Info,
240 "Coupled module",
241 "Modules share excessive dependencies",
242 )
243 .with_location(location.clone())
244 .with_evidence(serde_json::json!({ "coupling": 0.87 }))
245 .with_docs_url("https://example.com/docs/coupling");
246
247 let expected_identity = format!(
248 "{}\0{}\0{}\0{}",
249 "tokmd",
250 findings::risk::CHECK_ID,
251 findings::risk::COUPLING,
252 location.path
253 );
254 let expected_hash = blake3::hash(expected_identity.as_bytes()).to_hex();
255 let expected_fingerprint = expected_hash[..32].to_string();
256
257 assert_eq!(finding.compute_fingerprint("tokmd"), expected_fingerprint);
258
259 let with_fp = finding.clone().with_fingerprint("tokmd");
260 assert_eq!(
261 with_fp.fingerprint.as_deref(),
262 Some(expected_fingerprint.as_str())
263 );
264
265 let no_location = Finding::new(
266 findings::risk::CHECK_ID,
267 findings::risk::HOTSPOT,
268 FindingSeverity::Warn,
269 "Hotspot",
270 "Churn is elevated",
271 );
272 assert_ne!(
273 no_location.compute_fingerprint("tokmd"),
274 finding.compute_fingerprint("tokmd")
275 );
276 }
277
278 #[test]
279 fn finding_location_constructors() {
280 let path_only = FindingLocation::path("src/main.rs");
281 assert_eq!(path_only.path, "src/main.rs");
282 assert_eq!(path_only.line, None);
283 assert_eq!(path_only.column, None);
284
285 let path_line = FindingLocation::path_line("src/main.rs", 42);
286 assert_eq!(path_line.path, "src/main.rs");
287 assert_eq!(path_line.line, Some(42));
288 assert_eq!(path_line.column, None);
289
290 let path_line_column = FindingLocation::path_line_column("src/main.rs", 7, 3);
291 assert_eq!(path_line_column.path, "src/main.rs");
292 assert_eq!(path_line_column.line, Some(7));
293 assert_eq!(path_line_column.column, Some(3));
294 }
295
296 #[test]
297 fn finding_omits_optional_fields_when_none() {
298 let finding = Finding::new(
299 findings::risk::CHECK_ID,
300 findings::risk::HOTSPOT,
301 FindingSeverity::Warn,
302 "Hotspot",
303 "Churn is elevated",
304 );
305 let json = serde_json::to_value(&finding).unwrap();
306
307 assert_eq!(json["check_id"], findings::risk::CHECK_ID);
308 assert_eq!(json["code"], findings::risk::HOTSPOT);
309 assert_eq!(json["severity"], "warn");
310 assert!(json.get("location").is_none());
311 assert!(json.get("evidence").is_none());
312 assert!(json.get("docs_url").is_none());
313 assert!(json.get("fingerprint").is_none());
314 }
315}