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// Module declarations
43pub mod cache;
44pub mod content;
45pub mod context;
46pub mod dependencies;
47pub mod filters;
48pub mod renderer;
49pub mod utils;
50
51// Re-exports for public API
52pub use context::{DependencyData, ResourceMetadata, TemplateContextBuilder};
53pub use renderer::TemplateRenderer;
54pub use utils::{deep_merge_json, to_native_path_display};
55
56#[cfg(test)]
57mod tests {
58    use super::content::{NON_TEMPLATED_LITERAL_GUARD_END, NON_TEMPLATED_LITERAL_GUARD_START};
59    use super::*;
60    use crate::core::ResourceType;
61    use crate::lockfile::{LockFile, LockedResource, LockedResourceBuilder};
62
63    use serde_json::json;
64    use std::collections::HashMap;
65    use std::sync::Arc;
66    use tera::Context as TeraContext;
67
68    fn create_test_lockfile() -> LockFile {
69        let mut lockfile = LockFile::default();
70
71        // Add a test agent
72        lockfile.agents.push(LockedResource {
73            name: "test-agent".to_string(),
74            source: Some("community".to_string()),
75            url: Some("https://github.com/example/community.git".to_string()),
76            path: "agents/test-agent.md".to_string(),
77            version: Some("v1.0.0".to_string()),
78            resolved_commit: Some("abc123def456".to_string()),
79            checksum: "sha256:testchecksum".to_string(),
80            context_checksum: None,
81            installed_at: ".claude/agents/test-agent.md".to_string(),
82            dependencies: vec![],
83            resource_type: ResourceType::Agent,
84            tool: Some("claude-code".to_string()),
85            manifest_alias: None,
86            applied_patches: std::collections::BTreeMap::new(),
87            install: None,
88            variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
89        });
90
91        lockfile
92    }
93
94    #[tokio::test]
95    async fn test_template_context_builder() {
96        let lockfile = create_test_lockfile();
97
98        let cache = crate::cache::Cache::new().unwrap();
99        let project_dir = std::env::current_dir().unwrap();
100        let builder =
101            TemplateContextBuilder::new(Arc::new(lockfile), None, Arc::new(cache), project_dir);
102
103        let variant_inputs = serde_json::json!({});
104        let hash = crate::utils::compute_variant_inputs_hash(&variant_inputs).unwrap();
105        let resource_id = crate::lockfile::ResourceId::new(
106            "test-agent",
107            Some("community"),
108            Some("claude-code"),
109            ResourceType::Agent,
110            hash,
111        );
112        let (_context, _checksum) =
113            builder.build_context(&resource_id, &variant_inputs).await.unwrap();
114
115        // If we got here without panicking, context building succeeded
116        // The actual context structure is tested implicitly by the renderer tests
117    }
118
119    #[test]
120    fn test_template_renderer() {
121        let project_dir = std::env::current_dir().unwrap();
122        let mut renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
123
124        // Test rendering without template syntax
125        let result = renderer.render_template("# Plain Markdown", &TeraContext::new()).unwrap();
126        assert_eq!(result, "# Plain Markdown");
127
128        // Test rendering with template syntax
129        let mut context = TeraContext::new();
130        context.insert("test_var", "test_value");
131
132        let result = renderer.render_template("# {{ test_var }}", &context).unwrap();
133        assert_eq!(result, "# test_value");
134    }
135
136    #[test]
137    fn test_template_renderer_disabled() {
138        let project_dir = std::env::current_dir().unwrap();
139        let mut renderer = TemplateRenderer::new(false, project_dir, None).unwrap();
140
141        let mut context = TeraContext::new();
142        context.insert("test_var", "test_value");
143
144        // Should return content as-is when disabled
145        let result = renderer.render_template("# {{ test_var }}", &context).unwrap();
146        assert_eq!(result, "# {{ test_var }}");
147    }
148
149    #[test]
150    fn test_template_error_formatting() {
151        let project_dir = std::env::current_dir().unwrap();
152        let mut renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
153        let context = TeraContext::new();
154
155        // Test with missing variable - should produce detailed error
156        let result = renderer.render_template("# {{ missing_var }}", &context);
157        assert!(result.is_err());
158
159        let error = result.unwrap_err();
160        let error_msg = format!("{}", error);
161
162        // Error should NOT contain "__tera_one_off"
163        assert!(
164            !error_msg.contains("__tera_one_off"),
165            "Error should not expose internal Tera template names"
166        );
167
168        // Error should contain useful information about the missing variable
169        assert!(
170            error_msg.contains("missing_var") || error_msg.contains("Variable"),
171            "Error should mention the problematic variable or that a variable is missing. Got: {}",
172            error_msg
173        );
174    }
175
176    #[test]
177    fn test_to_native_path_display() {
178        // Test Unix-style path conversion
179        let unix_path = ".claude/agents/test.md";
180        let native_path = to_native_path_display(unix_path);
181
182        #[cfg(windows)]
183        {
184            assert_eq!(native_path, ".claude\\agents\\test.md");
185        }
186
187        #[cfg(not(windows))]
188        {
189            assert_eq!(native_path, ".claude/agents/test.md");
190        }
191    }
192
193    #[test]
194    fn test_to_native_path_display_nested() {
195        // Test deeply nested path
196        let unix_path = ".claude/agents/ai/helpers/test.md";
197        let native_path = to_native_path_display(unix_path);
198
199        #[cfg(windows)]
200        {
201            assert_eq!(native_path, ".claude\\agents\\ai\\helpers\\test.md");
202        }
203
204        #[cfg(not(windows))]
205        {
206            assert_eq!(native_path, ".claude/agents/ai/helpers/test.md");
207        }
208    }
209
210    #[tokio::test]
211    async fn test_template_context_uses_native_paths() {
212        use tempfile::TempDir;
213        use tokio::fs;
214
215        let temp_dir = TempDir::new().unwrap();
216        let project_dir = temp_dir.path().to_path_buf();
217
218        let mut lockfile = create_test_lockfile();
219
220        // Add another resource with a nested path
221        lockfile.snippets.push(LockedResource {
222            name: "test-snippet".to_string(),
223            source: Some("community".to_string()),
224            url: Some("https://github.com/example/community.git".to_string()),
225            path: "snippets/utils/test.md".to_string(),
226            version: Some("v1.0.0".to_string()),
227            resolved_commit: Some("abc123def456".to_string()),
228            checksum: "sha256:testchecksum".to_string(),
229            installed_at: ".claude/snippets/utils/test.md".to_string(),
230            dependencies: vec![],
231            resource_type: ResourceType::Snippet,
232            context_checksum: None,
233            tool: Some("claude-code".to_string()),
234            manifest_alias: None,
235            applied_patches: std::collections::BTreeMap::new(),
236            install: None,
237            variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
238        });
239
240        // Add the snippet as a dependency of the test-agent
241        if let Some(agent) = lockfile.agents.first_mut() {
242            agent.dependencies.push("snippet:test-snippet".to_string());
243        }
244
245        // Create the snippet file at the installed location
246        let snippet_path = project_dir.join(".claude/snippets/utils/test.md");
247        fs::create_dir_all(snippet_path.parent().unwrap()).await.unwrap();
248        let snippet_content = "# Test Snippet\n\nSome content here.";
249        fs::write(&snippet_path, snippet_content).await.unwrap();
250
251        let cache = crate::cache::Cache::new().unwrap();
252        let builder =
253            TemplateContextBuilder::new(Arc::new(lockfile), None, Arc::new(cache), project_dir);
254        let variant_inputs = serde_json::json!({});
255        let hash = crate::utils::compute_variant_inputs_hash(&variant_inputs).unwrap();
256        let resource_id = crate::lockfile::ResourceId::new(
257            "test-agent",
258            Some("community"),
259            Some("claude-code"),
260            ResourceType::Agent,
261            hash,
262        );
263        let (context, _checksum) =
264            builder.build_context(&resource_id, &variant_inputs).await.unwrap();
265
266        // Extract the agpm.resource.install_path from context
267        let agpm_value = context.get("agpm").expect("agpm context should exist");
268        let agpm_obj = agpm_value.as_object().expect("agpm should be an object");
269        let resource_value = agpm_obj.get("resource").expect("resource should exist");
270        let resource_obj = resource_value.as_object().expect("resource should be an object");
271        let install_path = resource_obj
272            .get("install_path")
273            .expect("install_path should exist")
274            .as_str()
275            .expect("install_path should be a string");
276
277        // Verify the path uses platform-native separators
278        #[cfg(windows)]
279        {
280            assert_eq!(install_path, ".claude\\agents\\test-agent.md");
281            assert!(install_path.contains('\\'), "Windows paths should use backslashes");
282        }
283
284        #[cfg(not(windows))]
285        {
286            assert_eq!(install_path, ".claude/agents/test-agent.md");
287            assert!(install_path.contains('/'), "Unix paths should use forward slashes");
288        }
289
290        // Also verify dependency paths
291        let deps_value = agpm_obj.get("deps").expect("deps should exist");
292        let deps_obj = deps_value.as_object().expect("deps should be an object");
293        let snippets = deps_obj.get("snippets").expect("snippets should exist");
294        let snippets_obj = snippets.as_object().expect("snippets should be an object");
295        let test_snippet = snippets_obj.get("test_snippet").expect("test_snippet should exist");
296        let snippet_obj = test_snippet.as_object().expect("test_snippet should be an object");
297        let snippet_path = snippet_obj
298            .get("install_path")
299            .expect("install_path should exist")
300            .as_str()
301            .expect("install_path should be a string");
302
303        #[cfg(windows)]
304        {
305            assert_eq!(snippet_path, ".claude\\snippets\\utils\\test.md");
306        }
307
308        #[cfg(not(windows))]
309        {
310            assert_eq!(snippet_path, ".claude/snippets/utils/test.md");
311        }
312    }
313
314    // Tests for literal block functionality (Phase 1)
315
316    #[test]
317    fn test_protect_literal_blocks_basic() {
318        let project_dir = std::env::current_dir().unwrap();
319        let renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
320
321        let content = r#"# Documentation
322
323Use this syntax:
324
325```literal
326{{ agpm.deps.snippets.example.content }}
327```
328
329That's how you embed content."#;
330
331        let (protected, placeholders) = renderer.protect_literal_blocks(content);
332
333        // Should have one placeholder
334        assert_eq!(placeholders.len(), 1);
335
336        // Protected content should contain placeholder
337        assert!(protected.contains("__AGPM_LITERAL_BLOCK_0__"));
338
339        // Protected content should NOT contain the template syntax
340        assert!(!protected.contains("{{ agpm.deps.snippets.example.content }}"));
341
342        // Placeholder should contain the original content
343        let placeholder_content = placeholders.get("__AGPM_LITERAL_BLOCK_0__").unwrap();
344        assert!(placeholder_content.contains("{{ agpm.deps.snippets.example.content }}"));
345    }
346
347    #[test]
348    fn test_protect_literal_blocks_multiple() {
349        let project_dir = std::env::current_dir().unwrap();
350        let renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
351
352        let content = r#"# First Example
353
354```literal
355{{ first.example }}
356```
357
358# Second Example
359
360```literal
361{{ second.example }}
362```"#;
363
364        let (protected, placeholders) = renderer.protect_literal_blocks(content);
365
366        // Should have two placeholders
367        assert_eq!(placeholders.len(), 2);
368
369        // Both placeholders should be in the protected content
370        assert!(protected.contains("__AGPM_LITERAL_BLOCK_0__"));
371        assert!(protected.contains("__AGPM_LITERAL_BLOCK_1__"));
372
373        // Original template syntax should not be in protected content
374        assert!(!protected.contains("{{ first.example }}"));
375        assert!(!protected.contains("{{ second.example }}"));
376    }
377
378    #[test]
379    fn test_restore_literal_blocks() {
380        let project_dir = std::env::current_dir().unwrap();
381        let renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
382
383        let mut placeholders = HashMap::new();
384        placeholders.insert(
385            "__AGPM_LITERAL_BLOCK_0__".to_string(),
386            "{{ agpm.deps.snippets.example.content }}".to_string(),
387        );
388
389        let content = "# Example\n\n__AGPM_LITERAL_BLOCK_0__\n\nDone.";
390        let restored = renderer.restore_literal_blocks(content, placeholders);
391
392        // Should contain the original content in a code fence
393        assert!(restored.contains("```\n{{ agpm.deps.snippets.example.content }}\n```"));
394
395        // Should NOT contain the placeholder
396        assert!(!restored.contains("__AGPM_LITERAL_BLOCK_0__"));
397    }
398
399    #[test]
400    fn test_literal_blocks_integration_with_rendering() {
401        let project_dir = std::env::current_dir().unwrap();
402        let mut renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
403
404        let template = r#"# Agent: {{ agent_name }}
405
406## Documentation
407
408Here's how to use template syntax:
409
410```literal
411{{ agpm.deps.snippets.helper.content }}
412```
413
414The agent name is: {{ agent_name }}"#;
415
416        let mut context = TeraContext::new();
417        context.insert("agent_name", "test-agent");
418
419        let result = renderer.render_template(template, &context).unwrap();
420
421        // The agent_name variable should be rendered
422        assert!(result.contains("# Agent: test-agent"));
423        assert!(result.contains("The agent name is: test-agent"));
424
425        // The literal block should be preserved and wrapped in code fence
426        assert!(result.contains("```\n{{ agpm.deps.snippets.helper.content }}\n```"));
427
428        // The literal block should NOT be rendered (still has template syntax)
429        assert!(result.contains("{{ agpm.deps.snippets.helper.content }}"));
430    }
431
432    #[test]
433    fn test_literal_blocks_with_complex_template_syntax() {
434        let project_dir = std::env::current_dir().unwrap();
435        let mut renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
436
437        let template = r#"# Documentation
438
439```literal
440{% for item in agpm.deps.agents %}
441{{ item.name }}: {{ item.version }}
442{% endfor %}
443```"#;
444
445        let context = TeraContext::new();
446        let result = renderer.render_template(template, &context).unwrap();
447
448        // Should preserve the for loop syntax
449        assert!(result.contains("{% for item in agpm.deps.agents %}"));
450        assert!(result.contains("{{ item.name }}"));
451        assert!(result.contains("{% endfor %}"));
452    }
453
454    #[test]
455    fn test_literal_blocks_empty() {
456        let project_dir = std::env::current_dir().unwrap();
457        let mut renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
458
459        let template = r#"# Example
460
461```literal
462```
463
464Done."#;
465
466        let context = TeraContext::new();
467        let result = renderer.render_template(template, &context).unwrap();
468
469        // Should handle empty literal blocks gracefully
470        assert!(result.contains("# Example"));
471        assert!(result.contains("Done."));
472    }
473
474    #[test]
475    fn test_literal_blocks_unclosed() {
476        let project_dir = std::env::current_dir().unwrap();
477        let renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
478
479        let content = r#"# Example
480
481```literal
482{{ template.syntax }}
483This block is not closed"#;
484
485        let (protected, placeholders) = renderer.protect_literal_blocks(content);
486
487        // Should have no placeholders (unclosed block is treated as regular content)
488        assert_eq!(placeholders.len(), 0);
489
490        // Content should be preserved as-is
491        assert!(protected.contains("```literal"));
492        assert!(protected.contains("{{ template.syntax }}"));
493    }
494
495    #[test]
496    fn test_literal_blocks_with_indentation() {
497        let project_dir = std::env::current_dir().unwrap();
498        let renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
499
500        let content = r#"# Example
501
502    ```literal
503    {{ indented.template }}
504    ```"#;
505
506        let (_protected, placeholders) = renderer.protect_literal_blocks(content);
507
508        // Should detect indented literal blocks
509        assert_eq!(placeholders.len(), 1);
510
511        // Should preserve the indented template syntax
512        let placeholder_content = placeholders.get("__AGPM_LITERAL_BLOCK_0__").unwrap();
513        assert!(placeholder_content.contains("{{ indented.template }}"));
514    }
515
516    #[test]
517    fn test_literal_blocks_in_transitive_dependency_content() {
518        use std::fs;
519        use tempfile::TempDir;
520
521        let temp_dir = TempDir::new().unwrap();
522        let project_dir = temp_dir.path().to_path_buf();
523
524        // Create a dependency file with literal blocks containing template syntax
525        let dep_content = r#"---
526agpm.templating: true
527---
528# Dependency Documentation
529
530Here's a template example:
531
532```literal
533{{ nonexistent_variable }}
534{{ agpm.deps.something.else }}
535```
536
537This should appear literally."#;
538
539        // Write the dependency file
540        let dep_path = project_dir.join("dependency.md");
541        fs::write(&dep_path, dep_content).unwrap();
542
543        // First, render the dependency content (simulating what happens when processing a dependency)
544        let mut dep_renderer = TemplateRenderer::new(true, project_dir.clone(), None).unwrap();
545        let dep_context = TeraContext::new();
546        let rendered_dep = dep_renderer.render_template(dep_content, &dep_context).unwrap();
547
548        // The rendered dependency should have the literal block converted to a regular code fence
549        assert!(rendered_dep.contains("```\n{{ nonexistent_variable }}"));
550        assert!(rendered_dep.contains("{{ agpm.deps.something.else }}\n```"));
551
552        // Now simulate embedding this in a parent resource
553        let parent_template = r#"# Parent Resource
554
555## Embedded Documentation
556
557{{ dependency_content }}
558
559## End"#;
560
561        // Create context with the rendered dependency content
562        let mut parent_context = TeraContext::new();
563        parent_context.insert("dependency_content", &rendered_dep);
564
565        // Render the parent (with templating enabled)
566        let mut parent_renderer = TemplateRenderer::new(true, project_dir.clone(), None).unwrap();
567        let final_output =
568            parent_renderer.render_template(parent_template, &parent_context).unwrap();
569
570        // Verify the final output contains the template syntax literally
571        assert!(
572            final_output.contains("{{ nonexistent_variable }}"),
573            "Template syntax from literal block should appear literally in final output"
574        );
575        assert!(
576            final_output.contains("{{ agpm.deps.something.else }}"),
577            "Template syntax from literal block should appear literally in final output"
578        );
579
580        // Verify it's in a code fence
581        assert!(
582            final_output.contains("```\n{{ nonexistent_variable }}"),
583            "Literal content should be in a code fence"
584        );
585
586        // Verify it doesn't cause rendering errors
587        assert!(!final_output.contains("__AGPM_LITERAL_BLOCK_"), "No placeholders should remain");
588    }
589
590    #[test]
591    fn test_literal_blocks_with_nested_dependencies() {
592        let project_dir = std::env::current_dir().unwrap();
593        let mut renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
594
595        // Simulate a dependency that was already rendered with literal blocks
596        let dep_content = r#"# Helper Snippet
597
598Use this syntax:
599
600```
601{{ agpm.deps.snippets.example.content }}
602{{ missing.variable }}
603```
604
605Done."#;
606
607        // Now embed this in a parent template
608        let parent_template = r#"# Main Agent
609
610## Documentation
611
612{{ helper_content }}
613
614The agent uses templating."#;
615
616        let mut context = TeraContext::new();
617        context.insert("helper_content", dep_content);
618
619        let result = renderer.render_template(parent_template, &context).unwrap();
620
621        // The template syntax from the dependency should be preserved
622        assert!(result.contains("{{ agpm.deps.snippets.example.content }}"));
623        assert!(result.contains("{{ missing.variable }}"));
624
625        // It should be in a code fence
626        assert!(result.contains("```\n{{ agpm.deps.snippets.example.content }}"));
627    }
628
629    #[tokio::test]
630    async fn test_non_templated_dependency_content_is_guarded() {
631        use tempfile::TempDir;
632        use tokio::fs;
633
634        let temp_dir = TempDir::new().unwrap();
635        let project_dir = temp_dir.path().to_path_buf();
636
637        let snippets_dir = project_dir.join("snippets");
638        fs::create_dir_all(&snippets_dir).await.unwrap();
639        let snippet_path = snippets_dir.join("non-templated.md");
640        let snippet_content = r#"---
641agpm:
642  templating: false
643---
644# Example Snippet
645
646This should show {{ agpm.deps.some.content }} literally.
647"#;
648        fs::write(&snippet_path, snippet_content).await.unwrap();
649
650        // Also create the installed file at the location referenced in the lockfile
651        let installed_snippets_dir = project_dir.join(".claude/snippets");
652        fs::create_dir_all(&installed_snippets_dir).await.unwrap();
653        let installed_snippet_path = installed_snippets_dir.join("non-templated.md");
654        fs::write(&installed_snippet_path, snippet_content).await.unwrap();
655
656        let mut lockfile = LockFile::default();
657        lockfile.commands.push(
658            LockedResourceBuilder::new(
659                "test-command".to_string(),
660                "commands/test.md".to_string(),
661                "sha256:test-command".to_string(),
662                ".claude/commands/test.md".to_string(),
663                ResourceType::Command,
664            )
665            .dependencies(vec!["snippet:non_templated".to_string()])
666            .tool(Some("claude-code".to_string()))
667            .applied_patches(std::collections::BTreeMap::new())
668            .variant_inputs(crate::resolver::lockfile_builder::VariantInputs::default())
669            .build(),
670        );
671        lockfile.snippets.push(
672            LockedResourceBuilder::new(
673                "non_templated".to_string(),
674                "snippets/non-templated.md".to_string(),
675                "sha256:test-snippet".to_string(),
676                ".claude/snippets/non-templated.md".to_string(),
677                ResourceType::Snippet,
678            )
679            .dependencies(vec![])
680            .tool(Some("claude-code".to_string()))
681            .applied_patches(std::collections::BTreeMap::new())
682            .variant_inputs(crate::resolver::lockfile_builder::VariantInputs::default())
683            .build(),
684        );
685
686        let cache = crate::cache::Cache::new().unwrap();
687        let builder = TemplateContextBuilder::new(
688            Arc::new(lockfile),
689            None,
690            Arc::new(cache),
691            project_dir.clone(),
692        );
693        let variant_inputs = serde_json::json!({});
694        let hash = crate::utils::compute_variant_inputs_hash(&variant_inputs).unwrap();
695        let resource_id = crate::lockfile::ResourceId::new(
696            "test-command",
697            None::<String>,
698            Some("claude-code"),
699            ResourceType::Command,
700            hash,
701        );
702        let (context, _checksum) =
703            builder.build_context(&resource_id, &variant_inputs).await.unwrap();
704
705        let mut renderer = TemplateRenderer::new(true, project_dir.clone(), None).unwrap();
706        let template = r#"# Combined Output
707
708{{ agpm.deps.snippets.non_templated.content }}
709"#;
710        let rendered = renderer.render_template(template, &context).unwrap();
711
712        assert!(
713            rendered.contains("# Example Snippet"),
714            "Rendered output should include the snippet heading"
715        );
716        assert!(
717            rendered.contains("{{ agpm.deps.some.content }}"),
718            "Template syntax inside non-templated dependency should remain literal"
719        );
720        assert!(
721            !rendered.contains(NON_TEMPLATED_LITERAL_GUARD_START)
722                && !rendered.contains(NON_TEMPLATED_LITERAL_GUARD_END),
723            "Internal literal guard markers should not leak into rendered output"
724        );
725        assert!(
726            !rendered.contains("```literal"),
727            "Synthetic literal fences should be removed after rendering"
728        );
729    }
730
731    #[tokio::test]
732    async fn test_template_vars_override() {
733        use serde_json::json;
734
735        let mut lockfile = create_test_lockfile();
736
737        // Add a second agent with template_vars to test overrides
738        let template_vars = json!({
739            "project": {
740                "language": "python",
741                "framework": "fastapi"
742            },
743            "custom": {
744                "style": "functional"
745            }
746        });
747
748        let variant_inputs_obj =
749            crate::resolver::lockfile_builder::VariantInputs::new(template_vars.clone());
750        lockfile.agents.push(LockedResource {
751            name: "test-agent-python".to_string(),
752            source: Some("community".to_string()),
753            url: Some("https://github.com/example/community.git".to_string()),
754            path: "agents/test-agent.md".to_string(),
755            version: Some("v1.0.0".to_string()),
756            resolved_commit: Some("abc123def456".to_string()),
757            checksum: "sha256:testchecksum2".to_string(),
758            installed_at: ".claude/agents/test-agent-python.md".to_string(),
759            dependencies: vec![],
760            resource_type: ResourceType::Agent,
761            tool: Some("claude-code".to_string()),
762            manifest_alias: None,
763            applied_patches: std::collections::BTreeMap::new(),
764            install: None,
765            variant_inputs: variant_inputs_obj.clone(),
766            context_checksum: None,
767        });
768
769        let cache = crate::cache::Cache::new().unwrap();
770        let project_dir = std::env::current_dir().unwrap();
771
772        // Create manifest with project config
773        let project_config = {
774            let mut map = toml::map::Map::new();
775            map.insert("language".to_string(), toml::Value::String("rust".into()));
776            map.insert("framework".to_string(), toml::Value::String("tokio".into()));
777            crate::manifest::ProjectConfig::from(map)
778        };
779
780        let builder = TemplateContextBuilder::new(
781            Arc::new(lockfile),
782            Some(project_config),
783            Arc::new(cache),
784            project_dir.clone(),
785        );
786
787        // Build context without template_vars
788        let variant_inputs_empty = serde_json::json!({});
789        let hash_empty = crate::utils::compute_variant_inputs_hash(&variant_inputs_empty).unwrap();
790        let resource_id_no_override = crate::lockfile::ResourceId::new(
791            "test-agent",
792            Some("community"),
793            Some("claude-code"),
794            ResourceType::Agent,
795            hash_empty,
796        );
797        let (context_without_override, _checksum) =
798            builder.build_context(&resource_id_no_override, &variant_inputs_empty).await.unwrap();
799
800        // Build context WITH template_vars (different lockfile entry)
801        // Must use the same variant_inputs that was stored in the lockfile
802        let hash_with_override = crate::utils::compute_variant_inputs_hash(&template_vars).unwrap();
803        let resource_id_with_override = crate::lockfile::ResourceId::new(
804            "test-agent-python",
805            Some("community"),
806            Some("claude-code"),
807            ResourceType::Agent,
808            hash_with_override,
809        );
810        let (context_with_override, _checksum) =
811            builder.build_context(&resource_id_with_override, &template_vars).await.unwrap();
812
813        // Test without overrides - should use project defaults
814        let project_dir = std::env::current_dir().unwrap();
815        let mut renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
816
817        let template = "Language: {{ project.language }}, Framework: {{ project.framework }}";
818
819        let rendered_without =
820            renderer.render_template(template, &context_without_override).unwrap();
821        assert_eq!(rendered_without, "Language: rust, Framework: tokio");
822
823        // Test with overrides - should use overridden values
824        let rendered_with = renderer.render_template(template, &context_with_override).unwrap();
825        assert_eq!(rendered_with, "Language: python, Framework: fastapi");
826
827        // Test new namespace from overrides
828        let custom_template = "Style: {{ custom.style }}";
829        let rendered_custom =
830            renderer.render_template(custom_template, &context_with_override).unwrap();
831        assert_eq!(rendered_custom, "Style: functional");
832    }
833
834    #[tokio::test]
835    async fn test_template_vars_deep_merge() {
836        use serde_json::json;
837
838        let mut lockfile = create_test_lockfile();
839
840        // Override only some database fields, leaving others unchanged
841        let template_vars = json!({
842            "project": {
843                "database": {
844                    "host": "db.example.com",
845                    "ssl": true
846                }
847            }
848        });
849
850        // Add a second agent with template_vars for deep merge testing
851        let variant_inputs =
852            crate::resolver::lockfile_builder::VariantInputs::new(template_vars.clone());
853        lockfile.agents.push(LockedResource {
854            name: "test-agent-merged".to_string(),
855            source: Some("community".to_string()),
856            url: Some("https://github.com/example/community.git".to_string()),
857            path: "agents/test-agent.md".to_string(),
858            version: Some("v1.0.0".to_string()),
859            resolved_commit: Some("abc123def456".to_string()),
860            checksum: "sha256:testchecksum3".to_string(),
861            installed_at: ".claude/agents/test-agent-merged.md".to_string(),
862            dependencies: vec![],
863            resource_type: ResourceType::Agent,
864            tool: Some("claude-code".to_string()),
865            manifest_alias: None,
866            applied_patches: std::collections::BTreeMap::new(),
867            install: None,
868            variant_inputs,
869            context_checksum: None,
870        });
871
872        let cache = crate::cache::Cache::new().unwrap();
873        let project_dir = std::env::current_dir().unwrap();
874
875        // Create manifest with nested project config
876        let project_config = {
877            let mut map = toml::map::Map::new();
878            let mut db_table = toml::map::Map::new();
879            db_table.insert("type".to_string(), toml::Value::String("postgres".into()));
880            db_table.insert("host".to_string(), toml::Value::String("localhost".into()));
881            db_table.insert("port".to_string(), toml::Value::Integer(5432));
882            map.insert("database".to_string(), toml::Value::Table(db_table));
883            map.insert("language".to_string(), toml::Value::String("rust".into()));
884            crate::manifest::ProjectConfig::from(map)
885        };
886
887        let builder = TemplateContextBuilder::new(
888            Arc::new(lockfile),
889            Some(project_config),
890            Arc::new(cache),
891            project_dir.clone(),
892        );
893
894        // Must use the same variant_inputs that was stored in the lockfile
895        let hash_for_id = crate::utils::compute_variant_inputs_hash(&template_vars).unwrap();
896        let resource_id = crate::lockfile::ResourceId::new(
897            "test-agent-merged",
898            Some("community"),
899            Some("claude-code"),
900            ResourceType::Agent,
901            hash_for_id,
902        );
903        let (context, _checksum) =
904            builder.build_context(&resource_id, &template_vars).await.unwrap();
905
906        let project_dir = std::env::current_dir().unwrap();
907        let mut renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
908
909        // Test that merge kept original values and added new ones
910        let template = r#"
911DB Type: {{ project.database.type }}
912DB Host: {{ project.database.host }}
913DB Port: {{ project.database.port }}
914DB SSL: {{ project.database.ssl }}
915Language: {{ project.language }}
916"#;
917
918        let rendered = renderer.render_template(template, &context).unwrap();
919
920        // Original values should be preserved
921        assert!(rendered.contains("DB Type: postgres"));
922        assert!(rendered.contains("DB Port: 5432"));
923        assert!(rendered.contains("Language: rust"));
924
925        // Overridden value should be used
926        assert!(rendered.contains("DB Host: db.example.com"));
927
928        // New value should be added
929        assert!(rendered.contains("DB SSL: true"));
930    }
931
932    #[tokio::test]
933    async fn test_template_vars_empty_object_noop() {
934        use serde_json::json;
935
936        let mut lockfile = create_test_lockfile();
937
938        // Empty object should be a no-op
939        let template_vars = json!({});
940        let variant_inputs =
941            crate::resolver::lockfile_builder::VariantInputs::new(template_vars.clone());
942
943        lockfile.agents.push(LockedResource {
944            name: "test-agent-empty".to_string(),
945            source: Some("community".to_string()),
946            url: Some("https://github.com/example/community.git".to_string()),
947            path: "agents/test-agent.md".to_string(),
948            version: Some("v1.0.0".to_string()),
949            resolved_commit: Some("abc123def456".to_string()),
950            checksum: "sha256:empty".to_string(),
951            installed_at: ".claude/agents/test-agent-empty.md".to_string(),
952            dependencies: vec![],
953            resource_type: ResourceType::Agent,
954            tool: Some("claude-code".to_string()),
955            manifest_alias: None,
956            applied_patches: std::collections::BTreeMap::new(),
957            install: None,
958            variant_inputs,
959            context_checksum: None,
960        });
961
962        let cache = crate::cache::Cache::new().unwrap();
963        let project_dir = std::env::current_dir().unwrap();
964
965        // Create manifest with project config
966        let project_config = {
967            let mut map = toml::map::Map::new();
968            map.insert("language".to_string(), toml::Value::String("rust".into()));
969            map.insert("version".to_string(), toml::Value::String("1.0".into()));
970            crate::manifest::ProjectConfig::from(map)
971        };
972
973        let builder = TemplateContextBuilder::new(
974            Arc::new(lockfile),
975            Some(project_config),
976            Arc::new(cache),
977            project_dir.clone(),
978        );
979
980        // Must use the same variant_inputs that was stored in the lockfile
981        let hash_for_id = crate::utils::compute_variant_inputs_hash(&template_vars).unwrap();
982        let resource_id = crate::lockfile::ResourceId::new(
983            "test-agent-empty",
984            Some("community"),
985            Some("claude-code"),
986            ResourceType::Agent,
987            hash_for_id,
988        );
989
990        let (context, _checksum) =
991            builder.build_context(&resource_id, &template_vars).await.unwrap();
992
993        let project_dir = std::env::current_dir().unwrap();
994        let mut renderer = TemplateRenderer::new(true, project_dir, None).unwrap();
995
996        // Empty template_vars should not change project config
997        let template = "Language: {{ project.language }}, Version: {{ project.version }}";
998        let rendered = renderer.render_template(template, &context).unwrap();
999        assert_eq!(rendered, "Language: rust, Version: 1.0");
1000    }
1001
1002    #[tokio::test]
1003    async fn test_template_vars_null_values() {
1004        use serde_json::json;
1005
1006        let mut lockfile = create_test_lockfile();
1007
1008        // Null value should replace field with JSON null
1009        let template_vars = json!({
1010            "project": {
1011                "optional_field": null
1012            }
1013        });
1014        let variant_inputs =
1015            crate::resolver::lockfile_builder::VariantInputs::new(template_vars.clone());
1016
1017        lockfile.agents.push(LockedResource {
1018            name: "test-agent-null".to_string(),
1019            source: Some("community".to_string()),
1020            url: Some("https://github.com/example/community.git".to_string()),
1021            path: "agents/test-agent.md".to_string(),
1022            version: Some("v1.0.0".to_string()),
1023            resolved_commit: Some("abc123def456".to_string()),
1024            checksum: "sha256:null".to_string(),
1025            installed_at: ".claude/agents/test-agent-null.md".to_string(),
1026            dependencies: vec![],
1027            resource_type: ResourceType::Agent,
1028            tool: Some("claude-code".to_string()),
1029            manifest_alias: None,
1030            applied_patches: std::collections::BTreeMap::new(),
1031            install: None,
1032            variant_inputs,
1033            context_checksum: None,
1034        });
1035
1036        let cache = crate::cache::Cache::new().unwrap();
1037        let project_dir = std::env::current_dir().unwrap();
1038
1039        // Create manifest with project config
1040        let project_config = {
1041            let mut map = toml::map::Map::new();
1042            map.insert("language".to_string(), toml::Value::String("rust".into()));
1043            crate::manifest::ProjectConfig::from(map)
1044        };
1045
1046        let builder = TemplateContextBuilder::new(
1047            Arc::new(lockfile),
1048            Some(project_config),
1049            Arc::new(cache),
1050            project_dir.clone(),
1051        );
1052
1053        // Must use the same variant_inputs that was stored in the lockfile
1054        let hash_for_id = crate::utils::compute_variant_inputs_hash(&template_vars).unwrap();
1055        let resource_id = crate::lockfile::ResourceId::new(
1056            "test-agent-null",
1057            Some("community"),
1058            Some("claude-code"),
1059            ResourceType::Agent,
1060            hash_for_id,
1061        );
1062
1063        let (context, _checksum) =
1064            builder.build_context(&resource_id, &template_vars).await.unwrap();
1065
1066        // Verify null is present in context
1067        let agpm_value = context.get("agpm").expect("agpm should exist");
1068        let agpm_obj = agpm_value.as_object().expect("agpm should be an object");
1069
1070        // Check both agpm.project and project namespaces
1071        let project_value = agpm_obj.get("project").expect("project should exist");
1072        let project_obj = project_value.as_object().expect("project should be an object");
1073        assert!(project_obj.get("optional_field").is_some());
1074        assert!(project_obj["optional_field"].is_null());
1075
1076        // Also verify in top-level project namespace
1077        let top_project = context.get("project").expect("top-level project should exist");
1078        let top_project_obj = top_project.as_object().expect("should be object");
1079        assert!(top_project_obj.get("optional_field").is_some());
1080        assert!(top_project_obj["optional_field"].is_null());
1081    }
1082}