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}