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    #[must_use]
36    pub fn new() -> Self {
37        // Try to detect terminal width, default to 120
38        let width = terminal_width().unwrap_or(120);
39        Self {
40            width,
41            show_line_numbers: true,
42            use_colors: true,
43        }
44    }
45
46    /// Set terminal width
47    #[must_use]
48    pub const fn width(mut self, width: usize) -> Self {
49        self.width = width;
50        self
51    }
52
53    /// Disable colors
54    #[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; // Account for " │ " and line numbers
96        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        // Calculate visible width (excluding ANSI codes)
196        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        // Header with file names
266        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        // Separator line
280        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        // Components section
291        out.push_str(&self.format_section_header("Components"));
292
293        let mut line_num = 1;
294
295        // Show removed components
296        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        // Show modified components
312        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        // Show added components
333        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        // Dependencies section (if any changes)
349        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        // Vulnerabilities section
377        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        // Summary
402        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        // Vulnerability details
504        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
525/// Try to get terminal width
526const fn terminal_width() -> Option<usize> {
527    // Try using terminal_size or just return None
528    // For simplicity, we'll return None and use default
529    None
530}
531
532/// Truncate string to fit width
533fn 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
543/// Strip ANSI escape codes for width calculation
544fn 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
563/// Get short component ID (just name@version from PURL)
564fn short_id(id: &str) -> String {
565    if id.starts_with("pkg:") {
566        // Extract name@version from PURL
567        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}