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