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