1use 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 #[arg(long, value_enum)]
35 pub resource: Option<ResourceKind>,
36
37 #[arg(long, requires = "resource")]
40 pub name: Option<String>,
41
42 #[arg(long)]
45 pub confirm: bool,
46
47 #[arg(long)]
51 pub allow_destructive: bool,
52
53 #[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 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
172fn 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 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 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 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 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}