agpm_cli/templating/
mod.rs

1//! Markdown templating engine for AGPM resources.
2//!
3//! This module provides Tera-based templating functionality for Markdown resources,
4//! enabling dynamic content generation during installation. It supports safe, sandboxed
5//! template rendering with a rich context containing installation metadata.
6//!
7//! # Overview
8//!
9//! The templating system allows resource authors to:
10//! - Reference other resources by name and type
11//! - Access resolved installation paths and versions
12//! - Use conditional logic and loops in templates
13//! - Read and embed project-specific files (style guides, best practices, etc.)
14//!
15//! # Template Context
16//!
17//! Templates are rendered with a structured context containing:
18//! - `agpm.resource`: Current resource information (name, type, install path, etc.)
19//! - `agpm.deps`: Nested dependency information by resource type and name
20//!
21//! # Custom Filters
22//!
23//! - `content`: Read project-specific files (e.g., `{{ 'docs/guide.md' | content }}`)
24//!
25//! # Syntax Restrictions
26//!
27//! For security and safety, the following Tera features are disabled:
28//! - `{% include %}` tags (no file system access)
29//! - `{% extends %}` tags (no template inheritance)
30//! - `{% import %}` tags (no external template imports)
31//! - Custom functions that access the file system or network (except content filter)
32//!
33//! # Supported Features
34//!
35//! - Variable substitution: `{{ agpm.resource.install_path }}`
36//! - Conditional logic: `{% if agpm.resource.source %}...{% endif %}`
37//! - Loops: `{% for name, dep in agpm.deps.agents %}...{% endfor %}`
38//! - Standard Tera filters (string manipulation, formatting)
39//! - Project file embedding: `{{ 'path/to/file.md' | content }}`
40//! - Literal blocks: Protect template syntax from rendering for documentation
41//!
42//! # Literal Blocks (Documentation Mode)
43//!
44//! When writing documentation that includes template syntax examples, you can use
45//! `literal` fenced code blocks to protect the content from being rendered:
46//!
47//! ````markdown
48//! # Template Documentation
49//!
50//! Here's how to use template variables:
51//!
52//! ```literal
53//! {{ agpm.deps.snippets.example.content }}
54//! ```
55//!
56//! The above syntax will be displayed literally, not rendered.
57//! ````
58//!
59//! This is particularly useful for:
60//! - Documentation snippets that show template syntax examples
61//! - Tutorial content that explains how to use templates
62//! - Example code that should not be executed during rendering
63//!
64//! The content inside `literal` blocks will be:
65//! 1. Protected from template rendering (preserved as-is)
66//! 2. Wrapped in standard markdown code fences in the output
67//! 3. Displayed literally to the end user
68//!
69//! # Examples
70//!
71//! ## Basic Variable Substitution
72//!
73//! ```markdown
74//! # {{ agpm.resource.name }}
75//!
76//! This agent is installed at: `{{ agpm.resource.install_path }}`
77//! Version: {{ agpm.resource.version }}
78//! ```
79//!
80//! ## Dependency Content Embedding (v0.4.7+)
81//!
82//! All dependencies automatically have `.content` field with processed content:
83//!
84//! ```markdown
85//! ---
86//! agpm.templating: true
87//! dependencies:
88//!   snippets:
89//!     - path: snippets/best-practices.md
90//!       name: best_practices
91//! ---
92//! # Code Reviewer
93//!
94//! ## Best Practices
95//! {{ agpm.deps.snippets.best_practices.content }}
96//! ```
97//!
98//! ## Project File Filter (v0.4.8+)
99//!
100//! Read project-specific files using the `content` filter:
101//!
102//! ```markdown
103//! ---
104//! agpm.templating: true
105//! ---
106//! # Team Agent
107//!
108//! ## Project Style Guide
109//! {{ 'project/styleguide.md' | content }}
110//!
111//! ## Team Conventions
112//! {{ 'docs/conventions.txt' | content }}
113//! ```
114//!
115//! ## Combining Dependency Content + Project Files
116//!
117//! Use both features together for maximum flexibility:
118//!
119//! ```markdown
120//! ---
121//! agpm.templating: true
122//! dependencies:
123//!   snippets:
124//!     - path: snippets/rust-patterns.md
125//!       name: rust_patterns
126//!     - path: snippets/error-handling.md
127//!       name: error_handling
128//! ---
129//! # Rust Code Reviewer
130//!
131//! ## Shared Patterns (from AGPM repository)
132//! {{ agpm.deps.snippets.rust_patterns.content }}
133//!
134//! ## Project-Specific Style Guide
135//! {{ 'project/rust-style.md' | content }}
136//!
137//! ## Error Handling Best Practices
138//! {{ agpm.deps.snippets.error_handling.content }}
139//!
140//! ## Team Conventions
141//! {{ 'docs/team-conventions.txt' | content }}
142//! ```
143//!
144//! **When to use each**:
145//! - **Dependency content**: Versioned, shared resources from AGPM repos
146//! - **Project files**: Team-specific, project-local documentation
147//!
148//! ## Literal Blocks for Documentation
149//!
150//! When creating documentation snippets that explain template syntax, use
151//! `literal` blocks to prevent the examples from being rendered:
152//!
153//! ````markdown
154//! ---
155//! agpm.templating: true
156//! ---
157//! # AGPM Template Guide
158//!
159//! ## How to Embed Snippet Content
160//!
161//! To embed a snippet's content in your template, use this syntax:
162//!
163//! ```literal
164//! {{ agpm.deps.snippets.best_practices.content }}
165//! ```
166//!
167//! This will render the **current agent name**: {{ agpm.resource.name }}
168//!
169//! ## How to Loop Over Dependencies
170//!
171//! ```literal
172//! {% for name, dep in agpm.deps.agents %}
173//! - {{ name }}: {{ dep.version }}
174//! {% endfor %}
175//! ```
176//!
177//! The syntax examples above are displayed literally, while the agent name
178//! below is dynamically rendered based on the context.
179//! ````
180//!
181//! In this example:
182//! - The `literal` blocks show template syntax examples without rendering them
183//! - Regular template variables like `{{ agpm.resource.name }}` are still rendered
184//! - This allows documentation to demonstrate template features while using them
185//!
186//! ## Recursive Project Files
187//!
188//! Project files can reference other project files (up to 10 levels):
189//!
190//! **Main agent** (`.claude/agents/reviewer.md`):
191//! ```markdown
192//! ---
193//! agpm.templating: true
194//! ---
195//! # Code Reviewer
196//!
197//! {{ 'project/styleguide.md' | content }}
198//! ```
199//!
200//! **Style guide** (`project/styleguide.md`):
201//! ```markdown
202//! # Coding Standards
203//!
204//! ## Rust-Specific Rules
205//! {{ 'project/rust-style.md' | content }}
206//! ```
207//!
208//! ## Dependency References
209//!
210//! Dependencies are accessible by name in the template context. The name is determined by:
211//! 1. For manifest deps: the key in `[agents]`, `[snippets]`, etc.
212//! 2. For transitive deps: the `name` field if specified, otherwise derived from path
213//!
214//! ```markdown
215//! ## Dependencies
216//!
217//! This agent uses the following helper:
218//! - {{ agpm.deps.snippets.helper.install_path }}
219//!
220//! {% if agpm.deps.agents %}
221//! ## Related Agents
222//! {% for agent in agpm.deps.agents %}
223//! - {{ agent.name }} ({{ agent.version }})
224//! {% endfor %}
225//! {% endif %}
226//! ```
227//!
228//! ### Custom Names for Transitive Dependencies
229//!
230//! ```yaml
231//! ---
232//! dependencies:
233//!   agents:
234//!     - path: "../shared/complex-path/helper.md"
235//!       name: "helper"  # Use "helper" instead of deriving from path
236//! ---
237//! ```
238//!
239//! ## Conditional Content
240//!
241//! ```markdown
242//! {% if agpm.resource.source == "community" %}
243//! This resource is from the community repository.
244//! {% elif agpm.resource.source %}
245//! This resource is from the {{ agpm.resource.source }} source.
246//! {% else %}
247//! This is a local resource.
248//! {% endif %}
249//! ```
250
251pub mod filters;
252
253use anyhow::{Context, Result, bail};
254use serde::{Deserialize, Serialize};
255use serde_json::{Map, to_string, to_value};
256use std::collections::HashMap;
257use std::path::PathBuf;
258use std::sync::Arc;
259use tera::{Context as TeraContext, Tera};
260
261use crate::core::ResourceType;
262use crate::lockfile::LockFile;
263
264/// Sentinel markers used to guard non-templated dependency content.
265/// Content enclosed between these markers should be treated as literal text
266/// and never passed through the templating engine.
267const NON_TEMPLATED_LITERAL_GUARD_START: &str = "__AGPM_LITERAL_RAW_START__";
268const NON_TEMPLATED_LITERAL_GUARD_END: &str = "__AGPM_LITERAL_RAW_END__";
269
270/// Convert Unix-style path (from lockfile) to platform-native format for display in templates.
271///
272/// Lockfiles always use Unix-style forward slashes for cross-platform compatibility,
273/// but when rendering templates, we want to show paths in the platform's native format
274/// so users see `.claude\agents\helper.md` on Windows and `.claude/agents/helper.md` on Unix.
275///
276/// # Arguments
277///
278/// * `unix_path` - Path string with forward slashes (from lockfile)
279///
280/// # Returns
281///
282/// Platform-native path string (backslashes on Windows, forward slashes on Unix)
283///
284/// # Examples
285///
286/// ```
287/// # use agpm_cli::templating::to_native_path_display;
288/// #[cfg(windows)]
289/// assert_eq!(to_native_path_display(".claude/agents/test.md"), ".claude\\agents\\test.md");
290///
291/// #[cfg(not(windows))]
292/// assert_eq!(to_native_path_display(".claude/agents/test.md"), ".claude/agents/test.md");
293/// ```
294pub fn to_native_path_display(unix_path: &str) -> String {
295    #[cfg(windows)]
296    {
297        unix_path.replace('/', "\\")
298    }
299    #[cfg(not(windows))]
300    {
301        unix_path.to_string()
302    }
303}
304
305/// Template context builder for AGPM resource installation.
306///
307/// This struct is responsible for building the template context that will be
308/// available to Markdown templates during rendering. It collects data from
309/// the manifest, lockfile, and current resource being processed.
310///
311/// # Context Structure
312///
313/// The built context follows this structure:
314/// ```json
315/// {
316///   "agpm": {
317///     "resource": {
318///       "type": "agent",
319///       "name": "example-agent",
320///       "install_path": ".claude/agents/example.md",
321///       "source": "community",
322///       "version": "v1.0.0",
323///       "resolved_commit": "abc123...",
324///       "checksum": "sha256:...",
325///       "path": "agents/example.md"
326///     },
327///     "deps": {
328///       "agents": {
329///         "helper": {
330///           "install_path": ".claude/agents/helper.md",
331///           "version": "v1.0.0",
332///           "resolved_commit": "def456...",
333///           "checksum": "sha256:...",
334///           "source": "community",
335///           "path": "agents/helper.md"
336///         }
337///       },
338///       "snippets": { ... },
339///       "commands": { ... }
340///     }
341///   }
342/// }
343/// ```
344pub struct TemplateContextBuilder {
345    /// The lockfile containing resolved resource information
346    /// Shared via Arc to avoid expensive clones when building contexts for multiple resources
347    lockfile: Arc<LockFile>,
348    /// Project-specific template variables from the manifest
349    project_config: Option<crate::manifest::ProjectConfig>,
350    /// Cache instance for reading source files during content extraction
351    /// Shared via Arc to avoid expensive clones
352    cache: Arc<crate::cache::Cache>,
353    /// Project root directory for resolving local file paths
354    project_dir: PathBuf,
355}
356
357/// Template renderer with Tera engine and custom functions.
358///
359/// This struct wraps a Tera instance with AGPM-specific configuration,
360/// custom functions, and filters. It provides a safe, sandboxed environment
361/// for rendering Markdown templates.
362///
363/// # Security
364///
365/// The renderer is configured with security restrictions:
366/// - No file system access via includes/extends (except content filter)
367/// - No network access
368/// - Sandboxed template execution
369/// - Custom functions are carefully vetted
370/// - Project file access restricted to project directory with validation
371pub struct TemplateRenderer {
372    /// The underlying Tera template engine
373    tera: Tera,
374    /// Whether templating is enabled globally
375    enabled: bool,
376}
377
378/// Metadata about a resource for template context.
379///
380/// This struct represents the information available about a resource
381/// in the template context. It includes both the resource's own metadata
382/// and its resolved installation information.
383#[derive(Clone, Serialize, Deserialize)]
384pub struct ResourceTemplateData {
385    /// Resource type (agent, snippet, command, etc.)
386    #[serde(rename = "type")]
387    pub resource_type: String,
388    /// Logical resource name from manifest
389    pub name: String,
390    /// Resolved installation path
391    pub install_path: String,
392    /// Source identifier (if applicable)
393    pub source: Option<String>,
394    /// Resolved version (if applicable)
395    pub version: Option<String>,
396    /// Git commit SHA (if applicable)
397    pub resolved_commit: Option<String>,
398    /// SHA256 checksum of the content
399    pub checksum: String,
400    /// Source-relative path in repository
401    pub path: String,
402    /// Processed content of the resource file.
403    ///
404    /// Contains the file content with metadata stripped:
405    /// - For Markdown: Content without YAML frontmatter
406    /// - For JSON: Content without metadata fields
407    ///
408    /// This field is available for all dependencies, enabling template
409    /// embedding via `{{ agpm.deps.<type>.<name>.content }}`.
410    ///
411    /// Note: This field is large and should not be printed in debug output.
412    /// Use the Debug impl which shows only the content length.
413    #[serde(skip_serializing_if = "Option::is_none")]
414    pub content: Option<String>,
415}
416
417impl std::fmt::Debug for ResourceTemplateData {
418    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
419        f.debug_struct("ResourceTemplateData")
420            .field("resource_type", &self.resource_type)
421            .field("name", &self.name)
422            .field("install_path", &self.install_path)
423            .field("source", &self.source)
424            .field("version", &self.version)
425            .field("resolved_commit", &self.resolved_commit)
426            .field("checksum", &self.checksum)
427            .field("path", &self.path)
428            .field("content", &self.content.as_ref().map(|c| format!("<{} bytes>", c.len())))
429            .finish()
430    }
431}
432
433impl TemplateContextBuilder {
434    /// Create a new template context builder.
435    ///
436    /// # Arguments
437    ///
438    /// * `lockfile` - The resolved lockfile, wrapped in Arc for efficient sharing
439    /// * `project_config` - Optional project-specific template variables from the manifest
440    /// * `cache` - Cache instance for reading source files during content extraction
441    /// * `project_dir` - Project root directory for resolving local file paths
442    pub fn new(
443        lockfile: Arc<LockFile>,
444        project_config: Option<crate::manifest::ProjectConfig>,
445        cache: Arc<crate::cache::Cache>,
446        project_dir: PathBuf,
447    ) -> Self {
448        Self {
449            lockfile,
450            project_config,
451            cache,
452            project_dir,
453        }
454    }
455
456    /// Build the complete template context for a specific resource.
457    ///
458    /// # Arguments
459    ///
460    /// * `resource_name` - Name of the resource being rendered
461    /// * `resource_type` - Type of the resource (agents, snippets, etc.)
462    ///
463    /// # Returns
464    ///
465    /// Returns a Tera `Context` containing all available template variables.
466    pub async fn build_context(
467        &self,
468        resource_name: &str,
469        resource_type: ResourceType,
470    ) -> Result<TeraContext> {
471        let mut context = TeraContext::new();
472
473        // Build the nested agpm structure
474        let mut agpm = Map::new();
475
476        // Build current resource data
477        let resource_data = self.build_resource_data(resource_name, resource_type)?;
478        agpm.insert("resource".to_string(), to_value(resource_data)?);
479
480        // Build dependency data
481        let deps_data = self.build_dependencies_data().await?;
482        agpm.insert("deps".to_string(), to_value(deps_data)?);
483
484        // Add project variables if available
485        if let Some(ref project_config) = self.project_config {
486            let project_json = project_config.to_json_value();
487            agpm.insert("project".to_string(), project_json);
488        }
489
490        // Insert the complete agpm object
491        context.insert("agpm", &agpm);
492
493        Ok(context)
494    }
495
496    /// Build resource metadata for the template context.
497    ///
498    /// # Arguments
499    ///
500    /// * `resource_name` - Name of the resource
501    /// * `resource_type` - Type of the resource
502    fn build_resource_data(
503        &self,
504        resource_name: &str,
505        resource_type: ResourceType,
506    ) -> Result<ResourceTemplateData> {
507        let entry =
508            self.lockfile.find_resource(resource_name, resource_type).with_context(|| {
509                format!(
510                    "Resource '{}' of type {:?} not found in lockfile",
511                    resource_name, resource_type
512                )
513            })?;
514
515        Ok(ResourceTemplateData {
516            resource_type: resource_type.to_string(),
517            name: resource_name.to_string(),
518            install_path: to_native_path_display(&entry.installed_at),
519            source: entry.source.clone(),
520            version: entry.version.clone(),
521            resolved_commit: entry.resolved_commit.clone(),
522            checksum: entry.checksum.clone(),
523            path: entry.path.clone(),
524            content: None, // Will be populated when content extraction is implemented
525        })
526    }
527
528    /// Extract and process content from a resource file.
529    ///
530    /// Reads the source file and processes it based on file type:
531    /// - Markdown (.md): Strips YAML frontmatter, returns content only
532    /// - JSON (.json): Removes metadata fields like `dependencies`
533    /// - Other files: Returns raw content
534    ///
535    /// # Arguments
536    ///
537    /// * `resource` - The locked resource to extract content from
538    ///
539    /// # Returns
540    ///
541    /// Returns `Some(content)` if extraction succeeded, `None` on error (with warning logged)
542    async fn extract_content(&self, resource: &crate::lockfile::LockedResource) -> Option<String> {
543        tracing::debug!(
544            "Attempting to extract content for resource '{}' (type: {:?})",
545            resource.name,
546            resource.resource_type
547        );
548
549        // Determine source path
550        let source_path = if let Some(source_name) = &resource.source {
551            let url = resource.url.as_ref()?;
552
553            // Check if this is a local directory source
554            let is_local_source = resource.resolved_commit.as_deref().is_none_or(str::is_empty);
555
556            tracing::debug!(
557                "Resource '{}': source='{}', url='{}', is_local={}",
558                resource.name,
559                source_name,
560                url,
561                is_local_source
562            );
563
564            if is_local_source {
565                // Local directory source - use URL as path directly
566                let path = std::path::PathBuf::from(url).join(&resource.path);
567                tracing::debug!("Using local source path: {}", path.display());
568                path
569            } else {
570                // Git-based source - get worktree path
571                let sha = resource.resolved_commit.as_deref()?;
572
573                tracing::debug!(
574                    "Resource '{}': Getting worktree for SHA {}...",
575                    resource.name,
576                    &sha[..8.min(sha.len())]
577                );
578
579                // Use centralized worktree path construction
580                let worktree_dir = match self.cache.get_worktree_path(url, sha) {
581                    Ok(path) => {
582                        tracing::debug!("Worktree path: {}", path.display());
583                        path
584                    }
585                    Err(e) => {
586                        tracing::warn!(
587                            "Failed to construct worktree path for resource '{}': {}",
588                            resource.name,
589                            e
590                        );
591                        return None;
592                    }
593                };
594
595                let full_path = worktree_dir.join(&resource.path);
596                tracing::debug!(
597                    "Full source path for '{}': {} (worktree exists: {})",
598                    resource.name,
599                    full_path.display(),
600                    worktree_dir.exists()
601                );
602                full_path
603            }
604        } else {
605            // Local file - path is relative to project or absolute
606            let local_path = std::path::Path::new(&resource.path);
607            let resolved_path = if local_path.is_absolute() {
608                local_path.to_path_buf()
609            } else {
610                self.project_dir.join(local_path)
611            };
612
613            tracing::debug!(
614                "Resource '{}': Using local file path: {}",
615                resource.name,
616                resolved_path.display()
617            );
618
619            resolved_path
620        };
621
622        // Read file content
623        let content = match tokio::fs::read_to_string(&source_path).await {
624            Ok(c) => c,
625            Err(e) => {
626                tracing::warn!(
627                    "Failed to read content for resource '{}' from {}: {}",
628                    resource.name,
629                    source_path.display(),
630                    e
631                );
632                return None;
633            }
634        };
635
636        // Process based on file type
637        let processed_content = if resource.path.ends_with(".md") {
638            // Markdown: strip frontmatter and guard non-templated content that contains template syntax
639            match crate::markdown::MarkdownDocument::parse(&content) {
640                Ok(doc) => {
641                    let templating_enabled =
642                        Self::is_markdown_templating_enabled(doc.metadata.as_ref());
643                    let mut stripped_content = doc.content;
644
645                    if !templating_enabled
646                        && Self::content_contains_template_syntax(&stripped_content)
647                    {
648                        tracing::debug!(
649                            "Protecting non-templated markdown content for '{}'",
650                            resource.name
651                        );
652                        stripped_content = Self::wrap_content_in_literal_guard(stripped_content);
653                    }
654
655                    stripped_content
656                }
657                Err(e) => {
658                    tracing::warn!(
659                        "Failed to parse markdown for resource '{}': {}. Using raw content.",
660                        resource.name,
661                        e
662                    );
663                    content
664                }
665            }
666        } else if resource.path.ends_with(".json") {
667            // JSON: parse and remove metadata fields
668            match serde_json::from_str::<serde_json::Value>(&content) {
669                Ok(mut json) => {
670                    if let Some(obj) = json.as_object_mut() {
671                        // Remove metadata fields that shouldn't be in embedded content
672                        obj.remove("dependencies");
673                    }
674                    serde_json::to_string_pretty(&json).unwrap_or(content)
675                }
676                Err(e) => {
677                    tracing::warn!(
678                        "Failed to parse JSON for resource '{}': {}. Using raw content.",
679                        resource.name,
680                        e
681                    );
682                    content
683                }
684            }
685        } else {
686            // Other files: use raw content
687            content
688        };
689
690        Some(processed_content)
691    }
692
693    /// Determine whether templating is explicitly enabled in Markdown frontmatter.
694    fn is_markdown_templating_enabled(
695        metadata: Option<&crate::markdown::MarkdownMetadata>,
696    ) -> bool {
697        metadata
698            .and_then(|md| md.extra.get("agpm"))
699            .and_then(|agpm| agpm.as_object())
700            .and_then(|agpm_obj| agpm_obj.get("templating"))
701            .and_then(|value| value.as_bool())
702            .unwrap_or(false)
703    }
704
705    /// Detect if content contains Tera template syntax markers.
706    fn content_contains_template_syntax(content: &str) -> bool {
707        content.contains("{{") || content.contains("{%") || content.contains("{#")
708    }
709
710    /// Wrap non-templated content in a literal fence so it renders safely without being evaluated.
711    fn wrap_content_in_literal_guard(content: String) -> String {
712        let mut wrapped = String::with_capacity(
713            content.len()
714                + NON_TEMPLATED_LITERAL_GUARD_START.len()
715                + NON_TEMPLATED_LITERAL_GUARD_END.len()
716                + 2, // newline separators
717        );
718
719        wrapped.push_str(NON_TEMPLATED_LITERAL_GUARD_START);
720        wrapped.push('\n');
721        wrapped.push_str(&content);
722        if !content.ends_with('\n') {
723            wrapped.push('\n');
724        }
725        wrapped.push_str(NON_TEMPLATED_LITERAL_GUARD_END);
726
727        wrapped
728    }
729
730    /// Build dependency data for the template context.
731    ///
732    /// This creates a nested structure of all dependencies by resource type and name.
733    async fn build_dependencies_data(
734        &self,
735    ) -> Result<HashMap<String, HashMap<String, ResourceTemplateData>>> {
736        let mut deps = HashMap::new();
737
738        // Process each resource type
739        for resource_type in [
740            ResourceType::Agent,
741            ResourceType::Snippet,
742            ResourceType::Command,
743            ResourceType::Script,
744            ResourceType::Hook,
745            ResourceType::McpServer,
746        ] {
747            let type_str_plural = resource_type.to_plural().to_string();
748            let type_str_singular = resource_type.to_string();
749            let mut type_deps = HashMap::new();
750
751            let resources = self.lockfile.get_resources_by_type(resource_type);
752            for resource in resources {
753                // Extract content from source file
754                let content = self.extract_content(resource).await;
755
756                let template_data = ResourceTemplateData {
757                    resource_type: type_str_singular.clone(),
758                    name: resource.name.clone(),
759                    install_path: to_native_path_display(&resource.installed_at),
760                    source: resource.source.clone(),
761                    version: resource.version.clone(),
762                    resolved_commit: resource.resolved_commit.clone(),
763                    checksum: resource.checksum.clone(),
764                    path: resource.path.clone(),
765                    content,
766                };
767
768                // Use manifest_alias if available, otherwise use resource name
769                // For path-like names (containing / or \), extract just the basename
770                // This ensures clean keys like "ai_attribution" instead of full paths
771                let key_name = if let Some(alias) = &resource.manifest_alias {
772                    alias.clone()
773                } else if resource.name.contains('/') || resource.name.contains('\\') {
774                    // Name looks like a path - extract basename without extension
775                    std::path::Path::new(&resource.name)
776                        .file_stem()
777                        .and_then(|s| s.to_str())
778                        .unwrap_or(&resource.name)
779                        .to_string()
780                } else {
781                    // Use name as-is
782                    resource.name.clone()
783                };
784
785                // Sanitize the key name by replacing hyphens with underscores
786                // to avoid Tera interpreting them as minus operators
787                let sanitized_key = key_name.replace('-', "_");
788                type_deps.insert(sanitized_key, template_data);
789            }
790
791            if !type_deps.is_empty() {
792                deps.insert(type_str_plural, type_deps);
793            }
794        }
795
796        // Debug: Print what we're building
797        tracing::debug!("Built dependencies data with {} resource types", deps.len());
798        for (resource_type, resources) in &deps {
799            tracing::debug!("  Type {}: {} resources", resource_type, resources.len());
800            for name in resources.keys() {
801                tracing::debug!("    - {}", name);
802            }
803        }
804
805        Ok(deps)
806    }
807
808    /// Compute a stable digest of the template context data.
809    ///
810    /// This method creates a deterministic hash of all lockfile metadata that could
811    /// affect template rendering. The digest is used as part of the cache key to ensure
812    /// that changes to dependency versions or metadata properly invalidate the cache.
813    ///
814    /// # Returns
815    ///
816    /// Returns a hex-encoded string containing the first 16 characters of the SHA-256
817    /// hash of the serialized template context data. This is sufficient to uniquely
818    /// identify context changes while keeping the digest compact.
819    ///
820    /// # What's Included
821    ///
822    /// The digest includes all lockfile metadata that affects rendering:
823    /// - Resource names, types, and installation paths
824    /// - Dependency versions and resolved commits
825    /// - Checksums and source information
826    ///
827    /// # Determinism
828    ///
829    /// The hash is stable across runs because:
830    /// - Resources are sorted by type and name before hashing
831    /// - JSON serialization uses consistent ordering (BTreeMap)
832    /// - Only metadata fields that affect rendering are included
833    ///
834    /// # Examples
835    ///
836    /// ```rust,no_run
837    /// use agpm_cli::templating::TemplateContextBuilder;
838    /// use agpm_cli::lockfile::LockFile;
839    /// use std::path::{Path, PathBuf};
840    /// use std::sync::Arc;
841    ///
842    /// # fn example() -> anyhow::Result<()> {
843    /// let lockfile = LockFile::load(Path::new("agpm.lock"))?;
844    /// let cache = Arc::new(agpm_cli::cache::Cache::new()?);
845    /// let project_dir = std::env::current_dir()?;
846    /// let builder = TemplateContextBuilder::new(
847    ///     Arc::new(lockfile),
848    ///     None,
849    ///     cache,
850    ///     project_dir
851    /// );
852    ///
853    /// let digest = builder.compute_context_digest()?;
854    /// println!("Template context digest: {}", digest);
855    /// # Ok(())
856    /// # }
857    /// ```
858    pub fn compute_context_digest(&self) -> Result<String> {
859        use sha2::{Digest, Sha256};
860        use std::collections::BTreeMap;
861
862        // Build a deterministic representation of the lockfile data
863        // Use BTreeMap for consistent ordering
864        let mut digest_data: BTreeMap<String, BTreeMap<String, BTreeMap<&str, String>>> =
865            BTreeMap::new();
866
867        // Process each resource type in a consistent order
868        for resource_type in [
869            ResourceType::Agent,
870            ResourceType::Snippet,
871            ResourceType::Command,
872            ResourceType::Script,
873            ResourceType::Hook,
874            ResourceType::McpServer,
875        ] {
876            let resources = self.lockfile.get_resources_by_type(resource_type);
877            if resources.is_empty() {
878                continue;
879            }
880
881            let type_str = resource_type.to_plural().to_string();
882            let mut sorted_resources: Vec<_> = resources.iter().collect();
883            // Sort by name for deterministic ordering
884            sorted_resources.sort_by(|a, b| a.name.cmp(&b.name));
885
886            let mut type_data = BTreeMap::new();
887            for resource in sorted_resources {
888                // Include only the fields that can affect template rendering
889                let mut resource_data: BTreeMap<&str, String> = BTreeMap::new();
890                resource_data.insert("name", resource.name.clone());
891                resource_data.insert("install_path", resource.installed_at.clone());
892                resource_data.insert("path", resource.path.clone());
893                resource_data.insert("checksum", resource.checksum.clone());
894
895                // Optional fields - only include if present
896                if let Some(ref source) = resource.source {
897                    resource_data.insert("source", source.to_string());
898                }
899                if let Some(ref version) = resource.version {
900                    resource_data.insert("version", version.to_string());
901                }
902                if let Some(ref commit) = resource.resolved_commit {
903                    resource_data.insert("resolved_commit", commit.to_string());
904                }
905
906                type_data.insert(resource.name.clone(), resource_data);
907            }
908
909            digest_data.insert(type_str, type_data);
910        }
911
912        // Serialize to JSON for stable representation
913        let json_str =
914            to_string(&digest_data).context("Failed to serialize template context for digest")?;
915
916        // Compute SHA-256 hash
917        let mut hasher = Sha256::new();
918        hasher.update(json_str.as_bytes());
919        let hash = hasher.finalize();
920
921        // Return first 16 hex characters (64 bits) - sufficient for uniqueness
922        Ok(hex::encode(&hash[..8]))
923    }
924}
925
926impl TemplateRenderer {
927    /// Create a new template renderer with AGPM-specific configuration.
928    ///
929    /// # Arguments
930    ///
931    /// * `enabled` - Whether templating is enabled globally
932    /// * `project_dir` - Project root directory for content filter validation
933    /// * `max_content_file_size` - Maximum file size in bytes for content filter (None for no limit)
934    ///
935    /// # Returns
936    ///
937    /// Returns a configured `TemplateRenderer` instance with custom filters registered.
938    ///
939    /// # Filters
940    ///
941    /// The following custom filters are registered:
942    /// - `content`: Read project-specific files with path validation and size limits
943    pub fn new(
944        enabled: bool,
945        project_dir: PathBuf,
946        max_content_file_size: Option<u64>,
947    ) -> Result<Self> {
948        let mut tera = Tera::default();
949
950        // Register custom filters
951        tera.register_filter(
952            "content",
953            filters::create_content_filter(project_dir.clone(), max_content_file_size),
954        );
955
956        Ok(Self {
957            tera,
958            enabled,
959        })
960    }
961
962    /// Protect literal blocks from template rendering by replacing them with placeholders.
963    ///
964    /// This method scans for ```literal fenced code blocks and replaces them with
965    /// unique placeholders that won't be affected by template rendering. The original
966    /// content is stored in a HashMap that can be used to restore the blocks later.
967    ///
968    /// # Arguments
969    ///
970    /// * `content` - The content to process
971    ///
972    /// # Returns
973    ///
974    /// Returns a tuple of:
975    /// - Modified content with placeholders instead of literal blocks
976    /// - HashMap mapping placeholder IDs to original content
977    ///
978    /// # Examples
979    ///
980    /// ````markdown
981    /// # Documentation Example
982    ///
983    /// Use this syntax in templates:
984    ///
985    /// ```literal
986    /// {{ agpm.deps.snippets.example.content }}
987    /// ```
988    /// ````
989    ///
990    /// The content inside the literal block will be protected from rendering.
991    fn protect_literal_blocks(&self, content: &str) -> (String, HashMap<String, String>) {
992        let mut placeholders = HashMap::new();
993        let mut counter = 0;
994        let mut result = String::with_capacity(content.len());
995
996        // Split content by triple backticks to find code blocks
997        let mut in_literal_block = false;
998        let mut current_block = String::new();
999        let lines = content.lines();
1000
1001        for line in lines {
1002            if line.trim().starts_with("```literal") {
1003                // Start of literal block
1004                in_literal_block = true;
1005                current_block.clear();
1006                tracing::debug!("Found start of literal block");
1007                continue; // Skip the fence line
1008            } else if in_literal_block && line.trim().starts_with("```") {
1009                // End of literal block
1010                in_literal_block = false;
1011
1012                // Generate unique placeholder
1013                let placeholder_id = format!("__AGPM_LITERAL_BLOCK_{}__", counter);
1014                counter += 1;
1015
1016                // Store original content
1017                placeholders.insert(placeholder_id.clone(), current_block.clone());
1018
1019                // Insert placeholder
1020                result.push_str(&placeholder_id);
1021                result.push('\n');
1022
1023                tracing::debug!(
1024                    "Protected literal block with placeholder {} ({} bytes)",
1025                    placeholder_id,
1026                    current_block.len()
1027                );
1028
1029                current_block.clear();
1030                continue; // Skip the fence line
1031            } else if in_literal_block {
1032                // Inside literal block - accumulate content
1033                if !current_block.is_empty() {
1034                    current_block.push('\n');
1035                }
1036                current_block.push_str(line);
1037            } else {
1038                // Regular content - pass through
1039                result.push_str(line);
1040                result.push('\n');
1041            }
1042        }
1043
1044        // Handle unclosed literal block (add back as-is)
1045        if in_literal_block {
1046            tracing::warn!("Unclosed literal block found - treating as regular content");
1047            result.push_str("```literal\n");
1048            result.push_str(&current_block);
1049        }
1050
1051        // Remove trailing newline if original didn't have one
1052        if !content.ends_with('\n') && result.ends_with('\n') {
1053            result.pop();
1054        }
1055
1056        tracing::debug!("Protected {} literal block(s)", placeholders.len());
1057        (result, placeholders)
1058    }
1059
1060    /// Restore literal blocks by replacing placeholders with original content.
1061    ///
1062    /// This method takes rendered content and restores any literal blocks that were
1063    /// protected during the rendering process.
1064    ///
1065    /// # Arguments
1066    ///
1067    /// * `content` - The rendered content containing placeholders
1068    /// * `placeholders` - HashMap mapping placeholder IDs to original content
1069    ///
1070    /// # Returns
1071    ///
1072    /// Returns the content with placeholders replaced by original literal blocks,
1073    /// wrapped in markdown code fences for proper display.
1074    fn restore_literal_blocks(
1075        &self,
1076        content: &str,
1077        placeholders: HashMap<String, String>,
1078    ) -> String {
1079        let mut result = content.to_string();
1080
1081        for (placeholder_id, original_content) in placeholders {
1082            if original_content.starts_with(NON_TEMPLATED_LITERAL_GUARD_START) {
1083                result = result.replace(&placeholder_id, &original_content);
1084            } else {
1085                // Wrap in markdown code fence for display
1086                let replacement = format!("```\n{}\n```", original_content);
1087                result = result.replace(&placeholder_id, &replacement);
1088            }
1089
1090            tracing::debug!(
1091                "Restored literal block {} ({} bytes)",
1092                placeholder_id,
1093                original_content.len()
1094            );
1095        }
1096
1097        result
1098    }
1099
1100    /// Collapse literal fences that were injected to protect non-templated dependency content.
1101    ///
1102    /// Any block that starts with ```literal, contains the sentinel marker on its first line,
1103    /// and ends with ``` will be replaced by the inner content without the sentinel or fences.
1104    fn collapse_non_templated_literal_guards(content: String) -> String {
1105        let mut result = String::with_capacity(content.len());
1106        let mut in_guard = false;
1107
1108        for chunk in content.split_inclusive('\n') {
1109            let trimmed = chunk.trim_end_matches(['\r', '\n']);
1110
1111            if !in_guard {
1112                if trimmed == NON_TEMPLATED_LITERAL_GUARD_START {
1113                    in_guard = true;
1114                } else {
1115                    result.push_str(chunk);
1116                }
1117            } else if trimmed == NON_TEMPLATED_LITERAL_GUARD_END {
1118                in_guard = false;
1119            } else {
1120                result.push_str(chunk);
1121            }
1122        }
1123
1124        // If guard never closed, re-append the start marker and captured content to avoid dropping data.
1125        if in_guard {
1126            result.push_str(NON_TEMPLATED_LITERAL_GUARD_START);
1127        }
1128
1129        result
1130    }
1131
1132    /// Render a Markdown template with the given context.
1133    ///
1134    /// This method supports recursive template rendering where project files
1135    /// can reference other project files using the `content` filter.
1136    /// Rendering continues up to [`filters::MAX_RENDER_DEPTH`] levels deep.
1137    ///
1138    /// # Arguments
1139    ///
1140    /// * `template_content` - The raw Markdown template content
1141    /// * `context` - The template context containing variables
1142    ///
1143    /// # Returns
1144    ///
1145    /// Returns the rendered Markdown content.
1146    ///
1147    /// # Errors
1148    ///
1149    /// Returns an error if:
1150    /// - Template syntax is invalid
1151    /// - Context variables are missing
1152    /// - Custom functions/filters fail
1153    /// - Recursive rendering exceeds maximum depth (10 levels)
1154    ///
1155    /// # Literal Blocks
1156    ///
1157    /// Content wrapped in ```literal fences will be protected from
1158    /// template rendering and displayed literally:
1159    ///
1160    /// ````markdown
1161    /// ```literal
1162    /// {{ agpm.deps.snippets.example.content }}
1163    /// ```
1164    /// ````
1165    ///
1166    /// This is useful for documentation that shows template syntax examples.
1167    ///
1168    /// # Recursive Rendering
1169    ///
1170    /// When a template contains `content` filter references, those files
1171    /// may themselves contain template syntax. The renderer automatically
1172    /// detects this and performs multiple rendering passes until either:
1173    /// - No template syntax remains in the output
1174    /// - Maximum depth is reached (error)
1175    ///
1176    /// Example recursive template chain:
1177    /// ```markdown
1178    /// # Main Agent
1179    /// {{ 'docs/guide.md' | content }}
1180    /// ```
1181    ///
1182    /// Where `docs/guide.md` contains:
1183    /// ```markdown
1184    /// # Guide
1185    /// {{ 'docs/common.md' | content }}
1186    /// ```
1187    ///
1188    /// This will render up to 10 levels deep.
1189    pub fn render_template(
1190        &mut self,
1191        template_content: &str,
1192        context: &TeraContext,
1193    ) -> Result<String> {
1194        tracing::debug!("render_template called, enabled={}", self.enabled);
1195
1196        if !self.enabled {
1197            // If templating is disabled, return content as-is
1198            tracing::debug!("Templating disabled, returning content as-is");
1199            return Ok(template_content.to_string());
1200        }
1201
1202        // Step 1: Protect literal blocks before any rendering
1203        let (protected_content, placeholders) = self.protect_literal_blocks(template_content);
1204
1205        // Check if content contains template syntax (after protecting literals)
1206        if !self.contains_template_syntax(&protected_content) {
1207            // No template syntax found, restore literals and return
1208            tracing::debug!(
1209                "No template syntax found after protecting literals, returning content"
1210            );
1211            return Ok(self.restore_literal_blocks(&protected_content, placeholders));
1212        }
1213
1214        // Log the template context for debugging
1215        tracing::debug!("Rendering template with context");
1216        Self::log_context_as_kv(context);
1217
1218        // Step 2: Multi-pass rendering for recursive templates
1219        // This allows project files to reference other project files
1220        let mut current_content = protected_content;
1221        let mut depth = 0;
1222        let max_depth = filters::MAX_RENDER_DEPTH;
1223
1224        let rendered = loop {
1225            depth += 1;
1226
1227            // Check depth limit
1228            if depth > max_depth {
1229                bail!(
1230                    "Template rendering exceeded maximum recursion depth of {}. \
1231                     This usually indicates circular dependencies between project files. \
1232                     Please check your content filter references for cycles.",
1233                    max_depth
1234                );
1235            }
1236
1237            tracing::debug!("Rendering pass {} of max {}", depth, max_depth);
1238
1239            // Render the current content
1240            let rendered = self.tera.render_str(&current_content, context).map_err(|e| {
1241                // Extract detailed error information from Tera error
1242                let error_msg = Self::format_tera_error(&e);
1243
1244                // Output the detailed error to stderr for immediate visibility
1245                eprintln!("Template rendering error:\n{}", error_msg);
1246
1247                // Include the context in the error message for user visibility
1248                let context_str = Self::format_context_as_string(context);
1249                anyhow::Error::new(e).context(format!(
1250                    "Template rendering failed at depth {}:\n{}\n\nTemplate context:\n{}",
1251                    depth, error_msg, context_str
1252                ))
1253            })?;
1254
1255            // Check if the rendered output still contains template syntax OUTSIDE code fences
1256            // This prevents re-rendering template syntax that was embedded as code examples
1257            if !self.contains_template_syntax_outside_fences(&rendered) {
1258                // No more template syntax outside fences - we're done with rendering
1259                tracing::debug!("Template rendering complete after {} pass(es)", depth);
1260                break rendered;
1261            }
1262
1263            // More template syntax found outside fences - prepare for next iteration
1264            tracing::debug!("Template syntax detected in output, continuing to pass {}", depth + 1);
1265            current_content = rendered;
1266        };
1267
1268        // Step 3: Restore literal blocks after all rendering is complete
1269        let restored = self.restore_literal_blocks(&rendered, placeholders);
1270
1271        // Step 4: Collapse any literal guards that were added for non-templated dependencies
1272        Ok(Self::collapse_non_templated_literal_guards(restored))
1273    }
1274
1275    /// Format a Tera error with detailed information about what went wrong.
1276    ///
1277    /// Tera errors can contain various types of issues:
1278    /// - Missing variables (e.g., "Variable `foo` not found")
1279    /// - Syntax errors (e.g., "Unexpected end of template")
1280    /// - Filter/function errors (e.g., "Filter `unknown` not found")
1281    ///
1282    /// This function extracts the root cause and formats it in a user-friendly way,
1283    /// filtering out unhelpful internal template names like '__tera_one_off'.
1284    ///
1285    /// # Arguments
1286    ///
1287    /// * `error` - The Tera error to format
1288    fn format_tera_error(error: &tera::Error) -> String {
1289        use std::error::Error;
1290
1291        let mut messages = Vec::new();
1292
1293        // Walk the entire error chain and collect all messages
1294        let mut all_messages = vec![error.to_string()];
1295        let mut current_error: Option<&dyn Error> = error.source();
1296        while let Some(err) = current_error {
1297            all_messages.push(err.to_string());
1298            current_error = err.source();
1299        }
1300
1301        // Process messages to extract useful information
1302        for msg in all_messages {
1303            // Clean up the message by removing internal template names
1304            let cleaned = msg
1305                .replace("while rendering '__tera_one_off'", "")
1306                .replace("Failed to render '__tera_one_off'", "Template rendering failed")
1307                .replace("Failed to parse '__tera_one_off'", "Template syntax error")
1308                .replace("'__tera_one_off'", "template")
1309                .trim()
1310                .to_string();
1311
1312            // Only keep non-empty, useful messages
1313            if !cleaned.is_empty()
1314                && cleaned != "Template rendering failed"
1315                && cleaned != "Template syntax error"
1316            {
1317                messages.push(cleaned);
1318            }
1319        }
1320
1321        // If we got useful messages, return them
1322        if !messages.is_empty() {
1323            messages.join("\n  → ")
1324        } else {
1325            // Fallback: extract just the error kind
1326            "Template syntax error (see details above)".to_string()
1327        }
1328    }
1329
1330    /// Format the template context as a string for error messages.
1331    ///
1332    /// # Arguments
1333    ///
1334    /// * `context` - The Tera context to format
1335    fn format_context_as_string(context: &TeraContext) -> String {
1336        let context_clone = context.clone();
1337        let json_value = context_clone.into_json();
1338        let mut output = String::new();
1339
1340        // Recursively format the JSON structure with indentation
1341        fn format_value(key: &str, value: &serde_json::Value, indent: usize) -> Vec<String> {
1342            let prefix = "  ".repeat(indent);
1343            let mut lines = Vec::new();
1344
1345            match value {
1346                serde_json::Value::Object(map) => {
1347                    lines.push(format!("{}{}:", prefix, key));
1348                    for (k, v) in map {
1349                        lines.extend(format_value(k, v, indent + 1));
1350                    }
1351                }
1352                serde_json::Value::Array(arr) => {
1353                    lines.push(format!("{}{}: [{} items]", prefix, key, arr.len()));
1354                    // Only show first few items to avoid spam
1355                    for (i, item) in arr.iter().take(3).enumerate() {
1356                        lines.extend(format_value(&format!("[{}]", i), item, indent + 1));
1357                    }
1358                    if arr.len() > 3 {
1359                        lines.push(format!("{}  ... {} more items", prefix, arr.len() - 3));
1360                    }
1361                }
1362                serde_json::Value::String(s) => {
1363                    // Truncate long strings
1364                    if s.len() > 100 {
1365                        lines.push(format!(
1366                            "{}{}: \"{}...\" ({} chars)",
1367                            prefix,
1368                            key,
1369                            &s[..97],
1370                            s.len()
1371                        ));
1372                    } else {
1373                        lines.push(format!("{}{}: \"{}\"", prefix, key, s));
1374                    }
1375                }
1376                serde_json::Value::Number(n) => {
1377                    lines.push(format!("{}{}: {}", prefix, key, n));
1378                }
1379                serde_json::Value::Bool(b) => {
1380                    lines.push(format!("{}{}: {}", prefix, key, b));
1381                }
1382                serde_json::Value::Null => {
1383                    lines.push(format!("{}{}: null", prefix, key));
1384                }
1385            }
1386            lines
1387        }
1388
1389        if let serde_json::Value::Object(map) = &json_value {
1390            for (key, value) in map {
1391                output.push_str(&format_value(key, value, 1).join("\n"));
1392                output.push('\n');
1393            }
1394        }
1395
1396        output
1397    }
1398
1399    /// Log the template context as key-value pairs at debug level.
1400    ///
1401    /// # Arguments
1402    ///
1403    /// * `context` - The Tera context to log
1404    fn log_context_as_kv(context: &TeraContext) {
1405        let formatted = Self::format_context_as_string(context);
1406        for line in formatted.lines() {
1407            tracing::debug!("{}", line);
1408        }
1409    }
1410
1411    /// Check if content contains Tera template syntax.
1412    ///
1413    /// # Arguments
1414    ///
1415    /// * `content` - The content to check
1416    ///
1417    /// # Returns
1418    ///
1419    /// Returns `true` if the content contains template delimiters.
1420    fn contains_template_syntax(&self, content: &str) -> bool {
1421        let has_vars = content.contains("{{");
1422        let has_tags = content.contains("{%");
1423        let has_comments = content.contains("{#");
1424        let result = has_vars || has_tags || has_comments;
1425        tracing::debug!(
1426            "Template syntax check: vars={}, tags={}, comments={}, result={}",
1427            has_vars,
1428            has_tags,
1429            has_comments,
1430            result
1431        );
1432        result
1433    }
1434
1435    /// Check if content contains template syntax outside of code fences.
1436    ///
1437    /// This is used after rendering to determine if another pass is needed.
1438    /// It ignores template syntax inside code fences to prevent re-rendering
1439    /// content that has already been processed (like embedded dependency content).
1440    fn contains_template_syntax_outside_fences(&self, content: &str) -> bool {
1441        let mut in_code_fence = false;
1442        let mut in_guard = 0usize;
1443
1444        for line in content.lines() {
1445            let trimmed = line.trim();
1446
1447            if trimmed == NON_TEMPLATED_LITERAL_GUARD_START {
1448                in_guard = in_guard.saturating_add(1);
1449                continue;
1450            } else if trimmed == NON_TEMPLATED_LITERAL_GUARD_END {
1451                in_guard = in_guard.saturating_sub(1);
1452                continue;
1453            }
1454
1455            if in_guard > 0 {
1456                continue;
1457            }
1458
1459            // Track code fence boundaries
1460            if trimmed.starts_with("```") {
1461                in_code_fence = !in_code_fence;
1462                continue;
1463            }
1464
1465            // Skip lines inside code fences
1466            if in_code_fence {
1467                continue;
1468            }
1469
1470            // Check for template syntax in non-fenced content
1471            if line.contains("{{") || line.contains("{%") || line.contains("{#") {
1472                tracing::debug!(
1473                    "Template syntax found outside code fences: {:?}",
1474                    &line[..line.len().min(80)]
1475                );
1476                return true;
1477            }
1478        }
1479
1480        tracing::debug!("No template syntax found outside code fences");
1481        false
1482    }
1483}
1484
1485#[cfg(test)]
1486mod tests {
1487    use super::*;
1488    use crate::lockfile::{LockFile, LockedResource};
1489
1490    fn create_test_lockfile() -> LockFile {
1491        let mut lockfile = LockFile::default();
1492
1493        // Add a test agent
1494        lockfile.agents.push(LockedResource {
1495            name: "test-agent".to_string(),
1496            source: Some("community".to_string()),
1497            url: Some("https://github.com/example/community.git".to_string()),
1498            path: "agents/test-agent.md".to_string(),
1499            version: Some("v1.0.0".to_string()),
1500            resolved_commit: Some("abc123def456".to_string()),
1501            checksum: "sha256:testchecksum".to_string(),
1502            installed_at: ".claude/agents/test-agent.md".to_string(),
1503            dependencies: vec![],
1504            resource_type: ResourceType::Agent,
1505            tool: Some("claude-code".to_string()),
1506            manifest_alias: None,
1507            applied_patches: std::collections::HashMap::new(),
1508            install: None,
1509        });
1510
1511        lockfile
1512    }
1513
1514    #[tokio::test]
1515    async fn test_template_context_builder() {
1516        let lockfile = create_test_lockfile();
1517
1518        let cache = crate::cache::Cache::new().unwrap();
1519        let project_dir = std::env::current_dir().unwrap();
1520        let builder =
1521            TemplateContextBuilder::new(Arc::new(lockfile), None, Arc::new(cache), project_dir);
1522
1523        let _context = builder.build_context("test-agent", ResourceType::Agent).await.unwrap();
1524
1525        // If we got here without panicking, context building succeeded
1526        // The actual context structure is tested implicitly by the renderer tests
1527    }
1528
1529    #[test]
1530    fn test_template_renderer() {
1531        let project_dir = std::env::current_dir().unwrap();
1532        let mut renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
1533
1534        // Test rendering without template syntax
1535        let result = renderer.render_template("# Plain Markdown", &TeraContext::new()).unwrap();
1536        assert_eq!(result, "# Plain Markdown");
1537
1538        // Test rendering with template syntax
1539        let mut context = TeraContext::new();
1540        context.insert("test_var", "test_value");
1541
1542        let result = renderer.render_template("# {{ test_var }}", &context).unwrap();
1543        assert_eq!(result, "# test_value");
1544    }
1545
1546    #[test]
1547    fn test_template_renderer_disabled() {
1548        let project_dir = std::env::current_dir().unwrap();
1549        let mut renderer = TemplateRenderer::new(false, project_dir, None).unwrap();
1550
1551        let mut context = TeraContext::new();
1552        context.insert("test_var", "test_value");
1553
1554        // Should return content as-is when disabled
1555        let result = renderer.render_template("# {{ test_var }}", &context).unwrap();
1556        assert_eq!(result, "# {{ test_var }}");
1557    }
1558
1559    #[test]
1560    fn test_template_error_formatting() {
1561        let project_dir = std::env::current_dir().unwrap();
1562        let mut renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
1563        let context = TeraContext::new();
1564
1565        // Test with missing variable - should produce detailed error
1566        let result = renderer.render_template("# {{ missing_var }}", &context);
1567        assert!(result.is_err());
1568
1569        let error = result.unwrap_err();
1570        let error_msg = format!("{}", error);
1571
1572        // Error should NOT contain "__tera_one_off"
1573        assert!(
1574            !error_msg.contains("__tera_one_off"),
1575            "Error should not expose internal Tera template names"
1576        );
1577
1578        // Error should contain useful information about the missing variable
1579        assert!(
1580            error_msg.contains("missing_var") || error_msg.contains("Variable"),
1581            "Error should mention the problematic variable or that a variable is missing. Got: {}",
1582            error_msg
1583        );
1584    }
1585
1586    #[test]
1587    fn test_to_native_path_display() {
1588        // Test Unix-style path conversion
1589        let unix_path = ".claude/agents/test.md";
1590        let native_path = to_native_path_display(unix_path);
1591
1592        #[cfg(windows)]
1593        {
1594            assert_eq!(native_path, ".claude\\agents\\test.md");
1595        }
1596
1597        #[cfg(not(windows))]
1598        {
1599            assert_eq!(native_path, ".claude/agents/test.md");
1600        }
1601    }
1602
1603    #[test]
1604    fn test_to_native_path_display_nested() {
1605        // Test deeply nested path
1606        let unix_path = ".claude/agents/ai/helpers/test.md";
1607        let native_path = to_native_path_display(unix_path);
1608
1609        #[cfg(windows)]
1610        {
1611            assert_eq!(native_path, ".claude\\agents\\ai\\helpers\\test.md");
1612        }
1613
1614        #[cfg(not(windows))]
1615        {
1616            assert_eq!(native_path, ".claude/agents/ai/helpers/test.md");
1617        }
1618    }
1619
1620    #[tokio::test]
1621    async fn test_template_context_uses_native_paths() {
1622        let mut lockfile = create_test_lockfile();
1623
1624        // Add another resource with a nested path
1625        lockfile.snippets.push(LockedResource {
1626            name: "test-snippet".to_string(),
1627            source: Some("community".to_string()),
1628            url: Some("https://github.com/example/community.git".to_string()),
1629            path: "snippets/utils/test.md".to_string(),
1630            version: Some("v1.0.0".to_string()),
1631            resolved_commit: Some("abc123def456".to_string()),
1632            checksum: "sha256:testchecksum".to_string(),
1633            installed_at: ".agpm/snippets/utils/test.md".to_string(),
1634            dependencies: vec![],
1635            resource_type: ResourceType::Snippet,
1636            tool: Some("agpm".to_string()),
1637            manifest_alias: None,
1638            applied_patches: std::collections::HashMap::new(),
1639            install: None,
1640        });
1641
1642        let cache = crate::cache::Cache::new().unwrap();
1643        let project_dir = std::env::current_dir().unwrap();
1644        let builder =
1645            TemplateContextBuilder::new(Arc::new(lockfile), None, Arc::new(cache), project_dir);
1646        let context = builder.build_context("test-agent", ResourceType::Agent).await.unwrap();
1647
1648        // Extract the agpm.resource.install_path from context
1649        let agpm_value = context.get("agpm").expect("agpm context should exist");
1650        let agpm_obj = agpm_value.as_object().expect("agpm should be an object");
1651        let resource_value = agpm_obj.get("resource").expect("resource should exist");
1652        let resource_obj = resource_value.as_object().expect("resource should be an object");
1653        let install_path = resource_obj
1654            .get("install_path")
1655            .expect("install_path should exist")
1656            .as_str()
1657            .expect("install_path should be a string");
1658
1659        // Verify the path uses platform-native separators
1660        #[cfg(windows)]
1661        {
1662            assert_eq!(install_path, ".claude\\agents\\test-agent.md");
1663            assert!(install_path.contains('\\'), "Windows paths should use backslashes");
1664        }
1665
1666        #[cfg(not(windows))]
1667        {
1668            assert_eq!(install_path, ".claude/agents/test-agent.md");
1669            assert!(install_path.contains('/'), "Unix paths should use forward slashes");
1670        }
1671
1672        // Also verify dependency paths
1673        let deps_value = agpm_obj.get("deps").expect("deps should exist");
1674        let deps_obj = deps_value.as_object().expect("deps should be an object");
1675        let snippets = deps_obj.get("snippets").expect("snippets should exist");
1676        let snippets_obj = snippets.as_object().expect("snippets should be an object");
1677        let test_snippet = snippets_obj.get("test_snippet").expect("test_snippet should exist");
1678        let snippet_obj = test_snippet.as_object().expect("test_snippet should be an object");
1679        let snippet_path = snippet_obj
1680            .get("install_path")
1681            .expect("install_path should exist")
1682            .as_str()
1683            .expect("install_path should be a string");
1684
1685        #[cfg(windows)]
1686        {
1687            assert_eq!(snippet_path, ".agpm\\snippets\\utils\\test.md");
1688        }
1689
1690        #[cfg(not(windows))]
1691        {
1692            assert_eq!(snippet_path, ".agpm/snippets/utils/test.md");
1693        }
1694    }
1695
1696    // Tests for literal block functionality (Phase 1)
1697
1698    #[test]
1699    fn test_protect_literal_blocks_basic() {
1700        let project_dir = std::env::current_dir().unwrap();
1701        let renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
1702
1703        let content = r#"# Documentation
1704
1705Use this syntax:
1706
1707```literal
1708{{ agpm.deps.snippets.example.content }}
1709```
1710
1711That's how you embed content."#;
1712
1713        let (protected, placeholders) = renderer.protect_literal_blocks(content);
1714
1715        // Should have one placeholder
1716        assert_eq!(placeholders.len(), 1);
1717
1718        // Protected content should contain placeholder
1719        assert!(protected.contains("__AGPM_LITERAL_BLOCK_0__"));
1720
1721        // Protected content should NOT contain the template syntax
1722        assert!(!protected.contains("{{ agpm.deps.snippets.example.content }}"));
1723
1724        // Placeholder should contain the original content
1725        let placeholder_content = placeholders.get("__AGPM_LITERAL_BLOCK_0__").unwrap();
1726        assert!(placeholder_content.contains("{{ agpm.deps.snippets.example.content }}"));
1727    }
1728
1729    #[test]
1730    fn test_protect_literal_blocks_multiple() {
1731        let project_dir = std::env::current_dir().unwrap();
1732        let renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
1733
1734        let content = r#"# First Example
1735
1736```literal
1737{{ first.example }}
1738```
1739
1740# Second Example
1741
1742```literal
1743{{ second.example }}
1744```"#;
1745
1746        let (protected, placeholders) = renderer.protect_literal_blocks(content);
1747
1748        // Should have two placeholders
1749        assert_eq!(placeholders.len(), 2);
1750
1751        // Both placeholders should be in the protected content
1752        assert!(protected.contains("__AGPM_LITERAL_BLOCK_0__"));
1753        assert!(protected.contains("__AGPM_LITERAL_BLOCK_1__"));
1754
1755        // Original template syntax should not be in protected content
1756        assert!(!protected.contains("{{ first.example }}"));
1757        assert!(!protected.contains("{{ second.example }}"));
1758    }
1759
1760    #[test]
1761    fn test_restore_literal_blocks() {
1762        let project_dir = std::env::current_dir().unwrap();
1763        let renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
1764
1765        let mut placeholders = HashMap::new();
1766        placeholders.insert(
1767            "__AGPM_LITERAL_BLOCK_0__".to_string(),
1768            "{{ agpm.deps.snippets.example.content }}".to_string(),
1769        );
1770
1771        let content = "# Example\n\n__AGPM_LITERAL_BLOCK_0__\n\nDone.";
1772        let restored = renderer.restore_literal_blocks(content, placeholders);
1773
1774        // Should contain the original content in a code fence
1775        assert!(restored.contains("```\n{{ agpm.deps.snippets.example.content }}\n```"));
1776
1777        // Should NOT contain the placeholder
1778        assert!(!restored.contains("__AGPM_LITERAL_BLOCK_0__"));
1779    }
1780
1781    #[test]
1782    fn test_literal_blocks_integration_with_rendering() {
1783        let project_dir = std::env::current_dir().unwrap();
1784        let mut renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
1785
1786        let template = r#"# Agent: {{ agent_name }}
1787
1788## Documentation
1789
1790Here's how to use template syntax:
1791
1792```literal
1793{{ agpm.deps.snippets.helper.content }}
1794```
1795
1796The agent name is: {{ agent_name }}"#;
1797
1798        let mut context = TeraContext::new();
1799        context.insert("agent_name", "test-agent");
1800
1801        let result = renderer.render_template(template, &context).unwrap();
1802
1803        // The agent_name variable should be rendered
1804        assert!(result.contains("# Agent: test-agent"));
1805        assert!(result.contains("The agent name is: test-agent"));
1806
1807        // The literal block should be preserved and wrapped in code fence
1808        assert!(result.contains("```\n{{ agpm.deps.snippets.helper.content }}\n```"));
1809
1810        // The literal block should NOT be rendered (still has template syntax)
1811        assert!(result.contains("{{ agpm.deps.snippets.helper.content }}"));
1812    }
1813
1814    #[test]
1815    fn test_literal_blocks_with_complex_template_syntax() {
1816        let project_dir = std::env::current_dir().unwrap();
1817        let mut renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
1818
1819        let template = r#"# Documentation
1820
1821```literal
1822{% for item in agpm.deps.agents %}
1823{{ item.name }}: {{ item.version }}
1824{% endfor %}
1825```"#;
1826
1827        let context = TeraContext::new();
1828        let result = renderer.render_template(template, &context).unwrap();
1829
1830        // Should preserve the for loop syntax
1831        assert!(result.contains("{% for item in agpm.deps.agents %}"));
1832        assert!(result.contains("{{ item.name }}"));
1833        assert!(result.contains("{% endfor %}"));
1834    }
1835
1836    #[test]
1837    fn test_literal_blocks_empty() {
1838        let project_dir = std::env::current_dir().unwrap();
1839        let mut renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
1840
1841        let template = r#"# Example
1842
1843```literal
1844```
1845
1846Done."#;
1847
1848        let context = TeraContext::new();
1849        let result = renderer.render_template(template, &context).unwrap();
1850
1851        // Should handle empty literal blocks gracefully
1852        assert!(result.contains("# Example"));
1853        assert!(result.contains("Done."));
1854    }
1855
1856    #[test]
1857    fn test_literal_blocks_unclosed() {
1858        let project_dir = std::env::current_dir().unwrap();
1859        let renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
1860
1861        let content = r#"# Example
1862
1863```literal
1864{{ template.syntax }}
1865This block is not closed"#;
1866
1867        let (protected, placeholders) = renderer.protect_literal_blocks(content);
1868
1869        // Should have no placeholders (unclosed block is treated as regular content)
1870        assert_eq!(placeholders.len(), 0);
1871
1872        // Content should be preserved as-is
1873        assert!(protected.contains("```literal"));
1874        assert!(protected.contains("{{ template.syntax }}"));
1875    }
1876
1877    #[test]
1878    fn test_literal_blocks_with_indentation() {
1879        let project_dir = std::env::current_dir().unwrap();
1880        let renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
1881
1882        let content = r#"# Example
1883
1884    ```literal
1885    {{ indented.template }}
1886    ```"#;
1887
1888        let (_protected, placeholders) = renderer.protect_literal_blocks(content);
1889
1890        // Should detect indented literal blocks
1891        assert_eq!(placeholders.len(), 1);
1892
1893        // Should preserve the indented template syntax
1894        let placeholder_content = placeholders.get("__AGPM_LITERAL_BLOCK_0__").unwrap();
1895        assert!(placeholder_content.contains("{{ indented.template }}"));
1896    }
1897
1898    #[test]
1899    fn test_literal_blocks_in_transitive_dependency_content() {
1900        use std::fs;
1901        use tempfile::TempDir;
1902
1903        let temp_dir = TempDir::new().unwrap();
1904        let project_dir = temp_dir.path().to_path_buf();
1905
1906        // Create a dependency file with literal blocks containing template syntax
1907        let dep_content = r#"---
1908agpm.templating: true
1909---
1910# Dependency Documentation
1911
1912Here's a template example:
1913
1914```literal
1915{{ nonexistent_variable }}
1916{{ agpm.deps.something.else }}
1917```
1918
1919This should appear literally."#;
1920
1921        // Write the dependency file
1922        let dep_path = project_dir.join("dependency.md");
1923        fs::write(&dep_path, dep_content).unwrap();
1924
1925        // First, render the dependency content (simulating what happens when processing a dependency)
1926        let mut dep_renderer = TemplateRenderer::new(true, project_dir.clone(), None).unwrap();
1927        let dep_context = TeraContext::new();
1928        let rendered_dep = dep_renderer.render_template(dep_content, &dep_context).unwrap();
1929
1930        // The rendered dependency should have the literal block converted to a regular code fence
1931        assert!(rendered_dep.contains("```\n{{ nonexistent_variable }}"));
1932        assert!(rendered_dep.contains("{{ agpm.deps.something.else }}\n```"));
1933
1934        // Now simulate embedding this in a parent resource
1935        let parent_template = r#"# Parent Resource
1936
1937## Embedded Documentation
1938
1939{{ dependency_content }}
1940
1941## End"#;
1942
1943        // Create context with the rendered dependency content
1944        let mut parent_context = TeraContext::new();
1945        parent_context.insert("dependency_content", &rendered_dep);
1946
1947        // Render the parent (with templating enabled)
1948        let mut parent_renderer = TemplateRenderer::new(true, project_dir.clone(), None).unwrap();
1949        let final_output =
1950            parent_renderer.render_template(parent_template, &parent_context).unwrap();
1951
1952        // Verify the final output contains the template syntax literally
1953        assert!(
1954            final_output.contains("{{ nonexistent_variable }}"),
1955            "Template syntax from literal block should appear literally in final output"
1956        );
1957        assert!(
1958            final_output.contains("{{ agpm.deps.something.else }}"),
1959            "Template syntax from literal block should appear literally in final output"
1960        );
1961
1962        // Verify it's in a code fence
1963        assert!(
1964            final_output.contains("```\n{{ nonexistent_variable }}"),
1965            "Literal content should be in a code fence"
1966        );
1967
1968        // Verify it doesn't cause rendering errors
1969        assert!(!final_output.contains("__AGPM_LITERAL_BLOCK_"), "No placeholders should remain");
1970    }
1971
1972    #[test]
1973    fn test_literal_blocks_with_nested_dependencies() {
1974        let project_dir = std::env::current_dir().unwrap();
1975        let mut renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
1976
1977        // Simulate a dependency that was already rendered with literal blocks
1978        let dep_content = r#"# Helper Snippet
1979
1980Use this syntax:
1981
1982```
1983{{ agpm.deps.snippets.example.content }}
1984{{ missing.variable }}
1985```
1986
1987Done."#;
1988
1989        // Now embed this in a parent template
1990        let parent_template = r#"# Main Agent
1991
1992## Documentation
1993
1994{{ helper_content }}
1995
1996The agent uses templating."#;
1997
1998        let mut context = TeraContext::new();
1999        context.insert("helper_content", dep_content);
2000
2001        let result = renderer.render_template(parent_template, &context).unwrap();
2002
2003        // The template syntax from the dependency should be preserved
2004        assert!(result.contains("{{ agpm.deps.snippets.example.content }}"));
2005        assert!(result.contains("{{ missing.variable }}"));
2006
2007        // It should be in a code fence
2008        assert!(result.contains("```\n{{ agpm.deps.snippets.example.content }}"));
2009    }
2010
2011    #[tokio::test]
2012    async fn test_non_templated_dependency_content_is_guarded() {
2013        use tempfile::TempDir;
2014        use tokio::fs;
2015
2016        let temp_dir = TempDir::new().unwrap();
2017        let project_dir = temp_dir.path().to_path_buf();
2018
2019        let snippets_dir = project_dir.join("snippets");
2020        fs::create_dir_all(&snippets_dir).await.unwrap();
2021        let snippet_path = snippets_dir.join("non-templated.md");
2022        fs::write(
2023            &snippet_path,
2024            r#"---
2025agpm:
2026  templating: false
2027---
2028# Example Snippet
2029
2030This should show {{ agpm.deps.some.content }} literally.
2031"#,
2032        )
2033        .await
2034        .unwrap();
2035
2036        let mut lockfile = LockFile::default();
2037        lockfile.commands.push(LockedResource {
2038            name: "test-command".to_string(),
2039            source: None,
2040            url: None,
2041            path: "commands/test.md".to_string(),
2042            version: None,
2043            resolved_commit: None,
2044            checksum: "sha256:test-command".to_string(),
2045            installed_at: ".claude/commands/test.md".to_string(),
2046            dependencies: vec![],
2047            resource_type: ResourceType::Command,
2048            tool: Some("claude-code".to_string()),
2049            manifest_alias: None,
2050            applied_patches: std::collections::HashMap::new(),
2051            install: None,
2052        });
2053        lockfile.snippets.push(LockedResource {
2054            name: "non_templated".to_string(),
2055            source: None,
2056            url: None,
2057            path: "snippets/non-templated.md".to_string(),
2058            version: None,
2059            resolved_commit: None,
2060            checksum: "sha256:test-snippet".to_string(),
2061            installed_at: ".agpm/snippets/non-templated.md".to_string(),
2062            dependencies: vec![],
2063            resource_type: ResourceType::Snippet,
2064            tool: Some("agpm".to_string()),
2065            manifest_alias: None,
2066            applied_patches: std::collections::HashMap::new(),
2067            install: None,
2068        });
2069
2070        let cache = crate::cache::Cache::new().unwrap();
2071        let builder = TemplateContextBuilder::new(
2072            Arc::new(lockfile),
2073            None,
2074            Arc::new(cache),
2075            project_dir.clone(),
2076        );
2077        let context = builder.build_context("test-command", ResourceType::Command).await.unwrap();
2078
2079        let mut renderer = TemplateRenderer::new(true, project_dir.clone(), None).unwrap();
2080        let template = r#"# Combined Output
2081
2082{{ agpm.deps.snippets.non_templated.content }}
2083"#;
2084        let rendered = renderer.render_template(template, &context).unwrap();
2085
2086        assert!(
2087            rendered.contains("# Example Snippet"),
2088            "Rendered output should include the snippet heading"
2089        );
2090        assert!(
2091            rendered.contains("{{ agpm.deps.some.content }}"),
2092            "Template syntax inside non-templated dependency should remain literal"
2093        );
2094        assert!(
2095            !rendered.contains(NON_TEMPLATED_LITERAL_GUARD_START)
2096                && !rendered.contains(NON_TEMPLATED_LITERAL_GUARD_END),
2097            "Internal literal guard markers should not leak into rendered output"
2098        );
2099        assert!(
2100            !rendered.contains("```literal"),
2101            "Synthetic literal fences should be removed after rendering"
2102        );
2103    }
2104}