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::{
11    extract_placeholders, refresh_content_block_values, refresh_email_template_values,
12    values_file_path, ExportUpdates, ValuesFile,
13};
14use anyhow::Context as _;
15use clap::Args;
16use futures::stream::{StreamExt, TryStreamExt};
17use regex_lite::Regex;
18use std::collections::BTreeSet;
19use std::path::Path;
20
21use super::{selected_kinds, warn_if_name_excluded, FETCH_CONCURRENCY};
22
23#[derive(Args, Debug, Default)]
24pub struct ExportArgs {
25    /// Limit export to a specific resource kind. Omit to export every
26    /// enabled resource kind in turn.
27    #[arg(long, value_enum)]
28    pub resource: Option<ResourceKind>,
29
30    /// When `--resource` is given, optionally restrict to a single named
31    /// resource. Requires `--resource`.
32    #[arg(long, requires = "resource")]
33    pub name: Option<String>,
34}
35
36pub async fn run(
37    args: &ExportArgs,
38    resolved: ResolvedConfig,
39    config_dir: &Path,
40) -> anyhow::Result<()> {
41    let catalogs_root = config_dir.join(&resolved.resources.catalog_schema.path);
42    let content_blocks_root = config_dir.join(&resolved.resources.content_block.path);
43    let email_templates_root = config_dir.join(&resolved.resources.email_template.path);
44    let custom_attributes_path = config_dir.join(&resolved.resources.custom_attribute.path);
45    let tags_path = config_dir.join(&resolved.resources.tag.path);
46    let client = BrazeClient::from_resolved(&resolved);
47    let kinds = selected_kinds(args.resource, &resolved.resources);
48
49    // Load existing values file so per-resource refresh can extend it
50    // in place. Absent file is fine — it will only be written back if
51    // at least one resource has placeholders.
52    let values_path = values_file_path(config_dir, &resolved);
53    // Only the placeholder-bearing kinds consume the values file. Loading
54    // it unconditionally would abort exports of unrelated kinds (tag,
55    // catalog_schema, custom_attribute) whenever a pre-existing values
56    // file is malformed — see preflight_values in integration.rs for the
57    // matching gate.
58    let needs_values = kinds
59        .iter()
60        .any(|k| matches!(k, ResourceKind::ContentBlock | ResourceKind::EmailTemplate));
61    let mut values = if needs_values && values_path.exists() {
62        ValuesFile::load(&values_path)?
63    } else {
64        ValuesFile {
65            version: crate::values::schema::SUPPORTED_VERSION,
66            ..Default::default()
67        }
68    };
69    let mut all_updates = ExportUpdates::default();
70
71    let mut total_written: usize = 0;
72    for kind in kinds {
73        // `custom_attribute` ignores `--name` (registry is a single file),
74        // so skipping by exclude match before dispatching wouldn't fit —
75        // handle it per-arm alongside the existing --name warning.
76        if !matches!(kind, ResourceKind::CustomAttribute | ResourceKind::Tag)
77            && warn_if_name_excluded(kind, args.name.as_deref(), resolved.excludes_for(kind))
78        {
79            continue;
80        }
81        match kind {
82            ResourceKind::CatalogSchema => {
83                let n = export_catalog_schemas(
84                    &client,
85                    &catalogs_root,
86                    args.name.as_deref(),
87                    resolved.excludes_for(ResourceKind::CatalogSchema),
88                )
89                .await
90                .context("exporting catalog_schema")?;
91                eprintln!("✓ catalog_schema: exported {n} resource(s)");
92                total_written += n;
93            }
94            ResourceKind::ContentBlock => {
95                let (n, updates) = export_content_blocks(
96                    &client,
97                    &content_blocks_root,
98                    args.name.as_deref(),
99                    resolved.excludes_for(ResourceKind::ContentBlock),
100                    &mut values,
101                )
102                .await
103                .context("exporting content_block")?;
104                all_updates.merge(updates);
105                eprintln!("✓ content_block: exported {n} resource(s)");
106                total_written += n;
107            }
108            ResourceKind::EmailTemplate => {
109                let (n, updates) = export_email_templates(
110                    &client,
111                    &email_templates_root,
112                    args.name.as_deref(),
113                    resolved.excludes_for(ResourceKind::EmailTemplate),
114                    &mut values,
115                )
116                .await
117                .context("exporting email_template")?;
118                all_updates.merge(updates);
119                eprintln!("✓ email_template: exported {n} resource(s)");
120                total_written += n;
121            }
122            ResourceKind::CustomAttribute => {
123                if args.name.is_some() {
124                    eprintln!(
125                        "⚠ custom_attribute: --name is not supported for export \
126                         (the registry is a single file); exporting all attributes"
127                    );
128                }
129                let n = export_custom_attributes(
130                    &client,
131                    &custom_attributes_path,
132                    resolved.excludes_for(ResourceKind::CustomAttribute),
133                )
134                .await
135                .context("exporting custom_attribute")?;
136                eprintln!("✓ custom_attribute: exported {n} attribute(s)");
137                total_written += n;
138            }
139            ResourceKind::Tag => {
140                if args.name.is_some() {
141                    eprintln!(
142                        "⚠ tag: --name is not supported for export \
143                         (the registry is a single file); exporting all tags"
144                    );
145                }
146                let n = export_tags(
147                    config_dir,
148                    &resolved,
149                    &tags_path,
150                    resolved.excludes_for(ResourceKind::Tag),
151                )
152                .context("exporting tag")?;
153                eprintln!("✓ tag: exported {n} tag(s)");
154                total_written += n;
155            }
156        }
157    }
158
159    // Write the values file back if any resource contributed updates.
160    // Empty maps still serialize cleanly, so we guard on "did we
161    // actually touch anything" to avoid creating an empty file for
162    // workspaces that don't use placeholders at all.
163    if all_updates.lid_updates + all_updates.cb_id_updates > 0 {
164        values.save(&values_path)?;
165        eprintln!(
166            "✓ values: refreshed {} lid + {} cb_id entry(ies) in {}",
167            all_updates.lid_updates,
168            all_updates.cb_id_updates,
169            values_path.display()
170        );
171    }
172    for w in &all_updates.orphan_warnings {
173        eprintln!("⚠ {w}");
174    }
175    for w in &all_updates.missing_entry_warnings {
176        eprintln!("⚠ {w}");
177    }
178    for w in &all_updates.ambiguity_warnings {
179        eprintln!("⚠ {w}");
180    }
181
182    eprintln!("done: {total_written} resource(s) written");
183    Ok(())
184}
185
186async fn export_catalog_schemas(
187    client: &BrazeClient,
188    catalogs_root: &Path,
189    name_filter: Option<&str>,
190    excludes: &[Regex],
191) -> anyhow::Result<usize> {
192    let catalogs = match name_filter {
193        Some(name) => match client.get_catalog(name).await {
194            Ok(c) => vec![c],
195            // Missing remote is informational, not a hard error.
196            Err(BrazeApiError::NotFound { .. }) => {
197                eprintln!("⚠ catalog_schema: '{name}' not found in Braze");
198                Vec::new()
199            }
200            Err(e) => return Err(e.into()),
201        },
202        None => client.list_catalogs().await?,
203    };
204
205    let filtered: Vec<_> = catalogs
206        .into_iter()
207        .filter(|c| !is_excluded(&c.name, excludes))
208        .collect();
209    let count = filtered.len();
210    for cat in filtered {
211        catalog_io::save_schema(catalogs_root, &cat)?;
212    }
213    Ok(count)
214}
215
216/// Lists first to discover ids, then fetches `/info` per block. With
217/// `--name`, the list still happens (to translate name → id) but only
218/// the matching block's body is fetched.
219///
220/// When a local template with `__BRAZESYNC.*__` placeholders exists for
221/// a given name, this function preserves the local body (the
222/// templatized form is the source of truth) and instead refreshes the
223/// per-env values entries from the remote body (RFC §2.5 "既存リソース").
224async fn export_content_blocks(
225    client: &BrazeClient,
226    content_blocks_root: &Path,
227    name_filter: Option<&str>,
228    excludes: &[Regex],
229    values: &mut ValuesFile,
230) -> anyhow::Result<(usize, ExportUpdates)> {
231    let summaries = client.list_content_blocks().await?;
232    let targets: Vec<_> = summaries
233        .into_iter()
234        .filter(|s| name_filter.is_none_or(|n| s.name == n))
235        .filter(|s| !is_excluded(&s.name, excludes))
236        .collect();
237
238    if targets.is_empty() {
239        if let Some(name) = name_filter {
240            eprintln!("⚠ content_block: '{name}' not found in Braze");
241        }
242        return Ok((0, ExportUpdates::default()));
243    }
244
245    let blocks: Vec<ContentBlock> = futures::stream::iter(targets.iter().map(|s| {
246        let name = s.name.as_str();
247        let id = s.content_block_id.as_str();
248        async move {
249            client
250                .get_content_block(id)
251                .await
252                .with_context(|| format!("fetching content block '{name}'"))
253        }
254    }))
255    .buffer_unordered(FETCH_CONCURRENCY)
256    .try_collect()
257    .await?;
258
259    let mut updates = ExportUpdates::default();
260    for remote in &blocks {
261        let local_path = content_blocks_root.join(format!("{}.liquid", remote.name));
262        let local = if local_path.exists() {
263            Some(content_block_io::read_content_block_file(&local_path)?)
264        } else {
265            None
266        };
267
268        let mut to_save = remote.clone();
269        if let Some(local) = local.as_ref() {
270            // Strict parse — a substring check would match documentation
271            // comments that happen to mention the placeholder convention
272            // and silently suppress remote-body updates for non-templated
273            // resources.
274            if !extract_placeholders(&local.content).is_empty() {
275                let report = refresh_content_block_values(local, remote, values);
276                updates.merge(report);
277                // Source of truth for templated bodies is local — write
278                // it back unchanged so placeholders survive the round trip.
279                to_save.content = local.content.clone();
280            }
281        }
282        content_block_io::save_content_block(content_blocks_root, &to_save)?;
283    }
284    Ok((blocks.len(), updates))
285}
286
287/// Same list-then-fetch pattern as content blocks. Per-field placeholder
288/// preservation: each of `subject`, `body_html`, `body_plaintext`,
289/// `preheader` is independently checked for `__BRAZESYNC.*__` content
290/// and, when present, kept from the local template instead of being
291/// overwritten by the remote body.
292async fn export_email_templates(
293    client: &BrazeClient,
294    email_templates_root: &Path,
295    name_filter: Option<&str>,
296    excludes: &[Regex],
297    values: &mut ValuesFile,
298) -> anyhow::Result<(usize, ExportUpdates)> {
299    let summaries = client.list_email_templates().await?;
300    let targets: Vec<_> = summaries
301        .into_iter()
302        .filter(|s| name_filter.is_none_or(|n| s.name == n))
303        .filter(|s| !is_excluded(&s.name, excludes))
304        .collect();
305
306    if targets.is_empty() {
307        if let Some(name) = name_filter {
308            eprintln!("⚠ email_template: '{name}' not found in Braze");
309        }
310        return Ok((0, ExportUpdates::default()));
311    }
312
313    let templates: Vec<EmailTemplate> = futures::stream::iter(targets.iter().map(|s| {
314        let name = s.name.as_str();
315        let id = s.email_template_id.as_str();
316        async move {
317            client
318                .get_email_template(id)
319                .await
320                .with_context(|| format!("fetching email template '{name}'"))
321        }
322    }))
323    .buffer_unordered(FETCH_CONCURRENCY)
324    .try_collect()
325    .await?;
326
327    let mut updates = ExportUpdates::default();
328    for remote in &templates {
329        let local_dir = email_templates_root.join(&remote.name);
330        let local = if local_dir.is_dir() {
331            Some(email_template_io::read_email_template_dir(&local_dir)?)
332        } else {
333            None
334        };
335
336        let mut to_save = remote.clone();
337        if let Some(local) = local.as_ref() {
338            // Strict parse per field — see content_block path for the
339            // same rationale (substring match is too eager).
340            let subject_has = !extract_placeholders(&local.subject).is_empty();
341            let body_html_has = !extract_placeholders(&local.body_html).is_empty();
342            let body_plain_has = !extract_placeholders(&local.body_plaintext).is_empty();
343            let preheader_has = local
344                .preheader
345                .as_deref()
346                .is_some_and(|p| !extract_placeholders(p).is_empty());
347            if subject_has || body_html_has || body_plain_has || preheader_has {
348                let report = refresh_email_template_values(local, remote, values);
349                updates.merge(report);
350                // Preserve per-field local body when it contains a
351                // placeholder. Fields without placeholders take the
352                // remote value so non-templated drift still round-trips.
353                if subject_has {
354                    to_save.subject = local.subject.clone();
355                }
356                if body_html_has {
357                    to_save.body_html = local.body_html.clone();
358                }
359                if body_plain_has {
360                    to_save.body_plaintext = local.body_plaintext.clone();
361                }
362                if preheader_has {
363                    to_save.preheader = local.preheader.clone();
364                }
365            }
366        }
367        email_template_io::save_email_template(email_templates_root, &to_save)?;
368    }
369    Ok((templates.len(), updates))
370}
371
372/// Aggregate tag names from local content_block + email_template files.
373///
374/// Braze does not expose a public REST API for workspace tags, so the
375/// registry is derived from the local Git state instead of a remote
376/// list. Operators are expected to run regular `export` first (to refresh
377/// content_block / email_template files), then `export tag` to rebuild
378/// the registry from the freshly-synced frontmatter. Tags found here are
379/// the union of every `tags:` array on every local resource, minus
380/// `tag.exclude_patterns`.
381fn export_tags(
382    config_dir: &Path,
383    resolved: &ResolvedConfig,
384    registry_path: &Path,
385    excludes: &[Regex],
386) -> anyhow::Result<usize> {
387    let referenced = collect_local_tag_references(config_dir, resolved)?;
388    let tags: Vec<Tag> = referenced
389        .into_iter()
390        .filter(|name| !is_excluded(name, excludes))
391        .map(|name| Tag {
392            name,
393            description: None,
394        })
395        .collect();
396    let count = tags.len();
397    let registry = TagRegistry { tags };
398    tag_io::save_registry(registry_path, &registry)?;
399    Ok(count)
400}
401
402/// Walk every local resource directory the config knows about and
403/// collect the union of `tags:` referenced on the resources. Used by
404/// both `export` (to rebuild registry) and `apply`/`validate`
405/// (to cross-check the registry against actual usage).
406pub(crate) fn collect_local_tag_references(
407    config_dir: &Path,
408    resolved: &ResolvedConfig,
409) -> anyhow::Result<BTreeSet<String>> {
410    let mut tags: BTreeSet<String> = BTreeSet::new();
411
412    if resolved.resources.content_block.enabled {
413        let root = config_dir.join(&resolved.resources.content_block.path);
414        let blocks = content_block_io::load_all_content_blocks(&root)
415            .context("loading local content_blocks for tag aggregation")?;
416        for cb in &blocks {
417            for t in &cb.tags {
418                tags.insert(t.clone());
419            }
420        }
421    }
422
423    if resolved.resources.email_template.enabled {
424        let root = config_dir.join(&resolved.resources.email_template.path);
425        let templates = crate::fs::email_template_io::load_all_email_templates(&root)
426            .context("loading local email_templates for tag aggregation")?;
427        for et in &templates {
428            for t in &et.tags {
429                tags.insert(t.clone());
430            }
431        }
432    }
433
434    Ok(tags)
435}
436
437async fn export_custom_attributes(
438    client: &BrazeClient,
439    registry_path: &Path,
440    excludes: &[Regex],
441) -> anyhow::Result<usize> {
442    let attrs: Vec<_> = client
443        .list_custom_attributes()
444        .await?
445        .into_iter()
446        .filter(|a| !is_excluded(&a.name, excludes))
447        .collect();
448    let count = attrs.len();
449    let registry = CustomAttributeRegistry { attributes: attrs };
450    custom_attribute_io::save_registry(registry_path, &registry)?;
451    Ok(count)
452}