agpm_cli/templating/
renderer.rs

1//! Template rendering engine with Tera.
2//!
3//! This module provides the TemplateRenderer struct that wraps Tera with
4//! AGPM-specific configuration, custom filters, and literal block handling.
5
6use anyhow::Result;
7use regex::Regex;
8use std::collections::HashMap;
9use std::path::PathBuf;
10use strsim::levenshtein;
11use tera::{Context as TeraContext, Tera};
12
13use super::error::{ErrorLocation, TemplateError};
14use super::filters;
15use crate::core::ResourceType;
16
17/// Maximum allowed Levenshtein distance as a percentage of target length for suggestions.
18/// This represents a 50% similarity threshold for variable name suggestions.
19const SIMILARITY_THRESHOLD_PERCENT: usize = 50;
20
21/// Context information about the current rendering operation
22#[derive(Debug, Clone)]
23pub struct RenderingMetadata {
24    /// The resource currently being rendered
25    pub resource_name: String,
26    /// The type of resource (agent, command, snippet, etc.)
27    pub resource_type: ResourceType,
28    /// Full dependency chain from root to current resource
29    pub dependency_chain: Vec<DependencyChainEntry>,
30    /// Source file path if available
31    pub source_path: Option<PathBuf>,
32    /// Current rendering depth (for content filter recursion)
33    pub depth: usize,
34}
35
36#[derive(Debug, Clone)]
37pub struct DependencyChainEntry {
38    pub resource_type: ResourceType,
39    pub name: String,
40    pub path: Option<String>,
41}
42
43/// Template renderer with Tera engine and custom functions.
44///
45/// This struct wraps a Tera instance with AGPM-specific configuration,
46/// custom functions, and filters. It provides a safe, sandboxed environment
47/// for rendering Markdown templates.
48///
49/// # Security
50///
51/// The renderer is configured with security restrictions:
52/// - No file system access via includes/extends (except content filter)
53/// - No network access
54/// - Sandboxed template execution
55/// - Custom functions are carefully vetted
56/// - Project file access restricted to project directory with validation
57pub struct TemplateRenderer {
58    /// Whether templating is enabled globally
59    enabled: bool,
60    /// Project directory for content filter validation
61    project_dir: PathBuf,
62    /// Maximum file size for content filter
63    max_content_file_size: Option<u64>,
64}
65
66impl TemplateRenderer {
67    /// Create a new template renderer with AGPM-specific configuration.
68    ///
69    /// # Arguments
70    ///
71    /// * `enabled` - Whether templating is enabled globally
72    /// * `project_dir` - Project root directory for content filter validation
73    /// * `max_content_file_size` - Maximum file size in bytes for content filter (None for no limit)
74    ///
75    /// # Returns
76    ///
77    /// Returns a configured `TemplateRenderer` instance with custom filters registered.
78    ///
79    /// # Filters
80    ///
81    /// The following custom filters are registered:
82    /// - `content`: Read project-specific files with path validation and size limits
83    pub fn new(
84        enabled: bool,
85        project_dir: PathBuf,
86        max_content_file_size: Option<u64>,
87    ) -> Result<Self> {
88        Ok(Self {
89            enabled,
90            project_dir,
91            max_content_file_size,
92        })
93    }
94
95    /// Protect literal blocks from template rendering by replacing them with placeholders.
96    ///
97    /// This method scans for ```literal fenced code blocks and replaces them with
98    /// unique placeholders that won't be affected by template rendering. The original
99    /// content is stored in a HashMap that can be used to restore the blocks later.
100    ///
101    /// # Arguments
102    ///
103    /// * `content` - The content to process
104    ///
105    /// # Returns
106    ///
107    /// Returns a tuple of:
108    /// - Modified content with placeholders instead of literal blocks
109    /// - HashMap mapping placeholder IDs to original content
110    ///
111    /// # Examples
112    ///
113    /// ````markdown
114    /// # Documentation Example
115    ///
116    /// Use this syntax in templates:
117    ///
118    /// ```literal
119    /// {{ agpm.deps.snippets.example.content }}
120    /// ```
121    /// ````
122    ///
123    /// The content inside the literal block will be protected from rendering.
124    pub(crate) fn protect_literal_blocks(
125        &self,
126        content: &str,
127    ) -> (String, HashMap<String, String>) {
128        let mut placeholders = HashMap::new();
129        let mut counter = 0;
130        let mut result = String::with_capacity(content.len());
131
132        // Split content by lines to find ```literal fences
133        let mut in_literal_fence = false;
134        let mut current_block = String::new();
135        let lines = content.lines();
136
137        for line in lines {
138            let trimmed = line.trim();
139
140            if trimmed.starts_with("```literal") {
141                // Start of ```literal fence
142                in_literal_fence = true;
143                current_block.clear();
144                tracing::debug!("Found start of literal fence");
145                // Skip the fence line
146            } else if in_literal_fence && trimmed.starts_with("```") {
147                // End of ```literal fence
148                in_literal_fence = false;
149
150                // Generate unique placeholder
151                let placeholder_id = format!("__AGPM_LITERAL_BLOCK_{}__", counter);
152                counter += 1;
153
154                // Store original content
155                placeholders.insert(placeholder_id.clone(), current_block.clone());
156
157                // Insert placeholder
158                result.push_str(&placeholder_id);
159                result.push('\n');
160
161                tracing::debug!(
162                    "Protected literal fence with placeholder {} ({} bytes)",
163                    placeholder_id,
164                    current_block.len()
165                );
166
167                current_block.clear();
168                // Skip the fence line
169            } else if in_literal_fence {
170                // Inside ```literal fence - accumulate content
171                if !current_block.is_empty() {
172                    current_block.push('\n');
173                }
174                current_block.push_str(line);
175            } else {
176                // Regular content - pass through
177                result.push_str(line);
178                result.push('\n');
179            }
180        }
181
182        // Handle unclosed blocks (add back as-is)
183        if in_literal_fence {
184            tracing::warn!("Unclosed literal fence found - treating as regular content");
185            result.push_str("```literal\n");
186            result.push_str(&current_block);
187        }
188
189        // Remove trailing newline if original didn't have one
190        if !content.ends_with('\n') && result.ends_with('\n') {
191            result.pop();
192        }
193
194        tracing::debug!("Protected {} literal block(s)", placeholders.len());
195        (result, placeholders)
196    }
197
198    /// Restore literal blocks by replacing placeholders with original content.
199    ///
200    /// This method takes rendered content and restores any literal blocks that were
201    /// protected during the rendering process.
202    ///
203    /// # Arguments
204    ///
205    /// * `content` - The rendered content containing placeholders
206    /// * `placeholders` - HashMap mapping placeholder IDs to original content
207    ///
208    /// # Returns
209    ///
210    /// Returns the content with placeholders replaced by original literal blocks,
211    /// wrapped in markdown code fences for proper display.
212    pub(crate) fn restore_literal_blocks(
213        &self,
214        content: &str,
215        placeholders: HashMap<String, String>,
216    ) -> String {
217        let mut result = content.to_string();
218
219        for (placeholder_id, original_content) in placeholders {
220            // Wrap in markdown code fence for display
221            let replacement = format!("```\n{}\n```", original_content);
222            result = result.replace(&placeholder_id, &replacement);
223
224            tracing::debug!(
225                "Restored literal block {} ({} bytes)",
226                placeholder_id,
227                original_content.len()
228            );
229        }
230
231        result
232    }
233
234    /// Render a Markdown template with the given context.
235    ///
236    /// This method supports recursive template rendering where project files
237    /// can reference other project files using the `content` filter.
238    /// Rendering continues up to [`filters::MAX_RENDER_DEPTH`] levels deep.
239    ///
240    /// Render a Markdown template with the given context.
241    ///
242    /// This method processes template syntax using the Tera engine. Content within
243    /// ```literal fences is protected from rendering by replacing it with unique
244    /// placeholders before processing, then restoring it afterwards.
245    ///
246    /// # Arguments
247    ///
248    /// * `template_content` - The raw Markdown template content
249    /// * `context` - The template context containing variables
250    ///
251    /// # Returns
252    ///
253    /// Returns the rendered Markdown content.
254    ///
255    /// # Errors
256    ///
257    /// Returns an error if:
258    /// - Template syntax is invalid
259    /// - Context variables are missing
260    /// - Custom functions/filters fail
261    /// - Recursive rendering exceeds maximum depth (10 levels)
262    ///
263    /// # Literal Blocks
264    ///
265    /// Content wrapped in ```literal fences will be protected from
266    /// template rendering and displayed literally:
267    ///
268    /// ````markdown
269    /// ```literal
270    /// {{ agpm.deps.snippets.example.content }}
271    /// ```
272    /// ```markdown
273    /// // This is a documentation example showing template syntax
274    /// // The actual template content would be in a separate file
275    ///
276    /// This is useful for documentation that shows template syntax examples.
277    ///
278    /// # Recursive Rendering
279    ///
280    /// When a template contains 'content' filter references, those files
281    /// may themselves contain template syntax. The renderer automatically
282    /// detects this and performs multiple rendering passes until either:
283    /// - No template syntax remains in the output
284    /// - Maximum depth is reached (error)
285    ///
286    /// Example recursive template chain:
287    /// // In a markdown file:
288    /// // # Main Agent
289    /// // {{ "docs/guide.md" | content }}
290    ///
291    /// Where 'docs/guide.md' contains:
292    /// // # Guide
293    /// // {{ "docs/common.md" | content }}
294    ///
295    /// This will render up to 10 levels deep.
296    pub fn render_template(
297        &mut self,
298        template_content: &str,
299        context: &TeraContext,
300        metadata: Option<&RenderingMetadata>,
301    ) -> Result<String, TemplateError> {
302        tracing::debug!("render_template called, enabled={}", self.enabled);
303
304        if !self.enabled {
305            // If templating is disabled, return content as-is
306            tracing::debug!("Templating disabled, returning content as-is");
307            return Ok(template_content.to_string());
308        }
309
310        // Step 1: Protect literal blocks before any rendering
311        let (protected_content, placeholders) = self.protect_literal_blocks(template_content);
312
313        // Log the template context for debugging
314        tracing::debug!("Rendering template with context");
315        Self::log_context_as_kv(context);
316
317        // Step 2: Single-pass rendering
318        // Dependencies are pre-rendered with their own contexts before being embedded,
319        // so parent templates only need one rendering pass. The content filter returns
320        // literal content by default (no template processing).
321        tracing::debug!("Rendering template (single pass)");
322
323        // Create fresh Tera instance per render (very cheap - just empty HashMaps)
324        // This enables future context-capturing filters without global state
325        let mut tera = Tera::default();
326
327        // Register content filter (currently returns literal content only)
328        tera.register_filter(
329            "content",
330            filters::create_content_filter(self.project_dir.clone(), self.max_content_file_size),
331        );
332
333        let rendered = tera.render_str(&protected_content, context).map_err(|e| {
334            // Parse into structured error
335            Self::parse_tera_error(&e, &protected_content, context, metadata)
336        })?;
337
338        tracing::debug!("Template rendering complete");
339
340        // Step 3: Restore literal blocks after rendering is complete
341        let restored = self.restore_literal_blocks(&rendered, placeholders);
342
343        // Return restored content (literal blocks have been restored)
344        Ok(restored)
345    }
346
347    /// Parse a Tera error into a structured TemplateError
348    fn parse_tera_error(
349        error: &tera::Error,
350        template_content: &str,
351        context: &TeraContext,
352        metadata: Option<&RenderingMetadata>,
353    ) -> TemplateError {
354        // Extract line number from Tera error (if available)
355        let line_number = Self::extract_line_from_tera_error(error);
356
357        // Extract context lines around the error
358        let context_lines = if let Some(line) = line_number {
359            let lines = Self::extract_context_lines(template_content, line, 5);
360            if lines.is_empty() {
361                None
362            } else {
363                Some(lines)
364            }
365        } else {
366            None
367        };
368
369        // Try to extract more specific error information based on the error kind
370        match &error.kind {
371            tera::ErrorKind::Msg(msg) => {
372                // Check if this is an undefined variable error in disguise
373                if msg.contains("Variable") && msg.contains("not found") {
374                    // Try to extract variable name
375                    if let Some(name) = Self::extract_variable_name(msg) {
376                        let available_variables = Self::extract_available_variables(context);
377                        let suggestions = Self::find_similar_variables(&name, &available_variables);
378                        return TemplateError::VariableNotFound {
379                            variable: name.clone(),
380                            available_variables: Box::new(available_variables),
381                            suggestions: Box::new(suggestions),
382                            location: Box::new(Self::build_error_location(
383                                metadata,
384                                line_number,
385                                context_lines,
386                            )),
387                        };
388                    }
389                }
390
391                // For other message types, use the format_tera_error function to clean them up
392                TemplateError::SyntaxError {
393                    message: Self::format_tera_error(error),
394                    location: Box::new(Self::build_error_location(
395                        metadata,
396                        line_number,
397                        context_lines,
398                    )),
399                }
400            }
401            _ => {
402                // Fallback to syntax error with detailed error formatting
403                TemplateError::SyntaxError {
404                    message: Self::format_tera_error(error),
405                    location: Box::new(Self::build_error_location(
406                        metadata,
407                        line_number,
408                        context_lines,
409                    )),
410                }
411            }
412        }
413    }
414
415    /// Extract variable name from "Variable `foo` not found" message
416    fn extract_variable_name(error_msg: &str) -> Option<String> {
417        // Pattern: "Variable `<name>` not found"
418        let re = Regex::new(r"Variable `([^`]+)` not found").ok()?;
419        if let Some(caps) = re.captures(error_msg) {
420            if let Some(m) = caps.get(1) {
421                return Some(m.as_str().to_string());
422            }
423        }
424
425        // Try other patterns if needed
426        // Pattern: "Unknown variable `foo`"
427        let re2 = Regex::new(r"Unknown variable `([^`]+)`").ok()?;
428        if let Some(caps) = re2.captures(error_msg) {
429            if let Some(m) = caps.get(1) {
430                return Some(m.as_str().to_string());
431            }
432        }
433
434        None
435    }
436
437    /// Extract available variables from Tera context
438    fn extract_available_variables(context: &TeraContext) -> Vec<String> {
439        // Tera context doesn't implement Serialize directly
440        // We need to access its internal data structure
441        let mut vars = Vec::new();
442
443        // Get the context as a Value by using Tera's internal data access
444        // TeraContext stores data as a tera::Value internally
445        if let Some(_data) = context.get("agpm") {
446            // This is a simplified version - we should walk the actual structure
447            vars.push("agpm.resource.name".to_string());
448            vars.push("agpm.resource.path".to_string());
449            vars.push("agpm.resource.install_path".to_string());
450        }
451
452        // Add common project variables if they exist
453        if context.contains_key("project") {
454            vars.push("project.language".to_string());
455            vars.push("project.framework".to_string());
456        }
457
458        // Add dependency variables (simplified check)
459        if context.contains_key("deps") {
460            vars.push("agpm.deps.*".to_string());
461        }
462
463        vars
464    }
465
466    /// Find similar variable names using Levenshtein distance
467    fn find_similar_variables(target: &str, available: &[String]) -> Vec<String> {
468        let mut scored: Vec<_> = available
469            .iter()
470            .map(|var| {
471                let distance = levenshtein(target, var);
472                (var.clone(), distance)
473            })
474            .collect();
475
476        // Sort by distance (closest first)
477        scored.sort_by_key(|(_, dist)| *dist);
478
479        // Return top 3 suggestions within reasonable distance
480        scored
481            .into_iter()
482            .filter(|(_, dist)| *dist <= target.len() * SIMILARITY_THRESHOLD_PERCENT / 100)
483            .take(3)
484            .map(|(var, _)| var)
485            .collect()
486    }
487
488    /// Extract context lines around an error location
489    ///
490    /// Returns up to `context_size` lines before and after the error line,
491    /// along with their line numbers (1-indexed).
492    fn extract_context_lines(
493        content: &str,
494        error_line: usize,
495        context_size: usize,
496    ) -> Vec<(usize, String)> {
497        let lines: Vec<&str> = content.lines().collect();
498        let total_lines = lines.len();
499
500        // Tera uses 1-indexed line numbers
501        if error_line == 0 || error_line > total_lines {
502            return Vec::new();
503        }
504
505        // Calculate range (convert to 0-indexed for array access)
506        let start = error_line.saturating_sub(context_size + 1);
507        let end = (error_line + context_size).min(total_lines);
508
509        // Extract lines with their line numbers (1-indexed for display)
510        lines[start..end]
511            .iter()
512            .enumerate()
513            .map(|(idx, line)| (start + idx + 1, line.to_string()))
514            .collect()
515    }
516
517    /// Extract line number from Tera error message
518    ///
519    /// Tera includes line:column information in parse error messages.
520    /// Examples: "1:7", "15:23", "864:1"
521    fn extract_line_from_tera_error(error: &tera::Error) -> Option<usize> {
522        let error_msg = format!("{:?}", error);
523
524        // Look for pattern like "1:7" or "864:1" in the error message
525        let re = Regex::new(r"(\d+):(\d+)").ok()?;
526        if let Some(caps) = re.captures(&error_msg) {
527            if let Some(line_str) = caps.get(1) {
528                return line_str.as_str().parse::<usize>().ok();
529            }
530        }
531        None
532    }
533
534    /// Build ErrorLocation from metadata
535    fn build_error_location(
536        metadata: Option<&RenderingMetadata>,
537        line_number: Option<usize>,
538        context_lines: Option<Vec<(usize, String)>>,
539    ) -> ErrorLocation {
540        let meta = metadata.cloned().unwrap_or_else(|| RenderingMetadata {
541            resource_name: "unknown".to_string(),
542            resource_type: ResourceType::Snippet, // Default to snippet
543            dependency_chain: vec![],
544            source_path: None,
545            depth: 0,
546        });
547
548        ErrorLocation {
549            resource_name: meta.resource_name,
550            resource_type: meta.resource_type,
551            dependency_chain: meta.dependency_chain,
552            file_path: meta.source_path,
553            line_number,
554            context_lines,
555        }
556    }
557
558    /// Format a Tera error with detailed information about what went wrong.
559    ///
560    /// Tera errors can contain various types of issues:
561    /// - Missing variables (e.g., "Variable `foo` not found")
562    /// - Syntax errors (e.g., "Unexpected end of template")
563    /// - Filter/function errors (e.g., "Filter `unknown` not found")
564    ///
565    /// This function extracts the root cause and formats it in a user-friendly way,
566    /// filtering out unhelpful internal template names like '__tera_one_off'.
567    ///
568    /// # Arguments
569    ///
570    /// * `error` - The Tera error to format
571    pub fn format_tera_error(error: &tera::Error) -> String {
572        use std::error::Error;
573
574        let mut messages = Vec::new();
575
576        // Walk the entire error chain and collect all messages
577        let mut all_messages = vec![error.to_string()];
578        let mut current_error: Option<&dyn Error> = error.source();
579        while let Some(err) = current_error {
580            all_messages.push(err.to_string());
581            current_error = err.source();
582        }
583
584        // Process messages to extract useful information
585        for msg in all_messages {
586            // Clean up the message by removing internal template names
587            let cleaned = msg
588                .replace("while rendering '__tera_one_off'", "")
589                .replace("Failed to render '__tera_one_off'", "Template rendering failed")
590                .replace("Failed to parse '__tera_one_off'", "Template syntax error")
591                .replace("'__tera_one_off'", "template")
592                .trim()
593                .to_string();
594
595            // Only keep non-empty, useful messages
596            if !cleaned.is_empty()
597                && cleaned != "Template rendering failed"
598                && cleaned != "Template syntax error"
599            {
600                messages.push(cleaned);
601            }
602        }
603
604        // If we got useful messages, return them
605        if !messages.is_empty() {
606            messages.join("\n  → ")
607        } else {
608            // Fallback: extract just the error kind
609            "Template syntax error (see details above)".to_string()
610        }
611    }
612
613    /// Format the template context as a string for error messages.
614    ///
615    /// # Arguments
616    ///
617    /// * `context` - The Tera context to format
618    fn format_context_as_string(context: &TeraContext) -> String {
619        let context_clone = context.clone();
620        let json_value = context_clone.into_json();
621        let mut output = String::new();
622
623        // Recursively format the JSON structure with indentation
624        fn format_value(key: &str, value: &serde_json::Value, indent: usize) -> Vec<String> {
625            let prefix = "  ".repeat(indent);
626            let mut lines = Vec::new();
627
628            match value {
629                serde_json::Value::Object(map) => {
630                    lines.push(format!("{}{}:", prefix, key));
631                    for (k, v) in map {
632                        lines.extend(format_value(k, v, indent + 1));
633                    }
634                }
635                serde_json::Value::Array(arr) => {
636                    lines.push(format!("{}{}: [{} items]", prefix, key, arr.len()));
637                    // Only show first few items to avoid spam
638                    for (i, item) in arr.iter().take(3).enumerate() {
639                        lines.extend(format_value(&format!("[{}]", i), item, indent + 1));
640                    }
641                    if arr.len() > 3 {
642                        lines.push(format!("{}  ... {} more items", prefix, arr.len() - 3));
643                    }
644                }
645                serde_json::Value::String(s) => {
646                    // Truncate long strings
647                    if s.len() > 100 {
648                        lines.push(format!(
649                            "{}{}: \"{}...\" ({} chars)",
650                            prefix,
651                            key,
652                            &s[..97],
653                            s.len()
654                        ));
655                    } else {
656                        lines.push(format!("{}{}: \"{}\"", prefix, key, s));
657                    }
658                }
659                serde_json::Value::Number(n) => {
660                    lines.push(format!("{}{}: {}", prefix, key, n));
661                }
662                serde_json::Value::Bool(b) => {
663                    lines.push(format!("{}{}: {}", prefix, key, b));
664                }
665                serde_json::Value::Null => {
666                    lines.push(format!("{}{}: null", prefix, key));
667                }
668            }
669            lines
670        }
671
672        if let serde_json::Value::Object(map) = &json_value {
673            for (key, value) in map {
674                output.push_str(&format_value(key, value, 1).join("\n"));
675                output.push('\n');
676            }
677        }
678
679        output
680    }
681
682    /// Log the template context as key-value pairs at debug level.
683    ///
684    /// # Arguments
685    ///
686    /// * `context` - The Tera context to log
687    fn log_context_as_kv(context: &TeraContext) {
688        let formatted = Self::format_context_as_string(context);
689        for line in formatted.lines() {
690            tracing::debug!("{}", line);
691        }
692    }
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698
699    #[test]
700    fn test_tera_embedding_preserves_template_syntax() {
701        // This test verifies that when we embed content containing template syntax
702        // (like {{ }}) as a STRING VALUE in a template context, Tera treats it as
703        // literal text and does NOT try to process it.
704        //
705        // This is critical for our guard collapsing logic: when we collapse guards
706        // and add the content to the parent's context, any {{ }} in that content
707        // should remain as literal text in the final output.
708
709        let mut tera = Tera::default();
710
711        // Simulate a dependency's content that contains template syntax
712        let dependency_content = "Value is {{ some.variable }} here";
713
714        // Create a parent template that embeds the dependency
715        let parent_template = r#"
716# Parent Document
717
718Embedded content below:
719{{ deps.foo.content }}
720
721Done.
722"#;
723
724        // Add template to Tera
725        tera.add_raw_template("parent", parent_template).unwrap();
726
727        // Create context with the dependency content as a STRING
728        let mut context = TeraContext::new();
729        context.insert(
730            "deps",
731            &serde_json::json!({
732                "foo": {
733                    "content": dependency_content
734                }
735            }),
736        );
737
738        // Render the parent
739        let result = tera.render("parent", &context).unwrap();
740
741        println!("Rendered output:\n{}", result);
742        println!(
743            "\nDoes it contain literal '{{{{ some.variable }}}}'? {}",
744            result.contains("{{ some.variable }}")
745        );
746
747        // THE KEY ASSERTION: The template syntax should be preserved as literal text
748        assert!(
749            result.contains("{{ some.variable }}"),
750            "Template syntax should be preserved as literal text when embedded as a string value.\n\
751            This test failing means Tera tried to process the {{ }} syntax, which would break our guard collapsing.\n\
752            Rendered output:\n{}",
753            result
754        );
755    }
756}