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, 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
261        let summary = StreamingSummary {
262            total_changes: result.summary.total_changes,
263            components_added: result.summary.components_added,
264            components_removed: result.summary.components_removed,
265            components_modified: result.summary.components_modified,
266            vulnerabilities_introduced: result.summary.vulnerabilities_introduced,
267            vulnerabilities_resolved: result.summary.vulnerabilities_resolved,
268            semantic_score: result.semantic_score,
269        };
270        self.write_key_value("summary", &summary, true)?;
271
272        // Write components (streamed)
273        if config.includes(ReportType::Components) {
274            self.write_key_array_start("components_added")?;
275            let added_len = result.components.added.len();
276            for (i, comp) in result.components.added.iter().enumerate() {
277                self.write_array_item(comp, i + 1 < added_len)?;
278            }
279            self.write_array_end()?;
280            self.write_raw(",")?;
281
282            self.write_key_array_start("components_removed")?;
283            let removed_len = result.components.removed.len();
284            for (i, comp) in result.components.removed.iter().enumerate() {
285                self.write_array_item(comp, i + 1 < removed_len)?;
286            }
287            self.write_array_end()?;
288            self.write_raw(",")?;
289
290            self.write_key_array_start("components_modified")?;
291            let modified_len = result.components.modified.len();
292            for (i, comp) in result.components.modified.iter().enumerate() {
293                self.write_array_item(comp, i + 1 < modified_len)?;
294            }
295            self.write_array_end()?;
296
297            // Check if more sections follow
298            let has_more = config.includes(ReportType::Vulnerabilities)
299                || config.includes(ReportType::Dependencies)
300                || config.includes(ReportType::Licenses);
301            if has_more {
302                self.write_raw(",")?;
303            }
304        }
305
306        // Write vulnerabilities (streamed)
307        if config.includes(ReportType::Vulnerabilities) {
308            self.write_key_array_start("vulnerabilities_introduced")?;
309            let introduced_len = result.vulnerabilities.introduced.len();
310            for (i, vuln) in result.vulnerabilities.introduced.iter().enumerate() {
311                self.write_array_item(vuln, i + 1 < introduced_len)?;
312            }
313            self.write_array_end()?;
314            self.write_raw(",")?;
315
316            self.write_key_array_start("vulnerabilities_resolved")?;
317            let resolved_len = result.vulnerabilities.resolved.len();
318            for (i, vuln) in result.vulnerabilities.resolved.iter().enumerate() {
319                self.write_array_item(vuln, i + 1 < resolved_len)?;
320            }
321            self.write_array_end()?;
322
323            let has_more =
324                config.includes(ReportType::Dependencies) || config.includes(ReportType::Licenses);
325            if has_more {
326                self.write_raw(",")?;
327            }
328        }
329
330        // Write dependencies (streamed)
331        if config.includes(ReportType::Dependencies) {
332            self.write_key_array_start("dependencies_added")?;
333            let added_len = result.dependencies.added.len();
334            for (i, dep) in result.dependencies.added.iter().enumerate() {
335                self.write_array_item(dep, i + 1 < added_len)?;
336            }
337            self.write_array_end()?;
338            self.write_raw(",")?;
339
340            self.write_key_array_start("dependencies_removed")?;
341            let removed_len = result.dependencies.removed.len();
342            for (i, dep) in result.dependencies.removed.iter().enumerate() {
343                self.write_array_item(dep, i + 1 < removed_len)?;
344            }
345            self.write_array_end()?;
346
347            if config.includes(ReportType::Licenses) {
348                self.write_raw(",")?;
349            }
350        }
351
352        // Write licenses (streamed)
353        if config.includes(ReportType::Licenses) {
354            self.write_key_array_start("licenses_new")?;
355            let new_len = result.licenses.new_licenses.len();
356            for (i, lic) in result.licenses.new_licenses.iter().enumerate() {
357                self.write_array_item(lic, i + 1 < new_len)?;
358            }
359            self.write_array_end()?;
360            self.write_raw(",")?;
361
362            self.write_key_array_start("licenses_removed")?;
363            let removed_len = result.licenses.removed_licenses.len();
364            for (i, lic) in result.licenses.removed_licenses.iter().enumerate() {
365                self.write_array_item(lic, i + 1 < removed_len)?;
366            }
367            self.write_array_end()?;
368        }
369
370        self.write_object_end()?;
371        self.write_newline()?;
372        self.writer.flush()?;
373
374        Ok(())
375    }
376}
377
378// ============================================================================
379// NDJSON Writer (Newline-Delimited JSON)
380// ============================================================================
381
382/// Writer for Newline-Delimited JSON (NDJSON) format.
383///
384/// NDJSON is ideal for streaming large datasets where each line is a
385/// complete JSON object. This allows easy processing with tools like
386/// `jq`, `grep`, or streaming parsers.
387pub struct NdjsonWriter<'w, W: Write> {
388    writer: &'w mut W,
389    flush_interval: usize,
390    items_written: usize,
391}
392
393impl<'w, W: Write> NdjsonWriter<'w, W> {
394    /// Create a new NDJSON writer.
395    pub const fn new(writer: &'w mut W) -> Self {
396        Self {
397            writer,
398            flush_interval: 100,
399            items_written: 0,
400        }
401    }
402
403    /// Set the flush interval.
404    #[must_use]
405    pub fn with_flush_interval(mut self, interval: usize) -> Self {
406        self.flush_interval = interval.max(1);
407        self
408    }
409
410    /// Write a single item as a JSON line.
411    pub fn write_item<T: Serialize>(&mut self, item: &T) -> Result<(), ReportError> {
412        let json = serde_json::to_string(item)
413            .map_err(|e| ReportError::SerializationError(e.to_string()))?;
414        self.writer.write_all(json.as_bytes())?;
415        self.writer.write_all(b"\n")?;
416
417        self.items_written += 1;
418        if self.items_written.is_multiple_of(self.flush_interval) {
419            self.writer.flush()?;
420        }
421
422        Ok(())
423    }
424
425    /// Write a tagged item (with a type field).
426    pub fn write_tagged<T: Serialize>(&mut self, tag: &str, item: &T) -> Result<(), ReportError> {
427        #[derive(Serialize)]
428        struct Tagged<'a, T> {
429            #[serde(rename = "type")]
430            type_: &'a str,
431            data: &'a T,
432        }
433
434        let tagged = Tagged {
435            type_: tag,
436            data: item,
437        };
438        self.write_item(&tagged)
439    }
440
441    /// Write all components from a diff result.
442    pub fn write_diff_components(&mut self, result: &DiffResult) -> Result<(), ReportError> {
443        for comp in &result.components.added {
444            self.write_tagged("component_added", comp)?;
445        }
446        for comp in &result.components.removed {
447            self.write_tagged("component_removed", comp)?;
448        }
449        for comp in &result.components.modified {
450            self.write_tagged("component_modified", comp)?;
451        }
452        self.writer.flush()?;
453        Ok(())
454    }
455
456    /// Write all vulnerabilities from a diff result.
457    pub fn write_diff_vulnerabilities(&mut self, result: &DiffResult) -> Result<(), ReportError> {
458        for vuln in &result.vulnerabilities.introduced {
459            self.write_tagged("vulnerability_introduced", vuln)?;
460        }
461        for vuln in &result.vulnerabilities.resolved {
462            self.write_tagged("vulnerability_resolved", vuln)?;
463        }
464        for vuln in &result.vulnerabilities.persistent {
465            self.write_tagged("vulnerability_persistent", vuln)?;
466        }
467        self.writer.flush()?;
468        Ok(())
469    }
470
471    /// Write a complete diff report in NDJSON format.
472    ///
473    /// The first line is metadata, then components, then vulnerabilities.
474    pub fn write_diff_report(
475        &mut self,
476        result: &DiffResult,
477        old_sbom: &NormalizedSbom,
478        new_sbom: &NormalizedSbom,
479        config: &ReportConfig,
480    ) -> Result<(), ReportError> {
481        // Write metadata as first line
482        let metadata = NdjsonMetadata {
483            type_: "metadata",
484            tool: "sbom-tools",
485            version: env!("CARGO_PKG_VERSION"),
486            generated_at: Utc::now().to_rfc3339(),
487            old_sbom_format: old_sbom.document.format.to_string(),
488            new_sbom_format: new_sbom.document.format.to_string(),
489            old_component_count: old_sbom.component_count(),
490            new_component_count: new_sbom.component_count(),
491        };
492        self.write_item(&metadata)?;
493
494        // Write summary
495        let summary = NdjsonSummary {
496            type_: "summary",
497            total_changes: result.summary.total_changes,
498            components_added: result.summary.components_added,
499            components_removed: result.summary.components_removed,
500            components_modified: result.summary.components_modified,
501            vulnerabilities_introduced: result.summary.vulnerabilities_introduced,
502            vulnerabilities_resolved: result.summary.vulnerabilities_resolved,
503            semantic_score: result.semantic_score,
504        };
505        self.write_item(&summary)?;
506
507        // Write components
508        if config.includes(ReportType::Components) {
509            self.write_diff_components(result)?;
510        }
511
512        // Write vulnerabilities
513        if config.includes(ReportType::Vulnerabilities) {
514            self.write_diff_vulnerabilities(result)?;
515        }
516
517        // Write dependencies
518        if config.includes(ReportType::Dependencies) {
519            for dep in &result.dependencies.added {
520                self.write_tagged("dependency_added", dep)?;
521            }
522            for dep in &result.dependencies.removed {
523                self.write_tagged("dependency_removed", dep)?;
524            }
525        }
526
527        // Write licenses
528        if config.includes(ReportType::Licenses) {
529            for lic in &result.licenses.new_licenses {
530                self.write_tagged("license_new", lic)?;
531            }
532            for lic in &result.licenses.removed_licenses {
533                self.write_tagged("license_removed", lic)?;
534            }
535        }
536
537        self.writer.flush()?;
538        Ok(())
539    }
540
541    /// Get the number of items written.
542    #[must_use]
543    pub const fn items_written(&self) -> usize {
544        self.items_written
545    }
546}
547
548// ============================================================================
549// Streaming Reporter Implementation
550// ============================================================================
551
552/// A streaming JSON reporter that writes incrementally to a `Write` sink.
553///
554/// This wraps `StreamingJsonWriter` to provide the `WriterReporter` trait
555/// with true incremental output (no full-report buffering).
556#[derive(Default)]
557pub struct StreamingJsonReporter {
558    pretty: bool,
559}
560
561impl StreamingJsonReporter {
562    /// Create a new streaming JSON reporter.
563    #[must_use]
564    pub const fn new() -> Self {
565        Self { pretty: true }
566    }
567
568    /// Create a compact (non-pretty) streaming JSON reporter.
569    #[must_use]
570    pub const fn compact() -> Self {
571        Self { pretty: false }
572    }
573}
574
575impl WriterReporter for StreamingJsonReporter {
576    fn write_diff_to<W: Write>(
577        &self,
578        result: &DiffResult,
579        old_sbom: &NormalizedSbom,
580        new_sbom: &NormalizedSbom,
581        config: &ReportConfig,
582        writer: &mut W,
583    ) -> Result<(), ReportError> {
584        let streaming = StreamingJsonWriter::new(writer, self.pretty);
585        streaming.write_diff_report(result, old_sbom, new_sbom, config)
586    }
587
588    fn write_view_to<W: Write>(
589        &self,
590        sbom: &NormalizedSbom,
591        config: &ReportConfig,
592        writer: &mut W,
593    ) -> Result<(), ReportError> {
594        // For view reports, use the regular JSON reporter
595        // (typically smaller, streaming less beneficial)
596        use super::JsonReporter;
597        use super::ReportGenerator;
598
599        let reporter = JsonReporter::new().pretty(self.pretty);
600        let report = reporter.generate_view_report(sbom, config)?;
601        writer.write_all(report.as_bytes())?;
602        Ok(())
603    }
604
605    fn format(&self) -> ReportFormat {
606        ReportFormat::Json
607    }
608}
609
610/// A streaming NDJSON reporter.
611#[derive(Default)]
612pub struct NdjsonReporter;
613
614impl NdjsonReporter {
615    /// Create a new NDJSON reporter.
616    #[must_use]
617    pub const fn new() -> Self {
618        Self
619    }
620}
621
622impl WriterReporter for NdjsonReporter {
623    fn write_diff_to<W: Write>(
624        &self,
625        result: &DiffResult,
626        old_sbom: &NormalizedSbom,
627        new_sbom: &NormalizedSbom,
628        config: &ReportConfig,
629        writer: &mut W,
630    ) -> Result<(), ReportError> {
631        let mut ndjson = NdjsonWriter::new(writer);
632        ndjson.write_diff_report(result, old_sbom, new_sbom, config)
633    }
634
635    fn write_view_to<W: Write>(
636        &self,
637        sbom: &NormalizedSbom,
638        _config: &ReportConfig,
639        writer: &mut W,
640    ) -> Result<(), ReportError> {
641        #[derive(Serialize)]
642        struct ViewMetadata<'a> {
643            #[serde(rename = "type")]
644            type_: &'a str,
645            format: String,
646            component_count: usize,
647        }
648
649        #[derive(Serialize)]
650        struct ComponentLine<'a> {
651            #[serde(rename = "type")]
652            type_: &'a str,
653            name: &'a str,
654            version: Option<&'a str>,
655            ecosystem: Option<String>,
656        }
657
658        let mut ndjson = NdjsonWriter::new(writer);
659
660        // Write metadata
661        let metadata = ViewMetadata {
662            type_: "metadata",
663            format: sbom.document.format.to_string(),
664            component_count: sbom.component_count(),
665        };
666        ndjson.write_item(&metadata)?;
667
668        // Write each component
669        for (_, comp) in &sbom.components {
670            let line = ComponentLine {
671                type_: "component",
672                name: &comp.name,
673                version: comp.version.as_deref(),
674                ecosystem: comp
675                    .ecosystem
676                    .as_ref()
677                    .map(std::string::ToString::to_string),
678            };
679            ndjson.write_item(&line)?;
680        }
681
682        Ok(())
683    }
684
685    fn format(&self) -> ReportFormat {
686        ReportFormat::Json // NDJSON is a variant of JSON
687    }
688}
689
690// ============================================================================
691// Helper Types
692// ============================================================================
693
694#[derive(Serialize)]
695struct ToolInfo {
696    name: String,
697    version: String,
698}
699
700#[derive(Serialize)]
701struct SbomInfo {
702    format: String,
703    #[serde(skip_serializing_if = "Option::is_none")]
704    file_path: Option<String>,
705    component_count: usize,
706}
707
708#[derive(Serialize)]
709struct StreamingMetadata {
710    tool: ToolInfo,
711    generated_at: String,
712    old_sbom: SbomInfo,
713    #[serde(skip_serializing_if = "Option::is_none")]
714    new_sbom: Option<SbomInfo>,
715}
716
717#[derive(Serialize)]
718struct StreamingSummary {
719    total_changes: usize,
720    components_added: usize,
721    components_removed: usize,
722    components_modified: usize,
723    vulnerabilities_introduced: usize,
724    vulnerabilities_resolved: usize,
725    semantic_score: f64,
726}
727
728#[derive(Serialize)]
729struct NdjsonMetadata<'a> {
730    #[serde(rename = "type")]
731    type_: &'a str,
732    tool: &'a str,
733    version: &'a str,
734    generated_at: String,
735    old_sbom_format: String,
736    new_sbom_format: String,
737    old_component_count: usize,
738    new_component_count: usize,
739}
740
741#[derive(Serialize)]
742struct NdjsonSummary<'a> {
743    #[serde(rename = "type")]
744    type_: &'a str,
745    total_changes: usize,
746    components_added: usize,
747    components_removed: usize,
748    components_modified: usize,
749    vulnerabilities_introduced: usize,
750    vulnerabilities_resolved: usize,
751    semantic_score: f64,
752}
753
754// ============================================================================
755// Tests
756// ============================================================================
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761
762    #[test]
763    fn test_ndjson_writer_item() {
764        let mut buffer = Vec::new();
765        let mut writer = NdjsonWriter::new(&mut buffer);
766
767        #[derive(Serialize)]
768        struct TestItem {
769            name: String,
770            value: i32,
771        }
772
773        let item = TestItem {
774            name: "test".to_string(),
775            value: 42,
776        };
777        writer.write_item(&item).unwrap();
778
779        let output = String::from_utf8(buffer).unwrap();
780        assert!(output.contains("\"name\":\"test\""));
781        assert!(output.contains("\"value\":42"));
782        assert!(output.ends_with('\n'));
783    }
784
785    #[test]
786    fn test_ndjson_writer_tagged() {
787        let mut buffer = Vec::new();
788        let mut writer = NdjsonWriter::new(&mut buffer);
789
790        writer.write_tagged("test_type", &42).unwrap();
791
792        let output = String::from_utf8(buffer).unwrap();
793        assert!(output.contains("\"type\":\"test_type\""));
794        assert!(output.contains("\"data\":42"));
795    }
796
797    #[test]
798    fn test_streaming_json_reporter_implements_writer_reporter() {
799        let reporter = StreamingJsonReporter::new();
800        // Verify it implements WriterReporter (compile-time check via trait method)
801        assert_eq!(WriterReporter::format(&reporter), ReportFormat::Json);
802    }
803
804    #[test]
805    fn test_ndjson_reporter_implements_writer_reporter() {
806        let reporter = NdjsonReporter::new();
807        assert_eq!(WriterReporter::format(&reporter), ReportFormat::Json);
808    }
809
810    #[test]
811    fn test_ndjson_writer_items_counted() {
812        let mut buffer = Vec::new();
813        let mut writer = NdjsonWriter::new(&mut buffer);
814
815        writer.write_item(&1).unwrap();
816        writer.write_item(&2).unwrap();
817        writer.write_item(&3).unwrap();
818
819        assert_eq!(writer.items_written(), 3);
820    }
821}