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