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{}\x1b[0m", text),
14 "green" => format!("\x1b[32m{}\x1b[0m", text),
15 "yellow" => format!("\x1b[33m{}\x1b[0m", text),
16 "cyan" => format!("\x1b[36m{}\x1b[0m", text),
17 "bold" => format!("\x1b[1m{}\x1b[0m", text),
18 "dim" => format!("\x1b[2m{}\x1b[0m", text),
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 pub fn new() -> Self {
35 Self { colored: true }
36 }
37
38 pub fn no_color(mut self) -> Self {
40 self.colored = false;
41 self
42 }
43
44 fn color(&self, text: &str, color: &str) -> String {
45 ansi_color(text, color, self.colored)
46 }
47}
48
49impl Default for SummaryReporter {
50 fn default() -> Self {
51 Self::new()
52 }
53}
54
55impl ReportGenerator for SummaryReporter {
56 fn generate_diff_report(
57 &self,
58 result: &DiffResult,
59 old_sbom: &NormalizedSbom,
60 new_sbom: &NormalizedSbom,
61 _config: &ReportConfig,
62 ) -> Result<String, ReportError> {
63 let mut lines = Vec::new();
64
65 lines.push(self.color("SBOM Diff Summary", "bold"));
67 lines.push(self.color("─".repeat(40).as_str(), "dim"));
68
69 let old_name = old_sbom.document.name.as_deref().unwrap_or("old");
71 let new_name = new_sbom.document.name.as_deref().unwrap_or("new");
72 lines.push(format!(
73 "{} {} → {}",
74 self.color("Files:", "cyan"),
75 old_name,
76 new_name
77 ));
78
79 lines.push(format!(
81 "{} {} → {} components",
82 self.color("Size:", "cyan"),
83 old_sbom.component_count(),
84 new_sbom.component_count()
85 ));
86
87 lines.push("".to_string());
88
89 lines.push(self.color("Changes:", "bold"));
91
92 let added = result.summary.components_added;
93 let removed = result.summary.components_removed;
94 let modified = result.summary.components_modified;
95
96 if added > 0 {
97 lines.push(format!(
98 " {} {} added",
99 self.color(&format!("+{}", added), "green"),
100 if added == 1 {
101 "component"
102 } else {
103 "components"
104 }
105 ));
106 }
107 if removed > 0 {
108 lines.push(format!(
109 " {} {} removed",
110 self.color(&format!("-{}", removed), "red"),
111 if removed == 1 {
112 "component"
113 } else {
114 "components"
115 }
116 ));
117 }
118 if modified > 0 {
119 lines.push(format!(
120 " {} {} modified",
121 self.color(&format!("~{}", modified), "yellow"),
122 if modified == 1 {
123 "component"
124 } else {
125 "components"
126 }
127 ));
128 }
129 if added == 0 && removed == 0 && modified == 0 {
130 lines.push(format!(" {}", self.color("No changes", "dim")));
131 }
132
133 let vulns_intro = result.summary.vulnerabilities_introduced;
135 let vulns_resolved = result.summary.vulnerabilities_resolved;
136
137 if vulns_intro > 0 || vulns_resolved > 0 {
138 lines.push("".to_string());
139 lines.push(self.color("Vulnerabilities:", "bold"));
140
141 if vulns_intro > 0 {
142 lines.push(format!(
143 " {} {} introduced",
144 self.color(&format!("!{}", vulns_intro), "red"),
145 if vulns_intro == 1 {
146 "vulnerability"
147 } else {
148 "vulnerabilities"
149 }
150 ));
151 }
152 if vulns_resolved > 0 {
153 lines.push(format!(
154 " {} {} resolved",
155 self.color(&format!("✓{}", vulns_resolved), "green"),
156 if vulns_resolved == 1 {
157 "vulnerability"
158 } else {
159 "vulnerabilities"
160 }
161 ));
162 }
163 }
164
165 lines.push("".to_string());
167 let score = result.semantic_score;
168 let score_color = if score > 90.0 {
169 "green"
170 } else if score > 70.0 {
171 "yellow"
172 } else {
173 "red"
174 };
175 lines.push(format!(
176 "{} {}",
177 self.color("Similarity:", "cyan"),
178 self.color(&format!("{:.1}%", score), score_color)
179 ));
180
181 Ok(lines.join("\n"))
182 }
183
184 fn generate_view_report(
185 &self,
186 sbom: &NormalizedSbom,
187 _config: &ReportConfig,
188 ) -> Result<String, ReportError> {
189 let mut lines = Vec::new();
190
191 lines.push(self.color("SBOM Summary", "bold"));
193 lines.push(self.color("─".repeat(40).as_str(), "dim"));
194
195 if let Some(name) = &sbom.document.name {
197 lines.push(format!("{} {}", self.color("Name:", "cyan"), name));
198 }
199 lines.push(format!(
200 "{} {}",
201 self.color("Format:", "cyan"),
202 sbom.document.format
203 ));
204 lines.push(format!(
205 "{} {}",
206 self.color("Components:", "cyan"),
207 sbom.component_count()
208 ));
209 lines.push(format!(
210 "{} {}",
211 self.color("Dependencies:", "cyan"),
212 sbom.edges.len()
213 ));
214
215 let ecosystems: Vec<_> = sbom.ecosystems().iter().map(|e| e.to_string()).collect();
217 if !ecosystems.is_empty() {
218 lines.push(format!(
219 "{} {}",
220 self.color("Ecosystems:", "cyan"),
221 ecosystems.join(", ")
222 ));
223 }
224
225 let counts = sbom.vulnerability_counts();
227 let total_vulns = counts.critical + counts.high + counts.medium + counts.low;
228 if total_vulns > 0 {
229 lines.push("".to_string());
230 lines.push(self.color("Vulnerabilities:", "bold"));
231 if counts.critical > 0 {
232 lines.push(format!(
233 " {}",
234 self.color(&format!("Critical: {}", counts.critical), "red")
235 ));
236 }
237 if counts.high > 0 {
238 lines.push(format!(
239 " {}",
240 self.color(&format!("High: {}", counts.high), "red")
241 ));
242 }
243 if counts.medium > 0 {
244 lines.push(format!(
245 " {}",
246 self.color(&format!("Medium: {}", counts.medium), "yellow")
247 ));
248 }
249 if counts.low > 0 {
250 lines.push(format!(
251 " {}",
252 self.color(&format!("Low: {}", counts.low), "dim")
253 ));
254 }
255 }
256
257 Ok(lines.join("\n"))
258 }
259
260 fn format(&self) -> ReportFormat {
261 ReportFormat::Summary
262 }
263}
264
265pub struct TableReporter {
267 colored: bool,
269}
270
271impl TableReporter {
272 pub fn new() -> Self {
274 Self { colored: true }
275 }
276
277 pub fn no_color(mut self) -> Self {
279 self.colored = false;
280 self
281 }
282
283 fn color(&self, text: &str, color: &str) -> String {
284 ansi_color(text, color, self.colored)
285 }
286}
287
288impl Default for TableReporter {
289 fn default() -> Self {
290 Self::new()
291 }
292}
293
294impl ReportGenerator for TableReporter {
295 fn generate_diff_report(
296 &self,
297 result: &DiffResult,
298 _old_sbom: &NormalizedSbom,
299 _new_sbom: &NormalizedSbom,
300 _config: &ReportConfig,
301 ) -> Result<String, ReportError> {
302 let mut lines = Vec::new();
303
304 lines.push(format!(
306 "{:<12} {:<40} {:<15} {:<15}",
307 self.color("STATUS", "bold"),
308 self.color("COMPONENT", "bold"),
309 self.color("OLD VERSION", "bold"),
310 self.color("NEW VERSION", "bold")
311 ));
312 lines.push("─".repeat(85));
313
314 for comp in &result.components.added {
316 let version = comp.new_version.as_deref().unwrap_or("-");
317 lines.push(format!(
318 "{:<12} {:<40} {:<15} {:<15}",
319 self.color("+ Added", "green"),
320 truncate(&comp.name, 40),
321 "-",
322 version
323 ));
324 }
325
326 for comp in &result.components.removed {
328 let version = comp.old_version.as_deref().unwrap_or("-");
329 lines.push(format!(
330 "{:<12} {:<40} {:<15} {:<15}",
331 self.color("- Removed", "red"),
332 truncate(&comp.name, 40),
333 version,
334 "-"
335 ));
336 }
337
338 for comp in &result.components.modified {
340 let old_ver = comp.old_version.as_deref().unwrap_or("-");
341 let new_ver = comp.new_version.as_deref().unwrap_or("-");
342 lines.push(format!(
343 "{:<12} {:<40} {:<15} {:<15}",
344 self.color("~ Modified", "yellow"),
345 truncate(&comp.name, 40),
346 old_ver,
347 new_ver
348 ));
349 }
350
351 if !result.vulnerabilities.introduced.is_empty() {
353 lines.push("".to_string());
354 lines.push(format!(
355 "{:<12} {:<20} {:<10} {:<40}",
356 self.color("VULNS", "bold"),
357 self.color("ID", "bold"),
358 self.color("SEVERITY", "bold"),
359 self.color("COMPONENT", "bold")
360 ));
361 lines.push("─".repeat(85));
362
363 for vuln in &result.vulnerabilities.introduced {
364 let severity_colored = match vuln.severity.to_lowercase().as_str() {
365 "critical" => self.color(&vuln.severity, "red"),
366 "high" => self.color(&vuln.severity, "red"),
367 "medium" => self.color(&vuln.severity, "yellow"),
368 _ => vuln.severity.clone(),
369 };
370 lines.push(format!(
371 "{:<12} {:<20} {:<10} {:<40}",
372 self.color("! NEW", "red"),
373 truncate(&vuln.id, 20),
374 severity_colored,
375 truncate(&vuln.component_name, 40)
376 ));
377 }
378 }
379
380 lines.push("".to_string());
382 lines.push(format!(
383 "Total: {} added, {} removed, {} modified | Vulns: {} new, {} resolved | Similarity: {:.1}%",
384 result.summary.components_added,
385 result.summary.components_removed,
386 result.summary.components_modified,
387 result.summary.vulnerabilities_introduced,
388 result.summary.vulnerabilities_resolved,
389 result.semantic_score
390 ));
391
392 Ok(lines.join("\n"))
393 }
394
395 fn generate_view_report(
396 &self,
397 sbom: &NormalizedSbom,
398 _config: &ReportConfig,
399 ) -> Result<String, ReportError> {
400 let mut lines = Vec::new();
401
402 lines.push(format!(
404 "{:<40} {:<15} {:<20} {:<10}",
405 self.color("COMPONENT", "bold"),
406 self.color("VERSION", "bold"),
407 self.color("LICENSE", "bold"),
408 self.color("VULNS", "bold")
409 ));
410 lines.push("─".repeat(90));
411
412 let mut components: Vec<_> = sbom.components.values().collect();
414 components.sort_by(|a, b| a.name.cmp(&b.name));
415
416 for comp in components.iter().take(50) {
417 let version = comp.version.as_deref().unwrap_or("-");
418 let license = comp
419 .licenses
420 .declared
421 .first()
422 .map(|l| l.expression.as_str())
423 .unwrap_or("-");
424 let vulns = comp.vulnerabilities.len();
425 let vuln_display = if vulns > 0 {
426 self.color(&vulns.to_string(), "red")
427 } else {
428 "0".to_string()
429 };
430
431 lines.push(format!(
432 "{:<40} {:<15} {:<20} {:<10}",
433 truncate(&comp.name, 40),
434 truncate(version, 15),
435 truncate(license, 20),
436 vuln_display
437 ));
438 }
439
440 if components.len() > 50 {
441 lines.push(self.color(
442 &format!("... and {} more components", components.len() - 50),
443 "dim",
444 ));
445 }
446
447 lines.push("".to_string());
449 let counts = sbom.vulnerability_counts();
450 let unknown_str = if counts.unknown > 0 {
451 format!(", {} unknown", counts.unknown)
452 } else {
453 String::new()
454 };
455 lines.push(format!(
456 "Total: {} components, {} dependencies | Vulns: {} critical, {} high, {} medium, {} low{}",
457 sbom.component_count(),
458 sbom.edges.len(),
459 counts.critical,
460 counts.high,
461 counts.medium,
462 counts.low,
463 unknown_str
464 ));
465
466 Ok(lines.join("\n"))
467 }
468
469 fn format(&self) -> ReportFormat {
470 ReportFormat::Table
471 }
472}
473
474fn truncate(s: &str, max_len: usize) -> String {
476 if s.len() <= max_len {
477 s.to_string()
478 } else if max_len > 3 {
479 let end = floor_char_boundary(s, max_len - 3);
480 format!("{}...", &s[..end])
481 } else {
482 let end = floor_char_boundary(s, max_len);
483 s[..end].to_string()
484 }
485}
486
487fn floor_char_boundary(s: &str, index: usize) -> usize {
489 if index >= s.len() {
490 s.len()
491 } else {
492 let mut i = index;
493 while i > 0 && !s.is_char_boundary(i) {
494 i -= 1;
495 }
496 i
497 }
498}