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