braze-sync 0.16.0

GitOps CLI for managing Braze configuration as code
Documentation
//! Wiring layer between [`crate::values`] and the diff / apply pipeline.

use crate::resource::{ContentBlock, EmailTemplate};
use crate::values::braze_managed::prepare_field;
use crate::values::placeholder::ResolutionError;
use crate::values::templatize::FieldKind;

/// One resource's worth of placeholder failures, ready to be folded
/// into a top-level error message.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolutionFailure {
    pub resource_kind: &'static str,
    pub resource_name: String,
    /// `Some(field)` for email_template fields, `None` for content_block.
    pub field: Option<&'static str>,
    pub errors: Vec<ResolutionError>,
}

/// Resolve every `__BRAZESYNC__` in `cb.content`.
///
/// `remote` provides the live lid / cb_id values; pass `None` for new
/// resources (lid → URL slug, cb_id filter stripped — see
/// [`prepare_field`]).
pub fn resolve_content_block_with_remote(
    cb: &mut ContentBlock,
    remote: Option<&ContentBlock>,
) -> std::result::Result<(), ResolutionFailure> {
    if !needs_resolve(&cb.content) {
        return Ok(());
    }
    let prep = prepare_field(
        &cb.content,
        remote.map(|r| r.content.as_str()),
        FieldKind::ContentBlock,
    );
    emit_prep_warnings("content_block", &cb.name, None, &prep.warnings);
    if !prep.errors.is_empty() {
        return Err(ResolutionFailure {
            resource_kind: "content_block",
            resource_name: cb.name.clone(),
            field: None,
            errors: prep.errors,
        });
    }
    cb.content = prep.body;
    Ok(())
}

/// Resolve placeholders across every Liquid-bearing field of `et`.
pub fn resolve_email_template_with_remote(
    et: &mut EmailTemplate,
    remote: Option<&EmailTemplate>,
) -> std::result::Result<(), Vec<ResolutionFailure>> {
    let mut failures: Vec<ResolutionFailure> = Vec::new();

    macro_rules! resolve_field {
        ($field_name:expr, $field_kind:expr, $accessor:expr, $remote_accessor:expr) => {{
            let body: &str = $accessor;
            if needs_resolve(body) {
                let prep = prepare_field(body, $remote_accessor, $field_kind);
                emit_prep_warnings(
                    "email_template",
                    &et.name,
                    Some($field_name),
                    &prep.warnings,
                );
                if !prep.errors.is_empty() {
                    failures.push(ResolutionFailure {
                        resource_kind: "email_template",
                        resource_name: et.name.clone(),
                        field: Some($field_name),
                        errors: prep.errors,
                    });
                    None
                } else {
                    Some(prep.body)
                }
            } else {
                None
            }
        }};
    }

    let new_subject = resolve_field!(
        "subject",
        FieldKind::EmailSubject,
        et.subject.as_str(),
        remote.map(|r| r.subject.as_str())
    );
    let new_body_html = resolve_field!(
        "body_html",
        FieldKind::EmailHtmlBody,
        et.body_html.as_str(),
        remote.map(|r| r.body_html.as_str())
    );
    let new_body_plaintext = resolve_field!(
        "body_plaintext",
        FieldKind::EmailPlainBody,
        et.body_plaintext.as_str(),
        remote.map(|r| r.body_plaintext.as_str())
    );
    let new_preheader = match et.preheader.as_deref() {
        Some(s) => resolve_field!(
            "preheader",
            FieldKind::EmailPreheader,
            s,
            remote.and_then(|r| r.preheader.as_deref())
        ),
        None => None,
    };

    if !failures.is_empty() {
        return Err(failures);
    }

    if let Some(v) = new_subject {
        et.subject = v;
    }
    if let Some(v) = new_body_html {
        et.body_html = v;
    }
    if let Some(v) = new_body_plaintext {
        et.body_plaintext = v;
    }
    if let Some(v) = new_preheader {
        et.preheader = Some(v);
    }
    Ok(())
}

/// Cheap pre-filter: a body needs resolution if it carries the strict
/// token *or* a retired-namespace token we need to surface as an error.
fn needs_resolve(body: &str) -> bool {
    body.contains("__BRAZESYNC") || body.contains("__BRAZSYNC")
}

fn emit_prep_warnings(
    kind: &'static str,
    name: &str,
    field: Option<&'static str>,
    warnings: &[String],
) {
    if warnings.is_empty() {
        return;
    }
    let scope = match field {
        Some(f) => format!("{kind} '{name}' ({f})"),
        None => format!("{kind} '{name}'"),
    };
    for w in warnings {
        eprintln!("warning: {scope}: {w}");
    }
}

/// Format aggregated failures into a single human-readable error.
pub fn format_failures(failures: &[ResolutionFailure]) -> crate::error::Error {
    let mut msg = String::new();
    msg.push_str(&format!(
        "Cannot continue: {} placeholder resolution failure(s)\n",
        failures.iter().map(|f| f.errors.len()).sum::<usize>(),
    ));
    for f in failures {
        let scope = match f.field {
            Some(field) => format!("  {} '{}' ({}):", f.resource_kind, f.resource_name, field),
            None => format!("  {} '{}':", f.resource_kind, f.resource_name),
        };
        msg.push_str(&scope);
        msg.push('\n');
        for e in &f.errors {
            match e {
                ResolutionError::UnresolvedLid { start, anchor } => {
                    let where_ = anchor
                        .as_deref()
                        .map(|u| format!("URL '{u}'"))
                        .unwrap_or_else(|| "no URL anchor".to_string());
                    msg.push_str(&format!(
                        "    - offset {start}: lid `__BRAZESYNC__` ({where_}) — no anchor match in remote body\n",
                    ));
                }
                ResolutionError::UnresolvedCbId { start, name } => {
                    let n = name.as_deref().unwrap_or("<unknown>");
                    msg.push_str(&format!(
                        "    - offset {start}: cb_id `__BRAZESYNC__` (`${{{n}}}`) — no `${{{n}}}` include in remote body\n",
                    ));
                }
                ResolutionError::UnknownContext { start } => {
                    msg.push_str(&format!(
                        "    - offset {start}: `__BRAZESYNC__` outside `| lid:` / `| id:` argument — cannot infer type\n",
                    ));
                }
                ResolutionError::RetiredNamespace { token } => {
                    msg.push_str(&format!(
                        "    - {token}: retired placeholder syntax \
                         (v0.15 `__BRAZESYNC.<type>.<key>__` was removed in v0.16; \
                         re-run `braze-sync templatize` to regenerate)\n",
                    ));
                }
            }
        }
    }
    crate::error::Error::Config(msg)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::resource::content_block::ContentBlockState;

    fn cb(name: &str, content: &str) -> ContentBlock {
        ContentBlock {
            name: name.into(),
            description: None,
            content: content.into(),
            tags: Vec::new(),
            state: ContentBlockState::Active,
        }
    }

    fn et(name: &str) -> EmailTemplate {
        EmailTemplate {
            name: name.into(),
            subject: String::new(),
            body_html: String::new(),
            body_plaintext: String::new(),
            description: None,
            preheader: None,
            should_inline_css: None,
            tags: Vec::new(),
        }
    }

    #[test]
    fn no_placeholders_skips_resolution() {
        let mut block = cb("plain", "<p>hi there</p>");
        resolve_content_block_with_remote(&mut block, None).unwrap();
        assert_eq!(block.content, "<p>hi there</p>");
    }

    #[test]
    fn content_block_resolves_lid_from_remote() {
        let mut block = cb(
            "promo",
            r#"<a href="https://x.com/cta">{{x | lid: '__BRAZESYNC__'}}</a>"#,
        );
        let remote = cb(
            "promo",
            r#"<a href="https://x.com/cta">{{x | lid: 'newlidvalue1'}}</a>"#,
        );
        resolve_content_block_with_remote(&mut block, Some(&remote)).unwrap();
        assert!(block.content.contains("'newlidvalue1'"));
    }

    #[test]
    fn new_resource_lid_uses_url_slug() {
        let mut block = cb(
            "promo",
            r#"<a href="https://x.com/spring-sale">{{x | lid: '__BRAZESYNC__'}}</a>"#,
        );
        resolve_content_block_with_remote(&mut block, None).unwrap();
        assert!(
            block.content.contains("'spring_sale'"),
            "got: {}",
            block.content
        );
    }

    #[test]
    fn new_resource_cb_id_filter_is_stripped() {
        let mut block = cb("page", "{{content_blocks.${promo} | id: '__BRAZESYNC__'}}");
        resolve_content_block_with_remote(&mut block, None).unwrap();
        assert_eq!(block.content, "{{content_blocks.${promo}}}");
    }

    #[test]
    fn email_template_resolves_per_field() {
        let mut t = et("welcome");
        t.body_html = r#"<a href="https://x.com/cta">{{x | lid: '__BRAZESYNC__'}}</a>"#.into();
        let mut remote = et("welcome");
        remote.body_html = r#"<a href="https://x.com/cta">{{x | lid: 'newhtmllidx'}}</a>"#.into();
        resolve_email_template_with_remote(&mut t, Some(&remote)).unwrap();
        assert!(t.body_html.contains("'newhtmllidx'"));
    }

    #[test]
    fn missing_remote_anchor_surfaces_as_failure() {
        let mut block = cb(
            "promo",
            r#"<a href="https://x.com/cta">{{x | lid: '__BRAZESYNC__'}}</a>"#,
        );
        let remote = cb("promo", "<p>no anchor here</p>");
        let err = resolve_content_block_with_remote(&mut block, Some(&remote)).unwrap_err();
        assert_eq!(err.errors.len(), 1);
        assert!(matches!(
            err.errors[0],
            ResolutionError::UnresolvedLid { .. }
        ));
    }

    #[test]
    fn subject_lid_resolves_positionally_from_remote() {
        let mut t = et("promo");
        t.subject = "Spring sale {{x | lid: '__BRAZESYNC__'}}".into();
        let mut remote = et("promo");
        remote.subject = "Spring sale {{x | lid: 'subjectlid1'}}".into();
        resolve_email_template_with_remote(&mut t, Some(&remote)).unwrap();
        assert!(t.subject.contains("'subjectlid1'"));
    }

    #[test]
    fn retired_v015_envelope_is_fatal() {
        let mut block = cb("legacy", "hello __BRAZESYNC.lid.foo__ world");
        let err = resolve_content_block_with_remote(&mut block, None).unwrap_err();
        assert!(err
            .errors
            .iter()
            .any(|e| matches!(e, ResolutionError::RetiredNamespace { .. })));
    }

    #[test]
    fn typo_suffixed_token_is_detected() {
        let mut block = cb("typo", "hello __BRAZESYNCTEST__ world");
        let err = resolve_content_block_with_remote(&mut block, None).unwrap_err();
        assert!(
            !err.errors.is_empty(),
            "typo-suffixed token must not pass silently"
        );
    }
}