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