Skip to main content

braze_sync/cli/
apply.rs

1//! `braze-sync apply` — push local intent to Braze.
2//!
3//! v0.1.0 supports Catalog Schema only (field add / field delete). The
4//! other resource kinds emit a "not yet implemented" warning.
5//!
6//! ## Safety chain
7//!
8//! Apply is the only command that mutates remote state, so it goes
9//! through a strict order of checks. Each check fails closed:
10//!
11//! 1. Recompute the diff (= the apply plan) using the same code path as
12//!    the [`super::diff`] command. They cannot disagree about what would
13//!    be applied.
14//! 2. Print the plan. The header line goes to stderr so the JSON output
15//!    on stdout stays parseable for CI consumers.
16//! 3. If `summary.changed_count() == 0` → "No changes" → exit 0.
17//! 4. If `--confirm` is **not** set → "DRY RUN" → exit 0. Zero write
18//!    calls reach Braze in this branch. Verified by integration tests
19//!    that mount a `method("POST")` mock with `.expect(0)`.
20//! 5. If `summary.destructive_count() > 0 && !args.allow_destructive` →
21//!    return [`Error::DestructiveBlocked`] which `cli::exit_code_for`
22//!    maps to exit code 6 per IMPLEMENTATION.md §7.1.
23//! 6. Pre-validate the plan against v0.1.0's known unsupported
24//!    operations (top-level catalog Added / Removed, field-level
25//!    Modified). This runs **before any API call** so we can never
26//!    leave Braze half-applied.
27//! 7. Apply each change. The loop uses `?`, so the first failure aborts
28//!    the rest — partial-apply is bad-by-default. Each call is logged
29//!    via `tracing::info!` with structured fields per §2.3 #4.
30
31use crate::braze::BrazeClient;
32use crate::config::ResolvedConfig;
33use crate::diff::catalog::CatalogSchemaDiff;
34use crate::diff::{DiffOp, DiffSummary, ResourceDiff};
35use crate::error::Error;
36use crate::format::OutputFormat;
37use crate::resource::ResourceKind;
38use anyhow::{anyhow, Context as _};
39use clap::Args;
40use std::path::Path;
41
42use super::diff::compute_catalog_schema_diffs;
43use super::{selected_kinds, warn_unimplemented};
44
45#[derive(Args, Debug)]
46pub struct ApplyArgs {
47    /// Limit apply to a specific resource kind.
48    #[arg(long, value_enum)]
49    pub resource: Option<ResourceKind>,
50
51    /// When `--resource` is given, optionally restrict to a single named
52    /// resource. Requires `--resource`.
53    #[arg(long, requires = "resource")]
54    pub name: Option<String>,
55
56    /// Actually apply changes. Without this, runs in dry-run mode and
57    /// makes zero write calls to Braze. This is the default for safety.
58    #[arg(long)]
59    pub confirm: bool,
60
61    /// Permit destructive operations (field deletes, etc.). Required in
62    /// addition to `--confirm` for any change that would lose data on
63    /// the Braze side.
64    #[arg(long)]
65    pub allow_destructive: bool,
66
67    /// Archive orphan Content Blocks / Email Templates by prefixing the
68    /// remote name with `[ARCHIVED-YYYY-MM-DD]`. Catalog Schema has no
69    /// orphans, so this flag is parsed but inert in v0.1.0; it lights up
70    /// in Phase B alongside the orphan-tracking resource kinds.
71    #[arg(long)]
72    pub archive_orphans: bool,
73}
74
75pub async fn run(
76    args: &ApplyArgs,
77    resolved: ResolvedConfig,
78    config_dir: &Path,
79    format: OutputFormat,
80) -> anyhow::Result<()> {
81    let catalogs_root = config_dir.join(&resolved.resources.catalog_schema.path);
82    let client = BrazeClient::from_resolved(&resolved);
83    let kinds = selected_kinds(args.resource, &resolved.resources);
84
85    let mut summary = DiffSummary::default();
86    for kind in kinds {
87        match kind {
88            ResourceKind::CatalogSchema => {
89                let diffs =
90                    compute_catalog_schema_diffs(&client, &catalogs_root, args.name.as_deref())
91                        .await
92                        .context("computing catalog_schema plan")?;
93                summary.diffs.extend(diffs);
94            }
95            other => warn_unimplemented(other),
96        }
97    }
98
99    let mode_label = if args.confirm {
100        "Plan:"
101    } else {
102        "Plan (dry-run, pass --confirm to apply):"
103    };
104    eprintln!("{mode_label}");
105    print!("{}", format.formatter().format(&summary));
106
107    if summary.changed_count() == 0 {
108        eprintln!("No changes to apply.");
109        return Ok(());
110    }
111
112    if !args.confirm {
113        eprintln!("DRY RUN — pass --confirm to apply these changes.");
114        return Ok(());
115    }
116
117    if summary.destructive_count() > 0 && !args.allow_destructive {
118        return Err(Error::DestructiveBlocked.into());
119    }
120
121    check_for_unsupported_ops(&summary)?;
122
123    let mut applied = 0;
124    for diff in &summary.diffs {
125        if let ResourceDiff::CatalogSchema(d) = diff {
126            applied += apply_catalog_schema(&client, d).await?;
127        }
128        // Other ResourceDiff variants are not yet implemented.
129    }
130
131    eprintln!("✓ Applied {applied} change(s).");
132    Ok(())
133}
134
135/// Walk the plan and reject anything v0.1.0 cannot actually do. Runs
136/// before any API call so a partial apply is impossible.
137fn check_for_unsupported_ops(summary: &DiffSummary) -> anyhow::Result<()> {
138    for diff in &summary.diffs {
139        if let ResourceDiff::CatalogSchema(d) = diff {
140            // Top-level catalog Added/Removed: not supported in v0.1.0.
141            // The §8.3 endpoint table only lists field-level POST/DELETE,
142            // not whole-catalog create/delete.
143            match &d.op {
144                DiffOp::Added(_) => {
145                    return Err(anyhow!(
146                        "creating a new catalog '{}' is not supported in v0.1.0; \
147                         create the catalog in the Braze dashboard first, then run \
148                         `braze-sync export` to populate the local schema",
149                        d.name
150                    ));
151                }
152                DiffOp::Removed(_) => {
153                    return Err(anyhow!(
154                        "deleting catalog '{}' (top-level) is not supported in v0.1.0; \
155                         only field-level changes can be applied",
156                        d.name
157                    ));
158                }
159                _ => {}
160            }
161            // Field-level Modified (type change): not supported. Auto
162            // delete-then-add is data-losing on the changed field, which
163            // we refuse to do silently. Document a manual workaround.
164            for fd in &d.field_diffs {
165                if let DiffOp::Modified { from, to } = fd {
166                    return Err(anyhow!(
167                        "modifying field '{}' on catalog '{}' (type {} → {}) \
168                         is not supported in v0.1.0; the change would be \
169                         data-losing on the field. Drop the field manually \
170                         in the Braze dashboard and re-run `braze-sync apply`",
171                        to.name,
172                        d.name,
173                        from.field_type.as_str(),
174                        to.field_type.as_str(),
175                    ));
176                }
177            }
178        }
179    }
180    Ok(())
181}
182
183async fn apply_catalog_schema(
184    client: &BrazeClient,
185    d: &CatalogSchemaDiff,
186) -> anyhow::Result<usize> {
187    let mut count = 0;
188    for fd in &d.field_diffs {
189        match fd {
190            DiffOp::Added(f) => {
191                tracing::info!(
192                    catalog = %d.name,
193                    field = %f.name,
194                    field_type = f.field_type.as_str(),
195                    "adding catalog field"
196                );
197                client.add_catalog_field(&d.name, f).await?;
198                count += 1;
199            }
200            DiffOp::Removed(f) => {
201                tracing::info!(
202                    catalog = %d.name,
203                    field = %f.name,
204                    "deleting catalog field"
205                );
206                client.delete_catalog_field(&d.name, &f.name).await?;
207                count += 1;
208            }
209            DiffOp::Modified { .. } => {
210                // Already rejected by check_for_unsupported_ops above.
211                // Defensive in case the validate step is ever bypassed.
212                return Err(anyhow!(
213                    "internal: Modified field op should have been rejected \
214                     by check_for_unsupported_ops"
215                ));
216            }
217            DiffOp::Unchanged => {}
218        }
219    }
220    Ok(count)
221}