Skip to main content

md_tmpl_core/
context.rs

1//! User-facing template rendering context.
2
3use alloc::string::String;
4
5use crate::{compat::HashMap, value::Value};
6
7/// Template rendering context — holds all variables available during rendering.
8///
9/// # Examples
10///
11/// From an iterator of tuples:
12/// ```
13/// use md_tmpl_core::{Context, Value};
14///
15/// let ctx: Context = vec![
16///     ("name", Value::from("Alice")),
17///     ("count", Value::from(3_i64)),
18/// ]
19/// .into_iter()
20/// .collect();
21///
22/// assert!(ctx.get("name").is_some());
23/// ```
24#[derive(Debug, Clone, Default)]
25pub struct Context {
26    pub(crate) values: HashMap<String, Value>,
27}
28
29impl Context {
30    /// Create an empty context.
31    #[must_use]
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    /// Create an empty context pre-allocated for `capacity` variables.
37    #[must_use]
38    pub fn with_capacity(capacity: usize) -> Self {
39        Self {
40            values: HashMap::with_capacity(capacity),
41        }
42    }
43
44    /// Returns the number of variables in this context.
45    #[must_use]
46    pub fn len(&self) -> usize {
47        self.values.len()
48    }
49
50    /// Returns `true` if this context contains no variables.
51    #[must_use]
52    pub fn is_empty(&self) -> bool {
53        self.values.is_empty()
54    }
55
56    /// Returns `true` if a variable with the given key exists.
57    #[must_use]
58    pub fn contains_key(&self, key: &str) -> bool {
59        self.values.contains_key(key)
60    }
61
62    /// Insert a value into the context.
63    ///
64    /// This is the **dynamic** API — the key is a plain string and type
65    /// mismatches are caught at [`render_ctx()`](crate::Template::render_ctx) time,
66    /// not here. For compile-time type safety, prefer one of:
67    ///
68    /// - `include_template!` (from `md-tmpl-macros`)
69    ///   — generates a strongly-typed parameter struct from your template.
70    /// - [`Template::render`](crate::Template::render) (feature `serde`)
71    ///   — renders directly from any `Serialize` struct.
72    ///
73    /// # Panics
74    ///
75    /// Panics if `key` is the reserved internal key `__kind__` — this key
76    /// is used internally for enum variant tagging and must not be set
77    /// directly. Use enum types in the template frontmatter instead.
78    pub fn set(&mut self, key: impl Into<String>, value: impl Into<Value>) {
79        let key = key.into();
80        assert!(
81            key != crate::consts::ENUM_TAG_KEY,
82            "cannot set reserved internal key '{}' directly in Context — \
83             use enum types in the template frontmatter instead",
84            crate::consts::ENUM_TAG_KEY,
85        );
86        self.values.insert(key, value.into());
87    }
88
89    /// Builder-style insert — returns `self` for chaining.
90    #[must_use]
91    pub fn var(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
92        self.set(key, value);
93        self
94    }
95
96    /// Look up a top-level variable.
97    #[must_use]
98    pub fn get(&self, key: &str) -> Option<&Value> {
99        self.values.get(key)
100    }
101
102    /// Consume this context and return the inner variable map.
103    ///
104    /// Useful for converting a context into a [`Value::Struct`](crate::Value::Struct).
105    #[must_use]
106    pub fn into_inner(self) -> HashMap<String, Value> {
107        self.values
108    }
109}
110
111/// Collect `(&str, Value)` tuples into a `Context`.
112impl<K: Into<String>, V: Into<Value>> FromIterator<(K, V)> for Context {
113    fn from_iter<I: IntoIterator<Item = (K, V)>>(iter: I) -> Self {
114        let iter = iter.into_iter();
115        let (lower, _) = iter.size_hint();
116        let mut ctx = Self::with_capacity(lower);
117        for (k, v) in iter {
118            ctx.set(k, v);
119        }
120        ctx
121    }
122}
123
124#[cfg(feature = "serde")]
125impl Context {
126    /// Build a `Context` directly from a [`Value::Struct`](crate::Value::Struct).
127    ///
128    /// # Errors
129    ///
130    /// Returns `TemplateError::Syntax` if the value is not a dict/map.
131    pub fn from_value(val: Value) -> Result<Self, crate::error::TemplateError> {
132        match val {
133            Value::Struct(arc_map) => {
134                // Try to unwrap the Arc to avoid cloning — we're the sole owner
135                // if it was just deserialized or serialized.
136                let values =
137                    alloc::sync::Arc::try_unwrap(arc_map).unwrap_or_else(|arc| (*arc).clone());
138                Ok(Self { values })
139            }
140            other => Err(crate::error::TemplateError::syntax(format!(
141                "expected struct/map, got {}",
142                other.type_name()
143            ))),
144        }
145    }
146
147    /// Build a `Context` from any `Serialize` type that serializes as a map/struct.
148    ///
149    /// # Errors
150    ///
151    /// Returns `TemplateError::Syntax` if the serialized value is not a dict/map.
152    pub fn from_serialize<T: serde::Serialize>(
153        value: &T,
154    ) -> Result<Self, crate::error::TemplateError> {
155        let val = crate::serde_support::to_value(value).map_err(|e| {
156            crate::error::TemplateError::syntax(format!("serde conversion failed: {e}"))
157        })?;
158        Self::from_value(val)
159    }
160}
161
162/// CBOR support — behind the `cbor` feature, which implies `serde`.
163///
164/// Available in `no_std` — ciborium uses its own `Read` trait with a
165/// blanket impl for `&[u8]`.
166#[cfg(feature = "cbor")]
167impl Context {
168    /// Build a `Context` from a CBOR binary buffer.
169    ///
170    /// # Errors
171    ///
172    /// Returns `TemplateError::Syntax` if the buffer is invalid or not a dict/map.
173    pub fn from_cbor(data: &[u8]) -> Result<Self, crate::error::TemplateError> {
174        let val: Value = ciborium::from_reader(data).map_err(|e| {
175            crate::error::TemplateError::syntax(format!("cbor deserialization failed: {e}"))
176        })?;
177        Self::from_value(val)
178    }
179}
180
181/// `FlexBuffers` support — behind the `flexbuffers` feature, which implies
182/// `std` and `serde` (the `flexbuffers` crate does not support `no_std`).
183#[cfg(feature = "flexbuffers")]
184impl Context {
185    /// Build a `Context` from a `FlexBuffers` binary buffer.
186    ///
187    /// # Errors
188    ///
189    /// Returns `TemplateError::Syntax` if the buffer is invalid or not a dict/map.
190    pub fn from_flexbuffers(data: &[u8]) -> Result<Self, crate::error::TemplateError> {
191        let r = flexbuffers::Reader::get_root(data).map_err(|e| {
192            crate::error::TemplateError::syntax(format!("flexbuffers root error: {e}"))
193        })?;
194        let val: Value = serde::Deserialize::deserialize(r).map_err(|e| {
195            crate::error::TemplateError::syntax(format!("flexbuffers deserialization failed: {e}"))
196        })?;
197        Self::from_value(val)
198    }
199}
200
201// ---------------------------------------------------------------------------
202// Tests
203// ---------------------------------------------------------------------------
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn new_context_is_empty() {
211        let ctx = Context::new();
212        assert!(ctx.get("anything").is_none());
213    }
214
215    #[test]
216    fn set_and_get_str() {
217        let mut ctx = Context::new();
218        ctx.set("greeting", "hello");
219        assert_eq!(ctx.get("greeting"), Some(&Value::Str("hello".into())));
220    }
221
222    #[test]
223    fn set_and_get_bool() {
224        let mut ctx = Context::new();
225        ctx.set("flag", true);
226        assert_eq!(ctx.get("flag"), Some(&Value::Bool(true)));
227    }
228
229    #[test]
230    fn set_and_get_int() {
231        let mut ctx = Context::new();
232        ctx.set("count", 42_i64);
233        assert_eq!(ctx.get("count"), Some(&Value::Int(42)));
234    }
235
236    #[test]
237    fn overwrite_value() {
238        let mut ctx = Context::new();
239        ctx.set("k", "first");
240        ctx.set("k", "second");
241        assert_eq!(ctx.get("k"), Some(&Value::Str("second".into())));
242    }
243
244    #[test]
245    fn get_missing_returns_none() {
246        let ctx = Context::new();
247        assert_eq!(ctx.get("nonexistent"), None);
248    }
249
250    #[test]
251    fn default_is_same_as_new() {
252        let a = Context::new();
253        let b = Context::default();
254        assert!(a.values.is_empty());
255        assert!(b.values.is_empty());
256    }
257
258    #[test]
259    #[should_panic(expected = "reserved internal key")]
260    fn set_rejects_internal_kind_key() {
261        let mut ctx = Context::new();
262        ctx.set(crate::consts::ENUM_TAG_KEY, "Variant");
263    }
264
265    #[test]
266    fn set_allows_similar_but_different_keys() {
267        // Keys that look similar but aren't ENUM_TAG_KEY should work fine.
268        let mut ctx = Context::new();
269        ctx.set("kind", "some_kind");
270        ctx.set("__type__", "some_type");
271        ctx.set("kind_of", "something");
272        assert!(ctx.get("kind").is_some());
273        assert!(ctx.get("__type__").is_some());
274    }
275
276    #[cfg(feature = "cbor")]
277    #[test]
278    fn from_cbor_roundtrip() {
279        use alloc::{collections::BTreeMap, vec::Vec};
280
281        let source = BTreeMap::from([("name", "Alice"), ("role", "admin")]);
282        let mut buf = Vec::new();
283        ciborium::into_writer(&source, &mut buf).expect("cbor encode");
284
285        let ctx = Context::from_cbor(&buf).expect("from_cbor");
286        assert_eq!(ctx.get("name"), Some(&Value::Str("Alice".into())));
287        assert_eq!(ctx.get("role"), Some(&Value::Str("admin".into())));
288    }
289
290    #[cfg(feature = "cbor")]
291    #[test]
292    fn from_cbor_rejects_non_map() {
293        use alloc::vec::Vec;
294
295        let mut buf = Vec::new();
296        ciborium::into_writer(&42_i64, &mut buf).expect("cbor encode");
297        Context::from_cbor(&buf).expect_err("a non-map CBOR value must not produce a Context");
298    }
299
300    #[cfg(feature = "cbor")]
301    #[test]
302    fn from_cbor_rejects_garbage() {
303        Context::from_cbor(&[]).expect_err("empty buffer must error");
304        Context::from_cbor(&[0xde, 0xad, 0xbe, 0xef])
305            .expect_err("garbage bytes must error without panicking");
306    }
307
308    #[cfg(feature = "flexbuffers")]
309    #[test]
310    fn from_flexbuffers_roundtrip() {
311        use serde::Serialize;
312
313        let source = alloc::collections::BTreeMap::from([("name", "Alice"), ("role", "admin")]);
314        let mut ser = flexbuffers::FlexbufferSerializer::new();
315        source.serialize(&mut ser).expect("flexbuffers encode");
316
317        let ctx = Context::from_flexbuffers(ser.view()).expect("from_flexbuffers");
318        assert_eq!(ctx.get("name"), Some(&Value::Str("Alice".into())));
319        assert_eq!(ctx.get("role"), Some(&Value::Str("admin".into())));
320    }
321
322    #[cfg(feature = "flexbuffers")]
323    #[test]
324    fn from_flexbuffers_rejects_garbage() {
325        Context::from_flexbuffers(&[]).expect_err("empty flexbuffer must error");
326        Context::from_flexbuffers(&[0xde, 0xad, 0xbe, 0xef])
327            .expect_err("garbage flexbuffer must error without panicking");
328    }
329}