1use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator};
4use crate::diff::{ChangeType, DiffResult};
5use crate::model::NormalizedSbom;
6use std::fmt::Write;
7
8mod colors {
10 pub const RESET: &str = "\x1b[0m";
11 pub const BOLD: &str = "\x1b[1m";
12 pub const DIM: &str = "\x1b[2m";
13 pub const RED: &str = "\x1b[31m";
14 pub const GREEN: &str = "\x1b[32m";
15 pub const YELLOW: &str = "\x1b[33m";
16 pub const MAGENTA: &str = "\x1b[35m";
17 pub const CYAN: &str = "\x1b[36m";
18 pub const WHITE: &str = "\x1b[37m";
19 pub const LINE_NUM: &str = "\x1b[38;5;242m"; }
21
22#[allow(dead_code)]
24pub struct SideBySideReporter {
25 width: usize,
27 show_line_numbers: bool,
29 use_colors: bool,
31}
32
33impl SideBySideReporter {
34 #[must_use]
36 pub fn new() -> Self {
37 let width = terminal_width().unwrap_or(120);
39 Self {
40 width,
41 show_line_numbers: true,
42 use_colors: true,
43 }
44 }
45
46 #[must_use]
48 pub const fn width(mut self, width: usize) -> Self {
49 self.width = width;
50 self
51 }
52
53 #[must_use]
55 pub const fn no_colors(mut self) -> Self {
56 self.use_colors = false;
57 self
58 }
59
60 const fn col(&self, code: &'static str) -> &'static str {
61 if self.use_colors { code } else { "" }
62 }
63
64 fn format_header(&self, old_name: &str, new_name: &str) -> String {
65 let half_width = (self.width - 3) / 2;
66 format!(
67 "{}{:<half_width$}{} │ {}{:<half_width$}{}\n",
68 self.col(colors::BOLD),
69 truncate(old_name, half_width),
70 self.col(colors::RESET),
71 self.col(colors::BOLD),
72 truncate(new_name, half_width),
73 self.col(colors::RESET),
74 )
75 }
76
77 fn format_section_header(&self, title: &str) -> String {
78 format!(
79 "\n{}{}═══ {} {}═══{}\n",
80 self.col(colors::CYAN),
81 self.col(colors::BOLD),
82 title,
83 "═".repeat(self.width.saturating_sub(title.len() + 8)),
84 self.col(colors::RESET),
85 )
86 }
87
88 fn format_component_row(
89 &self,
90 line_num: usize,
91 old_text: Option<&str>,
92 new_text: Option<&str>,
93 change_type: ChangeType,
94 ) -> String {
95 let half_width = (self.width - 7) / 2; let num_width = 3;
97
98 let (left_num, left_text, right_num, right_text) = match change_type {
99 ChangeType::Removed => (
100 format!(
101 "{}{:>num_width$}{}",
102 self.col(colors::RED),
103 line_num,
104 self.col(colors::RESET)
105 ),
106 format!(
107 "{}{}{}",
108 self.col(colors::RED),
109 truncate(old_text.unwrap_or(""), half_width),
110 self.col(colors::RESET)
111 ),
112 format!(
113 "{}{:>num_width$}{}",
114 self.col(colors::DIM),
115 ".",
116 self.col(colors::RESET)
117 ),
118 format!(
119 "{}{}{}",
120 self.col(colors::DIM),
121 "...",
122 self.col(colors::RESET)
123 ),
124 ),
125 ChangeType::Added => (
126 format!(
127 "{}{:>num_width$}{}",
128 self.col(colors::DIM),
129 ".",
130 self.col(colors::RESET)
131 ),
132 format!(
133 "{}{}{}",
134 self.col(colors::DIM),
135 "...",
136 self.col(colors::RESET)
137 ),
138 format!(
139 "{}{:>num_width$}{}",
140 self.col(colors::GREEN),
141 line_num,
142 self.col(colors::RESET)
143 ),
144 format!(
145 "{}{}{}",
146 self.col(colors::GREEN),
147 truncate(new_text.unwrap_or(""), half_width),
148 self.col(colors::RESET)
149 ),
150 ),
151 ChangeType::Modified => (
152 format!(
153 "{}{:>num_width$}{}",
154 self.col(colors::YELLOW),
155 line_num,
156 self.col(colors::RESET)
157 ),
158 format!(
159 "{}{}{}",
160 self.col(colors::RED),
161 truncate(old_text.unwrap_or(""), half_width),
162 self.col(colors::RESET)
163 ),
164 format!(
165 "{}{:>num_width$}{}",
166 self.col(colors::YELLOW),
167 line_num,
168 self.col(colors::RESET)
169 ),
170 format!(
171 "{}{}{}",
172 self.col(colors::GREEN),
173 truncate(new_text.unwrap_or(""), half_width),
174 self.col(colors::RESET)
175 ),
176 ),
177 ChangeType::Unchanged => (
178 format!(
179 "{}{:>num_width$}{}",
180 self.col(colors::LINE_NUM),
181 line_num,
182 self.col(colors::RESET)
183 ),
184 truncate(old_text.unwrap_or(""), half_width),
185 format!(
186 "{}{:>num_width$}{}",
187 self.col(colors::LINE_NUM),
188 line_num,
189 self.col(colors::RESET)
190 ),
191 truncate(new_text.unwrap_or(""), half_width),
192 ),
193 };
194
195 let left_visible = strip_ansi(&left_text);
197 let right_visible = strip_ansi(&right_text);
198 let left_padding = half_width.saturating_sub(left_visible.len());
199 let right_padding = half_width.saturating_sub(right_visible.len());
200
201 format!(
202 "{} {}{} │ {} {}{}\n",
203 left_num,
204 left_text,
205 " ".repeat(left_padding),
206 right_num,
207 right_text,
208 " ".repeat(right_padding),
209 )
210 }
211
212 fn format_vulnerability_row(
213 &self,
214 vuln_id: &str,
215 severity: &str,
216 component: &str,
217 is_introduced: bool,
218 ) -> String {
219 let icon = if is_introduced { "+" } else { "-" };
220 let color = if is_introduced {
221 colors::RED
222 } else {
223 colors::GREEN
224 };
225 let severity_color = match severity.to_lowercase().as_str() {
226 "critical" => colors::MAGENTA,
227 "high" => colors::RED,
228 "medium" => colors::YELLOW,
229 "low" => colors::CYAN,
230 _ => colors::WHITE,
231 };
232
233 format!(
234 " {}{}{} {}{:<16}{} {}{:<10}{} → {}\n",
235 self.col(color),
236 icon,
237 self.col(colors::RESET),
238 self.col(colors::BOLD),
239 vuln_id,
240 self.col(colors::RESET),
241 self.col(severity_color),
242 severity,
243 self.col(colors::RESET),
244 component,
245 )
246 }
247}
248
249impl Default for SideBySideReporter {
250 fn default() -> Self {
251 Self::new()
252 }
253}
254
255impl ReportGenerator for SideBySideReporter {
256 fn generate_diff_report(
257 &self,
258 result: &DiffResult,
259 old_sbom: &NormalizedSbom,
260 new_sbom: &NormalizedSbom,
261 _config: &ReportConfig,
262 ) -> Result<String, ReportError> {
263 let mut out = String::new();
264
265 let old_name = old_sbom.document.name.as_deref().unwrap_or("Old SBOM");
267 let new_name = new_sbom.document.name.as_deref().unwrap_or("New SBOM");
268
269 writeln!(
270 out,
271 "{}sbom-tools{} --- {}",
272 self.col(colors::CYAN),
273 self.col(colors::RESET),
274 old_sbom.document.format
275 )?;
276
277 out.push_str(&self.format_header(old_name, new_name));
278
279 let half_width = (self.width - 3) / 2;
281 writeln!(
282 out,
283 "{}{}│{}{}",
284 self.col(colors::DIM),
285 "─".repeat(half_width + 4),
286 "─".repeat(half_width + 4),
287 self.col(colors::RESET)
288 )?;
289
290 out.push_str(&self.format_section_header("Components"));
292
293 let mut line_num = 1;
294
295 for comp in &result.components.removed {
297 let old_text = format!(
298 "{} {}",
299 comp.name,
300 comp.old_version.as_deref().unwrap_or("")
301 );
302 out.push_str(&self.format_component_row(
303 line_num,
304 Some(&old_text),
305 None,
306 ChangeType::Removed,
307 ));
308 line_num += 1;
309 }
310
311 for comp in &result.components.modified {
313 let old_text = format!(
314 "{} {}",
315 comp.name,
316 comp.old_version.as_deref().unwrap_or("")
317 );
318 let new_text = format!(
319 "{} {}",
320 comp.name,
321 comp.new_version.as_deref().unwrap_or("")
322 );
323 out.push_str(&self.format_component_row(
324 line_num,
325 Some(&old_text),
326 Some(&new_text),
327 ChangeType::Modified,
328 ));
329 line_num += 1;
330 }
331
332 for comp in &result.components.added {
334 let new_text = format!(
335 "{} {}",
336 comp.name,
337 comp.new_version.as_deref().unwrap_or("")
338 );
339 out.push_str(&self.format_component_row(
340 line_num,
341 None,
342 Some(&new_text),
343 ChangeType::Added,
344 ));
345 line_num += 1;
346 }
347
348 if !result.dependencies.added.is_empty() || !result.dependencies.removed.is_empty() {
350 out.push_str(&self.format_section_header("Dependencies"));
351
352 line_num = 1;
353 for dep in &result.dependencies.removed {
354 let old_text = format!("{} → {}", short_id(&dep.from), short_id(&dep.to));
355 out.push_str(&self.format_component_row(
356 line_num,
357 Some(&old_text),
358 None,
359 ChangeType::Removed,
360 ));
361 line_num += 1;
362 }
363
364 for dep in &result.dependencies.added {
365 let new_text = format!("{} → {}", short_id(&dep.from), short_id(&dep.to));
366 out.push_str(&self.format_component_row(
367 line_num,
368 None,
369 Some(&new_text),
370 ChangeType::Added,
371 ));
372 line_num += 1;
373 }
374 }
375
376 if !result.vulnerabilities.introduced.is_empty()
378 || !result.vulnerabilities.resolved.is_empty()
379 {
380 out.push_str(&self.format_section_header("Vulnerabilities"));
381
382 for vuln in &result.vulnerabilities.resolved {
383 out.push_str(&self.format_vulnerability_row(
384 &vuln.id,
385 &vuln.severity,
386 &vuln.component_name,
387 false,
388 ));
389 }
390
391 for vuln in &result.vulnerabilities.introduced {
392 out.push_str(&self.format_vulnerability_row(
393 &vuln.id,
394 &vuln.severity,
395 &vuln.component_name,
396 true,
397 ));
398 }
399 }
400
401 out.push_str(&self.format_section_header("Summary"));
403 writeln!(
404 out,
405 " {}Components:{} {}+{}{} added, {}-{}{} removed, {}~{}{} modified",
406 self.col(colors::BOLD),
407 self.col(colors::RESET),
408 self.col(colors::GREEN),
409 result.summary.components_added,
410 self.col(colors::RESET),
411 self.col(colors::RED),
412 result.summary.components_removed,
413 self.col(colors::RESET),
414 self.col(colors::YELLOW),
415 result.summary.components_modified,
416 self.col(colors::RESET),
417 )?;
418
419 if result.summary.vulnerabilities_introduced > 0
420 || result.summary.vulnerabilities_resolved > 0
421 {
422 writeln!(
423 out,
424 " {}Vulnerabilities:{} {}+{}{} introduced, {}-{}{} resolved",
425 self.col(colors::BOLD),
426 self.col(colors::RESET),
427 self.col(colors::RED),
428 result.summary.vulnerabilities_introduced,
429 self.col(colors::RESET),
430 self.col(colors::GREEN),
431 result.summary.vulnerabilities_resolved,
432 self.col(colors::RESET),
433 )?;
434 }
435
436 writeln!(
437 out,
438 " {}Semantic Score:{} {}{:.1}{}",
439 self.col(colors::BOLD),
440 self.col(colors::RESET),
441 self.col(colors::CYAN),
442 result.semantic_score,
443 self.col(colors::RESET),
444 )?;
445
446 Ok(out)
447 }
448
449 fn generate_view_report(
450 &self,
451 sbom: &NormalizedSbom,
452 _config: &ReportConfig,
453 ) -> Result<String, ReportError> {
454 let mut out = String::new();
455
456 let name = sbom.document.name.as_deref().unwrap_or("SBOM");
457
458 writeln!(
459 out,
460 "{}sbom-tools view{} --- {}\n",
461 self.col(colors::CYAN),
462 self.col(colors::RESET),
463 sbom.document.format
464 )?;
465
466 writeln!(
467 out,
468 "{}{}{}\n",
469 self.col(colors::BOLD),
470 name,
471 self.col(colors::RESET),
472 )?;
473
474 out.push_str(&self.format_section_header("Components"));
475
476 for (i, (_id, comp)) in sbom.components.iter().enumerate() {
477 let vuln_count = comp.vulnerabilities.len();
478 let vuln_text = if vuln_count > 0 {
479 format!(
480 " {}[{} vulns]{}",
481 self.col(colors::RED),
482 vuln_count,
483 self.col(colors::RESET)
484 )
485 } else {
486 String::new()
487 };
488
489 writeln!(
490 out,
491 "{}{:>3}{} {} {}{}{}{}",
492 self.col(colors::LINE_NUM),
493 i + 1,
494 self.col(colors::RESET),
495 comp.name,
496 self.col(colors::DIM),
497 comp.version.as_deref().unwrap_or(""),
498 self.col(colors::RESET),
499 vuln_text,
500 )?;
501 }
502
503 let vulns = sbom.all_vulnerabilities();
505 if !vulns.is_empty() {
506 out.push_str(&self.format_section_header("Vulnerabilities"));
507
508 for (comp, vuln) in vulns {
509 let severity = vuln
510 .severity
511 .as_ref()
512 .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string);
513 out.push_str(&self.format_vulnerability_row(&vuln.id, &severity, &comp.name, true));
514 }
515 }
516
517 Ok(out)
518 }
519
520 fn format(&self) -> ReportFormat {
521 ReportFormat::SideBySide
522 }
523}
524
525const fn terminal_width() -> Option<usize> {
527 None
530}
531
532fn truncate(s: &str, max_width: usize) -> String {
534 if s.len() <= max_width {
535 s.to_string()
536 } else if max_width > 3 {
537 format!("{}...", &s[..max_width - 3])
538 } else {
539 s[..max_width].to_string()
540 }
541}
542
543fn strip_ansi(s: &str) -> String {
545 let mut result = String::new();
546 let mut in_escape = false;
547
548 for c in s.chars() {
549 if c == '\x1b' {
550 in_escape = true;
551 } else if in_escape {
552 if c == 'm' {
553 in_escape = false;
554 }
555 } else {
556 result.push(c);
557 }
558 }
559
560 result
561}
562
563fn short_id(id: &str) -> String {
565 if id.starts_with("pkg:") {
566 if let Some(rest) = id.strip_prefix("pkg:")
568 && let Some(slash_pos) = rest.find('/')
569 {
570 let name_ver = &rest[slash_pos + 1..];
571 return name_ver.to_string();
572 }
573 }
574 id.to_string()
575}