1use std::collections::BTreeMap;
5
6use schemars::JsonSchema;
7use serde::Serialize;
8use serde_json::Value;
9
10use crate::CadIr;
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
14pub struct ModifiedEntity {
15 pub id: String,
17 pub fields: Vec<String>,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
24pub struct ArenaDiff {
25 pub kind: String,
27 pub added: Vec<String>,
29 pub removed: Vec<String>,
31 pub modified: Vec<ModifiedEntity>,
33}
34
35#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
37pub struct IrDiff {
38 pub unit_change: Option<(crate::units::Units, crate::units::Units)>,
40 pub tolerance_change: Option<(crate::units::Tolerances, crate::units::Tolerances)>,
42 pub per_arena: Vec<ArenaDiff>,
44}
45
46impl IrDiff {
47 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
156pub 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}