agpm_cli/templating/
cache.rs

1//! Render cache for template content during installation.
2//!
3//! This module provides caching functionality to avoid re-rendering the same
4//! dependencies multiple times during a single installation operation.
5
6use std::collections::HashMap;
7
8use crate::core::ResourceType;
9
10/// Cache key for rendered template content.
11///
12/// Uniquely identifies a rendered version of a resource based on:
13/// - The source file path (canonical path to the resource)
14/// - The resource type (Agent, Snippet, Command, etc.)
15/// - Template variable overrides (pre-computed hash)
16/// - The specific Git commit (resolved_commit) being rendered
17///
18/// This ensures that the same resource with different template_vars
19/// or different commits produces different cache entries, while identical
20/// resources share cached content across multiple parent resources.
21#[derive(Debug, Clone, PartialEq, Eq, Hash)]
22pub(crate) struct RenderCacheKey {
23    /// Canonical path to the resource file in the source repository
24    pub(crate) resource_path: String,
25    /// Resource type (Agent, Snippet, etc.)
26    pub(crate) resource_type: ResourceType,
27    /// Tool (claude-code, opencode, etc.) - CRITICAL for cache isolation!
28    /// Same path renders differently for different tools.
29    pub(crate) tool: Option<String>,
30    /// Pre-computed variant_inputs_hash (never recomputed!)
31    pub(crate) variant_inputs_hash: String,
32    /// Resolved Git commit SHA - CRITICAL for cache isolation!
33    /// Same resource path from different commits must have different cache entries.
34    pub(crate) resolved_commit: Option<String>,
35    /// Dependency hash for proper cache invalidation when dependencies change
36    pub(crate) dependency_hash: String,
37}
38
39impl RenderCacheKey {
40    /// Create a new cache key using the pre-computed variant_inputs_hash
41    #[must_use]
42    pub(crate) fn new(
43        resource_path: String,
44        resource_type: ResourceType,
45        tool: Option<String>,
46        variant_inputs_hash: String,
47        resolved_commit: Option<String>,
48        dependency_hash: String,
49    ) -> Self {
50        Self {
51            resource_path,
52            resource_type,
53            tool,
54            variant_inputs_hash,
55            resolved_commit,
56            dependency_hash,
57        }
58    }
59}
60
61/// Cache for rendered template content during installation.
62///
63/// This cache stores rendered content to avoid re-rendering the same
64/// dependencies multiple times. It lives for the duration of a single
65/// install operation and is cleared afterward.
66///
67/// # Performance Impact
68///
69/// For installations with many transitive dependencies (e.g., 145+ resources),
70/// this cache prevents O(N²) rendering complexity by ensuring each unique
71/// resource is rendered only once, regardless of how many parents depend on it.
72///
73/// # Cache Invalidation
74///
75/// The cache is cleared after each installation completes. It does not
76/// persist across operations, ensuring that file changes are always reflected
77/// in subsequent installations.
78#[derive(Debug, Default)]
79pub(crate) struct RenderCache {
80    /// Map from cache key to rendered content
81    cache: HashMap<RenderCacheKey, String>,
82    /// Cache statistics
83    hits: usize,
84    misses: usize,
85}
86
87impl RenderCache {
88    /// Create a new empty render cache
89    #[must_use]
90    pub(crate) fn new() -> Self {
91        Self {
92            cache: HashMap::new(),
93            hits: 0,
94            misses: 0,
95        }
96    }
97
98    /// Get cached rendered content if available
99    pub(crate) fn get(&mut self, key: &RenderCacheKey) -> Option<&String> {
100        if let Some(content) = self.cache.get(key) {
101            self.hits += 1;
102            Some(content)
103        } else {
104            self.misses += 1;
105            None
106        }
107    }
108
109    /// Insert rendered content into the cache
110    pub(crate) fn insert(&mut self, key: RenderCacheKey, content: String) {
111        self.cache.insert(key, content);
112    }
113
114    /// Clear all cached content
115    pub(crate) fn clear(&mut self) {
116        self.cache.clear();
117        self.hits = 0;
118        self.misses = 0;
119    }
120
121    /// Get cache statistics
122    #[must_use]
123    pub(crate) fn stats(&self) -> (usize, usize) {
124        (self.hits, self.misses)
125    }
126
127    /// Calculate hit rate as a percentage
128    #[must_use]
129    pub(crate) fn hit_rate(&self) -> f64 {
130        let total = self.hits + self.misses;
131        if total == 0 {
132            0.0
133        } else {
134            (self.hits as f64 / total as f64) * 100.0
135        }
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn test_render_cache_key_includes_commit() {
145        // Test that different commits produce different cache keys
146        let key1 = RenderCacheKey::new(
147            "agents/helper.md".to_string(),
148            ResourceType::Agent,
149            Some("claude-code".to_string()),
150            "hash123".to_string(),
151            Some("abc123def".to_string()),
152            "dep_hash_123".to_string(),
153        );
154
155        let key2 = RenderCacheKey::new(
156            "agents/helper.md".to_string(),
157            ResourceType::Agent,
158            Some("claude-code".to_string()),
159            "hash123".to_string(),
160            Some("def456ghi".to_string()),
161            "dep_hash_456".to_string(),
162        );
163
164        assert_ne!(key1, key2, "Different commits should have different cache keys");
165
166        // Test that same commits produce same cache keys
167        let key3 = RenderCacheKey::new(
168            "agents/helper.md".to_string(),
169            ResourceType::Agent,
170            Some("claude-code".to_string()),
171            "hash123".to_string(),
172            Some("abc123def".to_string()),
173            "dep_hash_123".to_string(),
174        );
175
176        assert_eq!(key1, key3, "Same commits should have identical cache keys");
177
178        // Test that None commits are handled correctly
179        let key4 = RenderCacheKey::new(
180            "agents/helper.md".to_string(),
181            ResourceType::Agent,
182            Some("claude-code".to_string()),
183            "hash123".to_string(),
184            None,
185            "dep_hash_none".to_string(),
186        );
187
188        assert_ne!(key1, key4, "Some(commit) vs None should have different cache keys");
189    }
190
191    #[test]
192    fn test_render_cache_basic_operations() {
193        let mut cache = RenderCache::new();
194        let key = RenderCacheKey::new(
195            "test.md".to_string(),
196            ResourceType::Snippet,
197            Some("claude-code".to_string()),
198            "hash789".to_string(),
199            Some("commit123".to_string()),
200            "dep_hash_test".to_string(),
201        );
202
203        // Initially empty
204        assert_eq!(cache.stats(), (0, 0));
205        assert_eq!(cache.hit_rate(), 0.0);
206
207        // Cache miss
208        assert!(cache.get(&key).is_none());
209        assert_eq!(cache.stats(), (0, 1));
210
211        // Insert and hit
212        cache.insert(key.clone(), "rendered content".to_string());
213        assert_eq!(cache.get(&key), Some(&"rendered content".to_string()));
214        assert_eq!(cache.stats(), (1, 1));
215        assert_eq!(cache.hit_rate(), 50.0);
216
217        // Clear cache
218        cache.clear();
219        assert_eq!(cache.stats(), (0, 0));
220        assert!(cache.get(&key).is_none());
221    }
222}