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::escape::sanitize_terminal;
6use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator};
7use crate::diff::DiffResult;
8use crate::model::NormalizedSbom;
9
10/// Apply ANSI color formatting if colored output is enabled.
11fn ansi_color(text: &str, color: &str, colored: bool) -> String {
12    if colored {
13        match color {
14            "red" => format!("\x1b[31m{text}\x1b[0m"),
15            "green" => format!("\x1b[32m{text}\x1b[0m"),
16            "yellow" => format!("\x1b[33m{text}\x1b[0m"),
17            "magenta" => format!("\x1b[35m{text}\x1b[0m"),
18            "cyan" => format!("\x1b[36m{text}\x1b[0m"),
19            "bold" => format!("\x1b[1m{text}\x1b[0m"),
20            "dim" => format!("\x1b[2m{text}\x1b[0m"),
21            _ => text.to_string(),
22        }
23    } else {
24        text.to_string()
25    }
26}
27
28/// Map a severity label to the shared 4-color scheme used by the TUI
29/// (`src/tui/theme.rs`) and the side-by-side report: Critical=magenta,
30/// High=red, Medium=yellow, Low=cyan. Unknown severities are left uncolored.
31fn severity_color_name(severity: &str) -> &'static str {
32    match severity.to_lowercase().as_str() {
33        "critical" => "magenta",
34        "high" => "red",
35        "medium" => "yellow",
36        "low" => "cyan",
37        _ => "",
38    }
39}
40
41/// Summary reporter for shell output
42pub struct SummaryReporter {
43    /// Use colored output
44    colored: bool,
45}
46
47impl SummaryReporter {
48    /// Create a new summary reporter
49    #[must_use]
50    pub const fn new() -> Self {
51        Self { colored: true }
52    }
53
54    /// Disable colored output
55    #[must_use]
56    pub const fn no_color(mut self) -> Self {
57        self.colored = false;
58        self
59    }
60
61    fn color(&self, text: &str, color: &str) -> String {
62        ansi_color(text, color, self.colored)
63    }
64}
65
66impl Default for SummaryReporter {
67    fn default() -> Self {
68        Self::new()
69    }
70}
71
72impl ReportGenerator for SummaryReporter {
73    fn generate_diff_report(
74        &self,
75        result: &DiffResult,
76        old_sbom: &NormalizedSbom,
77        new_sbom: &NormalizedSbom,
78        _config: &ReportConfig,
79    ) -> Result<String, ReportError> {
80        let mut lines = Vec::new();
81
82        // Header
83        lines.push(self.color("SBOM Diff Summary", "bold"));
84        lines.push(self.color("─".repeat(40).as_str(), "dim"));
85
86        // File info
87        let old_name = sanitize_terminal(old_sbom.document.name.as_deref().unwrap_or("old"));
88        let new_name = sanitize_terminal(new_sbom.document.name.as_deref().unwrap_or("new"));
89        lines.push(format!(
90            "{}  {} → {}",
91            self.color("Files:", "cyan"),
92            old_name,
93            new_name
94        ));
95
96        // Component counts
97        lines.push(format!(
98            "{}  {} → {} components",
99            self.color("Size:", "cyan"),
100            old_sbom.component_count(),
101            new_sbom.component_count()
102        ));
103
104        lines.push(String::new());
105
106        // Changes
107        lines.push(self.color("Changes:", "bold"));
108
109        let added = result.summary.components_added;
110        let removed = result.summary.components_removed;
111        let modified = result.summary.components_modified;
112
113        if added > 0 {
114            lines.push(format!(
115                "  {} {} added",
116                self.color(&format!("+{added}"), "green"),
117                if added == 1 {
118                    "component"
119                } else {
120                    "components"
121                }
122            ));
123        }
124        if removed > 0 {
125            lines.push(format!(
126                "  {} {} removed",
127                self.color(&format!("-{removed}"), "red"),
128                if removed == 1 {
129                    "component"
130                } else {
131                    "components"
132                }
133            ));
134        }
135        if modified > 0 {
136            lines.push(format!(
137                "  {} {} modified",
138                self.color(&format!("~{modified}"), "yellow"),
139                if modified == 1 {
140                    "component"
141                } else {
142                    "components"
143                }
144            ));
145        }
146        if added == 0 && removed == 0 && modified == 0 {
147            lines.push(format!("  {}", self.color("No changes", "dim")));
148        }
149
150        // Document-metadata changes (author/tool/timestamp/spec-version/etc.)
151        if !result.metadata_changes.is_empty() {
152            lines.push(String::new());
153            lines.push(self.color("Metadata:", "bold"));
154            for change in &result.metadata_changes {
155                let old = change.old_value.as_deref().unwrap_or("∅");
156                let new = change.new_value.as_deref().unwrap_or("∅");
157                lines.push(format!(
158                    "  {}: {} → {}",
159                    sanitize_terminal(&change.field),
160                    sanitize_terminal(old),
161                    sanitize_terminal(new),
162                ));
163            }
164        }
165
166        // Vulnerabilities
167        let vulns_intro = result.summary.vulnerabilities_introduced;
168        let vulns_resolved = result.summary.vulnerabilities_resolved;
169
170        if vulns_intro > 0 || vulns_resolved > 0 {
171            lines.push(String::new());
172            lines.push(self.color("Vulnerabilities:", "bold"));
173
174            if vulns_intro > 0 {
175                lines.push(format!(
176                    "  {} {} introduced",
177                    self.color(&format!("!{vulns_intro}"), "red"),
178                    if vulns_intro == 1 {
179                        "vulnerability"
180                    } else {
181                        "vulnerabilities"
182                    }
183                ));
184            }
185            if vulns_resolved > 0 {
186                lines.push(format!(
187                    "  {} {} resolved",
188                    self.color(&format!("✓{vulns_resolved}"), "green"),
189                    if vulns_resolved == 1 {
190                        "vulnerability"
191                    } else {
192                        "vulnerabilities"
193                    }
194                ));
195            }
196        }
197
198        // End-of-life summary (from new SBOM)
199        {
200            let eol_counts = count_eol_statuses(new_sbom);
201            if eol_counts.total > 0 {
202                lines.push(String::new());
203                lines.push(self.color("End-of-Life:", "bold"));
204                let mut parts = Vec::new();
205                if eol_counts.eol > 0 {
206                    parts.push(self.color(&format!("{} EOL", eol_counts.eol), "red"));
207                }
208                if eol_counts.approaching > 0 {
209                    parts.push(
210                        self.color(&format!("{} approaching", eol_counts.approaching), "yellow"),
211                    );
212                }
213                if eol_counts.supported > 0 {
214                    parts.push(self.color(&format!("{} supported", eol_counts.supported), "green"));
215                }
216                if eol_counts.security_only > 0 {
217                    parts.push(format!("{} security-only", eol_counts.security_only));
218                }
219                if eol_counts.unknown > 0 {
220                    parts.push(format!("{} unknown", eol_counts.unknown));
221                }
222                lines.push(format!("  {}", parts.join(", ")));
223            }
224        }
225
226        // Graph changes
227        if let Some(ref summary) = result.graph_summary
228            && summary.total_changes > 0
229        {
230            lines.push(String::new());
231            lines.push(self.color("Graph Changes:", "bold"));
232            lines.push(format!(
233                "  {} added, {} removed, {} rel changed, {} reparented, {} depth changes",
234                summary.dependencies_added,
235                summary.dependencies_removed,
236                summary.relationship_changed,
237                summary.reparented,
238                summary.depth_changed,
239            ));
240
241            // Impact breakdown
242            let mut impact_parts = Vec::new();
243            if summary.by_impact.critical > 0 {
244                impact_parts
245                    .push(self.color(&format!("{} critical", summary.by_impact.critical), "red"));
246            }
247            if summary.by_impact.high > 0 {
248                impact_parts
249                    .push(self.color(&format!("{} high", summary.by_impact.high), "yellow"));
250            }
251            if summary.by_impact.medium > 0 {
252                impact_parts.push(format!("{} medium", summary.by_impact.medium));
253            }
254            if summary.by_impact.low > 0 {
255                impact_parts.push(format!("{} low", summary.by_impact.low));
256            }
257            if !impact_parts.is_empty() {
258                lines.push(format!("  By impact: {}", impact_parts.join(", ")));
259            }
260        }
261
262        // Score
263        lines.push(String::new());
264        let score = result.semantic_score;
265        let score_color = if score > 90.0 {
266            "green"
267        } else if score > 70.0 {
268            "yellow"
269        } else {
270            "red"
271        };
272        lines.push(format!(
273            "{}  {}",
274            self.color("Similarity:", "cyan"),
275            self.color(&format!("{score:.1}%"), score_color)
276        ));
277
278        Ok(lines.join("\n"))
279    }
280
281    fn generate_view_report(
282        &self,
283        sbom: &NormalizedSbom,
284        _config: &ReportConfig,
285    ) -> Result<String, ReportError> {
286        let mut lines = Vec::new();
287
288        // Header
289        lines.push(self.color("SBOM Summary", "bold"));
290        lines.push(self.color("─".repeat(40).as_str(), "dim"));
291
292        // Basic info
293        if let Some(name) = &sbom.document.name {
294            lines.push(format!(
295                "{}  {}",
296                self.color("Name:", "cyan"),
297                sanitize_terminal(name)
298            ));
299        }
300        lines.push(format!(
301            "{}  {}",
302            self.color("Format:", "cyan"),
303            sbom.document.format
304        ));
305        lines.push(format!(
306            "{}  {}",
307            self.color("Components:", "cyan"),
308            sbom.component_count()
309        ));
310        lines.push(format!(
311            "{}  {}",
312            self.color("Dependencies:", "cyan"),
313            sbom.edges.len()
314        ));
315
316        // Ecosystems
317        let ecosystems: Vec<_> = sbom
318            .ecosystems()
319            .iter()
320            .map(std::string::ToString::to_string)
321            .collect();
322        if !ecosystems.is_empty() {
323            let joined = ecosystems.join(", ");
324            lines.push(format!(
325                "{}  {}",
326                self.color("Ecosystems:", "cyan"),
327                sanitize_terminal(&joined)
328            ));
329        }
330
331        // Vulnerabilities
332        let counts = sbom.vulnerability_counts();
333        let total_vulns = counts.critical + counts.high + counts.medium + counts.low;
334        if total_vulns > 0 {
335            lines.push(String::new());
336            lines.push(self.color("Vulnerabilities:", "bold"));
337            if counts.critical > 0 {
338                lines.push(format!(
339                    "  {}",
340                    self.color(
341                        &format!("Critical: {}", counts.critical),
342                        severity_color_name("critical")
343                    )
344                ));
345            }
346            if counts.high > 0 {
347                lines.push(format!(
348                    "  {}",
349                    self.color(
350                        &format!("High: {}", counts.high),
351                        severity_color_name("high")
352                    )
353                ));
354            }
355            if counts.medium > 0 {
356                lines.push(format!(
357                    "  {}",
358                    self.color(
359                        &format!("Medium: {}", counts.medium),
360                        severity_color_name("medium")
361                    )
362                ));
363            }
364            if counts.low > 0 {
365                lines.push(format!(
366                    "  {}",
367                    self.color(&format!("Low: {}", counts.low), severity_color_name("low"))
368                ));
369            }
370        }
371
372        // Crypto summary (if crypto components exist)
373        let crypto_metrics = crate::quality::CryptographyMetrics::from_sbom(sbom);
374        if crypto_metrics.has_data() {
375            lines.push(String::new());
376            lines.push(self.color(
377                &format!("Crypto: {} assets", crypto_metrics.total_crypto_components),
378                "bold",
379            ));
380            lines.push(format!(
381                "  Algorithms: {} | Certificates: {} | Keys: {} | Protocols: {}",
382                crypto_metrics.algorithms_count,
383                crypto_metrics.certificates_count,
384                crypto_metrics.keys_count,
385                crypto_metrics.protocols_count,
386            ));
387            // `None` (no algorithms) omits the row — 0/0 readiness is absence
388            // of evidence, not a perfect score.
389            if let Some(readiness) = crypto_metrics.quantum_readiness_score() {
390                let color = if readiness >= 80.0 {
391                    "green"
392                } else if readiness >= 40.0 {
393                    "yellow"
394                } else {
395                    "red"
396                };
397                lines.push(format!(
398                    "  {}",
399                    self.color(&format!("Quantum readiness: {readiness:.0}%"), color)
400                ));
401            }
402            if crypto_metrics.weak_algorithm_count > 0 {
403                lines.push(format!(
404                    "  {}",
405                    self.color(
406                        &format!("Weak algorithms: {}", crypto_metrics.weak_algorithm_count),
407                        "red"
408                    )
409                ));
410            }
411            if crypto_metrics.expired_certificates > 0 {
412                lines.push(format!(
413                    "  {}",
414                    self.color(
415                        &format!(
416                            "Expired certificates: {}",
417                            crypto_metrics.expired_certificates
418                        ),
419                        "red"
420                    )
421                ));
422            }
423            if crypto_metrics.compromised_keys > 0 {
424                lines.push(format!(
425                    "  {}",
426                    self.color(
427                        &format!("Compromised keys: {}", crypto_metrics.compromised_keys),
428                        "red"
429                    )
430                ));
431            }
432        }
433
434        Ok(lines.join("\n"))
435    }
436
437    fn format(&self) -> ReportFormat {
438        ReportFormat::Summary
439    }
440}
441
442/// Table reporter for terminal output with aligned columns
443pub struct TableReporter {
444    /// Use colored output
445    colored: bool,
446}
447
448impl TableReporter {
449    /// Create a new table reporter
450    #[must_use]
451    pub const fn new() -> Self {
452        Self { colored: true }
453    }
454
455    /// Disable colored output
456    #[must_use]
457    pub const fn no_color(mut self) -> Self {
458        self.colored = false;
459        self
460    }
461
462    fn color(&self, text: &str, color: &str) -> String {
463        ansi_color(text, color, self.colored)
464    }
465}
466
467impl Default for TableReporter {
468    fn default() -> Self {
469        Self::new()
470    }
471}
472
473impl ReportGenerator for TableReporter {
474    fn generate_diff_report(
475        &self,
476        result: &DiffResult,
477        _old_sbom: &NormalizedSbom,
478        _new_sbom: &NormalizedSbom,
479        _config: &ReportConfig,
480    ) -> Result<String, ReportError> {
481        let mut lines = Vec::new();
482
483        // Header
484        lines.push(format!(
485            "{:<12} {:<40} {:<15} {:<15}",
486            self.color("STATUS", "bold"),
487            self.color("COMPONENT", "bold"),
488            self.color("OLD VERSION", "bold"),
489            self.color("NEW VERSION", "bold")
490        ));
491        lines.push("─".repeat(85));
492
493        // Added components
494        for comp in &result.components.added {
495            let version = sanitize_terminal(comp.new_version.as_deref().unwrap_or("-"));
496            lines.push(format!(
497                "{:<12} {:<40} {:<15} {:<15}",
498                self.color("+ Added", "green"),
499                truncate(&sanitize_terminal(&comp.name), 40),
500                "-",
501                version
502            ));
503        }
504
505        // Removed components
506        for comp in &result.components.removed {
507            let version = sanitize_terminal(comp.old_version.as_deref().unwrap_or("-"));
508            lines.push(format!(
509                "{:<12} {:<40} {:<15} {:<15}",
510                self.color("- Removed", "red"),
511                truncate(&sanitize_terminal(&comp.name), 40),
512                version,
513                "-"
514            ));
515        }
516
517        // Modified components
518        for comp in &result.components.modified {
519            let old_ver = sanitize_terminal(comp.old_version.as_deref().unwrap_or("-"));
520            let new_ver = sanitize_terminal(comp.new_version.as_deref().unwrap_or("-"));
521            let (label, color) = if comp.change_type == crate::diff::ChangeType::Unchanged {
522                ("= Unchanged", "white")
523            } else {
524                ("~ Modified", "yellow")
525            };
526            lines.push(format!(
527                "{:<12} {:<40} {:<15} {:<15}",
528                self.color(label, color),
529                truncate(&sanitize_terminal(&comp.name), 40),
530                old_ver,
531                new_ver
532            ));
533        }
534
535        // Vulnerabilities section
536        if !result.vulnerabilities.introduced.is_empty() {
537            lines.push(String::new());
538            lines.push(format!(
539                "{:<12} {:<20} {:<10} {:<40}",
540                self.color("VULNS", "bold"),
541                self.color("ID", "bold"),
542                self.color("SEVERITY", "bold"),
543                self.color("COMPONENT", "bold")
544            ));
545            lines.push("─".repeat(85));
546
547            for vuln in &result.vulnerabilities.introduced {
548                let severity = sanitize_terminal(&vuln.severity);
549                let severity_colored = match severity_color_name(&vuln.severity) {
550                    "" => severity.into_owned(),
551                    name => self.color(&severity, name),
552                };
553                lines.push(format!(
554                    "{:<12} {:<20} {:<10} {:<40}",
555                    self.color("! NEW", "red"),
556                    truncate(&sanitize_terminal(&vuln.id), 20),
557                    severity_colored,
558                    truncate(&sanitize_terminal(&vuln.component_name), 40)
559                ));
560            }
561        }
562
563        // Summary footer
564        lines.push(String::new());
565        lines.push(format!(
566            "Total: {} added, {} removed, {} modified | Vulns: {} new, {} resolved | Similarity: {:.1}%",
567            result.summary.components_added,
568            result.summary.components_removed,
569            result.summary.components_modified,
570            result.summary.vulnerabilities_introduced,
571            result.summary.vulnerabilities_resolved,
572            result.semantic_score
573        ));
574
575        Ok(lines.join("\n"))
576    }
577
578    fn generate_view_report(
579        &self,
580        sbom: &NormalizedSbom,
581        _config: &ReportConfig,
582    ) -> Result<String, ReportError> {
583        let mut lines = Vec::new();
584
585        // Header
586        lines.push(format!(
587            "{:<40} {:<15} {:<20} {:<10}",
588            self.color("COMPONENT", "bold"),
589            self.color("VERSION", "bold"),
590            self.color("LICENSE", "bold"),
591            self.color("VULNS", "bold")
592        ));
593        lines.push("─".repeat(90));
594
595        // Components (limit to 50 for readability)
596        let mut components: Vec<_> = sbom.components.values().collect();
597        components.sort_by(|a, b| a.name.cmp(&b.name));
598
599        for comp in components.iter().take(50) {
600            let version = comp.version.as_deref().unwrap_or("-");
601            let license = comp
602                .licenses
603                .declared
604                .first()
605                .map_or("-", |l| l.display_name());
606            let vulns = comp.vulnerabilities.len();
607            let vuln_display = if vulns > 0 {
608                self.color(&vulns.to_string(), "red")
609            } else {
610                "0".to_string()
611            };
612
613            lines.push(format!(
614                "{:<40} {:<15} {:<20} {:<10}",
615                truncate(&sanitize_terminal(&comp.name), 40),
616                truncate(&sanitize_terminal(version), 15),
617                truncate(&sanitize_terminal(license), 20),
618                vuln_display
619            ));
620        }
621
622        if components.len() > 50 {
623            lines.push(self.color(
624                &format!("... and {} more components", components.len() - 50),
625                "dim",
626            ));
627        }
628
629        // Summary
630        lines.push(String::new());
631        let counts = sbom.vulnerability_counts();
632        let unknown_str = if counts.unknown > 0 {
633            format!(", {} unknown", counts.unknown)
634        } else {
635            String::new()
636        };
637        lines.push(format!(
638            "Total: {} components, {} dependencies | Vulns: {} critical, {} high, {} medium, {} low{}",
639            sbom.component_count(),
640            sbom.edges.len(),
641            counts.critical,
642            counts.high,
643            counts.medium,
644            counts.low,
645            unknown_str
646        ));
647
648        Ok(lines.join("\n"))
649    }
650
651    fn format(&self) -> ReportFormat {
652        ReportFormat::Table
653    }
654}
655
656/// Truncate a string to fit within `max_len` (UTF-8 safe)
657fn truncate(s: &str, max_len: usize) -> String {
658    if s.len() <= max_len {
659        s.to_string()
660    } else if max_len > 3 {
661        let end = floor_char_boundary(s, max_len - 3);
662        format!("{}...", &s[..end])
663    } else {
664        let end = floor_char_boundary(s, max_len);
665        s[..end].to_string()
666    }
667}
668
669/// EOL status counts for summary display.
670struct EolCounts {
671    total: usize,
672    eol: usize,
673    approaching: usize,
674    supported: usize,
675    security_only: usize,
676    unknown: usize,
677}
678
679/// Count EOL statuses across all components in an SBOM.
680fn count_eol_statuses(sbom: &NormalizedSbom) -> EolCounts {
681    use crate::model::EolStatus;
682
683    let mut counts = EolCounts {
684        total: 0,
685        eol: 0,
686        approaching: 0,
687        supported: 0,
688        security_only: 0,
689        unknown: 0,
690    };
691
692    for comp in sbom.components.values() {
693        if let Some(eol) = &comp.eol {
694            counts.total += 1;
695            match eol.status {
696                EolStatus::EndOfLife => counts.eol += 1,
697                EolStatus::ApproachingEol => counts.approaching += 1,
698                EolStatus::Supported => counts.supported += 1,
699                EolStatus::SecurityOnly => counts.security_only += 1,
700                EolStatus::Unknown => counts.unknown += 1,
701            }
702        }
703    }
704
705    counts
706}
707
708/// Find the largest byte index <= `index` that is a valid UTF-8 char boundary.
709const fn floor_char_boundary(s: &str, index: usize) -> usize {
710    if index >= s.len() {
711        s.len()
712    } else {
713        let mut i = index;
714        while i > 0 && !s.is_char_boundary(i) {
715            i -= 1;
716        }
717        i
718    }
719}
720
721#[cfg(test)]
722mod tests {
723    use super::{ansi_color, severity_color_name};
724
725    #[test]
726    fn severity_colors_match_shared_four_color_scheme() {
727        // The shared scheme (src/tui/theme.rs, src/reports/sidebyside.rs):
728        // Critical=magenta, High=red, Medium=yellow, Low=cyan.
729        assert_eq!(severity_color_name("critical"), "magenta");
730        assert_eq!(severity_color_name("high"), "red");
731        assert_eq!(severity_color_name("medium"), "yellow");
732        assert_eq!(severity_color_name("low"), "cyan");
733
734        // Case-insensitive.
735        assert_eq!(severity_color_name("CRITICAL"), "magenta");
736        assert_eq!(severity_color_name("High"), "red");
737
738        // Unknown severities are left uncolored.
739        assert_eq!(severity_color_name("none"), "");
740        assert_eq!(severity_color_name(""), "");
741    }
742
743    #[test]
744    fn four_severities_render_distinct_ansi_colors() {
745        let critical = ansi_color("x", severity_color_name("critical"), true);
746        let high = ansi_color("x", severity_color_name("high"), true);
747        let medium = ansi_color("x", severity_color_name("medium"), true);
748        let low = ansi_color("x", severity_color_name("low"), true);
749
750        // Each severity maps to its own ANSI SGR code: 35/31/33/36.
751        assert_eq!(critical, "\x1b[35mx\x1b[0m");
752        assert_eq!(high, "\x1b[31mx\x1b[0m");
753        assert_eq!(medium, "\x1b[33mx\x1b[0m");
754        assert_eq!(low, "\x1b[36mx\x1b[0m");
755
756        // All four are distinct from one another.
757        let all = [&critical, &high, &medium, &low];
758        for (i, a) in all.iter().enumerate() {
759            for (j, b) in all.iter().enumerate() {
760                if i != j {
761                    assert_ne!(a, b, "severities {i} and {j} share a color");
762                }
763            }
764        }
765    }
766}