1use crate::braze::error::BrazeApiError;
8use crate::braze::BrazeClient;
9use crate::config::ResolvedConfig;
10use crate::diff::catalog::{diff_items, 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::{DiffSummary, ResourceDiff};
19use crate::error::Error;
20use crate::format::OutputFormat;
21use crate::fs::{catalog_io, content_block_io, custom_attribute_io, email_template_io};
22use crate::resource::{Catalog, CatalogItems, ContentBlock, EmailTemplate, ResourceKind};
23use anyhow::Context as _;
24use clap::Args;
25use futures::stream::{StreamExt, TryStreamExt};
26use std::collections::{BTreeMap, BTreeSet, HashMap};
27use std::path::Path;
28
29use super::{selected_kinds, FETCH_CONCURRENCY};
30
31#[derive(Args, Debug)]
32pub struct DiffArgs {
33 #[arg(long, value_enum)]
35 pub resource: Option<ResourceKind>,
36
37 #[arg(long, requires = "resource")]
40 pub name: Option<String>,
41
42 #[arg(long)]
44 pub fail_on_drift: bool,
45}
46
47pub async fn run(
48 args: &DiffArgs,
49 resolved: ResolvedConfig,
50 config_dir: &Path,
51 format: OutputFormat,
52) -> anyhow::Result<()> {
53 let catalogs_root = config_dir.join(&resolved.resources.catalog_schema.path);
54 let content_blocks_root = config_dir.join(&resolved.resources.content_block.path);
55 let email_templates_root = config_dir.join(&resolved.resources.email_template.path);
56 let custom_attributes_path = config_dir.join(&resolved.resources.custom_attribute.path);
57 let client = BrazeClient::from_resolved(&resolved);
58 let kinds = selected_kinds(args.resource, &resolved.resources);
59
60 let mut summary = DiffSummary::default();
61 for kind in kinds {
62 match kind {
63 ResourceKind::CatalogSchema => {
64 let diffs =
65 compute_catalog_schema_diffs(&client, &catalogs_root, args.name.as_deref())
66 .await
67 .context("computing catalog_schema diff")?;
68 summary.diffs.extend(diffs);
69 }
70 ResourceKind::ContentBlock => {
71 let (diffs, _idx) =
72 compute_content_block_plan(&client, &content_blocks_root, args.name.as_deref())
73 .await
74 .context("computing content_block diff")?;
75 summary.diffs.extend(diffs);
76 }
77 ResourceKind::CatalogItems => {
78 let (diffs, _map) = compute_catalog_items_diffs(
79 &client,
80 &catalogs_root,
81 args.name.as_deref(),
82 false,
83 )
84 .await
85 .context("computing catalog_items diff")?;
86 summary.diffs.extend(diffs);
87 }
88 ResourceKind::EmailTemplate => {
89 let (diffs, _idx) = compute_email_template_plan(
90 &client,
91 &email_templates_root,
92 args.name.as_deref(),
93 )
94 .await
95 .context("computing email_template diff")?;
96 summary.diffs.extend(diffs);
97 }
98 ResourceKind::CustomAttribute => {
99 let diffs = compute_custom_attribute_diffs(
100 &client,
101 &custom_attributes_path,
102 args.name.as_deref(),
103 )
104 .await
105 .context("computing custom_attribute diff")?;
106 summary.diffs.extend(diffs);
107 }
108 }
109 }
110
111 let formatted = format.formatter().format(&summary);
112 print!("{formatted}");
113
114 if args.fail_on_drift && summary.changed_count() > 0 {
115 return Err(Error::DriftDetected {
116 count: summary.changed_count(),
117 }
118 .into());
119 }
120
121 Ok(())
122}
123
124pub(crate) async fn compute_catalog_schema_diffs(
127 client: &BrazeClient,
128 catalogs_root: &Path,
129 name_filter: Option<&str>,
130) -> anyhow::Result<Vec<ResourceDiff>> {
131 let mut local = catalog_io::load_all_schemas(catalogs_root)?;
132 if let Some(name) = name_filter {
133 local.retain(|c| c.name == name);
134 }
135
136 let remote: Vec<Catalog> = match name_filter {
137 Some(name) => match client.get_catalog(name).await {
138 Ok(c) => vec![c],
139 Err(BrazeApiError::NotFound { .. }) => Vec::new(),
142 Err(e) => return Err(e.into()),
143 },
144 None => client.list_catalogs().await?,
145 };
146
147 let local_by_name: BTreeMap<&str, &Catalog> =
148 local.iter().map(|c| (c.name.as_str(), c)).collect();
149 let remote_by_name: BTreeMap<&str, &Catalog> =
150 remote.iter().map(|c| (c.name.as_str(), c)).collect();
151
152 let mut all_names: BTreeSet<&str> = BTreeSet::new();
153 all_names.extend(local_by_name.keys().copied());
154 all_names.extend(remote_by_name.keys().copied());
155
156 let mut diffs = Vec::new();
157 for name in all_names {
158 let l = local_by_name.get(name).copied();
159 let r = remote_by_name.get(name).copied();
160 if let Some(d) = diff_schema(l, r) {
161 diffs.push(ResourceDiff::CatalogSchema(d));
162 }
163 }
164
165 Ok(diffs)
166}
167
168pub(crate) async fn compute_content_block_plan(
172 client: &BrazeClient,
173 content_blocks_root: &Path,
174 name_filter: Option<&str>,
175) -> anyhow::Result<(Vec<ResourceDiff>, ContentBlockIdIndex)> {
176 let mut local = content_block_io::load_all_content_blocks(content_blocks_root)?;
177 if let Some(name) = name_filter {
178 local.retain(|c| c.name == name);
179 }
180
181 let mut summaries = client.list_content_blocks().await?;
182 if let Some(name) = name_filter {
183 summaries.retain(|s| s.name == name);
184 }
185
186 let id_index: ContentBlockIdIndex = summaries
187 .into_iter()
188 .map(|s| (s.name, s.content_block_id))
189 .collect();
190
191 let local_by_name: BTreeMap<&str, &ContentBlock> =
192 local.iter().map(|c| (c.name.as_str(), c)).collect();
193
194 let shared_names: Vec<&str> = id_index
197 .keys()
198 .map(String::as_str)
199 .filter(|n| local_by_name.contains_key(n))
200 .collect();
201 let fetched: BTreeMap<String, ContentBlock> =
202 futures::stream::iter(shared_names.iter().map(|name| {
203 let id = id_index
204 .get(*name)
205 .expect("id_index built from the same summaries set");
206 async move {
207 client
208 .get_content_block(id)
209 .await
210 .map(|cb| (name.to_string(), cb))
211 .with_context(|| format!("fetching content block '{name}'"))
212 }
213 }))
214 .buffer_unordered(FETCH_CONCURRENCY)
215 .try_collect()
216 .await?;
217
218 let mut all_names: BTreeSet<&str> = BTreeSet::new();
219 all_names.extend(local_by_name.keys().copied());
220 all_names.extend(id_index.keys().map(String::as_str));
221
222 let mut diffs = Vec::new();
223 for name in all_names {
224 let local_cb = local_by_name.get(name).copied();
225 let remote_cb = fetched.get(name);
226 let remote_present = id_index.contains_key(name);
227 let diff_result = match (local_cb, remote_cb, remote_present) {
234 (Some(l), Some(r), true) => diff_content_block(Some(l), Some(r)),
235 (Some(l), None, false) => diff_content_block(Some(l), None),
236 (None, None, true) => Some(ContentBlockDiff::orphan(name)),
237 _ => unreachable!(
238 "content_block diff invariant violated for '{name}': \
239 local={} remote={} remote_present={remote_present}",
240 local_cb.is_some(),
241 remote_cb.is_some(),
242 ),
243 };
244 if let Some(d) = diff_result {
245 diffs.push(ResourceDiff::ContentBlock(d));
246 }
247 }
248
249 Ok((diffs, id_index))
250}
251
252pub(crate) async fn compute_email_template_plan(
255 client: &BrazeClient,
256 email_templates_root: &Path,
257 name_filter: Option<&str>,
258) -> anyhow::Result<(Vec<ResourceDiff>, EmailTemplateIdIndex)> {
259 let mut local = email_template_io::load_all_email_templates(email_templates_root)?;
260 if let Some(name) = name_filter {
261 local.retain(|t| t.name == name);
262 }
263
264 let mut summaries = client.list_email_templates().await?;
265 if let Some(name) = name_filter {
266 summaries.retain(|s| s.name == name);
267 }
268
269 let id_index: EmailTemplateIdIndex = summaries
270 .into_iter()
271 .map(|s| (s.name, s.email_template_id))
272 .collect();
273
274 let local_by_name: BTreeMap<&str, &EmailTemplate> =
275 local.iter().map(|t| (t.name.as_str(), t)).collect();
276
277 let shared_names: Vec<&str> = id_index
278 .keys()
279 .map(String::as_str)
280 .filter(|n| local_by_name.contains_key(n))
281 .collect();
282 let fetched: BTreeMap<String, EmailTemplate> =
283 futures::stream::iter(shared_names.iter().map(|name| {
284 let id = id_index
285 .get(*name)
286 .expect("id_index built from the same summaries set");
287 async move {
288 client
289 .get_email_template(id)
290 .await
291 .map(|et| (name.to_string(), et))
292 .with_context(|| format!("fetching email template '{name}'"))
293 }
294 }))
295 .buffer_unordered(FETCH_CONCURRENCY)
296 .try_collect()
297 .await?;
298
299 let mut all_names: BTreeSet<&str> = BTreeSet::new();
300 all_names.extend(local_by_name.keys().copied());
301 all_names.extend(id_index.keys().map(String::as_str));
302
303 let mut diffs = Vec::new();
304 for name in all_names {
305 let local_et = local_by_name.get(name).copied();
306 let remote_et = fetched.get(name);
307 let remote_present = id_index.contains_key(name);
308 let diff_result = match (local_et, remote_et, remote_present) {
309 (Some(l), Some(r), true) => diff_email_template(Some(l), Some(r)),
310 (Some(l), None, false) => diff_email_template(Some(l), None),
311 (None, None, true) => Some(EmailTemplateDiff::orphan(name)),
312 _ => unreachable!(
313 "email_template diff invariant violated for '{name}': \
314 local={} remote={} remote_present={remote_present}",
315 local_et.is_some(),
316 remote_et.is_some(),
317 ),
318 };
319 if let Some(d) = diff_result {
320 diffs.push(ResourceDiff::EmailTemplate(d));
321 }
322 }
323
324 Ok((diffs, id_index))
325}
326
327pub(crate) async fn resolve_catalog_names(
330 client: &BrazeClient,
331 name_filter: Option<&str>,
332) -> anyhow::Result<Vec<String>> {
333 match name_filter {
334 Some(name) => Ok(vec![name.to_string()]),
335 None => {
336 let catalogs = client.list_catalogs().await?;
337 Ok(catalogs.into_iter().map(|c| c.name).collect())
338 }
339 }
340}
341
342pub(crate) async fn compute_catalog_items_diffs(
347 client: &BrazeClient,
348 catalogs_root: &Path,
349 name_filter: Option<&str>,
350 materialize_rows: bool,
351) -> anyhow::Result<(Vec<ResourceDiff>, BTreeMap<String, CatalogItems>)> {
352 let local_map: BTreeMap<String, CatalogItems> = match name_filter {
353 Some(name) => {
354 let items_path = catalogs_root.join(name).join(catalog_io::ITEMS_FILE_NAME);
355 if items_path.is_file() {
356 let ci = if materialize_rows {
357 catalog_io::load_items(&items_path)?
358 } else {
359 catalog_io::load_item_hashes(&items_path)?
360 };
361 BTreeMap::from([(ci.catalog_name.clone(), ci)])
362 } else {
363 BTreeMap::new()
364 }
365 }
366 None => {
367 let items = if materialize_rows {
368 catalog_io::load_all_items(catalogs_root)?
369 } else {
370 catalog_io::load_all_item_hashes(catalogs_root)?
371 };
372 items
373 .into_iter()
374 .map(|ci| (ci.catalog_name.clone(), ci))
375 .collect()
376 }
377 };
378
379 let remote_catalog_names = resolve_catalog_names(client, name_filter).await?;
380
381 let mut all_names: BTreeSet<String> = BTreeSet::new();
383 all_names.extend(local_map.keys().cloned());
384 all_names.extend(remote_catalog_names);
385
386 let fetched: HashMap<String, Option<HashMap<String, String>>> =
390 futures::stream::iter(all_names.iter().map(|name| {
391 let client = client.clone();
392 let name = name.clone();
393 async move {
394 match client.list_catalog_items(&name).await {
395 Ok(rows) => {
396 let hashes = rows
397 .iter()
398 .map(|r| (r.id.clone(), r.content_hash()))
399 .collect();
400 Ok((name, Some(hashes)))
401 }
402 Err(BrazeApiError::NotFound { .. }) => Ok((name, None)),
403 Err(e) => Err(e),
404 }
405 }
406 }))
407 .buffer_unordered(FETCH_CONCURRENCY)
408 .try_collect()
409 .await?;
410
411 let empty_hashes = HashMap::new();
412
413 let mut diffs = Vec::new();
414 for name in &all_names {
415 let local_hashes = local_map
416 .get(name)
417 .map(|ci| &ci.item_hashes)
418 .unwrap_or(&empty_hashes);
419
420 let remote_hashes = fetched
421 .get(name)
422 .and_then(|opt| opt.as_ref())
423 .unwrap_or(&empty_hashes);
424
425 let d = diff_items(name, local_hashes, remote_hashes);
426 if d.has_changes() {
427 diffs.push(ResourceDiff::CatalogItems(d));
428 }
429 }
430
431 Ok((diffs, local_map))
432}
433
434pub(crate) async fn compute_custom_attribute_diffs(
441 client: &BrazeClient,
442 registry_path: &Path,
443 name_filter: Option<&str>,
444) -> anyhow::Result<Vec<ResourceDiff>> {
445 let mut local = custom_attribute_io::load_registry(registry_path)?;
446 let mut remote = client.list_custom_attributes().await?;
447 if let Some(name) = name_filter {
448 if let Some(r) = local.as_mut() {
449 r.attributes.retain(|a| a.name == name);
450 }
451 remote.retain(|a| a.name == name);
452 }
453 let attr_diffs = diff_custom_attributes(local.as_ref(), &remote);
454 Ok(attr_diffs
455 .into_iter()
456 .map(ResourceDiff::CustomAttribute)
457 .collect())
458}