Skip to main content

nils_markdown/
engine.rs

1use serde::Serialize;
2use tera::{Context, Tera};
3
4use crate::error::RenderError;
5
6/// Tera-backed Markdown rendering engine for the nils-cli workspace.
7///
8/// Engine is constructed via [`Engine::builder`] so the determinism
9/// posture (no auto-escape, no `now()`) is enforced in one place.
10/// Templates are registered as raw `(name, body)` pairs, which lets
11/// consumer crates ship `.md.tera` assets through `include_str!`
12/// without filesystem lookups at runtime.
13pub struct Engine {
14    tera: Tera,
15}
16
17impl Engine {
18    /// Returns an [`EngineBuilder`] configured for deterministic
19    /// rendering. Always start engines from `Engine::builder()` —
20    /// `Engine` has no public constructor besides this builder.
21    pub fn builder() -> EngineBuilder {
22        EngineBuilder::new()
23    }
24
25    /// Register a template body under `name`. The body is parsed
26    /// eagerly so syntax errors surface at registration time rather
27    /// than render time. The check for `now()` calls is the only
28    /// content gate; everything else is delegated to Tera's parser.
29    pub fn register_template(&mut self, name: &str, body: &str) -> Result<(), RenderError> {
30        if contains_now_call(body) {
31            return Err(RenderError::NonDeterministicTemplate { name: name.into() });
32        }
33        self.tera
34            .add_raw_template(name, body)
35            .map_err(|source| RenderError::TemplateParse {
36                name: name.into(),
37                source,
38            })
39    }
40
41    /// Render a registered template against an opaque
42    /// [`serde_json::Value`] view. This is the entry point the
43    /// `md-render` binary will use in Sprint 3.
44    pub fn render_value(
45        &self,
46        name: &str,
47        view: &serde_json::Value,
48    ) -> Result<String, RenderError> {
49        let context = Context::from_value(view.clone()).map_err(|source| RenderError::Render {
50            name: name.into(),
51            source,
52        })?;
53        self.render_context(name, &context)
54    }
55
56    /// Render a registered template against a typed view struct.
57    /// Consumers prepare a flat [`serde::Serialize`] view in Rust and
58    /// hand it to this method; the engine performs the
59    /// `serde_json::to_value` conversion and the Tera render in one
60    /// step.
61    pub fn render<T: Serialize>(&self, name: &str, view: &T) -> Result<String, RenderError> {
62        let value =
63            serde_json::to_value(view).map_err(|source| RenderError::Serialize { source })?;
64        self.render_value(name, &value)
65    }
66
67    /// Render a literal template body without persistently
68    /// registering it. The body is checked for `now()` calls and
69    /// then rendered with the engine's registered helpers and the
70    /// supplied view. This is the migration path for callers that
71    /// today use `Tera::render_str` directly and treat every render
72    /// as a fresh one-shot template.
73    pub fn render_str<T: Serialize>(
74        &mut self,
75        body: &str,
76        view: &T,
77    ) -> Result<String, RenderError> {
78        const INLINE_NAME: &str = "<inline>";
79        if contains_now_call(body) {
80            return Err(RenderError::NonDeterministicTemplate {
81                name: INLINE_NAME.into(),
82            });
83        }
84        let context = serialize_to_context(view).map_err(|source| RenderError::Render {
85            name: INLINE_NAME.into(),
86            source,
87        })?;
88        self.tera
89            .render_str(body, &context)
90            .map_err(|source| RenderError::Render {
91                name: INLINE_NAME.into(),
92                source,
93            })
94    }
95
96    /// Attach a domain-specific Tera function under `name`. This is
97    /// the consumer extension point for Task 1.4: nils-agent-runtime's
98    /// `cli_ref / script / skill_ref / state_out` helpers register
99    /// here without `nils-markdown` knowing the consumer's domain.
100    pub fn register_helper<F>(&mut self, name: &str, function: F)
101    where
102        F: tera::Function + 'static,
103    {
104        self.tera.register_function(name, function);
105    }
106
107    fn render_context(&self, name: &str, context: &Context) -> Result<String, RenderError> {
108        if !self.tera.get_template_names().any(|n| n == name) {
109            return Err(RenderError::MissingTemplate { name: name.into() });
110        }
111        self.tera
112            .render(name, context)
113            .map_err(|source| RenderError::Render {
114                name: name.into(),
115                source,
116            })
117    }
118}
119
120/// Builder for [`Engine`]. Holds the deterministic-Tera defaults so
121/// consumers cannot construct an engine with auto-escape or
122/// `now()`-enabled templates by accident.
123pub struct EngineBuilder {
124    tera: Tera,
125}
126
127impl EngineBuilder {
128    fn new() -> Self {
129        let mut tera = Tera::default();
130        tera.autoescape_on(vec![]);
131        crate::filters::install_defaults(&mut tera);
132        Self { tera }
133    }
134
135    pub fn build(self) -> Engine {
136        Engine { tera: self.tera }
137    }
138}
139
140impl Default for EngineBuilder {
141    fn default() -> Self {
142        Self::new()
143    }
144}
145
146/// Serialize a view into a Tera [`Context`]. Tera requires the
147/// top-level value to be a JSON object; we allow null / empty
148/// callers (the nils-agent-runtime render path passes no view, the
149/// helpers carry every variable) and map them to an empty context.
150fn serialize_to_context<T: Serialize>(view: &T) -> Result<Context, tera::Error> {
151    let value = serde_json::to_value(view).map_err(tera::Error::json)?;
152    match value {
153        serde_json::Value::Null => Ok(Context::new()),
154        serde_json::Value::Object(_) => Context::from_value(value),
155        other => Err(tera::Error::msg(format!(
156            "render_str view must serialize to a JSON object or null, got {other:?}"
157        ))),
158    }
159}
160
161fn contains_now_call(body: &str) -> bool {
162    let bytes = body.as_bytes();
163    let needle = b"now";
164    let mut i = 0usize;
165    while i + needle.len() <= bytes.len() {
166        if &bytes[i..i + needle.len()] == needle {
167            let prev_ok = i == 0 || !is_ident_char(bytes[i - 1]);
168            let mut j = i + needle.len();
169            while j < bytes.len() && matches!(bytes[j], b' ' | b'\t') {
170                j += 1;
171            }
172            let next_ok = j < bytes.len() && bytes[j] == b'(';
173            if prev_ok && next_ok {
174                return true;
175            }
176        }
177        i += 1;
178    }
179    false
180}
181
182fn is_ident_char(c: u8) -> bool {
183    c.is_ascii_alphanumeric() || c == b'_'
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use serde::Serialize;
190
191    #[derive(Serialize)]
192    struct Greeting {
193        name: String,
194    }
195
196    fn build() -> Engine {
197        Engine::builder().build()
198    }
199
200    #[test]
201    fn build_yields_engine_with_no_templates() {
202        let engine = build();
203        assert_eq!(engine.tera.get_template_names().count(), 0);
204    }
205
206    #[test]
207    fn register_then_render_value_round_trips() {
208        let mut engine = build();
209        engine
210            .register_template("hello", "Hello, {{ name }}!")
211            .unwrap();
212        let view = serde_json::json!({"name": "world"});
213        let out = engine.render_value("hello", &view).unwrap();
214        assert_eq!(out, "Hello, world!");
215    }
216
217    #[test]
218    fn render_struct_round_trips() {
219        let mut engine = build();
220        engine
221            .register_template("hello", "Hello, {{ name }}!")
222            .unwrap();
223        let view = Greeting {
224            name: "tera".into(),
225        };
226        let out = engine.render("hello", &view).unwrap();
227        assert_eq!(out, "Hello, tera!");
228    }
229
230    #[test]
231    fn missing_template_is_reported_by_name() {
232        let engine = build();
233        let err = engine
234            .render_value("absent", &serde_json::json!({}))
235            .unwrap_err();
236        match err {
237            RenderError::MissingTemplate { name } => assert_eq!(name, "absent"),
238            other => panic!("expected MissingTemplate, got {other:?}"),
239        }
240    }
241
242    #[test]
243    fn template_with_now_call_is_rejected() {
244        let mut engine = build();
245        let err = engine
246            .register_template("bad", "stamp: {{ now() }}")
247            .unwrap_err();
248        match err {
249            RenderError::NonDeterministicTemplate { name } => assert_eq!(name, "bad"),
250            other => panic!("expected NonDeterministicTemplate, got {other:?}"),
251        }
252    }
253
254    #[test]
255    fn template_with_now_call_and_whitespace_is_rejected() {
256        let mut engine = build();
257        let err = engine
258            .register_template("bad", "{{   now  ( ) }}")
259            .unwrap_err();
260        assert!(matches!(err, RenderError::NonDeterministicTemplate { .. }));
261    }
262
263    #[test]
264    fn identifier_containing_now_substring_is_allowed() {
265        let mut engine = build();
266        engine
267            .register_template("snowflake", "Hello, {{ snowflake }}!")
268            .unwrap();
269        let view = serde_json::json!({"snowflake": "ok"});
270        let out = engine.render_value("snowflake", &view).unwrap();
271        assert_eq!(out, "Hello, ok!");
272    }
273
274    #[test]
275    fn template_parse_error_surfaces_name_and_source() {
276        let mut engine = build();
277        let err = engine.register_template("broken", "{% if %}").unwrap_err();
278        match err {
279            RenderError::TemplateParse { name, source } => {
280                assert_eq!(name, "broken");
281                let printed = format!("{source}");
282                assert!(
283                    !printed.is_empty(),
284                    "tera error message should not be empty"
285                );
286            }
287            other => panic!("expected TemplateParse, got {other:?}"),
288        }
289    }
290
291    #[test]
292    fn render_runtime_error_surfaces_name() {
293        let mut engine = build();
294        engine
295            .register_template("strict", "{{ value | upper }}")
296            .unwrap();
297        let err = engine
298            .render_value("strict", &serde_json::json!({"value": 42}))
299            .unwrap_err();
300        match err {
301            RenderError::Render { name, .. } => assert_eq!(name, "strict"),
302            other => panic!("expected Render, got {other:?}"),
303        }
304    }
305
306    #[test]
307    fn engine_does_not_auto_escape_html() {
308        let mut engine = build();
309        engine.register_template("md", "value: {{ raw }}").unwrap();
310        let view = serde_json::json!({"raw": "<b>bold</b>"});
311        let out = engine.render_value("md", &view).unwrap();
312        assert_eq!(out, "value: <b>bold</b>");
313    }
314
315    #[test]
316    fn render_str_uses_registered_helpers_and_view() {
317        let mut engine = build();
318        engine.register_helper(
319            "shout",
320            |args: &std::collections::HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
321                let v = args
322                    .get("v")
323                    .and_then(|x| x.as_str())
324                    .ok_or_else(|| tera::Error::msg("shout(): required arg `v`"))?;
325                Ok(tera::Value::String(v.to_uppercase()))
326            },
327        );
328        let out = engine
329            .render_str(
330                r#"hi {{ name }} / {{ shout(v="ok") }}"#,
331                &serde_json::json!({"name": "tera"}),
332            )
333            .unwrap();
334        assert_eq!(out, "hi tera / OK");
335    }
336
337    #[test]
338    fn render_str_rejects_now_call() {
339        let mut engine = build();
340        let err = engine
341            .render_str("stamp: {{ now() }}", &serde_json::json!({}))
342            .unwrap_err();
343        assert!(matches!(err, RenderError::NonDeterministicTemplate { .. }));
344    }
345
346    #[test]
347    fn register_helper_attaches_consumer_function() {
348        use std::collections::HashMap;
349        let mut engine = build();
350        engine.register_helper(
351            "shout",
352            |args: &HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
353                let v = args
354                    .get("v")
355                    .and_then(|x| x.as_str())
356                    .ok_or_else(|| tera::Error::msg("shout(): required arg `v`"))?;
357                Ok(tera::Value::String(v.to_uppercase()))
358            },
359        );
360        engine
361            .register_template("greet", r#"hey, {{ shout(v="world") }}!"#)
362            .unwrap();
363        let out = engine
364            .render_value("greet", &serde_json::json!({}))
365            .unwrap();
366        assert_eq!(out, "hey, WORLD!");
367    }
368}