Skip to main content

oparry_core/
report.rs

1//! Validation reports and output formatting
2
3use serde::{Deserialize, Serialize};
4use serde_json::json;
5use std::fmt;
6
7/// Severity level for rules and issues
8#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
9#[serde(rename_all = "lowercase")]
10pub enum Severity {
11    /// Informational notice
12    Note,
13    /// Warning - doesn't block in non-strict mode
14    Warning,
15    /// Error - blocks validation
16    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
29/// Issue level in report
30pub type IssueLevel = Severity;
31
32/// A single validation issue
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct Issue {
35    /// Severity level
36    pub level: IssueLevel,
37
38    /// Issue code (e.g., "tailwind-invalid-class")
39    pub code: String,
40
41    /// Human-readable message
42    pub message: String,
43
44    /// File path
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub file: Option<String>,
47
48    /// Line number (0-indexed)
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub line: Option<usize>,
51
52    /// Column number (0-indexed)
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub column: Option<usize>,
55
56    /// Suggested fix
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub suggestion: Option<String>,
59
60    /// Additional context
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub context: Option<String>,
63}
64
65impl Issue {
66    /// Create a new issue
67    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    /// Add file location
81    pub fn with_file(mut self, file: impl Into<String>) -> Self {
82        self.file = Some(file.into());
83        self
84    }
85
86    /// Add line location
87    pub fn with_line(mut self, line: usize) -> Self {
88        self.line = Some(line);
89        self
90    }
91
92    /// Add column location
93    pub fn with_column(mut self, column: usize) -> Self {
94        self.column = Some(column);
95        self
96    }
97
98    /// Add suggestion
99    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
100        self.suggestion = Some(suggestion.into());
101        self
102    }
103
104    /// Add context
105    pub fn with_context(mut self, context: impl Into<String>) -> Self {
106        self.context = Some(context.into());
107        self
108    }
109
110    /// Create an error issue
111    pub fn error(code: impl Into<String>, message: impl Into<String>) -> Self {
112        Self::new(Severity::Error, code, message)
113    }
114
115    /// Create a warning issue
116    pub fn warning(code: impl Into<String>, message: impl Into<String>) -> Self {
117        Self::new(Severity::Warning, code, message)
118    }
119
120    /// Create a note issue
121    pub fn note(code: impl Into<String>, message: impl Into<String>) -> Self {
122        Self::new(Severity::Note, code, message)
123    }
124}
125
126/// Validation result
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct ValidationResult {
129    /// Whether validation passed
130    pub passed: bool,
131
132    /// Issues found
133    pub issues: Vec<Issue>,
134
135    /// Number of files checked
136    pub files_checked: usize,
137
138    /// Duration of validation
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub duration_ms: Option<u64>,
141}
142
143impl ValidationResult {
144    /// Create a new validation result
145    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    /// Add an issue
155    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    /// Merge another result into this one
163    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    /// Get the count of issues by severity
170    pub fn count_by_severity(&self, severity: Severity) -> usize {
171        self.issues.iter().filter(|i| i.level == severity).count()
172    }
173
174    /// Get total errors
175    pub fn error_count(&self) -> usize {
176        self.count_by_severity(Severity::Error)
177    }
178
179    /// Get total warnings
180    pub fn warning_count(&self) -> usize {
181        self.count_by_severity(Severity::Warning)
182    }
183
184    /// Finalize validation result with strict mode consideration
185    /// In strict mode, warnings are treated as errors
186    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    /// Check if validation passes considering strict mode
193    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/// Complete validation report
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct Report {
213    /// Parry version
214    pub version: String,
215
216    /// Validation results
217    pub result: ValidationResult,
218
219    /// Timestamp
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub timestamp: Option<chrono::DateTime<chrono::Utc>>,
222}
223
224impl Report {
225    /// Create a new report
226    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    /// Convert to JSON
235    pub fn to_json(&self) -> Result<String, serde_json::Error> {
236        serde_json::to_string_pretty(self)
237    }
238
239    /// Convert to SARIF format (manual implementation)
240    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                // Add location if available
261                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); // Still passed with only warning
324
325        result.add_issue(Issue::error("err", "error"));
326        assert!(!result.passed); // Failed with error
327        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}