Skip to main content

deps_cli/format/
table.rs

1//! Human-readable table output (FR-006): findings grouped by file, then severity.
2
3use super::{DryRun, severity_str};
4use crate::report::CheckReport;
5use std::collections::BTreeMap;
6use std::fmt::Write as _;
7
8/// Renders `report` as a table grouped by manifest path, then by severity
9/// (error > warning > information > hint) within each file.
10///
11/// # Examples
12///
13/// ```
14/// use deps_cli::format::table::render;
15/// use deps_cli::report::CheckReport;
16///
17/// assert_eq!(render(&CheckReport::default()), "No findings.\n");
18/// ```
19#[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        // `Severity`'s own `Ord` (declaration order: `Error` most severe, `Hint` least)
37        // sorts error-first for free — issue #1532 code-review finding 1: this used to be a
38        // hand-rolled rank function that had drifted out of sync with an oppositely-signed
39        // one in `deps-core`.
40        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/// Renders an `update` run's plan: one line per item (name, current, target, outcome,
69/// reason), following `render`'s table style.
70///
71/// `dry_run` prints a leading note so an `applied` line is never confused with an edit that
72/// was actually written (critic finding M1/US-005 — mirrors
73/// `format::json::render_update`'s `dry_run` field, which is likewise emitted unconditionally
74/// regardless of item count — code review finding 3: this note used to be skipped for an
75/// empty plan, so `--dry-run --format table` on an empty plan was indistinguishable from a
76/// real run, unlike the json format's always-present `dry_run` field).
77///
78/// # Examples
79///
80/// ```
81/// use deps_cli::format::DryRun;
82/// use deps_cli::format::table::render_update;
83/// use deps_cli::update::UpdatePlan;
84///
85/// assert_eq!(
86///     render_update(&UpdatePlan::default(), DryRun::No),
87///     "No eligible updates.\n"
88/// );
89/// assert_eq!(
90///     render_update(&UpdatePlan::default(), DryRun::Yes),
91///     "(dry run — no changes written)\nNo eligible updates.\n"
92/// );
93/// ```
94#[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        // Security-S3: `target`/`reason()` carry unvalidated `ConcreteVersion` text, sanitized
107        // like `name`/`current` below; only `None` means "no target".
108        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    /// Snapshot test (S5, spec 062 review) covering multiple files, categories, and
201    /// severities in one report — pins the exact rendered layout so a formatting
202    /// regression (stray whitespace, column reordering, ordering change) shows up as a
203    /// snapshot diff instead of silently passing a `.contains()`-only assertion.
204    #[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    /// S5: `render_update` on a non-empty plan — the empty-plan doctest alone never exercised
251    /// the per-item line format or the `dry_run` leading note.
252    #[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    /// Code review finding 3: an empty plan must still carry the `dry_run` leading note —
275    /// otherwise `--dry-run --format table` on an empty plan was indistinguishable from a
276    /// real run (`format::json::render_update`'s `dry_run` field never had this gap, since it
277    /// is emitted unconditionally regardless of item count).
278    #[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    /// #1605: `None` is the sole "no target" sentinel now — it still renders as `-`.
286    #[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    /// #1605 critic S1: `target` and `reason()`'s cooldown-fallback attribution both carry
298    /// unvalidated registry text (`ConcreteVersion` is deliberately unchecked) and must both be
299    /// sanitized at this render sink.
300    #[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}