sbom_tools/serialization/emit/
fidelity.rs1use std::collections::BTreeMap;
8
9#[derive(Debug, Default, Clone)]
15pub struct FidelityReport {
16 source_format: String,
18 target_format: String,
20 synthesized: BTreeMap<String, usize>,
22 preserved: BTreeMap<String, usize>,
24 dropped: BTreeMap<String, usize>,
26}
27
28impl FidelityReport {
29 #[must_use]
31 pub fn new(source_format: impl Into<String>, target_format: impl Into<String>) -> Self {
32 Self {
33 source_format: source_format.into(),
34 target_format: target_format.into(),
35 synthesized: BTreeMap::new(),
36 preserved: BTreeMap::new(),
37 dropped: BTreeMap::new(),
38 }
39 }
40
41 pub fn synthesized(&mut self, label: impl Into<String>) {
43 *self.synthesized.entry(label.into()).or_insert(0) += 1;
44 }
45
46 pub fn preserved(&mut self, label: impl Into<String>) {
48 *self.preserved.entry(label.into()).or_insert(0) += 1;
49 }
50
51 pub fn dropped(&mut self, label: impl Into<String>) {
53 *self.dropped.entry(label.into()).or_insert(0) += 1;
54 }
55
56 #[must_use]
58 pub fn is_lossy(&self) -> bool {
59 !self.dropped.is_empty()
60 }
61
62 #[must_use]
64 pub fn dropped_count(&self) -> usize {
65 self.dropped.len()
66 }
67
68 #[must_use]
70 pub fn render(&self) -> String {
71 use std::fmt::Write as _;
72 let mut out = String::new();
73
74 let _ = writeln!(
75 out,
76 "Fidelity report: {} → {}",
77 self.source_format, self.target_format
78 );
79
80 Self::render_section(&mut out, "Synthesized from model", &self.synthesized);
81 Self::render_section(&mut out, "Preserved from source", &self.preserved);
82 Self::render_section(&mut out, "Dropped (no target mapping)", &self.dropped);
83
84 if self.dropped.is_empty() {
85 let _ = writeln!(out, " No fields dropped.");
86 }
87
88 out
89 }
90
91 fn render_section(out: &mut String, title: &str, entries: &BTreeMap<String, usize>) {
92 use std::fmt::Write as _;
93 if entries.is_empty() {
94 return;
95 }
96 let _ = writeln!(out, " {title}:");
97 for (label, count) in entries {
98 if *count > 1 {
99 let _ = writeln!(out, " - {label} (x{count})");
100 } else {
101 let _ = writeln!(out, " - {label}");
102 }
103 }
104 }
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110
111 #[test]
112 fn lossy_when_dropped() {
113 let mut r = FidelityReport::new("SPDX", "CycloneDX");
114 assert!(!r.is_lossy());
115 r.dropped("spdx annotations");
116 assert!(r.is_lossy());
117 assert_eq!(r.dropped_count(), 1);
118 }
119
120 #[test]
121 fn render_collapses_counts() {
122 let mut r = FidelityReport::new("SPDX", "CycloneDX");
123 r.synthesized("bom-ref from canonical id");
124 r.synthesized("bom-ref from canonical id");
125 let text = r.render();
126 assert!(text.contains("SPDX → CycloneDX"));
127 assert!(text.contains("bom-ref from canonical id (x2)"));
128 assert!(text.contains("No fields dropped."));
129 }
130}