Skip to main content

normalize_output/
diagnostics.rs

1//! Unified diagnostic types for all issue-reporting commands.
2//!
3//! Any command that finds "problems in files" — broken references, stale docs,
4//! missing examples, security findings, lint violations, rule matches — should
5//! converge on these types.
6
7use crate::OutputFormatter;
8use serde::{Deserialize, Serialize};
9
10/// Severity level for a diagnostic issue.
11#[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    /// Return the severity as a lowercase string.
49    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    /// Convert to SARIF level string.
59    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    /// Parse from SARIF level string.
68    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/// A secondary location related to an issue (e.g., the other file in a circular dep).
79#[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/// A single diagnostic issue found during a check.
97#[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    /// Which engine/check produced this issue.
118    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    /// Format as `file:line:col: severity [rule_id] message`.
127    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/// A record of a tool that failed to run or produce valid output.
140#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
141pub struct ToolFailure {
142    /// Name of the tool that failed.
143    pub tool: String,
144    /// Human-readable error message.
145    pub message: String,
146}
147
148/// Report containing diagnostic issues from one or more checks.
149#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
150pub struct DiagnosticsReport {
151    pub issues: Vec<Issue>,
152    pub files_checked: usize,
153    /// Which checks/engines produced issues in this report.
154    pub sources_run: Vec<String>,
155    /// Errors from tools that failed to run or produce valid output.
156    #[serde(skip_serializing_if = "Vec::is_empty", default)]
157    pub tool_errors: Vec<ToolFailure>,
158    /// Whether the results were served from the daemon's diagnostics cache.
159    /// When `true`, all requested engines were served from cache and the caller
160    /// should skip local re-evaluation of those engines.
161    #[serde(skip_serializing_if = "std::ops::Not::not", default)]
162    pub daemon_cached: bool,
163}
164
165impl DiagnosticsReport {
166    /// Create an empty report.
167    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    /// Merge another report into this one.
178    ///
179    /// `files_checked` is summed (not maxed) across both reports. Issues from
180    /// `other` are appended to `self.issues`. Sources are union-merged (no
181    /// duplicates). Tool errors are appended.
182    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    /// Sort issues by file, then line, then severity (most severe first).
194    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    /// Format as SARIF 2.1.0 JSON.
204    pub fn format_sarif(&self) -> String {
205        // Collect unique rule IDs to build the tool.driver.rules array
206        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                // Find the first issue with this rule_id to derive default severity
217                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        // normalize-syntax-allow: rust/unwrap-in-impl - serde_json::Value is always serializable
274        serde_json::to_string_pretty(&sarif).unwrap()
275    }
276
277    /// Count issues by severity.
278    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    /// Format as text with an optional limit on the number of issues shown.
294    /// Only errors and warnings are shown in detail; info/hints are summarized at the end.
295    pub fn format_text_limited(&self, limit: Option<usize>) -> String {
296        let mut out = String::new();
297
298        // Show tool errors first so they're visible even when there are no issues.
299        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        // Header counts all issues for complete picture
327        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        // Only show errors and warnings in detail; info/hints are noisy and informational only.
359        // Errors are always shown regardless of limit; limit applies only to warnings.
360        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        // Show tool errors prominently at the top.
431        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        // Group issues by file, printing the filename once as a bold header.
509        // Locationless issues (file == "") are shown flat before the file groups.
510        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                // Locationless: flat format, no file grouping.
521                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                // File-located: print file header when file changes, then indent.
529                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
578/// Convert diagnostic `Severity` to SARIF level string.
579fn 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}