Skip to main content

braze_sync/diff/
mod.rs

1//! Pure structural diff layer. No I/O.
2//!
3//! See IMPLEMENTATION.md §6.6 and §11. The shape of [`DiffOp`] and
4//! [`ResourceDiff`] is the central design contract: every diff site in the
5//! crate goes through these types so that adding a resource forces all
6//! match arms to be updated by the compiler.
7
8use crate::resource::ResourceKind;
9use similar::{ChangeTag, TextDiff};
10
11pub mod catalog;
12pub mod content_block;
13pub mod content_block_order;
14pub mod custom_attribute;
15pub mod email_template;
16pub mod orphan;
17pub mod tag;
18
19#[derive(Debug, Clone)]
20pub struct TextDiffSummary {
21    pub additions: usize,
22    pub deletions: usize,
23}
24
25pub(crate) fn compute_text_diff(from: &str, to: &str) -> TextDiffSummary {
26    let diff = TextDiff::from_lines(from, to);
27    let mut additions = 0;
28    let mut deletions = 0;
29    for change in diff.iter_all_changes() {
30        match change.tag() {
31            ChangeTag::Insert => additions += 1,
32            ChangeTag::Delete => deletions += 1,
33            ChangeTag::Equal => {}
34        }
35    }
36    TextDiffSummary {
37        additions,
38        deletions,
39    }
40}
41
42/// Treats `None` and `Some("")` as equal — Braze may omit a field or
43/// return an empty string interchangeably.
44pub(crate) fn opt_str_eq(a: &Option<String>, b: &Option<String>) -> bool {
45    a.as_deref().unwrap_or("") == b.as_deref().unwrap_or("")
46}
47
48/// Multiset equality: same elements after sort, ignoring order.
49pub(crate) fn tags_eq_unordered(a: &[String], b: &[String]) -> bool {
50    if a.len() != b.len() {
51        return false;
52    }
53    let mut a: Vec<&str> = a.iter().map(String::as_str).collect();
54    let mut b: Vec<&str> = b.iter().map(String::as_str).collect();
55    a.sort_unstable();
56    b.sort_unstable();
57    a == b
58}
59
60/// A diff operation on a single entity. Polymorphic over the entity type so
61/// the same vocabulary applies to whole resources, individual fields, etc.
62#[derive(Debug, Clone, PartialEq)]
63pub enum DiffOp<T> {
64    Added(T),
65    Removed(T),
66    Modified { from: T, to: T },
67    Unchanged,
68}
69
70impl<T> DiffOp<T> {
71    pub fn is_change(&self) -> bool {
72        !matches!(self, Self::Unchanged)
73    }
74
75    pub fn is_destructive(&self) -> bool {
76        matches!(self, Self::Removed(_))
77    }
78}
79
80/// Per-resource-kind diff result.
81#[derive(Debug, Clone)]
82pub enum ResourceDiff {
83    CatalogSchema(catalog::CatalogSchemaDiff),
84    ContentBlock(content_block::ContentBlockDiff),
85    EmailTemplate(email_template::EmailTemplateDiff),
86    CustomAttribute(custom_attribute::CustomAttributeDiff),
87    Tag(tag::TagDiff),
88}
89
90impl ResourceDiff {
91    pub fn kind(&self) -> ResourceKind {
92        match self {
93            Self::CatalogSchema(_) => ResourceKind::CatalogSchema,
94            Self::ContentBlock(_) => ResourceKind::ContentBlock,
95            Self::EmailTemplate(_) => ResourceKind::EmailTemplate,
96            Self::CustomAttribute(_) => ResourceKind::CustomAttribute,
97            Self::Tag(_) => ResourceKind::Tag,
98        }
99    }
100
101    pub fn name(&self) -> &str {
102        match self {
103            Self::CatalogSchema(d) => &d.name,
104            Self::ContentBlock(d) => &d.name,
105            Self::EmailTemplate(d) => &d.name,
106            Self::CustomAttribute(d) => &d.name,
107            Self::Tag(d) => &d.name,
108        }
109    }
110
111    pub fn has_changes(&self) -> bool {
112        match self {
113            Self::CatalogSchema(d) => d.has_changes(),
114            Self::ContentBlock(d) => d.has_changes(),
115            Self::EmailTemplate(d) => d.has_changes(),
116            Self::CustomAttribute(d) => d.has_changes(),
117            Self::Tag(d) => d.has_changes(),
118        }
119    }
120
121    /// Whether `apply` can act on this diff. For most resource types this
122    /// is the same as `has_changes()`. Custom Attributes are the exception:
123    /// only `DeprecationToggled` produces an API call. `MetadataOnly`,
124    /// `UnregisteredInGit`, and `PresentInGitOnly` are all informational
125    /// drift — Braze has no create endpoint for custom attributes (they
126    /// materialize on first `/users/track`), so registry-only entries are
127    /// expected and must not block apply.
128    pub fn is_actionable(&self) -> bool {
129        match self {
130            Self::CustomAttribute(d) => d.is_actionable(),
131            // Tag drift is informational from apply's perspective: Braze has
132            // no tag-mutation API, so `apply --kind tag` cannot push changes.
133            // Pre-flight (in cli/apply.rs) consumes Tag diffs separately to
134            // block apply when a referenced tag is unregistered.
135            Self::Tag(_) => false,
136            other => other.has_changes(),
137        }
138    }
139
140    pub fn has_destructive(&self) -> bool {
141        match self {
142            Self::CatalogSchema(d) => d.has_destructive(),
143            // Content Block / Email Template have no DELETE API. "Destructive"
144            // for these resources is reframed as orphan tracking (§11.6); the
145            // apply path performs no destructive call.
146            Self::ContentBlock(_) => false,
147            Self::EmailTemplate(_) => false,
148            // Custom Attribute "removal" is only a deprecation flag toggle.
149            Self::CustomAttribute(_) => false,
150            // Tags have no API mutation at all.
151            Self::Tag(_) => false,
152        }
153    }
154
155    pub fn is_orphan(&self) -> bool {
156        match self {
157            Self::ContentBlock(d) => d.is_orphan(),
158            Self::EmailTemplate(d) => d.is_orphan(),
159            _ => false,
160        }
161    }
162}
163
164#[derive(Debug, Clone, Default)]
165pub struct DiffSummary {
166    pub diffs: Vec<ResourceDiff>,
167}
168
169impl DiffSummary {
170    pub fn changed_count(&self) -> usize {
171        self.diffs.iter().filter(|d| d.has_changes()).count()
172    }
173
174    /// Count of diffs that `apply` can actually act on. Excludes
175    /// informational-only drift (e.g. Custom Attribute metadata-only).
176    pub fn actionable_count(&self) -> usize {
177        self.diffs.iter().filter(|d| d.is_actionable()).count()
178    }
179
180    pub fn destructive_count(&self) -> usize {
181        self.diffs.iter().filter(|d| d.has_destructive()).count()
182    }
183
184    pub fn orphan_count(&self) -> usize {
185        self.diffs.iter().filter(|d| d.is_orphan()).count()
186    }
187
188    pub fn in_sync_count(&self) -> usize {
189        self.diffs.iter().filter(|d| !d.has_changes()).count()
190    }
191}