1use 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::tag::diff as diff_tags;
19use crate::diff::{DiffSummary, ResourceDiff};
20use crate::error::Error;
21use crate::format::OutputFormat;
22use crate::fs::{catalog_io, content_block_io, custom_attribute_io, email_template_io, tag_io};
23use crate::resource::{Catalog, ContentBlock, EmailTemplate, ResourceKind};
24use anyhow::Context as _;
25use clap::Args;
26use futures::stream::{StreamExt, TryStreamExt};
27use regex_lite::Regex;
28use std::collections::{BTreeMap, BTreeSet};
29use std::path::Path;
30
31use super::{selected_kinds, warn_if_name_excluded, FETCH_CONCURRENCY};
32
33#[derive(Args, Debug)]
34pub struct DiffArgs {
35 #[arg(long, value_enum)]
37 pub resource: Option<ResourceKind>,
38
39 #[arg(long, requires = "resource")]
42 pub name: Option<String>,
43
44 #[arg(long)]
46 pub fail_on_drift: bool,
47}
48
49pub async fn run(
50 args: &DiffArgs,
51 resolved: ResolvedConfig,
52 config_dir: &Path,
53 format: OutputFormat,
54) -> anyhow::Result<()> {
55 let catalogs_root = config_dir.join(&resolved.resources.catalog_schema.path);
56 let content_blocks_root = config_dir.join(&resolved.resources.content_block.path);
57 let email_templates_root = config_dir.join(&resolved.resources.email_template.path);
58 let custom_attributes_path = config_dir.join(&resolved.resources.custom_attribute.path);
59 let tags_path = config_dir.join(&resolved.resources.tag.path);
60 let client = BrazeClient::from_resolved(&resolved);
61 let kinds = selected_kinds(args.resource, &resolved.resources);
62
63 let mut summary = DiffSummary::default();
64 for kind in kinds {
65 if warn_if_name_excluded(kind, args.name.as_deref(), resolved.excludes_for(kind)) {
66 continue;
67 }
68 match kind {
69 ResourceKind::CatalogSchema => {
70 let diffs = compute_catalog_schema_diffs(
71 &client,
72 &catalogs_root,
73 args.name.as_deref(),
74 resolved.excludes_for(ResourceKind::CatalogSchema),
75 )
76 .await
77 .context("computing catalog_schema diff")?;
78 summary.diffs.extend(diffs);
79 }
80 ResourceKind::ContentBlock => {
81 let (diffs, _idx) = compute_content_block_plan(
82 &client,
83 &content_blocks_root,
84 args.name.as_deref(),
85 resolved.excludes_for(ResourceKind::ContentBlock),
86 )
87 .await
88 .context("computing content_block diff")?;
89 summary.diffs.extend(diffs);
90 }
91 ResourceKind::EmailTemplate => {
92 let (diffs, _idx) = compute_email_template_plan(
93 &client,
94 &email_templates_root,
95 args.name.as_deref(),
96 resolved.excludes_for(ResourceKind::EmailTemplate),
97 )
98 .await
99 .context("computing email_template diff")?;
100 summary.diffs.extend(diffs);
101 }
102 ResourceKind::CustomAttribute => {
103 let diffs = compute_custom_attribute_diffs(
104 &client,
105 &custom_attributes_path,
106 args.name.as_deref(),
107 resolved.excludes_for(ResourceKind::CustomAttribute),
108 )
109 .await
110 .context("computing custom_attribute diff")?;
111 summary.diffs.extend(diffs);
112 }
113 ResourceKind::Tag => {
114 let diffs = compute_tag_diffs(
115 config_dir,
116 &resolved,
117 &tags_path,
118 args.name.as_deref(),
119 resolved.excludes_for(ResourceKind::Tag),
120 )
121 .context("computing tag diff")?;
122 summary.diffs.extend(diffs);
123 }
124 }
125 }
126
127 let formatted = format.formatter().format(&summary);
128 print!("{formatted}");
129
130 if args.fail_on_drift && summary.changed_count() > 0 {
131 return Err(Error::DriftDetected {
132 count: summary.changed_count(),
133 }
134 .into());
135 }
136
137 Ok(())
138}
139
140pub(crate) async fn compute_catalog_schema_diffs(
143 client: &BrazeClient,
144 catalogs_root: &Path,
145 name_filter: Option<&str>,
146 excludes: &[Regex],
147) -> anyhow::Result<Vec<ResourceDiff>> {
148 let mut local = catalog_io::load_all_schemas(catalogs_root)?;
149 if let Some(name) = name_filter {
150 local.retain(|c| c.name == name);
151 }
152 local.retain(|c| !is_excluded(&c.name, excludes));
153
154 let mut remote: Vec<Catalog> = match name_filter {
155 Some(name) => match client.get_catalog(name).await {
156 Ok(c) => vec![c],
157 Err(BrazeApiError::NotFound { .. }) => Vec::new(),
160 Err(e) => return Err(e.into()),
161 },
162 None => client.list_catalogs().await?,
163 };
164 remote.retain(|c| !is_excluded(&c.name, excludes));
165
166 let local_by_name: BTreeMap<&str, &Catalog> =
167 local.iter().map(|c| (c.name.as_str(), c)).collect();
168 let remote_by_name: BTreeMap<&str, &Catalog> =
169 remote.iter().map(|c| (c.name.as_str(), c)).collect();
170
171 let mut all_names: BTreeSet<&str> = BTreeSet::new();
172 all_names.extend(local_by_name.keys().copied());
173 all_names.extend(remote_by_name.keys().copied());
174
175 let mut diffs = Vec::new();
176 for name in all_names {
177 let l = local_by_name.get(name).copied();
178 let r = remote_by_name.get(name).copied();
179 if let Some(d) = diff_schema(l, r) {
180 diffs.push(ResourceDiff::CatalogSchema(d));
181 }
182 }
183
184 Ok(diffs)
185}
186
187pub(crate) async fn compute_content_block_plan(
191 client: &BrazeClient,
192 content_blocks_root: &Path,
193 name_filter: Option<&str>,
194 excludes: &[Regex],
195) -> anyhow::Result<(Vec<ResourceDiff>, ContentBlockIdIndex)> {
196 let mut local = content_block_io::load_all_content_blocks(content_blocks_root)?;
197 if let Some(name) = name_filter {
198 local.retain(|c| c.name == name);
199 }
200 local.retain(|c| !is_excluded(&c.name, excludes));
201
202 let mut summaries = client.list_content_blocks().await?;
203 if let Some(name) = name_filter {
204 summaries.retain(|s| s.name == name);
205 }
206 summaries.retain(|s| !is_excluded(&s.name, excludes));
207
208 let id_index: ContentBlockIdIndex = summaries
209 .into_iter()
210 .map(|s| (s.name, s.content_block_id))
211 .collect();
212
213 let local_by_name: BTreeMap<&str, &ContentBlock> =
214 local.iter().map(|c| (c.name.as_str(), c)).collect();
215
216 let shared_names: Vec<&str> = id_index
219 .keys()
220 .map(String::as_str)
221 .filter(|n| local_by_name.contains_key(n))
222 .collect();
223 let fetched: BTreeMap<String, ContentBlock> =
224 futures::stream::iter(shared_names.iter().map(|name| {
225 let id = id_index
226 .get(*name)
227 .expect("id_index built from the same summaries set");
228 async move {
229 client
230 .get_content_block(id)
231 .await
232 .map(|cb| (name.to_string(), cb))
233 .with_context(|| format!("fetching content block '{name}'"))
234 }
235 }))
236 .buffer_unordered(FETCH_CONCURRENCY)
237 .try_collect()
238 .await?;
239
240 let mut all_names: BTreeSet<&str> = BTreeSet::new();
241 all_names.extend(local_by_name.keys().copied());
242 all_names.extend(id_index.keys().map(String::as_str));
243
244 let mut diffs = Vec::new();
245 for name in all_names {
246 let local_cb = local_by_name.get(name).copied();
247 let remote_cb = fetched.get(name);
248 let remote_present = id_index.contains_key(name);
249 let diff_result = match (local_cb, remote_cb, remote_present) {
256 (Some(l), Some(r), true) => diff_content_block(Some(l), Some(r)),
257 (Some(l), None, false) => diff_content_block(Some(l), None),
258 (None, None, true) => Some(ContentBlockDiff::orphan(name)),
259 _ => unreachable!(
260 "content_block diff invariant violated for '{name}': \
261 local={} remote={} remote_present={remote_present}",
262 local_cb.is_some(),
263 remote_cb.is_some(),
264 ),
265 };
266 if let Some(d) = diff_result {
267 diffs.push(ResourceDiff::ContentBlock(d));
268 }
269 }
270
271 Ok((diffs, id_index))
272}
273
274pub(crate) async fn compute_email_template_plan(
277 client: &BrazeClient,
278 email_templates_root: &Path,
279 name_filter: Option<&str>,
280 excludes: &[Regex],
281) -> anyhow::Result<(Vec<ResourceDiff>, EmailTemplateIdIndex)> {
282 let mut local = email_template_io::load_all_email_templates(email_templates_root)?;
283 if let Some(name) = name_filter {
284 local.retain(|t| t.name == name);
285 }
286 local.retain(|t| !is_excluded(&t.name, excludes));
287
288 let mut summaries = client.list_email_templates().await?;
289 if let Some(name) = name_filter {
290 summaries.retain(|s| s.name == name);
291 }
292 summaries.retain(|s| !is_excluded(&s.name, excludes));
293
294 let id_index: EmailTemplateIdIndex = summaries
295 .into_iter()
296 .map(|s| (s.name, s.email_template_id))
297 .collect();
298
299 let local_by_name: BTreeMap<&str, &EmailTemplate> =
300 local.iter().map(|t| (t.name.as_str(), t)).collect();
301
302 let shared_names: Vec<&str> = id_index
303 .keys()
304 .map(String::as_str)
305 .filter(|n| local_by_name.contains_key(n))
306 .collect();
307 let fetched: BTreeMap<String, EmailTemplate> =
308 futures::stream::iter(shared_names.iter().map(|name| {
309 let id = id_index
310 .get(*name)
311 .expect("id_index built from the same summaries set");
312 async move {
313 client
314 .get_email_template(id)
315 .await
316 .map(|et| (name.to_string(), et))
317 .with_context(|| format!("fetching email template '{name}'"))
318 }
319 }))
320 .buffer_unordered(FETCH_CONCURRENCY)
321 .try_collect()
322 .await?;
323
324 let mut all_names: BTreeSet<&str> = BTreeSet::new();
325 all_names.extend(local_by_name.keys().copied());
326 all_names.extend(id_index.keys().map(String::as_str));
327
328 let mut diffs = Vec::new();
329 for name in all_names {
330 let local_et = local_by_name.get(name).copied();
331 let remote_et = fetched.get(name);
332 let remote_present = id_index.contains_key(name);
333 let diff_result = match (local_et, remote_et, remote_present) {
334 (Some(l), Some(r), true) => diff_email_template(Some(l), Some(r)),
335 (Some(l), None, false) => diff_email_template(Some(l), None),
336 (None, None, true) => Some(EmailTemplateDiff::orphan(name)),
337 _ => unreachable!(
338 "email_template diff invariant violated for '{name}': \
339 local={} remote={} remote_present={remote_present}",
340 local_et.is_some(),
341 remote_et.is_some(),
342 ),
343 };
344 if let Some(d) = diff_result {
345 diffs.push(ResourceDiff::EmailTemplate(d));
346 }
347 }
348
349 Ok((diffs, id_index))
350}
351
352pub(crate) async fn compute_custom_attribute_diffs(
359 client: &BrazeClient,
360 registry_path: &Path,
361 name_filter: Option<&str>,
362 excludes: &[Regex],
363) -> anyhow::Result<Vec<ResourceDiff>> {
364 let mut local = custom_attribute_io::load_registry(registry_path)?;
365 let mut remote = client.list_custom_attributes().await?;
366 if let Some(name) = name_filter {
367 if let Some(r) = local.as_mut() {
368 r.attributes.retain(|a| a.name == name);
369 }
370 remote.retain(|a| a.name == name);
371 }
372 if let Some(r) = local.as_mut() {
373 r.attributes.retain(|a| !is_excluded(&a.name, excludes));
374 }
375 remote.retain(|a| !is_excluded(&a.name, excludes));
376 let attr_diffs = diff_custom_attributes(local.as_ref(), &remote);
377 Ok(attr_diffs
378 .into_iter()
379 .map(ResourceDiff::CustomAttribute)
380 .collect())
381}
382
383pub(crate) fn compute_tag_diffs(
389 config_dir: &Path,
390 resolved: &ResolvedConfig,
391 registry_path: &Path,
392 name_filter: Option<&str>,
393 excludes: &[Regex],
394) -> anyhow::Result<Vec<ResourceDiff>> {
395 let mut local = tag_io::load_registry(registry_path)?;
396 let mut referenced = crate::cli::export::collect_local_tag_references(config_dir, resolved)?;
397
398 if let Some(name) = name_filter {
399 if let Some(r) = local.as_mut() {
400 r.tags.retain(|t| t.name == name);
401 }
402 referenced.retain(|n| n == name);
403 }
404 if let Some(r) = local.as_mut() {
405 r.tags.retain(|t| !is_excluded(&t.name, excludes));
406 }
407 referenced.retain(|n| !is_excluded(n, excludes));
408
409 let tag_diffs = diff_tags(local.as_ref(), &referenced);
410 Ok(tag_diffs.into_iter().map(ResourceDiff::Tag).collect())
411}