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};
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    /// Limit export to a specific resource kind. Omit to export every
19    /// enabled resource kind in turn.
20    #[arg(long, value_enum)]
21    pub resource: Option<ResourceKind>,
22
23    /// When `--resource` is given, optionally restrict to a single named
24    /// resource. Requires `--resource`.
25    #[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        // `custom_attribute` ignores `--name` (registry is a single file),
44        // so skipping by exclude match before dispatching wouldn't fit —
45        // handle it per-arm alongside the existing --name warning.
46        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            // Missing remote is informational, not a hard error.
122            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
142/// Lists first to discover ids, then fetches `/info` per block. With
143/// `--name`, the list still happens (to translate name → id) but only
144/// the matching block's body is fetched.
145async 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
186/// Same list-then-fetch pattern as content blocks.
187async 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, &registry)?;
242    Ok(count)
243}