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::{CustomAttributeRegistry, ResourceKind, Tag, TagRegistry};
8use anyhow::Context as _;
9use clap::Args;
10use futures::stream::{StreamExt, TryStreamExt};
11use regex_lite::Regex;
12use std::collections::BTreeSet;
13use std::path::Path;
14
15use super::{selected_kinds, warn_if_name_excluded, FETCH_CONCURRENCY};
16
17#[derive(Args, Debug, Default)]
18pub struct ExportArgs {
19 #[arg(long, value_enum)]
22 pub resource: Option<ResourceKind>,
23
24 #[arg(long, requires = "resource")]
27 pub name: Option<String>,
28}
29
30pub async fn run(
31 args: &ExportArgs,
32 resolved: ResolvedConfig,
33 config_dir: &Path,
34) -> anyhow::Result<()> {
35 let catalogs_root = config_dir.join(&resolved.resources.catalog_schema.path);
36 let content_blocks_root = config_dir.join(&resolved.resources.content_block.path);
37 let email_templates_root = config_dir.join(&resolved.resources.email_template.path);
38 let custom_attributes_path = config_dir.join(&resolved.resources.custom_attribute.path);
39 let tags_path = config_dir.join(&resolved.resources.tag.path);
40 let client = BrazeClient::from_resolved(&resolved);
41 let kinds = selected_kinds(args.resource, &resolved.resources);
42
43 let mut total_written: usize = 0;
44 for kind in kinds {
45 if !matches!(kind, ResourceKind::CustomAttribute | ResourceKind::Tag)
49 && warn_if_name_excluded(kind, args.name.as_deref(), resolved.excludes_for(kind))
50 {
51 continue;
52 }
53 match kind {
54 ResourceKind::CatalogSchema => {
55 let n = export_catalog_schemas(
56 &client,
57 &catalogs_root,
58 args.name.as_deref(),
59 resolved.excludes_for(ResourceKind::CatalogSchema),
60 )
61 .await
62 .context("exporting catalog_schema")?;
63 eprintln!("✓ catalog_schema: exported {n} resource(s)");
64 total_written += n;
65 }
66 ResourceKind::ContentBlock => {
67 let n = export_content_blocks(
68 &client,
69 &content_blocks_root,
70 args.name.as_deref(),
71 resolved.excludes_for(ResourceKind::ContentBlock),
72 )
73 .await
74 .context("exporting content_block")?;
75 eprintln!("✓ content_block: exported {n} resource(s)");
76 total_written += n;
77 }
78 ResourceKind::EmailTemplate => {
79 let n = export_email_templates(
80 &client,
81 &email_templates_root,
82 args.name.as_deref(),
83 resolved.excludes_for(ResourceKind::EmailTemplate),
84 )
85 .await
86 .context("exporting email_template")?;
87 eprintln!("✓ email_template: exported {n} resource(s)");
88 total_written += n;
89 }
90 ResourceKind::CustomAttribute => {
91 if args.name.is_some() {
92 eprintln!(
93 "⚠ custom_attribute: --name is not supported for export \
94 (the registry is a single file); exporting all attributes"
95 );
96 }
97 let n = export_custom_attributes(
98 &client,
99 &custom_attributes_path,
100 resolved.excludes_for(ResourceKind::CustomAttribute),
101 )
102 .await
103 .context("exporting custom_attribute")?;
104 eprintln!("✓ custom_attribute: exported {n} attribute(s)");
105 total_written += n;
106 }
107 ResourceKind::Tag => {
108 if args.name.is_some() {
109 eprintln!(
110 "⚠ tag: --name is not supported for export \
111 (the registry is a single file); exporting all tags"
112 );
113 }
114 let n = export_tags(
115 config_dir,
116 &resolved,
117 &tags_path,
118 resolved.excludes_for(ResourceKind::Tag),
119 )
120 .context("exporting tag")?;
121 eprintln!("✓ tag: exported {n} tag(s)");
122 total_written += n;
123 }
124 }
125 }
126
127 eprintln!("done: {total_written} resource(s) written");
128 Ok(())
129}
130
131async fn export_catalog_schemas(
132 client: &BrazeClient,
133 catalogs_root: &Path,
134 name_filter: Option<&str>,
135 excludes: &[Regex],
136) -> anyhow::Result<usize> {
137 let catalogs = match name_filter {
138 Some(name) => match client.get_catalog(name).await {
139 Ok(c) => vec![c],
140 Err(BrazeApiError::NotFound { .. }) => {
142 eprintln!("⚠ catalog_schema: '{name}' not found in Braze");
143 Vec::new()
144 }
145 Err(e) => return Err(e.into()),
146 },
147 None => client.list_catalogs().await?,
148 };
149
150 let filtered: Vec<_> = catalogs
151 .into_iter()
152 .filter(|c| !is_excluded(&c.name, excludes))
153 .collect();
154 let count = filtered.len();
155 for cat in filtered {
156 catalog_io::save_schema(catalogs_root, &cat)?;
157 }
158 Ok(count)
159}
160
161async fn export_content_blocks(
165 client: &BrazeClient,
166 content_blocks_root: &Path,
167 name_filter: Option<&str>,
168 excludes: &[Regex],
169) -> anyhow::Result<usize> {
170 let summaries = client.list_content_blocks().await?;
171 let targets: Vec<_> = summaries
172 .into_iter()
173 .filter(|s| name_filter.is_none_or(|n| s.name == n))
174 .filter(|s| !is_excluded(&s.name, excludes))
175 .collect();
176
177 if targets.is_empty() {
178 if let Some(name) = name_filter {
179 eprintln!("⚠ content_block: '{name}' not found in Braze");
180 }
181 return Ok(0);
182 }
183
184 let blocks: Vec<crate::resource::ContentBlock> =
185 futures::stream::iter(targets.iter().map(|s| {
186 let name = s.name.as_str();
187 let id = s.content_block_id.as_str();
188 async move {
189 client
190 .get_content_block(id)
191 .await
192 .with_context(|| format!("fetching content block '{name}'"))
193 }
194 }))
195 .buffer_unordered(FETCH_CONCURRENCY)
196 .try_collect()
197 .await?;
198
199 for cb in &blocks {
200 content_block_io::save_content_block(content_blocks_root, cb)?;
201 }
202 Ok(blocks.len())
203}
204
205async fn export_email_templates(
207 client: &BrazeClient,
208 email_templates_root: &Path,
209 name_filter: Option<&str>,
210 excludes: &[Regex],
211) -> anyhow::Result<usize> {
212 let summaries = client.list_email_templates().await?;
213 let targets: Vec<_> = summaries
214 .into_iter()
215 .filter(|s| name_filter.is_none_or(|n| s.name == n))
216 .filter(|s| !is_excluded(&s.name, excludes))
217 .collect();
218
219 if targets.is_empty() {
220 if let Some(name) = name_filter {
221 eprintln!("⚠ email_template: '{name}' not found in Braze");
222 }
223 return Ok(0);
224 }
225
226 let templates: Vec<crate::resource::EmailTemplate> =
227 futures::stream::iter(targets.iter().map(|s| {
228 let name = s.name.as_str();
229 let id = s.email_template_id.as_str();
230 async move {
231 client
232 .get_email_template(id)
233 .await
234 .with_context(|| format!("fetching email template '{name}'"))
235 }
236 }))
237 .buffer_unordered(FETCH_CONCURRENCY)
238 .try_collect()
239 .await?;
240
241 for et in &templates {
242 email_template_io::save_email_template(email_templates_root, et)?;
243 }
244 Ok(templates.len())
245}
246
247fn export_tags(
257 config_dir: &Path,
258 resolved: &ResolvedConfig,
259 registry_path: &Path,
260 excludes: &[Regex],
261) -> anyhow::Result<usize> {
262 let referenced = collect_local_tag_references(config_dir, resolved)?;
263 let tags: Vec<Tag> = referenced
264 .into_iter()
265 .filter(|name| !is_excluded(name, excludes))
266 .map(|name| Tag {
267 name,
268 description: None,
269 })
270 .collect();
271 let count = tags.len();
272 let registry = TagRegistry { tags };
273 tag_io::save_registry(registry_path, ®istry)?;
274 Ok(count)
275}
276
277pub(crate) fn collect_local_tag_references(
282 config_dir: &Path,
283 resolved: &ResolvedConfig,
284) -> anyhow::Result<BTreeSet<String>> {
285 let mut tags: BTreeSet<String> = BTreeSet::new();
286
287 if resolved.resources.content_block.enabled {
288 let root = config_dir.join(&resolved.resources.content_block.path);
289 let blocks = content_block_io::load_all_content_blocks(&root)
290 .context("loading local content_blocks for tag aggregation")?;
291 for cb in &blocks {
292 for t in &cb.tags {
293 tags.insert(t.clone());
294 }
295 }
296 }
297
298 if resolved.resources.email_template.enabled {
299 let root = config_dir.join(&resolved.resources.email_template.path);
300 let templates = crate::fs::email_template_io::load_all_email_templates(&root)
301 .context("loading local email_templates for tag aggregation")?;
302 for et in &templates {
303 for t in &et.tags {
304 tags.insert(t.clone());
305 }
306 }
307 }
308
309 Ok(tags)
310}
311
312async fn export_custom_attributes(
313 client: &BrazeClient,
314 registry_path: &Path,
315 excludes: &[Regex],
316) -> anyhow::Result<usize> {
317 let attrs: Vec<_> = client
318 .list_custom_attributes()
319 .await?
320 .into_iter()
321 .filter(|a| !is_excluded(&a.name, excludes))
322 .collect();
323 let count = attrs.len();
324 let registry = CustomAttributeRegistry { attributes: attrs };
325 custom_attribute_io::save_registry(registry_path, ®istry)?;
326 Ok(count)
327}