1use crate::OutputFormatter;
8use serde::{Deserialize, Serialize};
9
10#[derive(
12 Debug,
13 Clone,
14 Copy,
15 PartialEq,
16 Eq,
17 PartialOrd,
18 Ord,
19 Hash,
20 Serialize,
21 Deserialize,
22 schemars::JsonSchema,
23 rkyv::Archive,
24 rkyv::Serialize,
25 rkyv::Deserialize,
26)]
27#[rkyv(derive(Debug))]
28#[serde(rename_all = "lowercase")]
29pub enum Severity {
30 Hint,
31 Info,
32 Warning,
33 Error,
34}
35
36impl std::fmt::Display for Severity {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 match self {
39 Severity::Hint => write!(f, "hint"),
40 Severity::Info => write!(f, "info"),
41 Severity::Warning => write!(f, "warning"),
42 Severity::Error => write!(f, "error"),
43 }
44 }
45}
46
47impl Severity {
48 pub fn as_str(&self) -> &'static str {
50 match self {
51 Self::Error => "error",
52 Self::Warning => "warning",
53 Self::Info => "info",
54 Self::Hint => "hint",
55 }
56 }
57
58 pub fn to_sarif_level(&self) -> &'static str {
60 match self {
61 Self::Error => "error",
62 Self::Warning => "warning",
63 Self::Info | Self::Hint => "note",
64 }
65 }
66
67 pub fn from_sarif_level(level: &str) -> Self {
69 match level.to_lowercase().as_str() {
70 "error" => Self::Error,
71 "warning" => Self::Warning,
72 "note" | "none" => Self::Info,
73 _ => Self::Warning,
74 }
75 }
76}
77
78#[derive(
80 Debug,
81 Clone,
82 Serialize,
83 Deserialize,
84 schemars::JsonSchema,
85 rkyv::Archive,
86 rkyv::Serialize,
87 rkyv::Deserialize,
88)]
89#[rkyv(derive(Debug))]
90pub struct RelatedLocation {
91 pub file: String,
92 pub line: Option<usize>,
93 pub message: Option<String>,
94}
95
96#[derive(
98 Debug,
99 Clone,
100 Serialize,
101 Deserialize,
102 schemars::JsonSchema,
103 rkyv::Archive,
104 rkyv::Serialize,
105 rkyv::Deserialize,
106)]
107#[rkyv(derive(Debug))]
108pub struct Issue {
109 pub file: String,
110 pub line: Option<usize>,
111 pub column: Option<usize>,
112 pub end_line: Option<usize>,
113 pub end_column: Option<usize>,
114 pub rule_id: String,
115 pub message: String,
116 pub severity: Severity,
117 pub source: String,
119 #[serde(default, skip_serializing_if = "Vec::is_empty")]
120 pub related: Vec<RelatedLocation>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub suggestion: Option<String>,
123}
124
125impl Issue {
126 pub fn format_location(&self) -> String {
128 let mut loc = self.file.clone();
129 if let Some(line) = self.line {
130 loc.push_str(&format!(":{line}"));
131 if let Some(col) = self.column {
132 loc.push_str(&format!(":{col}"));
133 }
134 }
135 loc
136 }
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
141pub struct ToolFailure {
142 pub tool: String,
144 pub message: String,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
150pub struct DiagnosticsReport {
151 pub issues: Vec<Issue>,
152 pub files_checked: usize,
153 pub sources_run: Vec<String>,
155 #[serde(skip_serializing_if = "Vec::is_empty", default)]
157 pub tool_errors: Vec<ToolFailure>,
158 #[serde(skip_serializing_if = "std::ops::Not::not", default)]
162 pub daemon_cached: bool,
163}
164
165impl DiagnosticsReport {
166 pub fn new() -> Self {
168 Self {
169 issues: Vec::new(),
170 files_checked: 0,
171 sources_run: Vec::new(),
172 tool_errors: Vec::new(),
173 daemon_cached: false,
174 }
175 }
176
177 pub fn merge(&mut self, other: DiagnosticsReport) {
183 self.files_checked += other.files_checked;
184 self.issues.extend(other.issues);
185 for source in other.sources_run {
186 if !self.sources_run.contains(&source) {
187 self.sources_run.push(source);
188 }
189 }
190 self.tool_errors.extend(other.tool_errors);
191 }
192
193 pub fn sort(&mut self) {
195 self.issues.sort_by(|a, b| {
196 a.file
197 .cmp(&b.file)
198 .then(a.line.cmp(&b.line))
199 .then(b.severity.cmp(&a.severity))
200 });
201 }
202
203 pub fn format_sarif(&self) -> String {
205 let mut rule_ids: Vec<String> = Vec::new();
207 for issue in &self.issues {
208 if !rule_ids.contains(&issue.rule_id) {
209 rule_ids.push(issue.rule_id.clone());
210 }
211 }
212
213 let sarif_rules: Vec<serde_json::Value> = rule_ids
214 .iter()
215 .map(|id| {
216 let first = self.issues.iter().find(|i| &i.rule_id == id);
218 let level = first.map_or("warning", |i| severity_to_sarif_level(i.severity));
219 serde_json::json!({
220 "id": id,
221 "defaultConfiguration": { "level": level }
222 })
223 })
224 .collect();
225
226 let results: Vec<serde_json::Value> = self
227 .issues
228 .iter()
229 .map(|issue| {
230 let mut region = serde_json::Map::new();
231 if let Some(line) = issue.line {
232 region.insert("startLine".into(), serde_json::json!(line));
233 }
234 if let Some(col) = issue.column {
235 region.insert("startColumn".into(), serde_json::json!(col));
236 }
237 if let Some(end_line) = issue.end_line {
238 region.insert("endLine".into(), serde_json::json!(end_line));
239 }
240 if let Some(end_col) = issue.end_column {
241 region.insert("endColumn".into(), serde_json::json!(end_col));
242 }
243
244 serde_json::json!({
245 "ruleId": issue.rule_id,
246 "level": severity_to_sarif_level(issue.severity),
247 "message": { "text": issue.message },
248 "locations": [{
249 "physicalLocation": {
250 "artifactLocation": { "uri": issue.file },
251 "region": region
252 }
253 }]
254 })
255 })
256 .collect();
257
258 let sarif = serde_json::json!({
259 "version": "2.1.0",
260 "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
261 "runs": [{
262 "tool": {
263 "driver": {
264 "name": "normalize",
265 "informationUri": "https://github.com/rhi-zone/normalize",
266 "rules": sarif_rules
267 }
268 },
269 "results": results
270 }]
271 });
272
273 serde_json::to_string_pretty(&sarif).unwrap()
275 }
276
277 pub fn count_by_severity(&self, severity: Severity) -> usize {
279 self.issues
280 .iter()
281 .filter(|i| i.severity == severity)
282 .count()
283 }
284}
285
286impl Default for DiagnosticsReport {
287 fn default() -> Self {
288 Self::new()
289 }
290}
291
292impl DiagnosticsReport {
293 pub fn format_text_limited(&self, limit: Option<usize>) -> String {
296 let mut out = String::new();
297
298 if !self.tool_errors.is_empty() {
300 out.push_str(&format!(
301 "{} tool error{}:\n",
302 self.tool_errors.len(),
303 if self.tool_errors.len() == 1 { "" } else { "s" }
304 ));
305 for err in &self.tool_errors {
306 out.push_str(&format!(" {}: {}\n", err.tool, err.message));
307 }
308 out.push('\n');
309 }
310
311 if self.issues.is_empty() {
312 out.push_str(&format!(
313 "No issues found ({} files checked, sources: {}).\n",
314 self.files_checked,
315 self.sources_run.join(", ")
316 ));
317 return out;
318 }
319
320 let errors = self.count_by_severity(Severity::Error);
321 let warnings = self.count_by_severity(Severity::Warning);
322 let infos = self.count_by_severity(Severity::Info);
323 let hints = self.count_by_severity(Severity::Hint);
324 let actionable = errors + warnings;
325
326 let files_str = if self.files_checked > 0 {
328 format!("{} files checked", self.files_checked)
329 } else {
330 format!("sources: {}", self.sources_run.join(", "))
331 };
332 out.push_str(&format!("{} issues ({})\n", self.issues.len(), files_str));
333
334 let mut parts = Vec::new();
335 if errors > 0 {
336 parts.push(format!(
337 "{errors} error{}",
338 if errors == 1 { "" } else { "s" }
339 ));
340 }
341 if warnings > 0 {
342 parts.push(format!(
343 "{warnings} warning{}",
344 if warnings == 1 { "" } else { "s" }
345 ));
346 }
347 if infos > 0 {
348 parts.push(format!("{infos} info"));
349 }
350 if hints > 0 {
351 parts.push(format!("{hints} hint{}", if hints == 1 { "" } else { "s" }));
352 }
353 if !parts.is_empty() {
354 out.push_str(&format!(" {}\n", parts.join(", ")));
355 }
356 out.push('\n');
357
358 let error_issues: Vec<&Issue> = self
361 .issues
362 .iter()
363 .filter(|i| matches!(i.severity, Severity::Error))
364 .collect();
365 let warning_issues: Vec<&Issue> = self
366 .issues
367 .iter()
368 .filter(|i| matches!(i.severity, Severity::Warning))
369 .collect();
370
371 let warning_limit = limit
372 .map(|l| l.saturating_sub(error_issues.len()))
373 .unwrap_or(warning_issues.len());
374 let shown_warnings = warning_issues.len().min(warning_limit);
375 let shown = error_issues.len() + shown_warnings;
376
377 for issue in error_issues
378 .iter()
379 .chain(warning_issues.iter().take(shown_warnings))
380 {
381 out.push_str(&format!(
382 "{}: {} [{}] {}\n",
383 issue.format_location(),
384 issue.severity,
385 issue.rule_id,
386 issue.message,
387 ));
388 for rel in &issue.related {
389 let rloc = if let Some(line) = rel.line {
390 format!("{}:{line}", rel.file)
391 } else {
392 rel.file.clone()
393 };
394 if let Some(msg) = &rel.message {
395 out.push_str(&format!(" --> {rloc}: {msg}\n"));
396 } else {
397 out.push_str(&format!(" --> {rloc}\n"));
398 }
399 }
400 if let Some(suggestion) = &issue.suggestion {
401 out.push_str(&format!(" suggestion: {suggestion}\n"));
402 }
403 }
404
405 if shown < actionable {
406 out.push_str(&format!(" ... {} more not shown\n", actionable - shown));
407 }
408 if infos + hints > 0 {
409 out.push_str(&format!(
410 " {} info/hint suggestion{} (use --pretty to show)\n",
411 infos + hints,
412 if infos + hints == 1 { "" } else { "s" }
413 ));
414 }
415
416 out
417 }
418}
419
420impl OutputFormatter for DiagnosticsReport {
421 fn format_text(&self) -> String {
422 self.format_text_limited(None)
423 }
424
425 fn format_pretty(&self) -> String {
426 use nu_ansi_term::Color;
427
428 let mut out = String::new();
429
430 if !self.tool_errors.is_empty() {
432 out.push_str(&format!(
433 "{}\n",
434 Color::Red.bold().paint(format!(
435 "{} tool error{}:",
436 self.tool_errors.len(),
437 if self.tool_errors.len() == 1 { "" } else { "s" }
438 ))
439 ));
440 for err in &self.tool_errors {
441 out.push_str(&format!(
442 " {}: {}\n",
443 Color::Red.paint(&err.tool),
444 err.message,
445 ));
446 }
447 out.push('\n');
448 }
449
450 if self.issues.is_empty() {
451 out.push_str(&format!(
452 "{} No issues found ({} files checked)\n",
453 Color::Green.paint("✓"),
454 self.files_checked
455 ));
456 return out;
457 }
458 let errors = self.count_by_severity(Severity::Error);
459 let warnings = self.count_by_severity(Severity::Warning);
460
461 let header_color = if errors > 0 {
462 Color::Red
463 } else {
464 Color::Yellow
465 };
466 out.push_str(&format!(
467 "{}\n",
468 header_color.bold().paint(format!(
469 "{} issues ({} files checked)",
470 self.issues.len(),
471 self.files_checked
472 ))
473 ));
474 let mut parts = Vec::new();
475 if errors > 0 {
476 parts.push(
477 Color::Red
478 .paint(format!(
479 "{errors} error{}",
480 if errors == 1 { "" } else { "s" }
481 ))
482 .to_string(),
483 );
484 }
485 if warnings > 0 {
486 parts.push(
487 Color::Yellow
488 .paint(format!(
489 "{warnings} warning{}",
490 if warnings == 1 { "" } else { "s" }
491 ))
492 .to_string(),
493 );
494 }
495 let infos = self.count_by_severity(Severity::Info);
496 let hints = self.count_by_severity(Severity::Hint);
497 if infos > 0 {
498 parts.push(format!("{infos} info"));
499 }
500 if hints > 0 {
501 parts.push(format!("{hints} hint{}", if hints == 1 { "" } else { "s" }));
502 }
503 if !parts.is_empty() {
504 out.push_str(&format!(" {}\n", parts.join(", ")));
505 }
506 out.push('\n');
507
508 let mut current_file: Option<&str> = None;
511 for issue in &self.issues {
512 let sev_color = match issue.severity {
513 Severity::Error => Color::Red,
514 Severity::Warning => Color::Yellow,
515 Severity::Info => Color::Cyan,
516 Severity::Hint => Color::DarkGray,
517 };
518
519 if issue.file.is_empty() {
520 out.push_str(&format!(
522 "{} {} {}\n",
523 sev_color.bold().paint(issue.severity.to_string()),
524 Color::DarkGray.paint(format!("[{}]", issue.rule_id)),
525 issue.message,
526 ));
527 } else {
528 if current_file != Some(issue.file.as_str()) {
530 current_file = Some(issue.file.as_str());
531 out.push_str(&format!(
532 "{}\n",
533 Color::White.bold().paint(issue.file.as_str())
534 ));
535 }
536 let line_str = match (issue.line, issue.column) {
537 (Some(line), Some(col)) => format!("{line}:{col}"),
538 (Some(line), None) => format!("{line}"),
539 _ => String::new(),
540 };
541 out.push_str(&format!(
542 " {} {} {} {}\n",
543 Color::DarkGray.paint(&line_str),
544 sev_color.bold().paint(issue.severity.to_string()),
545 Color::DarkGray.paint(format!("[{}]", issue.rule_id)),
546 issue.message,
547 ));
548 }
549
550 for rel in &issue.related {
551 let rloc = if let Some(line) = rel.line {
552 format!("{}:{line}", rel.file)
553 } else {
554 rel.file.clone()
555 };
556 if let Some(msg) = &rel.message {
557 out.push_str(&format!(
558 " {} {}: {msg}\n",
559 Color::DarkGray.paint("-->"),
560 rloc
561 ));
562 } else {
563 out.push_str(&format!(" {} {}\n", Color::DarkGray.paint("-->"), rloc));
564 }
565 }
566 if let Some(suggestion) = &issue.suggestion {
567 out.push_str(&format!(
568 " {} {suggestion}\n",
569 Color::Green.paint("suggestion:")
570 ));
571 }
572 }
573
574 out
575 }
576}
577
578fn severity_to_sarif_level(severity: Severity) -> &'static str {
580 match severity {
581 Severity::Error => "error",
582 Severity::Warning => "warning",
583 Severity::Info => "note",
584 Severity::Hint => "note",
585 }
586}
587
588#[cfg(test)]
589mod tests {
590 use super::*;
591
592 #[test]
593 fn test_empty_report() {
594 let report = DiagnosticsReport {
595 issues: vec![],
596 files_checked: 10,
597 sources_run: vec!["check-refs".into()],
598 tool_errors: vec![],
599 daemon_cached: false,
600 };
601 let text = report.format_text();
602 assert!(text.contains("No issues found"));
603 assert!(text.contains("10 files checked"));
604 }
605
606 #[test]
607 fn test_issue_format_location() {
608 let issue = Issue {
609 file: "src/main.rs".into(),
610 line: Some(42),
611 column: Some(5),
612 end_line: None,
613 end_column: None,
614 rule_id: "broken-ref".into(),
615 message: "Unknown symbol `Foo`".into(),
616 severity: Severity::Warning,
617 source: "check-refs".into(),
618 related: vec![],
619 suggestion: None,
620 };
621 assert_eq!(issue.format_location(), "src/main.rs:42:5");
622 }
623
624 #[test]
625 fn test_issue_format_location_no_col() {
626 let issue = Issue {
627 file: "docs/README.md".into(),
628 line: Some(10),
629 column: None,
630 end_line: None,
631 end_column: None,
632 rule_id: "stale-doc".into(),
633 message: "Doc is stale".into(),
634 severity: Severity::Info,
635 source: "stale-docs".into(),
636 related: vec![],
637 suggestion: None,
638 };
639 assert_eq!(issue.format_location(), "docs/README.md:10");
640 }
641
642 #[test]
643 fn test_report_merge() {
644 let mut a = DiagnosticsReport {
645 issues: vec![Issue {
646 file: "a.rs".into(),
647 line: Some(1),
648 column: None,
649 end_line: None,
650 end_column: None,
651 rule_id: "r1".into(),
652 message: "msg1".into(),
653 severity: Severity::Warning,
654 source: "check-refs".into(),
655 related: vec![],
656 suggestion: None,
657 }],
658 files_checked: 5,
659 sources_run: vec!["check-refs".into()],
660 tool_errors: vec![],
661 daemon_cached: false,
662 };
663 let b = DiagnosticsReport {
664 issues: vec![Issue {
665 file: "b.rs".into(),
666 line: Some(2),
667 column: None,
668 end_line: None,
669 end_column: None,
670 rule_id: "r2".into(),
671 message: "msg2".into(),
672 severity: Severity::Error,
673 source: "stale-docs".into(),
674 related: vec![],
675 suggestion: None,
676 }],
677 files_checked: 8,
678 sources_run: vec!["stale-docs".into()],
679 tool_errors: vec![],
680 daemon_cached: false,
681 };
682 a.merge(b);
683 assert_eq!(a.issues.len(), 2);
684 assert_eq!(a.files_checked, 13);
685 assert_eq!(a.sources_run, vec!["check-refs", "stale-docs"]);
686 }
687
688 #[test]
689 fn test_severity_ordering() {
690 assert!(Severity::Error > Severity::Warning);
691 assert!(Severity::Warning > Severity::Info);
692 assert!(Severity::Info > Severity::Hint);
693 }
694
695 #[test]
696 fn test_report_sort() {
697 let mut report = DiagnosticsReport {
698 issues: vec![
699 Issue {
700 file: "b.rs".into(),
701 line: Some(1),
702 column: None,
703 end_line: None,
704 end_column: None,
705 rule_id: "r1".into(),
706 message: "m".into(),
707 severity: Severity::Warning,
708 source: "s".into(),
709 related: vec![],
710 suggestion: None,
711 },
712 Issue {
713 file: "a.rs".into(),
714 line: Some(1),
715 column: None,
716 end_line: None,
717 end_column: None,
718 rule_id: "r2".into(),
719 message: "m".into(),
720 severity: Severity::Error,
721 source: "s".into(),
722 related: vec![],
723 suggestion: None,
724 },
725 ],
726 files_checked: 2,
727 sources_run: vec!["s".into()],
728 tool_errors: vec![],
729 daemon_cached: false,
730 };
731 report.sort();
732 assert_eq!(report.issues[0].file, "a.rs");
733 assert_eq!(report.issues[1].file, "b.rs");
734 }
735
736 #[test]
737 fn test_tool_errors_shown_in_text() {
738 let report = DiagnosticsReport {
739 issues: vec![],
740 files_checked: 0,
741 sources_run: vec!["sarif".into()],
742 tool_errors: vec![
743 ToolFailure {
744 tool: "eslint".into(),
745 message: "failed to run: No such file or directory".into(),
746 },
747 ToolFailure {
748 tool: "clippy-sarif".into(),
749 message: "did not emit valid JSON: expected value at line 1".into(),
750 },
751 ],
752 daemon_cached: false,
753 };
754 let text = report.format_text();
755 assert!(text.contains("2 tool errors:"));
756 assert!(text.contains("eslint: failed to run"));
757 assert!(text.contains("clippy-sarif: did not emit valid JSON"));
758 }
759
760 #[test]
761 fn test_merge_combines_tool_errors() {
762 let mut a = DiagnosticsReport {
763 issues: vec![],
764 files_checked: 0,
765 sources_run: vec![],
766 tool_errors: vec![ToolFailure {
767 tool: "tool-a".into(),
768 message: "error a".into(),
769 }],
770 daemon_cached: false,
771 };
772 let b = DiagnosticsReport {
773 issues: vec![],
774 files_checked: 0,
775 sources_run: vec![],
776 tool_errors: vec![ToolFailure {
777 tool: "tool-b".into(),
778 message: "error b".into(),
779 }],
780 daemon_cached: false,
781 };
782 a.merge(b);
783 assert_eq!(a.tool_errors.len(), 2);
784 assert_eq!(a.tool_errors[0].tool, "tool-a");
785 assert_eq!(a.tool_errors[1].tool, "tool-b");
786 }
787}