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(¤t_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}