1use crate::braze::BrazeClient;
13use crate::config::{ApplyOrder, ResolvedConfig};
14use crate::diff::catalog::CatalogSchemaDiff;
15use crate::diff::content_block::{ContentBlockDiff, ContentBlockIdIndex};
16use crate::diff::content_block_order::reorder_content_block_diffs_by_dependency;
17use crate::diff::custom_attribute::CustomAttributeOp;
18use crate::diff::email_template::{EmailTemplateDiff, EmailTemplateIdIndex};
19use crate::diff::orphan;
20use crate::diff::plan::{self, PlanFile};
21use crate::diff::{DiffOp, DiffSummary, ResourceDiff};
22use crate::error::Error;
23use crate::format::OutputFormat;
24use crate::resource::ResourceKind;
25use crate::values::{compute_values_input_hashes, preflight_values, PreflightArgs};
26use anyhow::{anyhow, Context as _};
27use clap::Args;
28use std::path::{Path, PathBuf};
29
30use super::diff::{
31 compute_catalog_schema_diffs, compute_content_block_plan, compute_custom_attribute_diffs,
32 compute_email_template_plan, compute_tag_diffs,
33};
34use super::{selected_kinds, warn_if_name_excluded};
35
36#[derive(Args, Debug)]
37pub struct ApplyArgs {
38 #[arg(long, value_enum)]
40 pub resource: Option<ResourceKind>,
41
42 #[arg(long, requires = "resource")]
45 pub name: Option<String>,
46
47 #[arg(long)]
50 pub confirm: bool,
51
52 #[arg(long)]
56 pub allow_destructive: bool,
57
58 #[arg(long)]
62 pub archive_orphans: bool,
63
64 #[arg(long, value_name = "PATH")]
68 pub plan: Option<PathBuf>,
69}
70
71pub async fn run(
72 args: &ApplyArgs,
73 resolved: ResolvedConfig,
74 config_dir: &Path,
75 format: OutputFormat,
76) -> anyhow::Result<()> {
77 let catalogs_root = config_dir.join(&resolved.resources.catalog_schema.path);
78 let content_blocks_root = config_dir.join(&resolved.resources.content_block.path);
79 let email_templates_root = config_dir.join(&resolved.resources.email_template.path);
80 let custom_attributes_path = config_dir.join(&resolved.resources.custom_attribute.path);
81 let tags_path = config_dir.join(&resolved.resources.tag.path);
82 let client = BrazeClient::from_resolved(&resolved);
83 let kinds = selected_kinds(args.resource, &resolved.resources);
84
85 let saved_plan = if let Some(path) = &args.plan {
87 let plan = PlanFile::read_from(path)
88 .with_context(|| format!("reading plan file {}", path.display()))?;
89 check_plan_scope(&plan, &resolved.environment_name, args)?;
90 warn_on_plan_metadata(&plan);
91 Some(plan)
92 } else {
93 None
94 };
95
96 let values = preflight_values(PreflightArgs {
101 config_dir,
102 resolved: &resolved,
103 content_blocks_root: &content_blocks_root,
104 email_templates_root: &email_templates_root,
105 kinds: &kinds,
106 cb_name_filter: args
107 .name
108 .as_deref()
109 .filter(|_| args.resource == Some(ResourceKind::ContentBlock)),
110 et_name_filter: args
111 .name
112 .as_deref()
113 .filter(|_| args.resource == Some(ResourceKind::EmailTemplate)),
114 cb_excludes: resolved.excludes_for(ResourceKind::ContentBlock),
115 et_excludes: resolved.excludes_for(ResourceKind::EmailTemplate),
116 })?;
117
118 let mut summary = DiffSummary::default();
119 let mut content_block_id_index: Option<ContentBlockIdIndex> = None;
120 let mut email_template_id_index: Option<EmailTemplateIdIndex> = None;
121 for kind in &kinds {
122 let kind = *kind;
123 if warn_if_name_excluded(kind, args.name.as_deref(), resolved.excludes_for(kind)) {
124 continue;
125 }
126 match kind {
127 ResourceKind::CatalogSchema => {
128 let diffs = compute_catalog_schema_diffs(
129 &client,
130 &catalogs_root,
131 args.name.as_deref(),
132 resolved.excludes_for(ResourceKind::CatalogSchema),
133 )
134 .await
135 .context("computing catalog_schema plan")?;
136 summary.diffs.extend(diffs);
137 }
138 ResourceKind::ContentBlock => {
139 let (diffs, idx) = compute_content_block_plan(
140 &client,
141 &content_blocks_root,
142 args.name.as_deref(),
143 resolved.excludes_for(ResourceKind::ContentBlock),
144 values.as_ref(),
145 )
146 .await
147 .context("computing content_block plan")?;
148 summary.diffs.extend(diffs);
149 content_block_id_index = Some(idx);
150 }
151 ResourceKind::EmailTemplate => {
152 let (diffs, idx) = compute_email_template_plan(
153 &client,
154 &email_templates_root,
155 args.name.as_deref(),
156 resolved.excludes_for(ResourceKind::EmailTemplate),
157 values.as_ref(),
158 )
159 .await
160 .context("computing email_template plan")?;
161 summary.diffs.extend(diffs);
162 email_template_id_index = Some(idx);
163 }
164 ResourceKind::CustomAttribute => {
165 let diffs = compute_custom_attribute_diffs(
166 &client,
167 &custom_attributes_path,
168 args.name.as_deref(),
169 resolved.excludes_for(ResourceKind::CustomAttribute),
170 )
171 .await
172 .context("computing custom_attribute plan")?;
173 summary.diffs.extend(diffs);
174 }
175 ResourceKind::Tag => {
176 let diffs = compute_tag_diffs(
177 config_dir,
178 &resolved,
179 &tags_path,
180 args.name.as_deref(),
181 resolved.excludes_for(ResourceKind::Tag),
182 )
183 .context("computing tag plan")?;
184 summary.diffs.extend(diffs);
185 }
186 }
187 }
188
189 enforce_tag_preflight(config_dir, &resolved, &summary)?;
195
196 if matches!(
201 resolved.resources.content_block.apply_order,
202 ApplyOrder::Dependency
203 ) {
204 summary.diffs = reorder_content_block_diffs_by_dependency(std::mem::take(
205 &mut summary.diffs,
206 ))
207 .map_err(|cycle| {
208 anyhow!(
209 "content_block dependency cycle detected\n \
210 {cycle}\n \
211 resolve by removing one of these references and try again"
212 )
213 })?;
214 }
215
216 let mode_label = if args.confirm {
217 "Plan:"
218 } else {
219 "Plan (dry-run, pass --confirm to apply):"
220 };
221 eprintln!("{mode_label}");
222 print!("{}", format.formatter().format(&summary));
223
224 if let Some(saved) = &saved_plan {
226 check_plan_ops(saved, &summary)?;
227 let fresh_hashes = compute_values_input_hashes(
231 PreflightArgs {
232 config_dir,
233 resolved: &resolved,
234 content_blocks_root: &content_blocks_root,
235 email_templates_root: &email_templates_root,
236 kinds: &kinds,
244 cb_name_filter: args
245 .name
246 .as_deref()
247 .filter(|_| args.resource == Some(ResourceKind::ContentBlock)),
248 et_name_filter: args
249 .name
250 .as_deref()
251 .filter(|_| args.resource == Some(ResourceKind::EmailTemplate)),
252 cb_excludes: resolved.excludes_for(ResourceKind::ContentBlock),
253 et_excludes: resolved.excludes_for(ResourceKind::EmailTemplate),
254 },
255 values.as_ref(),
256 )?;
257 check_plan_values_hashes(saved, &fresh_hashes)?;
258 eprintln!(
259 "✓ Plan matches saved plan ({} op(s){}).",
260 saved.ops.len(),
261 if saved.values_input_hashes.is_empty() {
262 String::new()
263 } else {
264 format!(
265 ", {} values hash(es) verified",
266 saved.values_input_hashes.len()
267 )
268 }
269 );
270 }
271
272 if summary.actionable_count() == 0 {
273 if summary.changed_count() > 0 {
274 eprintln!(
275 "No actionable changes to apply \
276 (informational drift above can be reconciled with `export`)."
277 );
278 } else {
279 eprintln!("No changes to apply.");
280 }
281 return Ok(());
282 }
283
284 if !args.confirm {
285 eprintln!("DRY RUN — pass --confirm to apply these changes.");
286 return Ok(());
287 }
288
289 if summary.destructive_count() > 0 && !args.allow_destructive {
290 return Err(Error::DestructiveBlocked.into());
291 }
292
293 check_for_unsupported_ops(&summary)?;
294
295 let today = chrono::Utc::now().date_naive();
301
302 let mut applied = 0;
303 let mut ca_deprecate: Vec<&str> = Vec::new();
304 let mut ca_reactivate: Vec<&str> = Vec::new();
305 for diff in &summary.diffs {
306 match diff {
307 ResourceDiff::CatalogSchema(d) => {
308 applied += apply_catalog_schema(&client, d).await?;
309 }
310 ResourceDiff::ContentBlock(d) => {
311 applied += apply_content_block(
312 &client,
313 d,
314 content_block_id_index.as_ref(),
315 args.archive_orphans,
316 today,
317 )
318 .await?;
319 }
320 ResourceDiff::EmailTemplate(d) => {
321 applied += apply_email_template(
322 &client,
323 d,
324 email_template_id_index.as_ref(),
325 args.archive_orphans,
326 today,
327 )
328 .await?;
329 }
330 ResourceDiff::CustomAttribute(d) => {
331 if let CustomAttributeOp::DeprecationToggled { to, .. } = &d.op {
332 if *to {
333 ca_deprecate.push(&d.name);
334 } else {
335 ca_reactivate.push(&d.name);
336 }
337 }
338 }
339 ResourceDiff::Tag(_) => {}
342 }
343 }
344
345 if !ca_deprecate.is_empty() || !ca_reactivate.is_empty() {
346 applied += apply_custom_attribute_batch(&client, &ca_deprecate, &ca_reactivate).await?;
347 }
348
349 eprintln!("✓ Applied {applied} change(s).");
350 Ok(())
351}
352
353fn enforce_tag_preflight(
374 config_dir: &Path,
375 resolved: &ResolvedConfig,
376 summary: &DiffSummary,
377) -> anyhow::Result<()> {
378 if !resolved.resources.is_enabled(ResourceKind::Tag) {
379 return Ok(());
380 }
381 let tags_path = config_dir.join(&resolved.resources.tag.path);
382 let registry = match crate::fs::tag_io::load_registry(&tags_path)? {
383 Some(r) => r,
384 None => return Ok(()),
387 };
388 let excludes = resolved.excludes_for(ResourceKind::Tag);
389
390 let mut missing: std::collections::BTreeMap<String, Vec<String>> =
391 std::collections::BTreeMap::new();
392 for diff in &summary.diffs {
393 let (resource_label, tags) = match diff {
395 ResourceDiff::ContentBlock(d) => match &d.op {
396 DiffOp::Added(cb) => (format!("content_block '{}'", cb.name), &cb.tags),
397 DiffOp::Modified { to, .. } => (format!("content_block '{}'", to.name), &to.tags),
398 _ => continue,
399 },
400 ResourceDiff::EmailTemplate(d) => match &d.op {
401 DiffOp::Added(et) => (format!("email_template '{}'", et.name), &et.tags),
402 DiffOp::Modified { to, .. } => (format!("email_template '{}'", to.name), &to.tags),
403 _ => continue,
404 },
405 _ => continue,
406 };
407 for t in tags {
408 if crate::config::is_excluded(t, excludes) {
409 continue;
410 }
411 if !registry.contains(t) {
412 missing
413 .entry(t.clone())
414 .or_default()
415 .push(resource_label.clone());
416 }
417 }
418 }
419
420 if missing.is_empty() {
421 return Ok(());
422 }
423
424 let mut msg = String::from(
425 "tag pre-flight: refusing to apply — the following tags are referenced \
426 by resources that would be created/updated, but are not declared in \
427 tags/registry.yaml:\n",
428 );
429 for (tag, refs) in &missing {
430 msg.push_str(&format!(" • '{tag}' — referenced by:\n"));
431 for r in refs {
432 msg.push_str(&format!(" - {r}\n"));
433 }
434 }
435 msg.push_str(
436 "\nFix: create each missing tag in the Braze dashboard \
437 (Settings → Tags), then add it to tags/registry.yaml and re-run apply. \
438 Braze does not expose a tag creation API.",
439 );
440 Err(anyhow!(msg))
441}
442
443fn check_plan_scope(plan: &PlanFile, environment: &str, args: &ApplyArgs) -> anyhow::Result<()> {
445 if plan.version != plan::CURRENT_PLAN_VERSION {
446 return Err(anyhow!(
447 "plan file version {} is not supported by this binary \
448 (expected {}). Regenerate with `diff --plan-out`.",
449 plan.version,
450 plan::CURRENT_PLAN_VERSION,
451 ));
452 }
453 if plan.scope.environment != environment {
454 eprintln!(
455 "✗ plan drift: plan was generated for environment '{}' \
456 but apply targets '{}'",
457 plan.scope.environment, environment,
458 );
459 return Err(Error::PlanDrift.into());
460 }
461 if plan.scope.resource != args.resource {
462 eprintln!(
463 "✗ plan drift: plan scope --resource={:?} but apply --resource={:?}",
464 plan.scope.resource.map(|k| k.as_str()),
465 args.resource.map(|k| k.as_str()),
466 );
467 return Err(Error::PlanDrift.into());
468 }
469 if plan.scope.name != args.name {
470 eprintln!(
471 "✗ plan drift: plan scope --name={:?} but apply --name={:?}",
472 plan.scope.name, args.name,
473 );
474 return Err(Error::PlanDrift.into());
475 }
476 if plan.scope.archive_orphans != args.archive_orphans {
480 eprintln!(
481 "✗ plan drift: plan scope --archive-orphans={} but apply --archive-orphans={}",
482 plan.scope.archive_orphans, args.archive_orphans,
483 );
484 return Err(Error::PlanDrift.into());
485 }
486 Ok(())
487}
488
489fn warn_on_plan_metadata(plan: &PlanFile) {
490 let current = env!("CARGO_PKG_VERSION");
491 if plan.braze_sync_version != current {
492 eprintln!(
493 "⚠ plan was generated by braze-sync {} (current: {}). \
494 Proceeding — pass `diff --plan-out` again if op semantics changed.",
495 plan.braze_sync_version, current,
496 );
497 }
498 let age = chrono::Utc::now().signed_duration_since(plan.generated_at);
499 if age > plan::STALE_PLAN_WARN_THRESHOLD {
500 eprintln!(
501 "⚠ plan is {} hour(s) old — Braze may have drifted; consider \
502 regenerating with `diff --plan-out`.",
503 age.num_hours(),
504 );
505 }
506}
507
508fn check_plan_values_hashes(
517 saved: &PlanFile,
518 fresh: &std::collections::BTreeMap<String, String>,
519) -> anyhow::Result<()> {
520 if saved.values_input_hashes.is_empty() {
521 return Ok(());
522 }
523 let mut mismatches: Vec<String> = Vec::new();
524 let mut missing_in_fresh: Vec<String> = Vec::new();
525 for (key, saved_hash) in &saved.values_input_hashes {
526 match fresh.get(key) {
527 Some(fresh_hash) if fresh_hash == saved_hash => {}
528 Some(_) => mismatches.push(key.clone()),
529 None => missing_in_fresh.push(key.clone()),
530 }
531 }
532 let extra: Vec<String> = fresh
533 .keys()
534 .filter(|k| !saved.values_input_hashes.contains_key(*k))
535 .cloned()
536 .collect();
537
538 if mismatches.is_empty() && missing_in_fresh.is_empty() && extra.is_empty() {
539 return Ok(());
540 }
541
542 eprintln!("✗ plan drift: values inputs changed since plan was generated");
543 if !mismatches.is_empty() {
544 eprintln!(" consumed values changed for:");
545 for k in &mismatches {
546 eprintln!(" - {k}");
547 }
548 if mismatches.len() > 1 {
552 eprintln!(
553 " hint: {} resources changed simultaneously — likely a `globals.custom.<key>` \
554 edit that affects every consumer (RFC §4 Phase 6)",
555 mismatches.len()
556 );
557 }
558 }
559 if !missing_in_fresh.is_empty() {
560 eprintln!(" resources in saved plan but no fresh hash (placeholders removed?):");
561 for k in &missing_in_fresh {
562 eprintln!(" - {k}");
563 }
564 }
565 if !extra.is_empty() {
566 eprintln!(" resources with fresh hashes not in saved plan (placeholders added?):");
567 for k in &extra {
568 eprintln!(" - {k}");
569 }
570 }
571 eprintln!(" → regenerate with `diff --plan-out` and review before re-applying");
572 Err(Error::PlanDrift.into())
573}
574
575fn check_plan_ops(saved: &PlanFile, fresh: &DiffSummary) -> anyhow::Result<()> {
578 let fresh_ops = plan::collect_ops(fresh);
579 let diff = saved.diff_ops(&fresh_ops);
580 if diff.is_match() {
581 return Ok(());
582 }
583 eprintln!("✗ plan drift: saved plan and fresh plan differ");
584 if !diff.missing.is_empty() {
585 eprintln!(" ops in saved plan but not in fresh plan (resolved or absorbed remotely):");
586 for op in &diff.missing {
587 eprintln!(" - {} '{}' [{:?}]", op.kind.as_str(), op.name, op.op);
588 }
589 }
590 if !diff.extra.is_empty() {
591 eprintln!(" ops in fresh plan but not in saved plan (new drift since plan):");
592 for op in &diff.extra {
593 eprintln!(" - {} '{}' [{:?}]", op.kind.as_str(), op.name, op.op);
594 }
595 }
596 eprintln!(" → regenerate with `diff --plan-out` and review before re-applying");
597 Err(Error::PlanDrift.into())
598}
599
600fn check_for_unsupported_ops(summary: &DiffSummary) -> anyhow::Result<()> {
601 for diff in &summary.diffs {
602 if let ResourceDiff::CatalogSchema(d) = diff {
603 for fd in &d.field_diffs {
606 if let DiffOp::Modified { from, to } = fd {
607 return Err(anyhow!(
608 "modifying field '{}' on catalog '{}' (type {} → {}) \
609 is not supported by braze-sync; the change would be \
610 data-losing on the field. Drop the field manually \
611 in the Braze dashboard and re-run `braze-sync apply`",
612 to.name,
613 d.name,
614 from.field_type.as_str(),
615 to.field_type.as_str(),
616 ));
617 }
618 }
619 }
620 }
621 Ok(())
622}
623
624async fn apply_content_block(
625 client: &BrazeClient,
626 d: &ContentBlockDiff,
627 id_index: Option<&ContentBlockIdIndex>,
628 archive_orphans: bool,
629 today: chrono::NaiveDate,
630) -> anyhow::Result<usize> {
631 if d.orphan {
634 if !archive_orphans {
635 return Ok(0);
636 }
637 let id_index = id_index.ok_or_else(|| {
638 anyhow!("internal: content_block id index missing for orphan apply path")
639 })?;
640 let id = id_index.get(&d.name).ok_or_else(|| {
641 anyhow!(
642 "internal: orphan '{}' missing from id index — list/diff drift",
643 d.name
644 )
645 })?;
646 let archived = orphan::archive_name(today, &d.name);
647 if archived == d.name {
648 return Ok(0);
649 }
650 let mut cb = client
657 .get_content_block(id)
658 .await
659 .with_context(|| format!("fetching content block '{}' for archive rename", d.name))?;
660 cb.name = archived;
661 tracing::info!(
662 content_block = %d.name,
663 new_name = %cb.name,
664 "archiving orphan content block"
665 );
666 client.update_content_block(id, &cb).await?;
667 return Ok(1);
668 }
669
670 match &d.op {
671 DiffOp::Added(cb) => {
672 tracing::info!(content_block = %cb.name, "creating content block");
673 let _ = client.create_content_block(cb).await?;
674 Ok(1)
675 }
676 DiffOp::Modified { to, .. } => {
677 let id_index = id_index.ok_or_else(|| {
678 anyhow!("internal: content_block id index missing for modified apply path")
679 })?;
680 let id = id_index.get(&to.name).ok_or_else(|| {
681 anyhow!(
682 "internal: modified content block '{}' missing from id index",
683 to.name
684 )
685 })?;
686 tracing::info!(content_block = %to.name, "updating content block");
687 client.update_content_block(id, to).await?;
688 Ok(1)
689 }
690 DiffOp::Removed(_) => {
693 unreachable!("diff layer routes content block removals through orphan")
694 }
695 DiffOp::Unchanged => Ok(0),
696 }
697}
698
699async fn apply_catalog_schema(
700 client: &BrazeClient,
701 d: &CatalogSchemaDiff,
702) -> anyhow::Result<usize> {
703 if let DiffOp::Added(cat) = &d.op {
706 tracing::info!(catalog = %d.name, "creating new catalog");
707 client
708 .create_catalog(cat)
709 .await
710 .with_context(|| format!("creating catalog '{}'", d.name))?;
711 return Ok(1);
712 }
713
714 if let DiffOp::Removed(_) = &d.op {
718 tracing::info!(catalog = %d.name, "deleting catalog");
719 client
720 .delete_catalog(&d.name)
721 .await
722 .with_context(|| format!("deleting catalog '{}'", d.name))?;
723 return Ok(1);
724 }
725
726 let mut count = 0;
727 for fd in &d.field_diffs {
728 match fd {
729 DiffOp::Added(f) => {
730 tracing::info!(
731 catalog = %d.name,
732 field = %f.name,
733 field_type = f.field_type.as_str(),
734 "adding catalog field"
735 );
736 client.add_catalog_field(&d.name, f).await?;
737 count += 1;
738 }
739 DiffOp::Removed(f) => {
740 tracing::info!(
741 catalog = %d.name,
742 field = %f.name,
743 "deleting catalog field"
744 );
745 client.delete_catalog_field(&d.name, &f.name).await?;
746 count += 1;
747 }
748 DiffOp::Modified { .. } => {
749 return Err(anyhow!(
750 "internal: Modified field op should have been rejected \
751 by check_for_unsupported_ops"
752 ));
753 }
754 DiffOp::Unchanged => {}
755 }
756 }
757 Ok(count)
758}
759
760async fn apply_email_template(
761 client: &BrazeClient,
762 d: &EmailTemplateDiff,
763 id_index: Option<&EmailTemplateIdIndex>,
764 archive_orphans: bool,
765 today: chrono::NaiveDate,
766) -> anyhow::Result<usize> {
767 if d.orphan {
768 if !archive_orphans {
769 return Ok(0);
770 }
771 let id_index = id_index.ok_or_else(|| {
772 anyhow!("internal: email_template id index missing for orphan apply path")
773 })?;
774 let id = id_index.get(&d.name).ok_or_else(|| {
775 anyhow!(
776 "internal: orphan '{}' missing from id index — list/diff drift",
777 d.name
778 )
779 })?;
780 let archived = orphan::archive_name(today, &d.name);
781 if archived == d.name {
782 return Ok(0);
783 }
784 let mut et = client
785 .get_email_template(id)
786 .await
787 .with_context(|| format!("fetching email template '{}' for archive rename", d.name))?;
788 et.name = archived;
789 tracing::info!(
790 email_template = %d.name,
791 new_name = %et.name,
792 "archiving orphan email template"
793 );
794 client.update_email_template(id, &et).await?;
795 return Ok(1);
796 }
797
798 match &d.op {
799 DiffOp::Added(et) => {
800 tracing::info!(email_template = %et.name, "creating email template");
801 let _ = client.create_email_template(et).await?;
802 Ok(1)
803 }
804 DiffOp::Modified { to, .. } => {
805 let id_index = id_index.ok_or_else(|| {
806 anyhow!("internal: email_template id index missing for modified apply path")
807 })?;
808 let id = id_index.get(&to.name).ok_or_else(|| {
809 anyhow!(
810 "internal: modified email template '{}' missing from id index",
811 to.name
812 )
813 })?;
814 tracing::info!(email_template = %to.name, "updating email template");
815 client.update_email_template(id, to).await?;
816 Ok(1)
817 }
818 DiffOp::Removed(_) => {
819 unreachable!("diff layer routes email template removals through orphan")
820 }
821 DiffOp::Unchanged => Ok(0),
822 }
823}
824
825async fn apply_custom_attribute_batch(
829 client: &BrazeClient,
830 to_deprecate: &[&str],
831 to_reactivate: &[&str],
832) -> anyhow::Result<usize> {
833 let mut applied = 0;
834 for (names, blocklisted, verb) in [
835 (to_deprecate, true, "deprecating"),
836 (to_reactivate, false, "reactivating"),
837 ] {
838 if names.is_empty() {
839 continue;
840 }
841 tracing::info!(attributes = ?names, "{verb} custom attributes");
842 client
843 .set_custom_attribute_blocklist(names, blocklisted)
844 .await
845 .with_context(|| format!("{verb} custom attributes"))?;
846 let n = names.len();
847 let past = if blocklisted {
848 "deprecated"
849 } else {
850 "reactivated"
851 };
852 eprintln!(" ✓ {past} {n} custom attribute(s)");
853 applied += n;
854 }
855
856 Ok(applied)
857}