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    /// Only `DeprecationToggled` produces an API call. Every other variant
43    /// is informational drift that `apply` cannot resolve, including
44    /// `PresentInGitOnly`: Braze has no creation endpoint for custom
45    /// attributes (they materialize on first `/users/track` call), so a
46    /// registry entry that isn't in Braze yet is normal — and must not
47    /// block apply of unrelated resources.
48    pub fn is_actionable(&self) -> bool {
49        matches!(self.op, CustomAttributeOp::DeprecationToggled { .. })
50    }
51}
52
53/// Compare a local registry against a remote (Braze) attribute set and
54/// produce one [`CustomAttributeDiff`] per attribute name across both
55/// sides. Results are sorted by name.
56///
57/// Either side may be `None` (no local file yet, or no remote
58/// attributes). When both are `None` the result is empty.
59pub 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
106/// Compare a single attribute present on both sides.
107///
108/// Priority order:
109///   1. `deprecated` flag → `DeprecationToggled` (the only actionable mutation)
110///   2. `description` text → `MetadataOnly`
111///   3. `attribute_type` → `Unchanged` (Braze is authoritative; see below)
112///
113/// When both `deprecated` and `description` differ, only
114/// `DeprecationToggled` is reported. This is by design: `apply` will
115/// push the deprecation toggle, and the user should re-run `export`
116/// afterwards to reconcile the description with Braze's state.
117fn 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    // attribute_type differences are treated as `Unchanged` — not
139    // `MetadataOnly` — because the semantics differ: MetadataOnly means
140    // "the user intentionally changed something that can't be pushed",
141    // whereas a type mismatch means "the local registry is stale" (Braze
142    // is the sole authority on types). The fix is always `export`, not
143    // `apply`.
144    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(&registry), &[]);
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(&registry), &remote);
201        assert_eq!(diffs.len(), 1);
202        // Last entry (deprecated=false) wins → matches remote → Unchanged.
203        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(&registry), &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(&registry), &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(&registry), &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(&registry), &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(&registry), &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(&registry), &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(&registry), &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(&registry), &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(&registry), &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(&registry), &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        // Verify snake_case format (Display), not Debug (Number/String).
413        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(&registry), &remote);
427        assert!(diffs[0].hints.is_empty());
428    }
429}