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}