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