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{text}\x1b[0m"),
14            "green" => format!("\x1b[32m{text}\x1b[0m"),
15            "yellow" => format!("\x1b[33m{text}\x1b[0m"),
16            "cyan" => format!("\x1b[36m{text}\x1b[0m"),
17            "bold" => format!("\x1b[1m{text}\x1b[0m"),
18            "dim" => format!("\x1b[2m{text}\x1b[0m"),
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    #[must_use]
35    pub const fn new() -> Self {
36        Self { colored: true }
37    }
38
39    /// Disable colored output
40    #[must_use]
41    pub const fn no_color(mut self) -> Self {
42        self.colored = false;
43        self
44    }
45
46    fn color(&self, text: &str, color: &str) -> String {
47        ansi_color(text, color, self.colored)
48    }
49}
50
51impl Default for SummaryReporter {
52    fn default() -> Self {
53        Self::new()
54    }
55}
56
57impl ReportGenerator for SummaryReporter {
58    fn generate_diff_report(
59        &self,
60        result: &DiffResult,
61        old_sbom: &NormalizedSbom,
62        new_sbom: &NormalizedSbom,
63        _config: &ReportConfig,
64    ) -> Result<String, ReportError> {
65        let mut lines = Vec::new();
66
67        // Header
68        lines.push(self.color("SBOM Diff Summary", "bold"));
69        lines.push(self.color("─".repeat(40).as_str(), "dim"));
70
71        // File info
72        let old_name = old_sbom.document.name.as_deref().unwrap_or("old");
73        let new_name = new_sbom.document.name.as_deref().unwrap_or("new");
74        lines.push(format!(
75            "{}  {} → {}",
76            self.color("Files:", "cyan"),
77            old_name,
78            new_name
79        ));
80
81        // Component counts
82        lines.push(format!(
83            "{}  {} → {} components",
84            self.color("Size:", "cyan"),
85            old_sbom.component_count(),
86            new_sbom.component_count()
87        ));
88
89        lines.push(String::new());
90
91        // Changes
92        lines.push(self.color("Changes:", "bold"));
93
94        let added = result.summary.components_added;
95        let removed = result.summary.components_removed;
96        let modified = result.summary.components_modified;
97
98        if added > 0 {
99            lines.push(format!(
100                "  {} {} added",
101                self.color(&format!("+{added}"), "green"),
102                if added == 1 {
103                    "component"
104                } else {
105                    "components"
106                }
107            ));
108        }
109        if removed > 0 {
110            lines.push(format!(
111                "  {} {} removed",
112                self.color(&format!("-{removed}"), "red"),
113                if removed == 1 {
114                    "component"
115                } else {
116                    "components"
117                }
118            ));
119        }
120        if modified > 0 {
121            lines.push(format!(
122                "  {} {} modified",
123                self.color(&format!("~{modified}"), "yellow"),
124                if modified == 1 {
125                    "component"
126                } else {
127                    "components"
128                }
129            ));
130        }
131        if added == 0 && removed == 0 && modified == 0 {
132            lines.push(format!("  {}", self.color("No changes", "dim")));
133        }
134
135        // Vulnerabilities
136        let vulns_intro = result.summary.vulnerabilities_introduced;
137        let vulns_resolved = result.summary.vulnerabilities_resolved;
138
139        if vulns_intro > 0 || vulns_resolved > 0 {
140            lines.push(String::new());
141            lines.push(self.color("Vulnerabilities:", "bold"));
142
143            if vulns_intro > 0 {
144                lines.push(format!(
145                    "  {} {} introduced",
146                    self.color(&format!("!{vulns_intro}"), "red"),
147                    if vulns_intro == 1 {
148                        "vulnerability"
149                    } else {
150                        "vulnerabilities"
151                    }
152                ));
153            }
154            if vulns_resolved > 0 {
155                lines.push(format!(
156                    "  {} {} resolved",
157                    self.color(&format!("✓{vulns_resolved}"), "green"),
158                    if vulns_resolved == 1 {
159                        "vulnerability"
160                    } else {
161                        "vulnerabilities"
162                    }
163                ));
164            }
165        }
166
167        // End-of-life summary (from new SBOM)
168        {
169            let eol_counts = count_eol_statuses(new_sbom);
170            if eol_counts.total > 0 {
171                lines.push(String::new());
172                lines.push(self.color("End-of-Life:", "bold"));
173                let mut parts = Vec::new();
174                if eol_counts.eol > 0 {
175                    parts.push(self.color(&format!("{} EOL", eol_counts.eol), "red"));
176                }
177                if eol_counts.approaching > 0 {
178                    parts.push(
179                        self.color(&format!("{} approaching", eol_counts.approaching), "yellow"),
180                    );
181                }
182                if eol_counts.supported > 0 {
183                    parts.push(self.color(&format!("{} supported", eol_counts.supported), "green"));
184                }
185                if eol_counts.security_only > 0 {
186                    parts.push(format!("{} security-only", eol_counts.security_only));
187                }
188                if eol_counts.unknown > 0 {
189                    parts.push(format!("{} unknown", eol_counts.unknown));
190                }
191                lines.push(format!("  {}", parts.join(", ")));
192            }
193        }
194
195        // Graph changes
196        if let Some(ref summary) = result.graph_summary
197            && summary.total_changes > 0
198        {
199            lines.push(String::new());
200            lines.push(self.color("Graph Changes:", "bold"));
201            lines.push(format!(
202                "  {} added, {} removed, {} rel changed, {} reparented, {} depth changes",
203                summary.dependencies_added,
204                summary.dependencies_removed,
205                summary.relationship_changed,
206                summary.reparented,
207                summary.depth_changed,
208            ));
209
210            // Impact breakdown
211            let mut impact_parts = Vec::new();
212            if summary.by_impact.critical > 0 {
213                impact_parts
214                    .push(self.color(&format!("{} critical", summary.by_impact.critical), "red"));
215            }
216            if summary.by_impact.high > 0 {
217                impact_parts
218                    .push(self.color(&format!("{} high", summary.by_impact.high), "yellow"));
219            }
220            if summary.by_impact.medium > 0 {
221                impact_parts.push(format!("{} medium", summary.by_impact.medium));
222            }
223            if summary.by_impact.low > 0 {
224                impact_parts.push(format!("{} low", summary.by_impact.low));
225            }
226            if !impact_parts.is_empty() {
227                lines.push(format!("  By impact: {}", impact_parts.join(", ")));
228            }
229        }
230
231        // Score
232        lines.push(String::new());
233        let score = result.semantic_score;
234        let score_color = if score > 90.0 {
235            "green"
236        } else if score > 70.0 {
237            "yellow"
238        } else {
239            "red"
240        };
241        lines.push(format!(
242            "{}  {}",
243            self.color("Similarity:", "cyan"),
244            self.color(&format!("{score:.1}%"), score_color)
245        ));
246
247        Ok(lines.join("\n"))
248    }
249
250    fn generate_view_report(
251        &self,
252        sbom: &NormalizedSbom,
253        _config: &ReportConfig,
254    ) -> Result<String, ReportError> {
255        let mut lines = Vec::new();
256
257        // Header
258        lines.push(self.color("SBOM Summary", "bold"));
259        lines.push(self.color("─".repeat(40).as_str(), "dim"));
260
261        // Basic info
262        if let Some(name) = &sbom.document.name {
263            lines.push(format!("{}  {}", self.color("Name:", "cyan"), name));
264        }
265        lines.push(format!(
266            "{}  {}",
267            self.color("Format:", "cyan"),
268            sbom.document.format
269        ));
270        lines.push(format!(
271            "{}  {}",
272            self.color("Components:", "cyan"),
273            sbom.component_count()
274        ));
275        lines.push(format!(
276            "{}  {}",
277            self.color("Dependencies:", "cyan"),
278            sbom.edges.len()
279        ));
280
281        // Ecosystems
282        let ecosystems: Vec<_> = sbom
283            .ecosystems()
284            .iter()
285            .map(std::string::ToString::to_string)
286            .collect();
287        if !ecosystems.is_empty() {
288            lines.push(format!(
289                "{}  {}",
290                self.color("Ecosystems:", "cyan"),
291                ecosystems.join(", ")
292            ));
293        }
294
295        // Vulnerabilities
296        let counts = sbom.vulnerability_counts();
297        let total_vulns = counts.critical + counts.high + counts.medium + counts.low;
298        if total_vulns > 0 {
299            lines.push(String::new());
300            lines.push(self.color("Vulnerabilities:", "bold"));
301            if counts.critical > 0 {
302                lines.push(format!(
303                    "  {}",
304                    self.color(&format!("Critical: {}", counts.critical), "red")
305                ));
306            }
307            if counts.high > 0 {
308                lines.push(format!(
309                    "  {}",
310                    self.color(&format!("High: {}", counts.high), "red")
311                ));
312            }
313            if counts.medium > 0 {
314                lines.push(format!(
315                    "  {}",
316                    self.color(&format!("Medium: {}", counts.medium), "yellow")
317                ));
318            }
319            if counts.low > 0 {
320                lines.push(format!(
321                    "  {}",
322                    self.color(&format!("Low: {}", counts.low), "dim")
323                ));
324            }
325        }
326
327        Ok(lines.join("\n"))
328    }
329
330    fn format(&self) -> ReportFormat {
331        ReportFormat::Summary
332    }
333}
334
335/// Table reporter for terminal output with aligned columns
336pub struct TableReporter {
337    /// Use colored output
338    colored: bool,
339}
340
341impl TableReporter {
342    /// Create a new table reporter
343    #[must_use]
344    pub const fn new() -> Self {
345        Self { colored: true }
346    }
347
348    /// Disable colored output
349    #[must_use]
350    pub const fn no_color(mut self) -> Self {
351        self.colored = false;
352        self
353    }
354
355    fn color(&self, text: &str, color: &str) -> String {
356        ansi_color(text, color, self.colored)
357    }
358}
359
360impl Default for TableReporter {
361    fn default() -> Self {
362        Self::new()
363    }
364}
365
366impl ReportGenerator for TableReporter {
367    fn generate_diff_report(
368        &self,
369        result: &DiffResult,
370        _old_sbom: &NormalizedSbom,
371        _new_sbom: &NormalizedSbom,
372        _config: &ReportConfig,
373    ) -> Result<String, ReportError> {
374        let mut lines = Vec::new();
375
376        // Header
377        lines.push(format!(
378            "{:<12} {:<40} {:<15} {:<15}",
379            self.color("STATUS", "bold"),
380            self.color("COMPONENT", "bold"),
381            self.color("OLD VERSION", "bold"),
382            self.color("NEW VERSION", "bold")
383        ));
384        lines.push("─".repeat(85));
385
386        // Added components
387        for comp in &result.components.added {
388            let version = comp.new_version.as_deref().unwrap_or("-");
389            lines.push(format!(
390                "{:<12} {:<40} {:<15} {:<15}",
391                self.color("+ Added", "green"),
392                truncate(&comp.name, 40),
393                "-",
394                version
395            ));
396        }
397
398        // Removed components
399        for comp in &result.components.removed {
400            let version = comp.old_version.as_deref().unwrap_or("-");
401            lines.push(format!(
402                "{:<12} {:<40} {:<15} {:<15}",
403                self.color("- Removed", "red"),
404                truncate(&comp.name, 40),
405                version,
406                "-"
407            ));
408        }
409
410        // Modified components
411        for comp in &result.components.modified {
412            let old_ver = comp.old_version.as_deref().unwrap_or("-");
413            let new_ver = comp.new_version.as_deref().unwrap_or("-");
414            lines.push(format!(
415                "{:<12} {:<40} {:<15} {:<15}",
416                self.color("~ Modified", "yellow"),
417                truncate(&comp.name, 40),
418                old_ver,
419                new_ver
420            ));
421        }
422
423        // Vulnerabilities section
424        if !result.vulnerabilities.introduced.is_empty() {
425            lines.push(String::new());
426            lines.push(format!(
427                "{:<12} {:<20} {:<10} {:<40}",
428                self.color("VULNS", "bold"),
429                self.color("ID", "bold"),
430                self.color("SEVERITY", "bold"),
431                self.color("COMPONENT", "bold")
432            ));
433            lines.push("─".repeat(85));
434
435            for vuln in &result.vulnerabilities.introduced {
436                let severity_colored = match vuln.severity.to_lowercase().as_str() {
437                    "critical" | "high" => self.color(&vuln.severity, "red"),
438                    "medium" => self.color(&vuln.severity, "yellow"),
439                    _ => vuln.severity.clone(),
440                };
441                lines.push(format!(
442                    "{:<12} {:<20} {:<10} {:<40}",
443                    self.color("! NEW", "red"),
444                    truncate(&vuln.id, 20),
445                    severity_colored,
446                    truncate(&vuln.component_name, 40)
447                ));
448            }
449        }
450
451        // Summary footer
452        lines.push(String::new());
453        lines.push(format!(
454            "Total: {} added, {} removed, {} modified | Vulns: {} new, {} resolved | Similarity: {:.1}%",
455            result.summary.components_added,
456            result.summary.components_removed,
457            result.summary.components_modified,
458            result.summary.vulnerabilities_introduced,
459            result.summary.vulnerabilities_resolved,
460            result.semantic_score
461        ));
462
463        Ok(lines.join("\n"))
464    }
465
466    fn generate_view_report(
467        &self,
468        sbom: &NormalizedSbom,
469        _config: &ReportConfig,
470    ) -> Result<String, ReportError> {
471        let mut lines = Vec::new();
472
473        // Header
474        lines.push(format!(
475            "{:<40} {:<15} {:<20} {:<10}",
476            self.color("COMPONENT", "bold"),
477            self.color("VERSION", "bold"),
478            self.color("LICENSE", "bold"),
479            self.color("VULNS", "bold")
480        ));
481        lines.push("─".repeat(90));
482
483        // Components (limit to 50 for readability)
484        let mut components: Vec<_> = sbom.components.values().collect();
485        components.sort_by(|a, b| a.name.cmp(&b.name));
486
487        for comp in components.iter().take(50) {
488            let version = comp.version.as_deref().unwrap_or("-");
489            let license = comp
490                .licenses
491                .declared
492                .first()
493                .map_or("-", |l| l.expression.as_str());
494            let vulns = comp.vulnerabilities.len();
495            let vuln_display = if vulns > 0 {
496                self.color(&vulns.to_string(), "red")
497            } else {
498                "0".to_string()
499            };
500
501            lines.push(format!(
502                "{:<40} {:<15} {:<20} {:<10}",
503                truncate(&comp.name, 40),
504                truncate(version, 15),
505                truncate(license, 20),
506                vuln_display
507            ));
508        }
509
510        if components.len() > 50 {
511            lines.push(self.color(
512                &format!("... and {} more components", components.len() - 50),
513                "dim",
514            ));
515        }
516
517        // Summary
518        lines.push(String::new());
519        let counts = sbom.vulnerability_counts();
520        let unknown_str = if counts.unknown > 0 {
521            format!(", {} unknown", counts.unknown)
522        } else {
523            String::new()
524        };
525        lines.push(format!(
526            "Total: {} components, {} dependencies | Vulns: {} critical, {} high, {} medium, {} low{}",
527            sbom.component_count(),
528            sbom.edges.len(),
529            counts.critical,
530            counts.high,
531            counts.medium,
532            counts.low,
533            unknown_str
534        ));
535
536        Ok(lines.join("\n"))
537    }
538
539    fn format(&self) -> ReportFormat {
540        ReportFormat::Table
541    }
542}
543
544/// Truncate a string to fit within `max_len` (UTF-8 safe)
545fn truncate(s: &str, max_len: usize) -> String {
546    if s.len() <= max_len {
547        s.to_string()
548    } else if max_len > 3 {
549        let end = floor_char_boundary(s, max_len - 3);
550        format!("{}...", &s[..end])
551    } else {
552        let end = floor_char_boundary(s, max_len);
553        s[..end].to_string()
554    }
555}
556
557/// EOL status counts for summary display.
558struct EolCounts {
559    total: usize,
560    eol: usize,
561    approaching: usize,
562    supported: usize,
563    security_only: usize,
564    unknown: usize,
565}
566
567/// Count EOL statuses across all components in an SBOM.
568fn count_eol_statuses(sbom: &NormalizedSbom) -> EolCounts {
569    use crate::model::EolStatus;
570
571    let mut counts = EolCounts {
572        total: 0,
573        eol: 0,
574        approaching: 0,
575        supported: 0,
576        security_only: 0,
577        unknown: 0,
578    };
579
580    for comp in sbom.components.values() {
581        if let Some(eol) = &comp.eol {
582            counts.total += 1;
583            match eol.status {
584                EolStatus::EndOfLife => counts.eol += 1,
585                EolStatus::ApproachingEol => counts.approaching += 1,
586                EolStatus::Supported => counts.supported += 1,
587                EolStatus::SecurityOnly => counts.security_only += 1,
588                EolStatus::Unknown => counts.unknown += 1,
589            }
590        }
591    }
592
593    counts
594}
595
596/// Find the largest byte index <= `index` that is a valid UTF-8 char boundary.
597const fn floor_char_boundary(s: &str, index: usize) -> usize {
598    if index >= s.len() {
599        s.len()
600    } else {
601        let mut i = index;
602        while i > 0 && !s.is_char_boundary(i) {
603            i -= 1;
604        }
605        i
606    }
607}