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, email_template_io};
7use crate::resource::ResourceKind;
8use anyhow::Context as _;
9use clap::Args;
10use futures::stream::{StreamExt, TryStreamExt};
11use std::path::Path;
12
13use super::{selected_kinds, warn_unimplemented, FETCH_CONCURRENCY};
14
15#[derive(Args, Debug)]
16pub struct ExportArgs {
17    /// Limit export to a specific resource kind. Omit to export every
18    /// enabled resource kind in turn.
19    #[arg(long, value_enum)]
20    pub resource: Option<ResourceKind>,
21
22    /// When `--resource` is given, optionally restrict to a single named
23    /// resource. Requires `--resource`.
24    #[arg(long, requires = "resource")]
25    pub name: Option<String>,
26}
27
28pub async fn run(
29    args: &ExportArgs,
30    resolved: ResolvedConfig,
31    config_dir: &Path,
32) -> anyhow::Result<()> {
33    let catalogs_root = config_dir.join(&resolved.resources.catalog_schema.path);
34    let content_blocks_root = config_dir.join(&resolved.resources.content_block.path);
35    let email_templates_root = config_dir.join(&resolved.resources.email_template.path);
36    let client = BrazeClient::from_resolved(&resolved);
37    let kinds = selected_kinds(args.resource, &resolved.resources);
38
39    let mut total_written: usize = 0;
40    for kind in kinds {
41        match kind {
42            ResourceKind::CatalogSchema => {
43                let n = export_catalog_schemas(&client, &catalogs_root, args.name.as_deref())
44                    .await
45                    .context("exporting catalog_schema")?;
46                eprintln!("✓ catalog_schema: exported {n} resource(s)");
47                total_written += n;
48            }
49            ResourceKind::ContentBlock => {
50                let n = export_content_blocks(&client, &content_blocks_root, args.name.as_deref())
51                    .await
52                    .context("exporting content_block")?;
53                eprintln!("✓ content_block: exported {n} resource(s)");
54                total_written += n;
55            }
56            ResourceKind::EmailTemplate => {
57                let n =
58                    export_email_templates(&client, &email_templates_root, args.name.as_deref())
59                        .await
60                        .context("exporting email_template")?;
61                eprintln!("✓ email_template: exported {n} resource(s)");
62                total_written += n;
63            }
64            other => {
65                warn_unimplemented(other);
66            }
67        }
68    }
69
70    eprintln!("done: {total_written} resource(s) written");
71    Ok(())
72}
73
74async fn export_catalog_schemas(
75    client: &BrazeClient,
76    catalogs_root: &Path,
77    name_filter: Option<&str>,
78) -> anyhow::Result<usize> {
79    let catalogs = match name_filter {
80        Some(name) => match client.get_catalog(name).await {
81            Ok(c) => vec![c],
82            // Missing remote is informational, not a hard error.
83            Err(BrazeApiError::NotFound { .. }) => {
84                eprintln!("⚠ catalog_schema: '{name}' not found in Braze");
85                Vec::new()
86            }
87            Err(e) => return Err(e.into()),
88        },
89        None => client.list_catalogs().await?,
90    };
91
92    let count = catalogs.len();
93    for cat in catalogs {
94        catalog_io::save_schema(catalogs_root, &cat)?;
95    }
96    Ok(count)
97}
98
99/// Lists first to discover ids, then fetches `/info` per block. With
100/// `--name`, the list still happens (to translate name → id) but only
101/// the matching block's body is fetched.
102async fn export_content_blocks(
103    client: &BrazeClient,
104    content_blocks_root: &Path,
105    name_filter: Option<&str>,
106) -> anyhow::Result<usize> {
107    let summaries = client.list_content_blocks().await?;
108    let targets: Vec<_> = match name_filter {
109        Some(name) => summaries.into_iter().filter(|s| s.name == name).collect(),
110        None => summaries,
111    };
112
113    if targets.is_empty() {
114        if let Some(name) = name_filter {
115            eprintln!("⚠ content_block: '{name}' not found in Braze");
116        }
117        return Ok(0);
118    }
119
120    let blocks: Vec<crate::resource::ContentBlock> =
121        futures::stream::iter(targets.iter().map(|s| {
122            let name = s.name.as_str();
123            let id = s.content_block_id.as_str();
124            async move {
125                client
126                    .get_content_block(id)
127                    .await
128                    .with_context(|| format!("fetching content block '{name}'"))
129            }
130        }))
131        .buffer_unordered(FETCH_CONCURRENCY)
132        .try_collect()
133        .await?;
134
135    for cb in &blocks {
136        content_block_io::save_content_block(content_blocks_root, cb)?;
137    }
138    Ok(blocks.len())
139}
140
141/// Same list-then-fetch pattern as content blocks.
142async fn export_email_templates(
143    client: &BrazeClient,
144    email_templates_root: &Path,
145    name_filter: Option<&str>,
146) -> anyhow::Result<usize> {
147    let summaries = client.list_email_templates().await?;
148    let targets: Vec<_> = match name_filter {
149        Some(name) => summaries.into_iter().filter(|s| s.name == name).collect(),
150        None => summaries,
151    };
152
153    if targets.is_empty() {
154        if let Some(name) = name_filter {
155            eprintln!("⚠ email_template: '{name}' not found in Braze");
156        }
157        return Ok(0);
158    }
159
160    let templates: Vec<crate::resource::EmailTemplate> =
161        futures::stream::iter(targets.iter().map(|s| {
162            let name = s.name.as_str();
163            let id = s.email_template_id.as_str();
164            async move {
165                client
166                    .get_email_template(id)
167                    .await
168                    .with_context(|| format!("fetching email template '{name}'"))
169            }
170        }))
171        .buffer_unordered(FETCH_CONCURRENCY)
172        .try_collect()
173        .await?;
174
175    for et in &templates {
176        email_template_io::save_email_template(email_templates_root, et)?;
177    }
178    Ok(templates.len())
179}