1use super::{DryRun, severity_str};
4use crate::report::CheckReport;
5use std::collections::BTreeMap;
6use std::fmt::Write as _;
7
8#[must_use]
20pub fn render(report: &CheckReport) -> String {
21 if report.findings.is_empty() {
22 return "No findings.\n".to_string();
23 }
24
25 let mut by_file: BTreeMap<&std::path::Path, Vec<&crate::report::CheckFinding>> =
26 BTreeMap::new();
27 for finding in &report.findings {
28 by_file
29 .entry(finding.manifest_path.as_path())
30 .or_default()
31 .push(finding);
32 }
33
34 let mut out = String::new();
35 for (path, mut findings) in by_file {
36 findings.sort_by_key(|f| (f.severity, f.range.start.line, f.range.start.character));
41 let _ = writeln!(out, "{}", path.display());
42 for finding in findings {
43 let dependency = finding.dependency_name.as_deref().unwrap_or("-");
44 let _ = writeln!(
45 out,
46 " [{}] {}:{} {} ({}) — {}",
47 severity_str(finding.severity),
48 finding.range.start.line + 1,
49 finding.range.start.character + 1,
50 dependency,
51 finding.category,
52 finding.message,
53 );
54 }
55 }
56
57 let summary = report.summary();
58 let _ = writeln!(out);
59 let _ = write!(out, "Summary:");
60 for (category, count) in &summary {
61 let _ = write!(out, " {category}={count}");
62 }
63 let _ = writeln!(out);
64
65 out
66}
67
68#[must_use]
95pub fn render_update(plan: &crate::update::UpdatePlan, dry_run: DryRun) -> String {
96 let mut out = String::new();
97 if dry_run == DryRun::Yes {
98 let _ = writeln!(out, "(dry run — no changes written)");
99 }
100 if plan.items.is_empty() {
101 out.push_str("No eligible updates.\n");
102 return out;
103 }
104
105 for item in &plan.items {
106 let target = item.target().map_or_else(
109 || "-".to_string(),
110 |v| crate::sanitize::sanitize_message_for_display(v.as_str()),
111 );
112 let _ = writeln!(
113 out,
114 "[{}] {} {} -> {} — {}",
115 item.outcome.wire_token(),
116 crate::sanitize::sanitize_message_for_display(&item.name),
117 crate::sanitize::sanitize_message_for_display(&item.current.render_text()),
118 target,
119 crate::sanitize::sanitize_message_for_display(&item.reason()),
120 );
121 }
122 out
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128 use crate::report::{Category, CheckFinding};
129 use deps_core::EcosystemId;
130 use deps_core::diagnostic::Severity;
131 use deps_core::position::{Position, Range};
132 use std::path::PathBuf;
133
134 fn finding(path: &str, category: Category, severity: Severity) -> CheckFinding {
135 CheckFinding {
136 ecosystem: EcosystemId::Cargo,
137 manifest_path: PathBuf::from(path),
138 dependency_name: Some("serde".to_string()),
139 requirement: Some("1.0".to_string()),
140 category,
141 code: None,
142 advisory_url: None,
143 advisory_severity: None,
144 severity,
145 range: Range::new(Position::new(4, 0), Position::new(4, 10)),
146 message: "Newer version available: 1.1.0".to_string(),
147 }
148 }
149
150 #[test]
151 fn test_render_empty_report() {
152 assert_eq!(render(&CheckReport::default()), "No findings.\n");
153 }
154
155 #[test]
156 fn test_render_single_finding_includes_path_and_message() {
157 let report = CheckReport {
158 findings: vec![finding("Cargo.toml", Category::Outdated, Severity::Hint)],
159 };
160 let table = render(&report);
161 assert!(table.contains("Cargo.toml"));
162 assert!(table.contains("serde"));
163 assert!(table.contains("outdated"));
164 assert!(table.contains("Newer version available"));
165 assert!(table.contains("Summary:"));
166 assert!(table.contains("outdated=1"));
167 }
168
169 #[test]
170 fn test_render_groups_findings_by_file() {
171 let report = CheckReport {
172 findings: vec![
173 finding("Cargo.toml", Category::Outdated, Severity::Hint),
174 finding("package.json", Category::Vulnerable, Severity::Error),
175 ],
176 };
177 let table = render(&report);
178 let cargo_pos = table.find("Cargo.toml").expect("Cargo.toml present");
179 let npm_pos = table.find("package.json").expect("package.json present");
180 assert!(
181 cargo_pos < npm_pos,
182 "files must be grouped, Cargo.toml sorts first"
183 );
184 }
185
186 #[test]
187 fn test_render_sorts_by_severity_within_a_file() {
188 let report = CheckReport {
189 findings: vec![
190 finding("Cargo.toml", Category::Outdated, Severity::Hint),
191 finding("Cargo.toml", Category::Vulnerable, Severity::Error),
192 ],
193 };
194 let table = render(&report);
195 let error_pos = table.find("[error]").expect("error entry present");
196 let hint_pos = table.find("[hint]").expect("hint entry present");
197 assert!(error_pos < hint_pos, "error severity must sort before hint");
198 }
199
200 #[test]
205 fn test_render_multi_category_snapshot() {
206 let mut vulnerable = finding("Cargo.toml", Category::Vulnerable, Severity::Error);
207 vulnerable.message = "GHSA-xxxx-yyyy-zzzz: example advisory".to_string();
208 let mut license = finding("package.json", Category::License, Severity::Warning);
209 license.dependency_name = Some("left-pad".to_string());
210 license.message = "left-pad: GPL-3.0 denied".to_string();
211
212 let report = CheckReport {
213 findings: vec![
214 finding("Cargo.toml", Category::Outdated, Severity::Hint),
215 vulnerable,
216 license,
217 ],
218 };
219 insta::assert_snapshot!(render(&report));
220 }
221
222 fn update_item(outcome: crate::update::Outcome) -> crate::update::PlannedUpdateItem {
223 crate::update::PlannedUpdateItem {
224 name: "serde".to_string(),
225 current: crate::update::CurrentVersion::Resolved(deps_core::ConcreteVersion::from(
226 "1.0.0",
227 )),
228 outcome,
229 advisory_ids: Vec::new(),
230 ignore_rule_overridden: false,
231 gossip_excluded_version: None,
232 cooldown_fallback: None,
233 }
234 }
235
236 fn applied_edit() -> deps_core::edit::ManifestEdit {
237 deps_core::edit::ManifestEdit {
238 range: Range::new(Position::new(0, 0), Position::new(0, 0)),
239 new_text: "1.2.0".to_string(),
240 }
241 }
242
243 fn applied_outcome() -> crate::update::Outcome {
244 crate::update::Outcome::Applied {
245 edit: applied_edit(),
246 target: deps_core::ConcreteVersion::from("1.2.0"),
247 }
248 }
249
250 #[test]
253 fn test_render_update_non_empty_plan_includes_item_line() {
254 let plan = crate::update::UpdatePlan {
255 items: vec![update_item(applied_outcome())],
256 };
257 let table = render_update(&plan, DryRun::No);
258 assert!(table.contains("serde"));
259 assert!(table.contains("1.0.0"));
260 assert!(table.contains("1.2.0"));
261 assert!(table.contains("applied"));
262 assert!(!table.contains("dry run"));
263 }
264
265 #[test]
266 fn test_render_update_dry_run_includes_leading_note() {
267 let plan = crate::update::UpdatePlan {
268 items: vec![update_item(applied_outcome())],
269 };
270 let table = render_update(&plan, DryRun::Yes);
271 assert!(table.starts_with("(dry run"));
272 }
273
274 #[test]
279 fn test_render_update_empty_plan_still_includes_dry_run_note() {
280 let table = render_update(&crate::update::UpdatePlan::default(), DryRun::Yes);
281 assert!(table.starts_with("(dry run"));
282 assert!(table.contains("No eligible updates."));
283 }
284
285 #[test]
287 fn test_render_update_none_target_renders_dash() {
288 let item = update_item(crate::update::Outcome::Skipped {
289 reason: crate::update::SkipReason::NotRequested,
290 target: None,
291 });
292 let plan = crate::update::UpdatePlan { items: vec![item] };
293 let table = render_update(&plan, DryRun::No);
294 assert!(table.contains(" -> - "), "got: {table}");
295 }
296
297 #[test]
301 fn test_render_update_strips_ansi_from_target_and_reason() {
302 let mut item = update_item(crate::update::Outcome::Skipped {
303 reason: crate::update::SkipReason::NotSafelyEditable(
304 deps_core::edit::UnplannableReason::LatestFlaggedByOsv,
305 ),
306 target: Some(deps_core::ConcreteVersion::from("1.2.0\x1B[31m")),
307 });
308 item.cooldown_fallback = Some(crate::update::CooldownFallbackNote::Blocked {
309 version: deps_core::ConcreteVersion::from("1.1.0\x1B[31m"),
310 });
311 let mut applied_instead_of_item = update_item(applied_outcome());
312 applied_instead_of_item.cooldown_fallback =
313 Some(crate::update::CooldownFallbackNote::AppliedInsteadOf(
314 deps_core::ConcreteVersion::from("1.3.0\x1B[31m"),
315 ));
316 let plan = crate::update::UpdatePlan {
317 items: vec![item, applied_instead_of_item],
318 };
319 let table = render_update(&plan, DryRun::No);
320 assert!(!table.contains('\x1B'), "got: {table}");
321 assert!(table.contains("1.2.0"), "got: {table}");
322 assert!(table.contains("1.1.0"), "got: {table}");
323 assert!(table.contains("1.3.0"), "got: {table}");
324 }
325}