1use super::escape::sanitize_terminal;
6use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator};
7use crate::diff::DiffResult;
8use crate::model::NormalizedSbom;
9
10fn 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
28fn 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
41pub struct SummaryReporter {
43 colored: bool,
45}
46
47impl SummaryReporter {
48 #[must_use]
50 pub const fn new() -> Self {
51 Self { colored: true }
52 }
53
54 #[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 lines.push(self.color("SBOM Diff Summary", "bold"));
84 lines.push(self.color("─".repeat(40).as_str(), "dim"));
85
86 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 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 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 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 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 {
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 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 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 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 lines.push(self.color("SBOM Summary", "bold"));
290 lines.push(self.color("─".repeat(40).as_str(), "dim"));
291
292 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 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 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 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 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
442pub struct TableReporter {
444 colored: bool,
446}
447
448impl TableReporter {
449 #[must_use]
451 pub const fn new() -> Self {
452 Self { colored: true }
453 }
454
455 #[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 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 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 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 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 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 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 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 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 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
656fn 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
669struct EolCounts {
671 total: usize,
672 eol: usize,
673 approaching: usize,
674 supported: usize,
675 security_only: usize,
676 unknown: usize,
677}
678
679fn 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
708const 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 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 assert_eq!(severity_color_name("CRITICAL"), "magenta");
736 assert_eq!(severity_color_name("High"), "red");
737
738 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 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 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}