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::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 std::path::Path;
12
13use super::diff::resolve_catalog_names;
14use super::{selected_kinds, 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        match kind {
44            ResourceKind::CatalogSchema => {
45                let n = export_catalog_schemas(&client, &catalogs_root, args.name.as_deref())
46                    .await
47                    .context("exporting catalog_schema")?;
48                eprintln!("✓ catalog_schema: exported {n} resource(s)");
49                total_written += n;
50            }
51            ResourceKind::CatalogItems => {
52                let n = export_catalog_items(&client, &catalogs_root, args.name.as_deref())
53                    .await
54                    .context("exporting catalog_items")?;
55                eprintln!("✓ catalog_items: exported {n} catalog(s)");
56                total_written += n;
57            }
58            ResourceKind::ContentBlock => {
59                let n = export_content_blocks(&client, &content_blocks_root, args.name.as_deref())
60                    .await
61                    .context("exporting content_block")?;
62                eprintln!("✓ content_block: exported {n} resource(s)");
63                total_written += n;
64            }
65            ResourceKind::EmailTemplate => {
66                let n =
67                    export_email_templates(&client, &email_templates_root, args.name.as_deref())
68                        .await
69                        .context("exporting email_template")?;
70                eprintln!("✓ email_template: exported {n} resource(s)");
71                total_written += n;
72            }
73            ResourceKind::CustomAttribute => {
74                if args.name.is_some() {
75                    eprintln!(
76                        "⚠ custom_attribute: --name is not supported for export \
77                         (the registry is a single file); exporting all attributes"
78                    );
79                }
80                let n = export_custom_attributes(&client, &custom_attributes_path)
81                    .await
82                    .context("exporting custom_attribute")?;
83                eprintln!("✓ custom_attribute: exported {n} attribute(s)");
84                total_written += n;
85            }
86        }
87    }
88
89    eprintln!("done: {total_written} resource(s) written");
90    Ok(())
91}
92
93async fn export_catalog_schemas(
94    client: &BrazeClient,
95    catalogs_root: &Path,
96    name_filter: Option<&str>,
97) -> anyhow::Result<usize> {
98    let catalogs = match name_filter {
99        Some(name) => match client.get_catalog(name).await {
100            Ok(c) => vec![c],
101            // Missing remote is informational, not a hard error.
102            Err(BrazeApiError::NotFound { .. }) => {
103                eprintln!("⚠ catalog_schema: '{name}' not found in Braze");
104                Vec::new()
105            }
106            Err(e) => return Err(e.into()),
107        },
108        None => client.list_catalogs().await?,
109    };
110
111    let count = catalogs.len();
112    for cat in catalogs {
113        catalog_io::save_schema(catalogs_root, &cat)?;
114    }
115    Ok(count)
116}
117
118/// Lists first to discover ids, then fetches `/info` per block. With
119/// `--name`, the list still happens (to translate name → id) but only
120/// the matching block's body is fetched.
121async fn export_content_blocks(
122    client: &BrazeClient,
123    content_blocks_root: &Path,
124    name_filter: Option<&str>,
125) -> anyhow::Result<usize> {
126    let summaries = client.list_content_blocks().await?;
127    let targets: Vec<_> = match name_filter {
128        Some(name) => summaries.into_iter().filter(|s| s.name == name).collect(),
129        None => summaries,
130    };
131
132    if targets.is_empty() {
133        if let Some(name) = name_filter {
134            eprintln!("⚠ content_block: '{name}' not found in Braze");
135        }
136        return Ok(0);
137    }
138
139    let blocks: Vec<crate::resource::ContentBlock> =
140        futures::stream::iter(targets.iter().map(|s| {
141            let name = s.name.as_str();
142            let id = s.content_block_id.as_str();
143            async move {
144                client
145                    .get_content_block(id)
146                    .await
147                    .with_context(|| format!("fetching content block '{name}'"))
148            }
149        }))
150        .buffer_unordered(FETCH_CONCURRENCY)
151        .try_collect()
152        .await?;
153
154    for cb in &blocks {
155        content_block_io::save_content_block(content_blocks_root, cb)?;
156    }
157    Ok(blocks.len())
158}
159
160/// Same list-then-fetch pattern as content blocks.
161async fn export_email_templates(
162    client: &BrazeClient,
163    email_templates_root: &Path,
164    name_filter: Option<&str>,
165) -> anyhow::Result<usize> {
166    let summaries = client.list_email_templates().await?;
167    let targets: Vec<_> = match name_filter {
168        Some(name) => summaries.into_iter().filter(|s| s.name == name).collect(),
169        None => summaries,
170    };
171
172    if targets.is_empty() {
173        if let Some(name) = name_filter {
174            eprintln!("⚠ email_template: '{name}' not found in Braze");
175        }
176        return Ok(0);
177    }
178
179    let templates: Vec<crate::resource::EmailTemplate> =
180        futures::stream::iter(targets.iter().map(|s| {
181            let name = s.name.as_str();
182            let id = s.email_template_id.as_str();
183            async move {
184                client
185                    .get_email_template(id)
186                    .await
187                    .with_context(|| format!("fetching email template '{name}'"))
188            }
189        }))
190        .buffer_unordered(FETCH_CONCURRENCY)
191        .try_collect()
192        .await?;
193
194    for et in &templates {
195        email_template_io::save_email_template(email_templates_root, et)?;
196    }
197    Ok(templates.len())
198}
199
200/// Export catalog items. Discovers catalogs via `list_catalogs` (to get
201/// names), then fetches items per catalog in parallel. With `--name`,
202/// fetches items for that single catalog only.
203async fn export_catalog_items(
204    client: &BrazeClient,
205    catalogs_root: &Path,
206    name_filter: Option<&str>,
207) -> anyhow::Result<usize> {
208    let catalog_names = resolve_catalog_names(client, name_filter).await?;
209
210    let mut stream = futures::stream::iter(catalog_names.into_iter().map(|name| {
211        let client = client.clone();
212        async move {
213            match client.list_catalog_items(&name).await {
214                Ok(items) => Ok(Some((name, items))),
215                Err(BrazeApiError::NotFound { .. }) => {
216                    eprintln!("⚠ catalog_items: catalog '{name}' not found in Braze");
217                    Ok(None)
218                }
219                Err(e) => Err(e),
220            }
221        }
222    }))
223    .buffer_unordered(FETCH_CONCURRENCY);
224
225    let mut count = 0;
226    while let Some(result) = stream.next().await {
227        if let Some((name, items)) = result? {
228            catalog_io::save_items(catalogs_root, &name, &items)?;
229            count += 1;
230        }
231    }
232    Ok(count)
233}
234
235async fn export_custom_attributes(
236    client: &BrazeClient,
237    registry_path: &Path,
238) -> anyhow::Result<usize> {
239    let attrs = client.list_custom_attributes().await?;
240    let count = attrs.len();
241    let registry = CustomAttributeRegistry { attributes: attrs };
242    custom_attribute_io::save_registry(registry_path, &registry)?;
243    Ok(count)
244}