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 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
441pub struct TableReporter {
443 colored: bool,
445}
446
447impl TableReporter {
448 #[must_use]
450 pub const fn new() -> Self {
451 Self { colored: true }
452 }
453
454 #[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 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 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 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 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 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 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 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 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 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
650fn 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
663struct EolCounts {
665 total: usize,
666 eol: usize,
667 approaching: usize,
668 supported: usize,
669 security_only: usize,
670 unknown: usize,
671}
672
673fn 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
702const 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 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 assert_eq!(severity_color_name("CRITICAL"), "magenta");
730 assert_eq!(severity_color_name("High"), "red");
731
732 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 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 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}