ricecoder_specs/
cache.rs

1//! Specification caching layer
2//!
3//! Caches parsed specification files to improve performance.
4//! Uses file-based cache with TTL support.
5
6use crate::error::SpecError;
7use crate::models::Spec;
8use ricecoder_storage::{CacheInvalidationStrategy, CacheManager};
9use std::path::Path;
10use std::sync::Arc;
11use tracing::{debug, info};
12
13/// Specification cache
14///
15/// Caches parsed specification files to avoid redundant parsing.
16/// Uses file modification time to detect changes.
17pub struct SpecCache {
18    cache: Arc<CacheManager>,
19    ttl_seconds: u64,
20}
21
22impl SpecCache {
23    /// Create a new spec cache
24    ///
25    /// # Arguments
26    ///
27    /// * `cache_dir` - Directory to store cache files
28    /// * `ttl_seconds` - Time-to-live for cache entries (default: 3600 = 1 hour)
29    ///
30    /// # Errors
31    ///
32    /// Returns error if cache directory cannot be created
33    pub fn new(cache_dir: impl AsRef<Path>, ttl_seconds: u64) -> Result<Self, SpecError> {
34        let cache = CacheManager::new(cache_dir)
35            .map_err(|e| SpecError::InvalidFormat(format!("Failed to create cache: {}", e)))?;
36
37        Ok(Self {
38            cache: Arc::new(cache),
39            ttl_seconds,
40        })
41    }
42
43    /// Get a cached spec
44    ///
45    /// # Arguments
46    ///
47    /// * `spec_path` - Path to specification file
48    ///
49    /// # Returns
50    ///
51    /// Returns cached spec if found and not expired, None otherwise
52    pub fn get(&self, spec_path: &Path) -> Result<Option<Spec>, SpecError> {
53        let cache_key = self.make_cache_key(spec_path);
54
55        // Check if file was modified since cache creation
56        if let Ok(metadata) = std::fs::metadata(spec_path) {
57            if let Ok(_modified) = metadata.modified() {
58                // If file was modified, invalidate cache
59                if let Ok(Some(_)) = self.cache.get(&cache_key) {
60                    // Check if we should invalidate based on modification time
61                    // For now, we'll use TTL-based expiration
62                }
63            }
64        }
65
66        match self.cache.get(&cache_key) {
67            Ok(Some(cached_json_str)) => {
68                match serde_json::from_str::<Spec>(&cached_json_str) {
69                    Ok(spec) => {
70                        debug!("Cache hit for spec: {}", spec_path.display());
71                        Ok(Some(spec))
72                    }
73                    Err(e) => {
74                        debug!("Failed to deserialize cached spec: {}", e);
75                        // Invalidate corrupted cache entry
76                        let _ = self.cache.invalidate(&cache_key);
77                        Ok(None)
78                    }
79                }
80            }
81            Ok(None) => {
82                debug!("Cache miss for spec: {}", spec_path.display());
83                Ok(None)
84            }
85            Err(e) => {
86                debug!("Cache lookup error: {}", e);
87                Ok(None)
88            }
89        }
90    }
91
92    /// Cache a spec
93    ///
94    /// # Arguments
95    ///
96    /// * `spec_path` - Path to specification file
97    /// * `spec` - Parsed specification to cache
98    ///
99    /// # Errors
100    ///
101    /// Returns error if spec cannot be cached
102    pub fn set(&self, spec_path: &Path, spec: &Spec) -> Result<(), SpecError> {
103        let cache_key = self.make_cache_key(spec_path);
104
105        let spec_json = serde_json::to_string(spec)
106            .map_err(|e| SpecError::InvalidFormat(format!("Failed to serialize spec: {}", e)))?;
107
108        let json_len = spec_json.len();
109
110        self.cache
111            .set(
112                &cache_key,
113                spec_json,
114                CacheInvalidationStrategy::Ttl(self.ttl_seconds),
115            )
116            .map_err(|e| SpecError::InvalidFormat(format!("Failed to cache spec: {}", e)))?;
117
118        debug!(
119            "Cached spec: {} ({} bytes)",
120            spec_path.display(),
121            json_len
122        );
123
124        Ok(())
125    }
126
127    /// Invalidate a cached spec
128    ///
129    /// # Arguments
130    ///
131    /// * `spec_path` - Path to specification file
132    ///
133    /// # Returns
134    ///
135    /// Returns Ok(true) if entry was deleted, Ok(false) if entry didn't exist
136    pub fn invalidate(&self, spec_path: &Path) -> Result<bool, SpecError> {
137        let cache_key = self.make_cache_key(spec_path);
138
139        self.cache
140            .invalidate(&cache_key)
141            .map_err(|e| SpecError::InvalidFormat(format!("Failed to invalidate cache: {}", e)))
142    }
143
144    /// Clear all cached specs
145    ///
146    /// # Errors
147    ///
148    /// Returns error if cache cannot be cleared
149    pub fn clear(&self) -> Result<(), SpecError> {
150        self.cache
151            .clear()
152            .map_err(|e| SpecError::InvalidFormat(format!("Failed to clear cache: {}", e)))
153    }
154
155    /// Clean up expired cache entries
156    ///
157    /// # Returns
158    ///
159    /// Returns the number of entries cleaned up
160    pub fn cleanup_expired(&self) -> Result<usize, SpecError> {
161        let cleaned = self
162            .cache
163            .cleanup_expired()
164            .map_err(|e| SpecError::InvalidFormat(format!("Failed to cleanup cache: {}", e)))?;
165
166        if cleaned > 0 {
167            info!("Cleaned up {} expired spec cache entries", cleaned);
168        }
169
170        Ok(cleaned)
171    }
172
173    /// Create a cache key from spec path
174    fn make_cache_key(&self, spec_path: &Path) -> String {
175        let path_str = spec_path.to_string_lossy();
176        let sanitized = path_str
177            .chars()
178            .map(|c| {
179                if c.is_alphanumeric() || c == '_' || c == '-' || c == '.' {
180                    c
181                } else {
182                    '_'
183                }
184            })
185            .collect::<String>();
186
187        format!("spec_{}", sanitized)
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::models::{SpecMetadata, SpecPhase, SpecStatus};
195    use chrono::Utc;
196    use std::path::PathBuf;
197    use tempfile::TempDir;
198
199    fn create_test_spec() -> Spec {
200        Spec {
201            id: "test-spec".to_string(),
202            name: "test".to_string(),
203            version: "1.0.0".to_string(),
204            requirements: vec![],
205            design: None,
206            tasks: vec![],
207            metadata: SpecMetadata {
208                author: None,
209                created_at: Utc::now(),
210                updated_at: Utc::now(),
211                phase: SpecPhase::Tasks,
212                status: SpecStatus::Approved,
213            },
214            inheritance: None,
215        }
216    }
217
218    #[test]
219    fn test_cache_set_and_get() -> Result<(), SpecError> {
220        let temp_dir = TempDir::new().unwrap();
221        let cache = SpecCache::new(temp_dir.path(), 3600)?;
222
223        let spec_path = PathBuf::from("test_spec.yaml");
224        let spec = create_test_spec();
225
226        // Cache spec
227        cache.set(&spec_path, &spec)?;
228
229        // Retrieve from cache
230        let cached = cache.get(&spec_path)?;
231        assert!(cached.is_some());
232        assert_eq!(cached.unwrap().name, "test");
233
234        Ok(())
235    }
236
237    #[test]
238    fn test_cache_miss() -> Result<(), SpecError> {
239        let temp_dir = TempDir::new().unwrap();
240        let cache = SpecCache::new(temp_dir.path(), 3600)?;
241
242        let spec_path = PathBuf::from("nonexistent_spec.yaml");
243
244        // Try to get non-existent entry
245        let cached = cache.get(&spec_path)?;
246        assert!(cached.is_none());
247
248        Ok(())
249    }
250
251    #[test]
252    fn test_cache_invalidate() -> Result<(), SpecError> {
253        let temp_dir = TempDir::new().unwrap();
254        let cache = SpecCache::new(temp_dir.path(), 3600)?;
255
256        let spec_path = PathBuf::from("test_spec.yaml");
257        let spec = create_test_spec();
258
259        // Cache spec
260        cache.set(&spec_path, &spec)?;
261
262        // Invalidate
263        let invalidated = cache.invalidate(&spec_path)?;
264        assert!(invalidated);
265
266        // Should be gone now
267        let cached = cache.get(&spec_path)?;
268        assert!(cached.is_none());
269
270        Ok(())
271    }
272
273    #[test]
274    fn test_cache_clear() -> Result<(), SpecError> {
275        let temp_dir = TempDir::new().unwrap();
276        let cache = SpecCache::new(temp_dir.path(), 3600)?;
277
278        let spec_path1 = PathBuf::from("spec1.yaml");
279        let spec_path2 = PathBuf::from("spec2.yaml");
280        let spec = create_test_spec();
281
282        // Cache multiple specs
283        cache.set(&spec_path1, &spec)?;
284        cache.set(&spec_path2, &spec)?;
285
286        // Clear all
287        cache.clear()?;
288
289        // Both should be gone
290        assert!(cache.get(&spec_path1)?.is_none());
291        assert!(cache.get(&spec_path2)?.is_none());
292
293        Ok(())
294    }
295}