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 | |
| 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')
75 files changed, 13862 insertions, 2596 deletions
diff --git a/crates/mozart-archiver/Cargo.toml b/crates/mozart-archiver/Cargo.toml new file mode 100644 index 0000000..6d96024 --- /dev/null +++ b/crates/mozart-archiver/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "mozart-archiver" +version.workspace = true +edition.workspace = true + +[dependencies] +anyhow.workspace = true +bzip2.workspace = true +flate2.workspace = true +regex.workspace = true +sha1.workspace = true +tar.workspace = true +zip.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/mozart-archiver/src/lib.rs b/crates/mozart-archiver/src/lib.rs new file mode 100644 index 0000000..19889ef --- /dev/null +++ b/crates/mozart-archiver/src/lib.rs @@ -0,0 +1,929 @@ +use anyhow::Context as _; +use regex::Regex; +use sha1::{Digest, Sha1}; +use std::fs; +use std::io::Write as IoWrite; +use std::path::{Path, PathBuf}; + +// ─── Exclude filters ───────────────────────────────────────────────────────── + +/// A compiled exclude pattern derived from a gitignore-style rule. +pub struct ExcludePattern { + regex: Regex, + /// If true, matching files are *re-included* (negation rule). + negate: bool, +} + +/// Convert a glob pattern string to a regex string. +/// +/// Mapping: +/// - `**` → `.*` (matches any path segment sequence) +/// - `*` → `[^/]*` (matches within a single path segment) +/// - `?` → `[^/]` (matches a single non-separator char) +/// - `[…]` → `[…]` (character class, passed through) +/// - all other characters are regex-escaped +fn glob_to_regex(glob: &str) -> String { + let mut result = String::new(); + let chars: Vec<char> = glob.chars().collect(); + let mut i = 0; + while i < chars.len() { + match chars[i] { + '*' if i + 1 < chars.len() && chars[i + 1] == '*' => { + result.push_str(".*"); + i += 2; + } + '*' => { + result.push_str("[^/]*"); + i += 1; + } + '?' => { + result.push_str("[^/]"); + i += 1; + } + '[' => { + // Pass character classes through as-is until the closing `]` + result.push('['); + i += 1; + while i < chars.len() && chars[i] != ']' { + result.push(chars[i]); + i += 1; + } + if i < chars.len() { + result.push(']'); + i += 1; + } + } + c => { + // Regex-escape special characters + if r"\.+^$|{}()?".contains(c) { + result.push('\\'); + } + result.push(c); + i += 1; + } + } + } + result +} + +/// Convert a single gitignore-style rule into an `ExcludePattern`. +/// +/// Returns `None` if the rule is empty or a comment. +pub fn parse_gitignore_pattern(rule: &str) -> Option<ExcludePattern> { + let rule = rule.trim(); + if rule.is_empty() || rule.starts_with('#') { + return None; + } + + // Leading `!` negates the pattern + let (negate, rule) = if let Some(rest) = rule.strip_prefix('!') { + (true, rest) + } else { + (false, rule) + }; + + // Strip trailing `/` before globbing + let rule = rule.trim_end_matches('/'); + if rule.is_empty() { + return None; + } + + // Determine anchor prefix: + // - leading `/` → anchored at root: `^/<glob_regex>` + // - no `/` inside pattern → matches anywhere: `/<glob_regex>` + // - `/` somewhere in middle → anchored at root: `^/<glob_regex>` + let (prefix, glob) = if let Some(without_leading_slash) = rule.strip_prefix('/') { + // Root-anchored + ("^/", without_leading_slash) + } else if rule.contains('/') { + // Slash in middle: treat as root-anchored + ("^/", rule) + } else { + // No slash: matches anywhere + ("/", rule) + }; + + let glob_regex = glob_to_regex(glob); + // The final regex: `<prefix><glob_regex>(/|$)` + // This matches the path component exactly (followed by a `/` or end-of-string). + let pattern = format!("{prefix}{glob_regex}(/|$)"); + let regex = Regex::new(&pattern).ok()?; + + Some(ExcludePattern { regex, negate }) +} + +/// Apply a chain of exclude patterns to a relative path (as a `/`-prefixed string). +/// +/// Patterns are applied in order; later patterns override earlier ones. +/// Returns `true` if the file is excluded by the final matching pattern +/// (or by `initially_excluded` if no pattern matches). +fn apply_filters( + path_with_slash: &str, + patterns: &[ExcludePattern], + initially_excluded: bool, +) -> bool { + let mut excluded = initially_excluded; + for pat in patterns { + if pat.regex.is_match(path_with_slash) { + // A negate pattern re-includes; a normal pattern excludes + excluded = !pat.negate; + } + } + excluded +} + +// ─── GitExcludeFilter ───────────────────────────────────────────────────────── + +/// Parse `.gitattributes` from the source directory. +/// +/// Returns exclude patterns for lines containing `export-ignore` or +/// `-export-ignore`. +pub fn parse_gitattributes(source_dir: &Path) -> Vec<ExcludePattern> { + let path = source_dir.join(".gitattributes"); + let content = match fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => return vec![], + }; + + let mut patterns = Vec::new(); + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 2 { + continue; + } + let file_pattern = parts[0]; + // Check each attribute token for export-ignore / -export-ignore + for attr in &parts[1..] { + if *attr == "export-ignore" { + if let Some(p) = parse_gitignore_pattern(file_pattern) { + patterns.push(p); + } + } else if *attr == "-export-ignore" { + // Negation: re-include files that would otherwise be excluded + let negated = format!("!{}", file_pattern); + if let Some(p) = parse_gitignore_pattern(&negated) { + patterns.push(p); + } + } + } + } + patterns +} + +// ─── ComposerExcludeFilter ──────────────────────────────────────────────────── + +/// Convert `composer.json` `archive.exclude` rules into exclude patterns. +pub fn parse_composer_excludes(excludes: &[String]) -> Vec<ExcludePattern> { + excludes + .iter() + .filter_map(|rule| parse_gitignore_pattern(rule)) + .collect() +} + +// ─── VCS directory names ────────────────────────────────────────────────────── + +const VCS_DIRS: &[&str] = &[".git", ".svn", ".hg", "CVS", ".bzr"]; + +// ─── File collection ────────────────────────────────────────────────────────── + +/// Collect all archivable files from the source directory. +/// +/// Returns paths relative to `source_dir`, sorted for deterministic output. +/// Applies `exclude_patterns` to filter files. VCS directories are always +/// skipped. Symlinks pointing outside `source_dir` are excluded. +pub fn collect_archivable_files( + source_dir: &Path, + exclude_patterns: &[ExcludePattern], +) -> anyhow::Result<Vec<PathBuf>> { + let source_dir = source_dir + .canonicalize() + .unwrap_or_else(|_| source_dir.to_path_buf()); + let mut files = Vec::new(); + collect_recursive(&source_dir, &source_dir, exclude_patterns, &mut files)?; + files.sort(); + Ok(files) +} + +fn collect_recursive( + source_dir: &Path, + current_dir: &Path, + exclude_patterns: &[ExcludePattern], + out: &mut Vec<PathBuf>, +) -> anyhow::Result<()> { + let entries = fs::read_dir(current_dir) + .with_context(|| format!("Failed to read directory: {}", current_dir.display()))?; + + let mut items: Vec<_> = entries.filter_map(|e| e.ok()).collect(); + // Sort for determinism + items.sort_by_key(|e| e.file_name()); + + for entry in items { + let path = entry.path(); + let file_name = entry.file_name(); + let name_str = file_name.to_string_lossy(); + + // Skip VCS directories + if VCS_DIRS.contains(&name_str.as_ref()) { + continue; + } + + // Compute the relative path (forward-slash, prefixed with `/` for filter matching) + let relative = path + .strip_prefix(source_dir) + .unwrap_or(&path) + .to_string_lossy() + .replace('\\', "/"); + let path_with_slash = format!("/{}", relative); + + // Check if this entry is excluded + if apply_filters(&path_with_slash, exclude_patterns, false) { + continue; + } + + let metadata = match entry.metadata() { + Ok(m) => m, + Err(_) => continue, + }; + + if metadata.is_symlink() { + // Resolve the symlink; skip if it points outside source_dir + if let Ok(resolved) = fs::canonicalize(&path) { + if !resolved.starts_with(source_dir) { + continue; + } + out.push(PathBuf::from(&relative)); + } + // If canonicalize fails, skip the symlink + } else if metadata.is_dir() { + // Collect children recursively + let mut children = Vec::new(); + collect_recursive(source_dir, &path, exclude_patterns, &mut children)?; + if children.is_empty() { + // Include empty directory + out.push(PathBuf::from(&relative)); + } else { + out.extend(children); + } + } else { + out.push(PathBuf::from(&relative)); + } + } + + Ok(()) +} + +// ─── Archive formats ────────────────────────────────────────────────────────── + +/// Supported archive formats. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ArchiveFormat { + Zip, + Tar, + TarGz, + TarBz2, +} + +impl ArchiveFormat { + /// Parse a format string (case-insensitive). Returns `None` for unsupported formats. + pub fn parse(s: &str) -> Option<Self> { + match s.to_lowercase().as_str() { + "zip" => Some(Self::Zip), + "tar" => Some(Self::Tar), + "tar.gz" | "tgz" => Some(Self::TarGz), + "tar.bz2" => Some(Self::TarBz2), + _ => None, + } + } + + /// File extension for this format. + pub fn extension(&self) -> &str { + match self { + Self::Zip => "zip", + Self::Tar => "tar", + Self::TarGz => "tar.gz", + Self::TarBz2 => "tar.bz2", + } + } +} + +// ─── Archive creation ───────────────────────────────────────────────────────── + +/// Create an archive of the given files. +/// +/// - `source_dir`: the root of the source tree +/// - `files`: relative paths (as returned by `collect_archivable_files`) +/// - `target`: full output path including extension +/// - `format`: the archive format to create +pub fn create_archive( + source_dir: &Path, + files: &[PathBuf], + target: &Path, + format: &ArchiveFormat, +) -> anyhow::Result<()> { + match format { + ArchiveFormat::Zip => create_zip(source_dir, files, target), + ArchiveFormat::Tar => create_tar(source_dir, files, target), + ArchiveFormat::TarGz => create_tar_gz(source_dir, files, target), + ArchiveFormat::TarBz2 => create_tar_bz2(source_dir, files, target), + } +} + +fn create_zip(source_dir: &Path, files: &[PathBuf], target: &Path) -> anyhow::Result<()> { + use zip::write::SimpleFileOptions; + + let file = fs::File::create(target) + .with_context(|| format!("Failed to create archive: {}", target.display()))?; + let mut writer = zip::ZipWriter::new(file); + + for rel in files { + let abs = source_dir.join(rel); + let rel_str = rel.to_string_lossy().replace('\\', "/"); + + if abs.is_dir() { + let opts = SimpleFileOptions::default(); + writer.add_directory(&rel_str, opts)?; + } else { + let metadata = fs::metadata(&abs)?; + + #[cfg(unix)] + let opts = { + use std::os::unix::fs::MetadataExt; + let mode = metadata.mode(); + SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated) + .unix_permissions(mode) + }; + + #[cfg(not(unix))] + let opts = + SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + + let _ = metadata; // suppress unused warning on non-unix + + writer.start_file(&rel_str, opts)?; + let content = fs::read(&abs)?; + writer.write_all(&content)?; + } + } + + writer.finish()?; + Ok(()) +} + +fn create_tar(source_dir: &Path, files: &[PathBuf], target: &Path) -> anyhow::Result<()> { + let file = fs::File::create(target) + .with_context(|| format!("Failed to create archive: {}", target.display()))?; + let mut builder = tar::Builder::new(file); + + for rel in files { + let abs = source_dir.join(rel); + if abs.is_dir() { + builder.append_dir(rel, &abs)?; + } else { + builder.append_path_with_name(&abs, rel)?; + } + } + + builder.finish()?; + Ok(()) +} + +fn create_tar_gz(source_dir: &Path, files: &[PathBuf], target: &Path) -> anyhow::Result<()> { + let file = fs::File::create(target) + .with_context(|| format!("Failed to create archive: {}", target.display()))?; + let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); + let mut builder = tar::Builder::new(encoder); + + for rel in files { + let abs = source_dir.join(rel); + if abs.is_dir() { + builder.append_dir(rel, &abs)?; + } else { + builder.append_path_with_name(&abs, rel)?; + } + } + + builder.into_inner()?.finish()?; + Ok(()) +} + +fn create_tar_bz2(source_dir: &Path, files: &[PathBuf], target: &Path) -> anyhow::Result<()> { + let file = fs::File::create(target) + .with_context(|| format!("Failed to create archive: {}", target.display()))?; + let encoder = bzip2::write::BzEncoder::new(file, bzip2::Compression::default()); + let mut builder = tar::Builder::new(encoder); + + for rel in files { + let abs = source_dir.join(rel); + if abs.is_dir() { + builder.append_dir(rel, &abs)?; + } else { + builder.append_path_with_name(&abs, rel)?; + } + } + + builder.into_inner()?.finish()?; + Ok(()) +} + +// ─── Filename generation ────────────────────────────────────────────────────── + +/// Generate an archive filename (without extension) for a package. +/// +/// Mirrors Composer's `ArchiveManager::getPackageFilenameParts()`. +pub fn generate_archive_filename( + name: &str, + archive_name: Option<&str>, + version: Option<&str>, + dist_reference: Option<&str>, + dist_type: Option<&str>, + source_reference: Option<&str>, +) -> String { + // Base: archive_name if set, otherwise replace non-alphanumeric chars with `-` + let base = if let Some(an) = archive_name { + an.to_string() + } else { + let re = Regex::new(r"[^a-zA-Z0-9_\-]").unwrap(); + re.replace_all(name, "-").to_string() + }; + + let mut parts: Vec<String> = vec![base]; + + // Determine if dist_reference is a 40-char hex (SHA-1 commit hash) + let is_sha_dist_ref = dist_reference + .map(|r| r.len() == 40 && r.chars().all(|c| c.is_ascii_hexdigit())) + .unwrap_or(false); + + if is_sha_dist_ref { + // Append dist_reference and dist_type + if let Some(dr) = dist_reference { + parts.push(dr.to_string()); + } + if let Some(dt) = dist_type { + parts.push(dt.to_string()); + } + } else { + // Append version (if any), then dist_reference (if any) + if let Some(v) = version { + parts.push(v.to_string()); + } + if let Some(dr) = dist_reference { + parts.push(dr.to_string()); + } + } + + // Append first 6 chars of SHA-1 of source_reference (if any) + if let Some(sr) = source_reference { + let mut hasher = Sha1::new(); + hasher.update(sr.as_bytes()); + let hash = format!("{:x}", hasher.finalize()); + parts.push(hash[..6.min(hash.len())].to_string()); + } + + // Replace `/` with `-` in each part, then join + parts + .iter() + .map(|p| p.replace('/', "-")) + .collect::<Vec<_>>() + .join("-") +} + +// ─── Self-exclusion patterns ────────────────────────────────────────────────── + +/// The set of archive extensions we support. +const ARCHIVE_EXTENSIONS: &[&str] = &["zip", "tar", "tar.gz", "tar.bz2"]; + +/// Generate patterns to exclude previous archives of this package from the archive. +/// +/// If `has_extra_parts` is true (version/ref was appended), the pattern is +/// `<base>-*.<ext>`. Otherwise it's `<base>.<ext>`. +pub fn self_exclusion_patterns(base_name: &str, has_extra_parts: bool) -> Vec<String> { + ARCHIVE_EXTENSIONS + .iter() + .map(|ext| { + if has_extra_parts { + format!("/{}-*.{}", base_name, ext) + } else { + format!("/{}.{}", base_name, ext) + } + }) + .collect() +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + // ── glob_to_regex ───────────────────────────────────────────────────────── + // Note: glob_to_regex produces a *fragment* for use inside a larger pattern. + // We test it by embedding it in a full anchored regex. + + fn full_pattern(glob: &str) -> Regex { + // Simulate the unanchored pattern: `/fragment(/|$)` + Regex::new(&format!("/{glob_re}(/|$)", glob_re = glob_to_regex(glob))).unwrap() + } + + #[test] + fn test_glob_to_regex_star() { + let re = full_pattern("*.txt"); + // Unanchored pattern: matches any .txt file at any depth + assert!(re.is_match("/foo.txt")); + // Also matches nested .txt files (unanchored `/` prefix) + assert!(re.is_match("/a/b.txt")); + // Does NOT match non-.txt files + assert!(!re.is_match("/foo.php")); + } + + #[test] + fn test_glob_to_regex_double_star() { + // Double star matches across path separators + let frag = glob_to_regex("**/*.txt"); + let re = Regex::new(&format!("/{frag}(/|$)")).unwrap(); + assert!(re.is_match("/a/b/c.txt")); + } + + #[test] + fn test_glob_to_regex_question() { + let frag = glob_to_regex("?.txt"); + let re = Regex::new(&format!("/{frag}(/|$)")).unwrap(); + assert!(re.is_match("/a.txt")); + assert!(!re.is_match("/ab.txt")); + } + + #[test] + fn test_glob_to_regex_bracket() { + let frag = glob_to_regex("[abc].txt"); + let re = Regex::new(&format!("/{frag}(/|$)")).unwrap(); + assert!(re.is_match("/a.txt")); + assert!(re.is_match("/b.txt")); + assert!(!re.is_match("/d.txt")); + } + + // ── parse_gitignore_pattern ─────────────────────────────────────────────── + + #[test] + fn test_parse_gitignore_simple() { + let pat = parse_gitignore_pattern("docs/").unwrap(); + assert!(!pat.negate); + // "/docs" should match + assert!(pat.regex.is_match("/docs")); + } + + #[test] + fn test_parse_gitignore_negated() { + let pat = parse_gitignore_pattern("!important.txt").unwrap(); + assert!(pat.negate); + } + + #[test] + fn test_parse_gitignore_rooted() { + let pat = parse_gitignore_pattern("/build").unwrap(); + assert!(!pat.negate); + // Should match at root + assert!(pat.regex.is_match("/build")); + // Should NOT match in subdirectory (rooted pattern) + assert!(!pat.regex.is_match("/src/build")); + } + + #[test] + fn test_parse_gitignore_unrooted() { + let pat = parse_gitignore_pattern("*.log").unwrap(); + assert!(!pat.negate); + // Should match anywhere + assert!(pat.regex.is_match("/app.log")); + assert!(pat.regex.is_match("/sub/dir/foo.log")); + } + + // ── parse_gitattributes ─────────────────────────────────────────────────── + + #[test] + fn test_parse_gitattributes_export_ignore() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join(".gitattributes"), "tests/ export-ignore\n").unwrap(); + let patterns = parse_gitattributes(dir.path()); + assert_eq!(patterns.len(), 1); + assert!(!patterns[0].negate); + assert!(patterns[0].regex.is_match("/tests")); + } + + #[test] + fn test_parse_gitattributes_neg_export_ignore() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join(".gitattributes"), "tests/ -export-ignore\n").unwrap(); + let patterns = parse_gitattributes(dir.path()); + assert_eq!(patterns.len(), 1); + assert!(patterns[0].negate); + } + + #[test] + fn test_parse_gitattributes_comment() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join(".gitattributes"), + "# comment\ntests/ export-ignore\n", + ) + .unwrap(); + let patterns = parse_gitattributes(dir.path()); + assert_eq!(patterns.len(), 1); + } + + #[test] + fn test_parse_gitattributes_non_export() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join(".gitattributes"), "*.php text\n").unwrap(); + let patterns = parse_gitattributes(dir.path()); + assert!(patterns.is_empty()); + } + + #[test] + fn test_parse_gitattributes_missing_file() { + let dir = tempdir().unwrap(); + let patterns = parse_gitattributes(dir.path()); + assert!(patterns.is_empty()); + } + + // ── collect_archivable_files ────────────────────────────────────────────── + + #[test] + fn test_collect_files_basic() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("a.php"), b"<?php").unwrap(); + fs::write(dir.path().join("b.php"), b"<?php").unwrap(); + fs::create_dir(dir.path().join("src")).unwrap(); + fs::write(dir.path().join("src").join("c.php"), b"<?php").unwrap(); + + let files = collect_archivable_files(dir.path(), &[]).unwrap(); + let strs: Vec<String> = files + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + assert!(strs.contains(&"a.php".to_string())); + assert!(strs.contains(&"b.php".to_string())); + assert!(strs.contains(&"src/c.php".to_string())); + } + + #[test] + fn test_collect_files_excludes() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("main.php"), b"<?php").unwrap(); + fs::create_dir(dir.path().join("tests")).unwrap(); + fs::write(dir.path().join("tests").join("test.php"), b"<?php").unwrap(); + + let patterns = vec![parse_gitignore_pattern("tests/").unwrap()]; + let files = collect_archivable_files(dir.path(), &patterns).unwrap(); + let strs: Vec<String> = files + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + assert!(strs.contains(&"main.php".to_string())); + assert!(!strs.iter().any(|s| s.starts_with("tests"))); + } + + #[test] + fn test_collect_files_skips_vcs() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("main.php"), b"<?php").unwrap(); + fs::create_dir(dir.path().join(".git")).unwrap(); + fs::write( + dir.path().join(".git").join("HEAD"), + b"ref: refs/heads/main", + ) + .unwrap(); + + let files = collect_archivable_files(dir.path(), &[]).unwrap(); + let strs: Vec<String> = files + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + assert!(strs.contains(&"main.php".to_string())); + assert!(!strs.iter().any(|s| s.starts_with(".git"))); + } + + #[test] + fn test_collect_files_empty_dir() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("main.php"), b"<?php").unwrap(); + fs::create_dir(dir.path().join("empty_dir")).unwrap(); + + let files = collect_archivable_files(dir.path(), &[]).unwrap(); + let strs: Vec<String> = files + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + assert!(strs.contains(&"main.php".to_string())); + assert!(strs.contains(&"empty_dir".to_string())); + } + + // ── create_archive ──────────────────────────────────────────────────────── + + fn make_source_tree(dir: &Path) { + fs::write(dir.join("main.php"), b"<?php echo 'hello';").unwrap(); + fs::create_dir(dir.join("src")).unwrap(); + fs::write(dir.join("src").join("Foo.php"), b"<?php class Foo {}").unwrap(); + } + + #[test] + fn test_create_zip_archive() { + let src = tempdir().unwrap(); + make_source_tree(src.path()); + let out = tempdir().unwrap(); + let target = out.path().join("test.zip"); + + let files = collect_archivable_files(src.path(), &[]).unwrap(); + create_archive(src.path(), &files, &target, &ArchiveFormat::Zip).unwrap(); + assert!(target.exists()); + + // Verify contents + let zip_data = fs::read(&target).unwrap(); + let cursor = std::io::Cursor::new(zip_data); + let mut archive = zip::ZipArchive::new(cursor).unwrap(); + let names: Vec<String> = (0..archive.len()) + .map(|i| archive.by_index(i).unwrap().name().to_string()) + .collect(); + assert!(names.contains(&"main.php".to_string())); + assert!(names.contains(&"src/Foo.php".to_string())); + } + + #[test] + fn test_create_tar_archive() { + let src = tempdir().unwrap(); + make_source_tree(src.path()); + let out = tempdir().unwrap(); + let target = out.path().join("test.tar"); + + let files = collect_archivable_files(src.path(), &[]).unwrap(); + create_archive(src.path(), &files, &target, &ArchiveFormat::Tar).unwrap(); + assert!(target.exists()); + + // Verify contents + let tar_data = fs::read(&target).unwrap(); + let cursor = std::io::Cursor::new(tar_data); + let mut archive = tar::Archive::new(cursor); + let names: Vec<String> = archive + .entries() + .unwrap() + .filter_map(|e| e.ok()) + .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string())) + .collect(); + assert!(names.contains(&"main.php".to_string())); + assert!(names.contains(&"src/Foo.php".to_string())); + } + + #[test] + fn test_create_tar_gz_archive() { + let src = tempdir().unwrap(); + make_source_tree(src.path()); + let out = tempdir().unwrap(); + let target = out.path().join("test.tar.gz"); + + let files = collect_archivable_files(src.path(), &[]).unwrap(); + create_archive(src.path(), &files, &target, &ArchiveFormat::TarGz).unwrap(); + assert!(target.exists()); + + let gz_data = fs::read(&target).unwrap(); + let cursor = std::io::Cursor::new(gz_data); + let decoder = flate2::read::GzDecoder::new(cursor); + let mut archive = tar::Archive::new(decoder); + let names: Vec<String> = archive + .entries() + .unwrap() + .filter_map(|e| e.ok()) + .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string())) + .collect(); + assert!(names.contains(&"main.php".to_string())); + } + + #[test] + fn test_create_tar_bz2_archive() { + let src = tempdir().unwrap(); + make_source_tree(src.path()); + let out = tempdir().unwrap(); + let target = out.path().join("test.tar.bz2"); + + let files = collect_archivable_files(src.path(), &[]).unwrap(); + create_archive(src.path(), &files, &target, &ArchiveFormat::TarBz2).unwrap(); + assert!(target.exists()); + + let bz_data = fs::read(&target).unwrap(); + let cursor = std::io::Cursor::new(bz_data); + let decoder = bzip2::read::BzDecoder::new(cursor); + let mut archive = tar::Archive::new(decoder); + let names: Vec<String> = archive + .entries() + .unwrap() + .filter_map(|e| e.ok()) + .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string())) + .collect(); + assert!(names.contains(&"main.php".to_string())); + } + + #[cfg(unix)] + #[test] + fn test_zip_preserves_permissions() { + use std::os::unix::fs::PermissionsExt; + + let src = tempdir().unwrap(); + let script = src.path().join("run.sh"); + fs::write(&script, b"#!/bin/sh\necho hello").unwrap(); + fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap(); + + let out = tempdir().unwrap(); + let target = out.path().join("test.zip"); + let files = collect_archivable_files(src.path(), &[]).unwrap(); + create_archive(src.path(), &files, &target, &ArchiveFormat::Zip).unwrap(); + + let zip_data = fs::read(&target).unwrap(); + let cursor = std::io::Cursor::new(zip_data); + let mut archive = zip::ZipArchive::new(cursor).unwrap(); + let entry = archive.by_name("run.sh").unwrap(); + let mode = entry.unix_mode().unwrap_or(0); + // Lower 9 bits should be 0o755 + assert_eq!(mode & 0o777, 0o755); + } + + // ── generate_archive_filename ───────────────────────────────────────────── + + #[test] + fn test_filename_simple_package() { + let name = generate_archive_filename("vendor/pkg", None, Some("1.2.3"), None, None, None); + assert_eq!(name, "vendor-pkg-1.2.3"); + } + + #[test] + fn test_filename_with_archive_name() { + let name = generate_archive_filename( + "vendor/pkg", + Some("my-package"), + Some("1.0.0"), + None, + None, + None, + ); + assert_eq!(name, "my-package-1.0.0"); + } + + #[test] + fn test_filename_with_sha_dist_ref() { + let sha = "a".repeat(40); + let name = generate_archive_filename( + "vendor/pkg", + None, + Some("1.0.0"), + Some(&sha), + Some("zip"), + None, + ); + // 40-char hex → append dist_ref and dist_type, not version + assert_eq!(name, format!("vendor-pkg-{}-zip", sha)); + } + + #[test] + fn test_filename_with_source_ref() { + let name = generate_archive_filename( + "vendor/pkg", + None, + Some("1.0.0"), + None, + None, + Some("abc123"), + ); + // Appends first 6 chars of SHA-1 of "abc123" + let mut hasher = Sha1::new(); + hasher.update(b"abc123"); + let hash = format!("{:x}", hasher.finalize()); + let expected = format!("vendor-pkg-1.0.0-{}", &hash[..6]); + assert_eq!(name, expected); + } + + #[test] + fn test_filename_slashes_replaced() { + let name = + generate_archive_filename("vendor/my-pkg", None, Some("1.0/beta"), None, None, None); + assert_eq!(name, "vendor-my-pkg-1.0-beta"); + } + + // ── self_exclusion_patterns ─────────────────────────────────────────────── + + #[test] + fn test_self_exclusion_patterns_with_extra_parts() { + let patterns = self_exclusion_patterns("vendor-pkg", true); + assert!(patterns.contains(&"/vendor-pkg-*.zip".to_string())); + assert!(patterns.contains(&"/vendor-pkg-*.tar".to_string())); + assert!(patterns.contains(&"/vendor-pkg-*.tar.gz".to_string())); + assert!(patterns.contains(&"/vendor-pkg-*.tar.bz2".to_string())); + } + + #[test] + fn test_self_exclusion_patterns_no_extra_parts() { + let patterns = self_exclusion_patterns("vendor-pkg", false); + assert!(patterns.contains(&"/vendor-pkg.zip".to_string())); + assert!(patterns.contains(&"/vendor-pkg.tar".to_string())); + } +} diff --git a/crates/mozart-autoload/Cargo.toml b/crates/mozart-autoload/Cargo.toml new file mode 100644 index 0000000..6aba8ab --- /dev/null +++ b/crates/mozart-autoload/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "mozart-autoload" +version.workspace = true +edition.workspace = true + +[dependencies] +mozart-core.workspace = true +mozart-registry.workspace = true +anyhow.workspace = true +md5.workspace = true +regex.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/mozart-autoload/src/autoload.rs b/crates/mozart-autoload/src/autoload.rs new file mode 100644 index 0000000..2e4158c --- /dev/null +++ b/crates/mozart-autoload/src/autoload.rs @@ -0,0 +1,1801 @@ +use mozart_registry::installed::InstalledPackages; +use mozart_registry::lockfile::LockedPackage; +use std::collections::{BTreeMap, HashSet}; +use std::path::{Path, PathBuf}; + +// Embed Composer PHP files from the submodule at compile time. +const CLASSLOADER_PHP: &str = + include_str!("../../../composer/src/Composer/Autoload/ClassLoader.php"); +const INSTALLED_VERSIONS_PHP: &str = + include_str!("../../../composer/src/Composer/InstalledVersions.php"); +const COMPOSER_LICENSE: &str = include_str!("../../../composer/LICENSE"); + +/// How platform requirements are checked during autoloader generation. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum PlatformCheckMode { + /// Check all platform requirements (php, ext-*, lib-*). + #[default] + Full, + /// Only check the PHP version requirement. + PhpOnly, + /// Disable platform requirement checks entirely. + Disabled, +} + +/// Configuration for autoload generation. +pub struct AutoloadConfig { + /// Absolute path to the project root (where composer.json lives). + pub project_dir: PathBuf, + /// Absolute path to the vendor directory. + pub vendor_dir: PathBuf, + /// Whether dev-mode autoloading is active (include autoload-dev rules). + pub dev_mode: bool, + /// Unique suffix for the autoloader class names (typically the lock file content-hash). + /// Used to generate `ComposerAutoloaderInit{suffix}` and `ComposerStaticInit{suffix}`. + pub suffix: String, + /// When true, emit `$loader->setClassMapAuthoritative(true)` in the generated autoloader. + pub classmap_authoritative: bool, + /// When true, scan PSR-4/PSR-0 directories and generate a full classmap (optimize mode). + pub optimize: bool, + /// When true, generate APCu-based class caching in the autoloader. + pub apcu: bool, + /// Optional prefix for APCu cache keys (implies `apcu`). + pub apcu_prefix: Option<String>, + /// When true, return an error on PSR mapping violations detected during classmap scan. + pub strict_psr: bool, + /// How to handle platform requirement checks. + pub platform_check: PlatformCheckMode, + /// When true, skip all platform requirement checks. + pub ignore_platform_reqs: bool, +} + +/// Collected autoload mappings from all packages. +pub struct AutoloadData { + /// PSR-4: namespace prefix -> list of directory path expressions. + /// Each path is a PHP expression string like `$vendorDir . '/psr/log/src'`. + pub psr4: BTreeMap<String, Vec<String>>, + /// PSR-0: namespace prefix -> list of directory path expressions. + /// (Empty in Phase 2.2, populated in 5.6.) + pub psr0: BTreeMap<String, Vec<String>>, + /// Classmap entries: class name -> file path expression. + /// (Empty in Phase 2.2, populated in 5.6.) + pub classmap: BTreeMap<String, String>, + /// Files to include on every request: file_identifier -> path expression. + pub files: BTreeMap<String, String>, +} + +/// Escape a string for use in a PHP single-quoted string literal. +pub fn php_escape(s: &str) -> String { + s.replace('\\', "\\\\").replace('\'', "\\'") +} + +/// Compute the file identifier matching Composer's `getFileIdentifier()`. +/// This is the MD5 hex digest of `"package_name:path"`. +pub fn file_identifier(package_name: &str, path: &str) -> String { + let input = format!("{package_name}:{path}"); + format!("{:x}", md5::compute(input.as_bytes())) +} + +/// Extract a path or array of paths from a JSON value. +/// Handles both string and array-of-strings (Composer allows both). +fn json_to_paths(value: &serde_json::Value) -> Vec<String> { + match value { + serde_json::Value::String(s) => vec![s.clone()], + serde_json::Value::Array(arr) => arr + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(), + _ => vec![], + } +} + +/// Strip trailing slash from a path component. +fn strip_trailing_slash(s: &str) -> &str { + s.trim_end_matches('/') +} + +/// Normalize a PSR-4 namespace: ensure it ends with `\`. +/// (The empty string "" is valid and is left as-is.) +fn normalize_namespace(ns: &str) -> String { + if ns.is_empty() || ns.ends_with('\\') { + ns.to_string() + } else { + format!("{ns}\\") + } +} + +/// Build a PHP path expression from a base expression and a relative path component. +/// +/// For vendor packages: `base_expr` = `"$vendorDir"`, `pkg_path` = `"psr/log"`, +/// `sub_path` = `"src/"` → result: `"$vendorDir . '/psr/log/src'"`. +/// +/// For root packages: `base_expr` = `"$baseDir"`, `pkg_path` = `""`, +/// `sub_path` = `"src/"` → result: `"$baseDir . '/src'"`. +fn build_path_expr(base_expr: &str, pkg_path: &str, sub_path: &str) -> String { + let sub = strip_trailing_slash(sub_path); + let combined = if pkg_path.is_empty() { + sub.to_string() + } else if sub.is_empty() { + pkg_path.to_string() + } else { + format!("{pkg_path}/{sub}") + }; + + if combined.is_empty() { + base_expr.to_string() + } else { + format!("{base_expr} . '/{combined}'") + } +} + +/// Process an autoload JSON value and merge its rules into `data`. +/// +/// `pkg_path` is the package-relative path segment within vendor. +/// For vendor packages it is `"vendor/name"` (e.g. `"psr/log"`). +/// For the root package it is `""`. +/// +/// `dyn_base` is the dynamic PHP variable: `"$vendorDir"` or `"$baseDir"`. +/// `static_base` is the static PHP expression: `"__DIR__ . '/..'"` or `"__DIR__ . '/../.'"`. +fn process_autoload_value( + autoload_val: &serde_json::Value, + package_name: &str, + pkg_path: &str, + dyn_base: &str, + static_base: &str, + data: &mut AutoloadData, + static_data: &mut AutoloadData, +) { + // PSR-4 + if let Some(psr4_obj) = autoload_val.get("psr-4").and_then(|v| v.as_object()) { + for (ns_raw, paths_val) in psr4_obj { + let ns = normalize_namespace(ns_raw); + let paths = json_to_paths(paths_val); + let entry = data.psr4.entry(ns.clone()).or_default(); + let static_entry = static_data.psr4.entry(ns).or_default(); + for path in paths { + entry.push(build_path_expr(dyn_base, pkg_path, &path)); + static_entry.push(build_path_expr(static_base, pkg_path, &path)); + } + } + } + + // PSR-0 + if let Some(psr0_obj) = autoload_val.get("psr-0").and_then(|v| v.as_object()) { + for (ns_raw, paths_val) in psr0_obj { + let ns = ns_raw.clone(); + let paths = json_to_paths(paths_val); + let entry = data.psr0.entry(ns.clone()).or_default(); + let static_entry = static_data.psr0.entry(ns).or_default(); + for path in paths { + entry.push(build_path_expr(dyn_base, pkg_path, &path)); + static_entry.push(build_path_expr(static_base, pkg_path, &path)); + } + } + } + + // Files + if let Some(files_arr) = autoload_val.get("files").and_then(|v| v.as_array()) { + for file_val in files_arr { + if let Some(file_path) = file_val.as_str() { + let id = file_identifier(package_name, file_path); + let expr = build_path_expr(dyn_base, pkg_path, file_path); + let static_expr = build_path_expr(static_base, pkg_path, file_path); + data.files.insert(id.clone(), expr); + static_data.files.insert(id, static_expr); + } + } + } +} + +/// Collect autoload rules from all installed packages and the root package. +/// +/// Returns a tuple of `(dynamic_data, static_data)` where: +/// - `dynamic_data` uses `$vendorDir` / `$baseDir` path expressions (for autoload_psr4.php, etc.) +/// - `static_data` uses `__DIR__ . '/..'` path expressions (for autoload_static.php) +fn collect_autoloads( + installed: &InstalledPackages, + root_autoload: Option<&serde_json::Value>, + root_autoload_dev: Option<&serde_json::Value>, + root_package_name: &str, + dev_mode: bool, +) -> (AutoloadData, AutoloadData) { + let mut data = AutoloadData { + psr4: BTreeMap::new(), + psr0: BTreeMap::new(), + classmap: BTreeMap::new(), + files: BTreeMap::new(), + }; + let mut static_data = AutoloadData { + psr4: BTreeMap::new(), + psr0: BTreeMap::new(), + classmap: BTreeMap::new(), + files: BTreeMap::new(), + }; + + // Process each installed package + for pkg in &installed.packages { + if let Some(autoload_val) = &pkg.autoload { + process_autoload_value( + autoload_val, + &pkg.name, + &pkg.name, // pkg_path within vendor + "$vendorDir", + "__DIR__ . '/..'", + &mut data, + &mut static_data, + ); + } + } + + // Process root package autoload + if let Some(autoload_val) = root_autoload { + process_autoload_value( + autoload_val, + root_package_name, + "", // no pkg_path for root + "$baseDir", + "__DIR__ . '/../..'", + &mut data, + &mut static_data, + ); + } + + // Process root package autoload-dev (only in dev mode) + if dev_mode && let Some(autoload_dev_val) = root_autoload_dev { + process_autoload_value( + autoload_dev_val, + root_package_name, + "", + "$baseDir", + "__DIR__ . '/../..'", + &mut data, + &mut static_data, + ); + } + + (data, static_data) +} + +/// Generate `vendor/composer/autoload_psr4.php`. +fn generate_autoload_psr4(data: &AutoloadData) -> String { + let mut out = String::new(); + out.push_str("<?php\n\n// autoload_psr4.php @generated by Composer\n\n"); + out.push_str("$vendorDir = dirname(__DIR__);\n"); + out.push_str("$baseDir = dirname($vendorDir);\n\n"); + out.push_str("return array(\n"); + + // krsort: reverse alphabetical (longer/more specific namespaces first) + let mut sorted: Vec<(&String, &Vec<String>)> = data.psr4.iter().collect(); + sorted.sort_by(|(a, _), (b, _)| b.cmp(a)); + + for (ns, paths) in &sorted { + let escaped_ns = php_escape(ns); + if paths.len() == 1 { + out.push_str(&format!(" '{}' => array({}),\n", escaped_ns, paths[0])); + } else { + out.push_str(&format!(" '{}' => array(\n", escaped_ns)); + for path in paths.iter() { + out.push_str(&format!(" {},\n", path)); + } + out.push_str(" ),\n"); + } + } + + out.push_str(");\n"); + out +} + +/// Generate `vendor/composer/autoload_namespaces.php` (PSR-0, empty for Phase 2.2). +fn generate_autoload_namespaces(data: &AutoloadData) -> String { + let mut out = String::new(); + out.push_str("<?php\n\n// autoload_namespaces.php @generated by Composer\n\n"); + out.push_str("$vendorDir = dirname(__DIR__);\n"); + out.push_str("$baseDir = dirname($vendorDir);\n\n"); + out.push_str("return array(\n"); + + let mut sorted: Vec<(&String, &Vec<String>)> = data.psr0.iter().collect(); + sorted.sort_by(|(a, _), (b, _)| b.cmp(a)); + + for (ns, paths) in &sorted { + let escaped_ns = php_escape(ns); + if paths.len() == 1 { + out.push_str(&format!(" '{}' => array({}),\n", escaped_ns, paths[0])); + } else { + out.push_str(&format!(" '{}' => array(\n", escaped_ns)); + for path in paths.iter() { + out.push_str(&format!(" {},\n", path)); + } + out.push_str(" ),\n"); + } + } + + out.push_str(");\n"); + out +} + +/// Generate `vendor/composer/autoload_classmap.php`. +/// Always contains `Composer\InstalledVersions`; classmap scanning deferred to Phase 5.6. +fn generate_autoload_classmap(data: &AutoloadData) -> String { + let mut out = String::new(); + out.push_str("<?php\n\n// autoload_classmap.php @generated by Composer\n\n"); + out.push_str("$vendorDir = dirname(__DIR__);\n"); + out.push_str("$baseDir = dirname($vendorDir);\n\n"); + out.push_str("return array(\n"); + out.push_str( + " 'Composer\\\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',\n", + ); + + // Include any additional classmap entries from data + for (class, path) in &data.classmap { + let escaped_class = php_escape(class); + out.push_str(&format!(" '{}' => {},\n", escaped_class, path)); + } + + out.push_str(");\n"); + out +} + +/// Generate `vendor/composer/autoload_files.php`. +/// Returns `None` if there are no files to autoload. +fn generate_autoload_files(data: &AutoloadData) -> Option<String> { + if data.files.is_empty() { + return None; + } + + let mut out = String::new(); + out.push_str("<?php\n\n// autoload_files.php @generated by Composer\n\n"); + out.push_str("$vendorDir = dirname(__DIR__);\n"); + out.push_str("$baseDir = dirname($vendorDir);\n\n"); + out.push_str("return array(\n"); + + for (id, path) in &data.files { + out.push_str(&format!(" '{}' => {},\n", id, path)); + } + + out.push_str(");\n"); + Some(out) +} + +/// Generate `vendor/composer/autoload_static.php`. +/// +/// `static_data` must have been collected with `__DIR__ . '/..'` path prefixes. +fn generate_autoload_static(static_data: &AutoloadData, suffix: &str) -> String { + let mut out = String::new(); + out.push_str("<?php\n\n// autoload_static.php @generated by Composer\n\n"); + out.push_str("namespace Composer\\Autoload;\n\n"); + out.push_str(&format!("class ComposerStaticInit{suffix}\n{{\n")); + + // $files + if !static_data.files.is_empty() { + out.push_str(" public static $files = array (\n"); + for (id, path) in &static_data.files { + out.push_str(&format!(" '{id}' => {path},\n")); + } + out.push_str(" );\n\n"); + } + + // $prefixLengthsPsr4 — group by first character of namespace + if !static_data.psr4.is_empty() { + // Group namespaces by first character, sorted reverse + let mut by_char: BTreeMap<char, Vec<(&String, usize)>> = BTreeMap::new(); + + let mut sorted_ns: Vec<&String> = static_data.psr4.keys().collect(); + sorted_ns.sort_by(|a, b| b.cmp(a)); + + for ns in sorted_ns { + if let Some(first_char) = ns.chars().next() { + // The byte length in PHP (single-quoted string with single backslashes) + // ns in our data uses single backslash (stored as-is from JSON). + let byte_len = ns.len(); + by_char.entry(first_char).or_default().push((ns, byte_len)); + } + } + + out.push_str(" public static $prefixLengthsPsr4 = array (\n"); + // Sort characters in reverse order too + let mut chars: Vec<char> = by_char.keys().copied().collect(); + chars.sort_by(|a, b| b.cmp(a)); + for ch in &chars { + out.push_str(&format!(" '{ch}' =>\n array (\n")); + if let Some(entries) = by_char.get(ch) { + for (ns, len) in entries { + let escaped_ns = php_escape(ns); + out.push_str(&format!(" '{escaped_ns}' => {len},\n")); + } + } + out.push_str(" ),\n"); + } + out.push_str(" );\n\n"); + + // $prefixDirsPsr4 + out.push_str(" public static $prefixDirsPsr4 = array (\n"); + let mut sorted_ns2: Vec<(&String, &Vec<String>)> = static_data.psr4.iter().collect(); + sorted_ns2.sort_by(|(a, _), (b, _)| b.cmp(a)); + for (ns, paths) in sorted_ns2 { + let escaped_ns = php_escape(ns); + out.push_str(&format!(" '{escaped_ns}' =>\n array (\n")); + for (i, path) in paths.iter().enumerate() { + out.push_str(&format!(" {i} => {path},\n")); + } + out.push_str(" ),\n"); + } + out.push_str(" );\n\n"); + } + + // $classMap — always contains Composer\InstalledVersions + out.push_str(" public static $classMap = array (\n"); + out.push_str( + " 'Composer\\\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',\n", + ); + for (class, path) in &static_data.classmap { + let escaped_class = php_escape(class); + out.push_str(&format!(" '{}' => {},\n", escaped_class, path)); + } + out.push_str(" );\n\n"); + + // getInitializer + out.push_str(" public static function getInitializer(ClassLoader $loader)\n {\n"); + out.push_str(" return \\Closure::bind(function () use ($loader) {\n"); + + if !static_data.psr4.is_empty() { + out.push_str(&format!( + " $loader->prefixLengthsPsr4 = ComposerStaticInit{suffix}::$prefixLengthsPsr4;\n" + )); + out.push_str(&format!( + " $loader->prefixDirsPsr4 = ComposerStaticInit{suffix}::$prefixDirsPsr4;\n" + )); + } + out.push_str(&format!( + " $loader->classMap = ComposerStaticInit{suffix}::$classMap;\n" + )); + out.push_str("\n }, null, ClassLoader::class);\n }\n}\n"); + + out +} + +/// Recursively collect PHP files from a directory, skipping excluded paths. +fn collect_php_files( + dir: &Path, + excluded: &[String], + vendor_dir: &Path, + project_dir: &Path, +) -> Vec<PathBuf> { + let mut result = Vec::new(); + if !dir.is_dir() { + return result; + } + collect_php_files_inner(dir, excluded, vendor_dir, project_dir, &mut result); + result +} + +fn collect_php_files_inner( + dir: &Path, + excluded: &[String], + vendor_dir: &Path, + project_dir: &Path, + result: &mut Vec<PathBuf>, +) { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + for entry in entries.flatten() { + let path = entry.path(); + + // Check if path matches any excluded pattern + if is_excluded(&path, excluded, vendor_dir, project_dir) { + continue; + } + + if path.is_dir() { + collect_php_files_inner(&path, excluded, vendor_dir, project_dir, result); + } else if crate::php_scanner::is_php_ext(&path) { + result.push(path); + } + } +} + +/// Check whether a path matches any of the excluded patterns. +fn is_excluded(path: &Path, excluded: &[String], vendor_dir: &Path, project_dir: &Path) -> bool { + for exc in excluded { + // Excluded patterns can be relative to project_dir or absolute + let exc_path = if Path::new(exc).is_absolute() { + PathBuf::from(exc) + } else { + project_dir.join(exc) + }; + if path.starts_with(&exc_path) || path == exc_path { + return true; + } + // Also check relative to vendor_dir + let exc_vendor = vendor_dir.join(exc); + if path.starts_with(&exc_vendor) || path == exc_vendor { + return true; + } + } + false +} + +/// Scan directories for PHP class declarations and return a classmap. +/// +/// `dirs` is a list of absolute directory paths to scan. +/// Returns a `BTreeMap<class_name, file_path_expression>` where the path expression +/// uses `$vendorDir` or `$baseDir` as appropriate. +fn scan_classmap_dirs( + dirs: &[PathBuf], + vendor_dir: &Path, + project_dir: &Path, + excluded: &[String], +) -> BTreeMap<String, String> { + let mut classmap = BTreeMap::new(); + + for dir in dirs { + let files = collect_php_files(dir, excluded, vendor_dir, project_dir); + for file in files { + match crate::php_scanner::find_classes(&file) { + Ok(classes) => { + for class in classes { + let path_expr = path_to_php_expr(&file, vendor_dir, project_dir); + classmap.entry(class).or_insert(path_expr); + } + } + Err(_) => continue, + } + } + } + + classmap +} + +/// Convert an absolute file path to a PHP path expression using `$vendorDir` or `$baseDir`. +fn path_to_php_expr(file: &Path, vendor_dir: &Path, project_dir: &Path) -> String { + if let Ok(rel) = file.strip_prefix(vendor_dir) { + let rel_str = rel.to_string_lossy().replace('\\', "/"); + format!("$vendorDir . '/{rel_str}'") + } else if let Ok(rel) = file.strip_prefix(project_dir) { + let rel_str = rel.to_string_lossy().replace('\\', "/"); + format!("$baseDir . '/{rel_str}'") + } else { + // Fall back to absolute path + let abs = file.to_string_lossy().replace('\\', "/"); + format!("'{abs}'") + } +} + +/// Convert an absolute file path to a static PHP path expression using `__DIR__ . '/..` form. +fn path_to_static_expr(file: &Path, vendor_dir: &Path, project_dir: &Path) -> String { + if let Ok(rel) = file.strip_prefix(vendor_dir) { + let rel_str = rel.to_string_lossy().replace('\\', "/"); + format!("__DIR__ . '/..' . '/{rel_str}'") + } else if let Ok(rel) = file.strip_prefix(project_dir) { + let rel_str = rel.to_string_lossy().replace('\\', "/"); + format!("__DIR__ . '/../..' . '/{rel_str}'") + } else { + let abs = file.to_string_lossy().replace('\\', "/"); + format!("'{abs}'") + } +} + +/// Scan PSR-4 and PSR-0 directories for class declarations (used in optimize mode). +/// +/// Returns `(dynamic_classmap, static_classmap, psr_violations)`. +fn scan_psr_for_classmap( + psr4: &BTreeMap<String, Vec<String>>, + psr0: &BTreeMap<String, Vec<String>>, + vendor_dir: &Path, + project_dir: &Path, + excluded: &[String], +) -> ( + BTreeMap<String, String>, + BTreeMap<String, String>, + Vec<String>, +) { + let mut dyn_map: BTreeMap<String, String> = BTreeMap::new(); + let mut static_map: BTreeMap<String, String> = BTreeMap::new(); + let mut violations: Vec<String> = Vec::new(); + + // Helper: resolve a PHP path expression to an absolute path. + let resolve = |expr: &str| -> Option<PathBuf> { + // Expressions look like: + // $vendorDir . '/psr/log/src' + // $baseDir . '/src' + // __DIR__ . '/..' . '/psr/log/src' + // __DIR__ . '/../..' . '/src' + if let Some(rest) = expr.strip_prefix("$vendorDir . '") { + let rel = rest.trim_end_matches('\''); + Some(vendor_dir.join(rel.trim_start_matches('/'))) + } else if let Some(rest) = expr.strip_prefix("$baseDir . '") { + let rel = rest.trim_end_matches('\''); + Some(project_dir.join(rel.trim_start_matches('/'))) + } else if expr == "$vendorDir" { + Some(vendor_dir.to_path_buf()) + } else if expr == "$baseDir" { + Some(project_dir.to_path_buf()) + } else { + None + } + }; + + // Scan PSR-4 dirs + for (ns, paths) in psr4 { + for path_expr in paths { + if let Some(abs_dir) = resolve(path_expr) { + let files = collect_php_files(&abs_dir, excluded, vendor_dir, project_dir); + for file in files { + match crate::php_scanner::find_classes(&file) { + Ok(classes) => { + for class in classes { + // PSR-4 validation + let file_str = file.to_string_lossy(); + let dir_str = abs_dir.to_string_lossy(); + let base_ns = ns.as_str(); + if !crate::php_scanner::validate_psr4_class( + &class, base_ns, &file_str, &dir_str, + ) { + violations.push(format!( + "Class {class} in {file_str} does not comply with PSR-4 (namespace prefix: {ns})" + )); + } + let dyn_expr = path_to_php_expr(&file, vendor_dir, project_dir); + let static_expr = + path_to_static_expr(&file, vendor_dir, project_dir); + dyn_map.entry(class.clone()).or_insert(dyn_expr); + static_map.entry(class).or_insert(static_expr); + } + } + Err(_) => continue, + } + } + } + } + } + + // Scan PSR-0 dirs + for (ns, paths) in psr0 { + for path_expr in paths { + if let Some(abs_dir) = resolve(path_expr) { + let files = collect_php_files(&abs_dir, excluded, vendor_dir, project_dir); + for file in files { + match crate::php_scanner::find_classes(&file) { + Ok(classes) => { + for class in classes { + let file_str = file.to_string_lossy(); + let dir_str = abs_dir.to_string_lossy(); + if !crate::php_scanner::validate_psr0_class( + &class, &file_str, &dir_str, + ) { + violations.push(format!( + "Class {class} in {file_str} does not comply with PSR-0 (namespace prefix: {ns})" + )); + } + let dyn_expr = path_to_php_expr(&file, vendor_dir, project_dir); + let static_expr = + path_to_static_expr(&file, vendor_dir, project_dir); + dyn_map.entry(class.clone()).or_insert(dyn_expr); + static_map.entry(class).or_insert(static_expr); + } + } + Err(_) => continue, + } + } + } + } + } + + (dyn_map, static_map, violations) +} + +/// Generate `vendor/composer/platform_check.php`. +/// +/// Returns `None` if mode is `Disabled` or there are no relevant requirements. +fn generate_platform_check( + packages: &[LockedPackage], + root_require: Option<&serde_json::Value>, + mode: &PlatformCheckMode, + dev_package_names: &HashSet<String>, +) -> Option<String> { + if matches!(mode, PlatformCheckMode::Disabled) { + return None; + } + + // Collect PHP version constraint from root require + let mut php_constraint: Option<String> = None; + if let Some(req_obj) = root_require.and_then(|v| v.as_object()) + && let Some(v) = req_obj.get("php").and_then(|v| v.as_str()) + { + php_constraint = Some(v.to_string()); + } + + // Collect extension requirements from packages (prod only) + let mut ext_reqs: Vec<(String, String)> = Vec::new(); + if matches!(mode, PlatformCheckMode::Full) { + for pkg in packages { + let is_dev = dev_package_names.contains(&pkg.name.to_lowercase()); + if is_dev { + continue; + } + for (req_name, req_constraint) in &pkg.require { + let lower = req_name.to_lowercase(); + if lower.starts_with("ext-") { + ext_reqs.push((req_name.clone(), req_constraint.clone())); + } + } + } + ext_reqs.sort(); + ext_reqs.dedup(); + } + + if php_constraint.is_none() && ext_reqs.is_empty() { + return None; + } + + let mut out = String::new(); + out.push_str("<?php\n\n"); + out.push_str("// platform_check.php @generated by Composer\n\n"); + out.push_str("$issues = array();\n\n"); + + if let Some(ref constraint) = php_constraint { + // Emit a simple PHP version check + let escaped = php_escape(constraint); + out.push_str(&format!("// PHP version check: {constraint}\n")); + out.push_str("if (!(PHP_VERSION_ID >= 50600)) {\n"); + out.push_str(&format!( + " $issues[] = 'Your Composer dependencies require a PHP version \"{escaped}\". You are running ' . PHP_VERSION . '.';\n" + )); + out.push_str("}\n\n"); + } + + for (ext_name, _constraint) in &ext_reqs { + let ext_short = ext_name.trim_start_matches("ext-"); + let escaped_ext = php_escape(ext_short); + out.push_str(&format!("if (!extension_loaded('{escaped_ext}')) {{\n")); + out.push_str(&format!( + " $issues[] = 'Your Composer dependencies require the \"{escaped_ext}\" PHP extension to be installed.';\n" + )); + out.push_str("}\n\n"); + } + + out.push_str("if ($issues) {\n"); + out.push_str(" if (!headers_sent()) {\n"); + out.push_str(" header('HTTP/1.1 500 Internal Server Error');\n"); + out.push_str(" }\n"); + out.push_str(" if (!ini_get('display_errors')) {\n"); + out.push_str(" if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {\n"); + out.push_str(" fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL);\n"); + out.push_str(" } elseif (!headers_sent()) {\n"); + out.push_str(" echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL;\n"); + out.push_str(" }\n"); + out.push_str(" }\n"); + out.push_str(" trigger_error(\n"); + out.push_str( + " 'Composer detected issues in your platform: ' . implode(' ', $issues),\n", + ); + out.push_str(" E_USER_ERROR\n"); + out.push_str(" );\n"); + out.push_str("}\n"); + + Some(out) +} + +/// Generate `vendor/composer/autoload_real.php`. +fn generate_autoload_real( + suffix: &str, + has_files: bool, + classmap_authoritative: bool, + apcu: bool, + apcu_prefix: Option<&str>, + has_platform_check: bool, +) -> String { + let mut out = String::new(); + out.push_str("<?php\n\n"); + out.push_str("// autoload_real.php @generated by Composer\n\n"); + out.push_str(&format!("class ComposerAutoloaderInit{suffix}\n")); + out.push_str("{\n"); + out.push_str(" private static $loader;\n\n"); + out.push_str(" public static function loadClassLoader($class)\n"); + out.push_str(" {\n"); + out.push_str(" if ('Composer\\Autoload\\ClassLoader' === $class) {\n"); + out.push_str(" require __DIR__ . '/ClassLoader.php';\n"); + out.push_str(" }\n"); + out.push_str(" }\n\n"); + out.push_str(" /**\n"); + out.push_str(" * @return \\Composer\\Autoload\\ClassLoader\n"); + out.push_str(" */\n"); + out.push_str(" public static function getLoader()\n"); + out.push_str(" {\n"); + out.push_str(" if (null !== self::$loader) {\n"); + out.push_str(" return self::$loader;\n"); + out.push_str(" }\n\n"); + out.push_str(&format!( + " spl_autoload_register(array('ComposerAutoloaderInit{suffix}', 'loadClassLoader'), true, true);\n" + )); + out.push_str( + " self::$loader = $loader = new \\Composer\\Autoload\\ClassLoader(\\dirname(__DIR__));\n", + ); + out.push_str(&format!( + " spl_autoload_unregister(array('ComposerAutoloaderInit{suffix}', 'loadClassLoader'));\n\n" + )); + if has_platform_check { + out.push_str(" require __DIR__ . '/platform_check.php';\n"); + } + out.push_str(" require __DIR__ . '/autoload_static.php';\n"); + out.push_str(&format!( + " call_user_func(\\Composer\\Autoload\\ComposerStaticInit{suffix}::getInitializer($loader));\n\n" + )); + out.push_str(" $loader->register(true);\n"); + + if classmap_authoritative { + out.push_str(" $loader->setClassMapAuthoritative(true);\n"); + } + + if apcu { + let prefix = apcu_prefix.unwrap_or(suffix); + let escaped = php_escape(prefix); + out.push_str(&format!(" $loader->setApcuPrefix('{escaped}');\n")); + } + + if has_files { + out.push('\n'); + out.push_str(&format!( + " $filesToLoad = \\Composer\\Autoload\\ComposerStaticInit{suffix}::$files;\n" + )); + out.push_str( + " $requireFile = \\Closure::bind(static function ($fileIdentifier, $file) {\n", + ); + out.push_str( + " if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) {\n", + ); + out.push_str( + " $GLOBALS['__composer_autoload_files'][$fileIdentifier] = true;\n", + ); + out.push('\n'); + out.push_str(" require $file;\n"); + out.push_str(" }\n"); + out.push_str(" }, null, null);\n"); + out.push_str(" foreach ($filesToLoad as $fileIdentifier => $file) {\n"); + out.push_str(" $requireFile($fileIdentifier, $file);\n"); + out.push_str(" }\n"); + } + + out.push('\n'); + out.push_str(" return $loader;\n"); + out.push_str(" }\n"); + out.push_str("}\n"); + out +} + +/// Generate `vendor/autoload.php` (the entry point). +fn generate_autoload_php(suffix: &str) -> String { + let mut out = String::new(); + out.push_str("<?php\n\n"); + out.push_str("// autoload.php @generated by Composer\n\n"); + out.push_str("if (PHP_VERSION_ID < 50600) {\n"); + out.push_str(" if (!headers_sent()) {\n"); + out.push_str(" header('HTTP/1.1 500 Internal Server Error');\n"); + out.push_str(" }\n"); + out.push_str(" $err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via \"composer self-update --2.2\". Aborting.'.PHP_EOL;\n"); + out.push_str(" if (!ini_get('display_errors')) {\n"); + out.push_str(" if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {\n"); + out.push_str(" fwrite(STDERR, $err);\n"); + out.push_str(" } elseif (!headers_sent()) {\n"); + out.push_str(" echo $err;\n"); + out.push_str(" }\n"); + out.push_str(" }\n"); + out.push_str(" throw new RuntimeException($err);\n"); + out.push_str("}\n\n"); + out.push_str("require_once __DIR__ . '/composer/autoload_real.php';\n\n"); + out.push_str(&format!( + "return ComposerAutoloaderInit{suffix}::getLoader();\n" + )); + out +} + +/// Generate `vendor/composer/installed.php`. +fn generate_installed_php( + root_name: &str, + root_type: &str, + installed: &InstalledPackages, + dev_mode: bool, +) -> String { + let dev_str = if dev_mode { "true" } else { "false" }; + + let mut out = String::new(); + out.push_str("<?php return array(\n"); + out.push_str(" 'root' => array(\n"); + out.push_str(&format!(" 'name' => '{}',\n", php_escape(root_name))); + out.push_str(" 'pretty_version' => 'dev-main',\n"); + out.push_str(" 'version' => 'dev-main',\n"); + out.push_str(" 'reference' => null,\n"); + out.push_str(&format!(" 'type' => '{}',\n", php_escape(root_type))); + out.push_str(" 'install_path' => __DIR__ . '/../../',\n"); + out.push_str(" 'aliases' => array(),\n"); + out.push_str(&format!(" 'dev' => {dev_str},\n")); + out.push_str(" ),\n"); + out.push_str(" 'versions' => array(\n"); + + for pkg in &installed.packages { + let version = &pkg.version; + let version_normalized = pkg.version_normalized.as_deref().unwrap_or(version); + let pkg_type = pkg.package_type.as_deref().unwrap_or("library"); + let is_dev = installed + .dev_package_names + .iter() + .any(|n| n.eq_ignore_ascii_case(&pkg.name)); + let is_dev_str = if is_dev { "true" } else { "false" }; + + out.push_str(&format!(" '{}' => array(\n", php_escape(&pkg.name))); + out.push_str(&format!( + " 'pretty_version' => '{}',\n", + php_escape(version) + )); + out.push_str(&format!( + " 'version' => '{}',\n", + php_escape(version_normalized) + )); + out.push_str(" 'reference' => null,\n"); + out.push_str(&format!( + " 'type' => '{}',\n", + php_escape(pkg_type) + )); + // Install path relative to vendor/composer/installed.php: __DIR__ . '/./' . relative_name + // The install_path stored is like '../psr/log', relative to vendor/composer/ + // So from vendor/composer/, the package is at __DIR__ . '/../psr/log/' + out.push_str(&format!( + " 'install_path' => __DIR__ . '/../{}/',\n", + pkg.name + )); + out.push_str(" 'aliases' => array(),\n"); + out.push_str(&format!(" 'dev_requirement' => {is_dev_str},\n")); + out.push_str(" ),\n"); + } + + out.push_str(" ),\n"); + out.push_str(");\n"); + out +} + +/// Determine the autoloader suffix. +/// +/// Priority: +/// 1. Existing `vendor/autoload.php` suffix (carry over to avoid breaking existing references). +/// 2. Lock file `content-hash` (if locked). +/// 3. Fall back to a timestamp-based hex string. +pub fn determine_suffix(working_dir: &Path, vendor_dir: &Path) -> anyhow::Result<String> { + // Try existing autoload.php + let autoload_path = vendor_dir.join("autoload.php"); + if autoload_path.exists() { + let content = std::fs::read_to_string(&autoload_path)?; + if let Some(start) = content.find("ComposerAutoloaderInit") { + let rest = &content[start + "ComposerAutoloaderInit".len()..]; + if let Some(end) = rest.find("::") { + let suffix = &rest[..end]; + if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_hexdigit()) { + return Ok(suffix.to_string()); + } + } + } + } + + // Try composer.lock content-hash + let lock_path = working_dir.join("composer.lock"); + if lock_path.exists() { + let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; + return Ok(lock.content_hash); + } + + // Fall back to MD5 of current timestamp + let ts = format!("{:?}", std::time::SystemTime::now()); + Ok(format!("{:x}", md5::compute(ts.as_bytes()))) +} + +/// Generate all autoloader files for the given project. +/// +/// This is the main entry point called by `install` and `dump-autoload`. +pub fn generate(config: &AutoloadConfig) -> anyhow::Result<()> { + // 1. Read installed.json + let installed = InstalledPackages::read(&config.vendor_dir)?; + + // 2. Read root package autoload from composer.json + let composer_json_path = config.project_dir.join("composer.json"); + let (root_autoload, root_autoload_dev, root_name, root_type) = if composer_json_path.exists() { + let content = std::fs::read_to_string(&composer_json_path)?; + let value: serde_json::Value = serde_json::from_str(&content)?; + ( + value.get("autoload").cloned(), + value.get("autoload-dev").cloned(), + value + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or("__root__") + .to_string(), + value + .get("type") + .and_then(|t| t.as_str()) + .unwrap_or("project") + .to_string(), + ) + } else { + (None, None, "__root__".to_string(), "project".to_string()) + }; + + // 3. Collect autoload data + let (mut data, mut static_data) = collect_autoloads( + &installed, + root_autoload.as_ref(), + root_autoload_dev.as_ref(), + &root_name, + config.dev_mode, + ); + + // 3a. Read classmap dirs declared in composer.json + let excluded: Vec<String> = root_autoload + .as_ref() + .and_then(|v| v.get("exclude-from-classmap")) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + + // Scan explicit classmap dirs from all packages + let mut classmap_dirs: Vec<PathBuf> = Vec::new(); + + // Collect classmap dirs from installed packages + for pkg in &installed.packages { + if let Some(autoload_val) = &pkg.autoload + && let Some(cm_arr) = autoload_val.get("classmap").and_then(|v| v.as_array()) + { + for cm_val in cm_arr { + if let Some(cm_path) = cm_val.as_str() { + let abs = config.vendor_dir.join(&pkg.name).join(cm_path); + classmap_dirs.push(abs); + } + } + } + } + + // Collect classmap dirs from root autoload + if let Some(autoload_val) = root_autoload.as_ref() + && let Some(cm_arr) = autoload_val.get("classmap").and_then(|v| v.as_array()) + { + for cm_val in cm_arr { + if let Some(cm_path) = cm_val.as_str() { + let abs = config.project_dir.join(cm_path); + classmap_dirs.push(abs); + } + } + } + + // Scan classmap dirs + if !classmap_dirs.is_empty() { + let scanned = scan_classmap_dirs( + &classmap_dirs, + &config.vendor_dir, + &config.project_dir, + &excluded, + ); + for (class, path_expr) in scanned { + // Also generate the static expression + // We store the dynamic expression in data.classmap; static_data.classmap + // will be populated similarly. For now we insert into both. + data.classmap.entry(class.clone()).or_insert(path_expr); + // Generate corresponding static expr by replacing dynamic prefixes + // (static_data classmap is populated in the static pass below) + } + } + + // 3b. Optimize mode: scan PSR-4/PSR-0 dirs for classmap + let do_optimize = config.optimize || config.classmap_authoritative; + let mut psr_violations: Vec<String> = Vec::new(); + + if do_optimize { + let (opt_dyn, opt_static, violations) = scan_psr_for_classmap( + &data.psr4, + &data.psr0, + &config.vendor_dir, + &config.project_dir, + &excluded, + ); + psr_violations = violations; + for (class, path_expr) in opt_dyn { + data.classmap.entry(class).or_insert(path_expr); + } + for (class, path_expr) in opt_static { + static_data.classmap.entry(class).or_insert(path_expr); + } + } + + // 3c. Handle strict-psr violations + if config.strict_psr && !psr_violations.is_empty() { + for violation in &psr_violations { + eprintln!("PSR violation: {violation}"); + } + return Err(anyhow::anyhow!( + "PSR mapping violations detected (--strict-psr). Run without --strict-psr to ignore." + )); + } + + // 4. Generate and write files + let composer_dir = config.vendor_dir.join("composer"); + std::fs::create_dir_all(&composer_dir)?; + + std::fs::write( + composer_dir.join("autoload_psr4.php"), + generate_autoload_psr4(&data), + )?; + std::fs::write( + composer_dir.join("autoload_namespaces.php"), + generate_autoload_namespaces(&data), + )?; + std::fs::write( + composer_dir.join("autoload_classmap.php"), + generate_autoload_classmap(&data), + )?; + + if let Some(files_content) = generate_autoload_files(&data) { + std::fs::write(composer_dir.join("autoload_files.php"), files_content)?; + } else { + // Remove stale file if it exists + let files_path = composer_dir.join("autoload_files.php"); + if files_path.exists() { + std::fs::remove_file(files_path)?; + } + } + + // 4a. Generate platform_check.php if needed + let dev_package_names_set: HashSet<String> = installed + .dev_package_names + .iter() + .map(|n| n.to_lowercase()) + .collect(); + + // Re-read composer.json for root require (not from autoload, but from root "require" key) + let root_require_val: Option<serde_json::Value> = if composer_json_path.exists() { + let content = std::fs::read_to_string(&composer_json_path)?; + let value: serde_json::Value = serde_json::from_str(&content)?; + value.get("require").cloned() + } else { + None + }; + + let all_locked: Vec<LockedPackage> = { + // Collect locked packages from installed for platform check + // (installed.packages are LockedPackage-compatible via InstalledPackageEntry) + // We'll build minimal LockedPackage-like data from installed entries + installed + .packages + .iter() + .map(|p| mozart_registry::lockfile::LockedPackage { + name: p.name.clone(), + version: p.version.clone(), + version_normalized: p.version_normalized.clone(), + source: None, + dist: None, + require: std::collections::BTreeMap::new(), + require_dev: std::collections::BTreeMap::new(), + conflict: std::collections::BTreeMap::new(), + suggest: None, + package_type: p.package_type.clone(), + autoload: p.autoload.clone(), + autoload_dev: None, + license: None, + description: None, + homepage: None, + keywords: None, + authors: None, + support: None, + funding: None, + time: None, + extra_fields: std::collections::BTreeMap::new(), + }) + .collect() + }; + + let effective_mode = if config.ignore_platform_reqs { + PlatformCheckMode::Disabled + } else { + config.platform_check.clone() + }; + + let platform_check_content = generate_platform_check( + &all_locked, + root_require_val.as_ref(), + &effective_mode, + &dev_package_names_set, + ); + let has_platform_check = platform_check_content.is_some(); + + if let Some(content) = platform_check_content { + std::fs::write(composer_dir.join("platform_check.php"), content)?; + } else { + let pc_path = composer_dir.join("platform_check.php"); + if pc_path.exists() { + std::fs::remove_file(pc_path)?; + } + } + + let has_files = !data.files.is_empty(); + let use_apcu = config.apcu || config.apcu_prefix.is_some(); + std::fs::write( + composer_dir.join("autoload_static.php"), + generate_autoload_static(&static_data, &config.suffix), + )?; + std::fs::write( + composer_dir.join("autoload_real.php"), + generate_autoload_real( + &config.suffix, + has_files, + config.classmap_authoritative, + use_apcu, + config.apcu_prefix.as_deref(), + has_platform_check, + ), + )?; + std::fs::write( + config.vendor_dir.join("autoload.php"), + generate_autoload_php(&config.suffix), + )?; + + // 5. Copy ClassLoader.php, InstalledVersions.php, LICENSE + std::fs::write(composer_dir.join("ClassLoader.php"), CLASSLOADER_PHP)?; + std::fs::write( + composer_dir.join("InstalledVersions.php"), + INSTALLED_VERSIONS_PHP, + )?; + std::fs::write(composer_dir.join("LICENSE"), COMPOSER_LICENSE)?; + + // 6. Generate installed.php + std::fs::write( + composer_dir.join("installed.php"), + generate_installed_php(&root_name, &root_type, &installed, config.dev_mode), + )?; + + Ok(()) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use mozart_registry::installed::{InstalledPackageEntry, InstalledPackages}; + use std::collections::BTreeMap; + use tempfile::tempdir; + + fn make_installed_pkg(name: &str, version: &str) -> InstalledPackageEntry { + InstalledPackageEntry { + name: name.to_string(), + version: version.to_string(), + version_normalized: None, + source: None, + dist: None, + package_type: Some("library".to_string()), + install_path: Some(format!("../{name}")), + autoload: None, + aliases: vec![], + extra_fields: BTreeMap::new(), + } + } + + fn make_installed_pkg_with_autoload( + name: &str, + version: &str, + autoload: serde_json::Value, + ) -> InstalledPackageEntry { + let mut entry = make_installed_pkg(name, version); + entry.autoload = Some(autoload); + entry + } + + // ------------------------------------------------------------------------- + // Helper function tests + // ------------------------------------------------------------------------- + + #[test] + fn test_php_escape_backslash() { + assert_eq!(php_escape("Psr\\Log\\"), "Psr\\\\Log\\\\"); + } + + #[test] + fn test_php_escape_quote() { + assert_eq!(php_escape("don't"), "don\\'t"); + } + + #[test] + fn test_php_escape_mixed() { + assert_eq!(php_escape("A\\B'C"), "A\\\\B\\'C"); + } + + #[test] + fn test_file_identifier_known_vector() { + // Known test vector from Composer docs: + // md5("symfony/polyfill-php80:bootstrap.php") = "a4a119a56e50fbb293281d9a48007e0e" + let id = file_identifier("symfony/polyfill-php80", "bootstrap.php"); + assert_eq!(id, "a4a119a56e50fbb293281d9a48007e0e"); + } + + #[test] + fn test_file_identifier_format() { + let id = file_identifier("psr/log", "src/functions.php"); + // Should be 32 hex chars (MD5) + assert_eq!(id.len(), 32); + assert!(id.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_json_to_paths_string() { + let v = serde_json::json!("src/"); + assert_eq!(json_to_paths(&v), vec!["src/"]); + } + + #[test] + fn test_json_to_paths_array() { + let v = serde_json::json!(["src/", "lib/"]); + assert_eq!(json_to_paths(&v), vec!["src/", "lib/"]); + } + + #[test] + fn test_json_to_paths_invalid() { + let v = serde_json::json!(42); + assert!(json_to_paths(&v).is_empty()); + } + + // ------------------------------------------------------------------------- + // collect_autoloads tests + // ------------------------------------------------------------------------- + + #[test] + fn test_collect_autoloads_psr4_basic() { + let mut installed = InstalledPackages::new(); + installed.upsert(make_installed_pkg_with_autoload( + "psr/log", + "3.0.2", + serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}), + )); + + let (data, _static_data) = collect_autoloads(&installed, None, None, "__root__", false); + + assert!(data.psr4.contains_key("Psr\\Log\\")); + let paths = &data.psr4["Psr\\Log\\"]; + assert_eq!(paths.len(), 1); + assert_eq!(paths[0], "$vendorDir . '/psr/log/src'"); + } + + #[test] + fn test_collect_autoloads_psr4_multiple_dirs() { + let mut installed = InstalledPackages::new(); + installed.upsert(make_installed_pkg_with_autoload( + "monolog/monolog", + "3.8.0", + serde_json::json!({"psr-4": {"Monolog\\": ["src/Monolog", "lib/"]}}), + )); + + let (data, _static_data) = collect_autoloads(&installed, None, None, "__root__", false); + + let paths = &data.psr4["Monolog\\"]; + assert_eq!(paths.len(), 2); + assert_eq!(paths[0], "$vendorDir . '/monolog/monolog/src/Monolog'"); + assert_eq!(paths[1], "$vendorDir . '/monolog/monolog/lib'"); + } + + #[test] + fn test_collect_autoloads_files() { + let mut installed = InstalledPackages::new(); + installed.upsert(make_installed_pkg_with_autoload( + "symfony/polyfill-php80", + "1.32.0", + serde_json::json!({"files": ["bootstrap.php"]}), + )); + + let (data, _static_data) = collect_autoloads(&installed, None, None, "__root__", false); + + // The identifier should match Composer's MD5 computation + let expected_id = "a4a119a56e50fbb293281d9a48007e0e"; + assert!(data.files.contains_key(expected_id)); + assert_eq!( + data.files[expected_id], + "$vendorDir . '/symfony/polyfill-php80/bootstrap.php'" + ); + } + + #[test] + fn test_collect_autoloads_root_package() { + let installed = InstalledPackages::new(); + let root_autoload = serde_json::json!({"psr-4": {"App\\": "src/"}}); + + let (data, _static_data) = collect_autoloads( + &installed, + Some(&root_autoload), + None, + "myproject/app", + false, + ); + + assert!(data.psr4.contains_key("App\\")); + let paths = &data.psr4["App\\"]; + assert_eq!(paths[0], "$baseDir . '/src'"); + } + + #[test] + fn test_collect_autoloads_root_autoload_dev_included_when_dev() { + let installed = InstalledPackages::new(); + let root_autoload_dev = serde_json::json!({"psr-4": {"Tests\\": "tests/"}}); + + let (data, _) = collect_autoloads( + &installed, + None, + Some(&root_autoload_dev), + "myproject/app", + true, // dev_mode = true + ); + + assert!(data.psr4.contains_key("Tests\\")); + } + + #[test] + fn test_collect_autoloads_root_autoload_dev_excluded_when_no_dev() { + let installed = InstalledPackages::new(); + let root_autoload_dev = serde_json::json!({"psr-4": {"Tests\\": "tests/"}}); + + let (data, _) = collect_autoloads( + &installed, + None, + Some(&root_autoload_dev), + "myproject/app", + false, // dev_mode = false + ); + + assert!(!data.psr4.contains_key("Tests\\")); + } + + // ------------------------------------------------------------------------- + // generate_autoload_psr4 tests + // ------------------------------------------------------------------------- + + #[test] + fn test_generate_autoload_psr4_output() { + let mut installed = InstalledPackages::new(); + installed.upsert(make_installed_pkg_with_autoload( + "psr/log", + "3.0.2", + serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}), + )); + + let (data, _) = collect_autoloads(&installed, None, None, "__root__", false); + let output = generate_autoload_psr4(&data); + + assert!(output.contains("<?php")); + assert!(output.contains("autoload_psr4.php @generated by Composer")); + assert!(output.contains("$vendorDir = dirname(__DIR__);")); + assert!(output.contains("$baseDir = dirname($vendorDir);")); + assert!(output.contains("'Psr\\\\Log\\\\'")); + assert!(output.contains("$vendorDir . '/psr/log/src'")); + assert!(output.starts_with("<?php\n")); + } + + #[test] + fn test_generate_autoload_psr4_empty() { + let data = AutoloadData { + psr4: BTreeMap::new(), + psr0: BTreeMap::new(), + classmap: BTreeMap::new(), + files: BTreeMap::new(), + }; + let output = generate_autoload_psr4(&data); + assert!(output.contains("return array(\n);")); + } + + #[test] + fn test_generate_autoload_psr4_sorted_reverse() { + let mut installed = InstalledPackages::new(); + installed.upsert(make_installed_pkg_with_autoload( + "aaa/pkg", + "1.0.0", + serde_json::json!({"psr-4": {"Aaa\\": "src/"}}), + )); + installed.upsert(make_installed_pkg_with_autoload( + "zzz/pkg", + "1.0.0", + serde_json::json!({"psr-4": {"Zzz\\": "src/"}}), + )); + + let (data, _) = collect_autoloads(&installed, None, None, "__root__", false); + let output = generate_autoload_psr4(&data); + + // Zzz should appear before Aaa (reverse sort) + let zzz_pos = output.find("Zzz").unwrap(); + let aaa_pos = output.find("Aaa").unwrap(); + assert!( + zzz_pos < aaa_pos, + "Zzz should appear before Aaa (reverse sort)" + ); + } + + // ------------------------------------------------------------------------- + // generate_autoload_static tests + // ------------------------------------------------------------------------- + + #[test] + fn test_generate_autoload_static_output() { + let mut installed = InstalledPackages::new(); + installed.upsert(make_installed_pkg_with_autoload( + "psr/log", + "3.0.2", + serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}), + )); + + let (_, static_data) = collect_autoloads(&installed, None, None, "__root__", false); + let output = generate_autoload_static(&static_data, "abc123"); + + assert!(output.contains("class ComposerStaticInitabc123")); + assert!(output.contains("$prefixLengthsPsr4")); + assert!(output.contains("$prefixDirsPsr4")); + assert!(output.contains("$classMap")); + assert!(output.contains("Composer\\\\InstalledVersions")); + assert!(output.contains("getInitializer")); + assert!(output.contains("__DIR__ . '/..' . '/psr/log/src'")); + } + + #[test] + fn test_generate_autoload_static_prefix_lengths() { + let mut installed = InstalledPackages::new(); + // "Psr\Log\" = 8 bytes (with single backslashes) + installed.upsert(make_installed_pkg_with_autoload( + "psr/log", + "3.0.2", + serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}), + )); + + let (_, static_data) = collect_autoloads(&installed, None, None, "__root__", false); + let output = generate_autoload_static(&static_data, "test"); + + // The namespace "Psr\Log\" is 8 bytes + assert!(output.contains("'Psr\\\\Log\\\\' => 8")); + } + + // ------------------------------------------------------------------------- + // generate_autoload_real tests + // ------------------------------------------------------------------------- + + #[test] + fn test_generate_autoload_real_with_files() { + let output = generate_autoload_real("abc123", true, false, false, None, false); + assert!(output.contains("class ComposerAutoloaderInitabc123")); + assert!(output.contains("ComposerStaticInitabc123::$files")); + assert!(output.contains("$requireFile")); + assert!(output.contains("__composer_autoload_files")); + } + + #[test] + fn test_generate_autoload_real_without_files() { + let output = generate_autoload_real("abc123", false, false, false, None, false); + assert!(output.contains("class ComposerAutoloaderInitabc123")); + assert!(!output.contains("$filesToLoad")); + assert!(!output.contains("__composer_autoload_files")); + } + + #[test] + fn test_generate_autoload_real_apcu() { + let output = generate_autoload_real("abc123", false, false, true, None, false); + assert!(output.contains("setApcuPrefix('abc123')")); + } + + #[test] + fn test_generate_autoload_real_apcu_custom_prefix() { + let output = generate_autoload_real("abc123", false, false, true, Some("myprefix"), false); + assert!(output.contains("setApcuPrefix('myprefix')")); + } + + #[test] + fn test_generate_autoload_real_platform_check() { + let output = generate_autoload_real("abc123", false, false, false, None, true); + assert!(output.contains("require __DIR__ . '/platform_check.php'")); + } + + #[test] + fn test_generate_autoload_real_no_platform_check() { + let output = generate_autoload_real("abc123", false, false, false, None, false); + assert!(!output.contains("platform_check.php")); + } + + // ------------------------------------------------------------------------- + // generate_installed_php tests + // ------------------------------------------------------------------------- + + #[test] + fn test_generate_installed_php() { + let mut installed = InstalledPackages::new(); + let mut pkg = make_installed_pkg("psr/log", "3.0.2"); + pkg.version_normalized = Some("3.0.2.0".to_string()); + installed.upsert(pkg); + + let output = generate_installed_php("myproject/app", "project", &installed, true); + + assert!(output.contains("'name' => 'myproject/app'")); + assert!(output.contains("'type' => 'project'")); + assert!(output.contains("'dev' => true")); + assert!(output.contains("'psr/log'")); + assert!(output.contains("'pretty_version' => '3.0.2'")); + assert!(output.contains("'version' => '3.0.2.0'")); + assert!(output.contains("__DIR__ . '/../psr/log/'")); + assert!(output.contains("'dev_requirement' => false")); + } + + #[test] + fn test_generate_installed_php_dev_package() { + let mut installed = InstalledPackages::new(); + installed.upsert(make_installed_pkg("phpunit/phpunit", "11.0.0")); + installed + .dev_package_names + .push("phpunit/phpunit".to_string()); + + let output = generate_installed_php("test/project", "project", &installed, true); + + assert!(output.contains("'dev_requirement' => true")); + } + + // ------------------------------------------------------------------------- + // generate() integration test + // ------------------------------------------------------------------------- + + #[test] + fn test_generate_full_roundtrip() { + let dir = tempdir().unwrap(); + let project_dir = dir.path().to_path_buf(); + let vendor_dir = project_dir.join("vendor"); + + // Write a minimal composer.json + std::fs::write( + project_dir.join("composer.json"), + r#"{"name": "test/project", "type": "project", "autoload": {"psr-4": {"App\\": "src/"}}}"#, + ) + .unwrap(); + + // Write a minimal installed.json + let mut installed = InstalledPackages::new(); + installed.upsert(make_installed_pkg_with_autoload( + "psr/log", + "3.0.2", + serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}), + )); + installed.write(&vendor_dir).unwrap(); + + let config = AutoloadConfig { + project_dir: project_dir.clone(), + vendor_dir: vendor_dir.clone(), + dev_mode: false, + suffix: "abc123def456".to_string(), + classmap_authoritative: false, + optimize: false, + apcu: false, + apcu_prefix: None, + strict_psr: false, + platform_check: PlatformCheckMode::Disabled, + ignore_platform_reqs: false, + }; + + generate(&config).unwrap(); + + // Verify all expected files exist + assert!( + vendor_dir.join("autoload.php").exists(), + "autoload.php should exist" + ); + assert!( + vendor_dir.join("composer/autoload_psr4.php").exists(), + "autoload_psr4.php should exist" + ); + assert!( + vendor_dir.join("composer/autoload_namespaces.php").exists(), + "autoload_namespaces.php should exist" + ); + assert!( + vendor_dir.join("composer/autoload_classmap.php").exists(), + "autoload_classmap.php should exist" + ); + assert!( + vendor_dir.join("composer/autoload_static.php").exists(), + "autoload_static.php should exist" + ); + assert!( + vendor_dir.join("composer/autoload_real.php").exists(), + "autoload_real.php should exist" + ); + assert!( + vendor_dir.join("composer/ClassLoader.php").exists(), + "ClassLoader.php should exist" + ); + assert!( + vendor_dir.join("composer/InstalledVersions.php").exists(), + "InstalledVersions.php should exist" + ); + assert!( + vendor_dir.join("composer/installed.php").exists(), + "installed.php should exist" + ); + assert!( + vendor_dir.join("composer/LICENSE").exists(), + "LICENSE should exist" + ); + // autoload_files.php should NOT exist (no files autoloading) + assert!( + !vendor_dir.join("composer/autoload_files.php").exists(), + "autoload_files.php should not exist when no files" + ); + + // Check autoload.php content + let autoload_php = std::fs::read_to_string(vendor_dir.join("autoload.php")).unwrap(); + assert!(autoload_php.contains("ComposerAutoloaderInitabc123def456")); + + // Check autoload_psr4.php + let psr4_php = + std::fs::read_to_string(vendor_dir.join("composer/autoload_psr4.php")).unwrap(); + assert!(psr4_php.contains("Psr\\\\Log\\\\")); + assert!(psr4_php.contains("App\\\\")); + assert!(psr4_php.contains("$vendorDir . '/psr/log/src'")); + assert!(psr4_php.contains("$baseDir . '/src'")); + + // Check installed.php + let installed_php = + std::fs::read_to_string(vendor_dir.join("composer/installed.php")).unwrap(); + assert!(installed_php.contains("'name' => 'test/project'")); + assert!(installed_php.contains("'psr/log'")); + } + + #[test] + fn test_generate_with_files_autoload() { + let dir = tempdir().unwrap(); + let project_dir = dir.path().to_path_buf(); + let vendor_dir = project_dir.join("vendor"); + + std::fs::write( + project_dir.join("composer.json"), + r#"{"name": "test/project", "type": "project"}"#, + ) + .unwrap(); + + let mut installed = InstalledPackages::new(); + installed.upsert(make_installed_pkg_with_autoload( + "symfony/polyfill-php80", + "1.32.0", + serde_json::json!({"files": ["bootstrap.php"]}), + )); + installed.write(&vendor_dir).unwrap(); + + let config = AutoloadConfig { + project_dir: project_dir.clone(), + vendor_dir: vendor_dir.clone(), + dev_mode: false, + suffix: "test".to_string(), + classmap_authoritative: false, + optimize: false, + apcu: false, + apcu_prefix: None, + strict_psr: false, + platform_check: PlatformCheckMode::Disabled, + ignore_platform_reqs: false, + }; + + generate(&config).unwrap(); + + // autoload_files.php SHOULD exist + assert!( + vendor_dir.join("composer/autoload_files.php").exists(), + "autoload_files.php should exist when files are present" + ); + + let files_php = + std::fs::read_to_string(vendor_dir.join("composer/autoload_files.php")).unwrap(); + assert!(files_php.contains("a4a119a56e50fbb293281d9a48007e0e")); + assert!(files_php.contains("$vendorDir . '/symfony/polyfill-php80/bootstrap.php'")); + + // autoload_real.php should contain the files loading block + let real_php = + std::fs::read_to_string(vendor_dir.join("composer/autoload_real.php")).unwrap(); + assert!(real_php.contains("$filesToLoad")); + } +} diff --git a/crates/mozart-autoload/src/lib.rs b/crates/mozart-autoload/src/lib.rs new file mode 100644 index 0000000..9d798c6 --- /dev/null +++ b/crates/mozart-autoload/src/lib.rs @@ -0,0 +1,2 @@ +pub mod autoload; +pub mod php_scanner; diff --git a/crates/mozart-autoload/src/php_scanner.rs b/crates/mozart-autoload/src/php_scanner.rs new file mode 100644 index 0000000..3d0d51d --- /dev/null +++ b/crates/mozart-autoload/src/php_scanner.rs @@ -0,0 +1,629 @@ +use anyhow::Result; +use regex::Regex; +use std::path::Path; + +/// File extensions considered PHP source files for class scanning. +const PHP_EXTENSIONS: &[&str] = &["php", "inc", "hh"]; + +/// Check if a file path has a PHP-like extension. +fn is_php_file(path: &Path) -> bool { + is_php_ext(path) +} + +/// Public version of the PHP extension check, used by the autoload scanner. +pub fn is_php_ext(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .map(|ext| PHP_EXTENSIONS.iter().any(|&e| ext.eq_ignore_ascii_case(e))) + .unwrap_or(false) +} + +/// Scan a PHP file and return the list of fully-qualified class names declared in it. +/// +/// Returns an empty vec if the file has no relevant extension or no class declarations. +pub fn find_classes(path: &Path) -> Result<Vec<String>> { + if !is_php_file(path) { + return Ok(vec![]); + } + + let contents = std::fs::read_to_string(path)?; + + // Quick check: does the file even contain a class-like keyword? + let quick_re = Regex::new(r"(?i)\b(?:class|interface|trait|enum)\s").unwrap(); + if !quick_re.is_match(&contents) { + return Ok(vec![]); + } + + let cleaned = clean_php_content(&contents); + Ok(extract_declarations(&cleaned)) +} + +/// State machine that strips strings, comments, and heredocs/nowdocs from PHP code. +/// +/// Returns a string of equal byte length where non-PHP content is replaced with spaces +/// so that regex offsets are preserved. Only PHP mode content is kept; everything else +/// is blanked out. +fn clean_php_content(contents: &str) -> String { + let bytes = contents.as_bytes(); + let len = bytes.len(); + let mut out = vec![b' '; len]; + let mut i = 0; + let mut in_php = false; + + while i < len { + if !in_php { + // Look for `<?` + if i + 1 < len && bytes[i] == b'<' && bytes[i + 1] == b'?' { + in_php = true; + out[i] = b' '; + out[i + 1] = b' '; + i += 2; + // Skip optional "php" or "=" + if i + 3 <= len && bytes[i..i + 3].eq_ignore_ascii_case(b"php") { + i += 3; + } else if i < len && bytes[i] == b'=' { + i += 1; + } + continue; + } + i += 1; + continue; + } + + // In PHP mode + // Check for `?>` + if i + 1 < len && bytes[i] == b'?' && bytes[i + 1] == b'>' { + in_php = false; + i += 2; + continue; + } + + // Line comment: // or # + if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'/' { + // Skip to end of line + while i < len && bytes[i] != b'\n' { + i += 1; + } + continue; + } + if bytes[i] == b'#' { + while i < len && bytes[i] != b'\n' { + i += 1; + } + continue; + } + + // Block comment: /* ... */ + if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' { + i += 2; + while i + 1 < len { + if bytes[i] == b'*' && bytes[i + 1] == b'/' { + i += 2; + break; + } + i += 1; + } + continue; + } + + // Single-quoted string + if bytes[i] == b'\'' { + out[i] = b'\''; + i += 1; + while i < len { + if bytes[i] == b'\\' && i + 1 < len { + // escaped character — blank both + i += 2; + } else if bytes[i] == b'\'' { + out[i] = b'\''; + i += 1; + break; + } else { + i += 1; + } + } + continue; + } + + // Double-quoted string + if bytes[i] == b'"' { + out[i] = b'"'; + i += 1; + while i < len { + if bytes[i] == b'\\' && i + 1 < len { + i += 2; + } else if bytes[i] == b'"' { + out[i] = b'"'; + i += 1; + break; + } else { + i += 1; + } + } + continue; + } + + // Heredoc / Nowdoc: <<< + if i + 2 < len && bytes[i] == b'<' && bytes[i + 1] == b'<' && bytes[i + 2] == b'<' { + i += 3; + // Skip whitespace + while i < len && (bytes[i] == b' ' || bytes[i] == b'\t') { + i += 1; + } + + // Nowdoc uses single quotes around label; heredoc may use double quotes. + let is_nowdoc = i < len && bytes[i] == b'\''; + // Skip optional opening quote (single for nowdoc, double for heredoc) + if i < len && (bytes[i] == b'\'' || bytes[i] == b'"') { + i += 1; + } + + // Read label + let label_start = i; + while i < len && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { + i += 1; + } + let label = std::str::from_utf8(&bytes[label_start..i]) + .unwrap_or("") + .to_string(); + + // Skip closing quote of label (must match the opening quote) + let expected_close = if is_nowdoc { b'\'' } else { b'"' }; + if i < len && bytes[i] == expected_close { + i += 1; + } + + // Skip to end of line + while i < len && bytes[i] != b'\n' { + i += 1; + } + if i < len { + i += 1; // consume newline + } + + // Scan for the terminator label on its own line + if !label.is_empty() { + loop { + if i >= len { + break; + } + // Check if current line starts with the label + let line_start = i; + // Skip optional whitespace for indented heredoc (PHP 7.3+) + while i < len && (bytes[i] == b' ' || bytes[i] == b'\t') { + i += 1; + } + let remaining = &bytes[i..]; + let label_bytes = label.as_bytes(); + if remaining.len() >= label_bytes.len() + && &remaining[..label_bytes.len()] == label_bytes + { + let after = i + label_bytes.len(); + // Terminator must be followed by ; or newline or EOF + if after >= len + || bytes[after] == b';' + || bytes[after] == b'\n' + || bytes[after] == b'\r' + { + // Skip to end of this line + i = after; + while i < len && bytes[i] != b'\n' { + i += 1; + } + if i < len { + i += 1; + } + break; + } + } + // Not a terminator line — skip to end of line + i = line_start; + while i < len && bytes[i] != b'\n' { + i += 1; + } + if i < len { + i += 1; + } + } + } + continue; + } + + // Backtick strings (shell exec) + if bytes[i] == b'`' { + out[i] = b'`'; + i += 1; + while i < len { + if bytes[i] == b'\\' && i + 1 < len { + i += 2; + } else if bytes[i] == b'`' { + out[i] = b'`'; + i += 1; + break; + } else { + i += 1; + } + } + continue; + } + + // Keep normal PHP content + out[i] = bytes[i]; + i += 1; + } + + String::from_utf8_lossy(&out).into_owned() +} + +/// Extract fully-qualified class names from cleaned PHP content. +/// +/// Tracks the current namespace and finds class/interface/trait/enum declarations. +fn extract_declarations(cleaned: &str) -> Vec<String> { + let mut results = Vec::new(); + + // Regex for namespace declarations: + // namespace Foo\Bar; — simple + // namespace Foo\Bar { — block + // namespace { — global block + let ns_re = Regex::new( + r"(?x) + \bnamespace\s+ + ((?:[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*\\)*[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*) + \s*[;{] + | + \bnamespace\s*\{ + ", + ) + .unwrap(); + + // Regex for class/interface/trait/enum declarations. + // We need to capture the name; anonymous classes (new class ...) are excluded. + let decl_re = Regex::new( + r"(?x) + \b(?:abstract\s+|final\s+|readonly\s+)* + (?P<kind>class|interface|trait|enum)\s+ + (?P<name>[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*) + ", + ) + .unwrap(); + + let mut current_ns = String::new(); + + // We process namespace changes as we walk through the file. + // Build a list of all namespace and declaration positions. + #[derive(Debug)] + enum Event { + Namespace(usize, String), // position, namespace + Declaration(usize, String), // position, simple name + } + + let mut events: Vec<Event> = Vec::new(); + + // Find namespace declarations + for cap in ns_re.captures_iter(cleaned) { + let pos = cap.get(0).unwrap().start(); + let ns_name = cap + .get(1) + .map(|m| m.as_str().to_string()) + .unwrap_or_default(); + events.push(Event::Namespace(pos, ns_name)); + } + + // Find class/interface/trait/enum declarations + for cap in decl_re.captures_iter(cleaned) { + let pos = cap.get(0).unwrap().start(); + let name = cap.name("name").unwrap().as_str().to_string(); + + // Skip anonymous classes: check if "new" precedes "class" on the same "expression". + // A reliable check: look back for "new " before this match. + let before = &cleaned[..pos]; + let kind = cap.name("kind").unwrap().as_str(); + if kind == "class" { + // Check if "new" appears right before (with possible whitespace/modifiers). + // Simple heuristic: scan backwards for non-whitespace token. + let trimmed = before.trim_end(); + if trimmed.ends_with("new") { + continue; + } + } + + events.push(Event::Declaration(pos, name)); + } + + // Sort all events by position + events.sort_by_key(|e| match e { + Event::Namespace(pos, _) => *pos, + Event::Declaration(pos, _) => *pos, + }); + + // Process events in order + for event in events { + match event { + Event::Namespace(_, ns) => { + current_ns = ns; + } + Event::Declaration(_, name) => { + let fqn = if current_ns.is_empty() { + name + } else { + format!("{}\\{}", current_ns, name) + }; + results.push(fqn); + } + } + } + + results +} + +/// Validate that a class file is correctly placed according to PSR-4. +/// +/// - `class`: fully-qualified class name (e.g. `Foo\Bar\Baz`) +/// - `base_namespace`: the PSR-4 namespace prefix (e.g. `Foo\Bar\`) +/// - `file_path`: absolute path to the PHP file +/// - `base_path`: the directory mapped to `base_namespace` (absolute) +/// +/// Returns `true` if the file path matches the PSR-4 mapping. +pub fn validate_psr4_class( + class: &str, + base_namespace: &str, + file_path: &str, + base_path: &str, +) -> bool { + // Normalize the base namespace: ensure it ends with `\` + let base_ns = if base_namespace.is_empty() || base_namespace.ends_with('\\') { + base_namespace.to_string() + } else { + format!("{base_namespace}\\") + }; + + // Class must start with the base namespace + if !class.starts_with(&*base_ns) { + return false; + } + + // The relative class name after the base namespace + let relative_class = &class[base_ns.len()..]; + + // Convert relative class to a relative file path: replace `\` with `/` + let expected_relative = relative_class.replace('\\', "/"); + let expected_file = format!( + "{}/{}.php", + base_path.trim_end_matches('/'), + expected_relative + ); + + // Normalize both paths for comparison (simplistic: just compare strings) + Path::new(file_path) == Path::new(&expected_file) +} + +/// Validate that a class file is correctly placed according to PSR-0. +/// +/// - `class`: fully-qualified class name (e.g. `Foo_Bar_Baz` or `Foo\Bar`) +/// - `file_path`: absolute path to the PHP file +/// - `base_path`: the base directory for PSR-0 lookup +/// +/// Returns `true` if the file path matches the PSR-0 mapping. +pub fn validate_psr0_class(class: &str, file_path: &str, base_path: &str) -> bool { + // PSR-0: namespace separators AND underscores (in class part) map to directory separators. + // Split on `\` first; the last segment may contain underscores that also become `/`. + let parts: Vec<&str> = class.split('\\').collect(); + let relative = if parts.len() == 1 { + // No namespace: underscores in class name become dir separators + parts[0].replace('_', "/") + } else { + let ns_part = parts[..parts.len() - 1].join("/"); + let class_part = parts[parts.len() - 1].replace('_', "/"); + format!("{}/{}", ns_part, class_part) + }; + + let expected_file = format!("{}/{}.php", base_path.trim_end_matches('/'), relative); + Path::new(file_path) == Path::new(&expected_file) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + fn write_php(content: &str) -> NamedTempFile { + let mut f = NamedTempFile::with_suffix(".php").unwrap(); + f.write_all(content.as_bytes()).unwrap(); + f + } + + // ------------------------------------------------------------------------- + // find_classes tests + // ------------------------------------------------------------------------- + + #[test] + fn test_find_classes_simple_class() { + let f = write_php("<?php\nclass Foo {}\n"); + let classes = find_classes(f.path()).unwrap(); + assert_eq!(classes, vec!["Foo"]); + } + + #[test] + fn test_find_classes_with_namespace() { + let f = write_php("<?php\nnamespace Foo\\Bar;\nclass Baz {}\n"); + let classes = find_classes(f.path()).unwrap(); + assert_eq!(classes, vec!["Foo\\Bar\\Baz"]); + } + + #[test] + fn test_find_classes_multiple_classes() { + let f = write_php("<?php\nnamespace App;\nclass Foo {}\nclass Bar {}\ninterface Baz {}\n"); + let classes = find_classes(f.path()).unwrap(); + assert_eq!(classes, vec!["App\\Foo", "App\\Bar", "App\\Baz"]); + } + + #[test] + fn test_find_classes_interface() { + let f = write_php("<?php\ninterface MyInterface {}\n"); + let classes = find_classes(f.path()).unwrap(); + assert_eq!(classes, vec!["MyInterface"]); + } + + #[test] + fn test_find_classes_trait() { + let f = write_php("<?php\ntrait MyTrait {}\n"); + let classes = find_classes(f.path()).unwrap(); + assert_eq!(classes, vec!["MyTrait"]); + } + + #[test] + fn test_find_classes_enum() { + let f = write_php("<?php\nenum Status {}\n"); + let classes = find_classes(f.path()).unwrap(); + assert_eq!(classes, vec!["Status"]); + } + + #[test] + fn test_find_classes_enum_with_backing_type() { + let f = write_php("<?php\nenum Color: string { case Red = 'red'; }\n"); + let classes = find_classes(f.path()).unwrap(); + assert_eq!(classes, vec!["Color"]); + } + + #[test] + fn test_find_classes_anonymous_class_skipped() { + let f = write_php("<?php\n$obj = new class {};\n"); + let classes = find_classes(f.path()).unwrap(); + assert!(classes.is_empty(), "anonymous class should not be scanned"); + } + + #[test] + fn test_find_classes_comments_ignored() { + let f = write_php( + "<?php\n// class FakeClass {}\n/* interface FakeInterface {} */\nclass RealClass {}\n", + ); + let classes = find_classes(f.path()).unwrap(); + assert_eq!(classes, vec!["RealClass"]); + } + + #[test] + fn test_find_classes_strings_ignored() { + let f = write_php( + "<?php\n$s = 'class NotAClass {}';\n$t = \"interface NotAnInterface {}\";\nclass RealClass {}\n", + ); + let classes = find_classes(f.path()).unwrap(); + assert_eq!(classes, vec!["RealClass"]); + } + + #[test] + fn test_find_classes_heredoc_ignored() { + let f = write_php("<?php\n$s = <<<EOT\nclass FakeClass {}\nEOT;\nclass RealClass {}\n"); + let classes = find_classes(f.path()).unwrap(); + assert_eq!(classes, vec!["RealClass"]); + } + + #[test] + fn test_find_classes_empty_file() { + let f = write_php("<?php\n// nothing here\n"); + let classes = find_classes(f.path()).unwrap(); + assert!(classes.is_empty()); + } + + #[test] + fn test_find_classes_no_classes() { + let f = write_php("<?php\necho 'hello';\n"); + let classes = find_classes(f.path()).unwrap(); + assert!(classes.is_empty()); + } + + #[test] + fn test_find_classes_abstract_final() { + let f = write_php("<?php\nabstract class AbstractFoo {}\nfinal class FinalBar {}\n"); + let classes = find_classes(f.path()).unwrap(); + assert!(classes.contains(&"AbstractFoo".to_string())); + assert!(classes.contains(&"FinalBar".to_string())); + } + + #[test] + fn test_find_classes_non_php_extension() { + let mut f = NamedTempFile::with_suffix(".txt").unwrap(); + f.write_all(b"<?php\nclass Foo {}\n").unwrap(); + let classes = find_classes(f.path()).unwrap(); + assert!(classes.is_empty(), "non-PHP extension should be skipped"); + } + + // ------------------------------------------------------------------------- + // PSR-4 validation tests + // ------------------------------------------------------------------------- + + #[test] + fn test_validate_psr4_correct() { + assert!(validate_psr4_class( + "Foo\\Bar\\Baz", + "Foo\\Bar\\", + "/srv/project/src/Baz.php", + "/srv/project/src" + )); + } + + #[test] + fn test_validate_psr4_wrong_path() { + assert!(!validate_psr4_class( + "Foo\\Bar\\Baz", + "Foo\\Bar\\", + "/srv/project/src/Wrong.php", + "/srv/project/src" + )); + } + + #[test] + fn test_validate_psr4_namespace_mismatch() { + assert!(!validate_psr4_class( + "Other\\Baz", + "Foo\\Bar\\", + "/srv/project/src/Baz.php", + "/srv/project/src" + )); + } + + #[test] + fn test_validate_psr4_nested() { + assert!(validate_psr4_class( + "App\\Http\\Controllers\\HomeController", + "App\\", + "/project/src/Http/Controllers/HomeController.php", + "/project/src" + )); + } + + // ------------------------------------------------------------------------- + // PSR-0 validation tests + // ------------------------------------------------------------------------- + + #[test] + fn test_validate_psr0_simple() { + assert!(validate_psr0_class( + "Foo_Bar_Baz", + "/srv/project/src/Foo/Bar/Baz.php", + "/srv/project/src" + )); + } + + #[test] + fn test_validate_psr0_with_namespace() { + assert!(validate_psr0_class( + "Foo\\Bar", + "/srv/project/src/Foo/Bar.php", + "/srv/project/src" + )); + } + + #[test] + fn test_validate_psr0_wrong_path() { + assert!(!validate_psr0_class( + "Foo_Bar", + "/srv/project/src/Foo/Baz.php", + "/srv/project/src" + )); + } +} diff --git a/crates/mozart-constraint/Cargo.toml b/crates/mozart-constraint/Cargo.toml new file mode 100644 index 0000000..49265d7 --- /dev/null +++ b/crates/mozart-constraint/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "mozart-constraint" +version.workspace = true +edition.workspace = true diff --git a/crates/mozart-constraint/src/lib.rs b/crates/mozart-constraint/src/lib.rs new file mode 100644 index 0000000..e41818c --- /dev/null +++ b/crates/mozart-constraint/src/lib.rs @@ -0,0 +1,1972 @@ +use std::cmp::Ordering; + +/// A parsed Composer version (always 4 numeric segments + optional stability suffix). +/// Composer normalizes all versions to `major.minor.patch.build[-stability[N]]`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Version { + pub major: u64, + pub minor: u64, + pub patch: u64, + pub build: u64, + /// None = stable, Some("alpha1"), Some("beta2"), Some("RC1"), Some("dev") + pub pre_release: Option<String>, + /// true for "dev-master", "dev-feature/foo", etc. + pub is_dev_branch: bool, + /// The original branch name for dev branches (e.g. "master", "feature/foo") + pub dev_branch_name: Option<String>, +} + +/// Stability rank for ordering (lower = more stable). +fn stability_rank(pre: &str) -> u8 { + let lower = pre.to_lowercase(); + if lower.starts_with("dev") { + 50 + } else if lower.starts_with("alpha") || lower.starts_with("a") { + 40 + } else if lower.starts_with("beta") || lower.starts_with("b") { + 30 + } else if lower.starts_with("rc") { + 20 + } else if lower.starts_with("patch") || lower.starts_with("pl") || lower == "p" { + 5 + } else { + 0 + } +} + +/// Extract numeric suffix from a pre-release string like "alpha1" → 1, "beta" → 0 +fn pre_release_number(pre: &str) -> u64 { + let digits: String = pre.chars().skip_while(|c| c.is_alphabetic()).collect(); + digits.parse().unwrap_or(0) +} + +impl PartialOrd for Version { + fn partial_cmp(&self, other: &Self) -> Option<Ordering> { + Some(self.cmp(other)) + } +} + +impl Ord for Version { + fn cmp(&self, other: &Self) -> Ordering { + // Dev branches are always lowest + match (self.is_dev_branch, other.is_dev_branch) { + (true, true) => { + // Compare branch names + return self.dev_branch_name.cmp(&other.dev_branch_name); + } + (true, false) => return Ordering::Less, + (false, true) => return Ordering::Greater, + (false, false) => {} + } + + // Compare numeric segments + let num_cmp = (self.major, self.minor, self.patch, self.build).cmp(&( + other.major, + other.minor, + other.patch, + other.build, + )); + if num_cmp != Ordering::Equal { + return num_cmp; + } + + // Compare pre-release: None (stable) > any pre-release + match (&self.pre_release, &other.pre_release) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Greater, + (Some(_), None) => Ordering::Less, + (Some(a), Some(b)) => { + let rank_a = stability_rank(a); + let rank_b = stability_rank(b); + match rank_a.cmp(&rank_b) { + Ordering::Equal => { + // Same stability: compare numeric suffix + pre_release_number(a).cmp(&pre_release_number(b)) + } + // Lower rank = more stable = greater version + Ordering::Less => Ordering::Greater, + Ordering::Greater => Ordering::Less, + } + } + } + } +} + +impl Version { + /// Parse a version string into a `Version` struct using Composer normalization rules. + /// + /// For inline aliases (`"1.0.x-dev as 1.0.0"`), the LEFT side (the real branch version) + /// is used. This is the correct behaviour for identifying *what* version a package provides. + pub fn parse(input: &str) -> Result<Version, String> { + let s = input.trim(); + + // Strip inline alias: "1.0.x-dev as 1.0.0" → "1.0.x-dev" + let s = if let Some(pos) = s.find(" as ") { + &s[..pos] + } else { + s + }; + + // Strip stability flag: "@dev", "@alpha", "@beta", "@RC", "@stable" + let s = if let Some(pos) = s.rfind('@') { + let after = &s[pos + 1..]; + let known = ["dev", "alpha", "beta", "rc", "stable"]; + if known.iter().any(|k| after.eq_ignore_ascii_case(k)) { + &s[..pos] + } else { + s + } + } else { + s + }; + + // Handle dev-* prefix branches + if s.to_lowercase().starts_with("dev-") { + let branch = &s[4..]; + return Ok(Version { + major: 0, + minor: 0, + patch: 0, + build: 0, + pre_release: Some("dev".to_string()), + is_dev_branch: true, + dev_branch_name: Some(branch.to_string()), + }); + } + + // Handle *-dev suffix (e.g., "2.1.x-dev" or "2.x-dev") + let s_lower = s.to_lowercase(); + if s_lower.ends_with("-dev") || s_lower.ends_with(".x-dev") { + let base = if s_lower.ends_with("-dev") { + &s[..s.len() - 4] + } else { + s + }; + // Replace any trailing .x with nothing, parse numeric parts + let base = base.trim_end_matches(".x").trim_end_matches("-dev"); + let parts: Vec<&str> = base.split('.').collect(); + let major = parts.first().and_then(|p| p.parse().ok()).unwrap_or(0); + let minor = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); + return Ok(Version { + major, + minor, + patch: 9999999, + build: 9999999, + pre_release: Some("dev".to_string()), + is_dev_branch: true, + dev_branch_name: None, + }); + } + + // Strip leading v/V + let s = s + .strip_prefix('v') + .or_else(|| s.strip_prefix('V')) + .unwrap_or(s); + + // Strip build metadata after + + let s = s.split('+').next().unwrap_or(s); + + // Parse the version using regex-like approach + parse_classical_version(s) + } + + /// Parse a version string for use inside a *constraint expression*. + /// + /// The difference from [`Version::parse`] is the treatment of inline aliases: + /// `"1.0.x-dev as 1.0.0"` → takes the **right** side (`1.0.0`). + /// + /// Inline aliases appear in `require` fields like: + /// ```text + /// "some/package": "1.0.x-dev as 1.0.0" + /// ``` + /// Here the author wants the constraint to be satisfied by the real version `1.0.0`, + /// while the left side (`1.0.x-dev`) indicates the branch that provides it. + pub fn parse_for_constraint(input: &str) -> Result<Version, String> { + let s = input.trim(); + // For inline aliases, take the RIGHT side (alias target) + let s = if let Some(pos) = s.find(" as ") { + s[pos + 4..].trim() + } else { + s + }; + Version::parse(s) + } + + /// Create a "dev boundary" version for constraint matching (major.minor.patch.build with dev pre-release). + pub fn dev_boundary(major: u64, minor: u64, patch: u64, build: u64) -> Version { + Version { + major, + minor, + patch, + build, + pre_release: Some("dev".to_string()), + is_dev_branch: false, + dev_branch_name: None, + } + } +} + +fn parse_classical_version(s: &str) -> Result<Version, String> { + // Split on '-' to separate version from 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 Err(format!("Invalid version: {s}")); + } + + let major: u64 = segments[0] + .parse() + .map_err(|_| format!("Invalid major version segment: {}", segments[0]))?; + let minor: u64 = segments.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); + let patch: u64 = segments + .get(2) + .and_then(|p| { + // strip trailing .x + let p = p.trim_end_matches('x').trim_end_matches('.'); + if p.is_empty() { + Some(0) + } else { + p.parse().ok() + } + }) + .unwrap_or(0); + let build: u64 = segments.get(3).and_then(|p| p.parse().ok()).unwrap_or(0); + + let pre_release = pre_part.map(normalize_pre_release); + + Ok(Version { + major, + minor, + patch, + build, + pre_release, + is_dev_branch: false, + dev_branch_name: None, + }) +} + +fn normalize_pre_release(s: &str) -> String { + // Normalize aliases: b→beta, a→alpha, rc→RC, p/pl/patch→patch + let lower = s.to_lowercase(); + // Strip leading non-alpha characters (dots, underscores, dashes used as separators) + let normalized = lower + .trim_start_matches(|c: char| !c.is_alphabetic()) + .to_string(); + + // Extract the alphabetic prefix (stability name) + let alpha: String = normalized + .chars() + .take_while(|c| c.is_alphabetic()) + .collect(); + // Extract only digits from the rest (strip separators like dots) + let num: String = normalized + .chars() + .skip_while(|c| c.is_alphabetic()) + .filter(|c| c.is_ascii_digit()) + .collect(); + + if alpha.starts_with("beta") || alpha == "b" { + format!("beta{num}") + } else if alpha.starts_with("alpha") || alpha == "a" { + format!("alpha{num}") + } else if alpha == "rc" { + format!("RC{num}") + } else if alpha == "patch" || alpha == "pl" || alpha == "p" { + format!("patch{num}") + } else if alpha == "dev" { + "dev".to_string() + } else { + s.to_string() + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Constraint types +// ───────────────────────────────────────────────────────────────────────────── + +/// A single atomic constraint. +#[derive(Debug, Clone)] +pub enum Constraint { + /// Exact version match + Exact(Version), + /// Greater than: `> 1.2.3` + GreaterThan(Version), + /// Greater than or equal: `>= 1.2.3` + GreaterThanOrEqual(Version), + /// Less than: `< 1.2.3` + LessThan(Version), + /// Less than or equal: `<= 1.2.3` + LessThanOrEqual(Version), + /// Not equal: `!= 1.2.3` + NotEqual(Version), + /// Matches any version + Any, +} + +impl Constraint { + pub fn matches(&self, v: &Version) -> bool { + match self { + Constraint::Exact(target) => v == target, + Constraint::GreaterThan(target) => v > target, + Constraint::GreaterThanOrEqual(target) => v >= target, + Constraint::LessThan(target) => v < target, + Constraint::LessThanOrEqual(target) => v <= target, + Constraint::NotEqual(target) => v != target, + Constraint::Any => true, + } + } +} + +/// A compound constraint with AND/OR combinators. +#[derive(Debug, Clone)] +pub enum VersionConstraint { + /// Single atomic constraint + Single(Constraint), + /// All must match (AND — space/comma separated) + And(Vec<VersionConstraint>), + /// At least one must match (OR — `||` separated) + Or(Vec<VersionConstraint>), +} + +impl VersionConstraint { + pub fn matches(&self, version: &Version) -> bool { + match self { + VersionConstraint::Single(c) => c.matches(version), + VersionConstraint::And(cs) => cs.iter().all(|c| c.matches(version)), + VersionConstraint::Or(cs) => cs.iter().any(|c| c.matches(version)), + } + } + + /// Parse a constraint string like `^1.2`, `>=1.0 <2.0`, `^1.0 || ^2.0`. + pub fn parse(input: &str) -> Result<VersionConstraint, String> { + let input = input.trim(); + + // Split on || (OR) + let or_parts: Vec<&str> = split_or(input); + + if or_parts.len() > 1 { + let constraints: Result<Vec<_>, _> = + or_parts.iter().map(|p| parse_and_group(p.trim())).collect(); + let mut cs = constraints?; + // Flatten single-element groups + if cs.len() == 1 { + return Ok(cs.remove(0)); + } + return Ok(VersionConstraint::Or(cs)); + } + + parse_and_group(input) + } +} + +/// Split on `||` (pipe-OR), but not inside version strings. +fn split_or(s: &str) -> Vec<&str> { + let mut parts = Vec::new(); + let mut start = 0; + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if i + 1 < bytes.len() && bytes[i] == b'|' && bytes[i + 1] == b'|' { + parts.push(s[start..i].trim()); + i += 2; + start = i; + } else { + i += 1; + } + } + parts.push(s[start..].trim()); + parts +} + +/// Parse an AND group (space or comma separated constraints). +fn parse_and_group(s: &str) -> Result<VersionConstraint, String> { + // Detect inline alias first: "1.0.x-dev as 1.0.0" + // The entire expression is a single atomic constraint; parse it directly. + if s.contains(" as ") { + return parse_single(s); + } + + // Detect hyphen range first: "1.0 - 2.0" where both sides start with a digit + if let Some(idx) = s.find(" - ") { + let before = s[..idx].trim(); + let after = s[idx + 3..].trim(); + let before_is_version = before + .chars() + .next() + .is_some_and(|c| c.is_ascii_digit() || c == 'v' || c == 'V'); + let after_is_version = after + .chars() + .next() + .is_some_and(|c| c.is_ascii_digit() || c == 'v' || c == 'V'); + if before_is_version && after_is_version { + return parse_hyphen_range(s); + } + } + + let parts = split_and(s); + + if parts.is_empty() { + return Err("Empty constraint".to_string()); + } + + let constraints: Result<Vec<_>, _> = parts.iter().map(|p| parse_single(p.trim())).collect(); + let mut cs = constraints?; + + if cs.len() == 1 { + return Ok(cs.remove(0)); + } + + // Flatten nested And + let flat: Vec<VersionConstraint> = cs + .into_iter() + .flat_map(|c| match c { + VersionConstraint::And(inner) => inner, + other => vec![other], + }) + .collect(); + + Ok(VersionConstraint::And(flat)) +} + +/// Split on spaces or commas (AND separator), respecting that version strings +/// can contain `-` (pre-release). +fn split_and(s: &str) -> Vec<String> { + // A constraint "part" is separated by space or comma when not part of + // operator prefixes like `>=`, `<=`, `!=`, or version like `1.2.3-beta`. + // Strategy: tokenize by whitespace/comma, then re-join multi-token ranges. + let tokens: Vec<&str> = s.split([' ', ',']).filter(|t| !t.is_empty()).collect(); + + let mut parts: Vec<String> = Vec::new(); + let mut current = String::new(); + + for token in tokens { + if current.is_empty() { + current = token.to_string(); + } else { + // If the token starts with an operator or a digit/^ ~/>, it's a new constraint + let starts_new = token.starts_with(|c: char| { + matches!(c, '>' | '<' | '!' | '=' | '^' | '~' | '*') || c.is_ascii_digit() + }); + if starts_new { + parts.push(current.trim().to_string()); + current = token.to_string(); + } else { + // Continuation (e.g. part of a version string with spaces) + current.push(' '); + current.push_str(token); + } + } + } + if !current.is_empty() { + parts.push(current.trim().to_string()); + } + + parts +} + +/// Parse a single constraint part. +fn parse_single(s: &str) -> Result<VersionConstraint, String> { + if s == "*" || s.is_empty() { + return Ok(VersionConstraint::Single(Constraint::Any)); + } + + // Caret: ^1.2.3 + if let Some(rest) = s.strip_prefix('^') { + return parse_caret(rest); + } + + // Tilde: ~1.2.3 + if let Some(rest) = s.strip_prefix('~') { + return parse_tilde(rest); + } + + // Hyphen range: "1.0 - 2.0" — handled at and-group level, but check here too + if s.contains(" - ") { + return parse_hyphen_range(s); + } + + // Comparison operators + // Use parse_for_constraint so that inline aliases like "1.0.x-dev as 1.0.0" + // resolve to the alias target (right-hand side) when used in constraint context. + if let Some(rest) = s.strip_prefix(">=") { + let v = Version::parse_for_constraint(rest.trim())?; + return Ok(VersionConstraint::Single(Constraint::GreaterThanOrEqual(v))); + } + if let Some(rest) = s.strip_prefix("<=") { + let v = Version::parse_for_constraint(rest.trim())?; + return Ok(VersionConstraint::Single(Constraint::LessThanOrEqual(v))); + } + if let Some(rest) = s.strip_prefix("!=") { + let v = Version::parse_for_constraint(rest.trim())?; + return Ok(VersionConstraint::Single(Constraint::NotEqual(v))); + } + if let Some(rest) = s.strip_prefix('>') { + let v = Version::parse_for_constraint(rest.trim())?; + return Ok(VersionConstraint::Single(Constraint::GreaterThan(v))); + } + if let Some(rest) = s.strip_prefix('<') { + let v = Version::parse_for_constraint(rest.trim())?; + return Ok(VersionConstraint::Single(Constraint::LessThan(v))); + } + if let Some(rest) = s.strip_prefix('=') { + let v = Version::parse_for_constraint(rest.trim())?; + return Ok(VersionConstraint::Single(Constraint::Exact(v))); + } + + // Wildcard: 1.2.* or 1.* + if s.ends_with(".*") || s.ends_with(".*.*") || s == "*" { + return parse_wildcard(s); + } + + // Exact version (may carry an inline alias; take the alias target for matching) + let v = Version::parse_for_constraint(s)?; + Ok(VersionConstraint::Single(Constraint::Exact(v))) +} + +/// Parse `^major.minor.patch` caret constraint. +/// First non-zero segment is the "locked" boundary. +fn parse_caret(s: &str) -> Result<VersionConstraint, String> { + let parts: Vec<&str> = s.split('.').collect(); + let major: u64 = parts.first().and_then(|p| p.parse().ok()).unwrap_or(0); + let minor: u64 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); + let patch: u64 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0); + let build: u64 = parts.get(3).and_then(|p| p.parse().ok()).unwrap_or(0); + + let lower = Version::dev_boundary(major, minor, patch, build); + + // Determine upper bound based on first non-zero segment + let upper = if major > 0 { + Version::dev_boundary(major + 1, 0, 0, 0) + } else if minor > 0 { + Version::dev_boundary(0, minor + 1, 0, 0) + } else if patch > 0 { + Version::dev_boundary(0, 0, patch + 1, 0) + } else { + Version::dev_boundary(0, 0, 1, 0) + }; + + Ok(VersionConstraint::And(vec![ + VersionConstraint::Single(Constraint::GreaterThanOrEqual(lower)), + VersionConstraint::Single(Constraint::LessThan(upper)), + ])) +} + +/// Parse `~major.minor.patch` tilde constraint. +fn parse_tilde(s: &str) -> Result<VersionConstraint, String> { + let parts: Vec<&str> = s.split('.').collect(); + let major: u64 = parts.first().and_then(|p| p.parse().ok()).unwrap_or(0); + let minor: u64 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); + let patch: u64 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0); + let build: u64 = parts.get(3).and_then(|p| p.parse().ok()).unwrap_or(0); + + let lower = Version::dev_boundary(major, minor, patch, build); + + // ~major.minor.patch → >=major.minor.patch <major.(minor+1).0 + // ~major.minor → >=major.minor.0 <(major+1).0.0 + // ~major → >=major.0.0 <(major+1).0.0 + let upper = if parts.len() >= 3 { + Version::dev_boundary(major, minor + 1, 0, 0) + } else { + Version::dev_boundary(major + 1, 0, 0, 0) + }; + + Ok(VersionConstraint::And(vec![ + VersionConstraint::Single(Constraint::GreaterThanOrEqual(lower)), + VersionConstraint::Single(Constraint::LessThan(upper)), + ])) +} + +/// Parse `1.2.*` wildcard constraint. +fn parse_wildcard(s: &str) -> Result<VersionConstraint, String> { + if s == "*" { + return Ok(VersionConstraint::Single(Constraint::Any)); + } + + // Strip trailing .* + let base = s.trim_end_matches(".*"); + if base.is_empty() { + return Ok(VersionConstraint::Single(Constraint::Any)); + } + + let parts: Vec<&str> = base.split('.').collect(); + let major: u64 = parts.first().and_then(|p| p.parse().ok()).unwrap_or(0); + let minor: u64 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); + + let (lower, upper) = if parts.len() == 1 { + ( + Version::dev_boundary(major, 0, 0, 0), + Version::dev_boundary(major + 1, 0, 0, 0), + ) + } else { + ( + Version::dev_boundary(major, minor, 0, 0), + Version::dev_boundary(major, minor + 1, 0, 0), + ) + }; + + Ok(VersionConstraint::And(vec![ + VersionConstraint::Single(Constraint::GreaterThanOrEqual(lower)), + VersionConstraint::Single(Constraint::LessThan(upper)), + ])) +} + +/// Parse `1.0 - 2.0` hyphen range. +fn parse_hyphen_range(s: &str) -> Result<VersionConstraint, String> { + let parts: Vec<&str> = s.splitn(2, " - ").collect(); + if parts.len() != 2 { + return Err(format!("Invalid hyphen range: {s}")); + } + + let lower_v = Version::parse_for_constraint(parts[0].trim())?; + let upper_v = Version::parse_for_constraint(parts[1].trim())?; + + Ok(VersionConstraint::And(vec![ + VersionConstraint::Single(Constraint::GreaterThanOrEqual(lower_v)), + VersionConstraint::Single(Constraint::LessThanOrEqual(upper_v)), + ])) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ──────────── Version parsing ──────────── + + #[test] + fn test_parse_simple() { + let v = Version::parse("1.2.3").unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 2); + assert_eq!(v.patch, 3); + assert_eq!(v.build, 0); + assert_eq!(v.pre_release, None); + assert!(!v.is_dev_branch); + } + + #[test] + fn test_parse_with_v_prefix() { + let v = Version::parse("v1.2").unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 2); + assert_eq!(v.patch, 0); + assert_eq!(v.build, 0); + assert_eq!(v.pre_release, None); + } + + #[test] + fn test_parse_four_segments() { + let v = Version::parse("1.2.3.4").unwrap(); + assert_eq!((v.major, v.minor, v.patch, v.build), (1, 2, 3, 4)); + } + + #[test] + fn test_parse_beta() { + let v = Version::parse("1.0.0-beta.1").unwrap(); + assert_eq!(v.major, 1); + // "beta.1" normalizes to "beta1" (dot is stripped) + assert_eq!(v.pre_release, Some("beta1".to_string())); + } + + #[test] + fn test_parse_beta1() { + let v = Version::parse("1.0.0-beta1").unwrap(); + assert_eq!(v.pre_release, Some("beta1".to_string())); + } + + #[test] + fn test_parse_rc() { + let v = Version::parse("1.0.0-RC1").unwrap(); + assert_eq!(v.pre_release, Some("RC1".to_string())); + } + + #[test] + fn test_parse_alpha() { + let v = Version::parse("2.0.0-alpha3").unwrap(); + assert_eq!(v.pre_release, Some("alpha3".to_string())); + } + + #[test] + fn test_parse_dev_master() { + let v = Version::parse("dev-master").unwrap(); + assert!(v.is_dev_branch); + assert_eq!(v.dev_branch_name, Some("master".to_string())); + assert_eq!(v.pre_release, Some("dev".to_string())); + } + + #[test] + fn test_parse_dev_feature() { + let v = Version::parse("dev-feature/foo").unwrap(); + assert!(v.is_dev_branch); + assert_eq!(v.dev_branch_name, Some("feature/foo".to_string())); + } + + #[test] + fn test_parse_x_dev() { + let v = Version::parse("2.1.x-dev").unwrap(); + assert!(v.is_dev_branch); + assert_eq!(v.major, 2); + assert_eq!(v.minor, 1); + assert_eq!(v.patch, 9999999); + assert_eq!(v.build, 9999999); + } + + #[test] + fn test_parse_strip_at_stability() { + let v = Version::parse("1.2.3@stable").unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 2); + assert_eq!(v.patch, 3); + assert_eq!(v.pre_release, None); + } + + #[test] + fn test_parse_inline_alias() { + let v = Version::parse("1.0.x-dev as 1.0.0").unwrap(); + // Takes left side: 1.0.x-dev + assert!(v.is_dev_branch); + } + + #[test] + fn test_parse_for_constraint_inline_alias() { + // parse_for_constraint takes the RIGHT side of an inline alias + let v = Version::parse_for_constraint("1.0.x-dev as 1.0.0").unwrap(); + assert!(!v.is_dev_branch); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 0); + assert_eq!(v.patch, 0); + assert_eq!(v.pre_release, None); + } + + #[test] + fn test_parse_for_constraint_no_alias() { + // Without an alias, parse_for_constraint behaves like parse + let v = Version::parse_for_constraint("1.2.3").unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 2); + assert_eq!(v.patch, 3); + assert!(!v.is_dev_branch); + } + + #[test] + fn test_constraint_inline_alias_exact_matches_target() { + // A constraint written as "1.0.x-dev as 1.0.0" should match 1.0.0 (the alias target) + let c = VersionConstraint::parse("1.0.x-dev as 1.0.0").unwrap(); + let target = Version::parse("1.0.0").unwrap(); + assert!(c.matches(&target)); + // But NOT a different version + let other = Version::parse("1.1.0").unwrap(); + assert!(!c.matches(&other)); + } + + // ──────────── Version ordering ──────────── + + #[test] + fn test_ordering_major() { + let a = Version::parse("2.0.0").unwrap(); + let b = Version::parse("1.0.0").unwrap(); + assert!(a > b); + } + + #[test] + fn test_ordering_minor() { + let a = Version::parse("1.2.0").unwrap(); + let b = Version::parse("1.1.0").unwrap(); + assert!(a > b); + } + + #[test] + fn test_ordering_stable_gt_rc() { + let stable = Version::parse("1.0.0").unwrap(); + let rc = Version::parse("1.0.0-RC1").unwrap(); + assert!(stable > rc); + } + + #[test] + fn test_ordering_rc_gt_beta() { + let rc = Version::parse("1.0.0-RC1").unwrap(); + let beta = Version::parse("1.0.0-beta1").unwrap(); + assert!(rc > beta); + } + + #[test] + fn test_ordering_beta_gt_alpha() { + let beta = Version::parse("1.0.0-beta1").unwrap(); + let alpha = Version::parse("1.0.0-alpha1").unwrap(); + assert!(beta > alpha); + } + + #[test] + fn test_ordering_alpha_gt_dev_branch() { + let alpha = Version::parse("1.0.0-alpha1").unwrap(); + let dev = Version::parse("dev-master").unwrap(); + assert!(alpha > dev); + } + + #[test] + fn test_ordering_pre_release_numbers() { + let beta2 = Version::parse("1.0.0-beta2").unwrap(); + let beta1 = Version::parse("1.0.0-beta1").unwrap(); + assert!(beta2 > beta1); + } + + // ──────────── Constraint parsing ──────────── + + #[test] + fn test_parse_any() { + let c = VersionConstraint::parse("*").unwrap(); + let v = Version::parse("1.2.3").unwrap(); + assert!(c.matches(&v)); + } + + #[test] + fn test_parse_exact() { + let c = VersionConstraint::parse("1.2.3").unwrap(); + let v = Version::parse("1.2.3").unwrap(); + assert!(c.matches(&v)); + let v2 = Version::parse("1.2.4").unwrap(); + assert!(!c.matches(&v2)); + } + + #[test] + fn test_parse_gte() { + let c = VersionConstraint::parse(">=1.0.0").unwrap(); + assert!(c.matches(&Version::parse("1.0.0").unwrap())); + assert!(c.matches(&Version::parse("2.0.0").unwrap())); + assert!(!c.matches(&Version::parse("0.9.0").unwrap())); + } + + #[test] + fn test_parse_caret_major() { + let c = VersionConstraint::parse("^1.2").unwrap(); + assert!(c.matches(&Version::parse("1.2.0").unwrap())); + assert!(c.matches(&Version::parse("1.3.0").unwrap())); + assert!(c.matches(&Version::parse("1.9.9").unwrap())); + assert!(!c.matches(&Version::parse("2.0.0").unwrap())); + assert!(!c.matches(&Version::parse("1.1.0").unwrap())); + } + + #[test] + fn test_parse_caret_zero_minor() { + // ^0.2.3 → >=0.2.3 <0.3.0 + let c = VersionConstraint::parse("^0.2.3").unwrap(); + assert!(c.matches(&Version::parse("0.2.3").unwrap())); + assert!(c.matches(&Version::parse("0.2.9").unwrap())); + assert!(!c.matches(&Version::parse("0.3.0").unwrap())); + assert!(!c.matches(&Version::parse("1.0.0").unwrap())); + } + + #[test] + fn test_parse_tilde_three_parts() { + // ~1.2.3 → >=1.2.3 <1.3.0 + let c = VersionConstraint::parse("~1.2.3").unwrap(); + assert!(c.matches(&Version::parse("1.2.3").unwrap())); + assert!(c.matches(&Version::parse("1.2.9").unwrap())); + assert!(!c.matches(&Version::parse("1.3.0").unwrap())); + } + + #[test] + fn test_parse_tilde_two_parts() { + // ~1.2 → >=1.2.0 <2.0.0 + let c = VersionConstraint::parse("~1.2").unwrap(); + assert!(c.matches(&Version::parse("1.2.0").unwrap())); + assert!(c.matches(&Version::parse("1.9.0").unwrap())); + assert!(!c.matches(&Version::parse("2.0.0").unwrap())); + } + + #[test] + fn test_parse_wildcard() { + let c = VersionConstraint::parse("1.2.*").unwrap(); + assert!(c.matches(&Version::parse("1.2.0").unwrap())); + assert!(c.matches(&Version::parse("1.2.9").unwrap())); + assert!(!c.matches(&Version::parse("1.3.0").unwrap())); + } + + #[test] + fn test_parse_and() { + let c = VersionConstraint::parse(">=1.0 <2.0").unwrap(); + assert!(c.matches(&Version::parse("1.0.0").unwrap())); + assert!(c.matches(&Version::parse("1.9.9").unwrap())); + assert!(!c.matches(&Version::parse("2.0.0").unwrap())); + assert!(!c.matches(&Version::parse("0.9.9").unwrap())); + } + + #[test] + fn test_parse_or() { + let c = VersionConstraint::parse("^1.0 || ^2.0").unwrap(); + assert!(c.matches(&Version::parse("1.5.0").unwrap())); + assert!(c.matches(&Version::parse("2.3.0").unwrap())); + assert!(!c.matches(&Version::parse("3.0.0").unwrap())); + } + + #[test] + fn test_parse_not_equal() { + let c = VersionConstraint::parse("!=1.5.0").unwrap(); + assert!(c.matches(&Version::parse("1.4.0").unwrap())); + assert!(!c.matches(&Version::parse("1.5.0").unwrap())); + assert!(c.matches(&Version::parse("1.6.0").unwrap())); + } + + #[test] + fn test_parse_hyphen_range() { + let c = VersionConstraint::parse("1.0 - 2.0").unwrap(); + assert!(c.matches(&Version::parse("1.0.0").unwrap())); + assert!(c.matches(&Version::parse("1.5.0").unwrap())); + assert!(c.matches(&Version::parse("2.0.0").unwrap())); + assert!(!c.matches(&Version::parse("0.9.0").unwrap())); + assert!(!c.matches(&Version::parse("2.1.0").unwrap())); + } + + // ──────────── Helper ──────────── + + fn satisfies(constraint: &str, version: &str) -> bool { + let c = VersionConstraint::parse(constraint).unwrap(); + let v = Version::parse(version).unwrap(); + c.matches(&v) + } + + // ══════════════════════════════════════════════════════════════════════════ + // 1. VERSION PARSING EDGE CASES + // ══════════════════════════════════════════════════════════════════════════ + + #[test] + fn test_parse_single_segment() { + let v = Version::parse("1").unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 0); + assert_eq!(v.patch, 0); + assert_eq!(v.build, 0); + assert_eq!(v.pre_release, None); + assert!(!v.is_dev_branch); + } + + #[test] + fn test_parse_two_segments() { + let v = Version::parse("1.2").unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 2); + assert_eq!(v.patch, 0); + assert_eq!(v.build, 0); + assert_eq!(v.pre_release, None); + } + + #[test] + fn test_parse_zero_version() { + let v = Version::parse("0.0.0").unwrap(); + assert_eq!(v.major, 0); + assert_eq!(v.minor, 0); + assert_eq!(v.patch, 0); + assert_eq!(v.build, 0); + assert_eq!(v.pre_release, None); + assert!(!v.is_dev_branch); + } + + #[test] + fn test_parse_zero_zero_one() { + let v = Version::parse("0.0.1").unwrap(); + assert_eq!((v.major, v.minor, v.patch, v.build), (0, 0, 1, 0)); + assert_eq!(v.pre_release, None); + } + + #[test] + fn test_parse_large_version_numbers() { + let v = Version::parse("99999.1.2.3").unwrap(); + assert_eq!(v.major, 99999); + assert_eq!(v.minor, 1); + assert_eq!(v.patch, 2); + assert_eq!(v.build, 3); + } + + #[test] + fn test_parse_uppercase_v_prefix() { + let v = Version::parse("V1.2.3").unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 2); + assert_eq!(v.patch, 3); + assert_eq!(v.pre_release, None); + assert!(!v.is_dev_branch); + } + + #[test] + fn test_parse_build_metadata_stripped() { + // Build metadata after '+' should be stripped + let v = Version::parse("1.2.3+build.456").unwrap(); + assert_eq!((v.major, v.minor, v.patch), (1, 2, 3)); + assert_eq!(v.pre_release, None); + } + + #[test] + fn test_parse_shorthand_b_normalizes_to_beta() { + // "b2" suffix → beta2 + let v = Version::parse("1.0.0-b2").unwrap(); + assert_eq!(v.pre_release, Some("beta2".to_string())); + } + + #[test] + fn test_parse_shorthand_a_normalizes_to_alpha() { + // "a1" suffix → alpha1 + let v = Version::parse("1.0.0-a1").unwrap(); + assert_eq!(v.pre_release, Some("alpha1".to_string())); + } + + #[test] + fn test_parse_shorthand_p_normalizes_to_patch() { + // "p1" suffix → patch1 + let v = Version::parse("1.0.0-p1").unwrap(); + assert_eq!(v.pre_release, Some("patch1".to_string())); + } + + #[test] + fn test_parse_shorthand_pl_normalizes_to_patch() { + // "pl2" suffix → patch2 + let v = Version::parse("1.0.0-pl2").unwrap(); + assert_eq!(v.pre_release, Some("patch2".to_string())); + } + + #[test] + fn test_parse_shorthand_rc_lowercase_normalizes_to_rc() { + // "rc2" suffix → RC2 + let v = Version::parse("1.0.0-rc2").unwrap(); + assert_eq!(v.pre_release, Some("RC2".to_string())); + } + + #[test] + fn test_parse_stability_beta_no_number() { + // "1.0.0-beta" with no number + let v = Version::parse("1.0.0-beta").unwrap(); + assert_eq!(v.pre_release, Some("beta".to_string())); + } + + #[test] + fn test_parse_dev_release_branch() { + // "dev-release-1.0" is a dev branch named "release-1.0" + let v = Version::parse("dev-release-1.0").unwrap(); + assert!(v.is_dev_branch); + assert_eq!(v.dev_branch_name, Some("release-1.0".to_string())); + assert_eq!(v.pre_release, Some("dev".to_string())); + } + + #[test] + fn test_parse_dev_master_uppercase() { + // "DEV-master" — case-insensitive dev- prefix + let v = Version::parse("DEV-master").unwrap(); + assert!(v.is_dev_branch); + assert_eq!(v.dev_branch_name, Some("master".to_string())); + } + + #[test] + fn test_parse_x_dev_two_segment() { + // "2.x-dev" → major=2, minor=0, patch=9999999, build=9999999 + let v = Version::parse("2.x-dev").unwrap(); + assert!(v.is_dev_branch); + assert_eq!(v.major, 2); + assert_eq!(v.minor, 0); + assert_eq!(v.patch, 9999999); + assert_eq!(v.build, 9999999); + } + + #[test] + fn test_parse_numeric_dev_suffix() { + // "2.1-dev" — ends with -dev, treated as *-dev suffix branch + let v = Version::parse("2.1-dev").unwrap(); + assert!(v.is_dev_branch); + assert_eq!(v.major, 2); + assert_eq!(v.minor, 1); + } + + #[test] + fn test_parse_stability_flag_dev() { + // "1.0.0@dev" → strip @dev suffix, parse 1.0.0 as stable + let v = Version::parse("1.0.0@dev").unwrap(); + assert_eq!((v.major, v.minor, v.patch), (1, 0, 0)); + assert!(!v.is_dev_branch); + // After stripping @dev, no pre-release suffix remains + assert_eq!(v.pre_release, None); + } + + #[test] + fn test_parse_stability_flag_alpha() { + let v = Version::parse("1.0.0@alpha").unwrap(); + assert_eq!((v.major, v.minor, v.patch), (1, 0, 0)); + assert_eq!(v.pre_release, None); + } + + #[test] + fn test_parse_stability_flag_beta() { + let v = Version::parse("1.0.0@beta").unwrap(); + assert_eq!((v.major, v.minor, v.patch), (1, 0, 0)); + assert_eq!(v.pre_release, None); + } + + #[test] + fn test_parse_stability_flag_rc() { + let v = Version::parse("1.0.0@rc").unwrap(); + assert_eq!((v.major, v.minor, v.patch), (1, 0, 0)); + assert_eq!(v.pre_release, None); + } + + #[test] + fn test_parse_inline_alias_left_side() { + // "dev-main as 1.0.x-dev" → left side is "dev-main" + let v = Version::parse("dev-main as 1.0.x-dev").unwrap(); + assert!(v.is_dev_branch); + assert_eq!(v.dev_branch_name, Some("main".to_string())); + } + + #[test] + fn test_parse_error_empty_string() { + let result = Version::parse(""); + assert!(result.is_err(), "Expected error for empty string"); + } + + #[test] + fn test_parse_error_not_a_version() { + // Strings with no numeric start should fail + let result = Version::parse("not-a-version"); + assert!( + result.is_err(), + "Expected error for 'not-a-version', got: {:?}", + result + ); + } + + #[test] + fn test_parse_error_only_dots() { + let result = Version::parse("...."); + assert!(result.is_err(), "Expected error for '....'"); + } + + #[test] + fn test_parse_error_non_numeric_segment() { + // "1.abc.3" — minor segment is non-numeric; parse degrades minor to 0 + // The implementation uses `and_then(|p| p.parse().ok()).unwrap_or(0)`, + // so non-numeric segments silently become 0. This is intentional behavior. + let v = Version::parse("1.abc.3").unwrap(); + assert_eq!(v.major, 1); + // minor "abc" fails to parse as u64, so falls back to 0 + assert_eq!(v.minor, 0); + } + + // ══════════════════════════════════════════════════════════════════════════ + // 2. VERSION ORDERING + // ══════════════════════════════════════════════════════════════════════════ + + #[test] + fn test_ordering_equal_versions() { + let a = Version::parse("1.2.3").unwrap(); + let b = Version::parse("1.2.3").unwrap(); + assert_eq!(a.cmp(&b), std::cmp::Ordering::Equal); + } + + #[test] + fn test_ordering_patch_difference() { + let a = Version::parse("1.2.4").unwrap(); + let b = Version::parse("1.2.3").unwrap(); + assert!(a > b); + } + + #[test] + fn test_ordering_build_segment_difference() { + let a = Version::parse("1.2.3.2").unwrap(); + let b = Version::parse("1.2.3.1").unwrap(); + assert!(a > b); + } + + #[test] + fn test_ordering_dev_branch_lt_dev_prerelease() { + // "1.0.0-dev" ends with "-dev", so the parser treats it as a *-dev suffix branch + // (is_dev_branch=true, dev_branch_name=None, major=1, minor=0, patch=9999999). + // "dev-master" is also is_dev_branch=true with dev_branch_name=Some("master"). + // When both are dev branches, they compare by dev_branch_name: + // Some("master") vs None → Some > None, so dev-master > 1.0.0-dev (x-dev form). + let dev_branch = Version::parse("dev-master").unwrap(); + let dev_prerelease = Version::parse("1.0.0-dev").unwrap(); + // Both are dev branches; "master" branch name > None → dev-master is Greater + assert!(dev_branch > dev_prerelease); + } + + #[test] + fn test_ordering_dev_prerelease_lt_alpha() { + let dev = Version::parse("1.0.0-dev").unwrap(); + let alpha = Version::parse("1.0.0-alpha1").unwrap(); + assert!(dev < alpha); + } + + #[test] + fn test_ordering_alpha_lt_beta() { + let alpha = Version::parse("1.0.0-alpha1").unwrap(); + let beta = Version::parse("1.0.0-beta1").unwrap(); + assert!(alpha < beta); + } + + #[test] + fn test_ordering_beta_lt_rc() { + let beta = Version::parse("1.0.0-beta1").unwrap(); + let rc = Version::parse("1.0.0-RC1").unwrap(); + assert!(beta < rc); + } + + #[test] + fn test_ordering_rc_lt_stable() { + let rc = Version::parse("1.0.0-RC1").unwrap(); + let stable = Version::parse("1.0.0").unwrap(); + assert!(rc < stable); + } + + #[test] + fn test_ordering_stable_lt_patch() { + // The Ord impl: (None, Some(_)) => Greater — stable (pre_release=None) beats any + // pre_release including "patch1". Even though stability_rank("patch")=5 which is + // higher than stable's implicit 0, that path is only reached when both sides are + // Some(_). Since stable has pre_release=None, stable > patch version. + let stable = Version::parse("1.0.0").unwrap(); + let patch = Version::parse("1.0.0-patch1").unwrap(); + assert!(stable > patch); + } + + #[test] + fn test_ordering_rc3_gt_rc2() { + let rc3 = Version::parse("1.0.0-RC3").unwrap(); + let rc2 = Version::parse("1.0.0-RC2").unwrap(); + assert!(rc3 > rc2); + } + + #[test] + fn test_ordering_alpha5_gt_alpha3() { + let a5 = Version::parse("1.0.0-alpha5").unwrap(); + let a3 = Version::parse("1.0.0-alpha3").unwrap(); + assert!(a5 > a3); + } + + #[test] + fn test_ordering_dev_branches_alphabetical() { + // Between two dev branches, compare branch names alphabetically + let dev_foo = Version::parse("dev-foo").unwrap(); + let dev_bar = Version::parse("dev-bar").unwrap(); + // "bar" < "foo" alphabetically + assert!(dev_foo > dev_bar); + } + + #[test] + fn test_ordering_zero_versions() { + let a = Version::parse("0.0.2").unwrap(); + let b = Version::parse("0.0.1").unwrap(); + assert!(a > b); + } + + #[test] + fn test_ordering_four_vs_three_segment_equal() { + // 1.2.3.0 and 1.2.3 should be equal (build defaults to 0) + let a = Version::parse("1.2.3.0").unwrap(); + let b = Version::parse("1.2.3").unwrap(); + assert_eq!(a, b); + } + + #[test] + fn test_ordering_comprehensive_chain() { + // Note: "1.0.0-dev" is parsed as a *-dev suffix branch (is_dev_branch=true, + // dev_branch_name=None) due to the "-dev" suffix rule in Version::parse. + // "dev-foo" is also a dev branch (is_dev_branch=true, dev_branch_name=Some("foo")). + // Comparing two dev branches uses dev_branch_name: None < Some("foo"), so + // the *-dev form (None) < "dev-foo" (Some("foo")). + // For "1.0.0-alpha1", "1.0.0-beta1", "1.0.0-RC1", "1.0.0": normal numeric ordering. + let dev_x_dev = Version::parse("1.0.0-dev").unwrap(); // *-dev branch, name=None + let dev_branch = Version::parse("dev-foo").unwrap(); // named branch, name=Some("foo") + let alpha = Version::parse("1.0.0-alpha1").unwrap(); + let beta = Version::parse("1.0.0-beta1").unwrap(); + let rc = Version::parse("1.0.0-RC1").unwrap(); + let stable = Version::parse("1.0.0").unwrap(); + + // Both dev branches; dev_branch_name None < Some("foo") + assert!(dev_x_dev < dev_branch); + // dev_branch (is_dev_branch=true) < alpha (is_dev_branch=false) + assert!(dev_branch < alpha); + assert!(alpha < beta); + assert!(beta < rc); + assert!(rc < stable); + } + + // ══════════════════════════════════════════════════════════════════════════ + // 3. CONSTRAINT PARSING EDGE CASES + // ══════════════════════════════════════════════════════════════════════════ + + // ── Caret ── + + #[test] + fn test_caret_zero_zero_three() { + // ^0.0.3 → >=0.0.3 <0.0.4 + assert!(satisfies("^0.0.3", "0.0.3")); + assert!(!satisfies("^0.0.3", "0.0.4")); + assert!(!satisfies("^0.0.3", "0.0.2")); + } + + #[test] + fn test_caret_zero_zero_zero() { + // ^0.0.0 → first non-zero is none, upper = 0.0.1 + assert!(satisfies("^0.0.0", "0.0.0")); + assert!(!satisfies("^0.0.0", "0.0.1")); + } + + #[test] + fn test_caret_single_major() { + // ^1 → >=1.0.0 <2.0.0 + assert!(satisfies("^1", "1.0.0")); + assert!(satisfies("^1", "1.99.99")); + assert!(!satisfies("^1", "2.0.0")); + assert!(!satisfies("^1", "0.9.9")); + } + + #[test] + fn test_caret_four_segments() { + // ^1.2.3.4 → >=1.2.3.4 <2.0.0.0 + assert!(satisfies("^1.2.3.4", "1.2.3.4")); + assert!(satisfies("^1.2.3.4", "1.9.0.0")); + assert!(!satisfies("^1.2.3.4", "2.0.0.0")); + assert!(!satisfies("^1.2.3.4", "1.2.3.3")); + } + + #[test] + fn test_caret_lower_boundary() { + // ^1.2.3 lower boundary: 1.2.3 matches but 1.2.2 does not + assert!(satisfies("^1.2.3", "1.2.3")); + assert!(!satisfies("^1.2.3", "1.2.2")); + } + + #[test] + fn test_caret_upper_boundary() { + // ^1.2.3 upper boundary: 1.9.9 matches, 2.0.0 does not + assert!(satisfies("^1.2.3", "1.9.9")); + assert!(!satisfies("^1.2.3", "2.0.0")); + } + + // ── Tilde ── + + #[test] + fn test_tilde_single_major() { + // ~1 → >=1.0.0 <2.0.0 + assert!(satisfies("~1", "1.0.0")); + assert!(satisfies("~1", "1.99.0")); + assert!(!satisfies("~1", "2.0.0")); + assert!(!satisfies("~1", "0.9.9")); + } + + #[test] + fn test_tilde_four_segments() { + // ~1.2.3.4 → >=1.2.3.4 <1.3.0.0 + assert!(satisfies("~1.2.3.4", "1.2.3.4")); + assert!(satisfies("~1.2.9.0", "1.2.9.0")); + assert!(!satisfies("~1.2.3.4", "1.3.0.0")); + assert!(!satisfies("~1.2.3.4", "1.2.3.3")); + } + + #[test] + fn test_tilde_lower_boundary() { + // ~1.2.3: 1.2.3 matches, 1.2.2 does not + assert!(satisfies("~1.2.3", "1.2.3")); + assert!(!satisfies("~1.2.3", "1.2.2")); + } + + #[test] + fn test_tilde_upper_boundary() { + // ~1.2.3: 1.2.9 matches, 1.3.0 does not + assert!(satisfies("~1.2.3", "1.2.9")); + assert!(!satisfies("~1.2.3", "1.3.0")); + } + + // ── Wildcard ── + + #[test] + fn test_wildcard_major_only() { + // 1.* → >=1.0.0 <2.0.0 + assert!(satisfies("1.*", "1.0.0")); + assert!(satisfies("1.*", "1.99.0")); + assert!(!satisfies("1.*", "2.0.0")); + assert!(!satisfies("1.*", "0.9.9")); + } + + #[test] + fn test_wildcard_double_star() { + // 1.*.* is treated like 1.* + assert!(satisfies("1.*.*", "1.5.0")); + assert!(!satisfies("1.*.*", "2.0.0")); + } + + #[test] + fn test_wildcard_three_segment() { + // 1.2.3.* — the implementation strips trailing .*; base is "1.2.3" + // parse_wildcard strips .* and splits on '.'; parts.len()=3 → minor constraint + assert!(satisfies("1.2.3.*", "1.2.3")); + assert!(satisfies("1.2.3.*", "1.2.9")); + assert!(!satisfies("1.2.3.*", "1.3.0")); + } + + #[test] + fn test_wildcard_zero_major() { + // 0.* → >=0.0.0 <1.0.0 + assert!(satisfies("0.*", "0.0.0")); + assert!(satisfies("0.*", "0.99.0")); + assert!(!satisfies("0.*", "1.0.0")); + } + + #[test] + fn test_wildcard_v_prefix() { + // v1.* — the wildcard parser strips the trailing .*; base becomes "v1" + // parse_wildcard's base.split('.') on "v1" → single part "v1" + // v1 fails to parse as u64, falls back to 0 — so this is like 0.* + // Mark as ignore since the behavior diverges from the expected semantic + #[allow(unused)] + let _ = VersionConstraint::parse("v1.*"); // just verify it doesn't panic + } + + // ── Hyphen ranges ── + + #[test] + fn test_hyphen_range_partial_from() { + // "1.0 - 2.0": 1.0 is a partial "from", lower = >=1.0.0 + assert!(satisfies("1.0 - 2.0", "1.0.0")); + assert!(satisfies("1.0 - 2.0", "1.5.0")); + } + + #[test] + fn test_hyphen_range_partial_to() { + // "1.0 - 2.0": upper = <=2.0.0 (inclusive) + assert!(satisfies("1.0 - 2.0", "2.0.0")); + assert!(!satisfies("1.0 - 2.0", "2.0.1")); + } + + #[test] + fn test_hyphen_range_same_version() { + // "1.0.0 - 1.0.0" → >=1.0.0 <=1.0.0, matches only 1.0.0 + assert!(satisfies("1.0.0 - 1.0.0", "1.0.0")); + assert!(!satisfies("1.0.0 - 1.0.0", "1.0.1")); + assert!(!satisfies("1.0.0 - 1.0.0", "0.9.9")); + } + + #[test] + fn test_hyphen_range_with_prerelease() { + // "1.0.0-alpha1 - 1.0.0-RC1" + assert!(satisfies("1.0.0-alpha1 - 1.0.0-RC1", "1.0.0-alpha1")); + assert!(satisfies("1.0.0-alpha1 - 1.0.0-RC1", "1.0.0-beta1")); + assert!(satisfies("1.0.0-alpha1 - 1.0.0-RC1", "1.0.0-RC1")); + assert!(!satisfies("1.0.0-alpha1 - 1.0.0-RC1", "1.0.0")); + } + + // ── Comparison operators ── + + #[test] + fn test_gt_boundary() { + assert!(!satisfies(">1.0.0", "1.0.0")); + assert!(satisfies(">1.0.0", "1.0.1")); + } + + #[test] + fn test_lt_boundary() { + assert!(!satisfies("<1.0.0", "1.0.0")); + assert!(satisfies("<1.0.0", "0.9.9")); + } + + #[test] + fn test_lte_boundary() { + assert!(satisfies("<=1.0.0", "1.0.0")); + assert!(!satisfies("<=1.0.0", "1.0.1")); + } + + #[test] + fn test_exact_equals_sign() { + // "=1.2.3" is exact match + assert!(satisfies("=1.2.3", "1.2.3")); + assert!(!satisfies("=1.2.3", "1.2.4")); + } + + #[test] + #[ignore = "== (double equals) is not supported: the '=' handler passes '=1.2.3' to \ + Version::parse_for_constraint which fails to parse '=1' as a major number"] + fn test_double_equals_sign() { + // "==1.2.3" — the '=' branch strips one '=', leaving "=1.2.3", which is then + // passed to Version::parse_for_constraint. That function tries to parse "=1" as + // a major version number and fails. Double-equals is not a supported syntax. + assert!(satisfies("==1.2.3", "1.2.3")); + assert!(!satisfies("==1.2.3", "1.2.4")); + } + + #[test] + fn test_not_equal_boundary() { + assert!(!satisfies("!=1.5.0", "1.5.0")); + assert!(satisfies("!=1.5.0", "1.4.9")); + assert!(satisfies("!=1.5.0", "1.5.1")); + } + + #[test] + fn test_gte_with_spaces() { + // Spaces after operator should be handled + assert!(satisfies(">=1.0.0", "1.0.0")); + } + + // ── AND constraints ── + + #[test] + fn test_and_comma_separated() { + // Comma-separated constraints act as AND + assert!(satisfies(">=1.0,<2.0", "1.5.0")); + assert!(!satisfies(">=1.0,<2.0", "2.0.0")); + assert!(!satisfies(">=1.0,<2.0", "0.9.0")); + } + + #[test] + fn test_and_three_way() { + assert!(satisfies(">=1.0 !=1.5.0 <2.0", "1.3.0")); + assert!(!satisfies(">=1.0 !=1.5.0 <2.0", "1.5.0")); + assert!(!satisfies(">=1.0 !=1.5.0 <2.0", "2.0.0")); + } + + #[test] + fn test_and_impossible_range() { + // >=2.0 <1.0 — impossible range, nothing should match + assert!(!satisfies(">=2.0 <1.0", "1.5.0")); + assert!(!satisfies(">=2.0 <1.0", "2.0.0")); + assert!(!satisfies(">=2.0 <1.0", "0.5.0")); + } + + #[test] + fn test_and_tight_range() { + // >=1.2.3 <=1.2.3 — only exactly 1.2.3 + assert!(satisfies(">=1.2.3 <=1.2.3", "1.2.3")); + assert!(!satisfies(">=1.2.3 <=1.2.3", "1.2.4")); + assert!(!satisfies(">=1.2.3 <=1.2.3", "1.2.2")); + } + + // ── OR constraints ── + + #[test] + fn test_or_double_pipe() { + assert!(satisfies("^1.0 || ^2.0", "1.5.0")); + assert!(satisfies("^1.0 || ^2.0", "2.3.0")); + assert!(!satisfies("^1.0 || ^2.0", "3.0.0")); + } + + #[test] + fn test_or_three_branches() { + assert!(satisfies("^1.0 || ^2.0 || ^3.0", "1.0.0")); + assert!(satisfies("^1.0 || ^2.0 || ^3.0", "2.5.0")); + assert!(satisfies("^1.0 || ^2.0 || ^3.0", "3.9.9")); + assert!(!satisfies("^1.0 || ^2.0 || ^3.0", "4.0.0")); + } + + #[test] + fn test_or_with_wildcard() { + assert!(satisfies("1.* || 3.*", "1.5.0")); + assert!(satisfies("1.* || 3.*", "3.0.0")); + assert!(!satisfies("1.* || 3.*", "2.0.0")); + } + + #[test] + fn test_or_overlapping_ranges() { + // Overlapping ranges are fine — union semantics + assert!(satisfies(">=1.0 <3.0 || >=2.0 <4.0", "1.5.0")); + assert!(satisfies(">=1.0 <3.0 || >=2.0 <4.0", "2.5.0")); + assert!(satisfies(">=1.0 <3.0 || >=2.0 <4.0", "3.5.0")); + assert!(!satisfies(">=1.0 <3.0 || >=2.0 <4.0", "0.9.0")); + assert!(!satisfies(">=1.0 <3.0 || >=2.0 <4.0", "4.0.0")); + } + + #[test] + fn test_or_exact_versions() { + assert!(satisfies("1.0.0 || 2.0.0 || 3.0.0", "1.0.0")); + assert!(satisfies("1.0.0 || 2.0.0 || 3.0.0", "2.0.0")); + assert!(satisfies("1.0.0 || 2.0.0 || 3.0.0", "3.0.0")); + assert!(!satisfies("1.0.0 || 2.0.0 || 3.0.0", "1.0.1")); + } + + // ── Complex combined ── + + #[test] + fn test_combined_and_within_or() { + // ">=1.0 <2.0 || >=3.0 <4.0" + assert!(satisfies(">=1.0 <2.0 || >=3.0 <4.0", "1.5.0")); + assert!(satisfies(">=1.0 <2.0 || >=3.0 <4.0", "3.5.0")); + assert!(!satisfies(">=1.0 <2.0 || >=3.0 <4.0", "2.5.0")); + assert!(!satisfies(">=1.0 <2.0 || >=3.0 <4.0", "4.0.0")); + } + + #[test] + fn test_combined_real_world_laravel_pattern() { + // "^8.0||^9.0||^10.0||^11.0" — real Laravel constraint + assert!(satisfies("^8.0||^9.0||^10.0||^11.0", "8.5.0")); + assert!(satisfies("^8.0||^9.0||^10.0||^11.0", "9.0.0")); + assert!(satisfies("^8.0||^9.0||^10.0||^11.0", "10.48.22")); + assert!(satisfies("^8.0||^9.0||^10.0||^11.0", "11.0.1")); + assert!(!satisfies("^8.0||^9.0||^10.0||^11.0", "7.9.9")); + assert!(!satisfies("^8.0||^9.0||^10.0||^11.0", "12.0.0")); + } + + #[test] + fn test_combined_real_world_symfony_pattern() { + // ">=5.4 <7.0" — typical Symfony range + assert!(satisfies(">=5.4 <7.0", "5.4.0")); + assert!(satisfies(">=5.4 <7.0", "6.4.5")); + assert!(!satisfies(">=5.4 <7.0", "5.3.9")); + assert!(!satisfies(">=5.4 <7.0", "7.0.0")); + } + + // ── Edge cases ── + + #[test] + fn test_constraint_empty_string_is_any() { + // Empty string → Any constraint + let c = VersionConstraint::parse("*").unwrap(); + let v = Version::parse("9.9.9").unwrap(); + assert!(c.matches(&v)); + } + + #[test] + fn test_constraint_v_prefix_in_exact() { + // "v1.2.3" exact constraint — strip v prefix + assert!(satisfies("v1.2.3", "1.2.3")); + assert!(!satisfies("v1.2.3", "1.2.4")); + } + + #[test] + fn test_constraint_extra_whitespace_and() { + // Extra spaces around operators in AND groups + assert!(satisfies(">=1.0.0 <2.0.0", "1.5.0")); + assert!(!satisfies(">=1.0.0 <2.0.0", "2.0.0")); + } + + // ══════════════════════════════════════════════════════════════════════════ + // 4. CONSTRAINT MATCHING + // ══════════════════════════════════════════════════════════════════════════ + + #[test] + fn test_dev_branch_exact_match() { + // dev-master matches dev-master constraint exactly + let c = VersionConstraint::parse("dev-master").unwrap(); + let v = Version::parse("dev-master").unwrap(); + assert!(c.matches(&v)); + } + + #[test] + fn test_dev_branch_different_branch_no_match() { + let c = VersionConstraint::parse("dev-master").unwrap(); + let v = Version::parse("dev-develop").unwrap(); + assert!(!c.matches(&v)); + } + + #[test] + fn test_dev_branch_against_caret_no_match() { + // dev-master does not satisfy ^1.0 (it is a dev branch, always lowest) + let c = VersionConstraint::parse("^1.0").unwrap(); + let v = Version::parse("dev-master").unwrap(); + assert!(!c.matches(&v)); + } + + #[test] + fn test_any_constraint_matches_dev_branch() { + // "*" matches any version including dev branches + let c = VersionConstraint::parse("*").unwrap(); + let v = Version::parse("dev-master").unwrap(); + assert!(c.matches(&v)); + } + + #[test] + fn test_prerelease_within_caret_range() { + // Pre-release of a version within ^1.0 should match + // e.g. 1.5.0-beta1 — it is >= dev boundary 1.0.0 and < dev boundary 2.0.0 + assert!(satisfies("^1.0", "1.5.0-beta1")); + } + + #[test] + fn test_caret_lower_minus_one_no_match() { + // ^1.2.3 lower-1 = 1.2.2 → should NOT match + assert!(!satisfies("^1.2.3", "1.2.2")); + } + + #[test] + fn test_caret_upper_minus_one_matches() { + // ^1.2.3 upper-1 patch: 1.9.9 should still match (below 2.0.0) + assert!(satisfies("^1.2.3", "1.9.9")); + } + + #[test] + fn test_tilde_lower_minus_one_no_match() { + assert!(!satisfies("~1.2.3", "1.2.2")); + } + + #[test] + fn test_tilde_upper_minus_one_matches() { + assert!(satisfies("~1.2.3", "1.2.9")); + } + + // ══════════════════════════════════════════════════════════════════════════ + // 5. INTERNAL FUNCTION TESTS (via public API) + // ══════════════════════════════════════════════════════════════════════════ + + // stability_rank() — tested via ordering since the function is private + + #[test] + fn test_stability_rank_dev_via_ordering() { + // dev rank=50 (highest number = least stable), alpha rank=40 + // So dev < alpha in version ordering terms + let dev = Version::parse("1.0.0-dev").unwrap(); + let alpha = Version::parse("1.0.0-alpha1").unwrap(); + assert!(dev < alpha, "dev should be less stable than alpha1"); + } + + #[test] + fn test_stability_rank_alpha_via_ordering() { + // alpha rank=40, beta rank=30 + let alpha = Version::parse("1.0.0-alpha1").unwrap(); + let beta = Version::parse("1.0.0-beta1").unwrap(); + assert!(alpha < beta, "alpha should be less stable than beta"); + } + + #[test] + fn test_stability_rank_beta_via_ordering() { + // beta rank=30, RC rank=20 + let beta = Version::parse("1.0.0-beta1").unwrap(); + let rc = Version::parse("1.0.0-RC1").unwrap(); + assert!(beta < rc, "beta should be less stable than RC"); + } + + #[test] + fn test_stability_rank_rc_via_ordering() { + // RC rank=20, stable rank=0 + let rc = Version::parse("1.0.0-RC1").unwrap(); + let stable = Version::parse("1.0.0").unwrap(); + assert!(rc < stable, "RC should be less stable than stable"); + } + + #[test] + fn test_stability_rank_patch_via_ordering() { + // The Ord impl: (None, Some(_)) => Greater. + // stable has pre_release=None; patch version has pre_release=Some("patch1"). + // The None arm wins unconditionally: stable is always Greater than any pre_release. + // This means "patch" releases (post-release fixes) sort BELOW stable in this impl. + let patch_ver = Version::parse("1.0.0-patch1").unwrap(); + let stable = Version::parse("1.0.0").unwrap(); + assert!( + stable > patch_ver, + "stable (None pre_release) beats patch pre-release" + ); + } + + // normalize_pre_release() — tested via Version::parse pre_release field + + #[test] + fn test_normalize_pre_release_b_to_beta() { + let v = Version::parse("1.0.0-b3").unwrap(); + assert_eq!(v.pre_release, Some("beta3".to_string())); + } + + #[test] + fn test_normalize_pre_release_a_to_alpha() { + let v = Version::parse("1.0.0-a1").unwrap(); + assert_eq!(v.pre_release, Some("alpha1".to_string())); + } + + #[test] + fn test_normalize_pre_release_rc_to_rc_uppercase() { + let v = Version::parse("1.0.0-rc").unwrap(); + assert_eq!(v.pre_release, Some("RC".to_string())); + } + + #[test] + fn test_normalize_pre_release_pl_to_patch() { + let v = Version::parse("1.0.0-pl2").unwrap(); + assert_eq!(v.pre_release, Some("patch2".to_string())); + } + + #[test] + fn test_normalize_pre_release_patch_explicit() { + let v = Version::parse("1.0.0-patch3").unwrap(); + assert_eq!(v.pre_release, Some("patch3".to_string())); + } + + // pre_release_number() — tested via ordering of numbered pre-releases + + #[test] + fn test_pre_release_number_ordering_beta() { + // beta10 > beta2 if pre_release_number extracts correctly + let b10 = Version::parse("1.0.0-beta10").unwrap(); + let b2 = Version::parse("1.0.0-beta2").unwrap(); + assert!(b10 > b2); + } + + #[test] + fn test_pre_release_number_ordering_rc() { + let rc5 = Version::parse("1.0.0-RC5").unwrap(); + let rc1 = Version::parse("1.0.0-RC1").unwrap(); + assert!(rc5 > rc1); + } + + #[test] + fn test_pre_release_number_zero_when_missing() { + // "alpha" with no number → 0; "alpha1" → 1; alpha1 > alpha + let alpha1 = Version::parse("1.0.0-alpha1").unwrap(); + let alpha = Version::parse("1.0.0-alpha").unwrap(); + assert!(alpha1 > alpha); + } + + // ══════════════════════════════════════════════════════════════════════════ + // 6. COMPOSER BEHAVIORAL COMPATIBILITY + // ══════════════════════════════════════════════════════════════════════════ + + #[test] + fn test_composer_caret_four_matches_minor_bump() { + // ^4.0 matches 4.5.3 + assert!(satisfies("^4.0", "4.5.3")); + } + + #[test] + fn test_composer_caret_four_does_not_match_next_major() { + assert!(!satisfies("^4.0", "5.0.0")); + } + + #[test] + fn test_composer_caret_zero_three_matches_patch() { + // ^0.3 matches 0.3.5 (same minor family) + assert!(satisfies("^0.3", "0.3.5")); + } + + #[test] + fn test_composer_caret_zero_three_does_not_match_next_minor() { + // ^0.3 does NOT match 0.4.0 + assert!(!satisfies("^0.3", "0.4.0")); + } + + #[test] + fn test_composer_tilde_four_one_matches_within_major() { + // ~4.1 → >=4.1.0 <5.0.0 — matches 4.9.0 + assert!(satisfies("~4.1", "4.9.0")); + } + + #[test] + fn test_composer_tilde_four_one_does_not_match_next_major() { + // ~4.1 does NOT match 5.0.0 + assert!(!satisfies("~4.1", "5.0.0")); + } + + #[test] + fn test_composer_range_gap_matches_second_range() { + // ">=1.0 <1.1 || >=1.2" — gap at 1.1.x; 1.2.0 matches + assert!(satisfies(">=1.0 <1.1 || >=1.2", "1.2.0")); + } + + #[test] + fn test_composer_range_gap_does_not_match_in_gap() { + // 1.1.5 is in the gap — should NOT match + assert!(!satisfies(">=1.0 <1.1 || >=1.2", "1.1.5")); + } + + #[test] + fn test_composer_laravel_constraint_matches_v10() { + // "^8.0||^9.0||^10.0||^11.0" — Laravel-style; 10.48.22 matches + assert!(satisfies("^8.0||^9.0||^10.0||^11.0", "10.48.22")); + } + + #[test] + fn test_composer_laravel_constraint_does_not_match_v7() { + assert!(!satisfies("^8.0||^9.0||^10.0||^11.0", "7.9.9")); + } + + #[test] + fn test_composer_symfony_range_matches_6_4() { + // ">=5.4 <7.0" — Symfony; 6.4.5 matches + assert!(satisfies(">=5.4 <7.0", "6.4.5")); + } + + #[test] + fn test_composer_symfony_range_does_not_match_7_0() { + assert!(!satisfies(">=5.4 <7.0", "7.0.0")); + } + + #[test] + fn test_composer_not_equal_in_range() { + // ">=1.0 !=1.5.0 <2.0" — typical blacklist constraint + assert!(satisfies(">=1.0 !=1.5.0 <2.0", "1.4.9")); + assert!(!satisfies(">=1.0 !=1.5.0 <2.0", "1.5.0")); + assert!(satisfies(">=1.0 !=1.5.0 <2.0", "1.5.1")); + assert!(!satisfies(">=1.0 !=1.5.0 <2.0", "2.0.0")); + } + + #[test] + fn test_composer_exact_major_minor_match() { + // exact "1.5.0" only matches 1.5.0 + assert!(satisfies("1.5.0", "1.5.0")); + assert!(!satisfies("1.5.0", "1.5.1")); + } + + // ══════════════════════════════════════════════════════════════════════════ + // 7. DIVERGENCE INVESTIGATION + // ══════════════════════════════════════════════════════════════════════════ + + #[test] + fn test_hyphen_range_partial_upper_two_segment() { + // "1.0 - 2": upper is <=2.0.0 (parse "2" → 2.0.0.0, inclusive) + assert!(satisfies("1.0 - 2", "2.0.0")); + assert!(!satisfies("1.0 - 2", "2.0.1")); + assert!(!satisfies("1.0 - 2", "2.1.0")); + } + + #[test] + fn test_caret_with_prerelease_suffix() { + // ^1.2.3-beta1 — the caret parser ignores pre-release in its bounds calculation + // because parse_caret works on the numeric parts only. + // Lower: dev_boundary(1,2,3,0). Upper: dev_boundary(2,0,0,0). + // 1.2.3-beta1 (pre_release=Some("beta1")) is >= lower boundary? + // dev_boundary uses pre_release=Some("dev"), so lower is (1,2,3,0,dev) + // Version 1.2.3-beta1 has same numeric, but beta > dev in stability terms + // so 1.2.3-beta1 >= lower (1.2.3-dev) is true. + assert!(satisfies("^1.2.3-beta1", "1.2.3-beta1")); + assert!(satisfies("^1.2.3-beta1", "1.5.0")); + assert!(!satisfies("^1.2.3-beta1", "2.0.0")); + } + + #[test] + fn test_tilde_with_prerelease_suffix() { + // ~1.2.3-alpha1: lower = dev_boundary(1,2,3,0), upper = dev_boundary(1,3,0,0) + // 1.2.3-alpha1 has numeric (1,2,3,0); pre_release "alpha1" > "dev" + assert!(satisfies("~1.2.3-alpha1", "1.2.3-alpha1")); + assert!(satisfies("~1.2.3-alpha1", "1.2.9")); + assert!(!satisfies("~1.2.3-alpha1", "1.3.0")); + } + + #[test] + fn test_dev_boundary_comparison() { + // Version::dev_boundary creates a version with pre_release=Some("dev") and + // is_dev_branch=false. These should sort correctly against real versions. + let lower = Version::dev_boundary(1, 0, 0, 0); + let v = Version::parse("1.0.0").unwrap(); + // 1.0.0 (stable) > 1.0.0-dev (lower boundary) + assert!(v > lower); + } + + #[test] + fn test_x_dev_ordering_within_range() { + // "2.x-dev" version has patch=9999999, build=9999999 and is a dev branch. + // Dev branches are always lowest. So "2.x-dev" < "2.0.0" < "3.0.0". + let x_dev = Version::parse("2.x-dev").unwrap(); + let stable = Version::parse("2.0.0").unwrap(); + assert!(x_dev < stable); + } + + #[test] + fn test_four_segment_vs_three_segment_constraint() { + // "1.2.3.4" exact constraint — matches only 1.2.3.4, not 1.2.3 + assert!(satisfies("1.2.3.4", "1.2.3.4")); + assert!(!satisfies("1.2.3.4", "1.2.3")); + assert!(!satisfies("1.2.3.4", "1.2.3.5")); + } + + #[test] + fn test_date_style_version_ordering() { + // Date-based versioning: 20230101 > 20220101 + let a = Version::parse("20230101.0.0").unwrap(); + let b = Version::parse("20220101.0.0").unwrap(); + assert!(a > b); + } +} diff --git a/crates/mozart-core/Cargo.toml b/crates/mozart-core/Cargo.toml new file mode 100644 index 0000000..25210be --- /dev/null +++ b/crates/mozart-core/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "mozart-core" +version.workspace = true +edition.workspace = true + +[dependencies] +anyhow.workspace = true +colored.workspace = true +dialoguer.workspace = true +regex.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/mozart-core/src/console.rs b/crates/mozart-core/src/console.rs new file mode 100644 index 0000000..e37ff23 --- /dev/null +++ b/crates/mozart-core/src/console.rs @@ -0,0 +1,358 @@ +use colored::{ColoredString, Colorize}; +use dialoguer::{Confirm, Input}; +use std::io::IsTerminal; + +// --------------------------------------------------------------------------- +// Tag-style color helpers (module-level free functions, unchanged API) +// --------------------------------------------------------------------------- + +/// `<info>` — green foreground +pub fn info(message: &str) -> ColoredString { + message.green() +} + +/// `<comment>` — yellow foreground +pub fn comment(message: &str) -> ColoredString { + message.yellow() +} + +/// `<error>` — white on red +pub fn error(message: &str) -> ColoredString { + message.white().on_red() +} + +/// `<question>` — black on cyan +pub fn question(message: &str) -> ColoredString { + message.black().on_cyan() +} + +/// `<highlight>` — red foreground (Composer extension) +pub fn highlight(message: &str) -> ColoredString { + message.red() +} + +/// `<warning>` — black on yellow (Composer extension) +pub fn warning(message: &str) -> ColoredString { + message.black().on_yellow() +} + +// --------------------------------------------------------------------------- +// Verbosity +// --------------------------------------------------------------------------- + +/// Output verbosity level, ordered from least to most verbose. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Verbosity { + /// `-q` / `--quiet`: suppress all non-error output. + Quiet, + /// Default: normal informational messages. + Normal, + /// `-v`: additional detail (URLs, cache hits, skips). + Verbose, + /// `-vv`: HTTP details, file operations, resolver iterations. + VeryVerbose, + /// `-vvv`: full debug output (headers, raw payloads, timing). + Debug, +} + +impl Verbosity { + /// Construct a `Verbosity` from CLI flag counts. + /// + /// - `quiet == true` → `Quiet` (takes priority over `-v` flags) + /// - `verbose_count == 0` → `Normal` + /// - `verbose_count == 1` → `Verbose` + /// - `verbose_count == 2` → `VeryVerbose` + /// - `verbose_count >= 3` → `Debug` + pub fn from_flags(verbose_count: u8, quiet: bool) -> Self { + if quiet { + return Verbosity::Quiet; + } + match verbose_count { + 0 => Verbosity::Normal, + 1 => Verbosity::Verbose, + 2 => Verbosity::VeryVerbose, + _ => Verbosity::Debug, + } + } +} + +// --------------------------------------------------------------------------- +// Console +// --------------------------------------------------------------------------- + +/// Central IO hub for Mozart commands. +/// +/// Constructed once in `commands::execute()` and passed as `&Console` to every +/// command and library function that needs to produce output. +pub struct Console { + /// Whether the user can answer interactive prompts. + pub interactive: bool, + /// Current verbosity level. + pub verbosity: Verbosity, + /// Whether ANSI color codes should be emitted. + pub decorated: bool, +} + +impl Console { + /// Build a `Console` from primitive arguments. + /// + /// This is the primary constructor. Pass the relevant CLI flag values: + /// - `verbose`: the `-v` flag count (0, 1, 2, 3+) + /// - `quiet`: whether `--quiet` was passed + /// - `ansi`: whether `--ansi` was passed + /// - `no_ansi`: whether `--no-ansi` was passed + /// - `no_interaction`: whether `--no-interaction` / `-n` was passed + pub fn new(verbose: u8, quiet: bool, ansi: bool, no_ansi: bool, no_interaction: bool) -> Self { + let verbosity = Verbosity::from_flags(verbose, quiet); + let decorated = Self::resolve_decorated(ansi, no_ansi); + colored::control::set_override(decorated); + Self { + interactive: !no_interaction, + verbosity, + decorated, + } + } + + /// Determine whether ANSI color output should be enabled. + /// + /// - `no_ansi == true` → always disable + /// - `ansi == true` → always enable + /// - Otherwise → auto-detect: enabled only when stderr is a TTY + pub fn resolve_decorated(ansi: bool, no_ansi: bool) -> bool { + if no_ansi { + return false; + } + if ansi { + return true; + } + std::io::stderr().is_terminal() + } + + // ----------------------------------------------------------------------- + // Output methods + // ----------------------------------------------------------------------- + + /// Write `msg` to stderr if `self.verbosity >= required`. + pub fn write(&self, msg: &str, required: Verbosity) { + if self.verbosity >= required { + eprintln!("{msg}"); + } + } + + /// Write `msg` to stdout if `self.verbosity >= required`. + pub fn write_stdout(&self, msg: &str, required: Verbosity) { + if self.verbosity >= required { + println!("{msg}"); + } + } + + /// Write an error to stderr. Always shown, even in quiet mode. + pub fn write_error(&self, msg: &str) { + eprintln!("{}", error(msg)); + } + + // Convenience verbosity-level shortcuts: + + /// Normal-level message (suppressed by `--quiet`). + pub fn info(&self, msg: &str) { + self.write(msg, Verbosity::Normal); + } + + /// Verbose-level message (shown with `-v` or higher). + pub fn verbose(&self, msg: &str) { + self.write(msg, Verbosity::Verbose); + } + + /// Very-verbose-level message (shown with `-vv` or higher). + pub fn very_verbose(&self, msg: &str) { + self.write(msg, Verbosity::VeryVerbose); + } + + /// Debug-level message (shown with `-vvv`). + pub fn debug(&self, msg: &str) { + self.write(msg, Verbosity::Debug); + } + + /// Error message — always shown. + pub fn error(&self, msg: &str) { + self.write_error(msg); + } + + // ----------------------------------------------------------------------- + // Query methods + // ----------------------------------------------------------------------- + + pub fn is_verbose(&self) -> bool { + self.verbosity >= Verbosity::Verbose + } + + pub fn is_very_verbose(&self) -> bool { + self.verbosity >= Verbosity::VeryVerbose + } + + pub fn is_debug(&self) -> bool { + self.verbosity >= Verbosity::Debug + } + + pub fn is_quiet(&self) -> bool { + self.verbosity == Verbosity::Quiet + } + + // ----------------------------------------------------------------------- + // Interactive prompt methods (unchanged from prior implementation) + // ----------------------------------------------------------------------- + + pub fn ask(&self, prompt: &str, default: &str) -> String { + if !self.interactive { + return default.to_string(); + } + + Input::new() + .with_prompt(prompt) + .default(default.to_string()) + .allow_empty(true) + .interact_text() + .unwrap_or_else(|_| default.to_string()) + } + + pub fn ask_validated<F>( + &self, + prompt: &str, + default: &str, + validator: F, + ) -> Result<String, String> + where + F: Fn(&str) -> Result<(), String>, + { + if !self.interactive { + validator(default)?; + return Ok(default.to_string()); + } + + loop { + let input: String = Input::new() + .with_prompt(prompt) + .default(default.to_string()) + .allow_empty(true) + .interact_text() + .unwrap_or_else(|_| default.to_string()); + + match validator(&input) { + Ok(()) => return Ok(input), + Err(e) => { + self.write_error(&e); + } + } + } + } + + pub fn confirm(&self, prompt: &str) -> bool { + if !self.interactive { + return true; + } + + Confirm::new() + .with_prompt(prompt) + .default(true) + .interact() + .unwrap_or(true) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // ── Verbosity::from_flags ─────────────────────────────────────────────── + + #[test] + fn test_verbosity_quiet_takes_priority() { + assert_eq!(Verbosity::from_flags(3, true), Verbosity::Quiet); + assert_eq!(Verbosity::from_flags(0, true), Verbosity::Quiet); + } + + #[test] + fn test_verbosity_normal() { + assert_eq!(Verbosity::from_flags(0, false), Verbosity::Normal); + } + + #[test] + fn test_verbosity_verbose() { + assert_eq!(Verbosity::from_flags(1, false), Verbosity::Verbose); + } + + #[test] + fn test_verbosity_very_verbose() { + assert_eq!(Verbosity::from_flags(2, false), Verbosity::VeryVerbose); + } + + #[test] + fn test_verbosity_debug() { + assert_eq!(Verbosity::from_flags(3, false), Verbosity::Debug); + assert_eq!(Verbosity::from_flags(10, false), Verbosity::Debug); + } + + // ── Verbosity ordering ────────────────────────────────────────────────── + + #[test] + fn test_verbosity_ordering() { + assert!(Verbosity::Quiet < Verbosity::Normal); + assert!(Verbosity::Normal < Verbosity::Verbose); + assert!(Verbosity::Verbose < Verbosity::VeryVerbose); + assert!(Verbosity::VeryVerbose < Verbosity::Debug); + } + + // ── Console::resolve_decorated ────────────────────────────────────────── + + #[test] + fn test_resolve_decorated_no_ansi_wins() { + assert!(!Console::resolve_decorated(true, true)); + assert!(!Console::resolve_decorated(false, true)); + } + + #[test] + fn test_resolve_decorated_ansi_forces_on() { + assert!(Console::resolve_decorated(true, false)); + } + + // ── Console query methods ─────────────────────────────────────────────── + + fn make_console(verbosity: Verbosity) -> Console { + Console { + interactive: false, + verbosity, + decorated: false, + } + } + + #[test] + fn test_is_quiet() { + assert!(make_console(Verbosity::Quiet).is_quiet()); + assert!(!make_console(Verbosity::Normal).is_quiet()); + } + + #[test] + fn test_is_verbose() { + assert!(!make_console(Verbosity::Normal).is_verbose()); + assert!(make_console(Verbosity::Verbose).is_verbose()); + assert!(make_console(Verbosity::VeryVerbose).is_verbose()); + assert!(make_console(Verbosity::Debug).is_verbose()); + } + + #[test] + fn test_is_very_verbose() { + assert!(!make_console(Verbosity::Verbose).is_very_verbose()); + assert!(make_console(Verbosity::VeryVerbose).is_very_verbose()); + assert!(make_console(Verbosity::Debug).is_very_verbose()); + } + + #[test] + fn test_is_debug() { + assert!(!make_console(Verbosity::VeryVerbose).is_debug()); + assert!(make_console(Verbosity::Debug).is_debug()); + } +} diff --git a/crates/mozart-core/src/exit_code.rs b/crates/mozart-core/src/exit_code.rs new file mode 100644 index 0000000..bc01cfa --- /dev/null +++ b/crates/mozart-core/src/exit_code.rs @@ -0,0 +1,114 @@ +/// Exit code: success. +pub const OK: i32 = 0; + +/// Exit code: general / unclassified error. +pub const GENERAL_ERROR: i32 = 1; + +/// Exit code: dependency resolution failed. +pub const DEPENDENCY_RESOLUTION_FAILED: i32 = 2; + +/// Exit code: partial update requested but no lock file exists. +pub const NO_LOCK_FILE_FOR_PARTIAL_UPDATE: i32 = 3; + +/// Exit code: lock file is invalid or corrupt. +pub const LOCK_FILE_INVALID: i32 = 4; + +/// Exit code: audit found a security advisory. +pub const AUDIT_FAILED: i32 = 5; + +/// Exit code: HTTP / network transport error. +pub const TRANSPORT_ERROR: i32 = 100; + +// --------------------------------------------------------------------------- +// MozartError — carries a specific exit code through anyhow's error chain +// --------------------------------------------------------------------------- + +/// An error type that carries a specific exit code for Mozart to use on exit. +/// +/// Use [`bail`] or [`bail_silent`] to construct one wrapped in `anyhow::Error`. +#[derive(Debug)] +pub struct MozartError { + pub message: String, + pub exit_code: i32, +} + +impl std::fmt::Display for MozartError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for MozartError {} + +/// Return an `anyhow::Error` that carries `exit_code` and prints `message`. +pub fn bail(exit_code: i32, message: impl Into<String>) -> anyhow::Error { + MozartError { + message: message.into(), + exit_code, + } + .into() +} + +/// Return an `anyhow::Error` that carries `exit_code` but suppresses the +/// message (caller has already printed it). +pub fn bail_silent(exit_code: i32) -> anyhow::Error { + MozartError { + message: String::new(), + exit_code, + } + .into() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_constants_have_expected_values() { + assert_eq!(OK, 0); + assert_eq!(GENERAL_ERROR, 1); + assert_eq!(DEPENDENCY_RESOLUTION_FAILED, 2); + assert_eq!(NO_LOCK_FILE_FOR_PARTIAL_UPDATE, 3); + assert_eq!(LOCK_FILE_INVALID, 4); + assert_eq!(AUDIT_FAILED, 5); + assert_eq!(TRANSPORT_ERROR, 100); + } + + #[test] + fn test_mozart_error_display() { + let err = MozartError { + message: "something went wrong".to_string(), + exit_code: GENERAL_ERROR, + }; + assert_eq!(format!("{err}"), "something went wrong"); + } + + #[test] + fn test_bail_can_be_downcast() { + let err = bail(DEPENDENCY_RESOLUTION_FAILED, "cannot resolve"); + let me = err.downcast_ref::<MozartError>().expect("should downcast"); + assert_eq!(me.exit_code, DEPENDENCY_RESOLUTION_FAILED); + assert_eq!(me.message, "cannot resolve"); + } + + #[test] + fn test_bail_silent_has_empty_message() { + let err = bail_silent(GENERAL_ERROR); + let me = err.downcast_ref::<MozartError>().expect("should downcast"); + assert_eq!(me.exit_code, GENERAL_ERROR); + assert!(me.message.is_empty()); + } + + #[test] + fn test_mozart_error_is_std_error() { + let err: Box<dyn std::error::Error> = Box::new(MozartError { + message: "test".to_string(), + exit_code: 1, + }); + assert_eq!(err.to_string(), "test"); + } +} diff --git a/crates/mozart-core/src/lib.rs b/crates/mozart-core/src/lib.rs new file mode 100644 index 0000000..b02e5a3 --- /dev/null +++ b/crates/mozart-core/src/lib.rs @@ -0,0 +1,7 @@ +pub mod console; +pub mod exit_code; +pub mod package; +pub mod platform; +pub mod suggest; +pub mod validation; +pub mod version_bumper; diff --git a/crates/mozart-core/src/package.rs b/crates/mozart-core/src/package.rs new file mode 100644 index 0000000..9904dc4 --- /dev/null +++ b/crates/mozart-core/src/package.rs @@ -0,0 +1,703 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +/// Package stability level. +/// Higher value = less stable. +/// Corresponds to `Composer\Package\BasePackage::STABILITY_*`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +#[repr(u8)] +pub enum Stability { + #[default] + Stable = 0, + RC = 5, + Beta = 10, + Alpha = 15, + Dev = 20, +} + +impl Stability { + /// Parse a stability string (case-insensitive) into a `Stability` value. + /// + /// Recognizes: "stable", "RC", "beta", "alpha", "dev". + /// Defaults to `Stability::Stable` for unrecognized values. + pub fn parse(s: &str) -> Self { + match s.to_lowercase().as_str() { + "dev" => Stability::Dev, + "alpha" => Stability::Alpha, + "beta" => Stability::Beta, + "rc" => Stability::RC, + _ => Stability::Stable, + } + } +} + +/// A versioned relationship between two packages. +/// Corresponds to `Composer\Package\Link`. +#[derive(Debug, Clone)] +pub struct Link { + pub source: String, + pub target: String, + pub constraint: String, + pub pretty_constraint: Option<String>, + pub description: String, +} + +/// Package author metadata. +#[derive(Debug, Clone)] +pub struct Author { + pub name: Option<String>, + pub email: Option<String>, + pub homepage: Option<String>, + pub role: Option<String>, +} + +/// Autoload rule sets (PSR-4, PSR-0, classmap, files). +#[derive(Debug, Clone, Default)] +pub struct AutoloadRules { + pub psr4: BTreeMap<String, Vec<String>>, + pub psr0: BTreeMap<String, Vec<String>>, + pub classmap: Vec<String>, + pub files: Vec<String>, +} + +/// Support channel information. +#[derive(Debug, Clone, Default)] +pub struct Support { + pub email: Option<String>, + pub issues: Option<String>, + pub forum: Option<String>, + pub wiki: Option<String>, + pub source: Option<String>, + pub docs: Option<String>, + pub irc: Option<String>, + pub chat: Option<String>, + pub rss: Option<String>, + pub security: Option<String>, +} + +/// Funding link. +#[derive(Debug, Clone)] +pub struct Funding { + pub url: Option<String>, + pub funding_type: Option<String>, +} + +/// Version alias entry for root packages. +#[derive(Debug, Clone)] +pub struct VersionAlias { + pub package: String, + pub version: String, + pub alias: String, + pub alias_normalized: String, +} + +/// Core package data covering `BasePackage` + `Package` fields. +/// Corresponds to `Composer\Package\Package` (implements `PackageInterface`). +#[derive(Debug, Clone)] +pub struct PackageData { + // BasePackage fields + pub name: String, + pub pretty_name: String, + + // Package fields + pub version: String, + pub pretty_version: String, + pub package_type: String, + pub target_dir: Option<String>, + + // source + pub source_type: Option<String>, + pub source_url: Option<String>, + pub source_reference: Option<String>, + + // dist + pub dist_type: Option<String>, + pub dist_url: Option<String>, + pub dist_reference: Option<String>, + pub dist_sha1_checksum: Option<String>, + + pub release_date: Option<String>, + pub extra: BTreeMap<String, serde_json::Value>, + pub binaries: Vec<String>, + pub dev: bool, + pub stability: Stability, + pub notification_url: Option<String>, + + // dependency links + pub requires: BTreeMap<String, Link>, + pub conflicts: BTreeMap<String, Link>, + pub provides: BTreeMap<String, Link>, + pub replaces: BTreeMap<String, Link>, + pub dev_requires: BTreeMap<String, Link>, + pub suggests: BTreeMap<String, String>, + + // autoload + pub autoload: AutoloadRules, + pub dev_autoload: AutoloadRules, + + pub is_default_branch: bool, +} + +/// Package with full metadata (description, authors, license, etc.). +/// Corresponds to `Composer\Package\CompletePackage`. +#[derive(Debug, Clone)] +pub struct CompletePackageData { + pub package: PackageData, + + pub description: Option<String>, + pub homepage: Option<String>, + pub license: Vec<String>, + pub keywords: Vec<String>, + pub authors: Vec<Author>, + pub scripts: BTreeMap<String, Vec<String>>, + pub support: Support, + pub funding: Vec<Funding>, + pub repositories: Vec<serde_json::Value>, + /// `None` = not abandoned, `Some("")` = abandoned, `Some(pkg)` = replaced by pkg. + pub abandoned: Option<String>, + pub archive_name: Option<String>, + pub archive_excludes: Vec<String>, +} + +/// The root project package with project-level configuration. +/// Corresponds to `Composer\Package\RootPackage`. +#[derive(Debug, Clone)] +pub struct RootPackageData { + pub complete: CompletePackageData, + + pub minimum_stability: Stability, + pub prefer_stable: bool, + pub stability_flags: BTreeMap<String, Stability>, + pub config: BTreeMap<String, serde_json::Value>, + pub references: BTreeMap<String, String>, + pub aliases: Vec<VersionAlias>, +} + +/// Accessor for `PackageData` fields. +/// Corresponds to `Composer\Package\PackageInterface`. +pub trait Package { + fn name(&self) -> &str; + fn pretty_name(&self) -> &str; + fn version(&self) -> &str; + fn pretty_version(&self) -> &str; + fn package_type(&self) -> &str; + fn target_dir(&self) -> Option<&str>; + fn source_type(&self) -> Option<&str>; + fn source_url(&self) -> Option<&str>; + fn source_reference(&self) -> Option<&str>; + fn dist_type(&self) -> Option<&str>; + fn dist_url(&self) -> Option<&str>; + fn dist_reference(&self) -> Option<&str>; + fn dist_sha1_checksum(&self) -> Option<&str>; + fn release_date(&self) -> Option<&str>; + fn extra(&self) -> &BTreeMap<String, serde_json::Value>; + fn binaries(&self) -> &[String]; + fn is_dev(&self) -> bool; + fn stability(&self) -> Stability; + fn notification_url(&self) -> Option<&str>; + fn requires(&self) -> &BTreeMap<String, Link>; + fn conflicts(&self) -> &BTreeMap<String, Link>; + fn provides(&self) -> &BTreeMap<String, Link>; + fn replaces(&self) -> &BTreeMap<String, Link>; + fn dev_requires(&self) -> &BTreeMap<String, Link>; + fn suggests(&self) -> &BTreeMap<String, String>; + fn autoload(&self) -> &AutoloadRules; + fn dev_autoload(&self) -> &AutoloadRules; + fn is_default_branch(&self) -> bool; +} + +/// Accessor for `CompletePackageData` fields. +/// Corresponds to `Composer\Package\CompletePackageInterface`. +pub trait CompletePackage: Package { + fn description(&self) -> Option<&str>; + fn homepage(&self) -> Option<&str>; + fn license(&self) -> &[String]; + fn keywords(&self) -> &[String]; + fn authors(&self) -> &[Author]; + fn scripts(&self) -> &BTreeMap<String, Vec<String>>; + fn support(&self) -> &Support; + fn funding(&self) -> &[Funding]; + fn repositories(&self) -> &[serde_json::Value]; + fn abandoned(&self) -> Option<&str>; + fn archive_name(&self) -> Option<&str>; + fn archive_excludes(&self) -> &[String]; +} + +/// Accessor for `RootPackageData` fields. +/// Corresponds to `Composer\Package\RootPackageInterface`. +pub trait RootPackage: CompletePackage { + fn minimum_stability(&self) -> Stability; + fn prefer_stable(&self) -> bool; + fn stability_flags(&self) -> &BTreeMap<String, Stability>; + fn config(&self) -> &BTreeMap<String, serde_json::Value>; + fn references(&self) -> &BTreeMap<String, String>; + fn aliases(&self) -> &[VersionAlias]; +} + +// ────────────────────────────────────────────── +// Delegation macros +// ────────────────────────────────────────────── + +/// Implements `Package` trait by delegating to an inner `PackageData` field. +macro_rules! delegate_package { + ($type:ty => $($path:ident).+) => { + impl Package for $type { + fn name(&self) -> &str { &self.$($path).+.name } + fn pretty_name(&self) -> &str { &self.$($path).+.pretty_name } + fn version(&self) -> &str { &self.$($path).+.version } + fn pretty_version(&self) -> &str { &self.$($path).+.pretty_version } + fn package_type(&self) -> &str { &self.$($path).+.package_type } + fn target_dir(&self) -> Option<&str> { self.$($path).+.target_dir.as_deref() } + fn source_type(&self) -> Option<&str> { self.$($path).+.source_type.as_deref() } + fn source_url(&self) -> Option<&str> { self.$($path).+.source_url.as_deref() } + fn source_reference(&self) -> Option<&str> { self.$($path).+.source_reference.as_deref() } + fn dist_type(&self) -> Option<&str> { self.$($path).+.dist_type.as_deref() } + fn dist_url(&self) -> Option<&str> { self.$($path).+.dist_url.as_deref() } + fn dist_reference(&self) -> Option<&str> { self.$($path).+.dist_reference.as_deref() } + fn dist_sha1_checksum(&self) -> Option<&str> { self.$($path).+.dist_sha1_checksum.as_deref() } + fn release_date(&self) -> Option<&str> { self.$($path).+.release_date.as_deref() } + fn extra(&self) -> &BTreeMap<String, serde_json::Value> { &self.$($path).+.extra } + fn binaries(&self) -> &[String] { &self.$($path).+.binaries } + fn is_dev(&self) -> bool { self.$($path).+.dev } + fn stability(&self) -> Stability { self.$($path).+.stability } + fn notification_url(&self) -> Option<&str> { self.$($path).+.notification_url.as_deref() } + fn requires(&self) -> &BTreeMap<String, Link> { &self.$($path).+.requires } + fn conflicts(&self) -> &BTreeMap<String, Link> { &self.$($path).+.conflicts } + fn provides(&self) -> &BTreeMap<String, Link> { &self.$($path).+.provides } + fn replaces(&self) -> &BTreeMap<String, Link> { &self.$($path).+.replaces } + fn dev_requires(&self) -> &BTreeMap<String, Link> { &self.$($path).+.dev_requires } + fn suggests(&self) -> &BTreeMap<String, String> { &self.$($path).+.suggests } + fn autoload(&self) -> &AutoloadRules { &self.$($path).+.autoload } + fn dev_autoload(&self) -> &AutoloadRules { &self.$($path).+.dev_autoload } + fn is_default_branch(&self) -> bool { self.$($path).+.is_default_branch } + } + }; +} + +/// Implements `CompletePackage` trait by delegating to an inner `CompletePackageData` field. +macro_rules! delegate_complete_package { + ($type:ty => $($path:ident).+) => { + impl CompletePackage for $type { + fn description(&self) -> Option<&str> { self.$($path).+.description.as_deref() } + fn homepage(&self) -> Option<&str> { self.$($path).+.homepage.as_deref() } + fn license(&self) -> &[String] { &self.$($path).+.license } + fn keywords(&self) -> &[String] { &self.$($path).+.keywords } + fn authors(&self) -> &[Author] { &self.$($path).+.authors } + fn scripts(&self) -> &BTreeMap<String, Vec<String>> { &self.$($path).+.scripts } + fn support(&self) -> &Support { &self.$($path).+.support } + fn funding(&self) -> &[Funding] { &self.$($path).+.funding } + fn repositories(&self) -> &[serde_json::Value] { &self.$($path).+.repositories } + fn abandoned(&self) -> Option<&str> { self.$($path).+.abandoned.as_deref() } + fn archive_name(&self) -> Option<&str> { self.$($path).+.archive_name.as_deref() } + fn archive_excludes(&self) -> &[String] { &self.$($path).+.archive_excludes } + } + }; +} + +impl Package for PackageData { + fn name(&self) -> &str { + &self.name + } + fn pretty_name(&self) -> &str { + &self.pretty_name + } + fn version(&self) -> &str { + &self.version + } + fn pretty_version(&self) -> &str { + &self.pretty_version + } + fn package_type(&self) -> &str { + &self.package_type + } + fn target_dir(&self) -> Option<&str> { + self.target_dir.as_deref() + } + fn source_type(&self) -> Option<&str> { + self.source_type.as_deref() + } + fn source_url(&self) -> Option<&str> { + self.source_url.as_deref() + } + fn source_reference(&self) -> Option<&str> { + self.source_reference.as_deref() + } + fn dist_type(&self) -> Option<&str> { + self.dist_type.as_deref() + } + fn dist_url(&self) -> Option<&str> { + self.dist_url.as_deref() + } + fn dist_reference(&self) -> Option<&str> { + self.dist_reference.as_deref() + } + fn dist_sha1_checksum(&self) -> Option<&str> { + self.dist_sha1_checksum.as_deref() + } + fn release_date(&self) -> Option<&str> { + self.release_date.as_deref() + } + fn extra(&self) -> &BTreeMap<String, serde_json::Value> { + &self.extra + } + fn binaries(&self) -> &[String] { + &self.binaries + } + fn is_dev(&self) -> bool { + self.dev + } + fn stability(&self) -> Stability { + self.stability + } + fn notification_url(&self) -> Option<&str> { + self.notification_url.as_deref() + } + fn requires(&self) -> &BTreeMap<String, Link> { + &self.requires + } + fn conflicts(&self) -> &BTreeMap<String, Link> { + &self.conflicts + } + fn provides(&self) -> &BTreeMap<String, Link> { + &self.provides + } + fn replaces(&self) -> &BTreeMap<String, Link> { + &self.replaces + } + fn dev_requires(&self) -> &BTreeMap<String, Link> { + &self.dev_requires + } + fn suggests(&self) -> &BTreeMap<String, String> { + &self.suggests + } + fn autoload(&self) -> &AutoloadRules { + &self.autoload + } + fn dev_autoload(&self) -> &AutoloadRules { + &self.dev_autoload + } + fn is_default_branch(&self) -> bool { + self.is_default_branch + } +} + +impl CompletePackage for CompletePackageData { + fn description(&self) -> Option<&str> { + self.description.as_deref() + } + fn homepage(&self) -> Option<&str> { + self.homepage.as_deref() + } + fn license(&self) -> &[String] { + &self.license + } + fn keywords(&self) -> &[String] { + &self.keywords + } + fn authors(&self) -> &[Author] { + &self.authors + } + fn scripts(&self) -> &BTreeMap<String, Vec<String>> { + &self.scripts + } + fn support(&self) -> &Support { + &self.support + } + fn funding(&self) -> &[Funding] { + &self.funding + } + fn repositories(&self) -> &[serde_json::Value] { + &self.repositories + } + fn abandoned(&self) -> Option<&str> { + self.abandoned.as_deref() + } + fn archive_name(&self) -> Option<&str> { + self.archive_name.as_deref() + } + fn archive_excludes(&self) -> &[String] { + &self.archive_excludes + } +} + +impl RootPackage for RootPackageData { + fn minimum_stability(&self) -> Stability { + self.minimum_stability + } + fn prefer_stable(&self) -> bool { + self.prefer_stable + } + fn stability_flags(&self) -> &BTreeMap<String, Stability> { + &self.stability_flags + } + fn config(&self) -> &BTreeMap<String, serde_json::Value> { + &self.config + } + fn references(&self) -> &BTreeMap<String, String> { + &self.references + } + fn aliases(&self) -> &[VersionAlias] { + &self.aliases + } +} + +// CompletePackageData delegates Package → inner PackageData +delegate_package!(CompletePackageData => package); + +// RootPackageData delegates Package → inner CompletePackageData → PackageData +delegate_package!(RootPackageData => complete.package); + +// RootPackageData delegates CompletePackage → inner CompletePackageData +delegate_complete_package!(RootPackageData => complete); + +/// Unstructured representation of a composer.json file. +/// Used by `init` and `create-project` to write a new composer.json. +/// Unlike the typed hierarchy above, all fields live at a single level +/// and map directly to the JSON keys via serde. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RawPackageData { + pub name: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option<String>, + + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub package_type: Option<String>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub homepage: Option<String>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub license: Option<String>, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub authors: Vec<RawAuthor>, + + #[serde(rename = "minimum-stability", skip_serializing_if = "Option::is_none")] + pub minimum_stability: Option<String>, + + #[serde(default)] + 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 = "Vec::is_empty")] + pub repositories: Vec<RawRepository>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub autoload: Option<RawAutoload>, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub bin: Vec<String>, + + #[serde(flatten)] + pub extra_fields: BTreeMap<String, serde_json::Value>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RawAuthor { + pub name: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RawAutoload { + #[serde(rename = "psr-4")] + pub psr4: BTreeMap<String, String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RawRepository { + #[serde(rename = "type")] + pub repo_type: String, + pub url: String, +} + +impl RawPackageData { + pub fn new(name: String) -> Self { + Self { + name, + description: None, + package_type: None, + homepage: None, + license: None, + authors: Vec::new(), + minimum_stability: None, + require: BTreeMap::new(), + require_dev: BTreeMap::new(), + repositories: Vec::new(), + autoload: None, + bin: Vec::new(), + extra_fields: BTreeMap::new(), + } + } +} + +pub fn read_from_file(path: &Path) -> anyhow::Result<RawPackageData> { + let content = fs::read_to_string(path)?; + let data: RawPackageData = serde_json::from_str(&content)?; + Ok(data) +} + +pub fn to_json_pretty(value: &impl Serialize) -> serde_json::Result<String> { + let formatter = serde_json::ser::PrettyFormatter::with_indent(b" "); + let mut buf = Vec::new(); + let mut ser = serde_json::Serializer::with_formatter(&mut buf, formatter); + value.serialize(&mut ser)?; + let mut json = String::from_utf8(buf).expect("serde_json produces valid UTF-8"); + json.push('\n'); + Ok(json) +} + +pub fn write_to_file(value: &impl Serialize, path: &Path) -> anyhow::Result<()> { + let json = to_json_pretty(value)?; + fs::write(path, json)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn raw_minimal_json() { + let raw = RawPackageData::new("test/pkg".to_string()); + let json = to_json_pretty(&raw).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed["name"], "test/pkg"); + assert!(parsed["require"].is_object()); + assert!(parsed.get("description").is_none()); + assert!(parsed.get("type").is_none()); + assert!(parsed.get("authors").is_none()); + assert!(parsed.get("require-dev").is_none()); + assert!(parsed.get("autoload").is_none()); + } + + #[test] + fn raw_full_json() { + let mut raw = RawPackageData::new("acme/full".to_string()); + raw.description = Some("A full package".to_string()); + raw.package_type = Some("library".to_string()); + raw.homepage = Some("https://example.com".to_string()); + raw.license = Some("MIT".to_string()); + raw.authors = vec![RawAuthor { + name: "Jane Doe".to_string(), + email: Some("jane@example.com".to_string()), + }]; + raw.minimum_stability = Some("dev".to_string()); + raw.require.insert("php".to_string(), ">=8.1".to_string()); + raw.require_dev + .insert("phpunit/phpunit".to_string(), "^10.0".to_string()); + raw.repositories = vec![RawRepository { + repo_type: "vcs".to_string(), + url: "https://github.com/acme/repo".to_string(), + }]; + + let mut psr4 = BTreeMap::new(); + psr4.insert("Acme\\Full\\".to_string(), "src/".to_string()); + raw.autoload = Some(RawAutoload { psr4 }); + + let json = to_json_pretty(&raw).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed["name"], "acme/full"); + assert_eq!(parsed["description"], "A full package"); + assert_eq!(parsed["type"], "library"); + assert_eq!(parsed["homepage"], "https://example.com"); + assert_eq!(parsed["license"], "MIT"); + assert_eq!(parsed["minimum-stability"], "dev"); + assert_eq!(parsed["authors"][0]["name"], "Jane Doe"); + assert_eq!(parsed["authors"][0]["email"], "jane@example.com"); + assert_eq!(parsed["require"]["php"], ">=8.1"); + assert_eq!(parsed["require-dev"]["phpunit/phpunit"], "^10.0"); + assert_eq!(parsed["repositories"][0]["type"], "vcs"); + assert_eq!(parsed["autoload"]["psr-4"]["Acme\\Full\\"], "src/"); + } + + #[test] + fn raw_deserialize_minimal() { + let json = r#"{"name": "test/pkg"}"#; + let raw: RawPackageData = serde_json::from_str(json).unwrap(); + assert_eq!(raw.name, "test/pkg"); + assert!(raw.description.is_none()); + assert!(raw.require.is_empty()); + assert!(raw.require_dev.is_empty()); + assert!(raw.authors.is_empty()); + assert!(raw.extra_fields.is_empty()); + } + + #[test] + fn raw_roundtrip_preserves_all_fields() { + let mut raw = RawPackageData::new("acme/roundtrip".to_string()); + raw.description = Some("Test roundtrip".to_string()); + raw.require.insert("php".to_string(), ">=8.1".to_string()); + raw.require_dev + .insert("phpunit/phpunit".to_string(), "^10.0".to_string()); + + let json1 = to_json_pretty(&raw).unwrap(); + let deserialized: RawPackageData = serde_json::from_str(&json1).unwrap(); + let json2 = to_json_pretty(&deserialized).unwrap(); + assert_eq!(json1, json2); + } + + #[test] + fn raw_extra_fields_preserved() { + let json = r#"{ + "name": "test/extra", + "require": {}, + "scripts": {"post-install-cmd": ["echo hello"]}, + "config": {"sort-packages": true}, + "extra": {"custom-key": "custom-value"} + }"#; + let raw: RawPackageData = serde_json::from_str(json).unwrap(); + assert_eq!(raw.name, "test/extra"); + assert!(raw.extra_fields.contains_key("scripts")); + assert!(raw.extra_fields.contains_key("config")); + assert!(raw.extra_fields.contains_key("extra")); + + // Roundtrip: extra fields should be preserved in output + let output = to_json_pretty(&raw).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert!(parsed["scripts"].is_object()); + assert!(parsed["config"].is_object()); + assert!(parsed["extra"].is_object()); + } + + #[test] + fn raw_read_from_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("composer.json"); + let content = r#"{"name": "test/file", "require": {"php": ">=8.0"}}"#; + std::fs::write(&path, content).unwrap(); + + let raw = read_from_file(&path).unwrap(); + assert_eq!(raw.name, "test/file"); + assert_eq!(raw.require.get("php").unwrap(), ">=8.0"); + } + + #[test] + fn raw_none_fields_omitted() { + let raw = RawPackageData::new("test/empty".to_string()); + let json = to_json_pretty(&raw).unwrap(); + + assert!(!json.contains("\"description\"")); + assert!(!json.contains("\"type\"")); + assert!(!json.contains("\"homepage\"")); + assert!(!json.contains("\"license\"")); + assert!(!json.contains("\"authors\"")); + assert!(!json.contains("\"minimum-stability\"")); + assert!(!json.contains("\"require-dev\"")); + assert!(!json.contains("\"repositories\"")); + assert!(!json.contains("\"autoload\"")); + } +} diff --git a/crates/mozart-core/src/platform.rs b/crates/mozart-core/src/platform.rs new file mode 100644 index 0000000..c1f187f --- /dev/null +++ b/crates/mozart-core/src/platform.rs @@ -0,0 +1,351 @@ +// Shared platform detection module. +// +// Provides detection of the PHP environment (version, extensions, capabilities) +// and helpers for identifying platform package names (php, ext-*, lib-*, etc.). + +// ─── Data structures ───────────────────────────────────────────────────────── + +/// A detected platform package with its name and version. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlatformPackage { + pub name: String, + pub version: String, +} + +// ─── Classification ────────────────────────────────────────────────────────── + +/// Returns true if the package name is a Composer platform package. +/// +/// Platform packages include: php, php-*, ext-*, lib-*, composer, +/// composer-plugin-api, composer-runtime-api. +pub fn is_platform_package(name: &str) -> bool { + let lower = name.to_lowercase(); + lower == "php" + || lower.starts_with("php-") + || lower.starts_with("ext-") + || lower.starts_with("lib-") + || lower == "composer" + || lower == "composer-plugin-api" + || lower == "composer-runtime-api" +} + +// ─── Detection ─────────────────────────────────────────────────────────────── + +/// Detect all platform packages by running a single PHP invocation. +/// +/// Returns an empty vec if PHP is not found or not executable. +pub fn detect_platform() -> Vec<PlatformPackage> { + let php_script = concat!( + "echo 'PHP_VERSION:' . PHP_VERSION . PHP_EOL;", + "echo 'PHP_INT_SIZE:' . PHP_INT_SIZE . PHP_EOL;", + "echo 'PHP_DEBUG:' . (PHP_DEBUG ? '1' : '0') . PHP_EOL;", + "echo 'PHP_ZTS:' . (defined('PHP_ZTS') && PHP_ZTS ? '1' : '0') . PHP_EOL;", + "echo 'IPV6:' . ((defined('AF_INET6') || @inet_pton('::') !== false) ? '1' : '0') . PHP_EOL;", + "echo 'EXTENSIONS:' . PHP_EOL;", + "foreach(get_loaded_extensions() as $e) { echo $e . ':' . (phpversion($e) ?: '0') . PHP_EOL; }" + ); + + let output = match std::process::Command::new("php") + .arg("-r") + .arg(php_script) + .output() + { + Ok(o) => o, + Err(_) => return vec![], + }; + + if !output.status.success() { + return vec![]; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + parse_platform_info(&stdout) +} + +/// Parse the output of the PHP platform detection script. +/// +/// Exposed for testing purposes. +pub fn parse_platform_info(output: &str) -> Vec<PlatformPackage> { + let mut packages: Vec<PlatformPackage> = Vec::new(); + + let mut php_version = String::new(); + let mut int_size: u8 = 0; + let mut php_debug = false; + let mut php_zts = false; + let mut php_ipv6 = false; + let mut in_extensions = false; + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + if let Some(v) = line.strip_prefix("PHP_VERSION:") { + php_version = v.to_string(); + continue; + } + if let Some(v) = line.strip_prefix("PHP_INT_SIZE:") { + int_size = v.parse().unwrap_or(0); + continue; + } + if let Some(v) = line.strip_prefix("PHP_DEBUG:") { + php_debug = v == "1"; + continue; + } + if let Some(v) = line.strip_prefix("PHP_ZTS:") { + php_zts = v == "1"; + continue; + } + if let Some(v) = line.strip_prefix("IPV6:") { + php_ipv6 = v == "1"; + continue; + } + if line == "EXTENSIONS:" { + in_extensions = true; + continue; + } + + if in_extensions { + // Format: ExtensionName:version + if let Some(colon_pos) = line.find(':') { + let ext_name = line[..colon_pos].trim().to_lowercase(); + let ext_version = line[colon_pos + 1..].trim(); + // Normalize: if version is "0", "false", or empty, use the PHP version + let version = + if ext_version.is_empty() || ext_version == "0" || ext_version == "false" { + if php_version.is_empty() { + "0.0.0".to_string() + } else { + php_version.clone() + } + } else { + ext_version.to_string() + }; + packages.push(PlatformPackage { + name: format!("ext-{ext_name}"), + version, + }); + } + } + } + + // Build the base php entry first (so it's easy to find) + if !php_version.is_empty() { + let mut result: Vec<PlatformPackage> = Vec::new(); + + result.push(PlatformPackage { + name: "php".to_string(), + version: php_version.clone(), + }); + + if int_size == 8 { + result.push(PlatformPackage { + name: "php-64bit".to_string(), + version: php_version.clone(), + }); + } + + if php_debug { + result.push(PlatformPackage { + name: "php-debug".to_string(), + version: php_version.clone(), + }); + } + + if php_zts { + result.push(PlatformPackage { + name: "php-zts".to_string(), + version: php_version.clone(), + }); + } + + if php_ipv6 { + result.push(PlatformPackage { + name: "php-ipv6".to_string(), + version: php_version.clone(), + }); + } + + result.extend(packages); + result + } else { + packages + } +} + +/// Try to detect the installed PHP version by running `php --version`. +pub fn detect_php_version() -> Option<String> { + let output = std::process::Command::new("php") + .arg("--version") + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + // Parse "PHP 8.2.1 (cli) ..." → "8.2.1" + let first_line = stdout.lines().next()?; + let parts: Vec<&str> = first_line.split_whitespace().collect(); + if parts.len() >= 2 && parts[0] == "PHP" { + Some(parts[1].to_string()) + } else { + None + } +} + +/// Try to detect PHP extensions by running `php -m`. +pub fn detect_php_extensions() -> Vec<String> { + let output = match std::process::Command::new("php").arg("-m").output() { + Ok(o) => o, + Err(_) => return vec![], + }; + + if !output.status.success() { + return vec![]; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + stdout + .lines() + .filter(|line| { + let l = line.trim(); + !l.is_empty() + && !l.starts_with('[') + && l.chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '-') + }) + .map(|l| l.trim().to_lowercase()) + .collect() +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_platform_package_php() { + assert!(is_platform_package("php")); + assert!(is_platform_package("PHP")); + } + + #[test] + fn test_is_platform_package_php_variants() { + assert!(is_platform_package("php-64bit")); + assert!(is_platform_package("php-debug")); + assert!(is_platform_package("php-zts")); + assert!(is_platform_package("php-ipv6")); + } + + #[test] + fn test_is_platform_package_ext() { + assert!(is_platform_package("ext-json")); + assert!(is_platform_package("ext-mbstring")); + assert!(is_platform_package("ext-ctype")); + } + + #[test] + fn test_is_platform_package_lib() { + assert!(is_platform_package("lib-pcre")); + assert!(is_platform_package("lib-curl")); + } + + #[test] + fn test_is_platform_package_composer() { + assert!(is_platform_package("composer")); + assert!(is_platform_package("composer-plugin-api")); + assert!(is_platform_package("composer-runtime-api")); + } + + #[test] + fn test_is_platform_package_not_platform() { + assert!(!is_platform_package("monolog/monolog")); + assert!(!is_platform_package("psr/log")); + assert!(!is_platform_package("symfony/console")); + assert!(!is_platform_package("vendor/package")); + } + + #[test] + fn test_parse_platform_info_basic() { + let output = "PHP_VERSION:8.2.1\nPHP_INT_SIZE:8\nPHP_DEBUG:0\nPHP_ZTS:0\nIPV6:1\nEXTENSIONS:\njson:8.2.1\nctype:8.2.1\n"; + let packages = parse_platform_info(output); + + let php = packages.iter().find(|p| p.name == "php"); + assert!(php.is_some()); + assert_eq!(php.unwrap().version, "8.2.1"); + + let php64 = packages.iter().find(|p| p.name == "php-64bit"); + assert!(php64.is_some(), "PHP_INT_SIZE=8 should produce php-64bit"); + + let ipv6 = packages.iter().find(|p| p.name == "php-ipv6"); + assert!(ipv6.is_some()); + + let ext_json = packages.iter().find(|p| p.name == "ext-json"); + assert!(ext_json.is_some()); + assert_eq!(ext_json.unwrap().version, "8.2.1"); + + let ext_ctype = packages.iter().find(|p| p.name == "ext-ctype"); + assert!(ext_ctype.is_some()); + } + + #[test] + fn test_parse_platform_info_no_debug_no_zts() { + let output = + "PHP_VERSION:8.1.0\nPHP_INT_SIZE:4\nPHP_DEBUG:0\nPHP_ZTS:0\nIPV6:0\nEXTENSIONS:\n"; + let packages = parse_platform_info(output); + + assert!(packages.iter().any(|p| p.name == "php")); + assert!(!packages.iter().any(|p| p.name == "php-64bit")); + assert!(!packages.iter().any(|p| p.name == "php-debug")); + assert!(!packages.iter().any(|p| p.name == "php-zts")); + assert!(!packages.iter().any(|p| p.name == "php-ipv6")); + } + + #[test] + fn test_parse_platform_info_debug_and_zts() { + let output = + "PHP_VERSION:8.3.0\nPHP_INT_SIZE:8\nPHP_DEBUG:1\nPHP_ZTS:1\nIPV6:0\nEXTENSIONS:\n"; + let packages = parse_platform_info(output); + + assert!(packages.iter().any(|p| p.name == "php-debug")); + assert!(packages.iter().any(|p| p.name == "php-zts")); + } + + #[test] + fn test_parse_platform_info_extension_version_zero() { + // Extensions returning version "0" should fall back to PHP version + let output = "PHP_VERSION:8.2.5\nPHP_INT_SIZE:8\nPHP_DEBUG:0\nPHP_ZTS:0\nIPV6:0\nEXTENSIONS:\nCore:0\n"; + let packages = parse_platform_info(output); + + let ext_core = packages.iter().find(|p| p.name == "ext-core"); + assert!(ext_core.is_some()); + assert_eq!( + ext_core.unwrap().version, + "8.2.5", + "version '0' should fall back to PHP version" + ); + } + + #[test] + fn test_parse_platform_info_no_php() { + // If PHP_VERSION is missing, only extensions are returned + let output = "EXTENSIONS:\njson:1.7\n"; + let packages = parse_platform_info(output); + + assert!(!packages.iter().any(|p| p.name == "php")); + assert!(packages.iter().any(|p| p.name == "ext-json")); + } + + #[test] + fn test_parse_platform_info_extension_names_lowercased() { + let output = "PHP_VERSION:8.0.0\nPHP_INT_SIZE:8\nPHP_DEBUG:0\nPHP_ZTS:0\nIPV6:0\nEXTENSIONS:\nJSON:8.0.0\nMbstring:8.0.0\n"; + let packages = parse_platform_info(output); + + assert!(packages.iter().any(|p| p.name == "ext-json")); + assert!(packages.iter().any(|p| p.name == "ext-mbstring")); + } +} diff --git a/crates/mozart-core/src/suggest.rs b/crates/mozart-core/src/suggest.rs new file mode 100644 index 0000000..9311fdb --- /dev/null +++ b/crates/mozart-core/src/suggest.rs @@ -0,0 +1,220 @@ +//! Fuzzy package name suggestions using Levenshtein distance. +//! +//! Used to provide "Did you mean ...?" hints when a user types a package name +//! that does not exist in the installed packages or in the require/require-dev +//! sections of composer.json. + +/// Compute the Levenshtein edit distance between two strings. +/// +/// This is a standard dynamic-programming implementation that runs in O(m*n) +/// time and O(min(m,n)) space. +pub fn levenshtein(a: &str, b: &str) -> usize { + let a: Vec<char> = a.chars().collect(); + let b: Vec<char> = b.chars().collect(); + + let m = a.len(); + let n = b.len(); + + if m == 0 { + return n; + } + if n == 0 { + return m; + } + + // Use two alternating rows to save memory. + let mut prev: Vec<usize> = (0..=n).collect(); + let mut curr: Vec<usize> = vec![0; n + 1]; + + for i in 1..=m { + curr[0] = i; + for j in 1..=n { + let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 }; + curr[j] = (prev[j] + 1) // deletion + .min(curr[j - 1] + 1) // insertion + .min(prev[j - 1] + cost); // substitution + } + std::mem::swap(&mut prev, &mut curr); + } + + prev[n] +} + +/// Maximum edit distance for a suggestion to be considered "similar". +/// +/// Packages with Levenshtein distance greater than this threshold are not +/// returned as suggestions. +const MAX_DISTANCE: usize = 5; + +/// Find package names from `candidates` that are similar to `query`. +/// +/// Returns a list of `(distance, name)` pairs sorted by ascending distance, +/// then ascending name for stability. Only candidates with a Levenshtein +/// distance <= [`MAX_DISTANCE`] are returned. +pub fn find_similar<'a>( + query: &str, + candidates: impl Iterator<Item = &'a str>, +) -> Vec<(usize, &'a str)> { + let query_lower = query.to_lowercase(); + let mut results: Vec<(usize, &'a str)> = candidates + .filter_map(|name| { + let dist = levenshtein(&query_lower, &name.to_lowercase()); + if dist <= MAX_DISTANCE && dist > 0 { + Some((dist, name)) + } else { + None + } + }) + .collect(); + + results.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1))); + results +} + +/// Format a "Did you mean ...?" message from a list of suggestions. +/// +/// Returns `None` when `suggestions` is empty. +/// +/// # Examples +/// +/// ``` +/// use mozart_core::suggest::format_did_you_mean; +/// let msg = format_did_you_mean(&["psr/log", "psr/cache"]); +/// assert!(msg.unwrap().contains("Did you mean")); +/// ``` +pub fn format_did_you_mean(suggestions: &[&str]) -> Option<String> { + if suggestions.is_empty() { + return None; + } + + let formatted = suggestions + .iter() + .map(|s| format!("\"{}\"", s)) + .collect::<Vec<_>>() + .join(" or "); + + Some(format!("Did you mean {}?", formatted)) +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ── levenshtein ─────────────────────────────────────────────────────────── + + #[test] + fn test_levenshtein_identical() { + assert_eq!(levenshtein("psr/log", "psr/log"), 0); + } + + #[test] + fn test_levenshtein_empty_left() { + assert_eq!(levenshtein("", "abc"), 3); + } + + #[test] + fn test_levenshtein_empty_right() { + assert_eq!(levenshtein("abc", ""), 3); + } + + #[test] + fn test_levenshtein_both_empty() { + assert_eq!(levenshtein("", ""), 0); + } + + #[test] + fn test_levenshtein_single_insertion() { + assert_eq!(levenshtein("psr/log", "psr/logs"), 1); + } + + #[test] + fn test_levenshtein_single_deletion() { + assert_eq!(levenshtein("psr/logs", "psr/log"), 1); + } + + #[test] + fn test_levenshtein_single_substitution() { + assert_eq!(levenshtein("psr/log", "psr/lag"), 1); + } + + #[test] + fn test_levenshtein_completely_different() { + assert_eq!(levenshtein("abc", "xyz"), 3); + } + + #[test] + fn test_levenshtein_package_names() { + // "monolog/monolog" vs "monolong/monolog" — 1 insertion + assert_eq!(levenshtein("monolog/monolog", "monolong/monolog"), 1); + } + + // ── find_similar ────────────────────────────────────────────────────────── + + #[test] + fn test_find_similar_returns_close_matches() { + let candidates = ["psr/log", "psr/cache", "monolog/monolog", "symfony/console"]; + let results = find_similar("psr/lod", candidates.iter().copied()); + assert!(!results.is_empty()); + // "psr/log" has distance 1 from "psr/lod" + assert_eq!(results[0].1, "psr/log"); + assert_eq!(results[0].0, 1); + } + + #[test] + fn test_find_similar_excludes_exact_match() { + let candidates = ["psr/log", "psr/cache"]; + // Exact match should not appear (distance == 0) + let results = find_similar("psr/log", candidates.iter().copied()); + assert!(!results.iter().any(|(_, name)| *name == "psr/log")); + } + + #[test] + fn test_find_similar_excludes_too_distant() { + let candidates = ["completely/different", "another/package"]; + let results = find_similar("psr/log", candidates.iter().copied()); + // All candidates are more than MAX_DISTANCE away + assert!(results.is_empty()); + } + + #[test] + fn test_find_similar_sorted_by_distance() { + let candidates = ["psr/log", "psr/logs", "psr/logsx"]; + // "psr/lod" -> "psr/log" distance 1, "psr/logs" distance 2, "psr/logsx" distance 3 + let results = find_similar("psr/lod", candidates.iter().copied()); + if results.len() >= 2 { + assert!(results[0].0 <= results[1].0); + } + } + + #[test] + fn test_find_similar_case_insensitive() { + let candidates = ["PSR/Log"]; + let results = find_similar("psr/log", candidates.iter().copied()); + // "psr/log" vs "psr/log" (both lowercased) = distance 0, so excluded + assert!(results.is_empty()); + } + + // ── format_did_you_mean ─────────────────────────────────────────────────── + + #[test] + fn test_format_did_you_mean_empty() { + assert!(format_did_you_mean(&[]).is_none()); + } + + #[test] + fn test_format_did_you_mean_single() { + let msg = format_did_you_mean(&["psr/log"]).unwrap(); + assert_eq!(msg, "Did you mean \"psr/log\"?"); + } + + #[test] + fn test_format_did_you_mean_multiple() { + let msg = format_did_you_mean(&["psr/log", "psr/cache"]).unwrap(); + assert!(msg.contains("Did you mean")); + assert!(msg.contains("\"psr/log\"")); + assert!(msg.contains("\"psr/cache\"")); + assert!(msg.contains(" or ")); + } +} diff --git a/crates/mozart-core/src/validation.rs b/crates/mozart-core/src/validation.rs new file mode 100644 index 0000000..7f946ae --- /dev/null +++ b/crates/mozart-core/src/validation.rs @@ -0,0 +1,226 @@ +use regex::Regex; +use std::sync::LazyLock; + +static PACKAGE_NAME_RE: LazyLock<Regex> = LazyLock::new(|| { + Regex::new(r"^[a-z0-9]([_.\-]?[a-z0-9]+)*/[a-z0-9](([_.]|\-{1,2})?[a-z0-9]+)*$").unwrap() +}); + +static AUTHOR_RE: LazyLock<Regex> = LazyLock::new(|| { + Regex::new(r"^(?P<name>[- .,\pL\pN\pM''\x{201C}\x{201D}()]+)(?:\s+<(?P<email>.+?)>)?$").unwrap() +}); + +static AUTOLOAD_PATH_RE: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r"^[^/][A-Za-z0-9\-_/]+/$").unwrap()); + +static CAMEL_SPLIT_RE: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r"(?:([a-z])([A-Z])|([A-Z])([A-Z][a-z]))").unwrap()); + +static SANITIZE_EDGES_RE: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r"^[_.\-]+|[_.\-]+$|[^a-z0-9_.\-]").unwrap()); + +static SANITIZE_REPEATS_RE: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r"([_.\-]){2,}").unwrap()); + +static NON_ALNUM_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[^a-zA-Z0-9]").unwrap()); + +const VALID_STABILITIES: &[&str] = &["dev", "alpha", "beta", "rc", "stable"]; + +pub fn validate_package_name(name: &str) -> bool { + PACKAGE_NAME_RE.is_match(name) +} + +pub struct ParsedAuthor { + pub name: String, + pub email: Option<String>, +} + +pub fn parse_author(input: &str) -> Result<ParsedAuthor, String> { + if let Some(caps) = AUTHOR_RE.captures(input) { + let name = caps.name("name").unwrap().as_str().trim().to_string(); + let email = caps.name("email").map(|m| m.as_str().to_string()); + Ok(ParsedAuthor { name, email }) + } else { + Err( + "Invalid author string. Must be in the formats: Jane Doe or John Smith <john@example.com>" + .to_string(), + ) + } +} + +pub fn validate_stability(s: &str) -> bool { + VALID_STABILITIES.contains(&s.to_lowercase().as_str()) +} + +pub fn validate_license(s: &str) -> bool { + // TODO: check SPDX Identifier + !s.is_empty() +} + +pub fn validate_autoload_path(s: &str) -> bool { + AUTOLOAD_PATH_RE.is_match(s) +} + +pub fn namespace_from_package_name(package_name: &str) -> Option<String> { + if package_name.is_empty() || !package_name.contains('/') { + return None; + } + + let parts: Vec<String> = package_name + .split('/') + .map(|part| { + let replaced = NON_ALNUM_RE.replace_all(part, " "); + let words: Vec<String> = replaced + .split_whitespace() + .map(|w| { + let mut chars = w.chars(); + match chars.next() { + Some(c) => c.to_uppercase().to_string() + &chars.collect::<String>(), + None => String::new(), + } + }) + .collect(); + words.join("") + }) + .collect(); + + Some(parts.join("\\")) +} + +pub fn sanitize_package_name_component(name: &str) -> String { + // CamelCase → kebab-case + let name = CAMEL_SPLIT_RE.replace_all(name, "${1}${3}-${2}${4}"); + let name = name.to_lowercase(); + // Remove leading/trailing separators and non-alnum chars + let name = SANITIZE_EDGES_RE.replace_all(&name, ""); + // Collapse repeated separators + let name = SANITIZE_REPEATS_RE.replace_all(&name, "$1"); + name.to_string() +} + +pub fn parse_require_string(s: &str) -> Result<(String, String), String> { + // Formats: "foo/bar:^1.0", "foo/bar=^1.0", "foo/bar ^1.0" + let s = s.trim(); + + for sep in [':', '=', ' '] { + if let Some(pos) = s.find(sep) { + let name = s[..pos].trim(); + let version = s[pos + sep.len_utf8()..].trim(); + if !name.is_empty() && !version.is_empty() { + return Ok((name.to_string(), version.to_string())); + } + } + } + + Err(format!( + "Could not parse requirement \"{s}\". Expected format: vendor/package:version" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_package_names() { + assert!(validate_package_name("vendor/package")); + assert!(validate_package_name("my-vendor/my-package")); + assert!(validate_package_name("vendor/pkg123")); + assert!(validate_package_name("a/b")); + assert!(validate_package_name("vendor/my_package")); + assert!(validate_package_name("vendor/my.package")); + assert!(validate_package_name("vendor/my--package")); + } + + #[test] + fn test_invalid_package_names() { + assert!(!validate_package_name("novendor")); + assert!(!validate_package_name("/package")); + assert!(!validate_package_name("vendor/")); + assert!(!validate_package_name("Vendor/Package")); + assert!(!validate_package_name("vendor/pack age")); + assert!(!validate_package_name("")); + } + + #[test] + fn test_parse_author_name_and_email() { + let a = parse_author("John Smith <john@example.com>").unwrap(); + assert_eq!(a.name, "John Smith"); + assert_eq!(a.email.as_deref(), Some("john@example.com")); + } + + #[test] + fn test_parse_author_name_only() { + let a = parse_author("Jane Doe").unwrap(); + assert_eq!(a.name, "Jane Doe"); + assert!(a.email.is_none()); + } + + #[test] + fn test_parse_author_invalid() { + assert!(parse_author("").is_err()); + } + + #[test] + fn test_validate_stability() { + assert!(validate_stability("dev")); + assert!(validate_stability("alpha")); + assert!(validate_stability("beta")); + assert!(validate_stability("rc")); + assert!(validate_stability("stable")); + assert!(validate_stability("Dev")); + assert!(validate_stability("STABLE")); + assert!(!validate_stability("invalid")); + assert!(!validate_stability("")); + } + + #[test] + fn test_validate_autoload_path() { + assert!(validate_autoload_path("src/")); + assert!(validate_autoload_path("lib/src/")); + assert!(!validate_autoload_path("/src/")); + assert!(!validate_autoload_path("src")); + assert!(!validate_autoload_path("")); + } + + #[test] + fn test_namespace_from_package_name() { + assert_eq!( + namespace_from_package_name("acme/my-pkg"), + Some("Acme\\MyPkg".to_string()) + ); + assert_eq!( + namespace_from_package_name("new_projects.acme-extra/package-name"), + Some("NewProjectsAcmeExtra\\PackageName".to_string()) + ); + assert_eq!(namespace_from_package_name(""), None); + assert_eq!(namespace_from_package_name("novendor"), None); + } + + #[test] + fn test_sanitize_package_name_component() { + assert_eq!(sanitize_package_name_component("MyPackage"), "my-package"); + assert_eq!( + sanitize_package_name_component("CamelCaseTest"), + "camel-case-test" + ); + assert_eq!(sanitize_package_name_component("already-ok"), "already-ok"); + assert_eq!(sanitize_package_name_component("__bad__"), "bad"); + } + + #[test] + fn test_parse_require_string() { + let (name, ver) = parse_require_string("foo/bar:^1.0").unwrap(); + assert_eq!(name, "foo/bar"); + assert_eq!(ver, "^1.0"); + + let (name, ver) = parse_require_string("foo/bar=^1.0").unwrap(); + assert_eq!(name, "foo/bar"); + assert_eq!(ver, "^1.0"); + + let (name, ver) = parse_require_string("foo/bar ^1.0").unwrap(); + assert_eq!(name, "foo/bar"); + assert_eq!(ver, "^1.0"); + + assert!(parse_require_string("invalid").is_err()); + } +} diff --git a/crates/mozart-core/src/version_bumper.rs b/crates/mozart-core/src/version_bumper.rs new file mode 100644 index 0000000..43c21d6 --- /dev/null +++ b/crates/mozart-core/src/version_bumper.rs @@ -0,0 +1,667 @@ +/// Version constraint bumper. +/// +/// Given a constraint string (from composer.json) and the installed version +/// (from composer.lock), computes a new constraint string that raises the +/// lower bound to match the installed version. +/// +/// Returns `None` if no change is needed, or `Some(new_constraint)` if the +/// constraint should be updated. +pub fn bump_requirement( + constraint_str: &str, + pretty_version: &str, + version_normalized: Option<&str>, +) -> Option<String> { + let constraint = constraint_str.trim(); + + // Strip and preserve stability flag (@dev, @beta, etc.) + let (constraint_body, stability_flag) = strip_stability_flag(constraint); + + // Dev constraints (dev-master, dev-main, etc.) are left unchanged + if constraint_body.trim().starts_with("dev-") { + return None; + } + + // Skip dev installed versions that have no alias + // An alias looks like "dev-master as 1.0.0" — the version string in the lock + // would be "dev-master" without " as ". + if pretty_version.starts_with("dev-") && !pretty_version.contains(" as ") { + return None; + } + if let Some(norm) = version_normalized + && norm.starts_with("dev-") + && !pretty_version.contains(" as ") + { + return None; + } + + // Resolve the actual version string to use for bumping. + // If the pretty_version contains an inline alias (e.g. "dev-master as 1.0.0"), + // take the alias target. Otherwise use pretty_version directly. + let installed_version = resolve_installed_version(pretty_version, version_normalized); + + // Handle OR constraints (^1.0 || ^2.0) + if constraint_body.contains("||") { + return bump_or_constraint(constraint_body, &installed_version, stability_flag); + } + + // Single constraint + bump_single(constraint_body.trim(), &installed_version, stability_flag) +} + +// ─── OR constraint handling ─────────────────────────────────────────────────── + +fn bump_or_constraint( + constraint_body: &str, + installed_version: &str, + stability_flag: Option<&str>, +) -> Option<String> { + let parts: Vec<&str> = constraint_body.split("||").map(str::trim).collect(); + + // Determine which major the installed version belongs to + let installed_major = parse_major(installed_version); + + let mut changed = false; + let mut new_parts: Vec<String> = Vec::new(); + + for part in &parts { + let part_trimmed = part.trim(); + // Determine the major range this disjunct covers + let part_major = constraint_major(part_trimmed); + + // Only bump the disjunct whose major matches the installed version's major + if part_major == installed_major { + if let Some(bumped) = bump_single(part_trimmed, installed_version, None) { + new_parts.push(bumped); + changed = true; + } else { + new_parts.push(part_trimmed.to_string()); + } + } else { + new_parts.push(part_trimmed.to_string()); + } + } + + if !changed { + return None; + } + + let joined = new_parts.join(" || "); + let result = append_stability_flag(&joined, stability_flag); + Some(result) +} + +// ─── Single constraint handling ─────────────────────────────────────────────── + +fn bump_single( + constraint: &str, + installed_version: &str, + stability_flag: Option<&str>, +) -> Option<String> { + // AND constraints (space-separated multiple operators like ">=1.0 <2.0" or + // comma-separated like ">=1.0,<2.0") are not supported for bumping — leave unchanged. + // We detect them by checking for a space or comma after the version spec begins. + // Quick check: if the constraint contains a space (ignoring leading operators), + // it's likely a multi-part AND constraint. + let after_op = constraint + .trim_start_matches('^') + .trim_start_matches('~') + .trim_start_matches(">=") + .trim_start_matches("<=") + .trim_start_matches("!=") + .trim_start_matches('>') + .trim_start_matches('<') + .trim_start_matches('='); + if after_op.contains(' ') || after_op.contains(',') { + return None; + } + + // Caret: ^X.Y.Z + if let Some(rest) = constraint.strip_prefix('^') { + return bump_caret(rest.trim(), installed_version, stability_flag); + } + + // Tilde: ~X.Y.Z + if let Some(rest) = constraint.strip_prefix('~') { + return bump_tilde(rest.trim(), installed_version, stability_flag); + } + + // Wildcard: * or X.* + if constraint == "*" || constraint.ends_with(".*") { + return bump_wildcard(constraint, installed_version, stability_flag); + } + + // Greater-or-equal: >=X.Y + if let Some(rest) = constraint.strip_prefix(">=") { + return bump_gte(rest.trim(), installed_version, stability_flag); + } + + // Other operators (exact, <, <=, >, !=, range) — leave unchanged + None +} + +// ─── Caret bump ─────────────────────────────────────────────────────────────── + +/// `^X.Y.Z` → bump to installed version if it is greater. +/// +/// The caret prefix is preserved; segments from installed version replace +/// those in the constraint (trimming trailing zeros appropriately). +fn bump_caret(rest: &str, installed_version: &str, stability_flag: Option<&str>) -> Option<String> { + let constraint_segments = parse_version_segments(rest); + let installed_segments = parse_version_segments(installed_version); + + // The constraint length determines how many segments to compare/output + let n_constraint = constraint_segments.len().max(1); + + // Compare: if installed <= current lower bound, no change needed + // We compare as many segments as the installed version has + let current_lower: Vec<u64> = constraint_segments + .iter() + .copied() + .chain(std::iter::repeat(0)) + .take(4) + .collect(); + let installed: Vec<u64> = installed_segments + .iter() + .copied() + .chain(std::iter::repeat(0)) + .take(4) + .collect(); + + if installed <= current_lower { + return None; + } + + // Build new constraint segments: use installed version, but only up to + // the number of non-trivial segments needed. + // We output at least as many segments as the original constraint had, + // but trim trailing zeros. + let mut new_segs: Vec<u64> = installed_segments + .iter() + .copied() + .chain(std::iter::repeat(0)) + .take(n_constraint.max(installed_segments.len())) + .collect(); + + // Trim trailing zeros (but keep at least n_constraint segments, minimum 1) + while new_segs.len() > n_constraint && new_segs.last() == Some(&0) { + new_segs.pop(); + } + // Also trim trailing zeros beyond 1 segment + while new_segs.len() > 1 && new_segs.last() == Some(&0) { + new_segs.pop(); + } + + let version_str = new_segs + .iter() + .map(|n| n.to_string()) + .collect::<Vec<_>>() + .join("."); + + let new_constraint = format!("^{version_str}"); + let result = append_stability_flag(&new_constraint, stability_flag); + Some(result) +} + +// ─── Tilde bump ─────────────────────────────────────────────────────────────── + +/// `~X.Y.Z` (3 segments) → bump patch: `~X.Y.new_patch` +/// `~X.Y` (2 segments) → convert to caret: `^X.Y.new_patch` +fn bump_tilde(rest: &str, installed_version: &str, stability_flag: Option<&str>) -> Option<String> { + let constraint_segments = parse_version_segments(rest); + let installed_segments = parse_version_segments(installed_version); + + let current_lower: Vec<u64> = constraint_segments + .iter() + .copied() + .chain(std::iter::repeat(0)) + .take(4) + .collect(); + let installed: Vec<u64> = installed_segments + .iter() + .copied() + .chain(std::iter::repeat(0)) + .take(4) + .collect(); + + if installed <= current_lower { + return None; + } + + let major = installed_segments.first().copied().unwrap_or(0); + let minor = installed_segments.get(1).copied().unwrap_or(0); + let patch = installed_segments.get(2).copied().unwrap_or(0); + + let new_constraint = if constraint_segments.len() >= 3 { + // ~X.Y.Z → keep tilde, bump patch + if patch == 0 { + format!("~{major}.{minor}.0") + } else { + format!("~{major}.{minor}.{patch}") + } + } else { + // ~X.Y → convert to caret + if patch == 0 { + format!("^{major}.{minor}") + } else { + format!("^{major}.{minor}.{patch}") + } + }; + + let result = append_stability_flag(&new_constraint, stability_flag); + Some(result) +} + +// ─── Wildcard bump ──────────────────────────────────────────────────────────── + +/// `*` → `>=installed` +/// `X.*` → `>=installed` (trimming trailing zeros) +fn bump_wildcard( + constraint: &str, + installed_version: &str, + stability_flag: Option<&str>, +) -> Option<String> { + let installed_segments = parse_version_segments(installed_version); + + // Trim trailing zeros + let mut segs = installed_segments.clone(); + while segs.len() > 1 && segs.last() == Some(&0) { + segs.pop(); + } + + let version_str = segs + .iter() + .map(|n| n.to_string()) + .collect::<Vec<_>>() + .join("."); + + // For plain wildcard "*", always produce >=installed + if constraint == "*" { + let new_constraint = format!(">={version_str}"); + return Some(append_stability_flag(&new_constraint, stability_flag)); + } + + // For "X.*", if installed is at that major, produce >=installed + let base = constraint.trim_end_matches(".*"); + let base_segs = parse_version_segments(base); + let current_lower: Vec<u64> = base_segs + .iter() + .copied() + .chain(std::iter::repeat(0)) + .take(4) + .collect(); + let installed: Vec<u64> = installed_segments + .iter() + .copied() + .chain(std::iter::repeat(0)) + .take(4) + .collect(); + + if installed <= current_lower { + return None; + } + + let new_constraint = format!(">={version_str}"); + Some(append_stability_flag(&new_constraint, stability_flag)) +} + +// ─── GTE bump ───────────────────────────────────────────────────────────────── + +/// `>=X.Y` → raise to installed version (trimming trailing zeros) +fn bump_gte(rest: &str, installed_version: &str, stability_flag: Option<&str>) -> Option<String> { + let constraint_segments = parse_version_segments(rest); + let installed_segments = parse_version_segments(installed_version); + + let current_lower: Vec<u64> = constraint_segments + .iter() + .copied() + .chain(std::iter::repeat(0)) + .take(4) + .collect(); + let installed: Vec<u64> = installed_segments + .iter() + .copied() + .chain(std::iter::repeat(0)) + .take(4) + .collect(); + + if installed <= current_lower { + return None; + } + + // Trim trailing zeros from installed version + let mut segs = installed_segments.clone(); + while segs.len() > 1 && segs.last() == Some(&0) { + segs.pop(); + } + + let version_str = segs + .iter() + .map(|n| n.to_string()) + .collect::<Vec<_>>() + .join("."); + + let new_constraint = format!(">={version_str}"); + let result = append_stability_flag(&new_constraint, stability_flag); + Some(result) +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/// Strip a trailing `@stability` flag from a constraint string. +/// Returns (body, flag) where flag is the `@...` suffix (without the `@`). +fn strip_stability_flag(constraint: &str) -> (&str, Option<&str>) { + let known = ["@dev", "@alpha", "@beta", "@RC", "@rc", "@stable"]; + for flag in &known { + if let Some(body) = constraint.strip_suffix(flag) { + let flag_str = &constraint[body.len()..]; + return (body.trim_end(), Some(flag_str)); + } + } + (constraint, None) +} + +/// Append an optional stability flag to a constraint string. +fn append_stability_flag(constraint: &str, flag: Option<&str>) -> String { + match flag { + Some(f) => format!("{constraint}{f}"), + None => constraint.to_string(), + } +} + +/// Parse a version string into numeric segments. +/// Handles "1.2.3", "1.2", "1", etc. +/// Stops at any non-numeric/non-dot character. +fn parse_version_segments(version: &str) -> Vec<u64> { + // Strip inline alias: "dev-master as 1.0.0" → "1.0.0" + let version = if let Some(pos) = version.find(" as ") { + &version[pos + 4..] + } else { + version + }; + + // Strip leading v/V + let version = version + .strip_prefix('v') + .or_else(|| version.strip_prefix('V')) + .unwrap_or(version); + + // Take up to any pre-release suffix (first '-' or '+') + let version = version.split(['-', '+']).next().unwrap_or(version); + + version + .split('.') + .filter_map(|s| s.parse::<u64>().ok()) + .collect() +} + +/// Parse the major version number from a version string. +fn parse_major(version: &str) -> Option<u64> { + parse_version_segments(version).into_iter().next() +} + +/// Determine the major version that a single disjunct constraint covers. +/// For `^1.2`, returns `Some(1)`. For `^0.3`, returns `Some(0)`. +fn constraint_major(constraint: &str) -> Option<u64> { + if let Some(rest) = constraint.strip_prefix('^') { + return parse_version_segments(rest).into_iter().next(); + } + if let Some(rest) = constraint.strip_prefix('~') { + return parse_version_segments(rest).into_iter().next(); + } + if let Some(rest) = constraint.strip_prefix(">=") { + return parse_version_segments(rest).into_iter().next(); + } + // Try as plain version + parse_version_segments(constraint).into_iter().next() +} + +/// Resolve the installed version string to use for comparison. +/// Handles inline aliases (e.g., "dev-main as 2.1.0" → "2.1.0"). +fn resolve_installed_version<'a>( + pretty_version: &'a str, + _version_normalized: Option<&'a str>, +) -> String { + // If pretty_version contains an inline alias, use the alias target + if let Some(pos) = pretty_version.find(" as ") { + return pretty_version[pos + 4..].trim().to_string(); + } + + // If version_normalized is available and not a dev branch, prefer it + // for more precise comparison, but use pretty_version for output + // Actually we use pretty_version for building constraint strings + // since normalized versions have extra .0 suffixes + + // Use pretty_version as-is (strip leading 'v' for normalization) + pretty_version + .strip_prefix('v') + .unwrap_or(pretty_version) + .to_string() +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ── Caret bumps ─────────────────────────────────────────────────────────── + + #[test] + fn test_caret_bump_basic() { + // ^1.0 + 1.2.1 → ^1.2.1 + let result = bump_requirement("^1.0", "1.2.1", Some("1.2.1.0")); + assert_eq!(result, Some("^1.2.1".to_string())); + } + + #[test] + fn test_caret_no_change_at_lower_bound() { + // ^1.2 + 1.2.0 → None (already at lower bound) + let result = bump_requirement("^1.2", "1.2.0", Some("1.2.0.0")); + assert_eq!(result, None); + } + + #[test] + fn test_caret_no_change_exact_match() { + // ^1.2.1 + 1.2.1 → None + let result = bump_requirement("^1.2.1", "1.2.1", Some("1.2.1.0")); + assert_eq!(result, None); + } + + #[test] + fn test_caret_bump_zero_major() { + // ^0.3 + 0.3.5 → ^0.3.5 + let result = bump_requirement("^0.3", "0.3.5", Some("0.3.5.0")); + assert_eq!(result, Some("^0.3.5".to_string())); + } + + #[test] + fn test_caret_bump_three_segments() { + // ^1.0.0 + 1.2.1 → ^1.2.1 + let result = bump_requirement("^1.0.0", "1.2.1", Some("1.2.1.0")); + assert_eq!(result, Some("^1.2.1".to_string())); + } + + #[test] + fn test_caret_bump_minor_only() { + // ^1.2 + 1.5.0 → ^1.5 (trailing zero trimmed) + let result = bump_requirement("^1.2", "1.5.0", Some("1.5.0.0")); + assert_eq!(result, Some("^1.5".to_string())); + } + + // ── Tilde bumps ─────────────────────────────────────────────────────────── + + #[test] + fn test_tilde_three_segment_bump() { + // ~2.0.0 + 2.0.3 → ~2.0.3 + let result = bump_requirement("~2.0.0", "2.0.3", Some("2.0.3.0")); + assert_eq!(result, Some("~2.0.3".to_string())); + } + + #[test] + fn test_tilde_two_segment_becomes_caret() { + // ~2.0 + 2.0.3 → ^2.0.3 + let result = bump_requirement("~2.0", "2.0.3", Some("2.0.3.0")); + assert_eq!(result, Some("^2.0.3".to_string())); + } + + #[test] + fn test_tilde_no_change() { + // ~2.0.3 + 2.0.3 → None + let result = bump_requirement("~2.0.3", "2.0.3", Some("2.0.3.0")); + assert_eq!(result, None); + } + + #[test] + fn test_tilde_two_segment_no_patch() { + // ~2.3 + 2.5.0 → ^2.5 (patch is 0, trimmed) + let result = bump_requirement("~2.3", "2.5.0", Some("2.5.0.0")); + assert_eq!(result, Some("^2.5".to_string())); + } + + // ── Wildcard bumps ──────────────────────────────────────────────────────── + + #[test] + fn test_wildcard_star() { + // * + 1.2.3 → >=1.2.3 + let result = bump_requirement("*", "1.2.3", Some("1.2.3.0")); + assert_eq!(result, Some(">=1.2.3".to_string())); + } + + #[test] + fn test_wildcard_major_star() { + // 2.* + 2.5.0 → >=2.5 + let result = bump_requirement("2.*", "2.5.0", Some("2.5.0.0")); + assert_eq!(result, Some(">=2.5".to_string())); + } + + #[test] + fn test_wildcard_no_change() { + // 2.* + 2.0.0 → None (installed is at lower bound) + let result = bump_requirement("2.*", "2.0.0", Some("2.0.0.0")); + assert_eq!(result, None); + } + + // ── GTE bumps ───────────────────────────────────────────────────────────── + + #[test] + fn test_gte_bump() { + // >=1.2 + 1.5.0 → >=1.5 + let result = bump_requirement(">=1.2", "1.5.0", Some("1.5.0.0")); + assert_eq!(result, Some(">=1.5".to_string())); + } + + #[test] + fn test_gte_no_change() { + // >=1.5 + 1.5.0 → None + let result = bump_requirement(">=1.5", "1.5.0", Some("1.5.0.0")); + assert_eq!(result, None); + } + + #[test] + fn test_gte_with_patch() { + // >=1.2.0 + 1.5.3 → >=1.5.3 + let result = bump_requirement(">=1.2.0", "1.5.3", Some("1.5.3.0")); + assert_eq!(result, Some(">=1.5.3".to_string())); + } + + // ── OR constraints ──────────────────────────────────────────────────────── + + #[test] + fn test_or_constraint_bumps_matching_major() { + // ^1.2 || ^2.3 + 1.3.0 → ^1.3 || ^2.3 + let result = bump_requirement("^1.2 || ^2.3", "1.3.0", Some("1.3.0.0")); + assert_eq!(result, Some("^1.3 || ^2.3".to_string())); + } + + #[test] + fn test_or_constraint_bumps_second_major() { + // ^1.2 || ^2.3 + 2.5.0 → ^1.2 || ^2.5 + let result = bump_requirement("^1.2 || ^2.3", "2.5.0", Some("2.5.0.0")); + assert_eq!(result, Some("^1.2 || ^2.5".to_string())); + } + + #[test] + fn test_or_constraint_no_change() { + // ^1.2 || ^2.3 + 1.2.0 → None + let result = bump_requirement("^1.2 || ^2.3", "1.2.0", Some("1.2.0.0")); + assert_eq!(result, None); + } + + // ── Dev constraints ─────────────────────────────────────────────────────── + + #[test] + fn test_dev_constraint_unchanged() { + // dev-master → None + let result = bump_requirement("dev-master", "dev-master", None); + assert_eq!(result, None); + } + + #[test] + fn test_dev_installed_no_alias_unchanged() { + // Installed is dev-main without alias → None + let result = bump_requirement("^1.0", "dev-main", None); + assert_eq!(result, None); + } + + #[test] + fn test_dev_installed_with_alias() { + // Installed is "dev-main as 1.2.0" → bump based on alias + let result = bump_requirement("^1.0", "dev-main as 1.2.0", None); + assert_eq!(result, Some("^1.2".to_string())); + } + + // ── Stability flags ─────────────────────────────────────────────────────── + + #[test] + fn test_stability_flag_preserved() { + // ^1.0@dev + 1.2.0 → ^1.2@dev + let result = bump_requirement("^1.0@dev", "1.2.0", Some("1.2.0.0")); + assert_eq!(result, Some("^1.2@dev".to_string())); + } + + #[test] + fn test_stability_flag_beta_preserved() { + // ^1.0@beta + 1.2.1 → ^1.2.1@beta + let result = bump_requirement("^1.0@beta", "1.2.1", Some("1.2.1.0")); + assert_eq!(result, Some("^1.2.1@beta".to_string())); + } + + // ── Edge cases ──────────────────────────────────────────────────────────── + + #[test] + fn test_exact_constraint_no_bump() { + // 1.2.3 → None (exact version, not bumped) + let result = bump_requirement("1.2.3", "1.3.0", Some("1.3.0.0")); + assert_eq!(result, None); + } + + #[test] + fn test_complex_range_no_bump() { + // >=1.0 <2.0 → None (complex range, not bumped) + let result = bump_requirement(">=1.0 <2.0", "1.5.0", Some("1.5.0.0")); + assert_eq!(result, None); + } + + #[test] + fn test_parse_version_segments_basic() { + assert_eq!(parse_version_segments("1.2.3"), vec![1, 2, 3]); + assert_eq!(parse_version_segments("1.2"), vec![1, 2]); + assert_eq!(parse_version_segments("1"), vec![1]); + } + + #[test] + fn test_parse_version_segments_with_prerelease() { + assert_eq!(parse_version_segments("1.2.3-beta1"), vec![1, 2, 3]); + } + + #[test] + fn test_parse_version_segments_with_v_prefix() { + assert_eq!(parse_version_segments("v1.2.3"), vec![1, 2, 3]); + } + + #[test] + fn test_parse_version_segments_alias() { + // "dev-master as 1.0.0" → segments of "1.0.0" + assert_eq!(parse_version_segments("dev-master as 1.0.0"), vec![1, 0, 0]); + } +} diff --git a/crates/mozart-registry/Cargo.toml b/crates/mozart-registry/Cargo.toml new file mode 100644 index 0000000..964e0a1 --- /dev/null +++ b/crates/mozart-registry/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "mozart-registry" +version.workspace = true +edition.workspace = true + +[dependencies] +mozart-constraint.workspace = true +mozart-core.workspace = true +anyhow.workspace = true +bzip2.workspace = true +filetime.workspace = true +flate2.workspace = true +md5.workspace = true +pubgrub.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +sha1.workspace = true +tar.workspace = true +tempfile.workspace = true +tokio.workspace = true +zip.workspace = true 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" + ); + } +} diff --git a/crates/mozart/Cargo.toml b/crates/mozart/Cargo.toml index ccf7f94..8a803fa 100644 --- a/crates/mozart/Cargo.toml +++ b/crates/mozart/Cargo.toml @@ -4,27 +4,23 @@ version.workspace = true edition.workspace = true [dependencies] -anyhow = "1.0.101" -bzip2 = "0.5" -filetime = "0.2" -clap = { version = "4.5.57", features = ["derive"] } -clap_complete = "4" -colored = "3.1.1" -dialoguer = "0.12.0" -flate2 = "1" -md5 = "0.7" -pubgrub = "0.3.0" -regex = "1.12.3" -reqwest = { version = "0.13.2", features = ["blocking", "json"] } -self-replace = "1" -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.149" -sha1 = "0.10" -tar = "0.4" -tempfile = "3.25.0" -tokio = { version = "1.49.0", features = ["full"] } -zip = { version = "2", default-features = false, features = ["deflate"] } +mozart-constraint.workspace = true +mozart-archiver.workspace = true +mozart-autoload.workspace = true +mozart-core.workspace = true +mozart-registry.workspace = true +anyhow.workspace = true +clap.workspace = true +clap_complete.workspace = true +colored.workspace = true +regex.workspace = true +reqwest.workspace = true +self-replace.workspace = true +serde.workspace = true +serde_json.workspace = true +sha1.workspace = true +tempfile.workspace = true [dev-dependencies] -assert_cmd = "2" -predicates = "3" +assert_cmd.workspace = true +predicates.workspace = true diff --git a/crates/mozart/src/archiver.rs b/crates/mozart/src/archiver.rs index 57985ef..d351e44 100644 --- a/crates/mozart/src/archiver.rs +++ b/crates/mozart/src/archiver.rs @@ -974,9 +974,9 @@ mod tests { no_ansi: false, }; - let console = crate::console::Console { + let console = mozart_core::console::Console { interactive: false, - verbosity: crate::console::Verbosity::Normal, + verbosity: mozart_core::console::Verbosity::Normal, decorated: false, }; execute(&args, &cli, &console).unwrap(); @@ -1043,9 +1043,9 @@ mod tests { no_ansi: false, }; - let console = crate::console::Console { + let console = mozart_core::console::Console { interactive: false, - verbosity: crate::console::Verbosity::Normal, + verbosity: mozart_core::console::Verbosity::Normal, decorated: false, }; execute(&args, &cli, &console).unwrap(); @@ -1098,9 +1098,9 @@ mod tests { no_ansi: false, }; - let console = crate::console::Console { + let console = mozart_core::console::Console { interactive: false, - verbosity: crate::console::Verbosity::Normal, + verbosity: mozart_core::console::Verbosity::Normal, decorated: false, }; execute(&args, &cli, &console).unwrap(); @@ -1152,9 +1152,9 @@ mod tests { no_ansi: false, }; - let console = crate::console::Console { + let console = mozart_core::console::Console { interactive: false, - verbosity: crate::console::Verbosity::Normal, + verbosity: mozart_core::console::Verbosity::Normal, decorated: false, }; execute(&args, &cli, &console).unwrap(); @@ -1210,9 +1210,9 @@ mod tests { no_ansi: false, }; - let console = crate::console::Console { + let console = mozart_core::console::Console { interactive: false, - verbosity: crate::console::Verbosity::Normal, + verbosity: mozart_core::console::Verbosity::Normal, decorated: false, }; execute(&args, &cli, &console).unwrap(); @@ -1280,9 +1280,9 @@ mod tests { no_ansi: false, }; - let console = crate::console::Console { + let console = mozart_core::console::Console { interactive: false, - verbosity: crate::console::Verbosity::Normal, + verbosity: mozart_core::console::Verbosity::Normal, decorated: false, }; execute(&args, &cli, &console).unwrap(); @@ -1351,9 +1351,9 @@ mod tests { no_ansi: false, }; - let console = crate::console::Console { + let console = mozart_core::console::Console { interactive: false, - verbosity: crate::console::Verbosity::Normal, + verbosity: mozart_core::console::Verbosity::Normal, decorated: false, }; execute(&args, &cli, &console).unwrap(); @@ -1418,9 +1418,9 @@ mod tests { no_ansi: false, }; - let console = crate::console::Console { + let console = mozart_core::console::Console { interactive: false, - verbosity: crate::console::Verbosity::Normal, + verbosity: mozart_core::console::Verbosity::Normal, decorated: false, }; let result = execute(&args, &cli, &console); diff --git a/crates/mozart/src/autoload.rs b/crates/mozart/src/autoload.rs index 5d5d13c..2e4158c 100644 --- a/crates/mozart/src/autoload.rs +++ b/crates/mozart/src/autoload.rs @@ -1,5 +1,5 @@ -use crate::installed::InstalledPackages; -use crate::lockfile::LockedPackage; +use mozart_registry::installed::InstalledPackages; +use mozart_registry::lockfile::LockedPackage; use std::collections::{BTreeMap, HashSet}; use std::path::{Path, PathBuf}; @@ -979,7 +979,7 @@ pub fn determine_suffix(working_dir: &Path, vendor_dir: &Path) -> anyhow::Result // Try composer.lock content-hash let lock_path = working_dir.join("composer.lock"); if lock_path.exists() { - let lock = crate::lockfile::LockFile::read_from_file(&lock_path)?; + let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; return Ok(lock.content_hash); } @@ -1167,7 +1167,7 @@ pub fn generate(config: &AutoloadConfig) -> anyhow::Result<()> { installed .packages .iter() - .map(|p| crate::lockfile::LockedPackage { + .map(|p| mozart_registry::lockfile::LockedPackage { name: p.name.clone(), version: p.version.clone(), version_normalized: p.version_normalized.clone(), @@ -1262,7 +1262,7 @@ pub fn generate(config: &AutoloadConfig) -> anyhow::Result<()> { #[cfg(test)] mod tests { use super::*; - use crate::installed::{InstalledPackageEntry, InstalledPackages}; + use mozart_registry::installed::{InstalledPackageEntry, InstalledPackages}; use std::collections::BTreeMap; use tempfile::tempdir; diff --git a/crates/mozart/src/cache.rs b/crates/mozart/src/cache.rs index 3e8d715..ac4b507 100644 --- a/crates/mozart/src/cache.rs +++ b/crates/mozart/src/cache.rs @@ -46,8 +46,8 @@ impl CacheConfig { /// /// Respects `$COMPOSER_CACHE_DIR` for the base directory, and /// `$COMPOSER_NO_CACHE` / `COMPOSER_CACHE_READ_ONLY` env vars. -pub fn build_cache_config(cli: &super::commands::Cli) -> CacheConfig { - let no_cache = std::env::var("COMPOSER_NO_CACHE").is_ok() || cli.no_cache; +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")) diff --git a/crates/mozart/src/commands.rs b/crates/mozart/src/commands.rs index 285f65d..a745b3a 100644 --- a/crates/mozart/src/commands.rs +++ b/crates/mozart/src/commands.rs @@ -198,7 +198,13 @@ pub enum Commands { } pub fn execute(cli: &Cli) -> anyhow::Result<()> { - let console = crate::console::Console::from_cli(cli); + let console = mozart_core::console::Console::new( + cli.verbose, + cli.quiet, + cli.ansi, + cli.no_ansi, + cli.no_interaction, + ); match &cli.command { Commands::About(args) => about::execute(args, cli, &console), Commands::Archive(args) => archive::execute(args, cli, &console), diff --git a/crates/mozart/src/commands/about.rs b/crates/mozart/src/commands/about.rs index d60aecf..d436526 100644 --- a/crates/mozart/src/commands/about.rs +++ b/crates/mozart/src/commands/about.rs @@ -1,5 +1,5 @@ -use crate::console; use clap::Args; +use mozart_core::console; #[derive(Args)] pub struct AboutArgs {} diff --git a/crates/mozart/src/commands/archive.rs b/crates/mozart/src/commands/archive.rs index 9be45e9..687e116 100644 --- a/crates/mozart/src/commands/archive.rs +++ b/crates/mozart/src/commands/archive.rs @@ -85,9 +85,9 @@ impl Drop for PackageMeta { pub fn execute( args: &ArchiveArgs, cli: &super::Cli, - console: &crate::console::Console, + console: &mozart_core::console::Console, ) -> anyhow::Result<()> { - use crate::archiver::{ + use mozart_archiver::{ ArchiveFormat, collect_archivable_files, create_archive, generate_archive_filename, parse_composer_excludes, parse_gitattributes, parse_gitignore_pattern, self_exclusion_patterns, @@ -154,7 +154,7 @@ pub fn execute( if !composer_json_path.exists() { anyhow::bail!("No composer.json found in {}", working_dir.display()); } - let root = crate::package::read_from_file(&composer_json_path)?; + let root = mozart_core::package::read_from_file(&composer_json_path)?; let (archive_name, archive_excludes) = read_archive_config(&composer_json_path)?; let version = root .extra_fields @@ -243,11 +243,11 @@ fn resolve_remote_package( package_name: &str, version_constraint: Option<&str>, ) -> anyhow::Result<PackageMeta> { - use crate::package::Stability; - use crate::version::find_best_candidate; + use mozart_core::package::Stability; + use mozart_registry::version::find_best_candidate; // Fetch versions from Packagist - let versions = crate::packagist::fetch_package_versions(package_name, None)?; + let versions = mozart_registry::packagist::fetch_package_versions(package_name, None)?; if versions.is_empty() { anyhow::bail!("No versions found for package \"{}\"", package_name); } @@ -292,11 +292,12 @@ fn resolve_remote_package( let temp_dir = temp_base.join(&unique); std::fs::create_dir_all(&temp_dir)?; - let bytes = crate::downloader::download_dist(&dist.url, dist.shasum.as_deref(), None, None)?; + let bytes = + mozart_registry::downloader::download_dist(&dist.url, dist.shasum.as_deref(), None, None)?; match dist.dist_type.as_str() { - "zip" => crate::downloader::extract_zip(&bytes, &temp_dir)?, - "tar" | "tar.gz" | "tgz" => crate::downloader::extract_tar_gz(&bytes, &temp_dir)?, + "zip" => mozart_registry::downloader::extract_zip(&bytes, &temp_dir)?, + "tar" | "tar.gz" | "tgz" => mozart_registry::downloader::extract_tar_gz(&bytes, &temp_dir)?, other => { let _ = std::fs::remove_dir_all(&temp_dir); anyhow::bail!("Unsupported dist type: {}", other); diff --git a/crates/mozart/src/commands/audit.rs b/crates/mozart/src/commands/audit.rs index 3e69bb3..7fd271f 100644 --- a/crates/mozart/src/commands/audit.rs +++ b/crates/mozart/src/commands/audit.rs @@ -2,7 +2,7 @@ use clap::Args; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; -use crate::packagist::SecurityAdvisory; +use mozart_registry::packagist::SecurityAdvisory; #[derive(Args)] pub struct AuditArgs { @@ -73,7 +73,7 @@ struct AuditResult { pub fn execute( args: &AuditArgs, cli: &super::Cli, - _console: &crate::console::Console, + _console: &mozart_core::console::Console, ) -> anyhow::Result<()> { // Validate format let format = args.format.as_str(); @@ -111,7 +111,7 @@ pub fn execute( // Fetch advisories let names: Vec<&str> = packages.iter().map(|p| p.name.as_str()).collect(); - let all_advisories = match crate::packagist::fetch_security_advisories(&names) { + let all_advisories = match mozart_registry::packagist::fetch_security_advisories(&names) { Ok(a) => a, Err(e) => { if args.ignore_unreachable { @@ -186,7 +186,7 @@ fn load_packages( fn load_installed_packages(working_dir: &Path, no_dev: bool) -> anyhow::Result<Vec<PackageEntry>> { let vendor_dir = working_dir.join("vendor"); - let installed = crate::installed::InstalledPackages::read(&vendor_dir)?; + let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?; let dev_names: std::collections::HashSet<String> = installed .dev_package_names @@ -225,9 +225,10 @@ fn load_locked_packages(working_dir: &Path, no_dev: bool) -> anyhow::Result<Vec< ); } - let lock = crate::lockfile::LockFile::read_from_file(&lock_path)?; + let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; - let mut all_packages: Vec<&crate::lockfile::LockedPackage> = lock.packages.iter().collect(); + let mut all_packages: Vec<&mozart_registry::lockfile::LockedPackage> = + lock.packages.iter().collect(); if !no_dev && let Some(ref pkgs_dev) = lock.packages_dev { all_packages.extend(pkgs_dev.iter()); @@ -272,7 +273,7 @@ fn filter_advisories( .as_deref() .unwrap_or(pkg.version.as_str()); - let installed_ver = match crate::constraint::Version::parse(version_str) { + let installed_ver = match mozart_constraint::Version::parse(version_str) { Ok(v) => v, Err(_) => { eprintln!( @@ -297,7 +298,7 @@ fn filter_advisories( // Normalize single-pipe OR separators (`|`) to double-pipe (`||`) // since the Packagist API may use either form. let normalized_constraint = normalize_or_separator(&advisory.affected_versions); - let constraint = match crate::constraint::VersionConstraint::parse( + let constraint = match mozart_constraint::VersionConstraint::parse( &normalized_constraint, ) { Ok(c) => c, @@ -391,7 +392,7 @@ fn render_table(result: &AuditResult) { if result.total_advisory_count == 0 && result.abandoned.is_empty() { println!( "{}", - crate::console::info("No security vulnerability advisories found.") + mozart_core::console::info("No security vulnerability advisories found.") ); return; } @@ -406,7 +407,7 @@ fn render_table(result: &AuditResult) { "Found {} security vulnerability {} affecting {} package(s):", result.total_advisory_count, advisory_word, result.affected_package_count ); - println!("{}", crate::console::highlight(&header)); + println!("{}", mozart_core::console::highlight(&header)); println!(); for advisories in result.advisories.values() { @@ -456,7 +457,7 @@ fn render_table(result: &AuditResult) { if !result.abandoned.is_empty() { let header = format!("Found {} abandoned package(s):", result.abandoned.len()); - println!("{}", crate::console::highlight(&header)); + println!("{}", mozart_core::console::highlight(&header)); println!(); let label_width = 20usize; @@ -605,7 +606,7 @@ fn render_summary(result: &AuditResult) { #[cfg(test)] mod tests { use super::*; - use crate::packagist::{AdvisorySource, SecurityAdvisory}; + use mozart_registry::packagist::{AdvisorySource, SecurityAdvisory}; use std::collections::BTreeMap; fn make_advisory( @@ -782,8 +783,8 @@ mod tests { let working_dir = dir.path(); let vendor_dir = working_dir.join("vendor"); - let mut installed = crate::installed::InstalledPackages::new(); - installed.upsert(crate::installed::InstalledPackageEntry { + let mut installed = mozart_registry::installed::InstalledPackages::new(); + installed.upsert(mozart_registry::installed::InstalledPackageEntry { name: "monolog/monolog".to_string(), version: "1.5.0".to_string(), version_normalized: Some("1.5.0.0".to_string()), @@ -811,8 +812,8 @@ mod tests { let working_dir = dir.path(); let vendor_dir = working_dir.join("vendor"); - let mut installed = crate::installed::InstalledPackages::new(); - installed.upsert(crate::installed::InstalledPackageEntry { + let mut installed = mozart_registry::installed::InstalledPackages::new(); + installed.upsert(mozart_registry::installed::InstalledPackageEntry { name: "monolog/monolog".to_string(), version: "1.5.0".to_string(), version_normalized: None, @@ -824,7 +825,7 @@ mod tests { aliases: vec![], extra_fields: BTreeMap::new(), }); - installed.upsert(crate::installed::InstalledPackageEntry { + installed.upsert(mozart_registry::installed::InstalledPackageEntry { name: "phpunit/phpunit".to_string(), version: "10.0.0".to_string(), version_normalized: None, @@ -848,7 +849,7 @@ mod tests { #[test] fn test_load_locked_packages() { - use crate::lockfile::{LockFile, LockedPackage}; + use mozart_registry::lockfile::{LockFile, LockedPackage}; use tempfile::tempdir; let dir = tempdir().unwrap(); @@ -902,7 +903,7 @@ mod tests { #[test] fn test_load_locked_packages_no_dev() { - use crate::lockfile::{LockFile, LockedPackage}; + use mozart_registry::lockfile::{LockFile, LockedPackage}; use tempfile::tempdir; let dir = tempdir().unwrap(); diff --git a/crates/mozart/src/commands/browse.rs b/crates/mozart/src/commands/browse.rs index 0a89ae7..d662ec0 100644 --- a/crates/mozart/src/commands/browse.rs +++ b/crates/mozart/src/commands/browse.rs @@ -21,7 +21,7 @@ pub struct BrowseArgs { pub fn execute( args: &BrowseArgs, cli: &super::Cli, - console: &crate::console::Console, + console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), @@ -36,7 +36,7 @@ pub fn execute( "No composer.json found in the current directory and no package specified." ); } - let root = crate::package::read_from_file(&composer_json)?; + let root = mozart_core::package::read_from_file(&composer_json)?; vec![root.name.clone()] } else { args.packages.clone() @@ -57,7 +57,7 @@ pub fn execute( None => { console.info(&format!( "{}", - crate::console::warning(&format!( + mozart_core::console::warning(&format!( "No URL found for package \"{}\".", package_name )) @@ -84,7 +84,7 @@ fn resolve_url( // 1. Check root package (composer.json) let composer_json = working_dir.join("composer.json"); if composer_json.exists() - && let Ok(root) = crate::package::read_from_file(&composer_json) + && let Ok(root) = mozart_core::package::read_from_file(&composer_json) && root.name.eq_ignore_ascii_case(package_name) && let Some(url) = extract_url_from_root(&root, prefer_homepage) { @@ -94,7 +94,7 @@ fn resolve_url( // 2. Check lock file (composer.lock) let lock_path = working_dir.join("composer.lock"); if lock_path.exists() - && let Ok(lock) = crate::lockfile::LockFile::read_from_file(&lock_path) + && let Ok(lock) = mozart_registry::lockfile::LockFile::read_from_file(&lock_path) { let all_packages = lock .packages @@ -109,7 +109,7 @@ fn resolve_url( } // 3. Fall back to Packagist API - match crate::packagist::fetch_package_versions(package_name, None) { + match mozart_registry::packagist::fetch_package_versions(package_name, None) { Ok(versions) => { // Find the latest stable version (first non-dev, or fallback to first) let best = versions @@ -129,7 +129,7 @@ fn resolve_url( // ─── URL extraction ─────────────────────────────────────────────────────────── fn extract_url_from_locked( - pkg: &crate::lockfile::LockedPackage, + pkg: &mozart_registry::lockfile::LockedPackage, prefer_homepage: bool, ) -> Option<String> { if prefer_homepage { @@ -161,7 +161,7 @@ fn extract_url_from_locked( } fn extract_url_from_root( - root: &crate::package::RawPackageData, + root: &mozart_core::package::RawPackageData, prefer_homepage: bool, ) -> Option<String> { if prefer_homepage { @@ -187,7 +187,7 @@ fn extract_url_from_root( } fn extract_url_from_packagist( - pkg: &crate::packagist::PackagistVersion, + pkg: &mozart_registry::packagist::PackagistVersion, prefer_homepage: bool, ) -> Option<String> { if prefer_homepage { @@ -278,14 +278,14 @@ mod tests { source_url: Option<&str>, homepage: Option<&str>, support_source: Option<&str>, - ) -> crate::lockfile::LockedPackage { + ) -> mozart_registry::lockfile::LockedPackage { let support = support_source.map(|s| serde_json::json!({"source": s})); - let source = source_url.map(|url| crate::lockfile::LockedSource { + let source = source_url.map(|url| mozart_registry::lockfile::LockedSource { source_type: "git".to_string(), url: url.to_string(), reference: None, }); - crate::lockfile::LockedPackage { + mozart_registry::lockfile::LockedPackage { name: "vendor/package".to_string(), version: "1.0.0".to_string(), version_normalized: None, diff --git a/crates/mozart/src/commands/bump.rs b/crates/mozart/src/commands/bump.rs index 4c37dd6..af2809d 100644 --- a/crates/mozart/src/commands/bump.rs +++ b/crates/mozart/src/commands/bump.rs @@ -25,7 +25,7 @@ pub struct BumpArgs { pub fn execute( args: &BumpArgs, cli: &super::Cli, - console: &crate::console::Console, + console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), @@ -44,7 +44,8 @@ pub fn execute( let composer_json_content = std::fs::read_to_string(&composer_json_path)?; // Parse composer.json - let mut root: crate::package::RawPackageData = serde_json::from_str(&composer_json_content)?; + let mut root: mozart_core::package::RawPackageData = + serde_json::from_str(&composer_json_content)?; // Warn if package is not a project (libraries shouldn't bump) if let Some(ref pkg_type) = root.package_type @@ -52,7 +53,7 @@ pub fn execute( { console.info(&format!( "{}", - crate::console::warning(&format!( + mozart_core::console::warning(&format!( "Warning: Bumping constraints for a non-project package (type=\"{pkg_type}\"). \ Libraries should not pin their dependencies." )) @@ -65,12 +66,12 @@ pub fn execute( } // Read and parse lock file - let lock = crate::lockfile::LockFile::read_from_file(&lock_path)?; + let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; // Check lock file freshness if !lock.is_fresh(&composer_json_content) { - return Err(crate::exit_code::bail( - crate::exit_code::LOCK_FILE_INVALID, + return Err(mozart_core::exit_code::bail( + mozart_core::exit_code::LOCK_FILE_INVALID, "composer.lock is not up to date with composer.json. \ Run `mozart install` or `mozart update` to refresh it.", )); @@ -107,7 +108,7 @@ pub fn execute( } if let Some((pretty_version, version_normalized)) = locked_versions.get(&pkg_name.to_lowercase()) - && let Some(new_constraint) = crate::version_bumper::bump_requirement( + && let Some(new_constraint) = mozart_core::version_bumper::bump_requirement( constraint, pretty_version, version_normalized.as_deref(), @@ -131,7 +132,7 @@ pub fn execute( } if let Some((pretty_version, version_normalized)) = locked_versions.get(&pkg_name.to_lowercase()) - && let Some(new_constraint) = crate::version_bumper::bump_requirement( + && let Some(new_constraint) = mozart_core::version_bumper::bump_requirement( constraint, pretty_version, version_normalized.as_deref(), @@ -154,16 +155,16 @@ pub fn execute( if args.dry_run { println!( "{}: {} → {}", - crate::console::info(name), + mozart_core::console::info(name), old, - crate::console::comment(new) + mozart_core::console::comment(new) ); } else { println!( "Bumping {} from {} to {}", - crate::console::info(name), + mozart_core::console::info(name), old, - crate::console::comment(new) + mozart_core::console::comment(new) ); } } @@ -182,18 +183,19 @@ pub fn execute( } // Write updated composer.json - crate::package::write_to_file(&root, &composer_json_path)?; + mozart_core::package::write_to_file(&root, &composer_json_path)?; // Update the lock file content-hash to match the new composer.json let new_composer_json_content = std::fs::read_to_string(&composer_json_path)?; - let new_hash = crate::lockfile::LockFile::compute_content_hash(&new_composer_json_content)?; + let new_hash = + mozart_registry::lockfile::LockFile::compute_content_hash(&new_composer_json_content)?; let mut updated_lock = lock; updated_lock.content_hash = new_hash; updated_lock.write_to_file(&lock_path)?; println!( "\n{}", - crate::console::info(&format!( + mozart_core::console::info(&format!( "{} constraint(s) bumped successfully.", total_changes )) @@ -206,7 +208,7 @@ pub fn execute( /// Build a map of lowercase package names to (pretty_version, version_normalized) from composer.lock. fn build_locked_versions_map( - lock: &crate::lockfile::LockFile, + lock: &mozart_registry::lockfile::LockFile, ) -> HashMap<String, (String, Option<String>)> { let mut map: HashMap<String, (String, Option<String>)> = HashMap::new(); @@ -242,7 +244,7 @@ fn is_platform_package(name: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::lockfile::{LockFile, LockedPackage}; + use mozart_registry::lockfile::{LockFile, LockedPackage}; use std::collections::BTreeMap; use tempfile::tempdir; @@ -344,9 +346,9 @@ mod tests { dry_run: false, }; let cli = make_cli(dir.path()); - let console = crate::console::Console { + let console = mozart_core::console::Console { interactive: false, - verbosity: crate::console::Verbosity::Normal, + verbosity: mozart_core::console::Verbosity::Normal, decorated: false, }; execute(&args, &cli, &console).unwrap(); @@ -380,9 +382,9 @@ mod tests { dry_run: true, }; let cli = make_cli(dir.path()); - let console = crate::console::Console { + let console = mozart_core::console::Console { interactive: false, - verbosity: crate::console::Verbosity::Normal, + verbosity: mozart_core::console::Verbosity::Normal, decorated: false, }; execute(&args, &cli, &console).unwrap(); @@ -417,9 +419,9 @@ mod tests { dry_run: false, }; let cli = make_cli(dir.path()); - let console = crate::console::Console { + let console = mozart_core::console::Console { interactive: false, - verbosity: crate::console::Verbosity::Normal, + verbosity: mozart_core::console::Verbosity::Normal, decorated: false, }; execute(&args, &cli, &console).unwrap(); @@ -460,9 +462,9 @@ mod tests { dry_run: false, }; let cli = make_cli(dir.path()); - let console = crate::console::Console { + let console = mozart_core::console::Console { interactive: false, - verbosity: crate::console::Verbosity::Normal, + verbosity: mozart_core::console::Verbosity::Normal, decorated: false, }; execute(&args, &cli, &console).unwrap(); @@ -505,9 +507,9 @@ mod tests { dry_run: false, }; let cli = make_cli(dir.path()); - let console = crate::console::Console { + let console = mozart_core::console::Console { interactive: false, - verbosity: crate::console::Verbosity::Normal, + verbosity: mozart_core::console::Verbosity::Normal, decorated: false, }; execute(&args, &cli, &console).unwrap(); @@ -589,9 +591,9 @@ mod tests { dry_run: false, }; let cli = make_cli(dir.path()); - let console = crate::console::Console { + let console = mozart_core::console::Console { interactive: false, - verbosity: crate::console::Verbosity::Normal, + verbosity: mozart_core::console::Verbosity::Normal, decorated: false, }; execute(&args, &cli, &console).unwrap(); @@ -636,9 +638,9 @@ mod tests { dry_run: false, }; let cli = make_cli(dir.path()); - let console = crate::console::Console { + let console = mozart_core::console::Console { interactive: false, - verbosity: crate::console::Verbosity::Normal, + verbosity: mozart_core::console::Verbosity::Normal, decorated: false, }; execute(&args, &cli, &console).unwrap(); diff --git a/crates/mozart/src/commands/check_platform_reqs.rs b/crates/mozart/src/commands/check_platform_reqs.rs index ad7b860..71728d3 100644 --- a/crates/mozart/src/commands/check_platform_reqs.rs +++ b/crates/mozart/src/commands/check_platform_reqs.rs @@ -55,7 +55,7 @@ struct CheckResult { pub fn execute( args: &CheckPlatformReqsArgs, cli: &super::Cli, - _console: &crate::console::Console, + _console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), @@ -89,7 +89,7 @@ pub fn execute( } // Detect real platform - let platform = crate::platform::detect_platform(); + let platform = mozart_core::platform::detect_platform(); // Check requirements against detected platform let results = check_requirements(&requirements, &platform); @@ -146,7 +146,7 @@ fn collect_requirements( // Always include root composer.json requirements let composer_json_path = working_dir.join("composer.json"); - let root = crate::package::read_from_file(&composer_json_path)?; + let root = mozart_core::package::read_from_file(&composer_json_path)?; add_platform_requirements_from_map(&root.require, "root", &mut requirements); if !args.no_dev { @@ -161,7 +161,7 @@ fn collect_from_lock( no_dev: bool, requirements: &mut BTreeMap<String, Vec<PlatformRequirement>>, ) -> anyhow::Result<()> { - let lock = crate::lockfile::LockFile::read_from_file(lock_path)?; + let lock = mozart_registry::lockfile::LockFile::read_from_file(lock_path)?; for pkg in &lock.packages { add_platform_requirements_from_map(&pkg.require, &pkg.name, requirements); @@ -181,7 +181,7 @@ fn collect_from_installed( no_dev: bool, requirements: &mut BTreeMap<String, Vec<PlatformRequirement>>, ) -> anyhow::Result<()> { - let installed = crate::installed::InstalledPackages::read(vendor_dir)?; + let installed = mozart_registry::installed::InstalledPackages::read(vendor_dir)?; let dev_names: std::collections::HashSet<String> = installed .dev_package_names @@ -200,7 +200,7 @@ fn collect_from_installed( { for (dep_name, dep_constraint_val) in require_obj { let dep_lower = dep_name.to_lowercase(); - if crate::platform::is_platform_package(&dep_lower) { + if mozart_core::platform::is_platform_package(&dep_lower) { let constraint = dep_constraint_val.as_str().unwrap_or("*").to_string(); requirements .entry(dep_lower) @@ -224,7 +224,7 @@ fn add_platform_requirements_from_map( ) { for (name, constraint) in require { let name_lower = name.to_lowercase(); - if crate::platform::is_platform_package(&name_lower) { + if mozart_core::platform::is_platform_package(&name_lower) { requirements .entry(name_lower) .or_default() @@ -240,7 +240,7 @@ fn add_platform_requirements_from_map( fn check_requirements( requirements: &BTreeMap<String, Vec<PlatformRequirement>>, - platform: &[crate::platform::PlatformPackage], + platform: &[mozart_core::platform::PlatformPackage], ) -> Vec<CheckResult> { let mut results: Vec<CheckResult> = Vec::new(); @@ -279,18 +279,18 @@ fn check_requirements( } Some(detected) => { // Check all constraints - let detected_version = match crate::constraint::Version::parse(&detected.version) { + let detected_version = match mozart_constraint::Version::parse(&detected.version) { Ok(v) => v, Err(_) => { // Unparseable version → treat as 0.0.0 - crate::constraint::Version::parse("0.0.0").unwrap() + mozart_constraint::Version::parse("0.0.0").unwrap() } }; let mut failed_req: Option<(String, String)> = None; for req in reqs { let constraint = - match crate::constraint::VersionConstraint::parse(&req.constraint) { + match mozart_constraint::VersionConstraint::parse(&req.constraint) { Ok(c) => c, Err(_) => continue, // skip unparseable constraints }; @@ -352,9 +352,9 @@ fn render_text(results: &[CheckResult]) { CheckStatus::Success => { println!( "{} {} {}", - crate::console::info(&padded_name), - crate::console::comment(&padded_version), - crate::console::info("success"), + mozart_core::console::info(&padded_name), + mozart_core::console::comment(&padded_version), + mozart_core::console::info("success"), ); } CheckStatus::Failed => { @@ -365,9 +365,9 @@ fn render_text(results: &[CheckResult]) { .unwrap_or(("", "")); println!( "{} {} {} requires {} ({})", - crate::console::comment(&padded_name), - crate::console::comment(&padded_version), - crate::console::error("failed"), + mozart_core::console::comment(&padded_name), + mozart_core::console::comment(&padded_version), + mozart_core::console::error("failed"), provider, constraint, ); @@ -380,9 +380,9 @@ fn render_text(results: &[CheckResult]) { .unwrap_or(("*", "")); println!( "{} {} {} requires {} ({})", - crate::console::comment(&padded_name), - crate::console::comment(&padded_version), - crate::console::error("missing"), + mozart_core::console::comment(&padded_name), + mozart_core::console::comment(&padded_version), + mozart_core::console::error("missing"), provider, constraint, ); @@ -426,7 +426,7 @@ fn render_json(results: &[CheckResult]) -> anyhow::Result<()> { #[cfg(test)] mod tests { use super::*; - use crate::platform::PlatformPackage; + use mozart_core::platform::PlatformPackage; use std::collections::BTreeMap; use tempfile::tempdir; @@ -501,17 +501,25 @@ mod tests { #[test] fn test_is_platform_package() { - assert!(crate::platform::is_platform_package("php")); - assert!(crate::platform::is_platform_package("ext-json")); - assert!(crate::platform::is_platform_package("ext-mbstring")); - assert!(crate::platform::is_platform_package("lib-pcre")); - assert!(crate::platform::is_platform_package("php-64bit")); - assert!(crate::platform::is_platform_package("composer-plugin-api")); - assert!(crate::platform::is_platform_package("composer-runtime-api")); + assert!(mozart_core::platform::is_platform_package("php")); + assert!(mozart_core::platform::is_platform_package("ext-json")); + assert!(mozart_core::platform::is_platform_package("ext-mbstring")); + assert!(mozart_core::platform::is_platform_package("lib-pcre")); + assert!(mozart_core::platform::is_platform_package("php-64bit")); + assert!(mozart_core::platform::is_platform_package( + "composer-plugin-api" + )); + assert!(mozart_core::platform::is_platform_package( + "composer-runtime-api" + )); - assert!(!crate::platform::is_platform_package("monolog/monolog")); - assert!(!crate::platform::is_platform_package("psr/log")); - assert!(!crate::platform::is_platform_package("symfony/console")); + assert!(!mozart_core::platform::is_platform_package( + "monolog/monolog" + )); + assert!(!mozart_core::platform::is_platform_package("psr/log")); + assert!(!mozart_core::platform::is_platform_package( + "symfony/console" + )); } // ── test_collect_requirements_from_lock ────────────────────────────────── diff --git a/crates/mozart/src/commands/clear_cache.rs b/crates/mozart/src/commands/clear_cache.rs index 59baff3..afab64d 100644 --- a/crates/mozart/src/commands/clear_cache.rs +++ b/crates/mozart/src/commands/clear_cache.rs @@ -1,5 +1,5 @@ -use crate::cache::{Cache, build_cache_config}; use clap::Args; +use mozart_registry::cache::{Cache, build_cache_config}; #[derive(Args)] pub struct ClearCacheArgs { @@ -11,9 +11,9 @@ pub struct ClearCacheArgs { pub fn execute( args: &ClearCacheArgs, cli: &super::Cli, - console: &crate::console::Console, + console: &mozart_core::console::Console, ) -> anyhow::Result<()> { - let config = build_cache_config(cli); + let config = build_cache_config(cli.no_cache); if args.gc { // Run GC only (probabilistic under normal circumstances, but forced here) diff --git a/crates/mozart/src/commands/completion.rs b/crates/mozart/src/commands/completion.rs index 406749c..4c2f4a8 100644 --- a/crates/mozart/src/commands/completion.rs +++ b/crates/mozart/src/commands/completion.rs @@ -12,7 +12,7 @@ pub struct CompletionArgs { pub fn execute( args: &CompletionArgs, _cli: &super::Cli, - _console: &crate::console::Console, + _console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let mut cmd = super::Cli::command(); clap_complete::aot::generate(args.shell, &mut cmd, "mozart", &mut std::io::stdout()); diff --git a/crates/mozart/src/commands/config.rs b/crates/mozart/src/commands/config.rs index e875a92..2a3ab85 100644 --- a/crates/mozart/src/commands/config.rs +++ b/crates/mozart/src/commands/config.rs @@ -514,7 +514,7 @@ fn write_json_file(path: &Path, value: &serde_json::Value) -> anyhow::Result<()> { std::fs::create_dir_all(parent)?; } - crate::package::write_to_file(value, path)?; + mozart_core::package::write_to_file(value, path)?; Ok(()) } @@ -606,7 +606,7 @@ fn render_value(v: &serde_json::Value) -> String { pub fn execute( args: &ConfigArgs, cli: &super::Cli, - _console: &crate::console::Console, + _console: &mozart_core::console::Console, ) -> anyhow::Result<()> { // 1. Handle --editor mode if args.editor { @@ -1025,7 +1025,7 @@ fn execute_read( None => { eprintln!( "{}", - crate::console::error( + mozart_core::console::error( "No command specified. Use --list to show all config values, \ or provide a setting key." ) diff --git a/crates/mozart/src/commands/create_project.rs b/crates/mozart/src/commands/create_project.rs index 654eb0e..e9a1911 100644 --- a/crates/mozart/src/commands/create_project.rs +++ b/crates/mozart/src/commands/create_project.rs @@ -1,12 +1,12 @@ -use crate::console; -use crate::downloader; -use crate::lockfile; -use crate::package::{self, Stability}; -use crate::packagist; -use crate::resolver::{self, PlatformConfig, ResolveRequest}; -use crate::validation; -use crate::version; use clap::Args; +use mozart_core::console; +use mozart_core::package::{self, Stability}; +use mozart_core::validation; +use mozart_registry::downloader; +use mozart_registry::lockfile; +use mozart_registry::packagist; +use mozart_registry::resolver::{self, PlatformConfig, ResolveRequest}; +use mozart_registry::version; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -173,7 +173,7 @@ fn is_dir_non_empty(path: &Path) -> bool { pub fn execute( args: &CreateProjectArgs, cli: &super::Cli, - console: &crate::console::Console, + console: &mozart_core::console::Console, ) -> anyhow::Result<()> { // --- Handle deprecated / no-op flags --- if args.prefer_source { @@ -423,8 +423,8 @@ pub fn execute( console.info("Resolving dependencies..."); let resolved = resolver::resolve(&request).map_err(|e| { - crate::exit_code::bail( - crate::exit_code::DEPENDENCY_RESOLUTION_FAILED, + mozart_core::exit_code::bail( + mozart_core::exit_code::DEPENDENCY_RESOLUTION_FAILED, e.to_string(), ) })?; diff --git a/crates/mozart/src/commands/dependency.rs b/crates/mozart/src/commands/dependency.rs index 0ae8bb4..4714de2 100644 --- a/crates/mozart/src/commands/dependency.rs +++ b/crates/mozart/src/commands/dependency.rs @@ -67,7 +67,7 @@ pub fn load_packages(working_dir: &Path, locked: bool) -> Result<Vec<PackageInfo // Add the root package (composer.json) as a synthetic entry if composer_json_path.exists() - && let Ok(root) = crate::package::read_from_file(&composer_json_path) + && let Ok(root) = mozart_core::package::read_from_file(&composer_json_path) { // Extract conflict from extra_fields if present let conflict: BTreeMap<String, String> = root @@ -98,7 +98,7 @@ fn load_from_lockfile(lock_path: &Path) -> Result<Vec<PackageInfo>> { if !lock_path.exists() { anyhow::bail!("composer.lock not found — run `mozart install` first or omit --locked"); } - let lock = crate::lockfile::LockFile::read_from_file(lock_path)?; + let lock = mozart_registry::lockfile::LockFile::read_from_file(lock_path)?; let mut packages: Vec<PackageInfo> = Vec::new(); @@ -131,7 +131,7 @@ fn load_from_lockfile(lock_path: &Path) -> Result<Vec<PackageInfo>> { fn load_from_installed(working_dir: &Path) -> Result<Vec<PackageInfo>> { let vendor_dir = working_dir.join("vendor"); - let installed = crate::installed::InstalledPackages::read(&vendor_dir)?; + let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?; let packages = installed .packages @@ -192,7 +192,7 @@ fn load_from_installed(working_dir: &Path) -> Result<Vec<PackageInfo>> { pub fn get_dependents( packages: &[PackageInfo], needles: &[String], - constraint: Option<&crate::constraint::VersionConstraint>, + constraint: Option<&mozart_constraint::VersionConstraint>, inverted: bool, recursive: bool, ) -> Result<Vec<DependencyResult>> { @@ -317,7 +317,7 @@ fn recurse_dependents( fn get_prohibitors( packages: &[PackageInfo], needles: &[String], - constraint: Option<&crate::constraint::VersionConstraint>, + constraint: Option<&mozart_constraint::VersionConstraint>, _recursive: bool, ) -> Result<Vec<DependencyResult>> { let mut results: Vec<DependencyResult> = Vec::new(); @@ -333,7 +333,7 @@ fn get_prohibitors( .find(|(k, _)| k.to_lowercase() == needle_lower) && let Some(requested_version) = constraint && let Ok(pkg_constraint) = - crate::constraint::VersionConstraint::parse(req_constraint_str) + mozart_constraint::VersionConstraint::parse(req_constraint_str) { // The package requires `needle` but with a different // (incompatible) constraint — it blocks the requested version. @@ -359,7 +359,7 @@ fn get_prohibitors( .find(|(k, _)| k.to_lowercase() == needle_lower) && let Some(requested_version) = constraint && let Ok(pkg_constraint) = - crate::constraint::VersionConstraint::parse(req_constraint_str) + mozart_constraint::VersionConstraint::parse(req_constraint_str) && constraint_prohibits(requested_version, &pkg_constraint) { results.push(DependencyResult { @@ -380,7 +380,7 @@ fn get_prohibitors( .find(|(k, _)| k.to_lowercase() == needle_lower) && let Some(requested_version) = constraint && let Ok(conflict_constraint) = - crate::constraint::VersionConstraint::parse(conflict_constraint_str) + mozart_constraint::VersionConstraint::parse(conflict_constraint_str) { // If the conflict constraint overlaps with (matches) the // requested version range, this package conflicts with it. @@ -408,8 +408,8 @@ fn get_prohibitors( /// We sample a set of "representative versions" from the requested constraint /// and check whether none of them satisfy the package's constraint. fn constraint_prohibits( - requested: &crate::constraint::VersionConstraint, - pkg_constraint: &crate::constraint::VersionConstraint, + requested: &mozart_constraint::VersionConstraint, + pkg_constraint: &mozart_constraint::VersionConstraint, ) -> bool { // We try to determine if there is any version satisfying *requested* that // does NOT satisfy *pkg_constraint*. @@ -430,8 +430,8 @@ fn constraint_prohibits( /// That is, if the conflict constraint matches at least one version that the /// requested constraint also matches. fn constraint_overlaps( - requested: &crate::constraint::VersionConstraint, - conflict_constraint: &crate::constraint::VersionConstraint, + requested: &mozart_constraint::VersionConstraint, + conflict_constraint: &mozart_constraint::VersionConstraint, ) -> bool { let probes = sample_versions_from_constraint(requested); if probes.is_empty() { @@ -446,9 +446,9 @@ fn constraint_overlaps( /// constraint. These are used for the "does this constraint overlap/prohibit /// that constraint?" heuristic. fn sample_versions_from_constraint( - constraint: &crate::constraint::VersionConstraint, -) -> Vec<crate::constraint::Version> { - use crate::constraint::Version; + constraint: &mozart_constraint::VersionConstraint, +) -> Vec<mozart_constraint::Version> { + use mozart_constraint::Version; // Broad grid of versions to probe let candidates: &[&str] = &[ @@ -498,7 +498,7 @@ fn sample_versions_from_constraint( /// Columns: package name | version | link description | link constraint pub fn print_table(results: &[DependencyResult]) { if results.is_empty() { - println!("{}", crate::console::info("No relationships found.")); + println!("{}", mozart_core::console::info("No relationships found.")); return; } @@ -522,10 +522,10 @@ pub fn print_table(results: &[DependencyResult]) { for r in results { println!( "{:<name_w$} {:<ver_w$} {:<desc_w$} {}", - crate::console::info(&r.package_name), - crate::console::comment(&r.package_version), + mozart_core::console::info(&r.package_name), + mozart_core::console::comment(&r.package_version), r.link_description, - crate::console::comment(&r.link_constraint), + mozart_core::console::comment(&r.link_constraint), name_w = name_w, ver_w = ver_w, desc_w = desc_w, @@ -544,7 +544,7 @@ pub fn print_table(results: &[DependencyResult]) { /// ``` pub fn print_tree(results: &[DependencyResult], depth: usize) { if results.is_empty() && depth == 0 { - println!("{}", crate::console::info("No relationships found.")); + println!("{}", mozart_core::console::info("No relationships found.")); return; } @@ -556,10 +556,10 @@ pub fn print_tree(results: &[DependencyResult], depth: usize) { println!( "{}{:<} {} {} {}", prefix, - crate::console::info(&r.package_name), - crate::console::comment(&r.package_version), + mozart_core::console::info(&r.package_name), + mozart_core::console::comment(&r.package_version), r.link_description, - crate::console::comment(&r.link_constraint), + mozart_core::console::comment(&r.link_constraint), ); if !r.children.is_empty() { @@ -685,7 +685,7 @@ mod tests { make_pkg("root/project", "ROOT", &[("vendor/a", "^1.0")], &[], true), make_pkg("vendor/a", "1.0.0", &[], &[], false), ]; - let constraint = crate::constraint::VersionConstraint::parse("2.0.0").unwrap(); + let constraint = mozart_constraint::VersionConstraint::parse("2.0.0").unwrap(); let needles = vec!["vendor/a".to_string()]; let results = get_dependents(&packages, &needles, Some(&constraint), true, false).unwrap(); assert!(!results.is_empty(), "root should prohibit vendor/a 2.0"); @@ -713,7 +713,7 @@ mod tests { false, ), ]; - let constraint = crate::constraint::VersionConstraint::parse("2.0.0").unwrap(); + let constraint = mozart_constraint::VersionConstraint::parse("2.0.0").unwrap(); let needles = vec!["vendor/a".to_string()]; let results = get_dependents(&packages, &needles, Some(&constraint), true, false).unwrap(); // vendor/b conflicts with vendor/a ^2.0 which covers 2.0.0 @@ -733,7 +733,7 @@ mod tests { make_pkg("root/project", "ROOT", &[("vendor/a", "^2.0")], &[], true), make_pkg("vendor/a", "2.0.0", &[], &[], false), ]; - let constraint = crate::constraint::VersionConstraint::parse("2.5.0").unwrap(); + let constraint = mozart_constraint::VersionConstraint::parse("2.5.0").unwrap(); let needles = vec!["vendor/a".to_string()]; let results = get_dependents(&packages, &needles, Some(&constraint), true, false).unwrap(); assert!( diff --git a/crates/mozart/src/commands/depends.rs b/crates/mozart/src/commands/depends.rs index 80e70f1..91b3829 100644 --- a/crates/mozart/src/commands/depends.rs +++ b/crates/mozart/src/commands/depends.rs @@ -22,7 +22,7 @@ pub struct DependsArgs { pub fn execute( args: &DependsArgs, cli: &super::Cli, - _console: &crate::console::Console, + _console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), @@ -34,7 +34,7 @@ pub fn execute( if packages.is_empty() { println!( "{}", - crate::console::info("No packages found. Run `mozart install` first.") + mozart_core::console::info("No packages found. Run `mozart install` first.") ); return Ok(()); } diff --git a/crates/mozart/src/commands/diagnose.rs b/crates/mozart/src/commands/diagnose.rs index 199ed60..606d00e 100644 --- a/crates/mozart/src/commands/diagnose.rs +++ b/crates/mozart/src/commands/diagnose.rs @@ -220,7 +220,7 @@ fn check_composer_lock(working_dir: &Path) -> CheckResult { } }; - let lock = match crate::lockfile::LockFile::read_from_file(&lock_path) { + let lock = match mozart_registry::lockfile::LockFile::read_from_file(&lock_path) { Ok(l) => l, Err(e) => return CheckResult::Fail(format!("composer.lock is invalid: {e}")), }; @@ -374,7 +374,7 @@ fn check_cache_dir(cache_dir: &Path) -> CheckResult { pub fn execute( _args: &DiagnoseArgs, cli: &super::Cli, - _console: &crate::console::Console, + _console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), @@ -551,7 +551,7 @@ mod tests { #[test] fn test_check_composer_lock_fresh() { - use crate::lockfile::LockFile; + use mozart_registry::lockfile::LockFile; let dir = tempdir().unwrap(); @@ -587,7 +587,7 @@ mod tests { #[test] fn test_check_composer_lock_stale() { - use crate::lockfile::LockFile; + use mozart_registry::lockfile::LockFile; let dir = tempdir().unwrap(); diff --git a/crates/mozart/src/commands/dump_autoload.rs b/crates/mozart/src/commands/dump_autoload.rs index 6f38ab9..a920f5a 100644 --- a/crates/mozart/src/commands/dump_autoload.rs +++ b/crates/mozart/src/commands/dump_autoload.rs @@ -51,7 +51,7 @@ pub struct DumpAutoloadArgs { pub fn execute( args: &DumpAutoloadArgs, cli: &super::Cli, - console: &crate::console::Console, + console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), @@ -62,14 +62,14 @@ pub fn execute( let dev_mode = !args.no_dev; // Determine suffix: read from existing autoload.php, or from lock file, or generate - let suffix = crate::autoload::determine_suffix(&working_dir, &vendor_dir)?; + let suffix = mozart_autoload::autoload::determine_suffix(&working_dir, &vendor_dir)?; if args.dry_run { console.info("Dry run: would generate autoload files"); return Ok(()); } - crate::autoload::generate(&crate::autoload::AutoloadConfig { + mozart_autoload::autoload::generate(&mozart_autoload::autoload::AutoloadConfig { project_dir: working_dir, vendor_dir, dev_mode, @@ -79,7 +79,7 @@ pub fn execute( apcu: args.apcu, apcu_prefix: args.apcu_prefix.clone(), strict_psr: args.strict_psr, - platform_check: crate::autoload::PlatformCheckMode::Full, + platform_check: mozart_autoload::autoload::PlatformCheckMode::Full, ignore_platform_reqs: args.ignore_platform_reqs, })?; diff --git a/crates/mozart/src/commands/exec.rs b/crates/mozart/src/commands/exec.rs index 32781a5..1e785cb 100644 --- a/crates/mozart/src/commands/exec.rs +++ b/crates/mozart/src/commands/exec.rs @@ -20,7 +20,7 @@ pub struct ExecArgs { pub fn execute( args: &ExecArgs, cli: &super::Cli, - _console: &crate::console::Console, + _console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), @@ -58,7 +58,7 @@ pub fn execute( } else { // Check root composer.json bin entries let composer_json_path = working_dir.join("composer.json"); - if let Ok(root) = crate::package::read_from_file(&composer_json_path) { + if let Ok(root) = mozart_core::package::read_from_file(&composer_json_path) { root.bin.into_iter().find_map(|entry| { let p = working_dir.join(&entry); let stem = Path::new(&entry) @@ -159,7 +159,7 @@ fn get_binaries(working_dir: &Path, bin_dir: &Path) -> Vec<(String, bool)> { // Collect from root composer.json bin entries let composer_json_path = working_dir.join("composer.json"); - if let Ok(root) = crate::package::read_from_file(&composer_json_path) { + if let Ok(root) = mozart_core::package::read_from_file(&composer_json_path) { let existing: std::collections::HashSet<&str> = binaries.iter().map(|(n, _)| n.as_str()).collect(); let mut local: Vec<String> = root @@ -363,7 +363,7 @@ mod tests { assert!(!candidate.exists()); // Confirm root bin entries are also empty - let root = crate::package::read_from_file(&dir.path().join("composer.json")).unwrap(); + let root = mozart_core::package::read_from_file(&dir.path().join("composer.json")).unwrap(); assert!(root.bin.is_empty()); } } diff --git a/crates/mozart/src/commands/fund.rs b/crates/mozart/src/commands/fund.rs index e8a42f5..ad91d6b 100644 --- a/crates/mozart/src/commands/fund.rs +++ b/crates/mozart/src/commands/fund.rs @@ -26,7 +26,7 @@ struct FundingEntry { pub fn execute( args: &FundArgs, cli: &super::Cli, - _console: &crate::console::Console, + _console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), @@ -64,9 +64,10 @@ pub fn execute( fn collect_funding_from_locked(working_dir: &Path) -> anyhow::Result<Vec<FundingEntry>> { let lock_path = working_dir.join("composer.lock"); - let lock = crate::lockfile::LockFile::read_from_file(&lock_path)?; + let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; - let mut all_packages: Vec<&crate::lockfile::LockedPackage> = lock.packages.iter().collect(); + let mut all_packages: Vec<&mozart_registry::lockfile::LockedPackage> = + lock.packages.iter().collect(); if let Some(ref pkgs_dev) = lock.packages_dev { all_packages.extend(pkgs_dev.iter()); } @@ -94,7 +95,7 @@ fn collect_funding_from_locked(working_dir: &Path) -> anyhow::Result<Vec<Funding fn collect_funding_from_installed(working_dir: &Path) -> anyhow::Result<Vec<FundingEntry>> { let vendor_dir = working_dir.join("vendor"); - let installed = crate::installed::InstalledPackages::read(&vendor_dir)?; + let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?; let entries = installed .packages @@ -361,7 +362,7 @@ mod tests { #[test] fn test_fund_from_lockfile() { - use crate::lockfile::{LockFile, LockedPackage}; + use mozart_registry::lockfile::{LockFile, LockedPackage}; use tempfile::tempdir; let dir = tempdir().unwrap(); @@ -451,7 +452,7 @@ mod tests { let working_dir = dir.path(); let vendor_dir = working_dir.join("vendor"); - let mut installed = crate::installed::InstalledPackages::new(); + let mut installed = mozart_registry::installed::InstalledPackages::new(); let mut extra = BTreeMap::new(); extra.insert( @@ -461,7 +462,7 @@ mod tests { "url": "https://github.com/Seldaek" }]), ); - installed.upsert(crate::installed::InstalledPackageEntry { + installed.upsert(mozart_registry::installed::InstalledPackageEntry { name: "monolog/monolog".to_string(), version: "3.0.0".to_string(), version_normalized: None, @@ -475,7 +476,7 @@ mod tests { }); // Package without funding - installed.upsert(crate::installed::InstalledPackageEntry { + installed.upsert(mozart_registry::installed::InstalledPackageEntry { name: "psr/log".to_string(), version: "3.0.0".to_string(), version_normalized: None, @@ -498,7 +499,7 @@ mod tests { #[test] fn test_fund_no_funding_data() { - use crate::lockfile::{LockFile, LockedPackage}; + use mozart_registry::lockfile::{LockFile, LockedPackage}; use tempfile::tempdir; let dir = tempdir().unwrap(); diff --git a/crates/mozart/src/commands/global.rs b/crates/mozart/src/commands/global.rs index e6e7bc8..1cde2c1 100644 --- a/crates/mozart/src/commands/global.rs +++ b/crates/mozart/src/commands/global.rs @@ -16,7 +16,7 @@ pub struct GlobalArgs { pub fn execute( args: &GlobalArgs, cli: &super::Cli, - console: &crate::console::Console, + console: &mozart_core::console::Console, ) -> anyhow::Result<()> { use clap::Parser as _; use std::fs; diff --git a/crates/mozart/src/commands/init.rs b/crates/mozart/src/commands/init.rs index be104c6..25cc70e 100644 --- a/crates/mozart/src/commands/init.rs +++ b/crates/mozart/src/commands/init.rs @@ -1,9 +1,9 @@ -use crate::console; -use crate::package::{self, RawAuthor, RawAutoload, RawPackageData, RawRepository}; -use crate::validation; use anyhow::{Context, bail}; use clap::Args; use colored::Colorize; +use mozart_core::console; +use mozart_core::package::{self, RawAuthor, RawAutoload, RawPackageData, RawRepository}; +use mozart_core::validation; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::process::Command; @@ -211,7 +211,7 @@ fn build_interactive( let name = console.ask_validated( &format!( "Package name (<vendor>/<name>) [{}]", - crate::console::comment(&default_name), + mozart_core::console::comment(&default_name), ), &default_name, |val| { @@ -229,7 +229,10 @@ fn build_interactive( // Description let default_desc = args.description.clone().unwrap_or_default(); let description = console.ask( - &format!("Description [{}]", crate::console::comment(&default_desc)), + &format!( + "Description [{}]", + mozart_core::console::comment(&default_desc) + ), &default_desc, ); let description = if description.is_empty() { @@ -248,7 +251,7 @@ fn build_interactive( &format!( "Author [{}n to skip]", if !default_author.is_empty() { - format!("{}, ", crate::console::comment(&default_author)) + format!("{}, ", mozart_core::console::comment(&default_author)) } else { String::new() } @@ -272,7 +275,7 @@ fn build_interactive( let stability_input = console.ask( &format!( "Minimum Stability [{}]", - crate::console::comment(&default_stability), + mozart_core::console::comment(&default_stability), ), &default_stability, ); @@ -292,7 +295,7 @@ fn build_interactive( let type_input = console.ask( &format!( "Package Type (e.g. library, project, metapackage, composer-plugin) [{}]", - crate::console::comment(&default_type), + mozart_core::console::comment(&default_type), ), &default_type, ); @@ -305,7 +308,10 @@ fn build_interactive( // License let default_license = args.license.clone().unwrap_or_default(); let license_input = console.ask( - &format!("License [{}]", crate::console::comment(&default_license),), + &format!( + "License [{}]", + mozart_core::console::comment(&default_license), + ), &default_license, ); let license = if license_input.is_empty() { @@ -319,7 +325,7 @@ fn build_interactive( console.info(""); console.info(&format!( "{}", - crate::console::info("Define your dependencies.") + mozart_core::console::info("Define your dependencies.") )); console.info(""); let require = parse_requirements(&args.require)?; @@ -335,7 +341,7 @@ fn build_interactive( &format!( "Add PSR-4 autoload mapping? Maps namespace \"{}\" to the entered relative path. [{}, n to skip]", namespace, - crate::console::comment(&default_autoload), + mozart_core::console::comment(&default_autoload), ), &default_autoload, ); diff --git a/crates/mozart/src/commands/install.rs b/crates/mozart/src/commands/install.rs index fb7335b..1094d99 100644 --- a/crates/mozart/src/commands/install.rs +++ b/crates/mozart/src/commands/install.rs @@ -1,8 +1,8 @@ -use crate::console; -use crate::downloader; -use crate::installed; -use crate::lockfile; use clap::Args; +use mozart_core::console; +use mozart_registry::downloader; +use mozart_registry::installed; +use mozart_registry::lockfile; use std::collections::{BTreeMap, HashSet}; use std::path::{Path, PathBuf}; @@ -475,7 +475,7 @@ pub fn install_from_lock( let suffix = lock.content_hash.clone(); - crate::autoload::generate(&crate::autoload::AutoloadConfig { + mozart_autoload::autoload::generate(&mozart_autoload::autoload::AutoloadConfig { project_dir: working_dir.to_path_buf(), vendor_dir: vendor_dir.to_path_buf(), dev_mode, @@ -485,7 +485,7 @@ pub fn install_from_lock( apcu: config.apcu_autoloader, apcu_prefix: config.apcu_autoloader_prefix.clone(), strict_psr: false, - platform_check: crate::autoload::PlatformCheckMode::Full, + platform_check: mozart_autoload::autoload::PlatformCheckMode::Full, ignore_platform_reqs: config.ignore_platform_reqs, })?; @@ -499,7 +499,7 @@ pub fn install_from_lock( pub fn execute( args: &InstallArgs, cli: &super::Cli, - console: &crate::console::Console, + console: &mozart_core::console::Console, ) -> anyhow::Result<()> { // Step 1: Resolve the working directory let working_dir = resolve_working_dir(cli); @@ -507,8 +507,8 @@ pub fn execute( // Step 2: Validate arguments if !args.packages.is_empty() { let pkgs = args.packages.join(" "); - return Err(crate::exit_code::bail( - crate::exit_code::GENERAL_ERROR, + return Err(mozart_core::exit_code::bail( + mozart_core::exit_code::GENERAL_ERROR, format!( "Invalid argument {pkgs}. Use \"mozart require {pkgs}\" instead to add packages to your composer.json." ), @@ -516,8 +516,8 @@ pub fn execute( } if args.no_install { - return Err(crate::exit_code::bail( - crate::exit_code::GENERAL_ERROR, + return Err(mozart_core::exit_code::bail( + mozart_core::exit_code::GENERAL_ERROR, "Invalid option \"--no-install\". Use \"mozart update --no-install\" instead if you are trying to update the composer.lock file.", )); } @@ -537,8 +537,8 @@ pub fn execute( // Step 3: Read composer.lock let lock_path = working_dir.join("composer.lock"); if !lock_path.exists() { - return Err(crate::exit_code::bail( - crate::exit_code::LOCK_FILE_INVALID, + return Err(mozart_core::exit_code::bail( + mozart_core::exit_code::LOCK_FILE_INVALID, "No composer.lock file present. Run \"mozart update\" to generate one.", )); } diff --git a/crates/mozart/src/commands/licenses.rs b/crates/mozart/src/commands/licenses.rs index 0703976..4ffd928 100644 --- a/crates/mozart/src/commands/licenses.rs +++ b/crates/mozart/src/commands/licenses.rs @@ -30,7 +30,7 @@ struct LicenseEntry { pub fn execute( args: &LicensesArgs, cli: &super::Cli, - _console: &crate::console::Console, + _console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), @@ -51,7 +51,7 @@ pub fn execute( if !composer_json_path.exists() { anyhow::bail!("No composer.json found in {}", working_dir.display()); } - let root = crate::package::read_from_file(&composer_json_path)?; + let root = mozart_core::package::read_from_file(&composer_json_path)?; let root_name = root.name.clone(); let root_version = root @@ -98,7 +98,7 @@ pub fn execute( fn load_installed_licenses(working_dir: &Path, no_dev: bool) -> anyhow::Result<Vec<LicenseEntry>> { let vendor_dir = working_dir.join("vendor"); - let installed = crate::installed::InstalledPackages::read(&vendor_dir)?; + let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?; let dev_names: HashSet<String> = installed .dev_package_names @@ -134,9 +134,10 @@ fn load_locked_licenses(working_dir: &Path, no_dev: bool) -> anyhow::Result<Vec< ); } - let lock = crate::lockfile::LockFile::read_from_file(&lock_path)?; + let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; - let mut all_packages: Vec<&crate::lockfile::LockedPackage> = lock.packages.iter().collect(); + let mut all_packages: Vec<&mozart_registry::lockfile::LockedPackage> = + lock.packages.iter().collect(); if !no_dev && let Some(ref pkgs_dev) = lock.packages_dev { all_packages.extend(pkgs_dev.iter()); @@ -157,7 +158,9 @@ fn load_locked_licenses(working_dir: &Path, no_dev: bool) -> anyhow::Result<Vec< // ─── License extraction ─────────────────────────────────────────────────────── -fn extract_installed_licenses(pkg: &crate::installed::InstalledPackageEntry) -> Vec<String> { +fn extract_installed_licenses( + pkg: &mozart_registry::installed::InstalledPackageEntry, +) -> Vec<String> { pkg.extra_fields .get("license") .and_then(|v| v.as_array()) @@ -205,9 +208,12 @@ fn render_text( root_licenses.join(", ") }; // Print root package header - println!("Name: {}", crate::console::comment(root_name)); - println!("Version: {}", crate::console::comment(root_version)); - println!("Licenses: {}", crate::console::comment(&license_display)); + println!("Name: {}", mozart_core::console::comment(root_name)); + println!("Version: {}", mozart_core::console::comment(root_version)); + println!( + "Licenses: {}", + mozart_core::console::comment(&license_display) + ); println!("Dependencies:"); println!(); @@ -312,8 +318,8 @@ mod tests { name: &str, version: &str, extra: BTreeMap<String, serde_json::Value>, - ) -> crate::installed::InstalledPackageEntry { - crate::installed::InstalledPackageEntry { + ) -> mozart_registry::installed::InstalledPackageEntry { + mozart_registry::installed::InstalledPackageEntry { name: name.to_string(), version: version.to_string(), version_normalized: None, @@ -429,10 +435,10 @@ mod tests { .unwrap(); // Build installed packages - let mut installed = crate::installed::InstalledPackages::new(); + let mut installed = mozart_registry::installed::InstalledPackages::new(); let mut extra = BTreeMap::new(); extra.insert("license".to_string(), serde_json::json!(["MIT"])); - installed.upsert(crate::installed::InstalledPackageEntry { + installed.upsert(mozart_registry::installed::InstalledPackageEntry { name: "monolog/monolog".to_string(), version: "3.0.0".to_string(), version_normalized: None, @@ -468,12 +474,12 @@ mod tests { ) .unwrap(); - let mut installed = crate::installed::InstalledPackages::new(); + let mut installed = mozart_registry::installed::InstalledPackages::new(); // Production package let mut extra_prod = BTreeMap::new(); extra_prod.insert("license".to_string(), serde_json::json!(["MIT"])); - installed.upsert(crate::installed::InstalledPackageEntry { + installed.upsert(mozart_registry::installed::InstalledPackageEntry { name: "monolog/monolog".to_string(), version: "3.0.0".to_string(), version_normalized: None, @@ -489,7 +495,7 @@ mod tests { // Dev package let mut extra_dev = BTreeMap::new(); extra_dev.insert("license".to_string(), serde_json::json!(["BSD-3-Clause"])); - installed.upsert(crate::installed::InstalledPackageEntry { + installed.upsert(mozart_registry::installed::InstalledPackageEntry { name: "phpunit/phpunit".to_string(), version: "10.0.0".to_string(), version_normalized: None, @@ -519,7 +525,7 @@ mod tests { #[test] fn test_load_locked_licenses_basic() { - use crate::lockfile::{LockFile, LockedPackage}; + use mozart_registry::lockfile::{LockFile, LockedPackage}; use tempfile::tempdir; let dir = tempdir().unwrap(); diff --git a/crates/mozart/src/commands/outdated.rs b/crates/mozart/src/commands/outdated.rs index b6672c4..49c541f 100644 --- a/crates/mozart/src/commands/outdated.rs +++ b/crates/mozart/src/commands/outdated.rs @@ -99,7 +99,7 @@ struct OutdatedEntry { pub fn execute( args: &OutdatedArgs, cli: &super::Cli, - _console: &crate::console::Console, + _console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), @@ -120,7 +120,7 @@ pub fn execute( // Load root composer.json for --direct filtering and constraint lookup let composer_json_path = working_dir.join("composer.json"); let root_package = if composer_json_path.exists() { - crate::package::read_from_file(&composer_json_path).ok() + mozart_core::package::read_from_file(&composer_json_path).ok() } else { None }; @@ -247,7 +247,7 @@ pub fn execute( fn load_installed_packages(working_dir: &Path, no_dev: bool) -> anyhow::Result<Vec<PackageInfo>> { let vendor_dir = working_dir.join("vendor"); - let installed = crate::installed::InstalledPackages::read(&vendor_dir)?; + let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?; let dev_names: HashSet<String> = installed .dev_package_names @@ -301,9 +301,10 @@ fn load_locked_packages(working_dir: &Path, no_dev: bool) -> anyhow::Result<Vec< ); } - let lock = crate::lockfile::LockFile::read_from_file(&lock_path)?; + let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; - let mut all_packages: Vec<&crate::lockfile::LockedPackage> = lock.packages.iter().collect(); + let mut all_packages: Vec<&mozart_registry::lockfile::LockedPackage> = + lock.packages.iter().collect(); if !no_dev && let Some(ref pkgs_dev) = lock.packages_dev { all_packages.extend(pkgs_dev.iter()); @@ -333,10 +334,10 @@ fn load_locked_packages(working_dir: &Path, no_dev: bool) -> anyhow::Result<Vec< // ─── Version fetching ──────────────────────────────────────────────────────── fn fetch_latest_version(name: &str) -> anyhow::Result<PackageInfo> { - use crate::package::Stability; - use crate::version::find_best_candidate; + use mozart_core::package::Stability; + use mozart_registry::version::find_best_candidate; - let versions = crate::packagist::fetch_package_versions(name, None)?; + let versions = mozart_registry::packagist::fetch_package_versions(name, None)?; let best = find_best_candidate(&versions, Stability::Stable) .ok_or_else(|| anyhow::anyhow!("No stable version found for {name}"))?; @@ -361,7 +362,7 @@ fn classify_update( latest_normalized: &str, root_constraint: Option<&str>, ) -> UpdateCategory { - use crate::version::compare_normalized_versions; + use mozart_registry::version::compare_normalized_versions; // If latest is not newer than current, it's up-to-date if compare_normalized_versions(latest_normalized, current_normalized) != Ordering::Greater { @@ -370,8 +371,8 @@ fn classify_update( // We have an update available — classify it if let Some(constraint_str) = root_constraint - && let Ok(constraint) = crate::constraint::VersionConstraint::parse(constraint_str) - && let Ok(latest_ver) = crate::constraint::Version::parse(latest_normalized) + && let Ok(constraint) = mozart_constraint::VersionConstraint::parse(constraint_str) + && let Ok(latest_ver) = mozart_constraint::Version::parse(latest_normalized) { if constraint.matches(&latest_ver) { return UpdateCategory::SemverCompatible; @@ -460,7 +461,10 @@ fn passes_level_filter(args: &OutdatedArgs, current: &str, latest: &str) -> bool fn render_text(entries: &[OutdatedEntry]) { if entries.is_empty() { - println!("{}", crate::console::info("All packages are up to date.")); + println!( + "{}", + mozart_core::console::info("All packages are up to date.") + ); return; } @@ -484,23 +488,23 @@ fn render_text(entries: &[OutdatedEntry]) { let (name_str, lat_str) = match entry.category { UpdateCategory::UpToDate => ( - crate::console::info(&name_col).to_string(), - crate::console::info(&lat_col).to_string(), + mozart_core::console::info(&name_col).to_string(), + mozart_core::console::info(&lat_col).to_string(), ), UpdateCategory::SemverCompatible => ( - crate::console::highlight(&name_col).to_string(), - crate::console::highlight(&lat_col).to_string(), + mozart_core::console::highlight(&name_col).to_string(), + mozart_core::console::highlight(&lat_col).to_string(), ), UpdateCategory::SemverIncompatible => ( - crate::console::comment(&name_col).to_string(), - crate::console::comment(&lat_col).to_string(), + mozart_core::console::comment(&name_col).to_string(), + mozart_core::console::comment(&lat_col).to_string(), ), }; println!( "{} {} {} {}", name_str, - crate::console::comment(&cur_col), + mozart_core::console::comment(&cur_col), lat_str, entry.description ); diff --git a/crates/mozart/src/commands/prohibits.rs b/crates/mozart/src/commands/prohibits.rs index f545bb2..3ec01a7 100644 --- a/crates/mozart/src/commands/prohibits.rs +++ b/crates/mozart/src/commands/prohibits.rs @@ -25,7 +25,7 @@ pub struct ProhibitsArgs { pub fn execute( args: &ProhibitsArgs, cli: &super::Cli, - _console: &crate::console::Console, + _console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), @@ -37,13 +37,13 @@ pub fn execute( if packages.is_empty() { println!( "{}", - crate::console::info("No packages found. Run `mozart install` first.") + mozart_core::console::info("No packages found. Run `mozart install` first.") ); return Ok(()); } // Parse the version constraint the user is asking about - let version_constraint = crate::constraint::VersionConstraint::parse(&args.version) + let version_constraint = mozart_constraint::VersionConstraint::parse(&args.version) .map_err(|e| anyhow::anyhow!("Invalid version constraint '{}': {}", args.version, e))?; let recursive = args.tree || args.recursive; @@ -61,7 +61,7 @@ pub fn execute( if results.is_empty() { println!( "{}", - crate::console::info(&format!( + mozart_core::console::info(&format!( "{} {} can be installed.", args.package, args.version )) diff --git a/crates/mozart/src/commands/reinstall.rs b/crates/mozart/src/commands/reinstall.rs index 78611b4..d064136 100644 --- a/crates/mozart/src/commands/reinstall.rs +++ b/crates/mozart/src/commands/reinstall.rs @@ -68,7 +68,7 @@ pub struct ReinstallArgs { pub fn execute( args: &ReinstallArgs, cli: &super::Cli, - console: &crate::console::Console, + console: &mozart_core::console::Console, ) -> anyhow::Result<()> { // Step 1: Resolve working directory let working_dir = match &cli.working_dir { @@ -79,7 +79,7 @@ pub fn execute( let vendor_dir = working_dir.join("vendor"); // Step 2: Read installed.json - let installed = crate::installed::InstalledPackages::read(&vendor_dir)?; + let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?; // Step 3: Read composer.lock let lock_path = working_dir.join("composer.lock"); @@ -89,7 +89,7 @@ pub fn execute( working_dir.display() ); } - let lock = crate::lockfile::LockFile::read_from_file(&lock_path)?; + let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; // Step 4: Validate — error if both --type and package names are provided; // error if neither is provided. @@ -116,7 +116,7 @@ pub fn execute( .map(|n| n.to_lowercase()) .collect(); - let candidates: Vec<&crate::installed::InstalledPackageEntry> = installed + let candidates: Vec<&mozart_registry::installed::InstalledPackageEntry> = installed .packages .iter() .filter(|pkg| { @@ -128,7 +128,7 @@ pub fn execute( }) .collect(); - let selected: Vec<&crate::installed::InstalledPackageEntry> = if has_type { + let selected: Vec<&mozart_registry::installed::InstalledPackageEntry> = if has_type { filter_by_type(&candidates, &args.r#type) } else { filter_by_names(&candidates, &args.packages) @@ -141,7 +141,7 @@ pub fn execute( // Step 6: For each selected package, find its locked metadata. // Build a lookup map: lowercase name -> LockedPackage - let all_locked: Vec<&crate::lockfile::LockedPackage> = lock + let all_locked: Vec<&mozart_registry::lockfile::LockedPackage> = lock .packages .iter() .chain(lock.packages_dev.as_deref().unwrap_or(&[])) @@ -161,8 +161,8 @@ pub fn execute( } // Step 8: For each package, remove vendor dir and re-download. - let cache_config = crate::cache::build_cache_config(cli); - let files_cache = crate::cache::Cache::files(&cache_config); + let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache); + let files_cache = mozart_registry::cache::Cache::files(&cache_config); let mut reinstalled_count = 0usize; @@ -202,12 +202,12 @@ pub fn execute( } // Re-download and install - let mut progress = crate::downloader::DownloadProgress::new( + let mut progress = mozart_registry::downloader::DownloadProgress::new( !args.no_progress, format!("{} ({})", locked.name, locked.version), ); - crate::downloader::install_package( + mozart_registry::downloader::install_package( &dist.url, &dist.dist_type, dist.shasum.as_deref(), @@ -233,7 +233,7 @@ pub fn execute( let dev_mode = !args.no_dev && installed.dev; let suffix = lock.content_hash.clone(); - crate::autoload::generate(&crate::autoload::AutoloadConfig { + mozart_autoload::autoload::generate(&mozart_autoload::autoload::AutoloadConfig { project_dir: working_dir.to_path_buf(), vendor_dir: vendor_dir.to_path_buf(), dev_mode, @@ -243,7 +243,7 @@ pub fn execute( apcu: args.apcu_autoloader, apcu_prefix: args.apcu_autoloader_prefix.clone(), strict_psr: false, - platform_check: crate::autoload::PlatformCheckMode::Full, + platform_check: mozart_autoload::autoload::PlatformCheckMode::Full, ignore_platform_reqs: args.ignore_platform_reqs, })?; @@ -257,9 +257,9 @@ pub fn execute( /// Filter candidates by package type (case-insensitive). fn filter_by_type<'a>( - candidates: &[&'a crate::installed::InstalledPackageEntry], + candidates: &[&'a mozart_registry::installed::InstalledPackageEntry], types: &[String], -) -> Vec<&'a crate::installed::InstalledPackageEntry> { +) -> Vec<&'a mozart_registry::installed::InstalledPackageEntry> { let lower_types: Vec<String> = types.iter().map(|t| t.to_lowercase()).collect(); candidates .iter() @@ -280,9 +280,9 @@ fn filter_by_type<'a>( /// Patterns support `*` as a wildcard matching any sequence of characters /// (including `/`). fn filter_by_names<'a>( - candidates: &[&'a crate::installed::InstalledPackageEntry], + candidates: &[&'a mozart_registry::installed::InstalledPackageEntry], patterns: &[String], -) -> Vec<&'a crate::installed::InstalledPackageEntry> { +) -> Vec<&'a mozart_registry::installed::InstalledPackageEntry> { candidates .iter() .filter(|pkg| { @@ -339,9 +339,9 @@ fn glob_matches(pattern: &str, value: &str) -> bool { /// Find a locked package by name (case-insensitive). fn find_locked_package<'a>( - locked: &[&'a crate::lockfile::LockedPackage], + locked: &[&'a mozart_registry::lockfile::LockedPackage], name: &str, -) -> Option<&'a crate::lockfile::LockedPackage> { +) -> Option<&'a mozart_registry::lockfile::LockedPackage> { let name_lower = name.to_lowercase(); locked .iter() @@ -361,8 +361,8 @@ mod tests { fn make_installed_entry( name: &str, pkg_type: Option<&str>, - ) -> crate::installed::InstalledPackageEntry { - crate::installed::InstalledPackageEntry { + ) -> mozart_registry::installed::InstalledPackageEntry { + mozart_registry::installed::InstalledPackageEntry { name: name.to_string(), version: "1.0.0".to_string(), version_normalized: None, @@ -376,8 +376,8 @@ mod tests { } } - fn make_locked_package(name: &str, version: &str) -> crate::lockfile::LockedPackage { - crate::lockfile::LockedPackage { + fn make_locked_package(name: &str, version: &str) -> mozart_registry::lockfile::LockedPackage { + mozart_registry::lockfile::LockedPackage { name: name.to_string(), version: version.to_string(), version_normalized: None, @@ -453,7 +453,7 @@ mod tests { make_locked_package("psr/log", "3.0.0"), make_locked_package("monolog/monolog", "3.8.0"), ]; - let refs: Vec<&crate::lockfile::LockedPackage> = pkgs.iter().collect(); + let refs: Vec<&mozart_registry::lockfile::LockedPackage> = pkgs.iter().collect(); let result = find_locked_package(&refs, "psr/log"); assert!(result.is_some()); @@ -463,7 +463,7 @@ mod tests { #[test] fn test_find_locked_package_case_insensitive() { let pkgs = vec![make_locked_package("Monolog/Monolog", "3.8.0")]; - let refs: Vec<&crate::lockfile::LockedPackage> = pkgs.iter().collect(); + let refs: Vec<&mozart_registry::lockfile::LockedPackage> = pkgs.iter().collect(); let result = find_locked_package(&refs, "monolog/monolog"); assert!(result.is_some()); @@ -472,7 +472,7 @@ mod tests { #[test] fn test_find_locked_package_not_found() { let pkgs = vec![make_locked_package("psr/log", "3.0.0")]; - let refs: Vec<&crate::lockfile::LockedPackage> = pkgs.iter().collect(); + let refs: Vec<&mozart_registry::lockfile::LockedPackage> = pkgs.iter().collect(); let result = find_locked_package(&refs, "monolog/monolog"); assert!(result.is_none()); @@ -601,7 +601,7 @@ mod tests { let e1 = make_installed_entry("psr/log", Some("library")); let e2 = make_installed_entry("phpunit/phpunit", Some("library")); - let mut installed = crate::installed::InstalledPackages::new(); + let mut installed = mozart_registry::installed::InstalledPackages::new(); installed.packages.push(e1.clone()); installed.packages.push(e2.clone()); installed.dev_package_names = vec!["phpunit/phpunit".to_string()]; @@ -613,7 +613,7 @@ mod tests { .collect(); // Simulate --no-dev filtering - let candidates: Vec<&crate::installed::InstalledPackageEntry> = installed + let candidates: Vec<&mozart_registry::installed::InstalledPackageEntry> = installed .packages .iter() .filter(|pkg| !dev_package_names.contains(&pkg.name.to_lowercase())) @@ -628,13 +628,13 @@ mod tests { let e1 = make_installed_entry("psr/log", Some("library")); let e2 = make_installed_entry("phpunit/phpunit", Some("library")); - let mut installed = crate::installed::InstalledPackages::new(); + let mut installed = mozart_registry::installed::InstalledPackages::new(); installed.packages.push(e1.clone()); installed.packages.push(e2.clone()); installed.dev_package_names = vec!["phpunit/phpunit".to_string()]; // no_dev = false: include all - let candidates: Vec<&crate::installed::InstalledPackageEntry> = + let candidates: Vec<&mozart_registry::installed::InstalledPackageEntry> = installed.packages.iter().collect(); assert_eq!(candidates.len(), 2); diff --git a/crates/mozart/src/commands/remove.rs b/crates/mozart/src/commands/remove.rs index 6969745..de4b77b 100644 --- a/crates/mozart/src/commands/remove.rs +++ b/crates/mozart/src/commands/remove.rs @@ -1,9 +1,9 @@ -use crate::console; -use crate::lockfile; -use crate::package; -use crate::resolver::{self, PlatformConfig, ResolveRequest}; -use crate::validation; use clap::Args; +use mozart_core::console; +use mozart_core::package; +use mozart_core::validation; +use mozart_registry::lockfile; +use mozart_registry::resolver::{self, PlatformConfig, ResolveRequest}; use std::collections::HashMap; #[derive(Args)] @@ -99,7 +99,7 @@ pub struct RemoveArgs { pub fn execute( args: &RemoveArgs, cli: &super::Cli, - console: &crate::console::Console, + console: &mozart_core::console::Console, ) -> anyhow::Result<()> { // Step 1: Validate inputs if args.packages.is_empty() && !args.unused { @@ -286,8 +286,8 @@ pub fn execute( // Run resolver let mut resolved = resolver::resolve(&request).map_err(|e| { - crate::exit_code::bail( - crate::exit_code::DEPENDENCY_RESOLUTION_FAILED, + mozart_core::exit_code::bail( + mozart_core::exit_code::DEPENDENCY_RESOLUTION_FAILED, e.to_string(), ) })?; @@ -477,8 +477,8 @@ pub fn execute( #[cfg(test)] mod tests { use super::*; - use crate::lockfile; - use crate::package::RawPackageData; + use mozart_core::package::RawPackageData; + use mozart_registry::lockfile; use std::collections::BTreeMap; // ──────────── Helper constructors ──────────── @@ -684,8 +684,8 @@ mod tests { #[test] #[ignore] fn test_remove_full_e2e() { - use crate::lockfile::{LockFileGenerationRequest, generate_lock_file}; - use crate::resolver::{ResolveRequest, resolve}; + use mozart_registry::lockfile::{LockFileGenerationRequest, generate_lock_file}; + use mozart_registry::resolver::{ResolveRequest, resolve}; use std::collections::HashMap; use tempfile::tempdir; @@ -705,11 +705,11 @@ mod tests { require: vec![("psr/log".to_string(), "^3.0".to_string())], require_dev: vec![], include_dev: false, - minimum_stability: crate::package::Stability::Stable, + minimum_stability: mozart_core::package::Stability::Stable, stability_flags: HashMap::new(), prefer_stable: true, prefer_lowest: false, - platform: crate::resolver::PlatformConfig::new(), + platform: mozart_registry::resolver::PlatformConfig::new(), ignore_platform_reqs: false, ignore_platform_req_list: vec![], repo_cache: None, @@ -736,11 +736,11 @@ mod tests { require: vec![], require_dev: vec![], include_dev: false, - minimum_stability: crate::package::Stability::Stable, + minimum_stability: mozart_core::package::Stability::Stable, stability_flags: HashMap::new(), prefer_stable: true, prefer_lowest: false, - platform: crate::resolver::PlatformConfig::new(), + platform: mozart_registry::resolver::PlatformConfig::new(), ignore_platform_reqs: false, ignore_platform_req_list: vec![], repo_cache: None, diff --git a/crates/mozart/src/commands/repository.rs b/crates/mozart/src/commands/repository.rs index c54601b..0974e29 100644 --- a/crates/mozart/src/commands/repository.rs +++ b/crates/mozart/src/commands/repository.rs @@ -38,7 +38,7 @@ pub struct RepositoryArgs { pub fn execute( _args: &RepositoryArgs, _cli: &super::Cli, - _console: &crate::console::Console, + _console: &mozart_core::console::Console, ) -> anyhow::Result<()> { todo!() } diff --git a/crates/mozart/src/commands/require.rs b/crates/mozart/src/commands/require.rs index 6e76b6e..960182f 100644 --- a/crates/mozart/src/commands/require.rs +++ b/crates/mozart/src/commands/require.rs @@ -1,11 +1,11 @@ -use crate::console; -use crate::lockfile; -use crate::package::{self, Stability}; -use crate::packagist; -use crate::resolver::{self, PlatformConfig, ResolveRequest}; -use crate::validation; -use crate::version; use clap::Args; +use mozart_core::console; +use mozart_core::package::{self, Stability}; +use mozart_core::validation; +use mozart_registry::lockfile; +use mozart_registry::packagist; +use mozart_registry::resolver::{self, PlatformConfig, ResolveRequest}; +use mozart_registry::version; use std::collections::HashMap; use std::io::{BufRead, IsTerminal, Write}; @@ -346,7 +346,7 @@ fn interactive_search_packages( pub fn execute( args: &RequireArgs, cli: &super::Cli, - console: &crate::console::Console, + console: &mozart_core::console::Console, ) -> anyhow::Result<()> { // Collect the effective list of packages to add. // If none were provided on the CLI, try interactive search (unless --no-interaction). @@ -609,8 +609,8 @@ pub fn execute( let mut resolved = match resolver::resolve(&request) { Ok(packages) => packages, Err(e) => { - return Err(crate::exit_code::bail( - crate::exit_code::DEPENDENCY_RESOLUTION_FAILED, + return Err(mozart_core::exit_code::bail( + mozart_core::exit_code::DEPENDENCY_RESOLUTION_FAILED, e.to_string(), )); } @@ -758,7 +758,7 @@ pub fn execute( .map(|s| s.eq_ignore_ascii_case("source")) .unwrap_or(false); if prefer_source { - console.info(&crate::console::warning( + console.info(&mozart_core::console::warning( "Warning: Source installs are not yet supported. Falling back to dist.", )); } @@ -836,7 +836,7 @@ mod tests { /// Verify that --sort-packages sorts both require and require-dev maps. #[test] fn test_sort_packages_sorts_both_sections() { - use crate::package::RawPackageData; + use mozart_core::package::RawPackageData; let mut raw = RawPackageData::new("test/project".to_string()); raw.require @@ -915,8 +915,8 @@ mod tests { #[test] #[ignore] fn test_require_full_e2e() { - use crate::lockfile::{LockFileGenerationRequest, generate_lock_file}; - use crate::package::RawPackageData; + use mozart_core::package::RawPackageData; + use mozart_registry::lockfile::{LockFileGenerationRequest, generate_lock_file}; let composer_json_content = r#"{"name": "test/project", "require": {"psr/log": "^3.0"}}"#; let composer_json: RawPackageData = serde_json::from_str(composer_json_content).unwrap(); @@ -956,7 +956,7 @@ mod tests { #[test] #[ignore] fn test_require_no_install_writes_lock_only() { - use crate::package::RawPackageData; + use mozart_core::package::RawPackageData; use tempfile::tempdir; let dir = tempdir().unwrap(); diff --git a/crates/mozart/src/commands/run_script.rs b/crates/mozart/src/commands/run_script.rs index 8f2b5cd..acef421 100644 --- a/crates/mozart/src/commands/run_script.rs +++ b/crates/mozart/src/commands/run_script.rs @@ -49,7 +49,7 @@ const INTERNAL_ONLY_EVENTS: &[&str] = &[ pub fn execute( args: &RunScriptArgs, cli: &super::Cli, - _console: &crate::console::Console, + _console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), diff --git a/crates/mozart/src/commands/search.rs b/crates/mozart/src/commands/search.rs index d172976..98189ff 100644 --- a/crates/mozart/src/commands/search.rs +++ b/crates/mozart/src/commands/search.rs @@ -1,5 +1,5 @@ -use crate::packagist::SearchResult; use clap::Args; +use mozart_registry::packagist::SearchResult; #[derive(Args)] pub struct SearchArgs { @@ -62,11 +62,12 @@ fn passes_only_vendor(result: &SearchResult, query: &str) -> bool { pub fn execute( args: &SearchArgs, _cli: &super::Cli, - _console: &crate::console::Console, + _console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let query = args.tokens.join(" "); - let (all_results, total) = crate::packagist::search_packages(&query, args.r#type.as_deref())?; + let (all_results, total) = + mozart_registry::packagist::search_packages(&query, args.r#type.as_deref())?; // Apply client-side filters let mut results: Vec<&SearchResult> = all_results.iter().collect(); @@ -92,7 +93,7 @@ pub fn execute( if results.is_empty() { eprintln!( "{}", - crate::console::warning(&format!("No packages found for \"{query}\"")) + mozart_core::console::warning(&format!("No packages found for \"{query}\"")) ); return Ok(()); } @@ -115,9 +116,13 @@ pub fn execute( println!( "{} {} {}", - crate::console::info(&format!("{:<width$}", result.name, width = name_width)), - crate::console::comment(&dl_str), - crate::console::comment(&fav_str), + mozart_core::console::info(&format!( + "{:<width$}", + result.name, + width = name_width + )), + mozart_core::console::comment(&dl_str), + mozart_core::console::comment(&fav_str), ); if !result.description.is_empty() { println!(" {}", result.description); @@ -163,7 +168,7 @@ mod tests { #[test] fn test_parse_search_response() { - use crate::packagist::SearchResponse; + use mozart_registry::packagist::SearchResponse; let json = r#"{ "results": [ @@ -209,7 +214,7 @@ mod tests { #[test] fn test_parse_search_response_with_next() { - use crate::packagist::SearchResponse; + use mozart_registry::packagist::SearchResponse; let json = r#"{ "results": [], diff --git a/crates/mozart/src/commands/self_update.rs b/crates/mozart/src/commands/self_update.rs index 29c67ef..03d2643 100644 --- a/crates/mozart/src/commands/self_update.rs +++ b/crates/mozart/src/commands/self_update.rs @@ -53,7 +53,7 @@ const BACKUP_EXTENSION: &str = ".old"; pub fn execute( args: &SelfUpdateArgs, _cli: &super::Cli, - _console: &crate::console::Console, + _console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let current_exe = std::env::current_exe() .map_err(|e| anyhow::anyhow!("Could not determine current executable path: {e}"))?; @@ -278,7 +278,7 @@ fn update(args: &SelfUpdateArgs, current_exe: &Path, data_dir: &Path) -> anyhow: if args.version.is_none() && target_version == current_version { println!( "{}", - crate::console::info(&format!( + mozart_core::console::info(&format!( "Mozart is already at the latest version ({current_version})" )) ); @@ -329,14 +329,14 @@ fn update(args: &SelfUpdateArgs, current_exe: &Path, data_dir: &Path) -> anyhow: println!( "{}", - crate::console::info(&format!( + mozart_core::console::info(&format!( "Mozart updated successfully from {current_version} to {target_version}" )) ); if args.clean_backups { clean_backups(data_dir)?; - println!("{}", crate::console::comment("Old backups removed.")); + println!("{}", mozart_core::console::comment("Old backups removed.")); } Ok(()) @@ -367,7 +367,7 @@ fn rollback(current_exe: &Path, data_dir: &Path) -> anyhow::Result<()> { println!( "{}", - crate::console::info(&format!( + mozart_core::console::info(&format!( "Rollback successful. Restored from {}", backup.file_name().unwrap_or_default().to_string_lossy() )) diff --git a/crates/mozart/src/commands/show.rs b/crates/mozart/src/commands/show.rs index a8ae995..c6a446d 100644 --- a/crates/mozart/src/commands/show.rs +++ b/crates/mozart/src/commands/show.rs @@ -102,7 +102,7 @@ pub struct ShowArgs { pub fn execute( args: &ShowArgs, cli: &super::Cli, - _console: &crate::console::Console, + _console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), @@ -142,17 +142,17 @@ pub fn execute( fn execute_installed(args: &ShowArgs, working_dir: &Path) -> anyhow::Result<()> { let vendor_dir = working_dir.join("vendor"); - let installed = crate::installed::InstalledPackages::read(&vendor_dir)?; + let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?; if installed.packages.is_empty() { // Warn if composer.json has requirements but nothing is installed let composer_json_path = working_dir.join("composer.json"); if composer_json_path.exists() { - let root = crate::package::read_from_file(&composer_json_path)?; + let root = mozart_core::package::read_from_file(&composer_json_path)?; if !root.require.is_empty() || !root.require_dev.is_empty() { eprintln!( "{}", - crate::console::warning( + mozart_core::console::warning( "No dependencies installed. Try running mozart install or update." ) ); @@ -216,11 +216,11 @@ fn execute_installed(args: &ShowArgs, working_dir: &Path) -> anyhow::Result<()> } fn filter_installed_packages<'a>( - installed: &'a crate::installed::InstalledPackages, + installed: &'a mozart_registry::installed::InstalledPackages, args: &ShowArgs, working_dir: &Path, -) -> anyhow::Result<Vec<&'a crate::installed::InstalledPackageEntry>> { - let mut packages: Vec<&crate::installed::InstalledPackageEntry> = +) -> anyhow::Result<Vec<&'a mozart_registry::installed::InstalledPackageEntry>> { + let mut packages: Vec<&mozart_registry::installed::InstalledPackageEntry> = installed.packages.iter().collect(); // --no-dev: exclude dev packages @@ -237,7 +237,7 @@ fn filter_installed_packages<'a>( if args.direct { let composer_json_path = working_dir.join("composer.json"); if composer_json_path.exists() { - let root = crate::package::read_from_file(&composer_json_path)?; + let root = mozart_core::package::read_from_file(&composer_json_path)?; let mut direct_names: HashSet<String> = root.require.keys().map(|k| k.to_lowercase()).collect(); if !args.no_dev { @@ -254,7 +254,7 @@ fn filter_installed_packages<'a>( } fn show_installed_package_list( - packages: &[&crate::installed::InstalledPackageEntry], + packages: &[&mozart_registry::installed::InstalledPackageEntry], args: &ShowArgs, _vendor_dir: &Path, ) -> anyhow::Result<()> { @@ -297,7 +297,7 @@ fn show_installed_package_list( // --outdated: skip packages that are up-to-date if args.outdated { if let Some(ref li) = latest_info { - use crate::version::compare_normalized_versions; + use mozart_registry::version::compare_normalized_versions; use std::cmp::Ordering; if compare_normalized_versions(&li.version_normalized, &version_normalized) != Ordering::Greater @@ -362,20 +362,24 @@ fn show_installed_package_list( .map(|li| classify_update_category(&entry.version_normalized, &li.version_normalized)); let name_str = match category { - Some(ListUpdateKind::Compatible) => { - crate::console::highlight(&format!("{:<width$}", entry.name, width = name_width)) - .to_string() - } - Some(ListUpdateKind::Incompatible) => { - crate::console::comment(&format!("{:<width$}", entry.name, width = name_width)) - .to_string() - } - _ => crate::console::info(&format!("{:<width$}", entry.name, width = name_width)) + Some(ListUpdateKind::Compatible) => mozart_core::console::highlight(&format!( + "{:<width$}", + entry.name, + width = name_width + )) + .to_string(), + Some(ListUpdateKind::Incompatible) => mozart_core::console::comment(&format!( + "{:<width$}", + entry.name, + width = name_width + )) + .to_string(), + _ => mozart_core::console::info(&format!("{:<width$}", entry.name, width = name_width)) .to_string(), }; let version_str = - crate::console::comment(&format!("{:<width$}", version, width = version_width)) + mozart_core::console::comment(&format!("{:<width$}", version, width = version_width)) .to_string(); if show_latest { @@ -383,20 +387,20 @@ fn show_installed_package_list( Some(li) => { let lv = format_version(&li.version); match category { - Some(ListUpdateKind::Compatible) => crate::console::highlight(&format!( - "{:<width$}", - lv, - width = latest_width - )) + Some(ListUpdateKind::Compatible) => mozart_core::console::highlight( + &format!("{:<width$}", lv, width = latest_width), + ) .to_string(), - Some(ListUpdateKind::Incompatible) => crate::console::comment(&format!( + Some(ListUpdateKind::Incompatible) => mozart_core::console::comment( + &format!("{:<width$}", lv, width = latest_width), + ) + .to_string(), + _ => mozart_core::console::info(&format!( "{:<width$}", lv, width = latest_width )) .to_string(), - _ => crate::console::info(&format!("{:<width$}", lv, width = latest_width)) - .to_string(), } } None => format!("{:<width$}", "", width = latest_width), @@ -439,7 +443,7 @@ enum ListUpdateKind { } fn classify_update_category(current_normalized: &str, latest_normalized: &str) -> ListUpdateKind { - use crate::version::compare_normalized_versions; + use mozart_registry::version::compare_normalized_versions; use std::cmp::Ordering; if compare_normalized_versions(latest_normalized, current_normalized) != Ordering::Greater { @@ -469,10 +473,10 @@ fn extract_major(version_normalized: &str) -> u64 { } fn fetch_latest_for_package(name: &str) -> anyhow::Result<LatestInfo> { - use crate::package::Stability; - use crate::version::find_best_candidate; + use mozart_core::package::Stability; + use mozart_registry::version::find_best_candidate; - let versions = crate::packagist::fetch_package_versions(name, None)?; + let versions = mozart_registry::packagist::fetch_package_versions(name, None)?; let best = find_best_candidate(&versions, Stability::Stable) .ok_or_else(|| anyhow::anyhow!("No stable version found for {name}"))?; @@ -513,7 +517,7 @@ fn render_installed_json(entries: &[InstalledListEntry]) -> anyhow::Result<()> { } fn show_installed_package_detail( - installed: &crate::installed::InstalledPackages, + installed: &mozart_registry::installed::InstalledPackages, package_name: &str, working_dir: &Path, ) -> anyhow::Result<()> { @@ -535,36 +539,36 @@ fn show_installed_package_detail( let vendor_dir = working_dir.join("vendor"); - println!("{} : {}", crate::console::info("name"), pkg.name); + println!("{} : {}", mozart_core::console::info("name"), pkg.name); println!( "{} : {}", - crate::console::info("descrip."), + mozart_core::console::info("descrip."), get_installed_description(pkg) ); println!( "{} : {}", - crate::console::info("keywords"), + mozart_core::console::info("keywords"), get_installed_keywords(pkg) ); println!( "{} : {}", - crate::console::info("versions"), + mozart_core::console::info("versions"), format_version_highlight(&pkg.version) ); println!( "{} : {}", - crate::console::info("type"), + mozart_core::console::info("type"), pkg.package_type.as_deref().unwrap_or("library") ); // License if let Some(licenses) = get_installed_license(pkg) { - println!("{} : {}", crate::console::info("license"), licenses); + println!("{} : {}", mozart_core::console::info("license"), licenses); } // Homepage if let Some(homepage) = get_installed_homepage(pkg) { - println!("{} : {}", crate::console::info("homepage"), homepage); + println!("{} : {}", mozart_core::console::info("homepage"), homepage); } // Source @@ -577,9 +581,9 @@ fn show_installed_package_detail( .unwrap_or(""); println!( "{} : [{}] {} {}", - crate::console::info("source"), + mozart_core::console::info("source"), source_type, - crate::console::comment(source_url), + mozart_core::console::comment(source_url), source_ref ); } @@ -591,9 +595,9 @@ fn show_installed_package_detail( let dist_ref = dist.get("reference").and_then(|v| v.as_str()).unwrap_or(""); println!( "{} : [{}] {} {}", - crate::console::info("dist"), + mozart_core::console::info("dist"), dist_type, - crate::console::comment(dist_url), + mozart_core::console::comment(dist_url), dist_ref ); } @@ -603,7 +607,7 @@ fn show_installed_package_detail( if install_path.exists() { println!( "{} : {}", - crate::console::info("path"), + mozart_core::console::info("path"), install_path.display() ); } @@ -613,10 +617,10 @@ fn show_installed_package_detail( && !requires.is_empty() { println!(); - println!("{}", crate::console::info("requires")); + println!("{}", mozart_core::console::info("requires")); for (name, constraint) in requires { let c = constraint.as_str().unwrap_or(""); - println!("{} {}", name, crate::console::comment(c)); + println!("{} {}", name, mozart_core::console::comment(c)); } } @@ -628,10 +632,10 @@ fn show_installed_package_detail( && !requires_dev.is_empty() { println!(); - println!("{}", crate::console::info("requires (dev)")); + println!("{}", mozart_core::console::info("requires (dev)")); for (name, constraint) in requires_dev { let c = constraint.as_str().unwrap_or(""); - println!("{} {}", name, crate::console::comment(c)); + println!("{} {}", name, mozart_core::console::comment(c)); } } @@ -648,10 +652,11 @@ fn execute_locked(args: &ShowArgs, working_dir: &Path) -> anyhow::Result<()> { ); } - let lock = crate::lockfile::LockFile::read_from_file(&lock_path)?; + let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; // Combine packages and packages-dev - let mut packages: Vec<&crate::lockfile::LockedPackage> = lock.packages.iter().collect(); + let mut packages: Vec<&mozart_registry::lockfile::LockedPackage> = + lock.packages.iter().collect(); if let Some(ref pkgs_dev) = lock.packages_dev && !args.no_dev @@ -663,7 +668,7 @@ fn execute_locked(args: &ShowArgs, working_dir: &Path) -> anyhow::Result<()> { if args.direct { let composer_json_path = working_dir.join("composer.json"); if composer_json_path.exists() { - let root = crate::package::read_from_file(&composer_json_path)?; + let root = mozart_core::package::read_from_file(&composer_json_path)?; let mut direct_names: HashSet<String> = root.require.keys().map(|k| k.to_lowercase()).collect(); if !args.no_dev { @@ -691,7 +696,7 @@ fn execute_locked(args: &ShowArgs, working_dir: &Path) -> anyhow::Result<()> { } fn show_locked_package_list( - packages: &[&crate::lockfile::LockedPackage], + packages: &[&mozart_registry::lockfile::LockedPackage], args: &ShowArgs, ) -> anyhow::Result<()> { let show_latest = args.latest || args.outdated; @@ -732,7 +737,7 @@ fn show_locked_package_list( // --outdated: skip packages that are up-to-date if args.outdated { if let Some(ref li) = latest_info { - use crate::version::compare_normalized_versions; + use mozart_registry::version::compare_normalized_versions; use std::cmp::Ordering; if compare_normalized_versions(&li.version_normalized, &version_normalized) != Ordering::Greater @@ -795,20 +800,24 @@ fn show_locked_package_list( .map(|li| classify_update_category(&entry.version_normalized, &li.version_normalized)); let name_str = match category { - Some(ListUpdateKind::Compatible) => { - crate::console::highlight(&format!("{:<width$}", entry.name, width = name_width)) - .to_string() - } - Some(ListUpdateKind::Incompatible) => { - crate::console::comment(&format!("{:<width$}", entry.name, width = name_width)) - .to_string() - } - _ => crate::console::info(&format!("{:<width$}", entry.name, width = name_width)) + Some(ListUpdateKind::Compatible) => mozart_core::console::highlight(&format!( + "{:<width$}", + entry.name, + width = name_width + )) + .to_string(), + Some(ListUpdateKind::Incompatible) => mozart_core::console::comment(&format!( + "{:<width$}", + entry.name, + width = name_width + )) + .to_string(), + _ => mozart_core::console::info(&format!("{:<width$}", entry.name, width = name_width)) .to_string(), }; let version_str = - crate::console::comment(&format!("{:<width$}", version, width = version_width)) + mozart_core::console::comment(&format!("{:<width$}", version, width = version_width)) .to_string(); if show_latest { @@ -816,20 +825,20 @@ fn show_locked_package_list( Some(li) => { let lv = format_version(&li.version); match category { - Some(ListUpdateKind::Compatible) => crate::console::highlight(&format!( - "{:<width$}", - lv, - width = latest_width - )) + Some(ListUpdateKind::Compatible) => mozart_core::console::highlight( + &format!("{:<width$}", lv, width = latest_width), + ) + .to_string(), + Some(ListUpdateKind::Incompatible) => mozart_core::console::comment( + &format!("{:<width$}", lv, width = latest_width), + ) .to_string(), - Some(ListUpdateKind::Incompatible) => crate::console::comment(&format!( + _ => mozart_core::console::info(&format!( "{:<width$}", lv, width = latest_width )) .to_string(), - _ => crate::console::info(&format!("{:<width$}", lv, width = latest_width)) - .to_string(), } } None => format!("{:<width$}", "", width = latest_width), @@ -889,7 +898,7 @@ fn render_locked_json(entries: &[LockedListEntry]) -> anyhow::Result<()> { } fn show_locked_package_detail( - lock: &crate::lockfile::LockFile, + lock: &mozart_registry::lockfile::LockFile, package_name: &str, ) -> anyhow::Result<()> { // Search in both packages and packages-dev @@ -906,10 +915,10 @@ fn show_locked_package_detail( } }; - println!("{} : {}", crate::console::info("name"), pkg.name); + println!("{} : {}", mozart_core::console::info("name"), pkg.name); println!( "{} : {}", - crate::console::info("descrip."), + mozart_core::console::info("descrip."), pkg.description.as_deref().unwrap_or("") ); @@ -919,16 +928,16 @@ fn show_locked_package_detail( .as_ref() .map(|kw| kw.join(", ")) .unwrap_or_default(); - println!("{} : {}", crate::console::info("keywords"), keywords); + println!("{} : {}", mozart_core::console::info("keywords"), keywords); println!( "{} : * {}", - crate::console::info("versions"), + mozart_core::console::info("versions"), format_version(&pkg.version) ); println!( "{} : {}", - crate::console::info("type"), + mozart_core::console::info("type"), pkg.package_type.as_deref().unwrap_or("library") ); @@ -936,23 +945,23 @@ fn show_locked_package_detail( if let Some(ref licenses) = pkg.license { println!( "{} : {}", - crate::console::info("license"), + mozart_core::console::info("license"), licenses.join(", ") ); } // Homepage if let Some(ref homepage) = pkg.homepage { - println!("{} : {}", crate::console::info("homepage"), homepage); + println!("{} : {}", mozart_core::console::info("homepage"), homepage); } // Source if let Some(ref source) = pkg.source { println!( "{} : [{}] {} {}", - crate::console::info("source"), + mozart_core::console::info("source"), source.source_type, - crate::console::comment(&source.url), + mozart_core::console::comment(&source.url), source.reference.as_deref().unwrap_or("") ); } @@ -961,9 +970,9 @@ fn show_locked_package_detail( if let Some(ref dist) = pkg.dist { println!( "{} : [{}] {} {}", - crate::console::info("dist"), + mozart_core::console::info("dist"), dist.dist_type, - crate::console::comment(&dist.url), + mozart_core::console::comment(&dist.url), dist.reference.as_deref().unwrap_or("") ); } @@ -971,18 +980,18 @@ fn show_locked_package_detail( // Requires if !pkg.require.is_empty() { println!(); - println!("{}", crate::console::info("requires")); + println!("{}", mozart_core::console::info("requires")); for (name, constraint) in &pkg.require { - println!("{} {}", name, crate::console::comment(constraint)); + println!("{} {}", name, mozart_core::console::comment(constraint)); } } // Requires (dev) if !pkg.require_dev.is_empty() { println!(); - println!("{}", crate::console::info("requires (dev)")); + println!("{}", mozart_core::console::info("requires (dev)")); for (name, constraint) in &pkg.require_dev { - println!("{} {}", name, crate::console::comment(constraint)); + println!("{} {}", name, mozart_core::console::comment(constraint)); } } @@ -991,9 +1000,9 @@ fn show_locked_package_detail( && !suggests.is_empty() { println!(); - println!("{}", crate::console::info("suggests")); + println!("{}", mozart_core::console::info("suggests")); for (name, reason) in suggests { - println!("{} {}", name, crate::console::comment(reason)); + println!("{} {}", name, mozart_core::console::comment(reason)); } } @@ -1007,46 +1016,46 @@ fn show_self(args: &ShowArgs, working_dir: &Path) -> anyhow::Result<()> { if !composer_json_path.exists() { anyhow::bail!("No composer.json found in {}", working_dir.display()); } - let root = crate::package::read_from_file(&composer_json_path)?; + let root = mozart_core::package::read_from_file(&composer_json_path)?; if args.name_only { println!("{}", root.name); return Ok(()); } - println!("{} : {}", crate::console::info("name"), root.name); + println!("{} : {}", mozart_core::console::info("name"), root.name); println!( "{} : {}", - crate::console::info("descrip."), + mozart_core::console::info("descrip."), root.description.as_deref().unwrap_or("") ); println!( "{} : {}", - crate::console::info("type"), + mozart_core::console::info("type"), root.package_type.as_deref().unwrap_or("project") ); if let Some(ref license) = root.license { - println!("{} : {}", crate::console::info("license"), license); + println!("{} : {}", mozart_core::console::info("license"), license); } if let Some(ref homepage) = root.homepage { - println!("{} : {}", crate::console::info("homepage"), homepage); + println!("{} : {}", mozart_core::console::info("homepage"), homepage); } // Requires if !root.require.is_empty() { println!(); - println!("{}", crate::console::info("requires")); + println!("{}", mozart_core::console::info("requires")); for (name, constraint) in &root.require { - println!("{} {}", name, crate::console::comment(constraint)); + println!("{} {}", name, mozart_core::console::comment(constraint)); } } // Requires (dev) if !root.require_dev.is_empty() { println!(); - println!("{}", crate::console::info("requires (dev)")); + println!("{}", mozart_core::console::info("requires (dev)")); for (name, constraint) in &root.require_dev { - println!("{} {}", name, crate::console::comment(constraint)); + println!("{} {}", name, mozart_core::console::comment(constraint)); } } @@ -1063,13 +1072,13 @@ fn show_tree(args: &ShowArgs, working_dir: &Path) -> anyhow::Result<()> { anyhow::bail!("No composer.json found in {}", working_dir.display()); } - let root = crate::package::read_from_file(&composer_json_path)?; + let root = mozart_core::package::read_from_file(&composer_json_path)?; // Load all locked packages into a map for quick lookup - let pkg_map: HashMap<String, &crate::lockfile::LockedPackage>; + let pkg_map: HashMap<String, &mozart_registry::lockfile::LockedPackage>; let lock_storage; if lock_path.exists() { - lock_storage = crate::lockfile::LockFile::read_from_file(&lock_path)?; + lock_storage = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; pkg_map = lock_storage .packages .iter() @@ -1101,8 +1110,8 @@ fn show_tree(args: &ShowArgs, working_dir: &Path) -> anyhow::Result<()> { // Print root println!( "{} {}", - crate::console::info(&root.name), - crate::console::comment(root.description.as_deref().unwrap_or("")) + mozart_core::console::info(&root.name), + mozart_core::console::comment(root.description.as_deref().unwrap_or("")) ); // Render each root dependency as a tree @@ -1130,7 +1139,7 @@ fn show_tree(args: &ShowArgs, working_dir: &Path) -> anyhow::Result<()> { fn print_tree_node( pkg_name: &str, constraint: &str, - pkg_map: &HashMap<String, &crate::lockfile::LockedPackage>, + pkg_map: &HashMap<String, &mozart_registry::lockfile::LockedPackage>, prefix: &str, child_prefix: &str, visited: &mut HashSet<String>, @@ -1148,8 +1157,8 @@ fn print_tree_node( println!( "{} {} {} {}", prefix, - crate::console::info(pkg_name), - crate::console::comment(&version), + mozart_core::console::info(pkg_name), + mozart_core::console::comment(&version), description ); @@ -1206,7 +1215,7 @@ fn print_tree_node( println!( "{} {} {} (not installed)", prefix, - crate::console::comment(pkg_name), + mozart_core::console::comment(pkg_name), constraint ); } @@ -1233,12 +1242,12 @@ fn show_platform(args: &ShowArgs, working_dir: &Path) -> anyhow::Result<()> { let mut platform_packages: Vec<(String, String, String)> = Vec::new(); // (name, version, source) // Try to detect PHP from the system - let php_version = crate::platform::detect_php_version(); + let php_version = mozart_core::platform::detect_php_version(); // Load platform requirements from lock file if available let lock_path = working_dir.join("composer.lock"); if lock_path.exists() { - let lock = crate::lockfile::LockFile::read_from_file(&lock_path)?; + let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; // Collect platform entries from lock's platform field if let Some(obj) = lock.platform.as_object() { @@ -1268,7 +1277,7 @@ fn show_platform(args: &ShowArgs, working_dir: &Path) -> anyhow::Result<()> { } // Detect PHP extensions if PHP is available - let extensions = crate::platform::detect_php_extensions(); + let extensions = mozart_core::platform::detect_php_extensions(); for ext in &extensions { let ext_name = format!("ext-{ext}"); if !platform_packages.iter().any(|(n, _, _)| *n == ext_name) { @@ -1327,8 +1336,8 @@ fn show_platform(args: &ShowArgs, working_dir: &Path) -> anyhow::Result<()> { for (name, version, _source) in &platform_packages { println!( "{} {}", - crate::console::info(&format!("{:<width$}", name, width = name_width)), - crate::console::comment(&format!("{:<width$}", version, width = version_width)), + mozart_core::console::info(&format!("{:<width$}", name, width = name_width)), + mozart_core::console::comment(&format!("{:<width$}", version, width = version_width)), ); } @@ -1346,7 +1355,7 @@ fn show_available(args: &ShowArgs, working_dir: &Path) -> anyhow::Result<()> { // Otherwise, show all installed packages with their available (latest) versions // by querying Packagist for each installed package let vendor_dir = working_dir.join("vendor"); - let installed = crate::installed::InstalledPackages::read(&vendor_dir); + let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir); let installed = match installed { Ok(i) if !i.packages.is_empty() => i, @@ -1354,16 +1363,16 @@ fn show_available(args: &ShowArgs, working_dir: &Path) -> anyhow::Result<()> { // Try lock file let lock_path = working_dir.join("composer.lock"); if lock_path.exists() { - let lock = crate::lockfile::LockFile::read_from_file(&lock_path)?; + let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; println!( "{}", - crate::console::info( + mozart_core::console::info( "Available versions for locked packages (from Packagist):" ) ); println!(); - let mut all_packages: Vec<&crate::lockfile::LockedPackage> = + let mut all_packages: Vec<&mozart_registry::lockfile::LockedPackage> = lock.packages.iter().collect(); if !args.no_dev && let Some(ref dev_pkgs) = lock.packages_dev @@ -1382,7 +1391,7 @@ fn show_available(args: &ShowArgs, working_dir: &Path) -> anyhow::Result<()> { eprintln!( "{}", - crate::console::warning( + mozart_core::console::warning( "No dependencies installed. Try running mozart install or update." ) ); @@ -1392,7 +1401,7 @@ fn show_available(args: &ShowArgs, working_dir: &Path) -> anyhow::Result<()> { println!( "{}", - crate::console::info("Available versions for installed packages (from Packagist):") + mozart_core::console::info("Available versions for installed packages (from Packagist):") ); println!(); @@ -1404,7 +1413,7 @@ fn show_available(args: &ShowArgs, working_dir: &Path) -> anyhow::Result<()> { if is_platform_package(&pkg.name) { continue; } - match crate::packagist::fetch_package_versions(&pkg.name, None) { + match mozart_registry::packagist::fetch_package_versions(&pkg.name, None) { Ok(versions) => { let version_strings: Vec<String> = versions.iter().map(|v| v.version.clone()).collect(); @@ -1439,7 +1448,7 @@ fn show_available(args: &ShowArgs, working_dir: &Path) -> anyhow::Result<()> { } fn show_available_versions(pkg_name: &str, args: &ShowArgs) -> anyhow::Result<()> { - let versions = crate::packagist::fetch_package_versions(pkg_name, None)?; + let versions = mozart_registry::packagist::fetch_package_versions(pkg_name, None)?; if versions.is_empty() { println!("No versions found for {pkg_name}"); return Ok(()); @@ -1458,19 +1467,22 @@ fn show_available_versions(pkg_name: &str, args: &ShowArgs) -> anyhow::Result<() println!( "{}", - crate::console::info(&format!("Available versions for {pkg_name}:")) + mozart_core::console::info(&format!("Available versions for {pkg_name}:")) ); for v in &versions { - println!(" {}", crate::console::comment(&v.version)); + println!(" {}", mozart_core::console::comment(&v.version)); } Ok(()) } fn show_available_versions_inline(pkg_name: &str) { - match crate::packagist::fetch_package_versions(pkg_name, None) { + match mozart_registry::packagist::fetch_package_versions(pkg_name, None) { Ok(versions) => { if versions.is_empty() { - println!("{}: no versions found", crate::console::info(pkg_name)); + println!( + "{}: no versions found", + mozart_core::console::info(pkg_name) + ); return; } // Show up to 5 most recent versions @@ -1486,15 +1498,15 @@ fn show_available_versions_inline(pkg_name: &str) { }; println!( "{}: {}{}", - crate::console::info(pkg_name), - crate::console::comment(&shown.join(", ")), + mozart_core::console::info(pkg_name), + mozart_core::console::comment(&shown.join(", ")), rest ); } Err(_) => { println!( "{}: (could not fetch from Packagist)", - crate::console::comment(pkg_name) + mozart_core::console::comment(pkg_name) ); } } @@ -1513,7 +1525,7 @@ fn format_version_highlight(version: &str) -> String { } /// Extract description from an InstalledPackageEntry's extra_fields. -fn get_installed_description(pkg: &crate::installed::InstalledPackageEntry) -> String { +fn get_installed_description(pkg: &mozart_registry::installed::InstalledPackageEntry) -> String { pkg.extra_fields .get("description") .and_then(|v| v.as_str()) @@ -1522,7 +1534,7 @@ fn get_installed_description(pkg: &crate::installed::InstalledPackageEntry) -> S } /// Extract keywords from an InstalledPackageEntry's extra_fields. -fn get_installed_keywords(pkg: &crate::installed::InstalledPackageEntry) -> String { +fn get_installed_keywords(pkg: &mozart_registry::installed::InstalledPackageEntry) -> String { pkg.extra_fields .get("keywords") .and_then(|v| v.as_array()) @@ -1536,7 +1548,9 @@ fn get_installed_keywords(pkg: &crate::installed::InstalledPackageEntry) -> Stri } /// Extract license from an InstalledPackageEntry's extra_fields. -fn get_installed_license(pkg: &crate::installed::InstalledPackageEntry) -> Option<String> { +fn get_installed_license( + pkg: &mozart_registry::installed::InstalledPackageEntry, +) -> Option<String> { pkg.extra_fields.get("license").and_then(|v| { v.as_array().map(|arr| { arr.iter() @@ -1548,7 +1562,9 @@ fn get_installed_license(pkg: &crate::installed::InstalledPackageEntry) -> Optio } /// Extract homepage from an InstalledPackageEntry's extra_fields. -fn get_installed_homepage(pkg: &crate::installed::InstalledPackageEntry) -> Option<String> { +fn get_installed_homepage( + pkg: &mozart_registry::installed::InstalledPackageEntry, +) -> Option<String> { pkg.extra_fields .get("homepage") .and_then(|v| v.as_str()) @@ -1711,7 +1727,7 @@ mod tests { "description".to_string(), serde_json::Value::String("A logging library".to_string()), ); - let pkg = crate::installed::InstalledPackageEntry { + let pkg = mozart_registry::installed::InstalledPackageEntry { name: "monolog/monolog".to_string(), version: "3.0.0".to_string(), version_normalized: None, @@ -1729,7 +1745,7 @@ mod tests { #[test] fn test_get_installed_description_absent() { use std::collections::BTreeMap; - let pkg = crate::installed::InstalledPackageEntry { + let pkg = mozart_registry::installed::InstalledPackageEntry { name: "psr/log".to_string(), version: "3.0.0".to_string(), version_normalized: None, @@ -1754,7 +1770,7 @@ mod tests { "keywords".to_string(), serde_json::json!(["log", "psr3", "logging"]), ); - let pkg = crate::installed::InstalledPackageEntry { + let pkg = mozart_registry::installed::InstalledPackageEntry { name: "psr/log".to_string(), version: "3.0.0".to_string(), version_normalized: None, diff --git a/crates/mozart/src/commands/status.rs b/crates/mozart/src/commands/status.rs index dc26a5f..ad6dac1 100644 --- a/crates/mozart/src/commands/status.rs +++ b/crates/mozart/src/commands/status.rs @@ -47,7 +47,7 @@ struct PackageStatus { pub fn execute( args: &StatusArgs, cli: &super::Cli, - _console: &crate::console::Console, + _console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), @@ -55,15 +55,15 @@ pub fn execute( }; let vendor_dir = working_dir.join("vendor"); - let installed = crate::installed::InstalledPackages::read(&vendor_dir)?; + let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?; if installed.packages.is_empty() { println!("No packages installed."); return Ok(()); } - let cache_config = crate::cache::build_cache_config(cli); - let files_cache = crate::cache::Cache::files(&cache_config); + let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache); + let files_cache = mozart_registry::cache::Cache::files(&cache_config); let show_files = args.verbose || cli.verbose > 0; @@ -99,7 +99,7 @@ pub fn execute( // Download original archive to a temp dir let tmp_dir = make_temp_dir(&pkg.name)?; - let downloaded = crate::downloader::download_dist( + let downloaded = mozart_registry::downloader::download_dist( &dist.url, dist.shasum.as_deref(), None, @@ -117,8 +117,10 @@ pub fn execute( // Extract archive to temp dir let extract_result = match dist.dist_type.as_str() { - "zip" => crate::downloader::extract_zip(&bytes, &tmp_dir), - "tar" | "tar.gz" | "tgz" => crate::downloader::extract_tar_gz(&bytes, &tmp_dir), + "zip" => mozart_registry::downloader::extract_zip(&bytes, &tmp_dir), + "tar" | "tar.gz" | "tgz" => { + mozart_registry::downloader::extract_tar_gz(&bytes, &tmp_dir) + } other => { eprintln!( " Warning: unsupported dist type '{}' for {}", @@ -184,7 +186,7 @@ pub fn execute( // ─── Helpers ────────────────────────────────────────────────────────────────── /// Extract dist info from an installed package entry. -fn extract_dist_info(pkg: &crate::installed::InstalledPackageEntry) -> Option<DistInfo> { +fn extract_dist_info(pkg: &mozart_registry::installed::InstalledPackageEntry) -> Option<DistInfo> { // Try the strongly-typed `dist` field first let dist_val = pkg.dist.as_ref().or_else(|| pkg.extra_fields.get("dist"))?; @@ -213,7 +215,7 @@ fn extract_dist_info(pkg: &crate::installed::InstalledPackageEntry) -> Option<Di /// since it is a path relative to `vendor/composer/`. Falls back to /// `vendor/<package-name>`. fn resolve_install_path( - pkg: &crate::installed::InstalledPackageEntry, + pkg: &mozart_registry::installed::InstalledPackageEntry, vendor_dir: &Path, ) -> PathBuf { if let Some(ref rel) = pkg.install_path { @@ -484,7 +486,7 @@ mod tests { fn test_extract_dist_info_from_dist_field() { use std::collections::BTreeMap; - let pkg = crate::installed::InstalledPackageEntry { + let pkg = mozart_registry::installed::InstalledPackageEntry { name: "vendor/pkg".to_string(), version: "1.0.0".to_string(), version_normalized: None, @@ -512,7 +514,7 @@ mod tests { fn test_extract_dist_info_no_url() { use std::collections::BTreeMap; - let pkg = crate::installed::InstalledPackageEntry { + let pkg = mozart_registry::installed::InstalledPackageEntry { name: "vendor/pkg".to_string(), version: "1.0.0".to_string(), version_normalized: None, @@ -536,7 +538,7 @@ mod tests { fn test_extract_dist_info_absent() { use std::collections::BTreeMap; - let pkg = crate::installed::InstalledPackageEntry { + let pkg = mozart_registry::installed::InstalledPackageEntry { name: "vendor/pkg".to_string(), version: "1.0.0".to_string(), version_normalized: None, @@ -558,7 +560,7 @@ mod tests { fn test_resolve_install_path_default() { use std::collections::BTreeMap; - let pkg = crate::installed::InstalledPackageEntry { + let pkg = mozart_registry::installed::InstalledPackageEntry { name: "monolog/monolog".to_string(), version: "3.0.0".to_string(), version_normalized: None, @@ -580,7 +582,7 @@ mod tests { fn test_resolve_install_path_with_install_path() { use std::collections::BTreeMap; - let pkg = crate::installed::InstalledPackageEntry { + let pkg = mozart_registry::installed::InstalledPackageEntry { name: "monolog/monolog".to_string(), version: "3.0.0".to_string(), version_normalized: None, diff --git a/crates/mozart/src/commands/suggests.rs b/crates/mozart/src/commands/suggests.rs index df58e47..d528171 100644 --- a/crates/mozart/src/commands/suggests.rs +++ b/crates/mozart/src/commands/suggests.rs @@ -41,7 +41,7 @@ struct Suggestion { pub fn execute( args: &SuggestsArgs, cli: &super::Cli, - _console: &crate::console::Console, + _console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), @@ -160,9 +160,10 @@ fn collect_suggestions_from_locked( no_dev: bool, ) -> anyhow::Result<Vec<Suggestion>> { let lock_path = working_dir.join("composer.lock"); - let lock = crate::lockfile::LockFile::read_from_file(&lock_path)?; + let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; - let mut all_packages: Vec<&crate::lockfile::LockedPackage> = lock.packages.iter().collect(); + let mut all_packages: Vec<&mozart_registry::lockfile::LockedPackage> = + lock.packages.iter().collect(); if !no_dev && let Some(ref pkgs_dev) = lock.packages_dev { all_packages.extend(pkgs_dev.iter()); } @@ -187,7 +188,7 @@ fn collect_suggestions_from_installed( no_dev: bool, ) -> anyhow::Result<Vec<Suggestion>> { let vendor_dir = working_dir.join("vendor"); - let installed = crate::installed::InstalledPackages::read(&vendor_dir)?; + let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?; if installed.packages.is_empty() { let installed_json = vendor_dir.join("composer/installed.json"); @@ -233,7 +234,7 @@ fn collect_suggestions_from_root(working_dir: &Path) -> anyhow::Result<Vec<Sugge return Ok(vec![]); } - let root = crate::package::read_from_file(&composer_json_path)?; + let root = mozart_core::package::read_from_file(&composer_json_path)?; // suggest is in extra_fields since RawPackageData doesn't model it explicitly let suggest_val = root.extra_fields.get("suggest"); @@ -264,11 +265,12 @@ fn collect_installed_names_from_lock( no_dev: bool, ) -> anyhow::Result<HashSet<String>> { let lock_path = working_dir.join("composer.lock"); - let lock = crate::lockfile::LockFile::read_from_file(&lock_path)?; + let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; let mut names: HashSet<String> = HashSet::new(); - let mut all_packages: Vec<&crate::lockfile::LockedPackage> = lock.packages.iter().collect(); + let mut all_packages: Vec<&mozart_registry::lockfile::LockedPackage> = + lock.packages.iter().collect(); if !no_dev && let Some(ref pkgs_dev) = lock.packages_dev { all_packages.extend(pkgs_dev.iter()); } @@ -299,7 +301,7 @@ fn collect_installed_names_from_installed( no_dev: bool, ) -> anyhow::Result<HashSet<String>> { let vendor_dir = working_dir.join("vendor"); - let installed = crate::installed::InstalledPackages::read(&vendor_dir)?; + let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?; let dev_names: HashSet<String> = installed .dev_package_names @@ -330,7 +332,7 @@ fn collect_installed_names_from_installed( // Add platform packages from require/require-dev in composer.json let composer_json_path = working_dir.join("composer.json"); if composer_json_path.exists() - && let Ok(root) = crate::package::read_from_file(&composer_json_path) + && let Ok(root) = mozart_core::package::read_from_file(&composer_json_path) { for name in root.require.keys().chain(root.require_dev.keys()) { if is_platform_package(name) { @@ -342,7 +344,10 @@ fn collect_installed_names_from_installed( Ok(names) } -fn add_platform_names_from_lock(lock: &crate::lockfile::LockFile, names: &mut HashSet<String>) { +fn add_platform_names_from_lock( + lock: &mozart_registry::lockfile::LockFile, + names: &mut HashSet<String>, +) { // Collect platform keys from the lock's platform and platform_dev objects if let Some(obj) = lock.platform.as_object() { for key in obj.keys() { @@ -372,7 +377,7 @@ fn compute_direct_deps(working_dir: &Path) -> anyhow::Result<HashSet<String>> { if !composer_json_path.exists() { return Ok(HashSet::new()); } - let root = crate::package::read_from_file(&composer_json_path)?; + let root = mozart_core::package::read_from_file(&composer_json_path)?; let mut deps: HashSet<String> = HashSet::new(); for name in root.require.keys().chain(root.require_dev.keys()) { deps.insert(name.to_lowercase()); @@ -447,8 +452,8 @@ mod tests { fn make_locked_package( name: &str, suggest: Option<BTreeMap<String, String>>, - ) -> crate::lockfile::LockedPackage { - crate::lockfile::LockedPackage { + ) -> mozart_registry::lockfile::LockedPackage { + mozart_registry::lockfile::LockedPackage { name: name.to_string(), version: "1.0.0".to_string(), version_normalized: None, @@ -476,7 +481,7 @@ mod tests { fn make_installed_entry( name: &str, suggest: Option<BTreeMap<String, String>>, - ) -> crate::installed::InstalledPackageEntry { + ) -> mozart_registry::installed::InstalledPackageEntry { let mut extra_fields: BTreeMap<String, serde_json::Value> = BTreeMap::new(); if let Some(s) = suggest { let map: serde_json::Map<String, serde_json::Value> = s @@ -485,7 +490,7 @@ mod tests { .collect(); extra_fields.insert("suggest".to_string(), serde_json::Value::Object(map)); } - crate::installed::InstalledPackageEntry { + mozart_registry::installed::InstalledPackageEntry { name: name.to_string(), version: "1.0.0".to_string(), version_normalized: None, @@ -500,11 +505,11 @@ mod tests { } fn minimal_lock( - packages: Vec<crate::lockfile::LockedPackage>, - packages_dev: Option<Vec<crate::lockfile::LockedPackage>>, - ) -> crate::lockfile::LockFile { - crate::lockfile::LockFile { - readme: crate::lockfile::LockFile::default_readme(), + packages: Vec<mozart_registry::lockfile::LockedPackage>, + packages_dev: Option<Vec<mozart_registry::lockfile::LockedPackage>>, + ) -> mozart_registry::lockfile::LockFile { + mozart_registry::lockfile::LockFile { + readme: mozart_registry::lockfile::LockFile::default_readme(), content_hash: "abc123".to_string(), packages, packages_dev, @@ -687,7 +692,7 @@ mod tests { let mut suggest = BTreeMap::new(); suggest.insert("ext-redis".to_string(), "For Redis caching".to_string()); - let mut installed = crate::installed::InstalledPackages::new(); + let mut installed = mozart_registry::installed::InstalledPackages::new(); installed.upsert(make_installed_entry("vendor/cache", Some(suggest))); installed.upsert(make_installed_entry("vendor/other", None)); installed.write(&vendor_dir).unwrap(); diff --git a/crates/mozart/src/commands/update.rs b/crates/mozart/src/commands/update.rs index d4056e4..3a7c423 100644 --- a/crates/mozart/src/commands/update.rs +++ b/crates/mozart/src/commands/update.rs @@ -1,8 +1,8 @@ -use crate::console; -use crate::lockfile; -use crate::package::{self, Stability}; -use crate::resolver::{self, PlatformConfig, ResolveRequest, ResolvedPackage}; use clap::Args; +use mozart_core::console; +use mozart_core::package::{self, Stability}; +use mozart_registry::lockfile; +use mozart_registry::resolver::{self, PlatformConfig, ResolveRequest, ResolvedPackage}; use std::collections::{HashMap, HashSet}; #[derive(Args)] @@ -633,7 +633,7 @@ pub fn apply_minimal_changes( pub fn execute( args: &UpdateArgs, cli: &super::Cli, - console: &crate::console::Console, + console: &mozart_core::console::Console, ) -> anyhow::Result<()> { // Step 1: Resolve the working directory let working_dir = super::install::resolve_working_dir(cli); @@ -670,8 +670,8 @@ pub fn execute( // Step 3: Read composer.json let composer_json_path = working_dir.join("composer.json"); if !composer_json_path.exists() { - return Err(crate::exit_code::bail( - crate::exit_code::GENERAL_ERROR, + return Err(mozart_core::exit_code::bail( + mozart_core::exit_code::GENERAL_ERROR, format!( "Composer could not find a composer.json file in {}", working_dir.display() @@ -746,8 +746,8 @@ pub fn execute( let mut resolved = match resolver::resolve(&request) { Ok(packages) => packages, Err(e) => { - return Err(crate::exit_code::bail( - crate::exit_code::DEPENDENCY_RESOLUTION_FAILED, + return Err(mozart_core::exit_code::bail( + mozart_core::exit_code::DEPENDENCY_RESOLUTION_FAILED, e.to_string(), )); } @@ -777,8 +777,8 @@ pub fn execute( let update_packages: Vec<String> = if !args.packages.is_empty() { match &old_lock { None => { - return Err(crate::exit_code::bail( - crate::exit_code::NO_LOCK_FILE_FOR_PARTIAL_UPDATE, + return Err(mozart_core::exit_code::bail( + mozart_core::exit_code::NO_LOCK_FILE_FOR_PARTIAL_UPDATE, "No lock file found. Cannot perform partial update. Run `mozart update` first.", )); } @@ -834,8 +834,8 @@ pub fn execute( if !update_packages.is_empty() { match &old_lock { None => { - return Err(crate::exit_code::bail( - crate::exit_code::NO_LOCK_FILE_FOR_PARTIAL_UPDATE, + return Err(mozart_core::exit_code::bail( + mozart_core::exit_code::NO_LOCK_FILE_FOR_PARTIAL_UPDATE, "No lock file found. Cannot perform partial update. Run `mozart update` first.", )); } @@ -947,7 +947,7 @@ pub fn execute( .map(|s| s.eq_ignore_ascii_case("source")) .unwrap_or(false); if prefer_source { - console.info(&crate::console::warning( + console.info(&mozart_core::console::warning( "Warning: Source installs are not yet supported. Falling back to dist.", )); } @@ -986,11 +986,11 @@ fn handle_lock_mode( lock_path: &std::path::Path, composer_json_content: &str, dry_run: bool, - console: &crate::console::Console, + console: &mozart_core::console::Console, ) -> anyhow::Result<()> { if !lock_path.exists() { - return Err(crate::exit_code::bail( - crate::exit_code::LOCK_FILE_INVALID, + return Err(mozart_core::exit_code::bail( + mozart_core::exit_code::LOCK_FILE_INVALID, "No lock file found. Run `mozart update` to generate one.", )); } @@ -1360,9 +1360,9 @@ mod tests { // Composer.json content that will produce a different hash let composer_json_content = r#"{"name": "test/project", "require": {"psr/log": "^3.0"}}"#; - let console = crate::console::Console { + let console = mozart_core::console::Console { interactive: false, - verbosity: crate::console::Verbosity::Normal, + verbosity: mozart_core::console::Verbosity::Normal, decorated: false, }; let result = handle_lock_mode(&lock_path, composer_json_content, false, &console); @@ -1388,9 +1388,9 @@ mod tests { lock.content_hash = correct_hash.clone(); lock.write_to_file(&lock_path).unwrap(); - let console = crate::console::Console { + let console = mozart_core::console::Console { interactive: false, - verbosity: crate::console::Verbosity::Normal, + verbosity: mozart_core::console::Verbosity::Normal, decorated: false, }; let result = handle_lock_mode(&lock_path, composer_json_content, false, &console); @@ -1412,9 +1412,9 @@ mod tests { let composer_json_content = r#"{"name": "test/project", "require": {"psr/log": "^3.0"}}"#; - let console = crate::console::Console { + let console = mozart_core::console::Console { interactive: false, - verbosity: crate::console::Verbosity::Normal, + verbosity: mozart_core::console::Verbosity::Normal, decorated: false, }; let result = handle_lock_mode(&lock_path, composer_json_content, true, &console); @@ -1660,9 +1660,9 @@ mod tests { #[test] #[ignore] fn test_update_full_e2e() { - use crate::lockfile::{LockFileGenerationRequest, generate_lock_file}; - use crate::package::RawPackageData; - use crate::resolver::{ResolveRequest, resolve}; + use mozart_core::package::RawPackageData; + use mozart_registry::lockfile::{LockFileGenerationRequest, generate_lock_file}; + use mozart_registry::resolver::{ResolveRequest, resolve}; let composer_json_content = r#"{"name": "test/project", "require": {"monolog/monolog": "^3.0"}}"#; @@ -1717,9 +1717,9 @@ mod tests { let expected_hash = lockfile::LockFile::compute_content_hash(composer_json_content).unwrap(); - let console = crate::console::Console { + let console = mozart_core::console::Console { interactive: false, - verbosity: crate::console::Verbosity::Normal, + verbosity: mozart_core::console::Verbosity::Normal, decorated: false, }; handle_lock_mode(&lock_path, composer_json_content, false, &console).unwrap(); diff --git a/crates/mozart/src/commands/validate.rs b/crates/mozart/src/commands/validate.rs index 1dec3fe..50e3cce 100644 --- a/crates/mozart/src/commands/validate.rs +++ b/crates/mozart/src/commands/validate.rs @@ -70,7 +70,7 @@ impl ValidationResult { pub fn execute( args: &ValidateArgs, cli: &super::Cli, - console: &crate::console::Console, + console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), @@ -91,7 +91,7 @@ pub fn execute( // Check file exists if !file.exists() { - return Err(crate::exit_code::bail( + return Err(mozart_core::exit_code::bail( VALIDATE_FILE_ERROR, format!("{} not found.", file.display()), )); @@ -101,7 +101,7 @@ pub fn execute( let content = match std::fs::read_to_string(&file) { Ok(c) => c, Err(_) => { - return Err(crate::exit_code::bail( + return Err(mozart_core::exit_code::bail( VALIDATE_FILE_ERROR, format!("{} is not readable.", file.display()), )); @@ -112,7 +112,7 @@ pub fn execute( let json_value: serde_json::Value = match serde_json::from_str(&content) { Ok(v) => v, Err(e) => { - return Err(crate::exit_code::bail( + return Err(mozart_core::exit_code::bail( VALIDATE_JSON_ERROR, format!("{} does not contain valid JSON: {e}", file.display()), )); @@ -147,7 +147,7 @@ pub fn execute( args.strict, ); if exit_code != 0 { - return Err(crate::exit_code::bail_silent(exit_code)); + return Err(mozart_core::exit_code::bail_silent(exit_code)); } Ok(()) @@ -208,7 +208,7 @@ fn check_name(obj: &serde_json::Map<String, serde_json::Value>, result: &mut Val // Must contain a slash (vendor/package format) if !name.is_empty() - && !crate::validation::validate_package_name(name) + && !mozart_core::validation::validate_package_name(name) && !name.contains('/') { result.errors.push(format!( @@ -365,7 +365,7 @@ fn check_minimum_stability( result: &mut ValidationResult, ) { if let Some(stability) = obj.get("minimum-stability").and_then(|v| v.as_str()) - && !crate::validation::validate_stability(stability) + && !mozart_core::validation::validate_stability(stability) { result.errors.push(format!( "The minimum-stability \"{stability}\" is invalid. \ @@ -391,7 +391,7 @@ fn check_lock_freshness( return; } - match crate::lockfile::LockFile::read_from_file(&lock_path) { + match mozart_registry::lockfile::LockFile::read_from_file(&lock_path) { Ok(lock) => { if !lock.is_fresh(composer_json_content) { lock_errors.push( @@ -422,35 +422,37 @@ fn output_result( if result.has_errors() { eprintln!( "{}", - crate::console::error(&format!( + mozart_core::console::error(&format!( "{name} is invalid, the following errors/warnings were found:" )) ); } else if result.has_publish_errors() && check_publish { eprintln!( "{}", - crate::console::info(&format!( + mozart_core::console::info(&format!( "{name} is valid for simple usage with Composer but has" )) ); eprintln!( "{}", - crate::console::info("strict errors that make it unable to be published as a package") + mozart_core::console::info( + "strict errors that make it unable to be published as a package" + ) ); eprintln!( "{}", - crate::console::warning( + mozart_core::console::warning( "See https://getcomposer.org/doc/04-schema.md for details on the schema" ) ); } else if result.has_warnings() { eprintln!( "{}", - crate::console::info(&format!("{name} is valid, but with a few warnings")) + mozart_core::console::info(&format!("{name} is valid, but with a few warnings")) ); eprintln!( "{}", - crate::console::warning( + mozart_core::console::warning( "See https://getcomposer.org/doc/04-schema.md for details on the schema" ) ); @@ -458,12 +460,15 @@ fn output_result( let kind = if check_lock { "errors" } else { "warnings" }; println!( "{}", - crate::console::info(&format!( + mozart_core::console::info(&format!( "{name} is valid but your composer.lock has some {kind}" )) ); } else { - println!("{}", crate::console::info(&format!("{name} is valid"))); + println!( + "{}", + mozart_core::console::info(&format!("{name} is valid")) + ); } // Collect error and warning message lines @@ -506,7 +511,7 @@ fn output_result( // Print errors for msg in &all_errors { if msg.starts_with('#') { - eprintln!("{}", crate::console::error(msg)); + eprintln!("{}", mozart_core::console::error(msg)); } else { eprintln!("{msg}"); } @@ -515,7 +520,7 @@ fn output_result( // Print warnings for msg in &all_warnings { if msg.starts_with('#') { - eprintln!("{}", crate::console::warning(msg)); + eprintln!("{}", mozart_core::console::warning(msg)); } else { eprintln!("{msg}"); } @@ -907,7 +912,7 @@ mod tests { #[test] fn test_check_lock_freshness_fresh_lock() { - use crate::lockfile::LockFile; + use mozart_registry::lockfile::LockFile; use tempfile::tempdir; let dir = tempdir().unwrap(); @@ -943,7 +948,7 @@ mod tests { #[test] fn test_check_lock_freshness_stale_lock() { - use crate::lockfile::LockFile; + use mozart_registry::lockfile::LockFile; use tempfile::tempdir; let dir = tempdir().unwrap(); diff --git a/crates/mozart/src/console.rs b/crates/mozart/src/console.rs index 5f108c0..e37ff23 100644 --- a/crates/mozart/src/console.rs +++ b/crates/mozart/src/console.rs @@ -94,17 +94,20 @@ pub struct Console { } impl Console { - /// Build a `Console` from the parsed CLI. + /// Build a `Console` from primitive arguments. /// - /// This is the primary constructor used in production. It reads - /// `cli.verbose`, `cli.quiet`, `cli.ansi`, `cli.no_ansi`, and - /// `cli.no_interaction` to configure all fields. - pub fn from_cli(cli: &crate::commands::Cli) -> Self { - let verbosity = Verbosity::from_flags(cli.verbose, cli.quiet); - let decorated = Self::resolve_decorated(cli.ansi, cli.no_ansi); + /// This is the primary constructor. Pass the relevant CLI flag values: + /// - `verbose`: the `-v` flag count (0, 1, 2, 3+) + /// - `quiet`: whether `--quiet` was passed + /// - `ansi`: whether `--ansi` was passed + /// - `no_ansi`: whether `--no-ansi` was passed + /// - `no_interaction`: whether `--no-interaction` / `-n` was passed + pub fn new(verbose: u8, quiet: bool, ansi: bool, no_ansi: bool, no_interaction: bool) -> Self { + let verbosity = Verbosity::from_flags(verbose, quiet); + let decorated = Self::resolve_decorated(ansi, no_ansi); colored::control::set_override(decorated); Self { - interactive: !cli.no_interaction, + interactive: !no_interaction, verbosity, decorated, } diff --git a/crates/mozart/src/constraint.rs b/crates/mozart/src/constraint.rs index e41818c..32dc84e 100644 --- a/crates/mozart/src/constraint.rs +++ b/crates/mozart/src/constraint.rs @@ -1,1972 +1,2 @@ -use std::cmp::Ordering; - -/// A parsed Composer version (always 4 numeric segments + optional stability suffix). -/// Composer normalizes all versions to `major.minor.patch.build[-stability[N]]`. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct Version { - pub major: u64, - pub minor: u64, - pub patch: u64, - pub build: u64, - /// None = stable, Some("alpha1"), Some("beta2"), Some("RC1"), Some("dev") - pub pre_release: Option<String>, - /// true for "dev-master", "dev-feature/foo", etc. - pub is_dev_branch: bool, - /// The original branch name for dev branches (e.g. "master", "feature/foo") - pub dev_branch_name: Option<String>, -} - -/// Stability rank for ordering (lower = more stable). -fn stability_rank(pre: &str) -> u8 { - let lower = pre.to_lowercase(); - if lower.starts_with("dev") { - 50 - } else if lower.starts_with("alpha") || lower.starts_with("a") { - 40 - } else if lower.starts_with("beta") || lower.starts_with("b") { - 30 - } else if lower.starts_with("rc") { - 20 - } else if lower.starts_with("patch") || lower.starts_with("pl") || lower == "p" { - 5 - } else { - 0 - } -} - -/// Extract numeric suffix from a pre-release string like "alpha1" → 1, "beta" → 0 -fn pre_release_number(pre: &str) -> u64 { - let digits: String = pre.chars().skip_while(|c| c.is_alphabetic()).collect(); - digits.parse().unwrap_or(0) -} - -impl PartialOrd for Version { - fn partial_cmp(&self, other: &Self) -> Option<Ordering> { - Some(self.cmp(other)) - } -} - -impl Ord for Version { - fn cmp(&self, other: &Self) -> Ordering { - // Dev branches are always lowest - match (self.is_dev_branch, other.is_dev_branch) { - (true, true) => { - // Compare branch names - return self.dev_branch_name.cmp(&other.dev_branch_name); - } - (true, false) => return Ordering::Less, - (false, true) => return Ordering::Greater, - (false, false) => {} - } - - // Compare numeric segments - let num_cmp = (self.major, self.minor, self.patch, self.build).cmp(&( - other.major, - other.minor, - other.patch, - other.build, - )); - if num_cmp != Ordering::Equal { - return num_cmp; - } - - // Compare pre-release: None (stable) > any pre-release - match (&self.pre_release, &other.pre_release) { - (None, None) => Ordering::Equal, - (None, Some(_)) => Ordering::Greater, - (Some(_), None) => Ordering::Less, - (Some(a), Some(b)) => { - let rank_a = stability_rank(a); - let rank_b = stability_rank(b); - match rank_a.cmp(&rank_b) { - Ordering::Equal => { - // Same stability: compare numeric suffix - pre_release_number(a).cmp(&pre_release_number(b)) - } - // Lower rank = more stable = greater version - Ordering::Less => Ordering::Greater, - Ordering::Greater => Ordering::Less, - } - } - } - } -} - -impl Version { - /// Parse a version string into a `Version` struct using Composer normalization rules. - /// - /// For inline aliases (`"1.0.x-dev as 1.0.0"`), the LEFT side (the real branch version) - /// is used. This is the correct behaviour for identifying *what* version a package provides. - pub fn parse(input: &str) -> Result<Version, String> { - let s = input.trim(); - - // Strip inline alias: "1.0.x-dev as 1.0.0" → "1.0.x-dev" - let s = if let Some(pos) = s.find(" as ") { - &s[..pos] - } else { - s - }; - - // Strip stability flag: "@dev", "@alpha", "@beta", "@RC", "@stable" - let s = if let Some(pos) = s.rfind('@') { - let after = &s[pos + 1..]; - let known = ["dev", "alpha", "beta", "rc", "stable"]; - if known.iter().any(|k| after.eq_ignore_ascii_case(k)) { - &s[..pos] - } else { - s - } - } else { - s - }; - - // Handle dev-* prefix branches - if s.to_lowercase().starts_with("dev-") { - let branch = &s[4..]; - return Ok(Version { - major: 0, - minor: 0, - patch: 0, - build: 0, - pre_release: Some("dev".to_string()), - is_dev_branch: true, - dev_branch_name: Some(branch.to_string()), - }); - } - - // Handle *-dev suffix (e.g., "2.1.x-dev" or "2.x-dev") - let s_lower = s.to_lowercase(); - if s_lower.ends_with("-dev") || s_lower.ends_with(".x-dev") { - let base = if s_lower.ends_with("-dev") { - &s[..s.len() - 4] - } else { - s - }; - // Replace any trailing .x with nothing, parse numeric parts - let base = base.trim_end_matches(".x").trim_end_matches("-dev"); - let parts: Vec<&str> = base.split('.').collect(); - let major = parts.first().and_then(|p| p.parse().ok()).unwrap_or(0); - let minor = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); - return Ok(Version { - major, - minor, - patch: 9999999, - build: 9999999, - pre_release: Some("dev".to_string()), - is_dev_branch: true, - dev_branch_name: None, - }); - } - - // Strip leading v/V - let s = s - .strip_prefix('v') - .or_else(|| s.strip_prefix('V')) - .unwrap_or(s); - - // Strip build metadata after + - let s = s.split('+').next().unwrap_or(s); - - // Parse the version using regex-like approach - parse_classical_version(s) - } - - /// Parse a version string for use inside a *constraint expression*. - /// - /// The difference from [`Version::parse`] is the treatment of inline aliases: - /// `"1.0.x-dev as 1.0.0"` → takes the **right** side (`1.0.0`). - /// - /// Inline aliases appear in `require` fields like: - /// ```text - /// "some/package": "1.0.x-dev as 1.0.0" - /// ``` - /// Here the author wants the constraint to be satisfied by the real version `1.0.0`, - /// while the left side (`1.0.x-dev`) indicates the branch that provides it. - pub fn parse_for_constraint(input: &str) -> Result<Version, String> { - let s = input.trim(); - // For inline aliases, take the RIGHT side (alias target) - let s = if let Some(pos) = s.find(" as ") { - s[pos + 4..].trim() - } else { - s - }; - Version::parse(s) - } - - /// Create a "dev boundary" version for constraint matching (major.minor.patch.build with dev pre-release). - pub fn dev_boundary(major: u64, minor: u64, patch: u64, build: u64) -> Version { - Version { - major, - minor, - patch, - build, - pre_release: Some("dev".to_string()), - is_dev_branch: false, - dev_branch_name: None, - } - } -} - -fn parse_classical_version(s: &str) -> Result<Version, String> { - // Split on '-' to separate version from 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 Err(format!("Invalid version: {s}")); - } - - let major: u64 = segments[0] - .parse() - .map_err(|_| format!("Invalid major version segment: {}", segments[0]))?; - let minor: u64 = segments.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); - let patch: u64 = segments - .get(2) - .and_then(|p| { - // strip trailing .x - let p = p.trim_end_matches('x').trim_end_matches('.'); - if p.is_empty() { - Some(0) - } else { - p.parse().ok() - } - }) - .unwrap_or(0); - let build: u64 = segments.get(3).and_then(|p| p.parse().ok()).unwrap_or(0); - - let pre_release = pre_part.map(normalize_pre_release); - - Ok(Version { - major, - minor, - patch, - build, - pre_release, - is_dev_branch: false, - dev_branch_name: None, - }) -} - -fn normalize_pre_release(s: &str) -> String { - // Normalize aliases: b→beta, a→alpha, rc→RC, p/pl/patch→patch - let lower = s.to_lowercase(); - // Strip leading non-alpha characters (dots, underscores, dashes used as separators) - let normalized = lower - .trim_start_matches(|c: char| !c.is_alphabetic()) - .to_string(); - - // Extract the alphabetic prefix (stability name) - let alpha: String = normalized - .chars() - .take_while(|c| c.is_alphabetic()) - .collect(); - // Extract only digits from the rest (strip separators like dots) - let num: String = normalized - .chars() - .skip_while(|c| c.is_alphabetic()) - .filter(|c| c.is_ascii_digit()) - .collect(); - - if alpha.starts_with("beta") || alpha == "b" { - format!("beta{num}") - } else if alpha.starts_with("alpha") || alpha == "a" { - format!("alpha{num}") - } else if alpha == "rc" { - format!("RC{num}") - } else if alpha == "patch" || alpha == "pl" || alpha == "p" { - format!("patch{num}") - } else if alpha == "dev" { - "dev".to_string() - } else { - s.to_string() - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Constraint types -// ───────────────────────────────────────────────────────────────────────────── - -/// A single atomic constraint. -#[derive(Debug, Clone)] -pub enum Constraint { - /// Exact version match - Exact(Version), - /// Greater than: `> 1.2.3` - GreaterThan(Version), - /// Greater than or equal: `>= 1.2.3` - GreaterThanOrEqual(Version), - /// Less than: `< 1.2.3` - LessThan(Version), - /// Less than or equal: `<= 1.2.3` - LessThanOrEqual(Version), - /// Not equal: `!= 1.2.3` - NotEqual(Version), - /// Matches any version - Any, -} - -impl Constraint { - pub fn matches(&self, v: &Version) -> bool { - match self { - Constraint::Exact(target) => v == target, - Constraint::GreaterThan(target) => v > target, - Constraint::GreaterThanOrEqual(target) => v >= target, - Constraint::LessThan(target) => v < target, - Constraint::LessThanOrEqual(target) => v <= target, - Constraint::NotEqual(target) => v != target, - Constraint::Any => true, - } - } -} - -/// A compound constraint with AND/OR combinators. -#[derive(Debug, Clone)] -pub enum VersionConstraint { - /// Single atomic constraint - Single(Constraint), - /// All must match (AND — space/comma separated) - And(Vec<VersionConstraint>), - /// At least one must match (OR — `||` separated) - Or(Vec<VersionConstraint>), -} - -impl VersionConstraint { - pub fn matches(&self, version: &Version) -> bool { - match self { - VersionConstraint::Single(c) => c.matches(version), - VersionConstraint::And(cs) => cs.iter().all(|c| c.matches(version)), - VersionConstraint::Or(cs) => cs.iter().any(|c| c.matches(version)), - } - } - - /// Parse a constraint string like `^1.2`, `>=1.0 <2.0`, `^1.0 || ^2.0`. - pub fn parse(input: &str) -> Result<VersionConstraint, String> { - let input = input.trim(); - - // Split on || (OR) - let or_parts: Vec<&str> = split_or(input); - - if or_parts.len() > 1 { - let constraints: Result<Vec<_>, _> = - or_parts.iter().map(|p| parse_and_group(p.trim())).collect(); - let mut cs = constraints?; - // Flatten single-element groups - if cs.len() == 1 { - return Ok(cs.remove(0)); - } - return Ok(VersionConstraint::Or(cs)); - } - - parse_and_group(input) - } -} - -/// Split on `||` (pipe-OR), but not inside version strings. -fn split_or(s: &str) -> Vec<&str> { - let mut parts = Vec::new(); - let mut start = 0; - let bytes = s.as_bytes(); - let mut i = 0; - while i < bytes.len() { - if i + 1 < bytes.len() && bytes[i] == b'|' && bytes[i + 1] == b'|' { - parts.push(s[start..i].trim()); - i += 2; - start = i; - } else { - i += 1; - } - } - parts.push(s[start..].trim()); - parts -} - -/// Parse an AND group (space or comma separated constraints). -fn parse_and_group(s: &str) -> Result<VersionConstraint, String> { - // Detect inline alias first: "1.0.x-dev as 1.0.0" - // The entire expression is a single atomic constraint; parse it directly. - if s.contains(" as ") { - return parse_single(s); - } - - // Detect hyphen range first: "1.0 - 2.0" where both sides start with a digit - if let Some(idx) = s.find(" - ") { - let before = s[..idx].trim(); - let after = s[idx + 3..].trim(); - let before_is_version = before - .chars() - .next() - .is_some_and(|c| c.is_ascii_digit() || c == 'v' || c == 'V'); - let after_is_version = after - .chars() - .next() - .is_some_and(|c| c.is_ascii_digit() || c == 'v' || c == 'V'); - if before_is_version && after_is_version { - return parse_hyphen_range(s); - } - } - - let parts = split_and(s); - - if parts.is_empty() { - return Err("Empty constraint".to_string()); - } - - let constraints: Result<Vec<_>, _> = parts.iter().map(|p| parse_single(p.trim())).collect(); - let mut cs = constraints?; - - if cs.len() == 1 { - return Ok(cs.remove(0)); - } - - // Flatten nested And - let flat: Vec<VersionConstraint> = cs - .into_iter() - .flat_map(|c| match c { - VersionConstraint::And(inner) => inner, - other => vec![other], - }) - .collect(); - - Ok(VersionConstraint::And(flat)) -} - -/// Split on spaces or commas (AND separator), respecting that version strings -/// can contain `-` (pre-release). -fn split_and(s: &str) -> Vec<String> { - // A constraint "part" is separated by space or comma when not part of - // operator prefixes like `>=`, `<=`, `!=`, or version like `1.2.3-beta`. - // Strategy: tokenize by whitespace/comma, then re-join multi-token ranges. - let tokens: Vec<&str> = s.split([' ', ',']).filter(|t| !t.is_empty()).collect(); - - let mut parts: Vec<String> = Vec::new(); - let mut current = String::new(); - - for token in tokens { - if current.is_empty() { - current = token.to_string(); - } else { - // If the token starts with an operator or a digit/^ ~/>, it's a new constraint - let starts_new = token.starts_with(|c: char| { - matches!(c, '>' | '<' | '!' | '=' | '^' | '~' | '*') || c.is_ascii_digit() - }); - if starts_new { - parts.push(current.trim().to_string()); - current = token.to_string(); - } else { - // Continuation (e.g. part of a version string with spaces) - current.push(' '); - current.push_str(token); - } - } - } - if !current.is_empty() { - parts.push(current.trim().to_string()); - } - - parts -} - -/// Parse a single constraint part. -fn parse_single(s: &str) -> Result<VersionConstraint, String> { - if s == "*" || s.is_empty() { - return Ok(VersionConstraint::Single(Constraint::Any)); - } - - // Caret: ^1.2.3 - if let Some(rest) = s.strip_prefix('^') { - return parse_caret(rest); - } - - // Tilde: ~1.2.3 - if let Some(rest) = s.strip_prefix('~') { - return parse_tilde(rest); - } - - // Hyphen range: "1.0 - 2.0" — handled at and-group level, but check here too - if s.contains(" - ") { - return parse_hyphen_range(s); - } - - // Comparison operators - // Use parse_for_constraint so that inline aliases like "1.0.x-dev as 1.0.0" - // resolve to the alias target (right-hand side) when used in constraint context. - if let Some(rest) = s.strip_prefix(">=") { - let v = Version::parse_for_constraint(rest.trim())?; - return Ok(VersionConstraint::Single(Constraint::GreaterThanOrEqual(v))); - } - if let Some(rest) = s.strip_prefix("<=") { - let v = Version::parse_for_constraint(rest.trim())?; - return Ok(VersionConstraint::Single(Constraint::LessThanOrEqual(v))); - } - if let Some(rest) = s.strip_prefix("!=") { - let v = Version::parse_for_constraint(rest.trim())?; - return Ok(VersionConstraint::Single(Constraint::NotEqual(v))); - } - if let Some(rest) = s.strip_prefix('>') { - let v = Version::parse_for_constraint(rest.trim())?; - return Ok(VersionConstraint::Single(Constraint::GreaterThan(v))); - } - if let Some(rest) = s.strip_prefix('<') { - let v = Version::parse_for_constraint(rest.trim())?; - return Ok(VersionConstraint::Single(Constraint::LessThan(v))); - } - if let Some(rest) = s.strip_prefix('=') { - let v = Version::parse_for_constraint(rest.trim())?; - return Ok(VersionConstraint::Single(Constraint::Exact(v))); - } - - // Wildcard: 1.2.* or 1.* - if s.ends_with(".*") || s.ends_with(".*.*") || s == "*" { - return parse_wildcard(s); - } - - // Exact version (may carry an inline alias; take the alias target for matching) - let v = Version::parse_for_constraint(s)?; - Ok(VersionConstraint::Single(Constraint::Exact(v))) -} - -/// Parse `^major.minor.patch` caret constraint. -/// First non-zero segment is the "locked" boundary. -fn parse_caret(s: &str) -> Result<VersionConstraint, String> { - let parts: Vec<&str> = s.split('.').collect(); - let major: u64 = parts.first().and_then(|p| p.parse().ok()).unwrap_or(0); - let minor: u64 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); - let patch: u64 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0); - let build: u64 = parts.get(3).and_then(|p| p.parse().ok()).unwrap_or(0); - - let lower = Version::dev_boundary(major, minor, patch, build); - - // Determine upper bound based on first non-zero segment - let upper = if major > 0 { - Version::dev_boundary(major + 1, 0, 0, 0) - } else if minor > 0 { - Version::dev_boundary(0, minor + 1, 0, 0) - } else if patch > 0 { - Version::dev_boundary(0, 0, patch + 1, 0) - } else { - Version::dev_boundary(0, 0, 1, 0) - }; - - Ok(VersionConstraint::And(vec![ - VersionConstraint::Single(Constraint::GreaterThanOrEqual(lower)), - VersionConstraint::Single(Constraint::LessThan(upper)), - ])) -} - -/// Parse `~major.minor.patch` tilde constraint. -fn parse_tilde(s: &str) -> Result<VersionConstraint, String> { - let parts: Vec<&str> = s.split('.').collect(); - let major: u64 = parts.first().and_then(|p| p.parse().ok()).unwrap_or(0); - let minor: u64 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); - let patch: u64 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0); - let build: u64 = parts.get(3).and_then(|p| p.parse().ok()).unwrap_or(0); - - let lower = Version::dev_boundary(major, minor, patch, build); - - // ~major.minor.patch → >=major.minor.patch <major.(minor+1).0 - // ~major.minor → >=major.minor.0 <(major+1).0.0 - // ~major → >=major.0.0 <(major+1).0.0 - let upper = if parts.len() >= 3 { - Version::dev_boundary(major, minor + 1, 0, 0) - } else { - Version::dev_boundary(major + 1, 0, 0, 0) - }; - - Ok(VersionConstraint::And(vec![ - VersionConstraint::Single(Constraint::GreaterThanOrEqual(lower)), - VersionConstraint::Single(Constraint::LessThan(upper)), - ])) -} - -/// Parse `1.2.*` wildcard constraint. -fn parse_wildcard(s: &str) -> Result<VersionConstraint, String> { - if s == "*" { - return Ok(VersionConstraint::Single(Constraint::Any)); - } - - // Strip trailing .* - let base = s.trim_end_matches(".*"); - if base.is_empty() { - return Ok(VersionConstraint::Single(Constraint::Any)); - } - - let parts: Vec<&str> = base.split('.').collect(); - let major: u64 = parts.first().and_then(|p| p.parse().ok()).unwrap_or(0); - let minor: u64 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); - - let (lower, upper) = if parts.len() == 1 { - ( - Version::dev_boundary(major, 0, 0, 0), - Version::dev_boundary(major + 1, 0, 0, 0), - ) - } else { - ( - Version::dev_boundary(major, minor, 0, 0), - Version::dev_boundary(major, minor + 1, 0, 0), - ) - }; - - Ok(VersionConstraint::And(vec![ - VersionConstraint::Single(Constraint::GreaterThanOrEqual(lower)), - VersionConstraint::Single(Constraint::LessThan(upper)), - ])) -} - -/// Parse `1.0 - 2.0` hyphen range. -fn parse_hyphen_range(s: &str) -> Result<VersionConstraint, String> { - let parts: Vec<&str> = s.splitn(2, " - ").collect(); - if parts.len() != 2 { - return Err(format!("Invalid hyphen range: {s}")); - } - - let lower_v = Version::parse_for_constraint(parts[0].trim())?; - let upper_v = Version::parse_for_constraint(parts[1].trim())?; - - Ok(VersionConstraint::And(vec![ - VersionConstraint::Single(Constraint::GreaterThanOrEqual(lower_v)), - VersionConstraint::Single(Constraint::LessThanOrEqual(upper_v)), - ])) -} - -#[cfg(test)] -mod tests { - use super::*; - - // ──────────── Version parsing ──────────── - - #[test] - fn test_parse_simple() { - let v = Version::parse("1.2.3").unwrap(); - assert_eq!(v.major, 1); - assert_eq!(v.minor, 2); - assert_eq!(v.patch, 3); - assert_eq!(v.build, 0); - assert_eq!(v.pre_release, None); - assert!(!v.is_dev_branch); - } - - #[test] - fn test_parse_with_v_prefix() { - let v = Version::parse("v1.2").unwrap(); - assert_eq!(v.major, 1); - assert_eq!(v.minor, 2); - assert_eq!(v.patch, 0); - assert_eq!(v.build, 0); - assert_eq!(v.pre_release, None); - } - - #[test] - fn test_parse_four_segments() { - let v = Version::parse("1.2.3.4").unwrap(); - assert_eq!((v.major, v.minor, v.patch, v.build), (1, 2, 3, 4)); - } - - #[test] - fn test_parse_beta() { - let v = Version::parse("1.0.0-beta.1").unwrap(); - assert_eq!(v.major, 1); - // "beta.1" normalizes to "beta1" (dot is stripped) - assert_eq!(v.pre_release, Some("beta1".to_string())); - } - - #[test] - fn test_parse_beta1() { - let v = Version::parse("1.0.0-beta1").unwrap(); - assert_eq!(v.pre_release, Some("beta1".to_string())); - } - - #[test] - fn test_parse_rc() { - let v = Version::parse("1.0.0-RC1").unwrap(); - assert_eq!(v.pre_release, Some("RC1".to_string())); - } - - #[test] - fn test_parse_alpha() { - let v = Version::parse("2.0.0-alpha3").unwrap(); - assert_eq!(v.pre_release, Some("alpha3".to_string())); - } - - #[test] - fn test_parse_dev_master() { - let v = Version::parse("dev-master").unwrap(); - assert!(v.is_dev_branch); - assert_eq!(v.dev_branch_name, Some("master".to_string())); - assert_eq!(v.pre_release, Some("dev".to_string())); - } - - #[test] - fn test_parse_dev_feature() { - let v = Version::parse("dev-feature/foo").unwrap(); - assert!(v.is_dev_branch); - assert_eq!(v.dev_branch_name, Some("feature/foo".to_string())); - } - - #[test] - fn test_parse_x_dev() { - let v = Version::parse("2.1.x-dev").unwrap(); - assert!(v.is_dev_branch); - assert_eq!(v.major, 2); - assert_eq!(v.minor, 1); - assert_eq!(v.patch, 9999999); - assert_eq!(v.build, 9999999); - } - - #[test] - fn test_parse_strip_at_stability() { - let v = Version::parse("1.2.3@stable").unwrap(); - assert_eq!(v.major, 1); - assert_eq!(v.minor, 2); - assert_eq!(v.patch, 3); - assert_eq!(v.pre_release, None); - } - - #[test] - fn test_parse_inline_alias() { - let v = Version::parse("1.0.x-dev as 1.0.0").unwrap(); - // Takes left side: 1.0.x-dev - assert!(v.is_dev_branch); - } - - #[test] - fn test_parse_for_constraint_inline_alias() { - // parse_for_constraint takes the RIGHT side of an inline alias - let v = Version::parse_for_constraint("1.0.x-dev as 1.0.0").unwrap(); - assert!(!v.is_dev_branch); - assert_eq!(v.major, 1); - assert_eq!(v.minor, 0); - assert_eq!(v.patch, 0); - assert_eq!(v.pre_release, None); - } - - #[test] - fn test_parse_for_constraint_no_alias() { - // Without an alias, parse_for_constraint behaves like parse - let v = Version::parse_for_constraint("1.2.3").unwrap(); - assert_eq!(v.major, 1); - assert_eq!(v.minor, 2); - assert_eq!(v.patch, 3); - assert!(!v.is_dev_branch); - } - - #[test] - fn test_constraint_inline_alias_exact_matches_target() { - // A constraint written as "1.0.x-dev as 1.0.0" should match 1.0.0 (the alias target) - let c = VersionConstraint::parse("1.0.x-dev as 1.0.0").unwrap(); - let target = Version::parse("1.0.0").unwrap(); - assert!(c.matches(&target)); - // But NOT a different version - let other = Version::parse("1.1.0").unwrap(); - assert!(!c.matches(&other)); - } - - // ──────────── Version ordering ──────────── - - #[test] - fn test_ordering_major() { - let a = Version::parse("2.0.0").unwrap(); - let b = Version::parse("1.0.0").unwrap(); - assert!(a > b); - } - - #[test] - fn test_ordering_minor() { - let a = Version::parse("1.2.0").unwrap(); - let b = Version::parse("1.1.0").unwrap(); - assert!(a > b); - } - - #[test] - fn test_ordering_stable_gt_rc() { - let stable = Version::parse("1.0.0").unwrap(); - let rc = Version::parse("1.0.0-RC1").unwrap(); - assert!(stable > rc); - } - - #[test] - fn test_ordering_rc_gt_beta() { - let rc = Version::parse("1.0.0-RC1").unwrap(); - let beta = Version::parse("1.0.0-beta1").unwrap(); - assert!(rc > beta); - } - - #[test] - fn test_ordering_beta_gt_alpha() { - let beta = Version::parse("1.0.0-beta1").unwrap(); - let alpha = Version::parse("1.0.0-alpha1").unwrap(); - assert!(beta > alpha); - } - - #[test] - fn test_ordering_alpha_gt_dev_branch() { - let alpha = Version::parse("1.0.0-alpha1").unwrap(); - let dev = Version::parse("dev-master").unwrap(); - assert!(alpha > dev); - } - - #[test] - fn test_ordering_pre_release_numbers() { - let beta2 = Version::parse("1.0.0-beta2").unwrap(); - let beta1 = Version::parse("1.0.0-beta1").unwrap(); - assert!(beta2 > beta1); - } - - // ──────────── Constraint parsing ──────────── - - #[test] - fn test_parse_any() { - let c = VersionConstraint::parse("*").unwrap(); - let v = Version::parse("1.2.3").unwrap(); - assert!(c.matches(&v)); - } - - #[test] - fn test_parse_exact() { - let c = VersionConstraint::parse("1.2.3").unwrap(); - let v = Version::parse("1.2.3").unwrap(); - assert!(c.matches(&v)); - let v2 = Version::parse("1.2.4").unwrap(); - assert!(!c.matches(&v2)); - } - - #[test] - fn test_parse_gte() { - let c = VersionConstraint::parse(">=1.0.0").unwrap(); - assert!(c.matches(&Version::parse("1.0.0").unwrap())); - assert!(c.matches(&Version::parse("2.0.0").unwrap())); - assert!(!c.matches(&Version::parse("0.9.0").unwrap())); - } - - #[test] - fn test_parse_caret_major() { - let c = VersionConstraint::parse("^1.2").unwrap(); - assert!(c.matches(&Version::parse("1.2.0").unwrap())); - assert!(c.matches(&Version::parse("1.3.0").unwrap())); - assert!(c.matches(&Version::parse("1.9.9").unwrap())); - assert!(!c.matches(&Version::parse("2.0.0").unwrap())); - assert!(!c.matches(&Version::parse("1.1.0").unwrap())); - } - - #[test] - fn test_parse_caret_zero_minor() { - // ^0.2.3 → >=0.2.3 <0.3.0 - let c = VersionConstraint::parse("^0.2.3").unwrap(); - assert!(c.matches(&Version::parse("0.2.3").unwrap())); - assert!(c.matches(&Version::parse("0.2.9").unwrap())); - assert!(!c.matches(&Version::parse("0.3.0").unwrap())); - assert!(!c.matches(&Version::parse("1.0.0").unwrap())); - } - - #[test] - fn test_parse_tilde_three_parts() { - // ~1.2.3 → >=1.2.3 <1.3.0 - let c = VersionConstraint::parse("~1.2.3").unwrap(); - assert!(c.matches(&Version::parse("1.2.3").unwrap())); - assert!(c.matches(&Version::parse("1.2.9").unwrap())); - assert!(!c.matches(&Version::parse("1.3.0").unwrap())); - } - - #[test] - fn test_parse_tilde_two_parts() { - // ~1.2 → >=1.2.0 <2.0.0 - let c = VersionConstraint::parse("~1.2").unwrap(); - assert!(c.matches(&Version::parse("1.2.0").unwrap())); - assert!(c.matches(&Version::parse("1.9.0").unwrap())); - assert!(!c.matches(&Version::parse("2.0.0").unwrap())); - } - - #[test] - fn test_parse_wildcard() { - let c = VersionConstraint::parse("1.2.*").unwrap(); - assert!(c.matches(&Version::parse("1.2.0").unwrap())); - assert!(c.matches(&Version::parse("1.2.9").unwrap())); - assert!(!c.matches(&Version::parse("1.3.0").unwrap())); - } - - #[test] - fn test_parse_and() { - let c = VersionConstraint::parse(">=1.0 <2.0").unwrap(); - assert!(c.matches(&Version::parse("1.0.0").unwrap())); - assert!(c.matches(&Version::parse("1.9.9").unwrap())); - assert!(!c.matches(&Version::parse("2.0.0").unwrap())); - assert!(!c.matches(&Version::parse("0.9.9").unwrap())); - } - - #[test] - fn test_parse_or() { - let c = VersionConstraint::parse("^1.0 || ^2.0").unwrap(); - assert!(c.matches(&Version::parse("1.5.0").unwrap())); - assert!(c.matches(&Version::parse("2.3.0").unwrap())); - assert!(!c.matches(&Version::parse("3.0.0").unwrap())); - } - - #[test] - fn test_parse_not_equal() { - let c = VersionConstraint::parse("!=1.5.0").unwrap(); - assert!(c.matches(&Version::parse("1.4.0").unwrap())); - assert!(!c.matches(&Version::parse("1.5.0").unwrap())); - assert!(c.matches(&Version::parse("1.6.0").unwrap())); - } - - #[test] - fn test_parse_hyphen_range() { - let c = VersionConstraint::parse("1.0 - 2.0").unwrap(); - assert!(c.matches(&Version::parse("1.0.0").unwrap())); - assert!(c.matches(&Version::parse("1.5.0").unwrap())); - assert!(c.matches(&Version::parse("2.0.0").unwrap())); - assert!(!c.matches(&Version::parse("0.9.0").unwrap())); - assert!(!c.matches(&Version::parse("2.1.0").unwrap())); - } - - // ──────────── Helper ──────────── - - fn satisfies(constraint: &str, version: &str) -> bool { - let c = VersionConstraint::parse(constraint).unwrap(); - let v = Version::parse(version).unwrap(); - c.matches(&v) - } - - // ══════════════════════════════════════════════════════════════════════════ - // 1. VERSION PARSING EDGE CASES - // ══════════════════════════════════════════════════════════════════════════ - - #[test] - fn test_parse_single_segment() { - let v = Version::parse("1").unwrap(); - assert_eq!(v.major, 1); - assert_eq!(v.minor, 0); - assert_eq!(v.patch, 0); - assert_eq!(v.build, 0); - assert_eq!(v.pre_release, None); - assert!(!v.is_dev_branch); - } - - #[test] - fn test_parse_two_segments() { - let v = Version::parse("1.2").unwrap(); - assert_eq!(v.major, 1); - assert_eq!(v.minor, 2); - assert_eq!(v.patch, 0); - assert_eq!(v.build, 0); - assert_eq!(v.pre_release, None); - } - - #[test] - fn test_parse_zero_version() { - let v = Version::parse("0.0.0").unwrap(); - assert_eq!(v.major, 0); - assert_eq!(v.minor, 0); - assert_eq!(v.patch, 0); - assert_eq!(v.build, 0); - assert_eq!(v.pre_release, None); - assert!(!v.is_dev_branch); - } - - #[test] - fn test_parse_zero_zero_one() { - let v = Version::parse("0.0.1").unwrap(); - assert_eq!((v.major, v.minor, v.patch, v.build), (0, 0, 1, 0)); - assert_eq!(v.pre_release, None); - } - - #[test] - fn test_parse_large_version_numbers() { - let v = Version::parse("99999.1.2.3").unwrap(); - assert_eq!(v.major, 99999); - assert_eq!(v.minor, 1); - assert_eq!(v.patch, 2); - assert_eq!(v.build, 3); - } - - #[test] - fn test_parse_uppercase_v_prefix() { - let v = Version::parse("V1.2.3").unwrap(); - assert_eq!(v.major, 1); - assert_eq!(v.minor, 2); - assert_eq!(v.patch, 3); - assert_eq!(v.pre_release, None); - assert!(!v.is_dev_branch); - } - - #[test] - fn test_parse_build_metadata_stripped() { - // Build metadata after '+' should be stripped - let v = Version::parse("1.2.3+build.456").unwrap(); - assert_eq!((v.major, v.minor, v.patch), (1, 2, 3)); - assert_eq!(v.pre_release, None); - } - - #[test] - fn test_parse_shorthand_b_normalizes_to_beta() { - // "b2" suffix → beta2 - let v = Version::parse("1.0.0-b2").unwrap(); - assert_eq!(v.pre_release, Some("beta2".to_string())); - } - - #[test] - fn test_parse_shorthand_a_normalizes_to_alpha() { - // "a1" suffix → alpha1 - let v = Version::parse("1.0.0-a1").unwrap(); - assert_eq!(v.pre_release, Some("alpha1".to_string())); - } - - #[test] - fn test_parse_shorthand_p_normalizes_to_patch() { - // "p1" suffix → patch1 - let v = Version::parse("1.0.0-p1").unwrap(); - assert_eq!(v.pre_release, Some("patch1".to_string())); - } - - #[test] - fn test_parse_shorthand_pl_normalizes_to_patch() { - // "pl2" suffix → patch2 - let v = Version::parse("1.0.0-pl2").unwrap(); - assert_eq!(v.pre_release, Some("patch2".to_string())); - } - - #[test] - fn test_parse_shorthand_rc_lowercase_normalizes_to_rc() { - // "rc2" suffix → RC2 - let v = Version::parse("1.0.0-rc2").unwrap(); - assert_eq!(v.pre_release, Some("RC2".to_string())); - } - - #[test] - fn test_parse_stability_beta_no_number() { - // "1.0.0-beta" with no number - let v = Version::parse("1.0.0-beta").unwrap(); - assert_eq!(v.pre_release, Some("beta".to_string())); - } - - #[test] - fn test_parse_dev_release_branch() { - // "dev-release-1.0" is a dev branch named "release-1.0" - let v = Version::parse("dev-release-1.0").unwrap(); - assert!(v.is_dev_branch); - assert_eq!(v.dev_branch_name, Some("release-1.0".to_string())); - assert_eq!(v.pre_release, Some("dev".to_string())); - } - - #[test] - fn test_parse_dev_master_uppercase() { - // "DEV-master" — case-insensitive dev- prefix - let v = Version::parse("DEV-master").unwrap(); - assert!(v.is_dev_branch); - assert_eq!(v.dev_branch_name, Some("master".to_string())); - } - - #[test] - fn test_parse_x_dev_two_segment() { - // "2.x-dev" → major=2, minor=0, patch=9999999, build=9999999 - let v = Version::parse("2.x-dev").unwrap(); - assert!(v.is_dev_branch); - assert_eq!(v.major, 2); - assert_eq!(v.minor, 0); - assert_eq!(v.patch, 9999999); - assert_eq!(v.build, 9999999); - } - - #[test] - fn test_parse_numeric_dev_suffix() { - // "2.1-dev" — ends with -dev, treated as *-dev suffix branch - let v = Version::parse("2.1-dev").unwrap(); - assert!(v.is_dev_branch); - assert_eq!(v.major, 2); - assert_eq!(v.minor, 1); - } - - #[test] - fn test_parse_stability_flag_dev() { - // "1.0.0@dev" → strip @dev suffix, parse 1.0.0 as stable - let v = Version::parse("1.0.0@dev").unwrap(); - assert_eq!((v.major, v.minor, v.patch), (1, 0, 0)); - assert!(!v.is_dev_branch); - // After stripping @dev, no pre-release suffix remains - assert_eq!(v.pre_release, None); - } - - #[test] - fn test_parse_stability_flag_alpha() { - let v = Version::parse("1.0.0@alpha").unwrap(); - assert_eq!((v.major, v.minor, v.patch), (1, 0, 0)); - assert_eq!(v.pre_release, None); - } - - #[test] - fn test_parse_stability_flag_beta() { - let v = Version::parse("1.0.0@beta").unwrap(); - assert_eq!((v.major, v.minor, v.patch), (1, 0, 0)); - assert_eq!(v.pre_release, None); - } - - #[test] - fn test_parse_stability_flag_rc() { - let v = Version::parse("1.0.0@rc").unwrap(); - assert_eq!((v.major, v.minor, v.patch), (1, 0, 0)); - assert_eq!(v.pre_release, None); - } - - #[test] - fn test_parse_inline_alias_left_side() { - // "dev-main as 1.0.x-dev" → left side is "dev-main" - let v = Version::parse("dev-main as 1.0.x-dev").unwrap(); - assert!(v.is_dev_branch); - assert_eq!(v.dev_branch_name, Some("main".to_string())); - } - - #[test] - fn test_parse_error_empty_string() { - let result = Version::parse(""); - assert!(result.is_err(), "Expected error for empty string"); - } - - #[test] - fn test_parse_error_not_a_version() { - // Strings with no numeric start should fail - let result = Version::parse("not-a-version"); - assert!( - result.is_err(), - "Expected error for 'not-a-version', got: {:?}", - result - ); - } - - #[test] - fn test_parse_error_only_dots() { - let result = Version::parse("...."); - assert!(result.is_err(), "Expected error for '....'"); - } - - #[test] - fn test_parse_error_non_numeric_segment() { - // "1.abc.3" — minor segment is non-numeric; parse degrades minor to 0 - // The implementation uses `and_then(|p| p.parse().ok()).unwrap_or(0)`, - // so non-numeric segments silently become 0. This is intentional behavior. - let v = Version::parse("1.abc.3").unwrap(); - assert_eq!(v.major, 1); - // minor "abc" fails to parse as u64, so falls back to 0 - assert_eq!(v.minor, 0); - } - - // ══════════════════════════════════════════════════════════════════════════ - // 2. VERSION ORDERING - // ══════════════════════════════════════════════════════════════════════════ - - #[test] - fn test_ordering_equal_versions() { - let a = Version::parse("1.2.3").unwrap(); - let b = Version::parse("1.2.3").unwrap(); - assert_eq!(a.cmp(&b), std::cmp::Ordering::Equal); - } - - #[test] - fn test_ordering_patch_difference() { - let a = Version::parse("1.2.4").unwrap(); - let b = Version::parse("1.2.3").unwrap(); - assert!(a > b); - } - - #[test] - fn test_ordering_build_segment_difference() { - let a = Version::parse("1.2.3.2").unwrap(); - let b = Version::parse("1.2.3.1").unwrap(); - assert!(a > b); - } - - #[test] - fn test_ordering_dev_branch_lt_dev_prerelease() { - // "1.0.0-dev" ends with "-dev", so the parser treats it as a *-dev suffix branch - // (is_dev_branch=true, dev_branch_name=None, major=1, minor=0, patch=9999999). - // "dev-master" is also is_dev_branch=true with dev_branch_name=Some("master"). - // When both are dev branches, they compare by dev_branch_name: - // Some("master") vs None → Some > None, so dev-master > 1.0.0-dev (x-dev form). - let dev_branch = Version::parse("dev-master").unwrap(); - let dev_prerelease = Version::parse("1.0.0-dev").unwrap(); - // Both are dev branches; "master" branch name > None → dev-master is Greater - assert!(dev_branch > dev_prerelease); - } - - #[test] - fn test_ordering_dev_prerelease_lt_alpha() { - let dev = Version::parse("1.0.0-dev").unwrap(); - let alpha = Version::parse("1.0.0-alpha1").unwrap(); - assert!(dev < alpha); - } - - #[test] - fn test_ordering_alpha_lt_beta() { - let alpha = Version::parse("1.0.0-alpha1").unwrap(); - let beta = Version::parse("1.0.0-beta1").unwrap(); - assert!(alpha < beta); - } - - #[test] - fn test_ordering_beta_lt_rc() { - let beta = Version::parse("1.0.0-beta1").unwrap(); - let rc = Version::parse("1.0.0-RC1").unwrap(); - assert!(beta < rc); - } - - #[test] - fn test_ordering_rc_lt_stable() { - let rc = Version::parse("1.0.0-RC1").unwrap(); - let stable = Version::parse("1.0.0").unwrap(); - assert!(rc < stable); - } - - #[test] - fn test_ordering_stable_lt_patch() { - // The Ord impl: (None, Some(_)) => Greater — stable (pre_release=None) beats any - // pre_release including "patch1". Even though stability_rank("patch")=5 which is - // higher than stable's implicit 0, that path is only reached when both sides are - // Some(_). Since stable has pre_release=None, stable > patch version. - let stable = Version::parse("1.0.0").unwrap(); - let patch = Version::parse("1.0.0-patch1").unwrap(); - assert!(stable > patch); - } - - #[test] - fn test_ordering_rc3_gt_rc2() { - let rc3 = Version::parse("1.0.0-RC3").unwrap(); - let rc2 = Version::parse("1.0.0-RC2").unwrap(); - assert!(rc3 > rc2); - } - - #[test] - fn test_ordering_alpha5_gt_alpha3() { - let a5 = Version::parse("1.0.0-alpha5").unwrap(); - let a3 = Version::parse("1.0.0-alpha3").unwrap(); - assert!(a5 > a3); - } - - #[test] - fn test_ordering_dev_branches_alphabetical() { - // Between two dev branches, compare branch names alphabetically - let dev_foo = Version::parse("dev-foo").unwrap(); - let dev_bar = Version::parse("dev-bar").unwrap(); - // "bar" < "foo" alphabetically - assert!(dev_foo > dev_bar); - } - - #[test] - fn test_ordering_zero_versions() { - let a = Version::parse("0.0.2").unwrap(); - let b = Version::parse("0.0.1").unwrap(); - assert!(a > b); - } - - #[test] - fn test_ordering_four_vs_three_segment_equal() { - // 1.2.3.0 and 1.2.3 should be equal (build defaults to 0) - let a = Version::parse("1.2.3.0").unwrap(); - let b = Version::parse("1.2.3").unwrap(); - assert_eq!(a, b); - } - - #[test] - fn test_ordering_comprehensive_chain() { - // Note: "1.0.0-dev" is parsed as a *-dev suffix branch (is_dev_branch=true, - // dev_branch_name=None) due to the "-dev" suffix rule in Version::parse. - // "dev-foo" is also a dev branch (is_dev_branch=true, dev_branch_name=Some("foo")). - // Comparing two dev branches uses dev_branch_name: None < Some("foo"), so - // the *-dev form (None) < "dev-foo" (Some("foo")). - // For "1.0.0-alpha1", "1.0.0-beta1", "1.0.0-RC1", "1.0.0": normal numeric ordering. - let dev_x_dev = Version::parse("1.0.0-dev").unwrap(); // *-dev branch, name=None - let dev_branch = Version::parse("dev-foo").unwrap(); // named branch, name=Some("foo") - let alpha = Version::parse("1.0.0-alpha1").unwrap(); - let beta = Version::parse("1.0.0-beta1").unwrap(); - let rc = Version::parse("1.0.0-RC1").unwrap(); - let stable = Version::parse("1.0.0").unwrap(); - - // Both dev branches; dev_branch_name None < Some("foo") - assert!(dev_x_dev < dev_branch); - // dev_branch (is_dev_branch=true) < alpha (is_dev_branch=false) - assert!(dev_branch < alpha); - assert!(alpha < beta); - assert!(beta < rc); - assert!(rc < stable); - } - - // ══════════════════════════════════════════════════════════════════════════ - // 3. CONSTRAINT PARSING EDGE CASES - // ══════════════════════════════════════════════════════════════════════════ - - // ── Caret ── - - #[test] - fn test_caret_zero_zero_three() { - // ^0.0.3 → >=0.0.3 <0.0.4 - assert!(satisfies("^0.0.3", "0.0.3")); - assert!(!satisfies("^0.0.3", "0.0.4")); - assert!(!satisfies("^0.0.3", "0.0.2")); - } - - #[test] - fn test_caret_zero_zero_zero() { - // ^0.0.0 → first non-zero is none, upper = 0.0.1 - assert!(satisfies("^0.0.0", "0.0.0")); - assert!(!satisfies("^0.0.0", "0.0.1")); - } - - #[test] - fn test_caret_single_major() { - // ^1 → >=1.0.0 <2.0.0 - assert!(satisfies("^1", "1.0.0")); - assert!(satisfies("^1", "1.99.99")); - assert!(!satisfies("^1", "2.0.0")); - assert!(!satisfies("^1", "0.9.9")); - } - - #[test] - fn test_caret_four_segments() { - // ^1.2.3.4 → >=1.2.3.4 <2.0.0.0 - assert!(satisfies("^1.2.3.4", "1.2.3.4")); - assert!(satisfies("^1.2.3.4", "1.9.0.0")); - assert!(!satisfies("^1.2.3.4", "2.0.0.0")); - assert!(!satisfies("^1.2.3.4", "1.2.3.3")); - } - - #[test] - fn test_caret_lower_boundary() { - // ^1.2.3 lower boundary: 1.2.3 matches but 1.2.2 does not - assert!(satisfies("^1.2.3", "1.2.3")); - assert!(!satisfies("^1.2.3", "1.2.2")); - } - - #[test] - fn test_caret_upper_boundary() { - // ^1.2.3 upper boundary: 1.9.9 matches, 2.0.0 does not - assert!(satisfies("^1.2.3", "1.9.9")); - assert!(!satisfies("^1.2.3", "2.0.0")); - } - - // ── Tilde ── - - #[test] - fn test_tilde_single_major() { - // ~1 → >=1.0.0 <2.0.0 - assert!(satisfies("~1", "1.0.0")); - assert!(satisfies("~1", "1.99.0")); - assert!(!satisfies("~1", "2.0.0")); - assert!(!satisfies("~1", "0.9.9")); - } - - #[test] - fn test_tilde_four_segments() { - // ~1.2.3.4 → >=1.2.3.4 <1.3.0.0 - assert!(satisfies("~1.2.3.4", "1.2.3.4")); - assert!(satisfies("~1.2.9.0", "1.2.9.0")); - assert!(!satisfies("~1.2.3.4", "1.3.0.0")); - assert!(!satisfies("~1.2.3.4", "1.2.3.3")); - } - - #[test] - fn test_tilde_lower_boundary() { - // ~1.2.3: 1.2.3 matches, 1.2.2 does not - assert!(satisfies("~1.2.3", "1.2.3")); - assert!(!satisfies("~1.2.3", "1.2.2")); - } - - #[test] - fn test_tilde_upper_boundary() { - // ~1.2.3: 1.2.9 matches, 1.3.0 does not - assert!(satisfies("~1.2.3", "1.2.9")); - assert!(!satisfies("~1.2.3", "1.3.0")); - } - - // ── Wildcard ── - - #[test] - fn test_wildcard_major_only() { - // 1.* → >=1.0.0 <2.0.0 - assert!(satisfies("1.*", "1.0.0")); - assert!(satisfies("1.*", "1.99.0")); - assert!(!satisfies("1.*", "2.0.0")); - assert!(!satisfies("1.*", "0.9.9")); - } - - #[test] - fn test_wildcard_double_star() { - // 1.*.* is treated like 1.* - assert!(satisfies("1.*.*", "1.5.0")); - assert!(!satisfies("1.*.*", "2.0.0")); - } - - #[test] - fn test_wildcard_three_segment() { - // 1.2.3.* — the implementation strips trailing .*; base is "1.2.3" - // parse_wildcard strips .* and splits on '.'; parts.len()=3 → minor constraint - assert!(satisfies("1.2.3.*", "1.2.3")); - assert!(satisfies("1.2.3.*", "1.2.9")); - assert!(!satisfies("1.2.3.*", "1.3.0")); - } - - #[test] - fn test_wildcard_zero_major() { - // 0.* → >=0.0.0 <1.0.0 - assert!(satisfies("0.*", "0.0.0")); - assert!(satisfies("0.*", "0.99.0")); - assert!(!satisfies("0.*", "1.0.0")); - } - - #[test] - fn test_wildcard_v_prefix() { - // v1.* — the wildcard parser strips the trailing .*; base becomes "v1" - // parse_wildcard's base.split('.') on "v1" → single part "v1" - // v1 fails to parse as u64, falls back to 0 — so this is like 0.* - // Mark as ignore since the behavior diverges from the expected semantic - #[allow(unused)] - let _ = VersionConstraint::parse("v1.*"); // just verify it doesn't panic - } - - // ── Hyphen ranges ── - - #[test] - fn test_hyphen_range_partial_from() { - // "1.0 - 2.0": 1.0 is a partial "from", lower = >=1.0.0 - assert!(satisfies("1.0 - 2.0", "1.0.0")); - assert!(satisfies("1.0 - 2.0", "1.5.0")); - } - - #[test] - fn test_hyphen_range_partial_to() { - // "1.0 - 2.0": upper = <=2.0.0 (inclusive) - assert!(satisfies("1.0 - 2.0", "2.0.0")); - assert!(!satisfies("1.0 - 2.0", "2.0.1")); - } - - #[test] - fn test_hyphen_range_same_version() { - // "1.0.0 - 1.0.0" → >=1.0.0 <=1.0.0, matches only 1.0.0 - assert!(satisfies("1.0.0 - 1.0.0", "1.0.0")); - assert!(!satisfies("1.0.0 - 1.0.0", "1.0.1")); - assert!(!satisfies("1.0.0 - 1.0.0", "0.9.9")); - } - - #[test] - fn test_hyphen_range_with_prerelease() { - // "1.0.0-alpha1 - 1.0.0-RC1" - assert!(satisfies("1.0.0-alpha1 - 1.0.0-RC1", "1.0.0-alpha1")); - assert!(satisfies("1.0.0-alpha1 - 1.0.0-RC1", "1.0.0-beta1")); - assert!(satisfies("1.0.0-alpha1 - 1.0.0-RC1", "1.0.0-RC1")); - assert!(!satisfies("1.0.0-alpha1 - 1.0.0-RC1", "1.0.0")); - } - - // ── Comparison operators ── - - #[test] - fn test_gt_boundary() { - assert!(!satisfies(">1.0.0", "1.0.0")); - assert!(satisfies(">1.0.0", "1.0.1")); - } - - #[test] - fn test_lt_boundary() { - assert!(!satisfies("<1.0.0", "1.0.0")); - assert!(satisfies("<1.0.0", "0.9.9")); - } - - #[test] - fn test_lte_boundary() { - assert!(satisfies("<=1.0.0", "1.0.0")); - assert!(!satisfies("<=1.0.0", "1.0.1")); - } - - #[test] - fn test_exact_equals_sign() { - // "=1.2.3" is exact match - assert!(satisfies("=1.2.3", "1.2.3")); - assert!(!satisfies("=1.2.3", "1.2.4")); - } - - #[test] - #[ignore = "== (double equals) is not supported: the '=' handler passes '=1.2.3' to \ - Version::parse_for_constraint which fails to parse '=1' as a major number"] - fn test_double_equals_sign() { - // "==1.2.3" — the '=' branch strips one '=', leaving "=1.2.3", which is then - // passed to Version::parse_for_constraint. That function tries to parse "=1" as - // a major version number and fails. Double-equals is not a supported syntax. - assert!(satisfies("==1.2.3", "1.2.3")); - assert!(!satisfies("==1.2.3", "1.2.4")); - } - - #[test] - fn test_not_equal_boundary() { - assert!(!satisfies("!=1.5.0", "1.5.0")); - assert!(satisfies("!=1.5.0", "1.4.9")); - assert!(satisfies("!=1.5.0", "1.5.1")); - } - - #[test] - fn test_gte_with_spaces() { - // Spaces after operator should be handled - assert!(satisfies(">=1.0.0", "1.0.0")); - } - - // ── AND constraints ── - - #[test] - fn test_and_comma_separated() { - // Comma-separated constraints act as AND - assert!(satisfies(">=1.0,<2.0", "1.5.0")); - assert!(!satisfies(">=1.0,<2.0", "2.0.0")); - assert!(!satisfies(">=1.0,<2.0", "0.9.0")); - } - - #[test] - fn test_and_three_way() { - assert!(satisfies(">=1.0 !=1.5.0 <2.0", "1.3.0")); - assert!(!satisfies(">=1.0 !=1.5.0 <2.0", "1.5.0")); - assert!(!satisfies(">=1.0 !=1.5.0 <2.0", "2.0.0")); - } - - #[test] - fn test_and_impossible_range() { - // >=2.0 <1.0 — impossible range, nothing should match - assert!(!satisfies(">=2.0 <1.0", "1.5.0")); - assert!(!satisfies(">=2.0 <1.0", "2.0.0")); - assert!(!satisfies(">=2.0 <1.0", "0.5.0")); - } - - #[test] - fn test_and_tight_range() { - // >=1.2.3 <=1.2.3 — only exactly 1.2.3 - assert!(satisfies(">=1.2.3 <=1.2.3", "1.2.3")); - assert!(!satisfies(">=1.2.3 <=1.2.3", "1.2.4")); - assert!(!satisfies(">=1.2.3 <=1.2.3", "1.2.2")); - } - - // ── OR constraints ── - - #[test] - fn test_or_double_pipe() { - assert!(satisfies("^1.0 || ^2.0", "1.5.0")); - assert!(satisfies("^1.0 || ^2.0", "2.3.0")); - assert!(!satisfies("^1.0 || ^2.0", "3.0.0")); - } - - #[test] - fn test_or_three_branches() { - assert!(satisfies("^1.0 || ^2.0 || ^3.0", "1.0.0")); - assert!(satisfies("^1.0 || ^2.0 || ^3.0", "2.5.0")); - assert!(satisfies("^1.0 || ^2.0 || ^3.0", "3.9.9")); - assert!(!satisfies("^1.0 || ^2.0 || ^3.0", "4.0.0")); - } - - #[test] - fn test_or_with_wildcard() { - assert!(satisfies("1.* || 3.*", "1.5.0")); - assert!(satisfies("1.* || 3.*", "3.0.0")); - assert!(!satisfies("1.* || 3.*", "2.0.0")); - } - - #[test] - fn test_or_overlapping_ranges() { - // Overlapping ranges are fine — union semantics - assert!(satisfies(">=1.0 <3.0 || >=2.0 <4.0", "1.5.0")); - assert!(satisfies(">=1.0 <3.0 || >=2.0 <4.0", "2.5.0")); - assert!(satisfies(">=1.0 <3.0 || >=2.0 <4.0", "3.5.0")); - assert!(!satisfies(">=1.0 <3.0 || >=2.0 <4.0", "0.9.0")); - assert!(!satisfies(">=1.0 <3.0 || >=2.0 <4.0", "4.0.0")); - } - - #[test] - fn test_or_exact_versions() { - assert!(satisfies("1.0.0 || 2.0.0 || 3.0.0", "1.0.0")); - assert!(satisfies("1.0.0 || 2.0.0 || 3.0.0", "2.0.0")); - assert!(satisfies("1.0.0 || 2.0.0 || 3.0.0", "3.0.0")); - assert!(!satisfies("1.0.0 || 2.0.0 || 3.0.0", "1.0.1")); - } - - // ── Complex combined ── - - #[test] - fn test_combined_and_within_or() { - // ">=1.0 <2.0 || >=3.0 <4.0" - assert!(satisfies(">=1.0 <2.0 || >=3.0 <4.0", "1.5.0")); - assert!(satisfies(">=1.0 <2.0 || >=3.0 <4.0", "3.5.0")); - assert!(!satisfies(">=1.0 <2.0 || >=3.0 <4.0", "2.5.0")); - assert!(!satisfies(">=1.0 <2.0 || >=3.0 <4.0", "4.0.0")); - } - - #[test] - fn test_combined_real_world_laravel_pattern() { - // "^8.0||^9.0||^10.0||^11.0" — real Laravel constraint - assert!(satisfies("^8.0||^9.0||^10.0||^11.0", "8.5.0")); - assert!(satisfies("^8.0||^9.0||^10.0||^11.0", "9.0.0")); - assert!(satisfies("^8.0||^9.0||^10.0||^11.0", "10.48.22")); - assert!(satisfies("^8.0||^9.0||^10.0||^11.0", "11.0.1")); - assert!(!satisfies("^8.0||^9.0||^10.0||^11.0", "7.9.9")); - assert!(!satisfies("^8.0||^9.0||^10.0||^11.0", "12.0.0")); - } - - #[test] - fn test_combined_real_world_symfony_pattern() { - // ">=5.4 <7.0" — typical Symfony range - assert!(satisfies(">=5.4 <7.0", "5.4.0")); - assert!(satisfies(">=5.4 <7.0", "6.4.5")); - assert!(!satisfies(">=5.4 <7.0", "5.3.9")); - assert!(!satisfies(">=5.4 <7.0", "7.0.0")); - } - - // ── Edge cases ── - - #[test] - fn test_constraint_empty_string_is_any() { - // Empty string → Any constraint - let c = VersionConstraint::parse("*").unwrap(); - let v = Version::parse("9.9.9").unwrap(); - assert!(c.matches(&v)); - } - - #[test] - fn test_constraint_v_prefix_in_exact() { - // "v1.2.3" exact constraint — strip v prefix - assert!(satisfies("v1.2.3", "1.2.3")); - assert!(!satisfies("v1.2.3", "1.2.4")); - } - - #[test] - fn test_constraint_extra_whitespace_and() { - // Extra spaces around operators in AND groups - assert!(satisfies(">=1.0.0 <2.0.0", "1.5.0")); - assert!(!satisfies(">=1.0.0 <2.0.0", "2.0.0")); - } - - // ══════════════════════════════════════════════════════════════════════════ - // 4. CONSTRAINT MATCHING - // ══════════════════════════════════════════════════════════════════════════ - - #[test] - fn test_dev_branch_exact_match() { - // dev-master matches dev-master constraint exactly - let c = VersionConstraint::parse("dev-master").unwrap(); - let v = Version::parse("dev-master").unwrap(); - assert!(c.matches(&v)); - } - - #[test] - fn test_dev_branch_different_branch_no_match() { - let c = VersionConstraint::parse("dev-master").unwrap(); - let v = Version::parse("dev-develop").unwrap(); - assert!(!c.matches(&v)); - } - - #[test] - fn test_dev_branch_against_caret_no_match() { - // dev-master does not satisfy ^1.0 (it is a dev branch, always lowest) - let c = VersionConstraint::parse("^1.0").unwrap(); - let v = Version::parse("dev-master").unwrap(); - assert!(!c.matches(&v)); - } - - #[test] - fn test_any_constraint_matches_dev_branch() { - // "*" matches any version including dev branches - let c = VersionConstraint::parse("*").unwrap(); - let v = Version::parse("dev-master").unwrap(); - assert!(c.matches(&v)); - } - - #[test] - fn test_prerelease_within_caret_range() { - // Pre-release of a version within ^1.0 should match - // e.g. 1.5.0-beta1 — it is >= dev boundary 1.0.0 and < dev boundary 2.0.0 - assert!(satisfies("^1.0", "1.5.0-beta1")); - } - - #[test] - fn test_caret_lower_minus_one_no_match() { - // ^1.2.3 lower-1 = 1.2.2 → should NOT match - assert!(!satisfies("^1.2.3", "1.2.2")); - } - - #[test] - fn test_caret_upper_minus_one_matches() { - // ^1.2.3 upper-1 patch: 1.9.9 should still match (below 2.0.0) - assert!(satisfies("^1.2.3", "1.9.9")); - } - - #[test] - fn test_tilde_lower_minus_one_no_match() { - assert!(!satisfies("~1.2.3", "1.2.2")); - } - - #[test] - fn test_tilde_upper_minus_one_matches() { - assert!(satisfies("~1.2.3", "1.2.9")); - } - - // ══════════════════════════════════════════════════════════════════════════ - // 5. INTERNAL FUNCTION TESTS (via public API) - // ══════════════════════════════════════════════════════════════════════════ - - // stability_rank() — tested via ordering since the function is private - - #[test] - fn test_stability_rank_dev_via_ordering() { - // dev rank=50 (highest number = least stable), alpha rank=40 - // So dev < alpha in version ordering terms - let dev = Version::parse("1.0.0-dev").unwrap(); - let alpha = Version::parse("1.0.0-alpha1").unwrap(); - assert!(dev < alpha, "dev should be less stable than alpha1"); - } - - #[test] - fn test_stability_rank_alpha_via_ordering() { - // alpha rank=40, beta rank=30 - let alpha = Version::parse("1.0.0-alpha1").unwrap(); - let beta = Version::parse("1.0.0-beta1").unwrap(); - assert!(alpha < beta, "alpha should be less stable than beta"); - } - - #[test] - fn test_stability_rank_beta_via_ordering() { - // beta rank=30, RC rank=20 - let beta = Version::parse("1.0.0-beta1").unwrap(); - let rc = Version::parse("1.0.0-RC1").unwrap(); - assert!(beta < rc, "beta should be less stable than RC"); - } - - #[test] - fn test_stability_rank_rc_via_ordering() { - // RC rank=20, stable rank=0 - let rc = Version::parse("1.0.0-RC1").unwrap(); - let stable = Version::parse("1.0.0").unwrap(); - assert!(rc < stable, "RC should be less stable than stable"); - } - - #[test] - fn test_stability_rank_patch_via_ordering() { - // The Ord impl: (None, Some(_)) => Greater. - // stable has pre_release=None; patch version has pre_release=Some("patch1"). - // The None arm wins unconditionally: stable is always Greater than any pre_release. - // This means "patch" releases (post-release fixes) sort BELOW stable in this impl. - let patch_ver = Version::parse("1.0.0-patch1").unwrap(); - let stable = Version::parse("1.0.0").unwrap(); - assert!( - stable > patch_ver, - "stable (None pre_release) beats patch pre-release" - ); - } - - // normalize_pre_release() — tested via Version::parse pre_release field - - #[test] - fn test_normalize_pre_release_b_to_beta() { - let v = Version::parse("1.0.0-b3").unwrap(); - assert_eq!(v.pre_release, Some("beta3".to_string())); - } - - #[test] - fn test_normalize_pre_release_a_to_alpha() { - let v = Version::parse("1.0.0-a1").unwrap(); - assert_eq!(v.pre_release, Some("alpha1".to_string())); - } - - #[test] - fn test_normalize_pre_release_rc_to_rc_uppercase() { - let v = Version::parse("1.0.0-rc").unwrap(); - assert_eq!(v.pre_release, Some("RC".to_string())); - } - - #[test] - fn test_normalize_pre_release_pl_to_patch() { - let v = Version::parse("1.0.0-pl2").unwrap(); - assert_eq!(v.pre_release, Some("patch2".to_string())); - } - - #[test] - fn test_normalize_pre_release_patch_explicit() { - let v = Version::parse("1.0.0-patch3").unwrap(); - assert_eq!(v.pre_release, Some("patch3".to_string())); - } - - // pre_release_number() — tested via ordering of numbered pre-releases - - #[test] - fn test_pre_release_number_ordering_beta() { - // beta10 > beta2 if pre_release_number extracts correctly - let b10 = Version::parse("1.0.0-beta10").unwrap(); - let b2 = Version::parse("1.0.0-beta2").unwrap(); - assert!(b10 > b2); - } - - #[test] - fn test_pre_release_number_ordering_rc() { - let rc5 = Version::parse("1.0.0-RC5").unwrap(); - let rc1 = Version::parse("1.0.0-RC1").unwrap(); - assert!(rc5 > rc1); - } - - #[test] - fn test_pre_release_number_zero_when_missing() { - // "alpha" with no number → 0; "alpha1" → 1; alpha1 > alpha - let alpha1 = Version::parse("1.0.0-alpha1").unwrap(); - let alpha = Version::parse("1.0.0-alpha").unwrap(); - assert!(alpha1 > alpha); - } - - // ══════════════════════════════════════════════════════════════════════════ - // 6. COMPOSER BEHAVIORAL COMPATIBILITY - // ══════════════════════════════════════════════════════════════════════════ - - #[test] - fn test_composer_caret_four_matches_minor_bump() { - // ^4.0 matches 4.5.3 - assert!(satisfies("^4.0", "4.5.3")); - } - - #[test] - fn test_composer_caret_four_does_not_match_next_major() { - assert!(!satisfies("^4.0", "5.0.0")); - } - - #[test] - fn test_composer_caret_zero_three_matches_patch() { - // ^0.3 matches 0.3.5 (same minor family) - assert!(satisfies("^0.3", "0.3.5")); - } - - #[test] - fn test_composer_caret_zero_three_does_not_match_next_minor() { - // ^0.3 does NOT match 0.4.0 - assert!(!satisfies("^0.3", "0.4.0")); - } - - #[test] - fn test_composer_tilde_four_one_matches_within_major() { - // ~4.1 → >=4.1.0 <5.0.0 — matches 4.9.0 - assert!(satisfies("~4.1", "4.9.0")); - } - - #[test] - fn test_composer_tilde_four_one_does_not_match_next_major() { - // ~4.1 does NOT match 5.0.0 - assert!(!satisfies("~4.1", "5.0.0")); - } - - #[test] - fn test_composer_range_gap_matches_second_range() { - // ">=1.0 <1.1 || >=1.2" — gap at 1.1.x; 1.2.0 matches - assert!(satisfies(">=1.0 <1.1 || >=1.2", "1.2.0")); - } - - #[test] - fn test_composer_range_gap_does_not_match_in_gap() { - // 1.1.5 is in the gap — should NOT match - assert!(!satisfies(">=1.0 <1.1 || >=1.2", "1.1.5")); - } - - #[test] - fn test_composer_laravel_constraint_matches_v10() { - // "^8.0||^9.0||^10.0||^11.0" — Laravel-style; 10.48.22 matches - assert!(satisfies("^8.0||^9.0||^10.0||^11.0", "10.48.22")); - } - - #[test] - fn test_composer_laravel_constraint_does_not_match_v7() { - assert!(!satisfies("^8.0||^9.0||^10.0||^11.0", "7.9.9")); - } - - #[test] - fn test_composer_symfony_range_matches_6_4() { - // ">=5.4 <7.0" — Symfony; 6.4.5 matches - assert!(satisfies(">=5.4 <7.0", "6.4.5")); - } - - #[test] - fn test_composer_symfony_range_does_not_match_7_0() { - assert!(!satisfies(">=5.4 <7.0", "7.0.0")); - } - - #[test] - fn test_composer_not_equal_in_range() { - // ">=1.0 !=1.5.0 <2.0" — typical blacklist constraint - assert!(satisfies(">=1.0 !=1.5.0 <2.0", "1.4.9")); - assert!(!satisfies(">=1.0 !=1.5.0 <2.0", "1.5.0")); - assert!(satisfies(">=1.0 !=1.5.0 <2.0", "1.5.1")); - assert!(!satisfies(">=1.0 !=1.5.0 <2.0", "2.0.0")); - } - - #[test] - fn test_composer_exact_major_minor_match() { - // exact "1.5.0" only matches 1.5.0 - assert!(satisfies("1.5.0", "1.5.0")); - assert!(!satisfies("1.5.0", "1.5.1")); - } - - // ══════════════════════════════════════════════════════════════════════════ - // 7. DIVERGENCE INVESTIGATION - // ══════════════════════════════════════════════════════════════════════════ - - #[test] - fn test_hyphen_range_partial_upper_two_segment() { - // "1.0 - 2": upper is <=2.0.0 (parse "2" → 2.0.0.0, inclusive) - assert!(satisfies("1.0 - 2", "2.0.0")); - assert!(!satisfies("1.0 - 2", "2.0.1")); - assert!(!satisfies("1.0 - 2", "2.1.0")); - } - - #[test] - fn test_caret_with_prerelease_suffix() { - // ^1.2.3-beta1 — the caret parser ignores pre-release in its bounds calculation - // because parse_caret works on the numeric parts only. - // Lower: dev_boundary(1,2,3,0). Upper: dev_boundary(2,0,0,0). - // 1.2.3-beta1 (pre_release=Some("beta1")) is >= lower boundary? - // dev_boundary uses pre_release=Some("dev"), so lower is (1,2,3,0,dev) - // Version 1.2.3-beta1 has same numeric, but beta > dev in stability terms - // so 1.2.3-beta1 >= lower (1.2.3-dev) is true. - assert!(satisfies("^1.2.3-beta1", "1.2.3-beta1")); - assert!(satisfies("^1.2.3-beta1", "1.5.0")); - assert!(!satisfies("^1.2.3-beta1", "2.0.0")); - } - - #[test] - fn test_tilde_with_prerelease_suffix() { - // ~1.2.3-alpha1: lower = dev_boundary(1,2,3,0), upper = dev_boundary(1,3,0,0) - // 1.2.3-alpha1 has numeric (1,2,3,0); pre_release "alpha1" > "dev" - assert!(satisfies("~1.2.3-alpha1", "1.2.3-alpha1")); - assert!(satisfies("~1.2.3-alpha1", "1.2.9")); - assert!(!satisfies("~1.2.3-alpha1", "1.3.0")); - } - - #[test] - fn test_dev_boundary_comparison() { - // Version::dev_boundary creates a version with pre_release=Some("dev") and - // is_dev_branch=false. These should sort correctly against real versions. - let lower = Version::dev_boundary(1, 0, 0, 0); - let v = Version::parse("1.0.0").unwrap(); - // 1.0.0 (stable) > 1.0.0-dev (lower boundary) - assert!(v > lower); - } - - #[test] - fn test_x_dev_ordering_within_range() { - // "2.x-dev" version has patch=9999999, build=9999999 and is a dev branch. - // Dev branches are always lowest. So "2.x-dev" < "2.0.0" < "3.0.0". - let x_dev = Version::parse("2.x-dev").unwrap(); - let stable = Version::parse("2.0.0").unwrap(); - assert!(x_dev < stable); - } - - #[test] - fn test_four_segment_vs_three_segment_constraint() { - // "1.2.3.4" exact constraint — matches only 1.2.3.4, not 1.2.3 - assert!(satisfies("1.2.3.4", "1.2.3.4")); - assert!(!satisfies("1.2.3.4", "1.2.3")); - assert!(!satisfies("1.2.3.4", "1.2.3.5")); - } - - #[test] - fn test_date_style_version_ordering() { - // Date-based versioning: 20230101 > 20220101 - let a = Version::parse("20230101.0.0").unwrap(); - let b = Version::parse("20220101.0.0").unwrap(); - assert!(a > b); - } -} +// This module has been moved to the `mozart-constraint` crate. +// This file is intentionally left empty and the module declaration removed from lib.rs. diff --git a/crates/mozart/src/downloader.rs b/crates/mozart/src/downloader.rs index cfed951..86fd677 100644 --- a/crates/mozart/src/downloader.rs +++ b/crates/mozart/src/downloader.rs @@ -1,4 +1,4 @@ -use crate::cache::Cache; +use mozart_registry::cache::Cache; use sha1::{Digest, Sha1}; use std::collections::HashSet; use std::fs; diff --git a/crates/mozart/src/installed.rs b/crates/mozart/src/installed.rs index 8ed4721..7543b0e 100644 --- a/crates/mozart/src/installed.rs +++ b/crates/mozart/src/installed.rs @@ -1,4 +1,4 @@ -use crate::package::to_json_pretty; +use mozart_core::package::to_json_pretty; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::fs; diff --git a/crates/mozart/src/lib.rs b/crates/mozart/src/lib.rs index 4275833..82b6da3 100644 --- a/crates/mozart/src/lib.rs +++ b/crates/mozart/src/lib.rs @@ -1,19 +1 @@ -pub mod archiver; -pub mod autoload; -pub mod cache; pub mod commands; -pub mod console; -pub mod constraint; -pub mod downloader; -pub mod exit_code; -pub mod installed; -pub mod lockfile; -pub mod package; -pub mod packagist; -pub mod php_scanner; -pub mod platform; -pub mod resolver; -pub mod suggest; -pub mod validation; -pub mod version; -pub mod version_bumper; diff --git a/crates/mozart/src/lockfile.rs b/crates/mozart/src/lockfile.rs index 4742772..3a13778 100644 --- a/crates/mozart/src/lockfile.rs +++ b/crates/mozart/src/lockfile.rs @@ -1,7 +1,7 @@ -use crate::cache::Cache; -use crate::package::{RawPackageData, to_json_pretty}; -use crate::packagist::{self, PackagistDist, PackagistSource, PackagistVersion}; -use crate::resolver::ResolvedPackage; +use mozart_registry::cache::Cache; +use mozart_core::package::{RawPackageData, to_json_pretty}; +use mozart_registry::packagist::{self, PackagistDist, PackagistSource, PackagistVersion}; +use mozart_registry::resolver::ResolvedPackage; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::fs; @@ -613,13 +613,13 @@ mod tests { replace: BTreeMap::new(), provide: BTreeMap::new(), conflict: BTreeMap::new(), - dist: Some(crate::packagist::PackagistDist { + dist: Some(mozart_registry::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: Some(mozart_registry::packagist::PackagistSource { source_type: "git".to_string(), url: "https://github.com/example/pkg.git".to_string(), reference: Some("deadbeef".to_string()), @@ -1012,9 +1012,9 @@ mod tests { #[test] #[ignore] fn test_generate_lock_file_monolog() { - use crate::package::Stability; - use crate::resolver::PlatformConfig; - use crate::resolver::{ResolveRequest, resolve}; + use mozart_core::package::Stability; + use mozart_registry::resolver::PlatformConfig; + use mozart_registry::resolver::{ResolveRequest, resolve}; // Resolve monolog/monolog ^3.0 let resolve_request = ResolveRequest { diff --git a/crates/mozart/src/main.rs b/crates/mozart/src/main.rs index dd85279..59ad392 100644 --- a/crates/mozart/src/main.rs +++ b/crates/mozart/src/main.rs @@ -1,6 +1,6 @@ use clap::Parser; use mozart::commands; -use mozart::exit_code; +use mozart_core::exit_code; fn main() { let cli = commands::Cli::parse(); @@ -11,13 +11,13 @@ fn main() { if let Some(mozart_err) = e.downcast_ref::<exit_code::MozartError>() { // Only print a message when there is one (bail_silent produces empty message). if !mozart_err.message.is_empty() { - eprintln!("{}", mozart::console::error(&mozart_err.message)); + eprintln!("{}", mozart_core::console::error(&mozart_err.message)); } std::process::exit(mozart_err.exit_code); } // Generic anyhow error — print and exit with GENERAL_ERROR. - eprintln!("{}", mozart::console::error(&format!("{e:#}"))); + eprintln!("{}", mozart_core::console::error(&format!("{e:#}"))); std::process::exit(exit_code::GENERAL_ERROR); } } diff --git a/crates/mozart/src/packagist.rs b/crates/mozart/src/packagist.rs index ba80e7e..2503255 100644 --- a/crates/mozart/src/packagist.rs +++ b/crates/mozart/src/packagist.rs @@ -1,4 +1,4 @@ -use crate::cache::Cache; +use mozart_registry::cache::Cache; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; diff --git a/crates/mozart/src/resolver.rs b/crates/mozart/src/resolver.rs index 3243a44..cb4561e 100644 --- a/crates/mozart/src/resolver.rs +++ b/crates/mozart/src/resolver.rs @@ -13,10 +13,10 @@ use pubgrub::{ PackageResolutionStatistics, PubGrubError, Ranges, Reporter, }; -use crate::cache::Cache; -use crate::constraint::{Constraint, VersionConstraint}; -use crate::package::Stability; -use crate::packagist; +use mozart_registry::cache::Cache; +use mozart_constraint::{Constraint, VersionConstraint}; +use mozart_core::package::Stability; +use mozart_registry::packagist; // ───────────────────────────────────────────────────────────────────────────── // Stability constants @@ -372,7 +372,7 @@ fn single_constraint_to_ranges(c: &Constraint) -> Result<ComposerVS, String> { } /// Convert a `constraint::Version` to a `ComposerVersion`. -fn version_to_composer(v: &crate::constraint::Version) -> Result<ComposerVersion, String> { +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!( diff --git a/crates/mozart/src/version.rs b/crates/mozart/src/version.rs index 7520464..d71be2c 100644 --- a/crates/mozart/src/version.rs +++ b/crates/mozart/src/version.rs @@ -1,5 +1,5 @@ -use crate::package::Stability; -use crate::packagist::PackagistVersion; +use mozart_core::package::Stability; +use mozart_registry::packagist::PackagistVersion; use std::cmp::Ordering; /// Determine the stability of a normalized version string. |
