From f2386320d1934f7e52b4cda36d19c86c239423b0 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 22 Feb 2026 12:43:46 +0900 Subject: chore: remove unused files --- crates/mozart/src/archiver.rs | 1430 -------------------------- crates/mozart/src/autoload.rs | 1801 -------------------------------- crates/mozart/src/cache.rs | 492 --------- crates/mozart/src/console.rs | 358 ------- crates/mozart/src/constraint.rs | 2 - crates/mozart/src/downloader.rs | 506 --------- crates/mozart/src/exit_code.rs | 114 --- crates/mozart/src/installed.rs | 229 ----- crates/mozart/src/lockfile.rs | 1088 -------------------- crates/mozart/src/package.rs | 703 ------------- crates/mozart/src/packagist.rs | 629 ------------ crates/mozart/src/php_scanner.rs | 629 ------------ crates/mozart/src/platform.rs | 351 ------- crates/mozart/src/resolver.rs | 1917 ----------------------------------- crates/mozart/src/suggest.rs | 220 ---- crates/mozart/src/validation.rs | 226 ----- crates/mozart/src/version.rs | 267 ----- crates/mozart/src/version_bumper.rs | 667 ------------ 18 files changed, 11629 deletions(-) delete mode 100644 crates/mozart/src/archiver.rs delete mode 100644 crates/mozart/src/autoload.rs delete mode 100644 crates/mozart/src/cache.rs delete mode 100644 crates/mozart/src/console.rs delete mode 100644 crates/mozart/src/constraint.rs delete mode 100644 crates/mozart/src/downloader.rs delete mode 100644 crates/mozart/src/exit_code.rs delete mode 100644 crates/mozart/src/installed.rs delete mode 100644 crates/mozart/src/lockfile.rs delete mode 100644 crates/mozart/src/package.rs delete mode 100644 crates/mozart/src/packagist.rs delete mode 100644 crates/mozart/src/php_scanner.rs delete mode 100644 crates/mozart/src/platform.rs delete mode 100644 crates/mozart/src/resolver.rs delete mode 100644 crates/mozart/src/suggest.rs delete mode 100644 crates/mozart/src/validation.rs delete mode 100644 crates/mozart/src/version.rs delete mode 100644 crates/mozart/src/version_bumper.rs (limited to 'crates/mozart/src') diff --git a/crates/mozart/src/archiver.rs b/crates/mozart/src/archiver.rs deleted file mode 100644 index d351e44..0000000 --- a/crates/mozart/src/archiver.rs +++ /dev/null @@ -1,1430 +0,0 @@ -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 = 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 { - 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: `^/` - // - no `/` inside pattern → matches anywhere: `/` - // - `/` somewhere in middle → anchored at root: `^/` - 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: `(/|$)` - // 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 { - 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 { - 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> { - 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, -) -> 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 { - 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 = 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::>() - .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 -/// `-*.`. Otherwise it's `.`. -pub fn self_exclusion_patterns(base_name: &str, has_extra_parts: bool) -> Vec { - 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" = 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" = 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" = 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" = 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" = (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 = 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 = 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 = 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())); - } - - // ── Integration tests ───────────────────────────────────────────────────── - - #[test] - fn test_archive_root_package_tar() { - use crate::commands::Cli; - use crate::commands::archive::{ArchiveArgs, execute}; - - let src = tempdir().unwrap(); - let out = tempdir().unwrap(); - - std::fs::write( - src.path().join("composer.json"), - r#"{"name": "test/project", "require": {}}"#, - ) - .unwrap(); - std::fs::write(src.path().join("main.php"), b" = 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(&"composer.json".to_string())); - } - - #[test] - fn test_archive_root_package_zip() { - use crate::commands::Cli; - use crate::commands::archive::{ArchiveArgs, execute}; - - let src = tempdir().unwrap(); - let out = tempdir().unwrap(); - - std::fs::write( - src.path().join("composer.json"), - r#"{"name": "test/project", "require": {}}"#, - ) - .unwrap(); - std::fs::write(src.path().join("main.php"), b" = 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.iter().any(|n| n.starts_with("tests"))); - } - - #[test] - fn test_archive_composer_excludes() { - use crate::commands::Cli; - use crate::commands::archive::{ArchiveArgs, execute}; - - let src = tempdir().unwrap(); - let out = tempdir().unwrap(); - - std::fs::write( - src.path().join("composer.json"), - r#"{"name": "test/project", "require": {}, "archive": {"exclude": ["/docs"]}}"#, - ) - .unwrap(); - std::fs::write(src.path().join("main.php"), b" = 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.iter().any(|n| n.starts_with("docs"))); - } - - #[test] - fn test_archive_ignore_filters() { - use crate::commands::Cli; - use crate::commands::archive::{ArchiveArgs, execute}; - - let src = tempdir().unwrap(); - let out = tempdir().unwrap(); - - std::fs::write( - src.path().join("composer.json"), - r#"{"name": "test/project", "require": {}}"#, - ) - .unwrap(); - std::fs::write(src.path().join("main.php"), b" = archive - .entries() - .unwrap() - .filter_map(|e| e.ok()) - .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string())) - .collect(); - // With --ignore-filters, tests/ should be included (VCS is still skipped) - assert!(names.iter().any(|n| n.starts_with("tests"))); - } - - #[test] - fn test_archive_invalid_format() { - use crate::commands::Cli; - use crate::commands::archive::{ArchiveArgs, execute}; - - let src = tempdir().unwrap(); - let out = tempdir().unwrap(); - - std::fs::write( - src.path().join("composer.json"), - r#"{"name": "test/project", "require": {}}"#, - ) - .unwrap(); - - let args = ArchiveArgs { - package: None, - version: None, - format: Some("rar".to_string()), - dir: Some(out.path().to_string_lossy().to_string()), - file: None, - ignore_filters: false, - }; - - let cli = Cli { - command: crate::commands::Commands::Archive(ArchiveArgs { - package: None, - version: None, - format: Some("rar".to_string()), - dir: Some(out.path().to_string_lossy().to_string()), - file: None, - ignore_filters: false, - }), - verbose: 0, - profile: false, - no_plugins: false, - no_scripts: false, - working_dir: Some(src.path().to_string_lossy().to_string()), - no_cache: false, - no_interaction: false, - quiet: false, - ansi: false, - no_ansi: false, - }; - - let console = mozart_core::console::Console { - interactive: false, - verbosity: mozart_core::console::Verbosity::Normal, - decorated: false, - }; - let result = execute(&args, &cli, &console); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("rar")); - } -} diff --git a/crates/mozart/src/autoload.rs b/crates/mozart/src/autoload.rs deleted file mode 100644 index 2e4158c..0000000 --- a/crates/mozart/src/autoload.rs +++ /dev/null @@ -1,1801 +0,0 @@ -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, - /// 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>, - /// PSR-0: namespace prefix -> list of directory path expressions. - /// (Empty in Phase 2.2, populated in 5.6.) - pub psr0: BTreeMap>, - /// Classmap entries: class name -> file path expression. - /// (Empty in Phase 2.2, populated in 5.6.) - pub classmap: BTreeMap, - /// Files to include on every request: file_identifier -> path expression. - pub files: BTreeMap, -} - -/// 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 { - 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(")> = 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(")> = 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(" $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 { - if data.files.is_empty() { - return None; - } - - let mut out = String::new(); - out.push_str(" {},\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(" {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> = 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 = 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)> = 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 { - 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, -) { - 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` 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 { - 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>, - psr0: &BTreeMap>, - vendor_dir: &Path, - project_dir: &Path, - excluded: &[String], -) -> ( - BTreeMap, - BTreeMap, - Vec, -) { - let mut dyn_map: BTreeMap = BTreeMap::new(); - let mut static_map: BTreeMap = BTreeMap::new(); - let mut violations: Vec = Vec::new(); - - // Helper: resolve a PHP path expression to an absolute path. - let resolve = |expr: &str| -> Option { - // 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, -) -> Option { - if matches!(mode, PlatformCheckMode::Disabled) { - return None; - } - - // Collect PHP version constraint from root require - let mut php_constraint: Option = 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("= 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("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(" String { - let dev_str = if dev_mode { "true" } else { "false" }; - - let mut out = String::new(); - out.push_str(" 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 { - // 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 = 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 = 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 = 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 = 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 = 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 = { - // 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(" 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/src/cache.rs b/crates/mozart/src/cache.rs deleted file mode 100644 index ac4b507..0000000 --- a/crates/mozart/src/cache.rs +++ /dev/null @@ -1,492 +0,0 @@ -//! 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 { - 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> { - 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 { - 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/src/console.rs b/crates/mozart/src/console.rs deleted file mode 100644 index e37ff23..0000000 --- a/crates/mozart/src/console.rs +++ /dev/null @@ -1,358 +0,0 @@ -use colored::{ColoredString, Colorize}; -use dialoguer::{Confirm, Input}; -use std::io::IsTerminal; - -// --------------------------------------------------------------------------- -// Tag-style color helpers (module-level free functions, unchanged API) -// --------------------------------------------------------------------------- - -/// `` — green foreground -pub fn info(message: &str) -> ColoredString { - message.green() -} - -/// `` — yellow foreground -pub fn comment(message: &str) -> ColoredString { - message.yellow() -} - -/// `` — white on red -pub fn error(message: &str) -> ColoredString { - message.white().on_red() -} - -/// `` — black on cyan -pub fn question(message: &str) -> ColoredString { - message.black().on_cyan() -} - -/// `` — red foreground (Composer extension) -pub fn highlight(message: &str) -> ColoredString { - message.red() -} - -/// `` — 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( - &self, - prompt: &str, - default: &str, - validator: F, - ) -> Result - 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/src/constraint.rs b/crates/mozart/src/constraint.rs deleted file mode 100644 index 32dc84e..0000000 --- a/crates/mozart/src/constraint.rs +++ /dev/null @@ -1,2 +0,0 @@ -// 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 deleted file mode 100644 index 86fd677..0000000 --- a/crates/mozart/src/downloader.rs +++ /dev/null @@ -1,506 +0,0 @@ -use mozart_registry::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) -> 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> { - // 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 { - if entries.is_empty() { - return None; - } - - let mut prefixes: HashSet = 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 = (0..archive.len()) - .map(|i| archive.by_index(i).map(|e| e.name().to_string())) - .collect::>()?; - - 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 = 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 { - 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 { - 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") -> 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) -> 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::().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::().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 = Box::new(MozartError { - message: "test".to_string(), - exit_code: 1, - }); - assert_eq!(err.to_string(), "test"); - } -} diff --git a/crates/mozart/src/installed.rs b/crates/mozart/src/installed.rs deleted file mode 100644 index 7543b0e..0000000 --- a/crates/mozart/src/installed.rs +++ /dev/null @@ -1,229 +0,0 @@ -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, - - #[serde(rename = "dev-package-names", default)] - pub dev_package_names: Vec, - - #[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, - - #[serde(skip_serializing_if = "Option::is_none")] - pub source: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub dist: Option, - - #[serde(rename = "type", skip_serializing_if = "Option::is_none")] - pub package_type: Option, - - #[serde(rename = "install-path", skip_serializing_if = "Option::is_none")] - pub install_path: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub autoload: Option, - - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub aliases: Vec, - - #[serde(flatten)] - pub extra_fields: BTreeMap, -} - -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 { - 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/src/lockfile.rs b/crates/mozart/src/lockfile.rs deleted file mode 100644 index 3a13778..0000000 --- a/crates/mozart/src/lockfile.rs +++ /dev/null @@ -1,1088 +0,0 @@ -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; -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, - - #[serde(rename = "content-hash")] - pub content_hash: String, - - pub packages: Vec, - - #[serde(rename = "packages-dev")] - pub packages_dev: Option>, - - #[serde(default)] - pub aliases: Vec, - - #[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, -} - -/// 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, - - #[serde(skip_serializing_if = "Option::is_none")] - pub source: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub dist: Option, - - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub require: BTreeMap, - - #[serde( - rename = "require-dev", - default, - skip_serializing_if = "BTreeMap::is_empty" - )] - pub require_dev: BTreeMap, - - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub conflict: BTreeMap, - - #[serde(skip_serializing_if = "Option::is_none")] - pub suggest: Option>, - - #[serde(rename = "type", skip_serializing_if = "Option::is_none")] - pub package_type: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub autoload: Option, - - #[serde(rename = "autoload-dev", skip_serializing_if = "Option::is_none")] - pub autoload_dev: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub license: Option>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub homepage: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub keywords: Option>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub authors: Option>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub support: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub funding: Option>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub time: Option, - - /// Catch-all for extra fields we don't explicitly model - #[serde(flatten)] - pub extra_fields: BTreeMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LockedSource { - #[serde(rename = "type")] - pub source_type: String, - pub url: String, - pub reference: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LockedDist { - #[serde(rename = "type")] - pub dist_type: String, - pub url: String, - pub reference: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub shasum: Option, -} - -#[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 { - 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 { - 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 { - 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, - /// 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, -} - -/// 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 = 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, - _require_dev: &BTreeMap, - package_metadata: &HashMap, -) -> HashSet { - // 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 = HashSet::new(); - let mut queue: VecDeque = 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) -> serde_json::Value { - let map: serde_json::Map = 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 { - // 1. Fetch full metadata for all resolved packages - let mut package_metadata: HashMap = 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 = Vec::new(); - let mut packages_dev: Vec = 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, - ) -> PackagistVersion { - PackagistVersion { - version: version.to_string(), - version_normalized: version_normalized.to_string(), - require, - replace: BTreeMap::new(), - provide: BTreeMap::new(), - conflict: BTreeMap::new(), - 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(mozart_registry::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 = 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 = 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 mozart_core::package::Stability; - use mozart_registry::resolver::PlatformConfig; - use mozart_registry::resolver::{ResolveRequest, resolve}; - - // 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/src/package.rs b/crates/mozart/src/package.rs deleted file mode 100644 index 9904dc4..0000000 --- a/crates/mozart/src/package.rs +++ /dev/null @@ -1,703 +0,0 @@ -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, - pub description: String, -} - -/// Package author metadata. -#[derive(Debug, Clone)] -pub struct Author { - pub name: Option, - pub email: Option, - pub homepage: Option, - pub role: Option, -} - -/// Autoload rule sets (PSR-4, PSR-0, classmap, files). -#[derive(Debug, Clone, Default)] -pub struct AutoloadRules { - pub psr4: BTreeMap>, - pub psr0: BTreeMap>, - pub classmap: Vec, - pub files: Vec, -} - -/// Support channel information. -#[derive(Debug, Clone, Default)] -pub struct Support { - pub email: Option, - pub issues: Option, - pub forum: Option, - pub wiki: Option, - pub source: Option, - pub docs: Option, - pub irc: Option, - pub chat: Option, - pub rss: Option, - pub security: Option, -} - -/// Funding link. -#[derive(Debug, Clone)] -pub struct Funding { - pub url: Option, - pub funding_type: Option, -} - -/// 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, - - // source - pub source_type: Option, - pub source_url: Option, - pub source_reference: Option, - - // dist - pub dist_type: Option, - pub dist_url: Option, - pub dist_reference: Option, - pub dist_sha1_checksum: Option, - - pub release_date: Option, - pub extra: BTreeMap, - pub binaries: Vec, - pub dev: bool, - pub stability: Stability, - pub notification_url: Option, - - // dependency links - pub requires: BTreeMap, - pub conflicts: BTreeMap, - pub provides: BTreeMap, - pub replaces: BTreeMap, - pub dev_requires: BTreeMap, - pub suggests: BTreeMap, - - // 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, - pub homepage: Option, - pub license: Vec, - pub keywords: Vec, - pub authors: Vec, - pub scripts: BTreeMap>, - pub support: Support, - pub funding: Vec, - pub repositories: Vec, - /// `None` = not abandoned, `Some("")` = abandoned, `Some(pkg)` = replaced by pkg. - pub abandoned: Option, - pub archive_name: Option, - pub archive_excludes: Vec, -} - -/// 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, - pub config: BTreeMap, - pub references: BTreeMap, - pub aliases: Vec, -} - -/// 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; - fn binaries(&self) -> &[String]; - fn is_dev(&self) -> bool; - fn stability(&self) -> Stability; - fn notification_url(&self) -> Option<&str>; - fn requires(&self) -> &BTreeMap; - fn conflicts(&self) -> &BTreeMap; - fn provides(&self) -> &BTreeMap; - fn replaces(&self) -> &BTreeMap; - fn dev_requires(&self) -> &BTreeMap; - fn suggests(&self) -> &BTreeMap; - 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>; - 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; - fn config(&self) -> &BTreeMap; - fn references(&self) -> &BTreeMap; - 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 { &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 { &self.$($path).+.requires } - fn conflicts(&self) -> &BTreeMap { &self.$($path).+.conflicts } - fn provides(&self) -> &BTreeMap { &self.$($path).+.provides } - fn replaces(&self) -> &BTreeMap { &self.$($path).+.replaces } - fn dev_requires(&self) -> &BTreeMap { &self.$($path).+.dev_requires } - fn suggests(&self) -> &BTreeMap { &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> { &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 { - &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 { - &self.requires - } - fn conflicts(&self) -> &BTreeMap { - &self.conflicts - } - fn provides(&self) -> &BTreeMap { - &self.provides - } - fn replaces(&self) -> &BTreeMap { - &self.replaces - } - fn dev_requires(&self) -> &BTreeMap { - &self.dev_requires - } - fn suggests(&self) -> &BTreeMap { - &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> { - &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 { - &self.stability_flags - } - fn config(&self) -> &BTreeMap { - &self.config - } - fn references(&self) -> &BTreeMap { - &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, - - #[serde(rename = "type", skip_serializing_if = "Option::is_none")] - pub package_type: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub homepage: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub license: Option, - - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub authors: Vec, - - #[serde(rename = "minimum-stability", skip_serializing_if = "Option::is_none")] - pub minimum_stability: Option, - - #[serde(default)] - pub require: BTreeMap, - - #[serde( - rename = "require-dev", - default, - skip_serializing_if = "BTreeMap::is_empty" - )] - pub require_dev: BTreeMap, - - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub repositories: Vec, - - #[serde(skip_serializing_if = "Option::is_none")] - pub autoload: Option, - - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub bin: Vec, - - #[serde(flatten)] - pub extra_fields: BTreeMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RawAuthor { - pub name: String, - - #[serde(skip_serializing_if = "Option::is_none")] - pub email: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RawAutoload { - #[serde(rename = "psr-4")] - pub psr4: BTreeMap, -} - -#[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 { - 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 { - 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/src/packagist.rs b/crates/mozart/src/packagist.rs deleted file mode 100644 index 2503255..0000000 --- a/crates/mozart/src/packagist.rs +++ /dev/null @@ -1,629 +0,0 @@ -use mozart_registry::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, - pub shasum: Option, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct PackagistSource { - #[serde(rename = "type")] - pub source_type: String, - pub url: String, - pub reference: Option, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct PackagistVersion { - pub version: String, - pub version_normalized: String, - #[serde(default)] - pub require: BTreeMap, - #[serde(default)] - pub replace: BTreeMap, - #[serde(default)] - pub provide: BTreeMap, - #[serde(default)] - pub conflict: BTreeMap, - pub dist: Option, - pub source: Option, - - #[serde(rename = "require-dev", default)] - pub require_dev: BTreeMap, - - #[serde(default)] - pub suggest: Option>, - - #[serde(rename = "type")] - pub package_type: Option, - - pub autoload: Option, - - #[serde(rename = "autoload-dev")] - pub autoload_dev: Option, - - pub license: Option>, - - pub description: Option, - - pub homepage: Option, - - pub keywords: Option>, - - pub authors: Option>, - - pub support: Option, - - pub funding: Option>, - - pub time: Option, - - pub extra: Option, - - #[serde(rename = "notification-url")] - pub notification_url: Option, -} - -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 { - 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> { - #[derive(Deserialize)] - struct P2Response { - packages: BTreeMap>, - } - - 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> { - // 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, - pub downloads: u64, - pub favers: u64, -} - -#[derive(Debug, Deserialize)] -pub struct SearchResponse { - pub results: Vec, - pub total: u64, - pub next: Option, -} - -/// 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, u64)> { - let client = reqwest::blocking::Client::builder() - .user_agent("mozart/0.1.0") - .build()?; - - let mut all_results: Vec = Vec::new(); - let mut page = 1usize; - let mut next_url: Option = 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, - - pub cve: Option, - - /// 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, - - pub severity: Option, - - #[serde(default)] - pub sources: Vec, -} - -/// 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>, -} - -/// 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>> { - let client = reqwest::blocking::Client::builder() - .user_agent("mozart/0.1.0") - .build()?; - - let mut all_advisories: BTreeMap> = BTreeMap::new(); - - for chunk in package_names.chunks(500) { - // Build an application/x-www-form-urlencoded body manually. - // Each package is encoded as `packages[]=` and joined with `&`. - let body: String = chunk - .iter() - .map(|name| format!("packages[]={}", url_encode(name))) - .collect::>() - .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/src/php_scanner.rs b/crates/mozart/src/php_scanner.rs deleted file mode 100644 index 3d0d51d..0000000 --- a/crates/mozart/src/php_scanner.rs +++ /dev/null @@ -1,629 +0,0 @@ -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> { - 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 = 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 { - 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+)* - (?Pclass|interface|trait|enum)\s+ - (?P[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 = 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(" 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 { - 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 { - let mut packages: Vec = 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 = 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 { - 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 { - 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/src/resolver.rs b/crates/mozart/src/resolver.rs deleted file mode 100644 index cb4561e..0000000 --- a/crates/mozart/src/resolver.rs +++ /dev/null @@ -1,1917 +0,0 @@ -//! Dependency resolver using the pubgrub v0.3.0 algorithm. -//! -//! This module converts Composer-style dependency constraints into pubgrub's `Ranges` -//! 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 mozart_registry::cache::Cache; -use mozart_constraint::{Constraint, VersionConstraint}; -use mozart_core::package::Stability; -use mozart_registry::packagist; - -// ───────────────────────────────────────────────────────────────────────────── -// 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 { - 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 { - 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 { - 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; - -// ───────────────────────────────────────────────────────────────────────────── -// Constraint-to-Ranges conversion -// ───────────────────────────────────────────────────────────────────────────── - -/// Convert a Composer version constraint string to a pubgrub `Ranges`. -/// -/// Supports: exact, >=, >, <=, <, !=, ^, ~, *, wildcards, hyphen ranges, AND, OR. -pub fn constraint_to_ranges(constraint: &str) -> Result { - 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 { - 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 { - 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 { - // 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) -> 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, -} - -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 { - 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, -} - -// ───────────────────────────────────────────────────────────────────────────── -// Provider internals -// ───────────────────────────────────────────────────────────────────────────── - -/// Cached version data for a single package. -struct PackageVersions { - /// All versions that pass the stability filter, sorted by ComposerVersion. - versions: BTreeMap, -} - -/// 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>, - - /// Optional on-disk repo cache for Packagist API responses. - repo_cache: Option, - - /// Platform packages (php, ext-*, lib-*) with their fixed versions. - platform_packages: HashMap, - - /// Minimum stability threshold. Versions below this are excluded. - minimum_stability: Stability, - - /// Per-package stability overrides from composer.json. - stability_flags: HashMap, - - /// 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, -} - -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, 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, 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, - /// 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, - /// Optional on-disk repo cache for Packagist API responses. - pub repo_cache: Option, -} - -/// 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, 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, 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; - - 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::::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::::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::::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::::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/src/suggest.rs b/crates/mozart/src/suggest.rs deleted file mode 100644 index d80ff3c..0000000 --- a/crates/mozart/src/suggest.rs +++ /dev/null @@ -1,220 +0,0 @@ -//! 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 = a.chars().collect(); - let b: Vec = 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 = (0..=n).collect(); - let mut curr: Vec = 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, -) -> 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::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 { - if suggestions.is_empty() { - return None; - } - - let formatted = suggestions - .iter() - .map(|s| format!("\"{}\"", s)) - .collect::>() - .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/src/validation.rs b/crates/mozart/src/validation.rs deleted file mode 100644 index 7f946ae..0000000 --- a/crates/mozart/src/validation.rs +++ /dev/null @@ -1,226 +0,0 @@ -use regex::Regex; -use std::sync::LazyLock; - -static PACKAGE_NAME_RE: LazyLock = LazyLock::new(|| { - Regex::new(r"^[a-z0-9]([_.\-]?[a-z0-9]+)*/[a-z0-9](([_.]|\-{1,2})?[a-z0-9]+)*$").unwrap() -}); - -static AUTHOR_RE: LazyLock = LazyLock::new(|| { - Regex::new(r"^(?P[- .,\pL\pN\pM''\x{201C}\x{201D}()]+)(?:\s+<(?P.+?)>)?$").unwrap() -}); - -static AUTOLOAD_PATH_RE: LazyLock = - LazyLock::new(|| Regex::new(r"^[^/][A-Za-z0-9\-_/]+/$").unwrap()); - -static CAMEL_SPLIT_RE: LazyLock = - LazyLock::new(|| Regex::new(r"(?:([a-z])([A-Z])|([A-Z])([A-Z][a-z]))").unwrap()); - -static SANITIZE_EDGES_RE: LazyLock = - LazyLock::new(|| Regex::new(r"^[_.\-]+|[_.\-]+$|[^a-z0-9_.\-]").unwrap()); - -static SANITIZE_REPEATS_RE: LazyLock = - LazyLock::new(|| Regex::new(r"([_.\-]){2,}").unwrap()); - -static NON_ALNUM_RE: LazyLock = 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, -} - -pub fn parse_author(input: &str) -> Result { - 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 " - .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 { - if package_name.is_empty() || !package_name.contains('/') { - return None; - } - - let parts: Vec = package_name - .split('/') - .map(|part| { - let replaced = NON_ALNUM_RE.replace_all(part, " "); - let words: Vec = replaced - .split_whitespace() - .map(|w| { - let mut chars = w.chars(); - match chars.next() { - Some(c) => c.to_uppercase().to_string() + &chars.collect::(), - 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 ").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/src/version.rs b/crates/mozart/src/version.rs deleted file mode 100644 index d71be2c..0000000 --- a/crates/mozart/src/version.rs +++ /dev/null @@ -1,267 +0,0 @@ -use mozart_core::package::Stability; -use mozart_registry::packagist::PackagistVersion; -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, Option) { - // 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 = 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/src/version_bumper.rs b/crates/mozart/src/version_bumper.rs deleted file mode 100644 index 43c21d6..0000000 --- a/crates/mozart/src/version_bumper.rs +++ /dev/null @@ -1,667 +0,0 @@ -/// 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 { - 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 { - 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 = 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 { - // 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 { - 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 = constraint_segments - .iter() - .copied() - .chain(std::iter::repeat(0)) - .take(4) - .collect(); - let installed: Vec = 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 = 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::>() - .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 { - let constraint_segments = parse_version_segments(rest); - let installed_segments = parse_version_segments(installed_version); - - let current_lower: Vec = constraint_segments - .iter() - .copied() - .chain(std::iter::repeat(0)) - .take(4) - .collect(); - let installed: Vec = 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 { - 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::>() - .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 = base_segs - .iter() - .copied() - .chain(std::iter::repeat(0)) - .take(4) - .collect(); - let installed: Vec = 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 { - let constraint_segments = parse_version_segments(rest); - let installed_segments = parse_version_segments(installed_version); - - let current_lower: Vec = constraint_segments - .iter() - .copied() - .chain(std::iter::repeat(0)) - .take(4) - .collect(); - let installed: Vec = 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::>() - .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 { - // 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::().ok()) - .collect() -} - -/// Parse the major version number from a version string. -fn parse_major(version: &str) -> Option { - 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 { - 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]); - } -} -- cgit v1.3.1