diff options
| -rw-r--r-- | crates/mozart-core/src/config.rs | 45 | ||||
| -rw-r--r-- | crates/mozart-registry/src/cache.rs | 101 | ||||
| -rw-r--r-- | crates/mozart/src/commands/clear_cache.rs | 79 | ||||
| -rw-r--r-- | crates/mozart/src/main.rs | 13 |
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(()) => {} |
