Skip to main content

braze_sync/cli/
apply.rs

1//! `braze-sync apply` — the only command that mutates remote state.
2//!
3//! Recomputes the diff via the same code path as `diff`, prints it,
4//! then short-circuits unless `--confirm` is set. Pre-validates
5//! unsupported/destructive ops before any API call, then walks the
6//! plan and aborts on the first write failure. Braze has no
7//! cross-resource transaction, so a mid-loop failure can still leave
8//! earlier writes applied — the pre-validation prevents *known-bad*
9//! plans from firing any writes at all, but it does not promise
10//! cross-write atomicity.
11
12use 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 crate::values::{compute_values_input_hashes, preflight_values, PreflightArgs};
26use anyhow::{anyhow, Context as _};
27use clap::Args;
28use std::path::{Path, PathBuf};
29
30use super::diff::{
31    compute_catalog_schema_diffs, compute_content_block_plan, compute_custom_attribute_diffs,
32    compute_email_template_plan, compute_tag_diffs,
33};
34use super::{selected_kinds, warn_if_name_excluded};
35
36#[derive(Args, Debug)]
37pub struct ApplyArgs {
38    /// Limit apply to a specific resource kind.
39    #[arg(long, value_enum)]
40    pub resource: Option<ResourceKind>,
41
42    /// When `--resource` is given, optionally restrict to a single named
43    /// resource. Requires `--resource`.
44    #[arg(long, requires = "resource")]
45    pub name: Option<String>,
46
47    /// Actually apply changes. Without this, runs in dry-run mode and
48    /// makes zero write calls to Braze. This is the default for safety.
49    #[arg(long)]
50    pub confirm: bool,
51
52    /// Permit destructive operations (field deletes, etc.). Required in
53    /// addition to `--confirm` for any change that would lose data on
54    /// the Braze side.
55    #[arg(long)]
56    pub allow_destructive: bool,
57
58    /// Archive orphan Content Blocks / Email Templates by prefixing the
59    /// remote name with `[ARCHIVED-YYYY-MM-DD]`. Inert for resource
60    /// kinds that have no orphan concept (e.g. catalog schema).
61    #[arg(long)]
62    pub archive_orphans: bool,
63
64    /// Load a plan file produced by `diff --plan-out=<path>` and refuse
65    /// to apply if the freshly-computed plan does not match. Exits with
66    /// code 7 on mismatch.
67    #[arg(long, value_name = "PATH")]
68    pub plan: Option<PathBuf>,
69}
70
71pub async fn run(
72    args: &ApplyArgs,
73    resolved: ResolvedConfig,
74    config_dir: &Path,
75    format: OutputFormat,
76) -> anyhow::Result<()> {
77    let catalogs_root = config_dir.join(&resolved.resources.catalog_schema.path);
78    let content_blocks_root = config_dir.join(&resolved.resources.content_block.path);
79    let email_templates_root = config_dir.join(&resolved.resources.email_template.path);
80    let custom_attributes_path = config_dir.join(&resolved.resources.custom_attribute.path);
81    let tags_path = config_dir.join(&resolved.resources.tag.path);
82    let client = BrazeClient::from_resolved(&resolved);
83    let kinds = selected_kinds(args.resource, &resolved.resources);
84
85    // Reject scope/env mismatch before any Braze API call.
86    let saved_plan = if let Some(path) = &args.plan {
87        let plan = PlanFile::read_from(path)
88            .with_context(|| format!("reading plan file {}", path.display()))?;
89        check_plan_scope(&plan, &resolved.environment_name, args)?;
90        warn_on_plan_metadata(&plan);
91        Some(plan)
92    } else {
93        None
94    };
95
96    // Pre-flight: resolve all `__BRAZESYNC.*__` placeholders before any
97    // Braze API call (RFC `feat-per-env-values.md` §2.4 / §3 Q7). Sits
98    // alongside `enforce_tag_preflight()` below — both are "no API
99    // calls have happened yet, refuse known-bad work" gates.
100    let values = preflight_values(PreflightArgs {
101        config_dir,
102        resolved: &resolved,
103        content_blocks_root: &content_blocks_root,
104        email_templates_root: &email_templates_root,
105        kinds: &kinds,
106        cb_name_filter: args
107            .name
108            .as_deref()
109            .filter(|_| args.resource == Some(ResourceKind::ContentBlock)),
110        et_name_filter: args
111            .name
112            .as_deref()
113            .filter(|_| args.resource == Some(ResourceKind::EmailTemplate)),
114        cb_excludes: resolved.excludes_for(ResourceKind::ContentBlock),
115        et_excludes: resolved.excludes_for(ResourceKind::EmailTemplate),
116    })?;
117
118    let mut summary = DiffSummary::default();
119    let mut content_block_id_index: Option<ContentBlockIdIndex> = None;
120    let mut email_template_id_index: Option<EmailTemplateIdIndex> = None;
121    for kind in &kinds {
122        let kind = *kind;
123        if warn_if_name_excluded(kind, args.name.as_deref(), resolved.excludes_for(kind)) {
124            continue;
125        }
126        match kind {
127            ResourceKind::CatalogSchema => {
128                let diffs = compute_catalog_schema_diffs(
129                    &client,
130                    &catalogs_root,
131                    args.name.as_deref(),
132                    resolved.excludes_for(ResourceKind::CatalogSchema),
133                )
134                .await
135                .context("computing catalog_schema plan")?;
136                summary.diffs.extend(diffs);
137            }
138            ResourceKind::ContentBlock => {
139                let (diffs, idx) = compute_content_block_plan(
140                    &client,
141                    &content_blocks_root,
142                    args.name.as_deref(),
143                    resolved.excludes_for(ResourceKind::ContentBlock),
144                    values.as_ref(),
145                )
146                .await
147                .context("computing content_block plan")?;
148                summary.diffs.extend(diffs);
149                content_block_id_index = Some(idx);
150            }
151            ResourceKind::EmailTemplate => {
152                let (diffs, idx) = compute_email_template_plan(
153                    &client,
154                    &email_templates_root,
155                    args.name.as_deref(),
156                    resolved.excludes_for(ResourceKind::EmailTemplate),
157                    values.as_ref(),
158                )
159                .await
160                .context("computing email_template plan")?;
161                summary.diffs.extend(diffs);
162                email_template_id_index = Some(idx);
163            }
164            ResourceKind::CustomAttribute => {
165                let diffs = compute_custom_attribute_diffs(
166                    &client,
167                    &custom_attributes_path,
168                    args.name.as_deref(),
169                    resolved.excludes_for(ResourceKind::CustomAttribute),
170                )
171                .await
172                .context("computing custom_attribute plan")?;
173                summary.diffs.extend(diffs);
174            }
175            ResourceKind::Tag => {
176                let diffs = compute_tag_diffs(
177                    config_dir,
178                    &resolved,
179                    &tags_path,
180                    args.name.as_deref(),
181                    resolved.excludes_for(ResourceKind::Tag),
182                )
183                .context("computing tag plan")?;
184                summary.diffs.extend(diffs);
185            }
186        }
187    }
188
189    // Tag pre-flight: refuse to fire any mutation if a referenced tag is
190    // missing from the registry. Without this, Braze rejects the first
191    // tag-bearing create/update with HTTP 400 and the rest of the plan
192    // halts mid-pipeline. Runs even when --kind is restricted to a non-tag
193    // kind so tag drift cannot sneak past a per-kind apply.
194    enforce_tag_preflight(config_dir, &resolved, &summary)?;
195
196    // Targets must precede referrers because Braze validates
197    // `{{content_blocks.${other}}}` includes at create time and rejects
198    // forward references with an opaque HTTP 500. Done before
199    // plan-print so the dry-run preview reflects actual write order.
200    if matches!(
201        resolved.resources.content_block.apply_order,
202        ApplyOrder::Dependency
203    ) {
204        summary.diffs = reorder_content_block_diffs_by_dependency(std::mem::take(
205            &mut summary.diffs,
206        ))
207        .map_err(|cycle| {
208            anyhow!(
209                "content_block dependency cycle detected\n       \
210                         {cycle}\n       \
211                         resolve by removing one of these references and try again"
212            )
213        })?;
214    }
215
216    let mode_label = if args.confirm {
217        "Plan:"
218    } else {
219        "Plan (dry-run, pass --confirm to apply):"
220    };
221    eprintln!("{mode_label}");
222    print!("{}", format.formatter().format(&summary));
223
224    // Print the fresh plan first so the operator sees it even on mismatch.
225    if let Some(saved) = &saved_plan {
226        check_plan_ops(saved, &summary)?;
227        // RFC §4 Phase 6: also verify per-resource consumed-values
228        // hashes. Catches values edits (including fan-out global
229        // edits) that occurred after the plan was written.
230        let fresh_hashes = compute_values_input_hashes(
231            PreflightArgs {
232                config_dir,
233                resolved: &resolved,
234                content_blocks_root: &content_blocks_root,
235                email_templates_root: &email_templates_root,
236                // Use the same kinds the operator scoped this apply to
237                // (check_plan_scope has already verified args.resource ==
238                // plan.scope.resource). Hardcoding [CB, ET] here would
239                // produce ET hashes that are absent from a saved plan
240                // built with `diff --resource content_block --plan-out`,
241                // and `check_plan_values_hashes` would report them as
242                // `extra` → spurious PlanDrift.
243                kinds: &kinds,
244                cb_name_filter: args
245                    .name
246                    .as_deref()
247                    .filter(|_| args.resource == Some(ResourceKind::ContentBlock)),
248                et_name_filter: args
249                    .name
250                    .as_deref()
251                    .filter(|_| args.resource == Some(ResourceKind::EmailTemplate)),
252                cb_excludes: resolved.excludes_for(ResourceKind::ContentBlock),
253                et_excludes: resolved.excludes_for(ResourceKind::EmailTemplate),
254            },
255            values.as_ref(),
256        )?;
257        check_plan_values_hashes(saved, &fresh_hashes)?;
258        eprintln!(
259            "✓ Plan matches saved plan ({} op(s){}).",
260            saved.ops.len(),
261            if saved.values_input_hashes.is_empty() {
262                String::new()
263            } else {
264                format!(
265                    ", {} values hash(es) verified",
266                    saved.values_input_hashes.len()
267                )
268            }
269        );
270    }
271
272    if summary.actionable_count() == 0 {
273        if summary.changed_count() > 0 {
274            eprintln!(
275                "No actionable changes to apply \
276                 (informational drift above can be reconciled with `export`)."
277            );
278        } else {
279            eprintln!("No changes to apply.");
280        }
281        return Ok(());
282    }
283
284    if !args.confirm {
285        eprintln!("DRY RUN — pass --confirm to apply these changes.");
286        return Ok(());
287    }
288
289    if summary.destructive_count() > 0 && !args.allow_destructive {
290        return Err(Error::DestructiveBlocked.into());
291    }
292
293    check_for_unsupported_ops(&summary)?;
294
295    // One canonical archive timestamp per run, even across multiple
296    // orphans. UTC (not Local) so two operators running the same archive
297    // on the same wall-clock day from different timezones produce the
298    // same `[ARCHIVED-YYYY-MM-DD]` prefix — determinism across a team
299    // matters more than matching the operator's local calendar.
300    let today = chrono::Utc::now().date_naive();
301
302    let mut applied = 0;
303    let mut ca_deprecate: Vec<&str> = Vec::new();
304    let mut ca_reactivate: Vec<&str> = Vec::new();
305    for diff in &summary.diffs {
306        match diff {
307            ResourceDiff::CatalogSchema(d) => {
308                applied += apply_catalog_schema(&client, d).await?;
309            }
310            ResourceDiff::ContentBlock(d) => {
311                applied += apply_content_block(
312                    &client,
313                    d,
314                    content_block_id_index.as_ref(),
315                    args.archive_orphans,
316                    today,
317                )
318                .await?;
319            }
320            ResourceDiff::EmailTemplate(d) => {
321                applied += apply_email_template(
322                    &client,
323                    d,
324                    email_template_id_index.as_ref(),
325                    args.archive_orphans,
326                    today,
327                )
328                .await?;
329            }
330            ResourceDiff::CustomAttribute(d) => {
331                if let CustomAttributeOp::DeprecationToggled { to, .. } = &d.op {
332                    if *to {
333                        ca_deprecate.push(&d.name);
334                    } else {
335                        ca_reactivate.push(&d.name);
336                    }
337                }
338            }
339            // Tags have no remote mutation API. The diff is informational;
340            // pre-flight (above) is what protects apply from missing tags.
341            ResourceDiff::Tag(_) => {}
342        }
343    }
344
345    if !ca_deprecate.is_empty() || !ca_reactivate.is_empty() {
346        applied += apply_custom_attribute_batch(&client, &ca_deprecate, &ca_reactivate).await?;
347    }
348
349    eprintln!("✓ Applied {applied} change(s).");
350    Ok(())
351}
352
353/// Reject ops the API can't actually perform. Runs before any write
354/// call so a statically-known-bad plan cannot fire a partial apply;
355/// runtime write failures can still leave earlier writes in place.
356///
357/// ContentBlock diffs are deliberately not inspected here: every shape
358/// the diff layer can produce (`Added` → create, `Modified` → update,
359/// `orphan` → archive-or-noop) maps to a supported API call, so there
360/// is nothing to statically reject. If a future diff shape is added
361/// (e.g. content-type change with no in-place update), re-evaluate.
362/// Block apply when any resource that would be mutated references a tag
363/// not declared in `tags/registry.yaml`. Braze has no public REST API for
364/// workspace tags, so we cannot create them; the only reliable way to
365/// avoid the cascading "Tags could not be found" failure (a single tagged
366/// content_block create that 400s blocks every queued create after it) is
367/// to refuse the apply entirely until the operator either (a) adds the
368/// tag to the registry after creating it in the Braze dashboard, or
369/// (b) removes the tag from the offending resource.
370///
371/// No-op when the `tag` resource is disabled in config — this is opt-in
372/// safety, not a forced gate.
373fn enforce_tag_preflight(
374    config_dir: &Path,
375    resolved: &ResolvedConfig,
376    summary: &DiffSummary,
377) -> anyhow::Result<()> {
378    if !resolved.resources.is_enabled(ResourceKind::Tag) {
379        return Ok(());
380    }
381    let tags_path = config_dir.join(&resolved.resources.tag.path);
382    let registry = match crate::fs::tag_io::load_registry(&tags_path)? {
383        Some(r) => r,
384        // No registry file → operator hasn't opted in to tag tracking yet;
385        // skip the gate rather than blocking on a config-only state.
386        None => return Ok(()),
387    };
388    let excludes = resolved.excludes_for(ResourceKind::Tag);
389
390    let mut missing: std::collections::BTreeMap<String, Vec<String>> =
391        std::collections::BTreeMap::new();
392    for diff in &summary.diffs {
393        // Only inspect resources that would actually fire a write.
394        let (resource_label, tags) = match diff {
395            ResourceDiff::ContentBlock(d) => match &d.op {
396                DiffOp::Added(cb) => (format!("content_block '{}'", cb.name), &cb.tags),
397                DiffOp::Modified { to, .. } => (format!("content_block '{}'", to.name), &to.tags),
398                _ => continue,
399            },
400            ResourceDiff::EmailTemplate(d) => match &d.op {
401                DiffOp::Added(et) => (format!("email_template '{}'", et.name), &et.tags),
402                DiffOp::Modified { to, .. } => (format!("email_template '{}'", to.name), &to.tags),
403                _ => continue,
404            },
405            _ => continue,
406        };
407        for t in tags {
408            if crate::config::is_excluded(t, excludes) {
409                continue;
410            }
411            if !registry.contains(t) {
412                missing
413                    .entry(t.clone())
414                    .or_default()
415                    .push(resource_label.clone());
416            }
417        }
418    }
419
420    if missing.is_empty() {
421        return Ok(());
422    }
423
424    let mut msg = String::from(
425        "tag pre-flight: refusing to apply — the following tags are referenced \
426         by resources that would be created/updated, but are not declared in \
427         tags/registry.yaml:\n",
428    );
429    for (tag, refs) in &missing {
430        msg.push_str(&format!("  • '{tag}' — referenced by:\n"));
431        for r in refs {
432            msg.push_str(&format!("      - {r}\n"));
433        }
434    }
435    msg.push_str(
436        "\nFix: create each missing tag in the Braze dashboard \
437         (Settings → Tags), then add it to tags/registry.yaml and re-run apply. \
438         Braze does not expose a tag creation API.",
439    );
440    Err(anyhow!(msg))
441}
442
443/// Reject obvious plan-vs-CLI mismatches before doing any remote work.
444fn check_plan_scope(plan: &PlanFile, environment: &str, args: &ApplyArgs) -> anyhow::Result<()> {
445    if plan.version != plan::CURRENT_PLAN_VERSION {
446        return Err(anyhow!(
447            "plan file version {} is not supported by this binary \
448             (expected {}). Regenerate with `diff --plan-out`.",
449            plan.version,
450            plan::CURRENT_PLAN_VERSION,
451        ));
452    }
453    if plan.scope.environment != environment {
454        eprintln!(
455            "✗ plan drift: plan was generated for environment '{}' \
456             but apply targets '{}'",
457            plan.scope.environment, environment,
458        );
459        return Err(Error::PlanDrift.into());
460    }
461    if plan.scope.resource != args.resource {
462        eprintln!(
463            "✗ plan drift: plan scope --resource={:?} but apply --resource={:?}",
464            plan.scope.resource.map(|k| k.as_str()),
465            args.resource.map(|k| k.as_str()),
466        );
467        return Err(Error::PlanDrift.into());
468    }
469    if plan.scope.name != args.name {
470        eprintln!(
471            "✗ plan drift: plan scope --name={:?} but apply --name={:?}",
472            plan.scope.name, args.name,
473        );
474        return Err(Error::PlanDrift.into());
475    }
476    // Orphan ops only fire writes when --archive-orphans is set, so the
477    // plan-time and apply-time intents must agree or the frozen op set
478    // would not reflect the real write set.
479    if plan.scope.archive_orphans != args.archive_orphans {
480        eprintln!(
481            "✗ plan drift: plan scope --archive-orphans={} but apply --archive-orphans={}",
482            plan.scope.archive_orphans, args.archive_orphans,
483        );
484        return Err(Error::PlanDrift.into());
485    }
486    Ok(())
487}
488
489fn warn_on_plan_metadata(plan: &PlanFile) {
490    let current = env!("CARGO_PKG_VERSION");
491    if plan.braze_sync_version != current {
492        eprintln!(
493            "⚠ plan was generated by braze-sync {} (current: {}). \
494             Proceeding — pass `diff --plan-out` again if op semantics changed.",
495            plan.braze_sync_version, current,
496        );
497    }
498    let age = chrono::Utc::now().signed_duration_since(plan.generated_at);
499    if age > plan::STALE_PLAN_WARN_THRESHOLD {
500        eprintln!(
501            "⚠ plan is {} hour(s) old — Braze may have drifted; consider \
502             regenerating with `diff --plan-out`.",
503            age.num_hours(),
504        );
505    }
506}
507
508/// Compare the saved plan's `values_input_hashes` against the
509/// freshly-computed hashes (RFC §4 Phase 6). Reuses `Error::PlanDrift`
510/// (exit 7) because the user-facing remedy is identical to ops drift:
511/// regenerate the plan with `diff --plan-out` and review.
512///
513/// Pre-v0.14 plan files have an empty `values_input_hashes` map; we
514/// treat that as "plan pre-dates the values hash feature, skip the
515/// integrity check" instead of forcing a regenerate.
516fn check_plan_values_hashes(
517    saved: &PlanFile,
518    fresh: &std::collections::BTreeMap<String, String>,
519) -> anyhow::Result<()> {
520    if saved.values_input_hashes.is_empty() {
521        return Ok(());
522    }
523    let mut mismatches: Vec<String> = Vec::new();
524    let mut missing_in_fresh: Vec<String> = Vec::new();
525    for (key, saved_hash) in &saved.values_input_hashes {
526        match fresh.get(key) {
527            Some(fresh_hash) if fresh_hash == saved_hash => {}
528            Some(_) => mismatches.push(key.clone()),
529            None => missing_in_fresh.push(key.clone()),
530        }
531    }
532    let extra: Vec<String> = fresh
533        .keys()
534        .filter(|k| !saved.values_input_hashes.contains_key(*k))
535        .cloned()
536        .collect();
537
538    if mismatches.is_empty() && missing_in_fresh.is_empty() && extra.is_empty() {
539        return Ok(());
540    }
541
542    eprintln!("✗ plan drift: values inputs changed since plan was generated");
543    if !mismatches.is_empty() {
544        eprintln!("  consumed values changed for:");
545        for k in &mismatches {
546            eprintln!("    - {k}");
547        }
548        // Heuristic: if many resources mismatch at once it's almost
549        // always a globals.custom edit fanning out to every consumer.
550        // Surface that hint so the operator looks in the right place.
551        if mismatches.len() > 1 {
552            eprintln!(
553                "  hint: {} resources changed simultaneously — likely a `globals.custom.<key>` \
554                 edit that affects every consumer (RFC §4 Phase 6)",
555                mismatches.len()
556            );
557        }
558    }
559    if !missing_in_fresh.is_empty() {
560        eprintln!("  resources in saved plan but no fresh hash (placeholders removed?):");
561        for k in &missing_in_fresh {
562            eprintln!("    - {k}");
563        }
564    }
565    if !extra.is_empty() {
566        eprintln!("  resources with fresh hashes not in saved plan (placeholders added?):");
567        for k in &extra {
568            eprintln!("    - {k}");
569        }
570    }
571    eprintln!("  → regenerate with `diff --plan-out` and review before re-applying");
572    Err(Error::PlanDrift.into())
573}
574
575/// Compare the saved plan's ops against the freshly-computed summary.
576/// Returns `Error::PlanDrift` on any (kind, name, op_type) mismatch.
577fn check_plan_ops(saved: &PlanFile, fresh: &DiffSummary) -> anyhow::Result<()> {
578    let fresh_ops = plan::collect_ops(fresh);
579    let diff = saved.diff_ops(&fresh_ops);
580    if diff.is_match() {
581        return Ok(());
582    }
583    eprintln!("✗ plan drift: saved plan and fresh plan differ");
584    if !diff.missing.is_empty() {
585        eprintln!("  ops in saved plan but not in fresh plan (resolved or absorbed remotely):");
586        for op in &diff.missing {
587            eprintln!("    - {} '{}' [{:?}]", op.kind.as_str(), op.name, op.op);
588        }
589    }
590    if !diff.extra.is_empty() {
591        eprintln!("  ops in fresh plan but not in saved plan (new drift since plan):");
592        for op in &diff.extra {
593            eprintln!("    - {} '{}' [{:?}]", op.kind.as_str(), op.name, op.op);
594        }
595    }
596    eprintln!("  → regenerate with `diff --plan-out` and review before re-applying");
597    Err(Error::PlanDrift.into())
598}
599
600fn check_for_unsupported_ops(summary: &DiffSummary) -> anyhow::Result<()> {
601    for diff in &summary.diffs {
602        if let ResourceDiff::CatalogSchema(d) = diff {
603            // Field type change would require delete-then-add, which is
604            // data-losing on the field — refuse rather than silently drop.
605            for fd in &d.field_diffs {
606                if let DiffOp::Modified { from, to } = fd {
607                    return Err(anyhow!(
608                        "modifying field '{}' on catalog '{}' (type {} → {}) \
609                         is not supported by braze-sync; the change would be \
610                         data-losing on the field. Drop the field manually \
611                         in the Braze dashboard and re-run `braze-sync apply`",
612                        to.name,
613                        d.name,
614                        from.field_type.as_str(),
615                        to.field_type.as_str(),
616                    ));
617                }
618            }
619        }
620    }
621    Ok(())
622}
623
624async fn apply_content_block(
625    client: &BrazeClient,
626    d: &ContentBlockDiff,
627    id_index: Option<&ContentBlockIdIndex>,
628    archive_orphans: bool,
629    today: chrono::NaiveDate,
630) -> anyhow::Result<usize> {
631    // Orphans only mutate remote state when --archive-orphans is set;
632    // otherwise the plan-print is the entire effect.
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: content_block 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        // Update endpoint requires the full body, not a partial. Safe
651        // re: state — `get_content_block` defaults state to Active
652        // (Braze /info has no state field) and `update_content_block`
653        // omits state from the wire body, so this rename can never
654        // toggle the remote state as a side effect. If either of those
655        // invariants ever changes, the orphan path needs revisiting.
656        let mut cb = client
657            .get_content_block(id)
658            .await
659            .with_context(|| format!("fetching content block '{}' for archive rename", d.name))?;
660        cb.name = archived;
661        tracing::info!(
662            content_block = %d.name,
663            new_name = %cb.name,
664            "archiving orphan content block"
665        );
666        client.update_content_block(id, &cb).await?;
667        return Ok(1);
668    }
669
670    match &d.op {
671        DiffOp::Added(cb) => {
672            tracing::info!(content_block = %cb.name, "creating content block");
673            let _ = client.create_content_block(cb).await?;
674            Ok(1)
675        }
676        DiffOp::Modified { to, .. } => {
677            let id_index = id_index.ok_or_else(|| {
678                anyhow!("internal: content_block id index missing for modified apply path")
679            })?;
680            let id = id_index.get(&to.name).ok_or_else(|| {
681                anyhow!(
682                    "internal: modified content block '{}' missing from id index",
683                    to.name
684                )
685            })?;
686            tracing::info!(content_block = %to.name, "updating content block");
687            client.update_content_block(id, to).await?;
688            Ok(1)
689        }
690        // The diff layer routes remote-only blocks through the orphan
691        // flag, never as a Removed op.
692        DiffOp::Removed(_) => {
693            unreachable!("diff layer routes content block removals through orphan")
694        }
695        DiffOp::Unchanged => Ok(0),
696    }
697}
698
699async fn apply_catalog_schema(
700    client: &BrazeClient,
701    d: &CatalogSchemaDiff,
702) -> anyhow::Result<usize> {
703    // `POST /catalogs` carries the full schema, so the per-field loop
704    // below is skipped for `Added` to avoid duplicate field POSTs.
705    if let DiffOp::Added(cat) = &d.op {
706        tracing::info!(catalog = %d.name, "creating new catalog");
707        client
708            .create_catalog(cat)
709            .await
710            .with_context(|| format!("creating catalog '{}'", d.name))?;
711        return Ok(1);
712    }
713
714    // Top-level delete short-circuits the field loop: `DELETE /catalogs/{name}`
715    // drops the schema and all items in one call, so per-field DELETEs would
716    // be redundant (and would 404 once the catalog is gone).
717    if let DiffOp::Removed(_) = &d.op {
718        tracing::info!(catalog = %d.name, "deleting catalog");
719        client
720            .delete_catalog(&d.name)
721            .await
722            .with_context(|| format!("deleting catalog '{}'", d.name))?;
723        return Ok(1);
724    }
725
726    let mut count = 0;
727    for fd in &d.field_diffs {
728        match fd {
729            DiffOp::Added(f) => {
730                tracing::info!(
731                    catalog = %d.name,
732                    field = %f.name,
733                    field_type = f.field_type.as_str(),
734                    "adding catalog field"
735                );
736                client.add_catalog_field(&d.name, f).await?;
737                count += 1;
738            }
739            DiffOp::Removed(f) => {
740                tracing::info!(
741                    catalog = %d.name,
742                    field = %f.name,
743                    "deleting catalog field"
744                );
745                client.delete_catalog_field(&d.name, &f.name).await?;
746                count += 1;
747            }
748            DiffOp::Modified { .. } => {
749                return Err(anyhow!(
750                    "internal: Modified field op should have been rejected \
751                     by check_for_unsupported_ops"
752                ));
753            }
754            DiffOp::Unchanged => {}
755        }
756    }
757    Ok(count)
758}
759
760async fn apply_email_template(
761    client: &BrazeClient,
762    d: &EmailTemplateDiff,
763    id_index: Option<&EmailTemplateIdIndex>,
764    archive_orphans: bool,
765    today: chrono::NaiveDate,
766) -> anyhow::Result<usize> {
767    if d.orphan {
768        if !archive_orphans {
769            return Ok(0);
770        }
771        let id_index = id_index.ok_or_else(|| {
772            anyhow!("internal: email_template id index missing for orphan apply path")
773        })?;
774        let id = id_index.get(&d.name).ok_or_else(|| {
775            anyhow!(
776                "internal: orphan '{}' missing from id index — list/diff drift",
777                d.name
778            )
779        })?;
780        let archived = orphan::archive_name(today, &d.name);
781        if archived == d.name {
782            return Ok(0);
783        }
784        let mut et = client
785            .get_email_template(id)
786            .await
787            .with_context(|| format!("fetching email template '{}' for archive rename", d.name))?;
788        et.name = archived;
789        tracing::info!(
790            email_template = %d.name,
791            new_name = %et.name,
792            "archiving orphan email template"
793        );
794        client.update_email_template(id, &et).await?;
795        return Ok(1);
796    }
797
798    match &d.op {
799        DiffOp::Added(et) => {
800            tracing::info!(email_template = %et.name, "creating email template");
801            let _ = client.create_email_template(et).await?;
802            Ok(1)
803        }
804        DiffOp::Modified { to, .. } => {
805            let id_index = id_index.ok_or_else(|| {
806                anyhow!("internal: email_template id index missing for modified apply path")
807            })?;
808            let id = id_index.get(&to.name).ok_or_else(|| {
809                anyhow!(
810                    "internal: modified email template '{}' missing from id index",
811                    to.name
812                )
813            })?;
814            tracing::info!(email_template = %to.name, "updating email template");
815            client.update_email_template(id, to).await?;
816            Ok(1)
817        }
818        DiffOp::Removed(_) => {
819            unreachable!("diff layer routes email template removals through orphan")
820        }
821        DiffOp::Unchanged => Ok(0),
822    }
823}
824
825/// Batch custom attribute deprecation toggles by direction so we issue
826/// at most two API calls. Each batch is reported to stderr on success so
827/// the user can tell what was committed if a later batch fails.
828async fn apply_custom_attribute_batch(
829    client: &BrazeClient,
830    to_deprecate: &[&str],
831    to_reactivate: &[&str],
832) -> anyhow::Result<usize> {
833    let mut applied = 0;
834    for (names, blocklisted, verb) in [
835        (to_deprecate, true, "deprecating"),
836        (to_reactivate, false, "reactivating"),
837    ] {
838        if names.is_empty() {
839            continue;
840        }
841        tracing::info!(attributes = ?names, "{verb} custom attributes");
842        client
843            .set_custom_attribute_blocklist(names, blocklisted)
844            .await
845            .with_context(|| format!("{verb} custom attributes"))?;
846        let n = names.len();
847        let past = if blocklisted {
848            "deprecated"
849        } else {
850            "reactivated"
851        };
852        eprintln!("  ✓ {past} {n} custom attribute(s)");
853        applied += n;
854    }
855
856    Ok(applied)
857}