use crate::diff::DiffResult;
use crate::model::NormalizedSbom;
use crate::reports::{ReportConfig, ReportFormat, ReportType, create_reporter};
use crate::tui::ViewTab;
use crate::tui::app::TabKind;
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExportFormat {
Json,
Markdown,
Html,
Sarif,
Csv,
}
impl ExportFormat {
pub(crate) const fn extension(self) -> &'static str {
match self {
Self::Json => "json",
Self::Markdown => "md",
Self::Html => "html",
Self::Sarif => "sarif.json",
Self::Csv => "csv",
}
}
const fn to_report_format(self) -> ReportFormat {
match self {
Self::Json => ReportFormat::Json,
Self::Markdown => ReportFormat::Markdown,
Self::Html => ReportFormat::Html,
Self::Sarif => ReportFormat::Sarif,
Self::Csv => ReportFormat::Csv,
}
}
}
#[derive(Debug)]
pub struct ExportResult {
pub path: PathBuf,
pub success: bool,
pub message: String,
}
#[must_use]
pub const fn tab_to_report_type(tab: TabKind) -> ReportType {
match tab {
TabKind::Components => ReportType::Components,
TabKind::Dependencies => ReportType::Dependencies,
TabKind::Licenses => ReportType::Licenses,
TabKind::Vulnerabilities => ReportType::Vulnerabilities,
TabKind::Summary
| TabKind::Overview
| TabKind::Tree
| TabKind::Quality
| TabKind::Compliance
| TabKind::SideBySide
| TabKind::GraphChanges
| TabKind::Source => ReportType::All,
}
}
#[must_use]
pub const fn view_tab_to_report_type(tab: ViewTab) -> ReportType {
match tab {
ViewTab::Tree => ReportType::Components,
ViewTab::Vulnerabilities => ReportType::Vulnerabilities,
ViewTab::Licenses => ReportType::Licenses,
ViewTab::Dependencies => ReportType::Dependencies,
ViewTab::Overview
| ViewTab::Quality
| ViewTab::Compliance
| ViewTab::Source
| ViewTab::Crypto
| ViewTab::Algorithms
| ViewTab::Certificates
| ViewTab::Keys
| ViewTab::Protocols
| ViewTab::PqcCompliance => ReportType::All,
}
}
#[must_use]
pub const fn tab_export_scope(tab: TabKind) -> &'static str {
match tab {
TabKind::Components => "Components",
TabKind::Dependencies => "Dependencies",
TabKind::Licenses => "Licenses",
TabKind::Vulnerabilities => "Vulnerabilities",
_ => "Report",
}
}
#[must_use]
pub const fn view_tab_export_scope(tab: ViewTab) -> &'static str {
match tab {
ViewTab::Tree => "Components",
ViewTab::Vulnerabilities => "Vulnerabilities",
ViewTab::Licenses => "Licenses",
ViewTab::Dependencies => "Dependencies",
_ => "Report",
}
}
fn expand_template(template: &str, command: &str, format: &ExportFormat) -> String {
let now = chrono::Local::now();
template
.replace("{date}", &now.format("%Y-%m-%d").to_string())
.replace("{time}", &now.format("%H%M%S").to_string())
.replace("{format}", format.extension())
.replace("{command}", command)
}
fn build_export_filename(
template: Option<&str>,
command: &str,
format: &ExportFormat,
output_dir: Option<&str>,
) -> PathBuf {
let filename = if let Some(tmpl) = template {
let expanded = expand_template(tmpl, command, format);
if std::path::Path::new(&expanded).extension().is_some() {
expanded
} else {
format!("{expanded}.{}", format.extension())
}
} else {
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
format!("sbom_{command}_{timestamp}.{}", format.extension())
};
output_dir.map_or_else(
|| PathBuf::from(&filename),
|dir| PathBuf::from(dir).join(&filename),
)
}
pub fn export_diff(
format: ExportFormat,
result: &DiffResult,
old_sbom: &NormalizedSbom,
new_sbom: &NormalizedSbom,
output_dir: Option<&str>,
config: &ReportConfig,
template: Option<&str>,
) -> ExportResult {
let path = build_export_filename(template, "diff", &format, output_dir);
export_with_reporter(
format.to_report_format(),
result,
old_sbom,
new_sbom,
&path,
config,
)
}
pub fn export_view(
format: ExportFormat,
sbom: &NormalizedSbom,
output_dir: Option<&str>,
config: &ReportConfig,
template: Option<&str>,
) -> ExportResult {
let path = build_export_filename(template, "view", &format, output_dir);
export_view_with_reporter(format.to_report_format(), sbom, &path, config)
}
fn export_with_reporter(
report_format: ReportFormat,
result: &DiffResult,
old_sbom: &NormalizedSbom,
new_sbom: &NormalizedSbom,
path: &Path,
config: &ReportConfig,
) -> ExportResult {
let reporter = create_reporter(report_format);
match reporter.generate_diff_report(result, old_sbom, new_sbom, config) {
Ok(content) => match write_to_file(path, &content) {
Ok(actual_path) => ExportResult {
message: format!("Exported to {}", display_path(&actual_path)),
path: actual_path,
success: true,
},
Err(e) => ExportResult {
path: path.to_path_buf(),
success: false,
message: format!("Failed to write file: {e}"),
},
},
Err(e) => ExportResult {
path: path.to_path_buf(),
success: false,
message: format!("Failed to generate report: {e}"),
},
}
}
fn export_view_with_reporter(
report_format: ReportFormat,
sbom: &NormalizedSbom,
path: &Path,
config: &ReportConfig,
) -> ExportResult {
let reporter = create_reporter(report_format);
match reporter.generate_view_report(sbom, config) {
Ok(content) => match write_to_file(path, &content) {
Ok(actual_path) => ExportResult {
message: format!("Exported to {}", display_path(&actual_path)),
path: actual_path,
success: true,
},
Err(e) => ExportResult {
path: path.to_path_buf(),
success: false,
message: format!("Failed to write file: {e}"),
},
},
Err(e) => ExportResult {
path: path.to_path_buf(),
success: false,
message: format!("Failed to generate report: {e}"),
},
}
}
pub fn export_compliance(
format: ExportFormat,
results: &[crate::quality::ComplianceResult],
selected_standard: usize,
output_dir: Option<&str>,
template: Option<&str>,
) -> ExportResult {
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
let result = results.get(selected_standard);
let (ext, content) = match format {
ExportFormat::Json => {
let json = compliance_to_json(results, selected_standard);
("json", json)
}
ExportFormat::Sarif => {
let sarif = compliance_to_sarif(result);
("sarif.json", sarif)
}
ExportFormat::Markdown => {
let md = compliance_to_markdown(results, selected_standard);
("md", md)
}
ExportFormat::Html => {
let html = compliance_to_html(results, selected_standard);
("html", html)
}
ExportFormat::Csv => {
let csv = compliance_to_csv(results, selected_standard);
("csv", csv)
}
};
let path = if let Some(tmpl) = template {
let expanded = expand_template(tmpl, "compliance", &format);
let expanded = if std::path::Path::new(&expanded).extension().is_some() {
expanded
} else {
format!("{expanded}.{ext}")
};
output_dir.map_or_else(
|| PathBuf::from(&expanded),
|dir| PathBuf::from(dir).join(&expanded),
)
} else {
let level_name = result.map_or_else(
|| "all".to_string(),
|r| r.level.name().to_lowercase().replace(' ', "_"),
);
let filename = format!("compliance_{level_name}_{timestamp}.{ext}");
output_dir.map_or_else(
|| PathBuf::from(&filename),
|dir| PathBuf::from(dir).join(&filename),
)
};
match write_to_file(&path, &content) {
Ok(actual_path) => ExportResult {
message: format!("Compliance exported to {}", display_path(&actual_path)),
path: actual_path,
success: true,
},
Err(e) => ExportResult {
path,
success: false,
message: format!("Failed to write: {e}"),
},
}
}
fn compliance_to_json(results: &[crate::quality::ComplianceResult], selected: usize) -> String {
use serde_json::{Value, json};
let to_value = |r: &crate::quality::ComplianceResult| -> Value {
let violations: Vec<Value> = r
.violations
.iter()
.map(|v| {
json!({
"severity": format!("{:?}", v.severity),
"category": v.category.name(),
"message": v.message,
"element": v.element,
"requirement": v.requirement,
"remediation": v.remediation_guidance(),
})
})
.collect();
json!({
"standard": r.level.name(),
"is_compliant": r.is_compliant,
"error_count": r.error_count,
"warning_count": r.warning_count,
"info_count": r.info_count,
"violations": violations,
})
};
let output = results.get(selected).map_or_else(
|| {
let all: Vec<Value> = results.iter().map(to_value).collect();
json!({ "standards": all })
},
to_value,
);
serde_json::to_string_pretty(&output).unwrap_or_default()
}
fn compliance_to_sarif(result: Option<&crate::quality::ComplianceResult>) -> String {
let Some(result) = result else {
return r#"{"error": "no compliance result selected"}"#.to_string();
};
crate::reports::generate_compliance_sarif(result)
.unwrap_or_else(|e| format!(r#"{{"error": "{e}"}}"#))
}
fn compliance_to_markdown(results: &[crate::quality::ComplianceResult], selected: usize) -> String {
let mut md = String::new();
let items: Vec<&crate::quality::ComplianceResult> = results
.get(selected)
.map_or_else(|| results.iter().collect(), |r| vec![r]);
for r in items {
let status = if r.is_compliant {
"COMPLIANT"
} else {
"NON-COMPLIANT"
};
md.push_str(&format!("# {} - {status}\n\n", r.level.name()));
md.push_str(&format!(
"Errors: {} | Warnings: {} | Info: {}\n\n",
r.error_count, r.warning_count, r.info_count
));
if r.violations.is_empty() {
md.push_str("No violations found.\n\n");
continue;
}
md.push_str("| Severity | Category | Requirement | Element | Remediation |\n");
md.push_str("|----------|----------|-------------|---------|-------------|\n");
for v in &r.violations {
let element = v.element.as_deref().unwrap_or("-");
md.push_str(&format!(
"| {:?} | {} | {} | {} | {} |\n",
v.severity,
v.category.name(),
v.requirement,
element,
v.remediation_guidance(),
));
}
md.push('\n');
}
md
}
fn compliance_to_csv(results: &[crate::quality::ComplianceResult], selected: usize) -> String {
let mut lines =
vec!["Standard,Severity,Category,Requirement,Element,Message,Remediation".to_string()];
let items: Vec<&crate::quality::ComplianceResult> = results
.get(selected)
.map_or_else(|| results.iter().collect(), |r| vec![r]);
for r in &items {
let std_name = r.level.name();
for v in &r.violations {
let element = v.element.as_deref().unwrap_or("");
lines.push(format!(
"\"{}\",\"{:?}\",\"{}\",\"{}\",\"{}\",\"{}\",\"{}\"",
std_name,
v.severity,
csv_escape(v.category.name()),
csv_escape(&v.requirement),
csv_escape(element),
csv_escape(&v.message),
csv_escape(v.remediation_guidance()),
));
}
}
lines.join("\n")
}
fn compliance_to_html(results: &[crate::quality::ComplianceResult], selected: usize) -> String {
use crate::reports::escape::escape_html;
let items: Vec<&crate::quality::ComplianceResult> = results
.get(selected)
.map_or_else(|| results.iter().collect(), |r| vec![r]);
let mut html = String::from(
r#"<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>SBOM Compliance Report</title>
<style>
body { font-family: system-ui, sans-serif; background: #1e1e2e; color: #cdd6f4; margin: 2rem; }
h1, h2 { color: #89b4fa; }
table { border-collapse: collapse; margin: 1rem 0; width: 100%; }
th, td { padding: 8px 12px; border: 1px solid #45475a; text-align: left; }
th { background: #313244; color: #89b4fa; font-weight: 600; }
.error { color: #f38ba8; }
.warning { color: #f9e2af; }
.info { color: #89b4fa; }
.pass { color: #a6e3a1; font-weight: bold; }
.fail { color: #f38ba8; font-weight: bold; }
.summary { margin: 1rem 0; color: #a6adc8; }
</style></head><body>
<h1>SBOM Compliance Report</h1>
<p class="summary">Generated by sbom-tools</p>
"#,
);
for r in &items {
let status_class = if r.is_compliant { "pass" } else { "fail" };
let status_label = if r.is_compliant {
"COMPLIANT"
} else {
"NON-COMPLIANT"
};
html.push_str(&format!(
"<h2>{} - <span class=\"{status_class}\">{status_label}</span></h2>\n",
escape_html(r.level.name()),
));
html.push_str(&format!(
"<p>Errors: {} | Warnings: {} | Info: {}</p>\n",
r.error_count, r.warning_count, r.info_count
));
if r.violations.is_empty() {
html.push_str("<p>No violations found.</p>\n");
continue;
}
html.push_str("<table><tr><th>Severity</th><th>Category</th><th>Requirement</th><th>Element</th><th>Message</th><th>Remediation</th></tr>\n");
for v in &r.violations {
let sev_class = match v.severity {
crate::quality::ViolationSeverity::Error => "error",
crate::quality::ViolationSeverity::Warning => "warning",
crate::quality::ViolationSeverity::Info => "info",
};
let element = v.element.as_deref().unwrap_or("-");
html.push_str(&format!(
"<tr><td class=\"{sev_class}\">{:?}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>\n",
v.severity,
escape_html(v.category.name()),
escape_html(&v.requirement),
escape_html(element),
escape_html(&v.message),
escape_html(v.remediation_guidance()),
));
}
html.push_str("</table>\n");
}
html.push_str("</body></html>");
html
}
pub fn export_source_content(content: &str, label: &str) -> ExportResult {
let ext = if content.trim_start().starts_with('<') {
"xml"
} else {
"json"
};
let filename = format!("sbom-source-{label}.{ext}");
let path = PathBuf::from(&filename);
match write_to_file(&path, content) {
Ok(actual_path) => ExportResult {
message: format!("Source exported to {}", display_path(&actual_path)),
path: actual_path,
success: true,
},
Err(e) => ExportResult {
path,
success: false,
message: format!("Failed to write: {e}"),
},
}
}
fn csv_escape(s: &str) -> String {
s.replace('"', "\"\"")
}
pub fn export_matrix(
format: ExportFormat,
result: &crate::diff::MatrixResult,
template: Option<&str>,
) -> ExportResult {
let path = build_export_filename(template, "matrix", &format, None);
let content = match format {
ExportFormat::Json => matrix_to_json(result),
ExportFormat::Csv => matrix_to_csv(result),
ExportFormat::Html => matrix_to_html(result),
_ => {
return ExportResult {
path,
success: false,
message: "Matrix export supports JSON, CSV, and HTML".to_string(),
};
}
};
match write_to_file(&path, &content) {
Ok(actual_path) => ExportResult {
message: format!("Matrix exported to {}", display_path(&actual_path)),
path: actual_path,
success: true,
},
Err(e) => ExportResult {
path,
success: false,
message: format!("Failed to write: {e}"),
},
}
}
fn matrix_to_json(result: &crate::diff::MatrixResult) -> String {
serde_json::to_string_pretty(result).unwrap_or_default()
}
fn matrix_to_csv(result: &crate::diff::MatrixResult) -> String {
let n = result.sboms.len();
let mut lines = Vec::with_capacity(n + 1);
let mut header = String::from("\"\"");
for sbom in &result.sboms {
header.push_str(&format!(",\"{}\"", sbom.name.replace('"', "\"\"")));
}
lines.push(header);
for i in 0..n {
let mut row = format!("\"{}\"", result.sboms[i].name.replace('"', "\"\""));
for j in 0..n {
if i == j {
row.push_str(",1.000");
} else {
let score = result.get_similarity(i, j);
row.push_str(&format!(",{score:.3}"));
}
}
lines.push(row);
}
lines.join("\n")
}
fn matrix_to_html(result: &crate::diff::MatrixResult) -> String {
use crate::reports::escape::escape_html;
let n = result.sboms.len();
let mut html = String::from(
r#"<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>SBOM Similarity Matrix</title>
<style>
body { font-family: system-ui, sans-serif; background: #1e1e2e; color: #cdd6f4; margin: 2rem; }
h1 { color: #89b4fa; }
table { border-collapse: collapse; margin: 1rem 0; }
th, td { padding: 8px 12px; border: 1px solid #45475a; text-align: center; }
th { background: #313244; color: #89b4fa; font-weight: 600; }
.high { background: #a6e3a1; color: #1e1e2e; }
.medium { background: #f9e2af; color: #1e1e2e; }
.low { background: #f38ba8; color: #1e1e2e; }
.self { background: #585b70; color: #a6adc8; }
.info { margin: 1rem 0; color: #a6adc8; }
</style></head><body>
<h1>SBOM Similarity Matrix</h1>
<p class="info">Generated by sbom-tools</p>
<table><tr><th></th>"#,
);
for sbom in &result.sboms {
html.push_str(&format!("<th>{}</th>", escape_html(&sbom.name)));
}
html.push_str("</tr>");
for i in 0..n {
html.push_str(&format!(
"<tr><th>{}</th>",
escape_html(&result.sboms[i].name)
));
for j in 0..n {
if i == j {
html.push_str("<td class=\"self\">-</td>");
} else {
let score = result.get_similarity(i, j);
let class = if score >= 0.8 {
"high"
} else if score >= 0.5 {
"medium"
} else {
"low"
};
html.push_str(&format!("<td class=\"{class}\">{score:.1}%</td>"));
}
}
html.push_str("</tr>");
}
html.push_str("</table>");
if let Some(ref clustering) = result.clustering {
html.push_str("<h2>Clusters</h2><ul>");
for (idx, cluster) in clustering.clusters.iter().enumerate() {
let names: Vec<&str> = cluster
.members
.iter()
.filter_map(|&i| result.sboms.get(i).map(|s| s.name.as_str()))
.collect();
html.push_str(&format!(
"<li>Cluster {} (avg similarity: {:.1}%): {}</li>",
idx + 1,
cluster.internal_similarity * 100.0,
names.join(", ")
));
}
html.push_str("</ul>");
}
html.push_str("</body></html>");
html
}
fn write_to_file(path: &Path, content: &str) -> std::io::Result<PathBuf> {
let actual_path = find_available_path(path);
let mut file = File::create(&actual_path)?;
file.write_all(content.as_bytes())?;
Ok(actual_path)
}
fn find_available_path(path: &Path) -> PathBuf {
if !path.exists() {
return path.to_path_buf();
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("export");
let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let parent = path.parent();
let (base_stem, full_ext) = if stem.ends_with(".sarif") && extension == "json" {
(stem.strip_suffix(".sarif").unwrap_or(stem), "sarif.json")
} else {
(stem, extension)
};
for i in 2..=99 {
let new_name = if full_ext.is_empty() {
format!("{base_stem}_{i}")
} else {
format!("{base_stem}_{i}.{full_ext}")
};
let new_path = parent.map_or_else(|| PathBuf::from(&new_name), |p| p.join(&new_name));
if !new_path.exists() {
return new_path;
}
}
path.to_path_buf()
}
fn display_path(path: &PathBuf) -> String {
if path.is_absolute() {
path.display().to_string()
} else {
std::env::current_dir()
.map(|cwd| cwd.join(path))
.unwrap_or_else(|_| path.to_path_buf())
.display()
.to_string()
}
}