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::extract_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 !extract_placeholders(&local.content).is_empty() {
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 = !extract_placeholders(&local.subject).is_empty();
273            let body_html_has = !extract_placeholders(&local.body_html).is_empty();
274            let body_plain_has = !extract_placeholders(&local.body_plaintext).is_empty();
275            let preheader_has = local
276                .preheader
277                .as_deref()
278                .is_some_and(|p| !extract_placeholders(p).is_empty());
279            if subject_has {
280                to_save.subject = local.subject.clone();
281            }
282            if body_html_has {
283                to_save.body_html = local.body_html.clone();
284            }
285            if body_plain_has {
286                to_save.body_plaintext = local.body_plaintext.clone();
287            }
288            if preheader_has {
289                to_save.preheader = local.preheader.clone();
290            }
291        }
292        email_template_io::save_email_template(email_templates_root, &to_save)?;
293    }
294    Ok(templates.len())
295}
296
297/// Aggregate tag names from local content_block + email_template files.
298///
299/// Braze does not expose a public REST API for workspace tags, so the
300/// registry is derived from the local Git state instead of a remote
301/// list. Operators are expected to run regular `export` first (to refresh
302/// content_block / email_template files), then `export tag` to rebuild
303/// the registry from the freshly-synced frontmatter. Tags found here are
304/// the union of every `tags:` array on every local resource, minus
305/// `tag.exclude_patterns`.
306fn export_tags(
307    config_dir: &Path,
308    resolved: &ResolvedConfig,
309    registry_path: &Path,
310    excludes: &[Regex],
311) -> anyhow::Result<usize> {
312    let referenced = collect_local_tag_references(config_dir, resolved)?;
313    let tags: Vec<Tag> = referenced
314        .into_iter()
315        .filter(|name| !is_excluded(name, excludes))
316        .map(|name| Tag {
317            name,
318            description: None,
319        })
320        .collect();
321    let count = tags.len();
322    let registry = TagRegistry { tags };
323    tag_io::save_registry(registry_path, &registry)?;
324    Ok(count)
325}
326
327/// Walk every local resource directory the config knows about and
328/// collect the union of `tags:` referenced on the resources. Used by
329/// both `export` (to rebuild registry) and `apply`/`validate`
330/// (to cross-check the registry against actual usage).
331pub(crate) fn collect_local_tag_references(
332    config_dir: &Path,
333    resolved: &ResolvedConfig,
334) -> anyhow::Result<BTreeSet<String>> {
335    let mut tags: BTreeSet<String> = BTreeSet::new();
336
337    if resolved.resources.content_block.enabled {
338        let root = config_dir.join(&resolved.resources.content_block.path);
339        let blocks = content_block_io::load_all_content_blocks(&root)
340            .context("loading local content_blocks for tag aggregation")?;
341        for cb in &blocks {
342            for t in &cb.tags {
343                tags.insert(t.clone());
344            }
345        }
346    }
347
348    if resolved.resources.email_template.enabled {
349        let root = config_dir.join(&resolved.resources.email_template.path);
350        let templates = crate::fs::email_template_io::load_all_email_templates(&root)
351            .context("loading local email_templates for tag aggregation")?;
352        for et in &templates {
353            for t in &et.tags {
354                tags.insert(t.clone());
355            }
356        }
357    }
358
359    Ok(tags)
360}
361
362async fn export_custom_attributes(
363    client: &BrazeClient,
364    registry_path: &Path,
365    excludes: &[Regex],
366) -> anyhow::Result<usize> {
367    let attrs: Vec<_> = client
368        .list_custom_attributes()
369        .await?
370        .into_iter()
371        .filter(|a| !is_excluded(&a.name, excludes))
372        .collect();
373    let count = attrs.len();
374    let registry = CustomAttributeRegistry { attributes: attrs };
375    custom_attribute_io::save_registry(registry_path, &registry)?;
376    Ok(count)
377}