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, compute_tag_diffs,
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 tags_path = config_dir.join(&resolved.resources.tag.path);
73 let client = BrazeClient::from_resolved(&resolved);
74 let kinds = selected_kinds(args.resource, &resolved.resources);
75
76 let mut summary = DiffSummary::default();
77 let mut content_block_id_index: Option<ContentBlockIdIndex> = None;
78 let mut email_template_id_index: Option<EmailTemplateIdIndex> = None;
79 for kind in kinds {
80 if warn_if_name_excluded(kind, args.name.as_deref(), resolved.excludes_for(kind)) {
81 continue;
82 }
83 match kind {
84 ResourceKind::CatalogSchema => {
85 let diffs = compute_catalog_schema_diffs(
86 &client,
87 &catalogs_root,
88 args.name.as_deref(),
89 resolved.excludes_for(ResourceKind::CatalogSchema),
90 )
91 .await
92 .context("computing catalog_schema plan")?;
93 summary.diffs.extend(diffs);
94 }
95 ResourceKind::ContentBlock => {
96 let (diffs, idx) = compute_content_block_plan(
97 &client,
98 &content_blocks_root,
99 args.name.as_deref(),
100 resolved.excludes_for(ResourceKind::ContentBlock),
101 )
102 .await
103 .context("computing content_block plan")?;
104 summary.diffs.extend(diffs);
105 content_block_id_index = Some(idx);
106 }
107 ResourceKind::EmailTemplate => {
108 let (diffs, idx) = compute_email_template_plan(
109 &client,
110 &email_templates_root,
111 args.name.as_deref(),
112 resolved.excludes_for(ResourceKind::EmailTemplate),
113 )
114 .await
115 .context("computing email_template plan")?;
116 summary.diffs.extend(diffs);
117 email_template_id_index = Some(idx);
118 }
119 ResourceKind::CustomAttribute => {
120 let diffs = compute_custom_attribute_diffs(
121 &client,
122 &custom_attributes_path,
123 args.name.as_deref(),
124 resolved.excludes_for(ResourceKind::CustomAttribute),
125 )
126 .await
127 .context("computing custom_attribute plan")?;
128 summary.diffs.extend(diffs);
129 }
130 ResourceKind::Tag => {
131 let diffs = compute_tag_diffs(
132 config_dir,
133 &resolved,
134 &tags_path,
135 args.name.as_deref(),
136 resolved.excludes_for(ResourceKind::Tag),
137 )
138 .context("computing tag plan")?;
139 summary.diffs.extend(diffs);
140 }
141 }
142 }
143
144 enforce_tag_preflight(config_dir, &resolved, &summary)?;
150
151 let mode_label = if args.confirm {
152 "Plan:"
153 } else {
154 "Plan (dry-run, pass --confirm to apply):"
155 };
156 eprintln!("{mode_label}");
157 print!("{}", format.formatter().format(&summary));
158
159 if summary.actionable_count() == 0 {
160 if summary.changed_count() > 0 {
161 eprintln!(
162 "No actionable changes to apply \
163 (informational drift above can be reconciled with `export`)."
164 );
165 } else {
166 eprintln!("No changes to apply.");
167 }
168 return Ok(());
169 }
170
171 if !args.confirm {
172 eprintln!("DRY RUN — pass --confirm to apply these changes.");
173 return Ok(());
174 }
175
176 if summary.destructive_count() > 0 && !args.allow_destructive {
177 return Err(Error::DestructiveBlocked.into());
178 }
179
180 check_for_unsupported_ops(&summary)?;
181
182 let today = chrono::Utc::now().date_naive();
188
189 let mut applied = 0;
190 let mut ca_deprecate: Vec<&str> = Vec::new();
191 let mut ca_reactivate: Vec<&str> = Vec::new();
192 for diff in &summary.diffs {
193 match diff {
194 ResourceDiff::CatalogSchema(d) => {
195 applied += apply_catalog_schema(&client, d).await?;
196 }
197 ResourceDiff::ContentBlock(d) => {
198 applied += apply_content_block(
199 &client,
200 d,
201 content_block_id_index.as_ref(),
202 args.archive_orphans,
203 today,
204 )
205 .await?;
206 }
207 ResourceDiff::EmailTemplate(d) => {
208 applied += apply_email_template(
209 &client,
210 d,
211 email_template_id_index.as_ref(),
212 args.archive_orphans,
213 today,
214 )
215 .await?;
216 }
217 ResourceDiff::CustomAttribute(d) => {
218 if let CustomAttributeOp::DeprecationToggled { to, .. } = &d.op {
219 if *to {
220 ca_deprecate.push(&d.name);
221 } else {
222 ca_reactivate.push(&d.name);
223 }
224 }
225 }
226 ResourceDiff::Tag(_) => {}
229 }
230 }
231
232 if !ca_deprecate.is_empty() || !ca_reactivate.is_empty() {
233 applied += apply_custom_attribute_batch(&client, &ca_deprecate, &ca_reactivate).await?;
234 }
235
236 eprintln!("✓ Applied {applied} change(s).");
237 Ok(())
238}
239
240fn enforce_tag_preflight(
261 config_dir: &Path,
262 resolved: &ResolvedConfig,
263 summary: &DiffSummary,
264) -> anyhow::Result<()> {
265 if !resolved.resources.is_enabled(ResourceKind::Tag) {
266 return Ok(());
267 }
268 let tags_path = config_dir.join(&resolved.resources.tag.path);
269 let registry = match crate::fs::tag_io::load_registry(&tags_path)? {
270 Some(r) => r,
271 None => return Ok(()),
274 };
275 let excludes = resolved.excludes_for(ResourceKind::Tag);
276
277 let mut missing: std::collections::BTreeMap<String, Vec<String>> =
278 std::collections::BTreeMap::new();
279 for diff in &summary.diffs {
280 let (resource_label, tags) = match diff {
282 ResourceDiff::ContentBlock(d) => match &d.op {
283 DiffOp::Added(cb) => (format!("content_block '{}'", cb.name), &cb.tags),
284 DiffOp::Modified { to, .. } => (format!("content_block '{}'", to.name), &to.tags),
285 _ => continue,
286 },
287 ResourceDiff::EmailTemplate(d) => match &d.op {
288 DiffOp::Added(et) => (format!("email_template '{}'", et.name), &et.tags),
289 DiffOp::Modified { to, .. } => (format!("email_template '{}'", to.name), &to.tags),
290 _ => continue,
291 },
292 _ => continue,
293 };
294 for t in tags {
295 if crate::config::is_excluded(t, excludes) {
296 continue;
297 }
298 if !registry.contains(t) {
299 missing
300 .entry(t.clone())
301 .or_default()
302 .push(resource_label.clone());
303 }
304 }
305 }
306
307 if missing.is_empty() {
308 return Ok(());
309 }
310
311 let mut msg = String::from(
312 "tag pre-flight: refusing to apply — the following tags are referenced \
313 by resources that would be created/updated, but are not declared in \
314 tags/registry.yaml:\n",
315 );
316 for (tag, refs) in &missing {
317 msg.push_str(&format!(" • '{tag}' — referenced by:\n"));
318 for r in refs {
319 msg.push_str(&format!(" - {r}\n"));
320 }
321 }
322 msg.push_str(
323 "\nFix: create each missing tag in the Braze dashboard \
324 (Settings → Tags), then add it to tags/registry.yaml and re-run apply. \
325 Braze does not expose a tag creation API.",
326 );
327 Err(anyhow!(msg))
328}
329
330fn check_for_unsupported_ops(summary: &DiffSummary) -> anyhow::Result<()> {
331 for diff in &summary.diffs {
332 if let ResourceDiff::CatalogSchema(d) = diff {
333 match &d.op {
334 DiffOp::Added(_) => {}
335 DiffOp::Removed(_) => {
336 return Err(anyhow!(
337 "deleting catalog '{}' (top-level) is not supported by braze-sync; \
338 only field-level changes can be applied",
339 d.name
340 ));
341 }
342 _ => {}
343 }
344 for fd in &d.field_diffs {
347 if let DiffOp::Modified { from, to } = fd {
348 return Err(anyhow!(
349 "modifying field '{}' on catalog '{}' (type {} → {}) \
350 is not supported by braze-sync; the change would be \
351 data-losing on the field. Drop the field manually \
352 in the Braze dashboard and re-run `braze-sync apply`",
353 to.name,
354 d.name,
355 from.field_type.as_str(),
356 to.field_type.as_str(),
357 ));
358 }
359 }
360 }
361 }
362 Ok(())
363}
364
365async fn apply_content_block(
366 client: &BrazeClient,
367 d: &ContentBlockDiff,
368 id_index: Option<&ContentBlockIdIndex>,
369 archive_orphans: bool,
370 today: chrono::NaiveDate,
371) -> anyhow::Result<usize> {
372 if d.orphan {
375 if !archive_orphans {
376 return Ok(0);
377 }
378 let id_index = id_index.ok_or_else(|| {
379 anyhow!("internal: content_block id index missing for orphan apply path")
380 })?;
381 let id = id_index.get(&d.name).ok_or_else(|| {
382 anyhow!(
383 "internal: orphan '{}' missing from id index — list/diff drift",
384 d.name
385 )
386 })?;
387 let archived = orphan::archive_name(today, &d.name);
388 if archived == d.name {
389 return Ok(0);
390 }
391 let mut cb = client
398 .get_content_block(id)
399 .await
400 .with_context(|| format!("fetching content block '{}' for archive rename", d.name))?;
401 cb.name = archived;
402 tracing::info!(
403 content_block = %d.name,
404 new_name = %cb.name,
405 "archiving orphan content block"
406 );
407 client.update_content_block(id, &cb).await?;
408 return Ok(1);
409 }
410
411 match &d.op {
412 DiffOp::Added(cb) => {
413 tracing::info!(content_block = %cb.name, "creating content block");
414 let _ = client.create_content_block(cb).await?;
415 Ok(1)
416 }
417 DiffOp::Modified { to, .. } => {
418 let id_index = id_index.ok_or_else(|| {
419 anyhow!("internal: content_block id index missing for modified apply path")
420 })?;
421 let id = id_index.get(&to.name).ok_or_else(|| {
422 anyhow!(
423 "internal: modified content block '{}' missing from id index",
424 to.name
425 )
426 })?;
427 tracing::info!(content_block = %to.name, "updating content block");
428 client.update_content_block(id, to).await?;
429 Ok(1)
430 }
431 DiffOp::Removed(_) => {
434 unreachable!("diff layer routes content block removals through orphan")
435 }
436 DiffOp::Unchanged => Ok(0),
437 }
438}
439
440async fn apply_catalog_schema(
441 client: &BrazeClient,
442 d: &CatalogSchemaDiff,
443) -> anyhow::Result<usize> {
444 if let DiffOp::Added(cat) = &d.op {
447 tracing::info!(catalog = %d.name, "creating new catalog");
448 client
449 .create_catalog(cat)
450 .await
451 .with_context(|| format!("creating catalog '{}'", d.name))?;
452 return Ok(1);
453 }
454
455 let mut count = 0;
456 for fd in &d.field_diffs {
457 match fd {
458 DiffOp::Added(f) => {
459 tracing::info!(
460 catalog = %d.name,
461 field = %f.name,
462 field_type = f.field_type.as_str(),
463 "adding catalog field"
464 );
465 client.add_catalog_field(&d.name, f).await?;
466 count += 1;
467 }
468 DiffOp::Removed(f) => {
469 tracing::info!(
470 catalog = %d.name,
471 field = %f.name,
472 "deleting catalog field"
473 );
474 client.delete_catalog_field(&d.name, &f.name).await?;
475 count += 1;
476 }
477 DiffOp::Modified { .. } => {
478 return Err(anyhow!(
479 "internal: Modified field op should have been rejected \
480 by check_for_unsupported_ops"
481 ));
482 }
483 DiffOp::Unchanged => {}
484 }
485 }
486 Ok(count)
487}
488
489async fn apply_email_template(
490 client: &BrazeClient,
491 d: &EmailTemplateDiff,
492 id_index: Option<&EmailTemplateIdIndex>,
493 archive_orphans: bool,
494 today: chrono::NaiveDate,
495) -> anyhow::Result<usize> {
496 if d.orphan {
497 if !archive_orphans {
498 return Ok(0);
499 }
500 let id_index = id_index.ok_or_else(|| {
501 anyhow!("internal: email_template id index missing for orphan apply path")
502 })?;
503 let id = id_index.get(&d.name).ok_or_else(|| {
504 anyhow!(
505 "internal: orphan '{}' missing from id index — list/diff drift",
506 d.name
507 )
508 })?;
509 let archived = orphan::archive_name(today, &d.name);
510 if archived == d.name {
511 return Ok(0);
512 }
513 let mut et = client
514 .get_email_template(id)
515 .await
516 .with_context(|| format!("fetching email template '{}' for archive rename", d.name))?;
517 et.name = archived;
518 tracing::info!(
519 email_template = %d.name,
520 new_name = %et.name,
521 "archiving orphan email template"
522 );
523 client.update_email_template(id, &et).await?;
524 return Ok(1);
525 }
526
527 match &d.op {
528 DiffOp::Added(et) => {
529 tracing::info!(email_template = %et.name, "creating email template");
530 let _ = client.create_email_template(et).await?;
531 Ok(1)
532 }
533 DiffOp::Modified { to, .. } => {
534 let id_index = id_index.ok_or_else(|| {
535 anyhow!("internal: email_template id index missing for modified apply path")
536 })?;
537 let id = id_index.get(&to.name).ok_or_else(|| {
538 anyhow!(
539 "internal: modified email template '{}' missing from id index",
540 to.name
541 )
542 })?;
543 tracing::info!(email_template = %to.name, "updating email template");
544 client.update_email_template(id, to).await?;
545 Ok(1)
546 }
547 DiffOp::Removed(_) => {
548 unreachable!("diff layer routes email template removals through orphan")
549 }
550 DiffOp::Unchanged => Ok(0),
551 }
552}
553
554async fn apply_custom_attribute_batch(
558 client: &BrazeClient,
559 to_deprecate: &[&str],
560 to_reactivate: &[&str],
561) -> anyhow::Result<usize> {
562 let mut applied = 0;
563 for (names, blocklisted, verb) in [
564 (to_deprecate, true, "deprecating"),
565 (to_reactivate, false, "reactivating"),
566 ] {
567 if names.is_empty() {
568 continue;
569 }
570 tracing::info!(attributes = ?names, "{verb} custom attributes");
571 client
572 .set_custom_attribute_blocklist(names, blocklisted)
573 .await
574 .with_context(|| format!("{verb} custom attributes"))?;
575 let n = names.len();
576 let past = if blocklisted {
577 "deprecated"
578 } else {
579 "reactivated"
580 };
581 eprintln!(" ✓ {past} {n} custom attribute(s)");
582 applied += n;
583 }
584
585 Ok(applied)
586}