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};
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 client = BrazeClient::from_resolved(&resolved);
36    let kinds = selected_kinds(args.resource, &resolved.resources);
37
38    let mut total_written: usize = 0;
39    for kind in kinds {
40        match kind {
41            ResourceKind::CatalogSchema => {
42                let n = export_catalog_schemas(&client, &catalogs_root, args.name.as_deref())
43                    .await
44                    .context("exporting catalog_schema")?;
45                eprintln!("✓ catalog_schema: exported {n} resource(s)");
46                total_written += n;
47            }
48            ResourceKind::ContentBlock => {
49                let n = export_content_blocks(&client, &content_blocks_root, args.name.as_deref())
50                    .await
51                    .context("exporting content_block")?;
52                eprintln!("✓ content_block: exported {n} resource(s)");
53                total_written += n;
54            }
55            other => {
56                warn_unimplemented(other);
57            }
58        }
59    }
60
61    eprintln!("done: {total_written} resource(s) written");
62    Ok(())
63}
64
65async fn export_catalog_schemas(
66    client: &BrazeClient,
67    catalogs_root: &Path,
68    name_filter: Option<&str>,
69) -> anyhow::Result<usize> {
70    let catalogs = match name_filter {
71        Some(name) => match client.get_catalog(name).await {
72            Ok(c) => vec![c],
73            // Missing remote is informational, not a hard error.
74            Err(BrazeApiError::NotFound { .. }) => {
75                eprintln!("⚠ catalog_schema: '{name}' not found in Braze");
76                Vec::new()
77            }
78            Err(e) => return Err(e.into()),
79        },
80        None => client.list_catalogs().await?,
81    };
82
83    let count = catalogs.len();
84    for cat in catalogs {
85        catalog_io::save_schema(catalogs_root, &cat)?;
86    }
87    Ok(count)
88}
89
90/// Lists first to discover ids, then fetches `/info` per block. With
91/// `--name`, the list still happens (to translate name → id) but only
92/// the matching block's body is fetched.
93async fn export_content_blocks(
94    client: &BrazeClient,
95    content_blocks_root: &Path,
96    name_filter: Option<&str>,
97) -> anyhow::Result<usize> {
98    let summaries = client.list_content_blocks().await?;
99    let targets: Vec<_> = match name_filter {
100        Some(name) => summaries.into_iter().filter(|s| s.name == name).collect(),
101        None => summaries,
102    };
103
104    if targets.is_empty() {
105        if let Some(name) = name_filter {
106            eprintln!("⚠ content_block: '{name}' not found in Braze");
107        }
108        return Ok(0);
109    }
110
111    let blocks: Vec<crate::resource::ContentBlock> =
112        futures::stream::iter(targets.iter().map(|s| {
113            let name = s.name.as_str();
114            let id = s.content_block_id.as_str();
115            async move {
116                client
117                    .get_content_block(id)
118                    .await
119                    .with_context(|| format!("fetching content block '{name}'"))
120            }
121        }))
122        .buffer_unordered(FETCH_CONCURRENCY)
123        .try_collect()
124        .await?;
125
126    for cb in &blocks {
127        content_block_io::save_content_block(content_blocks_root, cb)?;
128    }
129    Ok(blocks.len())
130}