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        // Now substitute variables in the result (using the new method that handles defaults)
497        let (result_after_sub, missing_vars) = Self::substitute_variables(&result, variables);
498
499        // Check for missing variables
500        if let Some(first_missing) = missing_vars.first() {
501            return Err(TemplateError::MissingVariable(first_missing.clone()));
502        }
503
504        Ok(result_after_sub)
505    }
506
507    /// Extract all partial references from template content.
508    ///
509    /// Returns Vec of (`full_match`, `partial_name`) tuples in order of appearance.
510    fn extract_partials(content: &str) -> Vec<(String, String)> {
511        let mut partials = Vec::new();
512        let bytes = content.as_bytes();
513
514        let mut i = 0;
515        while i < bytes.len().saturating_sub(2) {
516            // Check for {{> pattern
517            if bytes[i] == b'{' && bytes[i + 1] == b'{' && i + 2 < bytes.len() {
518                let start = i;
519                i += 2;
520
521                // Skip whitespace after {{
522                while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
523                    i += 1;
524                }
525
526                // Check for > character
527                if i < bytes.len() && bytes[i] == b'>' {
528                    i += 1;
529
530                    // Skip whitespace after >
531                    while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
532                        i += 1;
533                    }
534
535                    // Extract partial name until }}
536                    let name_start = i;
537                    while i < bytes.len()
538                        && !(bytes[i] == b'}' && i + 1 < bytes.len() && bytes[i + 1] == b'}')
539                    {
540                        i += 1;
541                    }
542
543                    if i < bytes.len()
544                        && bytes[i] == b'}'
545                        && i + 1 < bytes.len()
546                        && bytes[i + 1] == b'}'
547                    {
548                        let end = i + 2;
549                        let full_match = &content[start..end];
550                        let name = &content[name_start..i];
551
552                        let partial_name = name.trim().to_string();
553                        if !partial_name.is_empty() {
554                            partials.push((full_match.to_string(), partial_name));
555                        }
556                        i = end;
557                        continue;
558                    }
559                }
560            }
561            i += 1;
562        }
563
564        partials
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    #[test]
573    fn test_render_template() {
574        let template = Template::new("Hello {{NAME}}, your score is {{SCORE}}.");
575        let variables = HashMap::from([("NAME", "Alice".to_string()), ("SCORE", "42".to_string())]);
576        let rendered = template.render(&variables).unwrap();
577        assert_eq!(rendered, "Hello Alice, your score is 42.");
578    }
579
580    #[test]
581    fn test_missing_variable() {
582        let template = Template::new("Hello {{NAME}}.");
583        let variables = HashMap::new();
584        let result = template.render(&variables);
585        assert_eq!(
586            result,
587            Err(TemplateError::MissingVariable("NAME".to_string()))
588        );
589    }
590
591    #[test]
592    fn test_no_variables() {
593        let template = Template::new("Just plain text.");
594        let rendered = template.render(&HashMap::new()).unwrap();
595        assert_eq!(rendered, "Just plain text.");
596    }
597
598    #[test]
599    fn test_multiline_template() {
600        let template = Template::new("Review this:\n{{DIFF}}\nEnd of review.");
601        let variables = HashMap::from([("DIFF", "+ new line".to_string())]);
602        let rendered = template.render(&variables).unwrap();
603        assert_eq!(rendered, "Review this:\n+ new line\nEnd of review.");
604    }
605
606    #[test]
607    fn test_whitespace_in_variables() {
608        let template = Template::new("Value: {{ VALUE }}.");
609        let variables = HashMap::from([("VALUE", "42".to_string())]);
610        let rendered = template.render(&variables).unwrap();
611        assert_eq!(rendered, "Value: 42.");
612    }
613
614    #[test]
615    fn test_unclosed_opening_braces() {
616        // Unclosed {{ should be ignored (no placeholder extracted)
617        let template = Template::new("Hello {{NAME and some text");
618        let rendered = template.render(&HashMap::new()).unwrap();
619        // The unclosed braces are treated as literal text
620        assert_eq!(rendered, "Hello {{NAME and some text");
621    }
622
623    #[test]
624    fn test_empty_variable_name() {
625        // Empty variable name {{}} should be ignored (no placeholder extracted)
626        let template = Template::new("Value: {{}}.");
627        let rendered = template.render(&HashMap::new()).unwrap();
628        // Empty placeholder is treated as literal text
629        assert_eq!(rendered, "Value: {{}}.");
630    }
631
632    #[test]
633    fn test_whitespace_only_variable_name() {
634        // Whitespace-only variable name {{   }} should be ignored
635        let template = Template::new("Value: {{   }}.");
636        let rendered = template.render(&HashMap::new()).unwrap();
637        // Whitespace-only placeholder is treated as literal text
638        assert_eq!(rendered, "Value: {{   }}.");
639    }
640
641    #[test]
642    fn test_multiple_unclosed_braces() {
643        // Multiple unclosed {{ should all be ignored
644        let template = Template::new("{{A text {{B text");
645        let rendered = template.render(&HashMap::new()).unwrap();
646        assert_eq!(rendered, "{{A text {{B text");
647    }
648
649    #[test]
650    fn test_partial_closing_brace() {
651        // Single closing brace without the second should not close the placeholder
652        let template = Template::new("Hello {{NAME}} and {{VAR}} text");
653        let variables = HashMap::from([("NAME", "Alice".to_string()), ("VAR", "Bob".to_string())]);
654        let rendered = template.render(&variables).unwrap();
655        assert_eq!(rendered, "Hello Alice and Bob text");
656    }
657
658    // =========================================================================
659    // Comment Stripping Tests
660    // =========================================================================
661
662    #[test]
663    fn test_inline_comment_stripped() {
664        let template = Template::new("Hello {# this is a comment #}world.");
665        let rendered = template.render(&HashMap::new()).unwrap();
666        assert_eq!(rendered, "Hello world.");
667    }
668
669    #[test]
670    fn test_comment_on_own_line_stripped() {
671        // Comment on its own line should be completely removed including the line itself
672        let template = Template::new("Line 1\n{# This is a comment #}\nLine 2");
673        let rendered = template.render(&HashMap::new()).unwrap();
674        assert_eq!(rendered, "Line 1\nLine 2");
675    }
676
677    #[test]
678    fn test_multiline_comment() {
679        // Multiline comments should be fully stripped
680        let template = Template::new("Before{# comment\nspanning\nlines #}After");
681        let rendered = template.render(&HashMap::new()).unwrap();
682        assert_eq!(rendered, "BeforeAfter");
683    }
684
685    #[test]
686    fn test_comment_at_end_of_content_line() {
687        // Comment at end of content line should only remove the comment
688        let template = Template::new("Content{# comment #}\nMore");
689        let rendered = template.render(&HashMap::new()).unwrap();
690        assert_eq!(rendered, "Content\nMore");
691    }
692
693    #[test]
694    fn test_multiple_comments() {
695        let template = Template::new("{# first #}A{# second #}B{# third #}");
696        let rendered = template.render(&HashMap::new()).unwrap();
697        assert_eq!(rendered, "AB");
698    }
699
700    #[test]
701    fn test_comment_with_variable() {
702        // Comments should work alongside variables
703        let template = Template::new("{# doc comment #}\nHello {{NAME}}!");
704        let variables = HashMap::from([("NAME", "World".to_string())]);
705        let rendered = template.render(&variables).unwrap();
706        assert_eq!(rendered, "Hello World!");
707    }
708
709    #[test]
710    fn test_unclosed_comment_preserved() {
711        // Unclosed comment should be treated as literal text
712        let template = Template::new("Hello {# unclosed comment");
713        let rendered = template.render(&HashMap::new()).unwrap();
714        assert_eq!(rendered, "Hello {# unclosed comment");
715    }
716
717    #[test]
718    fn test_comment_documentation_use_case() {
719        // Real use case: documentation comments in template
720        let content = r"{# Template Version: 1.0 #}
721{# This template generates commit messages #}
722You are a commit message expert.
723
724{# DIFF variable contains the git diff #}
725DIFF:
726{{DIFF}}
727
728{# End of template #}
729";
730        let template = Template::new(content);
731        let variables = HashMap::from([("DIFF", "+added line".to_string())]);
732        let rendered = template.render(&variables).unwrap();
733
734        // Verify documentation comments are stripped
735        assert!(!rendered.contains("Template Version"));
736        assert!(!rendered.contains("This template generates"));
737        assert!(!rendered.contains("DIFF variable contains"));
738        assert!(!rendered.contains("End of template"));
739
740        // Verify content is preserved
741        assert!(rendered.contains("You are a commit message expert."));
742        assert!(rendered.contains("+added line"));
743    }
744
745    // =========================================================================
746    // Partials Tests
747    // =========================================================================
748
749    #[test]
750    fn test_simple_partial_include() {
751        let partials = HashMap::from([("header".to_string(), "Common Header".to_string())]);
752        let template = Template::new("{{>header}}\nContent here");
753        let variables = HashMap::new();
754        let rendered = template
755            .render_with_partials(&variables, &partials)
756            .unwrap();
757        assert_eq!(rendered, "Common Header\nContent here");
758    }
759
760    #[test]
761    fn test_partial_with_whitespace() {
762        let partials = HashMap::from([("header".to_string(), "Header".to_string())]);
763        let template = Template::new("{{> header}}\nContent");
764        let variables = HashMap::new();
765        let rendered = template
766            .render_with_partials(&variables, &partials)
767            .unwrap();
768        assert_eq!(rendered, "Header\nContent");
769    }
770
771    #[test]
772    fn test_partial_with_variables() {
773        let partials = HashMap::from([("greeting".to_string(), "Hello {{NAME}}\n".to_string())]);
774        let template = Template::new("{{>greeting}}Body content");
775        let variables = HashMap::from([("NAME", "World".to_string())]);
776        let rendered = template
777            .render_with_partials(&variables, &partials)
778            .unwrap();
779        assert_eq!(rendered, "Hello World\nBody content");
780    }
781
782    #[test]
783    fn test_multiple_partials() {
784        let partials = HashMap::from([
785            ("header".to_string(), "=== HEADER ===\n".to_string()),
786            ("footer".to_string(), "\n=== FOOTER ===".to_string()),
787        ]);
788        let template = Template::new("{{>header}}Content{{>footer}}");
789        let variables = HashMap::new();
790        let rendered = template
791            .render_with_partials(&variables, &partials)
792            .unwrap();
793        assert_eq!(rendered, "=== HEADER ===\nContent\n=== FOOTER ===");
794    }
795
796    #[test]
797    fn test_nested_partials() {
798        let partials = HashMap::from([
799            (
800                "outer".to_string(),
801                "Outer start\n{{>inner}}\nOuter end".to_string(),
802            ),
803            ("inner".to_string(), "INNER CONTENT".to_string()),
804        ]);
805        let template = Template::new("{{>outer}}");
806        let variables = HashMap::new();
807        let rendered = template
808            .render_with_partials(&variables, &partials)
809            .unwrap();
810        assert_eq!(rendered, "Outer start\nINNER CONTENT\nOuter end");
811    }
812
813    #[test]
814    fn test_partial_not_found() {
815        let partials = HashMap::new();
816        let template = Template::new("{{>missing_partial}}");
817        let variables = HashMap::new();
818        let result = template.render_with_partials(&variables, &partials);
819        assert_eq!(
820            result,
821            Err(TemplateError::PartialNotFound(
822                "missing_partial".to_string()
823            ))
824        );
825    }
826
827    #[test]
828    fn test_circular_reference_detection() {
829        let partials = HashMap::from([
830            ("a".to_string(), "{{>b}}".to_string()),
831            ("b".to_string(), "{{>a}}".to_string()),
832        ]);
833        let template = Template::new("{{>a}}");
834        let variables = HashMap::new();
835        let result = template.render_with_partials(&variables, &partials);
836        match result {
837            Err(TemplateError::CircularReference(chain)) => {
838                // Chain should contain a circular reference between a and b
839                assert_eq!(chain.len(), 3);
840                assert!(chain.contains(&"a".to_string()));
841                assert!(chain.contains(&"b".to_string()));
842                // First and last elements should be the same (indicating a cycle)
843                assert_eq!(chain.first(), chain.last());
844            }
845            _ => panic!("Expected CircularReference error"),
846        }
847    }
848
849    #[test]
850    fn test_self_referential_partial() {
851        let partials = HashMap::from([("loop".to_string(), "{{>loop}}".to_string())]);
852        let template = Template::new("{{>loop}}");
853        let variables = HashMap::new();
854        let result = template.render_with_partials(&variables, &partials);
855        match result {
856            Err(TemplateError::CircularReference(chain)) => {
857                assert_eq!(chain, vec!["loop".to_string(), "loop".to_string()]);
858            }
859            _ => panic!("Expected CircularReference error"),
860        }
861    }
862
863    #[test]
864    fn test_partial_with_missing_variable() {
865        let partials = HashMap::from([("greeting".to_string(), "Hello {{NAME}}".to_string())]);
866        let template = Template::new("{{>greeting}}");
867        let variables = HashMap::new(); // NAME not provided
868        let result = template.render_with_partials(&variables, &partials);
869        assert_eq!(
870            result,
871            Err(TemplateError::MissingVariable("NAME".to_string()))
872        );
873    }
874
875    #[test]
876    fn test_partial_and_main_variables() {
877        let partials = HashMap::from([("greeting".to_string(), "Hello {{NAME}}\n".to_string())]);
878        let template = Template::new("{{>greeting}}Your score is {{SCORE}}");
879        let variables = HashMap::from([("NAME", "Alice".to_string()), ("SCORE", "42".to_string())]);
880        let rendered = template
881            .render_with_partials(&variables, &partials)
882            .unwrap();
883        assert_eq!(rendered, "Hello Alice\nYour score is 42");
884    }
885
886    #[test]
887    fn test_partial_with_comments() {
888        let partials = HashMap::from([(
889            "header".to_string(),
890            "{# This is a header #}Header Content\n".to_string(),
891        )]);
892        let template = Template::new("{{>header}}Body");
893        let variables = HashMap::new();
894        let rendered = template
895            .render_with_partials(&variables, &partials)
896            .unwrap();
897        assert_eq!(rendered, "Header Content\nBody");
898    }
899
900    #[test]
901    fn test_partial_with_path_style_name() {
902        let partials = HashMap::from([("shared/_header".to_string(), "Shared Header".to_string())]);
903        let template = Template::new("{{> shared/_header}}\nContent");
904        let variables = HashMap::new();
905        let rendered = template
906            .render_with_partials(&variables, &partials)
907            .unwrap();
908        assert_eq!(rendered, "Shared Header\nContent");
909    }
910
911    #[test]
912    fn test_backward_compatibility_render_without_partials() {
913        // Ensure the original render() method still works
914        let template = Template::new("Hello {{NAME}}");
915        let variables = HashMap::from([("NAME", "World".to_string())]);
916        let rendered = template.render(&variables).unwrap();
917        assert_eq!(rendered, "Hello World");
918    }
919
920    #[test]
921    fn test_empty_partial_name_ignored() {
922        // {{> }} with empty name should be treated as literal text
923        let template = Template::new("Before {{> }} After");
924        let variables = HashMap::new();
925        let rendered = template.render(&variables).unwrap();
926        assert_eq!(rendered, "Before {{> }} After");
927    }
928
929    // =========================================================================
930    // Conditional Tests
931    // =========================================================================
932
933    #[test]
934    fn test_conditional_with_true_variable() {
935        let template = Template::new("{% if NAME %}Hello {{NAME}}{% endif %}");
936        let variables = HashMap::from([("NAME", "World".to_string())]);
937        let rendered = template.render(&variables).unwrap();
938        assert_eq!(rendered, "Hello World");
939    }
940
941    #[test]
942    fn test_conditional_with_false_variable() {
943        let template = Template::new("{% if NAME %}Hello {{NAME}}{% endif %}");
944        let variables = HashMap::new(); // NAME not provided
945        let rendered = template.render(&variables).unwrap();
946        assert_eq!(rendered, "");
947    }
948
949    #[test]
950    fn test_conditional_with_empty_variable() {
951        let template = Template::new("{% if NAME %}Hello {{NAME}}{% endif %}");
952        let variables = HashMap::from([("NAME", String::new())]);
953        let rendered = template.render(&variables).unwrap();
954        assert_eq!(rendered, "");
955    }
956
957    #[test]
958    fn test_conditional_with_negation_true() {
959        let template = Template::new("{% if !NAME %}No name{% endif %}");
960        let variables = HashMap::new(); // NAME not provided
961        let rendered = template.render(&variables).unwrap();
962        assert_eq!(rendered, "No name");
963    }
964
965    #[test]
966    fn test_conditional_with_negation_false() {
967        let template = Template::new("{% if !NAME %}No name{% endif %}");
968        let variables = HashMap::from([("NAME", "Alice".to_string())]);
969        let rendered = template.render(&variables).unwrap();
970        assert_eq!(rendered, "");
971    }
972
973    #[test]
974    fn test_multiple_conditionals() {
975        let template = Template::new(
976            "{% if GREETING %}{{GREETING}}{% endif %} {% if NAME %}{{NAME}}{% endif %}",
977        );
978        let variables = HashMap::from([("NAME", "Bob".to_string())]);
979        let rendered = template.render(&variables).unwrap();
980        assert_eq!(rendered, " Bob");
981    }
982
983    #[test]
984    fn test_conditional_with_surrounding_content() {
985        let template = Template::new("Start {% if SHOW %}shown{% endif %} End");
986        let variables = HashMap::from([("SHOW", "yes".to_string())]);
987        let rendered = template.render(&variables).unwrap();
988        assert_eq!(rendered, "Start shown End");
989    }
990
991    // =========================================================================
992    // Default Value Tests
993    // =========================================================================
994
995    #[test]
996    fn test_default_value_with_missing_variable() {
997        let template = Template::new("Hello {{NAME|default=\"Guest\"}}");
998        let variables = HashMap::new();
999        let rendered = template.render(&variables).unwrap();
1000        assert_eq!(rendered, "Hello Guest");
1001    }
1002
1003    #[test]
1004    fn test_default_value_with_empty_variable() {
1005        let template = Template::new("Hello {{NAME|default=\"Guest\"}}");
1006        let variables = HashMap::from([("NAME", String::new())]);
1007        let rendered = template.render(&variables).unwrap();
1008        assert_eq!(rendered, "Hello Guest");
1009    }
1010
1011    #[test]
1012    fn test_default_value_with_present_variable() {
1013        let template = Template::new("Hello {{NAME|default=\"Guest\"}}");
1014        let variables = HashMap::from([("NAME", "Alice".to_string())]);
1015        let rendered = template.render(&variables).unwrap();
1016        assert_eq!(rendered, "Hello Alice");
1017    }
1018
1019    #[test]
1020    fn test_default_value_with_single_quotes() {
1021        let template = Template::new("Hello {{NAME|default='Guest'}}");
1022        let variables = HashMap::new();
1023        let rendered = template.render(&variables).unwrap();
1024        assert_eq!(rendered, "Hello Guest");
1025    }
1026
1027    // =========================================================================
1028    // Loop Tests
1029    // =========================================================================
1030
1031    #[test]
1032    fn test_loop_with_items() {
1033        let template = Template::new("{% for item in ITEMS %}{{item}} {% endfor %}");
1034        let variables = HashMap::from([("ITEMS", "apple,banana,cherry".to_string())]);
1035        let rendered = template.render(&variables).unwrap();
1036        assert_eq!(rendered, "apple banana cherry ");
1037    }
1038
1039    #[test]
1040    fn test_loop_with_empty_list() {
1041        let template = Template::new("{% for item in ITEMS %}{{item}} {% endfor %}");
1042        let variables = HashMap::from([("ITEMS", String::new())]);
1043        let rendered = template.render(&variables).unwrap();
1044        assert_eq!(rendered, "");
1045    }
1046
1047    #[test]
1048    fn test_loop_with_missing_variable() {
1049        let template = Template::new("{% for item in ITEMS %}{{item}} {% endfor %}");
1050        let variables = HashMap::new();
1051        let rendered = template.render(&variables).unwrap();
1052        assert_eq!(rendered, "");
1053    }
1054
1055    #[test]
1056    fn test_loop_with_conditional_inside() {
1057        let template =
1058            Template::new("{% for item in ITEMS %}{% if item %}{{item}} {% endif %}{% endfor %}");
1059        let variables = HashMap::from([("ITEMS", "apple,,cherry".to_string())]);
1060        let rendered = template.render(&variables).unwrap();
1061        assert_eq!(rendered, "apple cherry ");
1062    }
1063}