Skip to main content

cadmpeg_ir/
diff.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Structural comparison of IR documents.
3
4use std::collections::BTreeMap;
5
6use schemars::JsonSchema;
7use serde::Serialize;
8use serde_json::Value;
9
10use crate::CadIr;
11
12/// A modified entity and its differing top-level fields.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
14pub struct ModifiedEntity {
15    /// Diff key of the entity, as produced by the arena's key function.
16    pub id: String,
17    /// Names of the top-level entity fields whose JSON-serialized values differ
18    /// between the two documents.
19    pub fields: Vec<String>,
20}
21
22/// Changes within one entity arena.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
24pub struct ArenaDiff {
25    /// Arena name, matching the field name in [`crate::CadIr`] (e.g. `"faces"`).
26    pub kind: String,
27    /// Diff keys of entities present only in the right-hand document.
28    pub added: Vec<String>,
29    /// Diff keys of entities present only in the left-hand document.
30    pub removed: Vec<String>,
31    /// Entities present in both documents with at least one differing field.
32    pub modified: Vec<ModifiedEntity>,
33}
34
35/// Structural changes between two IR documents.
36#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
37pub struct IrDiff {
38    /// `(left, right)` units, present only when the two documents' units differ.
39    pub unit_change: Option<(crate::units::Units, crate::units::Units)>,
40    /// `(left, right)` tolerances, present only when the two documents' tolerances differ.
41    pub tolerance_change: Option<(crate::units::Tolerances, crate::units::Tolerances)>,
42    /// Per-arena diffs, one entry per arena compared.
43    pub per_arena: Vec<ArenaDiff>,
44}
45
46impl IrDiff {
47    /// Returns `true` when neither units, tolerances, nor any arena differ.
48    pub fn is_empty(&self) -> bool {
49        self.unit_change.is_none()
50            && self.tolerance_change.is_none()
51            && self.per_arena.iter().all(|arena| {
52                arena.added.is_empty() && arena.removed.is_empty() && arena.modified.is_empty()
53            })
54    }
55}
56
57fn differing_fields<T: Serialize>(left: &T, right: &T) -> Vec<String> {
58    let (Ok(Value::Object(left)), Ok(Value::Object(right))) =
59        (serde_json::to_value(left), serde_json::to_value(right))
60    else {
61        return vec!["value".to_string()];
62    };
63    left.keys()
64        .chain(right.keys())
65        .collect::<std::collections::BTreeSet<_>>()
66        .into_iter()
67        .filter(|key| left.get(*key) != right.get(*key))
68        .cloned()
69        .collect()
70}
71
72fn arena<T, F>(kind: impl Into<String>, left: &[T], right: &[T], id: F) -> ArenaDiff
73where
74    T: PartialEq + Serialize,
75    F: Fn(&T) -> String,
76{
77    let left: BTreeMap<_, _> = left.iter().map(|entity| (id(entity), entity)).collect();
78    let right: BTreeMap<_, _> = right.iter().map(|entity| (id(entity), entity)).collect();
79    let removed = left
80        .keys()
81        .filter(|id| !right.contains_key(*id))
82        .cloned()
83        .collect();
84    let added = right
85        .keys()
86        .filter(|id| !left.contains_key(*id))
87        .cloned()
88        .collect();
89    let modified = left
90        .iter()
91        .filter_map(|(id, before)| {
92            let after = right.get(id)?;
93            (*before != *after).then(|| ModifiedEntity {
94                id: id.clone(),
95                fields: differing_fields(*before, *after),
96            })
97        })
98        .collect();
99    ArenaDiff {
100        kind: kind.into(),
101        added,
102        removed,
103        modified,
104    }
105}
106
107macro_rules! define_diff_arenas {
108    ($( $field:ident: $element:ty, $doc:literal, [$($attribute:meta),*] => $key:expr; )*) => {
109        fn diff_arenas(left: &CadIr, right: &CadIr) -> Vec<ArenaDiff> {
110            vec![$(arena(
111                stringify!($field),
112                &left.model.$field,
113                &right.model.$field,
114                $key,
115            )),*]
116        }
117    };
118}
119crate::document::arena_registry!(define_diff_arenas);
120
121fn diff_native_namespaces(left: &CadIr, right: &CadIr) -> Vec<ArenaDiff> {
122    let namespaces = left
123        .native
124        .0
125        .keys()
126        .chain(right.native.0.keys())
127        .collect::<std::collections::BTreeSet<_>>();
128    namespaces
129        .into_iter()
130        .flat_map(|namespace| {
131            let left_ns = left.native.namespace(namespace);
132            let right_ns = right.native.namespace(namespace);
133            let arenas = left_ns
134                .into_iter()
135                .flat_map(|value| value.arenas.keys())
136                .chain(right_ns.into_iter().flat_map(|value| value.arenas.keys()))
137                .collect::<std::collections::BTreeSet<_>>();
138            arenas.into_iter().map(move |name| {
139                arena(
140                    format!("native.{namespace}.{name}"),
141                    left_ns
142                        .and_then(|value| value.arenas.get(name))
143                        .map(Vec::as_slice)
144                        .unwrap_or_default(),
145                    right_ns
146                        .and_then(|value| value.arenas.get(name))
147                        .map(Vec::as_slice)
148                        .unwrap_or_default(),
149                    |record| record.id.clone(),
150                )
151            })
152        })
153        .collect()
154}
155
156/// Compare units, tolerances, and every entity arena by stable entity ID.
157pub fn diff(left: &CadIr, right: &CadIr) -> IrDiff {
158    let unit_change =
159        (left.units != right.units).then(|| (left.units.clone(), right.units.clone()));
160    let tolerance_change =
161        (left.tolerances != right.tolerances).then_some((left.tolerances, right.tolerances));
162    let mut per_arena = diff_arenas(left, right);
163    per_arena.extend(diff_native_namespaces(left, right));
164    IrDiff {
165        unit_change,
166        tolerance_change,
167        per_arena,
168    }
169}
170
171#[cfg(test)]
172#[allow(clippy::unwrap_used)]
173mod tests {
174    use super::diff;
175    use crate::examples::unit_cube;
176
177    #[test]
178    fn detects_changes_in_all_document_dimensions() {
179        let left = unit_cube();
180        let mut right = left.clone();
181        right.model.points[0].position.x += 1.0;
182        right.model.loops.pop();
183        right.model.coedges.pop();
184
185        let result = diff(&left, &right);
186        assert!(!result.is_empty());
187        assert_eq!(
188            result
189                .per_arena
190                .iter()
191                .find(|a| a.kind == "points")
192                .expect("required invariant")
193                .modified[0]
194                .fields,
195            ["position"]
196        );
197        assert_eq!(
198            result
199                .per_arena
200                .iter()
201                .find(|a| a.kind == "loops")
202                .expect("required invariant")
203                .removed
204                .len(),
205            1
206        );
207        assert_eq!(
208            result
209                .per_arena
210                .iter()
211                .find(|a| a.kind == "coedges")
212                .expect("required invariant")
213                .removed
214                .len(),
215            1
216        );
217    }
218
219    #[test]
220    fn identical_documents_have_empty_diff() {
221        let ir = unit_cube();
222        assert!(diff(&ir, &ir).is_empty());
223    }
224}