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            let tag = if comp.change_type == crate::diff::ChangeType::Unchanged {
458                "component_unchanged"
459            } else {
460                "component_modified"
461            };
462            self.write_tagged(tag, comp)?;
463        }
464        self.writer.flush()?;
465        Ok(())
466    }
467
468    /// Write all vulnerabilities from a diff result.
469    pub fn write_diff_vulnerabilities(&mut self, result: &DiffResult) -> Result<(), ReportError> {
470        for vuln in &result.vulnerabilities.introduced {
471            self.write_tagged("vulnerability_introduced", vuln)?;
472        }
473        for vuln in &result.vulnerabilities.resolved {
474            self.write_tagged("vulnerability_resolved", vuln)?;
475        }
476        for vuln in &result.vulnerabilities.persistent {
477            self.write_tagged("vulnerability_persistent", vuln)?;
478        }
479        self.writer.flush()?;
480        Ok(())
481    }
482
483    /// Write a complete diff report in NDJSON format.
484    ///
485    /// The first line is metadata, then components, then vulnerabilities.
486    pub fn write_diff_report(
487        &mut self,
488        result: &DiffResult,
489        old_sbom: &NormalizedSbom,
490        new_sbom: &NormalizedSbom,
491        config: &ReportConfig,
492    ) -> Result<(), ReportError> {
493        // Write metadata as first line
494        let metadata = NdjsonMetadata {
495            type_: "metadata",
496            tool: "sbom-tools",
497            version: env!("CARGO_PKG_VERSION"),
498            generated_at: Utc::now().to_rfc3339(),
499            old_sbom_format: old_sbom.document.format.to_string(),
500            new_sbom_format: new_sbom.document.format.to_string(),
501            old_component_count: old_sbom.component_count(),
502            new_component_count: new_sbom.component_count(),
503        };
504        self.write_item(&metadata)?;
505
506        // Write summary
507        let summary = NdjsonSummary {
508            type_: "summary",
509            total_changes: result.summary.total_changes,
510            components_added: result.summary.components_added,
511            components_removed: result.summary.components_removed,
512            components_modified: result.summary.components_modified,
513            vulnerabilities_introduced: result.summary.vulnerabilities_introduced,
514            vulnerabilities_resolved: result.summary.vulnerabilities_resolved,
515            semantic_score: result.semantic_score,
516        };
517        self.write_item(&summary)?;
518
519        // Write components
520        if config.includes(ReportType::Components) {
521            self.write_diff_components(result)?;
522        }
523
524        // Write vulnerabilities
525        if config.includes(ReportType::Vulnerabilities) {
526            self.write_diff_vulnerabilities(result)?;
527        }
528
529        // Write dependencies
530        if config.includes(ReportType::Dependencies) {
531            for dep in &result.dependencies.added {
532                self.write_tagged("dependency_added", dep)?;
533            }
534            for dep in &result.dependencies.removed {
535                self.write_tagged("dependency_removed", dep)?;
536            }
537        }
538
539        // Write licenses
540        if config.includes(ReportType::Licenses) {
541            for lic in &result.licenses.new_licenses {
542                self.write_tagged("license_new", lic)?;
543            }
544            for lic in &result.licenses.removed_licenses {
545                self.write_tagged("license_removed", lic)?;
546            }
547        }
548
549        self.writer.flush()?;
550        Ok(())
551    }
552
553    /// Get the number of items written.
554    #[must_use]
555    pub const fn items_written(&self) -> usize {
556        self.items_written
557    }
558}
559
560// ============================================================================
561// Streaming Reporter Implementation
562// ============================================================================
563
564/// A streaming JSON reporter that writes incrementally to a `Write` sink.
565///
566/// This wraps `StreamingJsonWriter` to provide the `WriterReporter` trait
567/// with true incremental output (no full-report buffering).
568#[derive(Default)]
569pub struct StreamingJsonReporter {
570    pretty: bool,
571}
572
573impl StreamingJsonReporter {
574    /// Create a new streaming JSON reporter.
575    #[must_use]
576    pub const fn new() -> Self {
577        Self { pretty: true }
578    }
579
580    /// Create a compact (non-pretty) streaming JSON reporter.
581    #[must_use]
582    pub const fn compact() -> Self {
583        Self { pretty: false }
584    }
585}
586
587impl WriterReporter for StreamingJsonReporter {
588    fn write_diff_to<W: Write>(
589        &self,
590        result: &DiffResult,
591        old_sbom: &NormalizedSbom,
592        new_sbom: &NormalizedSbom,
593        config: &ReportConfig,
594        writer: &mut W,
595    ) -> Result<(), ReportError> {
596        let streaming = StreamingJsonWriter::new(writer, self.pretty);
597        streaming.write_diff_report(result, old_sbom, new_sbom, config)
598    }
599
600    fn write_view_to<W: Write>(
601        &self,
602        sbom: &NormalizedSbom,
603        config: &ReportConfig,
604        writer: &mut W,
605    ) -> Result<(), ReportError> {
606        // For view reports, use the regular JSON reporter
607        // (typically smaller, streaming less beneficial)
608        use super::JsonReporter;
609        use super::ReportGenerator;
610
611        let reporter = JsonReporter::new().pretty(self.pretty);
612        let report = reporter.generate_view_report(sbom, config)?;
613        writer.write_all(report.as_bytes())?;
614        Ok(())
615    }
616
617    fn format(&self) -> ReportFormat {
618        ReportFormat::Json
619    }
620}
621
622/// A streaming NDJSON reporter.
623#[derive(Default)]
624pub struct NdjsonReporter;
625
626impl NdjsonReporter {
627    /// Create a new NDJSON reporter.
628    #[must_use]
629    pub const fn new() -> Self {
630        Self
631    }
632}
633
634impl WriterReporter for NdjsonReporter {
635    fn write_diff_to<W: Write>(
636        &self,
637        result: &DiffResult,
638        old_sbom: &NormalizedSbom,
639        new_sbom: &NormalizedSbom,
640        config: &ReportConfig,
641        writer: &mut W,
642    ) -> Result<(), ReportError> {
643        let mut ndjson = NdjsonWriter::new(writer);
644        ndjson.write_diff_report(result, old_sbom, new_sbom, config)
645    }
646
647    fn write_view_to<W: Write>(
648        &self,
649        sbom: &NormalizedSbom,
650        _config: &ReportConfig,
651        writer: &mut W,
652    ) -> Result<(), ReportError> {
653        #[derive(Serialize)]
654        struct ViewMetadata<'a> {
655            #[serde(rename = "type")]
656            type_: &'a str,
657            format: String,
658            component_count: usize,
659        }
660
661        #[derive(Serialize)]
662        struct ComponentLine<'a> {
663            #[serde(rename = "type")]
664            type_: &'a str,
665            name: &'a str,
666            version: Option<&'a str>,
667            ecosystem: Option<String>,
668        }
669
670        let mut ndjson = NdjsonWriter::new(writer);
671
672        // Write metadata
673        let metadata = ViewMetadata {
674            type_: "metadata",
675            format: sbom.document.format.to_string(),
676            component_count: sbom.component_count(),
677        };
678        ndjson.write_item(&metadata)?;
679
680        // Write each component
681        for (_, comp) in &sbom.components {
682            let line = ComponentLine {
683                type_: "component",
684                name: &comp.name,
685                version: comp.version.as_deref(),
686                ecosystem: comp
687                    .ecosystem
688                    .as_ref()
689                    .map(std::string::ToString::to_string),
690            };
691            ndjson.write_item(&line)?;
692        }
693
694        Ok(())
695    }
696
697    fn format(&self) -> ReportFormat {
698        ReportFormat::Ndjson
699    }
700}
701
702/// `ReportGenerator` adapter for [`NdjsonReporter`].
703///
704/// [`NdjsonReporter`] implements [`WriterReporter`] directly so it can stream
705/// without buffering, which makes it incompatible with the blanket
706/// `WriterReporter` impl over `ReportGenerator`. This thin wrapper bridges it
707/// into the `Box<dyn ReportGenerator>` world used by `create_reporter`,
708/// buffering the NDJSON into a `String` on demand.
709#[derive(Default)]
710pub struct NdjsonReportGenerator;
711
712impl NdjsonReportGenerator {
713    /// Create a new NDJSON report generator.
714    #[must_use]
715    pub const fn new() -> Self {
716        Self
717    }
718}
719
720impl ReportGenerator for NdjsonReportGenerator {
721    fn generate_diff_report(
722        &self,
723        result: &DiffResult,
724        old_sbom: &NormalizedSbom,
725        new_sbom: &NormalizedSbom,
726        config: &ReportConfig,
727    ) -> Result<String, ReportError> {
728        let mut buf = Vec::new();
729        NdjsonReporter::new().write_diff_to(result, old_sbom, new_sbom, config, &mut buf)?;
730        String::from_utf8(buf).map_err(|e| ReportError::SerializationError(e.to_string()))
731    }
732
733    fn generate_view_report(
734        &self,
735        sbom: &NormalizedSbom,
736        config: &ReportConfig,
737    ) -> Result<String, ReportError> {
738        let mut buf = Vec::new();
739        NdjsonReporter::new().write_view_to(sbom, config, &mut buf)?;
740        String::from_utf8(buf).map_err(|e| ReportError::SerializationError(e.to_string()))
741    }
742
743    fn format(&self) -> ReportFormat {
744        ReportFormat::Ndjson
745    }
746}
747
748// ============================================================================
749// Helper Types
750// ============================================================================
751
752#[derive(Serialize)]
753struct ToolInfo {
754    name: String,
755    version: String,
756}
757
758#[derive(Serialize)]
759struct SbomInfo {
760    format: String,
761    #[serde(skip_serializing_if = "Option::is_none")]
762    file_path: Option<String>,
763    component_count: usize,
764}
765
766#[derive(Serialize)]
767struct StreamingMetadata {
768    tool: ToolInfo,
769    generated_at: String,
770    old_sbom: SbomInfo,
771    #[serde(skip_serializing_if = "Option::is_none")]
772    new_sbom: Option<SbomInfo>,
773}
774
775#[derive(Serialize)]
776struct StreamingSummary {
777    total_changes: usize,
778    components_added: usize,
779    components_removed: usize,
780    components_modified: usize,
781    vulnerabilities_introduced: usize,
782    vulnerabilities_resolved: usize,
783    semantic_score: f64,
784}
785
786#[derive(Serialize)]
787struct NdjsonMetadata<'a> {
788    #[serde(rename = "type")]
789    type_: &'a str,
790    tool: &'a str,
791    version: &'a str,
792    generated_at: String,
793    old_sbom_format: String,
794    new_sbom_format: String,
795    old_component_count: usize,
796    new_component_count: usize,
797}
798
799#[derive(Serialize)]
800struct NdjsonSummary<'a> {
801    #[serde(rename = "type")]
802    type_: &'a str,
803    total_changes: usize,
804    components_added: usize,
805    components_removed: usize,
806    components_modified: usize,
807    vulnerabilities_introduced: usize,
808    vulnerabilities_resolved: usize,
809    semantic_score: f64,
810}
811
812// ============================================================================
813// Tests
814// ============================================================================
815
816#[cfg(test)]
817mod tests {
818    use super::*;
819
820    #[test]
821    fn test_ndjson_writer_item() {
822        let mut buffer = Vec::new();
823        let mut writer = NdjsonWriter::new(&mut buffer);
824
825        #[derive(Serialize)]
826        struct TestItem {
827            name: String,
828            value: i32,
829        }
830
831        let item = TestItem {
832            name: "test".to_string(),
833            value: 42,
834        };
835        writer.write_item(&item).unwrap();
836
837        let output = String::from_utf8(buffer).unwrap();
838        assert!(output.contains("\"name\":\"test\""));
839        assert!(output.contains("\"value\":42"));
840        assert!(output.ends_with('\n'));
841    }
842
843    #[test]
844    fn test_ndjson_writer_tagged() {
845        let mut buffer = Vec::new();
846        let mut writer = NdjsonWriter::new(&mut buffer);
847
848        writer.write_tagged("test_type", &42).unwrap();
849
850        let output = String::from_utf8(buffer).unwrap();
851        assert!(output.contains("\"type\":\"test_type\""));
852        assert!(output.contains("\"data\":42"));
853    }
854
855    #[test]
856    fn test_streaming_json_reporter_implements_writer_reporter() {
857        let reporter = StreamingJsonReporter::new();
858        // Verify it implements WriterReporter (compile-time check via trait method)
859        assert_eq!(WriterReporter::format(&reporter), ReportFormat::Json);
860    }
861
862    #[test]
863    fn test_ndjson_reporter_implements_writer_reporter() {
864        let reporter = NdjsonReporter::new();
865        assert_eq!(WriterReporter::format(&reporter), ReportFormat::Ndjson);
866    }
867
868    #[test]
869    fn test_ndjson_report_generator_reports_ndjson_format() {
870        let generator = NdjsonReportGenerator::new();
871        assert_eq!(ReportGenerator::format(&generator), ReportFormat::Ndjson);
872    }
873
874    #[test]
875    fn test_ndjson_report_generator_view_yields_ndjson_lines() {
876        use crate::model::NormalizedSbom;
877
878        let sbom = NormalizedSbom::default();
879        let config = ReportConfig::default();
880        let generator = NdjsonReportGenerator::new();
881        let report = generator
882            .generate_view_report(&sbom, &config)
883            .expect("view report should render");
884
885        let first = report.lines().next().expect("at least one NDJSON line");
886        let value: serde_json::Value =
887            serde_json::from_str(first).expect("first line should be valid json");
888        assert_eq!(value["type"], "metadata");
889    }
890
891    #[test]
892    fn test_ndjson_writer_items_counted() {
893        let mut buffer = Vec::new();
894        let mut writer = NdjsonWriter::new(&mut buffer);
895
896        writer.write_item(&1).unwrap();
897        writer.write_item(&2).unwrap();
898        writer.write_item(&3).unwrap();
899
900        assert_eq!(writer.items_written(), 3);
901    }
902}