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            let is_local_source = resource.resolved_commit.as_deref().is_none_or(str::is_empty);
337
338            if is_local_source {
339                // Local directory source - use URL as path directly
340                std::path::PathBuf::from(url).join(&resource.path)
341            } else {
342                // Git-based source - get worktree path
343                let sha = resource.resolved_commit.as_deref().ok_or_else(|| {
344                    anyhow!("Resource '{}' has no resolved commit", resource.name)
345                })?;
346
347                // Use centralized worktree path construction
348                let worktree_dir = self.cache.get_worktree_path(url, sha)?;
349                worktree_dir.join(&resource.path)
350            }
351        } else {
352            // Local file - path is relative to project or absolute
353            let local_path = std::path::Path::new(&resource.path);
354            if local_path.is_absolute() {
355                local_path.to_path_buf()
356            } else {
357                self.project_dir.join(local_path)
358            }
359        };
360
361        // Read and parse the Markdown file
362        // If the file doesn't exist or can't be read, assume templating is disabled
363        let content = match tokio::fs::read_to_string(&source_path).await {
364            Ok(c) => c,
365            Err(e) => {
366                tracing::debug!(
367                    "Could not read file for resource '{}' from {}: {}. Assuming templating disabled.",
368                    resource.name,
369                    source_path.display(),
370                    e
371                );
372                return Ok(false);
373            }
374        };
375
376        // Parse the markdown document
377        // If parsing fails, assume templating is disabled
378        let doc = match crate::markdown::MarkdownDocument::parse(&content) {
379            Ok(d) => d,
380            Err(e) => {
381                tracing::debug!(
382                    "Could not parse markdown for resource '{}': {}. Assuming templating disabled.",
383                    resource.name,
384                    e
385                );
386                return Ok(false);
387            }
388        };
389
390        // Check frontmatter for agpm.templating flag
391        Ok(super::content::is_markdown_templating_enabled(doc.metadata.as_ref()))
392    }
393
394    /// Build resource metadata for the template context.
395    ///
396    /// # Arguments
397    ///
398    /// * `resource` - The locked resource entry (already looked up by full ResourceId)
399    fn build_resource_data(&self, resource: &crate::lockfile::LockedResource) -> ResourceMetadata {
400        ResourceMetadata {
401            resource_type: resource.resource_type.to_string(),
402            name: resource.name.clone(),
403            install_path: to_native_path_display(&resource.installed_at),
404            source: resource.source.clone(),
405            version: resource.version.clone(),
406            resolved_commit: resource.resolved_commit.clone(),
407            checksum: resource.checksum.clone(),
408            path: resource.path.clone(),
409        }
410    }
411
412    /// Compute a stable digest of the template context data.
413    ///
414    /// This method creates a deterministic hash of all lockfile metadata that could
415    /// affect template rendering. The digest is used as part of the cache key to ensure
416    /// that changes to dependency versions or metadata properly invalidate the cache.
417    ///
418    /// # Returns
419    ///
420    /// Returns a hex-encoded string containing the first 16 characters of the SHA-256
421    /// hash of the serialized template context data. This is sufficient to uniquely
422    /// identify context changes while keeping the digest compact.
423    ///
424    /// # What's Included
425    ///
426    /// The digest includes all lockfile metadata that affects rendering:
427    /// - Resource names, types, and installation paths
428    /// - Dependency versions and resolved commits
429    /// - Checksums and source information
430    ///
431    /// # Determinism
432    ///
433    /// The hash is stable across runs because:
434    /// - Resources are sorted by type and name before hashing
435    /// - JSON serialization uses consistent ordering (BTreeMap)
436    /// - Only metadata fields that affect rendering are included
437    ///
438    /// # Examples
439    ///
440    /// ```rust,no_run
441    /// use agpm_cli::templating::TemplateContextBuilder;
442    /// use agpm_cli::lockfile::LockFile;
443    /// use std::path::{Path, PathBuf};
444    /// use std::sync::Arc;
445    ///
446    /// # fn example() -> anyhow::Result<()> {
447    /// let lockfile = LockFile::load(Path::new("agpm.lock"))?;
448    /// let cache = Arc::new(agpm_cli::cache::Cache::new()?);
449    /// let project_dir = std::env::current_dir()?;
450    /// let builder = TemplateContextBuilder::new(
451    ///     Arc::new(lockfile),
452    ///     None,
453    ///     cache,
454    ///     project_dir
455    /// );
456    ///
457    /// let digest = builder.compute_context_digest()?;
458    /// println!("Template context digest: {}", digest);
459    /// # Ok(())
460    /// # }
461    /// ```
462    pub fn compute_context_digest(&self) -> Result<String> {
463        use sha2::{Digest, Sha256};
464        use std::collections::BTreeMap;
465
466        // Build a deterministic representation of the lockfile data
467        // Use BTreeMap for consistent ordering
468        let mut digest_data: BTreeMap<String, BTreeMap<String, BTreeMap<&str, String>>> =
469            BTreeMap::new();
470
471        // Process each resource type in a consistent order
472        for resource_type in [
473            ResourceType::Agent,
474            ResourceType::Snippet,
475            ResourceType::Command,
476            ResourceType::Script,
477            ResourceType::Hook,
478            ResourceType::McpServer,
479        ] {
480            let resources = self.lockfile.get_resources_by_type(&resource_type);
481            if resources.is_empty() {
482                continue;
483            }
484
485            let type_str = resource_type.to_plural().to_string();
486            let mut sorted_resources: Vec<_> = resources.iter().collect();
487            // Sort by name for deterministic ordering
488            sorted_resources.sort_by(|a, b| a.name.cmp(&b.name));
489
490            let mut type_data = BTreeMap::new();
491            for resource in sorted_resources {
492                // Include only the fields that can affect template rendering
493                let mut resource_data: BTreeMap<&str, String> = BTreeMap::new();
494                resource_data.insert("name", resource.name.clone());
495                resource_data.insert("install_path", resource.installed_at.clone());
496                resource_data.insert("path", resource.path.clone());
497                resource_data.insert("checksum", resource.checksum.clone());
498
499                // Optional fields - only include if present
500                if let Some(ref source) = resource.source {
501                    resource_data.insert("source", source.to_string());
502                }
503                if let Some(ref version) = resource.version {
504                    resource_data.insert("version", version.to_string());
505                }
506                if let Some(ref commit) = resource.resolved_commit {
507                    resource_data.insert("resolved_commit", commit.to_string());
508                }
509
510                type_data.insert(resource.name.clone(), resource_data);
511            }
512
513            digest_data.insert(type_str, type_data);
514        }
515
516        // Serialize to JSON for stable representation
517        let json_str =
518            to_string(&digest_data).context("Failed to serialize template context for digest")?;
519
520        // Compute SHA-256 hash
521        let mut hasher = Sha256::new();
522        hasher.update(json_str.as_bytes());
523        let hash = hasher.finalize();
524
525        // Return first 16 hex characters (64 bits) - sufficient for uniqueness
526        Ok(hex::encode(&hash[..8]))
527    }
528}
529
530/// A cached Tera context with pre-computed checksum for performance.
531///
532/// This structure optimizes repeated checksum calculations by computing
533/// the checksum once when the context is first created, then caching
534/// it for subsequent accesses.
535#[derive(Debug, Clone)]
536pub struct ContextWithChecksum {
537    /// The template context
538    pub context: TeraContext,
539    /// Pre-computed checksum for cache invalidation
540    pub checksum: Option<String>,
541}
542
543impl ContextWithChecksum {
544    /// Create a new context with optional checksum computation.
545    ///
546    /// The checksum is computed only if `compute_checksum` is true.
547    /// This avoids expensive hash calculations for non-templated resources.
548    #[must_use]
549    pub fn new(context: TeraContext, compute_checksum: bool) -> Self {
550        let checksum = if compute_checksum {
551            Self::compute_checksum(&context).ok()
552        } else {
553            None
554        };
555
556        Self {
557            context,
558            checksum,
559        }
560    }
561
562    /// Compute checksum of a Tera context for cache invalidation.
563    ///
564    /// Creates a deterministic hash based on the context data structure.
565    /// This ensures that changes to template inputs are detected.
566    fn compute_checksum(context: &TeraContext) -> Result<String> {
567        use crate::utils::canonicalize_json;
568        use sha2::{Digest, Sha256};
569
570        // Convert TeraContext to JSON Value using its built-in conversion
571        let context_clone = context.clone();
572        let json_value = context_clone.into_json();
573
574        // Serialize to deterministic JSON with preserved order
575        let json_str = canonicalize_json(&json_value)?;
576
577        // Compute SHA-256 hash
578        let mut hasher = Sha256::new();
579        hasher.update(json_str.as_bytes());
580        let hash = hasher.finalize();
581
582        Ok(format!("sha256:{}", hex::encode(hash)))
583    }
584
585    /// Get the context
586    #[must_use]
587    pub fn context(&self) -> &TeraContext {
588        &self.context
589    }
590
591    /// Get the checksum (if computed)
592    #[must_use]
593    pub fn checksum(&self) -> Option<&str> {
594        self.checksum.as_deref()
595    }
596
597    /// Convert to tuple for backward compatibility
598    #[must_use]
599    pub fn into_tuple(self) -> (TeraContext, Option<String>) {
600        (self.context, self.checksum)
601    }
602}
603
604// Implement ContentExtractor trait for TemplateContextBuilder
605impl ContentExtractor for TemplateContextBuilder {
606    fn cache(&self) -> &Arc<crate::cache::Cache> {
607        &self.cache
608    }
609
610    fn project_dir(&self) -> &PathBuf {
611        &self.project_dir
612    }
613}
614
615// Implement DependencyExtractor trait for TemplateContextBuilder
616impl DependencyExtractor for TemplateContextBuilder {
617    fn lockfile(&self) -> &Arc<LockFile> {
618        &self.lockfile
619    }
620
621    fn render_cache(&self) -> &Arc<Mutex<RenderCache>> {
622        &self.render_cache
623    }
624
625    fn custom_names_cache(&self) -> &Arc<Mutex<HashMap<String, BTreeMap<String, String>>>> {
626        &self.custom_names_cache
627    }
628
629    fn dependency_specs_cache(
630        &self,
631    ) -> &Arc<Mutex<HashMap<String, BTreeMap<String, crate::manifest::DependencySpec>>>> {
632        &self.dependency_specs_cache
633    }
634
635    async fn build_context_with_visited(
636        &self,
637        resource_id: &ResourceId,
638        variant_inputs: &serde_json::Value,
639        rendering_stack: &mut HashSet<String>,
640    ) -> Result<TeraContext> {
641        // Check recursion depth to prevent stack overflow
642        if rendering_stack.len() >= MAX_RECURSION_DEPTH {
643            anyhow::bail!(
644                "Maximum recursion depth ({}) exceeded while rendering '{}'. \
645                 This likely indicates a complex or cyclic dependency chain. \
646                 Current stack contains {} resources.",
647                MAX_RECURSION_DEPTH,
648                resource_id.name(),
649                rendering_stack.len()
650            );
651        }
652
653        tracing::info!(
654            "Starting context build for '{}' (type: {:?}, depth: {})",
655            resource_id.name(),
656            resource_id.resource_type(),
657            rendering_stack.len()
658        );
659
660        let mut context = TeraContext::new();
661
662        // Build the nested agpm structure
663        let mut agpm = Map::new();
664
665        // Get the current resource to access its declared dependencies
666        let current_resource =
667            self.lockfile.find_resource_by_id(resource_id).with_context(|| {
668                format!(
669                    "Resource '{}' of type {:?} not found in lockfile (source: {:?}, tool: {:?})",
670                    resource_id.name(),
671                    resource_id.resource_type(),
672                    resource_id.source(),
673                    resource_id.tool()
674                )
675            })?;
676
677        tracing::info!(
678            "Found resource '{}' with {} dependencies",
679            resource_id.name(),
680            current_resource.dependencies.len()
681        );
682
683        // Build current resource data (using already-looked-up resource to preserve full identity)
684        let resource_data = self.build_resource_data(current_resource);
685        agpm.insert("resource".to_string(), to_value(resource_data)?);
686
687        // Build dependency data from ALL lockfile resources + current resource's declared dependencies
688        tracing::info!("Building dependencies data for '{}'...", resource_id.name());
689        let deps_data = self
690            .build_dependencies_data(current_resource, rendering_stack)
691            .await
692            .with_context(|| {
693                format!(
694                    "Failed to build dependencies data for resource '{}' (type: {:?})",
695                    resource_id.name(),
696                    resource_id.resource_type()
697                )
698            })?;
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}