pub mod cache;
pub mod content;
pub mod context;
pub mod dependencies;
pub mod filters;
pub mod renderer;
pub mod utils;
pub use context::{DependencyData, ResourceMetadata, TemplateContextBuilder};
pub use renderer::TemplateRenderer;
pub use utils::{deep_merge_json, to_native_path_display};
#[cfg(test)]
mod tests {
use super::content::{NON_TEMPLATED_LITERAL_GUARD_END, NON_TEMPLATED_LITERAL_GUARD_START};
use super::*;
use crate::core::ResourceType;
use crate::lockfile::{LockFile, LockedResource, LockedResourceBuilder};
use serde_json::json;
use std::collections::HashMap;
use std::sync::Arc;
use tera::Context as TeraContext;
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(),
context_checksum: None,
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::BTreeMap::new(),
install: None,
variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
});
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 variant_inputs = serde_json::json!({});
let hash = crate::utils::compute_variant_inputs_hash(&variant_inputs).unwrap();
let resource_id = crate::lockfile::ResourceId::new(
"test-agent",
Some("community"),
Some("claude-code"),
ResourceType::Agent,
hash,
);
let (_context, _checksum) =
builder.build_context(&resource_id, &variant_inputs).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() {
use tempfile::TempDir;
use tokio::fs;
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path().to_path_buf();
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: ".claude/snippets/utils/test.md".to_string(),
dependencies: vec![],
resource_type: ResourceType::Snippet,
context_checksum: None,
tool: Some("claude-code".to_string()),
manifest_alias: None,
applied_patches: std::collections::BTreeMap::new(),
install: None,
variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
});
if let Some(agent) = lockfile.agents.first_mut() {
agent.dependencies.push("snippet:test-snippet".to_string());
}
let snippet_path = project_dir.join(".claude/snippets/utils/test.md");
fs::create_dir_all(snippet_path.parent().unwrap()).await.unwrap();
let snippet_content = "# Test Snippet\n\nSome content here.";
fs::write(&snippet_path, snippet_content).await.unwrap();
let cache = crate::cache::Cache::new().unwrap();
let builder =
TemplateContextBuilder::new(Arc::new(lockfile), None, Arc::new(cache), project_dir);
let variant_inputs = serde_json::json!({});
let hash = crate::utils::compute_variant_inputs_hash(&variant_inputs).unwrap();
let resource_id = crate::lockfile::ResourceId::new(
"test-agent",
Some("community"),
Some("claude-code"),
ResourceType::Agent,
hash,
);
let (context, _checksum) =
builder.build_context(&resource_id, &variant_inputs).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, ".claude\\snippets\\utils\\test.md");
}
#[cfg(not(windows))]
{
assert_eq!(snippet_path, ".claude/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");
let snippet_content = r#"---
agpm:
templating: false
---
# Example Snippet
This should show {{ agpm.deps.some.content }} literally.
"#;
fs::write(&snippet_path, snippet_content).await.unwrap();
let installed_snippets_dir = project_dir.join(".claude/snippets");
fs::create_dir_all(&installed_snippets_dir).await.unwrap();
let installed_snippet_path = installed_snippets_dir.join("non-templated.md");
fs::write(&installed_snippet_path, snippet_content).await.unwrap();
let mut lockfile = LockFile::default();
lockfile.commands.push(
LockedResourceBuilder::new(
"test-command".to_string(),
"commands/test.md".to_string(),
"sha256:test-command".to_string(),
".claude/commands/test.md".to_string(),
ResourceType::Command,
)
.dependencies(vec!["snippet:non_templated".to_string()])
.tool(Some("claude-code".to_string()))
.applied_patches(std::collections::BTreeMap::new())
.variant_inputs(crate::resolver::lockfile_builder::VariantInputs::default())
.build(),
);
lockfile.snippets.push(
LockedResourceBuilder::new(
"non_templated".to_string(),
"snippets/non-templated.md".to_string(),
"sha256:test-snippet".to_string(),
".claude/snippets/non-templated.md".to_string(),
ResourceType::Snippet,
)
.dependencies(vec![])
.tool(Some("claude-code".to_string()))
.applied_patches(std::collections::BTreeMap::new())
.variant_inputs(crate::resolver::lockfile_builder::VariantInputs::default())
.build(),
);
let cache = crate::cache::Cache::new().unwrap();
let builder = TemplateContextBuilder::new(
Arc::new(lockfile),
None,
Arc::new(cache),
project_dir.clone(),
);
let variant_inputs = serde_json::json!({});
let hash = crate::utils::compute_variant_inputs_hash(&variant_inputs).unwrap();
let resource_id = crate::lockfile::ResourceId::new(
"test-command",
None::<String>,
Some("claude-code"),
ResourceType::Command,
hash,
);
let (context, _checksum) =
builder.build_context(&resource_id, &variant_inputs).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"
);
}
#[tokio::test]
async fn test_template_vars_override() {
use serde_json::json;
let mut lockfile = create_test_lockfile();
let template_vars = json!({
"project": {
"language": "python",
"framework": "fastapi"
},
"custom": {
"style": "functional"
}
});
let variant_inputs_obj =
crate::resolver::lockfile_builder::VariantInputs::new(template_vars.clone());
lockfile.agents.push(LockedResource {
name: "test-agent-python".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:testchecksum2".to_string(),
installed_at: ".claude/agents/test-agent-python.md".to_string(),
dependencies: vec![],
resource_type: ResourceType::Agent,
tool: Some("claude-code".to_string()),
manifest_alias: None,
applied_patches: std::collections::BTreeMap::new(),
install: None,
variant_inputs: variant_inputs_obj.clone(),
context_checksum: None,
});
let cache = crate::cache::Cache::new().unwrap();
let project_dir = std::env::current_dir().unwrap();
let project_config = {
let mut map = toml::map::Map::new();
map.insert("language".to_string(), toml::Value::String("rust".into()));
map.insert("framework".to_string(), toml::Value::String("tokio".into()));
crate::manifest::ProjectConfig::from(map)
};
let builder = TemplateContextBuilder::new(
Arc::new(lockfile),
Some(project_config),
Arc::new(cache),
project_dir.clone(),
);
let variant_inputs_empty = serde_json::json!({});
let hash_empty = crate::utils::compute_variant_inputs_hash(&variant_inputs_empty).unwrap();
let resource_id_no_override = crate::lockfile::ResourceId::new(
"test-agent",
Some("community"),
Some("claude-code"),
ResourceType::Agent,
hash_empty,
);
let (context_without_override, _checksum) =
builder.build_context(&resource_id_no_override, &variant_inputs_empty).await.unwrap();
let hash_with_override = crate::utils::compute_variant_inputs_hash(&template_vars).unwrap();
let resource_id_with_override = crate::lockfile::ResourceId::new(
"test-agent-python",
Some("community"),
Some("claude-code"),
ResourceType::Agent,
hash_with_override,
);
let (context_with_override, _checksum) =
builder.build_context(&resource_id_with_override, &template_vars).await.unwrap();
let project_dir = std::env::current_dir().unwrap();
let mut renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
let template = "Language: {{ project.language }}, Framework: {{ project.framework }}";
let rendered_without =
renderer.render_template(template, &context_without_override).unwrap();
assert_eq!(rendered_without, "Language: rust, Framework: tokio");
let rendered_with = renderer.render_template(template, &context_with_override).unwrap();
assert_eq!(rendered_with, "Language: python, Framework: fastapi");
let custom_template = "Style: {{ custom.style }}";
let rendered_custom =
renderer.render_template(custom_template, &context_with_override).unwrap();
assert_eq!(rendered_custom, "Style: functional");
}
#[tokio::test]
async fn test_template_vars_deep_merge() {
use serde_json::json;
let mut lockfile = create_test_lockfile();
let template_vars = json!({
"project": {
"database": {
"host": "db.example.com",
"ssl": true
}
}
});
let variant_inputs =
crate::resolver::lockfile_builder::VariantInputs::new(template_vars.clone());
lockfile.agents.push(LockedResource {
name: "test-agent-merged".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:testchecksum3".to_string(),
installed_at: ".claude/agents/test-agent-merged.md".to_string(),
dependencies: vec![],
resource_type: ResourceType::Agent,
tool: Some("claude-code".to_string()),
manifest_alias: None,
applied_patches: std::collections::BTreeMap::new(),
install: None,
variant_inputs,
context_checksum: None,
});
let cache = crate::cache::Cache::new().unwrap();
let project_dir = std::env::current_dir().unwrap();
let project_config = {
let mut map = toml::map::Map::new();
let mut db_table = toml::map::Map::new();
db_table.insert("type".to_string(), toml::Value::String("postgres".into()));
db_table.insert("host".to_string(), toml::Value::String("localhost".into()));
db_table.insert("port".to_string(), toml::Value::Integer(5432));
map.insert("database".to_string(), toml::Value::Table(db_table));
map.insert("language".to_string(), toml::Value::String("rust".into()));
crate::manifest::ProjectConfig::from(map)
};
let builder = TemplateContextBuilder::new(
Arc::new(lockfile),
Some(project_config),
Arc::new(cache),
project_dir.clone(),
);
let hash_for_id = crate::utils::compute_variant_inputs_hash(&template_vars).unwrap();
let resource_id = crate::lockfile::ResourceId::new(
"test-agent-merged",
Some("community"),
Some("claude-code"),
ResourceType::Agent,
hash_for_id,
);
let (context, _checksum) =
builder.build_context(&resource_id, &template_vars).await.unwrap();
let project_dir = std::env::current_dir().unwrap();
let mut renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
let template = r#"
DB Type: {{ project.database.type }}
DB Host: {{ project.database.host }}
DB Port: {{ project.database.port }}
DB SSL: {{ project.database.ssl }}
Language: {{ project.language }}
"#;
let rendered = renderer.render_template(template, &context).unwrap();
assert!(rendered.contains("DB Type: postgres"));
assert!(rendered.contains("DB Port: 5432"));
assert!(rendered.contains("Language: rust"));
assert!(rendered.contains("DB Host: db.example.com"));
assert!(rendered.contains("DB SSL: true"));
}
#[tokio::test]
async fn test_template_vars_empty_object_noop() {
use serde_json::json;
let mut lockfile = create_test_lockfile();
let template_vars = json!({});
let variant_inputs =
crate::resolver::lockfile_builder::VariantInputs::new(template_vars.clone());
lockfile.agents.push(LockedResource {
name: "test-agent-empty".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:empty".to_string(),
installed_at: ".claude/agents/test-agent-empty.md".to_string(),
dependencies: vec![],
resource_type: ResourceType::Agent,
tool: Some("claude-code".to_string()),
manifest_alias: None,
applied_patches: std::collections::BTreeMap::new(),
install: None,
variant_inputs,
context_checksum: None,
});
let cache = crate::cache::Cache::new().unwrap();
let project_dir = std::env::current_dir().unwrap();
let project_config = {
let mut map = toml::map::Map::new();
map.insert("language".to_string(), toml::Value::String("rust".into()));
map.insert("version".to_string(), toml::Value::String("1.0".into()));
crate::manifest::ProjectConfig::from(map)
};
let builder = TemplateContextBuilder::new(
Arc::new(lockfile),
Some(project_config),
Arc::new(cache),
project_dir.clone(),
);
let hash_for_id = crate::utils::compute_variant_inputs_hash(&template_vars).unwrap();
let resource_id = crate::lockfile::ResourceId::new(
"test-agent-empty",
Some("community"),
Some("claude-code"),
ResourceType::Agent,
hash_for_id,
);
let (context, _checksum) =
builder.build_context(&resource_id, &template_vars).await.unwrap();
let project_dir = std::env::current_dir().unwrap();
let mut renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
let template = "Language: {{ project.language }}, Version: {{ project.version }}";
let rendered = renderer.render_template(template, &context).unwrap();
assert_eq!(rendered, "Language: rust, Version: 1.0");
}
#[tokio::test]
async fn test_template_vars_null_values() {
use serde_json::json;
let mut lockfile = create_test_lockfile();
let template_vars = json!({
"project": {
"optional_field": null
}
});
let variant_inputs =
crate::resolver::lockfile_builder::VariantInputs::new(template_vars.clone());
lockfile.agents.push(LockedResource {
name: "test-agent-null".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:null".to_string(),
installed_at: ".claude/agents/test-agent-null.md".to_string(),
dependencies: vec![],
resource_type: ResourceType::Agent,
tool: Some("claude-code".to_string()),
manifest_alias: None,
applied_patches: std::collections::BTreeMap::new(),
install: None,
variant_inputs,
context_checksum: None,
});
let cache = crate::cache::Cache::new().unwrap();
let project_dir = std::env::current_dir().unwrap();
let project_config = {
let mut map = toml::map::Map::new();
map.insert("language".to_string(), toml::Value::String("rust".into()));
crate::manifest::ProjectConfig::from(map)
};
let builder = TemplateContextBuilder::new(
Arc::new(lockfile),
Some(project_config),
Arc::new(cache),
project_dir.clone(),
);
let hash_for_id = crate::utils::compute_variant_inputs_hash(&template_vars).unwrap();
let resource_id = crate::lockfile::ResourceId::new(
"test-agent-null",
Some("community"),
Some("claude-code"),
ResourceType::Agent,
hash_for_id,
);
let (context, _checksum) =
builder.build_context(&resource_id, &template_vars).await.unwrap();
let agpm_value = context.get("agpm").expect("agpm should exist");
let agpm_obj = agpm_value.as_object().expect("agpm should be an object");
let project_value = agpm_obj.get("project").expect("project should exist");
let project_obj = project_value.as_object().expect("project should be an object");
assert!(project_obj.get("optional_field").is_some());
assert!(project_obj["optional_field"].is_null());
let top_project = context.get("project").expect("top-level project should exist");
let top_project_obj = top_project.as_object().expect("should be object");
assert!(top_project_obj.get("optional_field").is_some());
assert!(top_project_obj["optional_field"].is_null());
}
}