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::ResolvedConfig;
14use crate::diff::catalog::CatalogSchemaDiff;
15use crate::diff::content_block::{ContentBlockDiff, ContentBlockIdIndex};
16use crate::diff::custom_attribute::CustomAttributeOp;
17use crate::diff::email_template::{EmailTemplateDiff, EmailTemplateIdIndex};
18use crate::diff::orphan;
19use crate::diff::{DiffOp, DiffSummary, ResourceDiff};
20use crate::error::Error;
21use crate::format::OutputFormat;
22use crate::resource::ResourceKind;
23use anyhow::{anyhow, Context as _};
24use clap::Args;
25use std::path::Path;
26
27use super::diff::{
28    compute_catalog_schema_diffs, compute_content_block_plan, compute_custom_attribute_diffs,
29    compute_email_template_plan,
30};
31use super::{selected_kinds, warn_if_name_excluded};
32
33#[derive(Args, Debug)]
34pub struct ApplyArgs {
35    /// Limit apply to a specific resource kind.
36    #[arg(long, value_enum)]
37    pub resource: Option<ResourceKind>,
38
39    /// When `--resource` is given, optionally restrict to a single named
40    /// resource. Requires `--resource`.
41    #[arg(long, requires = "resource")]
42    pub name: Option<String>,
43
44    /// Actually apply changes. Without this, runs in dry-run mode and
45    /// makes zero write calls to Braze. This is the default for safety.
46    #[arg(long)]
47    pub confirm: bool,
48
49    /// Permit destructive operations (field deletes, etc.). Required in
50    /// addition to `--confirm` for any change that would lose data on
51    /// the Braze side.
52    #[arg(long)]
53    pub allow_destructive: bool,
54
55    /// Archive orphan Content Blocks / Email Templates by prefixing the
56    /// remote name with `[ARCHIVED-YYYY-MM-DD]`. Inert for resource
57    /// kinds that have no orphan concept (e.g. catalog schema).
58    #[arg(long)]
59    pub archive_orphans: bool,
60}
61
62pub async fn run(
63    args: &ApplyArgs,
64    resolved: ResolvedConfig,
65    config_dir: &Path,
66    format: OutputFormat,
67) -> anyhow::Result<()> {
68    let catalogs_root = config_dir.join(&resolved.resources.catalog_schema.path);
69    let content_blocks_root = config_dir.join(&resolved.resources.content_block.path);
70    let email_templates_root = config_dir.join(&resolved.resources.email_template.path);
71    let custom_attributes_path = config_dir.join(&resolved.resources.custom_attribute.path);
72    let client = BrazeClient::from_resolved(&resolved);
73    let kinds = selected_kinds(args.resource, &resolved.resources);
74
75    let mut summary = DiffSummary::default();
76    let mut content_block_id_index: Option<ContentBlockIdIndex> = None;
77    let mut email_template_id_index: Option<EmailTemplateIdIndex> = None;
78    for kind in kinds {
79        if warn_if_name_excluded(kind, args.name.as_deref(), resolved.excludes_for(kind)) {
80            continue;
81        }
82        match kind {
83            ResourceKind::CatalogSchema => {
84                let diffs = compute_catalog_schema_diffs(
85                    &client,
86                    &catalogs_root,
87                    args.name.as_deref(),
88                    resolved.excludes_for(ResourceKind::CatalogSchema),
89                )
90                .await
91                .context("computing catalog_schema plan")?;
92                summary.diffs.extend(diffs);
93            }
94            ResourceKind::ContentBlock => {
95                let (diffs, idx) = compute_content_block_plan(
96                    &client,
97                    &content_blocks_root,
98                    args.name.as_deref(),
99                    resolved.excludes_for(ResourceKind::ContentBlock),
100                )
101                .await
102                .context("computing content_block plan")?;
103                summary.diffs.extend(diffs);
104                content_block_id_index = Some(idx);
105            }
106            ResourceKind::EmailTemplate => {
107                let (diffs, idx) = compute_email_template_plan(
108                    &client,
109                    &email_templates_root,
110                    args.name.as_deref(),
111                    resolved.excludes_for(ResourceKind::EmailTemplate),
112                )
113                .await
114                .context("computing email_template plan")?;
115                summary.diffs.extend(diffs);
116                email_template_id_index = Some(idx);
117            }
118            ResourceKind::CustomAttribute => {
119                let diffs = compute_custom_attribute_diffs(
120                    &client,
121                    &custom_attributes_path,
122                    args.name.as_deref(),
123                    resolved.excludes_for(ResourceKind::CustomAttribute),
124                )
125                .await
126                .context("computing custom_attribute plan")?;
127                summary.diffs.extend(diffs);
128            }
129        }
130    }
131
132    let mode_label = if args.confirm {
133        "Plan:"
134    } else {
135        "Plan (dry-run, pass --confirm to apply):"
136    };
137    eprintln!("{mode_label}");
138    print!("{}", format.formatter().format(&summary));
139
140    if summary.actionable_count() == 0 {
141        if summary.changed_count() > 0 {
142            eprintln!(
143                "No actionable changes to apply \
144                 (informational drift above can be reconciled with `export`)."
145            );
146        } else {
147            eprintln!("No changes to apply.");
148        }
149        return Ok(());
150    }
151
152    if !args.confirm {
153        eprintln!("DRY RUN — pass --confirm to apply these changes.");
154        return Ok(());
155    }
156
157    if summary.destructive_count() > 0 && !args.allow_destructive {
158        return Err(Error::DestructiveBlocked.into());
159    }
160
161    check_for_unsupported_ops(&summary)?;
162
163    // One canonical archive timestamp per run, even across multiple
164    // orphans. UTC (not Local) so two operators running the same archive
165    // on the same wall-clock day from different timezones produce the
166    // same `[ARCHIVED-YYYY-MM-DD]` prefix — determinism across a team
167    // matters more than matching the operator's local calendar.
168    let today = chrono::Utc::now().date_naive();
169
170    let mut applied = 0;
171    let mut ca_deprecate: Vec<&str> = Vec::new();
172    let mut ca_reactivate: Vec<&str> = Vec::new();
173    for diff in &summary.diffs {
174        match diff {
175            ResourceDiff::CatalogSchema(d) => {
176                applied += apply_catalog_schema(&client, d).await?;
177            }
178            ResourceDiff::ContentBlock(d) => {
179                applied += apply_content_block(
180                    &client,
181                    d,
182                    content_block_id_index.as_ref(),
183                    args.archive_orphans,
184                    today,
185                )
186                .await?;
187            }
188            ResourceDiff::EmailTemplate(d) => {
189                applied += apply_email_template(
190                    &client,
191                    d,
192                    email_template_id_index.as_ref(),
193                    args.archive_orphans,
194                    today,
195                )
196                .await?;
197            }
198            ResourceDiff::CustomAttribute(d) => {
199                if let CustomAttributeOp::DeprecationToggled { to, .. } = &d.op {
200                    if *to {
201                        ca_deprecate.push(&d.name);
202                    } else {
203                        ca_reactivate.push(&d.name);
204                    }
205                }
206            }
207        }
208    }
209
210    if !ca_deprecate.is_empty() || !ca_reactivate.is_empty() {
211        applied += apply_custom_attribute_batch(&client, &ca_deprecate, &ca_reactivate).await?;
212    }
213
214    eprintln!("✓ Applied {applied} change(s).");
215    Ok(())
216}
217
218/// Reject ops the API can't actually perform. Runs before any write
219/// call so a statically-known-bad plan cannot fire a partial apply;
220/// runtime write failures can still leave earlier writes in place.
221///
222/// ContentBlock diffs are deliberately not inspected here: every shape
223/// the diff layer can produce (`Added` → create, `Modified` → update,
224/// `orphan` → archive-or-noop) maps to a supported API call, so there
225/// is nothing to statically reject. If a future diff shape is added
226/// (e.g. content-type change with no in-place update), re-evaluate.
227fn check_for_unsupported_ops(summary: &DiffSummary) -> anyhow::Result<()> {
228    for diff in &summary.diffs {
229        if let ResourceDiff::CustomAttribute(d) = diff {
230            if matches!(d.op, CustomAttributeOp::PresentInGitOnly) {
231                return Err(Error::CustomAttributeCreateNotSupported {
232                    name: d.name.clone(),
233                }
234                .into());
235            }
236        }
237        if let ResourceDiff::CatalogSchema(d) = diff {
238            match &d.op {
239                DiffOp::Added(_) => {
240                    return Err(anyhow!(
241                        "creating a new catalog '{}' is not supported by braze-sync; \
242                         create the catalog in the Braze dashboard first, then run \
243                         `braze-sync export` to populate the local schema",
244                        d.name
245                    ));
246                }
247                DiffOp::Removed(_) => {
248                    return Err(anyhow!(
249                        "deleting catalog '{}' (top-level) is not supported by braze-sync; \
250                         only field-level changes can be applied",
251                        d.name
252                    ));
253                }
254                _ => {}
255            }
256            // Field type change would require delete-then-add, which is
257            // data-losing on the field — refuse rather than silently drop.
258            for fd in &d.field_diffs {
259                if let DiffOp::Modified { from, to } = fd {
260                    return Err(anyhow!(
261                        "modifying field '{}' on catalog '{}' (type {} → {}) \
262                         is not supported by braze-sync; the change would be \
263                         data-losing on the field. Drop the field manually \
264                         in the Braze dashboard and re-run `braze-sync apply`",
265                        to.name,
266                        d.name,
267                        from.field_type.as_str(),
268                        to.field_type.as_str(),
269                    ));
270                }
271            }
272        }
273    }
274    Ok(())
275}
276
277async fn apply_content_block(
278    client: &BrazeClient,
279    d: &ContentBlockDiff,
280    id_index: Option<&ContentBlockIdIndex>,
281    archive_orphans: bool,
282    today: chrono::NaiveDate,
283) -> anyhow::Result<usize> {
284    // Orphans only mutate remote state when --archive-orphans is set;
285    // otherwise the plan-print is the entire effect.
286    if d.orphan {
287        if !archive_orphans {
288            return Ok(0);
289        }
290        let id_index = id_index.ok_or_else(|| {
291            anyhow!("internal: content_block id index missing for orphan apply path")
292        })?;
293        let id = id_index.get(&d.name).ok_or_else(|| {
294            anyhow!(
295                "internal: orphan '{}' missing from id index — list/diff drift",
296                d.name
297            )
298        })?;
299        let archived = orphan::archive_name(today, &d.name);
300        if archived == d.name {
301            return Ok(0);
302        }
303        // Update endpoint requires the full body, not a partial. Safe
304        // re: state — `get_content_block` defaults state to Active
305        // (Braze /info has no state field) and `update_content_block`
306        // omits state from the wire body, so this rename can never
307        // toggle the remote state as a side effect. If either of those
308        // invariants ever changes, the orphan path needs revisiting.
309        let mut cb = client
310            .get_content_block(id)
311            .await
312            .with_context(|| format!("fetching content block '{}' for archive rename", d.name))?;
313        cb.name = archived;
314        tracing::info!(
315            content_block = %d.name,
316            new_name = %cb.name,
317            "archiving orphan content block"
318        );
319        client.update_content_block(id, &cb).await?;
320        return Ok(1);
321    }
322
323    match &d.op {
324        DiffOp::Added(cb) => {
325            tracing::info!(content_block = %cb.name, "creating content block");
326            let _ = client.create_content_block(cb).await?;
327            Ok(1)
328        }
329        DiffOp::Modified { to, .. } => {
330            let id_index = id_index.ok_or_else(|| {
331                anyhow!("internal: content_block id index missing for modified apply path")
332            })?;
333            let id = id_index.get(&to.name).ok_or_else(|| {
334                anyhow!(
335                    "internal: modified content block '{}' missing from id index",
336                    to.name
337                )
338            })?;
339            tracing::info!(content_block = %to.name, "updating content block");
340            client.update_content_block(id, to).await?;
341            Ok(1)
342        }
343        // The diff layer routes remote-only blocks through the orphan
344        // flag, never as a Removed op.
345        DiffOp::Removed(_) => {
346            unreachable!("diff layer routes content block removals through orphan")
347        }
348        DiffOp::Unchanged => Ok(0),
349    }
350}
351
352async fn apply_catalog_schema(
353    client: &BrazeClient,
354    d: &CatalogSchemaDiff,
355) -> anyhow::Result<usize> {
356    let mut count = 0;
357    for fd in &d.field_diffs {
358        match fd {
359            DiffOp::Added(f) => {
360                tracing::info!(
361                    catalog = %d.name,
362                    field = %f.name,
363                    field_type = f.field_type.as_str(),
364                    "adding catalog field"
365                );
366                client.add_catalog_field(&d.name, f).await?;
367                count += 1;
368            }
369            DiffOp::Removed(f) => {
370                tracing::info!(
371                    catalog = %d.name,
372                    field = %f.name,
373                    "deleting catalog field"
374                );
375                client.delete_catalog_field(&d.name, &f.name).await?;
376                count += 1;
377            }
378            DiffOp::Modified { .. } => {
379                return Err(anyhow!(
380                    "internal: Modified field op should have been rejected \
381                     by check_for_unsupported_ops"
382                ));
383            }
384            DiffOp::Unchanged => {}
385        }
386    }
387    Ok(count)
388}
389
390async fn apply_email_template(
391    client: &BrazeClient,
392    d: &EmailTemplateDiff,
393    id_index: Option<&EmailTemplateIdIndex>,
394    archive_orphans: bool,
395    today: chrono::NaiveDate,
396) -> anyhow::Result<usize> {
397    if d.orphan {
398        if !archive_orphans {
399            return Ok(0);
400        }
401        let id_index = id_index.ok_or_else(|| {
402            anyhow!("internal: email_template id index missing for orphan apply path")
403        })?;
404        let id = id_index.get(&d.name).ok_or_else(|| {
405            anyhow!(
406                "internal: orphan '{}' missing from id index — list/diff drift",
407                d.name
408            )
409        })?;
410        let archived = orphan::archive_name(today, &d.name);
411        if archived == d.name {
412            return Ok(0);
413        }
414        let mut et = client
415            .get_email_template(id)
416            .await
417            .with_context(|| format!("fetching email template '{}' for archive rename", d.name))?;
418        et.name = archived;
419        tracing::info!(
420            email_template = %d.name,
421            new_name = %et.name,
422            "archiving orphan email template"
423        );
424        client.update_email_template(id, &et).await?;
425        return Ok(1);
426    }
427
428    match &d.op {
429        DiffOp::Added(et) => {
430            tracing::info!(email_template = %et.name, "creating email template");
431            let _ = client.create_email_template(et).await?;
432            Ok(1)
433        }
434        DiffOp::Modified { to, .. } => {
435            let id_index = id_index.ok_or_else(|| {
436                anyhow!("internal: email_template id index missing for modified apply path")
437            })?;
438            let id = id_index.get(&to.name).ok_or_else(|| {
439                anyhow!(
440                    "internal: modified email template '{}' missing from id index",
441                    to.name
442                )
443            })?;
444            tracing::info!(email_template = %to.name, "updating email template");
445            client.update_email_template(id, to).await?;
446            Ok(1)
447        }
448        DiffOp::Removed(_) => {
449            unreachable!("diff layer routes email template removals through orphan")
450        }
451        DiffOp::Unchanged => Ok(0),
452    }
453}
454
455/// Batch custom attribute deprecation toggles by direction so we issue
456/// at most two API calls. Each batch is reported to stderr on success so
457/// the user can tell what was committed if a later batch fails.
458async fn apply_custom_attribute_batch(
459    client: &BrazeClient,
460    to_deprecate: &[&str],
461    to_reactivate: &[&str],
462) -> anyhow::Result<usize> {
463    let mut applied = 0;
464    for (names, blocklisted, verb) in [
465        (to_deprecate, true, "deprecating"),
466        (to_reactivate, false, "reactivating"),
467    ] {
468        if names.is_empty() {
469            continue;
470        }
471        tracing::info!(attributes = ?names, "{verb} custom attributes");
472        client
473            .set_custom_attribute_blocklist(names, blocklisted)
474            .await
475            .with_context(|| format!("{verb} custom attributes"))?;
476        let n = names.len();
477        let past = if blocklisted {
478            "deprecated"
479        } else {
480            "reactivated"
481        };
482        eprintln!("  ✓ {past} {n} custom attribute(s)");
483        applied += n;
484    }
485
486    Ok(applied)
487}