1use serde::{Deserialize, Serialize};
4use serde_json::json;
5use std::fmt;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
9#[serde(rename_all = "lowercase")]
10pub enum Severity {
11 Note,
13 Warning,
15 Error,
17}
18
19impl fmt::Display for Severity {
20 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21 match self {
22 Severity::Note => write!(f, "note"),
23 Severity::Warning => write!(f, "warning"),
24 Severity::Error => write!(f, "error"),
25 }
26 }
27}
28
29pub type IssueLevel = Severity;
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct Issue {
35 pub level: IssueLevel,
37
38 pub code: String,
40
41 pub message: String,
43
44 #[serde(skip_serializing_if = "Option::is_none")]
46 pub file: Option<String>,
47
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub line: Option<usize>,
51
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub column: Option<usize>,
55
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub suggestion: Option<String>,
59
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub context: Option<String>,
63}
64
65impl Issue {
66 pub fn new(level: IssueLevel, code: impl Into<String>, message: impl Into<String>) -> Self {
68 Self {
69 level,
70 code: code.into(),
71 message: message.into(),
72 file: None,
73 line: None,
74 column: None,
75 suggestion: None,
76 context: None,
77 }
78 }
79
80 pub fn with_file(mut self, file: impl Into<String>) -> Self {
82 self.file = Some(file.into());
83 self
84 }
85
86 pub fn with_line(mut self, line: usize) -> Self {
88 self.line = Some(line);
89 self
90 }
91
92 pub fn with_column(mut self, column: usize) -> Self {
94 self.column = Some(column);
95 self
96 }
97
98 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
100 self.suggestion = Some(suggestion.into());
101 self
102 }
103
104 pub fn with_context(mut self, context: impl Into<String>) -> Self {
106 self.context = Some(context.into());
107 self
108 }
109
110 pub fn error(code: impl Into<String>, message: impl Into<String>) -> Self {
112 Self::new(Severity::Error, code, message)
113 }
114
115 pub fn warning(code: impl Into<String>, message: impl Into<String>) -> Self {
117 Self::new(Severity::Warning, code, message)
118 }
119
120 pub fn note(code: impl Into<String>, message: impl Into<String>) -> Self {
122 Self::new(Severity::Note, code, message)
123 }
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct ValidationResult {
129 pub passed: bool,
131
132 pub issues: Vec<Issue>,
134
135 pub files_checked: usize,
137
138 #[serde(skip_serializing_if = "Option::is_none")]
140 pub duration_ms: Option<u64>,
141}
142
143impl ValidationResult {
144 pub fn new() -> Self {
146 Self {
147 passed: true,
148 issues: Vec::new(),
149 files_checked: 0,
150 duration_ms: None,
151 }
152 }
153
154 pub fn add_issue(&mut self, issue: Issue) {
156 if issue.level >= Severity::Error {
157 self.passed = false;
158 }
159 self.issues.push(issue);
160 }
161
162 pub fn merge(&mut self, other: ValidationResult) {
164 self.passed = self.passed && other.passed;
165 self.files_checked += other.files_checked;
166 self.issues.extend(other.issues);
167 }
168
169 pub fn count_by_severity(&self, severity: Severity) -> usize {
171 self.issues.iter().filter(|i| i.level == severity).count()
172 }
173
174 pub fn error_count(&self) -> usize {
176 self.count_by_severity(Severity::Error)
177 }
178
179 pub fn warning_count(&self) -> usize {
181 self.count_by_severity(Severity::Warning)
182 }
183
184 pub fn finalize_with_strict_mode(&mut self, strict_mode: bool) {
187 if strict_mode && self.warning_count() > 0 {
188 self.passed = false;
189 }
190 }
191
192 pub fn is_passing_with_strict(&self, strict_mode: bool) -> bool {
194 if !self.passed {
195 return false;
196 }
197 if strict_mode && self.warning_count() > 0 {
198 return false;
199 }
200 true
201 }
202}
203
204impl Default for ValidationResult {
205 fn default() -> Self {
206 Self::new()
207 }
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct Report {
213 pub version: String,
215
216 pub result: ValidationResult,
218
219 #[serde(skip_serializing_if = "Option::is_none")]
221 pub timestamp: Option<chrono::DateTime<chrono::Utc>>,
222}
223
224impl Report {
225 pub fn new(result: ValidationResult) -> Self {
227 Self {
228 version: env!("CARGO_PKG_VERSION").to_string(),
229 result,
230 timestamp: Some(chrono::Utc::now()),
231 }
232 }
233
234 pub fn to_json(&self) -> Result<String, serde_json::Error> {
236 serde_json::to_string_pretty(self)
237 }
238
239 pub fn to_sarif(&self) -> std::result::Result<serde_json::Value, String> {
241 use serde_json::json;
242
243 let results: Vec<serde_json::Value> = self
244 .result
245 .issues
246 .iter()
247 .map(|issue| {
248 let mut result = json!({
249 "ruleId": issue.code,
250 "level": match issue.level {
251 Severity::Error => "error",
252 Severity::Warning => "warning",
253 Severity::Note => "note",
254 },
255 "message": {
256 "text": issue.message
257 }
258 });
259
260 if let (Some(file), Some(line)) = (&issue.file, issue.line) {
262 let location = json!({
263 "physicalLocation": {
264 "artifactLocation": {
265 "filePath": file
266 },
267 "region": {
268 "startLine": line,
269 "startColumn": issue.column.unwrap_or(0)
270 }
271 }
272 });
273 result["locations"] = json!([location]);
274 }
275
276 result
277 })
278 .collect();
279
280 Ok(json!({
281 "version": "2.1.0",
282 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
283 "runs": [{
284 "tool": {
285 "driver": {
286 "name": "Parry",
287 "version": env!("CARGO_PKG_VERSION"),
288 "informationUri": "https://github.com/yourusername/parry"
289 }
290 },
291 "results": results
292 }]
293 }))
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
302 fn test_issue_creation() {
303 let issue = Issue::error("test-code", "test message")
304 .with_file("test.ts")
305 .with_line(10)
306 .with_column(5)
307 .with_suggestion("fix it");
308
309 assert_eq!(issue.code, "test-code");
310 assert_eq!(issue.level, Severity::Error);
311 assert_eq!(issue.file, Some("test.ts".to_string()));
312 assert_eq!(issue.line, Some(10));
313 assert_eq!(issue.column, Some(5));
314 assert_eq!(issue.suggestion, Some("fix it".to_string()));
315 }
316
317 #[test]
318 fn test_validation_result() {
319 let mut result = ValidationResult::new();
320 assert!(result.passed);
321
322 result.add_issue(Issue::warning("warn", "warning"));
323 assert!(result.passed); result.add_issue(Issue::error("err", "error"));
326 assert!(!result.passed); assert_eq!(result.error_count(), 1);
328 assert_eq!(result.warning_count(), 1);
329 }
330
331 #[test]
332 fn test_issue_note() {
333 let issue = Issue::note("note-code", "just a note");
334 assert_eq!(issue.level, Severity::Note);
335 assert_eq!(issue.code, "note-code");
336 }
337
338 #[test]
339 fn test_issue_warning() {
340 let issue = Issue::warning("warn-code", "warning message");
341 assert_eq!(issue.level, Severity::Warning);
342 assert_eq!(issue.code, "warn-code");
343 }
344
345 #[test]
346 fn test_issue_with_context() {
347 let issue = Issue::error("err", "error")
348 .with_context("context info");
349
350 assert_eq!(issue.context, Some("context info".to_string()));
351 }
352
353 #[test]
354 fn test_issue_serialization() {
355 let issue = Issue::error("test", "message")
356 .with_file("test.ts")
357 .with_line(5);
358
359 let json = serde_json::to_string(&issue);
360 assert!(json.is_ok());
361
362 let parsed: Issue = serde_json::from_str(&json.unwrap()).unwrap();
363 assert_eq!(parsed.code, "test");
364 assert_eq!(parsed.level, Severity::Error);
365 }
366
367 #[test]
368 fn test_validation_result_merge() {
369 let mut result1 = ValidationResult::new();
370 result1.add_issue(Issue::error("err1", "error 1"));
371
372 let mut result2 = ValidationResult::new();
373 result2.add_issue(Issue::error("err2", "error 2"));
374
375 result1.merge(result2);
376
377 assert_eq!(result1.error_count(), 2);
378 assert!(!result1.passed);
379 }
380
381 #[test]
382 fn test_validation_result_count_by_severity() {
383 let mut result = ValidationResult::new();
384
385 result.add_issue(Issue::error("e1", "error 1"));
386 result.add_issue(Issue::error("e2", "error 2"));
387 result.add_issue(Issue::warning("w1", "warning 1"));
388 result.add_issue(Issue::note("n1", "note 1"));
389
390 assert_eq!(result.count_by_severity(Severity::Error), 2);
391 assert_eq!(result.count_by_severity(Severity::Warning), 1);
392 assert_eq!(result.count_by_severity(Severity::Note), 1);
393 }
394
395 #[test]
396 fn test_validation_result_files_checked() {
397 let mut result = ValidationResult::new();
398 result.files_checked = 5;
399 assert_eq!(result.files_checked, 5);
400
401 let mut result2 = ValidationResult::new();
402 result2.files_checked = 3;
403
404 result.merge(result2);
405 assert_eq!(result.files_checked, 8);
406 }
407
408 #[test]
409 fn test_severity_display() {
410 assert_eq!(Severity::Error.to_string(), "error");
411 assert_eq!(Severity::Warning.to_string(), "warning");
412 assert_eq!(Severity::Note.to_string(), "note");
413 }
414
415 #[test]
416 fn test_severity_ordering() {
417 assert!(Severity::Error > Severity::Warning);
418 assert!(Severity::Warning > Severity::Note);
419 assert!(Severity::Error > Severity::Note);
420 }
421
422 #[test]
423 fn test_report_creation() {
424 let mut result = ValidationResult::new();
425 result.add_issue(Issue::error("test", "test error"));
426
427 let report = Report::new(result);
428 assert!(!report.result.passed);
429 assert!(report.timestamp.is_some());
430 assert!(!report.version.is_empty());
431 }
432
433 #[test]
434 fn test_report_to_json() {
435 let result = ValidationResult::new();
436 let report = Report::new(result);
437
438 let json = report.to_json();
439 assert!(json.is_ok());
440 }
441
442 #[test]
443 fn test_report_to_sarif() {
444 let mut result = ValidationResult::new();
445 result.add_issue(
446 Issue::error("test-error", "test message")
447 .with_file("test.ts")
448 .with_line(10)
449 .with_column(5)
450 );
451
452 let report = Report::new(result);
453 let sarif = report.to_sarif();
454 assert!(sarif.is_ok());
455
456 let sarif_value = sarif.unwrap();
457 assert_eq!(sarif_value["version"], "2.1.0");
458 assert!(sarif_value["runs"].is_array());
459 }
460
461 #[test]
462 fn test_validation_result_default() {
463 let result = ValidationResult::default();
464 assert!(result.passed);
465 assert!(result.issues.is_empty());
466 assert_eq!(result.files_checked, 0);
467 }
468
469 #[test]
470 fn test_warning_only_still_passes() {
471 let mut result = ValidationResult::new();
472 result.add_issue(Issue::warning("warn", "warning"));
473 result.add_issue(Issue::note("note", "note"));
474
475 assert!(result.passed);
476 assert_eq!(result.warning_count(), 1);
477 }
478}