aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart
diff options
context:
space:
mode:
Diffstat (limited to 'crates/mozart')
-rw-r--r--crates/mozart/Cargo.toml1
-rw-r--r--crates/mozart/src/cache.rs492
-rw-r--r--crates/mozart/src/commands/clear_cache.rs44
-rw-r--r--crates/mozart/src/commands/install.rs1
-rw-r--r--crates/mozart/src/commands/outdated.rs2
-rw-r--r--crates/mozart/src/commands/remove.rs6
-rw-r--r--crates/mozart/src/commands/require.rs10
-rw-r--r--crates/mozart/src/commands/show.rs8
-rw-r--r--crates/mozart/src/commands/update.rs4
-rw-r--r--crates/mozart/src/downloader.rs36
-rw-r--r--crates/mozart/src/lib.rs1
-rw-r--r--crates/mozart/src/lockfile.rs8
-rw-r--r--crates/mozart/src/packagist.rs27
-rw-r--r--crates/mozart/src/resolver.rs23
14 files changed, 649 insertions, 14 deletions
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);