1use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator};
6use crate::diff::DiffResult;
7use crate::model::NormalizedSbom;
8
9fn 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
26pub struct SummaryReporter {
28 colored: bool,
30}
31
32impl SummaryReporter {
33 #[must_use]
35 pub const fn new() -> Self {
36 Self { colored: true }
37 }
38
39 #[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 lines.push(self.color("SBOM Diff Summary", "bold"));
69 lines.push(self.color("─".repeat(40).as_str(), "dim"));
70
71 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 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 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 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 {
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 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 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 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 lines.push(self.color("SBOM Summary", "bold"));
259 lines.push(self.color("─".repeat(40).as_str(), "dim"));
260
261 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 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 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
335pub struct TableReporter {
337 colored: bool,
339}
340
341impl TableReporter {
342 #[must_use]
344 pub const fn new() -> Self {
345 Self { colored: true }
346 }
347
348 #[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 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 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 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 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 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 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 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 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 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
544fn 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
557struct EolCounts {
559 total: usize,
560 eol: usize,
561 approaching: usize,
562 supported: usize,
563 security_only: usize,
564 unknown: usize,
565}
566
567fn 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
596const 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}