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, bail};
7use std::collections::HashMap;
8use std::path::PathBuf;
9use tera::{Context as TeraContext, Tera};
10
11use super::content::NON_TEMPLATED_LITERAL_GUARD_END;
12use super::content::NON_TEMPLATED_LITERAL_GUARD_START;
13use super::filters;
14
15/// Template renderer with Tera engine and custom functions.
16///
17/// This struct wraps a Tera instance with AGPM-specific configuration,
18/// custom functions, and filters. It provides a safe, sandboxed environment
19/// for rendering Markdown templates.
20///
21/// # Security
22///
23/// The renderer is configured with security restrictions:
24/// - No file system access via includes/extends (except content filter)
25/// - No network access
26/// - Sandboxed template execution
27/// - Custom functions are carefully vetted
28/// - Project file access restricted to project directory with validation
29pub struct TemplateRenderer {
30    /// The underlying Tera template engine
31    tera: Tera,
32    /// Whether templating is enabled globally
33    enabled: bool,
34}
35
36impl TemplateRenderer {
37    /// Create a new template renderer with AGPM-specific configuration.
38    ///
39    /// # Arguments
40    ///
41    /// * `enabled` - Whether templating is enabled globally
42    /// * `project_dir` - Project root directory for content filter validation
43    /// * `max_content_file_size` - Maximum file size in bytes for content filter (None for no limit)
44    ///
45    /// # Returns
46    ///
47    /// Returns a configured `TemplateRenderer` instance with custom filters registered.
48    ///
49    /// # Filters
50    ///
51    /// The following custom filters are registered:
52    /// - `content`: Read project-specific files with path validation and size limits
53    pub fn new(
54        enabled: bool,
55        project_dir: PathBuf,
56        max_content_file_size: Option<u64>,
57    ) -> Result<Self> {
58        let mut tera = Tera::default();
59
60        // Register custom filters
61        tera.register_filter(
62            "content",
63            filters::create_content_filter(project_dir.clone(), max_content_file_size),
64        );
65
66        Ok(Self {
67            tera,
68            enabled,
69        })
70    }
71
72    /// Protect literal blocks from template rendering by replacing them with placeholders.
73    ///
74    /// This method scans for ```literal fenced code blocks and replaces them with
75    /// unique placeholders that won't be affected by template rendering. The original
76    /// content is stored in a HashMap that can be used to restore the blocks later.
77    ///
78    /// # Arguments
79    ///
80    /// * `content` - The content to process
81    ///
82    /// # Returns
83    ///
84    /// Returns a tuple of:
85    /// - Modified content with placeholders instead of literal blocks
86    /// - HashMap mapping placeholder IDs to original content
87    ///
88    /// # Examples
89    ///
90    /// ````markdown
91    /// # Documentation Example
92    ///
93    /// Use this syntax in templates:
94    ///
95    /// ```literal
96    /// {{ agpm.deps.snippets.example.content }}
97    /// ```
98    /// ````
99    ///
100    /// The content inside the literal block will be protected from rendering.
101    pub(crate) fn protect_literal_blocks(
102        &self,
103        content: &str,
104    ) -> (String, HashMap<String, String>) {
105        let mut placeholders = HashMap::new();
106        let mut counter = 0;
107        let mut result = String::with_capacity(content.len());
108
109        // Split content by lines to find both ```literal fences and RAW guards
110        let mut in_literal_fence = false;
111        let mut in_raw_guard = false;
112        let mut current_block = String::new();
113        let lines = content.lines();
114
115        for line in lines {
116            let trimmed = line.trim();
117
118            if trimmed == NON_TEMPLATED_LITERAL_GUARD_START {
119                // Start of RAW guard block
120                in_raw_guard = true;
121                current_block.clear();
122                tracing::debug!("Found start of RAW guard block");
123                // Skip the guard line
124            } else if in_raw_guard && trimmed == NON_TEMPLATED_LITERAL_GUARD_END {
125                // End of RAW guard block
126                in_raw_guard = false;
127
128                // Generate unique placeholder
129                let placeholder_id = format!("__AGPM_LITERAL_BLOCK_{}__", counter);
130                counter += 1;
131
132                // Store original content (keep the guards for later processing)
133                let guarded_content = format!(
134                    "{}\n{}\n{}",
135                    NON_TEMPLATED_LITERAL_GUARD_START,
136                    current_block,
137                    NON_TEMPLATED_LITERAL_GUARD_END
138                );
139                placeholders.insert(placeholder_id.clone(), guarded_content);
140
141                // Insert placeholder
142                result.push_str(&placeholder_id);
143                result.push('\n');
144
145                tracing::debug!(
146                    "Protected RAW guard block with placeholder {} ({} bytes)",
147                    placeholder_id,
148                    current_block.len()
149                );
150
151                current_block.clear();
152                // Skip the guard line
153            } else if in_raw_guard {
154                // Inside RAW guard - accumulate content
155                if !current_block.is_empty() {
156                    current_block.push('\n');
157                }
158                current_block.push_str(line);
159            } else if trimmed.starts_with("```literal") {
160                // Start of ```literal fence
161                in_literal_fence = true;
162                current_block.clear();
163                tracing::debug!("Found start of literal fence");
164                // Skip the fence line
165            } else if in_literal_fence && trimmed.starts_with("```") {
166                // End of ```literal fence
167                in_literal_fence = false;
168
169                // Generate unique placeholder
170                let placeholder_id = format!("__AGPM_LITERAL_BLOCK_{}__", counter);
171                counter += 1;
172
173                // Store original content
174                placeholders.insert(placeholder_id.clone(), current_block.clone());
175
176                // Insert placeholder
177                result.push_str(&placeholder_id);
178                result.push('\n');
179
180                tracing::debug!(
181                    "Protected literal fence with placeholder {} ({} bytes)",
182                    placeholder_id,
183                    current_block.len()
184                );
185
186                current_block.clear();
187                // Skip the fence line
188            } else if in_literal_fence {
189                // Inside ```literal fence - accumulate content
190                if !current_block.is_empty() {
191                    current_block.push('\n');
192                }
193                current_block.push_str(line);
194            } else {
195                // Regular content - pass through
196                result.push_str(line);
197                result.push('\n');
198            }
199        }
200
201        // Handle unclosed blocks (add back as-is)
202        if in_literal_fence {
203            tracing::warn!("Unclosed literal fence found - treating as regular content");
204            result.push_str("```literal\n");
205            result.push_str(&current_block);
206        }
207        if in_raw_guard {
208            tracing::warn!("Unclosed RAW guard found - treating as regular content");
209            result.push_str(NON_TEMPLATED_LITERAL_GUARD_START);
210            result.push('\n');
211            result.push_str(&current_block);
212        }
213
214        // Remove trailing newline if original didn't have one
215        if !content.ends_with('\n') && result.ends_with('\n') {
216            result.pop();
217        }
218
219        tracing::debug!("Protected {} literal block(s)", placeholders.len());
220        (result, placeholders)
221    }
222
223    /// Restore literal blocks by replacing placeholders with original content.
224    ///
225    /// This method takes rendered content and restores any literal blocks that were
226    /// protected during the rendering process.
227    ///
228    /// # Arguments
229    ///
230    /// * `content` - The rendered content containing placeholders
231    /// * `placeholders` - HashMap mapping placeholder IDs to original content
232    ///
233    /// # Returns
234    ///
235    /// Returns the content with placeholders replaced by original literal blocks,
236    /// wrapped in markdown code fences for proper display.
237    pub(crate) fn restore_literal_blocks(
238        &self,
239        content: &str,
240        placeholders: HashMap<String, String>,
241    ) -> String {
242        let mut result = content.to_string();
243
244        for (placeholder_id, original_content) in placeholders {
245            if original_content.starts_with(NON_TEMPLATED_LITERAL_GUARD_START) {
246                result = result.replace(&placeholder_id, &original_content);
247            } else {
248                // Wrap in markdown code fence for display
249                let replacement = format!("```\n{}\n```", original_content);
250                result = result.replace(&placeholder_id, &replacement);
251            }
252
253            tracing::debug!(
254                "Restored literal block {} ({} bytes)",
255                placeholder_id,
256                original_content.len()
257            );
258        }
259
260        result
261    }
262
263    /// Collapse literal fences that were injected to protect non-templated dependency content.
264    ///
265    /// Any block that starts with ```literal, contains the sentinel marker on its first line,
266    /// and ends with ``` will be replaced by the inner content without the sentinel or fences.
267    fn collapse_non_templated_literal_guards(content: String) -> String {
268        let mut result = String::with_capacity(content.len());
269        let mut in_guard = false;
270
271        for chunk in content.split_inclusive('\n') {
272            let trimmed = chunk.trim_end_matches(['\r', '\n']);
273
274            if !in_guard {
275                if trimmed == NON_TEMPLATED_LITERAL_GUARD_START {
276                    in_guard = true;
277                } else {
278                    result.push_str(chunk);
279                }
280            } else if trimmed == NON_TEMPLATED_LITERAL_GUARD_END {
281                in_guard = false;
282            } else {
283                result.push_str(chunk);
284            }
285        }
286
287        // If guard never closed, re-append the start marker and captured content to avoid dropping data.
288        if in_guard {
289            result.push_str(NON_TEMPLATED_LITERAL_GUARD_START);
290        }
291
292        result
293    }
294
295    /// Render a Markdown template with the given context.
296    ///
297    /// This method supports recursive template rendering where project files
298    /// can reference other project files using the `content` filter.
299    /// Rendering continues up to [`filters::MAX_RENDER_DEPTH`] levels deep.
300    ///
301    /// # Arguments
302    ///
303    /// * `template_content` - The raw Markdown template content
304    /// * `context` - The template context containing variables
305    ///
306    /// # Returns
307    ///
308    /// Returns the rendered Markdown content.
309    ///
310    /// # Errors
311    ///
312    /// Returns an error if:
313    /// - Template syntax is invalid
314    /// - Context variables are missing
315    /// - Custom functions/filters fail
316    /// - Recursive rendering exceeds maximum depth (10 levels)
317    ///
318    /// # Literal Blocks
319    ///
320    /// Content wrapped in ```literal fences will be protected from
321    /// template rendering and displayed literally:
322    ///
323    /// ````markdown
324    /// ```literal
325    /// {{ agpm.deps.snippets.example.content }}
326    /// ```
327    /// ````
328    ///
329    /// This is useful for documentation that shows template syntax examples.
330    ///
331    /// # Recursive Rendering
332    ///
333    /// When a template contains `content` filter references, those files
334    /// may themselves contain template syntax. The renderer automatically
335    /// detects this and performs multiple rendering passes until either:
336    /// - No template syntax remains in the output
337    /// - Maximum depth is reached (error)
338    ///
339    /// Example recursive template chain:
340    /// ```markdown
341    /// # Main Agent
342    /// {{ 'docs/guide.md' | content }}
343    /// ```
344    ///
345    /// Where `docs/guide.md` contains:
346    /// ```markdown
347    /// # Guide
348    /// {{ 'docs/common.md' | content }}
349    /// ```
350    ///
351    /// This will render up to 10 levels deep.
352    pub fn render_template(
353        &mut self,
354        template_content: &str,
355        context: &TeraContext,
356    ) -> Result<String> {
357        tracing::debug!("render_template called, enabled={}", self.enabled);
358
359        if !self.enabled {
360            // If templating is disabled, return content as-is
361            tracing::debug!("Templating disabled, returning content as-is");
362            return Ok(template_content.to_string());
363        }
364
365        // Step 1: Protect literal blocks before any rendering
366        let (protected_content, placeholders) = self.protect_literal_blocks(template_content);
367
368        // Check if content contains template syntax (after protecting literals)
369        if !self.contains_template_syntax(&protected_content) {
370            // No template syntax found, restore literals and return
371            tracing::debug!(
372                "No template syntax found after protecting literals, returning content"
373            );
374            return Ok(self.restore_literal_blocks(&protected_content, placeholders));
375        }
376
377        // Log the template context for debugging
378        tracing::debug!("Rendering template with context");
379        Self::log_context_as_kv(context);
380
381        // Step 2: Multi-pass rendering for recursive templates
382        // This allows project files to reference other project files
383        let mut current_content = protected_content;
384        let mut depth = 0;
385        let max_depth = filters::MAX_RENDER_DEPTH;
386
387        let rendered = loop {
388            depth += 1;
389
390            // Check depth limit
391            if depth > max_depth {
392                bail!(
393                    "Template rendering exceeded maximum recursion depth of {}. \
394                     This usually indicates circular dependencies between project files. \
395                     Please check your content filter references for cycles.",
396                    max_depth
397                );
398            }
399
400            tracing::debug!("Rendering pass {} of max {}", depth, max_depth);
401
402            // Render the current content
403            let rendered = self.tera.render_str(&current_content, context).map_err(|e| {
404                // Extract detailed error information from Tera error
405                let error_msg = Self::format_tera_error(&e);
406
407                // Return just the error without verbose template context
408                anyhow::Error::new(e).context(format!(
409                    "Template rendering failed at depth {}:\n{}",
410                    depth, error_msg
411                ))
412            })?;
413
414            // Check if the rendered output still contains template syntax OUTSIDE code fences
415            // This prevents re-rendering template syntax that was embedded as code examples
416            if !self.contains_template_syntax_outside_fences(&rendered) {
417                // No more template syntax outside fences - we're done with rendering
418                tracing::debug!("Template rendering complete after {} pass(es)", depth);
419                break rendered;
420            }
421
422            // More template syntax found outside fences - prepare for next iteration
423            tracing::debug!("Template syntax detected in output, continuing to pass {}", depth + 1);
424            current_content = rendered;
425        };
426
427        // Step 3: Restore literal blocks after all rendering is complete
428        let restored = self.restore_literal_blocks(&rendered, placeholders);
429
430        // Step 4: Collapse any literal guards that were added for non-templated dependencies
431        Ok(Self::collapse_non_templated_literal_guards(restored))
432    }
433
434    /// Format a Tera error with detailed information about what went wrong.
435    ///
436    /// Tera errors can contain various types of issues:
437    /// - Missing variables (e.g., "Variable `foo` not found")
438    /// - Syntax errors (e.g., "Unexpected end of template")
439    /// - Filter/function errors (e.g., "Filter `unknown` not found")
440    ///
441    /// This function extracts the root cause and formats it in a user-friendly way,
442    /// filtering out unhelpful internal template names like '__tera_one_off'.
443    ///
444    /// # Arguments
445    ///
446    /// * `error` - The Tera error to format
447    fn format_tera_error(error: &tera::Error) -> String {
448        use std::error::Error;
449
450        let mut messages = Vec::new();
451
452        // Walk the entire error chain and collect all messages
453        let mut all_messages = vec![error.to_string()];
454        let mut current_error: Option<&dyn Error> = error.source();
455        while let Some(err) = current_error {
456            all_messages.push(err.to_string());
457            current_error = err.source();
458        }
459
460        // Process messages to extract useful information
461        for msg in all_messages {
462            // Clean up the message by removing internal template names
463            let cleaned = msg
464                .replace("while rendering '__tera_one_off'", "")
465                .replace("Failed to render '__tera_one_off'", "Template rendering failed")
466                .replace("Failed to parse '__tera_one_off'", "Template syntax error")
467                .replace("'__tera_one_off'", "template")
468                .trim()
469                .to_string();
470
471            // Only keep non-empty, useful messages
472            if !cleaned.is_empty()
473                && cleaned != "Template rendering failed"
474                && cleaned != "Template syntax error"
475            {
476                messages.push(cleaned);
477            }
478        }
479
480        // If we got useful messages, return them
481        if !messages.is_empty() {
482            messages.join("\n  → ")
483        } else {
484            // Fallback: extract just the error kind
485            "Template syntax error (see details above)".to_string()
486        }
487    }
488
489    /// Format the template context as a string for error messages.
490    ///
491    /// # Arguments
492    ///
493    /// * `context` - The Tera context to format
494    fn format_context_as_string(context: &TeraContext) -> String {
495        let context_clone = context.clone();
496        let json_value = context_clone.into_json();
497        let mut output = String::new();
498
499        // Recursively format the JSON structure with indentation
500        fn format_value(key: &str, value: &serde_json::Value, indent: usize) -> Vec<String> {
501            let prefix = "  ".repeat(indent);
502            let mut lines = Vec::new();
503
504            match value {
505                serde_json::Value::Object(map) => {
506                    lines.push(format!("{}{}:", prefix, key));
507                    for (k, v) in map {
508                        lines.extend(format_value(k, v, indent + 1));
509                    }
510                }
511                serde_json::Value::Array(arr) => {
512                    lines.push(format!("{}{}: [{} items]", prefix, key, arr.len()));
513                    // Only show first few items to avoid spam
514                    for (i, item) in arr.iter().take(3).enumerate() {
515                        lines.extend(format_value(&format!("[{}]", i), item, indent + 1));
516                    }
517                    if arr.len() > 3 {
518                        lines.push(format!("{}  ... {} more items", prefix, arr.len() - 3));
519                    }
520                }
521                serde_json::Value::String(s) => {
522                    // Truncate long strings
523                    if s.len() > 100 {
524                        lines.push(format!(
525                            "{}{}: \"{}...\" ({} chars)",
526                            prefix,
527                            key,
528                            &s[..97],
529                            s.len()
530                        ));
531                    } else {
532                        lines.push(format!("{}{}: \"{}\"", prefix, key, s));
533                    }
534                }
535                serde_json::Value::Number(n) => {
536                    lines.push(format!("{}{}: {}", prefix, key, n));
537                }
538                serde_json::Value::Bool(b) => {
539                    lines.push(format!("{}{}: {}", prefix, key, b));
540                }
541                serde_json::Value::Null => {
542                    lines.push(format!("{}{}: null", prefix, key));
543                }
544            }
545            lines
546        }
547
548        if let serde_json::Value::Object(map) = &json_value {
549            for (key, value) in map {
550                output.push_str(&format_value(key, value, 1).join("\n"));
551                output.push('\n');
552            }
553        }
554
555        output
556    }
557
558    /// Log the template context as key-value pairs at debug level.
559    ///
560    /// # Arguments
561    ///
562    /// * `context` - The Tera context to log
563    fn log_context_as_kv(context: &TeraContext) {
564        let formatted = Self::format_context_as_string(context);
565        for line in formatted.lines() {
566            tracing::debug!("{}", line);
567        }
568    }
569
570    /// Check if content contains Tera template syntax.
571    ///
572    /// # Arguments
573    ///
574    /// * `content` - The content to check
575    ///
576    /// # Returns
577    ///
578    /// Returns `true` if the content contains template delimiters.
579    pub(crate) fn contains_template_syntax(&self, content: &str) -> bool {
580        let has_vars = content.contains("{{");
581        let has_tags = content.contains("{%");
582        let has_comments = content.contains("{#");
583        let result = has_vars || has_tags || has_comments;
584        tracing::debug!(
585            "Template syntax check: vars={}, tags={}, comments={}, result={}",
586            has_vars,
587            has_tags,
588            has_comments,
589            result
590        );
591        result
592    }
593
594    /// Check if content contains template syntax outside of code fences.
595    ///
596    /// This is used after rendering to determine if another pass is needed.
597    /// It ignores template syntax inside code fences to prevent re-rendering
598    /// content that has already been processed (like embedded dependency content).
599    pub(crate) fn contains_template_syntax_outside_fences(&self, content: &str) -> bool {
600        let mut in_code_fence = false;
601        let mut in_guard = 0usize;
602
603        for line in content.lines() {
604            let trimmed = line.trim();
605
606            if trimmed == NON_TEMPLATED_LITERAL_GUARD_START {
607                in_guard = in_guard.saturating_add(1);
608                continue;
609            } else if trimmed == NON_TEMPLATED_LITERAL_GUARD_END {
610                in_guard = in_guard.saturating_sub(1);
611                continue;
612            }
613
614            if in_guard > 0 {
615                continue;
616            }
617
618            // Track code fence boundaries
619            if trimmed.starts_with("```") {
620                in_code_fence = !in_code_fence;
621                continue;
622            }
623
624            // Skip lines inside code fences
625            if in_code_fence {
626                continue;
627            }
628
629            // Check for template syntax in non-fenced content
630            if line.contains("{{") || line.contains("{%") || line.contains("{#") {
631                tracing::debug!(
632                    "Template syntax found outside code fences: {:?}",
633                    &line[..line.len().min(80)]
634                );
635                return true;
636            }
637        }
638
639        tracing::debug!("No template syntax found outside code fences");
640        false
641    }
642}