Skip to main content

sbom_tools/reports/
streaming.rs

1//! Streaming report generators for memory-efficient output.
2//!
3//! This module provides report generators that write directly to output
4//! without buffering the entire report in memory. This is essential for
5//! large SBOMs with thousands of components.
6//!
7//! # Supported Formats
8//!
9//! - **JSON Streaming**: Writes JSON incrementally with periodic flushing
10//! - **NDJSON**: Newline-delimited JSON for easy processing of large datasets
11//!
12//! # Example
13//!
14//! ```ignore
15//! use sbom_tools::reports::streaming::{StreamingJsonWriter, NdjsonWriter};
16//! use std::io::BufWriter;
17//! use std::fs::File;
18//!
19//! // Stream JSON to a file
20//! let file = File::create("report.json")?;
21//! let mut writer = BufWriter::new(file);
22//! let json_writer = StreamingJsonWriter::new(&mut writer, true);
23//! json_writer.write_diff_report(&result, &old, &new, &config)?;
24//!
25//! // Stream NDJSON (one JSON object per line)
26//! let file = File::create("components.ndjson")?;
27//! let mut writer = BufWriter::new(file);
28//! let ndjson = NdjsonWriter::new(&mut writer);
29//! ndjson.write_components(&result.components)?;
30//! ```
31
32use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator, ReportType, WriterReporter};
33use crate::diff::DiffResult;
34use crate::model::NormalizedSbom;
35use chrono::Utc;
36use serde::Serialize;
37use std::io::Write;
38
39// ============================================================================
40// Streaming JSON Writer
41// ============================================================================
42
43/// A streaming JSON report writer that writes incrementally.
44///
45/// Unlike the standard `JsonReporter`, this writer streams data directly
46/// to the output without building the entire JSON document in memory.
47pub struct StreamingJsonWriter<'w, W: Write> {
48    writer: &'w mut W,
49    pretty: bool,
50    indent_level: usize,
51    flush_interval: usize,
52    items_written: usize,
53}
54
55impl<'w, W: Write> StreamingJsonWriter<'w, W> {
56    /// Create a new streaming JSON writer.
57    pub const fn new(writer: &'w mut W, pretty: bool) -> Self {
58        Self {
59            writer,
60            pretty,
61            indent_level: 0,
62            flush_interval: 100, // Flush every 100 items
63            items_written: 0,
64        }
65    }
66
67    /// Set the flush interval (number of items between flushes).
68    #[must_use]
69    pub fn with_flush_interval(mut self, interval: usize) -> Self {
70        self.flush_interval = interval.max(1);
71        self
72    }
73
74    /// Write the opening brace and increase indent.
75    fn write_object_start(&mut self) -> Result<(), ReportError> {
76        self.write_raw("{")?;
77        self.indent_level += 1;
78        Ok(())
79    }
80
81    /// Write the closing brace and decrease indent.
82    fn write_object_end(&mut self) -> Result<(), ReportError> {
83        self.indent_level = self.indent_level.saturating_sub(1);
84        self.write_newline()?;
85        self.write_indent()?;
86        self.write_raw("}")?;
87        Ok(())
88    }
89
90    /// Write the opening bracket and increase indent.
91    fn write_array_start(&mut self) -> Result<(), ReportError> {
92        self.write_raw("[")?;
93        self.indent_level += 1;
94        Ok(())
95    }
96
97    /// Write the closing bracket and decrease indent.
98    fn write_array_end(&mut self) -> Result<(), ReportError> {
99        self.indent_level = self.indent_level.saturating_sub(1);
100        self.write_newline()?;
101        self.write_indent()?;
102        self.write_raw("]")?;
103        Ok(())
104    }
105
106    /// Write a key-value pair.
107    fn write_key_value<V: Serialize>(
108        &mut self,
109        key: &str,
110        value: &V,
111        trailing_comma: bool,
112    ) -> Result<(), ReportError> {
113        self.write_newline()?;
114        self.write_indent()?;
115        self.write_raw(&format!("\"{key}\":"))?;
116        if self.pretty {
117            self.write_raw(" ")?;
118        }
119
120        let json = if self.pretty {
121            serde_json::to_string_pretty(value)
122        } else {
123            serde_json::to_string(value)
124        }
125        .map_err(|e| ReportError::SerializationError(e.to_string()))?;
126
127        // For pretty printing, re-indent multi-line values
128        if self.pretty && json.contains('\n') {
129            let indented = self.indent_multiline(&json);
130            self.write_raw(&indented)?;
131        } else {
132            self.write_raw(&json)?;
133        }
134
135        if trailing_comma {
136            self.write_raw(",")?;
137        }
138
139        Ok(())
140    }
141
142    /// Write a key and start an array for it.
143    fn write_key_array_start(&mut self, key: &str) -> Result<(), ReportError> {
144        self.write_newline()?;
145        self.write_indent()?;
146        self.write_raw(&format!("\"{key}\":"))?;
147        if self.pretty {
148            self.write_raw(" ")?;
149        }
150        self.write_array_start()?;
151        Ok(())
152    }
153
154    /// Write a single array item.
155    fn write_array_item<V: Serialize>(
156        &mut self,
157        value: &V,
158        trailing_comma: bool,
159    ) -> Result<(), ReportError> {
160        self.write_newline()?;
161        self.write_indent()?;
162
163        let json = if self.pretty {
164            serde_json::to_string_pretty(value)
165        } else {
166            serde_json::to_string(value)
167        }
168        .map_err(|e| ReportError::SerializationError(e.to_string()))?;
169
170        if self.pretty && json.contains('\n') {
171            let indented = self.indent_multiline(&json);
172            self.write_raw(&indented)?;
173        } else {
174            self.write_raw(&json)?;
175        }
176
177        if trailing_comma {
178            self.write_raw(",")?;
179        }
180
181        self.items_written += 1;
182        if self.items_written.is_multiple_of(self.flush_interval) {
183            self.writer.flush()?;
184        }
185
186        Ok(())
187    }
188
189    /// Write raw bytes.
190    fn write_raw(&mut self, s: &str) -> Result<(), ReportError> {
191        self.writer.write_all(s.as_bytes())?;
192        Ok(())
193    }
194
195    /// Write a newline if pretty printing.
196    fn write_newline(&mut self) -> Result<(), ReportError> {
197        if self.pretty {
198            self.write_raw("\n")?;
199        }
200        Ok(())
201    }
202
203    /// Write indentation if pretty printing.
204    fn write_indent(&mut self) -> Result<(), ReportError> {
205        if self.pretty {
206            let indent = "  ".repeat(self.indent_level);
207            self.write_raw(&indent)?;
208        }
209        Ok(())
210    }
211
212    /// Re-indent a multi-line JSON string.
213    fn indent_multiline(&self, json: &str) -> String {
214        let base_indent = "  ".repeat(self.indent_level);
215        let lines: Vec<&str> = json.lines().collect();
216        if lines.len() <= 1 {
217            return json.to_string();
218        }
219
220        let mut result = String::new();
221        result.push_str(lines[0]);
222        for line in &lines[1..] {
223            result.push('\n');
224            result.push_str(&base_indent);
225            result.push_str(line);
226        }
227        result
228    }
229
230    /// Write a complete diff report.
231    pub fn write_diff_report(
232        mut self,
233        result: &DiffResult,
234        old_sbom: &NormalizedSbom,
235        new_sbom: &NormalizedSbom,
236        config: &ReportConfig,
237    ) -> Result<(), ReportError> {
238        self.write_object_start()?;
239
240        // Write metadata
241        let metadata = StreamingMetadata {
242            tool: ToolInfo {
243                name: "sbom-tools".to_string(),
244                version: env!("CARGO_PKG_VERSION").to_string(),
245            },
246            generated_at: Utc::now().to_rfc3339(),
247            old_sbom: SbomInfo {
248                format: old_sbom.document.format.to_string(),
249                file_path: config.metadata.old_sbom_path.clone(),
250                component_count: old_sbom.component_count(),
251            },
252            new_sbom: Some(SbomInfo {
253                format: new_sbom.document.format.to_string(),
254                file_path: config.metadata.new_sbom_path.clone(),
255                component_count: new_sbom.component_count(),
256            }),
257        };
258        self.write_key_value("metadata", &metadata, true)?;
259
260        // Write summary. Only emit a trailing comma when at least one report
261        // section follows; otherwise (e.g. an empty `report_types` selection)
262        // the comma would dangle before the closing `}` and produce invalid
263        // JSON.
264        let summary = StreamingSummary {
265            total_changes: result.summary.total_changes,
266            components_added: result.summary.components_added,
267            components_removed: result.summary.components_removed,
268            components_modified: result.summary.components_modified,
269            vulnerabilities_introduced: result.summary.vulnerabilities_introduced,
270            vulnerabilities_resolved: result.summary.vulnerabilities_resolved,
271            semantic_score: result.semantic_score,
272        };
273        let any_section = config.includes(ReportType::Components)
274            || config.includes(ReportType::Vulnerabilities)
275            || config.includes(ReportType::Dependencies)
276            || config.includes(ReportType::Licenses);
277        self.write_key_value("summary", &summary, any_section)?;
278
279        // Write components (streamed)
280        if config.includes(ReportType::Components) {
281            self.write_key_array_start("components_added")?;
282            let added_len = result.components.added.len();
283            for (i, comp) in result.components.added.iter().enumerate() {
284                self.write_array_item(comp, i + 1 < added_len)?;
285            }
286            self.write_array_end()?;
287            self.write_raw(",")?;
288
289            self.write_key_array_start("components_removed")?;
290            let removed_len = result.components.removed.len();
291            for (i, comp) in result.components.removed.iter().enumerate() {
292                self.write_array_item(comp, i + 1 < removed_len)?;
293            }
294            self.write_array_end()?;
295            self.write_raw(",")?;
296
297            self.write_key_array_start("components_modified")?;
298            let modified_len = result.components.modified.len();
299            for (i, comp) in result.components.modified.iter().enumerate() {
300                self.write_array_item(comp, i + 1 < modified_len)?;
301            }
302            self.write_array_end()?;
303
304            // Check if more sections follow
305            let has_more = config.includes(ReportType::Vulnerabilities)
306                || config.includes(ReportType::Dependencies)
307                || config.includes(ReportType::Licenses);
308            if has_more {
309                self.write_raw(",")?;
310            }
311        }
312
313        // Write vulnerabilities (streamed)
314        if config.includes(ReportType::Vulnerabilities) {
315            self.write_key_array_start("vulnerabilities_introduced")?;
316            let introduced_len = result.vulnerabilities.introduced.len();
317            for (i, vuln) in result.vulnerabilities.introduced.iter().enumerate() {
318                self.write_array_item(vuln, i + 1 < introduced_len)?;
319            }
320            self.write_array_end()?;
321            self.write_raw(",")?;
322
323            self.write_key_array_start("vulnerabilities_resolved")?;
324            let resolved_len = result.vulnerabilities.resolved.len();
325            for (i, vuln) in result.vulnerabilities.resolved.iter().enumerate() {
326                self.write_array_item(vuln, i + 1 < resolved_len)?;
327            }
328            self.write_array_end()?;
329
330            let has_more =
331                config.includes(ReportType::Dependencies) || config.includes(ReportType::Licenses);
332            if has_more {
333                self.write_raw(",")?;
334            }
335        }
336
337        // Write dependencies (streamed)
338        if config.includes(ReportType::Dependencies) {
339            self.write_key_array_start("dependencies_added")?;
340            let added_len = result.dependencies.added.len();
341            for (i, dep) in result.dependencies.added.iter().enumerate() {
342                self.write_array_item(dep, i + 1 < added_len)?;
343            }
344            self.write_array_end()?;
345            self.write_raw(",")?;
346
347            self.write_key_array_start("dependencies_removed")?;
348            let removed_len = result.dependencies.removed.len();
349            for (i, dep) in result.dependencies.removed.iter().enumerate() {
350                self.write_array_item(dep, i + 1 < removed_len)?;
351            }
352            self.write_array_end()?;
353
354            if config.includes(ReportType::Licenses) {
355                self.write_raw(",")?;
356            }
357        }
358
359        // Write licenses (streamed)
360        if config.includes(ReportType::Licenses) {
361            self.write_key_array_start("licenses_new")?;
362            let new_len = result.licenses.new_licenses.len();
363            for (i, lic) in result.licenses.new_licenses.iter().enumerate() {
364                self.write_array_item(lic, i + 1 < new_len)?;
365            }
366            self.write_array_end()?;
367            self.write_raw(",")?;
368
369            self.write_key_array_start("licenses_removed")?;
370            let removed_len = result.licenses.removed_licenses.len();
371            for (i, lic) in result.licenses.removed_licenses.iter().enumerate() {
372                self.write_array_item(lic, i + 1 < removed_len)?;
373            }
374            self.write_array_end()?;
375        }
376
377        self.write_object_end()?;
378        self.write_newline()?;
379        self.writer.flush()?;
380
381        Ok(())
382    }
383}
384
385// ============================================================================
386// NDJSON Writer (Newline-Delimited JSON)
387// ============================================================================
388
389/// Writer for Newline-Delimited JSON (NDJSON) format.
390///
391/// NDJSON is ideal for streaming large datasets where each line is a
392/// complete JSON object. This allows easy processing with tools like
393/// `jq`, `grep`, or streaming parsers.
394pub struct NdjsonWriter<'w, W: Write> {
395    writer: &'w mut W,
396    flush_interval: usize,
397    items_written: usize,
398}
399
400impl<'w, W: Write> NdjsonWriter<'w, W> {
401    /// Create a new NDJSON writer.
402    pub const fn new(writer: &'w mut W) -> Self {
403        Self {
404            writer,
405            flush_interval: 100,
406            items_written: 0,
407        }
408    }
409
410    /// Set the flush interval.
411    #[must_use]
412    pub fn with_flush_interval(mut self, interval: usize) -> Self {
413        self.flush_interval = interval.max(1);
414        self
415    }
416
417    /// Write a single item as a JSON line.
418    pub fn write_item<T: Serialize>(&mut self, item: &T) -> Result<(), ReportError> {
419        let json = serde_json::to_string(item)
420            .map_err(|e| ReportError::SerializationError(e.to_string()))?;
421        self.writer.write_all(json.as_bytes())?;
422        self.writer.write_all(b"\n")?;
423
424        self.items_written += 1;
425        if self.items_written.is_multiple_of(self.flush_interval) {
426            self.writer.flush()?;
427        }
428
429        Ok(())
430    }
431
432    /// Write a tagged item (with a type field).
433    pub fn write_tagged<T: Serialize>(&mut self, tag: &str, item: &T) -> Result<(), ReportError> {
434        #[derive(Serialize)]
435        struct Tagged<'a, T> {
436            #[serde(rename = "type")]
437            type_: &'a str,
438            data: &'a T,
439        }
440
441        let tagged = Tagged {
442            type_: tag,
443            data: item,
444        };
445        self.write_item(&tagged)
446    }
447
448    /// Write all components from a diff result.
449    pub fn write_diff_components(&mut self, result: &DiffResult) -> Result<(), ReportError> {
450        for comp in &result.components.added {
451            self.write_tagged("component_added", comp)?;
452        }
453        for comp in &result.components.removed {
454            self.write_tagged("component_removed", comp)?;
455        }
456        for comp in &result.components.modified {
457            self.write_tagged("component_modified", comp)?;
458        }
459        self.writer.flush()?;
460        Ok(())
461    }
462
463    /// Write all vulnerabilities from a diff result.
464    pub fn write_diff_vulnerabilities(&mut self, result: &DiffResult) -> Result<(), ReportError> {
465        for vuln in &result.vulnerabilities.introduced {
466            self.write_tagged("vulnerability_introduced", vuln)?;
467        }
468        for vuln in &result.vulnerabilities.resolved {
469            self.write_tagged("vulnerability_resolved", vuln)?;
470        }
471        for vuln in &result.vulnerabilities.persistent {
472            self.write_tagged("vulnerability_persistent", vuln)?;
473        }
474        self.writer.flush()?;
475        Ok(())
476    }
477
478    /// Write a complete diff report in NDJSON format.
479    ///
480    /// The first line is metadata, then components, then vulnerabilities.
481    pub fn write_diff_report(
482        &mut self,
483        result: &DiffResult,
484        old_sbom: &NormalizedSbom,
485        new_sbom: &NormalizedSbom,
486        config: &ReportConfig,
487    ) -> Result<(), ReportError> {
488        // Write metadata as first line
489        let metadata = NdjsonMetadata {
490            type_: "metadata",
491            tool: "sbom-tools",
492            version: env!("CARGO_PKG_VERSION"),
493            generated_at: Utc::now().to_rfc3339(),
494            old_sbom_format: old_sbom.document.format.to_string(),
495            new_sbom_format: new_sbom.document.format.to_string(),
496            old_component_count: old_sbom.component_count(),
497            new_component_count: new_sbom.component_count(),
498        };
499        self.write_item(&metadata)?;
500
501        // Write summary
502        let summary = NdjsonSummary {
503            type_: "summary",
504            total_changes: result.summary.total_changes,
505            components_added: result.summary.components_added,
506            components_removed: result.summary.components_removed,
507            components_modified: result.summary.components_modified,
508            vulnerabilities_introduced: result.summary.vulnerabilities_introduced,
509            vulnerabilities_resolved: result.summary.vulnerabilities_resolved,
510            semantic_score: result.semantic_score,
511        };
512        self.write_item(&summary)?;
513
514        // Write components
515        if config.includes(ReportType::Components) {
516            self.write_diff_components(result)?;
517        }
518
519        // Write vulnerabilities
520        if config.includes(ReportType::Vulnerabilities) {
521            self.write_diff_vulnerabilities(result)?;
522        }
523
524        // Write dependencies
525        if config.includes(ReportType::Dependencies) {
526            for dep in &result.dependencies.added {
527                self.write_tagged("dependency_added", dep)?;
528            }
529            for dep in &result.dependencies.removed {
530                self.write_tagged("dependency_removed", dep)?;
531            }
532        }
533
534        // Write licenses
535        if config.includes(ReportType::Licenses) {
536            for lic in &result.licenses.new_licenses {
537                self.write_tagged("license_new", lic)?;
538            }
539            for lic in &result.licenses.removed_licenses {
540                self.write_tagged("license_removed", lic)?;
541            }
542        }
543
544        self.writer.flush()?;
545        Ok(())
546    }
547
548    /// Get the number of items written.
549    #[must_use]
550    pub const fn items_written(&self) -> usize {
551        self.items_written
552    }
553}
554
555// ============================================================================
556// Streaming Reporter Implementation
557// ============================================================================
558
559/// A streaming JSON reporter that writes incrementally to a `Write` sink.
560///
561/// This wraps `StreamingJsonWriter` to provide the `WriterReporter` trait
562/// with true incremental output (no full-report buffering).
563#[derive(Default)]
564pub struct StreamingJsonReporter {
565    pretty: bool,
566}
567
568impl StreamingJsonReporter {
569    /// Create a new streaming JSON reporter.
570    #[must_use]
571    pub const fn new() -> Self {
572        Self { pretty: true }
573    }
574
575    /// Create a compact (non-pretty) streaming JSON reporter.
576    #[must_use]
577    pub const fn compact() -> Self {
578        Self { pretty: false }
579    }
580}
581
582impl WriterReporter for StreamingJsonReporter {
583    fn write_diff_to<W: Write>(
584        &self,
585        result: &DiffResult,
586        old_sbom: &NormalizedSbom,
587        new_sbom: &NormalizedSbom,
588        config: &ReportConfig,
589        writer: &mut W,
590    ) -> Result<(), ReportError> {
591        let streaming = StreamingJsonWriter::new(writer, self.pretty);
592        streaming.write_diff_report(result, old_sbom, new_sbom, config)
593    }
594
595    fn write_view_to<W: Write>(
596        &self,
597        sbom: &NormalizedSbom,
598        config: &ReportConfig,
599        writer: &mut W,
600    ) -> Result<(), ReportError> {
601        // For view reports, use the regular JSON reporter
602        // (typically smaller, streaming less beneficial)
603        use super::JsonReporter;
604        use super::ReportGenerator;
605
606        let reporter = JsonReporter::new().pretty(self.pretty);
607        let report = reporter.generate_view_report(sbom, config)?;
608        writer.write_all(report.as_bytes())?;
609        Ok(())
610    }
611
612    fn format(&self) -> ReportFormat {
613        ReportFormat::Json
614    }
615}
616
617/// A streaming NDJSON reporter.
618#[derive(Default)]
619pub struct NdjsonReporter;
620
621impl NdjsonReporter {
622    /// Create a new NDJSON reporter.
623    #[must_use]
624    pub const fn new() -> Self {
625        Self
626    }
627}
628
629impl WriterReporter for NdjsonReporter {
630    fn write_diff_to<W: Write>(
631        &self,
632        result: &DiffResult,
633        old_sbom: &NormalizedSbom,
634        new_sbom: &NormalizedSbom,
635        config: &ReportConfig,
636        writer: &mut W,
637    ) -> Result<(), ReportError> {
638        let mut ndjson = NdjsonWriter::new(writer);
639        ndjson.write_diff_report(result, old_sbom, new_sbom, config)
640    }
641
642    fn write_view_to<W: Write>(
643        &self,
644        sbom: &NormalizedSbom,
645        _config: &ReportConfig,
646        writer: &mut W,
647    ) -> Result<(), ReportError> {
648        #[derive(Serialize)]
649        struct ViewMetadata<'a> {
650            #[serde(rename = "type")]
651            type_: &'a str,
652            format: String,
653            component_count: usize,
654        }
655
656        #[derive(Serialize)]
657        struct ComponentLine<'a> {
658            #[serde(rename = "type")]
659            type_: &'a str,
660            name: &'a str,
661            version: Option<&'a str>,
662            ecosystem: Option<String>,
663        }
664
665        let mut ndjson = NdjsonWriter::new(writer);
666
667        // Write metadata
668        let metadata = ViewMetadata {
669            type_: "metadata",
670            format: sbom.document.format.to_string(),
671            component_count: sbom.component_count(),
672        };
673        ndjson.write_item(&metadata)?;
674
675        // Write each component
676        for (_, comp) in &sbom.components {
677            let line = ComponentLine {
678                type_: "component",
679                name: &comp.name,
680                version: comp.version.as_deref(),
681                ecosystem: comp
682                    .ecosystem
683                    .as_ref()
684                    .map(std::string::ToString::to_string),
685            };
686            ndjson.write_item(&line)?;
687        }
688
689        Ok(())
690    }
691
692    fn format(&self) -> ReportFormat {
693        ReportFormat::Ndjson
694    }
695}
696
697/// `ReportGenerator` adapter for [`NdjsonReporter`].
698///
699/// [`NdjsonReporter`] implements [`WriterReporter`] directly so it can stream
700/// without buffering, which makes it incompatible with the blanket
701/// `WriterReporter` impl over `ReportGenerator`. This thin wrapper bridges it
702/// into the `Box<dyn ReportGenerator>` world used by `create_reporter`,
703/// buffering the NDJSON into a `String` on demand.
704#[derive(Default)]
705pub struct NdjsonReportGenerator;
706
707impl NdjsonReportGenerator {
708    /// Create a new NDJSON report generator.
709    #[must_use]
710    pub const fn new() -> Self {
711        Self
712    }
713}
714
715impl ReportGenerator for NdjsonReportGenerator {
716    fn generate_diff_report(
717        &self,
718        result: &DiffResult,
719        old_sbom: &NormalizedSbom,
720        new_sbom: &NormalizedSbom,
721        config: &ReportConfig,
722    ) -> Result<String, ReportError> {
723        let mut buf = Vec::new();
724        NdjsonReporter::new().write_diff_to(result, old_sbom, new_sbom, config, &mut buf)?;
725        String::from_utf8(buf).map_err(|e| ReportError::SerializationError(e.to_string()))
726    }
727
728    fn generate_view_report(
729        &self,
730        sbom: &NormalizedSbom,
731        config: &ReportConfig,
732    ) -> Result<String, ReportError> {
733        let mut buf = Vec::new();
734        NdjsonReporter::new().write_view_to(sbom, config, &mut buf)?;
735        String::from_utf8(buf).map_err(|e| ReportError::SerializationError(e.to_string()))
736    }
737
738    fn format(&self) -> ReportFormat {
739        ReportFormat::Ndjson
740    }
741}
742
743// ============================================================================
744// Helper Types
745// ============================================================================
746
747#[derive(Serialize)]
748struct ToolInfo {
749    name: String,
750    version: String,
751}
752
753#[derive(Serialize)]
754struct SbomInfo {
755    format: String,
756    #[serde(skip_serializing_if = "Option::is_none")]
757    file_path: Option<String>,
758    component_count: usize,
759}
760
761#[derive(Serialize)]
762struct StreamingMetadata {
763    tool: ToolInfo,
764    generated_at: String,
765    old_sbom: SbomInfo,
766    #[serde(skip_serializing_if = "Option::is_none")]
767    new_sbom: Option<SbomInfo>,
768}
769
770#[derive(Serialize)]
771struct StreamingSummary {
772    total_changes: usize,
773    components_added: usize,
774    components_removed: usize,
775    components_modified: usize,
776    vulnerabilities_introduced: usize,
777    vulnerabilities_resolved: usize,
778    semantic_score: f64,
779}
780
781#[derive(Serialize)]
782struct NdjsonMetadata<'a> {
783    #[serde(rename = "type")]
784    type_: &'a str,
785    tool: &'a str,
786    version: &'a str,
787    generated_at: String,
788    old_sbom_format: String,
789    new_sbom_format: String,
790    old_component_count: usize,
791    new_component_count: usize,
792}
793
794#[derive(Serialize)]
795struct NdjsonSummary<'a> {
796    #[serde(rename = "type")]
797    type_: &'a str,
798    total_changes: usize,
799    components_added: usize,
800    components_removed: usize,
801    components_modified: usize,
802    vulnerabilities_introduced: usize,
803    vulnerabilities_resolved: usize,
804    semantic_score: f64,
805}
806
807// ============================================================================
808// Tests
809// ============================================================================
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814
815    #[test]
816    fn test_ndjson_writer_item() {
817        let mut buffer = Vec::new();
818        let mut writer = NdjsonWriter::new(&mut buffer);
819
820        #[derive(Serialize)]
821        struct TestItem {
822            name: String,
823            value: i32,
824        }
825
826        let item = TestItem {
827            name: "test".to_string(),
828            value: 42,
829        };
830        writer.write_item(&item).unwrap();
831
832        let output = String::from_utf8(buffer).unwrap();
833        assert!(output.contains("\"name\":\"test\""));
834        assert!(output.contains("\"value\":42"));
835        assert!(output.ends_with('\n'));
836    }
837
838    #[test]
839    fn test_ndjson_writer_tagged() {
840        let mut buffer = Vec::new();
841        let mut writer = NdjsonWriter::new(&mut buffer);
842
843        writer.write_tagged("test_type", &42).unwrap();
844
845        let output = String::from_utf8(buffer).unwrap();
846        assert!(output.contains("\"type\":\"test_type\""));
847        assert!(output.contains("\"data\":42"));
848    }
849
850    #[test]
851    fn test_streaming_json_reporter_implements_writer_reporter() {
852        let reporter = StreamingJsonReporter::new();
853        // Verify it implements WriterReporter (compile-time check via trait method)
854        assert_eq!(WriterReporter::format(&reporter), ReportFormat::Json);
855    }
856
857    #[test]
858    fn test_ndjson_reporter_implements_writer_reporter() {
859        let reporter = NdjsonReporter::new();
860        assert_eq!(WriterReporter::format(&reporter), ReportFormat::Ndjson);
861    }
862
863    #[test]
864    fn test_ndjson_report_generator_reports_ndjson_format() {
865        let generator = NdjsonReportGenerator::new();
866        assert_eq!(ReportGenerator::format(&generator), ReportFormat::Ndjson);
867    }
868
869    #[test]
870    fn test_ndjson_report_generator_view_yields_ndjson_lines() {
871        use crate::model::NormalizedSbom;
872
873        let sbom = NormalizedSbom::default();
874        let config = ReportConfig::default();
875        let generator = NdjsonReportGenerator::new();
876        let report = generator
877            .generate_view_report(&sbom, &config)
878            .expect("view report should render");
879
880        let first = report.lines().next().expect("at least one NDJSON line");
881        let value: serde_json::Value =
882            serde_json::from_str(first).expect("first line should be valid json");
883        assert_eq!(value["type"], "metadata");
884    }
885
886    #[test]
887    fn test_ndjson_writer_items_counted() {
888        let mut buffer = Vec::new();
889        let mut writer = NdjsonWriter::new(&mut buffer);
890
891        writer.write_item(&1).unwrap();
892        writer.write_item(&2).unwrap();
893        writer.write_item(&3).unwrap();
894
895        assert_eq!(writer.items_written(), 3);
896    }
897}