aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-06 02:48:36 +0900
committernsfisis <nsfisis@gmail.com>2026-05-06 02:48:36 +0900
commitb97e34358be5df05a3db9f5f3ef1502eaa94b1c0 (patch)
treee0058b1ad79b60a3751f64ae6d07186cfddf1ee7 /crates
parent5254a9e9b698c3618229f4f802b39a82baf9169a (diff)
downloadphp-mozart-b97e34358be5df05a3db9f5f3ef1502eaa94b1c0.tar.gz
php-mozart-b97e34358be5df05a3db9f5f3ef1502eaa94b1c0.tar.zst
php-mozart-b97e34358be5df05a3db9f5f3ef1502eaa94b1c0.zip
fix(cache): mirror Composer no-cache and cache-files-maxsize handling
--no-cache redirects COMPOSER_CACHE_DIR to /dev/null (mirrors Application::doRun). cache_files_maxsize is now u64 with a "300MiB"-style string deserializer. Cache::new() takes readonly instead of enabled; is_usable() detects null devices.
Diffstat (limited to 'crates')
-rw-r--r--crates/mozart-core/src/config.rs45
-rw-r--r--crates/mozart-registry/src/cache.rs101
-rw-r--r--crates/mozart/src/commands/clear_cache.rs79
-rw-r--r--crates/mozart/src/main.rs13
4 files changed, 158 insertions, 80 deletions
diff --git a/crates/mozart-core/src/config.rs b/crates/mozart-core/src/config.rs
index dd8a9de..cbb3ba6 100644
--- a/crates/mozart-core/src/config.rs
+++ b/crates/mozart-core/src/config.rs
@@ -10,6 +10,46 @@ use std::collections::BTreeMap;
use crate::composer::composer_home;
+/// Parse a size string like "300MiB", "1GB", "512k", or a plain integer string
+/// into a byte count. Mirrors Composer's `Config::get('cache-files-maxsize')`.
+fn parse_size_bytes(s: &str) -> Option<u64> {
+ let s = s.trim();
+ let i = s.find(|c: char| c.is_ascii_alphabetic()).unwrap_or(s.len());
+ let num: f64 = s[..i].trim().parse().ok()?;
+ let multiplier: f64 = match s[i..].trim().chars().next().map(|c| c.to_ascii_lowercase()) {
+ Some('g') => 1024.0 * 1024.0 * 1024.0,
+ Some('m') => 1024.0 * 1024.0,
+ Some('k') => 1024.0,
+ None => 1.0,
+ Some(_) => return None,
+ };
+ Some((num * multiplier).max(0.0) as u64)
+}
+
+fn deserialize_size_bytes<'de, D: serde::Deserializer<'de>>(d: D) -> Result<u64, D::Error> {
+ use serde::de::{Error, Visitor};
+ struct V;
+ impl<'de> Visitor<'de> for V {
+ type Value = u64;
+ fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+ f.write_str("a non-negative integer or a size string like \"300MiB\"")
+ }
+ fn visit_u64<E: Error>(self, v: u64) -> Result<u64, E> {
+ Ok(v)
+ }
+ fn visit_i64<E: Error>(self, v: i64) -> Result<u64, E> {
+ Ok(v.max(0) as u64)
+ }
+ fn visit_f64<E: Error>(self, v: f64) -> Result<u64, E> {
+ Ok(v.max(0.0) as u64)
+ }
+ fn visit_str<E: Error>(self, v: &str) -> Result<u64, E> {
+ parse_size_bytes(v).ok_or_else(|| E::custom(format!("invalid size: {v}")))
+ }
+ }
+ d.deserialize_any(V)
+}
+
/// Effective Composer configuration for a project.
///
/// Known properties are typed fields; anything else lands in `extra`.
@@ -31,7 +71,8 @@ pub struct Config {
pub cache_repo_dir: String,
pub cache_vcs_dir: String,
pub cache_files_ttl: u64,
- pub cache_files_maxsize: String,
+ #[serde(deserialize_with = "deserialize_size_bytes")]
+ pub cache_files_maxsize: u64,
pub cache_read_only: bool,
pub prepend_autoloader: bool,
pub autoloader_suffix: Option<String>,
@@ -74,7 +115,7 @@ impl Default for Config {
cache_repo_dir: "{$cache-dir}/repo".to_string(),
cache_vcs_dir: "{$cache-dir}/vcs".to_string(),
cache_files_ttl: 15_552_000,
- cache_files_maxsize: "300MiB".to_string(),
+ cache_files_maxsize: 300 * 1024 * 1024,
cache_read_only: false,
prepend_autoloader: true,
autoloader_suffix: None,
diff --git a/crates/mozart-registry/src/cache.rs b/crates/mozart-registry/src/cache.rs
index 62efd02..3ba3258 100644
--- a/crates/mozart-registry/src/cache.rs
+++ b/crates/mozart-registry/src/cache.rs
@@ -30,8 +30,6 @@ pub struct CacheConfig {
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 {
@@ -45,6 +43,10 @@ impl CacheConfig {
///
/// Respects `$COMPOSER_CACHE_DIR` for the base directory, and
/// `$COMPOSER_NO_CACHE` / `COMPOSER_CACHE_READ_ONLY` env vars.
+///
+/// When no-cache mode is active (via `cli_no_cache` or `$COMPOSER_NO_CACHE`),
+/// all cache directories are set to a null device, mirroring Composer's
+/// `Application::doRun()` which calls `putenv('COMPOSER_CACHE_DIR', '/dev/null')`.
pub fn build_cache_config(cli_no_cache: bool) -> CacheConfig {
let no_cache = std::env::var("COMPOSER_NO_CACHE").is_ok() || cli_no_cache;
@@ -52,10 +54,20 @@ pub fn build_cache_config(cli_no_cache: bool) -> CacheConfig {
.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") {
+ let cache_dir = if no_cache {
+ // Mirrors Composer: --no-cache redirects all cache paths to a null device so
+ // that Cache::is_usable() returns false and caching is transparently disabled.
+ #[cfg(windows)]
+ {
+ PathBuf::from("nul")
+ }
+ #[cfg(not(windows))]
+ {
+ PathBuf::from("/dev/null")
+ }
+ } else 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")
};
@@ -74,7 +86,6 @@ pub fn build_cache_config(cli_no_cache: bool) -> CacheConfig {
cache_files_maxsize: CacheConfig::DEFAULT_FILES_MAXSIZE,
cache_dir,
read_only,
- no_cache,
}
}
@@ -94,27 +105,61 @@ fn dirs_cache_dir() -> PathBuf {
pub struct Cache {
root: PathBuf,
enabled: bool,
+ readonly: 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);
+ /// Mirrors Composer's `Cache::__construct` + `Cache::isEnabled()`:
+ /// - If the path is a null device (`/dev/null`, `nul`, etc.), the cache is disabled.
+ /// - If `readonly` is true, the cache is always enabled (no writability check).
+ /// - Otherwise, tries to create the directory and checks that it is writable;
+ /// disables the cache with a warning if not.
+ pub fn new(root: PathBuf, readonly: bool) -> Self {
+ let enabled = if !Self::is_usable(&root) {
+ false
+ } else if readonly {
+ true
+ } else {
+ if fs::create_dir_all(&root).is_err() {
+ false
+ } else {
+ fs::metadata(&root)
+ .map(|m| !m.permissions().readonly())
+ .unwrap_or(false)
+ }
+ };
+ Self {
+ root,
+ enabled,
+ readonly,
+ }
+ }
+
+ /// Returns `false` for null-device paths that should never be used as a real cache.
+ ///
+ /// Mirrors Composer's `Cache::isUsable()`.
+ fn is_usable(path: &Path) -> bool {
+ let s = path.to_string_lossy();
+ if cfg!(windows) {
+ // On Windows, "nul" and "$null" (any case) are null devices.
+ !s.split(['/', '\\'])
+ .any(|c| c.eq_ignore_ascii_case("nul") || c == "$null")
+ } else {
+ // On Unix, /dev/null and any path under it are unusable.
+ s != "/dev/null" && !s.starts_with("/dev/null/")
}
- 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)
+ Self::new(config.cache_repo_dir.clone(), config.read_only)
}
/// Shorthand: create the files cache from a `CacheConfig`.
pub fn files(config: &CacheConfig) -> Self {
- Self::new(config.cache_files_dir.clone(), !config.no_cache)
+ Self::new(config.cache_files_dir.clone(), config.read_only)
}
/// Whether caching is enabled for this bucket.
@@ -148,7 +193,7 @@ impl Cache {
/// Write a string entry atomically (write to temp file, then rename).
pub fn write(&self, key: &str, contents: &str) -> anyhow::Result<()> {
- if !self.enabled {
+ if !self.enabled || self.readonly {
return Ok(());
}
self.write_bytes(key, contents.as_bytes())
@@ -164,7 +209,7 @@ impl Cache {
/// 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 {
+ if !self.enabled || self.readonly {
return Ok(());
}
let dest = self.path_for(key);
@@ -181,6 +226,9 @@ impl Cache {
/// Delete all cached entries in this bucket.
pub fn clear(&self) -> anyhow::Result<()> {
+ if !self.enabled || self.readonly {
+ return Ok(());
+ }
if !self.root.exists() {
return Ok(());
}
@@ -202,7 +250,7 @@ impl Cache {
/// 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() {
+ if !self.enabled || self.readonly || !self.root.exists() {
return Ok(());
}
@@ -365,7 +413,7 @@ mod tests {
#[test]
fn test_write_read_roundtrip_string() {
let dir = tempdir().unwrap();
- let cache = Cache::new(dir.path().to_path_buf(), true);
+ let cache = Cache::new(dir.path().to_path_buf(), false);
cache.write("test-key", "hello world").unwrap();
let result = cache.read("test-key");
@@ -375,7 +423,7 @@ mod tests {
#[test]
fn test_write_read_roundtrip_bytes() {
let dir = tempdir().unwrap();
- let cache = Cache::new(dir.path().to_path_buf(), true);
+ let cache = Cache::new(dir.path().to_path_buf(), false);
let data = vec![0u8, 1, 2, 3, 255];
cache.write_bytes("bin-key", &data).unwrap();
@@ -386,7 +434,7 @@ mod tests {
#[test]
fn test_clear_removes_all_entries() {
let dir = tempdir().unwrap();
- let cache = Cache::new(dir.path().to_path_buf(), true);
+ let cache = Cache::new(dir.path().to_path_buf(), false);
cache.write("key1", "value1").unwrap();
cache.write("key2", "value2").unwrap();
@@ -401,8 +449,8 @@ mod tests {
#[test]
fn test_disabled_cache_returns_none() {
- let dir = tempdir().unwrap();
- let cache = Cache::new(dir.path().to_path_buf(), false);
+ // Point cache at /dev/null — is_usable() returns false → cache disabled.
+ let cache = Cache::new(PathBuf::from("/dev/null/files"), false);
// Write should silently succeed (no-op)
cache.write("key", "value").unwrap();
@@ -415,7 +463,7 @@ mod tests {
#[test]
fn test_gc_ttl_expiration() {
let dir = tempdir().unwrap();
- let cache = Cache::new(dir.path().to_path_buf(), true);
+ let cache = Cache::new(dir.path().to_path_buf(), false);
// Write a file, then manually set its mtime to the past
cache.write("old-key", "old content").unwrap();
@@ -446,7 +494,7 @@ mod tests {
#[test]
fn test_gc_size_limit() {
let dir = tempdir().unwrap();
- let cache = Cache::new(dir.path().to_path_buf(), true);
+ let cache = Cache::new(dir.path().to_path_buf(), false);
// Write two files; the first one should be older
cache.write("old-file", "aaaaaaaaaa").unwrap(); // 10 bytes
@@ -477,7 +525,7 @@ mod tests {
#[test]
fn test_gc_vcs_removes_old_subdirs() {
let dir = tempdir().unwrap();
- let cache = Cache::new(dir.path().to_path_buf(), true);
+ let cache = Cache::new(dir.path().to_path_buf(), false);
let old_mirror = dir.path().join("old-mirror");
let new_mirror = dir.path().join("new-mirror");
@@ -502,7 +550,7 @@ mod tests {
#[test]
fn test_age_existing_entry() {
let dir = tempdir().unwrap();
- let cache = Cache::new(dir.path().to_path_buf(), true);
+ let cache = Cache::new(dir.path().to_path_buf(), false);
cache.write("fresh-key", "content").unwrap();
let age = cache.age("fresh-key");
@@ -515,14 +563,13 @@ mod tests {
#[test]
fn test_age_missing_entry() {
let dir = tempdir().unwrap();
- let cache = Cache::new(dir.path().to_path_buf(), true);
+ let cache = Cache::new(dir.path().to_path_buf(), false);
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);
+ let cache = Cache::new(PathBuf::from("/dev/null/files"), 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 0740c85..b6ab2f1 100644
--- a/crates/mozart/src/commands/clear_cache.rs
+++ b/crates/mozart/src/commands/clear_cache.rs
@@ -1,5 +1,8 @@
+use std::{borrow::Cow, path::Path};
+
use clap::Args;
-use mozart_registry::cache::{Cache, build_cache_config};
+use mozart_core::{composer::Composer, factory::create_config};
+use mozart_registry::cache::Cache;
#[derive(Args)]
pub struct ClearCacheArgs {
@@ -13,21 +16,28 @@ pub async fn execute(
cli: &super::Cli,
console: &mozart_core::console::Console,
) -> anyhow::Result<()> {
- let config = build_cache_config(cli.no_cache);
+ let composer = Composer::try_load(cli.working_dir()?)?;
+ let config = if let Some(composer) = &composer {
+ Cow::Borrowed(composer.config())
+ } else {
+ Cow::Owned(create_config()?)
+ };
- // Build the list of (key, path) pairs to process.
- // cache-dir is only included in full clear mode, not GC mode.
- let mut cache_paths = vec![
+ let cache_paths = [
("cache-vcs-dir", &config.cache_vcs_dir),
("cache-repo-dir", &config.cache_repo_dir),
("cache-files-dir", &config.cache_files_dir),
+ ("cache-dir", &config.cache_dir),
];
- if !args.gc {
- cache_paths.push(("cache-dir", &config.cache_dir));
- }
- for (key, path) in &cache_paths {
- // Non-existent directory: skip with informational message
+ for (key, path) in cache_paths {
+ // only individual dirs get garbage collected
+ if key == "cache-dir" && args.gc {
+ continue;
+ }
+
+ let path = Path::new(path);
+
if !path.exists() {
console.info(&format!(
"Cache directory does not exist ({key}): {}",
@@ -36,8 +46,8 @@ pub async fn execute(
continue;
}
- // Read-only guard: skip with informational message
- if config.read_only {
+ let cache = Cache::new(path.to_owned(), config.cache_read_only);
+ if !cache.is_enabled() {
console.info(&format!("Cache is not enabled ({key}): {}", path.display()));
continue;
}
@@ -47,48 +57,15 @@ pub async fn execute(
"Garbage-collecting cache ({key}): {}",
path.display()
));
- let cache = Cache::new((*path).clone(), !config.no_cache);
- let result = match *key {
- "cache-files-dir" => cache.gc(config.cache_files_ttl, config.cache_files_maxsize),
- "cache-vcs-dir" => cache.gc_vcs(config.cache_ttl),
- // cache-repo-dir: 1 GB cap (matches Composer)
- _ => cache.gc(config.cache_ttl, 1024 * 1024 * 1024),
+ match key {
+ "cache-files-dir" => cache.gc(config.cache_files_ttl, config.cache_files_maxsize)?,
+ "cache-repo-dir" => cache.gc(config.cache_files_ttl, 1024 * 1024 * 1024 /* 1GB, this should almost never clear anything that is not outdated */)?,
+ "cache-vcs-dir" => cache.gc_vcs(config.cache_files_ttl)?,
+ _ => unreachable!(),
};
- if let Err(e) = result {
- console.error(&format!("Error during GC of {key}: {e}"));
- }
} else {
console.info(&format!("Clearing cache ({key}): {}", path.display()));
- if *key == "cache-dir" {
- // Clear anything at the root that isn't covered by sub-caches
- let result = (|| -> anyhow::Result<()> {
- for entry in std::fs::read_dir(path)? {
- let entry = entry?;
- let entry_path = entry.path();
- // Skip repo/files/vcs subdirs (cleared by their own iterations)
- if entry_path == config.cache_files_dir
- || entry_path == config.cache_repo_dir
- || entry_path == config.cache_vcs_dir
- {
- continue;
- }
- if entry_path.is_file() {
- std::fs::remove_file(&entry_path)?;
- } else if entry_path.is_dir() {
- std::fs::remove_dir_all(&entry_path)?;
- }
- }
- Ok(())
- })();
- if let Err(e) = result {
- console.error(&format!("Error clearing {key}: {e}"));
- }
- } else {
- let cache = Cache::new((*path).clone(), !config.no_cache);
- if let Err(e) = cache.clear() {
- console.error(&format!("Error clearing {key}: {e}"));
- }
- }
+ cache.clear()?;
}
}
diff --git a/crates/mozart/src/main.rs b/crates/mozart/src/main.rs
index 35f8485..701f3ef 100644
--- a/crates/mozart/src/main.rs
+++ b/crates/mozart/src/main.rs
@@ -76,6 +76,19 @@ async fn main() {
return;
};
+ if cli.no_cache {
+ println!("Disabling cache usage");
+ // SAFETY: single-threaded at this point; no other threads have started yet.
+ #[cfg(windows)]
+ unsafe {
+ std::env::set_var("COMPOSER_CACHE_DIR", "nul");
+ }
+ #[cfg(not(windows))]
+ unsafe {
+ std::env::set_var("COMPOSER_CACHE_DIR", "/dev/null");
+ }
+ }
+
init_tracing(cli.profile, cli.verbose, cli.quiet);
match commands::execute(&cli).await {
Ok(()) => {}