use std::collections::BTreeMap;
#[derive(Debug, Default, Clone)]
pub struct FidelityReport {
source_format: String,
target_format: String,
synthesized: BTreeMap<String, usize>,
preserved: BTreeMap<String, usize>,
dropped: BTreeMap<String, usize>,
}
impl FidelityReport {
#[must_use]
pub fn new(source_format: impl Into<String>, target_format: impl Into<String>) -> Self {
Self {
source_format: source_format.into(),
target_format: target_format.into(),
synthesized: BTreeMap::new(),
preserved: BTreeMap::new(),
dropped: BTreeMap::new(),
}
}
pub fn synthesized(&mut self, label: impl Into<String>) {
*self.synthesized.entry(label.into()).or_insert(0) += 1;
}
pub fn preserved(&mut self, label: impl Into<String>) {
*self.preserved.entry(label.into()).or_insert(0) += 1;
}
pub fn dropped(&mut self, label: impl Into<String>) {
*self.dropped.entry(label.into()).or_insert(0) += 1;
}
#[must_use]
pub fn is_lossy(&self) -> bool {
!self.dropped.is_empty()
}
#[must_use]
pub fn dropped_count(&self) -> usize {
self.dropped.len()
}
#[must_use]
pub fn render(&self) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(
out,
"Fidelity report: {} → {}",
self.source_format, self.target_format
);
Self::render_section(&mut out, "Synthesized from model", &self.synthesized);
Self::render_section(&mut out, "Preserved from source", &self.preserved);
Self::render_section(&mut out, "Dropped (no target mapping)", &self.dropped);
if self.dropped.is_empty() {
let _ = writeln!(out, " No fields dropped.");
}
out
}
fn render_section(out: &mut String, title: &str, entries: &BTreeMap<String, usize>) {
use std::fmt::Write as _;
if entries.is_empty() {
return;
}
let _ = writeln!(out, " {title}:");
for (label, count) in entries {
if *count > 1 {
let _ = writeln!(out, " - {label} (x{count})");
} else {
let _ = writeln!(out, " - {label}");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lossy_when_dropped() {
let mut r = FidelityReport::new("SPDX", "CycloneDX");
assert!(!r.is_lossy());
r.dropped("spdx annotations");
assert!(r.is_lossy());
assert_eq!(r.dropped_count(), 1);
}
#[test]
fn render_collapses_counts() {
let mut r = FidelityReport::new("SPDX", "CycloneDX");
r.synthesized("bom-ref from canonical id");
r.synthesized("bom-ref from canonical id");
let text = r.render();
assert!(text.contains("SPDX → CycloneDX"));
assert!(text.contains("bom-ref from canonical id (x2)"));
assert!(text.contains("No fields dropped."));
}
}