provable_contracts/lint/
finding.rs1use serde::{Deserialize, Serialize};
10
11use super::rules::RuleSeverity;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct LintFinding {
16 pub rule_id: String,
17 pub severity: RuleSeverity,
18 pub message: String,
19 pub file: String,
20 pub line: Option<u32>,
21 pub contract_stem: Option<String>,
22 pub suppressed: bool,
23 #[serde(skip_serializing_if = "Option::is_none")]
24 pub suppression_reason: Option<String>,
25 #[serde(default)]
27 pub is_new: bool,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub snippet: Option<String>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub suggestion: Option<String>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub evidence: Option<String>,
37}
38
39impl LintFinding {
40 pub fn new(
42 rule_id: impl Into<String>,
43 severity: RuleSeverity,
44 message: impl Into<String>,
45 file: impl Into<String>,
46 ) -> Self {
47 Self {
48 rule_id: rule_id.into(),
49 severity,
50 message: message.into(),
51 file: file.into(),
52 line: None,
53 contract_stem: None,
54 suppressed: false,
55 suppression_reason: None,
56 is_new: false,
57 snippet: None,
58 suggestion: None,
59 evidence: None,
60 }
61 }
62
63 #[must_use]
64 pub fn with_line(mut self, line: u32) -> Self {
65 self.line = Some(line);
66 self
67 }
68
69 #[must_use]
70 pub fn with_stem(mut self, stem: impl Into<String>) -> Self {
71 self.contract_stem = Some(stem.into());
72 self
73 }
74
75 #[must_use]
76 pub fn with_snippet(mut self, snippet: impl Into<String>) -> Self {
77 self.snippet = Some(snippet.into());
78 self
79 }
80
81 #[must_use]
82 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
83 self.suggestion = Some(suggestion.into());
84 self
85 }
86
87 #[must_use]
88 pub fn with_evidence(mut self, evidence: impl Into<String>) -> Self {
89 self.evidence = Some(evidence.into());
90 self
91 }
92
93 #[must_use]
94 pub fn suppress(mut self, reason: impl Into<String>) -> Self {
95 self.suppressed = true;
96 self.suppression_reason = Some(reason.into());
97 self
98 }
99
100 pub fn to_github_annotation(&self) -> String {
102 let level = match self.severity {
103 RuleSeverity::Error => "error",
104 RuleSeverity::Warning => "warning",
105 RuleSeverity::Info | RuleSeverity::Off => "notice",
106 };
107 let line_part = self.line.map(|l| format!(",line={l}")).unwrap_or_default();
108 format!(
109 "::{level} file={}{line_part}::{}: {}",
110 self.file, self.rule_id, self.message
111 )
112 }
113
114 pub fn fingerprint(&self) -> String {
116 use std::collections::hash_map::DefaultHasher;
117 use std::hash::{Hash, Hasher};
118 let mut hasher = DefaultHasher::new();
119 self.rule_id.hash(&mut hasher);
120 self.file.hash(&mut hasher);
121 self.message.hash(&mut hasher);
122 format!("{}:{}:{:016x}", self.rule_id, self.file, hasher.finish())
123 }
124}
125
126pub fn read_snippet(file: &str, line: Option<u32>) -> Option<String> {
128 let line_num = line? as usize;
129 if line_num == 0 {
130 return None;
131 }
132 let content = std::fs::read_to_string(file).ok()?;
133 let target = content.lines().nth(line_num - 1)?;
134 let trimmed = target.trim();
135 if trimmed.is_empty() {
136 None
137 } else {
138 Some(trimmed.to_string())
139 }
140}
141
142impl std::fmt::Display for LintFinding {
143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144 let sev = match self.severity {
145 RuleSeverity::Error => "ERROR",
146 RuleSeverity::Warning => "WARN",
147 RuleSeverity::Info => "INFO",
148 RuleSeverity::Off => "OFF",
149 };
150 let suppressed = if self.suppressed { " [suppressed]" } else { "" };
151 let new_badge = if self.is_new { " [NEW]" } else { "" };
152 write!(
153 f,
154 "[{sev}] {}: {} ({}){suppressed}{new_badge}",
155 self.rule_id, self.message, self.file
156 )
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 fn sample() -> LintFinding {
165 LintFinding::new(
166 "PV-VAL-001",
167 RuleSeverity::Error,
168 "Missing proof_obligations",
169 "contracts/example-v1.yaml",
170 )
171 }
172
173 #[test]
174 fn display_format() {
175 let f = sample();
176 let s = f.to_string();
177 assert!(s.contains("[ERROR]"));
178 assert!(s.contains("PV-VAL-001"));
179 assert!(s.contains("Missing proof_obligations"));
180 assert!(s.contains("example-v1.yaml"));
181 }
182
183 #[test]
184 fn display_suppressed() {
185 let f = sample().suppress("known gap");
186 assert!(f.to_string().contains("[suppressed]"));
187 }
188
189 #[test]
190 fn github_annotation_error() {
191 let f = sample().with_line(42);
192 let ann = f.to_github_annotation();
193 assert!(ann.starts_with("::error "));
194 assert!(ann.contains("file=contracts/example-v1.yaml"));
195 assert!(ann.contains(",line=42"));
196 assert!(ann.contains("PV-VAL-001"));
197 }
198
199 #[test]
200 fn github_annotation_warning() {
201 let f = LintFinding::new("PV-AUD-001", RuleSeverity::Warning, "test", "file.yaml");
202 assert!(f.to_github_annotation().starts_with("::warning "));
203 }
204
205 #[test]
206 fn github_annotation_no_line() {
207 let f = sample();
208 let ann = f.to_github_annotation();
209 assert!(!ann.contains(",line="));
210 }
211
212 #[test]
213 fn fingerprint_deterministic() {
214 let f = sample();
215 let fp1 = f.fingerprint();
216 let fp2 = f.fingerprint();
217 assert_eq!(fp1, fp2);
218 }
219
220 #[test]
221 fn fingerprint_differs_on_rule() {
222 let f1 = sample();
223 let f2 = LintFinding::new(
224 "PV-VAL-002",
225 RuleSeverity::Error,
226 "Missing proof_obligations",
227 "contracts/example-v1.yaml",
228 );
229 assert_ne!(f1.fingerprint(), f2.fingerprint());
230 }
231
232 #[test]
233 fn with_stem() {
234 let f = sample().with_stem("example-v1");
235 assert_eq!(f.contract_stem.as_deref(), Some("example-v1"));
236 }
237
238 #[test]
239 fn serializes_to_json() {
240 let f = sample();
241 let json = serde_json::to_string(&f).unwrap();
242 assert!(json.contains("PV-VAL-001"));
243 assert!(json.contains("error"));
244 }
245
246 #[test]
247 fn suppressed_serializes() {
248 let f = sample().suppress("reason");
249 let json = serde_json::to_string(&f).unwrap();
250 assert!(json.contains("\"suppressed\":true"));
251 assert!(json.contains("\"suppression_reason\":\"reason\""));
252 }
253
254 #[test]
255 fn is_new_defaults_to_false() {
256 let f = sample();
257 assert!(!f.is_new);
258 }
259
260 #[test]
261 fn is_new_display_badge() {
262 let mut f = sample();
263 f.is_new = true;
264 let s = f.to_string();
265 assert!(s.contains("[NEW]"));
266 }
267
268 #[test]
269 fn is_new_no_badge_when_false() {
270 let f = sample();
271 assert!(!f.to_string().contains("[NEW]"));
272 }
273
274 #[test]
275 fn is_new_deserializes_default() {
276 let json = r#"{"rule_id":"PV-VAL-001","severity":"error","message":"msg","file":"f.yaml","suppressed":false}"#;
277 let f: LintFinding = serde_json::from_str(json).unwrap();
278 assert!(!f.is_new);
279 }
280
281 #[test]
282 fn is_new_serializes_when_true() {
283 let mut f = sample();
284 f.is_new = true;
285 let json = serde_json::to_string(&f).unwrap();
286 assert!(json.contains("\"is_new\":true"));
287 }
288}