Skip to main content

sbom_tools/reports/
csv.rs

1//! CSV report generator.
2//!
3//! Generates comma-separated reports for diff and view modes,
4//! suitable for spreadsheet import and data analysis pipelines.
5
6use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator};
7use crate::diff::{DiffResult, SlaStatus, VulnerabilityDetail};
8use crate::model::NormalizedSbom;
9use std::fmt::Write;
10
11/// CSV report generator.
12pub struct CsvReporter;
13
14impl CsvReporter {
15    pub fn new() -> Self {
16        Self
17    }
18}
19
20impl Default for CsvReporter {
21    fn default() -> Self {
22        Self::new()
23    }
24}
25
26impl ReportGenerator for CsvReporter {
27    fn generate_diff_report(
28        &self,
29        result: &DiffResult,
30        _old_sbom: &NormalizedSbom,
31        _new_sbom: &NormalizedSbom,
32        _config: &ReportConfig,
33    ) -> Result<String, ReportError> {
34        // Pre-allocate based on estimated output size
35        let estimated_lines = result.components.total()
36            + result.vulnerabilities.introduced.len()
37            + result.vulnerabilities.resolved.len()
38            + result.vulnerabilities.persistent.len()
39            + 10; // headers
40        let mut content = String::with_capacity(estimated_lines * 100);
41
42        // Components CSV
43        content.push_str("# Components\n");
44        content.push_str("Change,Name,Old Version,New Version,Ecosystem\n");
45
46        for comp in &result.components.added {
47            write_component_line(&mut content, "Added", comp);
48        }
49
50        for comp in &result.components.removed {
51            write_component_line(&mut content, "Removed", comp);
52        }
53
54        for comp in &result.components.modified {
55            write_component_line(&mut content, "Modified", comp);
56        }
57
58        // Vulnerabilities CSV
59        content.push_str("\n# Vulnerabilities\n");
60        content.push_str("Status,ID,Severity,Type,SLA,Component,Description\n");
61
62        for vuln in &result.vulnerabilities.introduced {
63            write_vuln_line(&mut content, "Introduced", vuln);
64        }
65
66        for vuln in &result.vulnerabilities.resolved {
67            write_vuln_line(&mut content, "Resolved", vuln);
68        }
69
70        for vuln in &result.vulnerabilities.persistent {
71            write_vuln_line(&mut content, "Persistent", vuln);
72        }
73
74        Ok(content)
75    }
76
77    fn generate_view_report(
78        &self,
79        sbom: &NormalizedSbom,
80        _config: &ReportConfig,
81    ) -> Result<String, ReportError> {
82        // Pre-allocate based on component count
83        let mut content = String::with_capacity(sbom.components.len() * 150 + 100);
84
85        content.push_str("Name,Version,Ecosystem,Type,PURL,Licenses,Vulnerabilities\n");
86
87        for (_, comp) in &sbom.components {
88            let licenses = comp
89                .licenses
90                .declared
91                .iter()
92                .map(|l| l.expression.as_str())
93                .collect::<Vec<_>>()
94                .join("; ");
95            let vuln_count = comp.vulnerabilities.len();
96            let ecosystem = comp
97                .ecosystem
98                .as_ref()
99                .map(|e| format!("{:?}", e))
100                .unwrap_or_else(|| "-".to_string());
101
102            let _ = writeln!(
103                content,
104                "\"{}\",\"{}\",\"{}\",\"{:?}\",\"{}\",\"{}\",{}",
105                escape_csv(&comp.name),
106                comp.version.as_deref().unwrap_or("-"),
107                ecosystem,
108                comp.component_type,
109                comp.identifiers.purl.as_deref().unwrap_or("-"),
110                escape_csv(&licenses),
111                vuln_count
112            );
113        }
114
115        Ok(content)
116    }
117
118    fn format(&self) -> ReportFormat {
119        ReportFormat::Csv
120    }
121}
122
123/// Write a component line using write! macro to avoid format! allocation.
124fn write_component_line(
125    content: &mut String,
126    change_type: &str,
127    comp: &crate::diff::ComponentChange,
128) {
129    let _ = writeln!(
130        content,
131        "{},\"{}\",\"{}\",\"{}\",\"{}\"",
132        change_type,
133        escape_csv(&comp.name),
134        comp.old_version.as_deref().unwrap_or("-"),
135        comp.new_version.as_deref().unwrap_or("-"),
136        comp.ecosystem.as_deref().unwrap_or("-")
137    );
138}
139
140fn write_vuln_line(content: &mut String, status: &str, vuln: &VulnerabilityDetail) {
141    let depth_label = match vuln.component_depth {
142        Some(1) => "Direct",
143        Some(_) => "Transitive",
144        None => "-",
145    };
146    let sla_display = format_sla_csv(vuln);
147    let desc = vuln
148        .description
149        .as_deref()
150        .map(escape_csv)
151        .unwrap_or_default();
152
153    let _ = writeln!(
154        content,
155        "{},\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",\"{}\"",
156        status,
157        escape_csv(&vuln.id),
158        escape_csv(&vuln.severity),
159        depth_label,
160        sla_display,
161        escape_csv(&vuln.component_name),
162        desc
163    );
164}
165
166/// Escape a string for CSV embedding: double-quote escaping per RFC 4180,
167/// plus newline flattening since fields are already wrapped in double quotes.
168fn escape_csv(s: &str) -> String {
169    s.replace('"', "\"\"").replace('\n', " ")
170}
171
172fn format_sla_csv(vuln: &VulnerabilityDetail) -> String {
173    match vuln.sla_status() {
174        SlaStatus::Overdue(days) => format!("{}d late", days),
175        SlaStatus::DueSoon(days) => format!("{}d left", days),
176        SlaStatus::OnTrack(days) => format!("{}d left", days),
177        SlaStatus::NoDueDate => vuln
178            .days_since_published
179            .map(|d| format!("{}d old", d))
180            .unwrap_or_else(|| "-".to_string()),
181    }
182}