1use crate::braze::BrazeClient;
13use crate::config::ResolvedConfig;
14use crate::diff::catalog::CatalogSchemaDiff;
15use crate::diff::content_block::{ContentBlockDiff, ContentBlockIdIndex};
16use crate::diff::custom_attribute::CustomAttributeOp;
17use crate::diff::email_template::{EmailTemplateDiff, EmailTemplateIdIndex};
18use crate::diff::orphan;
19use crate::diff::{DiffOp, DiffSummary, ResourceDiff};
20use crate::error::Error;
21use crate::format::OutputFormat;
22use crate::resource::ResourceKind;
23use anyhow::{anyhow, Context as _};
24use clap::Args;
25use std::path::Path;
26
27use super::diff::{
28 compute_catalog_schema_diffs, compute_content_block_plan, compute_custom_attribute_diffs,
29 compute_email_template_plan,
30};
31use super::{selected_kinds, warn_if_name_excluded};
32
33#[derive(Args, Debug)]
34pub struct ApplyArgs {
35 #[arg(long, value_enum)]
37 pub resource: Option<ResourceKind>,
38
39 #[arg(long, requires = "resource")]
42 pub name: Option<String>,
43
44 #[arg(long)]
47 pub confirm: bool,
48
49 #[arg(long)]
53 pub allow_destructive: bool,
54
55 #[arg(long)]
59 pub archive_orphans: bool,
60}
61
62pub async fn run(
63 args: &ApplyArgs,
64 resolved: ResolvedConfig,
65 config_dir: &Path,
66 format: OutputFormat,
67) -> anyhow::Result<()> {
68 let catalogs_root = config_dir.join(&resolved.resources.catalog_schema.path);
69 let content_blocks_root = config_dir.join(&resolved.resources.content_block.path);
70 let email_templates_root = config_dir.join(&resolved.resources.email_template.path);
71 let custom_attributes_path = config_dir.join(&resolved.resources.custom_attribute.path);
72 let client = BrazeClient::from_resolved(&resolved);
73 let kinds = selected_kinds(args.resource, &resolved.resources);
74
75 let mut summary = DiffSummary::default();
76 let mut content_block_id_index: Option<ContentBlockIdIndex> = None;
77 let mut email_template_id_index: Option<EmailTemplateIdIndex> = None;
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 plan")?;
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 plan")?;
103 summary.diffs.extend(diffs);
104 content_block_id_index = Some(idx);
105 }
106 ResourceKind::EmailTemplate => {
107 let (diffs, idx) = compute_email_template_plan(
108 &client,
109 &email_templates_root,
110 args.name.as_deref(),
111 resolved.excludes_for(ResourceKind::EmailTemplate),
112 )
113 .await
114 .context("computing email_template plan")?;
115 summary.diffs.extend(diffs);
116 email_template_id_index = Some(idx);
117 }
118 ResourceKind::CustomAttribute => {
119 let diffs = compute_custom_attribute_diffs(
120 &client,
121 &custom_attributes_path,
122 args.name.as_deref(),
123 resolved.excludes_for(ResourceKind::CustomAttribute),
124 )
125 .await
126 .context("computing custom_attribute plan")?;
127 summary.diffs.extend(diffs);
128 }
129 }
130 }
131
132 let mode_label = if args.confirm {
133 "Plan:"
134 } else {
135 "Plan (dry-run, pass --confirm to apply):"
136 };
137 eprintln!("{mode_label}");
138 print!("{}", format.formatter().format(&summary));
139
140 if summary.actionable_count() == 0 {
141 if summary.changed_count() > 0 {
142 eprintln!(
143 "No actionable changes to apply \
144 (informational drift above can be reconciled with `export`)."
145 );
146 } else {
147 eprintln!("No changes to apply.");
148 }
149 return Ok(());
150 }
151
152 if !args.confirm {
153 eprintln!("DRY RUN — pass --confirm to apply these changes.");
154 return Ok(());
155 }
156
157 if summary.destructive_count() > 0 && !args.allow_destructive {
158 return Err(Error::DestructiveBlocked.into());
159 }
160
161 check_for_unsupported_ops(&summary)?;
162
163 let today = chrono::Utc::now().date_naive();
169
170 let mut applied = 0;
171 let mut ca_deprecate: Vec<&str> = Vec::new();
172 let mut ca_reactivate: Vec<&str> = Vec::new();
173 for diff in &summary.diffs {
174 match diff {
175 ResourceDiff::CatalogSchema(d) => {
176 applied += apply_catalog_schema(&client, d).await?;
177 }
178 ResourceDiff::ContentBlock(d) => {
179 applied += apply_content_block(
180 &client,
181 d,
182 content_block_id_index.as_ref(),
183 args.archive_orphans,
184 today,
185 )
186 .await?;
187 }
188 ResourceDiff::EmailTemplate(d) => {
189 applied += apply_email_template(
190 &client,
191 d,
192 email_template_id_index.as_ref(),
193 args.archive_orphans,
194 today,
195 )
196 .await?;
197 }
198 ResourceDiff::CustomAttribute(d) => {
199 if let CustomAttributeOp::DeprecationToggled { to, .. } = &d.op {
200 if *to {
201 ca_deprecate.push(&d.name);
202 } else {
203 ca_reactivate.push(&d.name);
204 }
205 }
206 }
207 }
208 }
209
210 if !ca_deprecate.is_empty() || !ca_reactivate.is_empty() {
211 applied += apply_custom_attribute_batch(&client, &ca_deprecate, &ca_reactivate).await?;
212 }
213
214 eprintln!("✓ Applied {applied} change(s).");
215 Ok(())
216}
217
218fn check_for_unsupported_ops(summary: &DiffSummary) -> anyhow::Result<()> {
228 for diff in &summary.diffs {
229 if let ResourceDiff::CustomAttribute(d) = diff {
230 if matches!(d.op, CustomAttributeOp::PresentInGitOnly) {
231 return Err(Error::CustomAttributeCreateNotSupported {
232 name: d.name.clone(),
233 }
234 .into());
235 }
236 }
237 if let ResourceDiff::CatalogSchema(d) = diff {
238 match &d.op {
239 DiffOp::Added(_) => {
240 return Err(anyhow!(
241 "creating a new catalog '{}' is not supported by braze-sync; \
242 create the catalog in the Braze dashboard first, then run \
243 `braze-sync export` to populate the local schema",
244 d.name
245 ));
246 }
247 DiffOp::Removed(_) => {
248 return Err(anyhow!(
249 "deleting catalog '{}' (top-level) is not supported by braze-sync; \
250 only field-level changes can be applied",
251 d.name
252 ));
253 }
254 _ => {}
255 }
256 for fd in &d.field_diffs {
259 if let DiffOp::Modified { from, to } = fd {
260 return Err(anyhow!(
261 "modifying field '{}' on catalog '{}' (type {} → {}) \
262 is not supported by braze-sync; the change would be \
263 data-losing on the field. Drop the field manually \
264 in the Braze dashboard and re-run `braze-sync apply`",
265 to.name,
266 d.name,
267 from.field_type.as_str(),
268 to.field_type.as_str(),
269 ));
270 }
271 }
272 }
273 }
274 Ok(())
275}
276
277async fn apply_content_block(
278 client: &BrazeClient,
279 d: &ContentBlockDiff,
280 id_index: Option<&ContentBlockIdIndex>,
281 archive_orphans: bool,
282 today: chrono::NaiveDate,
283) -> anyhow::Result<usize> {
284 if d.orphan {
287 if !archive_orphans {
288 return Ok(0);
289 }
290 let id_index = id_index.ok_or_else(|| {
291 anyhow!("internal: content_block id index missing for orphan apply path")
292 })?;
293 let id = id_index.get(&d.name).ok_or_else(|| {
294 anyhow!(
295 "internal: orphan '{}' missing from id index — list/diff drift",
296 d.name
297 )
298 })?;
299 let archived = orphan::archive_name(today, &d.name);
300 if archived == d.name {
301 return Ok(0);
302 }
303 let mut cb = client
310 .get_content_block(id)
311 .await
312 .with_context(|| format!("fetching content block '{}' for archive rename", d.name))?;
313 cb.name = archived;
314 tracing::info!(
315 content_block = %d.name,
316 new_name = %cb.name,
317 "archiving orphan content block"
318 );
319 client.update_content_block(id, &cb).await?;
320 return Ok(1);
321 }
322
323 match &d.op {
324 DiffOp::Added(cb) => {
325 tracing::info!(content_block = %cb.name, "creating content block");
326 let _ = client.create_content_block(cb).await?;
327 Ok(1)
328 }
329 DiffOp::Modified { to, .. } => {
330 let id_index = id_index.ok_or_else(|| {
331 anyhow!("internal: content_block id index missing for modified apply path")
332 })?;
333 let id = id_index.get(&to.name).ok_or_else(|| {
334 anyhow!(
335 "internal: modified content block '{}' missing from id index",
336 to.name
337 )
338 })?;
339 tracing::info!(content_block = %to.name, "updating content block");
340 client.update_content_block(id, to).await?;
341 Ok(1)
342 }
343 DiffOp::Removed(_) => {
346 unreachable!("diff layer routes content block removals through orphan")
347 }
348 DiffOp::Unchanged => Ok(0),
349 }
350}
351
352async fn apply_catalog_schema(
353 client: &BrazeClient,
354 d: &CatalogSchemaDiff,
355) -> anyhow::Result<usize> {
356 let mut count = 0;
357 for fd in &d.field_diffs {
358 match fd {
359 DiffOp::Added(f) => {
360 tracing::info!(
361 catalog = %d.name,
362 field = %f.name,
363 field_type = f.field_type.as_str(),
364 "adding catalog field"
365 );
366 client.add_catalog_field(&d.name, f).await?;
367 count += 1;
368 }
369 DiffOp::Removed(f) => {
370 tracing::info!(
371 catalog = %d.name,
372 field = %f.name,
373 "deleting catalog field"
374 );
375 client.delete_catalog_field(&d.name, &f.name).await?;
376 count += 1;
377 }
378 DiffOp::Modified { .. } => {
379 return Err(anyhow!(
380 "internal: Modified field op should have been rejected \
381 by check_for_unsupported_ops"
382 ));
383 }
384 DiffOp::Unchanged => {}
385 }
386 }
387 Ok(count)
388}
389
390async fn apply_email_template(
391 client: &BrazeClient,
392 d: &EmailTemplateDiff,
393 id_index: Option<&EmailTemplateIdIndex>,
394 archive_orphans: bool,
395 today: chrono::NaiveDate,
396) -> anyhow::Result<usize> {
397 if d.orphan {
398 if !archive_orphans {
399 return Ok(0);
400 }
401 let id_index = id_index.ok_or_else(|| {
402 anyhow!("internal: email_template id index missing for orphan apply path")
403 })?;
404 let id = id_index.get(&d.name).ok_or_else(|| {
405 anyhow!(
406 "internal: orphan '{}' missing from id index — list/diff drift",
407 d.name
408 )
409 })?;
410 let archived = orphan::archive_name(today, &d.name);
411 if archived == d.name {
412 return Ok(0);
413 }
414 let mut et = client
415 .get_email_template(id)
416 .await
417 .with_context(|| format!("fetching email template '{}' for archive rename", d.name))?;
418 et.name = archived;
419 tracing::info!(
420 email_template = %d.name,
421 new_name = %et.name,
422 "archiving orphan email template"
423 );
424 client.update_email_template(id, &et).await?;
425 return Ok(1);
426 }
427
428 match &d.op {
429 DiffOp::Added(et) => {
430 tracing::info!(email_template = %et.name, "creating email template");
431 let _ = client.create_email_template(et).await?;
432 Ok(1)
433 }
434 DiffOp::Modified { to, .. } => {
435 let id_index = id_index.ok_or_else(|| {
436 anyhow!("internal: email_template id index missing for modified apply path")
437 })?;
438 let id = id_index.get(&to.name).ok_or_else(|| {
439 anyhow!(
440 "internal: modified email template '{}' missing from id index",
441 to.name
442 )
443 })?;
444 tracing::info!(email_template = %to.name, "updating email template");
445 client.update_email_template(id, to).await?;
446 Ok(1)
447 }
448 DiffOp::Removed(_) => {
449 unreachable!("diff layer routes email template removals through orphan")
450 }
451 DiffOp::Unchanged => Ok(0),
452 }
453}
454
455async fn apply_custom_attribute_batch(
459 client: &BrazeClient,
460 to_deprecate: &[&str],
461 to_reactivate: &[&str],
462) -> anyhow::Result<usize> {
463 let mut applied = 0;
464 for (names, blocklisted, verb) in [
465 (to_deprecate, true, "deprecating"),
466 (to_reactivate, false, "reactivating"),
467 ] {
468 if names.is_empty() {
469 continue;
470 }
471 tracing::info!(attributes = ?names, "{verb} custom attributes");
472 client
473 .set_custom_attribute_blocklist(names, blocklisted)
474 .await
475 .with_context(|| format!("{verb} custom attributes"))?;
476 let n = names.len();
477 let past = if blocklisted {
478 "deprecated"
479 } else {
480 "reactivated"
481 };
482 eprintln!(" ✓ {past} {n} custom attribute(s)");
483 applied += n;
484 }
485
486 Ok(applied)
487}