1use 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
39pub 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 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, items_written: 0,
64 }
65 }
66
67 #[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 fn write_object_start(&mut self) -> Result<(), ReportError> {
76 self.write_raw("{")?;
77 self.indent_level += 1;
78 Ok(())
79 }
80
81 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 fn write_array_start(&mut self) -> Result<(), ReportError> {
92 self.write_raw("[")?;
93 self.indent_level += 1;
94 Ok(())
95 }
96
97 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 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 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 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 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 fn write_raw(&mut self, s: &str) -> Result<(), ReportError> {
191 self.writer.write_all(s.as_bytes())?;
192 Ok(())
193 }
194
195 fn write_newline(&mut self) -> Result<(), ReportError> {
197 if self.pretty {
198 self.write_raw("\n")?;
199 }
200 Ok(())
201 }
202
203 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 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 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 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 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 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 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 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 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 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
385pub 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 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 #[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 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 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 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 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 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 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 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 if config.includes(ReportType::Components) {
521 self.write_diff_components(result)?;
522 }
523
524 if config.includes(ReportType::Vulnerabilities) {
526 self.write_diff_vulnerabilities(result)?;
527 }
528
529 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 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 #[must_use]
555 pub const fn items_written(&self) -> usize {
556 self.items_written
557 }
558}
559
560#[derive(Default)]
569pub struct StreamingJsonReporter {
570 pretty: bool,
571}
572
573impl StreamingJsonReporter {
574 #[must_use]
576 pub const fn new() -> Self {
577 Self { pretty: true }
578 }
579
580 #[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 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#[derive(Default)]
624pub struct NdjsonReporter;
625
626impl NdjsonReporter {
627 #[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 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 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#[derive(Default)]
710pub struct NdjsonReportGenerator;
711
712impl NdjsonReportGenerator {
713 #[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#[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#[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 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}