pub mod filters;
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use serde_json::{Map, to_string, to_value};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tera::{Context as TeraContext, Tera};
use crate::core::ResourceType;
use crate::lockfile::LockFile;
const NON_TEMPLATED_LITERAL_GUARD_START: &str = "__AGPM_LITERAL_RAW_START__";
const NON_TEMPLATED_LITERAL_GUARD_END: &str = "__AGPM_LITERAL_RAW_END__";
pub fn to_native_path_display(unix_path: &str) -> String {
#[cfg(windows)]
{
unix_path.replace('/', "\\")
}
#[cfg(not(windows))]
{
unix_path.to_string()
}
}
pub struct TemplateContextBuilder {
lockfile: Arc<LockFile>,
project_config: Option<crate::manifest::ProjectConfig>,
cache: Arc<crate::cache::Cache>,
project_dir: PathBuf,
}
pub struct TemplateRenderer {
tera: Tera,
enabled: bool,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct ResourceTemplateData {
#[serde(rename = "type")]
pub resource_type: String,
pub name: String,
pub install_path: String,
pub source: Option<String>,
pub version: Option<String>,
pub resolved_commit: Option<String>,
pub checksum: String,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
}
impl std::fmt::Debug for ResourceTemplateData {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ResourceTemplateData")
.field("resource_type", &self.resource_type)
.field("name", &self.name)
.field("install_path", &self.install_path)
.field("source", &self.source)
.field("version", &self.version)
.field("resolved_commit", &self.resolved_commit)
.field("checksum", &self.checksum)
.field("path", &self.path)
.field("content", &self.content.as_ref().map(|c| format!("<{} bytes>", c.len())))
.finish()
}
}
impl TemplateContextBuilder {
pub fn new(
lockfile: Arc<LockFile>,
project_config: Option<crate::manifest::ProjectConfig>,
cache: Arc<crate::cache::Cache>,
project_dir: PathBuf,
) -> Self {
Self {
lockfile,
project_config,
cache,
project_dir,
}
}
pub async fn build_context(
&self,
resource_name: &str,
resource_type: ResourceType,
) -> Result<TeraContext> {
let mut context = TeraContext::new();
let mut agpm = Map::new();
let resource_data = self.build_resource_data(resource_name, resource_type)?;
agpm.insert("resource".to_string(), to_value(resource_data)?);
let deps_data = self.build_dependencies_data().await?;
agpm.insert("deps".to_string(), to_value(deps_data)?);
if let Some(ref project_config) = self.project_config {
let project_json = project_config.to_json_value();
agpm.insert("project".to_string(), project_json);
}
context.insert("agpm", &agpm);
Ok(context)
}
fn build_resource_data(
&self,
resource_name: &str,
resource_type: ResourceType,
) -> Result<ResourceTemplateData> {
let entry =
self.lockfile.find_resource(resource_name, resource_type).with_context(|| {
format!(
"Resource '{}' of type {:?} not found in lockfile",
resource_name, resource_type
)
})?;
Ok(ResourceTemplateData {
resource_type: resource_type.to_string(),
name: resource_name.to_string(),
install_path: to_native_path_display(&entry.installed_at),
source: entry.source.clone(),
version: entry.version.clone(),
resolved_commit: entry.resolved_commit.clone(),
checksum: entry.checksum.clone(),
path: entry.path.clone(),
content: None, })
}
async fn extract_content(&self, resource: &crate::lockfile::LockedResource) -> Option<String> {
tracing::debug!(
"Attempting to extract content for resource '{}' (type: {:?})",
resource.name,
resource.resource_type
);
let source_path = if let Some(source_name) = &resource.source {
let url = resource.url.as_ref()?;
let is_local_source = resource.resolved_commit.as_deref().is_none_or(str::is_empty);
tracing::debug!(
"Resource '{}': source='{}', url='{}', is_local={}",
resource.name,
source_name,
url,
is_local_source
);
if is_local_source {
let path = std::path::PathBuf::from(url).join(&resource.path);
tracing::debug!("Using local source path: {}", path.display());
path
} else {
let sha = resource.resolved_commit.as_deref()?;
tracing::debug!(
"Resource '{}': Getting worktree for SHA {}...",
resource.name,
&sha[..8.min(sha.len())]
);
let worktree_dir = match self.cache.get_worktree_path(url, sha) {
Ok(path) => {
tracing::debug!("Worktree path: {}", path.display());
path
}
Err(e) => {
tracing::warn!(
"Failed to construct worktree path for resource '{}': {}",
resource.name,
e
);
return None;
}
};
let full_path = worktree_dir.join(&resource.path);
tracing::debug!(
"Full source path for '{}': {} (worktree exists: {})",
resource.name,
full_path.display(),
worktree_dir.exists()
);
full_path
}
} else {
let local_path = std::path::Path::new(&resource.path);
let resolved_path = if local_path.is_absolute() {
local_path.to_path_buf()
} else {
self.project_dir.join(local_path)
};
tracing::debug!(
"Resource '{}': Using local file path: {}",
resource.name,
resolved_path.display()
);
resolved_path
};
let content = match tokio::fs::read_to_string(&source_path).await {
Ok(c) => c,
Err(e) => {
tracing::warn!(
"Failed to read content for resource '{}' from {}: {}",
resource.name,
source_path.display(),
e
);
return None;
}
};
let processed_content = if resource.path.ends_with(".md") {
match crate::markdown::MarkdownDocument::parse(&content) {
Ok(doc) => {
let templating_enabled =
Self::is_markdown_templating_enabled(doc.metadata.as_ref());
let mut stripped_content = doc.content;
if !templating_enabled
&& Self::content_contains_template_syntax(&stripped_content)
{
tracing::debug!(
"Protecting non-templated markdown content for '{}'",
resource.name
);
stripped_content = Self::wrap_content_in_literal_guard(stripped_content);
}
stripped_content
}
Err(e) => {
tracing::warn!(
"Failed to parse markdown for resource '{}': {}. Using raw content.",
resource.name,
e
);
content
}
}
} else if resource.path.ends_with(".json") {
match serde_json::from_str::<serde_json::Value>(&content) {
Ok(mut json) => {
if let Some(obj) = json.as_object_mut() {
obj.remove("dependencies");
}
serde_json::to_string_pretty(&json).unwrap_or(content)
}
Err(e) => {
tracing::warn!(
"Failed to parse JSON for resource '{}': {}. Using raw content.",
resource.name,
e
);
content
}
}
} else {
content
};
Some(processed_content)
}
fn is_markdown_templating_enabled(
metadata: Option<&crate::markdown::MarkdownMetadata>,
) -> bool {
metadata
.and_then(|md| md.extra.get("agpm"))
.and_then(|agpm| agpm.as_object())
.and_then(|agpm_obj| agpm_obj.get("templating"))
.and_then(|value| value.as_bool())
.unwrap_or(false)
}
fn content_contains_template_syntax(content: &str) -> bool {
content.contains("{{") || content.contains("{%") || content.contains("{#")
}
fn wrap_content_in_literal_guard(content: String) -> String {
let mut wrapped = String::with_capacity(
content.len()
+ NON_TEMPLATED_LITERAL_GUARD_START.len()
+ NON_TEMPLATED_LITERAL_GUARD_END.len()
+ 2, );
wrapped.push_str(NON_TEMPLATED_LITERAL_GUARD_START);
wrapped.push('\n');
wrapped.push_str(&content);
if !content.ends_with('\n') {
wrapped.push('\n');
}
wrapped.push_str(NON_TEMPLATED_LITERAL_GUARD_END);
wrapped
}
async fn build_dependencies_data(
&self,
) -> Result<HashMap<String, HashMap<String, ResourceTemplateData>>> {
let mut deps = HashMap::new();
for resource_type in [
ResourceType::Agent,
ResourceType::Snippet,
ResourceType::Command,
ResourceType::Script,
ResourceType::Hook,
ResourceType::McpServer,
] {
let type_str_plural = resource_type.to_plural().to_string();
let type_str_singular = resource_type.to_string();
let mut type_deps = HashMap::new();
let resources = self.lockfile.get_resources_by_type(resource_type);
for resource in resources {
let content = self.extract_content(resource).await;
let template_data = ResourceTemplateData {
resource_type: type_str_singular.clone(),
name: resource.name.clone(),
install_path: to_native_path_display(&resource.installed_at),
source: resource.source.clone(),
version: resource.version.clone(),
resolved_commit: resource.resolved_commit.clone(),
checksum: resource.checksum.clone(),
path: resource.path.clone(),
content,
};
let key_name = if let Some(alias) = &resource.manifest_alias {
alias.clone()
} else if resource.name.contains('/') || resource.name.contains('\\') {
std::path::Path::new(&resource.name)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(&resource.name)
.to_string()
} else {
resource.name.clone()
};
let sanitized_key = key_name.replace('-', "_");
type_deps.insert(sanitized_key, template_data);
}
if !type_deps.is_empty() {
deps.insert(type_str_plural, type_deps);
}
}
tracing::debug!("Built dependencies data with {} resource types", deps.len());
for (resource_type, resources) in &deps {
tracing::debug!(" Type {}: {} resources", resource_type, resources.len());
for name in resources.keys() {
tracing::debug!(" - {}", name);
}
}
Ok(deps)
}
pub fn compute_context_digest(&self) -> Result<String> {
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
let mut digest_data: BTreeMap<String, BTreeMap<String, BTreeMap<&str, String>>> =
BTreeMap::new();
for resource_type in [
ResourceType::Agent,
ResourceType::Snippet,
ResourceType::Command,
ResourceType::Script,
ResourceType::Hook,
ResourceType::McpServer,
] {
let resources = self.lockfile.get_resources_by_type(resource_type);
if resources.is_empty() {
continue;
}
let type_str = resource_type.to_plural().to_string();
let mut sorted_resources: Vec<_> = resources.iter().collect();
sorted_resources.sort_by(|a, b| a.name.cmp(&b.name));
let mut type_data = BTreeMap::new();
for resource in sorted_resources {
let mut resource_data: BTreeMap<&str, String> = BTreeMap::new();
resource_data.insert("name", resource.name.clone());
resource_data.insert("install_path", resource.installed_at.clone());
resource_data.insert("path", resource.path.clone());
resource_data.insert("checksum", resource.checksum.clone());
if let Some(ref source) = resource.source {
resource_data.insert("source", source.to_string());
}
if let Some(ref version) = resource.version {
resource_data.insert("version", version.to_string());
}
if let Some(ref commit) = resource.resolved_commit {
resource_data.insert("resolved_commit", commit.to_string());
}
type_data.insert(resource.name.clone(), resource_data);
}
digest_data.insert(type_str, type_data);
}
let json_str =
to_string(&digest_data).context("Failed to serialize template context for digest")?;
let mut hasher = Sha256::new();
hasher.update(json_str.as_bytes());
let hash = hasher.finalize();
Ok(hex::encode(&hash[..8]))
}
}
impl TemplateRenderer {
pub fn new(
enabled: bool,
project_dir: PathBuf,
max_content_file_size: Option<u64>,
) -> Result<Self> {
let mut tera = Tera::default();
tera.register_filter(
"content",
filters::create_content_filter(project_dir.clone(), max_content_file_size),
);
Ok(Self {
tera,
enabled,
})
}
fn protect_literal_blocks(&self, content: &str) -> (String, HashMap<String, String>) {
let mut placeholders = HashMap::new();
let mut counter = 0;
let mut result = String::with_capacity(content.len());
let mut in_literal_block = false;
let mut current_block = String::new();
let lines = content.lines();
for line in lines {
if line.trim().starts_with("```literal") {
in_literal_block = true;
current_block.clear();
tracing::debug!("Found start of literal block");
continue; } else if in_literal_block && line.trim().starts_with("```") {
in_literal_block = false;
let placeholder_id = format!("__AGPM_LITERAL_BLOCK_{}__", counter);
counter += 1;
placeholders.insert(placeholder_id.clone(), current_block.clone());
result.push_str(&placeholder_id);
result.push('\n');
tracing::debug!(
"Protected literal block with placeholder {} ({} bytes)",
placeholder_id,
current_block.len()
);
current_block.clear();
continue; } else if in_literal_block {
if !current_block.is_empty() {
current_block.push('\n');
}
current_block.push_str(line);
} else {
result.push_str(line);
result.push('\n');
}
}
if in_literal_block {
tracing::warn!("Unclosed literal block found - treating as regular content");
result.push_str("```literal\n");
result.push_str(¤t_block);
}
if !content.ends_with('\n') && result.ends_with('\n') {
result.pop();
}
tracing::debug!("Protected {} literal block(s)", placeholders.len());
(result, placeholders)
}
fn restore_literal_blocks(
&self,
content: &str,
placeholders: HashMap<String, String>,
) -> String {
let mut result = content.to_string();
for (placeholder_id, original_content) in placeholders {
if original_content.starts_with(NON_TEMPLATED_LITERAL_GUARD_START) {
result = result.replace(&placeholder_id, &original_content);
} else {
let replacement = format!("```\n{}\n```", original_content);
result = result.replace(&placeholder_id, &replacement);
}
tracing::debug!(
"Restored literal block {} ({} bytes)",
placeholder_id,
original_content.len()
);
}
result
}
fn collapse_non_templated_literal_guards(content: String) -> String {
let mut result = String::with_capacity(content.len());
let mut in_guard = false;
for chunk in content.split_inclusive('\n') {
let trimmed = chunk.trim_end_matches(['\r', '\n']);
if !in_guard {
if trimmed == NON_TEMPLATED_LITERAL_GUARD_START {
in_guard = true;
} else {
result.push_str(chunk);
}
} else if trimmed == NON_TEMPLATED_LITERAL_GUARD_END {
in_guard = false;
} else {
result.push_str(chunk);
}
}
if in_guard {
result.push_str(NON_TEMPLATED_LITERAL_GUARD_START);
}
result
}
pub fn render_template(
&mut self,
template_content: &str,
context: &TeraContext,
) -> Result<String> {
tracing::debug!("render_template called, enabled={}", self.enabled);
if !self.enabled {
tracing::debug!("Templating disabled, returning content as-is");
return Ok(template_content.to_string());
}
let (protected_content, placeholders) = self.protect_literal_blocks(template_content);
if !self.contains_template_syntax(&protected_content) {
tracing::debug!(
"No template syntax found after protecting literals, returning content"
);
return Ok(self.restore_literal_blocks(&protected_content, placeholders));
}
tracing::debug!("Rendering template with context");
Self::log_context_as_kv(context);
let mut current_content = protected_content;
let mut depth = 0;
let max_depth = filters::MAX_RENDER_DEPTH;
let rendered = loop {
depth += 1;
if depth > max_depth {
bail!(
"Template rendering exceeded maximum recursion depth of {}. \
This usually indicates circular dependencies between project files. \
Please check your content filter references for cycles.",
max_depth
);
}
tracing::debug!("Rendering pass {} of max {}", depth, max_depth);
let rendered = self.tera.render_str(¤t_content, context).map_err(|e| {
let error_msg = Self::format_tera_error(&e);
eprintln!("Template rendering error:\n{}", error_msg);
let context_str = Self::format_context_as_string(context);
anyhow::Error::new(e).context(format!(
"Template rendering failed at depth {}:\n{}\n\nTemplate context:\n{}",
depth, error_msg, context_str
))
})?;
if !self.contains_template_syntax_outside_fences(&rendered) {
tracing::debug!("Template rendering complete after {} pass(es)", depth);
break rendered;
}
tracing::debug!("Template syntax detected in output, continuing to pass {}", depth + 1);
current_content = rendered;
};
let restored = self.restore_literal_blocks(&rendered, placeholders);
Ok(Self::collapse_non_templated_literal_guards(restored))
}
fn format_tera_error(error: &tera::Error) -> String {
use std::error::Error;
let mut messages = Vec::new();
let mut all_messages = vec![error.to_string()];
let mut current_error: Option<&dyn Error> = error.source();
while let Some(err) = current_error {
all_messages.push(err.to_string());
current_error = err.source();
}
for msg in all_messages {
let cleaned = msg
.replace("while rendering '__tera_one_off'", "")
.replace("Failed to render '__tera_one_off'", "Template rendering failed")
.replace("Failed to parse '__tera_one_off'", "Template syntax error")
.replace("'__tera_one_off'", "template")
.trim()
.to_string();
if !cleaned.is_empty()
&& cleaned != "Template rendering failed"
&& cleaned != "Template syntax error"
{
messages.push(cleaned);
}
}
if !messages.is_empty() {
messages.join("\n → ")
} else {
"Template syntax error (see details above)".to_string()
}
}
fn format_context_as_string(context: &TeraContext) -> String {
let context_clone = context.clone();
let json_value = context_clone.into_json();
let mut output = String::new();
fn format_value(key: &str, value: &serde_json::Value, indent: usize) -> Vec<String> {
let prefix = " ".repeat(indent);
let mut lines = Vec::new();
match value {
serde_json::Value::Object(map) => {
lines.push(format!("{}{}:", prefix, key));
for (k, v) in map {
lines.extend(format_value(k, v, indent + 1));
}
}
serde_json::Value::Array(arr) => {
lines.push(format!("{}{}: [{} items]", prefix, key, arr.len()));
for (i, item) in arr.iter().take(3).enumerate() {
lines.extend(format_value(&format!("[{}]", i), item, indent + 1));
}
if arr.len() > 3 {
lines.push(format!("{} ... {} more items", prefix, arr.len() - 3));
}
}
serde_json::Value::String(s) => {
if s.len() > 100 {
lines.push(format!(
"{}{}: \"{}...\" ({} chars)",
prefix,
key,
&s[..97],
s.len()
));
} else {
lines.push(format!("{}{}: \"{}\"", prefix, key, s));
}
}
serde_json::Value::Number(n) => {
lines.push(format!("{}{}: {}", prefix, key, n));
}
serde_json::Value::Bool(b) => {
lines.push(format!("{}{}: {}", prefix, key, b));
}
serde_json::Value::Null => {
lines.push(format!("{}{}: null", prefix, key));
}
}
lines
}
if let serde_json::Value::Object(map) = &json_value {
for (key, value) in map {
output.push_str(&format_value(key, value, 1).join("\n"));
output.push('\n');
}
}
output
}
fn log_context_as_kv(context: &TeraContext) {
let formatted = Self::format_context_as_string(context);
for line in formatted.lines() {
tracing::debug!("{}", line);
}
}
fn contains_template_syntax(&self, content: &str) -> bool {
let has_vars = content.contains("{{");
let has_tags = content.contains("{%");
let has_comments = content.contains("{#");
let result = has_vars || has_tags || has_comments;
tracing::debug!(
"Template syntax check: vars={}, tags={}, comments={}, result={}",
has_vars,
has_tags,
has_comments,
result
);
result
}
fn contains_template_syntax_outside_fences(&self, content: &str) -> bool {
let mut in_code_fence = false;
let mut in_guard = 0usize;
for line in content.lines() {
let trimmed = line.trim();
if trimmed == NON_TEMPLATED_LITERAL_GUARD_START {
in_guard = in_guard.saturating_add(1);
continue;
} else if trimmed == NON_TEMPLATED_LITERAL_GUARD_END {
in_guard = in_guard.saturating_sub(1);
continue;
}
if in_guard > 0 {
continue;
}
if trimmed.starts_with("```") {
in_code_fence = !in_code_fence;
continue;
}
if in_code_fence {
continue;
}
if line.contains("{{") || line.contains("{%") || line.contains("{#") {
tracing::debug!(
"Template syntax found outside code fences: {:?}",
&line[..line.len().min(80)]
);
return true;
}
}
tracing::debug!("No template syntax found outside code fences");
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lockfile::{LockFile, LockedResource};
fn create_test_lockfile() -> LockFile {
let mut lockfile = LockFile::default();
lockfile.agents.push(LockedResource {
name: "test-agent".to_string(),
source: Some("community".to_string()),
url: Some("https://github.com/example/community.git".to_string()),
path: "agents/test-agent.md".to_string(),
version: Some("v1.0.0".to_string()),
resolved_commit: Some("abc123def456".to_string()),
checksum: "sha256:testchecksum".to_string(),
installed_at: ".claude/agents/test-agent.md".to_string(),
dependencies: vec![],
resource_type: ResourceType::Agent,
tool: Some("claude-code".to_string()),
manifest_alias: None,
applied_patches: std::collections::HashMap::new(),
install: None,
});
lockfile
}
#[tokio::test]
async fn test_template_context_builder() {
let lockfile = create_test_lockfile();
let cache = crate::cache::Cache::new().unwrap();
let project_dir = std::env::current_dir().unwrap();
let builder =
TemplateContextBuilder::new(Arc::new(lockfile), None, Arc::new(cache), project_dir);
let _context = builder.build_context("test-agent", ResourceType::Agent).await.unwrap();
}
#[test]
fn test_template_renderer() {
let project_dir = std::env::current_dir().unwrap();
let mut renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
let result = renderer.render_template("# Plain Markdown", &TeraContext::new()).unwrap();
assert_eq!(result, "# Plain Markdown");
let mut context = TeraContext::new();
context.insert("test_var", "test_value");
let result = renderer.render_template("# {{ test_var }}", &context).unwrap();
assert_eq!(result, "# test_value");
}
#[test]
fn test_template_renderer_disabled() {
let project_dir = std::env::current_dir().unwrap();
let mut renderer = TemplateRenderer::new(false, project_dir, None).unwrap();
let mut context = TeraContext::new();
context.insert("test_var", "test_value");
let result = renderer.render_template("# {{ test_var }}", &context).unwrap();
assert_eq!(result, "# {{ test_var }}");
}
#[test]
fn test_template_error_formatting() {
let project_dir = std::env::current_dir().unwrap();
let mut renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
let context = TeraContext::new();
let result = renderer.render_template("# {{ missing_var }}", &context);
assert!(result.is_err());
let error = result.unwrap_err();
let error_msg = format!("{}", error);
assert!(
!error_msg.contains("__tera_one_off"),
"Error should not expose internal Tera template names"
);
assert!(
error_msg.contains("missing_var") || error_msg.contains("Variable"),
"Error should mention the problematic variable or that a variable is missing. Got: {}",
error_msg
);
}
#[test]
fn test_to_native_path_display() {
let unix_path = ".claude/agents/test.md";
let native_path = to_native_path_display(unix_path);
#[cfg(windows)]
{
assert_eq!(native_path, ".claude\\agents\\test.md");
}
#[cfg(not(windows))]
{
assert_eq!(native_path, ".claude/agents/test.md");
}
}
#[test]
fn test_to_native_path_display_nested() {
let unix_path = ".claude/agents/ai/helpers/test.md";
let native_path = to_native_path_display(unix_path);
#[cfg(windows)]
{
assert_eq!(native_path, ".claude\\agents\\ai\\helpers\\test.md");
}
#[cfg(not(windows))]
{
assert_eq!(native_path, ".claude/agents/ai/helpers/test.md");
}
}
#[tokio::test]
async fn test_template_context_uses_native_paths() {
let mut lockfile = create_test_lockfile();
lockfile.snippets.push(LockedResource {
name: "test-snippet".to_string(),
source: Some("community".to_string()),
url: Some("https://github.com/example/community.git".to_string()),
path: "snippets/utils/test.md".to_string(),
version: Some("v1.0.0".to_string()),
resolved_commit: Some("abc123def456".to_string()),
checksum: "sha256:testchecksum".to_string(),
installed_at: ".agpm/snippets/utils/test.md".to_string(),
dependencies: vec![],
resource_type: ResourceType::Snippet,
tool: Some("agpm".to_string()),
manifest_alias: None,
applied_patches: std::collections::HashMap::new(),
install: None,
});
let cache = crate::cache::Cache::new().unwrap();
let project_dir = std::env::current_dir().unwrap();
let builder =
TemplateContextBuilder::new(Arc::new(lockfile), None, Arc::new(cache), project_dir);
let context = builder.build_context("test-agent", ResourceType::Agent).await.unwrap();
let agpm_value = context.get("agpm").expect("agpm context should exist");
let agpm_obj = agpm_value.as_object().expect("agpm should be an object");
let resource_value = agpm_obj.get("resource").expect("resource should exist");
let resource_obj = resource_value.as_object().expect("resource should be an object");
let install_path = resource_obj
.get("install_path")
.expect("install_path should exist")
.as_str()
.expect("install_path should be a string");
#[cfg(windows)]
{
assert_eq!(install_path, ".claude\\agents\\test-agent.md");
assert!(install_path.contains('\\'), "Windows paths should use backslashes");
}
#[cfg(not(windows))]
{
assert_eq!(install_path, ".claude/agents/test-agent.md");
assert!(install_path.contains('/'), "Unix paths should use forward slashes");
}
let deps_value = agpm_obj.get("deps").expect("deps should exist");
let deps_obj = deps_value.as_object().expect("deps should be an object");
let snippets = deps_obj.get("snippets").expect("snippets should exist");
let snippets_obj = snippets.as_object().expect("snippets should be an object");
let test_snippet = snippets_obj.get("test_snippet").expect("test_snippet should exist");
let snippet_obj = test_snippet.as_object().expect("test_snippet should be an object");
let snippet_path = snippet_obj
.get("install_path")
.expect("install_path should exist")
.as_str()
.expect("install_path should be a string");
#[cfg(windows)]
{
assert_eq!(snippet_path, ".agpm\\snippets\\utils\\test.md");
}
#[cfg(not(windows))]
{
assert_eq!(snippet_path, ".agpm/snippets/utils/test.md");
}
}
#[test]
fn test_protect_literal_blocks_basic() {
let project_dir = std::env::current_dir().unwrap();
let renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
let content = r#"# Documentation
Use this syntax:
```literal
{{ agpm.deps.snippets.example.content }}
```
That's how you embed content."#;
let (protected, placeholders) = renderer.protect_literal_blocks(content);
assert_eq!(placeholders.len(), 1);
assert!(protected.contains("__AGPM_LITERAL_BLOCK_0__"));
assert!(!protected.contains("{{ agpm.deps.snippets.example.content }}"));
let placeholder_content = placeholders.get("__AGPM_LITERAL_BLOCK_0__").unwrap();
assert!(placeholder_content.contains("{{ agpm.deps.snippets.example.content }}"));
}
#[test]
fn test_protect_literal_blocks_multiple() {
let project_dir = std::env::current_dir().unwrap();
let renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
let content = r#"# First Example
```literal
{{ first.example }}
```
# Second Example
```literal
{{ second.example }}
```"#;
let (protected, placeholders) = renderer.protect_literal_blocks(content);
assert_eq!(placeholders.len(), 2);
assert!(protected.contains("__AGPM_LITERAL_BLOCK_0__"));
assert!(protected.contains("__AGPM_LITERAL_BLOCK_1__"));
assert!(!protected.contains("{{ first.example }}"));
assert!(!protected.contains("{{ second.example }}"));
}
#[test]
fn test_restore_literal_blocks() {
let project_dir = std::env::current_dir().unwrap();
let renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
let mut placeholders = HashMap::new();
placeholders.insert(
"__AGPM_LITERAL_BLOCK_0__".to_string(),
"{{ agpm.deps.snippets.example.content }}".to_string(),
);
let content = "# Example\n\n__AGPM_LITERAL_BLOCK_0__\n\nDone.";
let restored = renderer.restore_literal_blocks(content, placeholders);
assert!(restored.contains("```\n{{ agpm.deps.snippets.example.content }}\n```"));
assert!(!restored.contains("__AGPM_LITERAL_BLOCK_0__"));
}
#[test]
fn test_literal_blocks_integration_with_rendering() {
let project_dir = std::env::current_dir().unwrap();
let mut renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
let template = r#"# Agent: {{ agent_name }}
## Documentation
Here's how to use template syntax:
```literal
{{ agpm.deps.snippets.helper.content }}
```
The agent name is: {{ agent_name }}"#;
let mut context = TeraContext::new();
context.insert("agent_name", "test-agent");
let result = renderer.render_template(template, &context).unwrap();
assert!(result.contains("# Agent: test-agent"));
assert!(result.contains("The agent name is: test-agent"));
assert!(result.contains("```\n{{ agpm.deps.snippets.helper.content }}\n```"));
assert!(result.contains("{{ agpm.deps.snippets.helper.content }}"));
}
#[test]
fn test_literal_blocks_with_complex_template_syntax() {
let project_dir = std::env::current_dir().unwrap();
let mut renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
let template = r#"# Documentation
```literal
{% for item in agpm.deps.agents %}
{{ item.name }}: {{ item.version }}
{% endfor %}
```"#;
let context = TeraContext::new();
let result = renderer.render_template(template, &context).unwrap();
assert!(result.contains("{% for item in agpm.deps.agents %}"));
assert!(result.contains("{{ item.name }}"));
assert!(result.contains("{% endfor %}"));
}
#[test]
fn test_literal_blocks_empty() {
let project_dir = std::env::current_dir().unwrap();
let mut renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
let template = r#"# Example
```literal
```
Done."#;
let context = TeraContext::new();
let result = renderer.render_template(template, &context).unwrap();
assert!(result.contains("# Example"));
assert!(result.contains("Done."));
}
#[test]
fn test_literal_blocks_unclosed() {
let project_dir = std::env::current_dir().unwrap();
let renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
let content = r#"# Example
```literal
{{ template.syntax }}
This block is not closed"#;
let (protected, placeholders) = renderer.protect_literal_blocks(content);
assert_eq!(placeholders.len(), 0);
assert!(protected.contains("```literal"));
assert!(protected.contains("{{ template.syntax }}"));
}
#[test]
fn test_literal_blocks_with_indentation() {
let project_dir = std::env::current_dir().unwrap();
let renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
let content = r#"# Example
```literal
{{ indented.template }}
```"#;
let (_protected, placeholders) = renderer.protect_literal_blocks(content);
assert_eq!(placeholders.len(), 1);
let placeholder_content = placeholders.get("__AGPM_LITERAL_BLOCK_0__").unwrap();
assert!(placeholder_content.contains("{{ indented.template }}"));
}
#[test]
fn test_literal_blocks_in_transitive_dependency_content() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path().to_path_buf();
let dep_content = r#"---
agpm.templating: true
---
# Dependency Documentation
Here's a template example:
```literal
{{ nonexistent_variable }}
{{ agpm.deps.something.else }}
```
This should appear literally."#;
let dep_path = project_dir.join("dependency.md");
fs::write(&dep_path, dep_content).unwrap();
let mut dep_renderer = TemplateRenderer::new(true, project_dir.clone(), None).unwrap();
let dep_context = TeraContext::new();
let rendered_dep = dep_renderer.render_template(dep_content, &dep_context).unwrap();
assert!(rendered_dep.contains("```\n{{ nonexistent_variable }}"));
assert!(rendered_dep.contains("{{ agpm.deps.something.else }}\n```"));
let parent_template = r#"# Parent Resource
## Embedded Documentation
{{ dependency_content }}
## End"#;
let mut parent_context = TeraContext::new();
parent_context.insert("dependency_content", &rendered_dep);
let mut parent_renderer = TemplateRenderer::new(true, project_dir.clone(), None).unwrap();
let final_output =
parent_renderer.render_template(parent_template, &parent_context).unwrap();
assert!(
final_output.contains("{{ nonexistent_variable }}"),
"Template syntax from literal block should appear literally in final output"
);
assert!(
final_output.contains("{{ agpm.deps.something.else }}"),
"Template syntax from literal block should appear literally in final output"
);
assert!(
final_output.contains("```\n{{ nonexistent_variable }}"),
"Literal content should be in a code fence"
);
assert!(!final_output.contains("__AGPM_LITERAL_BLOCK_"), "No placeholders should remain");
}
#[test]
fn test_literal_blocks_with_nested_dependencies() {
let project_dir = std::env::current_dir().unwrap();
let mut renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
let dep_content = r#"# Helper Snippet
Use this syntax:
```
{{ agpm.deps.snippets.example.content }}
{{ missing.variable }}
```
Done."#;
let parent_template = r#"# Main Agent
## Documentation
{{ helper_content }}
The agent uses templating."#;
let mut context = TeraContext::new();
context.insert("helper_content", dep_content);
let result = renderer.render_template(parent_template, &context).unwrap();
assert!(result.contains("{{ agpm.deps.snippets.example.content }}"));
assert!(result.contains("{{ missing.variable }}"));
assert!(result.contains("```\n{{ agpm.deps.snippets.example.content }}"));
}
#[tokio::test]
async fn test_non_templated_dependency_content_is_guarded() {
use tempfile::TempDir;
use tokio::fs;
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path().to_path_buf();
let snippets_dir = project_dir.join("snippets");
fs::create_dir_all(&snippets_dir).await.unwrap();
let snippet_path = snippets_dir.join("non-templated.md");
fs::write(
&snippet_path,
r#"---
agpm:
templating: false
---
# Example Snippet
This should show {{ agpm.deps.some.content }} literally.
"#,
)
.await
.unwrap();
let mut lockfile = LockFile::default();
lockfile.commands.push(LockedResource {
name: "test-command".to_string(),
source: None,
url: None,
path: "commands/test.md".to_string(),
version: None,
resolved_commit: None,
checksum: "sha256:test-command".to_string(),
installed_at: ".claude/commands/test.md".to_string(),
dependencies: vec![],
resource_type: ResourceType::Command,
tool: Some("claude-code".to_string()),
manifest_alias: None,
applied_patches: std::collections::HashMap::new(),
install: None,
});
lockfile.snippets.push(LockedResource {
name: "non_templated".to_string(),
source: None,
url: None,
path: "snippets/non-templated.md".to_string(),
version: None,
resolved_commit: None,
checksum: "sha256:test-snippet".to_string(),
installed_at: ".agpm/snippets/non-templated.md".to_string(),
dependencies: vec![],
resource_type: ResourceType::Snippet,
tool: Some("agpm".to_string()),
manifest_alias: None,
applied_patches: std::collections::HashMap::new(),
install: None,
});
let cache = crate::cache::Cache::new().unwrap();
let builder = TemplateContextBuilder::new(
Arc::new(lockfile),
None,
Arc::new(cache),
project_dir.clone(),
);
let context = builder.build_context("test-command", ResourceType::Command).await.unwrap();
let mut renderer = TemplateRenderer::new(true, project_dir.clone(), None).unwrap();
let template = r#"# Combined Output
{{ agpm.deps.snippets.non_templated.content }}
"#;
let rendered = renderer.render_template(template, &context).unwrap();
assert!(
rendered.contains("# Example Snippet"),
"Rendered output should include the snippet heading"
);
assert!(
rendered.contains("{{ agpm.deps.some.content }}"),
"Template syntax inside non-templated dependency should remain literal"
);
assert!(
!rendered.contains(NON_TEMPLATED_LITERAL_GUARD_START)
&& !rendered.contains(NON_TEMPLATED_LITERAL_GUARD_END),
"Internal literal guard markers should not leak into rendered output"
);
assert!(
!rendered.contains("```literal"),
"Synthetic literal fences should be removed after rendering"
);
}
}