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