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