Skip to main content

sbom_tools/serialization/emit/
fidelity.rs

1//! Fidelity reporting for cross-format emission.
2//!
3//! Records — honestly, like protobom — which fields were synthesized from the
4//! canonical model, spliced back from preserved source JSON, or dropped because
5//! they have no representation in the target format.
6
7use std::collections::BTreeMap;
8
9/// A human-readable, counted report of what happened during emission.
10///
11/// Counts are keyed by a short field label so repeated decisions across many
12/// components collapse into one line (e.g. "synthesized bom-ref from canonical
13/// id (x42)").
14#[derive(Debug, Default, Clone)]
15pub struct FidelityReport {
16    /// Source format of the input document (for the report header).
17    source_format: String,
18    /// Target format of the emitted document.
19    target_format: String,
20    /// Fields synthesized from the typed model, keyed by label → count.
21    synthesized: BTreeMap<String, usize>,
22    /// Fields spliced back verbatim from preserved source JSON.
23    preserved: BTreeMap<String, usize>,
24    /// Fields dropped (no target representation), keyed by label → count.
25    dropped: BTreeMap<String, usize>,
26}
27
28impl FidelityReport {
29    /// Create a report for a `source` → `target` conversion.
30    #[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    /// Record that `label` was synthesized from the typed model.
42    pub fn synthesized(&mut self, label: impl Into<String>) {
43        *self.synthesized.entry(label.into()).or_insert(0) += 1;
44    }
45
46    /// Record that `label` was spliced back from preserved source JSON.
47    pub fn preserved(&mut self, label: impl Into<String>) {
48        *self.preserved.entry(label.into()).or_insert(0) += 1;
49    }
50
51    /// Record that `label` was dropped (no representation in the target format).
52    pub fn dropped(&mut self, label: impl Into<String>) {
53        *self.dropped.entry(label.into()).or_insert(0) += 1;
54    }
55
56    /// Whether any field was dropped (i.e. the conversion was lossy).
57    #[must_use]
58    pub fn is_lossy(&self) -> bool {
59        !self.dropped.is_empty()
60    }
61
62    /// Total number of distinct dropped-field labels.
63    #[must_use]
64    pub fn dropped_count(&self) -> usize {
65        self.dropped.len()
66    }
67
68    /// Render the report as a multi-line, stderr-friendly string.
69    #[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}