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