Skip to main content

sbom_tools/reports/
summary.rs

1//! Summary report generator for shell output.
2//!
3//! Provides a compact, human-readable summary for terminal usage.
4
5use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator};
6use crate::diff::DiffResult;
7use crate::model::NormalizedSbom;
8
9/// Apply ANSI color formatting if colored output is enabled.
10fn ansi_color(text: &str, color: &str, colored: bool) -> String {
11    if colored {
12        match color {
13            "red" => format!("\x1b[31m{}\x1b[0m", text),
14            "green" => format!("\x1b[32m{}\x1b[0m", text),
15            "yellow" => format!("\x1b[33m{}\x1b[0m", text),
16            "cyan" => format!("\x1b[36m{}\x1b[0m", text),
17            "bold" => format!("\x1b[1m{}\x1b[0m", text),
18            "dim" => format!("\x1b[2m{}\x1b[0m", text),
19            _ => text.to_string(),
20        }
21    } else {
22        text.to_string()
23    }
24}
25
26/// Summary reporter for shell output
27pub struct SummaryReporter {
28    /// Use colored output
29    colored: bool,
30}
31
32impl SummaryReporter {
33    /// Create a new summary reporter
34    pub fn new() -> Self {
35        Self { colored: true }
36    }
37
38    /// Disable colored output
39    pub fn no_color(mut self) -> Self {
40        self.colored = false;
41        self
42    }
43
44    fn color(&self, text: &str, color: &str) -> String {
45        ansi_color(text, color, self.colored)
46    }
47}
48
49impl Default for SummaryReporter {
50    fn default() -> Self {
51        Self::new()
52    }
53}
54
55impl ReportGenerator for SummaryReporter {
56    fn generate_diff_report(
57        &self,
58        result: &DiffResult,
59        old_sbom: &NormalizedSbom,
60        new_sbom: &NormalizedSbom,
61        _config: &ReportConfig,
62    ) -> Result<String, ReportError> {
63        let mut lines = Vec::new();
64
65        // Header
66        lines.push(self.color("SBOM Diff Summary", "bold"));
67        lines.push(self.color("─".repeat(40).as_str(), "dim"));
68
69        // File info
70        let old_name = old_sbom.document.name.as_deref().unwrap_or("old");
71        let new_name = new_sbom.document.name.as_deref().unwrap_or("new");
72        lines.push(format!(
73            "{}  {} → {}",
74            self.color("Files:", "cyan"),
75            old_name,
76            new_name
77        ));
78
79        // Component counts
80        lines.push(format!(
81            "{}  {} → {} components",
82            self.color("Size:", "cyan"),
83            old_sbom.component_count(),
84            new_sbom.component_count()
85        ));
86
87        lines.push("".to_string());
88
89        // Changes
90        lines.push(self.color("Changes:", "bold"));
91
92        let added = result.summary.components_added;
93        let removed = result.summary.components_removed;
94        let modified = result.summary.components_modified;
95
96        if added > 0 {
97            lines.push(format!(
98                "  {} {} added",
99                self.color(&format!("+{}", added), "green"),
100                if added == 1 {
101                    "component"
102                } else {
103                    "components"
104                }
105            ));
106        }
107        if removed > 0 {
108            lines.push(format!(
109                "  {} {} removed",
110                self.color(&format!("-{}", removed), "red"),
111                if removed == 1 {
112                    "component"
113                } else {
114                    "components"
115                }
116            ));
117        }
118        if modified > 0 {
119            lines.push(format!(
120                "  {} {} modified",
121                self.color(&format!("~{}", modified), "yellow"),
122                if modified == 1 {
123                    "component"
124                } else {
125                    "components"
126                }
127            ));
128        }
129        if added == 0 && removed == 0 && modified == 0 {
130            lines.push(format!("  {}", self.color("No changes", "dim")));
131        }
132
133        // Vulnerabilities
134        let vulns_intro = result.summary.vulnerabilities_introduced;
135        let vulns_resolved = result.summary.vulnerabilities_resolved;
136
137        if vulns_intro > 0 || vulns_resolved > 0 {
138            lines.push("".to_string());
139            lines.push(self.color("Vulnerabilities:", "bold"));
140
141            if vulns_intro > 0 {
142                lines.push(format!(
143                    "  {} {} introduced",
144                    self.color(&format!("!{}", vulns_intro), "red"),
145                    if vulns_intro == 1 {
146                        "vulnerability"
147                    } else {
148                        "vulnerabilities"
149                    }
150                ));
151            }
152            if vulns_resolved > 0 {
153                lines.push(format!(
154                    "  {} {} resolved",
155                    self.color(&format!("✓{}", vulns_resolved), "green"),
156                    if vulns_resolved == 1 {
157                        "vulnerability"
158                    } else {
159                        "vulnerabilities"
160                    }
161                ));
162            }
163        }
164
165        // Score
166        lines.push("".to_string());
167        let score = result.semantic_score;
168        let score_color = if score > 90.0 {
169            "green"
170        } else if score > 70.0 {
171            "yellow"
172        } else {
173            "red"
174        };
175        lines.push(format!(
176            "{}  {}",
177            self.color("Similarity:", "cyan"),
178            self.color(&format!("{:.1}%", score), score_color)
179        ));
180
181        Ok(lines.join("\n"))
182    }
183
184    fn generate_view_report(
185        &self,
186        sbom: &NormalizedSbom,
187        _config: &ReportConfig,
188    ) -> Result<String, ReportError> {
189        let mut lines = Vec::new();
190
191        // Header
192        lines.push(self.color("SBOM Summary", "bold"));
193        lines.push(self.color("─".repeat(40).as_str(), "dim"));
194
195        // Basic info
196        if let Some(name) = &sbom.document.name {
197            lines.push(format!("{}  {}", self.color("Name:", "cyan"), name));
198        }
199        lines.push(format!(
200            "{}  {}",
201            self.color("Format:", "cyan"),
202            sbom.document.format
203        ));
204        lines.push(format!(
205            "{}  {}",
206            self.color("Components:", "cyan"),
207            sbom.component_count()
208        ));
209        lines.push(format!(
210            "{}  {}",
211            self.color("Dependencies:", "cyan"),
212            sbom.edges.len()
213        ));
214
215        // Ecosystems
216        let ecosystems: Vec<_> = sbom.ecosystems().iter().map(|e| e.to_string()).collect();
217        if !ecosystems.is_empty() {
218            lines.push(format!(
219                "{}  {}",
220                self.color("Ecosystems:", "cyan"),
221                ecosystems.join(", ")
222            ));
223        }
224
225        // Vulnerabilities
226        let counts = sbom.vulnerability_counts();
227        let total_vulns = counts.critical + counts.high + counts.medium + counts.low;
228        if total_vulns > 0 {
229            lines.push("".to_string());
230            lines.push(self.color("Vulnerabilities:", "bold"));
231            if counts.critical > 0 {
232                lines.push(format!(
233                    "  {}",
234                    self.color(&format!("Critical: {}", counts.critical), "red")
235                ));
236            }
237            if counts.high > 0 {
238                lines.push(format!(
239                    "  {}",
240                    self.color(&format!("High: {}", counts.high), "red")
241                ));
242            }
243            if counts.medium > 0 {
244                lines.push(format!(
245                    "  {}",
246                    self.color(&format!("Medium: {}", counts.medium), "yellow")
247                ));
248            }
249            if counts.low > 0 {
250                lines.push(format!(
251                    "  {}",
252                    self.color(&format!("Low: {}", counts.low), "dim")
253                ));
254            }
255        }
256
257        Ok(lines.join("\n"))
258    }
259
260    fn format(&self) -> ReportFormat {
261        ReportFormat::Summary
262    }
263}
264
265/// Table reporter for terminal output with aligned columns
266pub struct TableReporter {
267    /// Use colored output
268    colored: bool,
269}
270
271impl TableReporter {
272    /// Create a new table reporter
273    pub fn new() -> Self {
274        Self { colored: true }
275    }
276
277    /// Disable colored output
278    pub fn no_color(mut self) -> Self {
279        self.colored = false;
280        self
281    }
282
283    fn color(&self, text: &str, color: &str) -> String {
284        ansi_color(text, color, self.colored)
285    }
286}
287
288impl Default for TableReporter {
289    fn default() -> Self {
290        Self::new()
291    }
292}
293
294impl ReportGenerator for TableReporter {
295    fn generate_diff_report(
296        &self,
297        result: &DiffResult,
298        _old_sbom: &NormalizedSbom,
299        _new_sbom: &NormalizedSbom,
300        _config: &ReportConfig,
301    ) -> Result<String, ReportError> {
302        let mut lines = Vec::new();
303
304        // Header
305        lines.push(format!(
306            "{:<12} {:<40} {:<15} {:<15}",
307            self.color("STATUS", "bold"),
308            self.color("COMPONENT", "bold"),
309            self.color("OLD VERSION", "bold"),
310            self.color("NEW VERSION", "bold")
311        ));
312        lines.push("─".repeat(85));
313
314        // Added components
315        for comp in &result.components.added {
316            let version = comp.new_version.as_deref().unwrap_or("-");
317            lines.push(format!(
318                "{:<12} {:<40} {:<15} {:<15}",
319                self.color("+ Added", "green"),
320                truncate(&comp.name, 40),
321                "-",
322                version
323            ));
324        }
325
326        // Removed components
327        for comp in &result.components.removed {
328            let version = comp.old_version.as_deref().unwrap_or("-");
329            lines.push(format!(
330                "{:<12} {:<40} {:<15} {:<15}",
331                self.color("- Removed", "red"),
332                truncate(&comp.name, 40),
333                version,
334                "-"
335            ));
336        }
337
338        // Modified components
339        for comp in &result.components.modified {
340            let old_ver = comp.old_version.as_deref().unwrap_or("-");
341            let new_ver = comp.new_version.as_deref().unwrap_or("-");
342            lines.push(format!(
343                "{:<12} {:<40} {:<15} {:<15}",
344                self.color("~ Modified", "yellow"),
345                truncate(&comp.name, 40),
346                old_ver,
347                new_ver
348            ));
349        }
350
351        // Vulnerabilities section
352        if !result.vulnerabilities.introduced.is_empty() {
353            lines.push("".to_string());
354            lines.push(format!(
355                "{:<12} {:<20} {:<10} {:<40}",
356                self.color("VULNS", "bold"),
357                self.color("ID", "bold"),
358                self.color("SEVERITY", "bold"),
359                self.color("COMPONENT", "bold")
360            ));
361            lines.push("─".repeat(85));
362
363            for vuln in &result.vulnerabilities.introduced {
364                let severity_colored = match vuln.severity.to_lowercase().as_str() {
365                    "critical" => self.color(&vuln.severity, "red"),
366                    "high" => self.color(&vuln.severity, "red"),
367                    "medium" => self.color(&vuln.severity, "yellow"),
368                    _ => vuln.severity.clone(),
369                };
370                lines.push(format!(
371                    "{:<12} {:<20} {:<10} {:<40}",
372                    self.color("! NEW", "red"),
373                    truncate(&vuln.id, 20),
374                    severity_colored,
375                    truncate(&vuln.component_name, 40)
376                ));
377            }
378        }
379
380        // Summary footer
381        lines.push("".to_string());
382        lines.push(format!(
383            "Total: {} added, {} removed, {} modified | Vulns: {} new, {} resolved | Similarity: {:.1}%",
384            result.summary.components_added,
385            result.summary.components_removed,
386            result.summary.components_modified,
387            result.summary.vulnerabilities_introduced,
388            result.summary.vulnerabilities_resolved,
389            result.semantic_score
390        ));
391
392        Ok(lines.join("\n"))
393    }
394
395    fn generate_view_report(
396        &self,
397        sbom: &NormalizedSbom,
398        _config: &ReportConfig,
399    ) -> Result<String, ReportError> {
400        let mut lines = Vec::new();
401
402        // Header
403        lines.push(format!(
404            "{:<40} {:<15} {:<20} {:<10}",
405            self.color("COMPONENT", "bold"),
406            self.color("VERSION", "bold"),
407            self.color("LICENSE", "bold"),
408            self.color("VULNS", "bold")
409        ));
410        lines.push("─".repeat(90));
411
412        // Components (limit to 50 for readability)
413        let mut components: Vec<_> = sbom.components.values().collect();
414        components.sort_by(|a, b| a.name.cmp(&b.name));
415
416        for comp in components.iter().take(50) {
417            let version = comp.version.as_deref().unwrap_or("-");
418            let license = comp
419                .licenses
420                .declared
421                .first()
422                .map(|l| l.expression.as_str())
423                .unwrap_or("-");
424            let vulns = comp.vulnerabilities.len();
425            let vuln_display = if vulns > 0 {
426                self.color(&vulns.to_string(), "red")
427            } else {
428                "0".to_string()
429            };
430
431            lines.push(format!(
432                "{:<40} {:<15} {:<20} {:<10}",
433                truncate(&comp.name, 40),
434                truncate(version, 15),
435                truncate(license, 20),
436                vuln_display
437            ));
438        }
439
440        if components.len() > 50 {
441            lines.push(self.color(
442                &format!("... and {} more components", components.len() - 50),
443                "dim",
444            ));
445        }
446
447        // Summary
448        lines.push("".to_string());
449        let counts = sbom.vulnerability_counts();
450        let unknown_str = if counts.unknown > 0 {
451            format!(", {} unknown", counts.unknown)
452        } else {
453            String::new()
454        };
455        lines.push(format!(
456            "Total: {} components, {} dependencies | Vulns: {} critical, {} high, {} medium, {} low{}",
457            sbom.component_count(),
458            sbom.edges.len(),
459            counts.critical,
460            counts.high,
461            counts.medium,
462            counts.low,
463            unknown_str
464        ));
465
466        Ok(lines.join("\n"))
467    }
468
469    fn format(&self) -> ReportFormat {
470        ReportFormat::Table
471    }
472}
473
474/// Truncate a string to fit within max_len (UTF-8 safe)
475fn truncate(s: &str, max_len: usize) -> String {
476    if s.len() <= max_len {
477        s.to_string()
478    } else if max_len > 3 {
479        let end = floor_char_boundary(s, max_len - 3);
480        format!("{}...", &s[..end])
481    } else {
482        let end = floor_char_boundary(s, max_len);
483        s[..end].to_string()
484    }
485}
486
487/// Find the largest byte index <= `index` that is a valid UTF-8 char boundary.
488fn floor_char_boundary(s: &str, index: usize) -> usize {
489    if index >= s.len() {
490        s.len()
491    } else {
492        let mut i = index;
493        while i > 0 && !s.is_char_boundary(i) {
494            i -= 1;
495        }
496        i
497    }
498}