Skip to main content

braze_sync/cli/
export.rs

1//! `braze-sync export` — pull current state from Braze into local files.
2
3use 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    /// Limit export to a specific resource kind. Omit to export every
20    /// enabled resource kind in turn.
21    #[arg(long, value_enum)]
22    pub resource: Option<ResourceKind>,
23
24    /// When `--resource` is given, optionally restrict to a single named
25    /// resource. Requires `--resource`.
26    #[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        // `custom_attribute` ignores `--name` (registry is a single file),
46        // so skipping by exclude match before dispatching wouldn't fit —
47        // handle it per-arm alongside the existing --name warning.
48        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            // Missing remote is informational, not a hard error.
141            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
161/// Lists first to discover ids, then fetches `/info` per block. With
162/// `--name`, the list still happens (to translate name → id) but only
163/// the matching block's body is fetched.
164async 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
205/// Same list-then-fetch pattern as content blocks.
206async 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
247/// Aggregate tag names from local content_block + email_template files.
248///
249/// Braze does not expose a public REST API for workspace tags, so the
250/// registry is derived from the local Git state instead of a remote
251/// list. Operators are expected to run regular `export` first (to refresh
252/// content_block / email_template files), then `export tag` to rebuild
253/// the registry from the freshly-synced frontmatter. Tags found here are
254/// the union of every `tags:` array on every local resource, minus
255/// `tag.exclude_patterns`.
256fn 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, &registry)?;
274    Ok(count)
275}
276
277/// Walk every local resource directory the config knows about and
278/// collect the union of `tags:` referenced on the resources. Used by
279/// both `export` (to rebuild registry) and `apply`/`validate`
280/// (to cross-check the registry against actual usage).
281pub(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, &registry)?;
326    Ok(count)
327}