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