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 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 #[arg(long, value_enum)]
42 pub resource: Option<ResourceKind>,
43
44 #[arg(long, requires = "resource")]
47 pub name: Option<String>,
48
49 #[arg(long)]
51 pub fail_on_drift: bool,
52
53 #[arg(long, value_name = "PATH")]
57 pub plan_out: Option<PathBuf>,
58
59 #[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 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 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
224pub(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 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
271pub(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 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 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 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
377pub(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 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
470pub(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
501pub(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}