agpm_cli/templating/
filters.rs

1//! Custom Tera filters for AGPM templates.
2//!
3//! This module provides template filters that extend Tera's functionality for
4//! AGPM-specific use cases, such as reading project files, content manipulation,
5//! and other template operations.
6//!
7//! # Security
8//!
9//! All file access is restricted to the project directory with the following protections:
10//! - Only relative paths are allowed (no absolute paths)
11//! - Directory traversal outside project root is prevented
12//! - Only text file types are permitted (.md, .txt, .json, .toml, .yaml)
13//! - Missing files produce hard errors to fail fast
14//!
15//! # Supported File Types
16//!
17//! - **Markdown (.md)**: YAML/TOML frontmatter is automatically stripped
18//! - **JSON (.json)**: Parsed and pretty-printed
19//! - **Text (.txt)**: Raw content
20//! - **TOML (.toml)**: Raw content
21//! - **YAML (.yaml, .yml)**: Raw content
22//!
23//! # Examples
24//!
25//! ## Basic File Reading
26//!
27//! ```markdown
28//! ---
29//! agpm.templating: true
30//! ---
31//! # Code Review Agent
32//!
33//! ## Style Guide
34//! {{ 'project/styleguide.md' | content }}
35//!
36//! ## Best Practices
37//! {{ 'docs/best-practices.txt' | content }}
38//! ```
39//!
40//! ## Combining with Dependency Content Embedding
41//!
42//! Use both `content` filter and dependency `.content` fields together:
43//!
44//! ```markdown
45//! ---
46//! agpm.templating: true
47//! dependencies:
48//!   snippets:
49//!     - path: snippets/rust-patterns.md
50//!       name: rust_patterns
51//! ---
52//! # Rust Code Reviewer
53//!
54//! ## Shared Rust Patterns (versioned, from AGPM)
55//! {{ agpm.deps.snippets.rust_patterns.content }}
56//!
57//! ## Project-Specific Style Guide (local)
58//! {{ 'project/rust-style.md' | content }}
59//! ```
60//!
61//! **When to use each**:
62//! - **`agpm.deps.<type>.<name>.content`**: Versioned content from AGPM repositories
63//! - **`content` filter**: Project-local files (team docs, company standards)
64//!
65//! ## Recursive Templates
66//!
67//! Project files can themselves contain template syntax:
68//!
69//! **project/styleguide.md**:
70//! ```markdown
71//! # Coding Standards
72//!
73//! ## Rust-Specific Rules
74//! {{ 'project/rust-style.md' | content }}
75//!
76//! ## Common Guidelines
77//! {{ 'project/common-style.md' | content }}
78//! ```
79//!
80//! The template system will render up to 10 levels of nested references.
81
82use anyhow::{Context, Result, bail};
83use std::collections::HashMap;
84use std::path::{Component, Path, PathBuf};
85
86/// Allowed file extensions for project file access.
87///
88/// Only text-based formats are permitted to prevent binary file inclusion
89/// and ensure content can be safely embedded in templates.
90const ALLOWED_EXTENSIONS: &[&str] = &["md", "txt", "json", "toml", "yaml", "yml"];
91
92/// Maximum nesting depth for recursive template rendering.
93///
94/// This prevents infinite loops and excessive memory usage when files
95/// reference each other cyclically or create deep nesting chains.
96pub const MAX_RENDER_DEPTH: usize = 10;
97
98/// Validates a file path for security and correctness.
99///
100/// This function ensures that:
101/// 1. The path is relative (not absolute)
102/// 2. The path doesn't traverse outside the project directory using `..`
103/// 3. The file extension is in the allowed list
104/// 4. The file exists and is readable
105/// 5. The file size doesn't exceed the maximum allowed
106///
107/// # Arguments
108///
109/// * `path_str` - The path string from the template
110/// * `project_dir` - The project root directory
111/// * `max_size` - Maximum file size in bytes (None for no limit)
112///
113/// # Returns
114///
115/// Returns the canonicalized absolute path to the file if all checks pass.
116///
117/// # Errors
118///
119/// Returns an error if:
120/// - Path is absolute
121/// - Path contains `..` components that escape project directory
122/// - File extension is not in the allowed list
123/// - File doesn't exist
124/// - File is not accessible (permissions, etc.)
125/// - File size exceeds the maximum allowed
126///
127/// # Security
128///
129/// This function is critical for preventing directory traversal attacks.
130/// It validates paths before any file system access occurs.
131///
132/// # Examples
133///
134/// ```rust,no_run
135/// # use std::path::Path;
136/// # use agpm_cli::templating::filters::validate_content_path;
137/// # fn example() -> anyhow::Result<()> {
138/// let project_dir = Path::new("/home/user/project");
139///
140/// // Valid relative path with no size limit
141/// let path = validate_content_path("docs/guide.md", project_dir, None)?;
142///
143/// // With size limit (1 MB)
144/// let path = validate_content_path("docs/guide.md", project_dir, Some(1024 * 1024))?;
145///
146/// // Invalid: absolute path
147/// let result = validate_content_path("/etc/passwd", project_dir, None);
148/// assert!(result.is_err());
149///
150/// // Invalid: directory traversal
151/// let result = validate_content_path("../../etc/passwd", project_dir, None);
152/// assert!(result.is_err());
153///
154/// // Invalid: wrong extension
155/// let result = validate_content_path("script.sh", project_dir, None);
156/// assert!(result.is_err());
157/// # Ok(())
158/// # }
159/// ```
160pub fn validate_content_path(
161    path_str: &str,
162    project_dir: &Path,
163    max_size: Option<u64>,
164) -> Result<PathBuf> {
165    // Parse the path
166    let path = Path::new(path_str);
167
168    // Reject absolute paths
169    if path.is_absolute() {
170        bail!(
171            "Absolute paths are not allowed in content filter. \
172             Path '{}' must be relative to project root.",
173            path_str
174        );
175    }
176
177    // Check for directory traversal attempts
178    // We need to resolve the path and ensure it stays within project_dir
179    let mut components_count: i32 = 0;
180    for component in path.components() {
181        match component {
182            Component::Normal(_) => components_count += 1,
183            Component::ParentDir => {
184                components_count -= 1;
185                // If we go negative, we're trying to escape the project directory
186                if components_count < 0 {
187                    bail!(
188                        "Path traversal outside project directory is not allowed. \
189                         Path '{}' attempts to access parent directories beyond project root.",
190                        path_str
191                    );
192                }
193            }
194            Component::CurDir => {
195                // `.` is fine, just ignore it
196            }
197            _ => {
198                // Prefix, RootDir shouldn't appear in relative paths
199                bail!("Invalid path component in '{}'. Only relative paths are allowed.", path_str);
200            }
201        }
202    }
203
204    // Validate file extension
205    let extension = path.extension().and_then(|ext| ext.to_str()).ok_or_else(|| {
206        anyhow::anyhow!(
207            "File '{}' has no extension. Allowed extensions: {}",
208            path_str,
209            ALLOWED_EXTENSIONS.join(", ")
210        )
211    })?;
212
213    let extension_lower = extension.to_lowercase();
214    if !ALLOWED_EXTENSIONS.contains(&extension_lower.as_str()) {
215        bail!(
216            "File extension '.{}' is not allowed. \
217             Allowed extensions: {}. \
218             Path: '{}'",
219            extension,
220            ALLOWED_EXTENSIONS.join(", "),
221            path_str
222        );
223    }
224
225    // Construct full path relative to project directory
226    let full_path = project_dir.join(path);
227
228    // Check if file exists
229    if !full_path.exists() {
230        bail!(
231            "File not found: '{}'. \
232             The content filter requires files to exist. \
233             Full path attempted: {}",
234            path_str,
235            full_path.display()
236        );
237    }
238
239    // Check if it's a regular file (not a directory or symlink)
240    if !full_path.is_file() {
241        bail!(
242            "Path '{}' is not a regular file. \
243             The content filter only works with files, not directories or special files.",
244            path_str
245        );
246    }
247
248    // Canonicalize to get absolute path and verify it's still within project_dir
249    let canonical_path = full_path
250        .canonicalize()
251        .with_context(|| format!("Failed to canonicalize path: {}", full_path.display()))?;
252
253    let canonical_project = project_dir.canonicalize().with_context(|| {
254        format!("Failed to canonicalize project directory: {}", project_dir.display())
255    })?;
256
257    // Final security check: ensure canonical path is within project directory
258    if !canonical_path.starts_with(&canonical_project) {
259        bail!(
260            "Security violation: Path '{}' resolves to '{}' which is outside project directory '{}'",
261            path_str,
262            canonical_path.display(),
263            canonical_project.display()
264        );
265    }
266
267    // Check file size if limit is specified
268    if let Some(max_bytes) = max_size {
269        let metadata = canonical_path.metadata().with_context(|| {
270            format!("Failed to read file metadata: {}", canonical_path.display())
271        })?;
272
273        let file_size = metadata.len();
274        if file_size > max_bytes {
275            bail!(
276                "File '{}' is too large ({} bytes). Maximum allowed size: {} bytes ({:.2} MB vs {:.2} MB limit).",
277                path_str,
278                file_size,
279                max_bytes,
280                file_size as f64 / (1024.0 * 1024.0),
281                max_bytes as f64 / (1024.0 * 1024.0)
282            );
283        }
284    }
285
286    Ok(canonical_path)
287}
288
289/// Reads and processes a project file based on its type.
290///
291/// This function handles different file types appropriately:
292/// - Markdown: Strips YAML/TOML frontmatter
293/// - JSON: Parses and pretty-prints
294/// - Other text files: Returns raw content
295///
296/// # Arguments
297///
298/// * `file_path` - Validated absolute path to the file
299///
300/// # Returns
301///
302/// Returns the processed file content as a string.
303///
304/// # Errors
305///
306/// Returns an error if:
307/// - File cannot be read (I/O error)
308/// - File contains invalid UTF-8
309/// - JSON file has invalid syntax
310/// - Markdown frontmatter is malformed
311///
312/// # Examples
313///
314/// ```rust,no_run
315/// # use std::path::Path;
316/// # use agpm_cli::templating::filters::read_and_process_content;
317/// # fn example() -> anyhow::Result<()> {
318/// let path = Path::new("/home/user/project/docs/guide.md");
319/// let content = read_and_process_content(path)?;
320/// println!("{}", content);
321/// # Ok(())
322/// # }
323/// ```
324pub fn read_and_process_content(file_path: &Path) -> Result<String> {
325    // Read file content
326    let content = std::fs::read_to_string(file_path).with_context(|| {
327        format!(
328            "Failed to read project file: {}. \
329             Ensure the file is readable and contains valid UTF-8.",
330            file_path.display()
331        )
332    })?;
333
334    // Process based on file extension
335    let extension = file_path
336        .extension()
337        .and_then(|ext| ext.to_str())
338        .map(|s| s.to_lowercase())
339        .unwrap_or_default();
340
341    let processed_content = match extension.as_str() {
342        "md" => {
343            // Markdown: strip frontmatter
344            match crate::markdown::MarkdownDocument::parse(&content) {
345                Ok(doc) => doc.content,
346                Err(e) => {
347                    tracing::warn!(
348                        "Failed to parse markdown file '{}': {}. Using raw content.",
349                        file_path.display(),
350                        e
351                    );
352                    content
353                }
354            }
355        }
356        "json" => {
357            // JSON: parse and pretty-print
358            match serde_json::from_str::<serde_json::Value>(&content) {
359                Ok(json) => serde_json::to_string_pretty(&json).unwrap_or(content),
360                Err(e) => {
361                    tracing::warn!(
362                        "Failed to parse JSON file '{}': {}. Using raw content.",
363                        file_path.display(),
364                        e
365                    );
366                    content
367                }
368            }
369        }
370        _ => {
371            // Text, TOML, YAML: return raw content
372            content
373        }
374    };
375
376    Ok(processed_content)
377}
378
379/// Creates a Tera filter function for reading and embedding file content.
380///
381/// This function returns a closure that can be registered as a Tera filter.
382/// The closure captures the project directory and uses it to validate and
383/// read files during template rendering.
384///
385/// # Arguments
386///
387/// * `project_dir` - The project root directory for path validation
388///
389/// # Returns
390///
391/// Returns a boxed closure compatible with Tera's filter registration API.
392///
393/// # Filter Usage
394///
395/// In templates, use the filter with a string value containing the relative path:
396///
397/// ```markdown
398/// {{ 'docs/styleguide.md' | content }}
399/// ```
400///
401/// # Errors
402///
403/// The returned filter will produce template rendering errors if:
404/// - The input value is not a string
405/// - Path validation fails (absolute path, traversal, invalid extension, etc.)
406/// - File cannot be read or processed
407///
408/// # Examples
409///
410/// ```rust,no_run
411/// # use std::path::Path;
412/// # use agpm_cli::templating::filters::create_content_filter;
413/// # fn example() -> anyhow::Result<()> {
414/// let project_dir = Path::new("/home/user/project");
415/// let max_size = Some(10 * 1024 * 1024); // 10 MB limit
416/// let filter = create_content_filter(project_dir.to_path_buf(), max_size);
417///
418/// // Filter is registered in Tera:
419/// // tera.register_filter("content", filter);
420/// # Ok(())
421/// # }
422/// ```
423pub fn create_content_filter(
424    project_dir: PathBuf,
425    max_size: Option<u64>,
426) -> impl tera::Filter + 'static {
427    move |value: &tera::Value, _args: &HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
428        // Extract path string from filter input
429        let path_str = value
430            .as_str()
431            .ok_or_else(|| tera::Error::msg("content filter requires a string path"))?;
432
433        // Validate and read the file
434        let file_path = validate_content_path(path_str, &project_dir, max_size)
435            .map_err(|e| tera::Error::msg(format!("content filter error: {}", e)))?;
436
437        let content = read_and_process_content(&file_path)
438            .map_err(|e| tera::Error::msg(format!("content filter error: {}", e)))?;
439
440        // Return content as string value
441        Ok(tera::Value::String(content))
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use std::fs;
449    use tempfile::TempDir;
450
451    fn create_test_project() -> TempDir {
452        let temp = TempDir::new().unwrap();
453        let project_dir = temp.path();
454
455        // Create directory structure
456        fs::create_dir_all(project_dir.join("docs")).unwrap();
457        fs::create_dir_all(project_dir.join("project")).unwrap();
458
459        // Create test files
460        fs::write(project_dir.join("docs/guide.md"), "# Guide\n\nContent here").unwrap();
461        fs::write(project_dir.join("docs/notes.txt"), "Plain text notes").unwrap();
462        fs::write(project_dir.join("project/config.json"), r#"{"key": "value"}"#).unwrap();
463
464        // Create markdown with frontmatter
465        fs::write(
466            project_dir.join("docs/with-frontmatter.md"),
467            "---\ntitle: Test\n---\n\n# Content",
468        )
469        .unwrap();
470
471        temp
472    }
473
474    #[test]
475    fn test_validate_valid_path() {
476        let temp = create_test_project();
477        let project_dir = temp.path();
478
479        let result = validate_content_path("docs/guide.md", project_dir, None);
480        assert!(result.is_ok());
481
482        let path = result.unwrap();
483        assert!(path.ends_with("docs/guide.md"));
484        assert!(path.is_absolute());
485    }
486
487    #[test]
488    fn test_validate_rejects_absolute_path() {
489        let temp = create_test_project();
490        let project_dir = temp.path();
491
492        // Use platform-specific absolute paths
493        #[cfg(windows)]
494        let absolute_path = "C:\\Windows\\System32\\config";
495        #[cfg(not(windows))]
496        let absolute_path = "/etc/passwd";
497
498        let result = validate_content_path(absolute_path, project_dir, None);
499        assert!(result.is_err());
500        assert!(result.unwrap_err().to_string().contains("Absolute paths"));
501    }
502
503    #[test]
504    fn test_validate_rejects_traversal() {
505        let temp = create_test_project();
506        let project_dir = temp.path();
507
508        let result = validate_content_path("../../etc/passwd", project_dir, None);
509        assert!(result.is_err());
510        assert!(result.unwrap_err().to_string().contains("traversal"));
511    }
512
513    #[test]
514    fn test_validate_rejects_invalid_extension() {
515        let temp = create_test_project();
516        let project_dir = temp.path();
517
518        // Create a .sh file
519        fs::write(project_dir.join("script.sh"), "#!/bin/bash").unwrap();
520
521        let result = validate_content_path("script.sh", project_dir, None);
522        assert!(result.is_err());
523        assert!(result.unwrap_err().to_string().contains("not allowed"));
524    }
525
526    #[test]
527    fn test_validate_rejects_missing_file() {
528        let temp = create_test_project();
529        let project_dir = temp.path();
530
531        let result = validate_content_path("docs/missing.md", project_dir, None);
532        assert!(result.is_err());
533        assert!(result.unwrap_err().to_string().contains("not found"));
534    }
535
536    #[test]
537    fn test_validate_rejects_file_too_large() {
538        let temp = create_test_project();
539        let project_dir = temp.path();
540
541        // Create a file with known size (1000 bytes)
542        let large_file = project_dir.join("large.md");
543        fs::write(&large_file, "a".repeat(1000)).unwrap();
544
545        // Should succeed with larger limit
546        let result = validate_content_path("large.md", project_dir, Some(1001));
547        assert!(result.is_ok());
548
549        // Should fail with smaller limit
550        let result = validate_content_path("large.md", project_dir, Some(999));
551        assert!(result.is_err());
552        let err_msg = result.unwrap_err().to_string();
553        assert!(err_msg.contains("too large"));
554        assert!(err_msg.contains("1000 bytes"));
555        assert!(err_msg.contains("999 bytes"));
556    }
557
558    #[test]
559    fn test_read_markdown_strips_frontmatter() {
560        let temp = create_test_project();
561        let project_dir = temp.path();
562
563        let path = project_dir.join("docs/with-frontmatter.md");
564        let content = read_and_process_content(&path).unwrap();
565
566        assert!(!content.contains("---"));
567        assert!(!content.contains("title: Test"));
568        assert!(content.contains("# Content"));
569    }
570
571    #[test]
572    fn test_read_json_pretty_prints() {
573        let temp = create_test_project();
574        let project_dir = temp.path();
575
576        let path = project_dir.join("project/config.json");
577        let content = read_and_process_content(&path).unwrap();
578
579        // Should be pretty-printed (contains newlines)
580        assert!(content.contains('\n'));
581        assert!(content.contains("\"key\""));
582        assert!(content.contains("\"value\""));
583    }
584
585    #[test]
586    fn test_read_text_returns_raw() {
587        let temp = create_test_project();
588        let project_dir = temp.path();
589
590        let path = project_dir.join("docs/notes.txt");
591        let content = read_and_process_content(&path).unwrap();
592
593        assert_eq!(content, "Plain text notes");
594    }
595
596    #[test]
597    fn test_filter_function() {
598        use tera::Tera;
599
600        let temp = create_test_project();
601        let project_dir = temp.path().to_path_buf();
602
603        // Register the filter in a Tera instance
604        let mut tera = Tera::default();
605        tera.register_filter("content", create_content_filter(project_dir, None));
606
607        // Test with valid path using Tera's template rendering
608        let template = r#"{{ 'docs/guide.md' | content }}"#;
609        let context = tera::Context::new();
610
611        let result = tera.render_str(template, &context);
612        assert!(result.is_ok(), "Filter should render successfully");
613
614        let content = result.unwrap();
615        assert!(content.contains("# Guide"));
616        assert!(content.contains("Content here"));
617    }
618
619    #[test]
620    fn test_filter_rejects_non_string() {
621        use tera::Tera;
622
623        let temp = create_test_project();
624        let project_dir = temp.path().to_path_buf();
625
626        // Register the filter in a Tera instance
627        let mut tera = Tera::default();
628        tera.register_filter("content", create_content_filter(project_dir, None));
629
630        // Test with number instead of string (this will be caught at template render time)
631        let template = r#"{{ 42 | content }}"#;
632        let context = tera::Context::new();
633
634        let result = tera.render_str(template, &context);
635        // The important thing is that it fails - Tera may wrap our error message
636        assert!(result.is_err(), "Filter should reject non-string values");
637    }
638
639    #[test]
640    fn test_recursive_template_rendering() {
641        // This test is in the templating module tests
642        // See test_recursive_content_rendering in mod.rs
643    }
644}
645
646// Integration tests for recursive rendering
647#[cfg(test)]
648mod recursive_tests {
649    use std::fs;
650
651    #[test]
652    fn test_two_level_recursion() {
653        use crate::templating::TemplateRenderer;
654        use tempfile::TempDir;
655        use tera::Context;
656
657        // Create test files with recursive references
658        let temp = TempDir::new().unwrap();
659        let project_dir = temp.path();
660
661        fs::create_dir_all(project_dir.join("docs")).unwrap();
662
663        // Level 1: References level 2
664        fs::write(
665            project_dir.join("docs/level1.md"),
666            "# Level 1\n{{ 'docs/level2.md' | content }}",
667        )
668        .unwrap();
669
670        // Level 2: Final content (no more references)
671        fs::write(project_dir.join("docs/level2.md"), "Content from level 2").unwrap();
672
673        // Create renderer and render
674        let mut renderer = TemplateRenderer::new(true, project_dir.to_path_buf(), None).unwrap();
675        let context = Context::new();
676
677        let template = "{{ 'docs/level1.md' | content }}";
678        let result = renderer.render_template(template, &context);
679
680        assert!(result.is_ok(), "Two-level recursion should succeed");
681        let content = result.unwrap();
682        assert!(content.contains("# Level 1"));
683        assert!(content.contains("Content from level 2"));
684        assert!(!content.contains("{{"), "No template syntax should remain");
685    }
686
687    #[test]
688    fn test_three_level_recursion() {
689        use crate::templating::TemplateRenderer;
690        use tempfile::TempDir;
691        use tera::Context;
692
693        let temp = TempDir::new().unwrap();
694        let project_dir = temp.path();
695
696        fs::create_dir_all(project_dir.join("docs")).unwrap();
697
698        // Level 1 → Level 2 → Level 3
699        fs::write(project_dir.join("docs/level1.md"), "L1: {{ 'docs/level2.md' | content }}")
700            .unwrap();
701
702        fs::write(project_dir.join("docs/level2.md"), "L2: {{ 'docs/level3.md' | content }}")
703            .unwrap();
704
705        fs::write(project_dir.join("docs/level3.md"), "L3: Final").unwrap();
706
707        let mut renderer = TemplateRenderer::new(true, project_dir.to_path_buf(), None).unwrap();
708        let context = Context::new();
709
710        let template = "{{ 'docs/level1.md' | content }}";
711        let result = renderer.render_template(template, &context);
712
713        assert!(result.is_ok(), "Three-level recursion should succeed");
714        let content = result.unwrap();
715        assert!(content.contains("L1:"));
716        assert!(content.contains("L2:"));
717        assert!(content.contains("L3: Final"));
718    }
719
720    #[test]
721    fn test_depth_limit_exceeded() {
722        use crate::templating::TemplateRenderer;
723        use tempfile::TempDir;
724        use tera::Context;
725
726        let temp = TempDir::new().unwrap();
727        let project_dir = temp.path();
728
729        fs::create_dir_all(project_dir.join("docs")).unwrap();
730
731        // Create a file that references itself (infinite loop)
732        fs::write(project_dir.join("docs/loop.md"), "Loop: {{ 'docs/loop.md' | content }}")
733            .unwrap();
734
735        let mut renderer = TemplateRenderer::new(true, project_dir.to_path_buf(), None).unwrap();
736        let context = Context::new();
737
738        let template = "{{ 'docs/loop.md' | content }}";
739        let result = renderer.render_template(template, &context);
740
741        assert!(result.is_err(), "Circular reference should cause error");
742        let err = result.unwrap_err().to_string();
743        assert!(
744            err.contains("maximum recursion depth") || err.contains("depth"),
745            "Error should mention depth limit. Got: {}",
746            err
747        );
748    }
749
750    #[test]
751    fn test_multiple_file_references_same_level() {
752        use crate::templating::TemplateRenderer;
753        use tempfile::TempDir;
754        use tera::Context;
755
756        let temp = TempDir::new().unwrap();
757        let project_dir = temp.path();
758
759        fs::create_dir_all(project_dir.join("docs")).unwrap();
760
761        // Main file references two files at the same level
762        fs::write(
763            project_dir.join("docs/main.md"),
764            "# Main\n\n{{ 'docs/part1.md' | content }}\n\n{{ 'docs/part2.md' | content }}",
765        )
766        .unwrap();
767
768        fs::write(project_dir.join("docs/part1.md"), "Part 1 content").unwrap();
769        fs::write(project_dir.join("docs/part2.md"), "Part 2 content").unwrap();
770
771        let mut renderer = TemplateRenderer::new(true, project_dir.to_path_buf(), None).unwrap();
772        let context = Context::new();
773
774        let template = "{{ 'docs/main.md' | content }}";
775        let result = renderer.render_template(template, &context);
776
777        assert!(result.is_ok(), "Multiple file references should succeed");
778        let content = result.unwrap();
779        assert!(content.contains("# Main"));
780        assert!(content.contains("Part 1 content"));
781        assert!(content.contains("Part 2 content"));
782    }
783}