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