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            if crypto_metrics.algorithms_count > 0 {
388                let readiness = crypto_metrics.quantum_readiness_score();
389                let color = if readiness >= 80.0 {
390                    "green"
391                } else if readiness >= 40.0 {
392                    "yellow"
393                } else {
394                    "red"
395                };
396                lines.push(format!(
397                    "  {}",
398                    self.color(&format!("Quantum readiness: {readiness:.0}%"), color)
399                ));
400            }
401            if crypto_metrics.weak_algorithm_count > 0 {
402                lines.push(format!(
403                    "  {}",
404                    self.color(
405                        &format!("Weak algorithms: {}", crypto_metrics.weak_algorithm_count),
406                        "red"
407                    )
408                ));
409            }
410            if crypto_metrics.expired_certificates > 0 {
411                lines.push(format!(
412                    "  {}",
413                    self.color(
414                        &format!(
415                            "Expired certificates: {}",
416                            crypto_metrics.expired_certificates
417                        ),
418                        "red"
419                    )
420                ));
421            }
422            if crypto_metrics.compromised_keys > 0 {
423                lines.push(format!(
424                    "  {}",
425                    self.color(
426                        &format!("Compromised keys: {}", crypto_metrics.compromised_keys),
427                        "red"
428                    )
429                ));
430            }
431        }
432
433        Ok(lines.join("\n"))
434    }
435
436    fn format(&self) -> ReportFormat {
437        ReportFormat::Summary
438    }
439}
440
441/// Table reporter for terminal output with aligned columns
442pub struct TableReporter {
443    /// Use colored output
444    colored: bool,
445}
446
447impl TableReporter {
448    /// Create a new table reporter
449    #[must_use]
450    pub const fn new() -> Self {
451        Self { colored: true }
452    }
453
454    /// Disable colored output
455    #[must_use]
456    pub const fn no_color(mut self) -> Self {
457        self.colored = false;
458        self
459    }
460
461    fn color(&self, text: &str, color: &str) -> String {
462        ansi_color(text, color, self.colored)
463    }
464}
465
466impl Default for TableReporter {
467    fn default() -> Self {
468        Self::new()
469    }
470}
471
472impl ReportGenerator for TableReporter {
473    fn generate_diff_report(
474        &self,
475        result: &DiffResult,
476        _old_sbom: &NormalizedSbom,
477        _new_sbom: &NormalizedSbom,
478        _config: &ReportConfig,
479    ) -> Result<String, ReportError> {
480        let mut lines = Vec::new();
481
482        // Header
483        lines.push(format!(
484            "{:<12} {:<40} {:<15} {:<15}",
485            self.color("STATUS", "bold"),
486            self.color("COMPONENT", "bold"),
487            self.color("OLD VERSION", "bold"),
488            self.color("NEW VERSION", "bold")
489        ));
490        lines.push("─".repeat(85));
491
492        // Added components
493        for comp in &result.components.added {
494            let version = sanitize_terminal(comp.new_version.as_deref().unwrap_or("-"));
495            lines.push(format!(
496                "{:<12} {:<40} {:<15} {:<15}",
497                self.color("+ Added", "green"),
498                truncate(&sanitize_terminal(&comp.name), 40),
499                "-",
500                version
501            ));
502        }
503
504        // Removed components
505        for comp in &result.components.removed {
506            let version = sanitize_terminal(comp.old_version.as_deref().unwrap_or("-"));
507            lines.push(format!(
508                "{:<12} {:<40} {:<15} {:<15}",
509                self.color("- Removed", "red"),
510                truncate(&sanitize_terminal(&comp.name), 40),
511                version,
512                "-"
513            ));
514        }
515
516        // Modified components
517        for comp in &result.components.modified {
518            let old_ver = sanitize_terminal(comp.old_version.as_deref().unwrap_or("-"));
519            let new_ver = sanitize_terminal(comp.new_version.as_deref().unwrap_or("-"));
520            lines.push(format!(
521                "{:<12} {:<40} {:<15} {:<15}",
522                self.color("~ Modified", "yellow"),
523                truncate(&sanitize_terminal(&comp.name), 40),
524                old_ver,
525                new_ver
526            ));
527        }
528
529        // Vulnerabilities section
530        if !result.vulnerabilities.introduced.is_empty() {
531            lines.push(String::new());
532            lines.push(format!(
533                "{:<12} {:<20} {:<10} {:<40}",
534                self.color("VULNS", "bold"),
535                self.color("ID", "bold"),
536                self.color("SEVERITY", "bold"),
537                self.color("COMPONENT", "bold")
538            ));
539            lines.push("─".repeat(85));
540
541            for vuln in &result.vulnerabilities.introduced {
542                let severity = sanitize_terminal(&vuln.severity);
543                let severity_colored = match severity_color_name(&vuln.severity) {
544                    "" => severity.into_owned(),
545                    name => self.color(&severity, name),
546                };
547                lines.push(format!(
548                    "{:<12} {:<20} {:<10} {:<40}",
549                    self.color("! NEW", "red"),
550                    truncate(&sanitize_terminal(&vuln.id), 20),
551                    severity_colored,
552                    truncate(&sanitize_terminal(&vuln.component_name), 40)
553                ));
554            }
555        }
556
557        // Summary footer
558        lines.push(String::new());
559        lines.push(format!(
560            "Total: {} added, {} removed, {} modified | Vulns: {} new, {} resolved | Similarity: {:.1}%",
561            result.summary.components_added,
562            result.summary.components_removed,
563            result.summary.components_modified,
564            result.summary.vulnerabilities_introduced,
565            result.summary.vulnerabilities_resolved,
566            result.semantic_score
567        ));
568
569        Ok(lines.join("\n"))
570    }
571
572    fn generate_view_report(
573        &self,
574        sbom: &NormalizedSbom,
575        _config: &ReportConfig,
576    ) -> Result<String, ReportError> {
577        let mut lines = Vec::new();
578
579        // Header
580        lines.push(format!(
581            "{:<40} {:<15} {:<20} {:<10}",
582            self.color("COMPONENT", "bold"),
583            self.color("VERSION", "bold"),
584            self.color("LICENSE", "bold"),
585            self.color("VULNS", "bold")
586        ));
587        lines.push("─".repeat(90));
588
589        // Components (limit to 50 for readability)
590        let mut components: Vec<_> = sbom.components.values().collect();
591        components.sort_by(|a, b| a.name.cmp(&b.name));
592
593        for comp in components.iter().take(50) {
594            let version = comp.version.as_deref().unwrap_or("-");
595            let license = comp
596                .licenses
597                .declared
598                .first()
599                .map_or("-", |l| l.expression.as_str());
600            let vulns = comp.vulnerabilities.len();
601            let vuln_display = if vulns > 0 {
602                self.color(&vulns.to_string(), "red")
603            } else {
604                "0".to_string()
605            };
606
607            lines.push(format!(
608                "{:<40} {:<15} {:<20} {:<10}",
609                truncate(&sanitize_terminal(&comp.name), 40),
610                truncate(&sanitize_terminal(version), 15),
611                truncate(&sanitize_terminal(license), 20),
612                vuln_display
613            ));
614        }
615
616        if components.len() > 50 {
617            lines.push(self.color(
618                &format!("... and {} more components", components.len() - 50),
619                "dim",
620            ));
621        }
622
623        // Summary
624        lines.push(String::new());
625        let counts = sbom.vulnerability_counts();
626        let unknown_str = if counts.unknown > 0 {
627            format!(", {} unknown", counts.unknown)
628        } else {
629            String::new()
630        };
631        lines.push(format!(
632            "Total: {} components, {} dependencies | Vulns: {} critical, {} high, {} medium, {} low{}",
633            sbom.component_count(),
634            sbom.edges.len(),
635            counts.critical,
636            counts.high,
637            counts.medium,
638            counts.low,
639            unknown_str
640        ));
641
642        Ok(lines.join("\n"))
643    }
644
645    fn format(&self) -> ReportFormat {
646        ReportFormat::Table
647    }
648}
649
650/// Truncate a string to fit within `max_len` (UTF-8 safe)
651fn truncate(s: &str, max_len: usize) -> String {
652    if s.len() <= max_len {
653        s.to_string()
654    } else if max_len > 3 {
655        let end = floor_char_boundary(s, max_len - 3);
656        format!("{}...", &s[..end])
657    } else {
658        let end = floor_char_boundary(s, max_len);
659        s[..end].to_string()
660    }
661}
662
663/// EOL status counts for summary display.
664struct EolCounts {
665    total: usize,
666    eol: usize,
667    approaching: usize,
668    supported: usize,
669    security_only: usize,
670    unknown: usize,
671}
672
673/// Count EOL statuses across all components in an SBOM.
674fn count_eol_statuses(sbom: &NormalizedSbom) -> EolCounts {
675    use crate::model::EolStatus;
676
677    let mut counts = EolCounts {
678        total: 0,
679        eol: 0,
680        approaching: 0,
681        supported: 0,
682        security_only: 0,
683        unknown: 0,
684    };
685
686    for comp in sbom.components.values() {
687        if let Some(eol) = &comp.eol {
688            counts.total += 1;
689            match eol.status {
690                EolStatus::EndOfLife => counts.eol += 1,
691                EolStatus::ApproachingEol => counts.approaching += 1,
692                EolStatus::Supported => counts.supported += 1,
693                EolStatus::SecurityOnly => counts.security_only += 1,
694                EolStatus::Unknown => counts.unknown += 1,
695            }
696        }
697    }
698
699    counts
700}
701
702/// Find the largest byte index <= `index` that is a valid UTF-8 char boundary.
703const fn floor_char_boundary(s: &str, index: usize) -> usize {
704    if index >= s.len() {
705        s.len()
706    } else {
707        let mut i = index;
708        while i > 0 && !s.is_char_boundary(i) {
709            i -= 1;
710        }
711        i
712    }
713}
714
715#[cfg(test)]
716mod tests {
717    use super::{ansi_color, severity_color_name};
718
719    #[test]
720    fn severity_colors_match_shared_four_color_scheme() {
721        // The shared scheme (src/tui/theme.rs, src/reports/sidebyside.rs):
722        // Critical=magenta, High=red, Medium=yellow, Low=cyan.
723        assert_eq!(severity_color_name("critical"), "magenta");
724        assert_eq!(severity_color_name("high"), "red");
725        assert_eq!(severity_color_name("medium"), "yellow");
726        assert_eq!(severity_color_name("low"), "cyan");
727
728        // Case-insensitive.
729        assert_eq!(severity_color_name("CRITICAL"), "magenta");
730        assert_eq!(severity_color_name("High"), "red");
731
732        // Unknown severities are left uncolored.
733        assert_eq!(severity_color_name("none"), "");
734        assert_eq!(severity_color_name(""), "");
735    }
736
737    #[test]
738    fn four_severities_render_distinct_ansi_colors() {
739        let critical = ansi_color("x", severity_color_name("critical"), true);
740        let high = ansi_color("x", severity_color_name("high"), true);
741        let medium = ansi_color("x", severity_color_name("medium"), true);
742        let low = ansi_color("x", severity_color_name("low"), true);
743
744        // Each severity maps to its own ANSI SGR code: 35/31/33/36.
745        assert_eq!(critical, "\x1b[35mx\x1b[0m");
746        assert_eq!(high, "\x1b[31mx\x1b[0m");
747        assert_eq!(medium, "\x1b[33mx\x1b[0m");
748        assert_eq!(low, "\x1b[36mx\x1b[0m");
749
750        // All four are distinct from one another.
751        let all = [&critical, &high, &medium, &low];
752        for (i, a) in all.iter().enumerate() {
753            for (j, b) in all.iter().enumerate() {
754                if i != j {
755                    assert_ne!(a, b, "severities {i} and {j} share a color");
756                }
757            }
758        }
759    }
760}