1use crate::braze::BrazeClient;
13use crate::config::{ApplyOrder, ResolvedConfig};
14use crate::diff::catalog::CatalogSchemaDiff;
15use crate::diff::content_block::{ContentBlockDiff, ContentBlockIdIndex};
16use crate::diff::content_block_order::reorder_content_block_diffs_by_dependency;
17use crate::diff::custom_attribute::CustomAttributeOp;
18use crate::diff::email_template::{EmailTemplateDiff, EmailTemplateIdIndex};
19use crate::diff::orphan;
20use crate::diff::plan::{self, PlanFile};
21use crate::diff::{DiffOp, DiffSummary, ResourceDiff};
22use crate::error::Error;
23use crate::format::OutputFormat;
24use crate::resource::ResourceKind;
25use anyhow::{anyhow, Context as _};
26use clap::Args;
27use std::path::{Path, PathBuf};
28
29use super::diff::{
30 compute_catalog_schema_diffs, compute_content_block_plan, compute_custom_attribute_diffs,
31 compute_email_template_plan, compute_tag_diffs,
32};
33use super::{selected_kinds, warn_if_name_excluded};
34
35#[derive(Args, Debug)]
36pub struct ApplyArgs {
37 #[arg(long, value_enum)]
39 pub resource: Option<ResourceKind>,
40
41 #[arg(long, requires = "resource")]
44 pub name: Option<String>,
45
46 #[arg(long)]
49 pub confirm: bool,
50
51 #[arg(long)]
55 pub allow_destructive: bool,
56
57 #[arg(long)]
61 pub archive_orphans: bool,
62
63 #[arg(long, value_name = "PATH")]
67 pub plan: Option<PathBuf>,
68}
69
70pub async fn run(
71 args: &ApplyArgs,
72 resolved: ResolvedConfig,
73 config_dir: &Path,
74 format: OutputFormat,
75) -> anyhow::Result<()> {
76 let catalogs_root = config_dir.join(&resolved.resources.catalog_schema.path);
77 let content_blocks_root = config_dir.join(&resolved.resources.content_block.path);
78 let email_templates_root = config_dir.join(&resolved.resources.email_template.path);
79 let custom_attributes_path = config_dir.join(&resolved.resources.custom_attribute.path);
80 let tags_path = config_dir.join(&resolved.resources.tag.path);
81 let client = BrazeClient::from_resolved(&resolved);
82 let kinds = selected_kinds(args.resource, &resolved.resources);
83
84 let saved_plan = if let Some(path) = &args.plan {
86 let plan = PlanFile::read_from(path)
87 .with_context(|| format!("reading plan file {}", path.display()))?;
88 check_plan_scope(&plan, &resolved.environment_name, args)?;
89 warn_on_plan_metadata(&plan);
90 Some(plan)
91 } else {
92 None
93 };
94
95 let mut summary = DiffSummary::default();
96 let mut content_block_id_index: Option<ContentBlockIdIndex> = None;
97 let mut email_template_id_index: Option<EmailTemplateIdIndex> = None;
98 for kind in &kinds {
99 let kind = *kind;
100 if warn_if_name_excluded(kind, args.name.as_deref(), resolved.excludes_for(kind)) {
101 continue;
102 }
103 match kind {
104 ResourceKind::CatalogSchema => {
105 let diffs = compute_catalog_schema_diffs(
106 &client,
107 &catalogs_root,
108 args.name.as_deref(),
109 resolved.excludes_for(ResourceKind::CatalogSchema),
110 )
111 .await
112 .context("computing catalog_schema plan")?;
113 summary.diffs.extend(diffs);
114 }
115 ResourceKind::ContentBlock => {
116 let (diffs, idx) = compute_content_block_plan(
117 &client,
118 &content_blocks_root,
119 args.name.as_deref(),
120 resolved.excludes_for(ResourceKind::ContentBlock),
121 )
122 .await
123 .context("computing content_block plan")?;
124 summary.diffs.extend(diffs);
125 content_block_id_index = Some(idx);
126 }
127 ResourceKind::EmailTemplate => {
128 let (diffs, idx) = compute_email_template_plan(
129 &client,
130 &email_templates_root,
131 args.name.as_deref(),
132 resolved.excludes_for(ResourceKind::EmailTemplate),
133 )
134 .await
135 .context("computing email_template plan")?;
136 summary.diffs.extend(diffs);
137 email_template_id_index = Some(idx);
138 }
139 ResourceKind::CustomAttribute => {
140 let diffs = compute_custom_attribute_diffs(
141 &client,
142 &custom_attributes_path,
143 args.name.as_deref(),
144 resolved.excludes_for(ResourceKind::CustomAttribute),
145 )
146 .await
147 .context("computing custom_attribute plan")?;
148 summary.diffs.extend(diffs);
149 }
150 ResourceKind::Tag => {
151 let diffs = compute_tag_diffs(
152 config_dir,
153 &resolved,
154 &tags_path,
155 args.name.as_deref(),
156 resolved.excludes_for(ResourceKind::Tag),
157 )
158 .context("computing tag plan")?;
159 summary.diffs.extend(diffs);
160 }
161 }
162 }
163
164 enforce_tag_preflight(config_dir, &resolved, &summary)?;
170
171 if matches!(
176 resolved.resources.content_block.apply_order,
177 ApplyOrder::Dependency
178 ) {
179 summary.diffs = reorder_content_block_diffs_by_dependency(std::mem::take(
180 &mut summary.diffs,
181 ))
182 .map_err(|cycle| {
183 anyhow!(
184 "content_block dependency cycle detected\n \
185 {cycle}\n \
186 resolve by removing one of these references and try again"
187 )
188 })?;
189 }
190
191 let mode_label = if args.confirm {
192 "Plan:"
193 } else {
194 "Plan (dry-run, pass --confirm to apply):"
195 };
196 eprintln!("{mode_label}");
197 print!("{}", format.formatter().format(&summary));
198
199 if let Some(saved) = &saved_plan {
201 check_plan_ops(saved, &summary)?;
202 eprintln!("✓ Plan matches saved plan ({} op(s)).", saved.ops.len());
203 }
204
205 if summary.actionable_count() == 0 {
206 if summary.changed_count() > 0 {
207 eprintln!(
208 "No actionable changes to apply \
209 (informational drift above can be reconciled with `export`)."
210 );
211 } else {
212 eprintln!("No changes to apply.");
213 }
214 return Ok(());
215 }
216
217 if !args.confirm {
218 eprintln!("DRY RUN — pass --confirm to apply these changes.");
219 return Ok(());
220 }
221
222 if summary.destructive_count() > 0 && !args.allow_destructive {
223 return Err(Error::DestructiveBlocked.into());
224 }
225
226 check_for_unsupported_ops(&summary)?;
227
228 let today = chrono::Utc::now().date_naive();
234
235 let mut applied = 0;
236 let mut ca_deprecate: Vec<&str> = Vec::new();
237 let mut ca_reactivate: Vec<&str> = Vec::new();
238 for diff in &summary.diffs {
239 match diff {
240 ResourceDiff::CatalogSchema(d) => {
241 applied += apply_catalog_schema(&client, d).await?;
242 }
243 ResourceDiff::ContentBlock(d) => {
244 applied += apply_content_block(
245 &client,
246 d,
247 content_block_id_index.as_ref(),
248 args.archive_orphans,
249 today,
250 )
251 .await?;
252 }
253 ResourceDiff::EmailTemplate(d) => {
254 applied += apply_email_template(
255 &client,
256 d,
257 email_template_id_index.as_ref(),
258 args.archive_orphans,
259 today,
260 )
261 .await?;
262 }
263 ResourceDiff::CustomAttribute(d) => {
264 if let CustomAttributeOp::DeprecationToggled { to, .. } = &d.op {
265 if *to {
266 ca_deprecate.push(&d.name);
267 } else {
268 ca_reactivate.push(&d.name);
269 }
270 }
271 }
272 ResourceDiff::Tag(_) => {}
275 }
276 }
277
278 if !ca_deprecate.is_empty() || !ca_reactivate.is_empty() {
279 applied += apply_custom_attribute_batch(&client, &ca_deprecate, &ca_reactivate).await?;
280 }
281
282 eprintln!("✓ Applied {applied} change(s).");
283 Ok(())
284}
285
286fn enforce_tag_preflight(
307 config_dir: &Path,
308 resolved: &ResolvedConfig,
309 summary: &DiffSummary,
310) -> anyhow::Result<()> {
311 if !resolved.resources.is_enabled(ResourceKind::Tag) {
312 return Ok(());
313 }
314 let tags_path = config_dir.join(&resolved.resources.tag.path);
315 let registry = match crate::fs::tag_io::load_registry(&tags_path)? {
316 Some(r) => r,
317 None => return Ok(()),
320 };
321 let excludes = resolved.excludes_for(ResourceKind::Tag);
322
323 let mut missing: std::collections::BTreeMap<String, Vec<String>> =
324 std::collections::BTreeMap::new();
325 for diff in &summary.diffs {
326 let (resource_label, tags) = match diff {
328 ResourceDiff::ContentBlock(d) => match &d.op {
329 DiffOp::Added(cb) => (format!("content_block '{}'", cb.name), &cb.tags),
330 DiffOp::Modified { to, .. } => (format!("content_block '{}'", to.name), &to.tags),
331 _ => continue,
332 },
333 ResourceDiff::EmailTemplate(d) => match &d.op {
334 DiffOp::Added(et) => (format!("email_template '{}'", et.name), &et.tags),
335 DiffOp::Modified { to, .. } => (format!("email_template '{}'", to.name), &to.tags),
336 _ => continue,
337 },
338 _ => continue,
339 };
340 for t in tags {
341 if crate::config::is_excluded(t, excludes) {
342 continue;
343 }
344 if !registry.contains(t) {
345 missing
346 .entry(t.clone())
347 .or_default()
348 .push(resource_label.clone());
349 }
350 }
351 }
352
353 if missing.is_empty() {
354 return Ok(());
355 }
356
357 let mut msg = String::from(
358 "tag pre-flight: refusing to apply — the following tags are referenced \
359 by resources that would be created/updated, but are not declared in \
360 tags/registry.yaml:\n",
361 );
362 for (tag, refs) in &missing {
363 msg.push_str(&format!(" • '{tag}' — referenced by:\n"));
364 for r in refs {
365 msg.push_str(&format!(" - {r}\n"));
366 }
367 }
368 msg.push_str(
369 "\nFix: create each missing tag in the Braze dashboard \
370 (Settings → Tags), then add it to tags/registry.yaml and re-run apply. \
371 Braze does not expose a tag creation API.",
372 );
373 Err(anyhow!(msg))
374}
375
376fn check_plan_scope(plan: &PlanFile, environment: &str, args: &ApplyArgs) -> anyhow::Result<()> {
378 if plan.version != plan::CURRENT_PLAN_VERSION {
379 return Err(anyhow!(
380 "plan file version {} is not supported by this binary \
381 (expected {}). Regenerate with `diff --plan-out`.",
382 plan.version,
383 plan::CURRENT_PLAN_VERSION,
384 ));
385 }
386 if plan.scope.environment != environment {
387 eprintln!(
388 "✗ plan drift: plan was generated for environment '{}' \
389 but apply targets '{}'",
390 plan.scope.environment, environment,
391 );
392 return Err(Error::PlanDrift.into());
393 }
394 if plan.scope.resource != args.resource {
395 eprintln!(
396 "✗ plan drift: plan scope --resource={:?} but apply --resource={:?}",
397 plan.scope.resource.map(|k| k.as_str()),
398 args.resource.map(|k| k.as_str()),
399 );
400 return Err(Error::PlanDrift.into());
401 }
402 if plan.scope.name != args.name {
403 eprintln!(
404 "✗ plan drift: plan scope --name={:?} but apply --name={:?}",
405 plan.scope.name, args.name,
406 );
407 return Err(Error::PlanDrift.into());
408 }
409 if plan.scope.archive_orphans != args.archive_orphans {
413 eprintln!(
414 "✗ plan drift: plan scope --archive-orphans={} but apply --archive-orphans={}",
415 plan.scope.archive_orphans, args.archive_orphans,
416 );
417 return Err(Error::PlanDrift.into());
418 }
419 Ok(())
420}
421
422fn warn_on_plan_metadata(plan: &PlanFile) {
423 let current = env!("CARGO_PKG_VERSION");
424 if plan.braze_sync_version != current {
425 eprintln!(
426 "⚠ plan was generated by braze-sync {} (current: {}). \
427 Proceeding — pass `diff --plan-out` again if op semantics changed.",
428 plan.braze_sync_version, current,
429 );
430 }
431 let age = chrono::Utc::now().signed_duration_since(plan.generated_at);
432 if age > plan::STALE_PLAN_WARN_THRESHOLD {
433 eprintln!(
434 "⚠ plan is {} hour(s) old — Braze may have drifted; consider \
435 regenerating with `diff --plan-out`.",
436 age.num_hours(),
437 );
438 }
439}
440
441fn check_plan_ops(saved: &PlanFile, fresh: &DiffSummary) -> anyhow::Result<()> {
444 let fresh_ops = plan::collect_ops(fresh);
445 let diff = saved.diff_ops(&fresh_ops);
446 if diff.is_match() {
447 return Ok(());
448 }
449 eprintln!("✗ plan drift: saved plan and fresh plan differ");
450 if !diff.missing.is_empty() {
451 eprintln!(" ops in saved plan but not in fresh plan (resolved or absorbed remotely):");
452 for op in &diff.missing {
453 eprintln!(" - {} '{}' [{:?}]", op.kind.as_str(), op.name, op.op);
454 }
455 }
456 if !diff.extra.is_empty() {
457 eprintln!(" ops in fresh plan but not in saved plan (new drift since plan):");
458 for op in &diff.extra {
459 eprintln!(" - {} '{}' [{:?}]", op.kind.as_str(), op.name, op.op);
460 }
461 }
462 eprintln!(" → regenerate with `diff --plan-out` and review before re-applying");
463 Err(Error::PlanDrift.into())
464}
465
466fn check_for_unsupported_ops(summary: &DiffSummary) -> anyhow::Result<()> {
467 for diff in &summary.diffs {
468 if let ResourceDiff::CatalogSchema(d) = diff {
469 for fd in &d.field_diffs {
472 if let DiffOp::Modified { from, to } = fd {
473 return Err(anyhow!(
474 "modifying field '{}' on catalog '{}' (type {} → {}) \
475 is not supported by braze-sync; the change would be \
476 data-losing on the field. Drop the field manually \
477 in the Braze dashboard and re-run `braze-sync apply`",
478 to.name,
479 d.name,
480 from.field_type.as_str(),
481 to.field_type.as_str(),
482 ));
483 }
484 }
485 }
486 }
487 Ok(())
488}
489
490async fn apply_content_block(
491 client: &BrazeClient,
492 d: &ContentBlockDiff,
493 id_index: Option<&ContentBlockIdIndex>,
494 archive_orphans: bool,
495 today: chrono::NaiveDate,
496) -> anyhow::Result<usize> {
497 if d.orphan {
500 if !archive_orphans {
501 return Ok(0);
502 }
503 let id_index = id_index.ok_or_else(|| {
504 anyhow!("internal: content_block id index missing for orphan apply path")
505 })?;
506 let id = id_index.get(&d.name).ok_or_else(|| {
507 anyhow!(
508 "internal: orphan '{}' missing from id index — list/diff drift",
509 d.name
510 )
511 })?;
512 let archived = orphan::archive_name(today, &d.name);
513 if archived == d.name {
514 return Ok(0);
515 }
516 let mut cb = client
523 .get_content_block(id)
524 .await
525 .with_context(|| format!("fetching content block '{}' for archive rename", d.name))?;
526 cb.name = archived;
527 tracing::info!(
528 content_block = %d.name,
529 new_name = %cb.name,
530 "archiving orphan content block"
531 );
532 client.update_content_block(id, &cb).await?;
533 return Ok(1);
534 }
535
536 match &d.op {
537 DiffOp::Added(cb) => {
538 tracing::info!(content_block = %cb.name, "creating content block");
539 let _ = client.create_content_block(cb).await?;
540 Ok(1)
541 }
542 DiffOp::Modified { to, .. } => {
543 let id_index = id_index.ok_or_else(|| {
544 anyhow!("internal: content_block id index missing for modified apply path")
545 })?;
546 let id = id_index.get(&to.name).ok_or_else(|| {
547 anyhow!(
548 "internal: modified content block '{}' missing from id index",
549 to.name
550 )
551 })?;
552 tracing::info!(content_block = %to.name, "updating content block");
553 client.update_content_block(id, to).await?;
554 Ok(1)
555 }
556 DiffOp::Removed(_) => {
559 unreachable!("diff layer routes content block removals through orphan")
560 }
561 DiffOp::Unchanged => Ok(0),
562 }
563}
564
565async fn apply_catalog_schema(
566 client: &BrazeClient,
567 d: &CatalogSchemaDiff,
568) -> anyhow::Result<usize> {
569 if let DiffOp::Added(cat) = &d.op {
572 tracing::info!(catalog = %d.name, "creating new catalog");
573 client
574 .create_catalog(cat)
575 .await
576 .with_context(|| format!("creating catalog '{}'", d.name))?;
577 return Ok(1);
578 }
579
580 if let DiffOp::Removed(_) = &d.op {
584 tracing::info!(catalog = %d.name, "deleting catalog");
585 client
586 .delete_catalog(&d.name)
587 .await
588 .with_context(|| format!("deleting catalog '{}'", d.name))?;
589 return Ok(1);
590 }
591
592 let mut count = 0;
593 for fd in &d.field_diffs {
594 match fd {
595 DiffOp::Added(f) => {
596 tracing::info!(
597 catalog = %d.name,
598 field = %f.name,
599 field_type = f.field_type.as_str(),
600 "adding catalog field"
601 );
602 client.add_catalog_field(&d.name, f).await?;
603 count += 1;
604 }
605 DiffOp::Removed(f) => {
606 tracing::info!(
607 catalog = %d.name,
608 field = %f.name,
609 "deleting catalog field"
610 );
611 client.delete_catalog_field(&d.name, &f.name).await?;
612 count += 1;
613 }
614 DiffOp::Modified { .. } => {
615 return Err(anyhow!(
616 "internal: Modified field op should have been rejected \
617 by check_for_unsupported_ops"
618 ));
619 }
620 DiffOp::Unchanged => {}
621 }
622 }
623 Ok(count)
624}
625
626async fn apply_email_template(
627 client: &BrazeClient,
628 d: &EmailTemplateDiff,
629 id_index: Option<&EmailTemplateIdIndex>,
630 archive_orphans: bool,
631 today: chrono::NaiveDate,
632) -> anyhow::Result<usize> {
633 if d.orphan {
634 if !archive_orphans {
635 return Ok(0);
636 }
637 let id_index = id_index.ok_or_else(|| {
638 anyhow!("internal: email_template id index missing for orphan apply path")
639 })?;
640 let id = id_index.get(&d.name).ok_or_else(|| {
641 anyhow!(
642 "internal: orphan '{}' missing from id index — list/diff drift",
643 d.name
644 )
645 })?;
646 let archived = orphan::archive_name(today, &d.name);
647 if archived == d.name {
648 return Ok(0);
649 }
650 let mut et = client
651 .get_email_template(id)
652 .await
653 .with_context(|| format!("fetching email template '{}' for archive rename", d.name))?;
654 et.name = archived;
655 tracing::info!(
656 email_template = %d.name,
657 new_name = %et.name,
658 "archiving orphan email template"
659 );
660 client.update_email_template(id, &et).await?;
661 return Ok(1);
662 }
663
664 match &d.op {
665 DiffOp::Added(et) => {
666 tracing::info!(email_template = %et.name, "creating email template");
667 let _ = client.create_email_template(et).await?;
668 Ok(1)
669 }
670 DiffOp::Modified { to, .. } => {
671 let id_index = id_index.ok_or_else(|| {
672 anyhow!("internal: email_template id index missing for modified apply path")
673 })?;
674 let id = id_index.get(&to.name).ok_or_else(|| {
675 anyhow!(
676 "internal: modified email template '{}' missing from id index",
677 to.name
678 )
679 })?;
680 tracing::info!(email_template = %to.name, "updating email template");
681 client.update_email_template(id, to).await?;
682 Ok(1)
683 }
684 DiffOp::Removed(_) => {
685 unreachable!("diff layer routes email template removals through orphan")
686 }
687 DiffOp::Unchanged => Ok(0),
688 }
689}
690
691async fn apply_custom_attribute_batch(
695 client: &BrazeClient,
696 to_deprecate: &[&str],
697 to_reactivate: &[&str],
698) -> anyhow::Result<usize> {
699 let mut applied = 0;
700 for (names, blocklisted, verb) in [
701 (to_deprecate, true, "deprecating"),
702 (to_reactivate, false, "reactivating"),
703 ] {
704 if names.is_empty() {
705 continue;
706 }
707 tracing::info!(attributes = ?names, "{verb} custom attributes");
708 client
709 .set_custom_attribute_blocklist(names, blocklisted)
710 .await
711 .with_context(|| format!("{verb} custom attributes"))?;
712 let n = names.len();
713 let past = if blocklisted {
714 "deprecated"
715 } else {
716 "reactivated"
717 };
718 eprintln!(" ✓ {past} {n} custom attribute(s)");
719 applied += n;
720 }
721
722 Ok(applied)
723}