1use crate::braze::error::BrazeApiError;
8use crate::braze::BrazeClient;
9use crate::config::ResolvedConfig;
10use crate::diff::catalog::diff_schema;
11use crate::diff::content_block::{
12 diff as diff_content_block, ContentBlockDiff, ContentBlockIdIndex,
13};
14use crate::diff::email_template::{
15 diff as diff_email_template, EmailTemplateDiff, EmailTemplateIdIndex,
16};
17use crate::diff::{DiffSummary, ResourceDiff};
18use crate::error::Error;
19use crate::format::OutputFormat;
20use crate::fs::{catalog_io, content_block_io, email_template_io};
21use crate::resource::{Catalog, ContentBlock, EmailTemplate, ResourceKind};
22use anyhow::Context as _;
23use clap::Args;
24use futures::stream::{StreamExt, TryStreamExt};
25use std::collections::{BTreeMap, BTreeSet};
26use std::path::Path;
27
28use super::{selected_kinds, warn_unimplemented, FETCH_CONCURRENCY};
29
30#[derive(Args, Debug)]
31pub struct DiffArgs {
32 #[arg(long, value_enum)]
34 pub resource: Option<ResourceKind>,
35
36 #[arg(long, requires = "resource")]
39 pub name: Option<String>,
40
41 #[arg(long)]
43 pub fail_on_drift: bool,
44}
45
46pub async fn run(
47 args: &DiffArgs,
48 resolved: ResolvedConfig,
49 config_dir: &Path,
50 format: OutputFormat,
51) -> anyhow::Result<()> {
52 let catalogs_root = config_dir.join(&resolved.resources.catalog_schema.path);
53 let content_blocks_root = config_dir.join(&resolved.resources.content_block.path);
54 let email_templates_root = config_dir.join(&resolved.resources.email_template.path);
55 let client = BrazeClient::from_resolved(&resolved);
56 let kinds = selected_kinds(args.resource, &resolved.resources);
57
58 let mut summary = DiffSummary::default();
59 for kind in kinds {
60 match kind {
61 ResourceKind::CatalogSchema => {
62 let diffs =
63 compute_catalog_schema_diffs(&client, &catalogs_root, args.name.as_deref())
64 .await
65 .context("computing catalog_schema diff")?;
66 summary.diffs.extend(diffs);
67 }
68 ResourceKind::ContentBlock => {
69 let (diffs, _idx) =
70 compute_content_block_plan(&client, &content_blocks_root, args.name.as_deref())
71 .await
72 .context("computing content_block diff")?;
73 summary.diffs.extend(diffs);
74 }
75 ResourceKind::EmailTemplate => {
76 let (diffs, _idx) = compute_email_template_plan(
77 &client,
78 &email_templates_root,
79 args.name.as_deref(),
80 )
81 .await
82 .context("computing email_template diff")?;
83 summary.diffs.extend(diffs);
84 }
85 other => {
86 warn_unimplemented(other);
87 }
88 }
89 }
90
91 let formatted = format.formatter().format(&summary);
92 print!("{formatted}");
93
94 if args.fail_on_drift && summary.changed_count() > 0 {
95 return Err(Error::DriftDetected {
96 count: summary.changed_count(),
97 }
98 .into());
99 }
100
101 Ok(())
102}
103
104pub(crate) async fn compute_catalog_schema_diffs(
107 client: &BrazeClient,
108 catalogs_root: &Path,
109 name_filter: Option<&str>,
110) -> anyhow::Result<Vec<ResourceDiff>> {
111 let mut local = catalog_io::load_all_schemas(catalogs_root)?;
112 if let Some(name) = name_filter {
113 local.retain(|c| c.name == name);
114 }
115
116 let remote: Vec<Catalog> = match name_filter {
117 Some(name) => match client.get_catalog(name).await {
118 Ok(c) => vec![c],
119 Err(BrazeApiError::NotFound { .. }) => Vec::new(),
122 Err(e) => return Err(e.into()),
123 },
124 None => client.list_catalogs().await?,
125 };
126
127 let local_by_name: BTreeMap<&str, &Catalog> =
128 local.iter().map(|c| (c.name.as_str(), c)).collect();
129 let remote_by_name: BTreeMap<&str, &Catalog> =
130 remote.iter().map(|c| (c.name.as_str(), c)).collect();
131
132 let mut all_names: BTreeSet<&str> = BTreeSet::new();
133 all_names.extend(local_by_name.keys().copied());
134 all_names.extend(remote_by_name.keys().copied());
135
136 let mut diffs = Vec::new();
137 for name in all_names {
138 let l = local_by_name.get(name).copied();
139 let r = remote_by_name.get(name).copied();
140 if let Some(d) = diff_schema(l, r) {
141 diffs.push(ResourceDiff::CatalogSchema(d));
142 }
143 }
144
145 Ok(diffs)
146}
147
148pub(crate) async fn compute_content_block_plan(
152 client: &BrazeClient,
153 content_blocks_root: &Path,
154 name_filter: Option<&str>,
155) -> anyhow::Result<(Vec<ResourceDiff>, ContentBlockIdIndex)> {
156 let mut local = content_block_io::load_all_content_blocks(content_blocks_root)?;
157 if let Some(name) = name_filter {
158 local.retain(|c| c.name == name);
159 }
160
161 let mut summaries = client.list_content_blocks().await?;
162 if let Some(name) = name_filter {
163 summaries.retain(|s| s.name == name);
164 }
165
166 let id_index: ContentBlockIdIndex = summaries
167 .into_iter()
168 .map(|s| (s.name, s.content_block_id))
169 .collect();
170
171 let local_by_name: BTreeMap<&str, &ContentBlock> =
172 local.iter().map(|c| (c.name.as_str(), c)).collect();
173
174 let shared_names: Vec<&str> = id_index
177 .keys()
178 .map(String::as_str)
179 .filter(|n| local_by_name.contains_key(n))
180 .collect();
181 let fetched: BTreeMap<String, ContentBlock> =
182 futures::stream::iter(shared_names.iter().map(|name| {
183 let id = id_index
184 .get(*name)
185 .expect("id_index built from the same summaries set");
186 async move {
187 client
188 .get_content_block(id)
189 .await
190 .map(|cb| (name.to_string(), cb))
191 .with_context(|| format!("fetching content block '{name}'"))
192 }
193 }))
194 .buffer_unordered(FETCH_CONCURRENCY)
195 .try_collect()
196 .await?;
197
198 let mut all_names: BTreeSet<&str> = BTreeSet::new();
199 all_names.extend(local_by_name.keys().copied());
200 all_names.extend(id_index.keys().map(String::as_str));
201
202 let mut diffs = Vec::new();
203 for name in all_names {
204 let local_cb = local_by_name.get(name).copied();
205 let remote_cb = fetched.get(name);
206 let remote_present = id_index.contains_key(name);
207 let diff_result = match (local_cb, remote_cb, remote_present) {
214 (Some(l), Some(r), true) => diff_content_block(Some(l), Some(r)),
215 (Some(l), None, false) => diff_content_block(Some(l), None),
216 (None, None, true) => Some(ContentBlockDiff::orphan(name)),
217 _ => unreachable!(
218 "content_block diff invariant violated for '{name}': \
219 local={} remote={} remote_present={remote_present}",
220 local_cb.is_some(),
221 remote_cb.is_some(),
222 ),
223 };
224 if let Some(d) = diff_result {
225 diffs.push(ResourceDiff::ContentBlock(d));
226 }
227 }
228
229 Ok((diffs, id_index))
230}
231
232pub(crate) async fn compute_email_template_plan(
235 client: &BrazeClient,
236 email_templates_root: &Path,
237 name_filter: Option<&str>,
238) -> anyhow::Result<(Vec<ResourceDiff>, EmailTemplateIdIndex)> {
239 let mut local = email_template_io::load_all_email_templates(email_templates_root)?;
240 if let Some(name) = name_filter {
241 local.retain(|t| t.name == name);
242 }
243
244 let mut summaries = client.list_email_templates().await?;
245 if let Some(name) = name_filter {
246 summaries.retain(|s| s.name == name);
247 }
248
249 let id_index: EmailTemplateIdIndex = summaries
250 .into_iter()
251 .map(|s| (s.name, s.email_template_id))
252 .collect();
253
254 let local_by_name: BTreeMap<&str, &EmailTemplate> =
255 local.iter().map(|t| (t.name.as_str(), t)).collect();
256
257 let shared_names: Vec<&str> = id_index
258 .keys()
259 .map(String::as_str)
260 .filter(|n| local_by_name.contains_key(n))
261 .collect();
262 let fetched: BTreeMap<String, EmailTemplate> =
263 futures::stream::iter(shared_names.iter().map(|name| {
264 let id = id_index
265 .get(*name)
266 .expect("id_index built from the same summaries set");
267 async move {
268 client
269 .get_email_template(id)
270 .await
271 .map(|et| (name.to_string(), et))
272 .with_context(|| format!("fetching email template '{name}'"))
273 }
274 }))
275 .buffer_unordered(FETCH_CONCURRENCY)
276 .try_collect()
277 .await?;
278
279 let mut all_names: BTreeSet<&str> = BTreeSet::new();
280 all_names.extend(local_by_name.keys().copied());
281 all_names.extend(id_index.keys().map(String::as_str));
282
283 let mut diffs = Vec::new();
284 for name in all_names {
285 let local_et = local_by_name.get(name).copied();
286 let remote_et = fetched.get(name);
287 let remote_present = id_index.contains_key(name);
288 let diff_result = match (local_et, remote_et, remote_present) {
289 (Some(l), Some(r), true) => diff_email_template(Some(l), Some(r)),
290 (Some(l), None, false) => diff_email_template(Some(l), None),
291 (None, None, true) => Some(EmailTemplateDiff::orphan(name)),
292 _ => unreachable!(
293 "email_template diff invariant violated for '{name}': \
294 local={} remote={} remote_present={remote_present}",
295 local_et.is_some(),
296 remote_et.is_some(),
297 ),
298 };
299 if let Some(d) = diff_result {
300 diffs.push(ResourceDiff::EmailTemplate(d));
301 }
302 }
303
304 Ok((diffs, id_index))
305}