1use super::severity_str;
4use crate::report::CheckReport;
5use deps_core::diagnostic::Severity;
6use std::collections::BTreeMap;
7use std::fmt::Write as _;
8
9#[must_use]
21pub fn render(report: &CheckReport) -> String {
22 if report.findings.is_empty() {
23 return "No findings.\n".to_string();
24 }
25
26 let mut by_file: BTreeMap<&std::path::Path, Vec<&crate::report::CheckFinding>> =
27 BTreeMap::new();
28 for finding in &report.findings {
29 by_file
30 .entry(finding.manifest_path.as_path())
31 .or_default()
32 .push(finding);
33 }
34
35 let mut out = String::new();
36 for (path, mut findings) in by_file {
37 findings.sort_by_key(|f| {
38 (
39 severity_rank(f.severity),
40 f.range.start.line,
41 f.range.start.character,
42 )
43 });
44 let _ = writeln!(out, "{}", path.display());
45 for finding in findings {
46 let dependency = finding.dependency_name.as_deref().unwrap_or("-");
47 let _ = writeln!(
48 out,
49 " [{}] {}:{} {} ({}) — {}",
50 severity_str(finding.severity),
51 finding.range.start.line + 1,
52 finding.range.start.character + 1,
53 dependency,
54 finding.category,
55 finding.message,
56 );
57 }
58 }
59
60 let summary = report.summary();
61 let _ = writeln!(out);
62 let _ = write!(out, "Summary:");
63 for (category, count) in &summary {
64 let _ = write!(out, " {category}={count}");
65 }
66 let _ = writeln!(out);
67
68 out
69}
70
71fn severity_rank(severity: Severity) -> u8 {
73 match severity {
74 Severity::Error => 0,
75 Severity::Warning => 1,
76 Severity::Information => 2,
77 Severity::Hint => 3,
78 }
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84 use crate::report::{Category, CheckFinding};
85 use deps_core::EcosystemId;
86 use deps_core::position::{Position, Range};
87 use std::path::PathBuf;
88
89 fn finding(path: &str, category: Category, severity: Severity) -> CheckFinding {
90 CheckFinding {
91 ecosystem: EcosystemId::Cargo,
92 manifest_path: PathBuf::from(path),
93 dependency_name: Some("serde".to_string()),
94 requirement: Some("1.0".to_string()),
95 category,
96 code: None,
97 advisory_url: None,
98 advisory_severity: None,
99 severity,
100 range: Range::new(Position::new(4, 0), Position::new(4, 10)),
101 message: "Newer version available: 1.1.0".to_string(),
102 }
103 }
104
105 #[test]
106 fn test_render_empty_report() {
107 assert_eq!(render(&CheckReport::default()), "No findings.\n");
108 }
109
110 #[test]
111 fn test_render_single_finding_includes_path_and_message() {
112 let report = CheckReport {
113 findings: vec![finding("Cargo.toml", Category::Outdated, Severity::Hint)],
114 };
115 let table = render(&report);
116 assert!(table.contains("Cargo.toml"));
117 assert!(table.contains("serde"));
118 assert!(table.contains("outdated"));
119 assert!(table.contains("Newer version available"));
120 assert!(table.contains("Summary:"));
121 assert!(table.contains("outdated=1"));
122 }
123
124 #[test]
125 fn test_render_groups_findings_by_file() {
126 let report = CheckReport {
127 findings: vec![
128 finding("Cargo.toml", Category::Outdated, Severity::Hint),
129 finding("package.json", Category::Vulnerable, Severity::Error),
130 ],
131 };
132 let table = render(&report);
133 let cargo_pos = table.find("Cargo.toml").expect("Cargo.toml present");
134 let npm_pos = table.find("package.json").expect("package.json present");
135 assert!(
136 cargo_pos < npm_pos,
137 "files must be grouped, Cargo.toml sorts first"
138 );
139 }
140
141 #[test]
142 fn test_render_sorts_by_severity_within_a_file() {
143 let report = CheckReport {
144 findings: vec![
145 finding("Cargo.toml", Category::Outdated, Severity::Hint),
146 finding("Cargo.toml", Category::Vulnerable, Severity::Error),
147 ],
148 };
149 let table = render(&report);
150 let error_pos = table.find("[error]").expect("error entry present");
151 let hint_pos = table.find("[hint]").expect("hint entry present");
152 assert!(error_pos < hint_pos, "error severity must sort before hint");
153 }
154
155 #[test]
160 fn test_render_multi_category_snapshot() {
161 let mut vulnerable = finding("Cargo.toml", Category::Vulnerable, Severity::Error);
162 vulnerable.message = "GHSA-xxxx-yyyy-zzzz: example advisory".to_string();
163 let mut license = finding("package.json", Category::License, Severity::Warning);
164 license.dependency_name = Some("left-pad".to_string());
165 license.message = "left-pad: GPL-3.0 denied".to_string();
166
167 let report = CheckReport {
168 findings: vec![
169 finding("Cargo.toml", Category::Outdated, Severity::Hint),
170 vulnerable,
171 license,
172 ],
173 };
174 insta::assert_snapshot!(render(&report));
175 }
176}