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::has_placeholders;
11use anyhow::Context as _;
12use clap::Args;
13use futures::stream::{StreamExt, TryStreamExt};
14use regex_lite::Regex;
15use std::collections::BTreeSet;
16use std::path::Path;
17
18use super::{selected_kinds, warn_if_name_excluded, FETCH_CONCURRENCY};
19
20#[derive(Args, Debug, Default)]
21pub struct ExportArgs {
22 #[arg(long, value_enum)]
25 pub resource: Option<ResourceKind>,
26
27 #[arg(long, requires = "resource")]
30 pub name: Option<String>,
31}
32
33pub async fn run(
34 args: &ExportArgs,
35 resolved: ResolvedConfig,
36 config_dir: &Path,
37) -> anyhow::Result<()> {
38 let catalogs_root = config_dir.join(&resolved.resources.catalog_schema.path);
39 let content_blocks_root = config_dir.join(&resolved.resources.content_block.path);
40 let email_templates_root = config_dir.join(&resolved.resources.email_template.path);
41 let custom_attributes_path = config_dir.join(&resolved.resources.custom_attribute.path);
42 let tags_path = config_dir.join(&resolved.resources.tag.path);
43 let client = BrazeClient::from_resolved(&resolved);
44 let kinds = selected_kinds(args.resource, &resolved.resources);
45
46 let mut total_written: usize = 0;
47 for kind in kinds {
48 if !matches!(kind, ResourceKind::CustomAttribute | ResourceKind::Tag)
52 && warn_if_name_excluded(kind, args.name.as_deref(), resolved.excludes_for(kind))
53 {
54 continue;
55 }
56 match kind {
57 ResourceKind::CatalogSchema => {
58 let n = export_catalog_schemas(
59 &client,
60 &catalogs_root,
61 args.name.as_deref(),
62 resolved.excludes_for(ResourceKind::CatalogSchema),
63 )
64 .await
65 .context("exporting catalog_schema")?;
66 eprintln!("✓ catalog_schema: exported {n} resource(s)");
67 total_written += n;
68 }
69 ResourceKind::ContentBlock => {
70 let n = export_content_blocks(
71 &client,
72 &content_blocks_root,
73 args.name.as_deref(),
74 resolved.excludes_for(ResourceKind::ContentBlock),
75 )
76 .await
77 .context("exporting content_block")?;
78 eprintln!("✓ content_block: exported {n} resource(s)");
79 total_written += n;
80 }
81 ResourceKind::EmailTemplate => {
82 let n = export_email_templates(
83 &client,
84 &email_templates_root,
85 args.name.as_deref(),
86 resolved.excludes_for(ResourceKind::EmailTemplate),
87 )
88 .await
89 .context("exporting email_template")?;
90 eprintln!("✓ email_template: exported {n} resource(s)");
91 total_written += n;
92 }
93 ResourceKind::CustomAttribute => {
94 if args.name.is_some() {
95 eprintln!(
96 "⚠ custom_attribute: --name is not supported for export \
97 (the registry is a single file); exporting all attributes"
98 );
99 }
100 let n = export_custom_attributes(
101 &client,
102 &custom_attributes_path,
103 resolved.excludes_for(ResourceKind::CustomAttribute),
104 )
105 .await
106 .context("exporting custom_attribute")?;
107 eprintln!("✓ custom_attribute: exported {n} attribute(s)");
108 total_written += n;
109 }
110 ResourceKind::Tag => {
111 if args.name.is_some() {
112 eprintln!(
113 "⚠ tag: --name is not supported for export \
114 (the registry is a single file); exporting all tags"
115 );
116 }
117 let n = export_tags(
118 config_dir,
119 &resolved,
120 &tags_path,
121 resolved.excludes_for(ResourceKind::Tag),
122 )
123 .context("exporting tag")?;
124 eprintln!("✓ tag: exported {n} tag(s)");
125 total_written += n;
126 }
127 }
128 }
129
130 eprintln!("done: {total_written} resource(s) written");
131 Ok(())
132}
133
134async fn export_catalog_schemas(
135 client: &BrazeClient,
136 catalogs_root: &Path,
137 name_filter: Option<&str>,
138 excludes: &[Regex],
139) -> anyhow::Result<usize> {
140 let catalogs = match name_filter {
141 Some(name) => match client.get_catalog(name).await {
142 Ok(c) => vec![c],
143 Err(BrazeApiError::NotFound { .. }) => {
145 eprintln!("⚠ catalog_schema: '{name}' not found in Braze");
146 Vec::new()
147 }
148 Err(e) => return Err(e.into()),
149 },
150 None => client.list_catalogs().await?,
151 };
152
153 let filtered: Vec<_> = catalogs
154 .into_iter()
155 .filter(|c| !is_excluded(&c.name, excludes))
156 .collect();
157 let count = filtered.len();
158 for cat in filtered {
159 catalog_io::save_schema(catalogs_root, &cat)?;
160 }
161 Ok(count)
162}
163
164async fn export_content_blocks(
173 client: &BrazeClient,
174 content_blocks_root: &Path,
175 name_filter: Option<&str>,
176 excludes: &[Regex],
177) -> anyhow::Result<usize> {
178 let summaries = client.list_content_blocks().await?;
179 let targets: Vec<_> = summaries
180 .into_iter()
181 .filter(|s| name_filter.is_none_or(|n| s.name == n))
182 .filter(|s| !is_excluded(&s.name, excludes))
183 .collect();
184
185 if targets.is_empty() {
186 if let Some(name) = name_filter {
187 eprintln!("⚠ content_block: '{name}' not found in Braze");
188 }
189 return Ok(0);
190 }
191
192 let blocks: Vec<ContentBlock> = futures::stream::iter(targets.iter().map(|s| {
193 let name = s.name.as_str();
194 let id = s.content_block_id.as_str();
195 async move {
196 client
197 .get_content_block(id)
198 .await
199 .with_context(|| format!("fetching content block '{name}'"))
200 }
201 }))
202 .buffer_unordered(FETCH_CONCURRENCY)
203 .try_collect()
204 .await?;
205
206 for remote in &blocks {
207 let local_path = content_blocks_root.join(format!("{}.liquid", remote.name));
208 let local = if local_path.exists() {
209 Some(content_block_io::read_content_block_file(&local_path)?)
210 } else {
211 None
212 };
213 let mut to_save = remote.clone();
214 if let Some(local) = local.as_ref() {
215 if has_placeholders(&local.content) {
216 to_save.content = local.content.clone();
217 }
218 }
219 content_block_io::save_content_block(content_blocks_root, &to_save)?;
220 }
221 Ok(blocks.len())
222}
223
224async fn export_email_templates(
230 client: &BrazeClient,
231 email_templates_root: &Path,
232 name_filter: Option<&str>,
233 excludes: &[Regex],
234) -> anyhow::Result<usize> {
235 let summaries = client.list_email_templates().await?;
236 let targets: Vec<_> = summaries
237 .into_iter()
238 .filter(|s| name_filter.is_none_or(|n| s.name == n))
239 .filter(|s| !is_excluded(&s.name, excludes))
240 .collect();
241
242 if targets.is_empty() {
243 if let Some(name) = name_filter {
244 eprintln!("⚠ email_template: '{name}' not found in Braze");
245 }
246 return Ok(0);
247 }
248
249 let templates: Vec<EmailTemplate> = futures::stream::iter(targets.iter().map(|s| {
250 let name = s.name.as_str();
251 let id = s.email_template_id.as_str();
252 async move {
253 client
254 .get_email_template(id)
255 .await
256 .with_context(|| format!("fetching email template '{name}'"))
257 }
258 }))
259 .buffer_unordered(FETCH_CONCURRENCY)
260 .try_collect()
261 .await?;
262
263 for remote in &templates {
264 let local_dir = email_templates_root.join(&remote.name);
265 let local = if local_dir.is_dir() {
266 Some(email_template_io::read_email_template_dir(&local_dir)?)
267 } else {
268 None
269 };
270 let mut to_save = remote.clone();
271 if let Some(local) = local.as_ref() {
272 let subject_has = has_placeholders(&local.subject);
273 let body_html_has = has_placeholders(&local.body_html);
274 let body_plain_has = has_placeholders(&local.body_plaintext);
275 let preheader_has = local.preheader.as_deref().is_some_and(has_placeholders);
276 if subject_has {
277 to_save.subject = local.subject.clone();
278 }
279 if body_html_has {
280 to_save.body_html = local.body_html.clone();
281 }
282 if body_plain_has {
283 to_save.body_plaintext = local.body_plaintext.clone();
284 }
285 if preheader_has {
286 to_save.preheader = local.preheader.clone();
287 }
288 }
289 email_template_io::save_email_template(email_templates_root, &to_save)?;
290 }
291 Ok(templates.len())
292}
293
294fn export_tags(
304 config_dir: &Path,
305 resolved: &ResolvedConfig,
306 registry_path: &Path,
307 excludes: &[Regex],
308) -> anyhow::Result<usize> {
309 let referenced = collect_local_tag_references(config_dir, resolved)?;
310 let tags: Vec<Tag> = referenced
311 .into_iter()
312 .filter(|name| !is_excluded(name, excludes))
313 .map(|name| Tag {
314 name,
315 description: None,
316 })
317 .collect();
318 let count = tags.len();
319 let registry = TagRegistry { tags };
320 tag_io::save_registry(registry_path, ®istry)?;
321 Ok(count)
322}
323
324pub(crate) fn collect_local_tag_references(
329 config_dir: &Path,
330 resolved: &ResolvedConfig,
331) -> anyhow::Result<BTreeSet<String>> {
332 let mut tags: BTreeSet<String> = BTreeSet::new();
333
334 if resolved.resources.content_block.enabled {
335 let root = config_dir.join(&resolved.resources.content_block.path);
336 let blocks = content_block_io::load_all_content_blocks(&root)
337 .context("loading local content_blocks for tag aggregation")?;
338 for cb in &blocks {
339 for t in &cb.tags {
340 tags.insert(t.clone());
341 }
342 }
343 }
344
345 if resolved.resources.email_template.enabled {
346 let root = config_dir.join(&resolved.resources.email_template.path);
347 let templates = crate::fs::email_template_io::load_all_email_templates(&root)
348 .context("loading local email_templates for tag aggregation")?;
349 for et in &templates {
350 for t in &et.tags {
351 tags.insert(t.clone());
352 }
353 }
354 }
355
356 Ok(tags)
357}
358
359async fn export_custom_attributes(
360 client: &BrazeClient,
361 registry_path: &Path,
362 excludes: &[Regex],
363) -> anyhow::Result<usize> {
364 let attrs: Vec<_> = client
365 .list_custom_attributes()
366 .await?
367 .into_iter()
368 .filter(|a| !is_excluded(&a.name, excludes))
369 .collect();
370 let count = attrs.len();
371 let registry = CustomAttributeRegistry { attributes: attrs };
372 custom_attribute_io::save_registry(registry_path, ®istry)?;
373 Ok(count)
374}