Skip to main content

sbom_tools/reports/
sidebyside.rs

1//! Side-by-side diff output similar to difftastic.
2
3use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator};
4use crate::diff::{ChangeType, DiffResult};
5use crate::model::NormalizedSbom;
6use std::fmt::Write;
7
8/// ANSI color codes
9mod 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"; // Gray for line numbers
20}
21
22/// Side-by-side diff reporter
23#[allow(dead_code)]
24pub struct SideBySideReporter {
25    /// Terminal width (auto-detect or default)
26    width: usize,
27    /// Show line numbers
28    show_line_numbers: bool,
29    /// Use colors
30    use_colors: bool,
31}
32
33impl SideBySideReporter {
34    /// Create a new side-by-side reporter
35    pub fn new() -> Self {
36        // Try to detect terminal width, default to 120
37        let width = terminal_width().unwrap_or(120);
38        Self {
39            width,
40            show_line_numbers: true,
41            use_colors: true,
42        }
43    }
44
45    /// Set terminal width
46    pub fn width(mut self, width: usize) -> Self {
47        self.width = width;
48        self
49    }
50
51    /// Disable colors
52    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; // Account for " │ " and line numbers
97        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        // Calculate visible width (excluding ANSI codes)
197        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        // Header with file names
267        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        // Separator line
281        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        // Components section
292        out.push_str(&self.format_section_header("Components"));
293
294        let mut line_num = 1;
295
296        // Show removed components
297        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        // Show modified components
313        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        // Show added components
334        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        // Dependencies section (if any changes)
350        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        // Vulnerabilities section
378        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        // Summary
403        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        // Vulnerability details
505        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
527/// Try to get terminal width
528fn terminal_width() -> Option<usize> {
529    // Try using terminal_size or just return None
530    // For simplicity, we'll return None and use default
531    None
532}
533
534/// Truncate string to fit width
535fn 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
545/// Strip ANSI escape codes for width calculation
546fn 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
565/// Get short component ID (just name@version from PURL)
566fn short_id(id: &str) -> String {
567    if id.starts_with("pkg:") {
568        // Extract name@version from PURL
569        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}