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 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    /// Limit apply to a specific resource kind.
38    #[arg(long, value_enum)]
39    pub resource: Option<ResourceKind>,
40
41    /// When `--resource` is given, optionally restrict to a single named
42    /// resource. Requires `--resource`.
43    #[arg(long, requires = "resource")]
44    pub name: Option<String>,
45
46    /// Actually apply changes. Without this, runs in dry-run mode and
47    /// makes zero write calls to Braze. This is the default for safety.
48    #[arg(long)]
49    pub confirm: bool,
50
51    /// Permit destructive operations (field deletes, etc.). Required in
52    /// addition to `--confirm` for any change that would lose data on
53    /// the Braze side.
54    #[arg(long)]
55    pub allow_destructive: bool,
56
57    /// Archive orphan Content Blocks / Email Templates by prefixing the
58    /// remote name with `[ARCHIVED-YYYY-MM-DD]`. Inert for resource
59    /// kinds that have no orphan concept (e.g. catalog schema).
60    #[arg(long)]
61    pub archive_orphans: bool,
62
63    /// Load a plan file produced by `diff --plan-out=<path>` and refuse
64    /// to apply if the freshly-computed plan does not match. Exits with
65    /// code 7 on mismatch.
66    #[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    // Reject scope/env mismatch before any Braze API call.
85    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    // Tag pre-flight: refuse to fire any mutation if a referenced tag is
164    // missing from the registry. Without this, Braze rejects the first
165    // tag-bearing create/update with HTTP 400 and the rest of the plan
166    // halts mid-pipeline. Runs even when --kind is restricted to a non-tag
167    // kind so tag drift cannot sneak past a per-kind apply.
168    enforce_tag_preflight(config_dir, &resolved, &summary)?;
169
170    // Targets must precede referrers because Braze validates
171    // `{{content_blocks.${other}}}` includes at create time and rejects
172    // forward references with an opaque HTTP 500. Done before
173    // plan-print so the dry-run preview reflects actual write order.
174    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    // Print the fresh plan first so the operator sees it even on mismatch.
199    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    // One canonical archive timestamp per run, even across multiple
228    // orphans. UTC (not Local) so two operators running the same archive
229    // on the same wall-clock day from different timezones produce the
230    // same `[ARCHIVED-YYYY-MM-DD]` prefix — determinism across a team
231    // matters more than matching the operator's local calendar.
232    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            // Tags have no remote mutation API. The diff is informational;
272            // pre-flight (above) is what protects apply from missing tags.
273            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
285/// Reject ops the API can't actually perform. Runs before any write
286/// call so a statically-known-bad plan cannot fire a partial apply;
287/// runtime write failures can still leave earlier writes in place.
288///
289/// ContentBlock diffs are deliberately not inspected here: every shape
290/// the diff layer can produce (`Added` → create, `Modified` → update,
291/// `orphan` → archive-or-noop) maps to a supported API call, so there
292/// is nothing to statically reject. If a future diff shape is added
293/// (e.g. content-type change with no in-place update), re-evaluate.
294/// Block apply when any resource that would be mutated references a tag
295/// not declared in `tags/registry.yaml`. Braze has no public REST API for
296/// workspace tags, so we cannot create them; the only reliable way to
297/// avoid the cascading "Tags could not be found" failure (a single tagged
298/// content_block create that 400s blocks every queued create after it) is
299/// to refuse the apply entirely until the operator either (a) adds the
300/// tag to the registry after creating it in the Braze dashboard, or
301/// (b) removes the tag from the offending resource.
302///
303/// No-op when the `tag` resource is disabled in config — this is opt-in
304/// safety, not a forced gate.
305fn 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        // No registry file → operator hasn't opted in to tag tracking yet;
317        // skip the gate rather than blocking on a config-only state.
318        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        // Only inspect resources that would actually fire a write.
326        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
375/// Reject obvious plan-vs-CLI mismatches before doing any remote work.
376fn 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    // Orphan ops only fire writes when --archive-orphans is set, so the
409    // plan-time and apply-time intents must agree or the frozen op set
410    // would not reflect the real write set.
411    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
440/// Compare the saved plan's ops against the freshly-computed summary.
441/// Returns `Error::PlanDrift` on any (kind, name, op_type) mismatch.
442fn 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            // Field type change would require delete-then-add, which is
469            // data-losing on the field — refuse rather than silently drop.
470            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    // Orphans only mutate remote state when --archive-orphans is set;
497    // otherwise the plan-print is the entire effect.
498    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        // Update endpoint requires the full body, not a partial. Safe
516        // re: state — `get_content_block` defaults state to Active
517        // (Braze /info has no state field) and `update_content_block`
518        // omits state from the wire body, so this rename can never
519        // toggle the remote state as a side effect. If either of those
520        // invariants ever changes, the orphan path needs revisiting.
521        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        // The diff layer routes remote-only blocks through the orphan
556        // flag, never as a Removed op.
557        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    // `POST /catalogs` carries the full schema, so the per-field loop
569    // below is skipped for `Added` to avoid duplicate field POSTs.
570    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    // Top-level delete short-circuits the field loop: `DELETE /catalogs/{name}`
580    // drops the schema and all items in one call, so per-field DELETEs would
581    // be redundant (and would 404 once the catalog is gone).
582    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
690/// Batch custom attribute deprecation toggles by direction so we issue
691/// at most two API calls. Each batch is reported to stderr on success so
692/// the user can tell what was committed if a later batch fails.
693async 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}