1use 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 #[arg(long, value_name = "ENV")]
42 pub from_env: String,
43
44 #[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 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 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 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 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 canonical.save(&canonical_path)?;
265 let mut written_skeletons: Vec<(String, PathBuf)> = Vec::new();
266 for (env, path, skeleton) in &skeleton_paths {
267 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
392impl ValuesFile {
396 pub fn skeleton_clone(&self) -> ValuesFile {
397 let mut out = ValuesFile {
398 version: self.version,
399 ..Default::default()
400 };
401 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 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}