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