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