1use 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
12pub struct PackageCache {
14 root: PathBuf,
16}
17
18#[derive(Debug, Serialize, Deserialize)]
20struct PackageMeta {
21 name: String,
23 git: String,
25 rev: String,
27 cached_at: u64,
29}
30
31impl PackageCache {
32 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 pub fn cache_dir() -> Result<PathBuf, PackageError> {
41 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 pub fn root(&self) -> &Path {
56 &self.root
57 }
58
59 pub fn package_path(&self, name: &str, rev: &str) -> PathBuf {
61 let short_rev = if rev.len() > 12 { &rev[..12] } else { rev };
63 self.root.join(name).join(short_rev)
64 }
65
66 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 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 pub fn fetch(
85 &self,
86 name: &str,
87 spec: &GitDependency,
88 ) -> Result<(PathBuf, String), PackageError> {
89 let sha = self.resolve_ref(&spec.git, spec.ref_string())?;
91
92 if let Some(path) = self.get(name, &sha) {
94 return Ok((path, sha));
95 }
96
97 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 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 pub fn resolve_ref(&self, url: &str, ref_str: &str) -> Result<String, PackageError> {
125 let temp_dir = self.root.join(".git-cache");
127 std::fs::create_dir_all(&temp_dir)?;
128
129 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 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 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 if ref_str.len() == 40 && ref_str.chars().all(|c| c.is_ascii_hexdigit()) {
183 return Ok(ref_str.to_string());
184 }
185
186 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 if let Ok(obj) = reference.peel(git2::ObjectType::Commit) {
193 return Ok(obj.id().to_string());
194 }
195 }
196
197 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 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 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 fn clone_at_rev(&self, url: &str, rev: &str, dest: &Path) -> Result<(), PackageError> {
224 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 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 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 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 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 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 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 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 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
366fn 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#[derive(Debug, Default)]
377pub struct ResolvedPackage {
378 pub name: String,
380 pub version: String,
382 pub path: PathBuf,
384 pub rev: Option<String>,
386 pub git: Option<String>,
388 pub source_path: Option<String>,
390 pub dependencies: Vec<String>,
392}
393
394pub type ResolvedPackagesMap = HashMap<String, ResolvedPackage>;