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