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