1use 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 #[arg(long, value_enum)]
32 pub resource: Option<ResourceKind>,
33
34 #[arg(long, requires = "resource")]
37 pub name: Option<String>,
38
39 #[arg(long)]
42 pub confirm: bool,
43
44 #[arg(long)]
48 pub allow_destructive: bool,
49
50 #[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 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
146fn 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 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 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 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 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}