use super::{ReportConfig, ReportError, ReportFormat, ReportType, WriterReporter};
use crate::diff::DiffResult;
use crate::model::NormalizedSbom;
use chrono::Utc;
use serde::Serialize;
use std::io::Write;
pub struct StreamingJsonWriter<'w, W: Write> {
writer: &'w mut W,
pretty: bool,
indent_level: usize,
flush_interval: usize,
items_written: usize,
}
impl<'w, W: Write> StreamingJsonWriter<'w, W> {
pub const fn new(writer: &'w mut W, pretty: bool) -> Self {
Self {
writer,
pretty,
indent_level: 0,
flush_interval: 100, items_written: 0,
}
}
#[must_use]
pub fn with_flush_interval(mut self, interval: usize) -> Self {
self.flush_interval = interval.max(1);
self
}
fn write_object_start(&mut self) -> Result<(), ReportError> {
self.write_raw("{")?;
self.indent_level += 1;
Ok(())
}
fn write_object_end(&mut self) -> Result<(), ReportError> {
self.indent_level = self.indent_level.saturating_sub(1);
self.write_newline()?;
self.write_indent()?;
self.write_raw("}")?;
Ok(())
}
fn write_array_start(&mut self) -> Result<(), ReportError> {
self.write_raw("[")?;
self.indent_level += 1;
Ok(())
}
fn write_array_end(&mut self) -> Result<(), ReportError> {
self.indent_level = self.indent_level.saturating_sub(1);
self.write_newline()?;
self.write_indent()?;
self.write_raw("]")?;
Ok(())
}
fn write_key_value<V: Serialize>(
&mut self,
key: &str,
value: &V,
trailing_comma: bool,
) -> Result<(), ReportError> {
self.write_newline()?;
self.write_indent()?;
self.write_raw(&format!("\"{key}\":"))?;
if self.pretty {
self.write_raw(" ")?;
}
let json = if self.pretty {
serde_json::to_string_pretty(value)
} else {
serde_json::to_string(value)
}
.map_err(|e| ReportError::SerializationError(e.to_string()))?;
if self.pretty && json.contains('\n') {
let indented = self.indent_multiline(&json);
self.write_raw(&indented)?;
} else {
self.write_raw(&json)?;
}
if trailing_comma {
self.write_raw(",")?;
}
Ok(())
}
fn write_key_array_start(&mut self, key: &str) -> Result<(), ReportError> {
self.write_newline()?;
self.write_indent()?;
self.write_raw(&format!("\"{key}\":"))?;
if self.pretty {
self.write_raw(" ")?;
}
self.write_array_start()?;
Ok(())
}
fn write_array_item<V: Serialize>(
&mut self,
value: &V,
trailing_comma: bool,
) -> Result<(), ReportError> {
self.write_newline()?;
self.write_indent()?;
let json = if self.pretty {
serde_json::to_string_pretty(value)
} else {
serde_json::to_string(value)
}
.map_err(|e| ReportError::SerializationError(e.to_string()))?;
if self.pretty && json.contains('\n') {
let indented = self.indent_multiline(&json);
self.write_raw(&indented)?;
} else {
self.write_raw(&json)?;
}
if trailing_comma {
self.write_raw(",")?;
}
self.items_written += 1;
if self.items_written.is_multiple_of(self.flush_interval) {
self.writer.flush()?;
}
Ok(())
}
fn write_raw(&mut self, s: &str) -> Result<(), ReportError> {
self.writer.write_all(s.as_bytes())?;
Ok(())
}
fn write_newline(&mut self) -> Result<(), ReportError> {
if self.pretty {
self.write_raw("\n")?;
}
Ok(())
}
fn write_indent(&mut self) -> Result<(), ReportError> {
if self.pretty {
let indent = " ".repeat(self.indent_level);
self.write_raw(&indent)?;
}
Ok(())
}
fn indent_multiline(&self, json: &str) -> String {
let base_indent = " ".repeat(self.indent_level);
let lines: Vec<&str> = json.lines().collect();
if lines.len() <= 1 {
return json.to_string();
}
let mut result = String::new();
result.push_str(lines[0]);
for line in &lines[1..] {
result.push('\n');
result.push_str(&base_indent);
result.push_str(line);
}
result
}
pub fn write_diff_report(
mut self,
result: &DiffResult,
old_sbom: &NormalizedSbom,
new_sbom: &NormalizedSbom,
config: &ReportConfig,
) -> Result<(), ReportError> {
self.write_object_start()?;
let metadata = StreamingMetadata {
tool: ToolInfo {
name: "sbom-tools".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
generated_at: Utc::now().to_rfc3339(),
old_sbom: SbomInfo {
format: old_sbom.document.format.to_string(),
file_path: config.metadata.old_sbom_path.clone(),
component_count: old_sbom.component_count(),
},
new_sbom: Some(SbomInfo {
format: new_sbom.document.format.to_string(),
file_path: config.metadata.new_sbom_path.clone(),
component_count: new_sbom.component_count(),
}),
};
self.write_key_value("metadata", &metadata, true)?;
let summary = StreamingSummary {
total_changes: result.summary.total_changes,
components_added: result.summary.components_added,
components_removed: result.summary.components_removed,
components_modified: result.summary.components_modified,
vulnerabilities_introduced: result.summary.vulnerabilities_introduced,
vulnerabilities_resolved: result.summary.vulnerabilities_resolved,
semantic_score: result.semantic_score,
};
self.write_key_value("summary", &summary, true)?;
if config.includes(ReportType::Components) {
self.write_key_array_start("components_added")?;
let added_len = result.components.added.len();
for (i, comp) in result.components.added.iter().enumerate() {
self.write_array_item(comp, i + 1 < added_len)?;
}
self.write_array_end()?;
self.write_raw(",")?;
self.write_key_array_start("components_removed")?;
let removed_len = result.components.removed.len();
for (i, comp) in result.components.removed.iter().enumerate() {
self.write_array_item(comp, i + 1 < removed_len)?;
}
self.write_array_end()?;
self.write_raw(",")?;
self.write_key_array_start("components_modified")?;
let modified_len = result.components.modified.len();
for (i, comp) in result.components.modified.iter().enumerate() {
self.write_array_item(comp, i + 1 < modified_len)?;
}
self.write_array_end()?;
let has_more = config.includes(ReportType::Vulnerabilities)
|| config.includes(ReportType::Dependencies)
|| config.includes(ReportType::Licenses);
if has_more {
self.write_raw(",")?;
}
}
if config.includes(ReportType::Vulnerabilities) {
self.write_key_array_start("vulnerabilities_introduced")?;
let introduced_len = result.vulnerabilities.introduced.len();
for (i, vuln) in result.vulnerabilities.introduced.iter().enumerate() {
self.write_array_item(vuln, i + 1 < introduced_len)?;
}
self.write_array_end()?;
self.write_raw(",")?;
self.write_key_array_start("vulnerabilities_resolved")?;
let resolved_len = result.vulnerabilities.resolved.len();
for (i, vuln) in result.vulnerabilities.resolved.iter().enumerate() {
self.write_array_item(vuln, i + 1 < resolved_len)?;
}
self.write_array_end()?;
let has_more =
config.includes(ReportType::Dependencies) || config.includes(ReportType::Licenses);
if has_more {
self.write_raw(",")?;
}
}
if config.includes(ReportType::Dependencies) {
self.write_key_array_start("dependencies_added")?;
let added_len = result.dependencies.added.len();
for (i, dep) in result.dependencies.added.iter().enumerate() {
self.write_array_item(dep, i + 1 < added_len)?;
}
self.write_array_end()?;
self.write_raw(",")?;
self.write_key_array_start("dependencies_removed")?;
let removed_len = result.dependencies.removed.len();
for (i, dep) in result.dependencies.removed.iter().enumerate() {
self.write_array_item(dep, i + 1 < removed_len)?;
}
self.write_array_end()?;
if config.includes(ReportType::Licenses) {
self.write_raw(",")?;
}
}
if config.includes(ReportType::Licenses) {
self.write_key_array_start("licenses_new")?;
let new_len = result.licenses.new_licenses.len();
for (i, lic) in result.licenses.new_licenses.iter().enumerate() {
self.write_array_item(lic, i + 1 < new_len)?;
}
self.write_array_end()?;
self.write_raw(",")?;
self.write_key_array_start("licenses_removed")?;
let removed_len = result.licenses.removed_licenses.len();
for (i, lic) in result.licenses.removed_licenses.iter().enumerate() {
self.write_array_item(lic, i + 1 < removed_len)?;
}
self.write_array_end()?;
}
self.write_object_end()?;
self.write_newline()?;
self.writer.flush()?;
Ok(())
}
}
pub struct NdjsonWriter<'w, W: Write> {
writer: &'w mut W,
flush_interval: usize,
items_written: usize,
}
impl<'w, W: Write> NdjsonWriter<'w, W> {
pub const fn new(writer: &'w mut W) -> Self {
Self {
writer,
flush_interval: 100,
items_written: 0,
}
}
#[must_use]
pub fn with_flush_interval(mut self, interval: usize) -> Self {
self.flush_interval = interval.max(1);
self
}
pub fn write_item<T: Serialize>(&mut self, item: &T) -> Result<(), ReportError> {
let json = serde_json::to_string(item)
.map_err(|e| ReportError::SerializationError(e.to_string()))?;
self.writer.write_all(json.as_bytes())?;
self.writer.write_all(b"\n")?;
self.items_written += 1;
if self.items_written.is_multiple_of(self.flush_interval) {
self.writer.flush()?;
}
Ok(())
}
pub fn write_tagged<T: Serialize>(&mut self, tag: &str, item: &T) -> Result<(), ReportError> {
#[derive(Serialize)]
struct Tagged<'a, T> {
#[serde(rename = "type")]
type_: &'a str,
data: &'a T,
}
let tagged = Tagged {
type_: tag,
data: item,
};
self.write_item(&tagged)
}
pub fn write_diff_components(&mut self, result: &DiffResult) -> Result<(), ReportError> {
for comp in &result.components.added {
self.write_tagged("component_added", comp)?;
}
for comp in &result.components.removed {
self.write_tagged("component_removed", comp)?;
}
for comp in &result.components.modified {
self.write_tagged("component_modified", comp)?;
}
self.writer.flush()?;
Ok(())
}
pub fn write_diff_vulnerabilities(&mut self, result: &DiffResult) -> Result<(), ReportError> {
for vuln in &result.vulnerabilities.introduced {
self.write_tagged("vulnerability_introduced", vuln)?;
}
for vuln in &result.vulnerabilities.resolved {
self.write_tagged("vulnerability_resolved", vuln)?;
}
for vuln in &result.vulnerabilities.persistent {
self.write_tagged("vulnerability_persistent", vuln)?;
}
self.writer.flush()?;
Ok(())
}
pub fn write_diff_report(
&mut self,
result: &DiffResult,
old_sbom: &NormalizedSbom,
new_sbom: &NormalizedSbom,
config: &ReportConfig,
) -> Result<(), ReportError> {
let metadata = NdjsonMetadata {
type_: "metadata",
tool: "sbom-tools",
version: env!("CARGO_PKG_VERSION"),
generated_at: Utc::now().to_rfc3339(),
old_sbom_format: old_sbom.document.format.to_string(),
new_sbom_format: new_sbom.document.format.to_string(),
old_component_count: old_sbom.component_count(),
new_component_count: new_sbom.component_count(),
};
self.write_item(&metadata)?;
let summary = NdjsonSummary {
type_: "summary",
total_changes: result.summary.total_changes,
components_added: result.summary.components_added,
components_removed: result.summary.components_removed,
components_modified: result.summary.components_modified,
vulnerabilities_introduced: result.summary.vulnerabilities_introduced,
vulnerabilities_resolved: result.summary.vulnerabilities_resolved,
semantic_score: result.semantic_score,
};
self.write_item(&summary)?;
if config.includes(ReportType::Components) {
self.write_diff_components(result)?;
}
if config.includes(ReportType::Vulnerabilities) {
self.write_diff_vulnerabilities(result)?;
}
if config.includes(ReportType::Dependencies) {
for dep in &result.dependencies.added {
self.write_tagged("dependency_added", dep)?;
}
for dep in &result.dependencies.removed {
self.write_tagged("dependency_removed", dep)?;
}
}
if config.includes(ReportType::Licenses) {
for lic in &result.licenses.new_licenses {
self.write_tagged("license_new", lic)?;
}
for lic in &result.licenses.removed_licenses {
self.write_tagged("license_removed", lic)?;
}
}
self.writer.flush()?;
Ok(())
}
#[must_use]
pub const fn items_written(&self) -> usize {
self.items_written
}
}
#[derive(Default)]
pub struct StreamingJsonReporter {
pretty: bool,
}
impl StreamingJsonReporter {
#[must_use]
pub const fn new() -> Self {
Self { pretty: true }
}
#[must_use]
pub const fn compact() -> Self {
Self { pretty: false }
}
}
impl WriterReporter for StreamingJsonReporter {
fn write_diff_to<W: Write>(
&self,
result: &DiffResult,
old_sbom: &NormalizedSbom,
new_sbom: &NormalizedSbom,
config: &ReportConfig,
writer: &mut W,
) -> Result<(), ReportError> {
let streaming = StreamingJsonWriter::new(writer, self.pretty);
streaming.write_diff_report(result, old_sbom, new_sbom, config)
}
fn write_view_to<W: Write>(
&self,
sbom: &NormalizedSbom,
config: &ReportConfig,
writer: &mut W,
) -> Result<(), ReportError> {
use super::JsonReporter;
use super::ReportGenerator;
let reporter = JsonReporter::new().pretty(self.pretty);
let report = reporter.generate_view_report(sbom, config)?;
writer.write_all(report.as_bytes())?;
Ok(())
}
fn format(&self) -> ReportFormat {
ReportFormat::Json
}
}
#[derive(Default)]
pub struct NdjsonReporter;
impl NdjsonReporter {
#[must_use]
pub const fn new() -> Self {
Self
}
}
impl WriterReporter for NdjsonReporter {
fn write_diff_to<W: Write>(
&self,
result: &DiffResult,
old_sbom: &NormalizedSbom,
new_sbom: &NormalizedSbom,
config: &ReportConfig,
writer: &mut W,
) -> Result<(), ReportError> {
let mut ndjson = NdjsonWriter::new(writer);
ndjson.write_diff_report(result, old_sbom, new_sbom, config)
}
fn write_view_to<W: Write>(
&self,
sbom: &NormalizedSbom,
_config: &ReportConfig,
writer: &mut W,
) -> Result<(), ReportError> {
#[derive(Serialize)]
struct ViewMetadata<'a> {
#[serde(rename = "type")]
type_: &'a str,
format: String,
component_count: usize,
}
#[derive(Serialize)]
struct ComponentLine<'a> {
#[serde(rename = "type")]
type_: &'a str,
name: &'a str,
version: Option<&'a str>,
ecosystem: Option<String>,
}
let mut ndjson = NdjsonWriter::new(writer);
let metadata = ViewMetadata {
type_: "metadata",
format: sbom.document.format.to_string(),
component_count: sbom.component_count(),
};
ndjson.write_item(&metadata)?;
for (_, comp) in &sbom.components {
let line = ComponentLine {
type_: "component",
name: &comp.name,
version: comp.version.as_deref(),
ecosystem: comp
.ecosystem
.as_ref()
.map(std::string::ToString::to_string),
};
ndjson.write_item(&line)?;
}
Ok(())
}
fn format(&self) -> ReportFormat {
ReportFormat::Json }
}
#[derive(Serialize)]
struct ToolInfo {
name: String,
version: String,
}
#[derive(Serialize)]
struct SbomInfo {
format: String,
#[serde(skip_serializing_if = "Option::is_none")]
file_path: Option<String>,
component_count: usize,
}
#[derive(Serialize)]
struct StreamingMetadata {
tool: ToolInfo,
generated_at: String,
old_sbom: SbomInfo,
#[serde(skip_serializing_if = "Option::is_none")]
new_sbom: Option<SbomInfo>,
}
#[derive(Serialize)]
struct StreamingSummary {
total_changes: usize,
components_added: usize,
components_removed: usize,
components_modified: usize,
vulnerabilities_introduced: usize,
vulnerabilities_resolved: usize,
semantic_score: f64,
}
#[derive(Serialize)]
struct NdjsonMetadata<'a> {
#[serde(rename = "type")]
type_: &'a str,
tool: &'a str,
version: &'a str,
generated_at: String,
old_sbom_format: String,
new_sbom_format: String,
old_component_count: usize,
new_component_count: usize,
}
#[derive(Serialize)]
struct NdjsonSummary<'a> {
#[serde(rename = "type")]
type_: &'a str,
total_changes: usize,
components_added: usize,
components_removed: usize,
components_modified: usize,
vulnerabilities_introduced: usize,
vulnerabilities_resolved: usize,
semantic_score: f64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ndjson_writer_item() {
let mut buffer = Vec::new();
let mut writer = NdjsonWriter::new(&mut buffer);
#[derive(Serialize)]
struct TestItem {
name: String,
value: i32,
}
let item = TestItem {
name: "test".to_string(),
value: 42,
};
writer.write_item(&item).unwrap();
let output = String::from_utf8(buffer).unwrap();
assert!(output.contains("\"name\":\"test\""));
assert!(output.contains("\"value\":42"));
assert!(output.ends_with('\n'));
}
#[test]
fn test_ndjson_writer_tagged() {
let mut buffer = Vec::new();
let mut writer = NdjsonWriter::new(&mut buffer);
writer.write_tagged("test_type", &42).unwrap();
let output = String::from_utf8(buffer).unwrap();
assert!(output.contains("\"type\":\"test_type\""));
assert!(output.contains("\"data\":42"));
}
#[test]
fn test_streaming_json_reporter_implements_writer_reporter() {
let reporter = StreamingJsonReporter::new();
assert_eq!(WriterReporter::format(&reporter), ReportFormat::Json);
}
#[test]
fn test_ndjson_reporter_implements_writer_reporter() {
let reporter = NdjsonReporter::new();
assert_eq!(WriterReporter::format(&reporter), ReportFormat::Json);
}
#[test]
fn test_ndjson_writer_items_counted() {
let mut buffer = Vec::new();
let mut writer = NdjsonWriter::new(&mut buffer);
writer.write_item(&1).unwrap();
writer.write_item(&2).unwrap();
writer.write_item(&3).unwrap();
assert_eq!(writer.items_written(), 3);
}
}