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::severity_str;
4use crate::report::CheckReport;
5use deps_core::diagnostic::Severity;
6use std::collections::BTreeMap;
7use std::fmt::Write as _;
8
9/// Renders `report` as a table grouped by manifest path, then by severity
10/// (error > warning > information > hint) within each file.
11///
12/// # Examples
13///
14/// ```
15/// use deps_cli::format::table::render;
16/// use deps_cli::report::CheckReport;
17///
18/// assert_eq!(render(&CheckReport::default()), "No findings.\n");
19/// ```
20#[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
71/// Sort key for severity within one file's findings — error first, hint last.
72fn 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    /// Snapshot test (S5, spec 062 review) covering multiple files, categories, and
156    /// severities in one report — pins the exact rendered layout so a formatting
157    /// regression (stray whitespace, column reordering, ordering change) shows up as a
158    /// snapshot diff instead of silently passing a `.contains()`-only assertion.
159    #[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}