diff options
| -rw-r--r-- | Cargo.lock | 1 | ||||
| -rw-r--r-- | crates/mozart/Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/mozart/src/cache.rs | 492 | ||||
| -rw-r--r-- | crates/mozart/src/commands/clear_cache.rs | 44 | ||||
| -rw-r--r-- | crates/mozart/src/commands/install.rs | 1 | ||||
| -rw-r--r-- | crates/mozart/src/commands/outdated.rs | 2 | ||||
| -rw-r--r-- | crates/mozart/src/commands/remove.rs | 6 | ||||
| -rw-r--r-- | crates/mozart/src/commands/require.rs | 10 | ||||
| -rw-r--r-- | crates/mozart/src/commands/show.rs | 8 | ||||
| -rw-r--r-- | crates/mozart/src/commands/update.rs | 4 | ||||
| -rw-r--r-- | crates/mozart/src/downloader.rs | 36 | ||||
| -rw-r--r-- | crates/mozart/src/lib.rs | 1 | ||||
| -rw-r--r-- | crates/mozart/src/lockfile.rs | 8 | ||||
| -rw-r--r-- | crates/mozart/src/packagist.rs | 27 | ||||
| -rw-r--r-- | crates/mozart/src/resolver.rs | 23 |
15 files changed, 650 insertions, 14 deletions
@@ -961,6 +961,7 @@ dependencies = [ "clap", "colored", "dialoguer", + "filetime", "flate2", "md5", "pubgrub", diff --git a/crates/mozart/Cargo.toml b/crates/mozart/Cargo.toml index 0b385fa..ced67ee 100644 --- a/crates/mozart/Cargo.toml +++ b/crates/mozart/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true [dependencies] anyhow = "1.0.101" +filetime = "0.2" clap = { version = "4.5.57", features = ["derive"] } colored = "3.1.1" dialoguer = "0.12.0" diff --git a/crates/mozart/src/cache.rs b/crates/mozart/src/cache.rs new file mode 100644 index 0000000..3e8d715 --- /dev/null +++ b/crates/mozart/src/cache.rs @@ -0,0 +1,492 @@ +//! Filesystem-backed cache system with TTL expiration and size-limited GC. +//! +//! Cache directory structure: +//! ```text +//! ~/.cache/mozart/ (or $COMPOSER_CACHE_DIR) +//! files/ dist archives (key: vendor~package~reference.ext) +//! repo/ API responses (key: provider-vendor~package.json) +//! ``` + +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +// ───────────────────────────────────────────────────────────────────────────── +// CacheConfig +// ───────────────────────────────────────────────────────────────────────────── + +/// Configuration for the Mozart cache system. +pub struct CacheConfig { + /// Root cache directory (e.g. `~/.cache/mozart`). + pub cache_dir: PathBuf, + /// Directory for dist archives. + pub cache_files_dir: PathBuf, + /// Directory for API responses. + pub cache_repo_dir: PathBuf, + /// TTL in seconds for repo entries (default: 15,552,000 = 6 months). + pub cache_ttl: u64, + /// TTL in seconds for files entries (falls back to `cache_ttl`). + pub cache_files_ttl: u64, + /// Maximum size of the files cache in bytes (default: 300 MiB). + pub cache_files_maxsize: u64, + /// Whether the cache is read-only (no writes). + pub read_only: bool, + /// Whether caching is entirely disabled. + pub no_cache: bool, +} + +impl CacheConfig { + /// Default TTL: 6 months in seconds. + pub const DEFAULT_TTL: u64 = 15_552_000; + /// Default max files cache size: 300 MiB. + pub const DEFAULT_FILES_MAXSIZE: u64 = 300 * 1024 * 1024; +} + +/// Build a `CacheConfig` from CLI flags and environment variables. +/// +/// Respects `$COMPOSER_CACHE_DIR` for the base directory, and +/// `$COMPOSER_NO_CACHE` / `COMPOSER_CACHE_READ_ONLY` env vars. +pub fn build_cache_config(cli: &super::commands::Cli) -> CacheConfig { + let no_cache = std::env::var("COMPOSER_NO_CACHE").is_ok() || cli.no_cache; + + let read_only = std::env::var("COMPOSER_CACHE_READ_ONLY") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + + let cache_dir = if let Ok(dir) = std::env::var("COMPOSER_CACHE_DIR") { + PathBuf::from(dir) + } else { + // Use XDG cache dir or fallback + dirs_cache_dir().join("mozart") + }; + + let cache_files_dir = cache_dir.join("files"); + let cache_repo_dir = cache_dir.join("repo"); + + CacheConfig { + cache_files_dir, + cache_repo_dir, + cache_ttl: CacheConfig::DEFAULT_TTL, + cache_files_ttl: CacheConfig::DEFAULT_TTL, + cache_files_maxsize: CacheConfig::DEFAULT_FILES_MAXSIZE, + cache_dir, + read_only, + no_cache, + } +} + +/// Return the platform cache directory (XDG_CACHE_HOME or ~/.cache). +fn dirs_cache_dir() -> PathBuf { + if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") { + return PathBuf::from(xdg); + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(".cache"); + } + PathBuf::from("/tmp") +} + +// ───────────────────────────────────────────────────────────────────────────── +// Cache +// ───────────────────────────────────────────────────────────────────────────── + +/// A single cache bucket (a directory on disk). +#[derive(Clone)] +pub struct Cache { + root: PathBuf, + enabled: bool, +} + +impl Cache { + /// Create a new cache rooted at `root`. + /// + /// Creates the directory if it doesn't exist and caching is enabled. + pub fn new(root: PathBuf, enabled: bool) -> Self { + if enabled { + let _ = fs::create_dir_all(&root); + } + Self { root, enabled } + } + + /// Shorthand: create the repo cache from a `CacheConfig`. + pub fn repo(config: &CacheConfig) -> Self { + Self::new(config.cache_repo_dir.clone(), !config.no_cache) + } + + /// Shorthand: create the files cache from a `CacheConfig`. + pub fn files(config: &CacheConfig) -> Self { + Self::new(config.cache_files_dir.clone(), !config.no_cache) + } + + /// Whether caching is enabled for this bucket. + pub fn is_enabled(&self) -> bool { + self.enabled + } + + /// Sanitize a cache key for use as a filename. + /// + /// Replaces `/` with `~` and strips characters that are unsafe in + /// filenames (anything except alphanumerics, `-`, `_`, `.`, `~`). + pub fn sanitize_key(key: &str) -> String { + key.replace('/', "~") + .chars() + .filter(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '~')) + .collect() + } + + /// Return the full path for a cache entry. + fn path_for(&self, key: &str) -> PathBuf { + self.root.join(Self::sanitize_key(key)) + } + + /// Read a cached string entry, or `None` if absent or cache disabled. + pub fn read(&self, key: &str) -> Option<String> { + if !self.enabled { + return None; + } + fs::read_to_string(self.path_for(key)).ok() + } + + /// Write a string entry atomically (write to temp file, then rename). + pub fn write(&self, key: &str, contents: &str) -> anyhow::Result<()> { + if !self.enabled { + return Ok(()); + } + self.write_bytes(key, contents.as_bytes()) + } + + /// Read a cached binary entry, or `None` if absent or cache disabled. + pub fn read_bytes(&self, key: &str) -> Option<Vec<u8>> { + if !self.enabled { + return None; + } + fs::read(self.path_for(key)).ok() + } + + /// Write a binary entry atomically (write to temp file, then rename). + pub fn write_bytes(&self, key: &str, data: &[u8]) -> anyhow::Result<()> { + if !self.enabled { + return Ok(()); + } + let dest = self.path_for(key); + // Ensure parent directory exists + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + // Write to a temp file next to the destination + let tmp = dest.with_extension("tmp"); + fs::write(&tmp, data)?; + fs::rename(&tmp, &dest)?; + Ok(()) + } + + /// Delete all cached entries in this bucket. + pub fn clear(&self) -> anyhow::Result<()> { + if !self.root.exists() { + return Ok(()); + } + for entry in fs::read_dir(&self.root)? { + let entry = entry?; + let path = entry.path(); + if path.is_file() { + fs::remove_file(&path)?; + } else if path.is_dir() { + fs::remove_dir_all(&path)?; + } + } + Ok(()) + } + + /// Run garbage collection on this cache bucket. + /// + /// 1. Deletes files with mtime older than `ttl_seconds`. + /// 2. If total remaining size > `max_size_bytes`, deletes the oldest files + /// (by mtime) until the total is under the limit. + pub fn gc(&self, ttl_seconds: u64, max_size_bytes: u64) -> anyhow::Result<()> { + if !self.enabled || !self.root.exists() { + return Ok(()); + } + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + // Collect (path, mtime, size) for all files + let mut files: Vec<(PathBuf, u64, u64)> = Vec::new(); + collect_files(&self.root, &mut files)?; + + // Phase 1: delete TTL-expired files + let mut remaining: Vec<(PathBuf, u64, u64)> = Vec::new(); + for (path, mtime, size) in files { + let age = now.saturating_sub(mtime); + if age > ttl_seconds { + let _ = fs::remove_file(&path); + } else { + remaining.push((path, mtime, size)); + } + } + + // Phase 2: enforce size limit by deleting oldest first + let total_size: u64 = remaining.iter().map(|(_, _, sz)| sz).sum(); + if total_size > max_size_bytes { + // Sort by mtime ascending (oldest first) + remaining.sort_by_key(|(_, mtime, _)| *mtime); + let mut current_size = total_size; + for (path, _, size) in &remaining { + if current_size <= max_size_bytes { + break; + } + if fs::remove_file(path).is_ok() { + current_size = current_size.saturating_sub(*size); + } + } + } + + Ok(()) + } + + /// Return the age in seconds of a cached entry based on its mtime, + /// or `None` if the entry doesn't exist or mtime can't be read. + pub fn age(&self, key: &str) -> Option<u64> { + if !self.enabled { + return None; + } + let path = self.path_for(key); + let metadata = fs::metadata(&path).ok()?; + let mtime = metadata.modified().ok()?; + let now = SystemTime::now(); + now.duration_since(mtime).ok().map(|d| d.as_secs()) + } +} + +/// Recursively collect all files under `dir` as `(path, mtime_secs, size_bytes)`. +fn collect_files(dir: &Path, out: &mut Vec<(PathBuf, u64, u64)>) -> anyhow::Result<()> { + if !dir.exists() { + return Ok(()); + } + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + let metadata = entry.metadata()?; + if metadata.is_dir() { + collect_files(&path, out)?; + } else if metadata.is_file() { + let mtime = metadata + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + let size = metadata.len(); + out.push((path, mtime, size)); + } + } + Ok(()) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Probabilistic GC trigger +// ───────────────────────────────────────────────────────────────────────────── + +/// Return `true` with a probability of 1 in 50 (based on system time nanos). +/// +/// Used to decide whether to run GC after an install/update operation. +pub fn gc_is_necessary() -> bool { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos(); + nanos.is_multiple_of(50) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + use tempfile::tempdir; + + // ──────────── sanitize_key ──────────── + + #[test] + fn test_sanitize_key_replaces_slash() { + assert_eq!(Cache::sanitize_key("vendor/package"), "vendor~package"); + } + + #[test] + fn test_sanitize_key_strips_unsafe_chars() { + // Colons and spaces should be stripped + assert_eq!(Cache::sanitize_key("foo:bar baz"), "foobarbaz"); + } + + #[test] + fn test_sanitize_key_preserves_safe_chars() { + let key = "provider-vendor~package.json"; + assert_eq!(Cache::sanitize_key(key), key); + } + + #[test] + fn test_sanitize_key_full_example() { + assert_eq!( + Cache::sanitize_key("provider-monolog/monolog.json"), + "provider-monolog~monolog.json" + ); + } + + // ──────────── read/write roundtrip (string) ──────────── + + #[test] + fn test_write_read_roundtrip_string() { + let dir = tempdir().unwrap(); + let cache = Cache::new(dir.path().to_path_buf(), true); + + cache.write("test-key", "hello world").unwrap(); + let result = cache.read("test-key"); + assert_eq!(result.as_deref(), Some("hello world")); + } + + // ──────────── read/write roundtrip (bytes) ──────────── + + #[test] + fn test_write_read_roundtrip_bytes() { + let dir = tempdir().unwrap(); + let cache = Cache::new(dir.path().to_path_buf(), true); + + let data = vec![0u8, 1, 2, 3, 255]; + cache.write_bytes("bin-key", &data).unwrap(); + let result = cache.read_bytes("bin-key"); + assert_eq!(result, Some(data)); + } + + // ──────────── clear removes all entries ──────────── + + #[test] + fn test_clear_removes_all_entries() { + let dir = tempdir().unwrap(); + let cache = Cache::new(dir.path().to_path_buf(), true); + + cache.write("key1", "value1").unwrap(); + cache.write("key2", "value2").unwrap(); + assert!(cache.read("key1").is_some()); + assert!(cache.read("key2").is_some()); + + cache.clear().unwrap(); + + assert!(cache.read("key1").is_none()); + assert!(cache.read("key2").is_none()); + } + + // ──────────── disabled cache returns None ──────────── + + #[test] + fn test_disabled_cache_returns_none() { + let dir = tempdir().unwrap(); + let cache = Cache::new(dir.path().to_path_buf(), false); + + // Write should silently succeed (no-op) + cache.write("key", "value").unwrap(); + + // Read should return None even if we wrote + assert!(cache.read("key").is_none()); + assert!(cache.read_bytes("key").is_none()); + } + + // ──────────── GC with TTL expiration ──────────── + + #[test] + fn test_gc_ttl_expiration() { + let dir = tempdir().unwrap(); + let cache = Cache::new(dir.path().to_path_buf(), true); + + // Write a file, then manually set its mtime to the past + cache.write("old-key", "old content").unwrap(); + let old_path = dir.path().join(Cache::sanitize_key("old-key")); + + // Write a fresh file + cache.write("new-key", "new content").unwrap(); + + // Set the old file's mtime to 2 hours ago + let two_hours_ago = SystemTime::now() - Duration::from_secs(7200); + filetime::set_file_mtime( + &old_path, + filetime::FileTime::from_system_time(two_hours_ago), + ) + .unwrap(); + + // GC with TTL of 1 hour (3600 seconds) + cache.gc(3600, u64::MAX).unwrap(); + + // Old file should be deleted, new file should remain + assert!( + cache.read("old-key").is_none(), + "expired file should be deleted" + ); + assert!(cache.read("new-key").is_some(), "fresh file should remain"); + } + + // ──────────── GC with size limit ──────────── + + #[test] + fn test_gc_size_limit() { + let dir = tempdir().unwrap(); + let cache = Cache::new(dir.path().to_path_buf(), true); + + // Write two files; the first one should be older + cache.write("old-file", "aaaaaaaaaa").unwrap(); // 10 bytes + let old_path = dir.path().join(Cache::sanitize_key("old-file")); + + // Add a small delay before writing second file via mtime manipulation + cache.write("new-file", "bbbbbbbbbb").unwrap(); // 10 bytes + + // Set old-file's mtime to 1 second ago so it's older + let one_second_ago = SystemTime::now() - Duration::from_secs(1); + filetime::set_file_mtime( + &old_path, + filetime::FileTime::from_system_time(one_second_ago), + ) + .unwrap(); + + // GC with a max size of 12 bytes (can only fit one 10-byte file) + // TTL is very long so no TTL expiration + cache.gc(u64::MAX / 2, 12).unwrap(); + + // The older file should be removed to get under the size limit + assert!( + cache.read("old-file").is_none() || cache.read("new-file").is_none(), + "at least one file should be removed to enforce size limit" + ); + } + + // ──────────── age ──────────── + + #[test] + fn test_age_existing_entry() { + let dir = tempdir().unwrap(); + let cache = Cache::new(dir.path().to_path_buf(), true); + + cache.write("fresh-key", "content").unwrap(); + let age = cache.age("fresh-key"); + + // Should be very recent (< 5 seconds) + assert!(age.is_some()); + assert!(age.unwrap() < 5); + } + + #[test] + fn test_age_missing_entry() { + let dir = tempdir().unwrap(); + let cache = Cache::new(dir.path().to_path_buf(), true); + assert!(cache.age("nonexistent-key").is_none()); + } + + #[test] + fn test_age_disabled_cache() { + let dir = tempdir().unwrap(); + let cache = Cache::new(dir.path().to_path_buf(), false); + assert!(cache.age("any-key").is_none()); + } +} diff --git a/crates/mozart/src/commands/clear_cache.rs b/crates/mozart/src/commands/clear_cache.rs index 638de06..819ca9f 100644 --- a/crates/mozart/src/commands/clear_cache.rs +++ b/crates/mozart/src/commands/clear_cache.rs @@ -1,3 +1,4 @@ +use crate::cache::{Cache, build_cache_config}; use clap::Args; #[derive(Args)] @@ -7,6 +8,45 @@ pub struct ClearCacheArgs { pub gc: bool, } -pub fn execute(_args: &ClearCacheArgs, _cli: &super::Cli) -> anyhow::Result<()> { - todo!() +pub fn execute(args: &ClearCacheArgs, cli: &super::Cli) -> anyhow::Result<()> { + let config = build_cache_config(cli); + + if args.gc { + // Run GC only (probabilistic under normal circumstances, but forced here) + let repo_cache = Cache::repo(&config); + let files_cache = Cache::files(&config); + + repo_cache.gc(config.cache_ttl, u64::MAX)?; + files_cache.gc(config.cache_files_ttl, config.cache_files_maxsize)?; + + eprintln!("Cache garbage collection complete."); + eprintln!("Cache directory: {}", config.cache_dir.display()); + } else { + // Full clear of all cache directories + let repo_cache = Cache::repo(&config); + let files_cache = Cache::files(&config); + repo_cache.clear()?; + files_cache.clear()?; + // Clear anything else at the root that isn't covered by sub-caches + if config.cache_dir.exists() { + for entry in std::fs::read_dir(&config.cache_dir)? { + let entry = entry?; + let path = entry.path(); + // Skip repo/files subdirs (already cleared above) + if path == config.cache_files_dir || path == config.cache_repo_dir { + continue; + } + if path.is_file() { + std::fs::remove_file(&path)?; + } else if path.is_dir() { + std::fs::remove_dir_all(&path)?; + } + } + } + + eprintln!("Cache cleared."); + eprintln!("Cache directory: {}", config.cache_dir.display()); + } + + Ok(()) } diff --git a/crates/mozart/src/commands/install.rs b/crates/mozart/src/commands/install.rs index 53b827a..b5f9142 100644 --- a/crates/mozart/src/commands/install.rs +++ b/crates/mozart/src/commands/install.rs @@ -417,6 +417,7 @@ pub fn install_from_lock( vendor_dir, &pkg.name, Some(&mut progress), + None, )?; progress.finish(); diff --git a/crates/mozart/src/commands/outdated.rs b/crates/mozart/src/commands/outdated.rs index f517871..f355e09 100644 --- a/crates/mozart/src/commands/outdated.rs +++ b/crates/mozart/src/commands/outdated.rs @@ -332,7 +332,7 @@ fn fetch_latest_version(name: &str) -> anyhow::Result<PackageInfo> { use crate::package::Stability; use crate::version::find_best_candidate; - let versions = crate::packagist::fetch_package_versions(name)?; + let versions = crate::packagist::fetch_package_versions(name, None)?; let best = find_best_candidate(&versions, Stability::Stable) .ok_or_else(|| anyhow::anyhow!("No stable version found for {name}"))?; diff --git a/crates/mozart/src/commands/remove.rs b/crates/mozart/src/commands/remove.rs index 3010547..ecfbba8 100644 --- a/crates/mozart/src/commands/remove.rs +++ b/crates/mozart/src/commands/remove.rs @@ -268,6 +268,7 @@ pub fn execute(args: &RemoveArgs, cli: &super::Cli) -> anyhow::Result<()> { platform: PlatformConfig::new(), ignore_platform_reqs: args.ignore_platform_reqs, ignore_platform_req_list: args.ignore_platform_req.clone(), + repo_cache: None, }; // Print header messages @@ -365,6 +366,7 @@ pub fn execute(args: &RemoveArgs, cli: &super::Cli) -> anyhow::Result<()> { composer_json_content: composer_json_content.clone(), composer_json: raw.clone(), include_dev: dev_mode, + repo_cache: None, })?; // Compute and print change report @@ -701,6 +703,7 @@ mod tests { platform: crate::resolver::PlatformConfig::new(), ignore_platform_reqs: false, ignore_platform_req_list: vec![], + repo_cache: None, }; let resolved = resolve(&request).expect("initial resolution should succeed"); let initial_lock = generate_lock_file(&LockFileGenerationRequest { @@ -708,6 +711,7 @@ mod tests { composer_json_content: content.to_string(), composer_json: raw.clone(), include_dev: false, + repo_cache: None, }) .expect("initial lock file generation should succeed"); initial_lock @@ -730,6 +734,7 @@ mod tests { platform: crate::resolver::PlatformConfig::new(), ignore_platform_reqs: false, ignore_platform_req_list: vec![], + repo_cache: None, }; let resolved2 = resolve(&request2).expect("post-remove resolution should succeed"); @@ -739,6 +744,7 @@ mod tests { composer_json_content: composer_json_content2, composer_json: raw, include_dev: false, + repo_cache: None, }) .expect("post-remove lock file generation should succeed"); diff --git a/crates/mozart/src/commands/require.rs b/crates/mozart/src/commands/require.rs index 128e4a9..7709f86 100644 --- a/crates/mozart/src/commands/require.rs +++ b/crates/mozart/src/commands/require.rs @@ -276,7 +276,7 @@ fn interactive_search_packages( )) ); - match packagist::fetch_package_versions(&package_name) { + match packagist::fetch_package_versions(&package_name, None) { Ok(versions) => { match version::find_best_candidate(&versions, preferred_stability) { Some(best) => { @@ -469,7 +469,7 @@ pub fn execute(args: &RequireArgs, cli: &super::Cli) -> anyhow::Result<()> { )) ); - let versions = packagist::fetch_package_versions(&name)?; + let versions = packagist::fetch_package_versions(&name, None)?; let best = version::find_best_candidate(&versions, preferred_stability) .ok_or_else(|| { anyhow::anyhow!( @@ -596,6 +596,7 @@ pub fn execute(args: &RequireArgs, cli: &super::Cli) -> anyhow::Result<()> { platform: PlatformConfig::new(), ignore_platform_reqs: args.ignore_platform_reqs, ignore_platform_req_list: args.ignore_platform_req.clone(), + repo_cache: None, }; // Print header messages @@ -673,6 +674,7 @@ pub fn execute(args: &RequireArgs, cli: &super::Cli) -> anyhow::Result<()> { composer_json_content: composer_json_content.clone(), composer_json: raw.clone(), include_dev: dev_mode, + repo_cache: None, })?; // Compute and print change report @@ -934,6 +936,7 @@ mod tests { platform: PlatformConfig::new(), ignore_platform_reqs: false, ignore_platform_req_list: vec![], + repo_cache: None, }; let resolved = resolver::resolve(&request).expect("Resolution should succeed"); @@ -945,6 +948,7 @@ mod tests { composer_json_content: composer_json_content.to_string(), composer_json, include_dev: false, + repo_cache: None, }) .expect("Lock file generation should succeed"); @@ -980,6 +984,7 @@ mod tests { platform: PlatformConfig::new(), ignore_platform_reqs: false, ignore_platform_req_list: vec![], + repo_cache: None, }; let resolved = resolver::resolve(&request).expect("Resolution should succeed"); @@ -988,6 +993,7 @@ mod tests { composer_json_content: content.to_string(), composer_json: raw, include_dev: false, + repo_cache: None, }) .expect("Lock file generation should succeed"); diff --git a/crates/mozart/src/commands/show.rs b/crates/mozart/src/commands/show.rs index ff157e0..ab013e3 100644 --- a/crates/mozart/src/commands/show.rs +++ b/crates/mozart/src/commands/show.rs @@ -468,7 +468,7 @@ fn fetch_latest_for_package(name: &str) -> anyhow::Result<LatestInfo> { use crate::package::Stability; use crate::version::find_best_candidate; - let versions = crate::packagist::fetch_package_versions(name)?; + let versions = crate::packagist::fetch_package_versions(name, None)?; let best = find_best_candidate(&versions, Stability::Stable) .ok_or_else(|| anyhow::anyhow!("No stable version found for {name}"))?; @@ -1447,7 +1447,7 @@ fn show_available(args: &ShowArgs, working_dir: &Path) -> anyhow::Result<()> { if is_platform_package(&pkg.name) { continue; } - match crate::packagist::fetch_package_versions(&pkg.name) { + match crate::packagist::fetch_package_versions(&pkg.name, None) { Ok(versions) => { let version_strings: Vec<String> = versions.iter().map(|v| v.version.clone()).collect(); @@ -1482,7 +1482,7 @@ fn show_available(args: &ShowArgs, working_dir: &Path) -> anyhow::Result<()> { } fn show_available_versions(pkg_name: &str, args: &ShowArgs) -> anyhow::Result<()> { - let versions = crate::packagist::fetch_package_versions(pkg_name)?; + let versions = crate::packagist::fetch_package_versions(pkg_name, None)?; if versions.is_empty() { println!("No versions found for {pkg_name}"); return Ok(()); @@ -1510,7 +1510,7 @@ fn show_available_versions(pkg_name: &str, args: &ShowArgs) -> anyhow::Result<() } fn show_available_versions_inline(pkg_name: &str) { - match crate::packagist::fetch_package_versions(pkg_name) { + match crate::packagist::fetch_package_versions(pkg_name, None) { Ok(versions) => { if versions.is_empty() { println!("{}: no versions found", crate::console::info(pkg_name)); diff --git a/crates/mozart/src/commands/update.rs b/crates/mozart/src/commands/update.rs index fc9400a..930c458 100644 --- a/crates/mozart/src/commands/update.rs +++ b/crates/mozart/src/commands/update.rs @@ -735,6 +735,7 @@ pub fn execute(args: &UpdateArgs, cli: &super::Cli) -> anyhow::Result<()> { platform: PlatformConfig::new(), ignore_platform_reqs: args.ignore_platform_reqs, ignore_platform_req_list: args.ignore_platform_req.clone(), + repo_cache: None, }; // Step 6: Print header and run resolver @@ -872,6 +873,7 @@ pub fn execute(args: &UpdateArgs, cli: &super::Cli) -> anyhow::Result<()> { composer_json_content: composer_json_content.clone(), composer_json: composer_json.clone(), include_dev: dev_mode, + repo_cache: None, })?; // Step 10: Compute and print change report @@ -1677,6 +1679,7 @@ mod tests { platform: PlatformConfig::new(), ignore_platform_reqs: false, ignore_platform_req_list: vec![], + repo_cache: None, }; let resolved = resolve(&request).expect("Resolution should succeed"); @@ -1688,6 +1691,7 @@ mod tests { composer_json_content: composer_json_content.to_string(), composer_json, include_dev: false, + repo_cache: None, }) .expect("Lock file generation should succeed"); diff --git a/crates/mozart/src/downloader.rs b/crates/mozart/src/downloader.rs index e7ded03..cfed951 100644 --- a/crates/mozart/src/downloader.rs +++ b/crates/mozart/src/downloader.rs @@ -1,3 +1,4 @@ +use crate::cache::Cache; use sha1::{Digest, Sha1}; use std::collections::HashSet; use std::fs; @@ -76,11 +77,37 @@ impl DownloadProgress { /// If `expected_shasum` is provided and non-empty, verifies SHA-1 of the downloaded bytes. /// If `progress` is provided, increments it as bytes are received and sets the total from /// the `Content-Length` response header. +/// If `files_cache` is provided, the downloaded bytes are cached by URL; cache hits skip +/// the network request entirely. pub fn download_dist( url: &str, expected_shasum: Option<&str>, progress: Option<&mut DownloadProgress>, + files_cache: Option<&Cache>, ) -> anyhow::Result<Vec<u8>> { + // Build a cache key from the URL + let cache_key = Cache::sanitize_key(url); + + // Check cache first + if let Some(cache) = files_cache + && let Some(cached_bytes) = cache.read_bytes(&cache_key) + { + // Verify checksum against cache hit if provided + if let Some(shasum) = expected_shasum + && !shasum.is_empty() + { + let mut hasher = Sha1::new(); + hasher.update(&cached_bytes); + let computed = format!("{:x}", hasher.finalize()); + if computed == shasum { + return Ok(cached_bytes); + } + // Checksum mismatch — discard cache, re-download + } else { + return Ok(cached_bytes); + } + } + let response = reqwest::blocking::get(url)?; if !response.status().is_success() { @@ -126,6 +153,11 @@ pub fn download_dist( } } + // Write to cache + if let Some(cache) = files_cache { + let _ = cache.write_bytes(&cache_key, &bytes); + } + Ok(bytes) } @@ -292,6 +324,7 @@ pub fn extract_tar_gz(data: &[u8], target_dir: &Path) -> anyhow::Result<()> { /// - `vendor_dir`: path to `vendor/` directory /// - `package_name`: e.g. `"monolog/monolog"` /// - `progress`: optional mutable progress tracker to update during download +/// - `files_cache`: optional files cache; if provided, the archive bytes are cached by URL pub fn install_package( dist_url: &str, dist_type: &str, @@ -299,6 +332,7 @@ pub fn install_package( vendor_dir: &Path, package_name: &str, progress: Option<&mut DownloadProgress>, + files_cache: Option<&Cache>, ) -> anyhow::Result<()> { let target = vendor_dir.join(package_name); @@ -308,7 +342,7 @@ pub fn install_package( } fs::create_dir_all(&target)?; - let bytes = download_dist(dist_url, dist_shasum, progress)?; + let bytes = download_dist(dist_url, dist_shasum, progress, files_cache)?; match dist_type { "zip" => extract_zip(&bytes, &target)?, diff --git a/crates/mozart/src/lib.rs b/crates/mozart/src/lib.rs index f9220e9..19b44c4 100644 --- a/crates/mozart/src/lib.rs +++ b/crates/mozart/src/lib.rs @@ -1,4 +1,5 @@ pub mod autoload; +pub mod cache; pub mod commands; pub mod console; pub mod constraint; diff --git a/crates/mozart/src/lockfile.rs b/crates/mozart/src/lockfile.rs index 7c945b8..4742772 100644 --- a/crates/mozart/src/lockfile.rs +++ b/crates/mozart/src/lockfile.rs @@ -1,3 +1,4 @@ +use crate::cache::Cache; use crate::package::{RawPackageData, to_json_pretty}; use crate::packagist::{self, PackagistDist, PackagistSource, PackagistVersion}; use crate::resolver::ResolvedPackage; @@ -241,6 +242,8 @@ pub struct LockFileGenerationRequest { pub composer_json: RawPackageData, /// Whether require-dev was included in resolution. pub include_dev: bool, + /// Optional repo cache for Packagist API calls made during generation. + pub repo_cache: Option<Cache>, } /// Convert a `PackagistSource` to a `LockedSource`. @@ -393,7 +396,7 @@ pub fn generate_lock_file(request: &LockFileGenerationRequest) -> anyhow::Result // 1. Fetch full metadata for all resolved packages let mut package_metadata: HashMap<String, PackagistVersion> = HashMap::new(); for pkg in &request.resolved_packages { - let versions = packagist::fetch_package_versions(&pkg.name)?; + let versions = packagist::fetch_package_versions(&pkg.name, request.repo_cache.as_ref())?; // Find the exact version matching pkg.version_normalized let matching = versions .into_iter() @@ -921,6 +924,7 @@ mod tests { composer_json_content: composer_json_content.clone(), composer_json, include_dev: true, + repo_cache: None, }; let lock = generate_lock_file(&request).unwrap(); @@ -1024,6 +1028,7 @@ mod tests { platform: PlatformConfig::new(), ignore_platform_reqs: false, ignore_platform_req_list: vec![], + repo_cache: None, }; let resolved = resolve(&resolve_request).expect("Resolution should succeed"); @@ -1038,6 +1043,7 @@ mod tests { composer_json_content: composer_json_content.clone(), composer_json, include_dev: false, + repo_cache: None, }; let lock = generate_lock_file(&gen_request).expect("Lock file generation should succeed"); diff --git a/crates/mozart/src/packagist.rs b/crates/mozart/src/packagist.rs index 7ca520e..65b1ecd 100644 --- a/crates/mozart/src/packagist.rs +++ b/crates/mozart/src/packagist.rs @@ -1,3 +1,4 @@ +use crate::cache::Cache; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -123,7 +124,25 @@ pub fn parse_p2_response(json: &str, package_name: &str) -> anyhow::Result<Vec<P } /// Fetch package version metadata from the Packagist p2 API. -pub fn fetch_package_versions(package_name: &str) -> anyhow::Result<Vec<PackagistVersion>> { +/// +/// If `repo_cache` is provided, the JSON response is cached on disk under the +/// key `"provider-{vendor}~{package}.json"`. Subsequent calls for the same +/// package are served from cache without a network request. +pub fn fetch_package_versions( + package_name: &str, + repo_cache: Option<&Cache>, +) -> anyhow::Result<Vec<PackagistVersion>> { + // Build cache key: replace `/` with `~` per cache key convention + let cache_key = format!("provider-{}.json", package_name.replace('/', "~")); + + // Check cache first + if let Some(cache) = repo_cache + && let Some(cached) = cache.read(&cache_key) + { + return parse_p2_response(&cached, package_name); + } + + // Cache miss — fetch from Packagist let url = format!("https://repo.packagist.org/p2/{package_name}.json"); let response = reqwest::blocking::get(&url)?; @@ -135,6 +154,12 @@ pub fn fetch_package_versions(package_name: &str) -> anyhow::Result<Vec<Packagis } let body = response.text()?; + + // Write to cache + if let Some(cache) = repo_cache { + let _ = cache.write(&cache_key, &body); + } + parse_p2_response(&body, package_name) } diff --git a/crates/mozart/src/resolver.rs b/crates/mozart/src/resolver.rs index ab837cf..3243a44 100644 --- a/crates/mozart/src/resolver.rs +++ b/crates/mozart/src/resolver.rs @@ -13,6 +13,7 @@ use pubgrub::{ PackageResolutionStatistics, PubGrubError, Ranges, Reporter, }; +use crate::cache::Cache; use crate::constraint::{Constraint, VersionConstraint}; use crate::package::Stability; use crate::packagist; @@ -588,6 +589,9 @@ pub struct MozartProvider { /// Cache of fetched package metadata. Populated lazily from Packagist. package_cache: RefCell<HashMap<String, PackageVersions>>, + /// Optional on-disk repo cache for Packagist API responses. + repo_cache: Option<Cache>, + /// Platform packages (php, ext-*, lib-*) with their fixed versions. platform_packages: HashMap<String, ComposerVersion>, @@ -627,8 +631,12 @@ impl MozartProvider { } } - // Fetch from Packagist - let packagist_versions = packagist::fetch_package_versions(package_name).map_err(|e| { + // Fetch from Packagist (with optional on-disk repo cache) + let packagist_versions = packagist::fetch_package_versions( + package_name, + self.repo_cache.as_ref(), + ) + .map_err(|e| { ResolverError::PackagistError(format!("Failed to fetch {}: {}", package_name, e)) })?; @@ -944,6 +952,8 @@ pub struct ResolveRequest { pub ignore_platform_reqs: bool, /// Specific platform requirements to ignore. pub ignore_platform_req_list: Vec<String>, + /// Optional on-disk repo cache for Packagist API responses. + pub repo_cache: Option<Cache>, } /// A single package in the resolution output. @@ -1005,6 +1015,7 @@ pub fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, Resolve // 2. Build the provider let provider = MozartProvider { package_cache: RefCell::new(HashMap::new()), + repo_cache: request.repo_cache.clone(), platform_packages: request.platform.to_versions(), minimum_stability: request.minimum_stability, stability_flags: request.stability_flags.clone(), @@ -1531,6 +1542,7 @@ mod tests { root_conflicts: vec![], ignore_platform_reqs: false, ignore_platform_req_list: vec![], + repo_cache: None, }; let stable_v = ComposerVersion::stable(1, 0, 0, 0); @@ -1583,6 +1595,7 @@ mod tests { root_conflicts: vec![], ignore_platform_reqs: false, ignore_platform_req_list: vec![], + repo_cache: None, }; let stable_v = ComposerVersion::stable(1, 0, 0, 0); @@ -1627,6 +1640,7 @@ mod tests { root_conflicts: vec![], ignore_platform_reqs: false, ignore_platform_req_list: vec![], + repo_cache: None, }; let dev_v = ComposerVersion { @@ -1652,6 +1666,7 @@ mod tests { root_conflicts: vec![], ignore_platform_reqs: true, ignore_platform_req_list: vec![], + repo_cache: None, }; assert!(provider.should_skip_platform_dep("php")); @@ -1672,6 +1687,7 @@ mod tests { root_conflicts: vec![], ignore_platform_reqs: false, ignore_platform_req_list: vec!["ext-intl".to_string()], + repo_cache: None, }; assert!(provider.should_skip_platform_dep("ext-intl")); @@ -1693,6 +1709,7 @@ mod tests { root_conflicts: vec![], ignore_platform_reqs: false, ignore_platform_req_list: vec![], + repo_cache: None, }; let root = PackageName::root(); @@ -1719,6 +1736,7 @@ mod tests { root_conflicts: vec![], ignore_platform_reqs: false, ignore_platform_req_list: vec![], + repo_cache: None, }; let php = PackageName("php".to_string()); @@ -1880,6 +1898,7 @@ mod tests { platform: PlatformConfig::new(), ignore_platform_reqs: false, ignore_platform_req_list: vec![], + repo_cache: None, }; let result = resolve(&request); |
