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