Skip to main content

braze_sync/cli/
templatize.rs

1//! `braze-sync templatize` — one-shot migration from raw lid/cb_id
2//! bodies to templated bodies + per-env values (RFC §2.7).
3//!
4//! Operates on local files only — no Braze API calls. Validates that
5//! `--from-env` is declared in the config, walks every local
6//! content_block and email_template, rewrites detected `lid` /
7//! `cb_id` occurrences to `__BRAZESYNC.<type>.<key>__` placeholders,
8//! and writes:
9//!   - `values/<from-env>.yaml` with the actual lid / cb_id values
10//!     observed in the local body (the canonical env);
11//!   - `values/<other-env>.yaml` for every other env in the config,
12//!     as a *skeleton* with the same key structure but `value: null`
13//!     (RFC §2.7 step 6).
14//!
15//! Skeletons signal "needs `braze-sync export --env=<other-env>`" to
16//! the per-env apply pre-flight — `value: null` survives shape
17//! validation (§5 Edge cases) and abort is delegated to the unresolved
18//! placeholder check.
19
20use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22
23use anyhow::{anyhow, Context as _};
24use clap::Args;
25
26use crate::config::ConfigFile;
27use crate::error::Error;
28use crate::fs::{content_block_io, email_template_io};
29use crate::resource::{ContentBlock, EmailTemplate};
30use crate::values::schema::{
31    CbIdEntry, ContentBlockValues, FieldValues, LidEntry, SUPPORTED_VERSION,
32};
33use crate::values::templatize::{templatize_body, DetectedEntry, FieldKind};
34use crate::values::ValuesFile;
35
36#[derive(Args, Debug)]
37pub struct TemplatizeArgs {
38    /// Canonical environment whose current body values are treated as
39    /// truth and copied into `values/<from-env>.yaml`. All other envs
40    /// declared in the config get a `value: null` skeleton file.
41    #[arg(long, value_name = "ENV")]
42    pub from_env: String,
43
44    /// Preview-only mode. Walks the same code path but does not touch
45    /// any file on disk. Prints a summary of what would change.
46    #[arg(long)]
47    pub dry_run: bool,
48}
49
50pub async fn run(args: &TemplatizeArgs, cfg: &ConfigFile, config_dir: &Path) -> anyhow::Result<()> {
51    if !cfg.environments.contains_key(&args.from_env) {
52        let known: Vec<&str> = cfg.environments.keys().map(String::as_str).collect();
53        return Err(anyhow!(
54            "unknown --from-env '{}'; declared envs: [{}]",
55            args.from_env,
56            known.join(", ")
57        ));
58    }
59
60    let content_blocks_root = config_dir.join(&cfg.resources.content_block.path);
61    let email_templates_root = config_dir.join(&cfg.resources.email_template.path);
62
63    // If a values file for the canonical env already exists, load it as
64    // the base so re-runs preserve operator-curated content (e.g.
65    // `globals.custom`, entries for resources not touched this run,
66    // and existing placeholder keys from earlier partial migrations).
67    // Fresh runs start from an empty document.
68    let canonical_path = values_path_for(config_dir, cfg, &args.from_env);
69    let mut canonical = if canonical_path.exists() {
70        ValuesFile::load(&canonical_path).with_context(|| {
71            format!(
72                "loading existing canonical values file {} before merge",
73                canonical_path.display()
74            )
75        })?
76    } else {
77        ValuesFile {
78            version: SUPPORTED_VERSION,
79            ..Default::default()
80        }
81    };
82    let mut summary = RunSummary::default();
83    let mut content_block_rewrites: Vec<(PathBuf, ContentBlock)> = Vec::new();
84    let mut email_template_rewrites: Vec<(PathBuf, EmailTemplate)> = Vec::new();
85
86    if cfg.resources.content_block.enabled && content_blocks_root.exists() {
87        let blocks = content_block_io::load_all_content_blocks(&content_blocks_root)
88            .context("loading local content_blocks for templatize")?;
89        for mut cb in blocks {
90            let result = templatize_body(&cb.content, FieldKind::ContentBlock);
91            if result.entries.is_empty() && cb.content.contains("__BRAZESYNC.") {
92                summary.skipped.push(format!(
93                    "content_block '{}' already templated — skipping",
94                    cb.name
95                ));
96                continue;
97            }
98            if result.entries.is_empty() {
99                // No placeholders, nothing to do.
100                continue;
101            }
102
103            let entry = canonical.content_block.entry(cb.name.clone()).or_default();
104            apply_entries_content_block(entry, &result.entries);
105            for w in &result.warnings {
106                summary
107                    .warnings
108                    .push(format!("content_block '{}': {w}", cb.name));
109            }
110            summary.touched_resources += 1;
111            summary.lid_rewrites += count_lid(&result.entries);
112            summary.cb_id_rewrites += count_cb_id(&result.entries);
113
114            cb.content = result.new_body;
115            let target = content_blocks_root.join(format!("{}.liquid", cb.name));
116            content_block_rewrites.push((target, cb));
117        }
118    }
119
120    if cfg.resources.email_template.enabled && email_templates_root.exists() {
121        let templates = email_template_io::load_all_email_templates(&email_templates_root)
122            .context("loading local email_templates for templatize")?;
123        for mut et in templates {
124            let already_templated = et.subject.contains("__BRAZESYNC.")
125                || et.body_html.contains("__BRAZESYNC.")
126                || et.body_plaintext.contains("__BRAZESYNC.")
127                || et
128                    .preheader
129                    .as_deref()
130                    .is_some_and(|p| p.contains("__BRAZESYNC."));
131
132            let subject_r = templatize_body(&et.subject, FieldKind::EmailSubject);
133            let body_html_r = templatize_body(&et.body_html, FieldKind::EmailHtmlBody);
134            let body_plain_r = templatize_body(&et.body_plaintext, FieldKind::EmailPlainBody);
135            let preheader_r = et
136                .preheader
137                .as_ref()
138                .map(|p| templatize_body(p, FieldKind::EmailPreheader));
139
140            let any_rewrite = !(subject_r.entries.is_empty()
141                && body_html_r.entries.is_empty()
142                && body_plain_r.entries.is_empty()
143                && preheader_r.as_ref().is_none_or(|r| r.entries.is_empty()));
144
145            if !any_rewrite {
146                if already_templated {
147                    summary.skipped.push(format!(
148                        "email_template '{}' already templated — skipping",
149                        et.name
150                    ));
151                }
152                continue;
153            }
154
155            let entry = canonical.email_template.entry(et.name.clone()).or_default();
156            apply_entries_email_template_field(
157                &mut entry.subject,
158                &subject_r.entries,
159                &mut summary.warnings,
160                &et.name,
161                "subject",
162                &subject_r.warnings,
163            );
164            apply_entries_email_template_field(
165                &mut entry.body_html,
166                &body_html_r.entries,
167                &mut summary.warnings,
168                &et.name,
169                "body_html",
170                &body_html_r.warnings,
171            );
172            apply_entries_email_template_field(
173                &mut entry.body_plaintext,
174                &body_plain_r.entries,
175                &mut summary.warnings,
176                &et.name,
177                "body_plaintext",
178                &body_plain_r.warnings,
179            );
180            if let Some(r) = preheader_r.as_ref() {
181                apply_entries_email_template_field(
182                    &mut entry.preheader,
183                    &r.entries,
184                    &mut summary.warnings,
185                    &et.name,
186                    "preheader",
187                    &r.warnings,
188                );
189            }
190
191            summary.touched_resources += 1;
192            summary.lid_rewrites += count_lid(&subject_r.entries)
193                + count_lid(&body_html_r.entries)
194                + count_lid(&body_plain_r.entries)
195                + preheader_r
196                    .as_ref()
197                    .map(|r| count_lid(&r.entries))
198                    .unwrap_or(0);
199            summary.cb_id_rewrites += count_cb_id(&subject_r.entries)
200                + count_cb_id(&body_html_r.entries)
201                + count_cb_id(&body_plain_r.entries)
202                + preheader_r
203                    .as_ref()
204                    .map(|r| count_cb_id(&r.entries))
205                    .unwrap_or(0);
206
207            et.subject = subject_r.new_body;
208            et.body_html = body_html_r.new_body;
209            et.body_plaintext = body_plain_r.new_body;
210            if let Some(r) = preheader_r {
211                et.preheader = Some(r.new_body);
212            }
213            email_template_rewrites.push((email_templates_root.join(&et.name), et));
214        }
215    }
216
217    // Build skeleton file contents for every non-canonical env. Same
218    // key structure, same correlation metadata, but `value: null` so
219    // apply pre-flight aborts until export populates it.
220    let mut skeleton_paths: Vec<(String, PathBuf, ValuesFile)> = Vec::new();
221    for env_name in cfg.environments.keys() {
222        if env_name == &args.from_env {
223            continue;
224        }
225        let skeleton = canonical.skeleton_clone();
226        let path = values_path_for(config_dir, cfg, env_name);
227        skeleton_paths.push((env_name.clone(), path, skeleton));
228    }
229
230    // Emit summary to stderr regardless of dry-run.
231    eprintln!("templatize summary (--from-env={}):", args.from_env);
232    eprintln!(
233        "  • touched {} resource(s); {} lid + {} cb_id rewrite(s)",
234        summary.touched_resources, summary.lid_rewrites, summary.cb_id_rewrites
235    );
236    for s in &summary.skipped {
237        eprintln!("  • {s}");
238    }
239    for w in &summary.warnings {
240        eprintln!("  ⚠ {w}");
241    }
242
243    if summary.touched_resources == 0 {
244        eprintln!("nothing to templatize.");
245        return Ok(());
246    }
247
248    if args.dry_run {
249        eprintln!("(dry-run) would write:");
250        eprintln!("  • {}", canonical_path.display());
251        for (env, path, _) in &skeleton_paths {
252            eprintln!("  • {} (skeleton for env '{}')", path.display(), env);
253        }
254        eprintln!(
255            "  • {} resource file(s) rewritten in place",
256            content_block_rewrites.len() + email_template_rewrites.len()
257        );
258        return Ok(());
259    }
260
261    // Write the canonical values file first — apply / diff pre-flight
262    // depends on it, and a failed rewrite later still leaves a usable
263    // values file for the from-env.
264    canonical.save(&canonical_path)?;
265    let mut written_skeletons: Vec<(String, PathBuf)> = Vec::new();
266    for (env, path, skeleton) in &skeleton_paths {
267        // Refuse to overwrite an existing skeleton: a user may have
268        // already exported real values for that env.
269        if path.exists() {
270            eprintln!(
271                "  • skipping skeleton for env '{}': {} already exists",
272                env,
273                path.display()
274            );
275            continue;
276        }
277        skeleton.save(path)?;
278        written_skeletons.push((env.clone(), path.clone()));
279    }
280    for (path, cb) in &content_block_rewrites {
281        content_block_io::save_content_block(path.parent().unwrap_or_else(|| Path::new(".")), cb)?;
282    }
283    for (_, et) in &email_template_rewrites {
284        email_template_io::save_email_template(&email_templates_root, et)?;
285    }
286
287    eprintln!("✓ templatize: wrote {}", canonical_path.display());
288    for (env, path) in &written_skeletons {
289        eprintln!(
290            "✓ templatize: wrote {} (skeleton for '{}')",
291            path.display(),
292            env
293        );
294    }
295    Ok(())
296}
297
298#[derive(Default)]
299struct RunSummary {
300    touched_resources: usize,
301    lid_rewrites: usize,
302    cb_id_rewrites: usize,
303    skipped: Vec<String>,
304    warnings: Vec<String>,
305}
306
307fn count_lid(entries: &[DetectedEntry]) -> usize {
308    entries
309        .iter()
310        .filter(|e| matches!(e, DetectedEntry::Lid { .. }))
311        .count()
312}
313fn count_cb_id(entries: &[DetectedEntry]) -> usize {
314    entries
315        .iter()
316        .filter(|e| matches!(e, DetectedEntry::CbId { .. }))
317        .count()
318}
319
320fn apply_entries_content_block(cb_values: &mut ContentBlockValues, entries: &[DetectedEntry]) {
321    for entry in entries {
322        match entry {
323            DetectedEntry::Lid { key, value, url } => {
324                cb_values.lid.insert(
325                    key.clone(),
326                    LidEntry {
327                        value: Some(value.clone()),
328                        url: url.clone(),
329                        anchor: None,
330                    },
331                );
332            }
333            DetectedEntry::CbId { key, value, .. } => {
334                cb_values.cb_id.insert(
335                    key.clone(),
336                    CbIdEntry {
337                        value: Some(value.clone()),
338                    },
339                );
340            }
341        }
342    }
343}
344
345fn apply_entries_email_template_field(
346    field: &mut FieldValues,
347    entries: &[DetectedEntry],
348    out_warnings: &mut Vec<String>,
349    et_name: &str,
350    field_name: &str,
351    field_warnings: &[String],
352) {
353    for entry in entries {
354        match entry {
355            DetectedEntry::Lid { key, value, url } => {
356                field.lid.insert(
357                    key.clone(),
358                    LidEntry {
359                        value: Some(value.clone()),
360                        url: url.clone(),
361                        anchor: None,
362                    },
363                );
364            }
365            DetectedEntry::CbId { key, value, .. } => {
366                field.cb_id.insert(
367                    key.clone(),
368                    CbIdEntry {
369                        value: Some(value.clone()),
370                    },
371                );
372            }
373        }
374    }
375    for w in field_warnings {
376        out_warnings.push(format!("email_template '{et_name}' ({field_name}): {w}"));
377    }
378}
379
380fn values_path_for(config_dir: &Path, cfg: &ConfigFile, env_name: &str) -> PathBuf {
381    if let Some(env) = cfg.environments.get(env_name) {
382        if let Some(custom) = &env.values_file {
383            if custom.is_absolute() {
384                return custom.clone();
385            }
386            return config_dir.join(custom);
387        }
388    }
389    crate::values::schema::default_values_path(config_dir, env_name)
390}
391
392/// Build a `value: null` skeleton with the same key + correlation
393/// metadata as `self`. Used by templatize to pre-populate per-env
394/// files for non-canonical envs (RFC §2.7 step 6).
395impl ValuesFile {
396    pub fn skeleton_clone(&self) -> ValuesFile {
397        let mut out = ValuesFile {
398            version: self.version,
399            ..Default::default()
400        };
401        // globals.custom keeps its keys (user-managed) but values blank.
402        for k in self.globals.custom.keys() {
403            out.globals.custom.insert(
404                k.clone(),
405                crate::values::schema::CustomEntry { value: None },
406            );
407        }
408        for (name, src) in &self.content_block {
409            let dst = out.content_block.entry(name.clone()).or_default();
410            for (k, e) in &src.lid {
411                dst.lid.insert(
412                    k.clone(),
413                    LidEntry {
414                        value: None,
415                        url: e.url.clone(),
416                        anchor: e.anchor.clone(),
417                    },
418                );
419            }
420            for k in src.cb_id.keys() {
421                dst.cb_id.insert(k.clone(), CbIdEntry { value: None });
422            }
423            for k in src.custom.keys() {
424                dst.custom.insert(
425                    k.clone(),
426                    crate::values::schema::CustomEntry { value: None },
427                );
428            }
429        }
430        for (name, src) in &self.email_template {
431            let dst = out.email_template.entry(name.clone()).or_default();
432            for k in src.custom.keys() {
433                dst.custom.insert(
434                    k.clone(),
435                    crate::values::schema::CustomEntry { value: None },
436                );
437            }
438            skeleton_field(&src.subject, &mut dst.subject);
439            skeleton_field(&src.preheader, &mut dst.preheader);
440            skeleton_field(&src.body_html, &mut dst.body_html);
441            skeleton_field(&src.body_plaintext, &mut dst.body_plaintext);
442        }
443        out
444    }
445}
446
447fn skeleton_field(src: &FieldValues, dst: &mut FieldValues) {
448    for (k, e) in &src.lid {
449        dst.lid.insert(
450            k.clone(),
451            LidEntry {
452                value: None,
453                url: e.url.clone(),
454                anchor: e.anchor.clone(),
455            },
456        );
457    }
458    for k in src.cb_id.keys() {
459        dst.cb_id.insert(k.clone(), CbIdEntry { value: None });
460    }
461}
462
463#[allow(dead_code)]
464fn _used_imports() {
465    // Suppress unused-import warning for helpers that are only
466    // referenced indirectly through trait impls in tests/cli flows.
467    let _ = std::mem::size_of::<BTreeMap<String, ()>>();
468    let _ = std::mem::size_of::<Error>();
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    #[test]
476    fn skeleton_clone_preserves_keys_and_clears_values() {
477        let mut canonical = ValuesFile {
478            version: 1,
479            ..Default::default()
480        };
481        let cb = canonical
482            .content_block
483            .entry("promo".to_string())
484            .or_default();
485        cb.lid.insert(
486            "cta".to_string(),
487            LidEntry {
488                value: Some("ai8kexrxcp03".into()),
489                url: Some("https://example.com/cta".into()),
490                anchor: None,
491            },
492        );
493        cb.cb_id.insert(
494            "shared".to_string(),
495            CbIdEntry {
496                value: Some("cb42".into()),
497            },
498        );
499
500        let skel = canonical.skeleton_clone();
501        let cb = &skel.content_block["promo"];
502        assert!(cb.lid["cta"].value.is_none());
503        assert_eq!(
504            cb.lid["cta"].url.as_deref(),
505            Some("https://example.com/cta")
506        );
507        assert!(cb.cb_id["shared"].value.is_none());
508    }
509}