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