1use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator};
7use crate::diff::{DiffResult, SlaStatus, VulnerabilityDetail};
8use crate::model::NormalizedSbom;
9use std::fmt::Write;
10
11pub struct CsvReporter;
13
14impl CsvReporter {
15 #[must_use]
16 pub const fn new() -> Self {
17 Self
18 }
19}
20
21impl Default for CsvReporter {
22 fn default() -> Self {
23 Self::new()
24 }
25}
26
27impl ReportGenerator for CsvReporter {
28 fn generate_diff_report(
29 &self,
30 result: &DiffResult,
31 _old_sbom: &NormalizedSbom,
32 _new_sbom: &NormalizedSbom,
33 _config: &ReportConfig,
34 ) -> Result<String, ReportError> {
35 let estimated_lines = result.components.total()
37 + result.vulnerabilities.introduced.len()
38 + result.vulnerabilities.resolved.len()
39 + result.vulnerabilities.persistent.len()
40 + 10; let mut content = String::with_capacity(estimated_lines * 100);
42
43 content.push_str("# Components\n");
45 content.push_str("Change,Name,Old Version,New Version,Ecosystem\n");
46
47 for comp in &result.components.added {
48 write_component_line(&mut content, "Added", comp);
49 }
50
51 for comp in &result.components.removed {
52 write_component_line(&mut content, "Removed", comp);
53 }
54
55 for comp in &result.components.modified {
56 write_component_line(&mut content, "Modified", comp);
57 }
58
59 content.push_str("\n# Vulnerabilities\n");
61 content.push_str("Status,ID,Severity,Type,SLA,Component,Description,VEX\n");
62
63 for vuln in &result.vulnerabilities.introduced {
64 write_vuln_line(&mut content, "Introduced", vuln);
65 }
66
67 for vuln in &result.vulnerabilities.resolved {
68 write_vuln_line(&mut content, "Resolved", vuln);
69 }
70
71 for vuln in &result.vulnerabilities.persistent {
72 write_vuln_line(&mut content, "Persistent", vuln);
73 }
74
75 Ok(content)
76 }
77
78 fn generate_view_report(
79 &self,
80 sbom: &NormalizedSbom,
81 _config: &ReportConfig,
82 ) -> Result<String, ReportError> {
83 let mut content = String::with_capacity(sbom.components.len() * 150 + 100);
85
86 content.push_str(
87 "Name,Version,Ecosystem,Type,PURL,Licenses,Vulnerabilities,EOL Status,EOL Date\n",
88 );
89
90 for (_, comp) in &sbom.components {
91 let licenses = comp
92 .licenses
93 .declared
94 .iter()
95 .map(|l| l.expression.as_str())
96 .collect::<Vec<_>>()
97 .join("; ");
98 let vuln_count = comp.vulnerabilities.len();
99 let ecosystem = comp.ecosystem.as_ref().map(|e| format!("{e:?}"));
100 let ecosystem = ecosystem.as_deref().unwrap_or("-");
101
102 let eol_status = comp.eol.as_ref().map_or("-", |e| e.status.label());
103 let eol_date = comp
104 .eol
105 .as_ref()
106 .and_then(|e| e.eol_date.map(|d| d.to_string()));
107 let eol_date = eol_date.as_deref().unwrap_or("-");
108
109 let _ = writeln!(
110 content,
111 "\"{}\",\"{}\",\"{}\",\"{:?}\",\"{}\",\"{}\",{},\"{}\",\"{}\"",
112 escape_csv(&comp.name),
113 comp.version.as_deref().unwrap_or("-"),
114 ecosystem,
115 comp.component_type,
116 comp.identifiers.purl.as_deref().unwrap_or("-"),
117 escape_csv(&licenses),
118 vuln_count,
119 eol_status,
120 eol_date,
121 );
122 }
123
124 Ok(content)
125 }
126
127 fn format(&self) -> ReportFormat {
128 ReportFormat::Csv
129 }
130}
131
132fn write_component_line(
134 content: &mut String,
135 change_type: &str,
136 comp: &crate::diff::ComponentChange,
137) {
138 let _ = writeln!(
139 content,
140 "{},\"{}\",\"{}\",\"{}\",\"{}\"",
141 change_type,
142 escape_csv(&comp.name),
143 comp.old_version.as_deref().unwrap_or("-"),
144 comp.new_version.as_deref().unwrap_or("-"),
145 comp.ecosystem.as_deref().unwrap_or("-")
146 );
147}
148
149fn write_vuln_line(content: &mut String, status: &str, vuln: &VulnerabilityDetail) {
150 let depth_label = match vuln.component_depth {
151 Some(1) => "Direct",
152 Some(_) => "Transitive",
153 None => "-",
154 };
155 let sla_display = format_sla_csv(vuln);
156 let desc = vuln
157 .description
158 .as_deref()
159 .map(escape_csv)
160 .unwrap_or_default();
161 let vex_display = match vuln.vex_state.as_ref() {
162 Some(crate::model::VexState::NotAffected) => "Not Affected",
163 Some(crate::model::VexState::Fixed) => "Fixed",
164 Some(crate::model::VexState::Affected) => "Affected",
165 Some(crate::model::VexState::UnderInvestigation) => "Under Investigation",
166 None => "",
167 };
168
169 let _ = writeln!(
170 content,
171 "{},\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",\"{}\"",
172 status,
173 escape_csv(&vuln.id),
174 escape_csv(&vuln.severity),
175 depth_label,
176 sla_display,
177 escape_csv(&vuln.component_name),
178 desc,
179 vex_display,
180 );
181}
182
183fn escape_csv(s: &str) -> String {
186 s.replace('"', "\"\"").replace('\n', " ")
187}
188
189fn format_sla_csv(vuln: &VulnerabilityDetail) -> String {
190 match vuln.sla_status() {
191 SlaStatus::Overdue(days) => format!("{days}d late"),
192 SlaStatus::DueSoon(days) | SlaStatus::OnTrack(days) => format!("{days}d left"),
193 SlaStatus::NoDueDate => vuln
194 .days_since_published
195 .map_or_else(|| "-".to_string(), |d| format!("{d}d old")),
196 }
197}