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