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}