aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-registry/src
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-22 00:37:54 +0900
committernsfisis <nsfisis@gmail.com>2026-02-22 00:37:54 +0900
commit0a8e5935e6305819bb02d8c69e2f046ff397913a (patch)
treee5a288e679477b1603d7989e986ca22bbe590aa4 /crates/mozart-registry/src
parentb5af594fec7da72b15c9a202c641af0494db6355 (diff)
downloadphp-mozart-0a8e5935e6305819bb02d8c69e2f046ff397913a.tar.gz
php-mozart-0a8e5935e6305819bb02d8c69e2f046ff397913a.tar.zst
php-mozart-0a8e5935e6305819bb02d8c69e2f046ff397913a.zip
refactor(workspace): split monolithic crate into 6 workspace crates
Extract modules from the single `mozart` crate into 5 focused library crates to improve compilation parallelism and architectural clarity: - mozart-constraint: version constraint parser (independent) - mozart-core: base types, console, validation, platform utilities - mozart-archiver: archive creation (tar, zip, bzip2) - mozart-registry: Packagist API, cache, resolver, downloader, lockfile - mozart-autoload: autoloader generation and PHP scanner Refactor Console::from_cli and build_cache_config to accept primitive args instead of &Cli to break circular dependencies. Introduce [workspace.dependencies] for centralized version management. Remove 9 unused direct dependencies from the CLI crate. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart-registry/src')
-rw-r--r--crates/mozart-registry/src/cache.rs492
-rw-r--r--crates/mozart-registry/src/downloader.rs506
-rw-r--r--crates/mozart-registry/src/installed.rs229
-rw-r--r--crates/mozart-registry/src/lib.rs7
-rw-r--r--crates/mozart-registry/src/lockfile.rs1088
-rw-r--r--crates/mozart-registry/src/packagist.rs629
-rw-r--r--crates/mozart-registry/src/resolver.rs1917
-rw-r--r--crates/mozart-registry/src/version.rs267
8 files changed, 5135 insertions, 0 deletions
diff --git a/crates/mozart-registry/src/cache.rs b/crates/mozart-registry/src/cache.rs
new file mode 100644
index 0000000..ac4b507
--- /dev/null
+++ b/crates/mozart-registry/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_no_cache: bool) -> 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-registry/src/downloader.rs b/crates/mozart-registry/src/downloader.rs
new file mode 100644
index 0000000..cfed951
--- /dev/null
+++ b/crates/mozart-registry/src/downloader.rs
@@ -0,0 +1,506 @@
+use crate::cache::Cache;
+use sha1::{Digest, Sha1};
+use std::collections::HashSet;
+use std::fs;
+use std::io::{Cursor, Read, Write};
+use std::path::Path;
+
+/// A simple download progress tracker that writes to stderr.
+///
+/// When `show` is false, all methods are no-ops. This lets callers toggle
+/// progress display without branching on every call.
+pub struct DownloadProgress {
+ show: bool,
+ total: u64,
+ downloaded: u64,
+ label: String,
+}
+
+impl DownloadProgress {
+ /// Create a new progress tracker.
+ ///
+ /// - `show`: whether to actually display anything.
+ /// - `label`: a human-readable label (e.g. "psr/log (3.0.2)").
+ pub fn new(show: bool, label: impl Into<String>) -> Self {
+ Self {
+ show,
+ total: 0,
+ downloaded: 0,
+ label: label.into(),
+ }
+ }
+
+ /// Set the total expected bytes from a `Content-Length` header.
+ pub fn set_total(&mut self, total: u64) {
+ self.total = total;
+ }
+
+ /// Advance the downloaded byte count and redraw the line.
+ pub fn inc(&mut self, n: u64) {
+ if !self.show {
+ return;
+ }
+ self.downloaded += n;
+ let stderr = std::io::stderr();
+ let mut out = stderr.lock();
+ if let Some(pct) = (self.downloaded * 100).checked_div(self.total) {
+ let _ = write!(
+ out,
+ "\r Downloading {} ({}/{} bytes, {}%)",
+ self.label, self.downloaded, self.total, pct
+ );
+ } else {
+ let _ = write!(
+ out,
+ "\r Downloading {} ({} bytes)",
+ self.label, self.downloaded
+ );
+ }
+ let _ = out.flush();
+ }
+
+ /// Clear the progress line from the terminal.
+ pub fn finish(&self) {
+ if !self.show {
+ return;
+ }
+ let stderr = std::io::stderr();
+ let mut out = stderr.lock();
+ // Clear the line with spaces then return to start
+ let _ = write!(out, "\r{}\r", " ".repeat(80));
+ let _ = out.flush();
+ }
+}
+
+/// Download a dist archive from a URL.
+/// Returns the raw bytes of the downloaded archive.
+/// 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() {
+ anyhow::bail!(
+ "Failed to download dist archive from {} (HTTP {})",
+ url,
+ response.status()
+ );
+ }
+
+ // Stream the response body, updating progress as bytes arrive
+ let bytes = if let Some(pb) = progress {
+ if let Some(content_length) = response.content_length() {
+ pb.set_total(content_length);
+ }
+ let mut reader = response;
+ let mut buf = Vec::new();
+ let mut chunk = [0u8; 8192];
+ loop {
+ let n = reader.read(&mut chunk)?;
+ if n == 0 {
+ break;
+ }
+ buf.extend_from_slice(&chunk[..n]);
+ pb.inc(n as u64);
+ }
+ buf
+ } else {
+ response.bytes()?.to_vec()
+ };
+
+ // Verify SHA-1 checksum if provided
+ if let Some(shasum) = expected_shasum
+ && !shasum.is_empty()
+ {
+ let mut hasher = Sha1::new();
+ hasher.update(&bytes);
+ let result = hasher.finalize();
+ let computed = format!("{result:x}");
+
+ if computed != shasum {
+ anyhow::bail!("SHA-1 checksum mismatch for {url}: expected {shasum}, got {computed}");
+ }
+ }
+
+ // Write to cache
+ if let Some(cache) = files_cache {
+ let _ = cache.write_bytes(&cache_key, &bytes);
+ }
+
+ Ok(bytes)
+}
+
+/// Find the common top-level directory prefix shared by all entries.
+/// Returns `Some(prefix)` if all entries share a single top-level directory.
+fn find_top_level_dir(entries: &[String]) -> Option<String> {
+ if entries.is_empty() {
+ return None;
+ }
+
+ let mut prefixes: HashSet<String> = HashSet::new();
+ for entry in entries {
+ if let Some(slash_pos) = entry.find('/') {
+ prefixes.insert(entry[..slash_pos + 1].to_string());
+ } else {
+ // Entry at root level — no common prefix to strip
+ return None;
+ }
+ }
+
+ if prefixes.len() == 1 {
+ prefixes.into_iter().next()
+ } else {
+ None
+ }
+}
+
+/// Extract a zip archive to the target directory.
+/// Strips a common top-level directory if all entries share one (Packagist pattern).
+pub fn extract_zip(data: &[u8], target_dir: &Path) -> anyhow::Result<()> {
+ let cursor = Cursor::new(data);
+ let mut archive = zip::ZipArchive::new(cursor)?;
+
+ // Collect all entry names to detect common prefix
+ let entry_names: Vec<String> = (0..archive.len())
+ .map(|i| archive.by_index(i).map(|e| e.name().to_string()))
+ .collect::<Result<_, _>>()?;
+
+ let prefix = find_top_level_dir(&entry_names);
+
+ for i in 0..archive.len() {
+ let mut entry = archive.by_index(i)?;
+ let raw_name = entry.name().to_string();
+
+ // Strip common prefix
+ let relative = if let Some(ref pfx) = prefix {
+ if raw_name.starts_with(pfx.as_str()) {
+ &raw_name[pfx.len()..]
+ } else {
+ &raw_name
+ }
+ } else {
+ &raw_name
+ };
+
+ // Skip the directory entry itself (empty name after stripping)
+ if relative.is_empty() {
+ continue;
+ }
+
+ let target_path = target_dir.join(relative);
+
+ if raw_name.ends_with('/') {
+ // Directory entry
+ fs::create_dir_all(&target_path)?;
+ } else {
+ // File entry
+ if let Some(parent) = target_path.parent() {
+ fs::create_dir_all(parent)?;
+ }
+
+ let mut buf = Vec::new();
+ entry.read_to_end(&mut buf)?;
+ fs::write(&target_path, &buf)?;
+
+ // Set permissions on Unix
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ if let Some(mode) = entry.unix_mode() {
+ fs::set_permissions(&target_path, fs::Permissions::from_mode(mode))?;
+ }
+ }
+ }
+ }
+
+ Ok(())
+}
+
+/// Extract a tar.gz archive to the target directory.
+/// Strips a common top-level directory if all entries share one (Packagist pattern).
+pub fn extract_tar_gz(data: &[u8], target_dir: &Path) -> anyhow::Result<()> {
+ let cursor = Cursor::new(data);
+ let decoder = flate2::read::GzDecoder::new(cursor);
+ let mut archive = tar::Archive::new(decoder);
+
+ // We need to process in two passes: first collect names, then extract.
+ // Use a buffered approach: collect entries into memory.
+ let cursor2 = Cursor::new(data);
+ let decoder2 = flate2::read::GzDecoder::new(cursor2);
+ let mut archive2 = tar::Archive::new(decoder2);
+
+ let entry_names: Vec<String> = archive2
+ .entries()?
+ .filter_map(|e| e.ok())
+ .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string()))
+ .collect();
+
+ let prefix = find_top_level_dir(&entry_names);
+
+ for entry in archive.entries()? {
+ let mut entry = entry?;
+ let raw_path = entry.path()?.to_string_lossy().to_string();
+
+ // Strip common prefix
+ let relative = if let Some(ref pfx) = prefix {
+ if raw_path.starts_with(pfx.as_str()) {
+ raw_path[pfx.len()..].to_string()
+ } else {
+ raw_path.clone()
+ }
+ } else {
+ raw_path.clone()
+ };
+
+ // Skip empty (top-level dir itself)
+ if relative.is_empty() {
+ continue;
+ }
+
+ let target_path = target_dir.join(&relative);
+
+ let entry_type = entry.header().entry_type();
+ if entry_type.is_dir() {
+ fs::create_dir_all(&target_path)?;
+ } else if entry_type.is_file() {
+ if let Some(parent) = target_path.parent() {
+ fs::create_dir_all(parent)?;
+ }
+ let mut buf = Vec::new();
+ entry.read_to_end(&mut buf)?;
+ fs::write(&target_path, &buf)?;
+
+ // Set permissions on Unix
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ if let Ok(mode) = entry.header().mode() {
+ fs::set_permissions(&target_path, fs::Permissions::from_mode(mode))?;
+ }
+ }
+ }
+ // Symlinks and other types are skipped for now
+ }
+
+ Ok(())
+}
+
+/// Download and install a package to the vendor directory.
+///
+/// - `dist_url`: the download URL (from `LockedPackage.dist.url`)
+/// - `dist_type`: `"zip"` or `"tar"` (from `LockedPackage.dist.dist_type`)
+/// - `dist_shasum`: optional SHA-1 checksum
+/// - `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,
+ dist_shasum: Option<&str>,
+ vendor_dir: &Path,
+ package_name: &str,
+ progress: Option<&mut DownloadProgress>,
+ files_cache: Option<&Cache>,
+) -> anyhow::Result<()> {
+ let target = vendor_dir.join(package_name);
+
+ // Remove existing installation for a clean reinstall
+ if target.exists() {
+ fs::remove_dir_all(&target)?;
+ }
+ fs::create_dir_all(&target)?;
+
+ let bytes = download_dist(dist_url, dist_shasum, progress, files_cache)?;
+
+ match dist_type {
+ "zip" => extract_zip(&bytes, &target)?,
+ "tar" | "tar.gz" | "tgz" => extract_tar_gz(&bytes, &target)?,
+ other => anyhow::bail!("Unsupported dist type: {other}"),
+ }
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::io::Write as IoWrite;
+ use tempfile::tempdir;
+
+ /// Build a minimal zip archive in memory.
+ fn make_zip(files: &[(&str, &[u8])]) -> Vec<u8> {
+ let buf = Vec::new();
+ let cursor = Cursor::new(buf);
+ let mut writer = zip::ZipWriter::new(cursor);
+ let options = zip::write::FileOptions::<()>::default()
+ .compression_method(zip::CompressionMethod::Stored);
+
+ for (name, content) in files {
+ writer.start_file(*name, options).unwrap();
+ writer.write_all(content).unwrap();
+ }
+
+ writer.finish().unwrap().into_inner()
+ }
+
+ /// Build a minimal tar.gz archive in memory.
+ fn make_tar_gz(files: &[(&str, &[u8])]) -> Vec<u8> {
+ let buf = Vec::new();
+ let enc = flate2::write::GzEncoder::new(buf, flate2::Compression::default());
+ let mut builder = tar::Builder::new(enc);
+
+ for (name, content) in files {
+ let mut header = tar::Header::new_gnu();
+ header.set_size(content.len() as u64);
+ header.set_mode(0o644);
+ header.set_cksum();
+ builder
+ .append_data(&mut header, name, Cursor::new(content))
+ .unwrap();
+ }
+
+ builder.into_inner().unwrap().finish().unwrap()
+ }
+
+ #[test]
+ fn test_extract_zip_flat() {
+ let zip_data = make_zip(&[("file1.txt", b"hello"), ("subdir/file2.txt", b"world")]);
+
+ let dir = tempdir().unwrap();
+ extract_zip(&zip_data, dir.path()).unwrap();
+
+ assert_eq!(
+ fs::read_to_string(dir.path().join("file1.txt")).unwrap(),
+ "hello"
+ );
+ assert_eq!(
+ fs::read_to_string(dir.path().join("subdir/file2.txt")).unwrap(),
+ "world"
+ );
+ }
+
+ #[test]
+ fn test_extract_zip_with_top_level_dir() {
+ // Packagist pattern: all files under vendor-package-abc123/
+ let zip_data = make_zip(&[
+ ("vendor-pkg-abc/", &[]),
+ ("vendor-pkg-abc/file1.txt", b"hello"),
+ ("vendor-pkg-abc/src/Foo.php", b"<?php"),
+ ]);
+
+ let dir = tempdir().unwrap();
+ extract_zip(&zip_data, dir.path()).unwrap();
+
+ // Top-level dir should be stripped
+ assert!(dir.path().join("file1.txt").exists());
+ assert!(dir.path().join("src/Foo.php").exists());
+ assert_eq!(
+ fs::read_to_string(dir.path().join("file1.txt")).unwrap(),
+ "hello"
+ );
+ }
+
+ #[test]
+ fn test_extract_tar_gz_flat() {
+ let tar_data = make_tar_gz(&[("file1.txt", b"hello"), ("subdir/file2.txt", b"world")]);
+
+ let dir = tempdir().unwrap();
+ extract_tar_gz(&tar_data, dir.path()).unwrap();
+
+ assert_eq!(
+ fs::read_to_string(dir.path().join("file1.txt")).unwrap(),
+ "hello"
+ );
+ assert_eq!(
+ fs::read_to_string(dir.path().join("subdir/file2.txt")).unwrap(),
+ "world"
+ );
+ }
+
+ #[test]
+ fn test_extract_tar_gz_with_top_level_dir() {
+ let tar_data = make_tar_gz(&[
+ ("vendor-pkg-abc/file1.txt", b"hello"),
+ ("vendor-pkg-abc/src/Foo.php", b"<?php"),
+ ]);
+
+ let dir = tempdir().unwrap();
+ extract_tar_gz(&tar_data, dir.path()).unwrap();
+
+ assert!(dir.path().join("file1.txt").exists());
+ assert!(dir.path().join("src/Foo.php").exists());
+ }
+
+ #[test]
+ fn test_sha1_verification() {
+ use sha1::{Digest, Sha1};
+
+ let data = b"test content";
+ let mut hasher = Sha1::new();
+ hasher.update(data);
+ let expected = format!("{:x}", hasher.finalize());
+
+ // We can't test download_dist without a server, but we can verify the
+ // SHA-1 logic: same data should produce same hash
+ let mut hasher2 = Sha1::new();
+ hasher2.update(data);
+ let computed = format!("{:x}", hasher2.finalize());
+
+ assert_eq!(expected, computed);
+ assert!(!expected.is_empty());
+ }
+
+ #[test]
+ fn test_find_top_level_dir_common() {
+ let entries = vec![
+ "pkg-1.0/".to_string(),
+ "pkg-1.0/README.md".to_string(),
+ "pkg-1.0/src/Foo.php".to_string(),
+ ];
+ assert_eq!(find_top_level_dir(&entries), Some("pkg-1.0/".to_string()));
+ }
+
+ #[test]
+ fn test_find_top_level_dir_none_when_mixed() {
+ let entries = vec!["pkg-1.0/file.txt".to_string(), "other/file.txt".to_string()];
+ assert_eq!(find_top_level_dir(&entries), None);
+ }
+
+ #[test]
+ fn test_find_top_level_dir_none_when_root_file() {
+ let entries = vec!["file.txt".to_string(), "pkg/other.txt".to_string()];
+ assert_eq!(find_top_level_dir(&entries), None);
+ }
+}
diff --git a/crates/mozart-registry/src/installed.rs b/crates/mozart-registry/src/installed.rs
new file mode 100644
index 0000000..7543b0e
--- /dev/null
+++ b/crates/mozart-registry/src/installed.rs
@@ -0,0 +1,229 @@
+use mozart_core::package::to_json_pretty;
+use serde::{Deserialize, Serialize};
+use std::collections::BTreeMap;
+use std::fs;
+use std::path::Path;
+
+fn default_true() -> bool {
+ true
+}
+
+/// Represents `vendor/composer/installed.json`.
+/// This is the Composer 2.x format.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct InstalledPackages {
+ pub packages: Vec<InstalledPackageEntry>,
+
+ #[serde(rename = "dev-package-names", default)]
+ pub dev_package_names: Vec<String>,
+
+ #[serde(default = "default_true")]
+ pub dev: bool,
+}
+
+/// An entry in installed.json's packages array.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct InstalledPackageEntry {
+ pub name: String,
+ pub version: String,
+
+ #[serde(rename = "version_normalized", skip_serializing_if = "Option::is_none")]
+ pub version_normalized: Option<String>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub source: Option<serde_json::Value>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub dist: Option<serde_json::Value>,
+
+ #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
+ pub package_type: Option<String>,
+
+ #[serde(rename = "install-path", skip_serializing_if = "Option::is_none")]
+ pub install_path: Option<String>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub autoload: Option<serde_json::Value>,
+
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub aliases: Vec<String>,
+
+ #[serde(flatten)]
+ pub extra_fields: BTreeMap<String, serde_json::Value>,
+}
+
+impl Default for InstalledPackages {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl InstalledPackages {
+ /// Create an empty registry.
+ pub fn new() -> InstalledPackages {
+ InstalledPackages {
+ packages: Vec::new(),
+ dev_package_names: Vec::new(),
+ dev: true,
+ }
+ }
+
+ /// Read installed.json from `vendor/composer/installed.json`.
+ /// If the file does not exist, returns an empty registry.
+ pub fn read(vendor_dir: &Path) -> anyhow::Result<InstalledPackages> {
+ let path = vendor_dir.join("composer/installed.json");
+ if !path.exists() {
+ return Ok(InstalledPackages::new());
+ }
+ let content = fs::read_to_string(&path)?;
+ let installed: InstalledPackages = serde_json::from_str(&content)?;
+ Ok(installed)
+ }
+
+ /// Write installed.json to `vendor/composer/installed.json`.
+ /// Creates the `vendor/composer/` directory if it doesn't exist.
+ pub fn write(&self, vendor_dir: &Path) -> anyhow::Result<()> {
+ let composer_dir = vendor_dir.join("composer");
+ fs::create_dir_all(&composer_dir)?;
+ let path = composer_dir.join("installed.json");
+ let json = to_json_pretty(self)?;
+ fs::write(path, json)?;
+ Ok(())
+ }
+
+ /// Check if a package at a specific version is installed.
+ pub fn is_installed(&self, name: &str, version: &str) -> bool {
+ self.packages
+ .iter()
+ .any(|p| p.name.eq_ignore_ascii_case(name) && p.version == version)
+ }
+
+ /// Add or update a package entry (replace if same name exists).
+ pub fn upsert(&mut self, entry: InstalledPackageEntry) {
+ if let Some(pos) = self
+ .packages
+ .iter()
+ .position(|p| p.name.eq_ignore_ascii_case(&entry.name))
+ {
+ self.packages[pos] = entry;
+ } else {
+ self.packages.push(entry);
+ }
+ }
+
+ /// Remove a package by name.
+ pub fn remove(&mut self, name: &str) {
+ self.packages.retain(|p| !p.name.eq_ignore_ascii_case(name));
+ self.dev_package_names
+ .retain(|n| !n.eq_ignore_ascii_case(name));
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use tempfile::tempdir;
+
+ fn make_entry(name: &str, version: &str) -> InstalledPackageEntry {
+ InstalledPackageEntry {
+ name: name.to_string(),
+ version: version.to_string(),
+ version_normalized: None,
+ source: None,
+ dist: None,
+ package_type: None,
+ install_path: None,
+ autoload: None,
+ aliases: vec![],
+ extra_fields: BTreeMap::new(),
+ }
+ }
+
+ #[test]
+ fn test_new_is_empty() {
+ let installed = InstalledPackages::new();
+ assert!(installed.packages.is_empty());
+ assert!(installed.dev_package_names.is_empty());
+ assert!(installed.dev);
+ }
+
+ #[test]
+ fn test_write_read_empty() {
+ let dir = tempdir().unwrap();
+ let vendor = dir.path().join("vendor");
+
+ let installed = InstalledPackages::new();
+ installed.write(&vendor).unwrap();
+
+ let loaded = InstalledPackages::read(&vendor).unwrap();
+ assert!(loaded.packages.is_empty());
+ assert!(loaded.dev);
+ }
+
+ #[test]
+ fn test_read_nonexistent_returns_empty() {
+ let dir = tempdir().unwrap();
+ let vendor = dir.path().join("vendor");
+ // Don't create the directory
+ let installed = InstalledPackages::read(&vendor).unwrap();
+ assert!(installed.packages.is_empty());
+ }
+
+ #[test]
+ fn test_upsert_and_is_installed() {
+ let mut installed = InstalledPackages::new();
+ installed.upsert(make_entry("monolog/monolog", "3.8.0"));
+
+ assert!(installed.is_installed("monolog/monolog", "3.8.0"));
+ assert!(!installed.is_installed("monolog/monolog", "3.7.0"));
+ assert!(!installed.is_installed("other/pkg", "1.0.0"));
+ }
+
+ #[test]
+ fn test_upsert_replaces_existing() {
+ let mut installed = InstalledPackages::new();
+ installed.upsert(make_entry("monolog/monolog", "3.7.0"));
+ installed.upsert(make_entry("monolog/monolog", "3.8.0"));
+
+ assert_eq!(installed.packages.len(), 1);
+ assert_eq!(installed.packages[0].version, "3.8.0");
+ }
+
+ #[test]
+ fn test_remove() {
+ let mut installed = InstalledPackages::new();
+ installed.upsert(make_entry("monolog/monolog", "3.8.0"));
+ installed.upsert(make_entry("psr/log", "3.0.0"));
+ installed
+ .dev_package_names
+ .push("monolog/monolog".to_string());
+
+ installed.remove("monolog/monolog");
+
+ assert_eq!(installed.packages.len(), 1);
+ assert_eq!(installed.packages[0].name, "psr/log");
+ assert!(installed.dev_package_names.is_empty());
+ }
+
+ #[test]
+ fn test_is_installed_case_insensitive() {
+ let mut installed = InstalledPackages::new();
+ installed.upsert(make_entry("Monolog/Monolog", "3.8.0"));
+ assert!(installed.is_installed("monolog/monolog", "3.8.0"));
+ }
+
+ #[test]
+ fn test_roundtrip_with_package() {
+ let dir = tempdir().unwrap();
+ let vendor = dir.path().join("vendor");
+
+ let mut installed = InstalledPackages::new();
+ installed.upsert(make_entry("monolog/monolog", "3.8.0"));
+ installed.write(&vendor).unwrap();
+
+ let loaded = InstalledPackages::read(&vendor).unwrap();
+ assert_eq!(loaded.packages.len(), 1);
+ assert_eq!(loaded.packages[0].name, "monolog/monolog");
+ assert_eq!(loaded.packages[0].version, "3.8.0");
+ }
+}
diff --git a/crates/mozart-registry/src/lib.rs b/crates/mozart-registry/src/lib.rs
new file mode 100644
index 0000000..9fd9aff
--- /dev/null
+++ b/crates/mozart-registry/src/lib.rs
@@ -0,0 +1,7 @@
+pub mod cache;
+pub mod downloader;
+pub mod installed;
+pub mod lockfile;
+pub mod packagist;
+pub mod resolver;
+pub mod version;
diff --git a/crates/mozart-registry/src/lockfile.rs b/crates/mozart-registry/src/lockfile.rs
new file mode 100644
index 0000000..16337c4
--- /dev/null
+++ b/crates/mozart-registry/src/lockfile.rs
@@ -0,0 +1,1088 @@
+use crate::cache::Cache;
+use crate::packagist::{self, PackagistDist, PackagistSource, PackagistVersion};
+use crate::resolver::ResolvedPackage;
+use mozart_core::package::{RawPackageData, to_json_pretty};
+use serde::{Deserialize, Serialize};
+use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
+use std::fs;
+use std::path::Path;
+
+fn default_stability() -> String {
+ "stable".to_string()
+}
+
+fn default_empty_object() -> serde_json::Value {
+ serde_json::Value::Object(serde_json::Map::new())
+}
+
+/// Represents the content of a composer.lock file.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct LockFile {
+ #[serde(rename = "_readme")]
+ pub readme: Vec<String>,
+
+ #[serde(rename = "content-hash")]
+ pub content_hash: String,
+
+ pub packages: Vec<LockedPackage>,
+
+ #[serde(rename = "packages-dev")]
+ pub packages_dev: Option<Vec<LockedPackage>>,
+
+ #[serde(default)]
+ pub aliases: Vec<LockAlias>,
+
+ #[serde(rename = "minimum-stability", default = "default_stability")]
+ pub minimum_stability: String,
+
+ #[serde(rename = "stability-flags", default = "default_empty_object")]
+ pub stability_flags: serde_json::Value,
+
+ #[serde(rename = "prefer-stable", default)]
+ pub prefer_stable: bool,
+
+ #[serde(rename = "prefer-lowest", default)]
+ pub prefer_lowest: bool,
+
+ #[serde(default = "default_empty_object")]
+ pub platform: serde_json::Value,
+
+ #[serde(rename = "platform-dev", default = "default_empty_object")]
+ pub platform_dev: serde_json::Value,
+
+ #[serde(rename = "plugin-api-version", skip_serializing_if = "Option::is_none")]
+ pub plugin_api_version: Option<String>,
+}
+
+/// A locked package entry in composer.lock.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct LockedPackage {
+ pub name: String,
+ pub version: String,
+
+ #[serde(rename = "version_normalized", skip_serializing_if = "Option::is_none")]
+ pub version_normalized: Option<String>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub source: Option<LockedSource>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub dist: Option<LockedDist>,
+
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+ pub require: BTreeMap<String, String>,
+
+ #[serde(
+ rename = "require-dev",
+ default,
+ skip_serializing_if = "BTreeMap::is_empty"
+ )]
+ pub require_dev: BTreeMap<String, String>,
+
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+ pub conflict: BTreeMap<String, String>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub suggest: Option<BTreeMap<String, String>>,
+
+ #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
+ pub package_type: Option<String>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub autoload: Option<serde_json::Value>,
+
+ #[serde(rename = "autoload-dev", skip_serializing_if = "Option::is_none")]
+ pub autoload_dev: Option<serde_json::Value>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub license: Option<Vec<String>>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub description: Option<String>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub homepage: Option<String>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub keywords: Option<Vec<String>>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub authors: Option<Vec<serde_json::Value>>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub support: Option<serde_json::Value>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub funding: Option<Vec<serde_json::Value>>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub time: Option<String>,
+
+ /// Catch-all for extra fields we don't explicitly model
+ #[serde(flatten)]
+ pub extra_fields: BTreeMap<String, serde_json::Value>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct LockedSource {
+ #[serde(rename = "type")]
+ pub source_type: String,
+ pub url: String,
+ pub reference: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct LockedDist {
+ #[serde(rename = "type")]
+ pub dist_type: String,
+ pub url: String,
+ pub reference: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub shasum: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct LockAlias {
+ pub package: String,
+ pub version: String,
+ pub alias: String,
+ pub alias_normalized: String,
+}
+
+impl LockFile {
+ /// Create default readme entries.
+ pub fn default_readme() -> Vec<String> {
+ vec![
+ "This file locks the dependencies of your project to a known state".to_string(),
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies".to_string(),
+ "This file is @generated automatically".to_string(),
+ ]
+ }
+
+ /// Read a composer.lock file from disk.
+ pub fn read_from_file(path: &Path) -> anyhow::Result<LockFile> {
+ let content = fs::read_to_string(path)?;
+ let lock: LockFile = serde_json::from_str(&content)?;
+ Ok(lock)
+ }
+
+ /// Write a composer.lock file to disk with deterministic formatting.
+ pub fn write_to_file(&self, path: &Path) -> anyhow::Result<()> {
+ let json = to_json_pretty(self)?;
+ fs::write(path, json)?;
+ Ok(())
+ }
+
+ /// Check if the lock file is fresh (content-hash matches composer.json).
+ pub fn is_fresh(&self, composer_json_content: &str) -> bool {
+ match Self::compute_content_hash(composer_json_content) {
+ Ok(hash) => hash == self.content_hash,
+ Err(_) => false,
+ }
+ }
+
+ /// Compute the content hash from composer.json content.
+ /// Matches Composer's `Locker::getContentHash()`.
+ pub fn compute_content_hash(composer_json_content: &str) -> anyhow::Result<String> {
+ let value: serde_json::Value = serde_json::from_str(composer_json_content)?;
+ let obj = value
+ .as_object()
+ .ok_or_else(|| anyhow::anyhow!("composer.json must be a JSON object"))?;
+
+ // Keys that affect the content hash (Composer's relevantKeys)
+ let relevant_keys = [
+ "name",
+ "version",
+ "require",
+ "require-dev",
+ "conflict",
+ "replace",
+ "provide",
+ "minimum-stability",
+ "prefer-stable",
+ "repositories",
+ "extra",
+ ];
+
+ // Collect relevant keys into a BTreeMap (auto-sorted by key)
+ let mut filtered: BTreeMap<&str, &serde_json::Value> = BTreeMap::new();
+ for key in &relevant_keys {
+ if let Some(v) = obj.get(*key) {
+ filtered.insert(key, v);
+ }
+ }
+
+ // Also include config.platform if present
+ if let Some(config) = obj.get("config")
+ && let Some(platform) = config.get("platform")
+ {
+ filtered.insert("config.platform", platform);
+ }
+
+ // Encode to compact JSON
+ let compact = serde_json::to_string(&filtered)?;
+
+ // Compute MD5
+ let digest = md5::compute(compact.as_bytes());
+ Ok(format!("{:x}", digest))
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Lock file generation
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// Input for lock file generation.
+pub struct LockFileGenerationRequest {
+ /// Resolved packages from the dependency resolver.
+ pub resolved_packages: Vec<ResolvedPackage>,
+ /// Raw composer.json content string (for content-hash computation).
+ pub composer_json_content: String,
+ /// Parsed composer.json data (for platform, minimum-stability, etc.).
+ 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`.
+fn packagist_source_to_locked(ps: &PackagistSource) -> LockedSource {
+ LockedSource {
+ source_type: ps.source_type.clone(),
+ url: ps.url.clone(),
+ reference: ps.reference.clone(),
+ }
+}
+
+/// Convert a `PackagistDist` to a `LockedDist`.
+fn packagist_dist_to_locked(pd: &PackagistDist) -> LockedDist {
+ LockedDist {
+ dist_type: pd.dist_type.clone(),
+ url: pd.url.clone(),
+ reference: pd.reference.clone(),
+ shasum: pd.shasum.clone(),
+ }
+}
+
+/// Convert a `PackagistVersion` to a `LockedPackage`.
+fn packagist_version_to_locked_package(name: &str, pv: &PackagistVersion) -> LockedPackage {
+ let mut extra_fields: BTreeMap<String, serde_json::Value> = BTreeMap::new();
+
+ if let Some(extra) = &pv.extra {
+ extra_fields.insert("extra".to_string(), extra.clone());
+ }
+ if let Some(notification_url) = &pv.notification_url {
+ extra_fields.insert(
+ "notification-url".to_string(),
+ serde_json::Value::String(notification_url.clone()),
+ );
+ }
+
+ LockedPackage {
+ name: name.to_string(),
+ version: pv.version.clone(),
+ version_normalized: Some(pv.version_normalized.clone()),
+ source: pv.source.as_ref().map(packagist_source_to_locked),
+ dist: pv.dist.as_ref().map(packagist_dist_to_locked),
+ require: pv.require.clone(),
+ require_dev: pv.require_dev.clone(),
+ conflict: pv.conflict.clone(),
+ suggest: pv.suggest.clone(),
+ package_type: pv.package_type.clone(),
+ autoload: pv.autoload.clone(),
+ autoload_dev: pv.autoload_dev.clone(),
+ license: pv.license.clone(),
+ description: pv.description.clone(),
+ homepage: pv.homepage.clone(),
+ keywords: pv.keywords.clone(),
+ authors: pv.authors.clone(),
+ support: pv.support.clone(),
+ funding: pv.funding.clone(),
+ time: pv.time.clone(),
+ extra_fields,
+ }
+}
+
+/// Determine which resolved packages are dev-only.
+///
+/// A package is dev-only if it is NOT reachable from the non-dev dependency tree
+/// (i.e., only reachable through require-dev paths).
+///
+/// `package_metadata` must be pre-fetched full `PackagistVersion` data for each resolved package.
+fn classify_dev_packages(
+ resolved: &[ResolvedPackage],
+ require: &BTreeMap<String, String>,
+ _require_dev: &BTreeMap<String, String>,
+ package_metadata: &HashMap<String, PackagistVersion>,
+) -> HashSet<String> {
+ // Build set of all resolved package names for quick lookup
+ let resolved_names: HashSet<&str> = resolved.iter().map(|p| p.name.as_str()).collect();
+
+ // BFS from non-dev root dependencies through each package's `require` map.
+ // All reachable packages are production packages.
+ let mut production: HashSet<String> = HashSet::new();
+ let mut queue: VecDeque<String> = VecDeque::new();
+
+ // Seed queue with non-dev root dependencies that are actual packages (not platform)
+ for name in require.keys() {
+ let name_lower = name.to_lowercase();
+ // Skip platform packages (php, ext-*, lib-*, etc.)
+ if is_platform_name(&name_lower) {
+ continue;
+ }
+ if resolved_names.contains(name_lower.as_str()) && production.insert(name_lower.clone()) {
+ queue.push_back(name_lower);
+ }
+ }
+
+ // BFS: walk transitive `require` deps of each production package
+ while let Some(pkg_name) = queue.pop_front() {
+ if let Some(pv) = package_metadata.get(&pkg_name) {
+ for dep_name in pv.require.keys() {
+ let dep_lower = dep_name.to_lowercase();
+ if is_platform_name(&dep_lower) {
+ continue;
+ }
+ if resolved_names.contains(dep_lower.as_str())
+ && production.insert(dep_lower.clone())
+ {
+ queue.push_back(dep_lower);
+ }
+ }
+ }
+ }
+
+ // Any resolved package not in `production` is dev-only
+ resolved
+ .iter()
+ .filter(|p| !production.contains(&p.name))
+ .map(|p| p.name.clone())
+ .collect()
+}
+
+/// Returns true if the package name is a platform package (php, ext-*, lib-*, etc.).
+fn is_platform_name(name: &str) -> bool {
+ name == "php"
+ || name.starts_with("ext-")
+ || name.starts_with("lib-")
+ || name == "php-64bit"
+ || name == "php-ipv6"
+ || name == "php-zts"
+ || name == "php-debug"
+}
+
+/// Extract platform requirements from a requirements map.
+///
+/// Filters the map to include only platform package keys (`php`, `ext-*`, `lib-*`, etc.)
+/// and returns them as a JSON object.
+fn extract_platform_requirements(requirements: &BTreeMap<String, String>) -> serde_json::Value {
+ let map: serde_json::Map<String, serde_json::Value> = requirements
+ .iter()
+ .filter(|(k, _)| is_platform_name(k))
+ .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
+ .collect();
+ serde_json::Value::Object(map)
+}
+
+/// Generate a complete `LockFile` from resolution results.
+///
+/// This function:
+/// 1. Fetches full metadata from Packagist for each resolved package
+/// 2. Separates packages into production vs dev-only
+/// 3. Computes the content-hash
+/// 4. Assembles the complete `LockFile` struct
+pub fn generate_lock_file(request: &LockFileGenerationRequest) -> anyhow::Result<LockFile> {
+ // 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, request.repo_cache.as_ref())?;
+ // Find the exact version matching pkg.version_normalized
+ let matching = versions
+ .into_iter()
+ .find(|v| v.version_normalized == pkg.version_normalized)
+ .ok_or_else(|| {
+ anyhow::anyhow!(
+ "Could not find version {} for package {} in Packagist response",
+ pkg.version_normalized,
+ pkg.name
+ )
+ })?;
+ package_metadata.insert(pkg.name.clone(), matching);
+ }
+
+ // 2. Classify dev vs non-dev packages
+ let dev_only = classify_dev_packages(
+ &request.resolved_packages,
+ &request.composer_json.require,
+ &request.composer_json.require_dev,
+ &package_metadata,
+ );
+
+ // 3. Build LockedPackage lists
+ let mut packages: Vec<LockedPackage> = Vec::new();
+ let mut packages_dev: Vec<LockedPackage> = Vec::new();
+ for pkg in &request.resolved_packages {
+ let pv = &package_metadata[&pkg.name];
+ let locked = packagist_version_to_locked_package(&pkg.name, pv);
+ if dev_only.contains(&pkg.name) {
+ packages_dev.push(locked);
+ } else {
+ packages.push(locked);
+ }
+ }
+
+ // 4. Sort each list alphabetically by name (Composer does this)
+ packages.sort_by(|a, b| a.name.cmp(&b.name));
+ packages_dev.sort_by(|a, b| a.name.cmp(&b.name));
+
+ // 5. Compute content-hash
+ let content_hash = LockFile::compute_content_hash(&request.composer_json_content)?;
+
+ // 6. Extract platform requirements
+ let platform = extract_platform_requirements(&request.composer_json.require);
+ let platform_dev = extract_platform_requirements(&request.composer_json.require_dev);
+
+ // 7. Determine minimum-stability and prefer-stable
+ let minimum_stability = request
+ .composer_json
+ .minimum_stability
+ .clone()
+ .unwrap_or_else(|| "stable".to_string());
+
+ let prefer_stable = request
+ .composer_json
+ .extra_fields
+ .get("prefer-stable")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+
+ // 8. Assemble LockFile
+ Ok(LockFile {
+ readme: LockFile::default_readme(),
+ content_hash,
+ packages,
+ packages_dev: if request.include_dev {
+ Some(packages_dev)
+ } else {
+ Some(vec![])
+ },
+ aliases: vec![],
+ minimum_stability,
+ stability_flags: serde_json::json!({}),
+ prefer_stable,
+ prefer_lowest: false,
+ platform,
+ platform_dev,
+ plugin_api_version: Some("2.6.0".to_string()),
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use tempfile::tempdir;
+
+ fn minimal_lock() -> LockFile {
+ LockFile {
+ readme: LockFile::default_readme(),
+ content_hash: "abc123".to_string(),
+ packages: vec![],
+ packages_dev: Some(vec![]),
+ aliases: vec![],
+ minimum_stability: "stable".to_string(),
+ stability_flags: serde_json::json!({}),
+ prefer_stable: false,
+ prefer_lowest: false,
+ platform: serde_json::json!({}),
+ platform_dev: serde_json::json!({}),
+ plugin_api_version: Some("2.6.0".to_string()),
+ }
+ }
+
+ #[test]
+ fn test_roundtrip_minimal() {
+ let dir = tempdir().unwrap();
+ let path = dir.path().join("composer.lock");
+
+ let lock = minimal_lock();
+ lock.write_to_file(&path).unwrap();
+
+ let loaded = LockFile::read_from_file(&path).unwrap();
+ assert_eq!(loaded.content_hash, "abc123");
+ assert_eq!(loaded.minimum_stability, "stable");
+ assert!(!loaded.prefer_stable);
+ assert_eq!(loaded.packages.len(), 0);
+ }
+
+ #[test]
+ fn test_roundtrip_with_package() {
+ let dir = tempdir().unwrap();
+ let path = dir.path().join("composer.lock");
+
+ let mut lock = minimal_lock();
+ lock.packages.push(LockedPackage {
+ name: "monolog/monolog".to_string(),
+ version: "3.8.0".to_string(),
+ version_normalized: None,
+ source: None,
+ dist: Some(LockedDist {
+ dist_type: "zip".to_string(),
+ url: "https://example.com/monolog.zip".to_string(),
+ reference: Some("abc123".to_string()),
+ shasum: Some("".to_string()),
+ }),
+ require: BTreeMap::new(),
+ require_dev: BTreeMap::new(),
+ conflict: BTreeMap::new(),
+ suggest: None,
+ package_type: Some("library".to_string()),
+ autoload: None,
+ autoload_dev: None,
+ license: Some(vec!["MIT".to_string()]),
+ description: Some("A logging library".to_string()),
+ homepage: None,
+ keywords: None,
+ authors: None,
+ support: None,
+ funding: None,
+ time: None,
+ extra_fields: BTreeMap::new(),
+ });
+
+ lock.write_to_file(&path).unwrap();
+ let loaded = LockFile::read_from_file(&path).unwrap();
+
+ assert_eq!(loaded.packages.len(), 1);
+ assert_eq!(loaded.packages[0].name, "monolog/monolog");
+ assert_eq!(loaded.packages[0].version, "3.8.0");
+ assert_eq!(
+ loaded.packages[0].description.as_deref(),
+ Some("A logging library")
+ );
+ }
+
+ #[test]
+ fn test_content_hash_deterministic() {
+ let composer_json = r#"{"name": "test/project", "require": {"monolog/monolog": "^3.0"}}"#;
+ let h1 = LockFile::compute_content_hash(composer_json).unwrap();
+ let h2 = LockFile::compute_content_hash(composer_json).unwrap();
+ assert_eq!(h1, h2);
+ assert!(!h1.is_empty());
+ }
+
+ #[test]
+ fn test_content_hash_changes_on_require_change() {
+ let composer1 = r#"{"name": "test/project", "require": {"monolog/monolog": "^3.0"}}"#;
+ let composer2 = r#"{"name": "test/project", "require": {"monolog/monolog": "^2.0"}}"#;
+ let h1 = LockFile::compute_content_hash(composer1).unwrap();
+ let h2 = LockFile::compute_content_hash(composer2).unwrap();
+ assert_ne!(h1, h2);
+ }
+
+ #[test]
+ fn test_is_fresh() {
+ let composer_json = r#"{"name": "test/project", "require": {"php": ">=8.1"}}"#;
+ let hash = LockFile::compute_content_hash(composer_json).unwrap();
+
+ let mut lock = minimal_lock();
+ lock.content_hash = hash;
+
+ assert!(lock.is_fresh(composer_json));
+ assert!(!lock.is_fresh(r#"{"name": "test/project", "require": {"php": ">=8.0"}}"#));
+ }
+
+ #[test]
+ fn test_default_readme() {
+ let readme = LockFile::default_readme();
+ assert_eq!(readme.len(), 3);
+ assert!(readme[0].contains("locks the dependencies"));
+ }
+
+ // ──────────── Lock file generation tests ────────────
+
+ fn make_packagist_version(
+ version: &str,
+ version_normalized: &str,
+ require: BTreeMap<String, String>,
+ ) -> PackagistVersion {
+ PackagistVersion {
+ version: version.to_string(),
+ version_normalized: version_normalized.to_string(),
+ require,
+ replace: BTreeMap::new(),
+ provide: BTreeMap::new(),
+ conflict: BTreeMap::new(),
+ dist: Some(crate::packagist::PackagistDist {
+ dist_type: "zip".to_string(),
+ url: format!("https://example.com/{version}.zip"),
+ reference: Some("deadbeef".to_string()),
+ shasum: Some("abc123".to_string()),
+ }),
+ source: Some(crate::packagist::PackagistSource {
+ source_type: "git".to_string(),
+ url: "https://github.com/example/pkg.git".to_string(),
+ reference: Some("deadbeef".to_string()),
+ }),
+ require_dev: BTreeMap::new(),
+ suggest: None,
+ package_type: Some("library".to_string()),
+ autoload: Some(serde_json::json!({"psr-4": {"Example\\": "src/"}})),
+ autoload_dev: None,
+ license: Some(vec!["MIT".to_string()]),
+ description: Some("An example package".to_string()),
+ homepage: Some("https://example.com".to_string()),
+ keywords: Some(vec!["example".to_string(), "test".to_string()]),
+ authors: Some(vec![
+ serde_json::json!({"name": "Alice", "email": "alice@example.com"}),
+ ]),
+ support: Some(serde_json::json!({"issues": "https://github.com/example/pkg/issues"})),
+ funding: Some(vec![
+ serde_json::json!({"type": "github", "url": "https://github.com/sponsors/alice"}),
+ ]),
+ time: Some("2024-01-15T10:00:00+00:00".to_string()),
+ extra: Some(serde_json::json!({"branch-alias": {"dev-main": "1.0.x-dev"}})),
+ notification_url: Some("https://packagist.org/downloads/".to_string()),
+ }
+ }
+
+ #[test]
+ fn test_packagist_version_to_locked_package() {
+ let pv = make_packagist_version("1.2.3", "1.2.3.0", BTreeMap::new());
+ let locked = packagist_version_to_locked_package("example/pkg", &pv);
+
+ assert_eq!(locked.name, "example/pkg");
+ assert_eq!(locked.version, "1.2.3");
+ assert_eq!(locked.version_normalized.as_deref(), Some("1.2.3.0"));
+ assert_eq!(locked.description.as_deref(), Some("An example package"));
+ assert_eq!(locked.homepage.as_deref(), Some("https://example.com"));
+ assert_eq!(
+ locked.license.as_deref(),
+ Some(vec!["MIT".to_string()].as_slice())
+ );
+ assert_eq!(
+ locked.keywords.as_ref().map(|k| k.as_slice()),
+ Some(["example".to_string(), "test".to_string()].as_slice())
+ );
+ assert_eq!(locked.package_type.as_deref(), Some("library"));
+ assert!(locked.autoload.is_some());
+ assert!(locked.authors.is_some());
+ assert!(locked.support.is_some());
+ assert!(locked.funding.is_some());
+ assert_eq!(locked.time.as_deref(), Some("2024-01-15T10:00:00+00:00"));
+
+ // Check dist
+ let dist = locked.dist.as_ref().unwrap();
+ assert_eq!(dist.dist_type, "zip");
+ assert_eq!(dist.reference.as_deref(), Some("deadbeef"));
+ assert_eq!(dist.shasum.as_deref(), Some("abc123"));
+
+ // Check source
+ let source = locked.source.as_ref().unwrap();
+ assert_eq!(source.source_type, "git");
+ assert_eq!(source.reference.as_deref(), Some("deadbeef"));
+
+ // Check extra_fields (extra and notification-url)
+ assert!(locked.extra_fields.contains_key("extra"));
+ assert!(locked.extra_fields.contains_key("notification-url"));
+ assert_eq!(
+ locked.extra_fields["notification-url"],
+ serde_json::Value::String("https://packagist.org/downloads/".to_string())
+ );
+ }
+
+ #[test]
+ fn test_packagist_version_to_locked_package_no_optional_fields() {
+ let pv = PackagistVersion {
+ version: "1.0.0".to_string(),
+ version_normalized: "1.0.0.0".to_string(),
+ require: BTreeMap::new(),
+ replace: BTreeMap::new(),
+ provide: BTreeMap::new(),
+ conflict: BTreeMap::new(),
+ dist: None,
+ source: None,
+ require_dev: BTreeMap::new(),
+ suggest: None,
+ package_type: None,
+ autoload: None,
+ autoload_dev: None,
+ license: None,
+ description: None,
+ homepage: None,
+ keywords: None,
+ authors: None,
+ support: None,
+ funding: None,
+ time: None,
+ extra: None,
+ notification_url: None,
+ };
+
+ let locked = packagist_version_to_locked_package("vendor/pkg", &pv);
+ assert_eq!(locked.name, "vendor/pkg");
+ assert!(locked.dist.is_none());
+ assert!(locked.source.is_none());
+ assert!(locked.description.is_none());
+ assert!(locked.license.is_none());
+ assert!(locked.extra_fields.is_empty());
+ }
+
+ #[test]
+ fn test_classify_dev_packages_simple() {
+ // Root: require={A}, require-dev={B}
+ // A depends on C; B depends on D
+ // Expected dev-only: {B, D}
+ let resolved = vec![
+ ResolvedPackage {
+ name: "vendor/a".to_string(),
+ version: "1.0.0".to_string(),
+ version_normalized: "1.0.0.0".to_string(),
+ is_dev: false,
+ },
+ ResolvedPackage {
+ name: "vendor/b".to_string(),
+ version: "1.0.0".to_string(),
+ version_normalized: "1.0.0.0".to_string(),
+ is_dev: false,
+ },
+ ResolvedPackage {
+ name: "vendor/c".to_string(),
+ version: "1.0.0".to_string(),
+ version_normalized: "1.0.0.0".to_string(),
+ is_dev: false,
+ },
+ ResolvedPackage {
+ name: "vendor/d".to_string(),
+ version: "1.0.0".to_string(),
+ version_normalized: "1.0.0.0".to_string(),
+ is_dev: false,
+ },
+ ];
+
+ let mut require = BTreeMap::new();
+ require.insert("vendor/a".to_string(), "^1.0".to_string());
+
+ let mut require_dev = BTreeMap::new();
+ require_dev.insert("vendor/b".to_string(), "^1.0".to_string());
+
+ let mut metadata: HashMap<String, PackagistVersion> = HashMap::new();
+
+ // A requires C
+ let mut a_require = BTreeMap::new();
+ a_require.insert("vendor/c".to_string(), "^1.0".to_string());
+ metadata.insert(
+ "vendor/a".to_string(),
+ make_packagist_version("1.0.0", "1.0.0.0", a_require),
+ );
+
+ // B requires D
+ let mut b_require = BTreeMap::new();
+ b_require.insert("vendor/d".to_string(), "^1.0".to_string());
+ metadata.insert(
+ "vendor/b".to_string(),
+ make_packagist_version("1.0.0", "1.0.0.0", b_require),
+ );
+
+ // C and D have no deps
+ metadata.insert(
+ "vendor/c".to_string(),
+ make_packagist_version("1.0.0", "1.0.0.0", BTreeMap::new()),
+ );
+ metadata.insert(
+ "vendor/d".to_string(),
+ make_packagist_version("1.0.0", "1.0.0.0", BTreeMap::new()),
+ );
+
+ let dev_only = classify_dev_packages(&resolved, &require, &require_dev, &metadata);
+
+ assert!(!dev_only.contains("vendor/a"), "A is a production package");
+ assert!(dev_only.contains("vendor/b"), "B is dev-only");
+ assert!(
+ !dev_only.contains("vendor/c"),
+ "C is reachable from A (production)"
+ );
+ assert!(
+ dev_only.contains("vendor/d"),
+ "D is only reachable from B (dev)"
+ );
+ }
+
+ #[test]
+ fn test_classify_dev_packages_shared() {
+ // Root: require={A}, require-dev={B}
+ // Both A and B depend on C — C is NOT dev-only (reachable from production)
+ let resolved = vec![
+ ResolvedPackage {
+ name: "vendor/a".to_string(),
+ version: "1.0.0".to_string(),
+ version_normalized: "1.0.0.0".to_string(),
+ is_dev: false,
+ },
+ ResolvedPackage {
+ name: "vendor/b".to_string(),
+ version: "1.0.0".to_string(),
+ version_normalized: "1.0.0.0".to_string(),
+ is_dev: false,
+ },
+ ResolvedPackage {
+ name: "vendor/c".to_string(),
+ version: "1.0.0".to_string(),
+ version_normalized: "1.0.0.0".to_string(),
+ is_dev: false,
+ },
+ ];
+
+ let mut require = BTreeMap::new();
+ require.insert("vendor/a".to_string(), "^1.0".to_string());
+
+ let mut require_dev = BTreeMap::new();
+ require_dev.insert("vendor/b".to_string(), "^1.0".to_string());
+
+ let mut metadata: HashMap<String, PackagistVersion> = HashMap::new();
+
+ // A requires C
+ let mut a_require = BTreeMap::new();
+ a_require.insert("vendor/c".to_string(), "^1.0".to_string());
+ metadata.insert(
+ "vendor/a".to_string(),
+ make_packagist_version("1.0.0", "1.0.0.0", a_require),
+ );
+
+ // B also requires C
+ let mut b_require = BTreeMap::new();
+ b_require.insert("vendor/c".to_string(), "^1.0".to_string());
+ metadata.insert(
+ "vendor/b".to_string(),
+ make_packagist_version("1.0.0", "1.0.0.0", b_require),
+ );
+
+ // C has no deps
+ metadata.insert(
+ "vendor/c".to_string(),
+ make_packagist_version("1.0.0", "1.0.0.0", BTreeMap::new()),
+ );
+
+ let dev_only = classify_dev_packages(&resolved, &require, &require_dev, &metadata);
+
+ assert!(!dev_only.contains("vendor/a"), "A is a production package");
+ assert!(dev_only.contains("vendor/b"), "B is dev-only");
+ assert!(
+ !dev_only.contains("vendor/c"),
+ "C is shared but reachable from production (A), so it's not dev-only"
+ );
+ }
+
+ #[test]
+ fn test_extract_platform_requirements() {
+ let mut requirements = BTreeMap::new();
+ requirements.insert("php".to_string(), ">=8.1".to_string());
+ requirements.insert("ext-json".to_string(), "*".to_string());
+ requirements.insert("ext-mbstring".to_string(), "*".to_string());
+ requirements.insert("monolog/monolog".to_string(), "^3.0".to_string());
+ requirements.insert("lib-pcre".to_string(), "*".to_string());
+
+ let platform = extract_platform_requirements(&requirements);
+ let obj = platform.as_object().unwrap();
+
+ assert!(obj.contains_key("php"), "php should be in platform");
+ assert!(
+ obj.contains_key("ext-json"),
+ "ext-json should be in platform"
+ );
+ assert!(
+ obj.contains_key("ext-mbstring"),
+ "ext-mbstring should be in platform"
+ );
+ assert!(
+ obj.contains_key("lib-pcre"),
+ "lib-pcre should be in platform"
+ );
+ assert!(
+ !obj.contains_key("monolog/monolog"),
+ "monolog/monolog should NOT be in platform"
+ );
+ assert_eq!(obj["php"], serde_json::Value::String(">=8.1".to_string()));
+ assert_eq!(obj["ext-json"], serde_json::Value::String("*".to_string()));
+ }
+
+ #[test]
+ fn test_extract_platform_requirements_empty() {
+ let requirements = BTreeMap::new();
+ let platform = extract_platform_requirements(&requirements);
+ assert_eq!(platform, serde_json::json!({}));
+ }
+
+ #[test]
+ fn test_generate_lock_file_minimal() {
+ let composer_json_content =
+ r#"{"name": "test/project", "require": {"php": ">=8.1"}}"#.to_string();
+ let composer_json: RawPackageData = serde_json::from_str(&composer_json_content).unwrap();
+
+ let request = LockFileGenerationRequest {
+ resolved_packages: vec![],
+ composer_json_content: composer_json_content.clone(),
+ composer_json,
+ include_dev: true,
+ repo_cache: None,
+ };
+
+ let lock = generate_lock_file(&request).unwrap();
+
+ assert_eq!(lock.packages.len(), 0);
+ assert_eq!(lock.packages_dev.as_ref().unwrap().len(), 0);
+ assert_eq!(lock.minimum_stability, "stable");
+ assert!(!lock.prefer_stable);
+ assert!(!lock.prefer_lowest);
+ assert_eq!(lock.plugin_api_version.as_deref(), Some("2.6.0"));
+
+ // Verify content-hash matches
+ let expected_hash = LockFile::compute_content_hash(&composer_json_content).unwrap();
+ assert_eq!(lock.content_hash, expected_hash);
+
+ // Verify platform requirements extracted
+ let platform_obj = lock.platform.as_object().unwrap();
+ assert!(platform_obj.contains_key("php"));
+ assert_eq!(
+ platform_obj["php"],
+ serde_json::Value::String(">=8.1".to_string())
+ );
+ }
+
+ #[test]
+ fn test_lock_file_packages_sorted() {
+ // Verify that packages are sorted alphabetically when assembled in generate_lock_file
+ // We test this by constructing two LockedPackages and sorting them the same way
+
+ let mut packages = vec![
+ LockedPackage {
+ name: "vendor/zebra".to_string(),
+ version: "1.0.0".to_string(),
+ version_normalized: None,
+ source: None,
+ dist: None,
+ require: BTreeMap::new(),
+ require_dev: BTreeMap::new(),
+ conflict: BTreeMap::new(),
+ suggest: None,
+ package_type: None,
+ autoload: None,
+ autoload_dev: None,
+ license: None,
+ description: None,
+ homepage: None,
+ keywords: None,
+ authors: None,
+ support: None,
+ funding: None,
+ time: None,
+ extra_fields: BTreeMap::new(),
+ },
+ LockedPackage {
+ name: "vendor/alpha".to_string(),
+ version: "1.0.0".to_string(),
+ version_normalized: None,
+ source: None,
+ dist: None,
+ require: BTreeMap::new(),
+ require_dev: BTreeMap::new(),
+ conflict: BTreeMap::new(),
+ suggest: None,
+ package_type: None,
+ autoload: None,
+ autoload_dev: None,
+ license: None,
+ description: None,
+ homepage: None,
+ keywords: None,
+ authors: None,
+ support: None,
+ funding: None,
+ time: None,
+ extra_fields: BTreeMap::new(),
+ },
+ ];
+
+ packages.sort_by(|a, b| a.name.cmp(&b.name));
+
+ assert_eq!(packages[0].name, "vendor/alpha");
+ assert_eq!(packages[1].name, "vendor/zebra");
+ }
+
+ #[test]
+ #[ignore]
+ fn test_generate_lock_file_monolog() {
+ use crate::resolver::PlatformConfig;
+ use crate::resolver::{ResolveRequest, resolve};
+ use mozart_core::package::Stability;
+
+ // Resolve monolog/monolog ^3.0
+ let resolve_request = ResolveRequest {
+ require: vec![("monolog/monolog".to_string(), "^3.0".to_string())],
+ require_dev: vec![],
+ include_dev: false,
+ minimum_stability: Stability::Stable,
+ stability_flags: HashMap::new(),
+ prefer_stable: true,
+ prefer_lowest: false,
+ platform: PlatformConfig::new(),
+ ignore_platform_reqs: false,
+ ignore_platform_req_list: vec![],
+ repo_cache: None,
+ };
+
+ let resolved = resolve(&resolve_request).expect("Resolution should succeed");
+ assert!(!resolved.is_empty());
+
+ let composer_json_content =
+ r#"{"name": "test/project", "require": {"monolog/monolog": "^3.0"}}"#.to_string();
+ let composer_json: RawPackageData = serde_json::from_str(&composer_json_content).unwrap();
+
+ let gen_request = LockFileGenerationRequest {
+ resolved_packages: resolved,
+ 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");
+
+ // Verify monolog is in packages
+ assert!(
+ lock.packages.iter().any(|p| p.name == "monolog/monolog"),
+ "monolog/monolog should be in packages"
+ );
+
+ // Verify packages are sorted alphabetically
+ let names: Vec<&str> = lock.packages.iter().map(|p| p.name.as_str()).collect();
+ let mut sorted_names = names.clone();
+ sorted_names.sort();
+ assert_eq!(
+ names, sorted_names,
+ "Packages should be sorted alphabetically"
+ );
+
+ // Verify content-hash matches
+ let expected_hash = LockFile::compute_content_hash(&composer_json_content).unwrap();
+ assert_eq!(lock.content_hash, expected_hash);
+
+ // Verify monolog has full metadata
+ let monolog = lock
+ .packages
+ .iter()
+ .find(|p| p.name == "monolog/monolog")
+ .unwrap();
+ assert!(monolog.dist.is_some(), "monolog should have dist info");
+ assert!(
+ monolog.description.is_some(),
+ "monolog should have description"
+ );
+ assert!(monolog.autoload.is_some(), "monolog should have autoload");
+
+ println!("Generated lock file with {} packages:", lock.packages.len());
+ for pkg in &lock.packages {
+ println!(" {} {}", pkg.name, pkg.version);
+ }
+ }
+}
diff --git a/crates/mozart-registry/src/packagist.rs b/crates/mozart-registry/src/packagist.rs
new file mode 100644
index 0000000..ba80e7e
--- /dev/null
+++ b/crates/mozart-registry/src/packagist.rs
@@ -0,0 +1,629 @@
+use crate::cache::Cache;
+use serde::{Deserialize, Serialize};
+use std::collections::BTreeMap;
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct PackagistDist {
+ #[serde(rename = "type")]
+ pub dist_type: String,
+ pub url: String,
+ pub reference: Option<String>,
+ pub shasum: Option<String>,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct PackagistSource {
+ #[serde(rename = "type")]
+ pub source_type: String,
+ pub url: String,
+ pub reference: Option<String>,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct PackagistVersion {
+ pub version: String,
+ pub version_normalized: String,
+ #[serde(default)]
+ pub require: BTreeMap<String, String>,
+ #[serde(default)]
+ pub replace: BTreeMap<String, String>,
+ #[serde(default)]
+ pub provide: BTreeMap<String, String>,
+ #[serde(default)]
+ pub conflict: BTreeMap<String, String>,
+ pub dist: Option<PackagistDist>,
+ pub source: Option<PackagistSource>,
+
+ #[serde(rename = "require-dev", default)]
+ pub require_dev: BTreeMap<String, String>,
+
+ #[serde(default)]
+ pub suggest: Option<BTreeMap<String, String>>,
+
+ #[serde(rename = "type")]
+ pub package_type: Option<String>,
+
+ pub autoload: Option<serde_json::Value>,
+
+ #[serde(rename = "autoload-dev")]
+ pub autoload_dev: Option<serde_json::Value>,
+
+ pub license: Option<Vec<String>>,
+
+ pub description: Option<String>,
+
+ pub homepage: Option<String>,
+
+ pub keywords: Option<Vec<String>>,
+
+ pub authors: Option<Vec<serde_json::Value>>,
+
+ pub support: Option<serde_json::Value>,
+
+ pub funding: Option<Vec<serde_json::Value>>,
+
+ pub time: Option<String>,
+
+ pub extra: Option<serde_json::Value>,
+
+ #[serde(rename = "notification-url")]
+ pub notification_url: Option<String>,
+}
+
+impl PackagistVersion {
+ /// Extract the `extra.branch-alias` map from this version's metadata.
+ ///
+ /// Composer packages can declare branch aliases in `extra.branch-alias`:
+ /// ```json
+ /// {
+ /// "extra": {
+ /// "branch-alias": {
+ /// "dev-master": "2.x-dev"
+ /// }
+ /// }
+ /// }
+ /// ```
+ ///
+ /// Returns a map from branch name (e.g. `"dev-master"`) to alias target
+ /// (e.g. `"2.x-dev"`). Returns an empty map when no aliases are declared.
+ pub fn branch_aliases(&self) -> BTreeMap<String, String> {
+ let Some(extra) = &self.extra else {
+ return BTreeMap::new();
+ };
+
+ let Some(branch_alias) = extra.get("branch-alias") else {
+ return BTreeMap::new();
+ };
+
+ let Some(map) = branch_alias.as_object() else {
+ return BTreeMap::new();
+ };
+
+ map.iter()
+ .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
+ .collect()
+ }
+}
+
+/// Parse a Packagist p2 API JSON response.
+///
+/// The response format is: `{"packages": {"vendor/package": [...]}}`.
+pub fn parse_p2_response(json: &str, package_name: &str) -> anyhow::Result<Vec<PackagistVersion>> {
+ #[derive(Deserialize)]
+ struct P2Response {
+ packages: BTreeMap<String, Vec<PackagistVersion>>,
+ }
+
+ let response: P2Response = serde_json::from_str(json)?;
+ response
+ .packages
+ .into_iter()
+ .find(|(key, _)| key == package_name)
+ .map(|(_, versions)| versions)
+ .ok_or_else(|| anyhow::anyhow!("Package \"{package_name}\" not found in response"))
+}
+
+/// Fetch package version metadata from the Packagist p2 API.
+///
+/// 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)?;
+
+ if !response.status().is_success() {
+ anyhow::bail!(
+ "Failed to fetch package \"{package_name}\" from Packagist (HTTP {})",
+ response.status()
+ );
+ }
+
+ 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)
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Packagist search API
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// A single search result from the Packagist search API.
+#[derive(Debug, Deserialize, Serialize, Clone)]
+pub struct SearchResult {
+ pub name: String,
+ pub description: String,
+ pub url: String,
+ pub repository: Option<String>,
+ pub downloads: u64,
+ pub favers: u64,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct SearchResponse {
+ pub results: Vec<SearchResult>,
+ pub total: u64,
+ pub next: Option<String>,
+}
+
+/// Maximum number of pages to fetch from the Packagist search API.
+const SEARCH_MAX_PAGES: usize = 20;
+
+/// Percent-encode a string for use in a URL query parameter value.
+fn url_encode(s: &str) -> String {
+ let mut encoded = String::with_capacity(s.len());
+ for byte in s.bytes() {
+ match byte {
+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
+ encoded.push(byte as char);
+ }
+ b' ' => encoded.push_str("%20"),
+ other => {
+ encoded.push_str(&format!("%{other:02X}"));
+ }
+ }
+ }
+ encoded
+}
+
+/// Search Packagist for packages matching `query`.
+///
+/// Fetches up to `SEARCH_MAX_PAGES` pages of results and returns the full list.
+/// An optional `package_type` filter can narrow results (e.g. `"library"`).
+pub fn search_packages(
+ query: &str,
+ package_type: Option<&str>,
+) -> anyhow::Result<(Vec<SearchResult>, u64)> {
+ let client = reqwest::blocking::Client::builder()
+ .user_agent("mozart/0.1.0")
+ .build()?;
+
+ let mut all_results: Vec<SearchResult> = Vec::new();
+ let mut page = 1usize;
+ let mut next_url: Option<String> = None;
+ let mut total: u64 = 0;
+
+ loop {
+ let response: SearchResponse = if let Some(ref url) = next_url {
+ let resp = client.get(url).send()?;
+ if !resp.status().is_success() {
+ anyhow::bail!("Packagist search request failed (HTTP {})", resp.status());
+ }
+ resp.json()?
+ } else {
+ let encoded_query = url_encode(query);
+ let mut url = format!("https://packagist.org/search.json?q={encoded_query}");
+ if let Some(t) = package_type {
+ url.push_str("&type=");
+ url.push_str(&url_encode(t));
+ }
+
+ let resp = client.get(&url).send()?;
+ if !resp.status().is_success() {
+ anyhow::bail!("Packagist search request failed (HTTP {})", resp.status());
+ }
+ resp.json()?
+ };
+
+ if page == 1 {
+ total = response.total;
+ }
+
+ all_results.extend(response.results);
+ next_url = response.next;
+ page += 1;
+
+ if next_url.is_none() || page > SEARCH_MAX_PAGES {
+ break;
+ }
+ }
+
+ Ok((all_results, total))
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Security Advisories API
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// A single security advisory from the Packagist API.
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct SecurityAdvisory {
+ #[serde(rename = "advisoryId")]
+ pub advisory_id: String,
+
+ #[serde(rename = "packageName")]
+ pub package_name: String,
+
+ #[serde(rename = "remoteId")]
+ pub remote_id: String,
+
+ pub title: String,
+
+ pub link: Option<String>,
+
+ pub cve: Option<String>,
+
+ /// Composer version constraint string, e.g. ">=1.0,<1.5.1|>=2.0,<2.3"
+ #[serde(rename = "affectedVersions")]
+ pub affected_versions: String,
+
+ pub source: String,
+
+ #[serde(rename = "reportedAt")]
+ pub reported_at: String,
+
+ #[serde(rename = "composerRepository")]
+ pub composer_repository: Option<String>,
+
+ pub severity: Option<String>,
+
+ #[serde(default)]
+ pub sources: Vec<AdvisorySource>,
+}
+
+/// A source entry within a security advisory.
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct AdvisorySource {
+ pub name: String,
+ #[serde(rename = "remoteId")]
+ pub remote_id: String,
+}
+
+/// Response from POST `https://packagist.org/api/security-advisories/`.
+#[derive(Debug, Deserialize)]
+pub struct SecurityAdvisoriesResponse {
+ pub advisories: BTreeMap<String, Vec<SecurityAdvisory>>,
+}
+
+/// Fetch security advisories for the given package names from the Packagist API.
+///
+/// Sends a POST request to `https://packagist.org/api/security-advisories/`
+/// with form-encoded package names. Returns advisories grouped by package name.
+///
+/// If the package list is very large (500+), requests are batched in chunks of
+/// 500 names per request and the results are merged.
+pub fn fetch_security_advisories(
+ package_names: &[&str],
+) -> anyhow::Result<BTreeMap<String, Vec<SecurityAdvisory>>> {
+ let client = reqwest::blocking::Client::builder()
+ .user_agent("mozart/0.1.0")
+ .build()?;
+
+ let mut all_advisories: BTreeMap<String, Vec<SecurityAdvisory>> = BTreeMap::new();
+
+ for chunk in package_names.chunks(500) {
+ // Build an application/x-www-form-urlencoded body manually.
+ // Each package is encoded as `packages[]=<name>` and joined with `&`.
+ let body: String = chunk
+ .iter()
+ .map(|name| format!("packages[]={}", url_encode(name)))
+ .collect::<Vec<_>>()
+ .join("&");
+
+ let response = client
+ .post("https://packagist.org/api/security-advisories/")
+ .header("Content-Type", "application/x-www-form-urlencoded")
+ .body(body)
+ .send()?;
+
+ if !response.status().is_success() {
+ anyhow::bail!(
+ "Packagist security advisories request failed (HTTP {})",
+ response.status()
+ );
+ }
+
+ let parsed: SecurityAdvisoriesResponse = response.json()?;
+
+ for (pkg_name, advisories) in parsed.advisories {
+ if !advisories.is_empty() {
+ all_advisories
+ .entry(pkg_name)
+ .or_default()
+ .extend(advisories);
+ }
+ }
+ }
+
+ Ok(all_advisories)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parse_p2_response_basic() {
+ let json = r#"{
+ "packages": {
+ "monolog/monolog": [
+ {
+ "version": "3.8.0",
+ "version_normalized": "3.8.0.0",
+ "require": {"php": ">=8.1"},
+ "dist": {
+ "type": "zip",
+ "url": "https://example.com/monolog-3.8.0.zip",
+ "reference": "abc123",
+ "shasum": ""
+ },
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Seldaek/monolog.git",
+ "reference": "abc123"
+ }
+ },
+ {
+ "version": "3.7.0",
+ "version_normalized": "3.7.0.0",
+ "require": {"php": ">=8.1"}
+ }
+ ]
+ }
+ }"#;
+
+ let versions = parse_p2_response(json, "monolog/monolog").unwrap();
+ assert_eq!(versions.len(), 2);
+ assert_eq!(versions[0].version, "3.8.0");
+ assert_eq!(versions[0].version_normalized, "3.8.0.0");
+ assert_eq!(versions[0].require.get("php").unwrap(), ">=8.1");
+ assert!(versions[0].dist.is_some());
+ assert!(versions[0].source.is_some());
+ assert_eq!(versions[1].version, "3.7.0");
+ assert!(versions[1].dist.is_none());
+ }
+
+ #[test]
+ fn parse_p2_response_not_found() {
+ let json = r#"{"packages": {"other/pkg": []}}"#;
+ let result = parse_p2_response(json, "monolog/monolog");
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn parse_p2_response_with_dev_version() {
+ let json = r#"{
+ "packages": {
+ "test/pkg": [
+ {
+ "version": "dev-master",
+ "version_normalized": "dev-master",
+ "require": {}
+ },
+ {
+ "version": "1.0.0",
+ "version_normalized": "1.0.0.0",
+ "require": {}
+ }
+ ]
+ }
+ }"#;
+
+ let versions = parse_p2_response(json, "test/pkg").unwrap();
+ assert_eq!(versions.len(), 2);
+ assert_eq!(versions[0].version, "dev-master");
+ assert_eq!(versions[1].version, "1.0.0");
+ }
+
+ // ──────────── branch_aliases() tests ────────────
+
+ #[test]
+ fn test_branch_aliases_present() {
+ let json = r#"{
+ "packages": {
+ "test/pkg": [
+ {
+ "version": "dev-master",
+ "version_normalized": "dev-master",
+ "require": {},
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.x-dev"
+ }
+ }
+ }
+ ]
+ }
+ }"#;
+
+ let versions = parse_p2_response(json, "test/pkg").unwrap();
+ let aliases = versions[0].branch_aliases();
+ assert_eq!(aliases.len(), 1);
+ assert_eq!(aliases.get("dev-master").unwrap(), "2.x-dev");
+ }
+
+ #[test]
+ fn test_branch_aliases_multiple() {
+ let json = r#"{
+ "packages": {
+ "test/pkg": [
+ {
+ "version": "dev-master",
+ "version_normalized": "dev-master",
+ "require": {},
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.x-dev",
+ "dev-1.x": "1.5.x-dev"
+ }
+ }
+ }
+ ]
+ }
+ }"#;
+
+ let versions = parse_p2_response(json, "test/pkg").unwrap();
+ let aliases = versions[0].branch_aliases();
+ assert_eq!(aliases.len(), 2);
+ assert_eq!(aliases.get("dev-master").unwrap(), "2.x-dev");
+ assert_eq!(aliases.get("dev-1.x").unwrap(), "1.5.x-dev");
+ }
+
+ #[test]
+ fn test_branch_aliases_no_extra() {
+ let json = r#"{
+ "packages": {
+ "test/pkg": [
+ {
+ "version": "dev-master",
+ "version_normalized": "dev-master",
+ "require": {}
+ }
+ ]
+ }
+ }"#;
+
+ let versions = parse_p2_response(json, "test/pkg").unwrap();
+ let aliases = versions[0].branch_aliases();
+ assert!(aliases.is_empty());
+ }
+
+ #[test]
+ fn test_branch_aliases_extra_without_branch_alias_key() {
+ let json = r#"{
+ "packages": {
+ "test/pkg": [
+ {
+ "version": "dev-master",
+ "version_normalized": "dev-master",
+ "require": {},
+ "extra": {
+ "installer-name": "my-plugin"
+ }
+ }
+ ]
+ }
+ }"#;
+
+ let versions = parse_p2_response(json, "test/pkg").unwrap();
+ let aliases = versions[0].branch_aliases();
+ assert!(aliases.is_empty());
+ }
+
+ // ──────────── SecurityAdvisory parsing tests ─────────────────────────────
+
+ #[test]
+ fn test_parse_security_advisories_response() {
+ let json = r#"{
+ "advisories": {
+ "monolog/monolog": [
+ {
+ "advisoryId": "PKSA-b2m0-qqf7-qck4",
+ "packageName": "monolog/monolog",
+ "remoteId": "monolog/monolog/2017-11-13-1.yaml",
+ "title": "Header injection in NativeMailerHandler",
+ "link": "https://github.com/Seldaek/monolog/pull/683",
+ "cve": null,
+ "affectedVersions": ">=1.8.0,<1.12.0",
+ "source": "FriendsOfPHP/security-advisories",
+ "reportedAt": "2017-11-13T00:00:00+00:00",
+ "composerRepository": "https://packagist.org",
+ "severity": "low",
+ "sources": [
+ {
+ "name": "FriendsOfPHP/security-advisories",
+ "remoteId": "monolog/monolog/2017-11-13-1.yaml"
+ }
+ ]
+ }
+ ]
+ }
+ }"#;
+
+ let response: SecurityAdvisoriesResponse = serde_json::from_str(json).unwrap();
+ assert_eq!(response.advisories.len(), 1);
+ let advisories = response.advisories.get("monolog/monolog").unwrap();
+ assert_eq!(advisories.len(), 1);
+ let adv = &advisories[0];
+ assert_eq!(adv.advisory_id, "PKSA-b2m0-qqf7-qck4");
+ assert_eq!(adv.package_name, "monolog/monolog");
+ assert_eq!(adv.title, "Header injection in NativeMailerHandler");
+ assert_eq!(adv.affected_versions, ">=1.8.0,<1.12.0");
+ assert_eq!(adv.severity.as_deref(), Some("low"));
+ assert!(adv.cve.is_none());
+ assert_eq!(adv.sources.len(), 1);
+ assert_eq!(adv.sources[0].name, "FriendsOfPHP/security-advisories");
+ }
+
+ #[test]
+ fn test_parse_security_advisories_empty() {
+ let json = r#"{"advisories": {"other/package": []}}"#;
+ let response: SecurityAdvisoriesResponse = serde_json::from_str(json).unwrap();
+ assert_eq!(response.advisories.len(), 1);
+ let advisories = response.advisories.get("other/package").unwrap();
+ assert!(advisories.is_empty());
+ }
+
+ #[test]
+ fn test_parse_security_advisories_null_fields() {
+ let json = r#"{
+ "advisories": {
+ "vendor/pkg": [
+ {
+ "advisoryId": "PKSA-0000-0000-0000",
+ "packageName": "vendor/pkg",
+ "remoteId": "vendor/pkg/2024-01-01.yaml",
+ "title": "Some vulnerability",
+ "link": null,
+ "cve": null,
+ "affectedVersions": ">=1.0,<2.0",
+ "source": "FriendsOfPHP/security-advisories",
+ "reportedAt": "2024-01-01T00:00:00+00:00",
+ "composerRepository": null,
+ "severity": null,
+ "sources": []
+ }
+ ]
+ }
+ }"#;
+
+ let response: SecurityAdvisoriesResponse = serde_json::from_str(json).unwrap();
+ let advisories = response.advisories.get("vendor/pkg").unwrap();
+ assert_eq!(advisories.len(), 1);
+ let adv = &advisories[0];
+ assert!(adv.link.is_none());
+ assert!(adv.cve.is_none());
+ assert!(adv.severity.is_none());
+ assert!(adv.composer_repository.is_none());
+ assert!(adv.sources.is_empty());
+ }
+}
diff --git a/crates/mozart-registry/src/resolver.rs b/crates/mozart-registry/src/resolver.rs
new file mode 100644
index 0000000..eb4f6e5
--- /dev/null
+++ b/crates/mozart-registry/src/resolver.rs
@@ -0,0 +1,1917 @@
+//! Dependency resolver using the pubgrub v0.3.0 algorithm.
+//!
+//! This module converts Composer-style dependency constraints into pubgrub's `Ranges<ComposerVersion>`
+//! and implements `DependencyProvider` for Mozart's package resolution.
+
+use std::cell::RefCell;
+use std::cmp::Reverse;
+use std::collections::{BTreeMap, HashMap};
+use std::fmt;
+
+use pubgrub::{
+ DefaultStringReporter, Dependencies, DependencyConstraints, DependencyProvider,
+ PackageResolutionStatistics, PubGrubError, Ranges, Reporter,
+};
+
+use crate::cache::Cache;
+use crate::packagist;
+use mozart_constraint::{Constraint, VersionConstraint};
+use mozart_core::package::Stability;
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Stability constants
+// ─────────────────────────────────────────────────────────────────────────────
+
+const STABILITY_DEV: u16 = 0;
+const STABILITY_ALPHA_BASE: u16 = 1000;
+const STABILITY_BETA_BASE: u16 = 2000;
+const STABILITY_RC_BASE: u16 = 3000;
+const STABILITY_STABLE: u16 = 4000;
+const STABILITY_PATCH_BASE: u16 = 5000;
+
+// ─────────────────────────────────────────────────────────────────────────────
+// ComposerVersion
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// A Composer version suitable for use with pubgrub.
+///
+/// Encodes a 4-segment Composer version plus stability into an ordered struct.
+/// Stability is encoded numerically so that higher values are more stable:
+/// - dev=0, alpha(N)=1000+N, beta(N)=2000+N, RC(N)=3000+N, stable=4000, patch(N)=5000+N
+///
+/// This ensures natural `Ord` comparison matches Composer's version ordering.
+/// Dev branches (dev-master, dev-*) are NOT representable and return `None` from `from_normalized`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct ComposerVersion {
+ pub major: u16,
+ pub minor: u16,
+ pub patch: u16,
+ pub build: u16,
+ /// Stability encoded as a comparable integer. Higher = more stable.
+ pub stability: u16,
+}
+
+impl PartialOrd for ComposerVersion {
+ fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+ Some(self.cmp(other))
+ }
+}
+
+impl Ord for ComposerVersion {
+ fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+ (
+ self.major,
+ self.minor,
+ self.patch,
+ self.build,
+ self.stability,
+ )
+ .cmp(&(
+ other.major,
+ other.minor,
+ other.patch,
+ other.build,
+ other.stability,
+ ))
+ }
+}
+
+impl fmt::Display for ComposerVersion {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(
+ f,
+ "{}.{}.{}.{}",
+ self.major, self.minor, self.patch, self.build
+ )?;
+ let s = self.stability;
+ if s == STABILITY_STABLE {
+ // no suffix
+ } else if s >= STABILITY_PATCH_BASE {
+ write!(f, "-patch{}", s - STABILITY_PATCH_BASE)?;
+ } else if s >= STABILITY_RC_BASE {
+ write!(f, "-RC{}", s - STABILITY_RC_BASE)?;
+ } else if s >= STABILITY_BETA_BASE {
+ write!(f, "-beta{}", s - STABILITY_BETA_BASE)?;
+ } else if s >= STABILITY_ALPHA_BASE {
+ write!(f, "-alpha{}", s - STABILITY_ALPHA_BASE)?;
+ } else {
+ write!(f, "-dev")?;
+ }
+ Ok(())
+ }
+}
+
+impl ComposerVersion {
+ /// Parse a branch alias target like "2.x-dev" or "1.0.x-dev" into a ComposerVersion
+ /// with dev stability.
+ ///
+ /// Used to represent aliased dev branches in the resolver. The version number is taken
+ /// from the numeric prefix (e.g. "2.x-dev" → major=2, minor=0, patch=0, build=0, stability=dev).
+ /// This allows constraints like `^2.0` to match `dev-master` when it is aliased to `2.x-dev`.
+ pub fn from_branch_alias_target(alias_target: &str) -> Option<ComposerVersion> {
+ let s = alias_target.trim().to_lowercase();
+ // Must end with "-dev" or ".x-dev"
+ if !s.ends_with("-dev") {
+ return None;
+ }
+ // Strip the trailing "-dev"
+ let base = &s[..s.len() - 4];
+ // Strip optional trailing ".x" segments (e.g. "2.x" → "2", "1.0.x" → "1.0")
+ let base = base.trim_end_matches(".x");
+ // Now parse whatever numeric segments remain
+ let parts: Vec<&str> = base.split('.').collect();
+ let major: u16 = parts.first().and_then(|p| p.parse().ok())?;
+ let minor: u16 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0);
+ let patch: u16 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0);
+ let build: u16 = parts.get(3).and_then(|p| p.parse().ok()).unwrap_or(0);
+ Some(ComposerVersion {
+ major,
+ minor,
+ patch,
+ build,
+ stability: STABILITY_DEV,
+ })
+ }
+
+ /// Parse from a Packagist normalized version string like "1.2.3.0", "1.0.0.0-beta1", "1.0.0.0-RC2".
+ /// Returns `None` for dev branches (dev-master, dev-*, *.x-dev).
+ pub fn from_normalized(normalized: &str) -> Option<ComposerVersion> {
+ let s = normalized.trim();
+
+ // Reject dev branches
+ if s.to_lowercase().starts_with("dev-") {
+ return None;
+ }
+ // Reject *.x-dev style (e.g. "9999999.9999999.9999999.9999999-dev" from packagist sometimes)
+ // Also reject anything like "2.1.x-dev"
+ if s.to_lowercase().ends_with("-dev") && s.contains(".x") {
+ return None;
+ }
+ // Packagist uses 9999999.9999999.9999999.9999999 for dev branches too
+ if s.starts_with("9999999") {
+ return None;
+ }
+
+ // Split on '-' for pre-release
+ let (version_part, pre_part) = if let Some(pos) = s.find('-') {
+ (&s[..pos], Some(&s[pos + 1..]))
+ } else {
+ (s, None)
+ };
+
+ let segments: Vec<&str> = version_part.split('.').collect();
+ if segments.is_empty() || segments[0].is_empty() {
+ return None;
+ }
+
+ let major: u16 = segments[0].parse().ok()?;
+ let minor: u16 = segments.get(1).and_then(|p| p.parse().ok()).unwrap_or(0);
+ let patch: u16 = segments.get(2).and_then(|p| p.parse().ok()).unwrap_or(0);
+ let build: u16 = segments.get(3).and_then(|p| p.parse().ok()).unwrap_or(0);
+
+ let stability = match pre_part {
+ None => STABILITY_STABLE,
+ Some(pre) => encode_pre_release_str(pre),
+ };
+
+ Some(ComposerVersion {
+ major,
+ minor,
+ patch,
+ build,
+ stability,
+ })
+ }
+
+ /// Construct a stable version from numeric segments.
+ pub fn stable(major: u16, minor: u16, patch: u16, build: u16) -> ComposerVersion {
+ ComposerVersion {
+ major,
+ minor,
+ patch,
+ build,
+ stability: STABILITY_STABLE,
+ }
+ }
+
+ /// Get the `Stability` enum value for this version.
+ pub fn stability_enum(&self) -> Stability {
+ if self.stability < STABILITY_ALPHA_BASE {
+ // Covers both STABILITY_DEV (0) and any value below ALPHA_BASE
+ Stability::Dev
+ } else if self.stability < STABILITY_BETA_BASE {
+ Stability::Alpha
+ } else if self.stability < STABILITY_RC_BASE {
+ Stability::Beta
+ } else if self.stability < STABILITY_STABLE {
+ Stability::RC
+ } else {
+ // >= STABILITY_STABLE (includes patch)
+ Stability::Stable
+ }
+ }
+}
+
+fn encode_pre_release_str(pre: &str) -> u16 {
+ let lower = pre.to_lowercase();
+ if lower == "dev" {
+ STABILITY_DEV
+ } else if lower.starts_with("alpha") || lower.starts_with('a') {
+ let n = extract_pre_release_number_from(
+ &lower,
+ if lower.starts_with("alpha") {
+ "alpha"
+ } else {
+ "a"
+ },
+ );
+ STABILITY_ALPHA_BASE + n
+ } else if lower.starts_with("beta") || lower.starts_with('b') {
+ let n = extract_pre_release_number_from(
+ &lower,
+ if lower.starts_with("beta") {
+ "beta"
+ } else {
+ "b"
+ },
+ );
+ STABILITY_BETA_BASE + n
+ } else if lower.starts_with("rc") {
+ let n = extract_pre_release_number_from(&lower, "rc");
+ STABILITY_RC_BASE + n
+ } else if lower.starts_with("patch") || lower.starts_with("pl") {
+ let n = extract_pre_release_number_from(
+ &lower,
+ if lower.starts_with("patch") {
+ "patch"
+ } else {
+ "pl"
+ },
+ );
+ STABILITY_PATCH_BASE + n
+ } else if lower == "p" {
+ STABILITY_PATCH_BASE
+ } else {
+ STABILITY_STABLE
+ }
+}
+
+fn extract_pre_release_number_from(s: &str, prefix: &str) -> u16 {
+ let after = &s[prefix.len()..];
+ let digits: String = after.chars().filter(|c| c.is_ascii_digit()).collect();
+ digits.parse().unwrap_or(0)
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// PackageName
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// A normalized package name (lowercase, e.g. "monolog/monolog").
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct PackageName(pub String);
+
+impl fmt::Display for PackageName {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(&self.0)
+ }
+}
+
+impl PackageName {
+ pub const ROOT: &'static str = "__root__";
+
+ pub fn root() -> Self {
+ PackageName(Self::ROOT.to_string())
+ }
+
+ /// Returns true if this is a platform package (php, ext-*, lib-*).
+ pub fn is_platform(&self) -> bool {
+ self.0 == "php"
+ || self.0.starts_with("ext-")
+ || self.0.starts_with("lib-")
+ || self.0 == "php-64bit"
+ || self.0 == "php-ipv6"
+ || self.0 == "php-zts"
+ || self.0 == "php-debug"
+ }
+
+ /// Returns true if this is the virtual root package.
+ pub fn is_root(&self) -> bool {
+ self.0 == Self::ROOT
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Type alias
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// The version set type used throughout the resolver.
+pub type ComposerVS = Ranges<ComposerVersion>;
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Constraint-to-Ranges conversion
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// Convert a Composer version constraint string to a pubgrub `Ranges<ComposerVersion>`.
+///
+/// Supports: exact, >=, >, <=, <, !=, ^, ~, *, wildcards, hyphen ranges, AND, OR.
+pub fn constraint_to_ranges(constraint: &str) -> Result<ComposerVS, String> {
+ let vc = VersionConstraint::parse(constraint)
+ .map_err(|e| format!("Failed to parse constraint '{}': {}", constraint, e))?;
+ version_constraint_to_ranges(&vc)
+}
+
+fn version_constraint_to_ranges(vc: &VersionConstraint) -> Result<ComposerVS, String> {
+ match vc {
+ VersionConstraint::Single(c) => single_constraint_to_ranges(c),
+ VersionConstraint::And(cs) => {
+ let mut result = Ranges::full();
+ for c in cs {
+ result = result.intersection(&version_constraint_to_ranges(c)?);
+ }
+ Ok(result)
+ }
+ VersionConstraint::Or(cs) => {
+ let mut result = Ranges::empty();
+ for c in cs {
+ result = result.union(&version_constraint_to_ranges(c)?);
+ }
+ Ok(result)
+ }
+ }
+}
+
+fn single_constraint_to_ranges(c: &Constraint) -> Result<ComposerVS, String> {
+ match c {
+ Constraint::Any => Ok(Ranges::full()),
+ Constraint::Exact(v) => {
+ let cv = version_to_composer(v)?;
+ Ok(Ranges::singleton(cv))
+ }
+ Constraint::GreaterThan(v) => {
+ let cv = version_to_composer(v)?;
+ Ok(Ranges::strictly_higher_than(cv))
+ }
+ Constraint::GreaterThanOrEqual(v) => {
+ let cv = version_to_composer(v)?;
+ Ok(Ranges::higher_than(cv))
+ }
+ Constraint::LessThan(v) => {
+ let cv = version_to_composer(v)?;
+ Ok(Ranges::strictly_lower_than(cv))
+ }
+ Constraint::LessThanOrEqual(v) => {
+ let cv = version_to_composer(v)?;
+ // No Ranges::lower_than in version-ranges 0.1.x, so use complement of strictly_higher_than
+ Ok(Ranges::strictly_higher_than(cv).complement())
+ }
+ Constraint::NotEqual(v) => {
+ let cv = version_to_composer(v)?;
+ Ok(Ranges::singleton(cv).complement())
+ }
+ }
+}
+
+/// Convert a `constraint::Version` to a `ComposerVersion`.
+fn version_to_composer(v: &mozart_constraint::Version) -> Result<ComposerVersion, String> {
+ // Dev branches cannot be represented as ComposerVersion
+ if v.is_dev_branch {
+ return Err(format!(
+ "Dev branch versions cannot be used in Ranges (branch: {:?})",
+ v.dev_branch_name
+ ));
+ }
+
+ let major: u16 = v
+ .major
+ .try_into()
+ .map_err(|_| format!("Major version {} too large for u16", v.major))?;
+ let minor: u16 = v
+ .minor
+ .try_into()
+ .map_err(|_| format!("Minor version {} too large for u16", v.minor))?;
+ let patch: u16 = v
+ .patch
+ .try_into()
+ .map_err(|_| format!("Patch version {} too large for u16", v.patch))?;
+ let build: u16 = v
+ .build
+ .try_into()
+ .map_err(|_| format!("Build version {} too large for u16", v.build))?;
+
+ let stability = encode_pre_release(&v.pre_release);
+
+ Ok(ComposerVersion {
+ major,
+ minor,
+ patch,
+ build,
+ stability,
+ })
+}
+
+fn encode_pre_release(pre: &Option<String>) -> u16 {
+ match pre {
+ None => STABILITY_STABLE,
+ Some(s) => encode_pre_release_str(s),
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Platform configuration
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// Platform package configuration.
+/// Maps package names to version strings (normalized, e.g. "8.1.0.0").
+pub struct PlatformConfig {
+ pub packages: HashMap<String, String>,
+}
+
+impl Default for PlatformConfig {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl PlatformConfig {
+ /// Create a default platform config with PHP 8.1 and common extensions.
+ pub fn new() -> Self {
+ let mut packages = HashMap::new();
+ packages.insert("php".to_string(), "8.1.0.0".to_string());
+ packages.insert("php-64bit".to_string(), "8.1.0.0".to_string());
+ for ext in &[
+ "json",
+ "mbstring",
+ "openssl",
+ "pdo",
+ "tokenizer",
+ "xml",
+ "ctype",
+ "iconv",
+ "curl",
+ "dom",
+ "fileinfo",
+ "filter",
+ "hash",
+ "pcre",
+ "session",
+ "zlib",
+ "intl",
+ "gd",
+ "bcmath",
+ ] {
+ packages.insert(format!("ext-{ext}"), "8.1.0.0".to_string());
+ }
+ Self { packages }
+ }
+
+ /// Parse platform packages into `ComposerVersion` values.
+ pub fn to_versions(&self) -> HashMap<String, ComposerVersion> {
+ self.packages
+ .iter()
+ .filter_map(|(name, version_str)| {
+ ComposerVersion::from_normalized(version_str).map(|v| (name.clone(), v))
+ })
+ .collect()
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Error types
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// Error returned by `DependencyProvider` methods (internal to the solver).
+#[derive(Debug)]
+pub enum ResolverError {
+ /// Network or API error fetching package metadata.
+ PackagistError(String),
+ /// Internal error.
+ Internal(String),
+}
+
+impl fmt::Display for ResolverError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::PackagistError(msg) => write!(f, "Packagist error: {}", msg),
+ Self::Internal(msg) => write!(f, "Internal resolver error: {}", msg),
+ }
+ }
+}
+
+impl std::error::Error for ResolverError {}
+
+/// Error returned by the public `resolve()` function.
+#[derive(Debug)]
+pub enum ResolveError {
+ /// No solution exists. Contains a human-readable explanation.
+ NoSolution(String),
+ /// Error parsing a version constraint.
+ ConstraintParseError(String, String, String), // (package, constraint, error)
+ /// Error fetching dependency metadata.
+ DependencyFetchError(String),
+ /// Internal error.
+ Internal(String),
+}
+
+impl fmt::Display for ResolveError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::NoSolution(report) => {
+ writeln!(
+ f,
+ "Your requirements could not be resolved to an installable set of packages."
+ )?;
+ writeln!(f)?;
+ write!(f, "{}", report)
+ }
+ Self::ConstraintParseError(pkg, constraint, err) => {
+ write!(
+ f,
+ "Could not parse version constraint '{}' for package {}: {}",
+ constraint, pkg, err
+ )
+ }
+ Self::DependencyFetchError(msg) => write!(f, "{}", msg),
+ Self::Internal(msg) => write!(f, "Internal resolver error: {}", msg),
+ }
+ }
+}
+
+impl std::error::Error for ResolveError {}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Priority type
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// Priority for package resolution ordering.
+/// Higher priority = resolved first.
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub struct ResolverPriority {
+ conflict_count: u32,
+ version_count_inverse: Reverse<usize>,
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Provider internals
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// Cached version data for a single package.
+struct PackageVersions {
+ /// All versions that pass the stability filter, sorted by ComposerVersion.
+ versions: BTreeMap<ComposerVersion, VersionDependencies>,
+}
+
+/// Dependencies of a specific package version.
+struct VersionDependencies {
+ /// Required packages: (package_name, constraint_string)
+ require: Vec<(String, String)>,
+ /// Replace declarations: (package_name, constraint_string)
+ /// Stored for future replace/provide support (Phase 3.8+).
+ #[allow(dead_code)]
+ replace: Vec<(String, String)>,
+ /// Provide declarations: (package_name, constraint_string)
+ /// Stored for future replace/provide support (Phase 3.8+).
+ #[allow(dead_code)]
+ provide: Vec<(String, String)>,
+ /// Conflict declarations: (package_name, constraint_string)
+ conflict: Vec<(String, String)>,
+ /// Original version string (for output).
+ version_string: String,
+ /// Normalized version string.
+ version_normalized: String,
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// MozartProvider
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// pubgrub `DependencyProvider` that fetches package metadata from Packagist.
+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>,
+
+ /// Minimum stability threshold. Versions below this are excluded.
+ minimum_stability: Stability,
+
+ /// Per-package stability overrides from composer.json.
+ stability_flags: HashMap<String, Stability>,
+
+ /// Whether prefer-stable is enabled.
+ prefer_stable: bool,
+
+ /// Whether prefer-lowest is enabled (for testing).
+ prefer_lowest: bool,
+
+ /// Root package dependencies (require + optionally require-dev).
+ root_dependencies: Vec<(PackageName, ComposerVS)>,
+
+ /// Root package conflicts.
+ root_conflicts: Vec<(PackageName, ComposerVS)>,
+
+ /// Ignore all platform requirements.
+ ignore_platform_reqs: bool,
+
+ /// Specific platform requirements to ignore.
+ ignore_platform_req_list: Vec<String>,
+}
+
+impl MozartProvider {
+ /// Ensure package metadata is fetched from Packagist and stored in cache.
+ fn ensure_fetched(&self, package_name: &str) -> Result<(), ResolverError> {
+ // Check if already cached
+ {
+ let cache = self.package_cache.borrow();
+ if cache.contains_key(package_name) {
+ return Ok(());
+ }
+ }
+
+ // 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))
+ })?;
+
+ // Convert and filter
+ let mut versions = BTreeMap::new();
+ for pv in &packagist_versions {
+ // Build the dependency metadata once (used for both the normal entry
+ // and any branch-alias synthetic entry).
+ let make_deps =
+ |version_string: String, version_normalized: String| VersionDependencies {
+ require: pv
+ .require
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect(),
+ replace: pv
+ .replace
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect(),
+ provide: pv
+ .provide
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect(),
+ conflict: pv
+ .conflict
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect(),
+ version_string,
+ version_normalized,
+ };
+
+ match ComposerVersion::from_normalized(&pv.version_normalized) {
+ Some(cv) => {
+ // Regular (non-dev) version
+ if self.passes_stability_filter(package_name, &cv) {
+ let deps = make_deps(pv.version.clone(), pv.version_normalized.clone());
+ versions.insert(cv, deps);
+ }
+ }
+ None => {
+ // Dev branch — check for branch aliases
+ let aliases = pv.branch_aliases();
+ for (branch, alias_target) in &aliases {
+ // The key in branch-alias is the full branch name, e.g. "dev-master".
+ // Verify it matches this version.
+ if branch.to_lowercase() != pv.version.to_lowercase() {
+ continue;
+ }
+ if let Some(alias_cv) =
+ ComposerVersion::from_branch_alias_target(alias_target)
+ && self.passes_stability_filter(package_name, &alias_cv)
+ {
+ // Use the alias target as the normalized version string so
+ // that constraint matching works correctly.
+ let deps = make_deps(pv.version.clone(), alias_target.clone());
+ // Only insert if no real release already occupies this slot
+ versions.entry(alias_cv).or_insert(deps);
+ }
+ }
+ }
+ }
+ }
+
+ let mut cache = self.package_cache.borrow_mut();
+ cache.insert(package_name.to_string(), PackageVersions { versions });
+
+ Ok(())
+ }
+
+ /// Check if a version passes the minimum-stability filter for the given package.
+ fn passes_stability_filter(&self, package_name: &str, version: &ComposerVersion) -> bool {
+ // Per-package stability override takes precedence
+ let min_stability = self
+ .stability_flags
+ .get(package_name)
+ .copied()
+ .unwrap_or(self.minimum_stability);
+
+ let version_stability = version.stability_enum();
+
+ // `Stability` enum: Stable=0, RC=5, Beta=10, Alpha=15, Dev=20
+ // Lower enum value = more stable.
+ // version_stability must be <= min_stability (i.e., at least as stable as minimum).
+ version_stability <= min_stability
+ }
+
+ /// Check whether a platform dependency should be skipped.
+ fn should_skip_platform_dep(&self, dep_name: &str) -> bool {
+ if !PackageName(dep_name.to_string()).is_platform() {
+ return false;
+ }
+ if self.ignore_platform_reqs {
+ return true;
+ }
+ self.ignore_platform_req_list.iter().any(|p| p == dep_name)
+ }
+}
+
+impl DependencyProvider for MozartProvider {
+ type P = PackageName;
+ type V = ComposerVersion;
+ type VS = ComposerVS;
+ type Priority = ResolverPriority;
+ type M = String;
+ type Err = ResolverError;
+
+ fn choose_version(
+ &self,
+ package: &PackageName,
+ range: &ComposerVS,
+ ) -> Result<Option<ComposerVersion>, ResolverError> {
+ // Root package: always version 0.0.0.0-stable
+ if package.is_root() {
+ let root_v = ComposerVersion::stable(0, 0, 0, 0);
+ if range.contains(&root_v) {
+ return Ok(Some(root_v));
+ }
+ return Ok(None);
+ }
+
+ // Platform packages: return the fixed version if it satisfies the range
+ if package.is_platform() {
+ if let Some(v) = self.platform_packages.get(&package.0)
+ && range.contains(v)
+ {
+ return Ok(Some(*v));
+ }
+ return Ok(None);
+ }
+
+ // Regular packages: ensure metadata is fetched
+ self.ensure_fetched(&package.0)?;
+
+ let cache = self.package_cache.borrow();
+ let Some(pkg_versions) = cache.get(&package.0) else {
+ return Ok(None);
+ };
+
+ if self.prefer_lowest {
+ // Pick the lowest matching version
+ return Ok(pkg_versions
+ .versions
+ .keys()
+ .find(|v| range.contains(*v))
+ .copied());
+ }
+
+ if self.prefer_stable {
+ // First try: highest stable version in range
+ if let Some(v) = pkg_versions
+ .versions
+ .keys()
+ .rev()
+ .find(|v| v.stability >= STABILITY_STABLE && range.contains(*v))
+ {
+ return Ok(Some(*v));
+ }
+ }
+
+ // Default: pick highest version in range
+ Ok(pkg_versions
+ .versions
+ .keys()
+ .rev()
+ .find(|v| range.contains(*v))
+ .copied())
+ }
+
+ fn prioritize(
+ &self,
+ package: &PackageName,
+ range: &ComposerVS,
+ package_conflicts_counts: &PackageResolutionStatistics,
+ ) -> Self::Priority {
+ // Root and platform packages: highest priority (resolved first)
+ if package.is_root() || package.is_platform() {
+ return ResolverPriority {
+ conflict_count: u32::MAX,
+ version_count_inverse: Reverse(0),
+ };
+ }
+
+ let cache = self.package_cache.borrow();
+ let count = cache
+ .get(&package.0)
+ .map(|pvs| pvs.versions.keys().filter(|v| range.contains(*v)).count())
+ .unwrap_or(0);
+
+ ResolverPriority {
+ conflict_count: package_conflicts_counts.conflict_count(),
+ version_count_inverse: Reverse(count),
+ }
+ }
+
+ fn get_dependencies(
+ &self,
+ package: &PackageName,
+ version: &ComposerVersion,
+ ) -> Result<Dependencies<PackageName, ComposerVS, String>, ResolverError> {
+ // Root package: return the configured root dependencies
+ if package.is_root() {
+ let mut deps = DependencyConstraints::default();
+ for (name, range) in &self.root_dependencies {
+ deps.insert(name.clone(), range.clone());
+ }
+ // Apply root conflicts as complement ranges
+ for (name, range) in &self.root_conflicts {
+ let anti_range = range.complement();
+ deps.entry(name.clone())
+ .and_modify(|existing| *existing = existing.intersection(&anti_range))
+ .or_insert(anti_range);
+ }
+ return Ok(Dependencies::Available(deps));
+ }
+
+ // Platform packages: no dependencies
+ if package.is_platform() {
+ return Ok(Dependencies::Available(DependencyConstraints::default()));
+ }
+
+ // Regular packages: fetch metadata and build dependency map
+ self.ensure_fetched(&package.0)?;
+
+ let cache = self.package_cache.borrow();
+ let Some(pkg_versions) = cache.get(&package.0) else {
+ return Ok(Dependencies::Unavailable(format!(
+ "package {} has no available versions",
+ package
+ )));
+ };
+
+ let Some(version_deps) = pkg_versions.versions.get(version) else {
+ return Ok(Dependencies::Unavailable(format!(
+ "{} {} is not available",
+ package, version
+ )));
+ };
+
+ let mut deps = DependencyConstraints::default();
+
+ // Process `require` constraints
+ for (dep_name, constraint_str) in &version_deps.require {
+ // Skip self-dependencies
+ if dep_name == &package.0 {
+ continue;
+ }
+
+ // Skip platform dependencies if configured
+ if self.should_skip_platform_dep(dep_name) {
+ continue;
+ }
+
+ let dep_pkg = PackageName(dep_name.clone());
+
+ match constraint_to_ranges(constraint_str) {
+ Ok(range) => {
+ deps.insert(dep_pkg, range);
+ }
+ Err(e) => {
+ // Unparseable constraint: mark this version as unavailable
+ return Ok(Dependencies::Unavailable(format!(
+ "cannot parse constraint '{}' for dependency {} of {} {}: {}",
+ constraint_str, dep_name, package, version, e
+ )));
+ }
+ }
+ }
+
+ // Process `conflict` declarations as complement ranges
+ for (conflict_name, constraint_str) in &version_deps.conflict {
+ if self.should_skip_platform_dep(conflict_name) {
+ continue;
+ }
+ let conflict_pkg = PackageName(conflict_name.clone());
+ if let Ok(range) = constraint_to_ranges(constraint_str) {
+ let anti_range = range.complement();
+ deps.entry(conflict_pkg)
+ .and_modify(|existing| *existing = existing.intersection(&anti_range))
+ .or_insert(anti_range);
+ }
+ }
+
+ Ok(Dependencies::Available(deps))
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Public API types
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// Input to the resolver.
+pub struct ResolveRequest {
+ /// Dependencies from composer.json "require" section.
+ pub require: Vec<(String, String)>,
+ /// Dependencies from composer.json "require-dev" section.
+ pub require_dev: Vec<(String, String)>,
+ /// Whether to include require-dev in resolution.
+ pub include_dev: bool,
+ /// Minimum stability from composer.json.
+ pub minimum_stability: Stability,
+ /// Per-package stability overrides.
+ pub stability_flags: HashMap<String, Stability>,
+ /// Whether prefer-stable is enabled.
+ pub prefer_stable: bool,
+ /// Whether prefer-lowest is enabled.
+ pub prefer_lowest: bool,
+ /// Platform package configuration.
+ pub platform: PlatformConfig,
+ /// Ignore all platform requirements.
+ 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.
+pub struct ResolvedPackage {
+ pub name: String,
+ /// Human-readable version string (e.g. "1.2.3").
+ pub version: String,
+ /// Normalized version string (e.g. "1.2.3.0").
+ pub version_normalized: String,
+ /// True if the resolved version is a dev/pre-release version.
+ pub is_dev: bool,
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Public resolve() function
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// Run the dependency resolver.
+///
+/// Returns a list of resolved packages (excluding root and platform packages),
+/// or a human-readable error.
+pub fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, ResolveError> {
+ // 1. Build root dependencies
+ let mut root_deps: Vec<(PackageName, ComposerVS)> = Vec::new();
+ let root_conflicts: Vec<(PackageName, ComposerVS)> = Vec::new();
+
+ let parse_dep =
+ |name: &str, constraint: &str| -> Result<Option<(PackageName, ComposerVS)>, ResolveError> {
+ let pkg = PackageName(name.to_string());
+
+ // Skip platform deps if ignore_platform_reqs is set
+ if pkg.is_platform()
+ && (request.ignore_platform_reqs
+ || request.ignore_platform_req_list.contains(&name.to_string()))
+ {
+ return Ok(None);
+ }
+
+ let range = constraint_to_ranges(constraint).map_err(|e| {
+ ResolveError::ConstraintParseError(name.to_string(), constraint.to_string(), e)
+ })?;
+ Ok(Some((pkg, range)))
+ };
+
+ for (name, constraint) in &request.require {
+ if let Some(dep) = parse_dep(name, constraint)? {
+ root_deps.push(dep);
+ }
+ }
+
+ if request.include_dev {
+ for (name, constraint) in &request.require_dev {
+ if let Some(dep) = parse_dep(name, constraint)? {
+ root_deps.push(dep);
+ }
+ }
+ }
+
+ // 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(),
+ prefer_stable: request.prefer_stable,
+ prefer_lowest: request.prefer_lowest,
+ root_dependencies: root_deps,
+ root_conflicts,
+ ignore_platform_reqs: request.ignore_platform_reqs,
+ ignore_platform_req_list: request.ignore_platform_req_list.clone(),
+ };
+
+ // 3. Run pubgrub
+ let root = PackageName::root();
+ let root_version = ComposerVersion::stable(0, 0, 0, 0);
+
+ match pubgrub::resolve(&provider, root, root_version) {
+ Ok(solution) => {
+ // 4. Convert solution to ResolvedPackage list
+ let mut result = Vec::new();
+ for (pkg, version) in solution {
+ // Skip root and platform packages
+ if pkg.is_root() || pkg.is_platform() {
+ continue;
+ }
+
+ // Look up the original version string from the cache
+ let cache = provider.package_cache.borrow();
+ let (version_str, version_normalized) = if let Some(pvs) = cache.get(&pkg.0) {
+ if let Some(vd) = pvs.versions.get(&version) {
+ (vd.version_string.clone(), vd.version_normalized.clone())
+ } else {
+ (version.to_string(), version.to_string())
+ }
+ } else {
+ (version.to_string(), version.to_string())
+ };
+
+ result.push(ResolvedPackage {
+ name: pkg.0.clone(),
+ version: version_str,
+ version_normalized,
+ is_dev: version.stability < STABILITY_ALPHA_BASE,
+ });
+ }
+ Ok(result)
+ }
+ Err(PubGrubError::NoSolution(mut derivation_tree)) => {
+ derivation_tree.collapse_no_versions();
+ let report = DefaultStringReporter::report(&derivation_tree);
+ Err(ResolveError::NoSolution(report))
+ }
+ Err(PubGrubError::ErrorRetrievingDependencies {
+ package,
+ version,
+ source,
+ }) => Err(ResolveError::DependencyFetchError(format!(
+ "Error retrieving dependencies for {} {}: {}",
+ package, version, source
+ ))),
+ Err(PubGrubError::ErrorChoosingVersion { package, source }) => {
+ Err(ResolveError::DependencyFetchError(format!(
+ "Error choosing version for {}: {}",
+ package, source
+ )))
+ }
+ Err(PubGrubError::ErrorInShouldCancel(e)) => {
+ Err(ResolveError::Internal(format!("Resolver cancelled: {}", e)))
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Tests
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pubgrub::{OfflineDependencyProvider, Ranges};
+
+ // ──────────── ComposerVersion parsing ────────────
+
+ #[test]
+ fn test_composer_version_parse_stable() {
+ let v = ComposerVersion::from_normalized("1.2.3.0").unwrap();
+ assert_eq!(v.major, 1);
+ assert_eq!(v.minor, 2);
+ assert_eq!(v.patch, 3);
+ assert_eq!(v.build, 0);
+ assert_eq!(v.stability, STABILITY_STABLE);
+ }
+
+ #[test]
+ fn test_composer_version_parse_beta() {
+ let v = ComposerVersion::from_normalized("1.0.0.0-beta1").unwrap();
+ assert_eq!(v.major, 1);
+ assert_eq!(v.minor, 0);
+ assert_eq!(v.patch, 0);
+ assert_eq!(v.build, 0);
+ assert_eq!(v.stability, STABILITY_BETA_BASE + 1);
+ }
+
+ #[test]
+ fn test_composer_version_parse_rc() {
+ let v = ComposerVersion::from_normalized("2.0.0.0-RC3").unwrap();
+ assert_eq!(v.major, 2);
+ assert_eq!(v.stability, STABILITY_RC_BASE + 3);
+ }
+
+ #[test]
+ fn test_composer_version_parse_alpha() {
+ let v = ComposerVersion::from_normalized("1.0.0.0-alpha2").unwrap();
+ assert_eq!(v.stability, STABILITY_ALPHA_BASE + 2);
+ }
+
+ #[test]
+ fn test_composer_version_parse_dev() {
+ let v = ComposerVersion::from_normalized("1.0.0.0-dev").unwrap();
+ assert_eq!(v.stability, STABILITY_DEV);
+ }
+
+ #[test]
+ fn test_composer_version_parse_dev_branch() {
+ let v = ComposerVersion::from_normalized("dev-master");
+ assert!(
+ v.is_none(),
+ "dev-master should not parse as ComposerVersion"
+ );
+ }
+
+ #[test]
+ fn test_composer_version_parse_x_dev() {
+ let v = ComposerVersion::from_normalized("dev-feature/foo");
+ assert!(v.is_none());
+ }
+
+ #[test]
+ fn test_composer_version_parse_9999999_dev() {
+ // Packagist sometimes uses 9999999.9999999.9999999.9999999 for dev
+ let v = ComposerVersion::from_normalized("9999999.9999999.9999999.9999999-dev");
+ assert!(v.is_none());
+ }
+
+ #[test]
+ fn test_composer_version_ordering_stable() {
+ let v1 = ComposerVersion::from_normalized("2.0.0.0").unwrap();
+ let v2 = ComposerVersion::from_normalized("1.0.0.0").unwrap();
+ assert!(v1 > v2);
+ }
+
+ #[test]
+ fn test_composer_version_ordering_stability() {
+ let stable = ComposerVersion::from_normalized("1.0.0.0").unwrap();
+ let rc = ComposerVersion::from_normalized("1.0.0.0-RC1").unwrap();
+ let beta = ComposerVersion::from_normalized("1.0.0.0-beta1").unwrap();
+ let alpha = ComposerVersion::from_normalized("1.0.0.0-alpha1").unwrap();
+ let dev = ComposerVersion::from_normalized("1.0.0.0-dev").unwrap();
+ assert!(stable > rc);
+ assert!(rc > beta);
+ assert!(beta > alpha);
+ assert!(alpha > dev);
+ }
+
+ #[test]
+ fn test_composer_version_ordering_pre_number() {
+ let beta2 = ComposerVersion::from_normalized("1.0.0.0-beta2").unwrap();
+ let beta1 = ComposerVersion::from_normalized("1.0.0.0-beta1").unwrap();
+ assert!(beta2 > beta1);
+ }
+
+ #[test]
+ fn test_composer_version_display() {
+ let stable = ComposerVersion::stable(1, 2, 3, 0);
+ assert_eq!(format!("{stable}"), "1.2.3.0");
+
+ let beta1 = ComposerVersion {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_BETA_BASE + 1,
+ };
+ assert_eq!(format!("{beta1}"), "1.0.0.0-beta1");
+
+ let rc2 = ComposerVersion {
+ major: 2,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_RC_BASE + 2,
+ };
+ assert_eq!(format!("{rc2}"), "2.0.0.0-RC2");
+
+ let dev = ComposerVersion {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_DEV,
+ };
+ assert_eq!(format!("{dev}"), "1.0.0.0-dev");
+ }
+
+ #[test]
+ fn test_composer_version_stability_enum() {
+ let stable = ComposerVersion::stable(1, 0, 0, 0);
+ assert_eq!(stable.stability_enum(), Stability::Stable);
+
+ let rc = ComposerVersion {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_RC_BASE,
+ };
+ assert_eq!(rc.stability_enum(), Stability::RC);
+
+ let beta = ComposerVersion {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_BETA_BASE,
+ };
+ assert_eq!(beta.stability_enum(), Stability::Beta);
+
+ let alpha = ComposerVersion {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_ALPHA_BASE,
+ };
+ assert_eq!(alpha.stability_enum(), Stability::Alpha);
+
+ let dev = ComposerVersion {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_DEV,
+ };
+ assert_eq!(dev.stability_enum(), Stability::Dev);
+ }
+
+ // ──────────── Constraint conversion ────────────
+
+ fn cv(major: u16, minor: u16, patch: u16, build: u16) -> ComposerVersion {
+ ComposerVersion::stable(major, minor, patch, build)
+ }
+
+ fn cv_dev(major: u16, minor: u16, patch: u16, build: u16) -> ComposerVersion {
+ ComposerVersion {
+ major,
+ minor,
+ patch,
+ build,
+ stability: STABILITY_DEV,
+ }
+ }
+
+ #[test]
+ fn test_constraint_any() {
+ let range = constraint_to_ranges("*").unwrap();
+ assert!(range.contains(&cv(1, 2, 3, 0)));
+ assert!(range.contains(&cv(0, 0, 0, 0)));
+ }
+
+ #[test]
+ fn test_constraint_exact() {
+ let range = constraint_to_ranges("1.2.3").unwrap();
+ // Exact "1.2.3" is parsed as Version { 1, 2, 3, 0, pre_release: None } → stable
+ assert!(range.contains(&cv(1, 2, 3, 0)));
+ assert!(!range.contains(&cv(1, 2, 4, 0)));
+ assert!(!range.contains(&cv(1, 2, 2, 0)));
+ }
+
+ #[test]
+ fn test_constraint_gte() {
+ let range = constraint_to_ranges(">=1.0").unwrap();
+ // >=1.0 parses "1.0" as a stable version (no dev_boundary), so >= 1.0.0.0 (stable)
+ assert!(range.contains(&cv(1, 0, 0, 0)));
+ assert!(range.contains(&cv(2, 0, 0, 0)));
+ // 0.9.0.0 should not be in range
+ assert!(!range.contains(&cv(0, 9, 0, 0)));
+ // 1.0.0.0-dev (stability=0) is LESS than 1.0.0.0 (stability=4000), so NOT in >=1.0
+ assert!(!range.contains(&cv_dev(1, 0, 0, 0)));
+ }
+
+ #[test]
+ fn test_constraint_lt() {
+ let range = constraint_to_ranges("<2.0").unwrap();
+ // <2.0 parses "2.0" as a stable version, so strictly < 2.0.0.0 (stable)
+ // 2.0.0.0-dev (stability=0) is LESS than 2.0.0.0 (stability=4000), so IS in <2.0
+ assert!(range.contains(&cv(1, 9, 9, 0)));
+ assert!(range.contains(&cv_dev(2, 0, 0, 0))); // 2.0.0.0-dev < 2.0.0.0 (stable)
+ // 2.0.0.0 (stable) and higher should not be in range
+ assert!(!range.contains(&cv(2, 0, 0, 0)));
+ }
+
+ #[test]
+ fn test_constraint_caret() {
+ // ^1.2 → >=1.2.0.0-dev <2.0.0.0-dev
+ let range = constraint_to_ranges("^1.2").unwrap();
+ assert!(range.contains(&cv_dev(1, 2, 0, 0)));
+ assert!(range.contains(&cv(1, 2, 0, 0)));
+ assert!(range.contains(&cv(1, 9, 9, 0)));
+ assert!(!range.contains(&cv_dev(2, 0, 0, 0)));
+ assert!(!range.contains(&cv(2, 0, 0, 0)));
+ // Below 1.2.0.0-dev should not match
+ assert!(!range.contains(&cv(1, 1, 9, 0)));
+ }
+
+ #[test]
+ fn test_constraint_caret_zero() {
+ // ^0.2.3 → >=0.2.3.0-dev <0.3.0.0-dev
+ let range = constraint_to_ranges("^0.2.3").unwrap();
+ assert!(range.contains(&cv(0, 2, 3, 0)));
+ assert!(range.contains(&cv(0, 2, 9, 0)));
+ assert!(!range.contains(&cv_dev(0, 3, 0, 0)));
+ assert!(!range.contains(&cv(1, 0, 0, 0)));
+ }
+
+ #[test]
+ fn test_constraint_tilde() {
+ // ~1.2.3 → >=1.2.3.0-dev <1.3.0.0-dev
+ let range = constraint_to_ranges("~1.2.3").unwrap();
+ assert!(range.contains(&cv(1, 2, 3, 0)));
+ assert!(range.contains(&cv(1, 2, 9, 0)));
+ assert!(!range.contains(&cv_dev(1, 3, 0, 0)));
+ }
+
+ #[test]
+ fn test_constraint_wildcard() {
+ // 1.2.* → >=1.2.0.0-dev <1.3.0.0-dev
+ let range = constraint_to_ranges("1.2.*").unwrap();
+ assert!(range.contains(&cv(1, 2, 0, 0)));
+ assert!(range.contains(&cv(1, 2, 9, 0)));
+ assert!(!range.contains(&cv_dev(1, 3, 0, 0)));
+ assert!(!range.contains(&cv(1, 3, 0, 0)));
+ }
+
+ #[test]
+ fn test_constraint_or() {
+ // ^1.0 || ^2.0
+ let range = constraint_to_ranges("^1.0 || ^2.0").unwrap();
+ assert!(range.contains(&cv(1, 5, 0, 0)));
+ assert!(range.contains(&cv(2, 3, 0, 0)));
+ assert!(!range.contains(&cv(3, 0, 0, 0)));
+ }
+
+ #[test]
+ fn test_constraint_and() {
+ // >=1.0 <2.0: >=1.0 means >= 1.0.0.0 (stable); <2.0 means < 2.0.0.0 (stable)
+ let range = constraint_to_ranges(">=1.0 <2.0").unwrap();
+ // 1.0.0.0-dev < 1.0.0.0 (stable), so NOT in >=1.0
+ assert!(!range.contains(&cv_dev(1, 0, 0, 0)));
+ assert!(range.contains(&cv(1, 0, 0, 0)));
+ assert!(range.contains(&cv(1, 9, 9, 0)));
+ // 2.0.0.0-dev < 2.0.0.0 (stable), so IS in <2.0 but overall intersection with >=1.0 is yes
+ assert!(range.contains(&cv_dev(2, 0, 0, 0)));
+ assert!(!range.contains(&cv(2, 0, 0, 0)));
+ }
+
+ #[test]
+ fn test_constraint_not_equal() {
+ let range = constraint_to_ranges("!=1.5.0").unwrap();
+ assert!(range.contains(&cv(1, 4, 0, 0)));
+ assert!(!range.contains(&cv(1, 5, 0, 0)));
+ assert!(range.contains(&cv(1, 6, 0, 0)));
+ }
+
+ #[test]
+ fn test_constraint_hyphen() {
+ // "1.0 - 2.0" → >=1.0.0.0 <=2.0.0.0
+ let range = constraint_to_ranges("1.0 - 2.0").unwrap();
+ assert!(range.contains(&cv(1, 0, 0, 0)));
+ assert!(range.contains(&cv(1, 5, 0, 0)));
+ assert!(range.contains(&cv(2, 0, 0, 0)));
+ assert!(!range.contains(&cv(2, 1, 0, 0)));
+ }
+
+ // ──────────── Provider tests (offline) ────────────
+
+ #[test]
+ fn test_package_name_is_platform() {
+ assert!(PackageName("php".to_string()).is_platform());
+ assert!(PackageName("ext-json".to_string()).is_platform());
+ assert!(PackageName("lib-curl".to_string()).is_platform());
+ assert!(!PackageName("monolog/monolog".to_string()).is_platform());
+ assert!(!PackageName("vendor/package".to_string()).is_platform());
+ }
+
+ #[test]
+ fn test_package_name_is_root() {
+ assert!(PackageName::root().is_root());
+ assert!(!PackageName("monolog/monolog".to_string()).is_root());
+ }
+
+ #[test]
+ fn test_platform_config_to_versions() {
+ let config = PlatformConfig::new();
+ let versions = config.to_versions();
+ assert!(versions.contains_key("php"));
+ assert!(versions.contains_key("ext-json"));
+ let php_v = versions["php"];
+ assert_eq!(php_v.major, 8);
+ assert_eq!(php_v.minor, 1);
+ }
+
+ // ──────────── Integration tests (offline, using OfflineDependencyProvider) ────────────
+
+ type TestVS = Ranges<ComposerVersion>;
+
+ fn cv_stable(major: u16, minor: u16, patch: u16) -> ComposerVersion {
+ ComposerVersion::stable(major, minor, patch, 0)
+ }
+
+ /// Test simple resolution: root → foo ^1.0, foo 1.0 → bar ^2.0, bar 2.0 → (nothing)
+ #[test]
+ fn test_resolve_simple_offline() {
+ let mut provider = OfflineDependencyProvider::<PackageName, TestVS>::new();
+
+ let root = PackageName::root();
+ let root_v = ComposerVersion::stable(0, 0, 0, 0);
+ let foo = PackageName("foo/foo".to_string());
+ let bar = PackageName("bar/bar".to_string());
+
+ let foo_1_0 = cv_stable(1, 0, 0);
+ let bar_2_0 = cv_stable(2, 0, 0);
+
+ // root depends on foo ^1.0
+ let foo_range = constraint_to_ranges("^1.0").unwrap();
+ provider.add_dependencies(root.clone(), root_v, [(foo.clone(), foo_range)]);
+
+ // foo 1.0 depends on bar ^2.0
+ let bar_range = constraint_to_ranges("^2.0").unwrap();
+ provider.add_dependencies(foo.clone(), foo_1_0, [(bar.clone(), bar_range)]);
+
+ // bar 2.0 has no dependencies
+ provider.add_dependencies(bar.clone(), bar_2_0, []);
+
+ let solution = pubgrub::resolve(&provider, root.clone(), root_v).unwrap();
+
+ assert_eq!(*solution.get(&foo).unwrap(), foo_1_0);
+ assert_eq!(*solution.get(&bar).unwrap(), bar_2_0);
+ }
+
+ /// Test conflict detection: two packages require incompatible versions of a third.
+ #[test]
+ fn test_resolve_no_solution_offline() {
+ let mut provider = OfflineDependencyProvider::<PackageName, TestVS>::new();
+
+ let root = PackageName::root();
+ let root_v = ComposerVersion::stable(0, 0, 0, 0);
+ let foo = PackageName("foo/foo".to_string());
+ let bar = PackageName("bar/bar".to_string());
+ let dep = PackageName("dep/dep".to_string());
+
+ let foo_1_0 = cv_stable(1, 0, 0);
+ let bar_1_0 = cv_stable(1, 0, 0);
+ let dep_1_0 = cv_stable(1, 0, 0);
+ let dep_2_0 = cv_stable(2, 0, 0);
+
+ // root depends on foo and bar
+ let foo_range = Ranges::singleton(foo_1_0);
+ let bar_range = Ranges::singleton(bar_1_0);
+ provider.add_dependencies(
+ root.clone(),
+ root_v,
+ [(foo.clone(), foo_range), (bar.clone(), bar_range)],
+ );
+
+ // foo 1.0 requires dep ^1.0 (excludes 2.x)
+ let dep_range_1 = constraint_to_ranges("^1.0").unwrap();
+ provider.add_dependencies(foo.clone(), foo_1_0, [(dep.clone(), dep_range_1)]);
+
+ // bar 1.0 requires dep ^2.0 (excludes 1.x)
+ let dep_range_2 = constraint_to_ranges("^2.0").unwrap();
+ provider.add_dependencies(bar.clone(), bar_1_0, [(dep.clone(), dep_range_2)]);
+
+ // dep has versions 1.0 and 2.0
+ provider.add_dependencies(dep.clone(), dep_1_0, []);
+ provider.add_dependencies(dep.clone(), dep_2_0, []);
+
+ let result = pubgrub::resolve(&provider, root.clone(), root_v);
+ assert!(result.is_err(), "Expected no solution for conflicting deps");
+ }
+
+ /// Test prefer-stable ordering: with prefer-stable, should pick stable over beta.
+ #[test]
+ fn test_prefer_stable() {
+ let stable = ComposerVersion::stable(1, 0, 0, 0);
+ let beta = ComposerVersion {
+ major: 1,
+ minor: 1,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_BETA_BASE + 1,
+ };
+
+ // stable should have higher stability numeric value than beta
+ assert!(
+ stable.stability > beta.stability,
+ "stable should be > beta numerically"
+ );
+ // But stable is 1.0.0.0 and beta is 1.1.0.0-beta1; when prefer-stable is on,
+ // we first look for stable version and pick the highest stable
+ assert!(stable.stability >= STABILITY_STABLE);
+ assert!(beta.stability < STABILITY_STABLE);
+ }
+
+ /// Test stability filter: alpha versions should be excluded when minimum_stability = stable.
+ #[test]
+ fn test_stability_filter() {
+ let provider = MozartProvider {
+ package_cache: RefCell::new(HashMap::new()),
+ platform_packages: HashMap::new(),
+ minimum_stability: Stability::Stable,
+ stability_flags: HashMap::new(),
+ prefer_stable: false,
+ prefer_lowest: false,
+ root_dependencies: vec![],
+ root_conflicts: vec![],
+ ignore_platform_reqs: false,
+ ignore_platform_req_list: vec![],
+ repo_cache: None,
+ };
+
+ let stable_v = ComposerVersion::stable(1, 0, 0, 0);
+ let alpha_v = ComposerVersion {
+ major: 1,
+ minor: 1,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_ALPHA_BASE,
+ };
+ let beta_v = ComposerVersion {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_BETA_BASE,
+ };
+ let rc_v = ComposerVersion {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_RC_BASE,
+ };
+ let dev_v = ComposerVersion {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_DEV,
+ };
+
+ assert!(provider.passes_stability_filter("foo/foo", &stable_v));
+ assert!(!provider.passes_stability_filter("foo/foo", &alpha_v));
+ assert!(!provider.passes_stability_filter("foo/foo", &beta_v));
+ assert!(!provider.passes_stability_filter("foo/foo", &rc_v));
+ assert!(!provider.passes_stability_filter("foo/foo", &dev_v));
+ }
+
+ #[test]
+ fn test_stability_filter_beta() {
+ let provider = MozartProvider {
+ package_cache: RefCell::new(HashMap::new()),
+ platform_packages: HashMap::new(),
+ minimum_stability: Stability::Beta,
+ stability_flags: HashMap::new(),
+ prefer_stable: false,
+ prefer_lowest: false,
+ root_dependencies: vec![],
+ root_conflicts: vec![],
+ ignore_platform_reqs: false,
+ ignore_platform_req_list: vec![],
+ repo_cache: None,
+ };
+
+ let stable_v = ComposerVersion::stable(1, 0, 0, 0);
+ let beta_v = ComposerVersion {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_BETA_BASE,
+ };
+ let alpha_v = ComposerVersion {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_ALPHA_BASE,
+ };
+ let dev_v = ComposerVersion {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_DEV,
+ };
+
+ assert!(provider.passes_stability_filter("foo/foo", &stable_v));
+ assert!(provider.passes_stability_filter("foo/foo", &beta_v));
+ assert!(!provider.passes_stability_filter("foo/foo", &alpha_v));
+ assert!(!provider.passes_stability_filter("foo/foo", &dev_v));
+ }
+
+ #[test]
+ fn test_stability_filter_dev() {
+ let provider = MozartProvider {
+ package_cache: RefCell::new(HashMap::new()),
+ platform_packages: HashMap::new(),
+ minimum_stability: Stability::Dev,
+ stability_flags: HashMap::new(),
+ prefer_stable: false,
+ prefer_lowest: false,
+ root_dependencies: vec![],
+ root_conflicts: vec![],
+ ignore_platform_reqs: false,
+ ignore_platform_req_list: vec![],
+ repo_cache: None,
+ };
+
+ let dev_v = ComposerVersion {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_DEV,
+ };
+ assert!(provider.passes_stability_filter("foo/foo", &dev_v));
+ }
+
+ #[test]
+ fn test_skip_platform_dep() {
+ let provider = MozartProvider {
+ package_cache: RefCell::new(HashMap::new()),
+ platform_packages: HashMap::new(),
+ minimum_stability: Stability::Stable,
+ stability_flags: HashMap::new(),
+ prefer_stable: false,
+ prefer_lowest: false,
+ root_dependencies: vec![],
+ root_conflicts: vec![],
+ ignore_platform_reqs: true,
+ ignore_platform_req_list: vec![],
+ repo_cache: None,
+ };
+
+ assert!(provider.should_skip_platform_dep("php"));
+ assert!(provider.should_skip_platform_dep("ext-json"));
+ assert!(!provider.should_skip_platform_dep("monolog/monolog"));
+ }
+
+ #[test]
+ fn test_skip_specific_platform_dep() {
+ let provider = MozartProvider {
+ package_cache: RefCell::new(HashMap::new()),
+ platform_packages: HashMap::new(),
+ minimum_stability: Stability::Stable,
+ stability_flags: HashMap::new(),
+ prefer_stable: false,
+ prefer_lowest: false,
+ root_dependencies: vec![],
+ 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"));
+ assert!(!provider.should_skip_platform_dep("ext-json"));
+ assert!(!provider.should_skip_platform_dep("php"));
+ assert!(!provider.should_skip_platform_dep("monolog/monolog"));
+ }
+
+ #[test]
+ fn test_root_package_choose_version() {
+ let provider = MozartProvider {
+ package_cache: RefCell::new(HashMap::new()),
+ platform_packages: HashMap::new(),
+ minimum_stability: Stability::Stable,
+ stability_flags: HashMap::new(),
+ prefer_stable: false,
+ prefer_lowest: false,
+ root_dependencies: vec![],
+ root_conflicts: vec![],
+ ignore_platform_reqs: false,
+ ignore_platform_req_list: vec![],
+ repo_cache: None,
+ };
+
+ let root = PackageName::root();
+ let root_v = ComposerVersion::stable(0, 0, 0, 0);
+ let full_range: ComposerVS = Ranges::full();
+ let result = provider.choose_version(&root, &full_range).unwrap();
+ assert_eq!(result, Some(root_v));
+ }
+
+ #[test]
+ fn test_platform_choose_version() {
+ let mut platform = HashMap::new();
+ let php_v = ComposerVersion::from_normalized("8.1.0.0").unwrap();
+ platform.insert("php".to_string(), php_v);
+
+ let provider = MozartProvider {
+ package_cache: RefCell::new(HashMap::new()),
+ platform_packages: platform,
+ minimum_stability: Stability::Stable,
+ stability_flags: HashMap::new(),
+ prefer_stable: false,
+ prefer_lowest: false,
+ root_dependencies: vec![],
+ root_conflicts: vec![],
+ ignore_platform_reqs: false,
+ ignore_platform_req_list: vec![],
+ repo_cache: None,
+ };
+
+ let php = PackageName("php".to_string());
+ let range = constraint_to_ranges(">=8.0").unwrap();
+ let result = provider.choose_version(&php, &range).unwrap();
+ assert_eq!(result, Some(php_v));
+
+ // Range that excludes 8.1
+ let too_new_range = constraint_to_ranges(">=9.0").unwrap();
+ let result2 = provider.choose_version(&php, &too_new_range).unwrap();
+ assert_eq!(result2, None);
+ }
+
+ /// Test constraint_to_ranges produces correct range with version containment checks.
+ #[test]
+ fn test_constraint_contains_version() {
+ // ^3.0 should contain 3.5.1.0 but not 4.0.0.0
+ let range = constraint_to_ranges("^3.0").unwrap();
+ assert!(range.contains(&cv_stable(3, 5, 1)));
+ assert!(!range.contains(&cv_stable(4, 0, 0)));
+ assert!(!range.contains(&cv_stable(2, 9, 9)));
+ }
+
+ // ──────────── Integration test with MozartProvider (no network) ────────────
+
+ /// Test resolve() with root dependencies using offline provider
+ #[test]
+ fn test_resolve_with_offline_provider_simple() {
+ let mut provider = OfflineDependencyProvider::<PackageName, TestVS>::new();
+
+ let root = PackageName::root();
+ let root_v = ComposerVersion::stable(0, 0, 0, 0);
+ let foo = PackageName("foo/foo".to_string());
+
+ let foo_1_0 = cv_stable(1, 0, 0);
+ let foo_1_1 = cv_stable(1, 1, 0);
+
+ let foo_range = constraint_to_ranges("^1.0").unwrap();
+ provider.add_dependencies(root.clone(), root_v, [(foo.clone(), foo_range)]);
+ provider.add_dependencies(foo.clone(), foo_1_0, []);
+ provider.add_dependencies(foo.clone(), foo_1_1, []);
+
+ let solution = pubgrub::resolve(&provider, root.clone(), root_v).unwrap();
+
+ // Should pick highest version: 1.1.0
+ assert_eq!(*solution.get(&foo).unwrap(), foo_1_1);
+ }
+
+ #[test]
+ fn test_resolve_or_constraint() {
+ let mut provider = OfflineDependencyProvider::<PackageName, TestVS>::new();
+
+ let root = PackageName::root();
+ let root_v = ComposerVersion::stable(0, 0, 0, 0);
+ let foo = PackageName("foo/foo".to_string());
+
+ // foo has versions 1.5.0 and 2.3.0
+ let foo_1_5 = cv_stable(1, 5, 0);
+ let foo_2_3 = cv_stable(2, 3, 0);
+
+ // root requires "^1.0 || ^2.0"
+ let foo_range = constraint_to_ranges("^1.0 || ^2.0").unwrap();
+ provider.add_dependencies(root.clone(), root_v, [(foo.clone(), foo_range)]);
+ provider.add_dependencies(foo.clone(), foo_1_5, []);
+ provider.add_dependencies(foo.clone(), foo_2_3, []);
+
+ let solution = pubgrub::resolve(&provider, root.clone(), root_v).unwrap();
+
+ // Should pick the highest matching version: 2.3.0
+ let picked = *solution.get(&foo).unwrap();
+ assert!(
+ picked == foo_1_5 || picked == foo_2_3,
+ "picked version should be one of the available versions"
+ );
+ }
+
+ // ──────────── Branch alias tests ────────────
+
+ #[test]
+ fn test_from_branch_alias_target_x_dev() {
+ let cv = ComposerVersion::from_branch_alias_target("2.x-dev").unwrap();
+ assert_eq!(cv.major, 2);
+ assert_eq!(cv.minor, 0);
+ assert_eq!(cv.patch, 0);
+ assert_eq!(cv.build, 0);
+ assert_eq!(cv.stability, STABILITY_DEV);
+ }
+
+ #[test]
+ fn test_from_branch_alias_target_minor_x_dev() {
+ let cv = ComposerVersion::from_branch_alias_target("1.5.x-dev").unwrap();
+ assert_eq!(cv.major, 1);
+ assert_eq!(cv.minor, 5);
+ assert_eq!(cv.patch, 0);
+ assert_eq!(cv.stability, STABILITY_DEV);
+ }
+
+ #[test]
+ fn test_from_branch_alias_target_patch_x_dev() {
+ let cv = ComposerVersion::from_branch_alias_target("1.0.2.x-dev").unwrap();
+ assert_eq!(cv.major, 1);
+ assert_eq!(cv.minor, 0);
+ assert_eq!(cv.patch, 2);
+ assert_eq!(cv.stability, STABILITY_DEV);
+ }
+
+ #[test]
+ fn test_from_branch_alias_target_invalid() {
+ // Must end with -dev
+ assert!(ComposerVersion::from_branch_alias_target("dev-master").is_none());
+ assert!(ComposerVersion::from_branch_alias_target("2.0.0").is_none());
+ assert!(ComposerVersion::from_branch_alias_target("").is_none());
+ }
+
+ /// Test that a branch alias entry created from "dev-master" aliased to "2.x-dev"
+ /// is contained in the ^2.0 constraint range.
+ #[test]
+ fn test_branch_alias_in_range() {
+ // "2.x-dev" alias target → ComposerVersion { major: 2, stability: STABILITY_DEV }
+ let aliased_cv = ComposerVersion::from_branch_alias_target("2.x-dev").unwrap();
+ // ^2.0 → >=2.0.0.0-dev <3.0.0.0-dev
+ let range = constraint_to_ranges("^2.0").unwrap();
+ assert!(
+ range.contains(&aliased_cv),
+ "dev-master aliased to 2.x-dev should satisfy ^2.0"
+ );
+ }
+
+ /// Test that a branch alias entry for "1.0.x-dev" satisfies a ^1.0 constraint.
+ #[test]
+ fn test_branch_alias_1_x_in_range() {
+ let aliased_cv = ComposerVersion::from_branch_alias_target("1.0.x-dev").unwrap();
+ let range = constraint_to_ranges("^1.0").unwrap();
+ assert!(
+ range.contains(&aliased_cv),
+ "dev branch aliased to 1.0.x-dev should satisfy ^1.0"
+ );
+ // But should NOT satisfy ^2.0
+ let range2 = constraint_to_ranges("^2.0").unwrap();
+ assert!(
+ !range2.contains(&aliased_cv),
+ "1.0.x-dev alias should not satisfy ^2.0"
+ );
+ }
+
+ // ──────────── End-to-end tests (require network, marked #[ignore]) ────────────
+
+ #[test]
+ #[ignore]
+ fn test_resolve_monolog_e2e() {
+ let request = ResolveRequest {
+ require: vec![("monolog/monolog".to_string(), "^3.0".to_string())],
+ require_dev: vec![],
+ include_dev: false,
+ minimum_stability: Stability::Stable,
+ stability_flags: HashMap::new(),
+ prefer_stable: true,
+ prefer_lowest: false,
+ platform: PlatformConfig::new(),
+ ignore_platform_reqs: false,
+ ignore_platform_req_list: vec![],
+ repo_cache: None,
+ };
+
+ let result = resolve(&request);
+ match result {
+ Ok(packages) => {
+ println!("Resolved {} packages:", packages.len());
+ for pkg in &packages {
+ println!(" {} {}", pkg.name, pkg.version);
+ }
+ assert!(!packages.is_empty());
+ assert!(packages.iter().any(|p| p.name == "monolog/monolog"));
+ }
+ Err(e) => panic!("Resolution failed: {}", e),
+ }
+ }
+}
diff --git a/crates/mozart-registry/src/version.rs b/crates/mozart-registry/src/version.rs
new file mode 100644
index 0000000..1e0f99a
--- /dev/null
+++ b/crates/mozart-registry/src/version.rs
@@ -0,0 +1,267 @@
+use crate::packagist::PackagistVersion;
+use mozart_core::package::Stability;
+use std::cmp::Ordering;
+
+/// Determine the stability of a normalized version string.
+pub fn stability_of(version_normalized: &str) -> Stability {
+ let v = version_normalized.to_lowercase();
+ if v.starts_with("dev-") || v.ends_with("-dev") {
+ return Stability::Dev;
+ }
+ // Check for pre-release suffixes: alpha, beta, RC
+ // Normalized versions use formats like "1.0.0.0-alpha1", "1.0.0.0-beta2", "1.0.0.0-RC1"
+ if let Some(pos) = v.rfind('-') {
+ let suffix = &v[pos + 1..];
+ if suffix.starts_with("alpha") {
+ return Stability::Alpha;
+ }
+ if suffix.starts_with("beta") {
+ return Stability::Beta;
+ }
+ if suffix.starts_with("rc") || suffix.starts_with("RC") {
+ return Stability::RC;
+ }
+ }
+ Stability::Stable
+}
+
+/// Compare two normalized version strings (e.g. "1.2.3.0" vs "1.2.4.0").
+///
+/// Each version is split into numeric parts. Non-numeric suffixes (like "-beta1")
+/// are handled by treating the base parts as numeric and the suffix separately.
+pub fn compare_normalized_versions(a: &str, b: &str) -> Ordering {
+ let parse = |v: &str| -> (Vec<u64>, Option<String>) {
+ // Split off any pre-release suffix
+ let (base, suffix) = if let Some(pos) = v.find('-') {
+ (&v[..pos], Some(v[pos + 1..].to_string()))
+ } else {
+ (v, None)
+ };
+ let parts: Vec<u64> = base.split('.').filter_map(|p| p.parse().ok()).collect();
+ (parts, suffix)
+ };
+
+ let (a_parts, a_suffix) = parse(a);
+ let (b_parts, b_suffix) = parse(b);
+
+ // Compare numeric parts
+ let max_len = a_parts.len().max(b_parts.len());
+ for i in 0..max_len {
+ let a_val = a_parts.get(i).copied().unwrap_or(0);
+ let b_val = b_parts.get(i).copied().unwrap_or(0);
+ match a_val.cmp(&b_val) {
+ Ordering::Equal => continue,
+ other => return other,
+ }
+ }
+
+ // If numeric parts are equal, compare stability
+ // A stable version (no suffix) is greater than a pre-release
+ match (&a_suffix, &b_suffix) {
+ (None, None) => Ordering::Equal,
+ (None, Some(_)) => Ordering::Greater, // stable > pre-release
+ (Some(_), None) => Ordering::Less, // pre-release < stable
+ (Some(a_s), Some(b_s)) => {
+ let stab_a = stability_of(&format!("0.0.0.0-{a_s}"));
+ let stab_b = stability_of(&format!("0.0.0.0-{b_s}"));
+ // Lower stability value = more stable = greater version
+ match stab_a.cmp(&stab_b) {
+ Ordering::Equal => a_s.cmp(b_s),
+ // Stability enum: Stable(0) < RC(5) < Beta(10) < Alpha(15) < Dev(20)
+ // But more stable = higher version, so we reverse
+ Ordering::Less => Ordering::Greater,
+ Ordering::Greater => Ordering::Less,
+ }
+ }
+ }
+}
+
+/// Find the best version candidate given a preferred minimum stability.
+///
+/// Returns the highest version whose stability is at least as stable as
+/// the preferred stability (i.e., stability value <= preferred value).
+pub fn find_best_candidate(
+ versions: &[PackagistVersion],
+ preferred_stability: Stability,
+) -> Option<&PackagistVersion> {
+ versions
+ .iter()
+ .filter(|v| stability_of(&v.version_normalized) <= preferred_stability)
+ .max_by(|a, b| compare_normalized_versions(&a.version_normalized, &b.version_normalized))
+}
+
+/// Generate a recommended version constraint string from a concrete version.
+///
+/// Examples:
+/// - `"1.2.1"` (stable) → `"^1.2"`
+/// - `"0.3.5"` (stable) → `"^0.3"`
+/// - `"2.0.0-beta.1"` (beta) → `"^2.0@beta"`
+/// - `"dev-master"` (dev) → `"dev-master"`
+pub fn find_recommended_require_version(
+ version: &str,
+ version_normalized: &str,
+ stability: Stability,
+) -> String {
+ // dev branches are returned as-is
+ if stability == Stability::Dev {
+ return version.to_string();
+ }
+
+ // Extract major.minor from the normalized version (e.g. "1.2.3.0" → "1.2")
+ let base = if let Some(pos) = version_normalized.find('-') {
+ &version_normalized[..pos]
+ } else {
+ version_normalized
+ };
+
+ let parts: Vec<&str> = base.split('.').collect();
+ let major = parts.first().copied().unwrap_or("0");
+ let minor = parts.get(1).copied().unwrap_or("0");
+
+ let constraint = format!("^{major}.{minor}");
+
+ match stability {
+ Stability::Stable => constraint,
+ Stability::RC => format!("{constraint}@RC"),
+ Stability::Beta => format!("{constraint}@beta"),
+ Stability::Alpha => format!("{constraint}@alpha"),
+ Stability::Dev => unreachable!(),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_stability_of() {
+ assert_eq!(stability_of("1.0.0.0"), Stability::Stable);
+ assert_eq!(stability_of("2.3.1.0"), Stability::Stable);
+ assert_eq!(stability_of("1.0.0.0-alpha1"), Stability::Alpha);
+ assert_eq!(stability_of("1.0.0.0-beta2"), Stability::Beta);
+ assert_eq!(stability_of("1.0.0.0-RC1"), Stability::RC);
+ assert_eq!(stability_of("dev-master"), Stability::Dev);
+ assert_eq!(stability_of("dev-feature/foo"), Stability::Dev);
+ assert_eq!(stability_of("1.0.0.0-dev"), Stability::Dev);
+ }
+
+ #[test]
+ fn test_compare_normalized_versions() {
+ assert_eq!(
+ compare_normalized_versions("1.0.0.0", "1.0.0.0"),
+ Ordering::Equal
+ );
+ assert_eq!(
+ compare_normalized_versions("2.0.0.0", "1.0.0.0"),
+ Ordering::Greater
+ );
+ assert_eq!(
+ compare_normalized_versions("1.0.0.0", "2.0.0.0"),
+ Ordering::Less
+ );
+ assert_eq!(
+ compare_normalized_versions("1.2.0.0", "1.1.0.0"),
+ Ordering::Greater
+ );
+ assert_eq!(
+ compare_normalized_versions("1.0.0.0", "1.0.0.0-beta1"),
+ Ordering::Greater
+ );
+ assert_eq!(
+ compare_normalized_versions("1.0.0.0-RC1", "1.0.0.0-beta1"),
+ Ordering::Greater
+ );
+ }
+
+ fn make_pv(version: &str, version_normalized: &str) -> PackagistVersion {
+ PackagistVersion {
+ version: version.to_string(),
+ version_normalized: version_normalized.to_string(),
+ require: Default::default(),
+ replace: Default::default(),
+ provide: Default::default(),
+ conflict: Default::default(),
+ dist: None,
+ source: None,
+ require_dev: Default::default(),
+ suggest: None,
+ package_type: None,
+ autoload: None,
+ autoload_dev: None,
+ license: None,
+ description: None,
+ homepage: None,
+ keywords: None,
+ authors: None,
+ support: None,
+ funding: None,
+ time: None,
+ extra: None,
+ notification_url: None,
+ }
+ }
+
+ #[test]
+ fn test_find_best_candidate_stable() {
+ let versions = vec![
+ make_pv("dev-master", "dev-master"),
+ make_pv("2.0.0-beta.1", "2.0.0.0-beta1"),
+ make_pv("1.5.0", "1.5.0.0"),
+ make_pv("1.4.0", "1.4.0.0"),
+ ];
+
+ let best = find_best_candidate(&versions, Stability::Stable).unwrap();
+ assert_eq!(best.version, "1.5.0");
+ }
+
+ #[test]
+ fn test_find_best_candidate_beta() {
+ let versions = vec![
+ make_pv("dev-master", "dev-master"),
+ make_pv("2.0.0-beta.1", "2.0.0.0-beta1"),
+ make_pv("1.5.0", "1.5.0.0"),
+ ];
+
+ let best = find_best_candidate(&versions, Stability::Beta).unwrap();
+ assert_eq!(best.version, "2.0.0-beta.1");
+ }
+
+ #[test]
+ fn test_find_best_candidate_no_match() {
+ let versions = vec![make_pv("dev-master", "dev-master")];
+
+ let best = find_best_candidate(&versions, Stability::Stable);
+ assert!(best.is_none());
+ }
+
+ #[test]
+ fn test_find_recommended_require_version() {
+ // Stable
+ assert_eq!(
+ find_recommended_require_version("1.2.1", "1.2.1.0", Stability::Stable),
+ "^1.2"
+ );
+ assert_eq!(
+ find_recommended_require_version("0.3.5", "0.3.5.0", Stability::Stable),
+ "^0.3"
+ );
+
+ // Beta
+ assert_eq!(
+ find_recommended_require_version("2.0.0-beta.1", "2.0.0.0-beta1", Stability::Beta),
+ "^2.0@beta"
+ );
+
+ // RC
+ assert_eq!(
+ find_recommended_require_version("3.0.0-RC1", "3.0.0.0-RC1", Stability::RC),
+ "^3.0@RC"
+ );
+
+ // Dev
+ assert_eq!(
+ find_recommended_require_version("dev-master", "dev-master", Stability::Dev),
+ "dev-master"
+ );
+ }
+}