ggen_core/cache.rs
1//! Local cache manager for gpack templates
2//!
3//! This module provides caching functionality for downloaded gpack templates.
4//! The `CacheManager` handles local storage, versioning, and integrity verification
5//! of cached template packs.
6//!
7//! ## Features
8//!
9//! - **Local caching**: Store downloaded packs in user cache directory
10//! - **SHA256 verification**: Verify pack integrity using checksums
11//! - **Version management**: Support multiple versions of the same pack
12//! - **Automatic cleanup**: Remove old versions, keeping only the latest
13//! - **Git integration**: Clone and checkout specific revisions
14//!
15//! ## Examples
16//!
17//! ### Creating a Cache Manager
18//!
19//! ```rust,no_run
20//! use ggen_core::cache::CacheManager;
21//!
22//! # fn main() -> ggen_utils::error::Result<()> {
23//! // Use default cache directory (~/.cache/ggen/gpacks)
24//! let cache = CacheManager::new()?;
25//!
26//! // Or use a custom directory (useful for testing)
27//! use std::path::PathBuf;
28//! let cache = CacheManager::with_dir(PathBuf::from("/tmp/ggen-cache"))?;
29//! # Ok(())
30//! # }
31//! ```
32//!
33//! ### Ensuring a Pack is Cached
34//!
35//! ```rust,no_run
36//! use ggen_core::cache::CacheManager;
37//! use ggen_core::registry::ResolvedPack;
38//!
39//! # async fn example() -> ggen_utils::error::Result<()> {
40//! let cache = CacheManager::new()?;
41//! let resolved_pack = ResolvedPack {
42//! id: "io.ggen.example".to_string(),
43//! version: "1.0.0".to_string(),
44//! git_url: "https://github.com/example/pack.git".to_string(),
45//! git_rev: "v1.0.0".to_string(),
46//! sha256: "abc123...".to_string(),
47//! };
48//!
49//! // Download and cache the pack if not already cached
50//! let cached = cache.ensure(&resolved_pack).await?;
51//! println!("Cached pack at: {:?}", cached.path);
52//! # Ok(())
53//! # }
54//! ```
55//!
56//! ### Listing Cached Packs
57//!
58//! ```rust,no_run
59//! use ggen_core::cache::CacheManager;
60//!
61//! # fn main() -> ggen_utils::error::Result<()> {
62//! let cache = CacheManager::new()?;
63//! let cached_packs = cache.list_cached()?;
64//!
65//! for pack in cached_packs {
66//! println!("{}@{}: {:?}", pack.id, pack.version, pack.path);
67//! }
68//! # Ok(())
69//! # }
70//! ```
71
72use ggen_utils::error::{Error, Result};
73use git2::{FetchOptions, RemoteCallbacks, Repository};
74use sha2::{Digest, Sha256};
75use std::fs;
76use std::path::{Path, PathBuf};
77use tempfile::TempDir;
78
79use crate::registry::ResolvedPack;
80
81/// Local cache manager for gpacks
82#[derive(Debug, Clone)]
83pub struct CacheManager {
84 cache_dir: PathBuf,
85}
86
87/// Cached gpack information
88#[derive(Debug, Clone)]
89pub struct CachedPack {
90 pub id: String,
91 pub version: String,
92 pub path: PathBuf,
93 pub sha256: String,
94 pub manifest: Option<crate::gpack::GpackManifest>,
95}
96
97impl CacheManager {
98 /// Create a new cache manager
99 ///
100 /// Uses the default cache directory (`~/.cache/ggen/gpacks` on Unix systems).
101 ///
102 /// # Example
103 ///
104 /// ```rust,no_run
105 /// use ggen_core::cache::CacheManager;
106 ///
107 /// # fn main() -> ggen_utils::error::Result<()> {
108 /// let cache = CacheManager::new()?;
109 /// println!("Cache directory: {:?}", cache.cache_dir());
110 /// # Ok(())
111 /// # }
112 /// ```
113 pub fn new() -> Result<Self> {
114 let cache_dir = dirs::cache_dir()
115 .ok_or_else(|| Error::new("Failed to find cache directory"))?
116 .join("ggen")
117 .join("gpacks");
118
119 fs::create_dir_all(&cache_dir)
120 .map_err(|e| Error::with_context("Failed to create cache directory", &e.to_string()))?;
121
122 Ok(Self { cache_dir })
123 }
124
125 /// Create a cache manager with custom directory (for testing)
126 ///
127 /// # Example
128 ///
129 /// ```rust,no_run
130 /// use ggen_core::cache::CacheManager;
131 /// use std::path::PathBuf;
132 ///
133 /// # fn main() -> ggen_utils::error::Result<()> {
134 /// let cache = CacheManager::with_dir(PathBuf::from("/tmp/ggen-cache"))?;
135 /// println!("Cache directory: {:?}", cache.cache_dir());
136 /// # Ok(())
137 /// # }
138 /// ```
139 pub fn with_dir(cache_dir: PathBuf) -> Result<Self> {
140 fs::create_dir_all(&cache_dir)
141 .map_err(|e| Error::with_context("Failed to create cache directory", &e.to_string()))?;
142
143 Ok(Self { cache_dir })
144 }
145
146 /// Get the cache directory path
147 ///
148 /// # Example
149 ///
150 /// ```rust,no_run
151 /// use ggen_core::cache::CacheManager;
152 ///
153 /// # fn main() -> ggen_utils::error::Result<()> {
154 /// let cache = CacheManager::new()?;
155 /// let cache_path = cache.cache_dir();
156 /// println!("Cache is at: {:?}", cache_path);
157 /// # Ok(())
158 /// # }
159 /// ```
160 pub fn cache_dir(&self) -> &Path {
161 &self.cache_dir
162 }
163
164 /// Ensure a pack is cached locally
165 ///
166 /// Downloads the pack from its git repository if not already cached, or
167 /// verifies the cached version matches the expected SHA256 checksum.
168 ///
169 /// **SHA256 verification**: If `resolved_pack.sha256` is provided and not empty,
170 /// the cached pack's SHA256 is verified. If the checksum doesn't match, the
171 /// corrupted cache is automatically removed and the pack is re-downloaded.
172 /// If no SHA256 is provided, the cached pack is used as-is without verification.
173 ///
174 /// **Automatic recovery**: If a cached pack is found but its SHA256 doesn't match
175 /// the expected value, the method automatically removes the corrupted cache and
176 /// re-downloads the pack. This ensures data integrity without manual intervention.
177 ///
178 /// # Arguments
179 ///
180 /// * `resolved_pack` - Pack metadata including git URL, revision, and optional SHA256
181 ///
182 /// # Returns
183 ///
184 /// Returns information about the cached pack, including its local path.
185 ///
186 /// # Errors
187 ///
188 /// Returns an error if:
189 /// - The pack cannot be downloaded from git
190 /// - The SHA256 checksum doesn't match after re-download (if provided)
191 /// - The cache directory cannot be accessed or created
192 /// - The corrupted cache cannot be removed (should not occur in normal use)
193 ///
194 /// # Examples
195 ///
196 /// ## Success case
197 ///
198 /// ```rust,no_run
199 /// use ggen_core::cache::CacheManager;
200 /// use ggen_core::registry::ResolvedPack;
201 ///
202 /// # async fn example() -> anyhow::Result<()> {
203 /// let cache = CacheManager::new()?;
204 /// let pack = ResolvedPack {
205 /// id: "io.ggen.example".to_string(),
206 /// version: "1.0.0".to_string(),
207 /// git_url: "https://github.com/example/pack.git".to_string(),
208 /// git_rev: "v1.0.0".to_string(),
209 /// sha256: "abc123...".to_string(),
210 /// };
211 ///
212 /// let cached = cache.ensure(&pack).await?;
213 /// println!("Pack cached at: {:?}", cached.path);
214 /// # Ok(())
215 /// # }
216 /// ```
217 ///
218 /// ## Error case - Invalid git URL
219 ///
220 /// ```rust,no_run
221 /// use ggen_core::cache::CacheManager;
222 /// use ggen_core::registry::ResolvedPack;
223 ///
224 /// # async fn example() -> anyhow::Result<()> {
225 /// let cache = CacheManager::new()?;
226 /// let pack = ResolvedPack {
227 /// id: "io.ggen.example".to_string(),
228 /// version: "1.0.0".to_string(),
229 /// git_url: "https://invalid-url-that-does-not-exist.git".to_string(),
230 /// git_rev: "v1.0.0".to_string(),
231 /// sha256: "".to_string(),
232 /// };
233 ///
234 /// // This will fail because the git URL is invalid
235 /// let result = cache.ensure(&pack).await;
236 /// assert!(result.is_err());
237 /// # Ok(())
238 /// # }
239 /// ```
240 pub async fn ensure(&self, resolved_pack: &ResolvedPack) -> Result<CachedPack> {
241 let pack_dir = self
242 .cache_dir
243 .join(&resolved_pack.id)
244 .join(&resolved_pack.version);
245
246 // Check if already cached and valid
247 if pack_dir.exists() {
248 if let Ok(cached) = self.load_cached(&resolved_pack.id, &resolved_pack.version) {
249 // Verify SHA256 if provided
250 if !resolved_pack.sha256.is_empty() {
251 let actual_sha256 = self.calculate_sha256(&pack_dir)?;
252 if actual_sha256 == resolved_pack.sha256 {
253 return Ok(cached);
254 } else {
255 // SHA256 mismatch, remove and re-download
256 fs::remove_dir_all(&pack_dir).map_err(|e| {
257 Error::with_context("Failed to remove corrupted cache", &e.to_string())
258 })?;
259 }
260 } else {
261 return Ok(cached);
262 }
263 }
264 }
265
266 // Download the pack
267 self.download_pack(resolved_pack, &pack_dir).await?;
268
269 // Load and return the cached pack
270 self.load_cached(&resolved_pack.id, &resolved_pack.version)
271 }
272
273 /// Download a pack from its git repository
274 async fn download_pack(&self, resolved_pack: &ResolvedPack, pack_dir: &Path) -> Result<()> {
275 // Create parent directory
276 let parent_dir = pack_dir
277 .parent()
278 .ok_or_else(|| Error::new("Invalid pack path: no parent directory"))?;
279 fs::create_dir_all(parent_dir)
280 .map_err(|e| Error::with_context("Failed to create pack directory", &e.to_string()))?;
281
282 // Clone the repository
283 let mut fetch_options = FetchOptions::new();
284 let mut callbacks = RemoteCallbacks::new();
285
286 // Progress callback
287 callbacks.transfer_progress(|stats| {
288 if stats.received_objects() % 100 == 0 {
289 log::info!("Downloaded {} objects", stats.received_objects());
290 }
291 true
292 });
293
294 fetch_options.remote_callbacks(callbacks);
295
296 // Clone to temporary directory first
297 let temp_dir = TempDir::new().map_err(|e| {
298 Error::with_context("Failed to create temporary directory", &e.to_string())
299 })?;
300
301 let repo = Repository::clone(&resolved_pack.git_url, temp_dir.path())
302 .map_err(|e| Error::with_context("Failed to clone repository", &e.to_string()))?;
303
304 // Checkout specific revision
305 let object = repo
306 .revparse_single(&resolved_pack.git_rev)
307 .map_err(|e| Error::with_context("Failed to find revision", &e.to_string()))?;
308
309 repo.checkout_tree(&object, None)
310 .map_err(|e| Error::with_context("Failed to checkout revision", &e.to_string()))?;
311
312 // Move to final location
313 fs::rename(temp_dir.path(), pack_dir)
314 .map_err(|e| Error::with_context("Failed to move downloaded pack", &e.to_string()))?;
315
316 Ok(())
317 }
318
319 /// Load a cached pack
320 ///
321 /// Returns information about a pack that is already cached locally.
322 ///
323 /// # Errors
324 ///
325 /// Returns an error if:
326 /// - The pack is not found in the cache
327 /// - The pack directory exists but is corrupted
328 /// - The manifest file cannot be read or parsed
329 ///
330 /// # Examples
331 ///
332 /// ## Success case
333 ///
334 /// ```rust,no_run
335 /// use ggen_core::cache::CacheManager;
336 ///
337 /// # fn main() -> ggen_utils::error::Result<()> {
338 /// let cache = CacheManager::new()?;
339 /// // Assuming pack is already cached
340 /// let cached = cache.load_cached("io.ggen.example", "1.0.0")?;
341 /// println!("Pack path: {:?}", cached.path);
342 /// println!("Pack SHA256: {}", cached.sha256);
343 /// # Ok(())
344 /// # }
345 /// ```
346 ///
347 /// ## Error case - Pack not found
348 ///
349 /// ```rust,no_run
350 /// use ggen_core::cache::CacheManager;
351 ///
352 /// # fn main() -> ggen_utils::error::Result<()> {
353 /// let cache = CacheManager::new()?;
354 /// // This will fail because the pack is not cached
355 /// let result = cache.load_cached("nonexistent.pack", "1.0.0");
356 /// assert!(result.is_err());
357 /// # Ok(())
358 /// # }
359 /// ```
360 pub fn load_cached(&self, pack_id: &str, version: &str) -> Result<CachedPack> {
361 let pack_dir = self.cache_dir.join(pack_id).join(version);
362
363 if !pack_dir.exists() {
364 return Err(Error::new(&format!(
365 "Pack not found in cache: {}@{}",
366 pack_id, version
367 )));
368 }
369
370 let sha256 = self.calculate_sha256(&pack_dir)?;
371
372 // Try to load manifest
373 let manifest_path = pack_dir.join("gpack.toml");
374 let manifest = if manifest_path.exists() {
375 let content = fs::read_to_string(&manifest_path)
376 .map_err(|e| Error::with_context("Failed to read manifest", &e.to_string()))?;
377 Some(
378 toml::from_str(&content)
379 .map_err(|e| Error::with_context("Failed to parse manifest", &e.to_string()))?,
380 )
381 } else {
382 None
383 };
384
385 Ok(CachedPack {
386 id: pack_id.to_string(),
387 version: version.to_string(),
388 path: pack_dir,
389 sha256,
390 manifest,
391 })
392 }
393
394 /// Calculate SHA256 hash of a directory
395 fn calculate_sha256(&self, dir: &Path) -> Result<String> {
396 let mut hasher = Sha256::new();
397
398 // Walk directory and hash all files
399 for entry in walkdir::WalkDir::new(dir) {
400 let entry = entry.map_err(|e| {
401 Error::with_context("Failed to read directory entry", &e.to_string())
402 })?;
403 let path = entry.path();
404
405 if path.is_file() {
406 let content = fs::read(path).map_err(|e| {
407 Error::with_context("Failed to read file for hashing", &e.to_string())
408 })?;
409 hasher.update(&content);
410 }
411 }
412
413 Ok(format!("{:x}", hasher.finalize()))
414 }
415
416 /// List all cached packs
417 ///
418 /// # Example
419 ///
420 /// ```rust,no_run
421 /// use ggen_core::cache::CacheManager;
422 ///
423 /// # fn main() -> ggen_utils::error::Result<()> {
424 /// let cache = CacheManager::new()?;
425 /// let cached_packs = cache.list_cached()?;
426 ///
427 /// for pack in cached_packs {
428 /// println!("{}@{}: {:?}", pack.id, pack.version, pack.path);
429 /// }
430 /// # Ok(())
431 /// # }
432 /// ```
433 pub fn list_cached(&self) -> Result<Vec<CachedPack>> {
434 let mut packs = Vec::new();
435
436 if !self.cache_dir.exists() {
437 return Ok(packs);
438 }
439
440 for pack_entry in fs::read_dir(&self.cache_dir)
441 .map_err(|e| Error::with_context("Failed to read cache directory", &e.to_string()))?
442 {
443 let pack_entry = pack_entry
444 .map_err(|e| Error::with_context("Failed to read pack entry", &e.to_string()))?;
445 let pack_path = pack_entry.path();
446
447 if pack_path.is_dir() {
448 let pack_id = pack_entry.file_name().to_string_lossy().to_string();
449
450 // Look for version directories
451 for version_entry in fs::read_dir(&pack_path).map_err(|e| {
452 Error::with_context("Failed to read pack directory", &e.to_string())
453 })? {
454 let version_entry = version_entry.map_err(|e| {
455 Error::with_context("Failed to read version entry", &e.to_string())
456 })?;
457 let version_path = version_entry.path();
458
459 if version_path.is_dir() {
460 let version = version_entry.file_name().to_string_lossy().to_string();
461
462 if let Ok(cached) = self.load_cached(&pack_id, &version) {
463 packs.push(cached);
464 }
465 }
466 }
467 }
468 }
469
470 Ok(packs)
471 }
472
473 /// Remove a cached pack
474 ///
475 /// # Errors
476 ///
477 /// Returns an error if:
478 /// - The pack directory cannot be removed
479 /// - The cache directory cannot be accessed
480 ///
481 /// # Examples
482 ///
483 /// ## Success case
484 ///
485 /// ```rust,no_run
486 /// use ggen_core::cache::CacheManager;
487 ///
488 /// # fn main() -> ggen_utils::error::Result<()> {
489 /// let cache = CacheManager::new()?;
490 /// // Remove a specific version of a pack
491 /// cache.remove("io.ggen.example", "1.0.0")?;
492 /// # Ok(())
493 /// # }
494 /// ```
495 ///
496 /// ## Error case - Permission denied
497 ///
498 /// ```rust,no_run
499 /// use ggen_core::cache::CacheManager;
500 ///
501 /// # fn main() -> ggen_utils::error::Result<()> {
502 /// let cache = CacheManager::new()?;
503 /// // This may fail if we don't have permission to remove the pack
504 /// let result = cache.remove("io.ggen.example", "1.0.0");
505 /// // Handle error appropriately
506 /// if let Err(e) = result {
507 /// eprintln!("Failed to remove pack: {}", e);
508 /// }
509 /// # Ok(())
510 /// # }
511 /// ```
512 pub fn remove(&self, pack_id: &str, version: &str) -> Result<()> {
513 let pack_dir = self.cache_dir.join(pack_id).join(version);
514
515 if pack_dir.exists() {
516 fs::remove_dir_all(&pack_dir)
517 .map_err(|e| Error::with_context("Failed to remove cached pack", &e.to_string()))?;
518 }
519
520 // Remove pack directory if empty
521 if let Some(pack_parent) = pack_dir.parent() {
522 if pack_parent.exists() && fs::read_dir(pack_parent)?.next().is_none() {
523 fs::remove_dir(pack_parent).map_err(|e| {
524 Error::with_context("Failed to remove empty pack directory", &e.to_string())
525 })?;
526 }
527 }
528
529 Ok(())
530 }
531
532 /// Clean up old versions, keeping only the latest
533 pub fn cleanup_old_versions(&self) -> Result<()> {
534 if !self.cache_dir.exists() {
535 return Ok(());
536 }
537
538 for pack_entry in fs::read_dir(&self.cache_dir)
539 .map_err(|e| Error::with_context("Failed to read cache directory", &e.to_string()))?
540 {
541 let pack_entry = pack_entry
542 .map_err(|e| Error::with_context("Failed to read pack entry", &e.to_string()))?;
543 let pack_path = pack_entry.path();
544
545 if pack_path.is_dir() {
546 let mut versions = Vec::new();
547
548 // Collect all versions
549 for version_entry in fs::read_dir(&pack_path).map_err(|e| {
550 Error::with_context("Failed to read pack directory", &e.to_string())
551 })? {
552 let version_entry = version_entry.map_err(|e| {
553 Error::with_context("Failed to read version entry", &e.to_string())
554 })?;
555 let version_path = version_entry.path();
556
557 if version_path.is_dir() {
558 let version_str = version_entry.file_name().to_string_lossy().to_string();
559
560 if let Ok(version) = semver::Version::parse(&version_str) {
561 versions.push((version, version_path));
562 }
563 }
564 }
565
566 // Sort by version and keep only the latest
567 versions.sort_by(|a, b| a.0.cmp(&b.0));
568
569 for (_, version_path) in versions.into_iter().rev().skip(1) {
570 fs::remove_dir_all(&version_path).map_err(|e| {
571 Error::with_context("Failed to remove old version", &e.to_string())
572 })?;
573 }
574 }
575 }
576
577 Ok(())
578 }
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584 use std::fs;
585 use tempfile::TempDir;
586
587 #[test]
588 fn test_cache_manager_creation() -> Result<()> {
589 let temp_dir = TempDir::new()
590 .map_err(|e| Error::with_context("Failed to create temp dir", &e.to_string()))?;
591 let cache_dir = temp_dir.path().to_path_buf();
592
593 let cache_manager = CacheManager::with_dir(cache_dir.clone())?;
594 assert_eq!(cache_manager.cache_dir(), cache_dir);
595 Ok(())
596 }
597
598 #[test]
599 fn test_sha256_calculation() -> Result<()> {
600 let temp_dir = TempDir::new()
601 .map_err(|e| Error::with_context("Failed to create temp dir", &e.to_string()))?;
602 let test_dir = temp_dir.path().join("test");
603 fs::create_dir_all(&test_dir)
604 .map_err(|e| Error::with_context("Failed to create test dir", &e.to_string()))?;
605
606 // Create test files
607 fs::write(test_dir.join("file1.txt"), "content1")
608 .map_err(|e| Error::with_context("Failed to write file1", &e.to_string()))?;
609 fs::write(test_dir.join("file2.txt"), "content2")
610 .map_err(|e| Error::with_context("Failed to write file2", &e.to_string()))?;
611
612 let cache_manager = CacheManager::with_dir(temp_dir.path().to_path_buf())?;
613 let sha256 = cache_manager.calculate_sha256(&test_dir)?;
614
615 assert_eq!(sha256.len(), 64);
616 assert!(sha256.chars().all(|c| c.is_ascii_hexdigit()));
617 Ok(())
618 }
619
620 #[test]
621 fn test_list_cached_empty() -> Result<()> {
622 let temp_dir = TempDir::new()
623 .map_err(|e| Error::with_context("Failed to create temp dir", &e.to_string()))?;
624 let cache_manager = CacheManager::with_dir(temp_dir.path().to_path_buf())?;
625
626 let cached = cache_manager.list_cached()?;
627 assert!(cached.is_empty());
628 Ok(())
629 }
630}