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