Skip to main content

braze_sync/values/
placeholder.rs

1//! Anonymous Braze-managed placeholder: `__BRAZESYNC__`.
2//!
3//! v0.16 model: a single anonymous token represents both `lid` and
4//! `cb_id` placeholders. Type is inferred from the surrounding
5//! `| lid: '…'` / `| id: '…'` syntax. Resolution is offset-based and
6//! lives in [`crate::values::braze_managed`].
7
8use regex_lite::Regex;
9use std::sync::OnceLock;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
12pub enum PlaceholderType {
13    Lid,
14    CbId,
15}
16
17impl PlaceholderType {
18    pub fn as_str(&self) -> &'static str {
19        match self {
20            PlaceholderType::Lid => "lid",
21            PlaceholderType::CbId => "cb_id",
22        }
23    }
24}
25
26/// One `__BRAZESYNC__` occurrence with its inferred type.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Placeholder {
29    /// `None` when the token is not in a recognized `| lid:` / `| id:`
30    /// argument position — caller treats this as a fatal context error.
31    pub ty: Option<PlaceholderType>,
32    /// Byte offset where the literal `__BRAZESYNC__` token begins.
33    pub start: usize,
34    /// Byte offset (exclusive) where it ends.
35    pub end: usize,
36}
37
38pub const TOKEN: &str = "__BRAZESYNC__";
39
40/// Resolution errors. All offset-based — no keys exist in the v0.16
41/// model.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum ResolutionError {
44    /// lid `__BRAZESYNC__` could not be resolved from the remote body.
45    UnresolvedLid {
46        start: usize,
47        anchor: Option<String>,
48    },
49    /// cb_id `__BRAZESYNC__` could not be matched to a remote
50    /// `${NAME} | id: 'cbN'`.
51    UnresolvedCbId { start: usize, name: Option<String> },
52    /// `__BRAZESYNC__` appeared outside any recognized
53    /// `| lid:` / `| id:` argument position.
54    UnknownContext { start: usize },
55    /// Token uses the retired `__BRAZESYNC.<…>__` envelope (legacy v0.15
56    /// syntax, or `custom`/`global` namespaces removed earlier). Surface
57    /// as fatal so operators re-run `templatize`.
58    RetiredNamespace { token: String },
59}
60
61fn lid_prefix_re() -> &'static Regex {
62    static RE: OnceLock<Regex> = OnceLock::new();
63    RE.get_or_init(|| Regex::new(r#"\|\s*lid:\s*['"]$"#).expect("lid prefix regex is valid"))
64}
65
66fn cb_id_prefix_re() -> &'static Regex {
67    static RE: OnceLock<Regex> = OnceLock::new();
68    RE.get_or_init(|| Regex::new(r#"\|\s*id:\s*['"]$"#).expect("cb_id prefix regex is valid"))
69}
70
71/// Decide which type a `__BRAZESYNC__` token represents based on the
72/// text immediately preceding it.
73pub fn infer_type(prefix: &str) -> Option<PlaceholderType> {
74    if lid_prefix_re().is_match(prefix) {
75        return Some(PlaceholderType::Lid);
76    }
77    if cb_id_prefix_re().is_match(prefix) {
78        return Some(PlaceholderType::CbId);
79    }
80    None
81}
82
83/// Extract every `__BRAZESYNC__` occurrence in `body` with its inferred
84/// type.
85pub fn extract_placeholders(body: &str) -> Vec<Placeholder> {
86    let mut out = Vec::new();
87    let mut i = 0;
88    while let Some(rel) = body[i..].find(TOKEN) {
89        let start = i + rel;
90        let end = start + TOKEN.len();
91        let ty = infer_type(&body[..start]);
92        out.push(Placeholder { ty, start, end });
93        i = end;
94    }
95    out
96}
97
98/// Cheap check used by callers that only need to know whether
99/// resolution must run at all.
100pub fn has_placeholders(body: &str) -> bool {
101    body.contains(TOKEN)
102}
103
104/// Loose envelope regex for the **retired** v0.15 syntax. We surface
105/// these as errors so operators re-run `templatize` rather than
106/// silently shipping unresolvable templates.
107fn retired_envelope_re() -> &'static Regex {
108    static RE: OnceLock<Regex> = OnceLock::new();
109    RE.get_or_init(|| {
110        Regex::new(r"__BRAZE?SYNC\.[A-Za-z0-9_]+\.[A-Za-z0-9_]+__")
111            .expect("retired envelope regex is valid")
112    })
113}
114
115/// Loose typo regex (e.g. `__BRAZSYNC__`, missing E). Excludes the
116/// strict token itself and the retired envelope shape.
117fn typo_token_re() -> &'static Regex {
118    static RE: OnceLock<Regex> = OnceLock::new();
119    RE.get_or_init(|| Regex::new(r"__BRAZE?SYNC[A-Z]*__").expect("typo token regex is valid"))
120}
121
122/// Find any token resembling a placeholder that is NOT the strict
123/// `__BRAZESYNC__`. Returns retired-envelope and typo-shaped strings.
124pub fn find_suspicious_placeholders(body: &str) -> Vec<String> {
125    let mut out = Vec::new();
126    for m in retired_envelope_re().find_iter(body) {
127        out.push(m.as_str().to_string());
128    }
129    for m in typo_token_re().find_iter(body) {
130        let s = m.as_str();
131        if s == TOKEN {
132            continue;
133        }
134        // Skip if already covered by a retired-envelope hit at the same
135        // start offset.
136        if out.iter().any(|o: &String| o.starts_with(s)) {
137            continue;
138        }
139        out.push(s.to_string());
140    }
141    out
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn extracts_anonymous_lid_token() {
150        let body = "x | lid: '__BRAZESYNC__' y";
151        let ps = extract_placeholders(body);
152        assert_eq!(ps.len(), 1);
153        assert_eq!(ps[0].ty, Some(PlaceholderType::Lid));
154    }
155
156    #[test]
157    fn extracts_anonymous_cb_id_token() {
158        let body = "x | id: '__BRAZESYNC__' y";
159        let ps = extract_placeholders(body);
160        assert_eq!(ps.len(), 1);
161        assert_eq!(ps[0].ty, Some(PlaceholderType::CbId));
162    }
163
164    #[test]
165    fn double_quoted_context_recognized() {
166        let body = r#"| lid: "__BRAZESYNC__""#;
167        let ps = extract_placeholders(body);
168        assert_eq!(ps[0].ty, Some(PlaceholderType::Lid));
169    }
170
171    #[test]
172    fn token_without_filter_context_has_none_type() {
173        let body = "bare __BRAZESYNC__ token";
174        let ps = extract_placeholders(body);
175        assert_eq!(ps.len(), 1);
176        assert!(ps[0].ty.is_none());
177    }
178
179    #[test]
180    fn token_outside_quotes_has_none_type() {
181        let body = "| lid: __BRAZESYNC__";
182        let ps = extract_placeholders(body);
183        assert!(ps[0].ty.is_none());
184    }
185
186    #[test]
187    fn multiple_tokens_in_order() {
188        let body = "a | lid: '__BRAZESYNC__' b | id: '__BRAZESYNC__' c";
189        let ps = extract_placeholders(body);
190        assert_eq!(ps.len(), 2);
191        assert_eq!(ps[0].ty, Some(PlaceholderType::Lid));
192        assert_eq!(ps[1].ty, Some(PlaceholderType::CbId));
193        assert!(ps[0].start < ps[1].start);
194    }
195
196    #[test]
197    fn retired_envelope_is_suspicious() {
198        let body = "stuff __BRAZESYNC.lid.foo__ stuff";
199        let warns = find_suspicious_placeholders(body);
200        assert_eq!(warns, vec!["__BRAZESYNC.lid.foo__".to_string()]);
201    }
202
203    #[test]
204    fn retired_custom_namespace_is_suspicious() {
205        let body = "x __BRAZESYNC.custom.foo__ y";
206        let warns = find_suspicious_placeholders(body);
207        assert_eq!(warns, vec!["__BRAZESYNC.custom.foo__".to_string()]);
208    }
209
210    #[test]
211    fn typo_token_is_suspicious() {
212        let body = "x __BRAZSYNC__ y";
213        let warns = find_suspicious_placeholders(body);
214        assert!(warns.iter().any(|w| w.contains("BRAZSYNC")));
215    }
216
217    #[test]
218    fn strict_token_is_not_suspicious() {
219        let body = "x __BRAZESYNC__ y";
220        assert!(find_suspicious_placeholders(body).is_empty());
221    }
222}