Skip to main content

sage_package/
cache.rs

1//! Package cache management.
2//!
3//! Packages are cached at `~/.grove/packages/<name>/<rev>/`
4
5use crate::dependency::GitDependency;
6use crate::error::PackageError;
7use git2::{FetchOptions, RemoteCallbacks, Repository};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12/// Manages the local package cache.
13pub struct PackageCache {
14    /// Root directory for the cache (~/.grove/packages/).
15    root: PathBuf,
16}
17
18/// Metadata file stored with each cached package.
19#[derive(Debug, Serialize, Deserialize)]
20struct PackageMeta {
21    /// Package name.
22    name: String,
23    /// Git URL.
24    git: String,
25    /// Full SHA.
26    rev: String,
27    /// When this was cached (Unix timestamp).
28    cached_at: u64,
29}
30
31impl PackageCache {
32    /// Create a new package cache, using XDG-compliant paths.
33    pub fn new() -> Result<Self, PackageError> {
34        let root = Self::cache_dir()?;
35        std::fs::create_dir_all(&root)?;
36        Ok(Self { root })
37    }
38
39    /// Get the cache directory path.
40    pub fn cache_dir() -> Result<PathBuf, PackageError> {
41        // Use XDG_CACHE_HOME or ~/.grove/packages
42        if let Some(cache) = dirs::cache_dir() {
43            Ok(cache.join("grove").join("packages"))
44        } else if let Some(home) = dirs::home_dir() {
45            Ok(home.join(".grove").join("packages"))
46        } else {
47            Err(PackageError::IoError {
48                message: "could not determine home directory".to_string(),
49                source: std::io::Error::new(std::io::ErrorKind::NotFound, "no home directory"),
50            })
51        }
52    }
53
54    /// Get the root path of the cache.
55    pub fn root(&self) -> &Path {
56        &self.root
57    }
58
59    /// Get the path for a cached package.
60    pub fn package_path(&self, name: &str, rev: &str) -> PathBuf {
61        // Use first 12 chars of rev for shorter paths
62        let short_rev = if rev.len() > 12 { &rev[..12] } else { rev };
63        self.root.join(name).join(short_rev)
64    }
65
66    /// Check if a package is cached.
67    pub fn is_cached(&self, name: &str, rev: &str) -> bool {
68        let path = self.package_path(name, rev);
69        let meta_path = path.join(".grove-meta.toml");
70        meta_path.exists()
71    }
72
73    /// Get the path of a cached package, or None if not cached.
74    pub fn get(&self, name: &str, rev: &str) -> Option<PathBuf> {
75        let path = self.package_path(name, rev);
76        if self.is_cached(name, rev) {
77            Some(path)
78        } else {
79            None
80        }
81    }
82
83    /// Fetch a git package to the cache, returning its path.
84    pub fn fetch(
85        &self,
86        name: &str,
87        spec: &GitDependency,
88    ) -> Result<(PathBuf, String), PackageError> {
89        // First resolve the ref to a SHA
90        let sha = self.resolve_ref(&spec.git, spec.ref_string())?;
91
92        // Check if already cached
93        if let Some(path) = self.get(name, &sha) {
94            return Ok((path, sha));
95        }
96
97        // Clone/checkout to cache
98        let path = self.package_path(name, &sha);
99        std::fs::create_dir_all(&path)?;
100
101        self.clone_at_rev(&spec.git, &sha, &path)?;
102
103        // Write metadata
104        let meta = PackageMeta {
105            name: name.to_string(),
106            git: spec.git.clone(),
107            rev: sha.clone(),
108            cached_at: std::time::SystemTime::now()
109                .duration_since(std::time::UNIX_EPOCH)
110                .map(|d| d.as_secs())
111                .unwrap_or(0),
112        };
113        let meta_path = path.join(".grove-meta.toml");
114        let meta_toml = toml::to_string_pretty(&meta).map_err(|e| PackageError::IoError {
115            message: format!("failed to serialize meta: {e}"),
116            source: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
117        })?;
118        std::fs::write(&meta_path, meta_toml)?;
119
120        Ok((path, sha))
121    }
122
123    /// Resolve a git ref (tag/branch/rev) to a full SHA.
124    pub fn resolve_ref(&self, url: &str, ref_str: &str) -> Result<String, PackageError> {
125        // Try to open existing bare repo for this URL, or create temp one
126        let temp_dir = self.root.join(".git-cache");
127        std::fs::create_dir_all(&temp_dir)?;
128
129        // Hash URL for stable temp path
130        let url_hash = format!("{:x}", md5_hash(url));
131        let repo_path = temp_dir.join(&url_hash);
132
133        let repo = if repo_path.exists() {
134            Repository::open_bare(&repo_path).map_err(|e| PackageError::GitFetchFailed {
135                url: url.to_string(),
136                reason: format!("failed to open cached repo: {e}"),
137            })?
138        } else {
139            // Clone bare for fetching refs
140            let mut callbacks = RemoteCallbacks::new();
141            callbacks.transfer_progress(|_| true);
142
143            let mut fetch_opts = FetchOptions::new();
144            fetch_opts.remote_callbacks(callbacks);
145
146            let mut builder = git2::build::RepoBuilder::new();
147            builder.bare(true);
148            builder.fetch_options(fetch_opts);
149
150            builder
151                .clone(url, &repo_path)
152                .map_err(|e| PackageError::GitFetchFailed {
153                    url: url.to_string(),
154                    reason: e.message().to_string(),
155                })?
156        };
157
158        // Fetch latest refs
159        let mut remote = repo
160            .find_remote("origin")
161            .or_else(|_| repo.remote_anonymous(url))
162            .map_err(|e| PackageError::GitFetchFailed {
163                url: url.to_string(),
164                reason: format!("failed to find remote: {e}"),
165            })?;
166
167        let mut callbacks = RemoteCallbacks::new();
168        callbacks.transfer_progress(|_| true);
169
170        let mut fetch_opts = FetchOptions::new();
171        fetch_opts.remote_callbacks(callbacks);
172
173        remote
174            .fetch(&[ref_str], Some(&mut fetch_opts), None)
175            .map_err(|e| PackageError::GitFetchFailed {
176                url: url.to_string(),
177                reason: format!("fetch failed: {e}"),
178            })?;
179
180        // Try to resolve the ref
181        // First check if it's already a full SHA
182        if ref_str.len() == 40 && ref_str.chars().all(|c| c.is_ascii_hexdigit()) {
183            return Ok(ref_str.to_string());
184        }
185
186        // Try as tag
187        if let Ok(reference) = repo.find_reference(&format!("refs/tags/{ref_str}")) {
188            if let Some(target) = reference.target() {
189                return Ok(target.to_string());
190            }
191            // Annotated tag - peel to commit
192            if let Ok(obj) = reference.peel(git2::ObjectType::Commit) {
193                return Ok(obj.id().to_string());
194            }
195        }
196
197        // Try as branch
198        if let Ok(reference) = repo.find_reference(&format!("refs/remotes/origin/{ref_str}")) {
199            if let Some(target) = reference.target() {
200                return Ok(target.to_string());
201            }
202        }
203
204        // Try FETCH_HEAD
205        if let Ok(reference) = repo.find_reference("FETCH_HEAD") {
206            if let Some(target) = reference.target() {
207                return Ok(target.to_string());
208            }
209        }
210
211        // Try as short SHA
212        if let Ok(obj) = repo.revparse_single(ref_str) {
213            return Ok(obj.id().to_string());
214        }
215
216        Err(PackageError::GitFetchFailed {
217            url: url.to_string(),
218            reason: format!("could not resolve ref '{ref_str}'"),
219        })
220    }
221
222    /// Clone a repo at a specific revision.
223    fn clone_at_rev(&self, url: &str, rev: &str, dest: &Path) -> Result<(), PackageError> {
224        // Clone the repository
225        let mut callbacks = RemoteCallbacks::new();
226        callbacks.transfer_progress(|_| true);
227
228        let mut fetch_opts = FetchOptions::new();
229        fetch_opts.remote_callbacks(callbacks);
230
231        let mut builder = git2::build::RepoBuilder::new();
232        builder.fetch_options(fetch_opts);
233
234        let repo = builder
235            .clone(url, dest)
236            .map_err(|e| PackageError::GitFetchFailed {
237                url: url.to_string(),
238                reason: e.message().to_string(),
239            })?;
240
241        // Checkout the specific revision
242        let oid = git2::Oid::from_str(rev).map_err(|e| PackageError::GitFetchFailed {
243            url: url.to_string(),
244            reason: format!("invalid SHA: {e}"),
245        })?;
246
247        let commit = repo
248            .find_commit(oid)
249            .map_err(|e| PackageError::GitFetchFailed {
250                url: url.to_string(),
251                reason: format!("commit not found: {e}"),
252            })?;
253
254        repo.checkout_tree(commit.as_object(), None)
255            .map_err(|e| PackageError::GitFetchFailed {
256                url: url.to_string(),
257                reason: format!("checkout failed: {e}"),
258            })?;
259
260        repo.set_head_detached(oid)
261            .map_err(|e| PackageError::GitFetchFailed {
262                url: url.to_string(),
263                reason: format!("set head failed: {e}"),
264            })?;
265
266        // Remove .git directory to save space
267        let git_dir = dest.join(".git");
268        if git_dir.exists() {
269            std::fs::remove_dir_all(&git_dir)?;
270        }
271
272        Ok(())
273    }
274
275    /// List all cached packages.
276    pub fn list(&self) -> Result<Vec<(String, String, PathBuf)>, PackageError> {
277        let mut packages = Vec::new();
278
279        if !self.root.exists() {
280            return Ok(packages);
281        }
282
283        for entry in std::fs::read_dir(&self.root)? {
284            let entry = entry?;
285            let pkg_name = entry.file_name().to_string_lossy().to_string();
286
287            // Skip .git-cache
288            if pkg_name.starts_with('.') {
289                continue;
290            }
291
292            if entry.path().is_dir() {
293                for version_entry in std::fs::read_dir(entry.path())? {
294                    let version_entry = version_entry?;
295                    let rev = version_entry.file_name().to_string_lossy().to_string();
296                    let path = version_entry.path();
297
298                    if path.join(".grove-meta.toml").exists() {
299                        packages.push((pkg_name.clone(), rev, path));
300                    }
301                }
302            }
303        }
304
305        Ok(packages)
306    }
307
308    /// Remove a package from the cache.
309    pub fn remove(&self, name: &str) -> Result<(), PackageError> {
310        let pkg_dir = self.root.join(name);
311        if pkg_dir.exists() {
312            std::fs::remove_dir_all(&pkg_dir)?;
313        }
314        Ok(())
315    }
316
317    /// Remove a specific version from the cache.
318    pub fn remove_version(&self, name: &str, rev: &str) -> Result<(), PackageError> {
319        let path = self.package_path(name, rev);
320        if path.exists() {
321            std::fs::remove_dir_all(&path)?;
322        }
323        Ok(())
324    }
325
326    /// Clear the entire cache.
327    pub fn clean(&self) -> Result<(), PackageError> {
328        if self.root.exists() {
329            std::fs::remove_dir_all(&self.root)?;
330            std::fs::create_dir_all(&self.root)?;
331        }
332        Ok(())
333    }
334
335    /// Get cache size in bytes.
336    pub fn size(&self) -> Result<u64, PackageError> {
337        fn dir_size(path: &Path) -> std::io::Result<u64> {
338            let mut size = 0;
339            if path.is_dir() {
340                for entry in std::fs::read_dir(path)? {
341                    let entry = entry?;
342                    let path = entry.path();
343                    if path.is_dir() {
344                        size += dir_size(&path)?;
345                    } else {
346                        size += entry.metadata()?.len();
347                    }
348                }
349            }
350            Ok(size)
351        }
352
353        dir_size(&self.root).map_err(|e| PackageError::IoError {
354            message: "failed to calculate cache size".to_string(),
355            source: e,
356        })
357    }
358}
359
360impl Default for PackageCache {
361    fn default() -> Self {
362        Self::new().expect("failed to create package cache")
363    }
364}
365
366/// Simple hash for URL -> directory name.
367fn md5_hash(s: &str) -> u64 {
368    use std::collections::hash_map::DefaultHasher;
369    use std::hash::{Hash, Hasher};
370    let mut hasher = DefaultHasher::new();
371    s.hash(&mut hasher);
372    hasher.finish()
373}
374
375/// Holds information about all resolved packages.
376#[derive(Debug, Default)]
377pub struct ResolvedPackage {
378    /// Package name.
379    pub name: String,
380    /// Package version.
381    pub version: String,
382    /// Path to the package (cached for git, local for path deps).
383    pub path: PathBuf,
384    /// Full SHA (None for path dependencies).
385    pub rev: Option<String>,
386    /// Git URL (None for path dependencies).
387    pub git: Option<String>,
388    /// Original path string (for path dependencies).
389    pub source_path: Option<String>,
390    /// Dependencies of this package.
391    pub dependencies: Vec<String>,
392}
393
394/// Map of package name to resolved package info.
395pub type ResolvedPackagesMap = HashMap<String, ResolvedPackage>;