1pub mod cache;
44pub mod content;
45pub mod context;
46pub mod dependencies;
47pub mod filters;
48pub mod renderer;
49pub mod utils;
50
51pub 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 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 }
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 let result = renderer.render_template("# Plain Markdown", &TeraContext::new()).unwrap();
126 assert_eq!(result, "# Plain Markdown");
127
128 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 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 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 assert!(
164 !error_msg.contains("__tera_one_off"),
165 "Error should not expose internal Tera template names"
166 );
167
168 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 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 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 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 if let Some(agent) = lockfile.agents.first_mut() {
242 agent.dependencies.push("snippet:test-snippet".to_string());
243 }
244
245 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 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 #[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 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 #[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 assert_eq!(placeholders.len(), 1);
335
336 assert!(protected.contains("__AGPM_LITERAL_BLOCK_0__"));
338
339 assert!(!protected.contains("{{ agpm.deps.snippets.example.content }}"));
341
342 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 assert_eq!(placeholders.len(), 2);
368
369 assert!(protected.contains("__AGPM_LITERAL_BLOCK_0__"));
371 assert!(protected.contains("__AGPM_LITERAL_BLOCK_1__"));
372
373 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 assert!(restored.contains("```\n{{ agpm.deps.snippets.example.content }}\n```"));
394
395 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 assert!(result.contains("# Agent: test-agent"));
423 assert!(result.contains("The agent name is: test-agent"));
424
425 assert!(result.contains("```\n{{ agpm.deps.snippets.helper.content }}\n```"));
427
428 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 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 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 assert_eq!(placeholders.len(), 0);
489
490 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 assert_eq!(placeholders.len(), 1);
510
511 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 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 let dep_path = project_dir.join("dependency.md");
541 fs::write(&dep_path, dep_content).unwrap();
542
543 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 assert!(rendered_dep.contains("```\n{{ nonexistent_variable }}"));
550 assert!(rendered_dep.contains("{{ agpm.deps.something.else }}\n```"));
551
552 let parent_template = r#"# Parent Resource
554
555## Embedded Documentation
556
557{{ dependency_content }}
558
559## End"#;
560
561 let mut parent_context = TeraContext::new();
563 parent_context.insert("dependency_content", &rendered_dep);
564
565 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 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 assert!(
582 final_output.contains("```\n{{ nonexistent_variable }}"),
583 "Literal content should be in a code fence"
584 );
585
586 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 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 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 assert!(result.contains("{{ agpm.deps.snippets.example.content }}"));
623 assert!(result.contains("{{ missing.variable }}"));
624
625 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 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 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 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 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 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 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 let rendered_with = renderer.render_template(template, &context_with_override).unwrap();
825 assert_eq!(rendered_with, "Language: python, Framework: fastapi");
826
827 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 let template_vars = json!({
842 "project": {
843 "database": {
844 "host": "db.example.com",
845 "ssl": true
846 }
847 }
848 });
849
850 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 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 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 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 assert!(rendered.contains("DB Type: postgres"));
922 assert!(rendered.contains("DB Port: 5432"));
923 assert!(rendered.contains("Language: rust"));
924
925 assert!(rendered.contains("DB Host: db.example.com"));
927
928 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 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 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 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 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 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 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 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 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 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 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}