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        let kind = *kind;
100        if warn_if_name_excluded(kind, args.name.as_deref(), resolved.excludes_for(kind)) {
101            continue;
102        }
103        match kind {
104            ResourceKind::CatalogSchema => {
105                let diffs = compute_catalog_schema_diffs(
106                    &client,
107                    &catalogs_root,
108                    args.name.as_deref(),
109                    resolved.excludes_for(ResourceKind::CatalogSchema),
110                )
111                .await
112                .context("computing catalog_schema plan")?;
113                summary.diffs.extend(diffs);
114            }
115            ResourceKind::ContentBlock => {
116                let (diffs, idx) = compute_content_block_plan(
117                    &client,
118                    &content_blocks_root,
119                    args.name.as_deref(),
120                    resolved.excludes_for(ResourceKind::ContentBlock),
121                )
122                .await
123                .context("computing content_block plan")?;
124                summary.diffs.extend(diffs);
125                content_block_id_index = Some(idx);
126            }
127            ResourceKind::EmailTemplate => {
128                let (diffs, idx) = compute_email_template_plan(
129                    &client,
130                    &email_templates_root,
131                    args.name.as_deref(),
132                    resolved.excludes_for(ResourceKind::EmailTemplate),
133                )
134                .await
135                .context("computing email_template plan")?;
136                summary.diffs.extend(diffs);
137                email_template_id_index = Some(idx);
138            }
139            ResourceKind::CustomAttribute => {
140                let diffs = compute_custom_attribute_diffs(
141                    &client,
142                    &custom_attributes_path,
143                    args.name.as_deref(),
144                    resolved.excludes_for(ResourceKind::CustomAttribute),
145                )
146                .await
147                .context("computing custom_attribute plan")?;
148                summary.diffs.extend(diffs);
149            }
150            ResourceKind::Tag => {
151                let diffs = compute_tag_diffs(
152                    config_dir,
153                    &resolved,
154                    &tags_path,
155                    args.name.as_deref(),
156                    resolved.excludes_for(ResourceKind::Tag),
157                )
158                .context("computing tag plan")?;
159                summary.diffs.extend(diffs);
160            }
161        }
162    }
163
164    // Tag pre-flight: refuse to fire any mutation if a referenced tag is
165    // missing from the registry. Without this, Braze rejects the first
166    // tag-bearing create/update with HTTP 400 and the rest of the plan
167    // halts mid-pipeline. Runs even when --kind is restricted to a non-tag
168    // kind so tag drift cannot sneak past a per-kind apply.
169    enforce_tag_preflight(config_dir, &resolved, &summary)?;
170
171    // Targets must precede referrers because Braze validates
172    // `{{content_blocks.${other}}}` includes at create time and rejects
173    // forward references with an opaque HTTP 500. Done before
174    // plan-print so the dry-run preview reflects actual write order.
175    if matches!(
176        resolved.resources.content_block.apply_order,
177        ApplyOrder::Dependency
178    ) {
179        summary.diffs = reorder_content_block_diffs_by_dependency(std::mem::take(
180            &mut summary.diffs,
181        ))
182        .map_err(|cycle| {
183            anyhow!(
184                "content_block dependency cycle detected\n       \
185                         {cycle}\n       \
186                         resolve by removing one of these references and try again"
187            )
188        })?;
189    }
190
191    let mode_label = if args.confirm {
192        "Plan:"
193    } else {
194        "Plan (dry-run, pass --confirm to apply):"
195    };
196    eprintln!("{mode_label}");
197    print!("{}", format.formatter().format(&summary));
198
199    // Print the fresh plan first so the operator sees it even on mismatch.
200    if let Some(saved) = &saved_plan {
201        check_plan_ops(saved, &summary)?;
202        eprintln!("✓ Plan matches saved plan ({} op(s)).", saved.ops.len());
203    }
204
205    if summary.actionable_count() == 0 {
206        if summary.changed_count() > 0 {
207            eprintln!(
208                "No actionable changes to apply \
209                 (informational drift above can be reconciled with `export`)."
210            );
211        } else {
212            eprintln!("No changes to apply.");
213        }
214        return Ok(());
215    }
216
217    if !args.confirm {
218        eprintln!("DRY RUN — pass --confirm to apply these changes.");
219        return Ok(());
220    }
221
222    if summary.destructive_count() > 0 && !args.allow_destructive {
223        return Err(Error::DestructiveBlocked.into());
224    }
225
226    check_for_unsupported_ops(&summary)?;
227
228    // One canonical archive timestamp per run, even across multiple
229    // orphans. UTC (not Local) so two operators running the same archive
230    // on the same wall-clock day from different timezones produce the
231    // same `[ARCHIVED-YYYY-MM-DD]` prefix — determinism across a team
232    // matters more than matching the operator's local calendar.
233    let today = chrono::Utc::now().date_naive();
234
235    let mut applied = 0;
236    let mut ca_deprecate: Vec<&str> = Vec::new();
237    let mut ca_reactivate: Vec<&str> = Vec::new();
238    for diff in &summary.diffs {
239        match diff {
240            ResourceDiff::CatalogSchema(d) => {
241                applied += apply_catalog_schema(&client, d).await?;
242            }
243            ResourceDiff::ContentBlock(d) => {
244                applied += apply_content_block(
245                    &client,
246                    d,
247                    content_block_id_index.as_ref(),
248                    args.archive_orphans,
249                    today,
250                )
251                .await?;
252            }
253            ResourceDiff::EmailTemplate(d) => {
254                applied += apply_email_template(
255                    &client,
256                    d,
257                    email_template_id_index.as_ref(),
258                    args.archive_orphans,
259                    today,
260                )
261                .await?;
262            }
263            ResourceDiff::CustomAttribute(d) => {
264                if let CustomAttributeOp::DeprecationToggled { to, .. } = &d.op {
265                    if *to {
266                        ca_deprecate.push(&d.name);
267                    } else {
268                        ca_reactivate.push(&d.name);
269                    }
270                }
271            }
272            // Tags have no remote mutation API. The diff is informational;
273            // pre-flight (above) is what protects apply from missing tags.
274            ResourceDiff::Tag(_) => {}
275        }
276    }
277
278    if !ca_deprecate.is_empty() || !ca_reactivate.is_empty() {
279        applied += apply_custom_attribute_batch(&client, &ca_deprecate, &ca_reactivate).await?;
280    }
281
282    eprintln!("✓ Applied {applied} change(s).");
283    Ok(())
284}
285
286/// Reject ops the API can't actually perform. Runs before any write
287/// call so a statically-known-bad plan cannot fire a partial apply;
288/// runtime write failures can still leave earlier writes in place.
289///
290/// ContentBlock diffs are deliberately not inspected here: every shape
291/// the diff layer can produce (`Added` → create, `Modified` → update,
292/// `orphan` → archive-or-noop) maps to a supported API call, so there
293/// is nothing to statically reject. If a future diff shape is added
294/// (e.g. content-type change with no in-place update), re-evaluate.
295/// Block apply when any resource that would be mutated references a tag
296/// not declared in `tags/registry.yaml`. Braze has no public REST API for
297/// workspace tags, so we cannot create them; the only reliable way to
298/// avoid the cascading "Tags could not be found" failure (a single tagged
299/// content_block create that 400s blocks every queued create after it) is
300/// to refuse the apply entirely until the operator either (a) adds the
301/// tag to the registry after creating it in the Braze dashboard, or
302/// (b) removes the tag from the offending resource.
303///
304/// No-op when the `tag` resource is disabled in config — this is opt-in
305/// safety, not a forced gate.
306fn enforce_tag_preflight(
307    config_dir: &Path,
308    resolved: &ResolvedConfig,
309    summary: &DiffSummary,
310) -> anyhow::Result<()> {
311    if !resolved.resources.is_enabled(ResourceKind::Tag) {
312        return Ok(());
313    }
314    let tags_path = config_dir.join(&resolved.resources.tag.path);
315    let registry = match crate::fs::tag_io::load_registry(&tags_path)? {
316        Some(r) => r,
317        // No registry file → operator hasn't opted in to tag tracking yet;
318        // skip the gate rather than blocking on a config-only state.
319        None => return Ok(()),
320    };
321    let excludes = resolved.excludes_for(ResourceKind::Tag);
322
323    let mut missing: std::collections::BTreeMap<String, Vec<String>> =
324        std::collections::BTreeMap::new();
325    for diff in &summary.diffs {
326        // Only inspect resources that would actually fire a write.
327        let (resource_label, tags) = match diff {
328            ResourceDiff::ContentBlock(d) => match &d.op {
329                DiffOp::Added(cb) => (format!("content_block '{}'", cb.name), &cb.tags),
330                DiffOp::Modified { to, .. } => (format!("content_block '{}'", to.name), &to.tags),
331                _ => continue,
332            },
333            ResourceDiff::EmailTemplate(d) => match &d.op {
334                DiffOp::Added(et) => (format!("email_template '{}'", et.name), &et.tags),
335                DiffOp::Modified { to, .. } => (format!("email_template '{}'", to.name), &to.tags),
336                _ => continue,
337            },
338            _ => continue,
339        };
340        for t in tags {
341            if crate::config::is_excluded(t, excludes) {
342                continue;
343            }
344            if !registry.contains(t) {
345                missing
346                    .entry(t.clone())
347                    .or_default()
348                    .push(resource_label.clone());
349            }
350        }
351    }
352
353    if missing.is_empty() {
354        return Ok(());
355    }
356
357    let mut msg = String::from(
358        "tag pre-flight: refusing to apply — the following tags are referenced \
359         by resources that would be created/updated, but are not declared in \
360         tags/registry.yaml:\n",
361    );
362    for (tag, refs) in &missing {
363        msg.push_str(&format!("  • '{tag}' — referenced by:\n"));
364        for r in refs {
365            msg.push_str(&format!("      - {r}\n"));
366        }
367    }
368    msg.push_str(
369        "\nFix: create each missing tag in the Braze dashboard \
370         (Settings → Tags), then add it to tags/registry.yaml and re-run apply. \
371         Braze does not expose a tag creation API.",
372    );
373    Err(anyhow!(msg))
374}
375
376/// Reject obvious plan-vs-CLI mismatches before doing any remote work.
377fn check_plan_scope(plan: &PlanFile, environment: &str, args: &ApplyArgs) -> anyhow::Result<()> {
378    if plan.version != plan::CURRENT_PLAN_VERSION {
379        return Err(anyhow!(
380            "plan file version {} is not supported by this binary \
381             (expected {}). Regenerate with `diff --plan-out`.",
382            plan.version,
383            plan::CURRENT_PLAN_VERSION,
384        ));
385    }
386    if plan.scope.environment != environment {
387        eprintln!(
388            "✗ plan drift: plan was generated for environment '{}' \
389             but apply targets '{}'",
390            plan.scope.environment, environment,
391        );
392        return Err(Error::PlanDrift.into());
393    }
394    if plan.scope.resource != args.resource {
395        eprintln!(
396            "✗ plan drift: plan scope --resource={:?} but apply --resource={:?}",
397            plan.scope.resource.map(|k| k.as_str()),
398            args.resource.map(|k| k.as_str()),
399        );
400        return Err(Error::PlanDrift.into());
401    }
402    if plan.scope.name != args.name {
403        eprintln!(
404            "✗ plan drift: plan scope --name={:?} but apply --name={:?}",
405            plan.scope.name, args.name,
406        );
407        return Err(Error::PlanDrift.into());
408    }
409    // Orphan ops only fire writes when --archive-orphans is set, so the
410    // plan-time and apply-time intents must agree or the frozen op set
411    // would not reflect the real write set.
412    if plan.scope.archive_orphans != args.archive_orphans {
413        eprintln!(
414            "✗ plan drift: plan scope --archive-orphans={} but apply --archive-orphans={}",
415            plan.scope.archive_orphans, args.archive_orphans,
416        );
417        return Err(Error::PlanDrift.into());
418    }
419    Ok(())
420}
421
422fn warn_on_plan_metadata(plan: &PlanFile) {
423    let current = env!("CARGO_PKG_VERSION");
424    if plan.braze_sync_version != current {
425        eprintln!(
426            "⚠ plan was generated by braze-sync {} (current: {}). \
427             Proceeding — pass `diff --plan-out` again if op semantics changed.",
428            plan.braze_sync_version, current,
429        );
430    }
431    let age = chrono::Utc::now().signed_duration_since(plan.generated_at);
432    if age > plan::STALE_PLAN_WARN_THRESHOLD {
433        eprintln!(
434            "⚠ plan is {} hour(s) old — Braze may have drifted; consider \
435             regenerating with `diff --plan-out`.",
436            age.num_hours(),
437        );
438    }
439}
440
441/// Compare the saved plan's ops against the freshly-computed summary.
442/// Returns `Error::PlanDrift` on any (kind, name, op_type) mismatch.
443fn check_plan_ops(saved: &PlanFile, fresh: &DiffSummary) -> anyhow::Result<()> {
444    let fresh_ops = plan::collect_ops(fresh);
445    let diff = saved.diff_ops(&fresh_ops);
446    if diff.is_match() {
447        return Ok(());
448    }
449    eprintln!("✗ plan drift: saved plan and fresh plan differ");
450    if !diff.missing.is_empty() {
451        eprintln!("  ops in saved plan but not in fresh plan (resolved or absorbed remotely):");
452        for op in &diff.missing {
453            eprintln!("    - {} '{}' [{:?}]", op.kind.as_str(), op.name, op.op);
454        }
455    }
456    if !diff.extra.is_empty() {
457        eprintln!("  ops in fresh plan but not in saved plan (new drift since plan):");
458        for op in &diff.extra {
459            eprintln!("    - {} '{}' [{:?}]", op.kind.as_str(), op.name, op.op);
460        }
461    }
462    eprintln!("  → regenerate with `diff --plan-out` and review before re-applying");
463    Err(Error::PlanDrift.into())
464}
465
466fn check_for_unsupported_ops(summary: &DiffSummary) -> anyhow::Result<()> {
467    for diff in &summary.diffs {
468        if let ResourceDiff::CatalogSchema(d) = diff {
469            // Field type change would require delete-then-add, which is
470            // data-losing on the field — refuse rather than silently drop.
471            for fd in &d.field_diffs {
472                if let DiffOp::Modified { from, to } = fd {
473                    return Err(anyhow!(
474                        "modifying field '{}' on catalog '{}' (type {} → {}) \
475                         is not supported by braze-sync; the change would be \
476                         data-losing on the field. Drop the field manually \
477                         in the Braze dashboard and re-run `braze-sync apply`",
478                        to.name,
479                        d.name,
480                        from.field_type.as_str(),
481                        to.field_type.as_str(),
482                    ));
483                }
484            }
485        }
486    }
487    Ok(())
488}
489
490async fn apply_content_block(
491    client: &BrazeClient,
492    d: &ContentBlockDiff,
493    id_index: Option<&ContentBlockIdIndex>,
494    archive_orphans: bool,
495    today: chrono::NaiveDate,
496) -> anyhow::Result<usize> {
497    // Orphans only mutate remote state when --archive-orphans is set;
498    // otherwise the plan-print is the entire effect.
499    if d.orphan {
500        if !archive_orphans {
501            return Ok(0);
502        }
503        let id_index = id_index.ok_or_else(|| {
504            anyhow!("internal: content_block id index missing for orphan apply path")
505        })?;
506        let id = id_index.get(&d.name).ok_or_else(|| {
507            anyhow!(
508                "internal: orphan '{}' missing from id index — list/diff drift",
509                d.name
510            )
511        })?;
512        let archived = orphan::archive_name(today, &d.name);
513        if archived == d.name {
514            return Ok(0);
515        }
516        // Update endpoint requires the full body, not a partial. Safe
517        // re: state — `get_content_block` defaults state to Active
518        // (Braze /info has no state field) and `update_content_block`
519        // omits state from the wire body, so this rename can never
520        // toggle the remote state as a side effect. If either of those
521        // invariants ever changes, the orphan path needs revisiting.
522        let mut cb = client
523            .get_content_block(id)
524            .await
525            .with_context(|| format!("fetching content block '{}' for archive rename", d.name))?;
526        cb.name = archived;
527        tracing::info!(
528            content_block = %d.name,
529            new_name = %cb.name,
530            "archiving orphan content block"
531        );
532        client.update_content_block(id, &cb).await?;
533        return Ok(1);
534    }
535
536    match &d.op {
537        DiffOp::Added(cb) => {
538            tracing::info!(content_block = %cb.name, "creating content block");
539            let _ = client.create_content_block(cb).await?;
540            Ok(1)
541        }
542        DiffOp::Modified { to, .. } => {
543            let id_index = id_index.ok_or_else(|| {
544                anyhow!("internal: content_block id index missing for modified apply path")
545            })?;
546            let id = id_index.get(&to.name).ok_or_else(|| {
547                anyhow!(
548                    "internal: modified content block '{}' missing from id index",
549                    to.name
550                )
551            })?;
552            tracing::info!(content_block = %to.name, "updating content block");
553            client.update_content_block(id, to).await?;
554            Ok(1)
555        }
556        // The diff layer routes remote-only blocks through the orphan
557        // flag, never as a Removed op.
558        DiffOp::Removed(_) => {
559            unreachable!("diff layer routes content block removals through orphan")
560        }
561        DiffOp::Unchanged => Ok(0),
562    }
563}
564
565async fn apply_catalog_schema(
566    client: &BrazeClient,
567    d: &CatalogSchemaDiff,
568) -> anyhow::Result<usize> {
569    // `POST /catalogs` carries the full schema, so the per-field loop
570    // below is skipped for `Added` to avoid duplicate field POSTs.
571    if let DiffOp::Added(cat) = &d.op {
572        tracing::info!(catalog = %d.name, "creating new catalog");
573        client
574            .create_catalog(cat)
575            .await
576            .with_context(|| format!("creating catalog '{}'", d.name))?;
577        return Ok(1);
578    }
579
580    // Top-level delete short-circuits the field loop: `DELETE /catalogs/{name}`
581    // drops the schema and all items in one call, so per-field DELETEs would
582    // be redundant (and would 404 once the catalog is gone).
583    if let DiffOp::Removed(_) = &d.op {
584        tracing::info!(catalog = %d.name, "deleting catalog");
585        client
586            .delete_catalog(&d.name)
587            .await
588            .with_context(|| format!("deleting catalog '{}'", d.name))?;
589        return Ok(1);
590    }
591
592    let mut count = 0;
593    for fd in &d.field_diffs {
594        match fd {
595            DiffOp::Added(f) => {
596                tracing::info!(
597                    catalog = %d.name,
598                    field = %f.name,
599                    field_type = f.field_type.as_str(),
600                    "adding catalog field"
601                );
602                client.add_catalog_field(&d.name, f).await?;
603                count += 1;
604            }
605            DiffOp::Removed(f) => {
606                tracing::info!(
607                    catalog = %d.name,
608                    field = %f.name,
609                    "deleting catalog field"
610                );
611                client.delete_catalog_field(&d.name, &f.name).await?;
612                count += 1;
613            }
614            DiffOp::Modified { .. } => {
615                return Err(anyhow!(
616                    "internal: Modified field op should have been rejected \
617                     by check_for_unsupported_ops"
618                ));
619            }
620            DiffOp::Unchanged => {}
621        }
622    }
623    Ok(count)
624}
625
626async fn apply_email_template(
627    client: &BrazeClient,
628    d: &EmailTemplateDiff,
629    id_index: Option<&EmailTemplateIdIndex>,
630    archive_orphans: bool,
631    today: chrono::NaiveDate,
632) -> anyhow::Result<usize> {
633    if d.orphan {
634        if !archive_orphans {
635            return Ok(0);
636        }
637        let id_index = id_index.ok_or_else(|| {
638            anyhow!("internal: email_template id index missing for orphan apply path")
639        })?;
640        let id = id_index.get(&d.name).ok_or_else(|| {
641            anyhow!(
642                "internal: orphan '{}' missing from id index — list/diff drift",
643                d.name
644            )
645        })?;
646        let archived = orphan::archive_name(today, &d.name);
647        if archived == d.name {
648            return Ok(0);
649        }
650        let mut et = client
651            .get_email_template(id)
652            .await
653            .with_context(|| format!("fetching email template '{}' for archive rename", d.name))?;
654        et.name = archived;
655        tracing::info!(
656            email_template = %d.name,
657            new_name = %et.name,
658            "archiving orphan email template"
659        );
660        client.update_email_template(id, &et).await?;
661        return Ok(1);
662    }
663
664    match &d.op {
665        DiffOp::Added(et) => {
666            tracing::info!(email_template = %et.name, "creating email template");
667            let _ = client.create_email_template(et).await?;
668            Ok(1)
669        }
670        DiffOp::Modified { to, .. } => {
671            let id_index = id_index.ok_or_else(|| {
672                anyhow!("internal: email_template id index missing for modified apply path")
673            })?;
674            let id = id_index.get(&to.name).ok_or_else(|| {
675                anyhow!(
676                    "internal: modified email template '{}' missing from id index",
677                    to.name
678                )
679            })?;
680            tracing::info!(email_template = %to.name, "updating email template");
681            client.update_email_template(id, to).await?;
682            Ok(1)
683        }
684        DiffOp::Removed(_) => {
685            unreachable!("diff layer routes email template removals through orphan")
686        }
687        DiffOp::Unchanged => Ok(0),
688    }
689}
690
691/// Batch custom attribute deprecation toggles by direction so we issue
692/// at most two API calls. Each batch is reported to stderr on success so
693/// the user can tell what was committed if a later batch fails.
694async fn apply_custom_attribute_batch(
695    client: &BrazeClient,
696    to_deprecate: &[&str],
697    to_reactivate: &[&str],
698) -> anyhow::Result<usize> {
699    let mut applied = 0;
700    for (names, blocklisted, verb) in [
701        (to_deprecate, true, "deprecating"),
702        (to_reactivate, false, "reactivating"),
703    ] {
704        if names.is_empty() {
705            continue;
706        }
707        tracing::info!(attributes = ?names, "{verb} custom attributes");
708        client
709            .set_custom_attribute_blocklist(names, blocklisted)
710            .await
711            .with_context(|| format!("{verb} custom attributes"))?;
712        let n = names.len();
713        let past = if blocklisted {
714            "deprecated"
715        } else {
716            "reactivated"
717        };
718        eprintln!("  ✓ {past} {n} custom attribute(s)");
719        applied += n;
720    }
721
722    Ok(applied)
723}