1use crate::braze::error::BrazeApiError;
4use crate::braze::BrazeClient;
5use crate::config::{is_excluded, ResolvedConfig};
6use crate::fs::{catalog_io, content_block_io, custom_attribute_io, email_template_io, tag_io};
7use crate::resource::{
8 ContentBlock, CustomAttributeRegistry, EmailTemplate, ResourceKind, Tag, TagRegistry,
9};
10use crate::values::{
11 extract_placeholders, refresh_content_block_values, refresh_email_template_values,
12 values_file_path, ExportUpdates, ValuesFile,
13};
14use anyhow::Context as _;
15use clap::Args;
16use futures::stream::{StreamExt, TryStreamExt};
17use regex_lite::Regex;
18use std::collections::BTreeSet;
19use std::path::Path;
20
21use super::{selected_kinds, warn_if_name_excluded, FETCH_CONCURRENCY};
22
23#[derive(Args, Debug, Default)]
24pub struct ExportArgs {
25 #[arg(long, value_enum)]
28 pub resource: Option<ResourceKind>,
29
30 #[arg(long, requires = "resource")]
33 pub name: Option<String>,
34}
35
36pub async fn run(
37 args: &ExportArgs,
38 resolved: ResolvedConfig,
39 config_dir: &Path,
40) -> anyhow::Result<()> {
41 let catalogs_root = config_dir.join(&resolved.resources.catalog_schema.path);
42 let content_blocks_root = config_dir.join(&resolved.resources.content_block.path);
43 let email_templates_root = config_dir.join(&resolved.resources.email_template.path);
44 let custom_attributes_path = config_dir.join(&resolved.resources.custom_attribute.path);
45 let tags_path = config_dir.join(&resolved.resources.tag.path);
46 let client = BrazeClient::from_resolved(&resolved);
47 let kinds = selected_kinds(args.resource, &resolved.resources);
48
49 let values_path = values_file_path(config_dir, &resolved);
53 let needs_values = kinds
59 .iter()
60 .any(|k| matches!(k, ResourceKind::ContentBlock | ResourceKind::EmailTemplate));
61 let mut values = if needs_values && values_path.exists() {
62 ValuesFile::load(&values_path)?
63 } else {
64 ValuesFile {
65 version: crate::values::schema::SUPPORTED_VERSION,
66 ..Default::default()
67 }
68 };
69 let mut all_updates = ExportUpdates::default();
70
71 let mut total_written: usize = 0;
72 for kind in kinds {
73 if !matches!(kind, ResourceKind::CustomAttribute | ResourceKind::Tag)
77 && warn_if_name_excluded(kind, args.name.as_deref(), resolved.excludes_for(kind))
78 {
79 continue;
80 }
81 match kind {
82 ResourceKind::CatalogSchema => {
83 let n = export_catalog_schemas(
84 &client,
85 &catalogs_root,
86 args.name.as_deref(),
87 resolved.excludes_for(ResourceKind::CatalogSchema),
88 )
89 .await
90 .context("exporting catalog_schema")?;
91 eprintln!("✓ catalog_schema: exported {n} resource(s)");
92 total_written += n;
93 }
94 ResourceKind::ContentBlock => {
95 let (n, updates) = export_content_blocks(
96 &client,
97 &content_blocks_root,
98 args.name.as_deref(),
99 resolved.excludes_for(ResourceKind::ContentBlock),
100 &mut values,
101 )
102 .await
103 .context("exporting content_block")?;
104 all_updates.merge(updates);
105 eprintln!("✓ content_block: exported {n} resource(s)");
106 total_written += n;
107 }
108 ResourceKind::EmailTemplate => {
109 let (n, updates) = export_email_templates(
110 &client,
111 &email_templates_root,
112 args.name.as_deref(),
113 resolved.excludes_for(ResourceKind::EmailTemplate),
114 &mut values,
115 )
116 .await
117 .context("exporting email_template")?;
118 all_updates.merge(updates);
119 eprintln!("✓ email_template: exported {n} resource(s)");
120 total_written += n;
121 }
122 ResourceKind::CustomAttribute => {
123 if args.name.is_some() {
124 eprintln!(
125 "⚠ custom_attribute: --name is not supported for export \
126 (the registry is a single file); exporting all attributes"
127 );
128 }
129 let n = export_custom_attributes(
130 &client,
131 &custom_attributes_path,
132 resolved.excludes_for(ResourceKind::CustomAttribute),
133 )
134 .await
135 .context("exporting custom_attribute")?;
136 eprintln!("✓ custom_attribute: exported {n} attribute(s)");
137 total_written += n;
138 }
139 ResourceKind::Tag => {
140 if args.name.is_some() {
141 eprintln!(
142 "⚠ tag: --name is not supported for export \
143 (the registry is a single file); exporting all tags"
144 );
145 }
146 let n = export_tags(
147 config_dir,
148 &resolved,
149 &tags_path,
150 resolved.excludes_for(ResourceKind::Tag),
151 )
152 .context("exporting tag")?;
153 eprintln!("✓ tag: exported {n} tag(s)");
154 total_written += n;
155 }
156 }
157 }
158
159 if all_updates.lid_updates + all_updates.cb_id_updates > 0 {
164 values.save(&values_path)?;
165 eprintln!(
166 "✓ values: refreshed {} lid + {} cb_id entry(ies) in {}",
167 all_updates.lid_updates,
168 all_updates.cb_id_updates,
169 values_path.display()
170 );
171 }
172 for w in &all_updates.orphan_warnings {
173 eprintln!("⚠ {w}");
174 }
175 for w in &all_updates.missing_entry_warnings {
176 eprintln!("⚠ {w}");
177 }
178 for w in &all_updates.ambiguity_warnings {
179 eprintln!("⚠ {w}");
180 }
181
182 eprintln!("done: {total_written} resource(s) written");
183 Ok(())
184}
185
186async fn export_catalog_schemas(
187 client: &BrazeClient,
188 catalogs_root: &Path,
189 name_filter: Option<&str>,
190 excludes: &[Regex],
191) -> anyhow::Result<usize> {
192 let catalogs = match name_filter {
193 Some(name) => match client.get_catalog(name).await {
194 Ok(c) => vec![c],
195 Err(BrazeApiError::NotFound { .. }) => {
197 eprintln!("⚠ catalog_schema: '{name}' not found in Braze");
198 Vec::new()
199 }
200 Err(e) => return Err(e.into()),
201 },
202 None => client.list_catalogs().await?,
203 };
204
205 let filtered: Vec<_> = catalogs
206 .into_iter()
207 .filter(|c| !is_excluded(&c.name, excludes))
208 .collect();
209 let count = filtered.len();
210 for cat in filtered {
211 catalog_io::save_schema(catalogs_root, &cat)?;
212 }
213 Ok(count)
214}
215
216async fn export_content_blocks(
225 client: &BrazeClient,
226 content_blocks_root: &Path,
227 name_filter: Option<&str>,
228 excludes: &[Regex],
229 values: &mut ValuesFile,
230) -> anyhow::Result<(usize, ExportUpdates)> {
231 let summaries = client.list_content_blocks().await?;
232 let targets: Vec<_> = summaries
233 .into_iter()
234 .filter(|s| name_filter.is_none_or(|n| s.name == n))
235 .filter(|s| !is_excluded(&s.name, excludes))
236 .collect();
237
238 if targets.is_empty() {
239 if let Some(name) = name_filter {
240 eprintln!("⚠ content_block: '{name}' not found in Braze");
241 }
242 return Ok((0, ExportUpdates::default()));
243 }
244
245 let blocks: Vec<ContentBlock> = futures::stream::iter(targets.iter().map(|s| {
246 let name = s.name.as_str();
247 let id = s.content_block_id.as_str();
248 async move {
249 client
250 .get_content_block(id)
251 .await
252 .with_context(|| format!("fetching content block '{name}'"))
253 }
254 }))
255 .buffer_unordered(FETCH_CONCURRENCY)
256 .try_collect()
257 .await?;
258
259 let mut updates = ExportUpdates::default();
260 for remote in &blocks {
261 let local_path = content_blocks_root.join(format!("{}.liquid", remote.name));
262 let local = if local_path.exists() {
263 Some(content_block_io::read_content_block_file(&local_path)?)
264 } else {
265 None
266 };
267
268 let mut to_save = remote.clone();
269 if let Some(local) = local.as_ref() {
270 if !extract_placeholders(&local.content).is_empty() {
275 let report = refresh_content_block_values(local, remote, values);
276 updates.merge(report);
277 to_save.content = local.content.clone();
280 }
281 }
282 content_block_io::save_content_block(content_blocks_root, &to_save)?;
283 }
284 Ok((blocks.len(), updates))
285}
286
287async fn export_email_templates(
293 client: &BrazeClient,
294 email_templates_root: &Path,
295 name_filter: Option<&str>,
296 excludes: &[Regex],
297 values: &mut ValuesFile,
298) -> anyhow::Result<(usize, ExportUpdates)> {
299 let summaries = client.list_email_templates().await?;
300 let targets: Vec<_> = summaries
301 .into_iter()
302 .filter(|s| name_filter.is_none_or(|n| s.name == n))
303 .filter(|s| !is_excluded(&s.name, excludes))
304 .collect();
305
306 if targets.is_empty() {
307 if let Some(name) = name_filter {
308 eprintln!("⚠ email_template: '{name}' not found in Braze");
309 }
310 return Ok((0, ExportUpdates::default()));
311 }
312
313 let templates: Vec<EmailTemplate> = futures::stream::iter(targets.iter().map(|s| {
314 let name = s.name.as_str();
315 let id = s.email_template_id.as_str();
316 async move {
317 client
318 .get_email_template(id)
319 .await
320 .with_context(|| format!("fetching email template '{name}'"))
321 }
322 }))
323 .buffer_unordered(FETCH_CONCURRENCY)
324 .try_collect()
325 .await?;
326
327 let mut updates = ExportUpdates::default();
328 for remote in &templates {
329 let local_dir = email_templates_root.join(&remote.name);
330 let local = if local_dir.is_dir() {
331 Some(email_template_io::read_email_template_dir(&local_dir)?)
332 } else {
333 None
334 };
335
336 let mut to_save = remote.clone();
337 if let Some(local) = local.as_ref() {
338 let subject_has = !extract_placeholders(&local.subject).is_empty();
341 let body_html_has = !extract_placeholders(&local.body_html).is_empty();
342 let body_plain_has = !extract_placeholders(&local.body_plaintext).is_empty();
343 let preheader_has = local
344 .preheader
345 .as_deref()
346 .is_some_and(|p| !extract_placeholders(p).is_empty());
347 if subject_has || body_html_has || body_plain_has || preheader_has {
348 let report = refresh_email_template_values(local, remote, values);
349 updates.merge(report);
350 if subject_has {
354 to_save.subject = local.subject.clone();
355 }
356 if body_html_has {
357 to_save.body_html = local.body_html.clone();
358 }
359 if body_plain_has {
360 to_save.body_plaintext = local.body_plaintext.clone();
361 }
362 if preheader_has {
363 to_save.preheader = local.preheader.clone();
364 }
365 }
366 }
367 email_template_io::save_email_template(email_templates_root, &to_save)?;
368 }
369 Ok((templates.len(), updates))
370}
371
372fn export_tags(
382 config_dir: &Path,
383 resolved: &ResolvedConfig,
384 registry_path: &Path,
385 excludes: &[Regex],
386) -> anyhow::Result<usize> {
387 let referenced = collect_local_tag_references(config_dir, resolved)?;
388 let tags: Vec<Tag> = referenced
389 .into_iter()
390 .filter(|name| !is_excluded(name, excludes))
391 .map(|name| Tag {
392 name,
393 description: None,
394 })
395 .collect();
396 let count = tags.len();
397 let registry = TagRegistry { tags };
398 tag_io::save_registry(registry_path, ®istry)?;
399 Ok(count)
400}
401
402pub(crate) fn collect_local_tag_references(
407 config_dir: &Path,
408 resolved: &ResolvedConfig,
409) -> anyhow::Result<BTreeSet<String>> {
410 let mut tags: BTreeSet<String> = BTreeSet::new();
411
412 if resolved.resources.content_block.enabled {
413 let root = config_dir.join(&resolved.resources.content_block.path);
414 let blocks = content_block_io::load_all_content_blocks(&root)
415 .context("loading local content_blocks for tag aggregation")?;
416 for cb in &blocks {
417 for t in &cb.tags {
418 tags.insert(t.clone());
419 }
420 }
421 }
422
423 if resolved.resources.email_template.enabled {
424 let root = config_dir.join(&resolved.resources.email_template.path);
425 let templates = crate::fs::email_template_io::load_all_email_templates(&root)
426 .context("loading local email_templates for tag aggregation")?;
427 for et in &templates {
428 for t in &et.tags {
429 tags.insert(t.clone());
430 }
431 }
432 }
433
434 Ok(tags)
435}
436
437async fn export_custom_attributes(
438 client: &BrazeClient,
439 registry_path: &Path,
440 excludes: &[Regex],
441) -> anyhow::Result<usize> {
442 let attrs: Vec<_> = client
443 .list_custom_attributes()
444 .await?
445 .into_iter()
446 .filter(|a| !is_excluded(&a.name, excludes))
447 .collect();
448 let count = attrs.len();
449 let registry = CustomAttributeRegistry { attributes: attrs };
450 custom_attribute_io::save_registry(registry_path, ®istry)?;
451 Ok(count)
452}