Skip to main content

dotzuki_renderer/layout_engine/
data_context.rs

1use crate::layout_engine::types::DataValue;
2use crate::layout_engine::types::DataContext;
3
4impl DataContext {
5    /// Create a new empty data context.
6    pub fn new() -> Self {
7        Self {
8            values: std::collections::HashMap::new(),
9        }
10    }
11
12    /// Set a value. Accepts various types via `Into<DataValue>`.
13    pub fn set<V: Into<DataValue>>(&mut self, key: &str, value: V) {
14        self.values.insert(key.to_string(), value.into());
15    }
16
17    /// Get a value by key.
18    pub fn get(&self, key: &str) -> Option<&DataValue> {
19        self.values.get(key)
20    }
21
22    /// The active language locale code (e.g. `"en"` / `"zh"`), read from the
23    /// reserved `__lang` key. Callers set it with `ctx.set("__lang", "zh")`
24    /// before rendering; it selects the variant for `@t("en", "中文")` text.
25    /// Defaults to `"en"` when unset.
26    pub fn lang(&self) -> &str {
27        match self.values.get("__lang") {
28            Some(DataValue::Str(s)) => s.as_str(),
29            _ => "en",
30        }
31    }
32
33    /// Check if variable exists and is truthy (for `visible` field evaluation).
34    /// Returns `true` if:
35    /// - The value is `Bool(true)`
36    /// - The value is a non-zero `Int`
37    /// - The value exists and is any other type
38    /// Returns `false` if the key is absent or the value is `Bool(false)` / `Int(0)`.
39    pub fn is_truthy(&self, key: &str) -> bool {
40        match self.values.get(key) {
41            Some(DataValue::Bool(b)) => *b,
42            Some(DataValue::Int(n)) => *n != 0,
43            Some(_) => true,
44            None => false,
45        }
46    }
47
48    /// Resolve a template string by substituting `{var}`, `{var:0W}`, and `{var%N}` patterns.
49    ///
50    /// # Examples
51    ///
52    /// ```
53    /// # use dotzuki_renderer::layout_engine::types::DataContext;
54    /// let mut ctx = DataContext::new();
55    /// ctx.set("name", "LEAFKIT");
56    /// assert_eq!(ctx.resolve("{name}"), "LEAFKIT");
57    ///
58    /// ctx.set("num", 1i64);
59    /// assert_eq!(ctx.resolve("\u{2116}\u{2022}{num:03}"), "\u{2116}\u{2022}001");
60    ///
61    /// ctx.set("weight", 150i64);
62    /// assert_eq!(ctx.resolve("{weight%10}"), "0");
63    /// ```
64    pub fn resolve(&self, template: &str) -> String {
65        let mut result = String::with_capacity(template.len());
66        let mut chars = template.chars();
67
68        while let Some(ch) = chars.next() {
69            if ch == '{' {
70                let mut expr = String::new();
71                let mut found_close = false;
72
73                for inner in chars.by_ref() {
74                    if inner == '}' {
75                        found_close = true;
76                        break;
77                    }
78                    if inner == '{' {
79                        break;
80                    }
81                    expr.push(inner);
82                }
83
84                if found_close && !expr.is_empty() {
85                    result.push_str(&self.resolve_expr(&expr));
86                } else {
87                    result.push('{');
88                    result.push_str(&expr);
89                    if found_close {
90                        result.push('}');
91                    }
92                }
93            } else {
94                result.push(ch);
95            }
96        }
97
98        result
99    }
100
101    /// Resolve a single `{...}` expression (without the braces).
102    fn resolve_expr(&self, expr: &str) -> String {
103        if let Some(colon_pos) = expr.find(':') {
104            let var = &expr[..colon_pos];
105            let format = &expr[colon_pos + 1..];
106            let value = self.values.get(var);
107            return self.format_zero_pad(value, format);
108        }
109
110        if let Some(pct_pos) = expr.find('%') {
111            let var = &expr[..pct_pos];
112            let modulo_str = &expr[pct_pos + 1..];
113            let value = self.values.get(var);
114            return self.format_modulo(value, modulo_str);
115        }
116
117        match self.values.get(expr) {
118            Some(value) => self.data_value_to_string(value),
119            None => "?".to_string(),
120        }
121    }
122
123    /// Apply zero-pad formatting: `:03` → "001".
124    fn format_zero_pad(&self, value: Option<&DataValue>, format: &str) -> String {
125        let width: usize = if format.starts_with('0') {
126            format[1..].parse().unwrap_or(0)
127        } else {
128            format.parse().unwrap_or(0)
129        };
130
131        match value {
132            Some(DataValue::Int(n)) => {
133                format!("{:0width$}", n, width = width)
134            }
135            Some(other) => self.data_value_to_string(other),
136            None => "?".to_string(),
137        }
138    }
139
140    /// Apply modulo formatting: `%10` → last digit.
141    fn format_modulo(&self, value: Option<&DataValue>, modulo_str: &str) -> String {
142        let modulo: i64 = modulo_str.parse().unwrap_or(1);
143
144        match value {
145            Some(DataValue::Int(n)) => {
146                let result = if modulo != 0 { n % modulo } else { *n };
147                result.to_string()
148            }
149            Some(other) => self.data_value_to_string(other),
150            None => "?".to_string(),
151        }
152    }
153
154    /// Convert a DataValue to its string representation.
155    fn data_value_to_string(&self, value: &DataValue) -> String {
156        match value {
157            DataValue::Str(s) => s.clone(),
158            DataValue::Int(n) => n.to_string(),
159            DataValue::Float(f) => f.to_string(),
160            DataValue::Bool(b) => b.to_string(),
161            DataValue::TileId(t) => t.to_string(),
162            DataValue::List(v) => v
163                .iter()
164                .map(|dv| self.data_value_to_string(dv))
165                .collect::<Vec<_>>()
166                .join(", "),
167        }
168    }
169}
170
171// ============================================================================
172// Tests
173// ============================================================================
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::layout_engine::types::DataValue;
179
180    #[test]
181    fn test_simple_substitution() {
182        let mut ctx = DataContext::new();
183        ctx.set("name", "LEAFKIT");
184        assert_eq!(ctx.resolve("{name}"), "LEAFKIT");
185    }
186
187    #[test]
188    fn test_zero_padded() {
189        let mut ctx = DataContext::new();
190        ctx.set("num", 1i64);
191        assert_eq!(ctx.resolve("№•{num:03}"), "№•001");
192    }
193
194    #[test]
195    fn test_multiple_vars() {
196        let mut ctx = DataContext::new();
197        ctx.set("feet", 2i64);
198        ctx.set("inches", 4i64);
199        assert_eq!(ctx.resolve("{feet}′{inches:02}″"), "2′04″");
200    }
201
202    #[test]
203    fn test_modulo() {
204        let mut ctx = DataContext::new();
205        ctx.set("weight", 150i64);
206        assert_eq!(ctx.resolve("{weight%10}"), "0");
207    }
208
209    #[test]
210    fn test_modulo_non_zero() {
211        let mut ctx = DataContext::new();
212        ctx.set("weight", 157i64);
213        assert_eq!(ctx.resolve("{weight%10}"), "7");
214    }
215
216    #[test]
217    fn test_unknown_var_placeholder() {
218        let ctx = DataContext::new();
219        assert_eq!(ctx.resolve("{unknown}"), "?");
220    }
221
222    #[test]
223    fn test_plain_text_no_vars() {
224        let ctx = DataContext::new();
225        assert_eq!(ctx.resolve("Hello World"), "Hello World");
226    }
227
228    #[test]
229    fn test_mixed_text_and_vars() {
230        let mut ctx = DataContext::new();
231        ctx.set("item", "POTION");
232        ctx.set("count", 3i64);
233        assert_eq!(ctx.resolve("{item} x{count}"), "POTION x3");
234    }
235
236    #[test]
237    fn test_bool_value() {
238        let mut ctx = DataContext::new();
239        ctx.set("flag", true);
240        assert_eq!(ctx.resolve("{flag}"), "true");
241
242        ctx.set("flag", false);
243        assert_eq!(ctx.resolve("{flag}"), "false");
244    }
245
246    #[test]
247    fn test_is_truthy() {
248        let mut ctx = DataContext::new();
249
250        assert!(!ctx.is_truthy("missing"));
251
252        ctx.set("active", true);
253        assert!(ctx.is_truthy("active"));
254
255        ctx.set("active", false);
256        assert!(!ctx.is_truthy("active"));
257
258        ctx.set("count", 5i64);
259        assert!(ctx.is_truthy("count"));
260
261        ctx.set("count", 0i64);
262        assert!(!ctx.is_truthy("count"));
263
264        ctx.set("name", "test");
265        assert!(ctx.is_truthy("name"));
266    }
267
268    #[test]
269    fn test_empty_braces_passthrough() {
270        let ctx = DataContext::new();
271        assert_eq!(ctx.resolve("before{}after"), "before{}after");
272    }
273
274    #[test]
275    fn test_malformed_brace_passthrough() {
276        let ctx = DataContext::new();
277        assert_eq!(ctx.resolve("Hello {unclosed"), "Hello {unclosed");
278    }
279
280    #[test]
281    fn test_get_value() {
282        let mut ctx = DataContext::new();
283        ctx.set("key", "value");
284        assert_eq!(ctx.get("key"), Some(&DataValue::Str("value".to_string())));
285        assert_eq!(ctx.get("missing"), None);
286    }
287
288    #[test]
289    fn test_zero_pad_large_number() {
290        let mut ctx = DataContext::new();
291        ctx.set("num", 42i64);
292        assert_eq!(ctx.resolve("{num:05}"), "00042");
293    }
294
295    #[test]
296    fn test_zero_pad_exact_width() {
297        let mut ctx = DataContext::new();
298        ctx.set("num", 999i64);
299        assert_eq!(ctx.resolve("{num:03}"), "999");
300    }
301
302    #[test]
303    fn test_tile_id_value() {
304        let mut ctx = DataContext::new();
305        ctx.set("tile", 42u16);
306        assert_eq!(ctx.resolve("Tile:{tile}"), "Tile:42");
307    }
308
309    #[test]
310    fn test_float_value() {
311        let mut ctx = DataContext::new();
312        ctx.set("ratio", DataValue::Float(1.5));
313        assert_eq!(ctx.resolve("{ratio}"), "1.5");
314    }
315
316    #[test]
317    fn test_list_value() {
318        let mut ctx = DataContext::new();
319        ctx.set(
320            "items",
321            DataValue::List(vec![
322                DataValue::Str("A".into()),
323                DataValue::Str("B".into()),
324                DataValue::Str("C".into()),
325            ]),
326        );
327        assert_eq!(ctx.resolve("{items}"), "A, B, C");
328    }
329}