Skip to main content

subcog/rendering/
template_renderer.rs

1//! Template renderer implementation.
2//!
3//! Provides the core rendering engine for context templates with:
4//! - Variable substitution (reuses sanitization from prompt module)
5//! - Iteration support (`{{#each collection}}...{{/each}}`)
6//! - Output format conversion (Markdown, JSON, XML)
7
8use regex::Regex;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::fmt::Write;
12use std::sync::LazyLock;
13
14use crate::models::{
15    ContextTemplate, OutputFormat, PromptVariable, TemplateVariable, extract_variables,
16    substitute_variables,
17};
18use crate::{Error, Result};
19
20/// Maximum number of items in an iteration (prevents denial-of-service).
21const MAX_ITERATION_ITEMS: usize = 1000;
22
23/// Maximum nesting depth for iterations (currently only 1 level supported).
24const MAX_ITERATION_DEPTH: usize = 1;
25
26/// Regex pattern for iteration blocks: `{{#each collection}}...{{/each}}`
27static EACH_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
28    Regex::new(r"\{\{#each\s+(\w+)\}\}([\s\S]*?)\{\{/each\}\}").unwrap_or_else(|_| unreachable!())
29});
30
31/// Regex pattern for item variable references: `{{item.field}}` where item matches collection singular
32static ITEM_VAR_PATTERN: LazyLock<Regex> =
33    LazyLock::new(|| Regex::new(r"\{\{(\w+)\.(\w+)\}\}").unwrap_or_else(|_| unreachable!()));
34
35/// A value that can be rendered in a template.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(untagged)]
38pub enum RenderValue {
39    /// A simple string value.
40    String(String),
41    /// A list of items for iteration.
42    List(Vec<HashMap<String, String>>),
43    /// A nested object (for statistics, etc.).
44    Object(HashMap<String, String>),
45}
46
47impl RenderValue {
48    /// Creates a string value.
49    #[must_use]
50    pub fn string(value: impl Into<String>) -> Self {
51        Self::String(value.into())
52    }
53
54    /// Creates a list value.
55    #[must_use]
56    pub const fn list(items: Vec<HashMap<String, String>>) -> Self {
57        Self::List(items)
58    }
59
60    /// Creates an object value.
61    #[must_use]
62    pub const fn object(fields: HashMap<String, String>) -> Self {
63        Self::Object(fields)
64    }
65
66    /// Returns the value as a string, or None if not a string.
67    #[must_use]
68    pub fn as_string(&self) -> Option<&str> {
69        match self {
70            Self::String(s) => Some(s),
71            _ => None,
72        }
73    }
74
75    /// Returns the value as a list, or None if not a list.
76    #[must_use]
77    pub fn as_list(&self) -> Option<&[HashMap<String, String>]> {
78        match self {
79            Self::List(l) => Some(l),
80            _ => None,
81        }
82    }
83
84    /// Converts the value to a string representation.
85    #[must_use]
86    pub fn to_string_repr(&self) -> String {
87        match self {
88            Self::String(s) => s.clone(),
89            Self::List(l) => serde_json::to_string(l).unwrap_or_else(|_| "[]".to_string()),
90            Self::Object(o) => serde_json::to_string(o).unwrap_or_else(|_| "{}".to_string()),
91        }
92    }
93}
94
95impl From<String> for RenderValue {
96    fn from(s: String) -> Self {
97        Self::String(s)
98    }
99}
100
101impl From<&str> for RenderValue {
102    fn from(s: &str) -> Self {
103        Self::String(s.to_string())
104    }
105}
106
107impl From<Vec<HashMap<String, String>>> for RenderValue {
108    fn from(l: Vec<HashMap<String, String>>) -> Self {
109        Self::List(l)
110    }
111}
112
113/// Context for rendering a template.
114#[derive(Debug, Clone, Default)]
115pub struct RenderContext {
116    /// Variable values keyed by name.
117    values: HashMap<String, RenderValue>,
118}
119
120impl RenderContext {
121    /// Creates a new empty render context.
122    #[must_use]
123    pub fn new() -> Self {
124        Self::default()
125    }
126
127    /// Adds a string value to the context.
128    pub fn add_string(&mut self, name: impl Into<String>, value: impl Into<String>) {
129        self.values
130            .insert(name.into(), RenderValue::String(value.into()));
131    }
132
133    /// Adds a list value to the context for iteration.
134    pub fn add_list(&mut self, name: impl Into<String>, items: Vec<HashMap<String, String>>) {
135        self.values.insert(name.into(), RenderValue::List(items));
136    }
137
138    /// Adds an object value to the context.
139    pub fn add_object(&mut self, name: impl Into<String>, fields: HashMap<String, String>) {
140        self.values.insert(name.into(), RenderValue::Object(fields));
141    }
142
143    /// Adds a render value to the context.
144    pub fn add_value(&mut self, name: impl Into<String>, value: RenderValue) {
145        self.values.insert(name.into(), value);
146    }
147
148    /// Sets a value in the context (alias for `add_value`).
149    pub fn set(&mut self, name: impl Into<String>, value: RenderValue) {
150        self.add_value(name, value);
151    }
152
153    /// Gets a value from the context.
154    #[must_use]
155    pub fn get(&self, name: &str) -> Option<&RenderValue> {
156        self.values.get(name)
157    }
158
159    /// Gets a string value from the context.
160    #[must_use]
161    pub fn get_string(&self, name: &str) -> Option<&str> {
162        self.values.get(name).and_then(RenderValue::as_string)
163    }
164
165    /// Gets a list value from the context.
166    #[must_use]
167    pub fn get_list(&self, name: &str) -> Option<&[HashMap<String, String>]> {
168        self.values.get(name).and_then(RenderValue::as_list)
169    }
170
171    /// Returns all values as a flat string map for variable substitution.
172    #[must_use]
173    pub fn to_string_map(&self) -> HashMap<String, String> {
174        self.values
175            .iter()
176            .map(|(k, v)| (k.clone(), v.to_string_repr()))
177            .collect()
178    }
179
180    /// Checks if the context contains a value.
181    #[must_use]
182    pub fn contains(&self, name: &str) -> bool {
183        self.values.contains_key(name)
184    }
185
186    /// Returns the number of values in the context.
187    #[must_use]
188    pub fn len(&self) -> usize {
189        self.values.len()
190    }
191
192    /// Returns true if the context is empty.
193    #[must_use]
194    pub fn is_empty(&self) -> bool {
195        self.values.is_empty()
196    }
197}
198
199/// Template rendering engine.
200#[derive(Debug, Clone, Default)]
201pub struct TemplateRenderer {
202    _private: (), // Prevent external construction, allow future fields
203}
204
205impl TemplateRenderer {
206    /// Creates a new template renderer.
207    #[must_use]
208    pub fn new() -> Self {
209        Self::default()
210    }
211
212    /// Renders a template with the given context and output format.
213    ///
214    /// # Errors
215    ///
216    /// Returns an error if:
217    /// - Required variables are missing
218    /// - Iteration collection is not found or not a list
219    /// - Format conversion fails
220    #[allow(clippy::similar_names)]
221    pub fn render(
222        &self,
223        template: &ContextTemplate,
224        ctx: &RenderContext,
225        format: OutputFormat,
226    ) -> Result<String> {
227        // 1. Process iteration blocks first
228        let processed = self.process_iterations(&template.content, ctx)?;
229
230        // 2. Build variable map for substitution
231        let values = ctx.to_string_map();
232
233        // 3. Re-extract variables from processed content (after iteration removal)
234        //    This ensures we only validate variables that remain after iteration
235        let remaining_vars = extract_variables(&processed);
236        let prompt_vars: Vec<PromptVariable> = remaining_vars
237            .into_iter()
238            .map(|ev| {
239                // Try to find the variable in template's definitions for metadata
240                template
241                    .variables
242                    .iter()
243                    .find(|v| v.name == ev.name)
244                    .cloned()
245                    .unwrap_or_else(|| TemplateVariable::new(&ev.name))
246            })
247            .map(Into::into)
248            .collect();
249
250        // 4. Substitute variables (reuses sanitization from prompt module)
251        let rendered = substitute_variables(&processed, &values, &prompt_vars)?;
252
253        // 5. Convert to output format
254        self.format_output(&rendered, format)
255    }
256
257    /// Processes iteration blocks in the template content.
258    #[allow(clippy::similar_names)]
259    fn process_iterations(&self, input: &str, ctx: &RenderContext) -> Result<String> {
260        let mut result = input.to_string();
261        let mut depth = 0;
262
263        // Process all {{#each}}...{{/each}} blocks
264        while let Some(captures) = EACH_PATTERN.captures(&result) {
265            depth += 1;
266            if depth > MAX_ITERATION_DEPTH {
267                return Err(Error::InvalidInput(format!(
268                    "Maximum iteration depth ({MAX_ITERATION_DEPTH}) exceeded"
269                )));
270            }
271
272            let full_match = captures.get(0).map_or("", |m| m.as_str());
273            let collection_name = captures.get(1).map_or("", |m| m.as_str());
274            let block_body = captures.get(2).map_or("", |m| m.as_str());
275
276            // Get the collection from context
277            let items = ctx.get_list(collection_name).ok_or_else(|| {
278                Error::InvalidInput(format!(
279                    "Iteration collection '{collection_name}' not found or not a list"
280                ))
281            })?;
282
283            // Check iteration limit
284            if items.len() > MAX_ITERATION_ITEMS {
285                return Err(Error::InvalidInput(format!(
286                    "Iteration collection '{collection_name}' has {} items, max is {MAX_ITERATION_ITEMS}",
287                    items.len()
288                )));
289            }
290
291            // Determine the item variable prefix (singular form of collection)
292            let item_prefix = get_item_prefix(collection_name);
293
294            // Render each item
295            let rendered_items: Vec<String> = items
296                .iter()
297                .map(|item| self.render_iteration_item(block_body, &item_prefix, item))
298                .collect();
299
300            // Replace the {{#each}}...{{/each}} block with rendered items
301            result = result.replace(full_match, &rendered_items.join(""));
302        }
303
304        Ok(result)
305    }
306
307    /// Renders a single iteration item.
308    #[allow(clippy::unused_self)]
309    fn render_iteration_item(
310        &self,
311        block: &str,
312        item_prefix: &str,
313        item: &HashMap<String, String>,
314    ) -> String {
315        let mut rendered = block.to_string();
316
317        // Replace {{item_prefix.field}} with item values
318        for (field, value) in item {
319            let pattern = format!("{{{{{item_prefix}.{field}}}}}");
320            rendered = rendered.replace(&pattern, value);
321        }
322
323        // Also handle {{this.field}} pattern (common Handlebars/Mustache syntax)
324        for (field, value) in item {
325            let pattern = format!("{{{{this.{field}}}}}");
326            rendered = rendered.replace(&pattern, value);
327        }
328
329        // Also handle the generic {{prefix.field}} pattern via regex
330        rendered = ITEM_VAR_PATTERN
331            .replace_all(&rendered, |caps: &regex::Captures| {
332                let prefix = caps.get(1).map_or("", |m| m.as_str());
333                let field = caps.get(2).map_or("", |m| m.as_str());
334
335                // Replace if prefix matches our item prefix or is "this"
336                if prefix == item_prefix || prefix == "this" {
337                    item.get(field).cloned().unwrap_or_default()
338                } else {
339                    // Leave other patterns unchanged
340                    caps.get(0).map_or("", |m| m.as_str()).to_string()
341                }
342            })
343            .to_string();
344
345        rendered
346    }
347
348    /// Converts rendered markdown to the target output format.
349    fn format_output(&self, markdown: &str, format: OutputFormat) -> Result<String> {
350        match format {
351            OutputFormat::Markdown => Ok(markdown.to_string()),
352            OutputFormat::Json => self.markdown_to_json(markdown),
353            OutputFormat::Xml => self.markdown_to_xml(markdown),
354        }
355    }
356
357    /// Converts markdown to JSON format.
358    ///
359    /// Creates a structured JSON object with sections and content.
360    #[allow(clippy::unused_self)]
361    fn markdown_to_json(&self, markdown: &str) -> Result<String> {
362        let mut sections: Vec<serde_json::Value> = Vec::new();
363        let mut current_section: Option<(String, Vec<String>)> = None;
364
365        for line in markdown.lines() {
366            if let Some(title) = line.strip_prefix("# ") {
367                flush_section(&mut sections, &mut current_section, 1);
368                current_section = Some((title.to_string(), Vec::new()));
369            } else if let Some(title) = line.strip_prefix("## ") {
370                flush_section(&mut sections, &mut current_section, 2);
371                current_section = Some((title.to_string(), Vec::new()));
372            } else if let Some(title) = line.strip_prefix("### ") {
373                flush_section(&mut sections, &mut current_section, 2);
374                current_section = Some((title.to_string(), Vec::new()));
375            } else if let Some((_, ref mut lines)) = current_section {
376                lines.push(line.to_string());
377            } else if !line.trim().is_empty() {
378                // Content before any section
379                sections.push(serde_json::json!({
380                    "level": 0,
381                    "title": "",
382                    "content": line
383                }));
384            }
385        }
386
387        // Flush final section
388        flush_section(&mut sections, &mut current_section, 2);
389
390        serde_json::to_string_pretty(&serde_json::json!({
391            "sections": sections,
392            "raw": markdown
393        }))
394        .map_err(|e| Error::OperationFailed {
395            operation: "json_conversion".to_string(),
396            cause: e.to_string(),
397        })
398    }
399
400    /// Converts markdown to XML format.
401    #[allow(clippy::unused_self, clippy::unnecessary_wraps)]
402    fn markdown_to_xml(&self, markdown: &str) -> Result<String> {
403        let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<context>\n");
404        let mut state = XmlState::default();
405
406        for line in markdown.lines() {
407            let trimmed = line.trim();
408            if trimmed.is_empty() {
409                continue;
410            }
411            process_xml_line(trimmed, &mut xml, &mut state);
412        }
413
414        // Close any open sections
415        while state.level > 0 {
416            let _ = writeln!(xml, "{}</section>", "  ".repeat(state.level));
417            state.level -= 1;
418        }
419
420        xml.push_str("</context>");
421        Ok(xml)
422    }
423}
424
425/// State for XML conversion.
426#[derive(Default)]
427struct XmlState {
428    in_section: bool,
429    level: usize,
430}
431
432/// Processes a single line for XML conversion.
433fn process_xml_line(trimmed: &str, xml: &mut String, state: &mut XmlState) {
434    if let Some(title) = trimmed.strip_prefix("# ") {
435        close_section_if_needed(xml, state);
436        state.level = 1;
437        state.in_section = true;
438        let escaped = escape_xml(title);
439        let _ = writeln!(xml, "  <section level=\"1\" title=\"{escaped}\">");
440    } else if let Some(title) = trimmed.strip_prefix("## ") {
441        close_section_at_level(xml, state, 2);
442        state.level = 2;
443        state.in_section = true;
444        let escaped = escape_xml(title);
445        let _ = writeln!(xml, "    <section level=\"2\" title=\"{escaped}\">");
446    } else if let Some(title) = trimmed.strip_prefix("### ") {
447        close_section_at_level(xml, state, 3);
448        state.level = 3;
449        state.in_section = true;
450        let escaped = escape_xml(title);
451        let _ = writeln!(xml, "      <section level=\"3\" title=\"{escaped}\">");
452    } else if let Some(item_text) = trimmed.strip_prefix("- ") {
453        let escaped = escape_xml(item_text);
454        let indent = "  ".repeat(state.level + 1);
455        let _ = writeln!(xml, "{indent}<item>{escaped}</item>");
456    } else {
457        let escaped = escape_xml(trimmed);
458        let indent = "  ".repeat(state.level + 1);
459        let _ = writeln!(xml, "{indent}<text>{escaped}</text>");
460    }
461}
462
463/// Closes the current section if one is open.
464fn close_section_if_needed(xml: &mut String, state: &XmlState) {
465    if state.in_section {
466        let _ = writeln!(xml, "{}</section>", "  ".repeat(state.level));
467    }
468}
469
470/// Closes section if at or above the given level.
471fn close_section_at_level(xml: &mut String, state: &XmlState, target: usize) {
472    if state.in_section && state.level >= target {
473        let _ = writeln!(xml, "{}</section>", "  ".repeat(state.level));
474    }
475}
476
477/// Flushes a section to the sections list.
478fn flush_section(
479    sections: &mut Vec<serde_json::Value>,
480    current: &mut Option<(String, Vec<String>)>,
481    level: usize,
482) {
483    if let Some((title, lines)) = current.take() {
484        sections.push(serde_json::json!({
485            "level": level,
486            "title": title,
487            "content": lines.join("\n").trim()
488        }));
489    }
490}
491
492/// Gets the item prefix for iteration (singular form of collection name).
493fn get_item_prefix(collection_name: &str) -> String {
494    // Handle common plurals
495    if collection_name == "memories" {
496        return "memory".to_string();
497    }
498    if let Some(stripped) = collection_name.strip_suffix("ies") {
499        // e.g., "entries" -> "entry"
500        return format!("{stripped}y");
501    }
502    if let Some(stripped) = collection_name.strip_suffix('s') {
503        // e.g., "items" -> "item"
504        return stripped.to_string();
505    }
506    // Default: use collection name as-is
507    collection_name.to_string()
508}
509
510/// Escapes special XML characters.
511fn escape_xml(s: &str) -> String {
512    s.replace('&', "&amp;")
513        .replace('<', "&lt;")
514        .replace('>', "&gt;")
515        .replace('"', "&quot;")
516        .replace('\'', "&apos;")
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use crate::models::ContextTemplate;
523
524    #[test]
525    fn test_render_value_string() {
526        let value = RenderValue::string("hello");
527        assert_eq!(value.as_string(), Some("hello"));
528        assert!(value.as_list().is_none());
529        assert_eq!(value.to_string_repr(), "hello");
530    }
531
532    #[test]
533    fn test_render_value_list() {
534        let mut item = HashMap::new();
535        item.insert("name".to_string(), "test".to_string());
536        let value = RenderValue::list(vec![item]);
537
538        assert!(value.as_string().is_none());
539        assert!(value.as_list().is_some());
540        assert_eq!(value.as_list().unwrap().len(), 1);
541    }
542
543    #[test]
544    fn test_render_context_add_and_get() {
545        let mut ctx = RenderContext::new();
546        ctx.add_string("name", "Alice");
547        ctx.add_string("count", "42");
548
549        assert_eq!(ctx.get_string("name"), Some("Alice"));
550        assert_eq!(ctx.get_string("count"), Some("42"));
551        assert!(ctx.get_string("missing").is_none());
552    }
553
554    #[test]
555    fn test_render_context_add_list() {
556        let mut ctx = RenderContext::new();
557        let mut item1 = HashMap::new();
558        item1.insert("id".to_string(), "1".to_string());
559        let mut item2 = HashMap::new();
560        item2.insert("id".to_string(), "2".to_string());
561
562        ctx.add_list("items", vec![item1, item2]);
563
564        let list = ctx.get_list("items").unwrap();
565        assert_eq!(list.len(), 2);
566    }
567
568    #[test]
569    fn test_render_context_to_string_map() {
570        let mut ctx = RenderContext::new();
571        ctx.add_string("name", "test");
572        ctx.add_string("value", "123");
573
574        let map = ctx.to_string_map();
575        assert_eq!(map.get("name"), Some(&"test".to_string()));
576        assert_eq!(map.get("value"), Some(&"123".to_string()));
577    }
578
579    #[test]
580    fn test_simple_variable_substitution() {
581        let renderer = TemplateRenderer::new();
582        let template = ContextTemplate::new("test", "Hello {{name}}!");
583
584        let mut ctx = RenderContext::new();
585        ctx.add_string("name", "World");
586
587        let result = renderer
588            .render(&template, &ctx, OutputFormat::Markdown)
589            .unwrap();
590        assert_eq!(result, "Hello World!");
591    }
592
593    #[test]
594    fn test_iteration_with_memories() {
595        let renderer = TemplateRenderer::new();
596        let template = ContextTemplate::new(
597            "test",
598            "Memories:\n{{#each memories}}- {{memory.content}}\n{{/each}}",
599        );
600
601        let mut ctx = RenderContext::new();
602        let mut mem1 = HashMap::new();
603        mem1.insert("content".to_string(), "First memory".to_string());
604        let mut mem2 = HashMap::new();
605        mem2.insert("content".to_string(), "Second memory".to_string());
606        ctx.add_list("memories", vec![mem1, mem2]);
607
608        let result = renderer
609            .render(&template, &ctx, OutputFormat::Markdown)
610            .unwrap();
611        assert!(result.contains("- First memory"));
612        assert!(result.contains("- Second memory"));
613    }
614
615    #[test]
616    fn test_iteration_with_multiple_fields() {
617        let renderer = TemplateRenderer::new();
618        let template = ContextTemplate::new(
619            "test",
620            "{{#each memories}}**{{memory.namespace}}**: {{memory.content}} ({{memory.score}})\n{{/each}}",
621        );
622
623        let mut ctx = RenderContext::new();
624        let mut mem = HashMap::new();
625        mem.insert("namespace".to_string(), "decisions".to_string());
626        mem.insert("content".to_string(), "Use Rust".to_string());
627        mem.insert("score".to_string(), "0.95".to_string());
628        ctx.add_list("memories", vec![mem]);
629
630        let result = renderer
631            .render(&template, &ctx, OutputFormat::Markdown)
632            .unwrap();
633        assert!(result.contains("**decisions**"));
634        assert!(result.contains("Use Rust"));
635        assert!(result.contains("0.95"));
636    }
637
638    #[test]
639    fn test_iteration_empty_collection() {
640        let renderer = TemplateRenderer::new();
641        let template = ContextTemplate::new(
642            "test",
643            "Start\n{{#each memories}}{{memory.content}}\n{{/each}}End",
644        );
645
646        let mut ctx = RenderContext::new();
647        ctx.add_list("memories", vec![]);
648
649        let result = renderer
650            .render(&template, &ctx, OutputFormat::Markdown)
651            .unwrap();
652        assert_eq!(result, "Start\nEnd");
653    }
654
655    #[test]
656    fn test_iteration_missing_collection() {
657        let renderer = TemplateRenderer::new();
658        let template = ContextTemplate::new("test", "{{#each missing}}{{item.value}}{{/each}}");
659
660        let ctx = RenderContext::new();
661        let result = renderer.render(&template, &ctx, OutputFormat::Markdown);
662
663        assert!(result.is_err());
664        let err = result.unwrap_err().to_string();
665        assert!(err.contains("not found"));
666    }
667
668    #[test]
669    fn test_mixed_iteration_and_variables() {
670        let renderer = TemplateRenderer::new();
671        let template = ContextTemplate::new(
672            "test",
673            "# {{title}}\n\n{{#each items}}- {{item.name}}\n{{/each}}\n\nTotal: {{total}}",
674        );
675
676        let mut ctx = RenderContext::new();
677        ctx.add_string("title", "My List");
678        ctx.add_string("total", "2");
679
680        let mut item1 = HashMap::new();
681        item1.insert("name".to_string(), "Item A".to_string());
682        let mut item2 = HashMap::new();
683        item2.insert("name".to_string(), "Item B".to_string());
684        ctx.add_list("items", vec![item1, item2]);
685
686        let result = renderer
687            .render(&template, &ctx, OutputFormat::Markdown)
688            .unwrap();
689        assert!(result.contains("# My List"));
690        assert!(result.contains("- Item A"));
691        assert!(result.contains("- Item B"));
692        assert!(result.contains("Total: 2"));
693    }
694
695    #[test]
696    fn test_get_item_prefix() {
697        assert_eq!(get_item_prefix("memories"), "memory");
698        assert_eq!(get_item_prefix("items"), "item");
699        assert_eq!(get_item_prefix("entries"), "entry");
700        assert_eq!(get_item_prefix("tags"), "tag");
701        assert_eq!(get_item_prefix("data"), "data"); // No change for non-standard
702    }
703
704    #[test]
705    fn test_format_json() {
706        let renderer = TemplateRenderer::new();
707        let template = ContextTemplate::new("test", "# Title\n\nContent here");
708
709        let ctx = RenderContext::new();
710        let result = renderer
711            .render(&template, &ctx, OutputFormat::Json)
712            .unwrap();
713
714        // Should be valid JSON
715        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
716        assert!(parsed.get("sections").is_some());
717        assert!(parsed.get("raw").is_some());
718    }
719
720    #[test]
721    fn test_format_xml() {
722        let renderer = TemplateRenderer::new();
723        let template = ContextTemplate::new("test", "# Title\n\nContent here\n- Item 1");
724
725        let ctx = RenderContext::new();
726        let result = renderer.render(&template, &ctx, OutputFormat::Xml).unwrap();
727
728        assert!(result.starts_with("<?xml"));
729        assert!(result.contains("<context>"));
730        assert!(result.contains("</context>"));
731        assert!(result.contains("<section"));
732        assert!(result.contains("<item>"));
733    }
734
735    #[test]
736    fn test_escape_xml() {
737        assert_eq!(escape_xml("a < b"), "a &lt; b");
738        assert_eq!(escape_xml("a > b"), "a &gt; b");
739        assert_eq!(escape_xml("a & b"), "a &amp; b");
740        assert_eq!(escape_xml("a \"b\" c"), "a &quot;b&quot; c");
741    }
742
743    #[test]
744    fn test_render_value_from_string() {
745        let value: RenderValue = "test".into();
746        assert_eq!(value.as_string(), Some("test"));
747
748        let owned: RenderValue = String::from("owned").into();
749        assert_eq!(owned.as_string(), Some("owned"));
750    }
751
752    #[test]
753    fn test_iteration_with_this_syntax() {
754        let renderer = TemplateRenderer::new();
755        let template = ContextTemplate::new(
756            "test",
757            "## Memories\n{{#each memories}}- **{{this.namespace}}**: {{this.content}}\n{{/each}}",
758        );
759
760        let mut ctx = RenderContext::new();
761        let mut mem1 = HashMap::new();
762        mem1.insert("namespace".to_string(), "decisions".to_string());
763        mem1.insert("content".to_string(), "Use Rust".to_string());
764        let mut mem2 = HashMap::new();
765        mem2.insert("namespace".to_string(), "learnings".to_string());
766        mem2.insert("content".to_string(), "SQLite is fast".to_string());
767        ctx.add_list("memories", vec![mem1, mem2]);
768
769        let result = renderer
770            .render(&template, &ctx, OutputFormat::Markdown)
771            .unwrap();
772        assert!(result.contains("**decisions**"));
773        assert!(result.contains("Use Rust"));
774        assert!(result.contains("**learnings**"));
775        assert!(result.contains("SQLite is fast"));
776    }
777
778    #[test]
779    fn test_render_context_length() {
780        let mut ctx = RenderContext::new();
781        assert!(ctx.is_empty());
782        assert_eq!(ctx.len(), 0);
783
784        ctx.add_string("a", "1");
785        ctx.add_string("b", "2");
786
787        assert!(!ctx.is_empty());
788        assert_eq!(ctx.len(), 2);
789        assert!(ctx.contains("a"));
790        assert!(!ctx.contains("c"));
791    }
792}