use std::collections::BTreeMap;
use crate::compare::{floats_agree, is_local_digest_attribute, values_agree};
#[cfg(feature = "schema")]
use schemars::JsonSchema;
use serde::Serialize;
use serde_json::Value;
use crate::document::SourceMeta;
use crate::CadIr;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct AttributeChange {
pub key: String,
pub left: Option<String>,
pub right: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct SourceDiff {
pub format_change: Option<(String, String)>,
pub attributes: Vec<AttributeChange>,
pub local_digests: Vec<AttributeChange>,
}
impl SourceDiff {
#[must_use]
pub fn is_empty(&self) -> bool {
self.format_change.is_none() && self.attributes.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct ModifiedEntity {
pub id: String,
pub fields: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct ArenaDiff {
pub kind: String,
pub added: Vec<String>,
pub removed: Vec<String>,
pub modified: Vec<ModifiedEntity>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct IrDiff {
pub unit_change: Option<(crate::units::Units, crate::units::Units)>,
pub tolerance_change: Option<(crate::units::Tolerances, crate::units::Tolerances)>,
pub source: SourceDiff,
pub per_arena: Vec<ArenaDiff>,
}
impl IrDiff {
pub fn is_empty(&self) -> bool {
self.unit_change.is_none()
&& self.tolerance_change.is_none()
&& self.source.is_empty()
&& self.per_arena.iter().all(|arena| {
arena.added.is_empty() && arena.removed.is_empty() && arena.modified.is_empty()
})
}
}
fn differing_fields<T: Serialize>(left: &T, right: &T) -> Vec<String> {
let (Ok(Value::Object(left)), Ok(Value::Object(right))) =
(serde_json::to_value(left), serde_json::to_value(right))
else {
return vec!["value".to_string()];
};
left.keys()
.chain(right.keys())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.filter(|key| match (left.get(*key), right.get(*key)) {
(Some(before), Some(after)) => values_agree(before, after).is_err(),
(None, None) => false,
_ => true,
})
.cloned()
.collect()
}
fn arena<T, F>(kind: impl Into<String>, left: &[T], right: &[T], identity: F) -> ArenaDiff
where
T: PartialEq + Serialize,
F: for<'a> Fn(&'a T) -> &'a str,
{
let left: BTreeMap<_, _> = left
.iter()
.map(|entity| (identity(entity), entity))
.collect();
let right: BTreeMap<_, _> = right
.iter()
.map(|entity| (identity(entity), entity))
.collect();
let removed = left
.keys()
.filter(|id| !right.contains_key(*id))
.map(|id| (*id).to_owned())
.collect();
let added = right
.keys()
.filter(|id| !left.contains_key(*id))
.map(|id| (*id).to_owned())
.collect();
let modified = left
.iter()
.filter_map(|(id, before)| {
let after = right.get(id)?;
if *before == *after {
return None;
}
let fields = differing_fields(*before, *after);
(!fields.is_empty()).then(|| ModifiedEntity {
id: (*id).to_owned(),
fields,
})
})
.collect();
ArenaDiff {
kind: kind.into(),
added,
removed,
modified,
}
}
macro_rules! define_diff_arenas {
($( $field:ident: $element:ty, $doc:literal, [$($attribute:meta),*]; )*) => {
fn diff_arenas(left: &CadIr, right: &CadIr) -> Vec<ArenaDiff> {
vec![$(arena(
stringify!($field),
&left.model.$field,
&right.model.$field,
crate::schema::EntitySchema::identity,
)),*]
}
};
}
crate::document::arena_registry!(define_diff_arenas);
fn diff_native_namespaces(left: &CadIr, right: &CadIr) -> Vec<ArenaDiff> {
let namespaces = left
.native
.0
.keys()
.chain(right.native.0.keys())
.collect::<std::collections::BTreeSet<_>>();
namespaces
.into_iter()
.flat_map(|namespace| {
let left_ns = left.native.namespace(namespace);
let right_ns = right.native.namespace(namespace);
let arenas = left_ns
.into_iter()
.flat_map(|value| value.arenas.keys())
.chain(right_ns.into_iter().flat_map(|value| value.arenas.keys()))
.collect::<std::collections::BTreeSet<_>>();
arenas.into_iter().map(move |name| {
arena(
format!("native.{namespace}.{name}"),
left_ns
.and_then(|value| value.arenas.get(name))
.map(Vec::as_slice)
.unwrap_or_default(),
right_ns
.and_then(|value| value.arenas.get(name))
.map(Vec::as_slice)
.unwrap_or_default(),
|record| record.id(),
)
})
})
.collect()
}
fn tolerances_agree(left: crate::units::Tolerances, right: crate::units::Tolerances) -> bool {
floats_agree(left.linear, right.linear) && floats_agree(left.angular, right.angular)
}
fn diff_source(left: &CadIr, right: &CadIr) -> SourceDiff {
let absent = SourceMeta::default();
let left = left.source.as_ref().unwrap_or(&absent);
let right = right.source.as_ref().unwrap_or(&absent);
let mut result = SourceDiff {
format_change: (left.format != right.format)
.then(|| (left.format.clone(), right.format.clone())),
..SourceDiff::default()
};
let keys = left
.attributes
.keys()
.chain(right.attributes.keys())
.collect::<std::collections::BTreeSet<_>>();
for key in keys {
let before = left.attributes.get(key);
let after = right.attributes.get(key);
if before == after {
continue;
}
let change = AttributeChange {
key: key.clone(),
left: before.cloned(),
right: after.cloned(),
};
if is_local_digest_attribute(key) {
result.local_digests.push(change);
} else {
result.attributes.push(change);
}
}
result
}
pub fn diff(left: &CadIr, right: &CadIr) -> IrDiff {
let unit_change =
(left.units != right.units).then(|| (left.units.clone(), right.units.clone()));
let tolerance_change = (!tolerances_agree(left.tolerances, right.tolerances))
.then_some((left.tolerances, right.tolerances));
let mut per_arena = diff_arenas(left, right);
per_arena.extend(diff_native_namespaces(left, right));
IrDiff {
unit_change,
tolerance_change,
source: diff_source(left, right),
per_arena,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::diff;
use crate::compare;
use crate::examples::unit_cube;
#[test]
fn detects_changes_in_all_document_dimensions() {
let left = unit_cube();
let mut right = left.clone();
right.model.points[0].position.x += 1.0;
right.model.loops.pop();
right.model.coedges.pop();
let result = diff(&left, &right);
assert!(!result.is_empty());
assert_eq!(
result
.per_arena
.iter()
.find(|a| a.kind == "points")
.expect("required invariant")
.modified[0]
.fields,
["position"]
);
assert_eq!(
result
.per_arena
.iter()
.find(|a| a.kind == "loops")
.expect("required invariant")
.removed
.len(),
1
);
assert_eq!(
result
.per_arena
.iter()
.find(|a| a.kind == "coedges")
.expect("required invariant")
.removed
.len(),
1
);
}
fn modified(result: &super::IrDiff, kind: &str) -> Vec<String> {
result
.per_arena
.iter()
.find(|arena| arena.kind == kind)
.expect("every model arena appears in every diff")
.modified
.iter()
.map(|entity| entity.id.clone())
.collect()
}
fn scaled_point(ir: &crate::CadIr) -> usize {
ir.model
.points
.iter()
.position(|point| point.position.x.abs() >= 1.0)
.expect("the cube fixture places points away from the origin")
}
#[test]
fn a_last_place_coordinate_move_is_not_a_difference() {
let left = unit_cube();
let mut right = left.clone();
let index = scaled_point(&left);
let before = right.model.points[index].position.x;
let after = f64::from_bits(before.to_bits() + 1);
assert_ne!(
before.to_bits(),
after.to_bits(),
"the coordinate must move, or this test proves nothing"
);
right.model.points[index].position.x = after;
assert_ne!(
serde_json::to_value(&left.model.points).unwrap(),
serde_json::to_value(&right.model.points).unwrap(),
"the serialized documents must differ, or exact equality would pass too"
);
let result = diff(&left, &right);
assert!(result.is_empty(), "{result:?}");
}
#[test]
fn a_last_place_tolerance_move_is_not_a_difference() {
let left = unit_cube();
let mut right = left.clone();
right.tolerances.linear = f64::from_bits(left.tolerances.linear.to_bits() + 1);
assert!(diff(&left, &right).is_empty());
right.tolerances.linear = left.tolerances.linear * 2.0;
assert!(diff(&left, &right).tolerance_change.is_some());
}
#[test]
fn a_genuine_coordinate_change_is_still_reported() {
let left = unit_cube();
let mut right = left.clone();
let index = scaled_point(&left);
let point = &mut right.model.points[index].position;
point.x = point.x.mul_add(1e-6, point.x);
let result = diff(&left, &right);
assert!(!result.is_empty());
assert_eq!(
modified(&result, "points"),
[left.model.points[index].id.0.clone()]
);
}
#[test]
fn an_integer_field_differing_by_one_is_always_reported() {
use crate::geometry::{Curve, CurveGeometry, NurbsCurve};
use crate::ids::CurveId;
use crate::math::Point3;
let nurbs = |degree: u32| Curve {
id: CurveId("synthetic:tolerance:curve#nurbs".into()),
geometry: CurveGeometry::Nurbs(NurbsCurve {
degree,
knots: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
control_points: vec![
Point3::new(0.0, 0.0, 0.0),
Point3::new(1.0, 0.0, 0.0),
Point3::new(2.0, 0.0, 0.0),
],
weights: None,
periodic: false,
}),
source_object: None,
};
let mut left = unit_cube();
let mut right = left.clone();
left.model.curves.push(nurbs(2));
right.model.curves.push(nurbs(3));
let result = diff(&left, &right);
assert!(!result.is_empty());
assert_eq!(
modified(&result, "curves"),
["synthetic:tolerance:curve#nurbs"]
);
}
fn with_source(attributes: &[(&str, &str)]) -> crate::CadIr {
let mut ir = unit_cube();
ir.source = Some(crate::document::SourceMeta {
format: "synthetic".into(),
attributes: attributes
.iter()
.map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
.collect(),
});
ir
}
#[test]
fn a_source_attribute_difference_is_a_difference() {
let left = with_source(&[("program_version", "1.0"), ("object_count", "3")]);
let right = with_source(&[("program_version", "1.1"), ("object_count", "3")]);
let result = diff(&left, &right);
assert!(!result.is_empty());
assert_eq!(
result.source.attributes,
[super::AttributeChange {
key: "program_version".to_owned(),
left: Some("1.0".to_owned()),
right: Some("1.1".to_owned()),
}]
);
assert!(result.source.local_digests.is_empty());
}
#[test]
fn an_added_or_removed_source_attribute_is_a_difference() {
let left = with_source(&[("object_count", "3")]);
let right = with_source(&[]);
let forward = diff(&left, &right);
assert!(!forward.is_empty());
assert_eq!(forward.source.attributes[0].left.as_deref(), Some("3"));
assert_eq!(forward.source.attributes[0].right, None);
let backward = diff(&right, &left);
assert!(!backward.is_empty());
assert_eq!(backward.source.attributes[0].left, None);
assert_eq!(backward.source.attributes[0].right.as_deref(), Some("3"));
}
#[test]
fn a_machine_local_digest_difference_is_reported_but_is_not_a_difference() {
let left = with_source(&[("document_local_sha256", &"0".repeat(64))]);
let right = with_source(&[("document_local_sha256", &"1".repeat(64))]);
let result = diff(&left, &right);
assert_ne!(left.source, right.source);
assert!(result.is_empty(), "{result:?}");
assert!(result.source.attributes.is_empty());
assert_eq!(
result
.source
.local_digests
.iter()
.map(|change| change.key.as_str())
.collect::<Vec<_>>(),
["document_local_sha256"]
);
}
#[test]
fn the_carve_out_follows_the_suffix_convention() {
let key = format!("future_codec_thing{}", compare::LOCAL_DIGEST_SUFFIX);
let left = with_source(&[(&key, "a"), ("footer_fingerprint", "f")]);
let right = with_source(&[(&key, "b"), ("footer_fingerprint", "f")]);
let result = diff(&left, &right);
assert!(result.is_empty(), "{result:?}");
assert_eq!(result.source.local_digests[0].key, key);
let right = with_source(&[(&key, "b"), ("footer_fingerprint", "g")]);
let result = diff(&left, &right);
assert!(!result.is_empty());
assert_eq!(result.source.attributes[0].key, "footer_fingerprint");
}
#[test]
fn absent_source_metadata_compares_without_panicking() {
let mut bare = unit_cube();
bare.source = None;
let populated = with_source(&[("object_count", "3")]);
for (left, right) in [(&bare, &populated), (&populated, &bare)] {
let result = diff(left, right);
assert!(!result.is_empty());
assert!(result.source.format_change.is_some());
assert_eq!(result.source.attributes.len(), 1);
}
let mut empty_source = unit_cube();
empty_source.source = Some(crate::document::SourceMeta::default());
assert!(diff(&bare, &empty_source).is_empty());
}
#[test]
fn identical_documents_have_empty_diff() {
let ir = unit_cube();
assert!(diff(&ir, &ir).is_empty());
}
}