diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-22 00:37:54 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-22 00:37:54 +0900 |
| commit | 0a8e5935e6305819bb02d8c69e2f046ff397913a (patch) | |
| tree | e5a288e679477b1603d7989e986ca22bbe590aa4 /crates/mozart-registry/src | |
| parent | b5af594fec7da72b15c9a202c641af0494db6355 (diff) | |
| download | php-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.rs | 492 | ||||
| -rw-r--r-- | crates/mozart-registry/src/downloader.rs | 506 | ||||
| -rw-r--r-- | crates/mozart-registry/src/installed.rs | 229 | ||||
| -rw-r--r-- | crates/mozart-registry/src/lib.rs | 7 | ||||
| -rw-r--r-- | crates/mozart-registry/src/lockfile.rs | 1088 | ||||
| -rw-r--r-- | crates/mozart-registry/src/packagist.rs | 629 | ||||
| -rw-r--r-- | crates/mozart-registry/src/resolver.rs | 1917 | ||||
| -rw-r--r-- | crates/mozart-registry/src/version.rs | 267 |
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" + ); + } +} |
