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};
7use crate::resource::{CustomAttributeRegistry, ResourceKind};
8use anyhow::Context as _;
9use clap::Args;
10use futures::stream::{StreamExt, TryStreamExt};
11use regex_lite::Regex;
12use std::path::Path;
13
14use super::{selected_kinds, warn_if_name_excluded, FETCH_CONCURRENCY};
15
16#[derive(Args, Debug, Default)]
17pub struct ExportArgs {
18 #[arg(long, value_enum)]
21 pub resource: Option<ResourceKind>,
22
23 #[arg(long, requires = "resource")]
26 pub name: Option<String>,
27}
28
29pub async fn run(
30 args: &ExportArgs,
31 resolved: ResolvedConfig,
32 config_dir: &Path,
33) -> anyhow::Result<()> {
34 let catalogs_root = config_dir.join(&resolved.resources.catalog_schema.path);
35 let content_blocks_root = config_dir.join(&resolved.resources.content_block.path);
36 let email_templates_root = config_dir.join(&resolved.resources.email_template.path);
37 let custom_attributes_path = config_dir.join(&resolved.resources.custom_attribute.path);
38 let client = BrazeClient::from_resolved(&resolved);
39 let kinds = selected_kinds(args.resource, &resolved.resources);
40
41 let mut total_written: usize = 0;
42 for kind in kinds {
43 if !matches!(kind, ResourceKind::CustomAttribute)
47 && warn_if_name_excluded(kind, args.name.as_deref(), resolved.excludes_for(kind))
48 {
49 continue;
50 }
51 match kind {
52 ResourceKind::CatalogSchema => {
53 let n = export_catalog_schemas(
54 &client,
55 &catalogs_root,
56 args.name.as_deref(),
57 resolved.excludes_for(ResourceKind::CatalogSchema),
58 )
59 .await
60 .context("exporting catalog_schema")?;
61 eprintln!("✓ catalog_schema: exported {n} resource(s)");
62 total_written += n;
63 }
64 ResourceKind::ContentBlock => {
65 let n = export_content_blocks(
66 &client,
67 &content_blocks_root,
68 args.name.as_deref(),
69 resolved.excludes_for(ResourceKind::ContentBlock),
70 )
71 .await
72 .context("exporting content_block")?;
73 eprintln!("✓ content_block: exported {n} resource(s)");
74 total_written += n;
75 }
76 ResourceKind::EmailTemplate => {
77 let n = export_email_templates(
78 &client,
79 &email_templates_root,
80 args.name.as_deref(),
81 resolved.excludes_for(ResourceKind::EmailTemplate),
82 )
83 .await
84 .context("exporting email_template")?;
85 eprintln!("✓ email_template: exported {n} resource(s)");
86 total_written += n;
87 }
88 ResourceKind::CustomAttribute => {
89 if args.name.is_some() {
90 eprintln!(
91 "⚠ custom_attribute: --name is not supported for export \
92 (the registry is a single file); exporting all attributes"
93 );
94 }
95 let n = export_custom_attributes(
96 &client,
97 &custom_attributes_path,
98 resolved.excludes_for(ResourceKind::CustomAttribute),
99 )
100 .await
101 .context("exporting custom_attribute")?;
102 eprintln!("✓ custom_attribute: exported {n} attribute(s)");
103 total_written += n;
104 }
105 }
106 }
107
108 eprintln!("done: {total_written} resource(s) written");
109 Ok(())
110}
111
112async fn export_catalog_schemas(
113 client: &BrazeClient,
114 catalogs_root: &Path,
115 name_filter: Option<&str>,
116 excludes: &[Regex],
117) -> anyhow::Result<usize> {
118 let catalogs = match name_filter {
119 Some(name) => match client.get_catalog(name).await {
120 Ok(c) => vec![c],
121 Err(BrazeApiError::NotFound { .. }) => {
123 eprintln!("⚠ catalog_schema: '{name}' not found in Braze");
124 Vec::new()
125 }
126 Err(e) => return Err(e.into()),
127 },
128 None => client.list_catalogs().await?,
129 };
130
131 let filtered: Vec<_> = catalogs
132 .into_iter()
133 .filter(|c| !is_excluded(&c.name, excludes))
134 .collect();
135 let count = filtered.len();
136 for cat in filtered {
137 catalog_io::save_schema(catalogs_root, &cat)?;
138 }
139 Ok(count)
140}
141
142async fn export_content_blocks(
146 client: &BrazeClient,
147 content_blocks_root: &Path,
148 name_filter: Option<&str>,
149 excludes: &[Regex],
150) -> anyhow::Result<usize> {
151 let summaries = client.list_content_blocks().await?;
152 let targets: Vec<_> = summaries
153 .into_iter()
154 .filter(|s| name_filter.is_none_or(|n| s.name == n))
155 .filter(|s| !is_excluded(&s.name, excludes))
156 .collect();
157
158 if targets.is_empty() {
159 if let Some(name) = name_filter {
160 eprintln!("⚠ content_block: '{name}' not found in Braze");
161 }
162 return Ok(0);
163 }
164
165 let blocks: Vec<crate::resource::ContentBlock> =
166 futures::stream::iter(targets.iter().map(|s| {
167 let name = s.name.as_str();
168 let id = s.content_block_id.as_str();
169 async move {
170 client
171 .get_content_block(id)
172 .await
173 .with_context(|| format!("fetching content block '{name}'"))
174 }
175 }))
176 .buffer_unordered(FETCH_CONCURRENCY)
177 .try_collect()
178 .await?;
179
180 for cb in &blocks {
181 content_block_io::save_content_block(content_blocks_root, cb)?;
182 }
183 Ok(blocks.len())
184}
185
186async fn export_email_templates(
188 client: &BrazeClient,
189 email_templates_root: &Path,
190 name_filter: Option<&str>,
191 excludes: &[Regex],
192) -> anyhow::Result<usize> {
193 let summaries = client.list_email_templates().await?;
194 let targets: Vec<_> = summaries
195 .into_iter()
196 .filter(|s| name_filter.is_none_or(|n| s.name == n))
197 .filter(|s| !is_excluded(&s.name, excludes))
198 .collect();
199
200 if targets.is_empty() {
201 if let Some(name) = name_filter {
202 eprintln!("⚠ email_template: '{name}' not found in Braze");
203 }
204 return Ok(0);
205 }
206
207 let templates: Vec<crate::resource::EmailTemplate> =
208 futures::stream::iter(targets.iter().map(|s| {
209 let name = s.name.as_str();
210 let id = s.email_template_id.as_str();
211 async move {
212 client
213 .get_email_template(id)
214 .await
215 .with_context(|| format!("fetching email template '{name}'"))
216 }
217 }))
218 .buffer_unordered(FETCH_CONCURRENCY)
219 .try_collect()
220 .await?;
221
222 for et in &templates {
223 email_template_io::save_email_template(email_templates_root, et)?;
224 }
225 Ok(templates.len())
226}
227
228async fn export_custom_attributes(
229 client: &BrazeClient,
230 registry_path: &Path,
231 excludes: &[Regex],
232) -> anyhow::Result<usize> {
233 let attrs: Vec<_> = client
234 .list_custom_attributes()
235 .await?
236 .into_iter()
237 .filter(|a| !is_excluded(&a.name, excludes))
238 .collect();
239 let count = attrs.len();
240 let registry = CustomAttributeRegistry { attributes: attrs };
241 custom_attribute_io::save_registry(registry_path, ®istry)?;
242 Ok(count)
243}