Skip to main content

braze_sync/diff/
custom_attribute.rs

1//! Custom Attribute diff types and registry comparison.
2//!
3//! The only mutation `apply` can perform is the deprecation flag toggle.
4
5use 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    /// Non-actionable notes surfaced in diff output (e.g. "description
14    /// also differs", "type is stale"). These do NOT count as changes.
15    pub hints: Vec<String>,
16}
17
18#[derive(Debug, Clone)]
19pub enum CustomAttributeOp {
20    /// Present in Braze but missing from local registry. Action: prompt `export`.
21    UnregisteredInGit,
22    /// Present in local registry but not in Braze. Often a typo.
23    PresentInGitOnly,
24    /// `deprecated` flag changed. The only mutation `apply` actually performs.
25    DeprecationToggled {
26        from: bool,
27        to: bool,
28    },
29    /// Only the description changed. No API to update it, so `apply` is a no-op.
30    MetadataOnly,
31    Unchanged,
32}
33
34impl CustomAttributeDiff {
35    pub fn has_changes(&self) -> bool {
36        !matches!(self.op, CustomAttributeOp::Unchanged)
37    }
38
39    /// Whether `apply` should consider this diff actionable — i.e. it
40    /// must not be skipped by the "No changes to apply" early exit.
41    ///
42    /// - `DeprecationToggled` produces an API call.
43    /// - `PresentInGitOnly` is included so it reaches
44    ///   `check_for_unsupported_ops` and produces a clear rejection
45    ///   error rather than being silently ignored.
46    /// - `MetadataOnly` and `UnregisteredInGit` are informational drift
47    ///   that `apply` cannot resolve (the fix is `export`, not `apply`).
48    pub fn is_actionable(&self) -> bool {
49        matches!(
50            self.op,
51            CustomAttributeOp::DeprecationToggled { .. } | CustomAttributeOp::PresentInGitOnly
52        )
53    }
54}
55
56/// Compare a local registry against a remote (Braze) attribute set and
57/// produce one [`CustomAttributeDiff`] per attribute name across both
58/// sides. Results are sorted by name.
59///
60/// Either side may be `None` (no local file yet, or no remote
61/// attributes). When both are `None` the result is empty.
62pub 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
109/// Compare a single attribute present on both sides.
110///
111/// Priority order:
112///   1. `deprecated` flag → `DeprecationToggled` (the only actionable mutation)
113///   2. `description` text → `MetadataOnly`
114///   3. `attribute_type` → `Unchanged` (Braze is authoritative; see below)
115///
116/// When both `deprecated` and `description` differ, only
117/// `DeprecationToggled` is reported. This is by design: `apply` will
118/// push the deprecation toggle, and the user should re-run `export`
119/// afterwards to reconcile the description with Braze's state.
120fn 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    // attribute_type differences are treated as `Unchanged` — not
142    // `MetadataOnly` — because the semantics differ: MetadataOnly means
143    // "the user intentionally changed something that can't be pushed",
144    // whereas a type mismatch means "the local registry is stale" (Braze
145    // is the sole authority on types). The fix is always `export`, not
146    // `apply`.
147    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(&registry), &[]);
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(&registry), &remote);
204        assert_eq!(diffs.len(), 1);
205        // Last entry (deprecated=false) wins → matches remote → Unchanged.
206        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(&registry), &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(&registry), &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(&registry), &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(&registry), &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(&registry), &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(&registry), &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(&registry), &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(&registry), &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(&registry), &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(&registry), &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        // Verify snake_case format (Display), not Debug (Number/String).
416        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(&registry), &remote);
430        assert!(diffs[0].hints.is_empty());
431    }
432}