1use crate::diff::opt_str_eq;
6use crate::resource::{CustomAttribute, CustomAttributeRegistry};
7use std::collections::{BTreeMap, BTreeSet};
8
9#[derive(Debug, Clone)]
10pub struct CustomAttributeDiff {
11 pub name: String,
12 pub op: CustomAttributeOp,
13 pub hints: Vec<String>,
16}
17
18#[derive(Debug, Clone)]
19pub enum CustomAttributeOp {
20 UnregisteredInGit,
22 PresentInGitOnly,
24 DeprecationToggled {
26 from: bool,
27 to: bool,
28 },
29 MetadataOnly,
31 Unchanged,
32}
33
34impl CustomAttributeDiff {
35 pub fn has_changes(&self) -> bool {
36 !matches!(self.op, CustomAttributeOp::Unchanged)
37 }
38
39 pub fn is_actionable(&self) -> bool {
49 matches!(
50 self.op,
51 CustomAttributeOp::DeprecationToggled { .. } | CustomAttributeOp::PresentInGitOnly
52 )
53 }
54}
55
56pub fn diff(
63 local: Option<&CustomAttributeRegistry>,
64 remote: &[CustomAttribute],
65) -> Vec<CustomAttributeDiff> {
66 let local_by_name: BTreeMap<&str, &CustomAttribute> = local
67 .map(|r| {
68 let mut map = BTreeMap::new();
69 for a in &r.attributes {
70 if map.insert(a.name.as_str(), a).is_some() {
71 tracing::warn!(
72 name = a.name.as_str(),
73 "duplicate custom attribute name in local registry; \
74 last entry wins (run `validate` to catch this)"
75 );
76 }
77 }
78 map
79 })
80 .unwrap_or_default();
81
82 let remote_by_name: BTreeMap<&str, &CustomAttribute> =
83 remote.iter().map(|a| (a.name.as_str(), a)).collect();
84
85 let mut all_names: BTreeSet<&str> = BTreeSet::new();
86 all_names.extend(local_by_name.keys());
87 all_names.extend(remote_by_name.keys());
88
89 let mut diffs = Vec::new();
90 for name in all_names {
91 let l = local_by_name.get(name);
92 let r = remote_by_name.get(name);
93 let (op, hints) = match (l, r) {
94 (Some(local_attr), Some(remote_attr)) => diff_single_attribute(local_attr, remote_attr),
95 (Some(_), None) => (CustomAttributeOp::PresentInGitOnly, Vec::new()),
96 (None, Some(_)) => (CustomAttributeOp::UnregisteredInGit, Vec::new()),
97 (None, None) => unreachable!("name came from one of the two maps"),
98 };
99 diffs.push(CustomAttributeDiff {
100 name: name.to_string(),
101 op,
102 hints,
103 });
104 }
105
106 diffs
107}
108
109fn diff_single_attribute(
121 local: &CustomAttribute,
122 remote: &CustomAttribute,
123) -> (CustomAttributeOp, Vec<String>) {
124 let mut hints = Vec::new();
125
126 if local.deprecated != remote.deprecated {
127 if !opt_str_eq(&local.description, &remote.description) {
128 hints.push("description also differs; will be reconciled on next export".into());
129 }
130 return (
131 CustomAttributeOp::DeprecationToggled {
132 from: remote.deprecated,
133 to: local.deprecated,
134 },
135 hints,
136 );
137 }
138 if !opt_str_eq(&local.description, &remote.description) {
139 return (CustomAttributeOp::MetadataOnly, hints);
140 }
141 if local.attribute_type != remote.attribute_type {
148 hints.push(format!(
149 "type mismatch: local {} vs Braze {} (run export to update)",
150 local.attribute_type.as_str(),
151 remote.attribute_type.as_str(),
152 ));
153 }
154 (CustomAttributeOp::Unchanged, hints)
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160 use crate::resource::CustomAttributeType;
161
162 fn attr(name: &str, deprecated: bool, desc: Option<&str>) -> CustomAttribute {
163 CustomAttribute {
164 name: name.into(),
165 attribute_type: CustomAttributeType::String,
166 description: desc.map(Into::into),
167 deprecated,
168 }
169 }
170
171 #[test]
172 fn both_sides_empty() {
173 let diffs = diff(None, &[]);
174 assert!(diffs.is_empty());
175 }
176
177 #[test]
178 fn local_only_attributes() {
179 let registry = CustomAttributeRegistry {
180 attributes: vec![attr("foo", false, None)],
181 };
182 let diffs = diff(Some(®istry), &[]);
183 assert_eq!(diffs.len(), 1);
184 assert_eq!(diffs[0].name, "foo");
185 assert!(matches!(diffs[0].op, CustomAttributeOp::PresentInGitOnly));
186 }
187
188 #[test]
189 fn remote_only_attributes() {
190 let remote = vec![attr("bar", false, None)];
191 let diffs = diff(None, &remote);
192 assert_eq!(diffs.len(), 1);
193 assert_eq!(diffs[0].name, "bar");
194 assert!(matches!(diffs[0].op, CustomAttributeOp::UnregisteredInGit));
195 }
196
197 #[test]
198 fn duplicate_local_name_uses_last_entry() {
199 let registry = CustomAttributeRegistry {
200 attributes: vec![attr("dup", true, None), attr("dup", false, None)],
201 };
202 let remote = vec![attr("dup", false, None)];
203 let diffs = diff(Some(®istry), &remote);
204 assert_eq!(diffs.len(), 1);
205 assert!(matches!(diffs[0].op, CustomAttributeOp::Unchanged));
207 }
208
209 #[test]
210 fn unchanged_attributes() {
211 let registry = CustomAttributeRegistry {
212 attributes: vec![attr("x", false, Some("desc"))],
213 };
214 let remote = vec![attr("x", false, Some("desc"))];
215 let diffs = diff(Some(®istry), &remote);
216 assert_eq!(diffs.len(), 1);
217 assert!(matches!(diffs[0].op, CustomAttributeOp::Unchanged));
218 }
219
220 #[test]
221 fn deprecation_toggled_local_deprecates() {
222 let registry = CustomAttributeRegistry {
223 attributes: vec![attr("x", true, None)],
224 };
225 let remote = vec![attr("x", false, None)];
226 let diffs = diff(Some(®istry), &remote);
227 assert_eq!(diffs.len(), 1);
228 match &diffs[0].op {
229 CustomAttributeOp::DeprecationToggled { from, to } => {
230 assert!(!from);
231 assert!(to);
232 }
233 other => panic!("expected DeprecationToggled, got {other:?}"),
234 }
235 }
236
237 #[test]
238 fn deprecation_toggled_local_reactivates() {
239 let registry = CustomAttributeRegistry {
240 attributes: vec![attr("x", false, None)],
241 };
242 let remote = vec![attr("x", true, None)];
243 let diffs = diff(Some(®istry), &remote);
244 match &diffs[0].op {
245 CustomAttributeOp::DeprecationToggled { from, to } => {
246 assert!(from);
247 assert!(!to);
248 }
249 other => panic!("expected DeprecationToggled, got {other:?}"),
250 }
251 }
252
253 #[test]
254 fn metadata_only_description_changed() {
255 let registry = CustomAttributeRegistry {
256 attributes: vec![attr("x", false, Some("new desc"))],
257 };
258 let remote = vec![attr("x", false, Some("old desc"))];
259 let diffs = diff(Some(®istry), &remote);
260 assert!(matches!(diffs[0].op, CustomAttributeOp::MetadataOnly));
261 }
262
263 #[test]
264 fn metadata_only_description_added() {
265 let registry = CustomAttributeRegistry {
266 attributes: vec![attr("x", false, Some("added"))],
267 };
268 let remote = vec![attr("x", false, None)];
269 let diffs = diff(Some(®istry), &remote);
270 assert!(matches!(diffs[0].op, CustomAttributeOp::MetadataOnly));
271 }
272
273 #[test]
274 fn deprecation_takes_precedence_over_metadata() {
275 let registry = CustomAttributeRegistry {
276 attributes: vec![CustomAttribute {
277 name: "x".into(),
278 attribute_type: CustomAttributeType::String,
279 description: Some("new desc".into()),
280 deprecated: true,
281 }],
282 };
283 let remote = vec![CustomAttribute {
284 name: "x".into(),
285 attribute_type: CustomAttributeType::String,
286 description: Some("old desc".into()),
287 deprecated: false,
288 }];
289 let diffs = diff(Some(®istry), &remote);
290 assert!(matches!(
291 diffs[0].op,
292 CustomAttributeOp::DeprecationToggled { .. }
293 ));
294 }
295
296 #[test]
297 fn mixed_operations_sorted_by_name() {
298 let registry = CustomAttributeRegistry {
299 attributes: vec![attr("charlie", false, None), attr("alpha", true, None)],
300 };
301 let remote = vec![attr("alpha", false, None), attr("bravo", false, None)];
302 let diffs = diff(Some(®istry), &remote);
303 assert_eq!(diffs.len(), 3);
304 assert_eq!(diffs[0].name, "alpha");
305 assert!(matches!(
306 diffs[0].op,
307 CustomAttributeOp::DeprecationToggled { .. }
308 ));
309 assert_eq!(diffs[1].name, "bravo");
310 assert!(matches!(diffs[1].op, CustomAttributeOp::UnregisteredInGit));
311 assert_eq!(diffs[2].name, "charlie");
312 assert!(matches!(diffs[2].op, CustomAttributeOp::PresentInGitOnly));
313 }
314
315 #[test]
316 fn type_difference_alone_is_unchanged() {
317 let registry = CustomAttributeRegistry {
318 attributes: vec![CustomAttribute {
319 name: "x".into(),
320 attribute_type: CustomAttributeType::Number,
321 description: None,
322 deprecated: false,
323 }],
324 };
325 let remote = vec![CustomAttribute {
326 name: "x".into(),
327 attribute_type: CustomAttributeType::String,
328 description: None,
329 deprecated: false,
330 }];
331 let diffs = diff(Some(®istry), &remote);
332 assert!(matches!(diffs[0].op, CustomAttributeOp::Unchanged));
333 }
334
335 #[test]
336 fn has_changes_correctly_classifies() {
337 let unchanged = CustomAttributeDiff {
338 name: "x".into(),
339 op: CustomAttributeOp::Unchanged,
340 hints: Vec::new(),
341 };
342 assert!(!unchanged.has_changes());
343
344 let changed = CustomAttributeDiff {
345 name: "x".into(),
346 op: CustomAttributeOp::PresentInGitOnly,
347 hints: Vec::new(),
348 };
349 assert!(changed.has_changes());
350 }
351
352 #[test]
353 fn is_actionable_correctly_classifies() {
354 let make = |op: CustomAttributeOp| CustomAttributeDiff {
355 name: "x".into(),
356 op,
357 hints: Vec::new(),
358 };
359 assert!(make(CustomAttributeOp::DeprecationToggled {
360 from: false,
361 to: true
362 })
363 .is_actionable());
364 assert!(make(CustomAttributeOp::PresentInGitOnly).is_actionable());
365 assert!(!make(CustomAttributeOp::MetadataOnly).is_actionable());
366 assert!(!make(CustomAttributeOp::UnregisteredInGit).is_actionable());
367 assert!(!make(CustomAttributeOp::Unchanged).is_actionable());
368 }
369
370 #[test]
371 fn deprecation_toggle_with_description_diff_adds_hint() {
372 let registry = CustomAttributeRegistry {
373 attributes: vec![CustomAttribute {
374 name: "x".into(),
375 attribute_type: CustomAttributeType::String,
376 description: Some("local desc".into()),
377 deprecated: true,
378 }],
379 };
380 let remote = vec![CustomAttribute {
381 name: "x".into(),
382 attribute_type: CustomAttributeType::String,
383 description: Some("remote desc".into()),
384 deprecated: false,
385 }];
386 let diffs = diff(Some(®istry), &remote);
387 assert!(matches!(
388 diffs[0].op,
389 CustomAttributeOp::DeprecationToggled { .. }
390 ));
391 assert_eq!(diffs[0].hints.len(), 1);
392 assert!(diffs[0].hints[0].contains("description"));
393 }
394
395 #[test]
396 fn type_mismatch_adds_hint_but_stays_unchanged() {
397 let registry = CustomAttributeRegistry {
398 attributes: vec![CustomAttribute {
399 name: "x".into(),
400 attribute_type: CustomAttributeType::Number,
401 description: None,
402 deprecated: false,
403 }],
404 };
405 let remote = vec![CustomAttribute {
406 name: "x".into(),
407 attribute_type: CustomAttributeType::String,
408 description: None,
409 deprecated: false,
410 }];
411 let diffs = diff(Some(®istry), &remote);
412 assert!(matches!(diffs[0].op, CustomAttributeOp::Unchanged));
413 assert_eq!(diffs[0].hints.len(), 1);
414 assert!(diffs[0].hints[0].contains("type mismatch"));
415 assert!(
417 diffs[0].hints[0].contains("local number vs Braze string"),
418 "hint should use snake_case: {}",
419 diffs[0].hints[0]
420 );
421 }
422
423 #[test]
424 fn no_hints_when_fully_matching() {
425 let registry = CustomAttributeRegistry {
426 attributes: vec![attr("x", false, Some("desc"))],
427 };
428 let remote = vec![attr("x", false, Some("desc"))];
429 let diffs = diff(Some(®istry), &remote);
430 assert!(diffs[0].hints.is_empty());
431 }
432}