agpm_cli/templating/context.rs
1//! Template context building for AGPM resource installation.
2//!
3//! This module provides structures and methods for building the template context
4//! that will be available to Markdown templates during rendering.
5
6use anyhow::{Context, Result, anyhow};
7use serde::{Deserialize, Serialize};
8use serde_json::{Map, to_string, to_value};
9use std::collections::{BTreeMap, HashMap, HashSet};
10use std::path::PathBuf;
11use std::sync::{Arc, Mutex};
12use tera::Context as TeraContext;
13
14use crate::core::ResourceType;
15use crate::lockfile::{LockFile, ResourceId};
16
17use super::cache::RenderCache;
18use super::content::ContentExtractor;
19use super::dependencies::DependencyExtractor;
20use super::utils::{deep_merge_json, to_native_path_display};
21
22/// Maximum recursion depth for template rendering to prevent stack overflow.
23///
24/// This limit prevents infinite recursion or extremely deep dependency chains
25/// that could cause stack overflow during template rendering. A depth of 50
26/// should be more than sufficient for any realistic use case while protecting
27/// against pathological cases.
28const MAX_RECURSION_DEPTH: usize = 50;
29
30/// Metadata about the current resource being rendered.
31///
32/// This struct represents information about the resource that is currently
33/// being rendered (available as `agpm.resource` in templates). It contains
34/// metadata but NOT content, since the content IS the template being rendered.
35#[derive(Clone, Serialize, Deserialize, Debug)]
36pub struct ResourceMetadata {
37 /// Resource type (agent, snippet, command, etc.)
38 #[serde(rename = "type")]
39 pub resource_type: String,
40 /// Logical resource name from manifest
41 pub name: String,
42 /// Resolved installation path
43 pub install_path: String,
44 /// Source identifier (if applicable)
45 pub source: Option<String>,
46 /// Resolved version (if applicable)
47 pub version: Option<String>,
48 /// Git commit SHA (if applicable)
49 pub resolved_commit: Option<String>,
50 /// SHA256 checksum of the content
51 pub checksum: String,
52 /// Source-relative path in repository
53 pub path: String,
54}
55
56/// Complete data about a dependency for template embedding.
57///
58/// This struct represents a dependency that can be embedded in templates
59/// (available as `agpm.deps.<type>.<name>` in templates). It includes
60/// the processed content of the dependency file, ready for embedding.
61#[derive(Clone, Serialize, Deserialize)]
62pub struct DependencyData {
63 /// Resource type (agent, snippet, command, etc.)
64 #[serde(rename = "type")]
65 pub resource_type: String,
66 /// Logical resource name from manifest
67 pub name: String,
68 /// Resolved installation path
69 pub install_path: String,
70 /// Source identifier (if applicable)
71 pub source: Option<String>,
72 /// Resolved version (if applicable)
73 pub version: Option<String>,
74 /// Git commit SHA (if applicable)
75 pub resolved_commit: Option<String>,
76 /// SHA256 checksum of the content
77 pub checksum: String,
78 /// Source-relative path in repository
79 pub path: String,
80 /// Processed content of the dependency file.
81 ///
82 /// Contains the file content with metadata stripped and optionally rendered:
83 /// - For Markdown: Content without YAML frontmatter
84 /// - For JSON: Content without metadata fields
85 ///
86 /// This enables template embedding via `{{ agpm.deps.<type>.<name>.content }}`.
87 ///
88 /// Note: This field is large and should not be printed in debug output.
89 /// Use the Debug impl which shows only the content length.
90 pub content: String,
91}
92
93impl std::fmt::Debug for DependencyData {
94 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 f.debug_struct("DependencyData")
96 .field("resource_type", &self.resource_type)
97 .field("name", &self.name)
98 .field("install_path", &self.install_path)
99 .field("source", &self.source)
100 .field("version", &self.version)
101 .field("resolved_commit", &self.resolved_commit)
102 .field("checksum", &self.checksum)
103 .field("path", &self.path)
104 .field("content", &format!("<{} bytes>", self.content.len()))
105 .finish()
106 }
107}
108
109/// Template context builder for AGPM resource installation.
110///
111/// This struct is responsible for building the template context that will be
112/// available to Markdown templates during rendering. It collects data from
113/// the manifest, lockfile, and current resource being processed.
114///
115/// # Context Structure
116///
117/// The built context follows this structure:
118/// ```json
119/// {
120/// "agpm": {
121/// "resource": {
122/// "type": "agent",
123/// "name": "example-agent",
124/// "install_path": ".claude/agents/example.md",
125/// "source": "community",
126/// "version": "v1.0.0",
127/// "resolved_commit": "abc123...",
128/// "checksum": "sha256:...",
129/// "path": "agents/example.md"
130/// },
131/// "deps": {
132/// "agents": {
133/// "helper": {
134/// "install_path": ".claude/agents/helper.md",
135/// "version": "v1.0.0",
136/// "resolved_commit": "def456...",
137/// "checksum": "sha256:...",
138/// "source": "community",
139/// "path": "agents/helper.md"
140/// }
141/// },
142/// "snippets": { ... },
143/// "commands": { ... }
144/// }
145/// }
146/// }
147/// ```
148pub struct TemplateContextBuilder {
149 /// The lockfile containing resolved resource information
150 /// Shared via Arc to avoid expensive clones when building contexts for multiple resources
151 lockfile: Arc<LockFile>,
152 /// Project-specific template variables from the manifest
153 project_config: Option<crate::manifest::ProjectConfig>,
154 /// Cache instance for reading source files during content extraction
155 /// Shared via Arc to avoid expensive clones
156 cache: Arc<crate::cache::Cache>,
157 /// Project root directory for resolving local file paths
158 project_dir: PathBuf,
159 /// Cache of rendered content to avoid re-rendering same dependencies
160 /// Shared via `Arc<Mutex>` for safe concurrent access during template rendering
161 render_cache: Arc<Mutex<RenderCache>>,
162 /// Cache of parsed custom dependency names to avoid re-reading and re-parsing files
163 /// Maps resource ID (name@type) to custom name mappings (dep_ref -> custom_name)
164 /// Shared via `Arc<Mutex>` for safe concurrent access
165 custom_names_cache: Arc<Mutex<HashMap<String, BTreeMap<String, String>>>>,
166 /// Cache of parsed dependency specifications to avoid re-reading and re-parsing files
167 /// Maps resource ID (name@type) to full DependencySpec objects (dep_ref -> DependencySpec)
168 /// Shared via `Arc<Mutex>` for safe concurrent access
169 dependency_specs_cache:
170 Arc<Mutex<HashMap<String, BTreeMap<String, crate::manifest::DependencySpec>>>>,
171}
172
173impl TemplateContextBuilder {
174 /// Create a new template context builder.
175 ///
176 /// # Arguments
177 ///
178 /// * `lockfile` - The resolved lockfile, wrapped in Arc for efficient sharing
179 /// * `project_config` - Optional project-specific template variables from the manifest
180 /// * `cache` - Cache instance for reading source files during content extraction
181 /// * `project_dir` - Project root directory for resolving local file paths
182 pub fn new(
183 lockfile: Arc<LockFile>,
184 project_config: Option<crate::manifest::ProjectConfig>,
185 cache: Arc<crate::cache::Cache>,
186 project_dir: PathBuf,
187 ) -> Self {
188 Self {
189 lockfile,
190 project_config,
191 cache,
192 project_dir,
193 render_cache: Arc::new(Mutex::new(RenderCache::new())),
194 custom_names_cache: Arc::new(Mutex::new(HashMap::new())),
195 dependency_specs_cache: Arc::new(Mutex::new(HashMap::new())),
196 }
197 }
198
199 /// Clear the render cache.
200 ///
201 /// Should be called after installation completes to free memory
202 /// and ensure next installation starts with a fresh cache.
203 pub fn clear_render_cache(&self) {
204 if let Ok(mut cache) = self.render_cache.lock() {
205 cache.clear();
206 }
207 }
208
209 /// Clear the custom names cache.
210 ///
211 /// Should be called after installation completes to free memory
212 /// and ensure next installation starts with a fresh cache.
213 pub fn clear_custom_names_cache(&self) {
214 if let Ok(mut cache) = self.custom_names_cache.lock() {
215 cache.clear();
216 }
217 }
218
219 /// Clear the dependency specs cache.
220 ///
221 /// Should be called after installation completes to free memory
222 /// and ensure next installation starts with a fresh cache.
223 pub fn clear_dependency_specs_cache(&self) {
224 if let Ok(mut cache) = self.dependency_specs_cache.lock() {
225 cache.clear();
226 }
227 }
228
229 /// Get render cache statistics.
230 ///
231 /// Returns (hits, misses, hit_rate) where hit_rate is a percentage.
232 pub fn render_cache_stats(&self) -> Option<(usize, usize, f64)> {
233 self.render_cache.lock().ok().map(|cache| {
234 let (hits, misses) = cache.stats();
235 let hit_rate = cache.hit_rate();
236 (hits, misses, hit_rate)
237 })
238 }
239
240 /// Build the complete template context for a specific resource.
241 ///
242 /// # Arguments
243 ///
244 /// * `resource_name` - Name of the resource being rendered
245 /// * `resource_type` - Type of the resource (agents, snippets, etc.)
246 /// * `template_vars_override` - Optional template variable overrides for this specific resource.
247 /// Overrides are deep-merged into the base context, preserving unmodified fields.
248 ///
249 /// # Returns
250 ///
251 /// Returns a Tera `Context` containing all available template variables.
252 ///
253 /// # Template Variable Override Behavior
254 ///
255 /// When `template_vars_override` is provided, it is deep-merged into the base template context:
256 ///
257 /// - **Objects**: Recursively merged, preserving fields not present in override
258 /// - **Primitives/Arrays**: Completely replaced by override value
259 /// - **Null values**: Replace existing value with JSON null (may cause template errors)
260 /// - **Empty objects**: No-op (no changes applied)
261 ///
262 /// Special handling for `project` namespace: Updates both `agpm.project` (canonical)
263 /// and top-level `project` (convenience alias) to maintain consistency.
264 ///
265 /// # Examples
266 ///
267 /// ```rust,no_run
268 /// # use serde_json::json;
269 /// # use agpm_cli::templating::TemplateContextBuilder;
270 /// # use agpm_cli::core::ResourceType;
271 /// # async fn example(builder: TemplateContextBuilder) -> anyhow::Result<()> {
272 /// // Base context has project.name = "agpm" and project.language = "rust"
273 /// let overrides = json!({
274 /// "project": {
275 /// "language": "python", // Replaces existing value
276 /// "framework": "fastapi" // Adds new field
277 /// }
278 /// });
279 ///
280 /// use agpm_cli::lockfile::ResourceId;
281 /// use agpm_cli::utils::compute_variant_inputs_hash;
282 /// // Create ResourceId with template_vars and ResourceType included
283 /// let variant_hash = compute_variant_inputs_hash(&overrides).unwrap_or_default();
284 /// let resource_id = ResourceId::new("agent", None::<String>, Some("claude-code"), ResourceType::Agent, variant_hash);
285 /// let (context, _context_checksum) = builder
286 /// .build_context(&resource_id, &overrides)
287 /// .await?;
288 ///
289 /// // Result: project.name preserved, language replaced, framework added
290 /// # Ok(())
291 /// # }
292 /// ```
293 pub async fn build_context(
294 &self,
295 resource_id: &ResourceId,
296 variant_inputs: &serde_json::Value,
297 ) -> Result<(TeraContext, Option<String>)> {
298 // Build the template context as before
299 let context = self
300 .build_context_with_visited(resource_id, variant_inputs, &mut HashSet::new())
301 .await?;
302
303 // Check if resource uses templating (optimized query)
304 let uses_templating = self.resource_uses_templating(resource_id).await?;
305
306 // Create optimized context with cached checksum
307 let context_with_checksum = ContextWithChecksum::new(context, uses_templating);
308
309 Ok(context_with_checksum.into_tuple())
310 }
311
312 /// Check if a resource has templating enabled.
313 ///
314 /// Returns true if the resource is a Markdown file with `agpm.templating: true`
315 /// in its frontmatter. Non-Markdown files always return false.
316 async fn resource_uses_templating(&self, resource_id: &ResourceId) -> Result<bool> {
317 // Look up resource in lockfile
318 let resource = self
319 .lockfile
320 .find_resource_by_id(resource_id)
321 .ok_or_else(|| anyhow!("Resource not found in lockfile"))?;
322
323 // Only Markdown files support templating
324 if !resource.path.ends_with(".md") {
325 return Ok(false);
326 }
327
328 // Determine source path (same logic as ContentExtractor::extract_content)
329 let source_path = if let Some(_source_name) = &resource.source {
330 let url = resource
331 .url
332 .as_ref()
333 .ok_or_else(|| anyhow!("Resource '{}' has source but no URL", resource.name))?;
334
335 // Check if this is a local directory source
336 if resource.is_local() {
337 // Local directory source - use URL as path directly
338 std::path::PathBuf::from(url).join(&resource.path)
339 } else {
340 // Git-based source - get worktree path
341 let sha = resource.resolved_commit.as_deref().ok_or_else(|| {
342 anyhow!("Resource '{}' has no resolved commit", resource.name)
343 })?;
344
345 // Use centralized worktree path construction
346 let worktree_dir = self.cache.get_worktree_path(url, sha)?;
347 worktree_dir.join(&resource.path)
348 }
349 } else {
350 // Local file - path is relative to project or absolute
351 let local_path = std::path::Path::new(&resource.path);
352 if local_path.is_absolute() {
353 local_path.to_path_buf()
354 } else {
355 self.project_dir.join(local_path)
356 }
357 };
358
359 // Read and parse the Markdown file
360 // If the file doesn't exist or can't be read, assume templating is disabled
361 let content = match tokio::fs::read_to_string(&source_path).await {
362 Ok(c) => c,
363 Err(e) => {
364 tracing::debug!(
365 "Could not read file for resource '{}' from {}: {}. Assuming templating disabled.",
366 resource.name,
367 source_path.display(),
368 e
369 );
370 return Ok(false);
371 }
372 };
373
374 // Parse the markdown document
375 // If parsing fails, assume templating is disabled
376 let doc = match crate::markdown::MarkdownDocument::parse(&content) {
377 Ok(d) => d,
378 Err(e) => {
379 tracing::debug!(
380 "Could not parse markdown for resource '{}': {}. Assuming templating disabled.",
381 resource.name,
382 e
383 );
384 return Ok(false);
385 }
386 };
387
388 // Check frontmatter for agpm.templating flag
389 Ok(super::content::is_markdown_templating_enabled(doc.metadata.as_ref()))
390 }
391
392 /// Build resource metadata for the template context.
393 ///
394 /// # Arguments
395 ///
396 /// * `resource` - The locked resource entry (already looked up by full ResourceId)
397 fn build_resource_data(&self, resource: &crate::lockfile::LockedResource) -> ResourceMetadata {
398 ResourceMetadata {
399 resource_type: resource.resource_type.to_string(),
400 name: resource.name.clone(),
401 install_path: to_native_path_display(&resource.installed_at),
402 source: resource.source.clone(),
403 version: resource.version.clone(),
404 resolved_commit: resource.resolved_commit.clone(),
405 checksum: resource.checksum.clone(),
406 path: resource.path.clone(),
407 }
408 }
409
410 /// Compute a stable digest of the template context data.
411 ///
412 /// This method creates a deterministic hash of all lockfile metadata that could
413 /// affect template rendering. The digest is used as part of the cache key to ensure
414 /// that changes to dependency versions or metadata properly invalidate the cache.
415 ///
416 /// # Returns
417 ///
418 /// Returns a hex-encoded string containing the first 16 characters of the SHA-256
419 /// hash of the serialized template context data. This is sufficient to uniquely
420 /// identify context changes while keeping the digest compact.
421 ///
422 /// # What's Included
423 ///
424 /// The digest includes all lockfile metadata that affects rendering:
425 /// - Resource names, types, and installation paths
426 /// - Dependency versions and resolved commits
427 /// - Checksums and source information
428 ///
429 /// # Determinism
430 ///
431 /// The hash is stable across runs because:
432 /// - Resources are sorted by type and name before hashing
433 /// - JSON serialization uses consistent ordering (BTreeMap)
434 /// - Only metadata fields that affect rendering are included
435 ///
436 /// # Examples
437 ///
438 /// ```rust,no_run
439 /// use agpm_cli::templating::TemplateContextBuilder;
440 /// use agpm_cli::lockfile::LockFile;
441 /// use std::path::{Path, PathBuf};
442 /// use std::sync::Arc;
443 ///
444 /// # fn example() -> anyhow::Result<()> {
445 /// let lockfile = LockFile::load(Path::new("agpm.lock"))?;
446 /// let cache = Arc::new(agpm_cli::cache::Cache::new()?);
447 /// let project_dir = std::env::current_dir()?;
448 /// let builder = TemplateContextBuilder::new(
449 /// Arc::new(lockfile),
450 /// None,
451 /// cache,
452 /// project_dir
453 /// );
454 ///
455 /// let digest = builder.compute_context_digest()?;
456 /// println!("Template context digest: {}", digest);
457 /// # Ok(())
458 /// # }
459 /// ```
460 pub fn compute_context_digest(&self) -> Result<String> {
461 use sha2::{Digest, Sha256};
462 use std::collections::BTreeMap;
463
464 // Build a deterministic representation of the lockfile data
465 // Use BTreeMap for consistent ordering
466 let mut digest_data: BTreeMap<String, BTreeMap<String, BTreeMap<&str, String>>> =
467 BTreeMap::new();
468
469 // Process each resource type in a consistent order
470 for resource_type in [
471 ResourceType::Agent,
472 ResourceType::Snippet,
473 ResourceType::Command,
474 ResourceType::Script,
475 ResourceType::Hook,
476 ResourceType::McpServer,
477 ] {
478 let resources = self.lockfile.get_resources_by_type(&resource_type);
479 if resources.is_empty() {
480 continue;
481 }
482
483 let type_str = resource_type.to_plural().to_string();
484 let mut sorted_resources: Vec<_> = resources.iter().collect();
485 // Sort by name for deterministic ordering
486 sorted_resources.sort_by(|a, b| a.name.cmp(&b.name));
487
488 let mut type_data = BTreeMap::new();
489 for resource in sorted_resources {
490 // Include only the fields that can affect template rendering
491 let mut resource_data: BTreeMap<&str, String> = BTreeMap::new();
492 resource_data.insert("name", resource.name.clone());
493 resource_data.insert("install_path", resource.installed_at.clone());
494 resource_data.insert("path", resource.path.clone());
495 resource_data.insert("checksum", resource.checksum.clone());
496
497 // Optional fields - only include if present
498 if let Some(ref source) = resource.source {
499 resource_data.insert("source", source.to_string());
500 }
501 if let Some(ref version) = resource.version {
502 resource_data.insert("version", version.to_string());
503 }
504 if let Some(ref commit) = resource.resolved_commit {
505 resource_data.insert("resolved_commit", commit.to_string());
506 }
507
508 type_data.insert(resource.name.clone(), resource_data);
509 }
510
511 digest_data.insert(type_str, type_data);
512 }
513
514 // Serialize to JSON for stable representation
515 let json_str =
516 to_string(&digest_data).context("Failed to serialize template context for digest")?;
517
518 // Compute SHA-256 hash
519 let mut hasher = Sha256::new();
520 hasher.update(json_str.as_bytes());
521 let hash = hasher.finalize();
522
523 // Return first 16 hex characters (64 bits) - sufficient for uniqueness
524 Ok(hex::encode(&hash[..8]))
525 }
526}
527
528/// A cached Tera context with pre-computed checksum for performance.
529///
530/// This structure optimizes repeated checksum calculations by computing
531/// the checksum once when the context is first created, then caching
532/// it for subsequent accesses.
533#[derive(Debug, Clone)]
534pub struct ContextWithChecksum {
535 /// The template context
536 pub context: TeraContext,
537 /// Pre-computed checksum for cache invalidation
538 pub checksum: Option<String>,
539}
540
541impl ContextWithChecksum {
542 /// Create a new context with optional checksum computation.
543 ///
544 /// The checksum is computed only if `compute_checksum` is true.
545 /// This avoids expensive hash calculations for non-templated resources.
546 #[must_use]
547 pub fn new(context: TeraContext, compute_checksum: bool) -> Self {
548 let checksum = if compute_checksum {
549 Self::compute_checksum(&context).ok()
550 } else {
551 None
552 };
553
554 Self {
555 context,
556 checksum,
557 }
558 }
559
560 /// Compute checksum of a Tera context for cache invalidation.
561 ///
562 /// Creates a deterministic hash based on the context data structure.
563 /// This ensures that changes to template inputs are detected.
564 fn compute_checksum(context: &TeraContext) -> Result<String> {
565 use crate::utils::canonicalize_json;
566 use sha2::{Digest, Sha256};
567
568 // Convert TeraContext to JSON Value using its built-in conversion
569 let context_clone = context.clone();
570 let json_value = context_clone.into_json();
571
572 // Serialize to deterministic JSON with preserved order
573 let json_str = canonicalize_json(&json_value)?;
574
575 // Compute SHA-256 hash
576 let mut hasher = Sha256::new();
577 hasher.update(json_str.as_bytes());
578 let hash = hasher.finalize();
579
580 Ok(format!("sha256:{}", hex::encode(hash)))
581 }
582
583 /// Get the context
584 #[must_use]
585 pub fn context(&self) -> &TeraContext {
586 &self.context
587 }
588
589 /// Get the checksum (if computed)
590 #[must_use]
591 pub fn checksum(&self) -> Option<&str> {
592 self.checksum.as_deref()
593 }
594
595 /// Convert to tuple for backward compatibility
596 #[must_use]
597 pub fn into_tuple(self) -> (TeraContext, Option<String>) {
598 (self.context, self.checksum)
599 }
600}
601
602// Implement ContentExtractor trait for TemplateContextBuilder
603impl ContentExtractor for TemplateContextBuilder {
604 fn cache(&self) -> &Arc<crate::cache::Cache> {
605 &self.cache
606 }
607
608 fn project_dir(&self) -> &PathBuf {
609 &self.project_dir
610 }
611}
612
613// Implement DependencyExtractor trait for TemplateContextBuilder
614impl DependencyExtractor for TemplateContextBuilder {
615 fn lockfile(&self) -> &Arc<LockFile> {
616 &self.lockfile
617 }
618
619 fn render_cache(&self) -> &Arc<Mutex<RenderCache>> {
620 &self.render_cache
621 }
622
623 fn custom_names_cache(&self) -> &Arc<Mutex<HashMap<String, BTreeMap<String, String>>>> {
624 &self.custom_names_cache
625 }
626
627 fn dependency_specs_cache(
628 &self,
629 ) -> &Arc<Mutex<HashMap<String, BTreeMap<String, crate::manifest::DependencySpec>>>> {
630 &self.dependency_specs_cache
631 }
632
633 async fn build_dependencies_data(
634 &self,
635 current_resource: &crate::lockfile::LockedResource,
636 rendering_stack: &mut HashSet<String>,
637 ) -> Result<BTreeMap<String, BTreeMap<String, DependencyData>>> {
638 // Call the builder function from the builders module
639 super::dependencies::build_dependencies_data(self, current_resource, rendering_stack).await
640 }
641
642 async fn build_context_with_visited(
643 &self,
644 resource_id: &ResourceId,
645 variant_inputs: &serde_json::Value,
646 rendering_stack: &mut HashSet<String>,
647 ) -> Result<TeraContext> {
648 // Check recursion depth to prevent stack overflow
649 if rendering_stack.len() >= MAX_RECURSION_DEPTH {
650 anyhow::bail!(
651 "Maximum recursion depth ({}) exceeded while rendering '{}'. \
652 This likely indicates a complex or cyclic dependency chain. \
653 Current stack contains {} resources.",
654 MAX_RECURSION_DEPTH,
655 resource_id.name(),
656 rendering_stack.len()
657 );
658 }
659
660 tracing::info!(
661 "Starting context build for '{}' (type: {:?}, depth: {})",
662 resource_id.name(),
663 resource_id.resource_type(),
664 rendering_stack.len()
665 );
666
667 let mut context = TeraContext::new();
668
669 // Build the nested agpm structure
670 let mut agpm = Map::new();
671
672 // Get the current resource to access its declared dependencies
673 let current_resource =
674 self.lockfile.find_resource_by_id(resource_id).with_context(|| {
675 format!(
676 "Resource '{}' of type {:?} not found in lockfile (source: {:?}, tool: {:?})",
677 resource_id.name(),
678 resource_id.resource_type(),
679 resource_id.source(),
680 resource_id.tool()
681 )
682 })?;
683
684 tracing::info!(
685 "Found resource '{}' with {} dependencies",
686 resource_id.name(),
687 current_resource.dependencies.len()
688 );
689
690 // Build current resource data (using already-looked-up resource to preserve full identity)
691 let resource_data = self.build_resource_data(current_resource);
692 agpm.insert("resource".to_string(), to_value(resource_data)?);
693
694 // Build dependency data from ALL lockfile resources + current resource's declared dependencies
695 tracing::info!("Building dependencies data for '{}'...", resource_id.name());
696
697 // Build dependencies using the dependency builder
698 let deps_data = self.build_dependencies_data(current_resource, rendering_stack).await?;
699 tracing::info!("Successfully built dependencies data with {} types", deps_data.len());
700 agpm.insert("deps".to_string(), to_value(deps_data)?);
701
702 // Add project variables if available
703 if let Some(ref project_config) = self.project_config {
704 let project_json = project_config.to_json_value();
705 agpm.insert("project".to_string(), project_json.clone());
706
707 // Also add at top level for convenience (will be overridden by template_vars if provided)
708 context.insert("project", &project_json);
709 }
710
711 // Insert the complete agpm object
712 context.insert("agpm", &agpm);
713
714 // Apply template variable overrides if provided (non-empty variant_inputs)
715 if let Some(overrides_obj) = variant_inputs.as_object() {
716 if !overrides_obj.is_empty() {
717 tracing::debug!(
718 "Applying template variable overrides for resource '{}'",
719 resource_id.name()
720 );
721
722 // Convert context to JSON for merging
723 let mut context_json = context.clone().into_json();
724
725 // Iterate through all keys in variant_inputs and merge them
726 for (key, value) in overrides_obj {
727 if key == "project" {
728 // Project vars need to be in both agpm.project and top-level project
729 let original_project = context_json
730 .get("agpm")
731 .and_then(|v| v.as_object())
732 .and_then(|o| o.get("project"))
733 .cloned()
734 .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
735
736 let merged_project = deep_merge_json(original_project, value);
737
738 // Update agpm.project
739 if let Some(agpm_obj) =
740 context_json.get_mut("agpm").and_then(|v| v.as_object_mut())
741 {
742 agpm_obj.insert("project".to_string(), merged_project.clone());
743 }
744
745 // Update top-level project
746 // SAFETY: context.into_json() always produces an object at the top level
747 context_json
748 .as_object_mut()
749 .expect("context JSON must be an object")
750 .insert("project".to_string(), merged_project);
751 } else {
752 // Other vars go to top-level context only
753 let original = context_json
754 .get(key)
755 .cloned()
756 .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
757 let merged = deep_merge_json(original, value);
758
759 // SAFETY: context.into_json() always produces an object at the top level
760 context_json
761 .as_object_mut()
762 .expect("context JSON must be an object")
763 .insert(key.clone(), merged);
764 }
765 }
766
767 // Replace context with merged result
768 context = TeraContext::from_serialize(&context_json)
769 .context("Failed to create context from merged template variables")?;
770
771 tracing::debug!(
772 "Applied template overrides: {}",
773 serde_json::to_string_pretty(&variant_inputs)
774 .unwrap_or_else(|_| "{}".to_string())
775 );
776 }
777 }
778
779 Ok(context)
780 }
781}
782
783#[cfg(test)]
784mod tests {
785 use super::*;
786
787 #[test]
788 fn test_context_with_checksum_computation() {
789 let mut context = TeraContext::new();
790 context.insert("test", "value");
791 context.insert("number", &42);
792
793 // Test with checksum computation enabled
794 let ctx_with_checksum = ContextWithChecksum::new(context.clone(), true);
795
796 assert!(ctx_with_checksum.checksum().is_some(), "Checksum should be computed when enabled");
797 assert_eq!(ctx_with_checksum.context(), &context, "Context should be preserved");
798
799 // Verify checksum is deterministic
800 let ctx_with_checksum2 = ContextWithChecksum::new(context.clone(), true);
801 assert_eq!(
802 ctx_with_checksum.checksum(),
803 ctx_with_checksum2.checksum(),
804 "Checksum should be deterministic for same context"
805 );
806 }
807
808 #[test]
809 fn test_context_with_checksum_disabled() {
810 let mut context = TeraContext::new();
811 context.insert("test", "value");
812
813 // Test with checksum computation disabled
814 let ctx_with_checksum = ContextWithChecksum::new(context.clone(), false);
815
816 assert!(
817 ctx_with_checksum.checksum().is_none(),
818 "Checksum should not be computed when disabled"
819 );
820 assert_eq!(ctx_with_checksum.context(), &context, "Context should be preserved");
821 }
822
823 #[test]
824 fn test_context_with_checksum_different_contexts() {
825 let mut context1 = TeraContext::new();
826 context1.insert("test", "value1");
827
828 let mut context2 = TeraContext::new();
829 context2.insert("test", "value2");
830
831 let ctx1 = ContextWithChecksum::new(context1, true);
832 let ctx2 = ContextWithChecksum::new(context2, true);
833
834 assert_ne!(
835 ctx1.checksum(),
836 ctx2.checksum(),
837 "Different contexts should have different checksums"
838 );
839 }
840
841 #[test]
842 fn test_context_with_checksum_into_tuple() {
843 let mut context = TeraContext::new();
844 context.insert("test", "value");
845
846 let ctx_with_checksum = ContextWithChecksum::new(context.clone(), true);
847 let (returned_context, returned_checksum) = ctx_with_checksum.into_tuple();
848
849 assert_eq!(returned_context, context, "Returned context should match original");
850 assert!(returned_checksum.is_some(), "Returned checksum should be present");
851 }
852
853 #[test]
854 fn test_context_with_checksum_complex_structure() {
855 let mut context = TeraContext::new();
856
857 // Add nested structure to test comprehensive checksum calculation
858 context.insert("simple", "value");
859 context.insert("number", &42);
860 context.insert("boolean", &true);
861
862 let ctx_with_checksum = ContextWithChecksum::new(context, true);
863
864 assert!(ctx_with_checksum.checksum().is_some(), "Complex context should produce checksum");
865
866 // Verify checksum format
867 let checksum = ctx_with_checksum.checksum().unwrap();
868 assert!(checksum.starts_with("sha256:"), "Checksum should have sha256: prefix");
869 assert_eq!(checksum.len(), 7 + 64, "SHA256 hex should be 64 characters plus prefix");
870 }
871}