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::{
8    ContentBlock, CustomAttributeRegistry, EmailTemplate, ResourceKind, Tag, TagRegistry,
9};
10use crate::values::has_placeholders;
11use anyhow::Context as _;
12use clap::Args;
13use futures::stream::{StreamExt, TryStreamExt};
14use regex_lite::Regex;
15use std::collections::BTreeSet;
16use std::path::Path;
17
18use super::{selected_kinds, warn_if_name_excluded, FETCH_CONCURRENCY};
19
20#[derive(Args, Debug, Default)]
21pub struct ExportArgs {
22    /// Limit export to a specific resource kind. Omit to export every
23    /// enabled resource kind in turn.
24    #[arg(long, value_enum)]
25    pub resource: Option<ResourceKind>,
26
27    /// When `--resource` is given, optionally restrict to a single named
28    /// resource. Requires `--resource`.
29    #[arg(long, requires = "resource")]
30    pub name: Option<String>,
31}
32
33pub async fn run(
34    args: &ExportArgs,
35    resolved: ResolvedConfig,
36    config_dir: &Path,
37) -> anyhow::Result<()> {
38    let catalogs_root = config_dir.join(&resolved.resources.catalog_schema.path);
39    let content_blocks_root = config_dir.join(&resolved.resources.content_block.path);
40    let email_templates_root = config_dir.join(&resolved.resources.email_template.path);
41    let custom_attributes_path = config_dir.join(&resolved.resources.custom_attribute.path);
42    let tags_path = config_dir.join(&resolved.resources.tag.path);
43    let client = BrazeClient::from_resolved(&resolved);
44    let kinds = selected_kinds(args.resource, &resolved.resources);
45
46    let mut total_written: usize = 0;
47    for kind in kinds {
48        // `custom_attribute` ignores `--name` (registry is a single file),
49        // so skipping by exclude match before dispatching wouldn't fit —
50        // handle it per-arm alongside the existing --name warning.
51        if !matches!(kind, ResourceKind::CustomAttribute | ResourceKind::Tag)
52            && warn_if_name_excluded(kind, args.name.as_deref(), resolved.excludes_for(kind))
53        {
54            continue;
55        }
56        match kind {
57            ResourceKind::CatalogSchema => {
58                let n = export_catalog_schemas(
59                    &client,
60                    &catalogs_root,
61                    args.name.as_deref(),
62                    resolved.excludes_for(ResourceKind::CatalogSchema),
63                )
64                .await
65                .context("exporting catalog_schema")?;
66                eprintln!("✓ catalog_schema: exported {n} resource(s)");
67                total_written += n;
68            }
69            ResourceKind::ContentBlock => {
70                let n = export_content_blocks(
71                    &client,
72                    &content_blocks_root,
73                    args.name.as_deref(),
74                    resolved.excludes_for(ResourceKind::ContentBlock),
75                )
76                .await
77                .context("exporting content_block")?;
78                eprintln!("✓ content_block: exported {n} resource(s)");
79                total_written += n;
80            }
81            ResourceKind::EmailTemplate => {
82                let n = export_email_templates(
83                    &client,
84                    &email_templates_root,
85                    args.name.as_deref(),
86                    resolved.excludes_for(ResourceKind::EmailTemplate),
87                )
88                .await
89                .context("exporting email_template")?;
90                eprintln!("✓ email_template: exported {n} resource(s)");
91                total_written += n;
92            }
93            ResourceKind::CustomAttribute => {
94                if args.name.is_some() {
95                    eprintln!(
96                        "⚠ custom_attribute: --name is not supported for export \
97                         (the registry is a single file); exporting all attributes"
98                    );
99                }
100                let n = export_custom_attributes(
101                    &client,
102                    &custom_attributes_path,
103                    resolved.excludes_for(ResourceKind::CustomAttribute),
104                )
105                .await
106                .context("exporting custom_attribute")?;
107                eprintln!("✓ custom_attribute: exported {n} attribute(s)");
108                total_written += n;
109            }
110            ResourceKind::Tag => {
111                if args.name.is_some() {
112                    eprintln!(
113                        "⚠ tag: --name is not supported for export \
114                         (the registry is a single file); exporting all tags"
115                    );
116                }
117                let n = export_tags(
118                    config_dir,
119                    &resolved,
120                    &tags_path,
121                    resolved.excludes_for(ResourceKind::Tag),
122                )
123                .context("exporting tag")?;
124                eprintln!("✓ tag: exported {n} tag(s)");
125                total_written += n;
126            }
127        }
128    }
129
130    eprintln!("done: {total_written} resource(s) written");
131    Ok(())
132}
133
134async fn export_catalog_schemas(
135    client: &BrazeClient,
136    catalogs_root: &Path,
137    name_filter: Option<&str>,
138    excludes: &[Regex],
139) -> anyhow::Result<usize> {
140    let catalogs = match name_filter {
141        Some(name) => match client.get_catalog(name).await {
142            Ok(c) => vec![c],
143            // Missing remote is informational, not a hard error.
144            Err(BrazeApiError::NotFound { .. }) => {
145                eprintln!("⚠ catalog_schema: '{name}' not found in Braze");
146                Vec::new()
147            }
148            Err(e) => return Err(e.into()),
149        },
150        None => client.list_catalogs().await?,
151    };
152
153    let filtered: Vec<_> = catalogs
154        .into_iter()
155        .filter(|c| !is_excluded(&c.name, excludes))
156        .collect();
157    let count = filtered.len();
158    for cat in filtered {
159        catalog_io::save_schema(catalogs_root, &cat)?;
160    }
161    Ok(count)
162}
163
164/// Lists first to discover ids, then fetches `/info` per block. With
165/// `--name`, the list still happens (to translate name → id) but only
166/// the matching block's body is fetched.
167///
168/// When a local template with `__BRAZESYNC__` placeholders exists for
169/// a given name, the local body is preserved verbatim (templatized
170/// form is the source of truth). v0.15: no values-file writeback —
171/// lid / cb_id are resolved from the remote body at apply/diff time.
172async fn export_content_blocks(
173    client: &BrazeClient,
174    content_blocks_root: &Path,
175    name_filter: Option<&str>,
176    excludes: &[Regex],
177) -> anyhow::Result<usize> {
178    let summaries = client.list_content_blocks().await?;
179    let targets: Vec<_> = summaries
180        .into_iter()
181        .filter(|s| name_filter.is_none_or(|n| s.name == n))
182        .filter(|s| !is_excluded(&s.name, excludes))
183        .collect();
184
185    if targets.is_empty() {
186        if let Some(name) = name_filter {
187            eprintln!("⚠ content_block: '{name}' not found in Braze");
188        }
189        return Ok(0);
190    }
191
192    let blocks: Vec<ContentBlock> = futures::stream::iter(targets.iter().map(|s| {
193        let name = s.name.as_str();
194        let id = s.content_block_id.as_str();
195        async move {
196            client
197                .get_content_block(id)
198                .await
199                .with_context(|| format!("fetching content block '{name}'"))
200        }
201    }))
202    .buffer_unordered(FETCH_CONCURRENCY)
203    .try_collect()
204    .await?;
205
206    for remote in &blocks {
207        let local_path = content_blocks_root.join(format!("{}.liquid", remote.name));
208        let local = if local_path.exists() {
209            Some(content_block_io::read_content_block_file(&local_path)?)
210        } else {
211            None
212        };
213        let mut to_save = remote.clone();
214        if let Some(local) = local.as_ref() {
215            if has_placeholders(&local.content) {
216                to_save.content = local.content.clone();
217            }
218        }
219        content_block_io::save_content_block(content_blocks_root, &to_save)?;
220    }
221    Ok(blocks.len())
222}
223
224/// Same list-then-fetch pattern as content blocks. Per-field placeholder
225/// preservation: each of `subject`, `body_html`, `body_plaintext`,
226/// `preheader` is independently checked for `__BRAZESYNC__` content
227/// and, when present, kept from the local template instead of being
228/// overwritten by the remote body.
229async fn export_email_templates(
230    client: &BrazeClient,
231    email_templates_root: &Path,
232    name_filter: Option<&str>,
233    excludes: &[Regex],
234) -> anyhow::Result<usize> {
235    let summaries = client.list_email_templates().await?;
236    let targets: Vec<_> = summaries
237        .into_iter()
238        .filter(|s| name_filter.is_none_or(|n| s.name == n))
239        .filter(|s| !is_excluded(&s.name, excludes))
240        .collect();
241
242    if targets.is_empty() {
243        if let Some(name) = name_filter {
244            eprintln!("⚠ email_template: '{name}' not found in Braze");
245        }
246        return Ok(0);
247    }
248
249    let templates: Vec<EmailTemplate> = futures::stream::iter(targets.iter().map(|s| {
250        let name = s.name.as_str();
251        let id = s.email_template_id.as_str();
252        async move {
253            client
254                .get_email_template(id)
255                .await
256                .with_context(|| format!("fetching email template '{name}'"))
257        }
258    }))
259    .buffer_unordered(FETCH_CONCURRENCY)
260    .try_collect()
261    .await?;
262
263    for remote in &templates {
264        let local_dir = email_templates_root.join(&remote.name);
265        let local = if local_dir.is_dir() {
266            Some(email_template_io::read_email_template_dir(&local_dir)?)
267        } else {
268            None
269        };
270        let mut to_save = remote.clone();
271        if let Some(local) = local.as_ref() {
272            let subject_has = has_placeholders(&local.subject);
273            let body_html_has = has_placeholders(&local.body_html);
274            let body_plain_has = has_placeholders(&local.body_plaintext);
275            let preheader_has = local.preheader.as_deref().is_some_and(has_placeholders);
276            if subject_has {
277                to_save.subject = local.subject.clone();
278            }
279            if body_html_has {
280                to_save.body_html = local.body_html.clone();
281            }
282            if body_plain_has {
283                to_save.body_plaintext = local.body_plaintext.clone();
284            }
285            if preheader_has {
286                to_save.preheader = local.preheader.clone();
287            }
288        }
289        email_template_io::save_email_template(email_templates_root, &to_save)?;
290    }
291    Ok(templates.len())
292}
293
294/// Aggregate tag names from local content_block + email_template files.
295///
296/// Braze does not expose a public REST API for workspace tags, so the
297/// registry is derived from the local Git state instead of a remote
298/// list. Operators are expected to run regular `export` first (to refresh
299/// content_block / email_template files), then `export tag` to rebuild
300/// the registry from the freshly-synced frontmatter. Tags found here are
301/// the union of every `tags:` array on every local resource, minus
302/// `tag.exclude_patterns`.
303fn export_tags(
304    config_dir: &Path,
305    resolved: &ResolvedConfig,
306    registry_path: &Path,
307    excludes: &[Regex],
308) -> anyhow::Result<usize> {
309    let referenced = collect_local_tag_references(config_dir, resolved)?;
310    let tags: Vec<Tag> = referenced
311        .into_iter()
312        .filter(|name| !is_excluded(name, excludes))
313        .map(|name| Tag {
314            name,
315            description: None,
316        })
317        .collect();
318    let count = tags.len();
319    let registry = TagRegistry { tags };
320    tag_io::save_registry(registry_path, &registry)?;
321    Ok(count)
322}
323
324/// Walk every local resource directory the config knows about and
325/// collect the union of `tags:` referenced on the resources. Used by
326/// both `export` (to rebuild registry) and `apply`/`validate`
327/// (to cross-check the registry against actual usage).
328pub(crate) fn collect_local_tag_references(
329    config_dir: &Path,
330    resolved: &ResolvedConfig,
331) -> anyhow::Result<BTreeSet<String>> {
332    let mut tags: BTreeSet<String> = BTreeSet::new();
333
334    if resolved.resources.content_block.enabled {
335        let root = config_dir.join(&resolved.resources.content_block.path);
336        let blocks = content_block_io::load_all_content_blocks(&root)
337            .context("loading local content_blocks for tag aggregation")?;
338        for cb in &blocks {
339            for t in &cb.tags {
340                tags.insert(t.clone());
341            }
342        }
343    }
344
345    if resolved.resources.email_template.enabled {
346        let root = config_dir.join(&resolved.resources.email_template.path);
347        let templates = crate::fs::email_template_io::load_all_email_templates(&root)
348            .context("loading local email_templates for tag aggregation")?;
349        for et in &templates {
350            for t in &et.tags {
351                tags.insert(t.clone());
352            }
353        }
354    }
355
356    Ok(tags)
357}
358
359async fn export_custom_attributes(
360    client: &BrazeClient,
361    registry_path: &Path,
362    excludes: &[Regex],
363) -> anyhow::Result<usize> {
364    let attrs: Vec<_> = client
365        .list_custom_attributes()
366        .await?
367        .into_iter()
368        .filter(|a| !is_excluded(&a.name, excludes))
369        .collect();
370    let count = attrs.len();
371    let registry = CustomAttributeRegistry { attributes: attrs };
372    custom_attribute_io::save_registry(registry_path, &registry)?;
373    Ok(count)
374}