Skip to main content

ralph_workflow/prompts/
template_engine.rs

1//! Template engine for rendering prompt templates.
2//!
3//! This module provides a template variable replacement system for prompt templates
4//! with support for variables, partials, comments, conditionals, loops, and defaults.
5//!
6//! ## Syntax
7//!
8//! - **Variables**: `{{VARIABLE}}` or `{{ VARIABLE }}` - replaced with values
9//! - **Default values**: `{{VARIABLE|default="value"}}` - uses value if VARIABLE is missing
10//! - **Conditionals**: `{% if VARIABLE %}...{% endif %}` - include content if VARIABLE is truthy
11//! - **Negation**: `{% if !VARIABLE %}...{% endif %}` - include content if VARIABLE is falsy
12//! - **Loops**: `{% for item in ITEMS %}...{% endfor %}` - iterate over comma-separated values
13//! - **Partials**: `{{> partial_name}}` or `{{> partial/path}}` - includes another template
14//! - **Comments**: `{# comment #}` - stripped from output, useful for documentation
15//!
16//! ## Partials System
17//!
18//! Partials allow sharing common template sections across multiple templates.
19//! When a partial is referenced, it's looked up from the provided partials map
20//! and recursively rendered with the same variables.
21//!
22//! Example partial include:
23//! ```text
24//! {{> shared/_critical_header}}
25//! ```
26//!
27//! The partials system:
28//! - Detects and prevents circular references
29//! - Provides clear error messages for missing partials
30//! - Supports hierarchical naming (dot notation or path-style)
31
32use std::collections::HashMap;
33
34/// Error type for template operations.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum TemplateError {
37    /// Required variable not provided.
38    MissingVariable(String),
39    /// Referenced partial not found in partials map.
40    PartialNotFound(String),
41    /// Circular reference detected in partial includes.
42    CircularReference(Vec<String>),
43}
44
45impl std::fmt::Display for TemplateError {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            Self::MissingVariable(name) => write!(f, "Missing required variable: {{{{ {name} }}}}"),
49            Self::PartialNotFound(name) => {
50                write!(f, "Partial not found: '{{> {name}}}'")
51            }
52            Self::CircularReference(chain) => {
53                write!(f, "Circular reference detected in partials: ")?;
54                let mut sep = "";
55                for partial in chain {
56                    write!(f, "{sep}{{{{> {partial}}}}}")?;
57                    sep = " -> ";
58                }
59                Ok(())
60            }
61        }
62    }
63}
64
65impl std::error::Error for TemplateError {}
66
67/// A simple template engine for prompt templates.
68///
69/// Templates use `{{VARIABLE}}` syntax for placeholders and `{{> partial}}` for
70/// including shared templates. Variables are replaced with the provided values.
71/// Comments using `{# comment #}` syntax are stripped.
72///
73/// # Example
74///
75/// ```ignore
76/// let partials = HashMap::from([("header", "Common Header\n")]);
77/// let template = Template::new("{{> header}}\nReview this diff:\n{{DIFF}}");
78/// let variables = HashMap::from([("DIFF", "+ new line")]);
79/// let rendered = template.render_with_partials(&variables, &partials)?;
80/// ```
81#[derive(Debug, Clone)]
82pub struct Template {
83    /// The template content with comments and partials processed.
84    content: String,
85}
86
87impl Template {
88    /// Create a template from a string.
89    ///
90    /// Comments (`{# ... #}`) are stripped during creation.
91    /// All features are enabled by default: variables, conditionals, loops, and defaults.
92    pub fn new(content: &str) -> Self {
93        // Strip comments first
94        let content = Self::strip_comments(content);
95        Self { content }
96    }
97
98    /// Strip `{# comment #}` style comments from the content.
99    ///
100    /// Comments can span multiple lines. Handles line-only comments that leave
101    /// empty lines behind by collapsing them.
102    fn strip_comments(content: &str) -> String {
103        let mut result = String::with_capacity(content.len());
104        let bytes = content.as_bytes();
105
106        let mut i = 0;
107        while i < bytes.len() {
108            // Check for {# comment start
109            if i + 1 < bytes.len() && bytes[i] == b'{' && bytes[i + 1] == b'#' {
110                // Find the end of the comment (#})
111                let comment_start = i;
112                i += 2;
113                while i + 1 < bytes.len() && !(bytes[i] == b'#' && bytes[i + 1] == b'}') {
114                    i += 1;
115                }
116                if i + 1 < bytes.len() && bytes[i] == b'#' && bytes[i + 1] == b'}' {
117                    i += 2;
118                    // Skip trailing whitespace on the same line if comment was on its own line
119                    // Check if we're at the end of a line (or there's only whitespace until newline)
120                    let whitespace_start = i;
121                    while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
122                        i += 1;
123                    }
124                    // If we hit a newline after whitespace, skip it too (comment was full line)
125                    if i < bytes.len() && bytes[i] == b'\n' {
126                        // Check if the line before the comment was also empty
127                        let was_line_start = result.is_empty() || result.ends_with('\n');
128                        if was_line_start {
129                            // Comment was on its own line - skip the newline
130                            i += 1;
131                        } else {
132                            // Comment was at end of a content line - restore whitespace position
133                            i = whitespace_start;
134                        }
135                    } else if i < bytes.len() {
136                        // Not a newline - restore whitespace position
137                        i = whitespace_start;
138                    }
139                    continue;
140                }
141                // Unclosed comment - treat as literal text
142                result.push_str(&content[comment_start..i]);
143            } else {
144                result.push(bytes[i] as char);
145                i += 1;
146            }
147        }
148
149        result
150    }
151
152    /// Process conditionals in the content based on variable values.
153    ///
154    /// Supports:
155    /// - `{% if VARIABLE %}...{% endif %}` - show content if VARIABLE is truthy
156    /// - `{% if !VARIABLE %}...{% endif %}` - show content if VARIABLE is falsy
157    ///
158    /// A variable is considered "truthy" if it exists and is non-empty.
159    fn process_conditionals(content: &str, variables: &HashMap<&str, String>) -> String {
160        let mut result = content.to_string();
161
162        // Find all {% if ... %} blocks
163        while let Some(start) = result.find("{% if ") {
164            // Find the end of the if condition
165            let if_end_start = start + 6; // "{% if " is 6 chars
166            let if_end = if let Some(pos) = result[if_end_start..].find("%}") {
167                if_end_start + pos + 2
168            } else {
169                // Unclosed if tag - skip it
170                result = result[start + 1..].to_string();
171                continue;
172            };
173
174            // Extract the condition
175            let condition = result[if_end_start..if_end - 2].trim().to_string();
176
177            // Find the matching {% endif %}
178            let endif_start = if let Some(pos) = result[if_end..].find("{% endif %}") {
179                if_end + pos
180            } else {
181                // Unclosed if block - skip it
182                result = result[start + 1..].to_string();
183                continue;
184            };
185
186            let endif_end = endif_start + 11; // "{% endif %}" is 11 chars
187
188            // Extract the content inside the if block
189            let block_content = result[if_end..endif_start].to_string();
190
191            // Evaluate the condition
192            let should_show = Self::evaluate_condition(&condition, variables);
193
194            // Replace the entire if block with the content or empty string
195            let replacement = if should_show {
196                block_content
197            } else {
198                String::new()
199            };
200            result.replace_range(start..endif_end, &replacement);
201        }
202
203        result
204    }
205
206    /// Evaluate a conditional expression.
207    ///
208    /// Supports:
209    /// - `VARIABLE` - true if variable exists and is non-empty
210    /// - `!VARIABLE` - true if variable doesn't exist or is empty
211    fn evaluate_condition(condition: &str, variables: &HashMap<&str, String>) -> bool {
212        let condition = condition.trim();
213
214        // Check for negation
215        if let Some(rest) = condition.strip_prefix('!') {
216            let var_name = rest.trim();
217            let value = variables.get(var_name);
218            return value.is_none_or(String::is_empty);
219        }
220
221        // Normal condition - check if variable exists and is non-empty
222        let value = variables.get(condition);
223        value.is_some_and(|v| !v.is_empty())
224    }
225
226    /// Process loops in the content based on variable values.
227    ///
228    /// Supports:
229    /// - `{% for item in ITEMS %}...{% endfor %}` - iterate over comma-separated ITEMS
230    ///
231    /// The loop variable is available for use in the block content.
232    fn process_loops(content: &str, variables: &HashMap<&str, String>) -> String {
233        let mut result = content.to_string();
234
235        // Find all {% for ... %} blocks
236        while let Some(start) = result.find("{% for ") {
237            // Find the end of the for condition
238            let for_end_start = start + 7; // "{% for " is 7 chars
239            let for_end = if let Some(pos) = result[for_end_start..].find("%}") {
240                for_end_start + pos + 2
241            } else {
242                // Unclosed for tag - skip it
243                result = result[start + 1..].to_string();
244                continue;
245            };
246
247            // Parse "item in ITEMS"
248            let condition = result[for_end_start..for_end - 2].trim();
249            let parts: Vec<&str> = condition.split(" in ").collect();
250            if parts.len() != 2 {
251                // Invalid for syntax - skip it
252                result = result[start + 1..].to_string();
253                continue;
254            }
255
256            let loop_var = parts[0].trim().to_string();
257            let list_var = parts[1].trim();
258
259            // Find the matching {% endfor %}
260            let endfor_start = if let Some(pos) = result[for_end..].find("{% endfor %}") {
261                for_end + pos
262            } else {
263                // Unclosed for block - skip it
264                result = result[start + 1..].to_string();
265                continue;
266            };
267
268            let endfor_end = endfor_start + 12; // "{% endfor %}" is 12 chars
269
270            // Extract the template inside the for block
271            let block_template = result[for_end..endfor_start].to_string();
272
273            // Get the list of values
274            let items: Vec<String> = variables.get(list_var).map_or(Vec::new(), |v| {
275                if v.is_empty() {
276                    Vec::new()
277                } else {
278                    // Split by comma and trim each item
279                    v.split(',').map(|s| s.trim().to_string()).collect()
280                }
281            });
282
283            // Build the loop output
284            let mut loop_output = String::new();
285            for item in items {
286                // Create a temporary variable map with the loop variable
287                let mut loop_vars: HashMap<&str, String> = variables.clone();
288                loop_vars.insert(&loop_var, item);
289
290                // Process conditionals first with loop variables
291                let processed = Self::process_conditionals(&block_template, &loop_vars);
292
293                // Then substitute variables
294                let (processed, _missing) = Self::substitute_variables(&processed, &loop_vars);
295                loop_output.push_str(&processed);
296            }
297
298            // Replace the entire for block with the loop output
299            result.replace_range(start..endfor_end, &loop_output);
300        }
301
302        result
303    }
304
305    /// Substitute variables in content (simple version without partials or conditionals).
306    /// Returns `(result, missing_vars)` where `missing_vars` is a list of variable names
307    /// that were referenced but not found (and had no default).
308    fn substitute_variables(
309        content: &str,
310        variables: &HashMap<&str, String>,
311    ) -> (String, Vec<String>) {
312        let mut result = content.to_string();
313        let mut missing_vars = Vec::new();
314
315        // Find all {{...}} patterns
316        let mut replacements = Vec::new();
317        let mut i = 0;
318        let bytes = content.as_bytes();
319        while i < bytes.len().saturating_sub(1) {
320            if bytes[i] == b'{' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
321                let start = i;
322                i += 2;
323
324                // Skip whitespace after {{
325                while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
326                    i += 1;
327                }
328
329                let name_start = i;
330
331                // Find the closing }}
332                while i < bytes.len()
333                    && !(bytes[i] == b'}' && i + 1 < bytes.len() && bytes[i + 1] == b'}')
334                {
335                    i += 1;
336                }
337
338                if i < bytes.len()
339                    && bytes[i] == b'}'
340                    && i + 1 < bytes.len()
341                    && bytes[i + 1] == b'}'
342                {
343                    let end = i + 2;
344                    let var_spec = &content[name_start..i];
345
346                    // Check for partial reference {{> partial}} - skip it
347                    if var_spec.trim().starts_with('>') {
348                        i = end;
349                        continue;
350                    }
351
352                    // Skip if variable name is empty or whitespace only
353                    let trimmed_var = var_spec.trim();
354                    if trimmed_var.is_empty() {
355                        i = end;
356                        continue;
357                    }
358
359                    // Check for default value syntax: {{VAR|default="value"}}
360                    let (var_name, default_value) =
361                        var_spec.find('|').map_or((trimmed_var, None), |pipe_pos| {
362                            let name = var_spec[..pipe_pos].trim();
363                            let rest = &var_spec[pipe_pos + 1..];
364                            // Parse default="value"
365                            rest.find('=').map_or((name, None), |eq_pos| {
366                                let key = rest[..eq_pos].trim();
367                                if key == "default" {
368                                    let value = rest[eq_pos + 1..].trim();
369                                    // Remove quotes if present (both single and double)
370                                    let value = if (value.starts_with('"') && value.ends_with('"'))
371                                        || (value.starts_with('\'') && value.ends_with('\''))
372                                    {
373                                        &value[1..value.len() - 1]
374                                    } else {
375                                        value
376                                    };
377                                    (name, Some(value.to_string()))
378                                } else {
379                                    (name, None)
380                                }
381                            })
382                        });
383
384                    // Look up the variable
385                    let (replacement, should_replace) = variables.get(var_name).map_or_else(
386                        || {
387                            default_value.as_ref().map_or_else(
388                                || {
389                                    // Track as missing
390                                    missing_vars.push(var_name.to_string());
391                                    (String::new(), false)
392                                },
393                                |default| (default.clone(), true),
394                            )
395                        },
396                        |value| {
397                            if !value.is_empty() {
398                                (value.clone(), true)
399                            } else if let Some(default) = &default_value {
400                                (default.clone(), true)
401                            } else {
402                                // Variable exists but is empty, and no default - keep placeholder
403                                (String::new(), false)
404                            }
405                        },
406                    );
407
408                    if should_replace {
409                        replacements.push((start, end, replacement));
410                    }
411                    i = end;
412                    continue;
413                }
414            }
415            i += 1;
416        }
417
418        // Apply replacements in reverse order to maintain correct positions
419        for (start, end, replacement) in replacements.into_iter().rev() {
420            result.replace_range(start..end, &replacement);
421        }
422
423        (result, missing_vars)
424    }
425
426    /// Render the template with the provided variables.
427    pub fn render(&self, variables: &HashMap<&str, String>) -> Result<String, TemplateError> {
428        // Process loops first (they may generate new variable references)
429        let mut result = Self::process_loops(&self.content, variables);
430
431        // Process conditionals
432        result = Self::process_conditionals(&result, variables);
433
434        // Substitute variables (with default values)
435        let (result_after_sub, missing_vars) = Self::substitute_variables(&result, variables);
436
437        // Check for missing variables
438        if let Some(first_missing) = missing_vars.first() {
439            return Err(TemplateError::MissingVariable(first_missing.clone()));
440        }
441
442        Ok(result_after_sub)
443    }
444
445    /// Render the template with variables and partials support.
446    ///
447    /// Partials are processed recursively, with the same variables passed to each partial.
448    /// Circular references are detected and reported with a clear error.
449    pub fn render_with_partials(
450        &self,
451        variables: &HashMap<&str, String>,
452        partials: &HashMap<String, String>,
453    ) -> Result<String, TemplateError> {
454        self.render_with_partials_recursive(variables, partials, &mut Vec::new())
455    }
456
457    /// Internal recursive rendering with circular reference detection.
458    /// `visited` is a Vec that tracks the order of partials visited for proper error reporting.
459    fn render_with_partials_recursive(
460        &self,
461        variables: &HashMap<&str, String>,
462        partials: &HashMap<String, String>,
463        visited: &mut Vec<String>,
464    ) -> Result<String, TemplateError> {
465        // First, extract and resolve all partials in this template
466        let mut result = self.content.clone();
467
468        // Find all {{> partial}} references
469        let partial_refs = Self::extract_partials(&result);
470
471        // Process partials in reverse order to maintain correct positions when replacing
472        for (full_match, partial_name) in partial_refs.into_iter().rev() {
473            // Check for circular reference
474            if visited.contains(&partial_name) {
475                let mut chain = visited.clone();
476                chain.push(partial_name);
477                return Err(TemplateError::CircularReference(chain));
478            }
479
480            // Look up the partial content
481            let partial_content = partials
482                .get(&partial_name)
483                .ok_or_else(|| TemplateError::PartialNotFound(partial_name.clone()))?;
484
485            // Create a template from the partial and render it recursively
486            let partial_template = Self::new(partial_content);
487            visited.push(partial_name.clone());
488            let rendered_partial =
489                partial_template.render_with_partials_recursive(variables, partials, visited)?;
490            visited.pop();
491
492            // Replace the partial reference with rendered content
493            result = result.replace(&full_match, &rendered_partial);
494        }
495
496        // Process loops (they may generate new variable references)
497        result = Self::process_loops(&result, variables);
498
499        // Process conditionals
500        result = Self::process_conditionals(&result, variables);
501
502        // Now substitute variables in the result (using the new method that handles defaults)
503        let (result_after_sub, missing_vars) = Self::substitute_variables(&result, variables);
504
505        // Check for missing variables
506        if let Some(first_missing) = missing_vars.first() {
507            return Err(TemplateError::MissingVariable(first_missing.clone()));
508        }
509
510        Ok(result_after_sub)
511    }
512
513    /// Extract all partial references from template content.
514    ///
515    /// Returns Vec of (`full_match`, `partial_name`) tuples in order of appearance.
516    fn extract_partials(content: &str) -> Vec<(String, String)> {
517        let mut partials = Vec::new();
518        let bytes = content.as_bytes();
519
520        let mut i = 0;
521        while i < bytes.len().saturating_sub(2) {
522            // Check for {{> pattern
523            if bytes[i] == b'{' && bytes[i + 1] == b'{' && i + 2 < bytes.len() {
524                let start = i;
525                i += 2;
526
527                // Skip whitespace after {{
528                while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
529                    i += 1;
530                }
531
532                // Check for > character
533                if i < bytes.len() && bytes[i] == b'>' {
534                    i += 1;
535
536                    // Skip whitespace after >
537                    while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
538                        i += 1;
539                    }
540
541                    // Extract partial name until }}
542                    let name_start = i;
543                    while i < bytes.len()
544                        && !(bytes[i] == b'}' && i + 1 < bytes.len() && bytes[i + 1] == b'}')
545                    {
546                        i += 1;
547                    }
548
549                    if i < bytes.len()
550                        && bytes[i] == b'}'
551                        && i + 1 < bytes.len()
552                        && bytes[i + 1] == b'}'
553                    {
554                        let end = i + 2;
555                        let full_match = &content[start..end];
556                        let name = &content[name_start..i];
557
558                        let partial_name = name.trim().to_string();
559                        if !partial_name.is_empty() {
560                            partials.push((full_match.to_string(), partial_name));
561                        }
562                        i = end;
563                        continue;
564                    }
565                }
566            }
567            i += 1;
568        }
569
570        partials
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577
578    #[test]
579    fn test_render_template() {
580        let template = Template::new("Hello {{NAME}}, your score is {{SCORE}}.");
581        let variables = HashMap::from([("NAME", "Alice".to_string()), ("SCORE", "42".to_string())]);
582        let rendered = template.render(&variables).unwrap();
583        assert_eq!(rendered, "Hello Alice, your score is 42.");
584    }
585
586    #[test]
587    fn test_missing_variable() {
588        let template = Template::new("Hello {{NAME}}.");
589        let variables = HashMap::new();
590        let result = template.render(&variables);
591        assert_eq!(
592            result,
593            Err(TemplateError::MissingVariable("NAME".to_string()))
594        );
595    }
596
597    #[test]
598    fn test_no_variables() {
599        let template = Template::new("Just plain text.");
600        let rendered = template.render(&HashMap::new()).unwrap();
601        assert_eq!(rendered, "Just plain text.");
602    }
603
604    #[test]
605    fn test_multiline_template() {
606        let template = Template::new("Review this:\n{{DIFF}}\nEnd of review.");
607        let variables = HashMap::from([("DIFF", "+ new line".to_string())]);
608        let rendered = template.render(&variables).unwrap();
609        assert_eq!(rendered, "Review this:\n+ new line\nEnd of review.");
610    }
611
612    #[test]
613    fn test_whitespace_in_variables() {
614        let template = Template::new("Value: {{ VALUE }}.");
615        let variables = HashMap::from([("VALUE", "42".to_string())]);
616        let rendered = template.render(&variables).unwrap();
617        assert_eq!(rendered, "Value: 42.");
618    }
619
620    #[test]
621    fn test_unclosed_opening_braces() {
622        // Unclosed {{ should be ignored (no placeholder extracted)
623        let template = Template::new("Hello {{NAME and some text");
624        let rendered = template.render(&HashMap::new()).unwrap();
625        // The unclosed braces are treated as literal text
626        assert_eq!(rendered, "Hello {{NAME and some text");
627    }
628
629    #[test]
630    fn test_empty_variable_name() {
631        // Empty variable name {{}} should be ignored (no placeholder extracted)
632        let template = Template::new("Value: {{}}.");
633        let rendered = template.render(&HashMap::new()).unwrap();
634        // Empty placeholder is treated as literal text
635        assert_eq!(rendered, "Value: {{}}.");
636    }
637
638    #[test]
639    fn test_whitespace_only_variable_name() {
640        // Whitespace-only variable name {{   }} should be ignored
641        let template = Template::new("Value: {{   }}.");
642        let rendered = template.render(&HashMap::new()).unwrap();
643        // Whitespace-only placeholder is treated as literal text
644        assert_eq!(rendered, "Value: {{   }}.");
645    }
646
647    #[test]
648    fn test_multiple_unclosed_braces() {
649        // Multiple unclosed {{ should all be ignored
650        let template = Template::new("{{A text {{B text");
651        let rendered = template.render(&HashMap::new()).unwrap();
652        assert_eq!(rendered, "{{A text {{B text");
653    }
654
655    #[test]
656    fn test_partial_closing_brace() {
657        // Single closing brace without the second should not close the placeholder
658        let template = Template::new("Hello {{NAME}} and {{VAR}} text");
659        let variables = HashMap::from([("NAME", "Alice".to_string()), ("VAR", "Bob".to_string())]);
660        let rendered = template.render(&variables).unwrap();
661        assert_eq!(rendered, "Hello Alice and Bob text");
662    }
663
664    // =========================================================================
665    // Comment Stripping Tests
666    // =========================================================================
667
668    #[test]
669    fn test_inline_comment_stripped() {
670        let template = Template::new("Hello {# this is a comment #}world.");
671        let rendered = template.render(&HashMap::new()).unwrap();
672        assert_eq!(rendered, "Hello world.");
673    }
674
675    #[test]
676    fn test_comment_on_own_line_stripped() {
677        // Comment on its own line should be completely removed including the line itself
678        let template = Template::new("Line 1\n{# This is a comment #}\nLine 2");
679        let rendered = template.render(&HashMap::new()).unwrap();
680        assert_eq!(rendered, "Line 1\nLine 2");
681    }
682
683    #[test]
684    fn test_multiline_comment() {
685        // Multiline comments should be fully stripped
686        let template = Template::new("Before{# comment\nspanning\nlines #}After");
687        let rendered = template.render(&HashMap::new()).unwrap();
688        assert_eq!(rendered, "BeforeAfter");
689    }
690
691    #[test]
692    fn test_comment_at_end_of_content_line() {
693        // Comment at end of content line should only remove the comment
694        let template = Template::new("Content{# comment #}\nMore");
695        let rendered = template.render(&HashMap::new()).unwrap();
696        assert_eq!(rendered, "Content\nMore");
697    }
698
699    #[test]
700    fn test_multiple_comments() {
701        let template = Template::new("{# first #}A{# second #}B{# third #}");
702        let rendered = template.render(&HashMap::new()).unwrap();
703        assert_eq!(rendered, "AB");
704    }
705
706    #[test]
707    fn test_comment_with_variable() {
708        // Comments should work alongside variables
709        let template = Template::new("{# doc comment #}\nHello {{NAME}}!");
710        let variables = HashMap::from([("NAME", "World".to_string())]);
711        let rendered = template.render(&variables).unwrap();
712        assert_eq!(rendered, "Hello World!");
713    }
714
715    #[test]
716    fn test_unclosed_comment_preserved() {
717        // Unclosed comment should be treated as literal text
718        let template = Template::new("Hello {# unclosed comment");
719        let rendered = template.render(&HashMap::new()).unwrap();
720        assert_eq!(rendered, "Hello {# unclosed comment");
721    }
722
723    #[test]
724    fn test_comment_documentation_use_case() {
725        // Real use case: documentation comments in template
726        let content = r"{# Template Version: 1.0 #}
727{# This template generates commit messages #}
728You are a commit message expert.
729
730{# DIFF variable contains the git diff #}
731DIFF:
732{{DIFF}}
733
734{# End of template #}
735";
736        let template = Template::new(content);
737        let variables = HashMap::from([("DIFF", "+added line".to_string())]);
738        let rendered = template.render(&variables).unwrap();
739
740        // Verify documentation comments are stripped
741        assert!(!rendered.contains("Template Version"));
742        assert!(!rendered.contains("This template generates"));
743        assert!(!rendered.contains("DIFF variable contains"));
744        assert!(!rendered.contains("End of template"));
745
746        // Verify content is preserved
747        assert!(rendered.contains("You are a commit message expert."));
748        assert!(rendered.contains("+added line"));
749    }
750
751    // =========================================================================
752    // Partials Tests
753    // =========================================================================
754
755    #[test]
756    fn test_simple_partial_include() {
757        let partials = HashMap::from([("header".to_string(), "Common Header".to_string())]);
758        let template = Template::new("{{>header}}\nContent here");
759        let variables = HashMap::new();
760        let rendered = template
761            .render_with_partials(&variables, &partials)
762            .unwrap();
763        assert_eq!(rendered, "Common Header\nContent here");
764    }
765
766    #[test]
767    fn test_partial_with_whitespace() {
768        let partials = HashMap::from([("header".to_string(), "Header".to_string())]);
769        let template = Template::new("{{> header}}\nContent");
770        let variables = HashMap::new();
771        let rendered = template
772            .render_with_partials(&variables, &partials)
773            .unwrap();
774        assert_eq!(rendered, "Header\nContent");
775    }
776
777    #[test]
778    fn test_partial_with_variables() {
779        let partials = HashMap::from([("greeting".to_string(), "Hello {{NAME}}\n".to_string())]);
780        let template = Template::new("{{>greeting}}Body content");
781        let variables = HashMap::from([("NAME", "World".to_string())]);
782        let rendered = template
783            .render_with_partials(&variables, &partials)
784            .unwrap();
785        assert_eq!(rendered, "Hello World\nBody content");
786    }
787
788    #[test]
789    fn test_multiple_partials() {
790        let partials = HashMap::from([
791            ("header".to_string(), "=== HEADER ===\n".to_string()),
792            ("footer".to_string(), "\n=== FOOTER ===".to_string()),
793        ]);
794        let template = Template::new("{{>header}}Content{{>footer}}");
795        let variables = HashMap::new();
796        let rendered = template
797            .render_with_partials(&variables, &partials)
798            .unwrap();
799        assert_eq!(rendered, "=== HEADER ===\nContent\n=== FOOTER ===");
800    }
801
802    #[test]
803    fn test_nested_partials() {
804        let partials = HashMap::from([
805            (
806                "outer".to_string(),
807                "Outer start\n{{>inner}}\nOuter end".to_string(),
808            ),
809            ("inner".to_string(), "INNER CONTENT".to_string()),
810        ]);
811        let template = Template::new("{{>outer}}");
812        let variables = HashMap::new();
813        let rendered = template
814            .render_with_partials(&variables, &partials)
815            .unwrap();
816        assert_eq!(rendered, "Outer start\nINNER CONTENT\nOuter end");
817    }
818
819    #[test]
820    fn test_partial_not_found() {
821        let partials = HashMap::new();
822        let template = Template::new("{{>missing_partial}}");
823        let variables = HashMap::new();
824        let result = template.render_with_partials(&variables, &partials);
825        assert_eq!(
826            result,
827            Err(TemplateError::PartialNotFound(
828                "missing_partial".to_string()
829            ))
830        );
831    }
832
833    #[test]
834    fn test_circular_reference_detection() {
835        let partials = HashMap::from([
836            ("a".to_string(), "{{>b}}".to_string()),
837            ("b".to_string(), "{{>a}}".to_string()),
838        ]);
839        let template = Template::new("{{>a}}");
840        let variables = HashMap::new();
841        let result = template.render_with_partials(&variables, &partials);
842        match result {
843            Err(TemplateError::CircularReference(chain)) => {
844                // Chain should contain a circular reference between a and b
845                assert_eq!(chain.len(), 3);
846                assert!(chain.contains(&"a".to_string()));
847                assert!(chain.contains(&"b".to_string()));
848                // First and last elements should be the same (indicating a cycle)
849                assert_eq!(chain.first(), chain.last());
850            }
851            _ => panic!("Expected CircularReference error"),
852        }
853    }
854
855    #[test]
856    fn test_self_referential_partial() {
857        let partials = HashMap::from([("loop".to_string(), "{{>loop}}".to_string())]);
858        let template = Template::new("{{>loop}}");
859        let variables = HashMap::new();
860        let result = template.render_with_partials(&variables, &partials);
861        match result {
862            Err(TemplateError::CircularReference(chain)) => {
863                assert_eq!(chain, vec!["loop".to_string(), "loop".to_string()]);
864            }
865            _ => panic!("Expected CircularReference error"),
866        }
867    }
868
869    #[test]
870    fn test_partial_with_missing_variable() {
871        let partials = HashMap::from([("greeting".to_string(), "Hello {{NAME}}".to_string())]);
872        let template = Template::new("{{>greeting}}");
873        let variables = HashMap::new(); // NAME not provided
874        let result = template.render_with_partials(&variables, &partials);
875        assert_eq!(
876            result,
877            Err(TemplateError::MissingVariable("NAME".to_string()))
878        );
879    }
880
881    #[test]
882    fn test_partial_and_main_variables() {
883        let partials = HashMap::from([("greeting".to_string(), "Hello {{NAME}}\n".to_string())]);
884        let template = Template::new("{{>greeting}}Your score is {{SCORE}}");
885        let variables = HashMap::from([("NAME", "Alice".to_string()), ("SCORE", "42".to_string())]);
886        let rendered = template
887            .render_with_partials(&variables, &partials)
888            .unwrap();
889        assert_eq!(rendered, "Hello Alice\nYour score is 42");
890    }
891
892    #[test]
893    fn test_partial_with_comments() {
894        let partials = HashMap::from([(
895            "header".to_string(),
896            "{# This is a header #}Header Content\n".to_string(),
897        )]);
898        let template = Template::new("{{>header}}Body");
899        let variables = HashMap::new();
900        let rendered = template
901            .render_with_partials(&variables, &partials)
902            .unwrap();
903        assert_eq!(rendered, "Header Content\nBody");
904    }
905
906    #[test]
907    fn test_partial_with_path_style_name() {
908        let partials = HashMap::from([("shared/_header".to_string(), "Shared Header".to_string())]);
909        let template = Template::new("{{> shared/_header}}\nContent");
910        let variables = HashMap::new();
911        let rendered = template
912            .render_with_partials(&variables, &partials)
913            .unwrap();
914        assert_eq!(rendered, "Shared Header\nContent");
915    }
916
917    #[test]
918    fn test_backward_compatibility_render_without_partials() {
919        // Ensure the original render() method still works
920        let template = Template::new("Hello {{NAME}}");
921        let variables = HashMap::from([("NAME", "World".to_string())]);
922        let rendered = template.render(&variables).unwrap();
923        assert_eq!(rendered, "Hello World");
924    }
925
926    #[test]
927    fn test_empty_partial_name_ignored() {
928        // {{> }} with empty name should be treated as literal text
929        let template = Template::new("Before {{> }} After");
930        let variables = HashMap::new();
931        let rendered = template.render(&variables).unwrap();
932        assert_eq!(rendered, "Before {{> }} After");
933    }
934
935    // =========================================================================
936    // Conditional Tests
937    // =========================================================================
938
939    #[test]
940    fn test_conditional_with_true_variable() {
941        let template = Template::new("{% if NAME %}Hello {{NAME}}{% endif %}");
942        let variables = HashMap::from([("NAME", "World".to_string())]);
943        let rendered = template.render(&variables).unwrap();
944        assert_eq!(rendered, "Hello World");
945    }
946
947    #[test]
948    fn test_conditional_with_false_variable() {
949        let template = Template::new("{% if NAME %}Hello {{NAME}}{% endif %}");
950        let variables = HashMap::new(); // NAME not provided
951        let rendered = template.render(&variables).unwrap();
952        assert_eq!(rendered, "");
953    }
954
955    #[test]
956    fn test_conditional_with_empty_variable() {
957        let template = Template::new("{% if NAME %}Hello {{NAME}}{% endif %}");
958        let variables = HashMap::from([("NAME", String::new())]);
959        let rendered = template.render(&variables).unwrap();
960        assert_eq!(rendered, "");
961    }
962
963    #[test]
964    fn test_conditional_with_negation_true() {
965        let template = Template::new("{% if !NAME %}No name{% endif %}");
966        let variables = HashMap::new(); // NAME not provided
967        let rendered = template.render(&variables).unwrap();
968        assert_eq!(rendered, "No name");
969    }
970
971    #[test]
972    fn test_conditional_with_negation_false() {
973        let template = Template::new("{% if !NAME %}No name{% endif %}");
974        let variables = HashMap::from([("NAME", "Alice".to_string())]);
975        let rendered = template.render(&variables).unwrap();
976        assert_eq!(rendered, "");
977    }
978
979    #[test]
980    fn test_multiple_conditionals() {
981        let template = Template::new(
982            "{% if GREETING %}{{GREETING}}{% endif %} {% if NAME %}{{NAME}}{% endif %}",
983        );
984        let variables = HashMap::from([("NAME", "Bob".to_string())]);
985        let rendered = template.render(&variables).unwrap();
986        assert_eq!(rendered, " Bob");
987    }
988
989    #[test]
990    fn test_conditional_with_surrounding_content() {
991        let template = Template::new("Start {% if SHOW %}shown{% endif %} End");
992        let variables = HashMap::from([("SHOW", "yes".to_string())]);
993        let rendered = template.render(&variables).unwrap();
994        assert_eq!(rendered, "Start shown End");
995    }
996
997    // =========================================================================
998    // Default Value Tests
999    // =========================================================================
1000
1001    #[test]
1002    fn test_default_value_with_missing_variable() {
1003        let template = Template::new("Hello {{NAME|default=\"Guest\"}}");
1004        let variables = HashMap::new();
1005        let rendered = template.render(&variables).unwrap();
1006        assert_eq!(rendered, "Hello Guest");
1007    }
1008
1009    #[test]
1010    fn test_default_value_with_empty_variable() {
1011        let template = Template::new("Hello {{NAME|default=\"Guest\"}}");
1012        let variables = HashMap::from([("NAME", String::new())]);
1013        let rendered = template.render(&variables).unwrap();
1014        assert_eq!(rendered, "Hello Guest");
1015    }
1016
1017    #[test]
1018    fn test_default_value_with_present_variable() {
1019        let template = Template::new("Hello {{NAME|default=\"Guest\"}}");
1020        let variables = HashMap::from([("NAME", "Alice".to_string())]);
1021        let rendered = template.render(&variables).unwrap();
1022        assert_eq!(rendered, "Hello Alice");
1023    }
1024
1025    #[test]
1026    fn test_default_value_with_single_quotes() {
1027        let template = Template::new("Hello {{NAME|default='Guest'}}");
1028        let variables = HashMap::new();
1029        let rendered = template.render(&variables).unwrap();
1030        assert_eq!(rendered, "Hello Guest");
1031    }
1032
1033    // =========================================================================
1034    // Loop Tests
1035    // =========================================================================
1036
1037    #[test]
1038    fn test_loop_with_items() {
1039        let template = Template::new("{% for item in ITEMS %}{{item}} {% endfor %}");
1040        let variables = HashMap::from([("ITEMS", "apple,banana,cherry".to_string())]);
1041        let rendered = template.render(&variables).unwrap();
1042        assert_eq!(rendered, "apple banana cherry ");
1043    }
1044
1045    #[test]
1046    fn test_loop_with_empty_list() {
1047        let template = Template::new("{% for item in ITEMS %}{{item}} {% endfor %}");
1048        let variables = HashMap::from([("ITEMS", String::new())]);
1049        let rendered = template.render(&variables).unwrap();
1050        assert_eq!(rendered, "");
1051    }
1052
1053    #[test]
1054    fn test_loop_with_missing_variable() {
1055        let template = Template::new("{% for item in ITEMS %}{{item}} {% endfor %}");
1056        let variables = HashMap::new();
1057        let rendered = template.render(&variables).unwrap();
1058        assert_eq!(rendered, "");
1059    }
1060
1061    #[test]
1062    fn test_loop_with_conditional_inside() {
1063        let template =
1064            Template::new("{% for item in ITEMS %}{% if item %}{{item}} {% endif %}{% endfor %}");
1065        let variables = HashMap::from([("ITEMS", "apple,,cherry".to_string())]);
1066        let rendered = template.render(&variables).unwrap();
1067        assert_eq!(rendered, "apple cherry ");
1068    }
1069}