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}
36
37impl RenderCacheKey {
38    /// Create a new cache key using the pre-computed variant_inputs_hash
39    #[must_use]
40    pub(crate) fn new(
41        resource_path: String,
42        resource_type: ResourceType,
43        tool: Option<String>,
44        variant_inputs_hash: String,
45        resolved_commit: Option<String>,
46    ) -> Self {
47        Self {
48            resource_path,
49            resource_type,
50            tool,
51            variant_inputs_hash,
52            resolved_commit,
53        }
54    }
55}
56
57/// Cache for rendered template content during installation.
58///
59/// This cache stores rendered content to avoid re-rendering the same
60/// dependencies multiple times. It lives for the duration of a single
61/// install operation and is cleared afterward.
62///
63/// # Performance Impact
64///
65/// For installations with many transitive dependencies (e.g., 145+ resources),
66/// this cache prevents O(N²) rendering complexity by ensuring each unique
67/// resource is rendered only once, regardless of how many parents depend on it.
68///
69/// # Cache Invalidation
70///
71/// The cache is cleared after each installation completes. It does not
72/// persist across operations, ensuring that file changes are always reflected
73/// in subsequent installations.
74#[derive(Debug, Default)]
75pub(crate) struct RenderCache {
76    /// Map from cache key to rendered content
77    cache: HashMap<RenderCacheKey, String>,
78    /// Cache statistics
79    hits: usize,
80    misses: usize,
81}
82
83impl RenderCache {
84    /// Create a new empty render cache
85    #[must_use]
86    pub(crate) fn new() -> Self {
87        Self {
88            cache: HashMap::new(),
89            hits: 0,
90            misses: 0,
91        }
92    }
93
94    /// Get cached rendered content if available
95    pub(crate) fn get(&mut self, key: &RenderCacheKey) -> Option<&String> {
96        if let Some(content) = self.cache.get(key) {
97            self.hits += 1;
98            Some(content)
99        } else {
100            self.misses += 1;
101            None
102        }
103    }
104
105    /// Insert rendered content into the cache
106    pub(crate) fn insert(&mut self, key: RenderCacheKey, content: String) {
107        self.cache.insert(key, content);
108    }
109
110    /// Clear all cached content
111    pub(crate) fn clear(&mut self) {
112        self.cache.clear();
113        self.hits = 0;
114        self.misses = 0;
115    }
116
117    /// Get cache statistics
118    #[must_use]
119    pub(crate) fn stats(&self) -> (usize, usize) {
120        (self.hits, self.misses)
121    }
122
123    /// Calculate hit rate as a percentage
124    #[must_use]
125    pub(crate) fn hit_rate(&self) -> f64 {
126        let total = self.hits + self.misses;
127        if total == 0 {
128            0.0
129        } else {
130            (self.hits as f64 / total as f64) * 100.0
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn test_render_cache_key_includes_commit() {
141        // Test that different commits produce different cache keys
142        let key1 = RenderCacheKey::new(
143            "agents/helper.md".to_string(),
144            ResourceType::Agent,
145            Some("claude-code".to_string()),
146            "hash123".to_string(),
147            Some("abc123def".to_string()),
148        );
149
150        let key2 = RenderCacheKey::new(
151            "agents/helper.md".to_string(),
152            ResourceType::Agent,
153            Some("claude-code".to_string()),
154            "hash123".to_string(),
155            Some("def456ghi".to_string()),
156        );
157
158        assert_ne!(key1, key2, "Different commits should have different cache keys");
159
160        // Test that same commits produce same cache keys
161        let key3 = RenderCacheKey::new(
162            "agents/helper.md".to_string(),
163            ResourceType::Agent,
164            Some("claude-code".to_string()),
165            "hash123".to_string(),
166            Some("abc123def".to_string()),
167        );
168
169        assert_eq!(key1, key3, "Same commits should have identical cache keys");
170
171        // Test that None commits are handled correctly
172        let key4 = RenderCacheKey::new(
173            "agents/helper.md".to_string(),
174            ResourceType::Agent,
175            Some("claude-code".to_string()),
176            "hash123".to_string(),
177            None,
178        );
179
180        assert_ne!(key1, key4, "Some(commit) vs None should have different cache keys");
181    }
182
183    #[test]
184    fn test_render_cache_basic_operations() {
185        let mut cache = RenderCache::new();
186        let key = RenderCacheKey::new(
187            "test.md".to_string(),
188            ResourceType::Snippet,
189            Some("claude-code".to_string()),
190            "hash789".to_string(),
191            Some("commit123".to_string()),
192        );
193
194        // Initially empty
195        assert_eq!(cache.stats(), (0, 0));
196        assert_eq!(cache.hit_rate(), 0.0);
197
198        // Cache miss
199        assert!(cache.get(&key).is_none());
200        assert_eq!(cache.stats(), (0, 1));
201
202        // Insert and hit
203        cache.insert(key.clone(), "rendered content".to_string());
204        assert_eq!(cache.get(&key), Some(&"rendered content".to_string()));
205        assert_eq!(cache.stats(), (1, 1));
206        assert_eq!(cache.hit_rate(), 50.0);
207
208        // Clear cache
209        cache.clear();
210        assert_eq!(cache.stats(), (0, 0));
211        assert!(cache.get(&key).is_none());
212    }
213}