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