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::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 format_failures, resolve_content_block_with_remote, resolve_email_template_with_remote,
27};
28use anyhow::Context as _;
29use clap::Args;
30use futures::stream::{StreamExt, TryStreamExt};
31use regex_lite::Regex;
32use std::collections::{BTreeMap, BTreeSet};
33use std::path::{Path, PathBuf};
34
35use super::{selected_kinds, warn_if_name_excluded, FETCH_CONCURRENCY};
36
37#[derive(Args, Debug)]
38pub struct DiffArgs {
39 #[arg(long, value_enum)]
41 pub resource: Option<ResourceKind>,
42
43 #[arg(long, requires = "resource")]
46 pub name: Option<String>,
47
48 #[arg(long)]
50 pub fail_on_drift: bool,
51
52 #[arg(long, value_name = "PATH")]
56 pub plan_out: Option<PathBuf>,
57
58 #[arg(long, requires = "plan_out")]
63 pub archive_orphans: bool,
64}
65
66pub async fn run(
67 args: &DiffArgs,
68 resolved: ResolvedConfig,
69 config_dir: &Path,
70 format: OutputFormat,
71) -> anyhow::Result<()> {
72 let catalogs_root = config_dir.join(&resolved.resources.catalog_schema.path);
73 let content_blocks_root = config_dir.join(&resolved.resources.content_block.path);
74 let email_templates_root = config_dir.join(&resolved.resources.email_template.path);
75 let custom_attributes_path = config_dir.join(&resolved.resources.custom_attribute.path);
76 let tags_path = config_dir.join(&resolved.resources.tag.path);
77 let client = BrazeClient::from_resolved(&resolved);
78 let kinds = selected_kinds(args.resource, &resolved.resources);
79
80 let mut summary = DiffSummary::default();
81 for kind in &kinds {
82 let kind = *kind;
83 if warn_if_name_excluded(kind, args.name.as_deref(), resolved.excludes_for(kind)) {
84 continue;
85 }
86 match kind {
87 ResourceKind::CatalogSchema => {
88 let diffs = compute_catalog_schema_diffs(
89 &client,
90 &catalogs_root,
91 args.name.as_deref(),
92 resolved.excludes_for(ResourceKind::CatalogSchema),
93 )
94 .await
95 .context("computing catalog_schema diff")?;
96 summary.diffs.extend(diffs);
97 }
98 ResourceKind::ContentBlock => {
99 let (diffs, _idx) = compute_content_block_plan(
100 &client,
101 &content_blocks_root,
102 args.name.as_deref(),
103 resolved.excludes_for(ResourceKind::ContentBlock),
104 )
105 .await
106 .context("computing content_block diff")?;
107 summary.diffs.extend(diffs);
108 }
109 ResourceKind::EmailTemplate => {
110 let (diffs, _idx) = compute_email_template_plan(
111 &client,
112 &email_templates_root,
113 args.name.as_deref(),
114 resolved.excludes_for(ResourceKind::EmailTemplate),
115 )
116 .await
117 .context("computing email_template diff")?;
118 summary.diffs.extend(diffs);
119 }
120 ResourceKind::CustomAttribute => {
121 let diffs = compute_custom_attribute_diffs(
122 &client,
123 &custom_attributes_path,
124 args.name.as_deref(),
125 resolved.excludes_for(ResourceKind::CustomAttribute),
126 )
127 .await
128 .context("computing custom_attribute diff")?;
129 summary.diffs.extend(diffs);
130 }
131 ResourceKind::Tag => {
132 let diffs = compute_tag_diffs(
133 config_dir,
134 &resolved,
135 &tags_path,
136 args.name.as_deref(),
137 resolved.excludes_for(ResourceKind::Tag),
138 )
139 .context("computing tag diff")?;
140 summary.diffs.extend(diffs);
141 }
142 }
143 }
144
145 let formatted = format.formatter().format(&summary);
146 print!("{formatted}");
147
148 if let Some(path) = &args.plan_out {
149 let plan = PlanFile::from_summary(
150 &summary,
151 resolved.environment_name.clone(),
152 args.resource,
153 args.name.clone(),
154 args.archive_orphans,
155 );
156 plan.write_to(path)
157 .with_context(|| format!("writing plan file to {}", path.display()))?;
158 eprintln!(
159 "✓ Wrote plan ({} op(s)) to {}",
160 plan.ops.len(),
161 path.display()
162 );
163 }
164
165 if args.fail_on_drift && summary.changed_count() > 0 {
166 return Err(Error::DriftDetected {
167 count: summary.changed_count(),
168 }
169 .into());
170 }
171
172 Ok(())
173}
174
175pub(crate) async fn compute_catalog_schema_diffs(
178 client: &BrazeClient,
179 catalogs_root: &Path,
180 name_filter: Option<&str>,
181 excludes: &[Regex],
182) -> anyhow::Result<Vec<ResourceDiff>> {
183 let mut local = catalog_io::load_all_schemas(catalogs_root)?;
184 if let Some(name) = name_filter {
185 local.retain(|c| c.name == name);
186 }
187 local.retain(|c| !is_excluded(&c.name, excludes));
188
189 let mut remote: Vec<Catalog> = match name_filter {
190 Some(name) => match client.get_catalog(name).await {
191 Ok(c) => vec![c],
192 Err(BrazeApiError::NotFound { .. }) => Vec::new(),
195 Err(e) => return Err(e.into()),
196 },
197 None => client.list_catalogs().await?,
198 };
199 remote.retain(|c| !is_excluded(&c.name, excludes));
200
201 let local_by_name: BTreeMap<&str, &Catalog> =
202 local.iter().map(|c| (c.name.as_str(), c)).collect();
203 let remote_by_name: BTreeMap<&str, &Catalog> =
204 remote.iter().map(|c| (c.name.as_str(), c)).collect();
205
206 let mut all_names: BTreeSet<&str> = BTreeSet::new();
207 all_names.extend(local_by_name.keys().copied());
208 all_names.extend(remote_by_name.keys().copied());
209
210 let mut diffs = Vec::new();
211 for name in all_names {
212 let l = local_by_name.get(name).copied();
213 let r = remote_by_name.get(name).copied();
214 if let Some(d) = diff_schema(l, r) {
215 diffs.push(ResourceDiff::CatalogSchema(d));
216 }
217 }
218
219 Ok(diffs)
220}
221
222pub(crate) async fn compute_content_block_plan(
226 client: &BrazeClient,
227 content_blocks_root: &Path,
228 name_filter: Option<&str>,
229 excludes: &[Regex],
230) -> anyhow::Result<(Vec<ResourceDiff>, ContentBlockIdIndex)> {
231 let mut local = content_block_io::load_all_content_blocks(content_blocks_root)?;
232 if let Some(name) = name_filter {
233 local.retain(|c| c.name == name);
234 }
235 local.retain(|c| !is_excluded(&c.name, excludes));
236
237 let mut summaries = client.list_content_blocks().await?;
238 if let Some(name) = name_filter {
239 summaries.retain(|s| s.name == name);
240 }
241 summaries.retain(|s| !is_excluded(&s.name, excludes));
242
243 let id_index: ContentBlockIdIndex = summaries
244 .into_iter()
245 .map(|s| (s.name, s.content_block_id))
246 .collect();
247
248 let local_by_name: BTreeMap<&str, &ContentBlock> =
249 local.iter().map(|c| (c.name.as_str(), c)).collect();
250
251 let shared_names: Vec<&str> = id_index
254 .keys()
255 .map(String::as_str)
256 .filter(|n| local_by_name.contains_key(n))
257 .collect();
258 let fetched: BTreeMap<String, ContentBlock> =
259 futures::stream::iter(shared_names.iter().map(|name| {
260 let id = id_index
261 .get(*name)
262 .expect("id_index built from the same summaries set");
263 async move {
264 client
265 .get_content_block(id)
266 .await
267 .map(|cb| (name.to_string(), cb))
268 .with_context(|| format!("fetching content block '{name}'"))
269 }
270 }))
271 .buffer_unordered(FETCH_CONCURRENCY)
272 .try_collect()
273 .await?;
274
275 drop(local_by_name);
279 let mut cb_failures = Vec::new();
280 for cb in &mut local {
281 let remote_cb = fetched.get(&cb.name);
282 if let Err(f) = resolve_content_block_with_remote(cb, remote_cb) {
283 cb_failures.push(f);
284 }
285 }
286 if !cb_failures.is_empty() {
287 return Err(format_failures(&cb_failures).into());
288 }
289 let local_by_name: BTreeMap<&str, &ContentBlock> =
290 local.iter().map(|c| (c.name.as_str(), c)).collect();
291
292 let mut all_names: BTreeSet<&str> = BTreeSet::new();
293 all_names.extend(local_by_name.keys().copied());
294 all_names.extend(id_index.keys().map(String::as_str));
295
296 let mut diffs = Vec::new();
297 for name in all_names {
298 let local_cb = local_by_name.get(name).copied();
299 let remote_cb = fetched.get(name);
300 let remote_present = id_index.contains_key(name);
301 let diff_result = match (local_cb, remote_cb, remote_present) {
308 (Some(l), Some(r), true) => diff_content_block(Some(l), Some(r)),
309 (Some(l), None, false) => diff_content_block(Some(l), None),
310 (None, None, true) => Some(ContentBlockDiff::orphan(name)),
311 _ => unreachable!(
312 "content_block diff invariant violated for '{name}': \
313 local={} remote={} remote_present={remote_present}",
314 local_cb.is_some(),
315 remote_cb.is_some(),
316 ),
317 };
318 if let Some(d) = diff_result {
319 diffs.push(ResourceDiff::ContentBlock(d));
320 }
321 }
322
323 Ok((diffs, id_index))
324}
325
326pub(crate) async fn compute_email_template_plan(
329 client: &BrazeClient,
330 email_templates_root: &Path,
331 name_filter: Option<&str>,
332 excludes: &[Regex],
333) -> anyhow::Result<(Vec<ResourceDiff>, EmailTemplateIdIndex)> {
334 let mut local = email_template_io::load_all_email_templates(email_templates_root)?;
335 if let Some(name) = name_filter {
336 local.retain(|t| t.name == name);
337 }
338 local.retain(|t| !is_excluded(&t.name, excludes));
339
340 let mut summaries = client.list_email_templates().await?;
341 if let Some(name) = name_filter {
342 summaries.retain(|s| s.name == name);
343 }
344 summaries.retain(|s| !is_excluded(&s.name, excludes));
345
346 let id_index: EmailTemplateIdIndex = summaries
347 .into_iter()
348 .map(|s| (s.name, s.email_template_id))
349 .collect();
350
351 let local_by_name: BTreeMap<&str, &EmailTemplate> =
352 local.iter().map(|t| (t.name.as_str(), t)).collect();
353
354 let shared_names: Vec<&str> = id_index
355 .keys()
356 .map(String::as_str)
357 .filter(|n| local_by_name.contains_key(n))
358 .collect();
359 let fetched: BTreeMap<String, EmailTemplate> =
360 futures::stream::iter(shared_names.iter().map(|name| {
361 let id = id_index
362 .get(*name)
363 .expect("id_index built from the same summaries set");
364 async move {
365 client
366 .get_email_template(id)
367 .await
368 .map(|et| (name.to_string(), et))
369 .with_context(|| format!("fetching email template '{name}'"))
370 }
371 }))
372 .buffer_unordered(FETCH_CONCURRENCY)
373 .try_collect()
374 .await?;
375
376 drop(local_by_name);
377 let mut et_failures = Vec::new();
378 for et in &mut local {
379 let remote_et = fetched.get(&et.name);
380 if let Err(failures) = resolve_email_template_with_remote(et, remote_et) {
381 et_failures.extend(failures);
382 }
383 }
384 if !et_failures.is_empty() {
385 return Err(format_failures(&et_failures).into());
386 }
387 let local_by_name: BTreeMap<&str, &EmailTemplate> =
388 local.iter().map(|t| (t.name.as_str(), t)).collect();
389
390 let mut all_names: BTreeSet<&str> = BTreeSet::new();
391 all_names.extend(local_by_name.keys().copied());
392 all_names.extend(id_index.keys().map(String::as_str));
393
394 let mut diffs = Vec::new();
395 for name in all_names {
396 let local_et = local_by_name.get(name).copied();
397 let remote_et = fetched.get(name);
398 let remote_present = id_index.contains_key(name);
399 let diff_result = match (local_et, remote_et, remote_present) {
400 (Some(l), Some(r), true) => diff_email_template(Some(l), Some(r)),
401 (Some(l), None, false) => diff_email_template(Some(l), None),
402 (None, None, true) => Some(EmailTemplateDiff::orphan(name)),
403 _ => unreachable!(
404 "email_template diff invariant violated for '{name}': \
405 local={} remote={} remote_present={remote_present}",
406 local_et.is_some(),
407 remote_et.is_some(),
408 ),
409 };
410 if let Some(d) = diff_result {
411 diffs.push(ResourceDiff::EmailTemplate(d));
412 }
413 }
414
415 Ok((diffs, id_index))
416}
417
418pub(crate) async fn compute_custom_attribute_diffs(
425 client: &BrazeClient,
426 registry_path: &Path,
427 name_filter: Option<&str>,
428 excludes: &[Regex],
429) -> anyhow::Result<Vec<ResourceDiff>> {
430 let mut local = custom_attribute_io::load_registry(registry_path)?;
431 let mut remote = client.list_custom_attributes().await?;
432 if let Some(name) = name_filter {
433 if let Some(r) = local.as_mut() {
434 r.attributes.retain(|a| a.name == name);
435 }
436 remote.retain(|a| a.name == name);
437 }
438 if let Some(r) = local.as_mut() {
439 r.attributes.retain(|a| !is_excluded(&a.name, excludes));
440 }
441 remote.retain(|a| !is_excluded(&a.name, excludes));
442 let attr_diffs = diff_custom_attributes(local.as_ref(), &remote);
443 Ok(attr_diffs
444 .into_iter()
445 .map(ResourceDiff::CustomAttribute)
446 .collect())
447}
448
449pub(crate) fn compute_tag_diffs(
455 config_dir: &Path,
456 resolved: &ResolvedConfig,
457 registry_path: &Path,
458 name_filter: Option<&str>,
459 excludes: &[Regex],
460) -> anyhow::Result<Vec<ResourceDiff>> {
461 let mut local = tag_io::load_registry(registry_path)?;
462 let mut referenced = crate::cli::export::collect_local_tag_references(config_dir, resolved)?;
463
464 if let Some(name) = name_filter {
465 if let Some(r) = local.as_mut() {
466 r.tags.retain(|t| t.name == name);
467 }
468 referenced.retain(|n| n == name);
469 }
470 if let Some(r) = local.as_mut() {
471 r.tags.retain(|t| !is_excluded(&t.name, excludes));
472 }
473 referenced.retain(|n| !is_excluded(n, excludes));
474
475 let tag_diffs = diff_tags(local.as_ref(), &referenced);
476 Ok(tag_diffs.into_iter().map(ResourceDiff::Tag).collect())
477}