Skip to main content

braze_sync/cli/
diff.rs

1//! `braze-sync diff` — show drift between local files and Braze.
2//!
3//! Plan output goes to stdout (so `braze-sync diff > drift.txt` is
4//! clean); warnings go to stderr. With `--fail-on-drift`, any drift
5//! exits 2 so CI can gate on a clean tree.
6
7use crate::braze::error::BrazeApiError;
8use crate::braze::BrazeClient;
9use crate::config::{is_excluded, ResolvedConfig};
10use crate::diff::catalog::diff_schema;
11use crate::diff::content_block::{
12    diff as diff_content_block, ContentBlockDiff, ContentBlockIdIndex,
13};
14use crate::diff::custom_attribute::diff as diff_custom_attributes;
15use crate::diff::email_template::{
16    diff as diff_email_template, EmailTemplateDiff, EmailTemplateIdIndex,
17};
18use crate::diff::{DiffSummary, ResourceDiff};
19use crate::error::Error;
20use crate::format::OutputFormat;
21use crate::fs::{catalog_io, content_block_io, custom_attribute_io, email_template_io};
22use crate::resource::{Catalog, ContentBlock, EmailTemplate, ResourceKind};
23use anyhow::Context as _;
24use clap::Args;
25use futures::stream::{StreamExt, TryStreamExt};
26use regex_lite::Regex;
27use std::collections::{BTreeMap, BTreeSet};
28use std::path::Path;
29
30use super::{selected_kinds, warn_if_name_excluded, FETCH_CONCURRENCY};
31
32#[derive(Args, Debug)]
33pub struct DiffArgs {
34    /// Limit diff to a specific resource kind.
35    #[arg(long, value_enum)]
36    pub resource: Option<ResourceKind>,
37
38    /// When `--resource` is given, optionally restrict to a single named
39    /// resource. Requires `--resource`.
40    #[arg(long, requires = "resource")]
41    pub name: Option<String>,
42
43    /// Exit with code 2 if any drift is detected. Intended for CI gates.
44    #[arg(long)]
45    pub fail_on_drift: bool,
46}
47
48pub async fn run(
49    args: &DiffArgs,
50    resolved: ResolvedConfig,
51    config_dir: &Path,
52    format: OutputFormat,
53) -> anyhow::Result<()> {
54    let catalogs_root = config_dir.join(&resolved.resources.catalog_schema.path);
55    let content_blocks_root = config_dir.join(&resolved.resources.content_block.path);
56    let email_templates_root = config_dir.join(&resolved.resources.email_template.path);
57    let custom_attributes_path = config_dir.join(&resolved.resources.custom_attribute.path);
58    let client = BrazeClient::from_resolved(&resolved);
59    let kinds = selected_kinds(args.resource, &resolved.resources);
60
61    let mut summary = DiffSummary::default();
62    for kind in kinds {
63        if warn_if_name_excluded(kind, args.name.as_deref(), resolved.excludes_for(kind)) {
64            continue;
65        }
66        match kind {
67            ResourceKind::CatalogSchema => {
68                let diffs = compute_catalog_schema_diffs(
69                    &client,
70                    &catalogs_root,
71                    args.name.as_deref(),
72                    resolved.excludes_for(ResourceKind::CatalogSchema),
73                )
74                .await
75                .context("computing catalog_schema diff")?;
76                summary.diffs.extend(diffs);
77            }
78            ResourceKind::ContentBlock => {
79                let (diffs, _idx) = compute_content_block_plan(
80                    &client,
81                    &content_blocks_root,
82                    args.name.as_deref(),
83                    resolved.excludes_for(ResourceKind::ContentBlock),
84                )
85                .await
86                .context("computing content_block diff")?;
87                summary.diffs.extend(diffs);
88            }
89            ResourceKind::EmailTemplate => {
90                let (diffs, _idx) = compute_email_template_plan(
91                    &client,
92                    &email_templates_root,
93                    args.name.as_deref(),
94                    resolved.excludes_for(ResourceKind::EmailTemplate),
95                )
96                .await
97                .context("computing email_template diff")?;
98                summary.diffs.extend(diffs);
99            }
100            ResourceKind::CustomAttribute => {
101                let diffs = compute_custom_attribute_diffs(
102                    &client,
103                    &custom_attributes_path,
104                    args.name.as_deref(),
105                    resolved.excludes_for(ResourceKind::CustomAttribute),
106                )
107                .await
108                .context("computing custom_attribute diff")?;
109                summary.diffs.extend(diffs);
110            }
111        }
112    }
113
114    let formatted = format.formatter().format(&summary);
115    print!("{formatted}");
116
117    if args.fail_on_drift && summary.changed_count() > 0 {
118        return Err(Error::DriftDetected {
119            count: summary.changed_count(),
120        }
121        .into());
122    }
123
124    Ok(())
125}
126
127/// Shared by `apply` so the printed plan and the executed plan cannot
128/// disagree.
129pub(crate) async fn compute_catalog_schema_diffs(
130    client: &BrazeClient,
131    catalogs_root: &Path,
132    name_filter: Option<&str>,
133    excludes: &[Regex],
134) -> anyhow::Result<Vec<ResourceDiff>> {
135    let mut local = catalog_io::load_all_schemas(catalogs_root)?;
136    if let Some(name) = name_filter {
137        local.retain(|c| c.name == name);
138    }
139    local.retain(|c| !is_excluded(&c.name, excludes));
140
141    let mut remote: Vec<Catalog> = match name_filter {
142        Some(name) => match client.get_catalog(name).await {
143            Ok(c) => vec![c],
144            // NotFound on a filtered fetch just means "no remote"; the
145            // local entry surfaces as Added rather than as an error.
146            Err(BrazeApiError::NotFound { .. }) => Vec::new(),
147            Err(e) => return Err(e.into()),
148        },
149        None => client.list_catalogs().await?,
150    };
151    remote.retain(|c| !is_excluded(&c.name, excludes));
152
153    let local_by_name: BTreeMap<&str, &Catalog> =
154        local.iter().map(|c| (c.name.as_str(), c)).collect();
155    let remote_by_name: BTreeMap<&str, &Catalog> =
156        remote.iter().map(|c| (c.name.as_str(), c)).collect();
157
158    let mut all_names: BTreeSet<&str> = BTreeSet::new();
159    all_names.extend(local_by_name.keys().copied());
160    all_names.extend(remote_by_name.keys().copied());
161
162    let mut diffs = Vec::new();
163    for name in all_names {
164        let l = local_by_name.get(name).copied();
165        let r = remote_by_name.get(name).copied();
166        if let Some(d) = diff_schema(l, r) {
167            diffs.push(ResourceDiff::CatalogSchema(d));
168        }
169    }
170
171    Ok(diffs)
172}
173
174/// Compute the per-content-block diff plan plus a name → id index for
175/// the apply path. Returning both keeps the second half of `apply` from
176/// having to refetch `/content_blocks/list`.
177pub(crate) async fn compute_content_block_plan(
178    client: &BrazeClient,
179    content_blocks_root: &Path,
180    name_filter: Option<&str>,
181    excludes: &[Regex],
182) -> anyhow::Result<(Vec<ResourceDiff>, ContentBlockIdIndex)> {
183    let mut local = content_block_io::load_all_content_blocks(content_blocks_root)?;
184    if let Some(name) = name_filter {
185        local.retain(|c| c.name == name);
186    }
187    local.retain(|c| !is_excluded(&c.name, excludes));
188
189    let mut summaries = client.list_content_blocks().await?;
190    if let Some(name) = name_filter {
191        summaries.retain(|s| s.name == name);
192    }
193    summaries.retain(|s| !is_excluded(&s.name, excludes));
194
195    let id_index: ContentBlockIdIndex = summaries
196        .into_iter()
197        .map(|s| (s.name, s.content_block_id))
198        .collect();
199
200    let local_by_name: BTreeMap<&str, &ContentBlock> =
201        local.iter().map(|c| (c.name.as_str(), c)).collect();
202
203    // Only names present on both sides need a /info fetch. Fan them out
204    // in parallel, bounded by FETCH_CONCURRENCY.
205    let shared_names: Vec<&str> = id_index
206        .keys()
207        .map(String::as_str)
208        .filter(|n| local_by_name.contains_key(n))
209        .collect();
210    let fetched: BTreeMap<String, ContentBlock> =
211        futures::stream::iter(shared_names.iter().map(|name| {
212            let id = id_index
213                .get(*name)
214                .expect("id_index built from the same summaries set");
215            async move {
216                client
217                    .get_content_block(id)
218                    .await
219                    .map(|cb| (name.to_string(), cb))
220                    .with_context(|| format!("fetching content block '{name}'"))
221            }
222        }))
223        .buffer_unordered(FETCH_CONCURRENCY)
224        .try_collect()
225        .await?;
226
227    let mut all_names: BTreeSet<&str> = BTreeSet::new();
228    all_names.extend(local_by_name.keys().copied());
229    all_names.extend(id_index.keys().map(String::as_str));
230
231    let mut diffs = Vec::new();
232    for name in all_names {
233        let local_cb = local_by_name.get(name).copied();
234        let remote_cb = fetched.get(name);
235        let remote_present = id_index.contains_key(name);
236        // Spell out only the legal triples. `fetched` carries only names
237        // present on BOTH sides, and `try_collect` aborts on the first
238        // /info failure, so a shared name always lands in `fetched`. The
239        // previous `(Some, None, _)` arm accepted `remote_present == true`
240        // and would have routed a partial-fetch shared name through
241        // `Added`, silently creating a duplicate in Braze on apply.
242        let diff_result = match (local_cb, remote_cb, remote_present) {
243            (Some(l), Some(r), true) => diff_content_block(Some(l), Some(r)),
244            (Some(l), None, false) => diff_content_block(Some(l), None),
245            (None, None, true) => Some(ContentBlockDiff::orphan(name)),
246            _ => unreachable!(
247                "content_block diff invariant violated for '{name}': \
248                 local={} remote={} remote_present={remote_present}",
249                local_cb.is_some(),
250                remote_cb.is_some(),
251            ),
252        };
253        if let Some(d) = diff_result {
254            diffs.push(ResourceDiff::ContentBlock(d));
255        }
256    }
257
258    Ok((diffs, id_index))
259}
260
261/// Same pattern as `compute_content_block_plan` — list first, fan-out
262/// /info fetches for shared names, then diff.
263pub(crate) async fn compute_email_template_plan(
264    client: &BrazeClient,
265    email_templates_root: &Path,
266    name_filter: Option<&str>,
267    excludes: &[Regex],
268) -> anyhow::Result<(Vec<ResourceDiff>, EmailTemplateIdIndex)> {
269    let mut local = email_template_io::load_all_email_templates(email_templates_root)?;
270    if let Some(name) = name_filter {
271        local.retain(|t| t.name == name);
272    }
273    local.retain(|t| !is_excluded(&t.name, excludes));
274
275    let mut summaries = client.list_email_templates().await?;
276    if let Some(name) = name_filter {
277        summaries.retain(|s| s.name == name);
278    }
279    summaries.retain(|s| !is_excluded(&s.name, excludes));
280
281    let id_index: EmailTemplateIdIndex = summaries
282        .into_iter()
283        .map(|s| (s.name, s.email_template_id))
284        .collect();
285
286    let local_by_name: BTreeMap<&str, &EmailTemplate> =
287        local.iter().map(|t| (t.name.as_str(), t)).collect();
288
289    let shared_names: Vec<&str> = id_index
290        .keys()
291        .map(String::as_str)
292        .filter(|n| local_by_name.contains_key(n))
293        .collect();
294    let fetched: BTreeMap<String, EmailTemplate> =
295        futures::stream::iter(shared_names.iter().map(|name| {
296            let id = id_index
297                .get(*name)
298                .expect("id_index built from the same summaries set");
299            async move {
300                client
301                    .get_email_template(id)
302                    .await
303                    .map(|et| (name.to_string(), et))
304                    .with_context(|| format!("fetching email template '{name}'"))
305            }
306        }))
307        .buffer_unordered(FETCH_CONCURRENCY)
308        .try_collect()
309        .await?;
310
311    let mut all_names: BTreeSet<&str> = BTreeSet::new();
312    all_names.extend(local_by_name.keys().copied());
313    all_names.extend(id_index.keys().map(String::as_str));
314
315    let mut diffs = Vec::new();
316    for name in all_names {
317        let local_et = local_by_name.get(name).copied();
318        let remote_et = fetched.get(name);
319        let remote_present = id_index.contains_key(name);
320        let diff_result = match (local_et, remote_et, remote_present) {
321            (Some(l), Some(r), true) => diff_email_template(Some(l), Some(r)),
322            (Some(l), None, false) => diff_email_template(Some(l), None),
323            (None, None, true) => Some(EmailTemplateDiff::orphan(name)),
324            _ => unreachable!(
325                "email_template diff invariant violated for '{name}': \
326                 local={} remote={} remote_present={remote_present}",
327                local_et.is_some(),
328                remote_et.is_some(),
329            ),
330        };
331        if let Some(d) = diff_result {
332            diffs.push(ResourceDiff::EmailTemplate(d));
333        }
334    }
335
336    Ok((diffs, id_index))
337}
338
339/// Compute Custom Attribute diffs by comparing the local registry file
340/// against the Braze attribute list. Shared by `diff` and `apply`.
341///
342/// When `name_filter` is `Some`, only the attribute with that exact name
343/// is included in the result — consistent with the `--name` flag on
344/// other resource types.
345pub(crate) async fn compute_custom_attribute_diffs(
346    client: &BrazeClient,
347    registry_path: &Path,
348    name_filter: Option<&str>,
349    excludes: &[Regex],
350) -> anyhow::Result<Vec<ResourceDiff>> {
351    let mut local = custom_attribute_io::load_registry(registry_path)?;
352    let mut remote = client.list_custom_attributes().await?;
353    if let Some(name) = name_filter {
354        if let Some(r) = local.as_mut() {
355            r.attributes.retain(|a| a.name == name);
356        }
357        remote.retain(|a| a.name == name);
358    }
359    if let Some(r) = local.as_mut() {
360        r.attributes.retain(|a| !is_excluded(&a.name, excludes));
361    }
362    remote.retain(|a| !is_excluded(&a.name, excludes));
363    let attr_diffs = diff_custom_attributes(local.as_ref(), &remote);
364    Ok(attr_diffs
365        .into_iter()
366        .map(ResourceDiff::CustomAttribute)
367        .collect())
368}