1use std::collections::HashMap;
21
22#[derive(Debug, Clone, Default)]
27pub struct TemplateContext {
28 vars: HashMap<String, String>,
29}
30
31impl TemplateContext {
32 pub fn new() -> Self {
34 Self::default()
35 }
36
37 pub fn insert(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
39 self.vars.insert(key.into(), value.into());
40 self
41 }
42
43 pub fn resolve(&self, html: &str) -> String {
49 let mut result = String::with_capacity(html.len());
50 let mut rest = html;
51
52 while let Some(start_pos) = rest.find("{=") {
53 result.push_str(&rest[..start_pos]);
55
56 let after_open = &rest[start_pos + 2..];
57 if let Some(end_pos) = after_open.find("=}") {
58 let key = after_open[..end_pos].trim();
59 if let Some(value) = self.vars.get(key) {
60 result.push_str(&escape_html(value));
61 }
62 rest = &after_open[end_pos + 2..];
64 } else {
65 result.push_str("{=");
67 rest = after_open;
68 }
69 }
70
71 result.push_str(rest);
73 result
74 }
75}
76
77fn escape_html(s: &str) -> String {
79 let mut escaped = String::with_capacity(s.len());
80 for c in s.chars() {
81 match c {
82 '&' => escaped.push_str("&"),
83 '<' => escaped.push_str("<"),
84 '>' => escaped.push_str(">"),
85 '"' => escaped.push_str("""),
86 _ => escaped.push(c),
87 }
88 }
89 escaped
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 #[test]
97 fn basic_interpolation() {
98 let mut ctx = TemplateContext::new();
99 ctx.insert("name", "Brady");
100 assert_eq!(ctx.resolve("Hello, {= name =}!"), "Hello, Brady!");
101 }
102
103 #[test]
104 fn dot_path_key() {
105 let mut ctx = TemplateContext::new();
106 ctx.insert("user.email", "brady@cloudsurf.com");
107 assert_eq!(
108 ctx.resolve("Email: {= user.email =}"),
109 "Email: brady@cloudsurf.com"
110 );
111 }
112
113 #[test]
114 fn missing_key_is_empty() {
115 let ctx = TemplateContext::new();
116 assert_eq!(ctx.resolve("Hello, {= unknown =}!"), "Hello, !");
117 }
118
119 #[test]
120 fn html_escaping() {
121 let mut ctx = TemplateContext::new();
122 ctx.insert("input", "<script>alert(1)</script>");
123 assert_eq!(
124 ctx.resolve("{= input =}"),
125 "<script>alert(1)</script>"
126 );
127 }
128
129 #[test]
130 fn ampersand_escaping() {
131 let mut ctx = TemplateContext::new();
132 ctx.insert("name", "Tom & Jerry");
133 assert_eq!(ctx.resolve("{= name =}"), "Tom & Jerry");
134 }
135
136 #[test]
137 fn quote_escaping() {
138 let mut ctx = TemplateContext::new();
139 ctx.insert("attr", "say \"hello\"");
140 assert_eq!(ctx.resolve("{= attr =}"), "say "hello"");
141 }
142
143 #[test]
144 fn no_variables_unchanged() {
145 let ctx = TemplateContext::new();
146 assert_eq!(
147 ctx.resolve("<h1>No variables here</h1>"),
148 "<h1>No variables here</h1>"
149 );
150 }
151
152 #[test]
153 fn multiple_variables_one_line() {
154 let mut ctx = TemplateContext::new();
155 ctx.insert("first", "Brady");
156 ctx.insert("last", "Davis");
157 assert_eq!(
158 ctx.resolve("{= first =} {= last =}"),
159 "Brady Davis"
160 );
161 }
162
163 #[test]
164 fn variable_in_html_context() {
165 let mut ctx = TemplateContext::new();
166 ctx.insert("name", "Brady");
167 assert_eq!(
168 ctx.resolve("<h1>Hello {= name =}</h1>"),
169 "<h1>Hello Brady</h1>"
170 );
171 }
172
173 #[test]
174 fn no_whitespace_in_delimiters() {
175 let mut ctx = TemplateContext::new();
176 ctx.insert("x", "42");
177 assert_eq!(ctx.resolve("{=x=}"), "42");
178 }
179
180 #[test]
181 fn extra_whitespace_in_delimiters() {
182 let mut ctx = TemplateContext::new();
183 ctx.insert("x", "42");
184 assert_eq!(ctx.resolve("{= x =}"), "42");
185 }
186
187 #[test]
188 fn unclosed_delimiter_is_literal() {
189 let ctx = TemplateContext::new();
190 assert_eq!(ctx.resolve("price {= no closing"), "price {= no closing");
191 }
192
193 #[test]
194 fn empty_key_is_missing() {
195 let ctx = TemplateContext::new();
196 assert_eq!(ctx.resolve("{= =}"), "");
197 }
198
199 #[test]
200 fn chained_insert() {
201 let mut ctx = TemplateContext::new();
202 ctx.insert("a", "1").insert("b", "2");
203 assert_eq!(ctx.resolve("{= a =},{= b =}"), "1,2");
204 }
205
206 #[test]
207 fn adjacent_variables() {
208 let mut ctx = TemplateContext::new();
209 ctx.insert("a", "hello");
210 ctx.insert("b", "world");
211 assert_eq!(ctx.resolve("{= a =}{= b =}"), "helloworld");
212 }
213
214 #[test]
215 fn variable_at_start_and_end() {
216 let mut ctx = TemplateContext::new();
217 ctx.insert("greeting", "Hi");
218 assert_eq!(ctx.resolve("{= greeting =}"), "Hi");
219 }
220
221 #[test]
222 fn preserves_surrounding_text() {
223 let mut ctx = TemplateContext::new();
224 ctx.insert("plan", "Pro");
225 assert_eq!(
226 ctx.resolve("Your plan: {= plan =}. Enjoy!"),
227 "Your plan: Pro. Enjoy!"
228 );
229 }
230}