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_schema;
11use crate::diff::content_block::{
12    diff as diff_content_block, ContentBlockDiff, ContentBlockIdIndex,
13};
14use crate::diff::email_template::{
15    diff as diff_email_template, EmailTemplateDiff, EmailTemplateIdIndex,
16};
17use crate::diff::{DiffSummary, ResourceDiff};
18use crate::error::Error;
19use crate::format::OutputFormat;
20use crate::fs::{catalog_io, content_block_io, email_template_io};
21use crate::resource::{Catalog, ContentBlock, EmailTemplate, ResourceKind};
22use anyhow::Context as _;
23use clap::Args;
24use futures::stream::{StreamExt, TryStreamExt};
25use std::collections::{BTreeMap, BTreeSet};
26use std::path::Path;
27
28use super::{selected_kinds, warn_unimplemented, FETCH_CONCURRENCY};
29
30#[derive(Args, Debug)]
31pub struct DiffArgs {
32    /// Limit diff to a specific resource kind.
33    #[arg(long, value_enum)]
34    pub resource: Option<ResourceKind>,
35
36    /// When `--resource` is given, optionally restrict to a single named
37    /// resource. Requires `--resource`.
38    #[arg(long, requires = "resource")]
39    pub name: Option<String>,
40
41    /// Exit with code 2 if any drift is detected. Intended for CI gates.
42    #[arg(long)]
43    pub fail_on_drift: bool,
44}
45
46pub async fn run(
47    args: &DiffArgs,
48    resolved: ResolvedConfig,
49    config_dir: &Path,
50    format: OutputFormat,
51) -> anyhow::Result<()> {
52    let catalogs_root = config_dir.join(&resolved.resources.catalog_schema.path);
53    let content_blocks_root = config_dir.join(&resolved.resources.content_block.path);
54    let email_templates_root = config_dir.join(&resolved.resources.email_template.path);
55    let client = BrazeClient::from_resolved(&resolved);
56    let kinds = selected_kinds(args.resource, &resolved.resources);
57
58    let mut summary = DiffSummary::default();
59    for kind in kinds {
60        match kind {
61            ResourceKind::CatalogSchema => {
62                let diffs =
63                    compute_catalog_schema_diffs(&client, &catalogs_root, args.name.as_deref())
64                        .await
65                        .context("computing catalog_schema diff")?;
66                summary.diffs.extend(diffs);
67            }
68            ResourceKind::ContentBlock => {
69                let (diffs, _idx) =
70                    compute_content_block_plan(&client, &content_blocks_root, args.name.as_deref())
71                        .await
72                        .context("computing content_block diff")?;
73                summary.diffs.extend(diffs);
74            }
75            ResourceKind::EmailTemplate => {
76                let (diffs, _idx) = compute_email_template_plan(
77                    &client,
78                    &email_templates_root,
79                    args.name.as_deref(),
80                )
81                .await
82                .context("computing email_template diff")?;
83                summary.diffs.extend(diffs);
84            }
85            other => {
86                warn_unimplemented(other);
87            }
88        }
89    }
90
91    let formatted = format.formatter().format(&summary);
92    print!("{formatted}");
93
94    if args.fail_on_drift && summary.changed_count() > 0 {
95        return Err(Error::DriftDetected {
96            count: summary.changed_count(),
97        }
98        .into());
99    }
100
101    Ok(())
102}
103
104/// Shared by `apply` so the printed plan and the executed plan cannot
105/// disagree.
106pub(crate) async fn compute_catalog_schema_diffs(
107    client: &BrazeClient,
108    catalogs_root: &Path,
109    name_filter: Option<&str>,
110) -> anyhow::Result<Vec<ResourceDiff>> {
111    let mut local = catalog_io::load_all_schemas(catalogs_root)?;
112    if let Some(name) = name_filter {
113        local.retain(|c| c.name == name);
114    }
115
116    let remote: Vec<Catalog> = match name_filter {
117        Some(name) => match client.get_catalog(name).await {
118            Ok(c) => vec![c],
119            // NotFound on a filtered fetch just means "no remote"; the
120            // local entry surfaces as Added rather than as an error.
121            Err(BrazeApiError::NotFound { .. }) => Vec::new(),
122            Err(e) => return Err(e.into()),
123        },
124        None => client.list_catalogs().await?,
125    };
126
127    let local_by_name: BTreeMap<&str, &Catalog> =
128        local.iter().map(|c| (c.name.as_str(), c)).collect();
129    let remote_by_name: BTreeMap<&str, &Catalog> =
130        remote.iter().map(|c| (c.name.as_str(), c)).collect();
131
132    let mut all_names: BTreeSet<&str> = BTreeSet::new();
133    all_names.extend(local_by_name.keys().copied());
134    all_names.extend(remote_by_name.keys().copied());
135
136    let mut diffs = Vec::new();
137    for name in all_names {
138        let l = local_by_name.get(name).copied();
139        let r = remote_by_name.get(name).copied();
140        if let Some(d) = diff_schema(l, r) {
141            diffs.push(ResourceDiff::CatalogSchema(d));
142        }
143    }
144
145    Ok(diffs)
146}
147
148/// Compute the per-content-block diff plan plus a name → id index for
149/// the apply path. Returning both keeps the second half of `apply` from
150/// having to refetch `/content_blocks/list`.
151pub(crate) async fn compute_content_block_plan(
152    client: &BrazeClient,
153    content_blocks_root: &Path,
154    name_filter: Option<&str>,
155) -> anyhow::Result<(Vec<ResourceDiff>, ContentBlockIdIndex)> {
156    let mut local = content_block_io::load_all_content_blocks(content_blocks_root)?;
157    if let Some(name) = name_filter {
158        local.retain(|c| c.name == name);
159    }
160
161    let mut summaries = client.list_content_blocks().await?;
162    if let Some(name) = name_filter {
163        summaries.retain(|s| s.name == name);
164    }
165
166    let id_index: ContentBlockIdIndex = summaries
167        .into_iter()
168        .map(|s| (s.name, s.content_block_id))
169        .collect();
170
171    let local_by_name: BTreeMap<&str, &ContentBlock> =
172        local.iter().map(|c| (c.name.as_str(), c)).collect();
173
174    // Only names present on both sides need a /info fetch. Fan them out
175    // in parallel; the BrazeClient's rate limiter still governs RPM.
176    let shared_names: Vec<&str> = id_index
177        .keys()
178        .map(String::as_str)
179        .filter(|n| local_by_name.contains_key(n))
180        .collect();
181    let fetched: BTreeMap<String, ContentBlock> =
182        futures::stream::iter(shared_names.iter().map(|name| {
183            let id = id_index
184                .get(*name)
185                .expect("id_index built from the same summaries set");
186            async move {
187                client
188                    .get_content_block(id)
189                    .await
190                    .map(|cb| (name.to_string(), cb))
191                    .with_context(|| format!("fetching content block '{name}'"))
192            }
193        }))
194        .buffer_unordered(FETCH_CONCURRENCY)
195        .try_collect()
196        .await?;
197
198    let mut all_names: BTreeSet<&str> = BTreeSet::new();
199    all_names.extend(local_by_name.keys().copied());
200    all_names.extend(id_index.keys().map(String::as_str));
201
202    let mut diffs = Vec::new();
203    for name in all_names {
204        let local_cb = local_by_name.get(name).copied();
205        let remote_cb = fetched.get(name);
206        let remote_present = id_index.contains_key(name);
207        // Spell out only the legal triples. `fetched` carries only names
208        // present on BOTH sides, and `try_collect` aborts on the first
209        // /info failure, so a shared name always lands in `fetched`. The
210        // previous `(Some, None, _)` arm accepted `remote_present == true`
211        // and would have routed a partial-fetch shared name through
212        // `Added`, silently creating a duplicate in Braze on apply.
213        let diff_result = match (local_cb, remote_cb, remote_present) {
214            (Some(l), Some(r), true) => diff_content_block(Some(l), Some(r)),
215            (Some(l), None, false) => diff_content_block(Some(l), None),
216            (None, None, true) => Some(ContentBlockDiff::orphan(name)),
217            _ => unreachable!(
218                "content_block diff invariant violated for '{name}': \
219                 local={} remote={} remote_present={remote_present}",
220                local_cb.is_some(),
221                remote_cb.is_some(),
222            ),
223        };
224        if let Some(d) = diff_result {
225            diffs.push(ResourceDiff::ContentBlock(d));
226        }
227    }
228
229    Ok((diffs, id_index))
230}
231
232/// Same pattern as `compute_content_block_plan` — list first, fan-out
233/// /info fetches for shared names, then diff.
234pub(crate) async fn compute_email_template_plan(
235    client: &BrazeClient,
236    email_templates_root: &Path,
237    name_filter: Option<&str>,
238) -> anyhow::Result<(Vec<ResourceDiff>, EmailTemplateIdIndex)> {
239    let mut local = email_template_io::load_all_email_templates(email_templates_root)?;
240    if let Some(name) = name_filter {
241        local.retain(|t| t.name == name);
242    }
243
244    let mut summaries = client.list_email_templates().await?;
245    if let Some(name) = name_filter {
246        summaries.retain(|s| s.name == name);
247    }
248
249    let id_index: EmailTemplateIdIndex = summaries
250        .into_iter()
251        .map(|s| (s.name, s.email_template_id))
252        .collect();
253
254    let local_by_name: BTreeMap<&str, &EmailTemplate> =
255        local.iter().map(|t| (t.name.as_str(), t)).collect();
256
257    let shared_names: Vec<&str> = id_index
258        .keys()
259        .map(String::as_str)
260        .filter(|n| local_by_name.contains_key(n))
261        .collect();
262    let fetched: BTreeMap<String, EmailTemplate> =
263        futures::stream::iter(shared_names.iter().map(|name| {
264            let id = id_index
265                .get(*name)
266                .expect("id_index built from the same summaries set");
267            async move {
268                client
269                    .get_email_template(id)
270                    .await
271                    .map(|et| (name.to_string(), et))
272                    .with_context(|| format!("fetching email template '{name}'"))
273            }
274        }))
275        .buffer_unordered(FETCH_CONCURRENCY)
276        .try_collect()
277        .await?;
278
279    let mut all_names: BTreeSet<&str> = BTreeSet::new();
280    all_names.extend(local_by_name.keys().copied());
281    all_names.extend(id_index.keys().map(String::as_str));
282
283    let mut diffs = Vec::new();
284    for name in all_names {
285        let local_et = local_by_name.get(name).copied();
286        let remote_et = fetched.get(name);
287        let remote_present = id_index.contains_key(name);
288        let diff_result = match (local_et, remote_et, remote_present) {
289            (Some(l), Some(r), true) => diff_email_template(Some(l), Some(r)),
290            (Some(l), None, false) => diff_email_template(Some(l), None),
291            (None, None, true) => Some(EmailTemplateDiff::orphan(name)),
292            _ => unreachable!(
293                "email_template diff invariant violated for '{name}': \
294                 local={} remote={} remote_present={remote_present}",
295                local_et.is_some(),
296                remote_et.is_some(),
297            ),
298        };
299        if let Some(d) = diff_result {
300            diffs.push(ResourceDiff::EmailTemplate(d));
301        }
302    }
303
304    Ok((diffs, id_index))
305}