aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-22 12:43:46 +0900
committernsfisis <nsfisis@gmail.com>2026-02-22 12:43:46 +0900
commitf2386320d1934f7e52b4cda36d19c86c239423b0 (patch)
tree68d55589be4be2f6bc423c02fa87778fcbf3740a
parent1ef1ebdcf50ae1358ec06e3c6a2fb797a8461617 (diff)
downloadphp-mozart-f2386320d1934f7e52b4cda36d19c86c239423b0.tar.gz
php-mozart-f2386320d1934f7e52b4cda36d19c86c239423b0.tar.zst
php-mozart-f2386320d1934f7e52b4cda36d19c86c239423b0.zip
chore: remove unused files
-rw-r--r--crates/mozart/src/archiver.rs1430
-rw-r--r--crates/mozart/src/autoload.rs1801
-rw-r--r--crates/mozart/src/cache.rs492
-rw-r--r--crates/mozart/src/console.rs358
-rw-r--r--crates/mozart/src/constraint.rs2
-rw-r--r--crates/mozart/src/downloader.rs506
-rw-r--r--crates/mozart/src/exit_code.rs114
-rw-r--r--crates/mozart/src/installed.rs229
-rw-r--r--crates/mozart/src/lockfile.rs1088
-rw-r--r--crates/mozart/src/package.rs703
-rw-r--r--crates/mozart/src/packagist.rs629
-rw-r--r--crates/mozart/src/php_scanner.rs629
-rw-r--r--crates/mozart/src/platform.rs351
-rw-r--r--crates/mozart/src/resolver.rs1917
-rw-r--r--crates/mozart/src/suggest.rs220
-rw-r--r--crates/mozart/src/validation.rs226
-rw-r--r--crates/mozart/src/version.rs267
-rw-r--r--crates/mozart/src/version_bumper.rs667
18 files changed, 0 insertions, 11629 deletions
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<char> = glob.chars().collect();
- let mut i = 0;
- while i < chars.len() {
- match chars[i] {
- '*' if i + 1 < chars.len() && chars[i + 1] == '*' => {
- result.push_str(".*");
- i += 2;
- }
- '*' => {
- result.push_str("[^/]*");
- i += 1;
- }
- '?' => {
- result.push_str("[^/]");
- i += 1;
- }
- '[' => {
- // Pass character classes through as-is until the closing `]`
- result.push('[');
- i += 1;
- while i < chars.len() && chars[i] != ']' {
- result.push(chars[i]);
- i += 1;
- }
- if i < chars.len() {
- result.push(']');
- i += 1;
- }
- }
- c => {
- // Regex-escape special characters
- if r"\.+^$|{}()?".contains(c) {
- result.push('\\');
- }
- result.push(c);
- i += 1;
- }
- }
- }
- result
-}
-
-/// Convert a single gitignore-style rule into an `ExcludePattern`.
-///
-/// Returns `None` if the rule is empty or a comment.
-pub fn parse_gitignore_pattern(rule: &str) -> Option<ExcludePattern> {
- let rule = rule.trim();
- if rule.is_empty() || rule.starts_with('#') {
- return None;
- }
-
- // Leading `!` negates the pattern
- let (negate, rule) = if let Some(rest) = rule.strip_prefix('!') {
- (true, rest)
- } else {
- (false, rule)
- };
-
- // Strip trailing `/` before globbing
- let rule = rule.trim_end_matches('/');
- if rule.is_empty() {
- return None;
- }
-
- // Determine anchor prefix:
- // - leading `/` → anchored at root: `^/<glob_regex>`
- // - no `/` inside pattern → matches anywhere: `/<glob_regex>`
- // - `/` somewhere in middle → anchored at root: `^/<glob_regex>`
- let (prefix, glob) = if let Some(without_leading_slash) = rule.strip_prefix('/') {
- // Root-anchored
- ("^/", without_leading_slash)
- } else if rule.contains('/') {
- // Slash in middle: treat as root-anchored
- ("^/", rule)
- } else {
- // No slash: matches anywhere
- ("/", rule)
- };
-
- let glob_regex = glob_to_regex(glob);
- // The final regex: `<prefix><glob_regex>(/|$)`
- // This matches the path component exactly (followed by a `/` or end-of-string).
- let pattern = format!("{prefix}{glob_regex}(/|$)");
- let regex = Regex::new(&pattern).ok()?;
-
- Some(ExcludePattern { regex, negate })
-}
-
-/// Apply a chain of exclude patterns to a relative path (as a `/`-prefixed string).
-///
-/// Patterns are applied in order; later patterns override earlier ones.
-/// Returns `true` if the file is excluded by the final matching pattern
-/// (or by `initially_excluded` if no pattern matches).
-fn apply_filters(
- path_with_slash: &str,
- patterns: &[ExcludePattern],
- initially_excluded: bool,
-) -> bool {
- let mut excluded = initially_excluded;
- for pat in patterns {
- if pat.regex.is_match(path_with_slash) {
- // A negate pattern re-includes; a normal pattern excludes
- excluded = !pat.negate;
- }
- }
- excluded
-}
-
-// ─── GitExcludeFilter ─────────────────────────────────────────────────────────
-
-/// Parse `.gitattributes` from the source directory.
-///
-/// Returns exclude patterns for lines containing `export-ignore` or
-/// `-export-ignore`.
-pub fn parse_gitattributes(source_dir: &Path) -> Vec<ExcludePattern> {
- let path = source_dir.join(".gitattributes");
- let content = match fs::read_to_string(&path) {
- Ok(c) => c,
- Err(_) => return vec![],
- };
-
- let mut patterns = Vec::new();
- for line in content.lines() {
- let line = line.trim();
- if line.is_empty() || line.starts_with('#') {
- continue;
- }
- let parts: Vec<&str> = line.split_whitespace().collect();
- if parts.len() < 2 {
- continue;
- }
- let file_pattern = parts[0];
- // Check each attribute token for export-ignore / -export-ignore
- for attr in &parts[1..] {
- if *attr == "export-ignore" {
- if let Some(p) = parse_gitignore_pattern(file_pattern) {
- patterns.push(p);
- }
- } else if *attr == "-export-ignore" {
- // Negation: re-include files that would otherwise be excluded
- let negated = format!("!{}", file_pattern);
- if let Some(p) = parse_gitignore_pattern(&negated) {
- patterns.push(p);
- }
- }
- }
- }
- patterns
-}
-
-// ─── ComposerExcludeFilter ────────────────────────────────────────────────────
-
-/// Convert `composer.json` `archive.exclude` rules into exclude patterns.
-pub fn parse_composer_excludes(excludes: &[String]) -> Vec<ExcludePattern> {
- excludes
- .iter()
- .filter_map(|rule| parse_gitignore_pattern(rule))
- .collect()
-}
-
-// ─── VCS directory names ──────────────────────────────────────────────────────
-
-const VCS_DIRS: &[&str] = &[".git", ".svn", ".hg", "CVS", ".bzr"];
-
-// ─── File collection ──────────────────────────────────────────────────────────
-
-/// Collect all archivable files from the source directory.
-///
-/// Returns paths relative to `source_dir`, sorted for deterministic output.
-/// Applies `exclude_patterns` to filter files. VCS directories are always
-/// skipped. Symlinks pointing outside `source_dir` are excluded.
-pub fn collect_archivable_files(
- source_dir: &Path,
- exclude_patterns: &[ExcludePattern],
-) -> anyhow::Result<Vec<PathBuf>> {
- let source_dir = source_dir
- .canonicalize()
- .unwrap_or_else(|_| source_dir.to_path_buf());
- let mut files = Vec::new();
- collect_recursive(&source_dir, &source_dir, exclude_patterns, &mut files)?;
- files.sort();
- Ok(files)
-}
-
-fn collect_recursive(
- source_dir: &Path,
- current_dir: &Path,
- exclude_patterns: &[ExcludePattern],
- out: &mut Vec<PathBuf>,
-) -> anyhow::Result<()> {
- let entries = fs::read_dir(current_dir)
- .with_context(|| format!("Failed to read directory: {}", current_dir.display()))?;
-
- let mut items: Vec<_> = entries.filter_map(|e| e.ok()).collect();
- // Sort for determinism
- items.sort_by_key(|e| e.file_name());
-
- for entry in items {
- let path = entry.path();
- let file_name = entry.file_name();
- let name_str = file_name.to_string_lossy();
-
- // Skip VCS directories
- if VCS_DIRS.contains(&name_str.as_ref()) {
- continue;
- }
-
- // Compute the relative path (forward-slash, prefixed with `/` for filter matching)
- let relative = path
- .strip_prefix(source_dir)
- .unwrap_or(&path)
- .to_string_lossy()
- .replace('\\', "/");
- let path_with_slash = format!("/{}", relative);
-
- // Check if this entry is excluded
- if apply_filters(&path_with_slash, exclude_patterns, false) {
- continue;
- }
-
- let metadata = match entry.metadata() {
- Ok(m) => m,
- Err(_) => continue,
- };
-
- if metadata.is_symlink() {
- // Resolve the symlink; skip if it points outside source_dir
- if let Ok(resolved) = fs::canonicalize(&path) {
- if !resolved.starts_with(source_dir) {
- continue;
- }
- out.push(PathBuf::from(&relative));
- }
- // If canonicalize fails, skip the symlink
- } else if metadata.is_dir() {
- // Collect children recursively
- let mut children = Vec::new();
- collect_recursive(source_dir, &path, exclude_patterns, &mut children)?;
- if children.is_empty() {
- // Include empty directory
- out.push(PathBuf::from(&relative));
- } else {
- out.extend(children);
- }
- } else {
- out.push(PathBuf::from(&relative));
- }
- }
-
- Ok(())
-}
-
-// ─── Archive formats ──────────────────────────────────────────────────────────
-
-/// Supported archive formats.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum ArchiveFormat {
- Zip,
- Tar,
- TarGz,
- TarBz2,
-}
-
-impl ArchiveFormat {
- /// Parse a format string (case-insensitive). Returns `None` for unsupported formats.
- pub fn parse(s: &str) -> Option<Self> {
- match s.to_lowercase().as_str() {
- "zip" => Some(Self::Zip),
- "tar" => Some(Self::Tar),
- "tar.gz" | "tgz" => Some(Self::TarGz),
- "tar.bz2" => Some(Self::TarBz2),
- _ => None,
- }
- }
-
- /// File extension for this format.
- pub fn extension(&self) -> &str {
- match self {
- Self::Zip => "zip",
- Self::Tar => "tar",
- Self::TarGz => "tar.gz",
- Self::TarBz2 => "tar.bz2",
- }
- }
-}
-
-// ─── Archive creation ─────────────────────────────────────────────────────────
-
-/// Create an archive of the given files.
-///
-/// - `source_dir`: the root of the source tree
-/// - `files`: relative paths (as returned by `collect_archivable_files`)
-/// - `target`: full output path including extension
-/// - `format`: the archive format to create
-pub fn create_archive(
- source_dir: &Path,
- files: &[PathBuf],
- target: &Path,
- format: &ArchiveFormat,
-) -> anyhow::Result<()> {
- match format {
- ArchiveFormat::Zip => create_zip(source_dir, files, target),
- ArchiveFormat::Tar => create_tar(source_dir, files, target),
- ArchiveFormat::TarGz => create_tar_gz(source_dir, files, target),
- ArchiveFormat::TarBz2 => create_tar_bz2(source_dir, files, target),
- }
-}
-
-fn create_zip(source_dir: &Path, files: &[PathBuf], target: &Path) -> anyhow::Result<()> {
- use zip::write::SimpleFileOptions;
-
- let file = fs::File::create(target)
- .with_context(|| format!("Failed to create archive: {}", target.display()))?;
- let mut writer = zip::ZipWriter::new(file);
-
- for rel in files {
- let abs = source_dir.join(rel);
- let rel_str = rel.to_string_lossy().replace('\\', "/");
-
- if abs.is_dir() {
- let opts = SimpleFileOptions::default();
- writer.add_directory(&rel_str, opts)?;
- } else {
- let metadata = fs::metadata(&abs)?;
-
- #[cfg(unix)]
- let opts = {
- use std::os::unix::fs::MetadataExt;
- let mode = metadata.mode();
- SimpleFileOptions::default()
- .compression_method(zip::CompressionMethod::Deflated)
- .unix_permissions(mode)
- };
-
- #[cfg(not(unix))]
- let opts =
- SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
-
- let _ = metadata; // suppress unused warning on non-unix
-
- writer.start_file(&rel_str, opts)?;
- let content = fs::read(&abs)?;
- writer.write_all(&content)?;
- }
- }
-
- writer.finish()?;
- Ok(())
-}
-
-fn create_tar(source_dir: &Path, files: &[PathBuf], target: &Path) -> anyhow::Result<()> {
- let file = fs::File::create(target)
- .with_context(|| format!("Failed to create archive: {}", target.display()))?;
- let mut builder = tar::Builder::new(file);
-
- for rel in files {
- let abs = source_dir.join(rel);
- if abs.is_dir() {
- builder.append_dir(rel, &abs)?;
- } else {
- builder.append_path_with_name(&abs, rel)?;
- }
- }
-
- builder.finish()?;
- Ok(())
-}
-
-fn create_tar_gz(source_dir: &Path, files: &[PathBuf], target: &Path) -> anyhow::Result<()> {
- let file = fs::File::create(target)
- .with_context(|| format!("Failed to create archive: {}", target.display()))?;
- let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
- let mut builder = tar::Builder::new(encoder);
-
- for rel in files {
- let abs = source_dir.join(rel);
- if abs.is_dir() {
- builder.append_dir(rel, &abs)?;
- } else {
- builder.append_path_with_name(&abs, rel)?;
- }
- }
-
- builder.into_inner()?.finish()?;
- Ok(())
-}
-
-fn create_tar_bz2(source_dir: &Path, files: &[PathBuf], target: &Path) -> anyhow::Result<()> {
- let file = fs::File::create(target)
- .with_context(|| format!("Failed to create archive: {}", target.display()))?;
- let encoder = bzip2::write::BzEncoder::new(file, bzip2::Compression::default());
- let mut builder = tar::Builder::new(encoder);
-
- for rel in files {
- let abs = source_dir.join(rel);
- if abs.is_dir() {
- builder.append_dir(rel, &abs)?;
- } else {
- builder.append_path_with_name(&abs, rel)?;
- }
- }
-
- builder.into_inner()?.finish()?;
- Ok(())
-}
-
-// ─── Filename generation ──────────────────────────────────────────────────────
-
-/// Generate an archive filename (without extension) for a package.
-///
-/// Mirrors Composer's `ArchiveManager::getPackageFilenameParts()`.
-pub fn generate_archive_filename(
- name: &str,
- archive_name: Option<&str>,
- version: Option<&str>,
- dist_reference: Option<&str>,
- dist_type: Option<&str>,
- source_reference: Option<&str>,
-) -> String {
- // Base: archive_name if set, otherwise replace non-alphanumeric chars with `-`
- let base = if let Some(an) = archive_name {
- an.to_string()
- } else {
- let re = Regex::new(r"[^a-zA-Z0-9_\-]").unwrap();
- re.replace_all(name, "-").to_string()
- };
-
- let mut parts: Vec<String> = vec![base];
-
- // Determine if dist_reference is a 40-char hex (SHA-1 commit hash)
- let is_sha_dist_ref = dist_reference
- .map(|r| r.len() == 40 && r.chars().all(|c| c.is_ascii_hexdigit()))
- .unwrap_or(false);
-
- if is_sha_dist_ref {
- // Append dist_reference and dist_type
- if let Some(dr) = dist_reference {
- parts.push(dr.to_string());
- }
- if let Some(dt) = dist_type {
- parts.push(dt.to_string());
- }
- } else {
- // Append version (if any), then dist_reference (if any)
- if let Some(v) = version {
- parts.push(v.to_string());
- }
- if let Some(dr) = dist_reference {
- parts.push(dr.to_string());
- }
- }
-
- // Append first 6 chars of SHA-1 of source_reference (if any)
- if let Some(sr) = source_reference {
- let mut hasher = Sha1::new();
- hasher.update(sr.as_bytes());
- let hash = format!("{:x}", hasher.finalize());
- parts.push(hash[..6.min(hash.len())].to_string());
- }
-
- // Replace `/` with `-` in each part, then join
- parts
- .iter()
- .map(|p| p.replace('/', "-"))
- .collect::<Vec<_>>()
- .join("-")
-}
-
-// ─── Self-exclusion patterns ──────────────────────────────────────────────────
-
-/// The set of archive extensions we support.
-const ARCHIVE_EXTENSIONS: &[&str] = &["zip", "tar", "tar.gz", "tar.bz2"];
-
-/// Generate patterns to exclude previous archives of this package from the archive.
-///
-/// If `has_extra_parts` is true (version/ref was appended), the pattern is
-/// `<base>-*.<ext>`. Otherwise it's `<base>.<ext>`.
-pub fn self_exclusion_patterns(base_name: &str, has_extra_parts: bool) -> Vec<String> {
- ARCHIVE_EXTENSIONS
- .iter()
- .map(|ext| {
- if has_extra_parts {
- format!("/{}-*.{}", base_name, ext)
- } else {
- format!("/{}.{}", base_name, ext)
- }
- })
- .collect()
-}
-
-// ─── Tests ────────────────────────────────────────────────────────────────────
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use tempfile::tempdir;
-
- // ── glob_to_regex ─────────────────────────────────────────────────────────
- // Note: glob_to_regex produces a *fragment* for use inside a larger pattern.
- // We test it by embedding it in a full anchored regex.
-
- fn full_pattern(glob: &str) -> Regex {
- // Simulate the unanchored pattern: `/fragment(/|$)`
- Regex::new(&format!("/{glob_re}(/|$)", glob_re = glob_to_regex(glob))).unwrap()
- }
-
- #[test]
- fn test_glob_to_regex_star() {
- let re = full_pattern("*.txt");
- // Unanchored pattern: matches any .txt file at any depth
- assert!(re.is_match("/foo.txt"));
- // Also matches nested .txt files (unanchored `/` prefix)
- assert!(re.is_match("/a/b.txt"));
- // Does NOT match non-.txt files
- assert!(!re.is_match("/foo.php"));
- }
-
- #[test]
- fn test_glob_to_regex_double_star() {
- // Double star matches across path separators
- let frag = glob_to_regex("**/*.txt");
- let re = Regex::new(&format!("/{frag}(/|$)")).unwrap();
- assert!(re.is_match("/a/b/c.txt"));
- }
-
- #[test]
- fn test_glob_to_regex_question() {
- let frag = glob_to_regex("?.txt");
- let re = Regex::new(&format!("/{frag}(/|$)")).unwrap();
- assert!(re.is_match("/a.txt"));
- assert!(!re.is_match("/ab.txt"));
- }
-
- #[test]
- fn test_glob_to_regex_bracket() {
- let frag = glob_to_regex("[abc].txt");
- let re = Regex::new(&format!("/{frag}(/|$)")).unwrap();
- assert!(re.is_match("/a.txt"));
- assert!(re.is_match("/b.txt"));
- assert!(!re.is_match("/d.txt"));
- }
-
- // ── parse_gitignore_pattern ───────────────────────────────────────────────
-
- #[test]
- fn test_parse_gitignore_simple() {
- let pat = parse_gitignore_pattern("docs/").unwrap();
- assert!(!pat.negate);
- // "/docs" should match
- assert!(pat.regex.is_match("/docs"));
- }
-
- #[test]
- fn test_parse_gitignore_negated() {
- let pat = parse_gitignore_pattern("!important.txt").unwrap();
- assert!(pat.negate);
- }
-
- #[test]
- fn test_parse_gitignore_rooted() {
- let pat = parse_gitignore_pattern("/build").unwrap();
- assert!(!pat.negate);
- // Should match at root
- assert!(pat.regex.is_match("/build"));
- // Should NOT match in subdirectory (rooted pattern)
- assert!(!pat.regex.is_match("/src/build"));
- }
-
- #[test]
- fn test_parse_gitignore_unrooted() {
- let pat = parse_gitignore_pattern("*.log").unwrap();
- assert!(!pat.negate);
- // Should match anywhere
- assert!(pat.regex.is_match("/app.log"));
- assert!(pat.regex.is_match("/sub/dir/foo.log"));
- }
-
- // ── parse_gitattributes ───────────────────────────────────────────────────
-
- #[test]
- fn test_parse_gitattributes_export_ignore() {
- let dir = tempdir().unwrap();
- fs::write(dir.path().join(".gitattributes"), "tests/ export-ignore\n").unwrap();
- let patterns = parse_gitattributes(dir.path());
- assert_eq!(patterns.len(), 1);
- assert!(!patterns[0].negate);
- assert!(patterns[0].regex.is_match("/tests"));
- }
-
- #[test]
- fn test_parse_gitattributes_neg_export_ignore() {
- let dir = tempdir().unwrap();
- fs::write(dir.path().join(".gitattributes"), "tests/ -export-ignore\n").unwrap();
- let patterns = parse_gitattributes(dir.path());
- assert_eq!(patterns.len(), 1);
- assert!(patterns[0].negate);
- }
-
- #[test]
- fn test_parse_gitattributes_comment() {
- let dir = tempdir().unwrap();
- fs::write(
- dir.path().join(".gitattributes"),
- "# comment\ntests/ export-ignore\n",
- )
- .unwrap();
- let patterns = parse_gitattributes(dir.path());
- assert_eq!(patterns.len(), 1);
- }
-
- #[test]
- fn test_parse_gitattributes_non_export() {
- let dir = tempdir().unwrap();
- fs::write(dir.path().join(".gitattributes"), "*.php text\n").unwrap();
- let patterns = parse_gitattributes(dir.path());
- assert!(patterns.is_empty());
- }
-
- #[test]
- fn test_parse_gitattributes_missing_file() {
- let dir = tempdir().unwrap();
- let patterns = parse_gitattributes(dir.path());
- assert!(patterns.is_empty());
- }
-
- // ── collect_archivable_files ──────────────────────────────────────────────
-
- #[test]
- fn test_collect_files_basic() {
- let dir = tempdir().unwrap();
- fs::write(dir.path().join("a.php"), b"<?php").unwrap();
- fs::write(dir.path().join("b.php"), b"<?php").unwrap();
- fs::create_dir(dir.path().join("src")).unwrap();
- fs::write(dir.path().join("src").join("c.php"), b"<?php").unwrap();
-
- let files = collect_archivable_files(dir.path(), &[]).unwrap();
- let strs: Vec<String> = files
- .iter()
- .map(|p| p.to_string_lossy().to_string())
- .collect();
- assert!(strs.contains(&"a.php".to_string()));
- assert!(strs.contains(&"b.php".to_string()));
- assert!(strs.contains(&"src/c.php".to_string()));
- }
-
- #[test]
- fn test_collect_files_excludes() {
- let dir = tempdir().unwrap();
- fs::write(dir.path().join("main.php"), b"<?php").unwrap();
- fs::create_dir(dir.path().join("tests")).unwrap();
- fs::write(dir.path().join("tests").join("test.php"), b"<?php").unwrap();
-
- let patterns = vec![parse_gitignore_pattern("tests/").unwrap()];
- let files = collect_archivable_files(dir.path(), &patterns).unwrap();
- let strs: Vec<String> = files
- .iter()
- .map(|p| p.to_string_lossy().to_string())
- .collect();
- assert!(strs.contains(&"main.php".to_string()));
- assert!(!strs.iter().any(|s| s.starts_with("tests")));
- }
-
- #[test]
- fn test_collect_files_skips_vcs() {
- let dir = tempdir().unwrap();
- fs::write(dir.path().join("main.php"), b"<?php").unwrap();
- fs::create_dir(dir.path().join(".git")).unwrap();
- fs::write(
- dir.path().join(".git").join("HEAD"),
- b"ref: refs/heads/main",
- )
- .unwrap();
-
- let files = collect_archivable_files(dir.path(), &[]).unwrap();
- let strs: Vec<String> = files
- .iter()
- .map(|p| p.to_string_lossy().to_string())
- .collect();
- assert!(strs.contains(&"main.php".to_string()));
- assert!(!strs.iter().any(|s| s.starts_with(".git")));
- }
-
- #[test]
- fn test_collect_files_empty_dir() {
- let dir = tempdir().unwrap();
- fs::write(dir.path().join("main.php"), b"<?php").unwrap();
- fs::create_dir(dir.path().join("empty_dir")).unwrap();
-
- let files = collect_archivable_files(dir.path(), &[]).unwrap();
- let strs: Vec<String> = files
- .iter()
- .map(|p| p.to_string_lossy().to_string())
- .collect();
- assert!(strs.contains(&"main.php".to_string()));
- assert!(strs.contains(&"empty_dir".to_string()));
- }
-
- // ── create_archive ────────────────────────────────────────────────────────
-
- fn make_source_tree(dir: &Path) {
- fs::write(dir.join("main.php"), b"<?php echo 'hello';").unwrap();
- fs::create_dir(dir.join("src")).unwrap();
- fs::write(dir.join("src").join("Foo.php"), b"<?php class Foo {}").unwrap();
- }
-
- #[test]
- fn test_create_zip_archive() {
- let src = tempdir().unwrap();
- make_source_tree(src.path());
- let out = tempdir().unwrap();
- let target = out.path().join("test.zip");
-
- let files = collect_archivable_files(src.path(), &[]).unwrap();
- create_archive(src.path(), &files, &target, &ArchiveFormat::Zip).unwrap();
- assert!(target.exists());
-
- // Verify contents
- let zip_data = fs::read(&target).unwrap();
- let cursor = std::io::Cursor::new(zip_data);
- let mut archive = zip::ZipArchive::new(cursor).unwrap();
- let names: Vec<String> = (0..archive.len())
- .map(|i| archive.by_index(i).unwrap().name().to_string())
- .collect();
- assert!(names.contains(&"main.php".to_string()));
- assert!(names.contains(&"src/Foo.php".to_string()));
- }
-
- #[test]
- fn test_create_tar_archive() {
- let src = tempdir().unwrap();
- make_source_tree(src.path());
- let out = tempdir().unwrap();
- let target = out.path().join("test.tar");
-
- let files = collect_archivable_files(src.path(), &[]).unwrap();
- create_archive(src.path(), &files, &target, &ArchiveFormat::Tar).unwrap();
- assert!(target.exists());
-
- // Verify contents
- let tar_data = fs::read(&target).unwrap();
- let cursor = std::io::Cursor::new(tar_data);
- let mut archive = tar::Archive::new(cursor);
- let names: Vec<String> = archive
- .entries()
- .unwrap()
- .filter_map(|e| e.ok())
- .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string()))
- .collect();
- assert!(names.contains(&"main.php".to_string()));
- assert!(names.contains(&"src/Foo.php".to_string()));
- }
-
- #[test]
- fn test_create_tar_gz_archive() {
- let src = tempdir().unwrap();
- make_source_tree(src.path());
- let out = tempdir().unwrap();
- let target = out.path().join("test.tar.gz");
-
- let files = collect_archivable_files(src.path(), &[]).unwrap();
- create_archive(src.path(), &files, &target, &ArchiveFormat::TarGz).unwrap();
- assert!(target.exists());
-
- let gz_data = fs::read(&target).unwrap();
- let cursor = std::io::Cursor::new(gz_data);
- let decoder = flate2::read::GzDecoder::new(cursor);
- let mut archive = tar::Archive::new(decoder);
- let names: Vec<String> = archive
- .entries()
- .unwrap()
- .filter_map(|e| e.ok())
- .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string()))
- .collect();
- assert!(names.contains(&"main.php".to_string()));
- }
-
- #[test]
- fn test_create_tar_bz2_archive() {
- let src = tempdir().unwrap();
- make_source_tree(src.path());
- let out = tempdir().unwrap();
- let target = out.path().join("test.tar.bz2");
-
- let files = collect_archivable_files(src.path(), &[]).unwrap();
- create_archive(src.path(), &files, &target, &ArchiveFormat::TarBz2).unwrap();
- assert!(target.exists());
-
- let bz_data = fs::read(&target).unwrap();
- let cursor = std::io::Cursor::new(bz_data);
- let decoder = bzip2::read::BzDecoder::new(cursor);
- let mut archive = tar::Archive::new(decoder);
- let names: Vec<String> = archive
- .entries()
- .unwrap()
- .filter_map(|e| e.ok())
- .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string()))
- .collect();
- assert!(names.contains(&"main.php".to_string()));
- }
-
- #[cfg(unix)]
- #[test]
- fn test_zip_preserves_permissions() {
- use std::os::unix::fs::PermissionsExt;
-
- let src = tempdir().unwrap();
- let script = src.path().join("run.sh");
- fs::write(&script, b"#!/bin/sh\necho hello").unwrap();
- fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
-
- let out = tempdir().unwrap();
- let target = out.path().join("test.zip");
- let files = collect_archivable_files(src.path(), &[]).unwrap();
- create_archive(src.path(), &files, &target, &ArchiveFormat::Zip).unwrap();
-
- let zip_data = fs::read(&target).unwrap();
- let cursor = std::io::Cursor::new(zip_data);
- let mut archive = zip::ZipArchive::new(cursor).unwrap();
- let entry = archive.by_name("run.sh").unwrap();
- let mode = entry.unix_mode().unwrap_or(0);
- // Lower 9 bits should be 0o755
- assert_eq!(mode & 0o777, 0o755);
- }
-
- // ── generate_archive_filename ─────────────────────────────────────────────
-
- #[test]
- fn test_filename_simple_package() {
- let name = generate_archive_filename("vendor/pkg", None, Some("1.2.3"), None, None, None);
- assert_eq!(name, "vendor-pkg-1.2.3");
- }
-
- #[test]
- fn test_filename_with_archive_name() {
- let name = generate_archive_filename(
- "vendor/pkg",
- Some("my-package"),
- Some("1.0.0"),
- None,
- None,
- None,
- );
- assert_eq!(name, "my-package-1.0.0");
- }
-
- #[test]
- fn test_filename_with_sha_dist_ref() {
- let sha = "a".repeat(40);
- let name = generate_archive_filename(
- "vendor/pkg",
- None,
- Some("1.0.0"),
- Some(&sha),
- Some("zip"),
- None,
- );
- // 40-char hex → append dist_ref and dist_type, not version
- assert_eq!(name, format!("vendor-pkg-{}-zip", sha));
- }
-
- #[test]
- fn test_filename_with_source_ref() {
- let name = generate_archive_filename(
- "vendor/pkg",
- None,
- Some("1.0.0"),
- None,
- None,
- Some("abc123"),
- );
- // Appends first 6 chars of SHA-1 of "abc123"
- let mut hasher = Sha1::new();
- hasher.update(b"abc123");
- let hash = format!("{:x}", hasher.finalize());
- let expected = format!("vendor-pkg-1.0.0-{}", &hash[..6]);
- assert_eq!(name, expected);
- }
-
- #[test]
- fn test_filename_slashes_replaced() {
- let name =
- generate_archive_filename("vendor/my-pkg", None, Some("1.0/beta"), None, None, None);
- assert_eq!(name, "vendor-my-pkg-1.0-beta");
- }
-
- // ── self_exclusion_patterns ───────────────────────────────────────────────
-
- #[test]
- fn test_self_exclusion_patterns_with_extra_parts() {
- let patterns = self_exclusion_patterns("vendor-pkg", true);
- assert!(patterns.contains(&"/vendor-pkg-*.zip".to_string()));
- assert!(patterns.contains(&"/vendor-pkg-*.tar".to_string()));
- assert!(patterns.contains(&"/vendor-pkg-*.tar.gz".to_string()));
- assert!(patterns.contains(&"/vendor-pkg-*.tar.bz2".to_string()));
- }
-
- #[test]
- fn test_self_exclusion_patterns_no_extra_parts() {
- let patterns = self_exclusion_patterns("vendor-pkg", false);
- assert!(patterns.contains(&"/vendor-pkg.zip".to_string()));
- assert!(patterns.contains(&"/vendor-pkg.tar".to_string()));
- }
-
- // ── 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"<?php").unwrap();
-
- let args = ArchiveArgs {
- package: None,
- version: None,
- format: Some("tar".to_string()),
- dir: Some(out.path().to_string_lossy().to_string()),
- file: Some("test-archive".to_string()),
- ignore_filters: false,
- };
-
- let cli = Cli {
- command: crate::commands::Commands::Archive(ArchiveArgs {
- package: None,
- version: None,
- format: Some("tar".to_string()),
- dir: Some(out.path().to_string_lossy().to_string()),
- file: Some("test-archive".to_string()),
- 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,
- };
- execute(&args, &cli, &console).unwrap();
-
- let archive_path = out.path().join("test-archive.tar");
- assert!(archive_path.exists(), "tar archive was not created");
-
- // Verify contents
- let tar_data = std::fs::read(&archive_path).unwrap();
- let cursor = std::io::Cursor::new(tar_data);
- let mut archive = tar::Archive::new(cursor);
- let names: Vec<String> = archive
- .entries()
- .unwrap()
- .filter_map(|e| e.ok())
- .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string()))
- .collect();
- assert!(names.contains(&"main.php".to_string()));
- assert!(names.contains(&"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"<?php").unwrap();
-
- let args = ArchiveArgs {
- package: None,
- version: None,
- format: Some("zip".to_string()),
- dir: Some(out.path().to_string_lossy().to_string()),
- file: Some("test-archive".to_string()),
- ignore_filters: false,
- };
-
- let cli = Cli {
- command: crate::commands::Commands::Archive(ArchiveArgs {
- package: None,
- version: None,
- format: Some("zip".to_string()),
- dir: Some(out.path().to_string_lossy().to_string()),
- file: Some("test-archive".to_string()),
- 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,
- };
- execute(&args, &cli, &console).unwrap();
-
- let archive_path = out.path().join("test-archive.zip");
- assert!(archive_path.exists(), "zip archive was not created");
- }
-
- #[test]
- fn test_archive_custom_dir() {
- use crate::commands::Cli;
- use crate::commands::archive::{ArchiveArgs, execute};
-
- let src = tempdir().unwrap();
- let custom_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("tar".to_string()),
- dir: Some(custom_out.path().to_string_lossy().to_string()),
- file: Some("custom".to_string()),
- ignore_filters: false,
- };
-
- let cli = Cli {
- command: crate::commands::Commands::Archive(ArchiveArgs {
- package: None,
- version: None,
- format: Some("tar".to_string()),
- dir: Some(custom_out.path().to_string_lossy().to_string()),
- file: Some("custom".to_string()),
- 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,
- };
- execute(&args, &cli, &console).unwrap();
-
- assert!(custom_out.path().join("custom.tar").exists());
- }
-
- #[test]
- fn test_archive_custom_filename() {
- 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("tar".to_string()),
- dir: Some(out.path().to_string_lossy().to_string()),
- file: Some("my-custom-name".to_string()),
- ignore_filters: false,
- };
-
- let cli = Cli {
- command: crate::commands::Commands::Archive(ArchiveArgs {
- package: None,
- version: None,
- format: Some("tar".to_string()),
- dir: Some(out.path().to_string_lossy().to_string()),
- file: Some("my-custom-name".to_string()),
- 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,
- };
- execute(&args, &cli, &console).unwrap();
-
- assert!(out.path().join("my-custom-name.tar").exists());
- }
-
- #[test]
- fn test_archive_gitattributes_filter() {
- 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"<?php").unwrap();
- std::fs::create_dir(src.path().join("tests")).unwrap();
- std::fs::write(src.path().join("tests").join("FooTest.php"), b"<?php").unwrap();
- std::fs::write(src.path().join(".gitattributes"), "tests/ export-ignore\n").unwrap();
-
- let args = ArchiveArgs {
- package: None,
- version: None,
- format: Some("tar".to_string()),
- dir: Some(out.path().to_string_lossy().to_string()),
- file: Some("filtered".to_string()),
- ignore_filters: false,
- };
-
- let cli = Cli {
- command: crate::commands::Commands::Archive(ArchiveArgs {
- package: None,
- version: None,
- format: Some("tar".to_string()),
- dir: Some(out.path().to_string_lossy().to_string()),
- file: Some("filtered".to_string()),
- 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,
- };
- execute(&args, &cli, &console).unwrap();
-
- let tar_path = out.path().join("filtered.tar");
- assert!(tar_path.exists());
-
- let tar_data = std::fs::read(&tar_path).unwrap();
- let cursor = std::io::Cursor::new(tar_data);
- let mut archive = tar::Archive::new(cursor);
- let names: Vec<String> = archive
- .entries()
- .unwrap()
- .filter_map(|e| e.ok())
- .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string()))
- .collect();
- assert!(names.contains(&"main.php".to_string()));
- assert!(!names.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"<?php").unwrap();
- std::fs::create_dir(src.path().join("docs")).unwrap();
- std::fs::write(src.path().join("docs").join("README.md"), b"# Docs").unwrap();
-
- let args = ArchiveArgs {
- package: None,
- version: None,
- format: Some("tar".to_string()),
- dir: Some(out.path().to_string_lossy().to_string()),
- file: Some("with-excludes".to_string()),
- ignore_filters: false,
- };
-
- let cli = Cli {
- command: crate::commands::Commands::Archive(ArchiveArgs {
- package: None,
- version: None,
- format: Some("tar".to_string()),
- dir: Some(out.path().to_string_lossy().to_string()),
- file: Some("with-excludes".to_string()),
- 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,
- };
- execute(&args, &cli, &console).unwrap();
-
- let tar_path = out.path().join("with-excludes.tar");
- assert!(tar_path.exists());
-
- let tar_data = std::fs::read(&tar_path).unwrap();
- let cursor = std::io::Cursor::new(tar_data);
- let mut archive = tar::Archive::new(cursor);
- let names: Vec<String> = archive
- .entries()
- .unwrap()
- .filter_map(|e| e.ok())
- .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string()))
- .collect();
- assert!(names.contains(&"main.php".to_string()));
- assert!(!names.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"<?php").unwrap();
- std::fs::create_dir(src.path().join("tests")).unwrap();
- std::fs::write(src.path().join("tests").join("FooTest.php"), b"<?php").unwrap();
- std::fs::write(src.path().join(".gitattributes"), "tests/ export-ignore\n").unwrap();
-
- let args = ArchiveArgs {
- package: None,
- version: None,
- format: Some("tar".to_string()),
- dir: Some(out.path().to_string_lossy().to_string()),
- file: Some("unfiltered".to_string()),
- ignore_filters: true, // All filters ignored
- };
-
- let cli = Cli {
- command: crate::commands::Commands::Archive(ArchiveArgs {
- package: None,
- version: None,
- format: Some("tar".to_string()),
- dir: Some(out.path().to_string_lossy().to_string()),
- file: Some("unfiltered".to_string()),
- ignore_filters: true,
- }),
- 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,
- };
- execute(&args, &cli, &console).unwrap();
-
- let tar_path = out.path().join("unfiltered.tar");
- assert!(tar_path.exists());
-
- let tar_data = std::fs::read(&tar_path).unwrap();
- let cursor = std::io::Cursor::new(tar_data);
- let mut archive = tar::Archive::new(cursor);
- let names: Vec<String> = archive
- .entries()
- .unwrap()
- .filter_map(|e| e.ok())
- .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string()))
- .collect();
- // 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<String>,
- /// When true, return an error on PSR mapping violations detected during classmap scan.
- pub strict_psr: bool,
- /// How to handle platform requirement checks.
- pub platform_check: PlatformCheckMode,
- /// When true, skip all platform requirement checks.
- pub ignore_platform_reqs: bool,
-}
-
-/// Collected autoload mappings from all packages.
-pub struct AutoloadData {
- /// PSR-4: namespace prefix -> list of directory path expressions.
- /// Each path is a PHP expression string like `$vendorDir . '/psr/log/src'`.
- pub psr4: BTreeMap<String, Vec<String>>,
- /// PSR-0: namespace prefix -> list of directory path expressions.
- /// (Empty in Phase 2.2, populated in 5.6.)
- pub psr0: BTreeMap<String, Vec<String>>,
- /// Classmap entries: class name -> file path expression.
- /// (Empty in Phase 2.2, populated in 5.6.)
- pub classmap: BTreeMap<String, String>,
- /// Files to include on every request: file_identifier -> path expression.
- pub files: BTreeMap<String, String>,
-}
-
-/// Escape a string for use in a PHP single-quoted string literal.
-pub fn php_escape(s: &str) -> String {
- s.replace('\\', "\\\\").replace('\'', "\\'")
-}
-
-/// Compute the file identifier matching Composer's `getFileIdentifier()`.
-/// This is the MD5 hex digest of `"package_name:path"`.
-pub fn file_identifier(package_name: &str, path: &str) -> String {
- let input = format!("{package_name}:{path}");
- format!("{:x}", md5::compute(input.as_bytes()))
-}
-
-/// Extract a path or array of paths from a JSON value.
-/// Handles both string and array-of-strings (Composer allows both).
-fn json_to_paths(value: &serde_json::Value) -> Vec<String> {
- match value {
- serde_json::Value::String(s) => vec![s.clone()],
- serde_json::Value::Array(arr) => arr
- .iter()
- .filter_map(|v| v.as_str().map(|s| s.to_string()))
- .collect(),
- _ => vec![],
- }
-}
-
-/// Strip trailing slash from a path component.
-fn strip_trailing_slash(s: &str) -> &str {
- s.trim_end_matches('/')
-}
-
-/// Normalize a PSR-4 namespace: ensure it ends with `\`.
-/// (The empty string "" is valid and is left as-is.)
-fn normalize_namespace(ns: &str) -> String {
- if ns.is_empty() || ns.ends_with('\\') {
- ns.to_string()
- } else {
- format!("{ns}\\")
- }
-}
-
-/// Build a PHP path expression from a base expression and a relative path component.
-///
-/// For vendor packages: `base_expr` = `"$vendorDir"`, `pkg_path` = `"psr/log"`,
-/// `sub_path` = `"src/"` → result: `"$vendorDir . '/psr/log/src'"`.
-///
-/// For root packages: `base_expr` = `"$baseDir"`, `pkg_path` = `""`,
-/// `sub_path` = `"src/"` → result: `"$baseDir . '/src'"`.
-fn build_path_expr(base_expr: &str, pkg_path: &str, sub_path: &str) -> String {
- let sub = strip_trailing_slash(sub_path);
- let combined = if pkg_path.is_empty() {
- sub.to_string()
- } else if sub.is_empty() {
- pkg_path.to_string()
- } else {
- format!("{pkg_path}/{sub}")
- };
-
- if combined.is_empty() {
- base_expr.to_string()
- } else {
- format!("{base_expr} . '/{combined}'")
- }
-}
-
-/// Process an autoload JSON value and merge its rules into `data`.
-///
-/// `pkg_path` is the package-relative path segment within vendor.
-/// For vendor packages it is `"vendor/name"` (e.g. `"psr/log"`).
-/// For the root package it is `""`.
-///
-/// `dyn_base` is the dynamic PHP variable: `"$vendorDir"` or `"$baseDir"`.
-/// `static_base` is the static PHP expression: `"__DIR__ . '/..'"` or `"__DIR__ . '/../.'"`.
-fn process_autoload_value(
- autoload_val: &serde_json::Value,
- package_name: &str,
- pkg_path: &str,
- dyn_base: &str,
- static_base: &str,
- data: &mut AutoloadData,
- static_data: &mut AutoloadData,
-) {
- // PSR-4
- if let Some(psr4_obj) = autoload_val.get("psr-4").and_then(|v| v.as_object()) {
- for (ns_raw, paths_val) in psr4_obj {
- let ns = normalize_namespace(ns_raw);
- let paths = json_to_paths(paths_val);
- let entry = data.psr4.entry(ns.clone()).or_default();
- let static_entry = static_data.psr4.entry(ns).or_default();
- for path in paths {
- entry.push(build_path_expr(dyn_base, pkg_path, &path));
- static_entry.push(build_path_expr(static_base, pkg_path, &path));
- }
- }
- }
-
- // PSR-0
- if let Some(psr0_obj) = autoload_val.get("psr-0").and_then(|v| v.as_object()) {
- for (ns_raw, paths_val) in psr0_obj {
- let ns = ns_raw.clone();
- let paths = json_to_paths(paths_val);
- let entry = data.psr0.entry(ns.clone()).or_default();
- let static_entry = static_data.psr0.entry(ns).or_default();
- for path in paths {
- entry.push(build_path_expr(dyn_base, pkg_path, &path));
- static_entry.push(build_path_expr(static_base, pkg_path, &path));
- }
- }
- }
-
- // Files
- if let Some(files_arr) = autoload_val.get("files").and_then(|v| v.as_array()) {
- for file_val in files_arr {
- if let Some(file_path) = file_val.as_str() {
- let id = file_identifier(package_name, file_path);
- let expr = build_path_expr(dyn_base, pkg_path, file_path);
- let static_expr = build_path_expr(static_base, pkg_path, file_path);
- data.files.insert(id.clone(), expr);
- static_data.files.insert(id, static_expr);
- }
- }
- }
-}
-
-/// Collect autoload rules from all installed packages and the root package.
-///
-/// Returns a tuple of `(dynamic_data, static_data)` where:
-/// - `dynamic_data` uses `$vendorDir` / `$baseDir` path expressions (for autoload_psr4.php, etc.)
-/// - `static_data` uses `__DIR__ . '/..'` path expressions (for autoload_static.php)
-fn collect_autoloads(
- installed: &InstalledPackages,
- root_autoload: Option<&serde_json::Value>,
- root_autoload_dev: Option<&serde_json::Value>,
- root_package_name: &str,
- dev_mode: bool,
-) -> (AutoloadData, AutoloadData) {
- let mut data = AutoloadData {
- psr4: BTreeMap::new(),
- psr0: BTreeMap::new(),
- classmap: BTreeMap::new(),
- files: BTreeMap::new(),
- };
- let mut static_data = AutoloadData {
- psr4: BTreeMap::new(),
- psr0: BTreeMap::new(),
- classmap: BTreeMap::new(),
- files: BTreeMap::new(),
- };
-
- // Process each installed package
- for pkg in &installed.packages {
- if let Some(autoload_val) = &pkg.autoload {
- process_autoload_value(
- autoload_val,
- &pkg.name,
- &pkg.name, // pkg_path within vendor
- "$vendorDir",
- "__DIR__ . '/..'",
- &mut data,
- &mut static_data,
- );
- }
- }
-
- // Process root package autoload
- if let Some(autoload_val) = root_autoload {
- process_autoload_value(
- autoload_val,
- root_package_name,
- "", // no pkg_path for root
- "$baseDir",
- "__DIR__ . '/../..'",
- &mut data,
- &mut static_data,
- );
- }
-
- // Process root package autoload-dev (only in dev mode)
- if dev_mode && let Some(autoload_dev_val) = root_autoload_dev {
- process_autoload_value(
- autoload_dev_val,
- root_package_name,
- "",
- "$baseDir",
- "__DIR__ . '/../..'",
- &mut data,
- &mut static_data,
- );
- }
-
- (data, static_data)
-}
-
-/// Generate `vendor/composer/autoload_psr4.php`.
-fn generate_autoload_psr4(data: &AutoloadData) -> String {
- let mut out = String::new();
- out.push_str("<?php\n\n// autoload_psr4.php @generated by Composer\n\n");
- out.push_str("$vendorDir = dirname(__DIR__);\n");
- out.push_str("$baseDir = dirname($vendorDir);\n\n");
- out.push_str("return array(\n");
-
- // krsort: reverse alphabetical (longer/more specific namespaces first)
- let mut sorted: Vec<(&String, &Vec<String>)> = data.psr4.iter().collect();
- sorted.sort_by(|(a, _), (b, _)| b.cmp(a));
-
- for (ns, paths) in &sorted {
- let escaped_ns = php_escape(ns);
- if paths.len() == 1 {
- out.push_str(&format!(" '{}' => array({}),\n", escaped_ns, paths[0]));
- } else {
- out.push_str(&format!(" '{}' => array(\n", escaped_ns));
- for path in paths.iter() {
- out.push_str(&format!(" {},\n", path));
- }
- out.push_str(" ),\n");
- }
- }
-
- out.push_str(");\n");
- out
-}
-
-/// Generate `vendor/composer/autoload_namespaces.php` (PSR-0, empty for Phase 2.2).
-fn generate_autoload_namespaces(data: &AutoloadData) -> String {
- let mut out = String::new();
- out.push_str("<?php\n\n// autoload_namespaces.php @generated by Composer\n\n");
- out.push_str("$vendorDir = dirname(__DIR__);\n");
- out.push_str("$baseDir = dirname($vendorDir);\n\n");
- out.push_str("return array(\n");
-
- let mut sorted: Vec<(&String, &Vec<String>)> = data.psr0.iter().collect();
- sorted.sort_by(|(a, _), (b, _)| b.cmp(a));
-
- for (ns, paths) in &sorted {
- let escaped_ns = php_escape(ns);
- if paths.len() == 1 {
- out.push_str(&format!(" '{}' => array({}),\n", escaped_ns, paths[0]));
- } else {
- out.push_str(&format!(" '{}' => array(\n", escaped_ns));
- for path in paths.iter() {
- out.push_str(&format!(" {},\n", path));
- }
- out.push_str(" ),\n");
- }
- }
-
- out.push_str(");\n");
- out
-}
-
-/// Generate `vendor/composer/autoload_classmap.php`.
-/// Always contains `Composer\InstalledVersions`; classmap scanning deferred to Phase 5.6.
-fn generate_autoload_classmap(data: &AutoloadData) -> String {
- let mut out = String::new();
- out.push_str("<?php\n\n// autoload_classmap.php @generated by Composer\n\n");
- out.push_str("$vendorDir = dirname(__DIR__);\n");
- out.push_str("$baseDir = dirname($vendorDir);\n\n");
- out.push_str("return array(\n");
- out.push_str(
- " 'Composer\\\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',\n",
- );
-
- // Include any additional classmap entries from data
- for (class, path) in &data.classmap {
- let escaped_class = php_escape(class);
- out.push_str(&format!(" '{}' => {},\n", escaped_class, path));
- }
-
- out.push_str(");\n");
- out
-}
-
-/// Generate `vendor/composer/autoload_files.php`.
-/// Returns `None` if there are no files to autoload.
-fn generate_autoload_files(data: &AutoloadData) -> Option<String> {
- if data.files.is_empty() {
- return None;
- }
-
- let mut out = String::new();
- out.push_str("<?php\n\n// autoload_files.php @generated by Composer\n\n");
- out.push_str("$vendorDir = dirname(__DIR__);\n");
- out.push_str("$baseDir = dirname($vendorDir);\n\n");
- out.push_str("return array(\n");
-
- for (id, path) in &data.files {
- out.push_str(&format!(" '{}' => {},\n", id, path));
- }
-
- out.push_str(");\n");
- Some(out)
-}
-
-/// Generate `vendor/composer/autoload_static.php`.
-///
-/// `static_data` must have been collected with `__DIR__ . '/..'` path prefixes.
-fn generate_autoload_static(static_data: &AutoloadData, suffix: &str) -> String {
- let mut out = String::new();
- out.push_str("<?php\n\n// autoload_static.php @generated by Composer\n\n");
- out.push_str("namespace Composer\\Autoload;\n\n");
- out.push_str(&format!("class ComposerStaticInit{suffix}\n{{\n"));
-
- // $files
- if !static_data.files.is_empty() {
- out.push_str(" public static $files = array (\n");
- for (id, path) in &static_data.files {
- out.push_str(&format!(" '{id}' => {path},\n"));
- }
- out.push_str(" );\n\n");
- }
-
- // $prefixLengthsPsr4 — group by first character of namespace
- if !static_data.psr4.is_empty() {
- // Group namespaces by first character, sorted reverse
- let mut by_char: BTreeMap<char, Vec<(&String, usize)>> = BTreeMap::new();
-
- let mut sorted_ns: Vec<&String> = static_data.psr4.keys().collect();
- sorted_ns.sort_by(|a, b| b.cmp(a));
-
- for ns in sorted_ns {
- if let Some(first_char) = ns.chars().next() {
- // The byte length in PHP (single-quoted string with single backslashes)
- // ns in our data uses single backslash (stored as-is from JSON).
- let byte_len = ns.len();
- by_char.entry(first_char).or_default().push((ns, byte_len));
- }
- }
-
- out.push_str(" public static $prefixLengthsPsr4 = array (\n");
- // Sort characters in reverse order too
- let mut chars: Vec<char> = by_char.keys().copied().collect();
- chars.sort_by(|a, b| b.cmp(a));
- for ch in &chars {
- out.push_str(&format!(" '{ch}' =>\n array (\n"));
- if let Some(entries) = by_char.get(ch) {
- for (ns, len) in entries {
- let escaped_ns = php_escape(ns);
- out.push_str(&format!(" '{escaped_ns}' => {len},\n"));
- }
- }
- out.push_str(" ),\n");
- }
- out.push_str(" );\n\n");
-
- // $prefixDirsPsr4
- out.push_str(" public static $prefixDirsPsr4 = array (\n");
- let mut sorted_ns2: Vec<(&String, &Vec<String>)> = static_data.psr4.iter().collect();
- sorted_ns2.sort_by(|(a, _), (b, _)| b.cmp(a));
- for (ns, paths) in sorted_ns2 {
- let escaped_ns = php_escape(ns);
- out.push_str(&format!(" '{escaped_ns}' =>\n array (\n"));
- for (i, path) in paths.iter().enumerate() {
- out.push_str(&format!(" {i} => {path},\n"));
- }
- out.push_str(" ),\n");
- }
- out.push_str(" );\n\n");
- }
-
- // $classMap — always contains Composer\InstalledVersions
- out.push_str(" public static $classMap = array (\n");
- out.push_str(
- " 'Composer\\\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',\n",
- );
- for (class, path) in &static_data.classmap {
- let escaped_class = php_escape(class);
- out.push_str(&format!(" '{}' => {},\n", escaped_class, path));
- }
- out.push_str(" );\n\n");
-
- // getInitializer
- out.push_str(" public static function getInitializer(ClassLoader $loader)\n {\n");
- out.push_str(" return \\Closure::bind(function () use ($loader) {\n");
-
- if !static_data.psr4.is_empty() {
- out.push_str(&format!(
- " $loader->prefixLengthsPsr4 = ComposerStaticInit{suffix}::$prefixLengthsPsr4;\n"
- ));
- out.push_str(&format!(
- " $loader->prefixDirsPsr4 = ComposerStaticInit{suffix}::$prefixDirsPsr4;\n"
- ));
- }
- out.push_str(&format!(
- " $loader->classMap = ComposerStaticInit{suffix}::$classMap;\n"
- ));
- out.push_str("\n }, null, ClassLoader::class);\n }\n}\n");
-
- out
-}
-
-/// Recursively collect PHP files from a directory, skipping excluded paths.
-fn collect_php_files(
- dir: &Path,
- excluded: &[String],
- vendor_dir: &Path,
- project_dir: &Path,
-) -> Vec<PathBuf> {
- let mut result = Vec::new();
- if !dir.is_dir() {
- return result;
- }
- collect_php_files_inner(dir, excluded, vendor_dir, project_dir, &mut result);
- result
-}
-
-fn collect_php_files_inner(
- dir: &Path,
- excluded: &[String],
- vendor_dir: &Path,
- project_dir: &Path,
- result: &mut Vec<PathBuf>,
-) {
- let entries = match std::fs::read_dir(dir) {
- Ok(e) => e,
- Err(_) => return,
- };
- for entry in entries.flatten() {
- let path = entry.path();
-
- // Check if path matches any excluded pattern
- if is_excluded(&path, excluded, vendor_dir, project_dir) {
- continue;
- }
-
- if path.is_dir() {
- collect_php_files_inner(&path, excluded, vendor_dir, project_dir, result);
- } else if crate::php_scanner::is_php_ext(&path) {
- result.push(path);
- }
- }
-}
-
-/// Check whether a path matches any of the excluded patterns.
-fn is_excluded(path: &Path, excluded: &[String], vendor_dir: &Path, project_dir: &Path) -> bool {
- for exc in excluded {
- // Excluded patterns can be relative to project_dir or absolute
- let exc_path = if Path::new(exc).is_absolute() {
- PathBuf::from(exc)
- } else {
- project_dir.join(exc)
- };
- if path.starts_with(&exc_path) || path == exc_path {
- return true;
- }
- // Also check relative to vendor_dir
- let exc_vendor = vendor_dir.join(exc);
- if path.starts_with(&exc_vendor) || path == exc_vendor {
- return true;
- }
- }
- false
-}
-
-/// Scan directories for PHP class declarations and return a classmap.
-///
-/// `dirs` is a list of absolute directory paths to scan.
-/// Returns a `BTreeMap<class_name, file_path_expression>` where the path expression
-/// uses `$vendorDir` or `$baseDir` as appropriate.
-fn scan_classmap_dirs(
- dirs: &[PathBuf],
- vendor_dir: &Path,
- project_dir: &Path,
- excluded: &[String],
-) -> BTreeMap<String, String> {
- let mut classmap = BTreeMap::new();
-
- for dir in dirs {
- let files = collect_php_files(dir, excluded, vendor_dir, project_dir);
- for file in files {
- match crate::php_scanner::find_classes(&file) {
- Ok(classes) => {
- for class in classes {
- let path_expr = path_to_php_expr(&file, vendor_dir, project_dir);
- classmap.entry(class).or_insert(path_expr);
- }
- }
- Err(_) => continue,
- }
- }
- }
-
- classmap
-}
-
-/// Convert an absolute file path to a PHP path expression using `$vendorDir` or `$baseDir`.
-fn path_to_php_expr(file: &Path, vendor_dir: &Path, project_dir: &Path) -> String {
- if let Ok(rel) = file.strip_prefix(vendor_dir) {
- let rel_str = rel.to_string_lossy().replace('\\', "/");
- format!("$vendorDir . '/{rel_str}'")
- } else if let Ok(rel) = file.strip_prefix(project_dir) {
- let rel_str = rel.to_string_lossy().replace('\\', "/");
- format!("$baseDir . '/{rel_str}'")
- } else {
- // Fall back to absolute path
- let abs = file.to_string_lossy().replace('\\', "/");
- format!("'{abs}'")
- }
-}
-
-/// Convert an absolute file path to a static PHP path expression using `__DIR__ . '/..` form.
-fn path_to_static_expr(file: &Path, vendor_dir: &Path, project_dir: &Path) -> String {
- if let Ok(rel) = file.strip_prefix(vendor_dir) {
- let rel_str = rel.to_string_lossy().replace('\\', "/");
- format!("__DIR__ . '/..' . '/{rel_str}'")
- } else if let Ok(rel) = file.strip_prefix(project_dir) {
- let rel_str = rel.to_string_lossy().replace('\\', "/");
- format!("__DIR__ . '/../..' . '/{rel_str}'")
- } else {
- let abs = file.to_string_lossy().replace('\\', "/");
- format!("'{abs}'")
- }
-}
-
-/// Scan PSR-4 and PSR-0 directories for class declarations (used in optimize mode).
-///
-/// Returns `(dynamic_classmap, static_classmap, psr_violations)`.
-fn scan_psr_for_classmap(
- psr4: &BTreeMap<String, Vec<String>>,
- psr0: &BTreeMap<String, Vec<String>>,
- vendor_dir: &Path,
- project_dir: &Path,
- excluded: &[String],
-) -> (
- BTreeMap<String, String>,
- BTreeMap<String, String>,
- Vec<String>,
-) {
- let mut dyn_map: BTreeMap<String, String> = BTreeMap::new();
- let mut static_map: BTreeMap<String, String> = BTreeMap::new();
- let mut violations: Vec<String> = Vec::new();
-
- // Helper: resolve a PHP path expression to an absolute path.
- let resolve = |expr: &str| -> Option<PathBuf> {
- // Expressions look like:
- // $vendorDir . '/psr/log/src'
- // $baseDir . '/src'
- // __DIR__ . '/..' . '/psr/log/src'
- // __DIR__ . '/../..' . '/src'
- if let Some(rest) = expr.strip_prefix("$vendorDir . '") {
- let rel = rest.trim_end_matches('\'');
- Some(vendor_dir.join(rel.trim_start_matches('/')))
- } else if let Some(rest) = expr.strip_prefix("$baseDir . '") {
- let rel = rest.trim_end_matches('\'');
- Some(project_dir.join(rel.trim_start_matches('/')))
- } else if expr == "$vendorDir" {
- Some(vendor_dir.to_path_buf())
- } else if expr == "$baseDir" {
- Some(project_dir.to_path_buf())
- } else {
- None
- }
- };
-
- // Scan PSR-4 dirs
- for (ns, paths) in psr4 {
- for path_expr in paths {
- if let Some(abs_dir) = resolve(path_expr) {
- let files = collect_php_files(&abs_dir, excluded, vendor_dir, project_dir);
- for file in files {
- match crate::php_scanner::find_classes(&file) {
- Ok(classes) => {
- for class in classes {
- // PSR-4 validation
- let file_str = file.to_string_lossy();
- let dir_str = abs_dir.to_string_lossy();
- let base_ns = ns.as_str();
- if !crate::php_scanner::validate_psr4_class(
- &class, base_ns, &file_str, &dir_str,
- ) {
- violations.push(format!(
- "Class {class} in {file_str} does not comply with PSR-4 (namespace prefix: {ns})"
- ));
- }
- let dyn_expr = path_to_php_expr(&file, vendor_dir, project_dir);
- let static_expr =
- path_to_static_expr(&file, vendor_dir, project_dir);
- dyn_map.entry(class.clone()).or_insert(dyn_expr);
- static_map.entry(class).or_insert(static_expr);
- }
- }
- Err(_) => continue,
- }
- }
- }
- }
- }
-
- // Scan PSR-0 dirs
- for (ns, paths) in psr0 {
- for path_expr in paths {
- if let Some(abs_dir) = resolve(path_expr) {
- let files = collect_php_files(&abs_dir, excluded, vendor_dir, project_dir);
- for file in files {
- match crate::php_scanner::find_classes(&file) {
- Ok(classes) => {
- for class in classes {
- let file_str = file.to_string_lossy();
- let dir_str = abs_dir.to_string_lossy();
- if !crate::php_scanner::validate_psr0_class(
- &class, &file_str, &dir_str,
- ) {
- violations.push(format!(
- "Class {class} in {file_str} does not comply with PSR-0 (namespace prefix: {ns})"
- ));
- }
- let dyn_expr = path_to_php_expr(&file, vendor_dir, project_dir);
- let static_expr =
- path_to_static_expr(&file, vendor_dir, project_dir);
- dyn_map.entry(class.clone()).or_insert(dyn_expr);
- static_map.entry(class).or_insert(static_expr);
- }
- }
- Err(_) => continue,
- }
- }
- }
- }
- }
-
- (dyn_map, static_map, violations)
-}
-
-/// Generate `vendor/composer/platform_check.php`.
-///
-/// Returns `None` if mode is `Disabled` or there are no relevant requirements.
-fn generate_platform_check(
- packages: &[LockedPackage],
- root_require: Option<&serde_json::Value>,
- mode: &PlatformCheckMode,
- dev_package_names: &HashSet<String>,
-) -> Option<String> {
- if matches!(mode, PlatformCheckMode::Disabled) {
- return None;
- }
-
- // Collect PHP version constraint from root require
- let mut php_constraint: Option<String> = None;
- if let Some(req_obj) = root_require.and_then(|v| v.as_object())
- && let Some(v) = req_obj.get("php").and_then(|v| v.as_str())
- {
- php_constraint = Some(v.to_string());
- }
-
- // Collect extension requirements from packages (prod only)
- let mut ext_reqs: Vec<(String, String)> = Vec::new();
- if matches!(mode, PlatformCheckMode::Full) {
- for pkg in packages {
- let is_dev = dev_package_names.contains(&pkg.name.to_lowercase());
- if is_dev {
- continue;
- }
- for (req_name, req_constraint) in &pkg.require {
- let lower = req_name.to_lowercase();
- if lower.starts_with("ext-") {
- ext_reqs.push((req_name.clone(), req_constraint.clone()));
- }
- }
- }
- ext_reqs.sort();
- ext_reqs.dedup();
- }
-
- if php_constraint.is_none() && ext_reqs.is_empty() {
- return None;
- }
-
- let mut out = String::new();
- out.push_str("<?php\n\n");
- out.push_str("// platform_check.php @generated by Composer\n\n");
- out.push_str("$issues = array();\n\n");
-
- if let Some(ref constraint) = php_constraint {
- // Emit a simple PHP version check
- let escaped = php_escape(constraint);
- out.push_str(&format!("// PHP version check: {constraint}\n"));
- out.push_str("if (!(PHP_VERSION_ID >= 50600)) {\n");
- out.push_str(&format!(
- " $issues[] = 'Your Composer dependencies require a PHP version \"{escaped}\". You are running ' . PHP_VERSION . '.';\n"
- ));
- out.push_str("}\n\n");
- }
-
- for (ext_name, _constraint) in &ext_reqs {
- let ext_short = ext_name.trim_start_matches("ext-");
- let escaped_ext = php_escape(ext_short);
- out.push_str(&format!("if (!extension_loaded('{escaped_ext}')) {{\n"));
- out.push_str(&format!(
- " $issues[] = 'Your Composer dependencies require the \"{escaped_ext}\" PHP extension to be installed.';\n"
- ));
- out.push_str("}\n\n");
- }
-
- out.push_str("if ($issues) {\n");
- out.push_str(" if (!headers_sent()) {\n");
- out.push_str(" header('HTTP/1.1 500 Internal Server Error');\n");
- out.push_str(" }\n");
- out.push_str(" if (!ini_get('display_errors')) {\n");
- out.push_str(" if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {\n");
- out.push_str(" fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL);\n");
- out.push_str(" } elseif (!headers_sent()) {\n");
- out.push_str(" echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL;\n");
- out.push_str(" }\n");
- out.push_str(" }\n");
- out.push_str(" trigger_error(\n");
- out.push_str(
- " 'Composer detected issues in your platform: ' . implode(' ', $issues),\n",
- );
- out.push_str(" E_USER_ERROR\n");
- out.push_str(" );\n");
- out.push_str("}\n");
-
- Some(out)
-}
-
-/// Generate `vendor/composer/autoload_real.php`.
-fn generate_autoload_real(
- suffix: &str,
- has_files: bool,
- classmap_authoritative: bool,
- apcu: bool,
- apcu_prefix: Option<&str>,
- has_platform_check: bool,
-) -> String {
- let mut out = String::new();
- out.push_str("<?php\n\n");
- out.push_str("// autoload_real.php @generated by Composer\n\n");
- out.push_str(&format!("class ComposerAutoloaderInit{suffix}\n"));
- out.push_str("{\n");
- out.push_str(" private static $loader;\n\n");
- out.push_str(" public static function loadClassLoader($class)\n");
- out.push_str(" {\n");
- out.push_str(" if ('Composer\\Autoload\\ClassLoader' === $class) {\n");
- out.push_str(" require __DIR__ . '/ClassLoader.php';\n");
- out.push_str(" }\n");
- out.push_str(" }\n\n");
- out.push_str(" /**\n");
- out.push_str(" * @return \\Composer\\Autoload\\ClassLoader\n");
- out.push_str(" */\n");
- out.push_str(" public static function getLoader()\n");
- out.push_str(" {\n");
- out.push_str(" if (null !== self::$loader) {\n");
- out.push_str(" return self::$loader;\n");
- out.push_str(" }\n\n");
- out.push_str(&format!(
- " spl_autoload_register(array('ComposerAutoloaderInit{suffix}', 'loadClassLoader'), true, true);\n"
- ));
- out.push_str(
- " self::$loader = $loader = new \\Composer\\Autoload\\ClassLoader(\\dirname(__DIR__));\n",
- );
- out.push_str(&format!(
- " spl_autoload_unregister(array('ComposerAutoloaderInit{suffix}', 'loadClassLoader'));\n\n"
- ));
- if has_platform_check {
- out.push_str(" require __DIR__ . '/platform_check.php';\n");
- }
- out.push_str(" require __DIR__ . '/autoload_static.php';\n");
- out.push_str(&format!(
- " call_user_func(\\Composer\\Autoload\\ComposerStaticInit{suffix}::getInitializer($loader));\n\n"
- ));
- out.push_str(" $loader->register(true);\n");
-
- if classmap_authoritative {
- out.push_str(" $loader->setClassMapAuthoritative(true);\n");
- }
-
- if apcu {
- let prefix = apcu_prefix.unwrap_or(suffix);
- let escaped = php_escape(prefix);
- out.push_str(&format!(" $loader->setApcuPrefix('{escaped}');\n"));
- }
-
- if has_files {
- out.push('\n');
- out.push_str(&format!(
- " $filesToLoad = \\Composer\\Autoload\\ComposerStaticInit{suffix}::$files;\n"
- ));
- out.push_str(
- " $requireFile = \\Closure::bind(static function ($fileIdentifier, $file) {\n",
- );
- out.push_str(
- " if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) {\n",
- );
- out.push_str(
- " $GLOBALS['__composer_autoload_files'][$fileIdentifier] = true;\n",
- );
- out.push('\n');
- out.push_str(" require $file;\n");
- out.push_str(" }\n");
- out.push_str(" }, null, null);\n");
- out.push_str(" foreach ($filesToLoad as $fileIdentifier => $file) {\n");
- out.push_str(" $requireFile($fileIdentifier, $file);\n");
- out.push_str(" }\n");
- }
-
- out.push('\n');
- out.push_str(" return $loader;\n");
- out.push_str(" }\n");
- out.push_str("}\n");
- out
-}
-
-/// Generate `vendor/autoload.php` (the entry point).
-fn generate_autoload_php(suffix: &str) -> String {
- let mut out = String::new();
- out.push_str("<?php\n\n");
- out.push_str("// autoload.php @generated by Composer\n\n");
- out.push_str("if (PHP_VERSION_ID < 50600) {\n");
- out.push_str(" if (!headers_sent()) {\n");
- out.push_str(" header('HTTP/1.1 500 Internal Server Error');\n");
- out.push_str(" }\n");
- out.push_str(" $err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via \"composer self-update --2.2\". Aborting.'.PHP_EOL;\n");
- out.push_str(" if (!ini_get('display_errors')) {\n");
- out.push_str(" if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {\n");
- out.push_str(" fwrite(STDERR, $err);\n");
- out.push_str(" } elseif (!headers_sent()) {\n");
- out.push_str(" echo $err;\n");
- out.push_str(" }\n");
- out.push_str(" }\n");
- out.push_str(" throw new RuntimeException($err);\n");
- out.push_str("}\n\n");
- out.push_str("require_once __DIR__ . '/composer/autoload_real.php';\n\n");
- out.push_str(&format!(
- "return ComposerAutoloaderInit{suffix}::getLoader();\n"
- ));
- out
-}
-
-/// Generate `vendor/composer/installed.php`.
-fn generate_installed_php(
- root_name: &str,
- root_type: &str,
- installed: &InstalledPackages,
- dev_mode: bool,
-) -> String {
- let dev_str = if dev_mode { "true" } else { "false" };
-
- let mut out = String::new();
- out.push_str("<?php return array(\n");
- out.push_str(" 'root' => array(\n");
- out.push_str(&format!(" 'name' => '{}',\n", php_escape(root_name)));
- out.push_str(" 'pretty_version' => 'dev-main',\n");
- out.push_str(" 'version' => 'dev-main',\n");
- out.push_str(" 'reference' => null,\n");
- out.push_str(&format!(" 'type' => '{}',\n", php_escape(root_type)));
- out.push_str(" 'install_path' => __DIR__ . '/../../',\n");
- out.push_str(" 'aliases' => array(),\n");
- out.push_str(&format!(" 'dev' => {dev_str},\n"));
- out.push_str(" ),\n");
- out.push_str(" 'versions' => array(\n");
-
- for pkg in &installed.packages {
- let version = &pkg.version;
- let version_normalized = pkg.version_normalized.as_deref().unwrap_or(version);
- let pkg_type = pkg.package_type.as_deref().unwrap_or("library");
- let is_dev = installed
- .dev_package_names
- .iter()
- .any(|n| n.eq_ignore_ascii_case(&pkg.name));
- let is_dev_str = if is_dev { "true" } else { "false" };
-
- out.push_str(&format!(" '{}' => array(\n", php_escape(&pkg.name)));
- out.push_str(&format!(
- " 'pretty_version' => '{}',\n",
- php_escape(version)
- ));
- out.push_str(&format!(
- " 'version' => '{}',\n",
- php_escape(version_normalized)
- ));
- out.push_str(" 'reference' => null,\n");
- out.push_str(&format!(
- " 'type' => '{}',\n",
- php_escape(pkg_type)
- ));
- // Install path relative to vendor/composer/installed.php: __DIR__ . '/./' . relative_name
- // The install_path stored is like '../psr/log', relative to vendor/composer/
- // So from vendor/composer/, the package is at __DIR__ . '/../psr/log/'
- out.push_str(&format!(
- " 'install_path' => __DIR__ . '/../{}/',\n",
- pkg.name
- ));
- out.push_str(" 'aliases' => array(),\n");
- out.push_str(&format!(" 'dev_requirement' => {is_dev_str},\n"));
- out.push_str(" ),\n");
- }
-
- out.push_str(" ),\n");
- out.push_str(");\n");
- out
-}
-
-/// Determine the autoloader suffix.
-///
-/// Priority:
-/// 1. Existing `vendor/autoload.php` suffix (carry over to avoid breaking existing references).
-/// 2. Lock file `content-hash` (if locked).
-/// 3. Fall back to a timestamp-based hex string.
-pub fn determine_suffix(working_dir: &Path, vendor_dir: &Path) -> anyhow::Result<String> {
- // Try existing autoload.php
- let autoload_path = vendor_dir.join("autoload.php");
- if autoload_path.exists() {
- let content = std::fs::read_to_string(&autoload_path)?;
- if let Some(start) = content.find("ComposerAutoloaderInit") {
- let rest = &content[start + "ComposerAutoloaderInit".len()..];
- if let Some(end) = rest.find("::") {
- let suffix = &rest[..end];
- if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_hexdigit()) {
- return Ok(suffix.to_string());
- }
- }
- }
- }
-
- // Try composer.lock content-hash
- let lock_path = working_dir.join("composer.lock");
- if lock_path.exists() {
- let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?;
- return Ok(lock.content_hash);
- }
-
- // Fall back to MD5 of current timestamp
- let ts = format!("{:?}", std::time::SystemTime::now());
- Ok(format!("{:x}", md5::compute(ts.as_bytes())))
-}
-
-/// Generate all autoloader files for the given project.
-///
-/// This is the main entry point called by `install` and `dump-autoload`.
-pub fn generate(config: &AutoloadConfig) -> anyhow::Result<()> {
- // 1. Read installed.json
- let installed = InstalledPackages::read(&config.vendor_dir)?;
-
- // 2. Read root package autoload from composer.json
- let composer_json_path = config.project_dir.join("composer.json");
- let (root_autoload, root_autoload_dev, root_name, root_type) = if composer_json_path.exists() {
- let content = std::fs::read_to_string(&composer_json_path)?;
- let value: serde_json::Value = serde_json::from_str(&content)?;
- (
- value.get("autoload").cloned(),
- value.get("autoload-dev").cloned(),
- value
- .get("name")
- .and_then(|n| n.as_str())
- .unwrap_or("__root__")
- .to_string(),
- value
- .get("type")
- .and_then(|t| t.as_str())
- .unwrap_or("project")
- .to_string(),
- )
- } else {
- (None, None, "__root__".to_string(), "project".to_string())
- };
-
- // 3. Collect autoload data
- let (mut data, mut static_data) = collect_autoloads(
- &installed,
- root_autoload.as_ref(),
- root_autoload_dev.as_ref(),
- &root_name,
- config.dev_mode,
- );
-
- // 3a. Read classmap dirs declared in composer.json
- let excluded: Vec<String> = root_autoload
- .as_ref()
- .and_then(|v| v.get("exclude-from-classmap"))
- .and_then(|v| v.as_array())
- .map(|arr| {
- arr.iter()
- .filter_map(|v| v.as_str().map(|s| s.to_string()))
- .collect()
- })
- .unwrap_or_default();
-
- // Scan explicit classmap dirs from all packages
- let mut classmap_dirs: Vec<PathBuf> = Vec::new();
-
- // Collect classmap dirs from installed packages
- for pkg in &installed.packages {
- if let Some(autoload_val) = &pkg.autoload
- && let Some(cm_arr) = autoload_val.get("classmap").and_then(|v| v.as_array())
- {
- for cm_val in cm_arr {
- if let Some(cm_path) = cm_val.as_str() {
- let abs = config.vendor_dir.join(&pkg.name).join(cm_path);
- classmap_dirs.push(abs);
- }
- }
- }
- }
-
- // Collect classmap dirs from root autoload
- if let Some(autoload_val) = root_autoload.as_ref()
- && let Some(cm_arr) = autoload_val.get("classmap").and_then(|v| v.as_array())
- {
- for cm_val in cm_arr {
- if let Some(cm_path) = cm_val.as_str() {
- let abs = config.project_dir.join(cm_path);
- classmap_dirs.push(abs);
- }
- }
- }
-
- // Scan classmap dirs
- if !classmap_dirs.is_empty() {
- let scanned = scan_classmap_dirs(
- &classmap_dirs,
- &config.vendor_dir,
- &config.project_dir,
- &excluded,
- );
- for (class, path_expr) in scanned {
- // Also generate the static expression
- // We store the dynamic expression in data.classmap; static_data.classmap
- // will be populated similarly. For now we insert into both.
- data.classmap.entry(class.clone()).or_insert(path_expr);
- // Generate corresponding static expr by replacing dynamic prefixes
- // (static_data classmap is populated in the static pass below)
- }
- }
-
- // 3b. Optimize mode: scan PSR-4/PSR-0 dirs for classmap
- let do_optimize = config.optimize || config.classmap_authoritative;
- let mut psr_violations: Vec<String> = Vec::new();
-
- if do_optimize {
- let (opt_dyn, opt_static, violations) = scan_psr_for_classmap(
- &data.psr4,
- &data.psr0,
- &config.vendor_dir,
- &config.project_dir,
- &excluded,
- );
- psr_violations = violations;
- for (class, path_expr) in opt_dyn {
- data.classmap.entry(class).or_insert(path_expr);
- }
- for (class, path_expr) in opt_static {
- static_data.classmap.entry(class).or_insert(path_expr);
- }
- }
-
- // 3c. Handle strict-psr violations
- if config.strict_psr && !psr_violations.is_empty() {
- for violation in &psr_violations {
- eprintln!("PSR violation: {violation}");
- }
- return Err(anyhow::anyhow!(
- "PSR mapping violations detected (--strict-psr). Run without --strict-psr to ignore."
- ));
- }
-
- // 4. Generate and write files
- let composer_dir = config.vendor_dir.join("composer");
- std::fs::create_dir_all(&composer_dir)?;
-
- std::fs::write(
- composer_dir.join("autoload_psr4.php"),
- generate_autoload_psr4(&data),
- )?;
- std::fs::write(
- composer_dir.join("autoload_namespaces.php"),
- generate_autoload_namespaces(&data),
- )?;
- std::fs::write(
- composer_dir.join("autoload_classmap.php"),
- generate_autoload_classmap(&data),
- )?;
-
- if let Some(files_content) = generate_autoload_files(&data) {
- std::fs::write(composer_dir.join("autoload_files.php"), files_content)?;
- } else {
- // Remove stale file if it exists
- let files_path = composer_dir.join("autoload_files.php");
- if files_path.exists() {
- std::fs::remove_file(files_path)?;
- }
- }
-
- // 4a. Generate platform_check.php if needed
- let dev_package_names_set: HashSet<String> = installed
- .dev_package_names
- .iter()
- .map(|n| n.to_lowercase())
- .collect();
-
- // Re-read composer.json for root require (not from autoload, but from root "require" key)
- let root_require_val: Option<serde_json::Value> = if composer_json_path.exists() {
- let content = std::fs::read_to_string(&composer_json_path)?;
- let value: serde_json::Value = serde_json::from_str(&content)?;
- value.get("require").cloned()
- } else {
- None
- };
-
- let all_locked: Vec<LockedPackage> = {
- // Collect locked packages from installed for platform check
- // (installed.packages are LockedPackage-compatible via InstalledPackageEntry)
- // We'll build minimal LockedPackage-like data from installed entries
- installed
- .packages
- .iter()
- .map(|p| mozart_registry::lockfile::LockedPackage {
- name: p.name.clone(),
- version: p.version.clone(),
- version_normalized: p.version_normalized.clone(),
- source: None,
- dist: None,
- require: std::collections::BTreeMap::new(),
- require_dev: std::collections::BTreeMap::new(),
- conflict: std::collections::BTreeMap::new(),
- suggest: None,
- package_type: p.package_type.clone(),
- autoload: p.autoload.clone(),
- autoload_dev: None,
- license: None,
- description: None,
- homepage: None,
- keywords: None,
- authors: None,
- support: None,
- funding: None,
- time: None,
- extra_fields: std::collections::BTreeMap::new(),
- })
- .collect()
- };
-
- let effective_mode = if config.ignore_platform_reqs {
- PlatformCheckMode::Disabled
- } else {
- config.platform_check.clone()
- };
-
- let platform_check_content = generate_platform_check(
- &all_locked,
- root_require_val.as_ref(),
- &effective_mode,
- &dev_package_names_set,
- );
- let has_platform_check = platform_check_content.is_some();
-
- if let Some(content) = platform_check_content {
- std::fs::write(composer_dir.join("platform_check.php"), content)?;
- } else {
- let pc_path = composer_dir.join("platform_check.php");
- if pc_path.exists() {
- std::fs::remove_file(pc_path)?;
- }
- }
-
- let has_files = !data.files.is_empty();
- let use_apcu = config.apcu || config.apcu_prefix.is_some();
- std::fs::write(
- composer_dir.join("autoload_static.php"),
- generate_autoload_static(&static_data, &config.suffix),
- )?;
- std::fs::write(
- composer_dir.join("autoload_real.php"),
- generate_autoload_real(
- &config.suffix,
- has_files,
- config.classmap_authoritative,
- use_apcu,
- config.apcu_prefix.as_deref(),
- has_platform_check,
- ),
- )?;
- std::fs::write(
- config.vendor_dir.join("autoload.php"),
- generate_autoload_php(&config.suffix),
- )?;
-
- // 5. Copy ClassLoader.php, InstalledVersions.php, LICENSE
- std::fs::write(composer_dir.join("ClassLoader.php"), CLASSLOADER_PHP)?;
- std::fs::write(
- composer_dir.join("InstalledVersions.php"),
- INSTALLED_VERSIONS_PHP,
- )?;
- std::fs::write(composer_dir.join("LICENSE"), COMPOSER_LICENSE)?;
-
- // 6. Generate installed.php
- std::fs::write(
- composer_dir.join("installed.php"),
- generate_installed_php(&root_name, &root_type, &installed, config.dev_mode),
- )?;
-
- Ok(())
-}
-
-// ─────────────────────────────────────────────────────────────────────────────
-// Tests
-// ─────────────────────────────────────────────────────────────────────────────
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use mozart_registry::installed::{InstalledPackageEntry, InstalledPackages};
- use std::collections::BTreeMap;
- use tempfile::tempdir;
-
- fn make_installed_pkg(name: &str, version: &str) -> InstalledPackageEntry {
- InstalledPackageEntry {
- name: name.to_string(),
- version: version.to_string(),
- version_normalized: None,
- source: None,
- dist: None,
- package_type: Some("library".to_string()),
- install_path: Some(format!("../{name}")),
- autoload: None,
- aliases: vec![],
- extra_fields: BTreeMap::new(),
- }
- }
-
- fn make_installed_pkg_with_autoload(
- name: &str,
- version: &str,
- autoload: serde_json::Value,
- ) -> InstalledPackageEntry {
- let mut entry = make_installed_pkg(name, version);
- entry.autoload = Some(autoload);
- entry
- }
-
- // -------------------------------------------------------------------------
- // Helper function tests
- // -------------------------------------------------------------------------
-
- #[test]
- fn test_php_escape_backslash() {
- assert_eq!(php_escape("Psr\\Log\\"), "Psr\\\\Log\\\\");
- }
-
- #[test]
- fn test_php_escape_quote() {
- assert_eq!(php_escape("don't"), "don\\'t");
- }
-
- #[test]
- fn test_php_escape_mixed() {
- assert_eq!(php_escape("A\\B'C"), "A\\\\B\\'C");
- }
-
- #[test]
- fn test_file_identifier_known_vector() {
- // Known test vector from Composer docs:
- // md5("symfony/polyfill-php80:bootstrap.php") = "a4a119a56e50fbb293281d9a48007e0e"
- let id = file_identifier("symfony/polyfill-php80", "bootstrap.php");
- assert_eq!(id, "a4a119a56e50fbb293281d9a48007e0e");
- }
-
- #[test]
- fn test_file_identifier_format() {
- let id = file_identifier("psr/log", "src/functions.php");
- // Should be 32 hex chars (MD5)
- assert_eq!(id.len(), 32);
- assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
- }
-
- #[test]
- fn test_json_to_paths_string() {
- let v = serde_json::json!("src/");
- assert_eq!(json_to_paths(&v), vec!["src/"]);
- }
-
- #[test]
- fn test_json_to_paths_array() {
- let v = serde_json::json!(["src/", "lib/"]);
- assert_eq!(json_to_paths(&v), vec!["src/", "lib/"]);
- }
-
- #[test]
- fn test_json_to_paths_invalid() {
- let v = serde_json::json!(42);
- assert!(json_to_paths(&v).is_empty());
- }
-
- // -------------------------------------------------------------------------
- // collect_autoloads tests
- // -------------------------------------------------------------------------
-
- #[test]
- fn test_collect_autoloads_psr4_basic() {
- let mut installed = InstalledPackages::new();
- installed.upsert(make_installed_pkg_with_autoload(
- "psr/log",
- "3.0.2",
- serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}),
- ));
-
- let (data, _static_data) = collect_autoloads(&installed, None, None, "__root__", false);
-
- assert!(data.psr4.contains_key("Psr\\Log\\"));
- let paths = &data.psr4["Psr\\Log\\"];
- assert_eq!(paths.len(), 1);
- assert_eq!(paths[0], "$vendorDir . '/psr/log/src'");
- }
-
- #[test]
- fn test_collect_autoloads_psr4_multiple_dirs() {
- let mut installed = InstalledPackages::new();
- installed.upsert(make_installed_pkg_with_autoload(
- "monolog/monolog",
- "3.8.0",
- serde_json::json!({"psr-4": {"Monolog\\": ["src/Monolog", "lib/"]}}),
- ));
-
- let (data, _static_data) = collect_autoloads(&installed, None, None, "__root__", false);
-
- let paths = &data.psr4["Monolog\\"];
- assert_eq!(paths.len(), 2);
- assert_eq!(paths[0], "$vendorDir . '/monolog/monolog/src/Monolog'");
- assert_eq!(paths[1], "$vendorDir . '/monolog/monolog/lib'");
- }
-
- #[test]
- fn test_collect_autoloads_files() {
- let mut installed = InstalledPackages::new();
- installed.upsert(make_installed_pkg_with_autoload(
- "symfony/polyfill-php80",
- "1.32.0",
- serde_json::json!({"files": ["bootstrap.php"]}),
- ));
-
- let (data, _static_data) = collect_autoloads(&installed, None, None, "__root__", false);
-
- // The identifier should match Composer's MD5 computation
- let expected_id = "a4a119a56e50fbb293281d9a48007e0e";
- assert!(data.files.contains_key(expected_id));
- assert_eq!(
- data.files[expected_id],
- "$vendorDir . '/symfony/polyfill-php80/bootstrap.php'"
- );
- }
-
- #[test]
- fn test_collect_autoloads_root_package() {
- let installed = InstalledPackages::new();
- let root_autoload = serde_json::json!({"psr-4": {"App\\": "src/"}});
-
- let (data, _static_data) = collect_autoloads(
- &installed,
- Some(&root_autoload),
- None,
- "myproject/app",
- false,
- );
-
- assert!(data.psr4.contains_key("App\\"));
- let paths = &data.psr4["App\\"];
- assert_eq!(paths[0], "$baseDir . '/src'");
- }
-
- #[test]
- fn test_collect_autoloads_root_autoload_dev_included_when_dev() {
- let installed = InstalledPackages::new();
- let root_autoload_dev = serde_json::json!({"psr-4": {"Tests\\": "tests/"}});
-
- let (data, _) = collect_autoloads(
- &installed,
- None,
- Some(&root_autoload_dev),
- "myproject/app",
- true, // dev_mode = true
- );
-
- assert!(data.psr4.contains_key("Tests\\"));
- }
-
- #[test]
- fn test_collect_autoloads_root_autoload_dev_excluded_when_no_dev() {
- let installed = InstalledPackages::new();
- let root_autoload_dev = serde_json::json!({"psr-4": {"Tests\\": "tests/"}});
-
- let (data, _) = collect_autoloads(
- &installed,
- None,
- Some(&root_autoload_dev),
- "myproject/app",
- false, // dev_mode = false
- );
-
- assert!(!data.psr4.contains_key("Tests\\"));
- }
-
- // -------------------------------------------------------------------------
- // generate_autoload_psr4 tests
- // -------------------------------------------------------------------------
-
- #[test]
- fn test_generate_autoload_psr4_output() {
- let mut installed = InstalledPackages::new();
- installed.upsert(make_installed_pkg_with_autoload(
- "psr/log",
- "3.0.2",
- serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}),
- ));
-
- let (data, _) = collect_autoloads(&installed, None, None, "__root__", false);
- let output = generate_autoload_psr4(&data);
-
- assert!(output.contains("<?php"));
- assert!(output.contains("autoload_psr4.php @generated by Composer"));
- assert!(output.contains("$vendorDir = dirname(__DIR__);"));
- assert!(output.contains("$baseDir = dirname($vendorDir);"));
- assert!(output.contains("'Psr\\\\Log\\\\'"));
- assert!(output.contains("$vendorDir . '/psr/log/src'"));
- assert!(output.starts_with("<?php\n"));
- }
-
- #[test]
- fn test_generate_autoload_psr4_empty() {
- let data = AutoloadData {
- psr4: BTreeMap::new(),
- psr0: BTreeMap::new(),
- classmap: BTreeMap::new(),
- files: BTreeMap::new(),
- };
- let output = generate_autoload_psr4(&data);
- assert!(output.contains("return array(\n);"));
- }
-
- #[test]
- fn test_generate_autoload_psr4_sorted_reverse() {
- let mut installed = InstalledPackages::new();
- installed.upsert(make_installed_pkg_with_autoload(
- "aaa/pkg",
- "1.0.0",
- serde_json::json!({"psr-4": {"Aaa\\": "src/"}}),
- ));
- installed.upsert(make_installed_pkg_with_autoload(
- "zzz/pkg",
- "1.0.0",
- serde_json::json!({"psr-4": {"Zzz\\": "src/"}}),
- ));
-
- let (data, _) = collect_autoloads(&installed, None, None, "__root__", false);
- let output = generate_autoload_psr4(&data);
-
- // Zzz should appear before Aaa (reverse sort)
- let zzz_pos = output.find("Zzz").unwrap();
- let aaa_pos = output.find("Aaa").unwrap();
- assert!(
- zzz_pos < aaa_pos,
- "Zzz should appear before Aaa (reverse sort)"
- );
- }
-
- // -------------------------------------------------------------------------
- // generate_autoload_static tests
- // -------------------------------------------------------------------------
-
- #[test]
- fn test_generate_autoload_static_output() {
- let mut installed = InstalledPackages::new();
- installed.upsert(make_installed_pkg_with_autoload(
- "psr/log",
- "3.0.2",
- serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}),
- ));
-
- let (_, static_data) = collect_autoloads(&installed, None, None, "__root__", false);
- let output = generate_autoload_static(&static_data, "abc123");
-
- assert!(output.contains("class ComposerStaticInitabc123"));
- assert!(output.contains("$prefixLengthsPsr4"));
- assert!(output.contains("$prefixDirsPsr4"));
- assert!(output.contains("$classMap"));
- assert!(output.contains("Composer\\\\InstalledVersions"));
- assert!(output.contains("getInitializer"));
- assert!(output.contains("__DIR__ . '/..' . '/psr/log/src'"));
- }
-
- #[test]
- fn test_generate_autoload_static_prefix_lengths() {
- let mut installed = InstalledPackages::new();
- // "Psr\Log\" = 8 bytes (with single backslashes)
- installed.upsert(make_installed_pkg_with_autoload(
- "psr/log",
- "3.0.2",
- serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}),
- ));
-
- let (_, static_data) = collect_autoloads(&installed, None, None, "__root__", false);
- let output = generate_autoload_static(&static_data, "test");
-
- // The namespace "Psr\Log\" is 8 bytes
- assert!(output.contains("'Psr\\\\Log\\\\' => 8"));
- }
-
- // -------------------------------------------------------------------------
- // generate_autoload_real tests
- // -------------------------------------------------------------------------
-
- #[test]
- fn test_generate_autoload_real_with_files() {
- let output = generate_autoload_real("abc123", true, false, false, None, false);
- assert!(output.contains("class ComposerAutoloaderInitabc123"));
- assert!(output.contains("ComposerStaticInitabc123::$files"));
- assert!(output.contains("$requireFile"));
- assert!(output.contains("__composer_autoload_files"));
- }
-
- #[test]
- fn test_generate_autoload_real_without_files() {
- let output = generate_autoload_real("abc123", false, false, false, None, false);
- assert!(output.contains("class ComposerAutoloaderInitabc123"));
- assert!(!output.contains("$filesToLoad"));
- assert!(!output.contains("__composer_autoload_files"));
- }
-
- #[test]
- fn test_generate_autoload_real_apcu() {
- let output = generate_autoload_real("abc123", false, false, true, None, false);
- assert!(output.contains("setApcuPrefix('abc123')"));
- }
-
- #[test]
- fn test_generate_autoload_real_apcu_custom_prefix() {
- let output = generate_autoload_real("abc123", false, false, true, Some("myprefix"), false);
- assert!(output.contains("setApcuPrefix('myprefix')"));
- }
-
- #[test]
- fn test_generate_autoload_real_platform_check() {
- let output = generate_autoload_real("abc123", false, false, false, None, true);
- assert!(output.contains("require __DIR__ . '/platform_check.php'"));
- }
-
- #[test]
- fn test_generate_autoload_real_no_platform_check() {
- let output = generate_autoload_real("abc123", false, false, false, None, false);
- assert!(!output.contains("platform_check.php"));
- }
-
- // -------------------------------------------------------------------------
- // generate_installed_php tests
- // -------------------------------------------------------------------------
-
- #[test]
- fn test_generate_installed_php() {
- let mut installed = InstalledPackages::new();
- let mut pkg = make_installed_pkg("psr/log", "3.0.2");
- pkg.version_normalized = Some("3.0.2.0".to_string());
- installed.upsert(pkg);
-
- let output = generate_installed_php("myproject/app", "project", &installed, true);
-
- assert!(output.contains("'name' => 'myproject/app'"));
- assert!(output.contains("'type' => 'project'"));
- assert!(output.contains("'dev' => true"));
- assert!(output.contains("'psr/log'"));
- assert!(output.contains("'pretty_version' => '3.0.2'"));
- assert!(output.contains("'version' => '3.0.2.0'"));
- assert!(output.contains("__DIR__ . '/../psr/log/'"));
- assert!(output.contains("'dev_requirement' => false"));
- }
-
- #[test]
- fn test_generate_installed_php_dev_package() {
- let mut installed = InstalledPackages::new();
- installed.upsert(make_installed_pkg("phpunit/phpunit", "11.0.0"));
- installed
- .dev_package_names
- .push("phpunit/phpunit".to_string());
-
- let output = generate_installed_php("test/project", "project", &installed, true);
-
- assert!(output.contains("'dev_requirement' => true"));
- }
-
- // -------------------------------------------------------------------------
- // generate() integration test
- // -------------------------------------------------------------------------
-
- #[test]
- fn test_generate_full_roundtrip() {
- let dir = tempdir().unwrap();
- let project_dir = dir.path().to_path_buf();
- let vendor_dir = project_dir.join("vendor");
-
- // Write a minimal composer.json
- std::fs::write(
- project_dir.join("composer.json"),
- r#"{"name": "test/project", "type": "project", "autoload": {"psr-4": {"App\\": "src/"}}}"#,
- )
- .unwrap();
-
- // Write a minimal installed.json
- let mut installed = InstalledPackages::new();
- installed.upsert(make_installed_pkg_with_autoload(
- "psr/log",
- "3.0.2",
- serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}),
- ));
- installed.write(&vendor_dir).unwrap();
-
- let config = AutoloadConfig {
- project_dir: project_dir.clone(),
- vendor_dir: vendor_dir.clone(),
- dev_mode: false,
- suffix: "abc123def456".to_string(),
- classmap_authoritative: false,
- optimize: false,
- apcu: false,
- apcu_prefix: None,
- strict_psr: false,
- platform_check: PlatformCheckMode::Disabled,
- ignore_platform_reqs: false,
- };
-
- generate(&config).unwrap();
-
- // Verify all expected files exist
- assert!(
- vendor_dir.join("autoload.php").exists(),
- "autoload.php should exist"
- );
- assert!(
- vendor_dir.join("composer/autoload_psr4.php").exists(),
- "autoload_psr4.php should exist"
- );
- assert!(
- vendor_dir.join("composer/autoload_namespaces.php").exists(),
- "autoload_namespaces.php should exist"
- );
- assert!(
- vendor_dir.join("composer/autoload_classmap.php").exists(),
- "autoload_classmap.php should exist"
- );
- assert!(
- vendor_dir.join("composer/autoload_static.php").exists(),
- "autoload_static.php should exist"
- );
- assert!(
- vendor_dir.join("composer/autoload_real.php").exists(),
- "autoload_real.php should exist"
- );
- assert!(
- vendor_dir.join("composer/ClassLoader.php").exists(),
- "ClassLoader.php should exist"
- );
- assert!(
- vendor_dir.join("composer/InstalledVersions.php").exists(),
- "InstalledVersions.php should exist"
- );
- assert!(
- vendor_dir.join("composer/installed.php").exists(),
- "installed.php should exist"
- );
- assert!(
- vendor_dir.join("composer/LICENSE").exists(),
- "LICENSE should exist"
- );
- // autoload_files.php should NOT exist (no files autoloading)
- assert!(
- !vendor_dir.join("composer/autoload_files.php").exists(),
- "autoload_files.php should not exist when no files"
- );
-
- // Check autoload.php content
- let autoload_php = std::fs::read_to_string(vendor_dir.join("autoload.php")).unwrap();
- assert!(autoload_php.contains("ComposerAutoloaderInitabc123def456"));
-
- // Check autoload_psr4.php
- let psr4_php =
- std::fs::read_to_string(vendor_dir.join("composer/autoload_psr4.php")).unwrap();
- assert!(psr4_php.contains("Psr\\\\Log\\\\"));
- assert!(psr4_php.contains("App\\\\"));
- assert!(psr4_php.contains("$vendorDir . '/psr/log/src'"));
- assert!(psr4_php.contains("$baseDir . '/src'"));
-
- // Check installed.php
- let installed_php =
- std::fs::read_to_string(vendor_dir.join("composer/installed.php")).unwrap();
- assert!(installed_php.contains("'name' => 'test/project'"));
- assert!(installed_php.contains("'psr/log'"));
- }
-
- #[test]
- fn test_generate_with_files_autoload() {
- let dir = tempdir().unwrap();
- let project_dir = dir.path().to_path_buf();
- let vendor_dir = project_dir.join("vendor");
-
- std::fs::write(
- project_dir.join("composer.json"),
- r#"{"name": "test/project", "type": "project"}"#,
- )
- .unwrap();
-
- let mut installed = InstalledPackages::new();
- installed.upsert(make_installed_pkg_with_autoload(
- "symfony/polyfill-php80",
- "1.32.0",
- serde_json::json!({"files": ["bootstrap.php"]}),
- ));
- installed.write(&vendor_dir).unwrap();
-
- let config = AutoloadConfig {
- project_dir: project_dir.clone(),
- vendor_dir: vendor_dir.clone(),
- dev_mode: false,
- suffix: "test".to_string(),
- classmap_authoritative: false,
- optimize: false,
- apcu: false,
- apcu_prefix: None,
- strict_psr: false,
- platform_check: PlatformCheckMode::Disabled,
- ignore_platform_reqs: false,
- };
-
- generate(&config).unwrap();
-
- // autoload_files.php SHOULD exist
- assert!(
- vendor_dir.join("composer/autoload_files.php").exists(),
- "autoload_files.php should exist when files are present"
- );
-
- let files_php =
- std::fs::read_to_string(vendor_dir.join("composer/autoload_files.php")).unwrap();
- assert!(files_php.contains("a4a119a56e50fbb293281d9a48007e0e"));
- assert!(files_php.contains("$vendorDir . '/symfony/polyfill-php80/bootstrap.php'"));
-
- // autoload_real.php should contain the files loading block
- let real_php =
- std::fs::read_to_string(vendor_dir.join("composer/autoload_real.php")).unwrap();
- assert!(real_php.contains("$filesToLoad"));
- }
-}
diff --git a/crates/mozart/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<String> {
- if !self.enabled {
- return None;
- }
- fs::read_to_string(self.path_for(key)).ok()
- }
-
- /// Write a string entry atomically (write to temp file, then rename).
- pub fn write(&self, key: &str, contents: &str) -> anyhow::Result<()> {
- if !self.enabled {
- return Ok(());
- }
- self.write_bytes(key, contents.as_bytes())
- }
-
- /// Read a cached binary entry, or `None` if absent or cache disabled.
- pub fn read_bytes(&self, key: &str) -> Option<Vec<u8>> {
- if !self.enabled {
- return None;
- }
- fs::read(self.path_for(key)).ok()
- }
-
- /// Write a binary entry atomically (write to temp file, then rename).
- pub fn write_bytes(&self, key: &str, data: &[u8]) -> anyhow::Result<()> {
- if !self.enabled {
- return Ok(());
- }
- let dest = self.path_for(key);
- // Ensure parent directory exists
- if let Some(parent) = dest.parent() {
- fs::create_dir_all(parent)?;
- }
- // Write to a temp file next to the destination
- let tmp = dest.with_extension("tmp");
- fs::write(&tmp, data)?;
- fs::rename(&tmp, &dest)?;
- Ok(())
- }
-
- /// Delete all cached entries in this bucket.
- pub fn clear(&self) -> anyhow::Result<()> {
- if !self.root.exists() {
- return Ok(());
- }
- for entry in fs::read_dir(&self.root)? {
- let entry = entry?;
- let path = entry.path();
- if path.is_file() {
- fs::remove_file(&path)?;
- } else if path.is_dir() {
- fs::remove_dir_all(&path)?;
- }
- }
- Ok(())
- }
-
- /// Run garbage collection on this cache bucket.
- ///
- /// 1. Deletes files with mtime older than `ttl_seconds`.
- /// 2. If total remaining size > `max_size_bytes`, deletes the oldest files
- /// (by mtime) until the total is under the limit.
- pub fn gc(&self, ttl_seconds: u64, max_size_bytes: u64) -> anyhow::Result<()> {
- if !self.enabled || !self.root.exists() {
- return Ok(());
- }
-
- let now = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .unwrap_or_default()
- .as_secs();
-
- // Collect (path, mtime, size) for all files
- let mut files: Vec<(PathBuf, u64, u64)> = Vec::new();
- collect_files(&self.root, &mut files)?;
-
- // Phase 1: delete TTL-expired files
- let mut remaining: Vec<(PathBuf, u64, u64)> = Vec::new();
- for (path, mtime, size) in files {
- let age = now.saturating_sub(mtime);
- if age > ttl_seconds {
- let _ = fs::remove_file(&path);
- } else {
- remaining.push((path, mtime, size));
- }
- }
-
- // Phase 2: enforce size limit by deleting oldest first
- let total_size: u64 = remaining.iter().map(|(_, _, sz)| sz).sum();
- if total_size > max_size_bytes {
- // Sort by mtime ascending (oldest first)
- remaining.sort_by_key(|(_, mtime, _)| *mtime);
- let mut current_size = total_size;
- for (path, _, size) in &remaining {
- if current_size <= max_size_bytes {
- break;
- }
- if fs::remove_file(path).is_ok() {
- current_size = current_size.saturating_sub(*size);
- }
- }
- }
-
- Ok(())
- }
-
- /// Return the age in seconds of a cached entry based on its mtime,
- /// or `None` if the entry doesn't exist or mtime can't be read.
- pub fn age(&self, key: &str) -> Option<u64> {
- if !self.enabled {
- return None;
- }
- let path = self.path_for(key);
- let metadata = fs::metadata(&path).ok()?;
- let mtime = metadata.modified().ok()?;
- let now = SystemTime::now();
- now.duration_since(mtime).ok().map(|d| d.as_secs())
- }
-}
-
-/// Recursively collect all files under `dir` as `(path, mtime_secs, size_bytes)`.
-fn collect_files(dir: &Path, out: &mut Vec<(PathBuf, u64, u64)>) -> anyhow::Result<()> {
- if !dir.exists() {
- return Ok(());
- }
- for entry in fs::read_dir(dir)? {
- let entry = entry?;
- let path = entry.path();
- let metadata = entry.metadata()?;
- if metadata.is_dir() {
- collect_files(&path, out)?;
- } else if metadata.is_file() {
- let mtime = metadata
- .modified()
- .ok()
- .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
- .map(|d| d.as_secs())
- .unwrap_or(0);
- let size = metadata.len();
- out.push((path, mtime, size));
- }
- }
- Ok(())
-}
-
-// ─────────────────────────────────────────────────────────────────────────────
-// Probabilistic GC trigger
-// ─────────────────────────────────────────────────────────────────────────────
-
-/// Return `true` with a probability of 1 in 50 (based on system time nanos).
-///
-/// Used to decide whether to run GC after an install/update operation.
-pub fn gc_is_necessary() -> bool {
- let nanos = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .unwrap_or_default()
- .subsec_nanos();
- nanos.is_multiple_of(50)
-}
-
-// ─────────────────────────────────────────────────────────────────────────────
-// Tests
-// ─────────────────────────────────────────────────────────────────────────────
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use std::time::Duration;
- use tempfile::tempdir;
-
- // ──────────── sanitize_key ────────────
-
- #[test]
- fn test_sanitize_key_replaces_slash() {
- assert_eq!(Cache::sanitize_key("vendor/package"), "vendor~package");
- }
-
- #[test]
- fn test_sanitize_key_strips_unsafe_chars() {
- // Colons and spaces should be stripped
- assert_eq!(Cache::sanitize_key("foo:bar baz"), "foobarbaz");
- }
-
- #[test]
- fn test_sanitize_key_preserves_safe_chars() {
- let key = "provider-vendor~package.json";
- assert_eq!(Cache::sanitize_key(key), key);
- }
-
- #[test]
- fn test_sanitize_key_full_example() {
- assert_eq!(
- Cache::sanitize_key("provider-monolog/monolog.json"),
- "provider-monolog~monolog.json"
- );
- }
-
- // ──────────── read/write roundtrip (string) ────────────
-
- #[test]
- fn test_write_read_roundtrip_string() {
- let dir = tempdir().unwrap();
- let cache = Cache::new(dir.path().to_path_buf(), true);
-
- cache.write("test-key", "hello world").unwrap();
- let result = cache.read("test-key");
- assert_eq!(result.as_deref(), Some("hello world"));
- }
-
- // ──────────── read/write roundtrip (bytes) ────────────
-
- #[test]
- fn test_write_read_roundtrip_bytes() {
- let dir = tempdir().unwrap();
- let cache = Cache::new(dir.path().to_path_buf(), true);
-
- let data = vec![0u8, 1, 2, 3, 255];
- cache.write_bytes("bin-key", &data).unwrap();
- let result = cache.read_bytes("bin-key");
- assert_eq!(result, Some(data));
- }
-
- // ──────────── clear removes all entries ────────────
-
- #[test]
- fn test_clear_removes_all_entries() {
- let dir = tempdir().unwrap();
- let cache = Cache::new(dir.path().to_path_buf(), true);
-
- cache.write("key1", "value1").unwrap();
- cache.write("key2", "value2").unwrap();
- assert!(cache.read("key1").is_some());
- assert!(cache.read("key2").is_some());
-
- cache.clear().unwrap();
-
- assert!(cache.read("key1").is_none());
- assert!(cache.read("key2").is_none());
- }
-
- // ──────────── disabled cache returns None ────────────
-
- #[test]
- fn test_disabled_cache_returns_none() {
- let dir = tempdir().unwrap();
- let cache = Cache::new(dir.path().to_path_buf(), false);
-
- // Write should silently succeed (no-op)
- cache.write("key", "value").unwrap();
-
- // Read should return None even if we wrote
- assert!(cache.read("key").is_none());
- assert!(cache.read_bytes("key").is_none());
- }
-
- // ──────────── GC with TTL expiration ────────────
-
- #[test]
- fn test_gc_ttl_expiration() {
- let dir = tempdir().unwrap();
- let cache = Cache::new(dir.path().to_path_buf(), true);
-
- // Write a file, then manually set its mtime to the past
- cache.write("old-key", "old content").unwrap();
- let old_path = dir.path().join(Cache::sanitize_key("old-key"));
-
- // Write a fresh file
- cache.write("new-key", "new content").unwrap();
-
- // Set the old file's mtime to 2 hours ago
- let two_hours_ago = SystemTime::now() - Duration::from_secs(7200);
- filetime::set_file_mtime(
- &old_path,
- filetime::FileTime::from_system_time(two_hours_ago),
- )
- .unwrap();
-
- // GC with TTL of 1 hour (3600 seconds)
- cache.gc(3600, u64::MAX).unwrap();
-
- // Old file should be deleted, new file should remain
- assert!(
- cache.read("old-key").is_none(),
- "expired file should be deleted"
- );
- assert!(cache.read("new-key").is_some(), "fresh file should remain");
- }
-
- // ──────────── GC with size limit ────────────
-
- #[test]
- fn test_gc_size_limit() {
- let dir = tempdir().unwrap();
- let cache = Cache::new(dir.path().to_path_buf(), true);
-
- // Write two files; the first one should be older
- cache.write("old-file", "aaaaaaaaaa").unwrap(); // 10 bytes
- let old_path = dir.path().join(Cache::sanitize_key("old-file"));
-
- // Add a small delay before writing second file via mtime manipulation
- cache.write("new-file", "bbbbbbbbbb").unwrap(); // 10 bytes
-
- // Set old-file's mtime to 1 second ago so it's older
- let one_second_ago = SystemTime::now() - Duration::from_secs(1);
- filetime::set_file_mtime(
- &old_path,
- filetime::FileTime::from_system_time(one_second_ago),
- )
- .unwrap();
-
- // GC with a max size of 12 bytes (can only fit one 10-byte file)
- // TTL is very long so no TTL expiration
- cache.gc(u64::MAX / 2, 12).unwrap();
-
- // The older file should be removed to get under the size limit
- assert!(
- cache.read("old-file").is_none() || cache.read("new-file").is_none(),
- "at least one file should be removed to enforce size limit"
- );
- }
-
- // ──────────── age ────────────
-
- #[test]
- fn test_age_existing_entry() {
- let dir = tempdir().unwrap();
- let cache = Cache::new(dir.path().to_path_buf(), true);
-
- cache.write("fresh-key", "content").unwrap();
- let age = cache.age("fresh-key");
-
- // Should be very recent (< 5 seconds)
- assert!(age.is_some());
- assert!(age.unwrap() < 5);
- }
-
- #[test]
- fn test_age_missing_entry() {
- let dir = tempdir().unwrap();
- let cache = Cache::new(dir.path().to_path_buf(), true);
- assert!(cache.age("nonexistent-key").is_none());
- }
-
- #[test]
- fn test_age_disabled_cache() {
- let dir = tempdir().unwrap();
- let cache = Cache::new(dir.path().to_path_buf(), false);
- assert!(cache.age("any-key").is_none());
- }
-}
diff --git a/crates/mozart/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)
-// ---------------------------------------------------------------------------
-
-/// `<info>` — green foreground
-pub fn info(message: &str) -> ColoredString {
- message.green()
-}
-
-/// `<comment>` — yellow foreground
-pub fn comment(message: &str) -> ColoredString {
- message.yellow()
-}
-
-/// `<error>` — white on red
-pub fn error(message: &str) -> ColoredString {
- message.white().on_red()
-}
-
-/// `<question>` — black on cyan
-pub fn question(message: &str) -> ColoredString {
- message.black().on_cyan()
-}
-
-/// `<highlight>` — red foreground (Composer extension)
-pub fn highlight(message: &str) -> ColoredString {
- message.red()
-}
-
-/// `<warning>` — black on yellow (Composer extension)
-pub fn warning(message: &str) -> ColoredString {
- message.black().on_yellow()
-}
-
-// ---------------------------------------------------------------------------
-// Verbosity
-// ---------------------------------------------------------------------------
-
-/// Output verbosity level, ordered from least to most verbose.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
-pub enum Verbosity {
- /// `-q` / `--quiet`: suppress all non-error output.
- Quiet,
- /// Default: normal informational messages.
- Normal,
- /// `-v`: additional detail (URLs, cache hits, skips).
- Verbose,
- /// `-vv`: HTTP details, file operations, resolver iterations.
- VeryVerbose,
- /// `-vvv`: full debug output (headers, raw payloads, timing).
- Debug,
-}
-
-impl Verbosity {
- /// Construct a `Verbosity` from CLI flag counts.
- ///
- /// - `quiet == true` → `Quiet` (takes priority over `-v` flags)
- /// - `verbose_count == 0` → `Normal`
- /// - `verbose_count == 1` → `Verbose`
- /// - `verbose_count == 2` → `VeryVerbose`
- /// - `verbose_count >= 3` → `Debug`
- pub fn from_flags(verbose_count: u8, quiet: bool) -> Self {
- if quiet {
- return Verbosity::Quiet;
- }
- match verbose_count {
- 0 => Verbosity::Normal,
- 1 => Verbosity::Verbose,
- 2 => Verbosity::VeryVerbose,
- _ => Verbosity::Debug,
- }
- }
-}
-
-// ---------------------------------------------------------------------------
-// Console
-// ---------------------------------------------------------------------------
-
-/// Central IO hub for Mozart commands.
-///
-/// Constructed once in `commands::execute()` and passed as `&Console` to every
-/// command and library function that needs to produce output.
-pub struct Console {
- /// Whether the user can answer interactive prompts.
- pub interactive: bool,
- /// Current verbosity level.
- pub verbosity: Verbosity,
- /// Whether ANSI color codes should be emitted.
- pub decorated: bool,
-}
-
-impl Console {
- /// Build a `Console` from primitive arguments.
- ///
- /// This is the primary constructor. Pass the relevant CLI flag values:
- /// - `verbose`: the `-v` flag count (0, 1, 2, 3+)
- /// - `quiet`: whether `--quiet` was passed
- /// - `ansi`: whether `--ansi` was passed
- /// - `no_ansi`: whether `--no-ansi` was passed
- /// - `no_interaction`: whether `--no-interaction` / `-n` was passed
- pub fn new(verbose: u8, quiet: bool, ansi: bool, no_ansi: bool, no_interaction: bool) -> Self {
- let verbosity = Verbosity::from_flags(verbose, quiet);
- let decorated = Self::resolve_decorated(ansi, no_ansi);
- colored::control::set_override(decorated);
- Self {
- interactive: !no_interaction,
- verbosity,
- decorated,
- }
- }
-
- /// Determine whether ANSI color output should be enabled.
- ///
- /// - `no_ansi == true` → always disable
- /// - `ansi == true` → always enable
- /// - Otherwise → auto-detect: enabled only when stderr is a TTY
- pub fn resolve_decorated(ansi: bool, no_ansi: bool) -> bool {
- if no_ansi {
- return false;
- }
- if ansi {
- return true;
- }
- std::io::stderr().is_terminal()
- }
-
- // -----------------------------------------------------------------------
- // Output methods
- // -----------------------------------------------------------------------
-
- /// Write `msg` to stderr if `self.verbosity >= required`.
- pub fn write(&self, msg: &str, required: Verbosity) {
- if self.verbosity >= required {
- eprintln!("{msg}");
- }
- }
-
- /// Write `msg` to stdout if `self.verbosity >= required`.
- pub fn write_stdout(&self, msg: &str, required: Verbosity) {
- if self.verbosity >= required {
- println!("{msg}");
- }
- }
-
- /// Write an error to stderr. Always shown, even in quiet mode.
- pub fn write_error(&self, msg: &str) {
- eprintln!("{}", error(msg));
- }
-
- // Convenience verbosity-level shortcuts:
-
- /// Normal-level message (suppressed by `--quiet`).
- pub fn info(&self, msg: &str) {
- self.write(msg, Verbosity::Normal);
- }
-
- /// Verbose-level message (shown with `-v` or higher).
- pub fn verbose(&self, msg: &str) {
- self.write(msg, Verbosity::Verbose);
- }
-
- /// Very-verbose-level message (shown with `-vv` or higher).
- pub fn very_verbose(&self, msg: &str) {
- self.write(msg, Verbosity::VeryVerbose);
- }
-
- /// Debug-level message (shown with `-vvv`).
- pub fn debug(&self, msg: &str) {
- self.write(msg, Verbosity::Debug);
- }
-
- /// Error message — always shown.
- pub fn error(&self, msg: &str) {
- self.write_error(msg);
- }
-
- // -----------------------------------------------------------------------
- // Query methods
- // -----------------------------------------------------------------------
-
- pub fn is_verbose(&self) -> bool {
- self.verbosity >= Verbosity::Verbose
- }
-
- pub fn is_very_verbose(&self) -> bool {
- self.verbosity >= Verbosity::VeryVerbose
- }
-
- pub fn is_debug(&self) -> bool {
- self.verbosity >= Verbosity::Debug
- }
-
- pub fn is_quiet(&self) -> bool {
- self.verbosity == Verbosity::Quiet
- }
-
- // -----------------------------------------------------------------------
- // Interactive prompt methods (unchanged from prior implementation)
- // -----------------------------------------------------------------------
-
- pub fn ask(&self, prompt: &str, default: &str) -> String {
- if !self.interactive {
- return default.to_string();
- }
-
- Input::new()
- .with_prompt(prompt)
- .default(default.to_string())
- .allow_empty(true)
- .interact_text()
- .unwrap_or_else(|_| default.to_string())
- }
-
- pub fn ask_validated<F>(
- &self,
- prompt: &str,
- default: &str,
- validator: F,
- ) -> Result<String, String>
- where
- F: Fn(&str) -> Result<(), String>,
- {
- if !self.interactive {
- validator(default)?;
- return Ok(default.to_string());
- }
-
- loop {
- let input: String = Input::new()
- .with_prompt(prompt)
- .default(default.to_string())
- .allow_empty(true)
- .interact_text()
- .unwrap_or_else(|_| default.to_string());
-
- match validator(&input) {
- Ok(()) => return Ok(input),
- Err(e) => {
- self.write_error(&e);
- }
- }
- }
- }
-
- pub fn confirm(&self, prompt: &str) -> bool {
- if !self.interactive {
- return true;
- }
-
- Confirm::new()
- .with_prompt(prompt)
- .default(true)
- .interact()
- .unwrap_or(true)
- }
-}
-
-// ---------------------------------------------------------------------------
-// Tests
-// ---------------------------------------------------------------------------
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- // ── Verbosity::from_flags ───────────────────────────────────────────────
-
- #[test]
- fn test_verbosity_quiet_takes_priority() {
- assert_eq!(Verbosity::from_flags(3, true), Verbosity::Quiet);
- assert_eq!(Verbosity::from_flags(0, true), Verbosity::Quiet);
- }
-
- #[test]
- fn test_verbosity_normal() {
- assert_eq!(Verbosity::from_flags(0, false), Verbosity::Normal);
- }
-
- #[test]
- fn test_verbosity_verbose() {
- assert_eq!(Verbosity::from_flags(1, false), Verbosity::Verbose);
- }
-
- #[test]
- fn test_verbosity_very_verbose() {
- assert_eq!(Verbosity::from_flags(2, false), Verbosity::VeryVerbose);
- }
-
- #[test]
- fn test_verbosity_debug() {
- assert_eq!(Verbosity::from_flags(3, false), Verbosity::Debug);
- assert_eq!(Verbosity::from_flags(10, false), Verbosity::Debug);
- }
-
- // ── Verbosity ordering ──────────────────────────────────────────────────
-
- #[test]
- fn test_verbosity_ordering() {
- assert!(Verbosity::Quiet < Verbosity::Normal);
- assert!(Verbosity::Normal < Verbosity::Verbose);
- assert!(Verbosity::Verbose < Verbosity::VeryVerbose);
- assert!(Verbosity::VeryVerbose < Verbosity::Debug);
- }
-
- // ── Console::resolve_decorated ──────────────────────────────────────────
-
- #[test]
- fn test_resolve_decorated_no_ansi_wins() {
- assert!(!Console::resolve_decorated(true, true));
- assert!(!Console::resolve_decorated(false, true));
- }
-
- #[test]
- fn test_resolve_decorated_ansi_forces_on() {
- assert!(Console::resolve_decorated(true, false));
- }
-
- // ── Console query methods ───────────────────────────────────────────────
-
- fn make_console(verbosity: Verbosity) -> Console {
- Console {
- interactive: false,
- verbosity,
- decorated: false,
- }
- }
-
- #[test]
- fn test_is_quiet() {
- assert!(make_console(Verbosity::Quiet).is_quiet());
- assert!(!make_console(Verbosity::Normal).is_quiet());
- }
-
- #[test]
- fn test_is_verbose() {
- assert!(!make_console(Verbosity::Normal).is_verbose());
- assert!(make_console(Verbosity::Verbose).is_verbose());
- assert!(make_console(Verbosity::VeryVerbose).is_verbose());
- assert!(make_console(Verbosity::Debug).is_verbose());
- }
-
- #[test]
- fn test_is_very_verbose() {
- assert!(!make_console(Verbosity::Verbose).is_very_verbose());
- assert!(make_console(Verbosity::VeryVerbose).is_very_verbose());
- assert!(make_console(Verbosity::Debug).is_very_verbose());
- }
-
- #[test]
- fn test_is_debug() {
- assert!(!make_console(Verbosity::VeryVerbose).is_debug());
- assert!(make_console(Verbosity::Debug).is_debug());
- }
-}
diff --git a/crates/mozart/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<String>) -> Self {
- Self {
- show,
- total: 0,
- downloaded: 0,
- label: label.into(),
- }
- }
-
- /// Set the total expected bytes from a `Content-Length` header.
- pub fn set_total(&mut self, total: u64) {
- self.total = total;
- }
-
- /// Advance the downloaded byte count and redraw the line.
- pub fn inc(&mut self, n: u64) {
- if !self.show {
- return;
- }
- self.downloaded += n;
- let stderr = std::io::stderr();
- let mut out = stderr.lock();
- if let Some(pct) = (self.downloaded * 100).checked_div(self.total) {
- let _ = write!(
- out,
- "\r Downloading {} ({}/{} bytes, {}%)",
- self.label, self.downloaded, self.total, pct
- );
- } else {
- let _ = write!(
- out,
- "\r Downloading {} ({} bytes)",
- self.label, self.downloaded
- );
- }
- let _ = out.flush();
- }
-
- /// Clear the progress line from the terminal.
- pub fn finish(&self) {
- if !self.show {
- return;
- }
- let stderr = std::io::stderr();
- let mut out = stderr.lock();
- // Clear the line with spaces then return to start
- let _ = write!(out, "\r{}\r", " ".repeat(80));
- let _ = out.flush();
- }
-}
-
-/// Download a dist archive from a URL.
-/// Returns the raw bytes of the downloaded archive.
-/// If `expected_shasum` is provided and non-empty, verifies SHA-1 of the downloaded bytes.
-/// If `progress` is provided, increments it as bytes are received and sets the total from
-/// the `Content-Length` response header.
-/// If `files_cache` is provided, the downloaded bytes are cached by URL; cache hits skip
-/// the network request entirely.
-pub fn download_dist(
- url: &str,
- expected_shasum: Option<&str>,
- progress: Option<&mut DownloadProgress>,
- files_cache: Option<&Cache>,
-) -> anyhow::Result<Vec<u8>> {
- // Build a cache key from the URL
- let cache_key = Cache::sanitize_key(url);
-
- // Check cache first
- if let Some(cache) = files_cache
- && let Some(cached_bytes) = cache.read_bytes(&cache_key)
- {
- // Verify checksum against cache hit if provided
- if let Some(shasum) = expected_shasum
- && !shasum.is_empty()
- {
- let mut hasher = Sha1::new();
- hasher.update(&cached_bytes);
- let computed = format!("{:x}", hasher.finalize());
- if computed == shasum {
- return Ok(cached_bytes);
- }
- // Checksum mismatch — discard cache, re-download
- } else {
- return Ok(cached_bytes);
- }
- }
-
- let response = reqwest::blocking::get(url)?;
-
- if !response.status().is_success() {
- anyhow::bail!(
- "Failed to download dist archive from {} (HTTP {})",
- url,
- response.status()
- );
- }
-
- // Stream the response body, updating progress as bytes arrive
- let bytes = if let Some(pb) = progress {
- if let Some(content_length) = response.content_length() {
- pb.set_total(content_length);
- }
- let mut reader = response;
- let mut buf = Vec::new();
- let mut chunk = [0u8; 8192];
- loop {
- let n = reader.read(&mut chunk)?;
- if n == 0 {
- break;
- }
- buf.extend_from_slice(&chunk[..n]);
- pb.inc(n as u64);
- }
- buf
- } else {
- response.bytes()?.to_vec()
- };
-
- // Verify SHA-1 checksum if provided
- if let Some(shasum) = expected_shasum
- && !shasum.is_empty()
- {
- let mut hasher = Sha1::new();
- hasher.update(&bytes);
- let result = hasher.finalize();
- let computed = format!("{result:x}");
-
- if computed != shasum {
- anyhow::bail!("SHA-1 checksum mismatch for {url}: expected {shasum}, got {computed}");
- }
- }
-
- // Write to cache
- if let Some(cache) = files_cache {
- let _ = cache.write_bytes(&cache_key, &bytes);
- }
-
- Ok(bytes)
-}
-
-/// Find the common top-level directory prefix shared by all entries.
-/// Returns `Some(prefix)` if all entries share a single top-level directory.
-fn find_top_level_dir(entries: &[String]) -> Option<String> {
- if entries.is_empty() {
- return None;
- }
-
- let mut prefixes: HashSet<String> = HashSet::new();
- for entry in entries {
- if let Some(slash_pos) = entry.find('/') {
- prefixes.insert(entry[..slash_pos + 1].to_string());
- } else {
- // Entry at root level — no common prefix to strip
- return None;
- }
- }
-
- if prefixes.len() == 1 {
- prefixes.into_iter().next()
- } else {
- None
- }
-}
-
-/// Extract a zip archive to the target directory.
-/// Strips a common top-level directory if all entries share one (Packagist pattern).
-pub fn extract_zip(data: &[u8], target_dir: &Path) -> anyhow::Result<()> {
- let cursor = Cursor::new(data);
- let mut archive = zip::ZipArchive::new(cursor)?;
-
- // Collect all entry names to detect common prefix
- let entry_names: Vec<String> = (0..archive.len())
- .map(|i| archive.by_index(i).map(|e| e.name().to_string()))
- .collect::<Result<_, _>>()?;
-
- let prefix = find_top_level_dir(&entry_names);
-
- for i in 0..archive.len() {
- let mut entry = archive.by_index(i)?;
- let raw_name = entry.name().to_string();
-
- // Strip common prefix
- let relative = if let Some(ref pfx) = prefix {
- if raw_name.starts_with(pfx.as_str()) {
- &raw_name[pfx.len()..]
- } else {
- &raw_name
- }
- } else {
- &raw_name
- };
-
- // Skip the directory entry itself (empty name after stripping)
- if relative.is_empty() {
- continue;
- }
-
- let target_path = target_dir.join(relative);
-
- if raw_name.ends_with('/') {
- // Directory entry
- fs::create_dir_all(&target_path)?;
- } else {
- // File entry
- if let Some(parent) = target_path.parent() {
- fs::create_dir_all(parent)?;
- }
-
- let mut buf = Vec::new();
- entry.read_to_end(&mut buf)?;
- fs::write(&target_path, &buf)?;
-
- // Set permissions on Unix
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt;
- if let Some(mode) = entry.unix_mode() {
- fs::set_permissions(&target_path, fs::Permissions::from_mode(mode))?;
- }
- }
- }
- }
-
- Ok(())
-}
-
-/// Extract a tar.gz archive to the target directory.
-/// Strips a common top-level directory if all entries share one (Packagist pattern).
-pub fn extract_tar_gz(data: &[u8], target_dir: &Path) -> anyhow::Result<()> {
- let cursor = Cursor::new(data);
- let decoder = flate2::read::GzDecoder::new(cursor);
- let mut archive = tar::Archive::new(decoder);
-
- // We need to process in two passes: first collect names, then extract.
- // Use a buffered approach: collect entries into memory.
- let cursor2 = Cursor::new(data);
- let decoder2 = flate2::read::GzDecoder::new(cursor2);
- let mut archive2 = tar::Archive::new(decoder2);
-
- let entry_names: Vec<String> = archive2
- .entries()?
- .filter_map(|e| e.ok())
- .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string()))
- .collect();
-
- let prefix = find_top_level_dir(&entry_names);
-
- for entry in archive.entries()? {
- let mut entry = entry?;
- let raw_path = entry.path()?.to_string_lossy().to_string();
-
- // Strip common prefix
- let relative = if let Some(ref pfx) = prefix {
- if raw_path.starts_with(pfx.as_str()) {
- raw_path[pfx.len()..].to_string()
- } else {
- raw_path.clone()
- }
- } else {
- raw_path.clone()
- };
-
- // Skip empty (top-level dir itself)
- if relative.is_empty() {
- continue;
- }
-
- let target_path = target_dir.join(&relative);
-
- let entry_type = entry.header().entry_type();
- if entry_type.is_dir() {
- fs::create_dir_all(&target_path)?;
- } else if entry_type.is_file() {
- if let Some(parent) = target_path.parent() {
- fs::create_dir_all(parent)?;
- }
- let mut buf = Vec::new();
- entry.read_to_end(&mut buf)?;
- fs::write(&target_path, &buf)?;
-
- // Set permissions on Unix
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt;
- if let Ok(mode) = entry.header().mode() {
- fs::set_permissions(&target_path, fs::Permissions::from_mode(mode))?;
- }
- }
- }
- // Symlinks and other types are skipped for now
- }
-
- Ok(())
-}
-
-/// Download and install a package to the vendor directory.
-///
-/// - `dist_url`: the download URL (from `LockedPackage.dist.url`)
-/// - `dist_type`: `"zip"` or `"tar"` (from `LockedPackage.dist.dist_type`)
-/// - `dist_shasum`: optional SHA-1 checksum
-/// - `vendor_dir`: path to `vendor/` directory
-/// - `package_name`: e.g. `"monolog/monolog"`
-/// - `progress`: optional mutable progress tracker to update during download
-/// - `files_cache`: optional files cache; if provided, the archive bytes are cached by URL
-pub fn install_package(
- dist_url: &str,
- dist_type: &str,
- dist_shasum: Option<&str>,
- vendor_dir: &Path,
- package_name: &str,
- progress: Option<&mut DownloadProgress>,
- files_cache: Option<&Cache>,
-) -> anyhow::Result<()> {
- let target = vendor_dir.join(package_name);
-
- // Remove existing installation for a clean reinstall
- if target.exists() {
- fs::remove_dir_all(&target)?;
- }
- fs::create_dir_all(&target)?;
-
- let bytes = download_dist(dist_url, dist_shasum, progress, files_cache)?;
-
- match dist_type {
- "zip" => extract_zip(&bytes, &target)?,
- "tar" | "tar.gz" | "tgz" => extract_tar_gz(&bytes, &target)?,
- other => anyhow::bail!("Unsupported dist type: {other}"),
- }
-
- Ok(())
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use std::io::Write as IoWrite;
- use tempfile::tempdir;
-
- /// Build a minimal zip archive in memory.
- fn make_zip(files: &[(&str, &[u8])]) -> Vec<u8> {
- let buf = Vec::new();
- let cursor = Cursor::new(buf);
- let mut writer = zip::ZipWriter::new(cursor);
- let options = zip::write::FileOptions::<()>::default()
- .compression_method(zip::CompressionMethod::Stored);
-
- for (name, content) in files {
- writer.start_file(*name, options).unwrap();
- writer.write_all(content).unwrap();
- }
-
- writer.finish().unwrap().into_inner()
- }
-
- /// Build a minimal tar.gz archive in memory.
- fn make_tar_gz(files: &[(&str, &[u8])]) -> Vec<u8> {
- let buf = Vec::new();
- let enc = flate2::write::GzEncoder::new(buf, flate2::Compression::default());
- let mut builder = tar::Builder::new(enc);
-
- for (name, content) in files {
- let mut header = tar::Header::new_gnu();
- header.set_size(content.len() as u64);
- header.set_mode(0o644);
- header.set_cksum();
- builder
- .append_data(&mut header, name, Cursor::new(content))
- .unwrap();
- }
-
- builder.into_inner().unwrap().finish().unwrap()
- }
-
- #[test]
- fn test_extract_zip_flat() {
- let zip_data = make_zip(&[("file1.txt", b"hello"), ("subdir/file2.txt", b"world")]);
-
- let dir = tempdir().unwrap();
- extract_zip(&zip_data, dir.path()).unwrap();
-
- assert_eq!(
- fs::read_to_string(dir.path().join("file1.txt")).unwrap(),
- "hello"
- );
- assert_eq!(
- fs::read_to_string(dir.path().join("subdir/file2.txt")).unwrap(),
- "world"
- );
- }
-
- #[test]
- fn test_extract_zip_with_top_level_dir() {
- // Packagist pattern: all files under vendor-package-abc123/
- let zip_data = make_zip(&[
- ("vendor-pkg-abc/", &[]),
- ("vendor-pkg-abc/file1.txt", b"hello"),
- ("vendor-pkg-abc/src/Foo.php", b"<?php"),
- ]);
-
- let dir = tempdir().unwrap();
- extract_zip(&zip_data, dir.path()).unwrap();
-
- // Top-level dir should be stripped
- assert!(dir.path().join("file1.txt").exists());
- assert!(dir.path().join("src/Foo.php").exists());
- assert_eq!(
- fs::read_to_string(dir.path().join("file1.txt")).unwrap(),
- "hello"
- );
- }
-
- #[test]
- fn test_extract_tar_gz_flat() {
- let tar_data = make_tar_gz(&[("file1.txt", b"hello"), ("subdir/file2.txt", b"world")]);
-
- let dir = tempdir().unwrap();
- extract_tar_gz(&tar_data, dir.path()).unwrap();
-
- assert_eq!(
- fs::read_to_string(dir.path().join("file1.txt")).unwrap(),
- "hello"
- );
- assert_eq!(
- fs::read_to_string(dir.path().join("subdir/file2.txt")).unwrap(),
- "world"
- );
- }
-
- #[test]
- fn test_extract_tar_gz_with_top_level_dir() {
- let tar_data = make_tar_gz(&[
- ("vendor-pkg-abc/file1.txt", b"hello"),
- ("vendor-pkg-abc/src/Foo.php", b"<?php"),
- ]);
-
- let dir = tempdir().unwrap();
- extract_tar_gz(&tar_data, dir.path()).unwrap();
-
- assert!(dir.path().join("file1.txt").exists());
- assert!(dir.path().join("src/Foo.php").exists());
- }
-
- #[test]
- fn test_sha1_verification() {
- use sha1::{Digest, Sha1};
-
- let data = b"test content";
- let mut hasher = Sha1::new();
- hasher.update(data);
- let expected = format!("{:x}", hasher.finalize());
-
- // We can't test download_dist without a server, but we can verify the
- // SHA-1 logic: same data should produce same hash
- let mut hasher2 = Sha1::new();
- hasher2.update(data);
- let computed = format!("{:x}", hasher2.finalize());
-
- assert_eq!(expected, computed);
- assert!(!expected.is_empty());
- }
-
- #[test]
- fn test_find_top_level_dir_common() {
- let entries = vec![
- "pkg-1.0/".to_string(),
- "pkg-1.0/README.md".to_string(),
- "pkg-1.0/src/Foo.php".to_string(),
- ];
- assert_eq!(find_top_level_dir(&entries), Some("pkg-1.0/".to_string()));
- }
-
- #[test]
- fn test_find_top_level_dir_none_when_mixed() {
- let entries = vec!["pkg-1.0/file.txt".to_string(), "other/file.txt".to_string()];
- assert_eq!(find_top_level_dir(&entries), None);
- }
-
- #[test]
- fn test_find_top_level_dir_none_when_root_file() {
- let entries = vec!["file.txt".to_string(), "pkg/other.txt".to_string()];
- assert_eq!(find_top_level_dir(&entries), None);
- }
-}
diff --git a/crates/mozart/src/exit_code.rs b/crates/mozart/src/exit_code.rs
deleted file mode 100644
index bc01cfa..0000000
--- a/crates/mozart/src/exit_code.rs
+++ /dev/null
@@ -1,114 +0,0 @@
-/// Exit code: success.
-pub const OK: i32 = 0;
-
-/// Exit code: general / unclassified error.
-pub const GENERAL_ERROR: i32 = 1;
-
-/// Exit code: dependency resolution failed.
-pub const DEPENDENCY_RESOLUTION_FAILED: i32 = 2;
-
-/// Exit code: partial update requested but no lock file exists.
-pub const NO_LOCK_FILE_FOR_PARTIAL_UPDATE: i32 = 3;
-
-/// Exit code: lock file is invalid or corrupt.
-pub const LOCK_FILE_INVALID: i32 = 4;
-
-/// Exit code: audit found a security advisory.
-pub const AUDIT_FAILED: i32 = 5;
-
-/// Exit code: HTTP / network transport error.
-pub const TRANSPORT_ERROR: i32 = 100;
-
-// ---------------------------------------------------------------------------
-// MozartError — carries a specific exit code through anyhow's error chain
-// ---------------------------------------------------------------------------
-
-/// An error type that carries a specific exit code for Mozart to use on exit.
-///
-/// Use [`bail`] or [`bail_silent`] to construct one wrapped in `anyhow::Error`.
-#[derive(Debug)]
-pub struct MozartError {
- pub message: String,
- pub exit_code: i32,
-}
-
-impl std::fmt::Display for MozartError {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- write!(f, "{}", self.message)
- }
-}
-
-impl std::error::Error for MozartError {}
-
-/// Return an `anyhow::Error` that carries `exit_code` and prints `message`.
-pub fn bail(exit_code: i32, message: impl Into<String>) -> anyhow::Error {
- MozartError {
- message: message.into(),
- exit_code,
- }
- .into()
-}
-
-/// Return an `anyhow::Error` that carries `exit_code` but suppresses the
-/// message (caller has already printed it).
-pub fn bail_silent(exit_code: i32) -> anyhow::Error {
- MozartError {
- message: String::new(),
- exit_code,
- }
- .into()
-}
-
-// ---------------------------------------------------------------------------
-// Tests
-// ---------------------------------------------------------------------------
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn test_constants_have_expected_values() {
- assert_eq!(OK, 0);
- assert_eq!(GENERAL_ERROR, 1);
- assert_eq!(DEPENDENCY_RESOLUTION_FAILED, 2);
- assert_eq!(NO_LOCK_FILE_FOR_PARTIAL_UPDATE, 3);
- assert_eq!(LOCK_FILE_INVALID, 4);
- assert_eq!(AUDIT_FAILED, 5);
- assert_eq!(TRANSPORT_ERROR, 100);
- }
-
- #[test]
- fn test_mozart_error_display() {
- let err = MozartError {
- message: "something went wrong".to_string(),
- exit_code: GENERAL_ERROR,
- };
- assert_eq!(format!("{err}"), "something went wrong");
- }
-
- #[test]
- fn test_bail_can_be_downcast() {
- let err = bail(DEPENDENCY_RESOLUTION_FAILED, "cannot resolve");
- let me = err.downcast_ref::<MozartError>().expect("should downcast");
- assert_eq!(me.exit_code, DEPENDENCY_RESOLUTION_FAILED);
- assert_eq!(me.message, "cannot resolve");
- }
-
- #[test]
- fn test_bail_silent_has_empty_message() {
- let err = bail_silent(GENERAL_ERROR);
- let me = err.downcast_ref::<MozartError>().expect("should downcast");
- assert_eq!(me.exit_code, GENERAL_ERROR);
- assert!(me.message.is_empty());
- }
-
- #[test]
- fn test_mozart_error_is_std_error() {
- let err: Box<dyn std::error::Error> = Box::new(MozartError {
- message: "test".to_string(),
- exit_code: 1,
- });
- assert_eq!(err.to_string(), "test");
- }
-}
diff --git a/crates/mozart/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<InstalledPackageEntry>,
-
- #[serde(rename = "dev-package-names", default)]
- pub dev_package_names: Vec<String>,
-
- #[serde(default = "default_true")]
- pub dev: bool,
-}
-
-/// An entry in installed.json's packages array.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct InstalledPackageEntry {
- pub name: String,
- pub version: String,
-
- #[serde(rename = "version_normalized", skip_serializing_if = "Option::is_none")]
- pub version_normalized: Option<String>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub source: Option<serde_json::Value>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub dist: Option<serde_json::Value>,
-
- #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
- pub package_type: Option<String>,
-
- #[serde(rename = "install-path", skip_serializing_if = "Option::is_none")]
- pub install_path: Option<String>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub autoload: Option<serde_json::Value>,
-
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
- pub aliases: Vec<String>,
-
- #[serde(flatten)]
- pub extra_fields: BTreeMap<String, serde_json::Value>,
-}
-
-impl Default for InstalledPackages {
- fn default() -> Self {
- Self::new()
- }
-}
-
-impl InstalledPackages {
- /// Create an empty registry.
- pub fn new() -> InstalledPackages {
- InstalledPackages {
- packages: Vec::new(),
- dev_package_names: Vec::new(),
- dev: true,
- }
- }
-
- /// Read installed.json from `vendor/composer/installed.json`.
- /// If the file does not exist, returns an empty registry.
- pub fn read(vendor_dir: &Path) -> anyhow::Result<InstalledPackages> {
- let path = vendor_dir.join("composer/installed.json");
- if !path.exists() {
- return Ok(InstalledPackages::new());
- }
- let content = fs::read_to_string(&path)?;
- let installed: InstalledPackages = serde_json::from_str(&content)?;
- Ok(installed)
- }
-
- /// Write installed.json to `vendor/composer/installed.json`.
- /// Creates the `vendor/composer/` directory if it doesn't exist.
- pub fn write(&self, vendor_dir: &Path) -> anyhow::Result<()> {
- let composer_dir = vendor_dir.join("composer");
- fs::create_dir_all(&composer_dir)?;
- let path = composer_dir.join("installed.json");
- let json = to_json_pretty(self)?;
- fs::write(path, json)?;
- Ok(())
- }
-
- /// Check if a package at a specific version is installed.
- pub fn is_installed(&self, name: &str, version: &str) -> bool {
- self.packages
- .iter()
- .any(|p| p.name.eq_ignore_ascii_case(name) && p.version == version)
- }
-
- /// Add or update a package entry (replace if same name exists).
- pub fn upsert(&mut self, entry: InstalledPackageEntry) {
- if let Some(pos) = self
- .packages
- .iter()
- .position(|p| p.name.eq_ignore_ascii_case(&entry.name))
- {
- self.packages[pos] = entry;
- } else {
- self.packages.push(entry);
- }
- }
-
- /// Remove a package by name.
- pub fn remove(&mut self, name: &str) {
- self.packages.retain(|p| !p.name.eq_ignore_ascii_case(name));
- self.dev_package_names
- .retain(|n| !n.eq_ignore_ascii_case(name));
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use tempfile::tempdir;
-
- fn make_entry(name: &str, version: &str) -> InstalledPackageEntry {
- InstalledPackageEntry {
- name: name.to_string(),
- version: version.to_string(),
- version_normalized: None,
- source: None,
- dist: None,
- package_type: None,
- install_path: None,
- autoload: None,
- aliases: vec![],
- extra_fields: BTreeMap::new(),
- }
- }
-
- #[test]
- fn test_new_is_empty() {
- let installed = InstalledPackages::new();
- assert!(installed.packages.is_empty());
- assert!(installed.dev_package_names.is_empty());
- assert!(installed.dev);
- }
-
- #[test]
- fn test_write_read_empty() {
- let dir = tempdir().unwrap();
- let vendor = dir.path().join("vendor");
-
- let installed = InstalledPackages::new();
- installed.write(&vendor).unwrap();
-
- let loaded = InstalledPackages::read(&vendor).unwrap();
- assert!(loaded.packages.is_empty());
- assert!(loaded.dev);
- }
-
- #[test]
- fn test_read_nonexistent_returns_empty() {
- let dir = tempdir().unwrap();
- let vendor = dir.path().join("vendor");
- // Don't create the directory
- let installed = InstalledPackages::read(&vendor).unwrap();
- assert!(installed.packages.is_empty());
- }
-
- #[test]
- fn test_upsert_and_is_installed() {
- let mut installed = InstalledPackages::new();
- installed.upsert(make_entry("monolog/monolog", "3.8.0"));
-
- assert!(installed.is_installed("monolog/monolog", "3.8.0"));
- assert!(!installed.is_installed("monolog/monolog", "3.7.0"));
- assert!(!installed.is_installed("other/pkg", "1.0.0"));
- }
-
- #[test]
- fn test_upsert_replaces_existing() {
- let mut installed = InstalledPackages::new();
- installed.upsert(make_entry("monolog/monolog", "3.7.0"));
- installed.upsert(make_entry("monolog/monolog", "3.8.0"));
-
- assert_eq!(installed.packages.len(), 1);
- assert_eq!(installed.packages[0].version, "3.8.0");
- }
-
- #[test]
- fn test_remove() {
- let mut installed = InstalledPackages::new();
- installed.upsert(make_entry("monolog/monolog", "3.8.0"));
- installed.upsert(make_entry("psr/log", "3.0.0"));
- installed
- .dev_package_names
- .push("monolog/monolog".to_string());
-
- installed.remove("monolog/monolog");
-
- assert_eq!(installed.packages.len(), 1);
- assert_eq!(installed.packages[0].name, "psr/log");
- assert!(installed.dev_package_names.is_empty());
- }
-
- #[test]
- fn test_is_installed_case_insensitive() {
- let mut installed = InstalledPackages::new();
- installed.upsert(make_entry("Monolog/Monolog", "3.8.0"));
- assert!(installed.is_installed("monolog/monolog", "3.8.0"));
- }
-
- #[test]
- fn test_roundtrip_with_package() {
- let dir = tempdir().unwrap();
- let vendor = dir.path().join("vendor");
-
- let mut installed = InstalledPackages::new();
- installed.upsert(make_entry("monolog/monolog", "3.8.0"));
- installed.write(&vendor).unwrap();
-
- let loaded = InstalledPackages::read(&vendor).unwrap();
- assert_eq!(loaded.packages.len(), 1);
- assert_eq!(loaded.packages[0].name, "monolog/monolog");
- assert_eq!(loaded.packages[0].version, "3.8.0");
- }
-}
diff --git a/crates/mozart/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<String>,
-
- #[serde(rename = "content-hash")]
- pub content_hash: String,
-
- pub packages: Vec<LockedPackage>,
-
- #[serde(rename = "packages-dev")]
- pub packages_dev: Option<Vec<LockedPackage>>,
-
- #[serde(default)]
- pub aliases: Vec<LockAlias>,
-
- #[serde(rename = "minimum-stability", default = "default_stability")]
- pub minimum_stability: String,
-
- #[serde(rename = "stability-flags", default = "default_empty_object")]
- pub stability_flags: serde_json::Value,
-
- #[serde(rename = "prefer-stable", default)]
- pub prefer_stable: bool,
-
- #[serde(rename = "prefer-lowest", default)]
- pub prefer_lowest: bool,
-
- #[serde(default = "default_empty_object")]
- pub platform: serde_json::Value,
-
- #[serde(rename = "platform-dev", default = "default_empty_object")]
- pub platform_dev: serde_json::Value,
-
- #[serde(rename = "plugin-api-version", skip_serializing_if = "Option::is_none")]
- pub plugin_api_version: Option<String>,
-}
-
-/// A locked package entry in composer.lock.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct LockedPackage {
- pub name: String,
- pub version: String,
-
- #[serde(rename = "version_normalized", skip_serializing_if = "Option::is_none")]
- pub version_normalized: Option<String>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub source: Option<LockedSource>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub dist: Option<LockedDist>,
-
- #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
- pub require: BTreeMap<String, String>,
-
- #[serde(
- rename = "require-dev",
- default,
- skip_serializing_if = "BTreeMap::is_empty"
- )]
- pub require_dev: BTreeMap<String, String>,
-
- #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
- pub conflict: BTreeMap<String, String>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub suggest: Option<BTreeMap<String, String>>,
-
- #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
- pub package_type: Option<String>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub autoload: Option<serde_json::Value>,
-
- #[serde(rename = "autoload-dev", skip_serializing_if = "Option::is_none")]
- pub autoload_dev: Option<serde_json::Value>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub license: Option<Vec<String>>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub description: Option<String>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub homepage: Option<String>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub keywords: Option<Vec<String>>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub authors: Option<Vec<serde_json::Value>>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub support: Option<serde_json::Value>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub funding: Option<Vec<serde_json::Value>>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub time: Option<String>,
-
- /// Catch-all for extra fields we don't explicitly model
- #[serde(flatten)]
- pub extra_fields: BTreeMap<String, serde_json::Value>,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct LockedSource {
- #[serde(rename = "type")]
- pub source_type: String,
- pub url: String,
- pub reference: Option<String>,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct LockedDist {
- #[serde(rename = "type")]
- pub dist_type: String,
- pub url: String,
- pub reference: Option<String>,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub shasum: Option<String>,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct LockAlias {
- pub package: String,
- pub version: String,
- pub alias: String,
- pub alias_normalized: String,
-}
-
-impl LockFile {
- /// Create default readme entries.
- pub fn default_readme() -> Vec<String> {
- vec![
- "This file locks the dependencies of your project to a known state".to_string(),
- "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies".to_string(),
- "This file is @generated automatically".to_string(),
- ]
- }
-
- /// Read a composer.lock file from disk.
- pub fn read_from_file(path: &Path) -> anyhow::Result<LockFile> {
- let content = fs::read_to_string(path)?;
- let lock: LockFile = serde_json::from_str(&content)?;
- Ok(lock)
- }
-
- /// Write a composer.lock file to disk with deterministic formatting.
- pub fn write_to_file(&self, path: &Path) -> anyhow::Result<()> {
- let json = to_json_pretty(self)?;
- fs::write(path, json)?;
- Ok(())
- }
-
- /// Check if the lock file is fresh (content-hash matches composer.json).
- pub fn is_fresh(&self, composer_json_content: &str) -> bool {
- match Self::compute_content_hash(composer_json_content) {
- Ok(hash) => hash == self.content_hash,
- Err(_) => false,
- }
- }
-
- /// Compute the content hash from composer.json content.
- /// Matches Composer's `Locker::getContentHash()`.
- pub fn compute_content_hash(composer_json_content: &str) -> anyhow::Result<String> {
- let value: serde_json::Value = serde_json::from_str(composer_json_content)?;
- let obj = value
- .as_object()
- .ok_or_else(|| anyhow::anyhow!("composer.json must be a JSON object"))?;
-
- // Keys that affect the content hash (Composer's relevantKeys)
- let relevant_keys = [
- "name",
- "version",
- "require",
- "require-dev",
- "conflict",
- "replace",
- "provide",
- "minimum-stability",
- "prefer-stable",
- "repositories",
- "extra",
- ];
-
- // Collect relevant keys into a BTreeMap (auto-sorted by key)
- let mut filtered: BTreeMap<&str, &serde_json::Value> = BTreeMap::new();
- for key in &relevant_keys {
- if let Some(v) = obj.get(*key) {
- filtered.insert(key, v);
- }
- }
-
- // Also include config.platform if present
- if let Some(config) = obj.get("config")
- && let Some(platform) = config.get("platform")
- {
- filtered.insert("config.platform", platform);
- }
-
- // Encode to compact JSON
- let compact = serde_json::to_string(&filtered)?;
-
- // Compute MD5
- let digest = md5::compute(compact.as_bytes());
- Ok(format!("{:x}", digest))
- }
-}
-
-// ─────────────────────────────────────────────────────────────────────────────
-// Lock file generation
-// ─────────────────────────────────────────────────────────────────────────────
-
-/// Input for lock file generation.
-pub struct LockFileGenerationRequest {
- /// Resolved packages from the dependency resolver.
- pub resolved_packages: Vec<ResolvedPackage>,
- /// Raw composer.json content string (for content-hash computation).
- pub composer_json_content: String,
- /// Parsed composer.json data (for platform, minimum-stability, etc.).
- pub composer_json: RawPackageData,
- /// Whether require-dev was included in resolution.
- pub include_dev: bool,
- /// Optional repo cache for Packagist API calls made during generation.
- pub repo_cache: Option<Cache>,
-}
-
-/// Convert a `PackagistSource` to a `LockedSource`.
-fn packagist_source_to_locked(ps: &PackagistSource) -> LockedSource {
- LockedSource {
- source_type: ps.source_type.clone(),
- url: ps.url.clone(),
- reference: ps.reference.clone(),
- }
-}
-
-/// Convert a `PackagistDist` to a `LockedDist`.
-fn packagist_dist_to_locked(pd: &PackagistDist) -> LockedDist {
- LockedDist {
- dist_type: pd.dist_type.clone(),
- url: pd.url.clone(),
- reference: pd.reference.clone(),
- shasum: pd.shasum.clone(),
- }
-}
-
-/// Convert a `PackagistVersion` to a `LockedPackage`.
-fn packagist_version_to_locked_package(name: &str, pv: &PackagistVersion) -> LockedPackage {
- let mut extra_fields: BTreeMap<String, serde_json::Value> = BTreeMap::new();
-
- if let Some(extra) = &pv.extra {
- extra_fields.insert("extra".to_string(), extra.clone());
- }
- if let Some(notification_url) = &pv.notification_url {
- extra_fields.insert(
- "notification-url".to_string(),
- serde_json::Value::String(notification_url.clone()),
- );
- }
-
- LockedPackage {
- name: name.to_string(),
- version: pv.version.clone(),
- version_normalized: Some(pv.version_normalized.clone()),
- source: pv.source.as_ref().map(packagist_source_to_locked),
- dist: pv.dist.as_ref().map(packagist_dist_to_locked),
- require: pv.require.clone(),
- require_dev: pv.require_dev.clone(),
- conflict: pv.conflict.clone(),
- suggest: pv.suggest.clone(),
- package_type: pv.package_type.clone(),
- autoload: pv.autoload.clone(),
- autoload_dev: pv.autoload_dev.clone(),
- license: pv.license.clone(),
- description: pv.description.clone(),
- homepage: pv.homepage.clone(),
- keywords: pv.keywords.clone(),
- authors: pv.authors.clone(),
- support: pv.support.clone(),
- funding: pv.funding.clone(),
- time: pv.time.clone(),
- extra_fields,
- }
-}
-
-/// Determine which resolved packages are dev-only.
-///
-/// A package is dev-only if it is NOT reachable from the non-dev dependency tree
-/// (i.e., only reachable through require-dev paths).
-///
-/// `package_metadata` must be pre-fetched full `PackagistVersion` data for each resolved package.
-fn classify_dev_packages(
- resolved: &[ResolvedPackage],
- require: &BTreeMap<String, String>,
- _require_dev: &BTreeMap<String, String>,
- package_metadata: &HashMap<String, PackagistVersion>,
-) -> HashSet<String> {
- // Build set of all resolved package names for quick lookup
- let resolved_names: HashSet<&str> = resolved.iter().map(|p| p.name.as_str()).collect();
-
- // BFS from non-dev root dependencies through each package's `require` map.
- // All reachable packages are production packages.
- let mut production: HashSet<String> = HashSet::new();
- let mut queue: VecDeque<String> = VecDeque::new();
-
- // Seed queue with non-dev root dependencies that are actual packages (not platform)
- for name in require.keys() {
- let name_lower = name.to_lowercase();
- // Skip platform packages (php, ext-*, lib-*, etc.)
- if is_platform_name(&name_lower) {
- continue;
- }
- if resolved_names.contains(name_lower.as_str()) && production.insert(name_lower.clone()) {
- queue.push_back(name_lower);
- }
- }
-
- // BFS: walk transitive `require` deps of each production package
- while let Some(pkg_name) = queue.pop_front() {
- if let Some(pv) = package_metadata.get(&pkg_name) {
- for dep_name in pv.require.keys() {
- let dep_lower = dep_name.to_lowercase();
- if is_platform_name(&dep_lower) {
- continue;
- }
- if resolved_names.contains(dep_lower.as_str())
- && production.insert(dep_lower.clone())
- {
- queue.push_back(dep_lower);
- }
- }
- }
- }
-
- // Any resolved package not in `production` is dev-only
- resolved
- .iter()
- .filter(|p| !production.contains(&p.name))
- .map(|p| p.name.clone())
- .collect()
-}
-
-/// Returns true if the package name is a platform package (php, ext-*, lib-*, etc.).
-fn is_platform_name(name: &str) -> bool {
- name == "php"
- || name.starts_with("ext-")
- || name.starts_with("lib-")
- || name == "php-64bit"
- || name == "php-ipv6"
- || name == "php-zts"
- || name == "php-debug"
-}
-
-/// Extract platform requirements from a requirements map.
-///
-/// Filters the map to include only platform package keys (`php`, `ext-*`, `lib-*`, etc.)
-/// and returns them as a JSON object.
-fn extract_platform_requirements(requirements: &BTreeMap<String, String>) -> serde_json::Value {
- let map: serde_json::Map<String, serde_json::Value> = requirements
- .iter()
- .filter(|(k, _)| is_platform_name(k))
- .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
- .collect();
- serde_json::Value::Object(map)
-}
-
-/// Generate a complete `LockFile` from resolution results.
-///
-/// This function:
-/// 1. Fetches full metadata from Packagist for each resolved package
-/// 2. Separates packages into production vs dev-only
-/// 3. Computes the content-hash
-/// 4. Assembles the complete `LockFile` struct
-pub fn generate_lock_file(request: &LockFileGenerationRequest) -> anyhow::Result<LockFile> {
- // 1. Fetch full metadata for all resolved packages
- let mut package_metadata: HashMap<String, PackagistVersion> = HashMap::new();
- for pkg in &request.resolved_packages {
- let versions = packagist::fetch_package_versions(&pkg.name, request.repo_cache.as_ref())?;
- // Find the exact version matching pkg.version_normalized
- let matching = versions
- .into_iter()
- .find(|v| v.version_normalized == pkg.version_normalized)
- .ok_or_else(|| {
- anyhow::anyhow!(
- "Could not find version {} for package {} in Packagist response",
- pkg.version_normalized,
- pkg.name
- )
- })?;
- package_metadata.insert(pkg.name.clone(), matching);
- }
-
- // 2. Classify dev vs non-dev packages
- let dev_only = classify_dev_packages(
- &request.resolved_packages,
- &request.composer_json.require,
- &request.composer_json.require_dev,
- &package_metadata,
- );
-
- // 3. Build LockedPackage lists
- let mut packages: Vec<LockedPackage> = Vec::new();
- let mut packages_dev: Vec<LockedPackage> = Vec::new();
- for pkg in &request.resolved_packages {
- let pv = &package_metadata[&pkg.name];
- let locked = packagist_version_to_locked_package(&pkg.name, pv);
- if dev_only.contains(&pkg.name) {
- packages_dev.push(locked);
- } else {
- packages.push(locked);
- }
- }
-
- // 4. Sort each list alphabetically by name (Composer does this)
- packages.sort_by(|a, b| a.name.cmp(&b.name));
- packages_dev.sort_by(|a, b| a.name.cmp(&b.name));
-
- // 5. Compute content-hash
- let content_hash = LockFile::compute_content_hash(&request.composer_json_content)?;
-
- // 6. Extract platform requirements
- let platform = extract_platform_requirements(&request.composer_json.require);
- let platform_dev = extract_platform_requirements(&request.composer_json.require_dev);
-
- // 7. Determine minimum-stability and prefer-stable
- let minimum_stability = request
- .composer_json
- .minimum_stability
- .clone()
- .unwrap_or_else(|| "stable".to_string());
-
- let prefer_stable = request
- .composer_json
- .extra_fields
- .get("prefer-stable")
- .and_then(|v| v.as_bool())
- .unwrap_or(false);
-
- // 8. Assemble LockFile
- Ok(LockFile {
- readme: LockFile::default_readme(),
- content_hash,
- packages,
- packages_dev: if request.include_dev {
- Some(packages_dev)
- } else {
- Some(vec![])
- },
- aliases: vec![],
- minimum_stability,
- stability_flags: serde_json::json!({}),
- prefer_stable,
- prefer_lowest: false,
- platform,
- platform_dev,
- plugin_api_version: Some("2.6.0".to_string()),
- })
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use tempfile::tempdir;
-
- fn minimal_lock() -> LockFile {
- LockFile {
- readme: LockFile::default_readme(),
- content_hash: "abc123".to_string(),
- packages: vec![],
- packages_dev: Some(vec![]),
- aliases: vec![],
- minimum_stability: "stable".to_string(),
- stability_flags: serde_json::json!({}),
- prefer_stable: false,
- prefer_lowest: false,
- platform: serde_json::json!({}),
- platform_dev: serde_json::json!({}),
- plugin_api_version: Some("2.6.0".to_string()),
- }
- }
-
- #[test]
- fn test_roundtrip_minimal() {
- let dir = tempdir().unwrap();
- let path = dir.path().join("composer.lock");
-
- let lock = minimal_lock();
- lock.write_to_file(&path).unwrap();
-
- let loaded = LockFile::read_from_file(&path).unwrap();
- assert_eq!(loaded.content_hash, "abc123");
- assert_eq!(loaded.minimum_stability, "stable");
- assert!(!loaded.prefer_stable);
- assert_eq!(loaded.packages.len(), 0);
- }
-
- #[test]
- fn test_roundtrip_with_package() {
- let dir = tempdir().unwrap();
- let path = dir.path().join("composer.lock");
-
- let mut lock = minimal_lock();
- lock.packages.push(LockedPackage {
- name: "monolog/monolog".to_string(),
- version: "3.8.0".to_string(),
- version_normalized: None,
- source: None,
- dist: Some(LockedDist {
- dist_type: "zip".to_string(),
- url: "https://example.com/monolog.zip".to_string(),
- reference: Some("abc123".to_string()),
- shasum: Some("".to_string()),
- }),
- require: BTreeMap::new(),
- require_dev: BTreeMap::new(),
- conflict: BTreeMap::new(),
- suggest: None,
- package_type: Some("library".to_string()),
- autoload: None,
- autoload_dev: None,
- license: Some(vec!["MIT".to_string()]),
- description: Some("A logging library".to_string()),
- homepage: None,
- keywords: None,
- authors: None,
- support: None,
- funding: None,
- time: None,
- extra_fields: BTreeMap::new(),
- });
-
- lock.write_to_file(&path).unwrap();
- let loaded = LockFile::read_from_file(&path).unwrap();
-
- assert_eq!(loaded.packages.len(), 1);
- assert_eq!(loaded.packages[0].name, "monolog/monolog");
- assert_eq!(loaded.packages[0].version, "3.8.0");
- assert_eq!(
- loaded.packages[0].description.as_deref(),
- Some("A logging library")
- );
- }
-
- #[test]
- fn test_content_hash_deterministic() {
- let composer_json = r#"{"name": "test/project", "require": {"monolog/monolog": "^3.0"}}"#;
- let h1 = LockFile::compute_content_hash(composer_json).unwrap();
- let h2 = LockFile::compute_content_hash(composer_json).unwrap();
- assert_eq!(h1, h2);
- assert!(!h1.is_empty());
- }
-
- #[test]
- fn test_content_hash_changes_on_require_change() {
- let composer1 = r#"{"name": "test/project", "require": {"monolog/monolog": "^3.0"}}"#;
- let composer2 = r#"{"name": "test/project", "require": {"monolog/monolog": "^2.0"}}"#;
- let h1 = LockFile::compute_content_hash(composer1).unwrap();
- let h2 = LockFile::compute_content_hash(composer2).unwrap();
- assert_ne!(h1, h2);
- }
-
- #[test]
- fn test_is_fresh() {
- let composer_json = r#"{"name": "test/project", "require": {"php": ">=8.1"}}"#;
- let hash = LockFile::compute_content_hash(composer_json).unwrap();
-
- let mut lock = minimal_lock();
- lock.content_hash = hash;
-
- assert!(lock.is_fresh(composer_json));
- assert!(!lock.is_fresh(r#"{"name": "test/project", "require": {"php": ">=8.0"}}"#));
- }
-
- #[test]
- fn test_default_readme() {
- let readme = LockFile::default_readme();
- assert_eq!(readme.len(), 3);
- assert!(readme[0].contains("locks the dependencies"));
- }
-
- // ──────────── Lock file generation tests ────────────
-
- fn make_packagist_version(
- version: &str,
- version_normalized: &str,
- require: BTreeMap<String, String>,
- ) -> PackagistVersion {
- PackagistVersion {
- version: version.to_string(),
- version_normalized: version_normalized.to_string(),
- require,
- replace: BTreeMap::new(),
- provide: BTreeMap::new(),
- conflict: BTreeMap::new(),
- dist: Some(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<String, PackagistVersion> = HashMap::new();
-
- // A requires C
- let mut a_require = BTreeMap::new();
- a_require.insert("vendor/c".to_string(), "^1.0".to_string());
- metadata.insert(
- "vendor/a".to_string(),
- make_packagist_version("1.0.0", "1.0.0.0", a_require),
- );
-
- // B requires D
- let mut b_require = BTreeMap::new();
- b_require.insert("vendor/d".to_string(), "^1.0".to_string());
- metadata.insert(
- "vendor/b".to_string(),
- make_packagist_version("1.0.0", "1.0.0.0", b_require),
- );
-
- // C and D have no deps
- metadata.insert(
- "vendor/c".to_string(),
- make_packagist_version("1.0.0", "1.0.0.0", BTreeMap::new()),
- );
- metadata.insert(
- "vendor/d".to_string(),
- make_packagist_version("1.0.0", "1.0.0.0", BTreeMap::new()),
- );
-
- let dev_only = classify_dev_packages(&resolved, &require, &require_dev, &metadata);
-
- assert!(!dev_only.contains("vendor/a"), "A is a production package");
- assert!(dev_only.contains("vendor/b"), "B is dev-only");
- assert!(
- !dev_only.contains("vendor/c"),
- "C is reachable from A (production)"
- );
- assert!(
- dev_only.contains("vendor/d"),
- "D is only reachable from B (dev)"
- );
- }
-
- #[test]
- fn test_classify_dev_packages_shared() {
- // Root: require={A}, require-dev={B}
- // Both A and B depend on C — C is NOT dev-only (reachable from production)
- let resolved = vec![
- ResolvedPackage {
- name: "vendor/a".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: "1.0.0.0".to_string(),
- is_dev: false,
- },
- ResolvedPackage {
- name: "vendor/b".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: "1.0.0.0".to_string(),
- is_dev: false,
- },
- ResolvedPackage {
- name: "vendor/c".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: "1.0.0.0".to_string(),
- is_dev: false,
- },
- ];
-
- let mut require = BTreeMap::new();
- require.insert("vendor/a".to_string(), "^1.0".to_string());
-
- let mut require_dev = BTreeMap::new();
- require_dev.insert("vendor/b".to_string(), "^1.0".to_string());
-
- let mut metadata: HashMap<String, PackagistVersion> = HashMap::new();
-
- // A requires C
- let mut a_require = BTreeMap::new();
- a_require.insert("vendor/c".to_string(), "^1.0".to_string());
- metadata.insert(
- "vendor/a".to_string(),
- make_packagist_version("1.0.0", "1.0.0.0", a_require),
- );
-
- // B also requires C
- let mut b_require = BTreeMap::new();
- b_require.insert("vendor/c".to_string(), "^1.0".to_string());
- metadata.insert(
- "vendor/b".to_string(),
- make_packagist_version("1.0.0", "1.0.0.0", b_require),
- );
-
- // C has no deps
- metadata.insert(
- "vendor/c".to_string(),
- make_packagist_version("1.0.0", "1.0.0.0", BTreeMap::new()),
- );
-
- let dev_only = classify_dev_packages(&resolved, &require, &require_dev, &metadata);
-
- assert!(!dev_only.contains("vendor/a"), "A is a production package");
- assert!(dev_only.contains("vendor/b"), "B is dev-only");
- assert!(
- !dev_only.contains("vendor/c"),
- "C is shared but reachable from production (A), so it's not dev-only"
- );
- }
-
- #[test]
- fn test_extract_platform_requirements() {
- let mut requirements = BTreeMap::new();
- requirements.insert("php".to_string(), ">=8.1".to_string());
- requirements.insert("ext-json".to_string(), "*".to_string());
- requirements.insert("ext-mbstring".to_string(), "*".to_string());
- requirements.insert("monolog/monolog".to_string(), "^3.0".to_string());
- requirements.insert("lib-pcre".to_string(), "*".to_string());
-
- let platform = extract_platform_requirements(&requirements);
- let obj = platform.as_object().unwrap();
-
- assert!(obj.contains_key("php"), "php should be in platform");
- assert!(
- obj.contains_key("ext-json"),
- "ext-json should be in platform"
- );
- assert!(
- obj.contains_key("ext-mbstring"),
- "ext-mbstring should be in platform"
- );
- assert!(
- obj.contains_key("lib-pcre"),
- "lib-pcre should be in platform"
- );
- assert!(
- !obj.contains_key("monolog/monolog"),
- "monolog/monolog should NOT be in platform"
- );
- assert_eq!(obj["php"], serde_json::Value::String(">=8.1".to_string()));
- assert_eq!(obj["ext-json"], serde_json::Value::String("*".to_string()));
- }
-
- #[test]
- fn test_extract_platform_requirements_empty() {
- let requirements = BTreeMap::new();
- let platform = extract_platform_requirements(&requirements);
- assert_eq!(platform, serde_json::json!({}));
- }
-
- #[test]
- fn test_generate_lock_file_minimal() {
- let composer_json_content =
- r#"{"name": "test/project", "require": {"php": ">=8.1"}}"#.to_string();
- let composer_json: RawPackageData = serde_json::from_str(&composer_json_content).unwrap();
-
- let request = LockFileGenerationRequest {
- resolved_packages: vec![],
- composer_json_content: composer_json_content.clone(),
- composer_json,
- include_dev: true,
- repo_cache: None,
- };
-
- let lock = generate_lock_file(&request).unwrap();
-
- assert_eq!(lock.packages.len(), 0);
- assert_eq!(lock.packages_dev.as_ref().unwrap().len(), 0);
- assert_eq!(lock.minimum_stability, "stable");
- assert!(!lock.prefer_stable);
- assert!(!lock.prefer_lowest);
- assert_eq!(lock.plugin_api_version.as_deref(), Some("2.6.0"));
-
- // Verify content-hash matches
- let expected_hash = LockFile::compute_content_hash(&composer_json_content).unwrap();
- assert_eq!(lock.content_hash, expected_hash);
-
- // Verify platform requirements extracted
- let platform_obj = lock.platform.as_object().unwrap();
- assert!(platform_obj.contains_key("php"));
- assert_eq!(
- platform_obj["php"],
- serde_json::Value::String(">=8.1".to_string())
- );
- }
-
- #[test]
- fn test_lock_file_packages_sorted() {
- // Verify that packages are sorted alphabetically when assembled in generate_lock_file
- // We test this by constructing two LockedPackages and sorting them the same way
-
- let mut packages = vec![
- LockedPackage {
- name: "vendor/zebra".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: None,
- source: None,
- dist: None,
- require: BTreeMap::new(),
- require_dev: BTreeMap::new(),
- conflict: BTreeMap::new(),
- suggest: None,
- package_type: None,
- autoload: None,
- autoload_dev: None,
- license: None,
- description: None,
- homepage: None,
- keywords: None,
- authors: None,
- support: None,
- funding: None,
- time: None,
- extra_fields: BTreeMap::new(),
- },
- LockedPackage {
- name: "vendor/alpha".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: None,
- source: None,
- dist: None,
- require: BTreeMap::new(),
- require_dev: BTreeMap::new(),
- conflict: BTreeMap::new(),
- suggest: None,
- package_type: None,
- autoload: None,
- autoload_dev: None,
- license: None,
- description: None,
- homepage: None,
- keywords: None,
- authors: None,
- support: None,
- funding: None,
- time: None,
- extra_fields: BTreeMap::new(),
- },
- ];
-
- packages.sort_by(|a, b| a.name.cmp(&b.name));
-
- assert_eq!(packages[0].name, "vendor/alpha");
- assert_eq!(packages[1].name, "vendor/zebra");
- }
-
- #[test]
- #[ignore]
- fn test_generate_lock_file_monolog() {
- use 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<String>,
- pub description: String,
-}
-
-/// Package author metadata.
-#[derive(Debug, Clone)]
-pub struct Author {
- pub name: Option<String>,
- pub email: Option<String>,
- pub homepage: Option<String>,
- pub role: Option<String>,
-}
-
-/// Autoload rule sets (PSR-4, PSR-0, classmap, files).
-#[derive(Debug, Clone, Default)]
-pub struct AutoloadRules {
- pub psr4: BTreeMap<String, Vec<String>>,
- pub psr0: BTreeMap<String, Vec<String>>,
- pub classmap: Vec<String>,
- pub files: Vec<String>,
-}
-
-/// Support channel information.
-#[derive(Debug, Clone, Default)]
-pub struct Support {
- pub email: Option<String>,
- pub issues: Option<String>,
- pub forum: Option<String>,
- pub wiki: Option<String>,
- pub source: Option<String>,
- pub docs: Option<String>,
- pub irc: Option<String>,
- pub chat: Option<String>,
- pub rss: Option<String>,
- pub security: Option<String>,
-}
-
-/// Funding link.
-#[derive(Debug, Clone)]
-pub struct Funding {
- pub url: Option<String>,
- pub funding_type: Option<String>,
-}
-
-/// Version alias entry for root packages.
-#[derive(Debug, Clone)]
-pub struct VersionAlias {
- pub package: String,
- pub version: String,
- pub alias: String,
- pub alias_normalized: String,
-}
-
-/// Core package data covering `BasePackage` + `Package` fields.
-/// Corresponds to `Composer\Package\Package` (implements `PackageInterface`).
-#[derive(Debug, Clone)]
-pub struct PackageData {
- // BasePackage fields
- pub name: String,
- pub pretty_name: String,
-
- // Package fields
- pub version: String,
- pub pretty_version: String,
- pub package_type: String,
- pub target_dir: Option<String>,
-
- // source
- pub source_type: Option<String>,
- pub source_url: Option<String>,
- pub source_reference: Option<String>,
-
- // dist
- pub dist_type: Option<String>,
- pub dist_url: Option<String>,
- pub dist_reference: Option<String>,
- pub dist_sha1_checksum: Option<String>,
-
- pub release_date: Option<String>,
- pub extra: BTreeMap<String, serde_json::Value>,
- pub binaries: Vec<String>,
- pub dev: bool,
- pub stability: Stability,
- pub notification_url: Option<String>,
-
- // dependency links
- pub requires: BTreeMap<String, Link>,
- pub conflicts: BTreeMap<String, Link>,
- pub provides: BTreeMap<String, Link>,
- pub replaces: BTreeMap<String, Link>,
- pub dev_requires: BTreeMap<String, Link>,
- pub suggests: BTreeMap<String, String>,
-
- // autoload
- pub autoload: AutoloadRules,
- pub dev_autoload: AutoloadRules,
-
- pub is_default_branch: bool,
-}
-
-/// Package with full metadata (description, authors, license, etc.).
-/// Corresponds to `Composer\Package\CompletePackage`.
-#[derive(Debug, Clone)]
-pub struct CompletePackageData {
- pub package: PackageData,
-
- pub description: Option<String>,
- pub homepage: Option<String>,
- pub license: Vec<String>,
- pub keywords: Vec<String>,
- pub authors: Vec<Author>,
- pub scripts: BTreeMap<String, Vec<String>>,
- pub support: Support,
- pub funding: Vec<Funding>,
- pub repositories: Vec<serde_json::Value>,
- /// `None` = not abandoned, `Some("")` = abandoned, `Some(pkg)` = replaced by pkg.
- pub abandoned: Option<String>,
- pub archive_name: Option<String>,
- pub archive_excludes: Vec<String>,
-}
-
-/// The root project package with project-level configuration.
-/// Corresponds to `Composer\Package\RootPackage`.
-#[derive(Debug, Clone)]
-pub struct RootPackageData {
- pub complete: CompletePackageData,
-
- pub minimum_stability: Stability,
- pub prefer_stable: bool,
- pub stability_flags: BTreeMap<String, Stability>,
- pub config: BTreeMap<String, serde_json::Value>,
- pub references: BTreeMap<String, String>,
- pub aliases: Vec<VersionAlias>,
-}
-
-/// Accessor for `PackageData` fields.
-/// Corresponds to `Composer\Package\PackageInterface`.
-pub trait Package {
- fn name(&self) -> &str;
- fn pretty_name(&self) -> &str;
- fn version(&self) -> &str;
- fn pretty_version(&self) -> &str;
- fn package_type(&self) -> &str;
- fn target_dir(&self) -> Option<&str>;
- fn source_type(&self) -> Option<&str>;
- fn source_url(&self) -> Option<&str>;
- fn source_reference(&self) -> Option<&str>;
- fn dist_type(&self) -> Option<&str>;
- fn dist_url(&self) -> Option<&str>;
- fn dist_reference(&self) -> Option<&str>;
- fn dist_sha1_checksum(&self) -> Option<&str>;
- fn release_date(&self) -> Option<&str>;
- fn extra(&self) -> &BTreeMap<String, serde_json::Value>;
- fn binaries(&self) -> &[String];
- fn is_dev(&self) -> bool;
- fn stability(&self) -> Stability;
- fn notification_url(&self) -> Option<&str>;
- fn requires(&self) -> &BTreeMap<String, Link>;
- fn conflicts(&self) -> &BTreeMap<String, Link>;
- fn provides(&self) -> &BTreeMap<String, Link>;
- fn replaces(&self) -> &BTreeMap<String, Link>;
- fn dev_requires(&self) -> &BTreeMap<String, Link>;
- fn suggests(&self) -> &BTreeMap<String, String>;
- fn autoload(&self) -> &AutoloadRules;
- fn dev_autoload(&self) -> &AutoloadRules;
- fn is_default_branch(&self) -> bool;
-}
-
-/// Accessor for `CompletePackageData` fields.
-/// Corresponds to `Composer\Package\CompletePackageInterface`.
-pub trait CompletePackage: Package {
- fn description(&self) -> Option<&str>;
- fn homepage(&self) -> Option<&str>;
- fn license(&self) -> &[String];
- fn keywords(&self) -> &[String];
- fn authors(&self) -> &[Author];
- fn scripts(&self) -> &BTreeMap<String, Vec<String>>;
- fn support(&self) -> &Support;
- fn funding(&self) -> &[Funding];
- fn repositories(&self) -> &[serde_json::Value];
- fn abandoned(&self) -> Option<&str>;
- fn archive_name(&self) -> Option<&str>;
- fn archive_excludes(&self) -> &[String];
-}
-
-/// Accessor for `RootPackageData` fields.
-/// Corresponds to `Composer\Package\RootPackageInterface`.
-pub trait RootPackage: CompletePackage {
- fn minimum_stability(&self) -> Stability;
- fn prefer_stable(&self) -> bool;
- fn stability_flags(&self) -> &BTreeMap<String, Stability>;
- fn config(&self) -> &BTreeMap<String, serde_json::Value>;
- fn references(&self) -> &BTreeMap<String, String>;
- fn aliases(&self) -> &[VersionAlias];
-}
-
-// ──────────────────────────────────────────────
-// Delegation macros
-// ──────────────────────────────────────────────
-
-/// Implements `Package` trait by delegating to an inner `PackageData` field.
-macro_rules! delegate_package {
- ($type:ty => $($path:ident).+) => {
- impl Package for $type {
- fn name(&self) -> &str { &self.$($path).+.name }
- fn pretty_name(&self) -> &str { &self.$($path).+.pretty_name }
- fn version(&self) -> &str { &self.$($path).+.version }
- fn pretty_version(&self) -> &str { &self.$($path).+.pretty_version }
- fn package_type(&self) -> &str { &self.$($path).+.package_type }
- fn target_dir(&self) -> Option<&str> { self.$($path).+.target_dir.as_deref() }
- fn source_type(&self) -> Option<&str> { self.$($path).+.source_type.as_deref() }
- fn source_url(&self) -> Option<&str> { self.$($path).+.source_url.as_deref() }
- fn source_reference(&self) -> Option<&str> { self.$($path).+.source_reference.as_deref() }
- fn dist_type(&self) -> Option<&str> { self.$($path).+.dist_type.as_deref() }
- fn dist_url(&self) -> Option<&str> { self.$($path).+.dist_url.as_deref() }
- fn dist_reference(&self) -> Option<&str> { self.$($path).+.dist_reference.as_deref() }
- fn dist_sha1_checksum(&self) -> Option<&str> { self.$($path).+.dist_sha1_checksum.as_deref() }
- fn release_date(&self) -> Option<&str> { self.$($path).+.release_date.as_deref() }
- fn extra(&self) -> &BTreeMap<String, serde_json::Value> { &self.$($path).+.extra }
- fn binaries(&self) -> &[String] { &self.$($path).+.binaries }
- fn is_dev(&self) -> bool { self.$($path).+.dev }
- fn stability(&self) -> Stability { self.$($path).+.stability }
- fn notification_url(&self) -> Option<&str> { self.$($path).+.notification_url.as_deref() }
- fn requires(&self) -> &BTreeMap<String, Link> { &self.$($path).+.requires }
- fn conflicts(&self) -> &BTreeMap<String, Link> { &self.$($path).+.conflicts }
- fn provides(&self) -> &BTreeMap<String, Link> { &self.$($path).+.provides }
- fn replaces(&self) -> &BTreeMap<String, Link> { &self.$($path).+.replaces }
- fn dev_requires(&self) -> &BTreeMap<String, Link> { &self.$($path).+.dev_requires }
- fn suggests(&self) -> &BTreeMap<String, String> { &self.$($path).+.suggests }
- fn autoload(&self) -> &AutoloadRules { &self.$($path).+.autoload }
- fn dev_autoload(&self) -> &AutoloadRules { &self.$($path).+.dev_autoload }
- fn is_default_branch(&self) -> bool { self.$($path).+.is_default_branch }
- }
- };
-}
-
-/// Implements `CompletePackage` trait by delegating to an inner `CompletePackageData` field.
-macro_rules! delegate_complete_package {
- ($type:ty => $($path:ident).+) => {
- impl CompletePackage for $type {
- fn description(&self) -> Option<&str> { self.$($path).+.description.as_deref() }
- fn homepage(&self) -> Option<&str> { self.$($path).+.homepage.as_deref() }
- fn license(&self) -> &[String] { &self.$($path).+.license }
- fn keywords(&self) -> &[String] { &self.$($path).+.keywords }
- fn authors(&self) -> &[Author] { &self.$($path).+.authors }
- fn scripts(&self) -> &BTreeMap<String, Vec<String>> { &self.$($path).+.scripts }
- fn support(&self) -> &Support { &self.$($path).+.support }
- fn funding(&self) -> &[Funding] { &self.$($path).+.funding }
- fn repositories(&self) -> &[serde_json::Value] { &self.$($path).+.repositories }
- fn abandoned(&self) -> Option<&str> { self.$($path).+.abandoned.as_deref() }
- fn archive_name(&self) -> Option<&str> { self.$($path).+.archive_name.as_deref() }
- fn archive_excludes(&self) -> &[String] { &self.$($path).+.archive_excludes }
- }
- };
-}
-
-impl Package for PackageData {
- fn name(&self) -> &str {
- &self.name
- }
- fn pretty_name(&self) -> &str {
- &self.pretty_name
- }
- fn version(&self) -> &str {
- &self.version
- }
- fn pretty_version(&self) -> &str {
- &self.pretty_version
- }
- fn package_type(&self) -> &str {
- &self.package_type
- }
- fn target_dir(&self) -> Option<&str> {
- self.target_dir.as_deref()
- }
- fn source_type(&self) -> Option<&str> {
- self.source_type.as_deref()
- }
- fn source_url(&self) -> Option<&str> {
- self.source_url.as_deref()
- }
- fn source_reference(&self) -> Option<&str> {
- self.source_reference.as_deref()
- }
- fn dist_type(&self) -> Option<&str> {
- self.dist_type.as_deref()
- }
- fn dist_url(&self) -> Option<&str> {
- self.dist_url.as_deref()
- }
- fn dist_reference(&self) -> Option<&str> {
- self.dist_reference.as_deref()
- }
- fn dist_sha1_checksum(&self) -> Option<&str> {
- self.dist_sha1_checksum.as_deref()
- }
- fn release_date(&self) -> Option<&str> {
- self.release_date.as_deref()
- }
- fn extra(&self) -> &BTreeMap<String, serde_json::Value> {
- &self.extra
- }
- fn binaries(&self) -> &[String] {
- &self.binaries
- }
- fn is_dev(&self) -> bool {
- self.dev
- }
- fn stability(&self) -> Stability {
- self.stability
- }
- fn notification_url(&self) -> Option<&str> {
- self.notification_url.as_deref()
- }
- fn requires(&self) -> &BTreeMap<String, Link> {
- &self.requires
- }
- fn conflicts(&self) -> &BTreeMap<String, Link> {
- &self.conflicts
- }
- fn provides(&self) -> &BTreeMap<String, Link> {
- &self.provides
- }
- fn replaces(&self) -> &BTreeMap<String, Link> {
- &self.replaces
- }
- fn dev_requires(&self) -> &BTreeMap<String, Link> {
- &self.dev_requires
- }
- fn suggests(&self) -> &BTreeMap<String, String> {
- &self.suggests
- }
- fn autoload(&self) -> &AutoloadRules {
- &self.autoload
- }
- fn dev_autoload(&self) -> &AutoloadRules {
- &self.dev_autoload
- }
- fn is_default_branch(&self) -> bool {
- self.is_default_branch
- }
-}
-
-impl CompletePackage for CompletePackageData {
- fn description(&self) -> Option<&str> {
- self.description.as_deref()
- }
- fn homepage(&self) -> Option<&str> {
- self.homepage.as_deref()
- }
- fn license(&self) -> &[String] {
- &self.license
- }
- fn keywords(&self) -> &[String] {
- &self.keywords
- }
- fn authors(&self) -> &[Author] {
- &self.authors
- }
- fn scripts(&self) -> &BTreeMap<String, Vec<String>> {
- &self.scripts
- }
- fn support(&self) -> &Support {
- &self.support
- }
- fn funding(&self) -> &[Funding] {
- &self.funding
- }
- fn repositories(&self) -> &[serde_json::Value] {
- &self.repositories
- }
- fn abandoned(&self) -> Option<&str> {
- self.abandoned.as_deref()
- }
- fn archive_name(&self) -> Option<&str> {
- self.archive_name.as_deref()
- }
- fn archive_excludes(&self) -> &[String] {
- &self.archive_excludes
- }
-}
-
-impl RootPackage for RootPackageData {
- fn minimum_stability(&self) -> Stability {
- self.minimum_stability
- }
- fn prefer_stable(&self) -> bool {
- self.prefer_stable
- }
- fn stability_flags(&self) -> &BTreeMap<String, Stability> {
- &self.stability_flags
- }
- fn config(&self) -> &BTreeMap<String, serde_json::Value> {
- &self.config
- }
- fn references(&self) -> &BTreeMap<String, String> {
- &self.references
- }
- fn aliases(&self) -> &[VersionAlias] {
- &self.aliases
- }
-}
-
-// CompletePackageData delegates Package → inner PackageData
-delegate_package!(CompletePackageData => package);
-
-// RootPackageData delegates Package → inner CompletePackageData → PackageData
-delegate_package!(RootPackageData => complete.package);
-
-// RootPackageData delegates CompletePackage → inner CompletePackageData
-delegate_complete_package!(RootPackageData => complete);
-
-/// Unstructured representation of a composer.json file.
-/// Used by `init` and `create-project` to write a new composer.json.
-/// Unlike the typed hierarchy above, all fields live at a single level
-/// and map directly to the JSON keys via serde.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct RawPackageData {
- pub name: String,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub description: Option<String>,
-
- #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
- pub package_type: Option<String>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub homepage: Option<String>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub license: Option<String>,
-
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
- pub authors: Vec<RawAuthor>,
-
- #[serde(rename = "minimum-stability", skip_serializing_if = "Option::is_none")]
- pub minimum_stability: Option<String>,
-
- #[serde(default)]
- pub require: BTreeMap<String, String>,
-
- #[serde(
- rename = "require-dev",
- default,
- skip_serializing_if = "BTreeMap::is_empty"
- )]
- pub require_dev: BTreeMap<String, String>,
-
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
- pub repositories: Vec<RawRepository>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub autoload: Option<RawAutoload>,
-
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
- pub bin: Vec<String>,
-
- #[serde(flatten)]
- pub extra_fields: BTreeMap<String, serde_json::Value>,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct RawAuthor {
- pub name: String,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub email: Option<String>,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct RawAutoload {
- #[serde(rename = "psr-4")]
- pub psr4: BTreeMap<String, String>,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct RawRepository {
- #[serde(rename = "type")]
- pub repo_type: String,
- pub url: String,
-}
-
-impl RawPackageData {
- pub fn new(name: String) -> Self {
- Self {
- name,
- description: None,
- package_type: None,
- homepage: None,
- license: None,
- authors: Vec::new(),
- minimum_stability: None,
- require: BTreeMap::new(),
- require_dev: BTreeMap::new(),
- repositories: Vec::new(),
- autoload: None,
- bin: Vec::new(),
- extra_fields: BTreeMap::new(),
- }
- }
-}
-
-pub fn read_from_file(path: &Path) -> anyhow::Result<RawPackageData> {
- let content = fs::read_to_string(path)?;
- let data: RawPackageData = serde_json::from_str(&content)?;
- Ok(data)
-}
-
-pub fn to_json_pretty(value: &impl Serialize) -> serde_json::Result<String> {
- let formatter = serde_json::ser::PrettyFormatter::with_indent(b" ");
- let mut buf = Vec::new();
- let mut ser = serde_json::Serializer::with_formatter(&mut buf, formatter);
- value.serialize(&mut ser)?;
- let mut json = String::from_utf8(buf).expect("serde_json produces valid UTF-8");
- json.push('\n');
- Ok(json)
-}
-
-pub fn write_to_file(value: &impl Serialize, path: &Path) -> anyhow::Result<()> {
- let json = to_json_pretty(value)?;
- fs::write(path, json)?;
- Ok(())
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn raw_minimal_json() {
- let raw = RawPackageData::new("test/pkg".to_string());
- let json = to_json_pretty(&raw).unwrap();
- let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
-
- assert_eq!(parsed["name"], "test/pkg");
- assert!(parsed["require"].is_object());
- assert!(parsed.get("description").is_none());
- assert!(parsed.get("type").is_none());
- assert!(parsed.get("authors").is_none());
- assert!(parsed.get("require-dev").is_none());
- assert!(parsed.get("autoload").is_none());
- }
-
- #[test]
- fn raw_full_json() {
- let mut raw = RawPackageData::new("acme/full".to_string());
- raw.description = Some("A full package".to_string());
- raw.package_type = Some("library".to_string());
- raw.homepage = Some("https://example.com".to_string());
- raw.license = Some("MIT".to_string());
- raw.authors = vec![RawAuthor {
- name: "Jane Doe".to_string(),
- email: Some("jane@example.com".to_string()),
- }];
- raw.minimum_stability = Some("dev".to_string());
- raw.require.insert("php".to_string(), ">=8.1".to_string());
- raw.require_dev
- .insert("phpunit/phpunit".to_string(), "^10.0".to_string());
- raw.repositories = vec![RawRepository {
- repo_type: "vcs".to_string(),
- url: "https://github.com/acme/repo".to_string(),
- }];
-
- let mut psr4 = BTreeMap::new();
- psr4.insert("Acme\\Full\\".to_string(), "src/".to_string());
- raw.autoload = Some(RawAutoload { psr4 });
-
- let json = to_json_pretty(&raw).unwrap();
- let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
-
- assert_eq!(parsed["name"], "acme/full");
- assert_eq!(parsed["description"], "A full package");
- assert_eq!(parsed["type"], "library");
- assert_eq!(parsed["homepage"], "https://example.com");
- assert_eq!(parsed["license"], "MIT");
- assert_eq!(parsed["minimum-stability"], "dev");
- assert_eq!(parsed["authors"][0]["name"], "Jane Doe");
- assert_eq!(parsed["authors"][0]["email"], "jane@example.com");
- assert_eq!(parsed["require"]["php"], ">=8.1");
- assert_eq!(parsed["require-dev"]["phpunit/phpunit"], "^10.0");
- assert_eq!(parsed["repositories"][0]["type"], "vcs");
- assert_eq!(parsed["autoload"]["psr-4"]["Acme\\Full\\"], "src/");
- }
-
- #[test]
- fn raw_deserialize_minimal() {
- let json = r#"{"name": "test/pkg"}"#;
- let raw: RawPackageData = serde_json::from_str(json).unwrap();
- assert_eq!(raw.name, "test/pkg");
- assert!(raw.description.is_none());
- assert!(raw.require.is_empty());
- assert!(raw.require_dev.is_empty());
- assert!(raw.authors.is_empty());
- assert!(raw.extra_fields.is_empty());
- }
-
- #[test]
- fn raw_roundtrip_preserves_all_fields() {
- let mut raw = RawPackageData::new("acme/roundtrip".to_string());
- raw.description = Some("Test roundtrip".to_string());
- raw.require.insert("php".to_string(), ">=8.1".to_string());
- raw.require_dev
- .insert("phpunit/phpunit".to_string(), "^10.0".to_string());
-
- let json1 = to_json_pretty(&raw).unwrap();
- let deserialized: RawPackageData = serde_json::from_str(&json1).unwrap();
- let json2 = to_json_pretty(&deserialized).unwrap();
- assert_eq!(json1, json2);
- }
-
- #[test]
- fn raw_extra_fields_preserved() {
- let json = r#"{
- "name": "test/extra",
- "require": {},
- "scripts": {"post-install-cmd": ["echo hello"]},
- "config": {"sort-packages": true},
- "extra": {"custom-key": "custom-value"}
- }"#;
- let raw: RawPackageData = serde_json::from_str(json).unwrap();
- assert_eq!(raw.name, "test/extra");
- assert!(raw.extra_fields.contains_key("scripts"));
- assert!(raw.extra_fields.contains_key("config"));
- assert!(raw.extra_fields.contains_key("extra"));
-
- // Roundtrip: extra fields should be preserved in output
- let output = to_json_pretty(&raw).unwrap();
- let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
- assert!(parsed["scripts"].is_object());
- assert!(parsed["config"].is_object());
- assert!(parsed["extra"].is_object());
- }
-
- #[test]
- fn raw_read_from_file() {
- let dir = tempfile::tempdir().unwrap();
- let path = dir.path().join("composer.json");
- let content = r#"{"name": "test/file", "require": {"php": ">=8.0"}}"#;
- std::fs::write(&path, content).unwrap();
-
- let raw = read_from_file(&path).unwrap();
- assert_eq!(raw.name, "test/file");
- assert_eq!(raw.require.get("php").unwrap(), ">=8.0");
- }
-
- #[test]
- fn raw_none_fields_omitted() {
- let raw = RawPackageData::new("test/empty".to_string());
- let json = to_json_pretty(&raw).unwrap();
-
- assert!(!json.contains("\"description\""));
- assert!(!json.contains("\"type\""));
- assert!(!json.contains("\"homepage\""));
- assert!(!json.contains("\"license\""));
- assert!(!json.contains("\"authors\""));
- assert!(!json.contains("\"minimum-stability\""));
- assert!(!json.contains("\"require-dev\""));
- assert!(!json.contains("\"repositories\""));
- assert!(!json.contains("\"autoload\""));
- }
-}
diff --git a/crates/mozart/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<String>,
- pub shasum: Option<String>,
-}
-
-#[derive(Debug, Clone, Deserialize)]
-pub struct PackagistSource {
- #[serde(rename = "type")]
- pub source_type: String,
- pub url: String,
- pub reference: Option<String>,
-}
-
-#[derive(Debug, Clone, Deserialize)]
-pub struct PackagistVersion {
- pub version: String,
- pub version_normalized: String,
- #[serde(default)]
- pub require: BTreeMap<String, String>,
- #[serde(default)]
- pub replace: BTreeMap<String, String>,
- #[serde(default)]
- pub provide: BTreeMap<String, String>,
- #[serde(default)]
- pub conflict: BTreeMap<String, String>,
- pub dist: Option<PackagistDist>,
- pub source: Option<PackagistSource>,
-
- #[serde(rename = "require-dev", default)]
- pub require_dev: BTreeMap<String, String>,
-
- #[serde(default)]
- pub suggest: Option<BTreeMap<String, String>>,
-
- #[serde(rename = "type")]
- pub package_type: Option<String>,
-
- pub autoload: Option<serde_json::Value>,
-
- #[serde(rename = "autoload-dev")]
- pub autoload_dev: Option<serde_json::Value>,
-
- pub license: Option<Vec<String>>,
-
- pub description: Option<String>,
-
- pub homepage: Option<String>,
-
- pub keywords: Option<Vec<String>>,
-
- pub authors: Option<Vec<serde_json::Value>>,
-
- pub support: Option<serde_json::Value>,
-
- pub funding: Option<Vec<serde_json::Value>>,
-
- pub time: Option<String>,
-
- pub extra: Option<serde_json::Value>,
-
- #[serde(rename = "notification-url")]
- pub notification_url: Option<String>,
-}
-
-impl PackagistVersion {
- /// Extract the `extra.branch-alias` map from this version's metadata.
- ///
- /// Composer packages can declare branch aliases in `extra.branch-alias`:
- /// ```json
- /// {
- /// "extra": {
- /// "branch-alias": {
- /// "dev-master": "2.x-dev"
- /// }
- /// }
- /// }
- /// ```
- ///
- /// Returns a map from branch name (e.g. `"dev-master"`) to alias target
- /// (e.g. `"2.x-dev"`). Returns an empty map when no aliases are declared.
- pub fn branch_aliases(&self) -> BTreeMap<String, String> {
- let Some(extra) = &self.extra else {
- return BTreeMap::new();
- };
-
- let Some(branch_alias) = extra.get("branch-alias") else {
- return BTreeMap::new();
- };
-
- let Some(map) = branch_alias.as_object() else {
- return BTreeMap::new();
- };
-
- map.iter()
- .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
- .collect()
- }
-}
-
-/// Parse a Packagist p2 API JSON response.
-///
-/// The response format is: `{"packages": {"vendor/package": [...]}}`.
-pub fn parse_p2_response(json: &str, package_name: &str) -> anyhow::Result<Vec<PackagistVersion>> {
- #[derive(Deserialize)]
- struct P2Response {
- packages: BTreeMap<String, Vec<PackagistVersion>>,
- }
-
- let response: P2Response = serde_json::from_str(json)?;
- response
- .packages
- .into_iter()
- .find(|(key, _)| key == package_name)
- .map(|(_, versions)| versions)
- .ok_or_else(|| anyhow::anyhow!("Package \"{package_name}\" not found in response"))
-}
-
-/// Fetch package version metadata from the Packagist p2 API.
-///
-/// If `repo_cache` is provided, the JSON response is cached on disk under the
-/// key `"provider-{vendor}~{package}.json"`. Subsequent calls for the same
-/// package are served from cache without a network request.
-pub fn fetch_package_versions(
- package_name: &str,
- repo_cache: Option<&Cache>,
-) -> anyhow::Result<Vec<PackagistVersion>> {
- // Build cache key: replace `/` with `~` per cache key convention
- let cache_key = format!("provider-{}.json", package_name.replace('/', "~"));
-
- // Check cache first
- if let Some(cache) = repo_cache
- && let Some(cached) = cache.read(&cache_key)
- {
- return parse_p2_response(&cached, package_name);
- }
-
- // Cache miss — fetch from Packagist
- let url = format!("https://repo.packagist.org/p2/{package_name}.json");
- let response = reqwest::blocking::get(&url)?;
-
- if !response.status().is_success() {
- anyhow::bail!(
- "Failed to fetch package \"{package_name}\" from Packagist (HTTP {})",
- response.status()
- );
- }
-
- let body = response.text()?;
-
- // Write to cache
- if let Some(cache) = repo_cache {
- let _ = cache.write(&cache_key, &body);
- }
-
- parse_p2_response(&body, package_name)
-}
-
-// ─────────────────────────────────────────────────────────────────────────────
-// Packagist search API
-// ─────────────────────────────────────────────────────────────────────────────
-
-/// A single search result from the Packagist search API.
-#[derive(Debug, Deserialize, Serialize, Clone)]
-pub struct SearchResult {
- pub name: String,
- pub description: String,
- pub url: String,
- pub repository: Option<String>,
- pub downloads: u64,
- pub favers: u64,
-}
-
-#[derive(Debug, Deserialize)]
-pub struct SearchResponse {
- pub results: Vec<SearchResult>,
- pub total: u64,
- pub next: Option<String>,
-}
-
-/// Maximum number of pages to fetch from the Packagist search API.
-const SEARCH_MAX_PAGES: usize = 20;
-
-/// Percent-encode a string for use in a URL query parameter value.
-fn url_encode(s: &str) -> String {
- let mut encoded = String::with_capacity(s.len());
- for byte in s.bytes() {
- match byte {
- b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
- encoded.push(byte as char);
- }
- b' ' => encoded.push_str("%20"),
- other => {
- encoded.push_str(&format!("%{other:02X}"));
- }
- }
- }
- encoded
-}
-
-/// Search Packagist for packages matching `query`.
-///
-/// Fetches up to `SEARCH_MAX_PAGES` pages of results and returns the full list.
-/// An optional `package_type` filter can narrow results (e.g. `"library"`).
-pub fn search_packages(
- query: &str,
- package_type: Option<&str>,
-) -> anyhow::Result<(Vec<SearchResult>, u64)> {
- let client = reqwest::blocking::Client::builder()
- .user_agent("mozart/0.1.0")
- .build()?;
-
- let mut all_results: Vec<SearchResult> = Vec::new();
- let mut page = 1usize;
- let mut next_url: Option<String> = None;
- let mut total: u64 = 0;
-
- loop {
- let response: SearchResponse = if let Some(ref url) = next_url {
- let resp = client.get(url).send()?;
- if !resp.status().is_success() {
- anyhow::bail!("Packagist search request failed (HTTP {})", resp.status());
- }
- resp.json()?
- } else {
- let encoded_query = url_encode(query);
- let mut url = format!("https://packagist.org/search.json?q={encoded_query}");
- if let Some(t) = package_type {
- url.push_str("&type=");
- url.push_str(&url_encode(t));
- }
-
- let resp = client.get(&url).send()?;
- if !resp.status().is_success() {
- anyhow::bail!("Packagist search request failed (HTTP {})", resp.status());
- }
- resp.json()?
- };
-
- if page == 1 {
- total = response.total;
- }
-
- all_results.extend(response.results);
- next_url = response.next;
- page += 1;
-
- if next_url.is_none() || page > SEARCH_MAX_PAGES {
- break;
- }
- }
-
- Ok((all_results, total))
-}
-
-// ─────────────────────────────────────────────────────────────────────────────
-// Security Advisories API
-// ─────────────────────────────────────────────────────────────────────────────
-
-/// A single security advisory from the Packagist API.
-#[derive(Debug, Clone, Deserialize, Serialize)]
-pub struct SecurityAdvisory {
- #[serde(rename = "advisoryId")]
- pub advisory_id: String,
-
- #[serde(rename = "packageName")]
- pub package_name: String,
-
- #[serde(rename = "remoteId")]
- pub remote_id: String,
-
- pub title: String,
-
- pub link: Option<String>,
-
- pub cve: Option<String>,
-
- /// Composer version constraint string, e.g. ">=1.0,<1.5.1|>=2.0,<2.3"
- #[serde(rename = "affectedVersions")]
- pub affected_versions: String,
-
- pub source: String,
-
- #[serde(rename = "reportedAt")]
- pub reported_at: String,
-
- #[serde(rename = "composerRepository")]
- pub composer_repository: Option<String>,
-
- pub severity: Option<String>,
-
- #[serde(default)]
- pub sources: Vec<AdvisorySource>,
-}
-
-/// A source entry within a security advisory.
-#[derive(Debug, Clone, Deserialize, Serialize)]
-pub struct AdvisorySource {
- pub name: String,
- #[serde(rename = "remoteId")]
- pub remote_id: String,
-}
-
-/// Response from POST `https://packagist.org/api/security-advisories/`.
-#[derive(Debug, Deserialize)]
-pub struct SecurityAdvisoriesResponse {
- pub advisories: BTreeMap<String, Vec<SecurityAdvisory>>,
-}
-
-/// Fetch security advisories for the given package names from the Packagist API.
-///
-/// Sends a POST request to `https://packagist.org/api/security-advisories/`
-/// with form-encoded package names. Returns advisories grouped by package name.
-///
-/// If the package list is very large (500+), requests are batched in chunks of
-/// 500 names per request and the results are merged.
-pub fn fetch_security_advisories(
- package_names: &[&str],
-) -> anyhow::Result<BTreeMap<String, Vec<SecurityAdvisory>>> {
- let client = reqwest::blocking::Client::builder()
- .user_agent("mozart/0.1.0")
- .build()?;
-
- let mut all_advisories: BTreeMap<String, Vec<SecurityAdvisory>> = BTreeMap::new();
-
- for chunk in package_names.chunks(500) {
- // Build an application/x-www-form-urlencoded body manually.
- // Each package is encoded as `packages[]=<name>` and joined with `&`.
- let body: String = chunk
- .iter()
- .map(|name| format!("packages[]={}", url_encode(name)))
- .collect::<Vec<_>>()
- .join("&");
-
- let response = client
- .post("https://packagist.org/api/security-advisories/")
- .header("Content-Type", "application/x-www-form-urlencoded")
- .body(body)
- .send()?;
-
- if !response.status().is_success() {
- anyhow::bail!(
- "Packagist security advisories request failed (HTTP {})",
- response.status()
- );
- }
-
- let parsed: SecurityAdvisoriesResponse = response.json()?;
-
- for (pkg_name, advisories) in parsed.advisories {
- if !advisories.is_empty() {
- all_advisories
- .entry(pkg_name)
- .or_default()
- .extend(advisories);
- }
- }
- }
-
- Ok(all_advisories)
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn parse_p2_response_basic() {
- let json = r#"{
- "packages": {
- "monolog/monolog": [
- {
- "version": "3.8.0",
- "version_normalized": "3.8.0.0",
- "require": {"php": ">=8.1"},
- "dist": {
- "type": "zip",
- "url": "https://example.com/monolog-3.8.0.zip",
- "reference": "abc123",
- "shasum": ""
- },
- "source": {
- "type": "git",
- "url": "https://github.com/Seldaek/monolog.git",
- "reference": "abc123"
- }
- },
- {
- "version": "3.7.0",
- "version_normalized": "3.7.0.0",
- "require": {"php": ">=8.1"}
- }
- ]
- }
- }"#;
-
- let versions = parse_p2_response(json, "monolog/monolog").unwrap();
- assert_eq!(versions.len(), 2);
- assert_eq!(versions[0].version, "3.8.0");
- assert_eq!(versions[0].version_normalized, "3.8.0.0");
- assert_eq!(versions[0].require.get("php").unwrap(), ">=8.1");
- assert!(versions[0].dist.is_some());
- assert!(versions[0].source.is_some());
- assert_eq!(versions[1].version, "3.7.0");
- assert!(versions[1].dist.is_none());
- }
-
- #[test]
- fn parse_p2_response_not_found() {
- let json = r#"{"packages": {"other/pkg": []}}"#;
- let result = parse_p2_response(json, "monolog/monolog");
- assert!(result.is_err());
- }
-
- #[test]
- fn parse_p2_response_with_dev_version() {
- let json = r#"{
- "packages": {
- "test/pkg": [
- {
- "version": "dev-master",
- "version_normalized": "dev-master",
- "require": {}
- },
- {
- "version": "1.0.0",
- "version_normalized": "1.0.0.0",
- "require": {}
- }
- ]
- }
- }"#;
-
- let versions = parse_p2_response(json, "test/pkg").unwrap();
- assert_eq!(versions.len(), 2);
- assert_eq!(versions[0].version, "dev-master");
- assert_eq!(versions[1].version, "1.0.0");
- }
-
- // ──────────── branch_aliases() tests ────────────
-
- #[test]
- fn test_branch_aliases_present() {
- let json = r#"{
- "packages": {
- "test/pkg": [
- {
- "version": "dev-master",
- "version_normalized": "dev-master",
- "require": {},
- "extra": {
- "branch-alias": {
- "dev-master": "2.x-dev"
- }
- }
- }
- ]
- }
- }"#;
-
- let versions = parse_p2_response(json, "test/pkg").unwrap();
- let aliases = versions[0].branch_aliases();
- assert_eq!(aliases.len(), 1);
- assert_eq!(aliases.get("dev-master").unwrap(), "2.x-dev");
- }
-
- #[test]
- fn test_branch_aliases_multiple() {
- let json = r#"{
- "packages": {
- "test/pkg": [
- {
- "version": "dev-master",
- "version_normalized": "dev-master",
- "require": {},
- "extra": {
- "branch-alias": {
- "dev-master": "2.x-dev",
- "dev-1.x": "1.5.x-dev"
- }
- }
- }
- ]
- }
- }"#;
-
- let versions = parse_p2_response(json, "test/pkg").unwrap();
- let aliases = versions[0].branch_aliases();
- assert_eq!(aliases.len(), 2);
- assert_eq!(aliases.get("dev-master").unwrap(), "2.x-dev");
- assert_eq!(aliases.get("dev-1.x").unwrap(), "1.5.x-dev");
- }
-
- #[test]
- fn test_branch_aliases_no_extra() {
- let json = r#"{
- "packages": {
- "test/pkg": [
- {
- "version": "dev-master",
- "version_normalized": "dev-master",
- "require": {}
- }
- ]
- }
- }"#;
-
- let versions = parse_p2_response(json, "test/pkg").unwrap();
- let aliases = versions[0].branch_aliases();
- assert!(aliases.is_empty());
- }
-
- #[test]
- fn test_branch_aliases_extra_without_branch_alias_key() {
- let json = r#"{
- "packages": {
- "test/pkg": [
- {
- "version": "dev-master",
- "version_normalized": "dev-master",
- "require": {},
- "extra": {
- "installer-name": "my-plugin"
- }
- }
- ]
- }
- }"#;
-
- let versions = parse_p2_response(json, "test/pkg").unwrap();
- let aliases = versions[0].branch_aliases();
- assert!(aliases.is_empty());
- }
-
- // ──────────── SecurityAdvisory parsing tests ─────────────────────────────
-
- #[test]
- fn test_parse_security_advisories_response() {
- let json = r#"{
- "advisories": {
- "monolog/monolog": [
- {
- "advisoryId": "PKSA-b2m0-qqf7-qck4",
- "packageName": "monolog/monolog",
- "remoteId": "monolog/monolog/2017-11-13-1.yaml",
- "title": "Header injection in NativeMailerHandler",
- "link": "https://github.com/Seldaek/monolog/pull/683",
- "cve": null,
- "affectedVersions": ">=1.8.0,<1.12.0",
- "source": "FriendsOfPHP/security-advisories",
- "reportedAt": "2017-11-13T00:00:00+00:00",
- "composerRepository": "https://packagist.org",
- "severity": "low",
- "sources": [
- {
- "name": "FriendsOfPHP/security-advisories",
- "remoteId": "monolog/monolog/2017-11-13-1.yaml"
- }
- ]
- }
- ]
- }
- }"#;
-
- let response: SecurityAdvisoriesResponse = serde_json::from_str(json).unwrap();
- assert_eq!(response.advisories.len(), 1);
- let advisories = response.advisories.get("monolog/monolog").unwrap();
- assert_eq!(advisories.len(), 1);
- let adv = &advisories[0];
- assert_eq!(adv.advisory_id, "PKSA-b2m0-qqf7-qck4");
- assert_eq!(adv.package_name, "monolog/monolog");
- assert_eq!(adv.title, "Header injection in NativeMailerHandler");
- assert_eq!(adv.affected_versions, ">=1.8.0,<1.12.0");
- assert_eq!(adv.severity.as_deref(), Some("low"));
- assert!(adv.cve.is_none());
- assert_eq!(adv.sources.len(), 1);
- assert_eq!(adv.sources[0].name, "FriendsOfPHP/security-advisories");
- }
-
- #[test]
- fn test_parse_security_advisories_empty() {
- let json = r#"{"advisories": {"other/package": []}}"#;
- let response: SecurityAdvisoriesResponse = serde_json::from_str(json).unwrap();
- assert_eq!(response.advisories.len(), 1);
- let advisories = response.advisories.get("other/package").unwrap();
- assert!(advisories.is_empty());
- }
-
- #[test]
- fn test_parse_security_advisories_null_fields() {
- let json = r#"{
- "advisories": {
- "vendor/pkg": [
- {
- "advisoryId": "PKSA-0000-0000-0000",
- "packageName": "vendor/pkg",
- "remoteId": "vendor/pkg/2024-01-01.yaml",
- "title": "Some vulnerability",
- "link": null,
- "cve": null,
- "affectedVersions": ">=1.0,<2.0",
- "source": "FriendsOfPHP/security-advisories",
- "reportedAt": "2024-01-01T00:00:00+00:00",
- "composerRepository": null,
- "severity": null,
- "sources": []
- }
- ]
- }
- }"#;
-
- let response: SecurityAdvisoriesResponse = serde_json::from_str(json).unwrap();
- let advisories = response.advisories.get("vendor/pkg").unwrap();
- assert_eq!(advisories.len(), 1);
- let adv = &advisories[0];
- assert!(adv.link.is_none());
- assert!(adv.cve.is_none());
- assert!(adv.severity.is_none());
- assert!(adv.composer_repository.is_none());
- assert!(adv.sources.is_empty());
- }
-}
diff --git a/crates/mozart/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<Vec<String>> {
- if !is_php_file(path) {
- return Ok(vec![]);
- }
-
- let contents = std::fs::read_to_string(path)?;
-
- // Quick check: does the file even contain a class-like keyword?
- let quick_re = Regex::new(r"(?i)\b(?:class|interface|trait|enum)\s").unwrap();
- if !quick_re.is_match(&contents) {
- return Ok(vec![]);
- }
-
- let cleaned = clean_php_content(&contents);
- Ok(extract_declarations(&cleaned))
-}
-
-/// State machine that strips strings, comments, and heredocs/nowdocs from PHP code.
-///
-/// Returns a string of equal byte length where non-PHP content is replaced with spaces
-/// so that regex offsets are preserved. Only PHP mode content is kept; everything else
-/// is blanked out.
-fn clean_php_content(contents: &str) -> String {
- let bytes = contents.as_bytes();
- let len = bytes.len();
- let mut out = vec![b' '; len];
- let mut i = 0;
- let mut in_php = false;
-
- while i < len {
- if !in_php {
- // Look for `<?`
- if i + 1 < len && bytes[i] == b'<' && bytes[i + 1] == b'?' {
- in_php = true;
- out[i] = b' ';
- out[i + 1] = b' ';
- i += 2;
- // Skip optional "php" or "="
- if i + 3 <= len && bytes[i..i + 3].eq_ignore_ascii_case(b"php") {
- i += 3;
- } else if i < len && bytes[i] == b'=' {
- i += 1;
- }
- continue;
- }
- i += 1;
- continue;
- }
-
- // In PHP mode
- // Check for `?>`
- if i + 1 < len && bytes[i] == b'?' && bytes[i + 1] == b'>' {
- in_php = false;
- i += 2;
- continue;
- }
-
- // Line comment: // or #
- if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'/' {
- // Skip to end of line
- while i < len && bytes[i] != b'\n' {
- i += 1;
- }
- continue;
- }
- if bytes[i] == b'#' {
- while i < len && bytes[i] != b'\n' {
- i += 1;
- }
- continue;
- }
-
- // Block comment: /* ... */
- if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' {
- i += 2;
- while i + 1 < len {
- if bytes[i] == b'*' && bytes[i + 1] == b'/' {
- i += 2;
- break;
- }
- i += 1;
- }
- continue;
- }
-
- // Single-quoted string
- if bytes[i] == b'\'' {
- out[i] = b'\'';
- i += 1;
- while i < len {
- if bytes[i] == b'\\' && i + 1 < len {
- // escaped character — blank both
- i += 2;
- } else if bytes[i] == b'\'' {
- out[i] = b'\'';
- i += 1;
- break;
- } else {
- i += 1;
- }
- }
- continue;
- }
-
- // Double-quoted string
- if bytes[i] == b'"' {
- out[i] = b'"';
- i += 1;
- while i < len {
- if bytes[i] == b'\\' && i + 1 < len {
- i += 2;
- } else if bytes[i] == b'"' {
- out[i] = b'"';
- i += 1;
- break;
- } else {
- i += 1;
- }
- }
- continue;
- }
-
- // Heredoc / Nowdoc: <<<
- if i + 2 < len && bytes[i] == b'<' && bytes[i + 1] == b'<' && bytes[i + 2] == b'<' {
- i += 3;
- // Skip whitespace
- while i < len && (bytes[i] == b' ' || bytes[i] == b'\t') {
- i += 1;
- }
-
- // Nowdoc uses single quotes around label; heredoc may use double quotes.
- let is_nowdoc = i < len && bytes[i] == b'\'';
- // Skip optional opening quote (single for nowdoc, double for heredoc)
- if i < len && (bytes[i] == b'\'' || bytes[i] == b'"') {
- i += 1;
- }
-
- // Read label
- let label_start = i;
- while i < len && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
- i += 1;
- }
- let label = std::str::from_utf8(&bytes[label_start..i])
- .unwrap_or("")
- .to_string();
-
- // Skip closing quote of label (must match the opening quote)
- let expected_close = if is_nowdoc { b'\'' } else { b'"' };
- if i < len && bytes[i] == expected_close {
- i += 1;
- }
-
- // Skip to end of line
- while i < len && bytes[i] != b'\n' {
- i += 1;
- }
- if i < len {
- i += 1; // consume newline
- }
-
- // Scan for the terminator label on its own line
- if !label.is_empty() {
- loop {
- if i >= len {
- break;
- }
- // Check if current line starts with the label
- let line_start = i;
- // Skip optional whitespace for indented heredoc (PHP 7.3+)
- while i < len && (bytes[i] == b' ' || bytes[i] == b'\t') {
- i += 1;
- }
- let remaining = &bytes[i..];
- let label_bytes = label.as_bytes();
- if remaining.len() >= label_bytes.len()
- && &remaining[..label_bytes.len()] == label_bytes
- {
- let after = i + label_bytes.len();
- // Terminator must be followed by ; or newline or EOF
- if after >= len
- || bytes[after] == b';'
- || bytes[after] == b'\n'
- || bytes[after] == b'\r'
- {
- // Skip to end of this line
- i = after;
- while i < len && bytes[i] != b'\n' {
- i += 1;
- }
- if i < len {
- i += 1;
- }
- break;
- }
- }
- // Not a terminator line — skip to end of line
- i = line_start;
- while i < len && bytes[i] != b'\n' {
- i += 1;
- }
- if i < len {
- i += 1;
- }
- }
- }
- continue;
- }
-
- // Backtick strings (shell exec)
- if bytes[i] == b'`' {
- out[i] = b'`';
- i += 1;
- while i < len {
- if bytes[i] == b'\\' && i + 1 < len {
- i += 2;
- } else if bytes[i] == b'`' {
- out[i] = b'`';
- i += 1;
- break;
- } else {
- i += 1;
- }
- }
- continue;
- }
-
- // Keep normal PHP content
- out[i] = bytes[i];
- i += 1;
- }
-
- String::from_utf8_lossy(&out).into_owned()
-}
-
-/// Extract fully-qualified class names from cleaned PHP content.
-///
-/// Tracks the current namespace and finds class/interface/trait/enum declarations.
-fn extract_declarations(cleaned: &str) -> Vec<String> {
- let mut results = Vec::new();
-
- // Regex for namespace declarations:
- // namespace Foo\Bar; — simple
- // namespace Foo\Bar { — block
- // namespace { — global block
- let ns_re = Regex::new(
- r"(?x)
- \bnamespace\s+
- ((?:[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*\\)*[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)
- \s*[;{]
- |
- \bnamespace\s*\{
- ",
- )
- .unwrap();
-
- // Regex for class/interface/trait/enum declarations.
- // We need to capture the name; anonymous classes (new class ...) are excluded.
- let decl_re = Regex::new(
- r"(?x)
- \b(?:abstract\s+|final\s+|readonly\s+)*
- (?P<kind>class|interface|trait|enum)\s+
- (?P<name>[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)
- ",
- )
- .unwrap();
-
- let mut current_ns = String::new();
-
- // We process namespace changes as we walk through the file.
- // Build a list of all namespace and declaration positions.
- #[derive(Debug)]
- enum Event {
- Namespace(usize, String), // position, namespace
- Declaration(usize, String), // position, simple name
- }
-
- let mut events: Vec<Event> = Vec::new();
-
- // Find namespace declarations
- for cap in ns_re.captures_iter(cleaned) {
- let pos = cap.get(0).unwrap().start();
- let ns_name = cap
- .get(1)
- .map(|m| m.as_str().to_string())
- .unwrap_or_default();
- events.push(Event::Namespace(pos, ns_name));
- }
-
- // Find class/interface/trait/enum declarations
- for cap in decl_re.captures_iter(cleaned) {
- let pos = cap.get(0).unwrap().start();
- let name = cap.name("name").unwrap().as_str().to_string();
-
- // Skip anonymous classes: check if "new" precedes "class" on the same "expression".
- // A reliable check: look back for "new " before this match.
- let before = &cleaned[..pos];
- let kind = cap.name("kind").unwrap().as_str();
- if kind == "class" {
- // Check if "new" appears right before (with possible whitespace/modifiers).
- // Simple heuristic: scan backwards for non-whitespace token.
- let trimmed = before.trim_end();
- if trimmed.ends_with("new") {
- continue;
- }
- }
-
- events.push(Event::Declaration(pos, name));
- }
-
- // Sort all events by position
- events.sort_by_key(|e| match e {
- Event::Namespace(pos, _) => *pos,
- Event::Declaration(pos, _) => *pos,
- });
-
- // Process events in order
- for event in events {
- match event {
- Event::Namespace(_, ns) => {
- current_ns = ns;
- }
- Event::Declaration(_, name) => {
- let fqn = if current_ns.is_empty() {
- name
- } else {
- format!("{}\\{}", current_ns, name)
- };
- results.push(fqn);
- }
- }
- }
-
- results
-}
-
-/// Validate that a class file is correctly placed according to PSR-4.
-///
-/// - `class`: fully-qualified class name (e.g. `Foo\Bar\Baz`)
-/// - `base_namespace`: the PSR-4 namespace prefix (e.g. `Foo\Bar\`)
-/// - `file_path`: absolute path to the PHP file
-/// - `base_path`: the directory mapped to `base_namespace` (absolute)
-///
-/// Returns `true` if the file path matches the PSR-4 mapping.
-pub fn validate_psr4_class(
- class: &str,
- base_namespace: &str,
- file_path: &str,
- base_path: &str,
-) -> bool {
- // Normalize the base namespace: ensure it ends with `\`
- let base_ns = if base_namespace.is_empty() || base_namespace.ends_with('\\') {
- base_namespace.to_string()
- } else {
- format!("{base_namespace}\\")
- };
-
- // Class must start with the base namespace
- if !class.starts_with(&*base_ns) {
- return false;
- }
-
- // The relative class name after the base namespace
- let relative_class = &class[base_ns.len()..];
-
- // Convert relative class to a relative file path: replace `\` with `/`
- let expected_relative = relative_class.replace('\\', "/");
- let expected_file = format!(
- "{}/{}.php",
- base_path.trim_end_matches('/'),
- expected_relative
- );
-
- // Normalize both paths for comparison (simplistic: just compare strings)
- Path::new(file_path) == Path::new(&expected_file)
-}
-
-/// Validate that a class file is correctly placed according to PSR-0.
-///
-/// - `class`: fully-qualified class name (e.g. `Foo_Bar_Baz` or `Foo\Bar`)
-/// - `file_path`: absolute path to the PHP file
-/// - `base_path`: the base directory for PSR-0 lookup
-///
-/// Returns `true` if the file path matches the PSR-0 mapping.
-pub fn validate_psr0_class(class: &str, file_path: &str, base_path: &str) -> bool {
- // PSR-0: namespace separators AND underscores (in class part) map to directory separators.
- // Split on `\` first; the last segment may contain underscores that also become `/`.
- let parts: Vec<&str> = class.split('\\').collect();
- let relative = if parts.len() == 1 {
- // No namespace: underscores in class name become dir separators
- parts[0].replace('_', "/")
- } else {
- let ns_part = parts[..parts.len() - 1].join("/");
- let class_part = parts[parts.len() - 1].replace('_', "/");
- format!("{}/{}", ns_part, class_part)
- };
-
- let expected_file = format!("{}/{}.php", base_path.trim_end_matches('/'), relative);
- Path::new(file_path) == Path::new(&expected_file)
-}
-
-// ─────────────────────────────────────────────────────────────────────────────
-// Tests
-// ─────────────────────────────────────────────────────────────────────────────
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use std::io::Write;
- use tempfile::NamedTempFile;
-
- fn write_php(content: &str) -> NamedTempFile {
- let mut f = NamedTempFile::with_suffix(".php").unwrap();
- f.write_all(content.as_bytes()).unwrap();
- f
- }
-
- // -------------------------------------------------------------------------
- // find_classes tests
- // -------------------------------------------------------------------------
-
- #[test]
- fn test_find_classes_simple_class() {
- let f = write_php("<?php\nclass Foo {}\n");
- let classes = find_classes(f.path()).unwrap();
- assert_eq!(classes, vec!["Foo"]);
- }
-
- #[test]
- fn test_find_classes_with_namespace() {
- let f = write_php("<?php\nnamespace Foo\\Bar;\nclass Baz {}\n");
- let classes = find_classes(f.path()).unwrap();
- assert_eq!(classes, vec!["Foo\\Bar\\Baz"]);
- }
-
- #[test]
- fn test_find_classes_multiple_classes() {
- let f = write_php("<?php\nnamespace App;\nclass Foo {}\nclass Bar {}\ninterface Baz {}\n");
- let classes = find_classes(f.path()).unwrap();
- assert_eq!(classes, vec!["App\\Foo", "App\\Bar", "App\\Baz"]);
- }
-
- #[test]
- fn test_find_classes_interface() {
- let f = write_php("<?php\ninterface MyInterface {}\n");
- let classes = find_classes(f.path()).unwrap();
- assert_eq!(classes, vec!["MyInterface"]);
- }
-
- #[test]
- fn test_find_classes_trait() {
- let f = write_php("<?php\ntrait MyTrait {}\n");
- let classes = find_classes(f.path()).unwrap();
- assert_eq!(classes, vec!["MyTrait"]);
- }
-
- #[test]
- fn test_find_classes_enum() {
- let f = write_php("<?php\nenum Status {}\n");
- let classes = find_classes(f.path()).unwrap();
- assert_eq!(classes, vec!["Status"]);
- }
-
- #[test]
- fn test_find_classes_enum_with_backing_type() {
- let f = write_php("<?php\nenum Color: string { case Red = 'red'; }\n");
- let classes = find_classes(f.path()).unwrap();
- assert_eq!(classes, vec!["Color"]);
- }
-
- #[test]
- fn test_find_classes_anonymous_class_skipped() {
- let f = write_php("<?php\n$obj = new class {};\n");
- let classes = find_classes(f.path()).unwrap();
- assert!(classes.is_empty(), "anonymous class should not be scanned");
- }
-
- #[test]
- fn test_find_classes_comments_ignored() {
- let f = write_php(
- "<?php\n// class FakeClass {}\n/* interface FakeInterface {} */\nclass RealClass {}\n",
- );
- let classes = find_classes(f.path()).unwrap();
- assert_eq!(classes, vec!["RealClass"]);
- }
-
- #[test]
- fn test_find_classes_strings_ignored() {
- let f = write_php(
- "<?php\n$s = 'class NotAClass {}';\n$t = \"interface NotAnInterface {}\";\nclass RealClass {}\n",
- );
- let classes = find_classes(f.path()).unwrap();
- assert_eq!(classes, vec!["RealClass"]);
- }
-
- #[test]
- fn test_find_classes_heredoc_ignored() {
- let f = write_php("<?php\n$s = <<<EOT\nclass FakeClass {}\nEOT;\nclass RealClass {}\n");
- let classes = find_classes(f.path()).unwrap();
- assert_eq!(classes, vec!["RealClass"]);
- }
-
- #[test]
- fn test_find_classes_empty_file() {
- let f = write_php("<?php\n// nothing here\n");
- let classes = find_classes(f.path()).unwrap();
- assert!(classes.is_empty());
- }
-
- #[test]
- fn test_find_classes_no_classes() {
- let f = write_php("<?php\necho 'hello';\n");
- let classes = find_classes(f.path()).unwrap();
- assert!(classes.is_empty());
- }
-
- #[test]
- fn test_find_classes_abstract_final() {
- let f = write_php("<?php\nabstract class AbstractFoo {}\nfinal class FinalBar {}\n");
- let classes = find_classes(f.path()).unwrap();
- assert!(classes.contains(&"AbstractFoo".to_string()));
- assert!(classes.contains(&"FinalBar".to_string()));
- }
-
- #[test]
- fn test_find_classes_non_php_extension() {
- let mut f = NamedTempFile::with_suffix(".txt").unwrap();
- f.write_all(b"<?php\nclass Foo {}\n").unwrap();
- let classes = find_classes(f.path()).unwrap();
- assert!(classes.is_empty(), "non-PHP extension should be skipped");
- }
-
- // -------------------------------------------------------------------------
- // PSR-4 validation tests
- // -------------------------------------------------------------------------
-
- #[test]
- fn test_validate_psr4_correct() {
- assert!(validate_psr4_class(
- "Foo\\Bar\\Baz",
- "Foo\\Bar\\",
- "/srv/project/src/Baz.php",
- "/srv/project/src"
- ));
- }
-
- #[test]
- fn test_validate_psr4_wrong_path() {
- assert!(!validate_psr4_class(
- "Foo\\Bar\\Baz",
- "Foo\\Bar\\",
- "/srv/project/src/Wrong.php",
- "/srv/project/src"
- ));
- }
-
- #[test]
- fn test_validate_psr4_namespace_mismatch() {
- assert!(!validate_psr4_class(
- "Other\\Baz",
- "Foo\\Bar\\",
- "/srv/project/src/Baz.php",
- "/srv/project/src"
- ));
- }
-
- #[test]
- fn test_validate_psr4_nested() {
- assert!(validate_psr4_class(
- "App\\Http\\Controllers\\HomeController",
- "App\\",
- "/project/src/Http/Controllers/HomeController.php",
- "/project/src"
- ));
- }
-
- // -------------------------------------------------------------------------
- // PSR-0 validation tests
- // -------------------------------------------------------------------------
-
- #[test]
- fn test_validate_psr0_simple() {
- assert!(validate_psr0_class(
- "Foo_Bar_Baz",
- "/srv/project/src/Foo/Bar/Baz.php",
- "/srv/project/src"
- ));
- }
-
- #[test]
- fn test_validate_psr0_with_namespace() {
- assert!(validate_psr0_class(
- "Foo\\Bar",
- "/srv/project/src/Foo/Bar.php",
- "/srv/project/src"
- ));
- }
-
- #[test]
- fn test_validate_psr0_wrong_path() {
- assert!(!validate_psr0_class(
- "Foo_Bar",
- "/srv/project/src/Foo/Baz.php",
- "/srv/project/src"
- ));
- }
-}
diff --git a/crates/mozart/src/platform.rs b/crates/mozart/src/platform.rs
deleted file mode 100644
index c1f187f..0000000
--- a/crates/mozart/src/platform.rs
+++ /dev/null
@@ -1,351 +0,0 @@
-// Shared platform detection module.
-//
-// Provides detection of the PHP environment (version, extensions, capabilities)
-// and helpers for identifying platform package names (php, ext-*, lib-*, etc.).
-
-// ─── Data structures ─────────────────────────────────────────────────────────
-
-/// A detected platform package with its name and version.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct PlatformPackage {
- pub name: String,
- pub version: String,
-}
-
-// ─── Classification ──────────────────────────────────────────────────────────
-
-/// Returns true if the package name is a Composer platform package.
-///
-/// Platform packages include: php, php-*, ext-*, lib-*, composer,
-/// composer-plugin-api, composer-runtime-api.
-pub fn is_platform_package(name: &str) -> bool {
- let lower = name.to_lowercase();
- lower == "php"
- || lower.starts_with("php-")
- || lower.starts_with("ext-")
- || lower.starts_with("lib-")
- || lower == "composer"
- || lower == "composer-plugin-api"
- || lower == "composer-runtime-api"
-}
-
-// ─── Detection ───────────────────────────────────────────────────────────────
-
-/// Detect all platform packages by running a single PHP invocation.
-///
-/// Returns an empty vec if PHP is not found or not executable.
-pub fn detect_platform() -> Vec<PlatformPackage> {
- let php_script = concat!(
- "echo 'PHP_VERSION:' . PHP_VERSION . PHP_EOL;",
- "echo 'PHP_INT_SIZE:' . PHP_INT_SIZE . PHP_EOL;",
- "echo 'PHP_DEBUG:' . (PHP_DEBUG ? '1' : '0') . PHP_EOL;",
- "echo 'PHP_ZTS:' . (defined('PHP_ZTS') && PHP_ZTS ? '1' : '0') . PHP_EOL;",
- "echo 'IPV6:' . ((defined('AF_INET6') || @inet_pton('::') !== false) ? '1' : '0') . PHP_EOL;",
- "echo 'EXTENSIONS:' . PHP_EOL;",
- "foreach(get_loaded_extensions() as $e) { echo $e . ':' . (phpversion($e) ?: '0') . PHP_EOL; }"
- );
-
- let output = match std::process::Command::new("php")
- .arg("-r")
- .arg(php_script)
- .output()
- {
- Ok(o) => o,
- Err(_) => return vec![],
- };
-
- if !output.status.success() {
- return vec![];
- }
-
- let stdout = String::from_utf8_lossy(&output.stdout);
- parse_platform_info(&stdout)
-}
-
-/// Parse the output of the PHP platform detection script.
-///
-/// Exposed for testing purposes.
-pub fn parse_platform_info(output: &str) -> Vec<PlatformPackage> {
- let mut packages: Vec<PlatformPackage> = Vec::new();
-
- let mut php_version = String::new();
- let mut int_size: u8 = 0;
- let mut php_debug = false;
- let mut php_zts = false;
- let mut php_ipv6 = false;
- let mut in_extensions = false;
-
- for line in output.lines() {
- let line = line.trim();
- if line.is_empty() {
- continue;
- }
-
- if let Some(v) = line.strip_prefix("PHP_VERSION:") {
- php_version = v.to_string();
- continue;
- }
- if let Some(v) = line.strip_prefix("PHP_INT_SIZE:") {
- int_size = v.parse().unwrap_or(0);
- continue;
- }
- if let Some(v) = line.strip_prefix("PHP_DEBUG:") {
- php_debug = v == "1";
- continue;
- }
- if let Some(v) = line.strip_prefix("PHP_ZTS:") {
- php_zts = v == "1";
- continue;
- }
- if let Some(v) = line.strip_prefix("IPV6:") {
- php_ipv6 = v == "1";
- continue;
- }
- if line == "EXTENSIONS:" {
- in_extensions = true;
- continue;
- }
-
- if in_extensions {
- // Format: ExtensionName:version
- if let Some(colon_pos) = line.find(':') {
- let ext_name = line[..colon_pos].trim().to_lowercase();
- let ext_version = line[colon_pos + 1..].trim();
- // Normalize: if version is "0", "false", or empty, use the PHP version
- let version =
- if ext_version.is_empty() || ext_version == "0" || ext_version == "false" {
- if php_version.is_empty() {
- "0.0.0".to_string()
- } else {
- php_version.clone()
- }
- } else {
- ext_version.to_string()
- };
- packages.push(PlatformPackage {
- name: format!("ext-{ext_name}"),
- version,
- });
- }
- }
- }
-
- // Build the base php entry first (so it's easy to find)
- if !php_version.is_empty() {
- let mut result: Vec<PlatformPackage> = Vec::new();
-
- result.push(PlatformPackage {
- name: "php".to_string(),
- version: php_version.clone(),
- });
-
- if int_size == 8 {
- result.push(PlatformPackage {
- name: "php-64bit".to_string(),
- version: php_version.clone(),
- });
- }
-
- if php_debug {
- result.push(PlatformPackage {
- name: "php-debug".to_string(),
- version: php_version.clone(),
- });
- }
-
- if php_zts {
- result.push(PlatformPackage {
- name: "php-zts".to_string(),
- version: php_version.clone(),
- });
- }
-
- if php_ipv6 {
- result.push(PlatformPackage {
- name: "php-ipv6".to_string(),
- version: php_version.clone(),
- });
- }
-
- result.extend(packages);
- result
- } else {
- packages
- }
-}
-
-/// Try to detect the installed PHP version by running `php --version`.
-pub fn detect_php_version() -> Option<String> {
- let output = std::process::Command::new("php")
- .arg("--version")
- .output()
- .ok()?;
-
- if !output.status.success() {
- return None;
- }
-
- let stdout = String::from_utf8_lossy(&output.stdout);
- // Parse "PHP 8.2.1 (cli) ..." → "8.2.1"
- let first_line = stdout.lines().next()?;
- let parts: Vec<&str> = first_line.split_whitespace().collect();
- if parts.len() >= 2 && parts[0] == "PHP" {
- Some(parts[1].to_string())
- } else {
- None
- }
-}
-
-/// Try to detect PHP extensions by running `php -m`.
-pub fn detect_php_extensions() -> Vec<String> {
- let output = match std::process::Command::new("php").arg("-m").output() {
- Ok(o) => o,
- Err(_) => return vec![],
- };
-
- if !output.status.success() {
- return vec![];
- }
-
- let stdout = String::from_utf8_lossy(&output.stdout);
- stdout
- .lines()
- .filter(|line| {
- let l = line.trim();
- !l.is_empty()
- && !l.starts_with('[')
- && l.chars()
- .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
- })
- .map(|l| l.trim().to_lowercase())
- .collect()
-}
-
-// ─── Tests ───────────────────────────────────────────────────────────────────
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn test_is_platform_package_php() {
- assert!(is_platform_package("php"));
- assert!(is_platform_package("PHP"));
- }
-
- #[test]
- fn test_is_platform_package_php_variants() {
- assert!(is_platform_package("php-64bit"));
- assert!(is_platform_package("php-debug"));
- assert!(is_platform_package("php-zts"));
- assert!(is_platform_package("php-ipv6"));
- }
-
- #[test]
- fn test_is_platform_package_ext() {
- assert!(is_platform_package("ext-json"));
- assert!(is_platform_package("ext-mbstring"));
- assert!(is_platform_package("ext-ctype"));
- }
-
- #[test]
- fn test_is_platform_package_lib() {
- assert!(is_platform_package("lib-pcre"));
- assert!(is_platform_package("lib-curl"));
- }
-
- #[test]
- fn test_is_platform_package_composer() {
- assert!(is_platform_package("composer"));
- assert!(is_platform_package("composer-plugin-api"));
- assert!(is_platform_package("composer-runtime-api"));
- }
-
- #[test]
- fn test_is_platform_package_not_platform() {
- assert!(!is_platform_package("monolog/monolog"));
- assert!(!is_platform_package("psr/log"));
- assert!(!is_platform_package("symfony/console"));
- assert!(!is_platform_package("vendor/package"));
- }
-
- #[test]
- fn test_parse_platform_info_basic() {
- let output = "PHP_VERSION:8.2.1\nPHP_INT_SIZE:8\nPHP_DEBUG:0\nPHP_ZTS:0\nIPV6:1\nEXTENSIONS:\njson:8.2.1\nctype:8.2.1\n";
- let packages = parse_platform_info(output);
-
- let php = packages.iter().find(|p| p.name == "php");
- assert!(php.is_some());
- assert_eq!(php.unwrap().version, "8.2.1");
-
- let php64 = packages.iter().find(|p| p.name == "php-64bit");
- assert!(php64.is_some(), "PHP_INT_SIZE=8 should produce php-64bit");
-
- let ipv6 = packages.iter().find(|p| p.name == "php-ipv6");
- assert!(ipv6.is_some());
-
- let ext_json = packages.iter().find(|p| p.name == "ext-json");
- assert!(ext_json.is_some());
- assert_eq!(ext_json.unwrap().version, "8.2.1");
-
- let ext_ctype = packages.iter().find(|p| p.name == "ext-ctype");
- assert!(ext_ctype.is_some());
- }
-
- #[test]
- fn test_parse_platform_info_no_debug_no_zts() {
- let output =
- "PHP_VERSION:8.1.0\nPHP_INT_SIZE:4\nPHP_DEBUG:0\nPHP_ZTS:0\nIPV6:0\nEXTENSIONS:\n";
- let packages = parse_platform_info(output);
-
- assert!(packages.iter().any(|p| p.name == "php"));
- assert!(!packages.iter().any(|p| p.name == "php-64bit"));
- assert!(!packages.iter().any(|p| p.name == "php-debug"));
- assert!(!packages.iter().any(|p| p.name == "php-zts"));
- assert!(!packages.iter().any(|p| p.name == "php-ipv6"));
- }
-
- #[test]
- fn test_parse_platform_info_debug_and_zts() {
- let output =
- "PHP_VERSION:8.3.0\nPHP_INT_SIZE:8\nPHP_DEBUG:1\nPHP_ZTS:1\nIPV6:0\nEXTENSIONS:\n";
- let packages = parse_platform_info(output);
-
- assert!(packages.iter().any(|p| p.name == "php-debug"));
- assert!(packages.iter().any(|p| p.name == "php-zts"));
- }
-
- #[test]
- fn test_parse_platform_info_extension_version_zero() {
- // Extensions returning version "0" should fall back to PHP version
- let output = "PHP_VERSION:8.2.5\nPHP_INT_SIZE:8\nPHP_DEBUG:0\nPHP_ZTS:0\nIPV6:0\nEXTENSIONS:\nCore:0\n";
- let packages = parse_platform_info(output);
-
- let ext_core = packages.iter().find(|p| p.name == "ext-core");
- assert!(ext_core.is_some());
- assert_eq!(
- ext_core.unwrap().version,
- "8.2.5",
- "version '0' should fall back to PHP version"
- );
- }
-
- #[test]
- fn test_parse_platform_info_no_php() {
- // If PHP_VERSION is missing, only extensions are returned
- let output = "EXTENSIONS:\njson:1.7\n";
- let packages = parse_platform_info(output);
-
- assert!(!packages.iter().any(|p| p.name == "php"));
- assert!(packages.iter().any(|p| p.name == "ext-json"));
- }
-
- #[test]
- fn test_parse_platform_info_extension_names_lowercased() {
- let output = "PHP_VERSION:8.0.0\nPHP_INT_SIZE:8\nPHP_DEBUG:0\nPHP_ZTS:0\nIPV6:0\nEXTENSIONS:\nJSON:8.0.0\nMbstring:8.0.0\n";
- let packages = parse_platform_info(output);
-
- assert!(packages.iter().any(|p| p.name == "ext-json"));
- assert!(packages.iter().any(|p| p.name == "ext-mbstring"));
- }
-}
diff --git a/crates/mozart/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<ComposerVersion>`
-//! and implements `DependencyProvider` for Mozart's package resolution.
-
-use std::cell::RefCell;
-use std::cmp::Reverse;
-use std::collections::{BTreeMap, HashMap};
-use std::fmt;
-
-use pubgrub::{
- DefaultStringReporter, Dependencies, DependencyConstraints, DependencyProvider,
- PackageResolutionStatistics, PubGrubError, Ranges, Reporter,
-};
-
-use 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<std::cmp::Ordering> {
- Some(self.cmp(other))
- }
-}
-
-impl Ord for ComposerVersion {
- fn cmp(&self, other: &Self) -> std::cmp::Ordering {
- (
- self.major,
- self.minor,
- self.patch,
- self.build,
- self.stability,
- )
- .cmp(&(
- other.major,
- other.minor,
- other.patch,
- other.build,
- other.stability,
- ))
- }
-}
-
-impl fmt::Display for ComposerVersion {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(
- f,
- "{}.{}.{}.{}",
- self.major, self.minor, self.patch, self.build
- )?;
- let s = self.stability;
- if s == STABILITY_STABLE {
- // no suffix
- } else if s >= STABILITY_PATCH_BASE {
- write!(f, "-patch{}", s - STABILITY_PATCH_BASE)?;
- } else if s >= STABILITY_RC_BASE {
- write!(f, "-RC{}", s - STABILITY_RC_BASE)?;
- } else if s >= STABILITY_BETA_BASE {
- write!(f, "-beta{}", s - STABILITY_BETA_BASE)?;
- } else if s >= STABILITY_ALPHA_BASE {
- write!(f, "-alpha{}", s - STABILITY_ALPHA_BASE)?;
- } else {
- write!(f, "-dev")?;
- }
- Ok(())
- }
-}
-
-impl ComposerVersion {
- /// Parse a branch alias target like "2.x-dev" or "1.0.x-dev" into a ComposerVersion
- /// with dev stability.
- ///
- /// Used to represent aliased dev branches in the resolver. The version number is taken
- /// from the numeric prefix (e.g. "2.x-dev" → major=2, minor=0, patch=0, build=0, stability=dev).
- /// This allows constraints like `^2.0` to match `dev-master` when it is aliased to `2.x-dev`.
- pub fn from_branch_alias_target(alias_target: &str) -> Option<ComposerVersion> {
- let s = alias_target.trim().to_lowercase();
- // Must end with "-dev" or ".x-dev"
- if !s.ends_with("-dev") {
- return None;
- }
- // Strip the trailing "-dev"
- let base = &s[..s.len() - 4];
- // Strip optional trailing ".x" segments (e.g. "2.x" → "2", "1.0.x" → "1.0")
- let base = base.trim_end_matches(".x");
- // Now parse whatever numeric segments remain
- let parts: Vec<&str> = base.split('.').collect();
- let major: u16 = parts.first().and_then(|p| p.parse().ok())?;
- let minor: u16 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0);
- let patch: u16 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0);
- let build: u16 = parts.get(3).and_then(|p| p.parse().ok()).unwrap_or(0);
- Some(ComposerVersion {
- major,
- minor,
- patch,
- build,
- stability: STABILITY_DEV,
- })
- }
-
- /// Parse from a Packagist normalized version string like "1.2.3.0", "1.0.0.0-beta1", "1.0.0.0-RC2".
- /// Returns `None` for dev branches (dev-master, dev-*, *.x-dev).
- pub fn from_normalized(normalized: &str) -> Option<ComposerVersion> {
- let s = normalized.trim();
-
- // Reject dev branches
- if s.to_lowercase().starts_with("dev-") {
- return None;
- }
- // Reject *.x-dev style (e.g. "9999999.9999999.9999999.9999999-dev" from packagist sometimes)
- // Also reject anything like "2.1.x-dev"
- if s.to_lowercase().ends_with("-dev") && s.contains(".x") {
- return None;
- }
- // Packagist uses 9999999.9999999.9999999.9999999 for dev branches too
- if s.starts_with("9999999") {
- return None;
- }
-
- // Split on '-' for pre-release
- let (version_part, pre_part) = if let Some(pos) = s.find('-') {
- (&s[..pos], Some(&s[pos + 1..]))
- } else {
- (s, None)
- };
-
- let segments: Vec<&str> = version_part.split('.').collect();
- if segments.is_empty() || segments[0].is_empty() {
- return None;
- }
-
- let major: u16 = segments[0].parse().ok()?;
- let minor: u16 = segments.get(1).and_then(|p| p.parse().ok()).unwrap_or(0);
- let patch: u16 = segments.get(2).and_then(|p| p.parse().ok()).unwrap_or(0);
- let build: u16 = segments.get(3).and_then(|p| p.parse().ok()).unwrap_or(0);
-
- let stability = match pre_part {
- None => STABILITY_STABLE,
- Some(pre) => encode_pre_release_str(pre),
- };
-
- Some(ComposerVersion {
- major,
- minor,
- patch,
- build,
- stability,
- })
- }
-
- /// Construct a stable version from numeric segments.
- pub fn stable(major: u16, minor: u16, patch: u16, build: u16) -> ComposerVersion {
- ComposerVersion {
- major,
- minor,
- patch,
- build,
- stability: STABILITY_STABLE,
- }
- }
-
- /// Get the `Stability` enum value for this version.
- pub fn stability_enum(&self) -> Stability {
- if self.stability < STABILITY_ALPHA_BASE {
- // Covers both STABILITY_DEV (0) and any value below ALPHA_BASE
- Stability::Dev
- } else if self.stability < STABILITY_BETA_BASE {
- Stability::Alpha
- } else if self.stability < STABILITY_RC_BASE {
- Stability::Beta
- } else if self.stability < STABILITY_STABLE {
- Stability::RC
- } else {
- // >= STABILITY_STABLE (includes patch)
- Stability::Stable
- }
- }
-}
-
-fn encode_pre_release_str(pre: &str) -> u16 {
- let lower = pre.to_lowercase();
- if lower == "dev" {
- STABILITY_DEV
- } else if lower.starts_with("alpha") || lower.starts_with('a') {
- let n = extract_pre_release_number_from(
- &lower,
- if lower.starts_with("alpha") {
- "alpha"
- } else {
- "a"
- },
- );
- STABILITY_ALPHA_BASE + n
- } else if lower.starts_with("beta") || lower.starts_with('b') {
- let n = extract_pre_release_number_from(
- &lower,
- if lower.starts_with("beta") {
- "beta"
- } else {
- "b"
- },
- );
- STABILITY_BETA_BASE + n
- } else if lower.starts_with("rc") {
- let n = extract_pre_release_number_from(&lower, "rc");
- STABILITY_RC_BASE + n
- } else if lower.starts_with("patch") || lower.starts_with("pl") {
- let n = extract_pre_release_number_from(
- &lower,
- if lower.starts_with("patch") {
- "patch"
- } else {
- "pl"
- },
- );
- STABILITY_PATCH_BASE + n
- } else if lower == "p" {
- STABILITY_PATCH_BASE
- } else {
- STABILITY_STABLE
- }
-}
-
-fn extract_pre_release_number_from(s: &str, prefix: &str) -> u16 {
- let after = &s[prefix.len()..];
- let digits: String = after.chars().filter(|c| c.is_ascii_digit()).collect();
- digits.parse().unwrap_or(0)
-}
-
-// ─────────────────────────────────────────────────────────────────────────────
-// PackageName
-// ─────────────────────────────────────────────────────────────────────────────
-
-/// A normalized package name (lowercase, e.g. "monolog/monolog").
-#[derive(Debug, Clone, PartialEq, Eq, Hash)]
-pub struct PackageName(pub String);
-
-impl fmt::Display for PackageName {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- f.write_str(&self.0)
- }
-}
-
-impl PackageName {
- pub const ROOT: &'static str = "__root__";
-
- pub fn root() -> Self {
- PackageName(Self::ROOT.to_string())
- }
-
- /// Returns true if this is a platform package (php, ext-*, lib-*).
- pub fn is_platform(&self) -> bool {
- self.0 == "php"
- || self.0.starts_with("ext-")
- || self.0.starts_with("lib-")
- || self.0 == "php-64bit"
- || self.0 == "php-ipv6"
- || self.0 == "php-zts"
- || self.0 == "php-debug"
- }
-
- /// Returns true if this is the virtual root package.
- pub fn is_root(&self) -> bool {
- self.0 == Self::ROOT
- }
-}
-
-// ─────────────────────────────────────────────────────────────────────────────
-// Type alias
-// ─────────────────────────────────────────────────────────────────────────────
-
-/// The version set type used throughout the resolver.
-pub type ComposerVS = Ranges<ComposerVersion>;
-
-// ─────────────────────────────────────────────────────────────────────────────
-// Constraint-to-Ranges conversion
-// ─────────────────────────────────────────────────────────────────────────────
-
-/// Convert a Composer version constraint string to a pubgrub `Ranges<ComposerVersion>`.
-///
-/// Supports: exact, >=, >, <=, <, !=, ^, ~, *, wildcards, hyphen ranges, AND, OR.
-pub fn constraint_to_ranges(constraint: &str) -> Result<ComposerVS, String> {
- let vc = VersionConstraint::parse(constraint)
- .map_err(|e| format!("Failed to parse constraint '{}': {}", constraint, e))?;
- version_constraint_to_ranges(&vc)
-}
-
-fn version_constraint_to_ranges(vc: &VersionConstraint) -> Result<ComposerVS, String> {
- match vc {
- VersionConstraint::Single(c) => single_constraint_to_ranges(c),
- VersionConstraint::And(cs) => {
- let mut result = Ranges::full();
- for c in cs {
- result = result.intersection(&version_constraint_to_ranges(c)?);
- }
- Ok(result)
- }
- VersionConstraint::Or(cs) => {
- let mut result = Ranges::empty();
- for c in cs {
- result = result.union(&version_constraint_to_ranges(c)?);
- }
- Ok(result)
- }
- }
-}
-
-fn single_constraint_to_ranges(c: &Constraint) -> Result<ComposerVS, String> {
- match c {
- Constraint::Any => Ok(Ranges::full()),
- Constraint::Exact(v) => {
- let cv = version_to_composer(v)?;
- Ok(Ranges::singleton(cv))
- }
- Constraint::GreaterThan(v) => {
- let cv = version_to_composer(v)?;
- Ok(Ranges::strictly_higher_than(cv))
- }
- Constraint::GreaterThanOrEqual(v) => {
- let cv = version_to_composer(v)?;
- Ok(Ranges::higher_than(cv))
- }
- Constraint::LessThan(v) => {
- let cv = version_to_composer(v)?;
- Ok(Ranges::strictly_lower_than(cv))
- }
- Constraint::LessThanOrEqual(v) => {
- let cv = version_to_composer(v)?;
- // No Ranges::lower_than in version-ranges 0.1.x, so use complement of strictly_higher_than
- Ok(Ranges::strictly_higher_than(cv).complement())
- }
- Constraint::NotEqual(v) => {
- let cv = version_to_composer(v)?;
- Ok(Ranges::singleton(cv).complement())
- }
- }
-}
-
-/// Convert a `constraint::Version` to a `ComposerVersion`.
-fn version_to_composer(v: &mozart_constraint::Version) -> Result<ComposerVersion, String> {
- // Dev branches cannot be represented as ComposerVersion
- if v.is_dev_branch {
- return Err(format!(
- "Dev branch versions cannot be used in Ranges (branch: {:?})",
- v.dev_branch_name
- ));
- }
-
- let major: u16 = v
- .major
- .try_into()
- .map_err(|_| format!("Major version {} too large for u16", v.major))?;
- let minor: u16 = v
- .minor
- .try_into()
- .map_err(|_| format!("Minor version {} too large for u16", v.minor))?;
- let patch: u16 = v
- .patch
- .try_into()
- .map_err(|_| format!("Patch version {} too large for u16", v.patch))?;
- let build: u16 = v
- .build
- .try_into()
- .map_err(|_| format!("Build version {} too large for u16", v.build))?;
-
- let stability = encode_pre_release(&v.pre_release);
-
- Ok(ComposerVersion {
- major,
- minor,
- patch,
- build,
- stability,
- })
-}
-
-fn encode_pre_release(pre: &Option<String>) -> u16 {
- match pre {
- None => STABILITY_STABLE,
- Some(s) => encode_pre_release_str(s),
- }
-}
-
-// ─────────────────────────────────────────────────────────────────────────────
-// Platform configuration
-// ─────────────────────────────────────────────────────────────────────────────
-
-/// Platform package configuration.
-/// Maps package names to version strings (normalized, e.g. "8.1.0.0").
-pub struct PlatformConfig {
- pub packages: HashMap<String, String>,
-}
-
-impl Default for PlatformConfig {
- fn default() -> Self {
- Self::new()
- }
-}
-
-impl PlatformConfig {
- /// Create a default platform config with PHP 8.1 and common extensions.
- pub fn new() -> Self {
- let mut packages = HashMap::new();
- packages.insert("php".to_string(), "8.1.0.0".to_string());
- packages.insert("php-64bit".to_string(), "8.1.0.0".to_string());
- for ext in &[
- "json",
- "mbstring",
- "openssl",
- "pdo",
- "tokenizer",
- "xml",
- "ctype",
- "iconv",
- "curl",
- "dom",
- "fileinfo",
- "filter",
- "hash",
- "pcre",
- "session",
- "zlib",
- "intl",
- "gd",
- "bcmath",
- ] {
- packages.insert(format!("ext-{ext}"), "8.1.0.0".to_string());
- }
- Self { packages }
- }
-
- /// Parse platform packages into `ComposerVersion` values.
- pub fn to_versions(&self) -> HashMap<String, ComposerVersion> {
- self.packages
- .iter()
- .filter_map(|(name, version_str)| {
- ComposerVersion::from_normalized(version_str).map(|v| (name.clone(), v))
- })
- .collect()
- }
-}
-
-// ─────────────────────────────────────────────────────────────────────────────
-// Error types
-// ─────────────────────────────────────────────────────────────────────────────
-
-/// Error returned by `DependencyProvider` methods (internal to the solver).
-#[derive(Debug)]
-pub enum ResolverError {
- /// Network or API error fetching package metadata.
- PackagistError(String),
- /// Internal error.
- Internal(String),
-}
-
-impl fmt::Display for ResolverError {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::PackagistError(msg) => write!(f, "Packagist error: {}", msg),
- Self::Internal(msg) => write!(f, "Internal resolver error: {}", msg),
- }
- }
-}
-
-impl std::error::Error for ResolverError {}
-
-/// Error returned by the public `resolve()` function.
-#[derive(Debug)]
-pub enum ResolveError {
- /// No solution exists. Contains a human-readable explanation.
- NoSolution(String),
- /// Error parsing a version constraint.
- ConstraintParseError(String, String, String), // (package, constraint, error)
- /// Error fetching dependency metadata.
- DependencyFetchError(String),
- /// Internal error.
- Internal(String),
-}
-
-impl fmt::Display for ResolveError {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::NoSolution(report) => {
- writeln!(
- f,
- "Your requirements could not be resolved to an installable set of packages."
- )?;
- writeln!(f)?;
- write!(f, "{}", report)
- }
- Self::ConstraintParseError(pkg, constraint, err) => {
- write!(
- f,
- "Could not parse version constraint '{}' for package {}: {}",
- constraint, pkg, err
- )
- }
- Self::DependencyFetchError(msg) => write!(f, "{}", msg),
- Self::Internal(msg) => write!(f, "Internal resolver error: {}", msg),
- }
- }
-}
-
-impl std::error::Error for ResolveError {}
-
-// ─────────────────────────────────────────────────────────────────────────────
-// Priority type
-// ─────────────────────────────────────────────────────────────────────────────
-
-/// Priority for package resolution ordering.
-/// Higher priority = resolved first.
-#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
-pub struct ResolverPriority {
- conflict_count: u32,
- version_count_inverse: Reverse<usize>,
-}
-
-// ─────────────────────────────────────────────────────────────────────────────
-// Provider internals
-// ─────────────────────────────────────────────────────────────────────────────
-
-/// Cached version data for a single package.
-struct PackageVersions {
- /// All versions that pass the stability filter, sorted by ComposerVersion.
- versions: BTreeMap<ComposerVersion, VersionDependencies>,
-}
-
-/// Dependencies of a specific package version.
-struct VersionDependencies {
- /// Required packages: (package_name, constraint_string)
- require: Vec<(String, String)>,
- /// Replace declarations: (package_name, constraint_string)
- /// Stored for future replace/provide support (Phase 3.8+).
- #[allow(dead_code)]
- replace: Vec<(String, String)>,
- /// Provide declarations: (package_name, constraint_string)
- /// Stored for future replace/provide support (Phase 3.8+).
- #[allow(dead_code)]
- provide: Vec<(String, String)>,
- /// Conflict declarations: (package_name, constraint_string)
- conflict: Vec<(String, String)>,
- /// Original version string (for output).
- version_string: String,
- /// Normalized version string.
- version_normalized: String,
-}
-
-// ─────────────────────────────────────────────────────────────────────────────
-// MozartProvider
-// ─────────────────────────────────────────────────────────────────────────────
-
-/// pubgrub `DependencyProvider` that fetches package metadata from Packagist.
-pub struct MozartProvider {
- /// Cache of fetched package metadata. Populated lazily from Packagist.
- package_cache: RefCell<HashMap<String, PackageVersions>>,
-
- /// Optional on-disk repo cache for Packagist API responses.
- repo_cache: Option<Cache>,
-
- /// Platform packages (php, ext-*, lib-*) with their fixed versions.
- platform_packages: HashMap<String, ComposerVersion>,
-
- /// Minimum stability threshold. Versions below this are excluded.
- minimum_stability: Stability,
-
- /// Per-package stability overrides from composer.json.
- stability_flags: HashMap<String, Stability>,
-
- /// Whether prefer-stable is enabled.
- prefer_stable: bool,
-
- /// Whether prefer-lowest is enabled (for testing).
- prefer_lowest: bool,
-
- /// Root package dependencies (require + optionally require-dev).
- root_dependencies: Vec<(PackageName, ComposerVS)>,
-
- /// Root package conflicts.
- root_conflicts: Vec<(PackageName, ComposerVS)>,
-
- /// Ignore all platform requirements.
- ignore_platform_reqs: bool,
-
- /// Specific platform requirements to ignore.
- ignore_platform_req_list: Vec<String>,
-}
-
-impl MozartProvider {
- /// Ensure package metadata is fetched from Packagist and stored in cache.
- fn ensure_fetched(&self, package_name: &str) -> Result<(), ResolverError> {
- // Check if already cached
- {
- let cache = self.package_cache.borrow();
- if cache.contains_key(package_name) {
- return Ok(());
- }
- }
-
- // Fetch from Packagist (with optional on-disk repo cache)
- let packagist_versions = packagist::fetch_package_versions(
- package_name,
- self.repo_cache.as_ref(),
- )
- .map_err(|e| {
- ResolverError::PackagistError(format!("Failed to fetch {}: {}", package_name, e))
- })?;
-
- // Convert and filter
- let mut versions = BTreeMap::new();
- for pv in &packagist_versions {
- // Build the dependency metadata once (used for both the normal entry
- // and any branch-alias synthetic entry).
- let make_deps =
- |version_string: String, version_normalized: String| VersionDependencies {
- require: pv
- .require
- .iter()
- .map(|(k, v)| (k.clone(), v.clone()))
- .collect(),
- replace: pv
- .replace
- .iter()
- .map(|(k, v)| (k.clone(), v.clone()))
- .collect(),
- provide: pv
- .provide
- .iter()
- .map(|(k, v)| (k.clone(), v.clone()))
- .collect(),
- conflict: pv
- .conflict
- .iter()
- .map(|(k, v)| (k.clone(), v.clone()))
- .collect(),
- version_string,
- version_normalized,
- };
-
- match ComposerVersion::from_normalized(&pv.version_normalized) {
- Some(cv) => {
- // Regular (non-dev) version
- if self.passes_stability_filter(package_name, &cv) {
- let deps = make_deps(pv.version.clone(), pv.version_normalized.clone());
- versions.insert(cv, deps);
- }
- }
- None => {
- // Dev branch — check for branch aliases
- let aliases = pv.branch_aliases();
- for (branch, alias_target) in &aliases {
- // The key in branch-alias is the full branch name, e.g. "dev-master".
- // Verify it matches this version.
- if branch.to_lowercase() != pv.version.to_lowercase() {
- continue;
- }
- if let Some(alias_cv) =
- ComposerVersion::from_branch_alias_target(alias_target)
- && self.passes_stability_filter(package_name, &alias_cv)
- {
- // Use the alias target as the normalized version string so
- // that constraint matching works correctly.
- let deps = make_deps(pv.version.clone(), alias_target.clone());
- // Only insert if no real release already occupies this slot
- versions.entry(alias_cv).or_insert(deps);
- }
- }
- }
- }
- }
-
- let mut cache = self.package_cache.borrow_mut();
- cache.insert(package_name.to_string(), PackageVersions { versions });
-
- Ok(())
- }
-
- /// Check if a version passes the minimum-stability filter for the given package.
- fn passes_stability_filter(&self, package_name: &str, version: &ComposerVersion) -> bool {
- // Per-package stability override takes precedence
- let min_stability = self
- .stability_flags
- .get(package_name)
- .copied()
- .unwrap_or(self.minimum_stability);
-
- let version_stability = version.stability_enum();
-
- // `Stability` enum: Stable=0, RC=5, Beta=10, Alpha=15, Dev=20
- // Lower enum value = more stable.
- // version_stability must be <= min_stability (i.e., at least as stable as minimum).
- version_stability <= min_stability
- }
-
- /// Check whether a platform dependency should be skipped.
- fn should_skip_platform_dep(&self, dep_name: &str) -> bool {
- if !PackageName(dep_name.to_string()).is_platform() {
- return false;
- }
- if self.ignore_platform_reqs {
- return true;
- }
- self.ignore_platform_req_list.iter().any(|p| p == dep_name)
- }
-}
-
-impl DependencyProvider for MozartProvider {
- type P = PackageName;
- type V = ComposerVersion;
- type VS = ComposerVS;
- type Priority = ResolverPriority;
- type M = String;
- type Err = ResolverError;
-
- fn choose_version(
- &self,
- package: &PackageName,
- range: &ComposerVS,
- ) -> Result<Option<ComposerVersion>, ResolverError> {
- // Root package: always version 0.0.0.0-stable
- if package.is_root() {
- let root_v = ComposerVersion::stable(0, 0, 0, 0);
- if range.contains(&root_v) {
- return Ok(Some(root_v));
- }
- return Ok(None);
- }
-
- // Platform packages: return the fixed version if it satisfies the range
- if package.is_platform() {
- if let Some(v) = self.platform_packages.get(&package.0)
- && range.contains(v)
- {
- return Ok(Some(*v));
- }
- return Ok(None);
- }
-
- // Regular packages: ensure metadata is fetched
- self.ensure_fetched(&package.0)?;
-
- let cache = self.package_cache.borrow();
- let Some(pkg_versions) = cache.get(&package.0) else {
- return Ok(None);
- };
-
- if self.prefer_lowest {
- // Pick the lowest matching version
- return Ok(pkg_versions
- .versions
- .keys()
- .find(|v| range.contains(*v))
- .copied());
- }
-
- if self.prefer_stable {
- // First try: highest stable version in range
- if let Some(v) = pkg_versions
- .versions
- .keys()
- .rev()
- .find(|v| v.stability >= STABILITY_STABLE && range.contains(*v))
- {
- return Ok(Some(*v));
- }
- }
-
- // Default: pick highest version in range
- Ok(pkg_versions
- .versions
- .keys()
- .rev()
- .find(|v| range.contains(*v))
- .copied())
- }
-
- fn prioritize(
- &self,
- package: &PackageName,
- range: &ComposerVS,
- package_conflicts_counts: &PackageResolutionStatistics,
- ) -> Self::Priority {
- // Root and platform packages: highest priority (resolved first)
- if package.is_root() || package.is_platform() {
- return ResolverPriority {
- conflict_count: u32::MAX,
- version_count_inverse: Reverse(0),
- };
- }
-
- let cache = self.package_cache.borrow();
- let count = cache
- .get(&package.0)
- .map(|pvs| pvs.versions.keys().filter(|v| range.contains(*v)).count())
- .unwrap_or(0);
-
- ResolverPriority {
- conflict_count: package_conflicts_counts.conflict_count(),
- version_count_inverse: Reverse(count),
- }
- }
-
- fn get_dependencies(
- &self,
- package: &PackageName,
- version: &ComposerVersion,
- ) -> Result<Dependencies<PackageName, ComposerVS, String>, ResolverError> {
- // Root package: return the configured root dependencies
- if package.is_root() {
- let mut deps = DependencyConstraints::default();
- for (name, range) in &self.root_dependencies {
- deps.insert(name.clone(), range.clone());
- }
- // Apply root conflicts as complement ranges
- for (name, range) in &self.root_conflicts {
- let anti_range = range.complement();
- deps.entry(name.clone())
- .and_modify(|existing| *existing = existing.intersection(&anti_range))
- .or_insert(anti_range);
- }
- return Ok(Dependencies::Available(deps));
- }
-
- // Platform packages: no dependencies
- if package.is_platform() {
- return Ok(Dependencies::Available(DependencyConstraints::default()));
- }
-
- // Regular packages: fetch metadata and build dependency map
- self.ensure_fetched(&package.0)?;
-
- let cache = self.package_cache.borrow();
- let Some(pkg_versions) = cache.get(&package.0) else {
- return Ok(Dependencies::Unavailable(format!(
- "package {} has no available versions",
- package
- )));
- };
-
- let Some(version_deps) = pkg_versions.versions.get(version) else {
- return Ok(Dependencies::Unavailable(format!(
- "{} {} is not available",
- package, version
- )));
- };
-
- let mut deps = DependencyConstraints::default();
-
- // Process `require` constraints
- for (dep_name, constraint_str) in &version_deps.require {
- // Skip self-dependencies
- if dep_name == &package.0 {
- continue;
- }
-
- // Skip platform dependencies if configured
- if self.should_skip_platform_dep(dep_name) {
- continue;
- }
-
- let dep_pkg = PackageName(dep_name.clone());
-
- match constraint_to_ranges(constraint_str) {
- Ok(range) => {
- deps.insert(dep_pkg, range);
- }
- Err(e) => {
- // Unparseable constraint: mark this version as unavailable
- return Ok(Dependencies::Unavailable(format!(
- "cannot parse constraint '{}' for dependency {} of {} {}: {}",
- constraint_str, dep_name, package, version, e
- )));
- }
- }
- }
-
- // Process `conflict` declarations as complement ranges
- for (conflict_name, constraint_str) in &version_deps.conflict {
- if self.should_skip_platform_dep(conflict_name) {
- continue;
- }
- let conflict_pkg = PackageName(conflict_name.clone());
- if let Ok(range) = constraint_to_ranges(constraint_str) {
- let anti_range = range.complement();
- deps.entry(conflict_pkg)
- .and_modify(|existing| *existing = existing.intersection(&anti_range))
- .or_insert(anti_range);
- }
- }
-
- Ok(Dependencies::Available(deps))
- }
-}
-
-// ─────────────────────────────────────────────────────────────────────────────
-// Public API types
-// ─────────────────────────────────────────────────────────────────────────────
-
-/// Input to the resolver.
-pub struct ResolveRequest {
- /// Dependencies from composer.json "require" section.
- pub require: Vec<(String, String)>,
- /// Dependencies from composer.json "require-dev" section.
- pub require_dev: Vec<(String, String)>,
- /// Whether to include require-dev in resolution.
- pub include_dev: bool,
- /// Minimum stability from composer.json.
- pub minimum_stability: Stability,
- /// Per-package stability overrides.
- pub stability_flags: HashMap<String, Stability>,
- /// Whether prefer-stable is enabled.
- pub prefer_stable: bool,
- /// Whether prefer-lowest is enabled.
- pub prefer_lowest: bool,
- /// Platform package configuration.
- pub platform: PlatformConfig,
- /// Ignore all platform requirements.
- pub ignore_platform_reqs: bool,
- /// Specific platform requirements to ignore.
- pub ignore_platform_req_list: Vec<String>,
- /// Optional on-disk repo cache for Packagist API responses.
- pub repo_cache: Option<Cache>,
-}
-
-/// A single package in the resolution output.
-pub struct ResolvedPackage {
- pub name: String,
- /// Human-readable version string (e.g. "1.2.3").
- pub version: String,
- /// Normalized version string (e.g. "1.2.3.0").
- pub version_normalized: String,
- /// True if the resolved version is a dev/pre-release version.
- pub is_dev: bool,
-}
-
-// ─────────────────────────────────────────────────────────────────────────────
-// Public resolve() function
-// ─────────────────────────────────────────────────────────────────────────────
-
-/// Run the dependency resolver.
-///
-/// Returns a list of resolved packages (excluding root and platform packages),
-/// or a human-readable error.
-pub fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, ResolveError> {
- // 1. Build root dependencies
- let mut root_deps: Vec<(PackageName, ComposerVS)> = Vec::new();
- let root_conflicts: Vec<(PackageName, ComposerVS)> = Vec::new();
-
- let parse_dep =
- |name: &str, constraint: &str| -> Result<Option<(PackageName, ComposerVS)>, ResolveError> {
- let pkg = PackageName(name.to_string());
-
- // Skip platform deps if ignore_platform_reqs is set
- if pkg.is_platform()
- && (request.ignore_platform_reqs
- || request.ignore_platform_req_list.contains(&name.to_string()))
- {
- return Ok(None);
- }
-
- let range = constraint_to_ranges(constraint).map_err(|e| {
- ResolveError::ConstraintParseError(name.to_string(), constraint.to_string(), e)
- })?;
- Ok(Some((pkg, range)))
- };
-
- for (name, constraint) in &request.require {
- if let Some(dep) = parse_dep(name, constraint)? {
- root_deps.push(dep);
- }
- }
-
- if request.include_dev {
- for (name, constraint) in &request.require_dev {
- if let Some(dep) = parse_dep(name, constraint)? {
- root_deps.push(dep);
- }
- }
- }
-
- // 2. Build the provider
- let provider = MozartProvider {
- package_cache: RefCell::new(HashMap::new()),
- repo_cache: request.repo_cache.clone(),
- platform_packages: request.platform.to_versions(),
- minimum_stability: request.minimum_stability,
- stability_flags: request.stability_flags.clone(),
- prefer_stable: request.prefer_stable,
- prefer_lowest: request.prefer_lowest,
- root_dependencies: root_deps,
- root_conflicts,
- ignore_platform_reqs: request.ignore_platform_reqs,
- ignore_platform_req_list: request.ignore_platform_req_list.clone(),
- };
-
- // 3. Run pubgrub
- let root = PackageName::root();
- let root_version = ComposerVersion::stable(0, 0, 0, 0);
-
- match pubgrub::resolve(&provider, root, root_version) {
- Ok(solution) => {
- // 4. Convert solution to ResolvedPackage list
- let mut result = Vec::new();
- for (pkg, version) in solution {
- // Skip root and platform packages
- if pkg.is_root() || pkg.is_platform() {
- continue;
- }
-
- // Look up the original version string from the cache
- let cache = provider.package_cache.borrow();
- let (version_str, version_normalized) = if let Some(pvs) = cache.get(&pkg.0) {
- if let Some(vd) = pvs.versions.get(&version) {
- (vd.version_string.clone(), vd.version_normalized.clone())
- } else {
- (version.to_string(), version.to_string())
- }
- } else {
- (version.to_string(), version.to_string())
- };
-
- result.push(ResolvedPackage {
- name: pkg.0.clone(),
- version: version_str,
- version_normalized,
- is_dev: version.stability < STABILITY_ALPHA_BASE,
- });
- }
- Ok(result)
- }
- Err(PubGrubError::NoSolution(mut derivation_tree)) => {
- derivation_tree.collapse_no_versions();
- let report = DefaultStringReporter::report(&derivation_tree);
- Err(ResolveError::NoSolution(report))
- }
- Err(PubGrubError::ErrorRetrievingDependencies {
- package,
- version,
- source,
- }) => Err(ResolveError::DependencyFetchError(format!(
- "Error retrieving dependencies for {} {}: {}",
- package, version, source
- ))),
- Err(PubGrubError::ErrorChoosingVersion { package, source }) => {
- Err(ResolveError::DependencyFetchError(format!(
- "Error choosing version for {}: {}",
- package, source
- )))
- }
- Err(PubGrubError::ErrorInShouldCancel(e)) => {
- Err(ResolveError::Internal(format!("Resolver cancelled: {}", e)))
- }
- }
-}
-
-// ─────────────────────────────────────────────────────────────────────────────
-// Tests
-// ─────────────────────────────────────────────────────────────────────────────
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use pubgrub::{OfflineDependencyProvider, Ranges};
-
- // ──────────── ComposerVersion parsing ────────────
-
- #[test]
- fn test_composer_version_parse_stable() {
- let v = ComposerVersion::from_normalized("1.2.3.0").unwrap();
- assert_eq!(v.major, 1);
- assert_eq!(v.minor, 2);
- assert_eq!(v.patch, 3);
- assert_eq!(v.build, 0);
- assert_eq!(v.stability, STABILITY_STABLE);
- }
-
- #[test]
- fn test_composer_version_parse_beta() {
- let v = ComposerVersion::from_normalized("1.0.0.0-beta1").unwrap();
- assert_eq!(v.major, 1);
- assert_eq!(v.minor, 0);
- assert_eq!(v.patch, 0);
- assert_eq!(v.build, 0);
- assert_eq!(v.stability, STABILITY_BETA_BASE + 1);
- }
-
- #[test]
- fn test_composer_version_parse_rc() {
- let v = ComposerVersion::from_normalized("2.0.0.0-RC3").unwrap();
- assert_eq!(v.major, 2);
- assert_eq!(v.stability, STABILITY_RC_BASE + 3);
- }
-
- #[test]
- fn test_composer_version_parse_alpha() {
- let v = ComposerVersion::from_normalized("1.0.0.0-alpha2").unwrap();
- assert_eq!(v.stability, STABILITY_ALPHA_BASE + 2);
- }
-
- #[test]
- fn test_composer_version_parse_dev() {
- let v = ComposerVersion::from_normalized("1.0.0.0-dev").unwrap();
- assert_eq!(v.stability, STABILITY_DEV);
- }
-
- #[test]
- fn test_composer_version_parse_dev_branch() {
- let v = ComposerVersion::from_normalized("dev-master");
- assert!(
- v.is_none(),
- "dev-master should not parse as ComposerVersion"
- );
- }
-
- #[test]
- fn test_composer_version_parse_x_dev() {
- let v = ComposerVersion::from_normalized("dev-feature/foo");
- assert!(v.is_none());
- }
-
- #[test]
- fn test_composer_version_parse_9999999_dev() {
- // Packagist sometimes uses 9999999.9999999.9999999.9999999 for dev
- let v = ComposerVersion::from_normalized("9999999.9999999.9999999.9999999-dev");
- assert!(v.is_none());
- }
-
- #[test]
- fn test_composer_version_ordering_stable() {
- let v1 = ComposerVersion::from_normalized("2.0.0.0").unwrap();
- let v2 = ComposerVersion::from_normalized("1.0.0.0").unwrap();
- assert!(v1 > v2);
- }
-
- #[test]
- fn test_composer_version_ordering_stability() {
- let stable = ComposerVersion::from_normalized("1.0.0.0").unwrap();
- let rc = ComposerVersion::from_normalized("1.0.0.0-RC1").unwrap();
- let beta = ComposerVersion::from_normalized("1.0.0.0-beta1").unwrap();
- let alpha = ComposerVersion::from_normalized("1.0.0.0-alpha1").unwrap();
- let dev = ComposerVersion::from_normalized("1.0.0.0-dev").unwrap();
- assert!(stable > rc);
- assert!(rc > beta);
- assert!(beta > alpha);
- assert!(alpha > dev);
- }
-
- #[test]
- fn test_composer_version_ordering_pre_number() {
- let beta2 = ComposerVersion::from_normalized("1.0.0.0-beta2").unwrap();
- let beta1 = ComposerVersion::from_normalized("1.0.0.0-beta1").unwrap();
- assert!(beta2 > beta1);
- }
-
- #[test]
- fn test_composer_version_display() {
- let stable = ComposerVersion::stable(1, 2, 3, 0);
- assert_eq!(format!("{stable}"), "1.2.3.0");
-
- let beta1 = ComposerVersion {
- major: 1,
- minor: 0,
- patch: 0,
- build: 0,
- stability: STABILITY_BETA_BASE + 1,
- };
- assert_eq!(format!("{beta1}"), "1.0.0.0-beta1");
-
- let rc2 = ComposerVersion {
- major: 2,
- minor: 0,
- patch: 0,
- build: 0,
- stability: STABILITY_RC_BASE + 2,
- };
- assert_eq!(format!("{rc2}"), "2.0.0.0-RC2");
-
- let dev = ComposerVersion {
- major: 1,
- minor: 0,
- patch: 0,
- build: 0,
- stability: STABILITY_DEV,
- };
- assert_eq!(format!("{dev}"), "1.0.0.0-dev");
- }
-
- #[test]
- fn test_composer_version_stability_enum() {
- let stable = ComposerVersion::stable(1, 0, 0, 0);
- assert_eq!(stable.stability_enum(), Stability::Stable);
-
- let rc = ComposerVersion {
- major: 1,
- minor: 0,
- patch: 0,
- build: 0,
- stability: STABILITY_RC_BASE,
- };
- assert_eq!(rc.stability_enum(), Stability::RC);
-
- let beta = ComposerVersion {
- major: 1,
- minor: 0,
- patch: 0,
- build: 0,
- stability: STABILITY_BETA_BASE,
- };
- assert_eq!(beta.stability_enum(), Stability::Beta);
-
- let alpha = ComposerVersion {
- major: 1,
- minor: 0,
- patch: 0,
- build: 0,
- stability: STABILITY_ALPHA_BASE,
- };
- assert_eq!(alpha.stability_enum(), Stability::Alpha);
-
- let dev = ComposerVersion {
- major: 1,
- minor: 0,
- patch: 0,
- build: 0,
- stability: STABILITY_DEV,
- };
- assert_eq!(dev.stability_enum(), Stability::Dev);
- }
-
- // ──────────── Constraint conversion ────────────
-
- fn cv(major: u16, minor: u16, patch: u16, build: u16) -> ComposerVersion {
- ComposerVersion::stable(major, minor, patch, build)
- }
-
- fn cv_dev(major: u16, minor: u16, patch: u16, build: u16) -> ComposerVersion {
- ComposerVersion {
- major,
- minor,
- patch,
- build,
- stability: STABILITY_DEV,
- }
- }
-
- #[test]
- fn test_constraint_any() {
- let range = constraint_to_ranges("*").unwrap();
- assert!(range.contains(&cv(1, 2, 3, 0)));
- assert!(range.contains(&cv(0, 0, 0, 0)));
- }
-
- #[test]
- fn test_constraint_exact() {
- let range = constraint_to_ranges("1.2.3").unwrap();
- // Exact "1.2.3" is parsed as Version { 1, 2, 3, 0, pre_release: None } → stable
- assert!(range.contains(&cv(1, 2, 3, 0)));
- assert!(!range.contains(&cv(1, 2, 4, 0)));
- assert!(!range.contains(&cv(1, 2, 2, 0)));
- }
-
- #[test]
- fn test_constraint_gte() {
- let range = constraint_to_ranges(">=1.0").unwrap();
- // >=1.0 parses "1.0" as a stable version (no dev_boundary), so >= 1.0.0.0 (stable)
- assert!(range.contains(&cv(1, 0, 0, 0)));
- assert!(range.contains(&cv(2, 0, 0, 0)));
- // 0.9.0.0 should not be in range
- assert!(!range.contains(&cv(0, 9, 0, 0)));
- // 1.0.0.0-dev (stability=0) is LESS than 1.0.0.0 (stability=4000), so NOT in >=1.0
- assert!(!range.contains(&cv_dev(1, 0, 0, 0)));
- }
-
- #[test]
- fn test_constraint_lt() {
- let range = constraint_to_ranges("<2.0").unwrap();
- // <2.0 parses "2.0" as a stable version, so strictly < 2.0.0.0 (stable)
- // 2.0.0.0-dev (stability=0) is LESS than 2.0.0.0 (stability=4000), so IS in <2.0
- assert!(range.contains(&cv(1, 9, 9, 0)));
- assert!(range.contains(&cv_dev(2, 0, 0, 0))); // 2.0.0.0-dev < 2.0.0.0 (stable)
- // 2.0.0.0 (stable) and higher should not be in range
- assert!(!range.contains(&cv(2, 0, 0, 0)));
- }
-
- #[test]
- fn test_constraint_caret() {
- // ^1.2 → >=1.2.0.0-dev <2.0.0.0-dev
- let range = constraint_to_ranges("^1.2").unwrap();
- assert!(range.contains(&cv_dev(1, 2, 0, 0)));
- assert!(range.contains(&cv(1, 2, 0, 0)));
- assert!(range.contains(&cv(1, 9, 9, 0)));
- assert!(!range.contains(&cv_dev(2, 0, 0, 0)));
- assert!(!range.contains(&cv(2, 0, 0, 0)));
- // Below 1.2.0.0-dev should not match
- assert!(!range.contains(&cv(1, 1, 9, 0)));
- }
-
- #[test]
- fn test_constraint_caret_zero() {
- // ^0.2.3 → >=0.2.3.0-dev <0.3.0.0-dev
- let range = constraint_to_ranges("^0.2.3").unwrap();
- assert!(range.contains(&cv(0, 2, 3, 0)));
- assert!(range.contains(&cv(0, 2, 9, 0)));
- assert!(!range.contains(&cv_dev(0, 3, 0, 0)));
- assert!(!range.contains(&cv(1, 0, 0, 0)));
- }
-
- #[test]
- fn test_constraint_tilde() {
- // ~1.2.3 → >=1.2.3.0-dev <1.3.0.0-dev
- let range = constraint_to_ranges("~1.2.3").unwrap();
- assert!(range.contains(&cv(1, 2, 3, 0)));
- assert!(range.contains(&cv(1, 2, 9, 0)));
- assert!(!range.contains(&cv_dev(1, 3, 0, 0)));
- }
-
- #[test]
- fn test_constraint_wildcard() {
- // 1.2.* → >=1.2.0.0-dev <1.3.0.0-dev
- let range = constraint_to_ranges("1.2.*").unwrap();
- assert!(range.contains(&cv(1, 2, 0, 0)));
- assert!(range.contains(&cv(1, 2, 9, 0)));
- assert!(!range.contains(&cv_dev(1, 3, 0, 0)));
- assert!(!range.contains(&cv(1, 3, 0, 0)));
- }
-
- #[test]
- fn test_constraint_or() {
- // ^1.0 || ^2.0
- let range = constraint_to_ranges("^1.0 || ^2.0").unwrap();
- assert!(range.contains(&cv(1, 5, 0, 0)));
- assert!(range.contains(&cv(2, 3, 0, 0)));
- assert!(!range.contains(&cv(3, 0, 0, 0)));
- }
-
- #[test]
- fn test_constraint_and() {
- // >=1.0 <2.0: >=1.0 means >= 1.0.0.0 (stable); <2.0 means < 2.0.0.0 (stable)
- let range = constraint_to_ranges(">=1.0 <2.0").unwrap();
- // 1.0.0.0-dev < 1.0.0.0 (stable), so NOT in >=1.0
- assert!(!range.contains(&cv_dev(1, 0, 0, 0)));
- assert!(range.contains(&cv(1, 0, 0, 0)));
- assert!(range.contains(&cv(1, 9, 9, 0)));
- // 2.0.0.0-dev < 2.0.0.0 (stable), so IS in <2.0 but overall intersection with >=1.0 is yes
- assert!(range.contains(&cv_dev(2, 0, 0, 0)));
- assert!(!range.contains(&cv(2, 0, 0, 0)));
- }
-
- #[test]
- fn test_constraint_not_equal() {
- let range = constraint_to_ranges("!=1.5.0").unwrap();
- assert!(range.contains(&cv(1, 4, 0, 0)));
- assert!(!range.contains(&cv(1, 5, 0, 0)));
- assert!(range.contains(&cv(1, 6, 0, 0)));
- }
-
- #[test]
- fn test_constraint_hyphen() {
- // "1.0 - 2.0" → >=1.0.0.0 <=2.0.0.0
- let range = constraint_to_ranges("1.0 - 2.0").unwrap();
- assert!(range.contains(&cv(1, 0, 0, 0)));
- assert!(range.contains(&cv(1, 5, 0, 0)));
- assert!(range.contains(&cv(2, 0, 0, 0)));
- assert!(!range.contains(&cv(2, 1, 0, 0)));
- }
-
- // ──────────── Provider tests (offline) ────────────
-
- #[test]
- fn test_package_name_is_platform() {
- assert!(PackageName("php".to_string()).is_platform());
- assert!(PackageName("ext-json".to_string()).is_platform());
- assert!(PackageName("lib-curl".to_string()).is_platform());
- assert!(!PackageName("monolog/monolog".to_string()).is_platform());
- assert!(!PackageName("vendor/package".to_string()).is_platform());
- }
-
- #[test]
- fn test_package_name_is_root() {
- assert!(PackageName::root().is_root());
- assert!(!PackageName("monolog/monolog".to_string()).is_root());
- }
-
- #[test]
- fn test_platform_config_to_versions() {
- let config = PlatformConfig::new();
- let versions = config.to_versions();
- assert!(versions.contains_key("php"));
- assert!(versions.contains_key("ext-json"));
- let php_v = versions["php"];
- assert_eq!(php_v.major, 8);
- assert_eq!(php_v.minor, 1);
- }
-
- // ──────────── Integration tests (offline, using OfflineDependencyProvider) ────────────
-
- type TestVS = Ranges<ComposerVersion>;
-
- fn cv_stable(major: u16, minor: u16, patch: u16) -> ComposerVersion {
- ComposerVersion::stable(major, minor, patch, 0)
- }
-
- /// Test simple resolution: root → foo ^1.0, foo 1.0 → bar ^2.0, bar 2.0 → (nothing)
- #[test]
- fn test_resolve_simple_offline() {
- let mut provider = OfflineDependencyProvider::<PackageName, TestVS>::new();
-
- let root = PackageName::root();
- let root_v = ComposerVersion::stable(0, 0, 0, 0);
- let foo = PackageName("foo/foo".to_string());
- let bar = PackageName("bar/bar".to_string());
-
- let foo_1_0 = cv_stable(1, 0, 0);
- let bar_2_0 = cv_stable(2, 0, 0);
-
- // root depends on foo ^1.0
- let foo_range = constraint_to_ranges("^1.0").unwrap();
- provider.add_dependencies(root.clone(), root_v, [(foo.clone(), foo_range)]);
-
- // foo 1.0 depends on bar ^2.0
- let bar_range = constraint_to_ranges("^2.0").unwrap();
- provider.add_dependencies(foo.clone(), foo_1_0, [(bar.clone(), bar_range)]);
-
- // bar 2.0 has no dependencies
- provider.add_dependencies(bar.clone(), bar_2_0, []);
-
- let solution = pubgrub::resolve(&provider, root.clone(), root_v).unwrap();
-
- assert_eq!(*solution.get(&foo).unwrap(), foo_1_0);
- assert_eq!(*solution.get(&bar).unwrap(), bar_2_0);
- }
-
- /// Test conflict detection: two packages require incompatible versions of a third.
- #[test]
- fn test_resolve_no_solution_offline() {
- let mut provider = OfflineDependencyProvider::<PackageName, TestVS>::new();
-
- let root = PackageName::root();
- let root_v = ComposerVersion::stable(0, 0, 0, 0);
- let foo = PackageName("foo/foo".to_string());
- let bar = PackageName("bar/bar".to_string());
- let dep = PackageName("dep/dep".to_string());
-
- let foo_1_0 = cv_stable(1, 0, 0);
- let bar_1_0 = cv_stable(1, 0, 0);
- let dep_1_0 = cv_stable(1, 0, 0);
- let dep_2_0 = cv_stable(2, 0, 0);
-
- // root depends on foo and bar
- let foo_range = Ranges::singleton(foo_1_0);
- let bar_range = Ranges::singleton(bar_1_0);
- provider.add_dependencies(
- root.clone(),
- root_v,
- [(foo.clone(), foo_range), (bar.clone(), bar_range)],
- );
-
- // foo 1.0 requires dep ^1.0 (excludes 2.x)
- let dep_range_1 = constraint_to_ranges("^1.0").unwrap();
- provider.add_dependencies(foo.clone(), foo_1_0, [(dep.clone(), dep_range_1)]);
-
- // bar 1.0 requires dep ^2.0 (excludes 1.x)
- let dep_range_2 = constraint_to_ranges("^2.0").unwrap();
- provider.add_dependencies(bar.clone(), bar_1_0, [(dep.clone(), dep_range_2)]);
-
- // dep has versions 1.0 and 2.0
- provider.add_dependencies(dep.clone(), dep_1_0, []);
- provider.add_dependencies(dep.clone(), dep_2_0, []);
-
- let result = pubgrub::resolve(&provider, root.clone(), root_v);
- assert!(result.is_err(), "Expected no solution for conflicting deps");
- }
-
- /// Test prefer-stable ordering: with prefer-stable, should pick stable over beta.
- #[test]
- fn test_prefer_stable() {
- let stable = ComposerVersion::stable(1, 0, 0, 0);
- let beta = ComposerVersion {
- major: 1,
- minor: 1,
- patch: 0,
- build: 0,
- stability: STABILITY_BETA_BASE + 1,
- };
-
- // stable should have higher stability numeric value than beta
- assert!(
- stable.stability > beta.stability,
- "stable should be > beta numerically"
- );
- // But stable is 1.0.0.0 and beta is 1.1.0.0-beta1; when prefer-stable is on,
- // we first look for stable version and pick the highest stable
- assert!(stable.stability >= STABILITY_STABLE);
- assert!(beta.stability < STABILITY_STABLE);
- }
-
- /// Test stability filter: alpha versions should be excluded when minimum_stability = stable.
- #[test]
- fn test_stability_filter() {
- let provider = MozartProvider {
- package_cache: RefCell::new(HashMap::new()),
- platform_packages: HashMap::new(),
- minimum_stability: Stability::Stable,
- stability_flags: HashMap::new(),
- prefer_stable: false,
- prefer_lowest: false,
- root_dependencies: vec![],
- root_conflicts: vec![],
- ignore_platform_reqs: false,
- ignore_platform_req_list: vec![],
- repo_cache: None,
- };
-
- let stable_v = ComposerVersion::stable(1, 0, 0, 0);
- let alpha_v = ComposerVersion {
- major: 1,
- minor: 1,
- patch: 0,
- build: 0,
- stability: STABILITY_ALPHA_BASE,
- };
- let beta_v = ComposerVersion {
- major: 1,
- minor: 0,
- patch: 0,
- build: 0,
- stability: STABILITY_BETA_BASE,
- };
- let rc_v = ComposerVersion {
- major: 1,
- minor: 0,
- patch: 0,
- build: 0,
- stability: STABILITY_RC_BASE,
- };
- let dev_v = ComposerVersion {
- major: 1,
- minor: 0,
- patch: 0,
- build: 0,
- stability: STABILITY_DEV,
- };
-
- assert!(provider.passes_stability_filter("foo/foo", &stable_v));
- assert!(!provider.passes_stability_filter("foo/foo", &alpha_v));
- assert!(!provider.passes_stability_filter("foo/foo", &beta_v));
- assert!(!provider.passes_stability_filter("foo/foo", &rc_v));
- assert!(!provider.passes_stability_filter("foo/foo", &dev_v));
- }
-
- #[test]
- fn test_stability_filter_beta() {
- let provider = MozartProvider {
- package_cache: RefCell::new(HashMap::new()),
- platform_packages: HashMap::new(),
- minimum_stability: Stability::Beta,
- stability_flags: HashMap::new(),
- prefer_stable: false,
- prefer_lowest: false,
- root_dependencies: vec![],
- root_conflicts: vec![],
- ignore_platform_reqs: false,
- ignore_platform_req_list: vec![],
- repo_cache: None,
- };
-
- let stable_v = ComposerVersion::stable(1, 0, 0, 0);
- let beta_v = ComposerVersion {
- major: 1,
- minor: 0,
- patch: 0,
- build: 0,
- stability: STABILITY_BETA_BASE,
- };
- let alpha_v = ComposerVersion {
- major: 1,
- minor: 0,
- patch: 0,
- build: 0,
- stability: STABILITY_ALPHA_BASE,
- };
- let dev_v = ComposerVersion {
- major: 1,
- minor: 0,
- patch: 0,
- build: 0,
- stability: STABILITY_DEV,
- };
-
- assert!(provider.passes_stability_filter("foo/foo", &stable_v));
- assert!(provider.passes_stability_filter("foo/foo", &beta_v));
- assert!(!provider.passes_stability_filter("foo/foo", &alpha_v));
- assert!(!provider.passes_stability_filter("foo/foo", &dev_v));
- }
-
- #[test]
- fn test_stability_filter_dev() {
- let provider = MozartProvider {
- package_cache: RefCell::new(HashMap::new()),
- platform_packages: HashMap::new(),
- minimum_stability: Stability::Dev,
- stability_flags: HashMap::new(),
- prefer_stable: false,
- prefer_lowest: false,
- root_dependencies: vec![],
- root_conflicts: vec![],
- ignore_platform_reqs: false,
- ignore_platform_req_list: vec![],
- repo_cache: None,
- };
-
- let dev_v = ComposerVersion {
- major: 1,
- minor: 0,
- patch: 0,
- build: 0,
- stability: STABILITY_DEV,
- };
- assert!(provider.passes_stability_filter("foo/foo", &dev_v));
- }
-
- #[test]
- fn test_skip_platform_dep() {
- let provider = MozartProvider {
- package_cache: RefCell::new(HashMap::new()),
- platform_packages: HashMap::new(),
- minimum_stability: Stability::Stable,
- stability_flags: HashMap::new(),
- prefer_stable: false,
- prefer_lowest: false,
- root_dependencies: vec![],
- root_conflicts: vec![],
- ignore_platform_reqs: true,
- ignore_platform_req_list: vec![],
- repo_cache: None,
- };
-
- assert!(provider.should_skip_platform_dep("php"));
- assert!(provider.should_skip_platform_dep("ext-json"));
- assert!(!provider.should_skip_platform_dep("monolog/monolog"));
- }
-
- #[test]
- fn test_skip_specific_platform_dep() {
- let provider = MozartProvider {
- package_cache: RefCell::new(HashMap::new()),
- platform_packages: HashMap::new(),
- minimum_stability: Stability::Stable,
- stability_flags: HashMap::new(),
- prefer_stable: false,
- prefer_lowest: false,
- root_dependencies: vec![],
- root_conflicts: vec![],
- ignore_platform_reqs: false,
- ignore_platform_req_list: vec!["ext-intl".to_string()],
- repo_cache: None,
- };
-
- assert!(provider.should_skip_platform_dep("ext-intl"));
- assert!(!provider.should_skip_platform_dep("ext-json"));
- assert!(!provider.should_skip_platform_dep("php"));
- assert!(!provider.should_skip_platform_dep("monolog/monolog"));
- }
-
- #[test]
- fn test_root_package_choose_version() {
- let provider = MozartProvider {
- package_cache: RefCell::new(HashMap::new()),
- platform_packages: HashMap::new(),
- minimum_stability: Stability::Stable,
- stability_flags: HashMap::new(),
- prefer_stable: false,
- prefer_lowest: false,
- root_dependencies: vec![],
- root_conflicts: vec![],
- ignore_platform_reqs: false,
- ignore_platform_req_list: vec![],
- repo_cache: None,
- };
-
- let root = PackageName::root();
- let root_v = ComposerVersion::stable(0, 0, 0, 0);
- let full_range: ComposerVS = Ranges::full();
- let result = provider.choose_version(&root, &full_range).unwrap();
- assert_eq!(result, Some(root_v));
- }
-
- #[test]
- fn test_platform_choose_version() {
- let mut platform = HashMap::new();
- let php_v = ComposerVersion::from_normalized("8.1.0.0").unwrap();
- platform.insert("php".to_string(), php_v);
-
- let provider = MozartProvider {
- package_cache: RefCell::new(HashMap::new()),
- platform_packages: platform,
- minimum_stability: Stability::Stable,
- stability_flags: HashMap::new(),
- prefer_stable: false,
- prefer_lowest: false,
- root_dependencies: vec![],
- root_conflicts: vec![],
- ignore_platform_reqs: false,
- ignore_platform_req_list: vec![],
- repo_cache: None,
- };
-
- let php = PackageName("php".to_string());
- let range = constraint_to_ranges(">=8.0").unwrap();
- let result = provider.choose_version(&php, &range).unwrap();
- assert_eq!(result, Some(php_v));
-
- // Range that excludes 8.1
- let too_new_range = constraint_to_ranges(">=9.0").unwrap();
- let result2 = provider.choose_version(&php, &too_new_range).unwrap();
- assert_eq!(result2, None);
- }
-
- /// Test constraint_to_ranges produces correct range with version containment checks.
- #[test]
- fn test_constraint_contains_version() {
- // ^3.0 should contain 3.5.1.0 but not 4.0.0.0
- let range = constraint_to_ranges("^3.0").unwrap();
- assert!(range.contains(&cv_stable(3, 5, 1)));
- assert!(!range.contains(&cv_stable(4, 0, 0)));
- assert!(!range.contains(&cv_stable(2, 9, 9)));
- }
-
- // ──────────── Integration test with MozartProvider (no network) ────────────
-
- /// Test resolve() with root dependencies using offline provider
- #[test]
- fn test_resolve_with_offline_provider_simple() {
- let mut provider = OfflineDependencyProvider::<PackageName, TestVS>::new();
-
- let root = PackageName::root();
- let root_v = ComposerVersion::stable(0, 0, 0, 0);
- let foo = PackageName("foo/foo".to_string());
-
- let foo_1_0 = cv_stable(1, 0, 0);
- let foo_1_1 = cv_stable(1, 1, 0);
-
- let foo_range = constraint_to_ranges("^1.0").unwrap();
- provider.add_dependencies(root.clone(), root_v, [(foo.clone(), foo_range)]);
- provider.add_dependencies(foo.clone(), foo_1_0, []);
- provider.add_dependencies(foo.clone(), foo_1_1, []);
-
- let solution = pubgrub::resolve(&provider, root.clone(), root_v).unwrap();
-
- // Should pick highest version: 1.1.0
- assert_eq!(*solution.get(&foo).unwrap(), foo_1_1);
- }
-
- #[test]
- fn test_resolve_or_constraint() {
- let mut provider = OfflineDependencyProvider::<PackageName, TestVS>::new();
-
- let root = PackageName::root();
- let root_v = ComposerVersion::stable(0, 0, 0, 0);
- let foo = PackageName("foo/foo".to_string());
-
- // foo has versions 1.5.0 and 2.3.0
- let foo_1_5 = cv_stable(1, 5, 0);
- let foo_2_3 = cv_stable(2, 3, 0);
-
- // root requires "^1.0 || ^2.0"
- let foo_range = constraint_to_ranges("^1.0 || ^2.0").unwrap();
- provider.add_dependencies(root.clone(), root_v, [(foo.clone(), foo_range)]);
- provider.add_dependencies(foo.clone(), foo_1_5, []);
- provider.add_dependencies(foo.clone(), foo_2_3, []);
-
- let solution = pubgrub::resolve(&provider, root.clone(), root_v).unwrap();
-
- // Should pick the highest matching version: 2.3.0
- let picked = *solution.get(&foo).unwrap();
- assert!(
- picked == foo_1_5 || picked == foo_2_3,
- "picked version should be one of the available versions"
- );
- }
-
- // ──────────── Branch alias tests ────────────
-
- #[test]
- fn test_from_branch_alias_target_x_dev() {
- let cv = ComposerVersion::from_branch_alias_target("2.x-dev").unwrap();
- assert_eq!(cv.major, 2);
- assert_eq!(cv.minor, 0);
- assert_eq!(cv.patch, 0);
- assert_eq!(cv.build, 0);
- assert_eq!(cv.stability, STABILITY_DEV);
- }
-
- #[test]
- fn test_from_branch_alias_target_minor_x_dev() {
- let cv = ComposerVersion::from_branch_alias_target("1.5.x-dev").unwrap();
- assert_eq!(cv.major, 1);
- assert_eq!(cv.minor, 5);
- assert_eq!(cv.patch, 0);
- assert_eq!(cv.stability, STABILITY_DEV);
- }
-
- #[test]
- fn test_from_branch_alias_target_patch_x_dev() {
- let cv = ComposerVersion::from_branch_alias_target("1.0.2.x-dev").unwrap();
- assert_eq!(cv.major, 1);
- assert_eq!(cv.minor, 0);
- assert_eq!(cv.patch, 2);
- assert_eq!(cv.stability, STABILITY_DEV);
- }
-
- #[test]
- fn test_from_branch_alias_target_invalid() {
- // Must end with -dev
- assert!(ComposerVersion::from_branch_alias_target("dev-master").is_none());
- assert!(ComposerVersion::from_branch_alias_target("2.0.0").is_none());
- assert!(ComposerVersion::from_branch_alias_target("").is_none());
- }
-
- /// Test that a branch alias entry created from "dev-master" aliased to "2.x-dev"
- /// is contained in the ^2.0 constraint range.
- #[test]
- fn test_branch_alias_in_range() {
- // "2.x-dev" alias target → ComposerVersion { major: 2, stability: STABILITY_DEV }
- let aliased_cv = ComposerVersion::from_branch_alias_target("2.x-dev").unwrap();
- // ^2.0 → >=2.0.0.0-dev <3.0.0.0-dev
- let range = constraint_to_ranges("^2.0").unwrap();
- assert!(
- range.contains(&aliased_cv),
- "dev-master aliased to 2.x-dev should satisfy ^2.0"
- );
- }
-
- /// Test that a branch alias entry for "1.0.x-dev" satisfies a ^1.0 constraint.
- #[test]
- fn test_branch_alias_1_x_in_range() {
- let aliased_cv = ComposerVersion::from_branch_alias_target("1.0.x-dev").unwrap();
- let range = constraint_to_ranges("^1.0").unwrap();
- assert!(
- range.contains(&aliased_cv),
- "dev branch aliased to 1.0.x-dev should satisfy ^1.0"
- );
- // But should NOT satisfy ^2.0
- let range2 = constraint_to_ranges("^2.0").unwrap();
- assert!(
- !range2.contains(&aliased_cv),
- "1.0.x-dev alias should not satisfy ^2.0"
- );
- }
-
- // ──────────── End-to-end tests (require network, marked #[ignore]) ────────────
-
- #[test]
- #[ignore]
- fn test_resolve_monolog_e2e() {
- let request = ResolveRequest {
- require: vec![("monolog/monolog".to_string(), "^3.0".to_string())],
- require_dev: vec![],
- include_dev: false,
- minimum_stability: Stability::Stable,
- stability_flags: HashMap::new(),
- prefer_stable: true,
- prefer_lowest: false,
- platform: PlatformConfig::new(),
- ignore_platform_reqs: false,
- ignore_platform_req_list: vec![],
- repo_cache: None,
- };
-
- let result = resolve(&request);
- match result {
- Ok(packages) => {
- println!("Resolved {} packages:", packages.len());
- for pkg in &packages {
- println!(" {} {}", pkg.name, pkg.version);
- }
- assert!(!packages.is_empty());
- assert!(packages.iter().any(|p| p.name == "monolog/monolog"));
- }
- Err(e) => panic!("Resolution failed: {}", e),
- }
- }
-}
diff --git a/crates/mozart/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<char> = a.chars().collect();
- let b: Vec<char> = b.chars().collect();
-
- let m = a.len();
- let n = b.len();
-
- if m == 0 {
- return n;
- }
- if n == 0 {
- return m;
- }
-
- // Use two alternating rows to save memory.
- let mut prev: Vec<usize> = (0..=n).collect();
- let mut curr: Vec<usize> = vec![0; n + 1];
-
- for i in 1..=m {
- curr[0] = i;
- for j in 1..=n {
- let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
- curr[j] = (prev[j] + 1) // deletion
- .min(curr[j - 1] + 1) // insertion
- .min(prev[j - 1] + cost); // substitution
- }
- std::mem::swap(&mut prev, &mut curr);
- }
-
- prev[n]
-}
-
-/// Maximum edit distance for a suggestion to be considered "similar".
-///
-/// Packages with Levenshtein distance greater than this threshold are not
-/// returned as suggestions.
-const MAX_DISTANCE: usize = 5;
-
-/// Find package names from `candidates` that are similar to `query`.
-///
-/// Returns a list of `(distance, name)` pairs sorted by ascending distance,
-/// then ascending name for stability. Only candidates with a Levenshtein
-/// distance <= [`MAX_DISTANCE`] are returned.
-pub fn find_similar<'a>(
- query: &str,
- candidates: impl Iterator<Item = &'a str>,
-) -> Vec<(usize, &'a str)> {
- let query_lower = query.to_lowercase();
- let mut results: Vec<(usize, &'a str)> = candidates
- .filter_map(|name| {
- let dist = levenshtein(&query_lower, &name.to_lowercase());
- if dist <= MAX_DISTANCE && dist > 0 {
- Some((dist, name))
- } else {
- None
- }
- })
- .collect();
-
- results.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1)));
- results
-}
-
-/// Format a "Did you mean ...?" message from a list of suggestions.
-///
-/// Returns `None` when `suggestions` is empty.
-///
-/// # Examples
-///
-/// ```
-/// use mozart::suggest::format_did_you_mean;
-/// let msg = format_did_you_mean(&["psr/log", "psr/cache"]);
-/// assert!(msg.unwrap().contains("Did you mean"));
-/// ```
-pub fn format_did_you_mean(suggestions: &[&str]) -> Option<String> {
- if suggestions.is_empty() {
- return None;
- }
-
- let formatted = suggestions
- .iter()
- .map(|s| format!("\"{}\"", s))
- .collect::<Vec<_>>()
- .join(" or ");
-
- Some(format!("Did you mean {}?", formatted))
-}
-
-// ─── Tests ───────────────────────────────────────────────────────────────────
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- // ── levenshtein ───────────────────────────────────────────────────────────
-
- #[test]
- fn test_levenshtein_identical() {
- assert_eq!(levenshtein("psr/log", "psr/log"), 0);
- }
-
- #[test]
- fn test_levenshtein_empty_left() {
- assert_eq!(levenshtein("", "abc"), 3);
- }
-
- #[test]
- fn test_levenshtein_empty_right() {
- assert_eq!(levenshtein("abc", ""), 3);
- }
-
- #[test]
- fn test_levenshtein_both_empty() {
- assert_eq!(levenshtein("", ""), 0);
- }
-
- #[test]
- fn test_levenshtein_single_insertion() {
- assert_eq!(levenshtein("psr/log", "psr/logs"), 1);
- }
-
- #[test]
- fn test_levenshtein_single_deletion() {
- assert_eq!(levenshtein("psr/logs", "psr/log"), 1);
- }
-
- #[test]
- fn test_levenshtein_single_substitution() {
- assert_eq!(levenshtein("psr/log", "psr/lag"), 1);
- }
-
- #[test]
- fn test_levenshtein_completely_different() {
- assert_eq!(levenshtein("abc", "xyz"), 3);
- }
-
- #[test]
- fn test_levenshtein_package_names() {
- // "monolog/monolog" vs "monolong/monolog" — 1 insertion
- assert_eq!(levenshtein("monolog/monolog", "monolong/monolog"), 1);
- }
-
- // ── find_similar ──────────────────────────────────────────────────────────
-
- #[test]
- fn test_find_similar_returns_close_matches() {
- let candidates = ["psr/log", "psr/cache", "monolog/monolog", "symfony/console"];
- let results = find_similar("psr/lod", candidates.iter().copied());
- assert!(!results.is_empty());
- // "psr/log" has distance 1 from "psr/lod"
- assert_eq!(results[0].1, "psr/log");
- assert_eq!(results[0].0, 1);
- }
-
- #[test]
- fn test_find_similar_excludes_exact_match() {
- let candidates = ["psr/log", "psr/cache"];
- // Exact match should not appear (distance == 0)
- let results = find_similar("psr/log", candidates.iter().copied());
- assert!(!results.iter().any(|(_, name)| *name == "psr/log"));
- }
-
- #[test]
- fn test_find_similar_excludes_too_distant() {
- let candidates = ["completely/different", "another/package"];
- let results = find_similar("psr/log", candidates.iter().copied());
- // All candidates are more than MAX_DISTANCE away
- assert!(results.is_empty());
- }
-
- #[test]
- fn test_find_similar_sorted_by_distance() {
- let candidates = ["psr/log", "psr/logs", "psr/logsx"];
- // "psr/lod" -> "psr/log" distance 1, "psr/logs" distance 2, "psr/logsx" distance 3
- let results = find_similar("psr/lod", candidates.iter().copied());
- if results.len() >= 2 {
- assert!(results[0].0 <= results[1].0);
- }
- }
-
- #[test]
- fn test_find_similar_case_insensitive() {
- let candidates = ["PSR/Log"];
- let results = find_similar("psr/log", candidates.iter().copied());
- // "psr/log" vs "psr/log" (both lowercased) = distance 0, so excluded
- assert!(results.is_empty());
- }
-
- // ── format_did_you_mean ───────────────────────────────────────────────────
-
- #[test]
- fn test_format_did_you_mean_empty() {
- assert!(format_did_you_mean(&[]).is_none());
- }
-
- #[test]
- fn test_format_did_you_mean_single() {
- let msg = format_did_you_mean(&["psr/log"]).unwrap();
- assert_eq!(msg, "Did you mean \"psr/log\"?");
- }
-
- #[test]
- fn test_format_did_you_mean_multiple() {
- let msg = format_did_you_mean(&["psr/log", "psr/cache"]).unwrap();
- assert!(msg.contains("Did you mean"));
- assert!(msg.contains("\"psr/log\""));
- assert!(msg.contains("\"psr/cache\""));
- assert!(msg.contains(" or "));
- }
-}
diff --git a/crates/mozart/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<Regex> = LazyLock::new(|| {
- Regex::new(r"^[a-z0-9]([_.\-]?[a-z0-9]+)*/[a-z0-9](([_.]|\-{1,2})?[a-z0-9]+)*$").unwrap()
-});
-
-static AUTHOR_RE: LazyLock<Regex> = LazyLock::new(|| {
- Regex::new(r"^(?P<name>[- .,\pL\pN\pM''\x{201C}\x{201D}()]+)(?:\s+<(?P<email>.+?)>)?$").unwrap()
-});
-
-static AUTOLOAD_PATH_RE: LazyLock<Regex> =
- LazyLock::new(|| Regex::new(r"^[^/][A-Za-z0-9\-_/]+/$").unwrap());
-
-static CAMEL_SPLIT_RE: LazyLock<Regex> =
- LazyLock::new(|| Regex::new(r"(?:([a-z])([A-Z])|([A-Z])([A-Z][a-z]))").unwrap());
-
-static SANITIZE_EDGES_RE: LazyLock<Regex> =
- LazyLock::new(|| Regex::new(r"^[_.\-]+|[_.\-]+$|[^a-z0-9_.\-]").unwrap());
-
-static SANITIZE_REPEATS_RE: LazyLock<Regex> =
- LazyLock::new(|| Regex::new(r"([_.\-]){2,}").unwrap());
-
-static NON_ALNUM_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[^a-zA-Z0-9]").unwrap());
-
-const VALID_STABILITIES: &[&str] = &["dev", "alpha", "beta", "rc", "stable"];
-
-pub fn validate_package_name(name: &str) -> bool {
- PACKAGE_NAME_RE.is_match(name)
-}
-
-pub struct ParsedAuthor {
- pub name: String,
- pub email: Option<String>,
-}
-
-pub fn parse_author(input: &str) -> Result<ParsedAuthor, String> {
- if let Some(caps) = AUTHOR_RE.captures(input) {
- let name = caps.name("name").unwrap().as_str().trim().to_string();
- let email = caps.name("email").map(|m| m.as_str().to_string());
- Ok(ParsedAuthor { name, email })
- } else {
- Err(
- "Invalid author string. Must be in the formats: Jane Doe or John Smith <john@example.com>"
- .to_string(),
- )
- }
-}
-
-pub fn validate_stability(s: &str) -> bool {
- VALID_STABILITIES.contains(&s.to_lowercase().as_str())
-}
-
-pub fn validate_license(s: &str) -> bool {
- // TODO: check SPDX Identifier
- !s.is_empty()
-}
-
-pub fn validate_autoload_path(s: &str) -> bool {
- AUTOLOAD_PATH_RE.is_match(s)
-}
-
-pub fn namespace_from_package_name(package_name: &str) -> Option<String> {
- if package_name.is_empty() || !package_name.contains('/') {
- return None;
- }
-
- let parts: Vec<String> = package_name
- .split('/')
- .map(|part| {
- let replaced = NON_ALNUM_RE.replace_all(part, " ");
- let words: Vec<String> = replaced
- .split_whitespace()
- .map(|w| {
- let mut chars = w.chars();
- match chars.next() {
- Some(c) => c.to_uppercase().to_string() + &chars.collect::<String>(),
- None => String::new(),
- }
- })
- .collect();
- words.join("")
- })
- .collect();
-
- Some(parts.join("\\"))
-}
-
-pub fn sanitize_package_name_component(name: &str) -> String {
- // CamelCase → kebab-case
- let name = CAMEL_SPLIT_RE.replace_all(name, "${1}${3}-${2}${4}");
- let name = name.to_lowercase();
- // Remove leading/trailing separators and non-alnum chars
- let name = SANITIZE_EDGES_RE.replace_all(&name, "");
- // Collapse repeated separators
- let name = SANITIZE_REPEATS_RE.replace_all(&name, "$1");
- name.to_string()
-}
-
-pub fn parse_require_string(s: &str) -> Result<(String, String), String> {
- // Formats: "foo/bar:^1.0", "foo/bar=^1.0", "foo/bar ^1.0"
- let s = s.trim();
-
- for sep in [':', '=', ' '] {
- if let Some(pos) = s.find(sep) {
- let name = s[..pos].trim();
- let version = s[pos + sep.len_utf8()..].trim();
- if !name.is_empty() && !version.is_empty() {
- return Ok((name.to_string(), version.to_string()));
- }
- }
- }
-
- Err(format!(
- "Could not parse requirement \"{s}\". Expected format: vendor/package:version"
- ))
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn test_valid_package_names() {
- assert!(validate_package_name("vendor/package"));
- assert!(validate_package_name("my-vendor/my-package"));
- assert!(validate_package_name("vendor/pkg123"));
- assert!(validate_package_name("a/b"));
- assert!(validate_package_name("vendor/my_package"));
- assert!(validate_package_name("vendor/my.package"));
- assert!(validate_package_name("vendor/my--package"));
- }
-
- #[test]
- fn test_invalid_package_names() {
- assert!(!validate_package_name("novendor"));
- assert!(!validate_package_name("/package"));
- assert!(!validate_package_name("vendor/"));
- assert!(!validate_package_name("Vendor/Package"));
- assert!(!validate_package_name("vendor/pack age"));
- assert!(!validate_package_name(""));
- }
-
- #[test]
- fn test_parse_author_name_and_email() {
- let a = parse_author("John Smith <john@example.com>").unwrap();
- assert_eq!(a.name, "John Smith");
- assert_eq!(a.email.as_deref(), Some("john@example.com"));
- }
-
- #[test]
- fn test_parse_author_name_only() {
- let a = parse_author("Jane Doe").unwrap();
- assert_eq!(a.name, "Jane Doe");
- assert!(a.email.is_none());
- }
-
- #[test]
- fn test_parse_author_invalid() {
- assert!(parse_author("").is_err());
- }
-
- #[test]
- fn test_validate_stability() {
- assert!(validate_stability("dev"));
- assert!(validate_stability("alpha"));
- assert!(validate_stability("beta"));
- assert!(validate_stability("rc"));
- assert!(validate_stability("stable"));
- assert!(validate_stability("Dev"));
- assert!(validate_stability("STABLE"));
- assert!(!validate_stability("invalid"));
- assert!(!validate_stability(""));
- }
-
- #[test]
- fn test_validate_autoload_path() {
- assert!(validate_autoload_path("src/"));
- assert!(validate_autoload_path("lib/src/"));
- assert!(!validate_autoload_path("/src/"));
- assert!(!validate_autoload_path("src"));
- assert!(!validate_autoload_path(""));
- }
-
- #[test]
- fn test_namespace_from_package_name() {
- assert_eq!(
- namespace_from_package_name("acme/my-pkg"),
- Some("Acme\\MyPkg".to_string())
- );
- assert_eq!(
- namespace_from_package_name("new_projects.acme-extra/package-name"),
- Some("NewProjectsAcmeExtra\\PackageName".to_string())
- );
- assert_eq!(namespace_from_package_name(""), None);
- assert_eq!(namespace_from_package_name("novendor"), None);
- }
-
- #[test]
- fn test_sanitize_package_name_component() {
- assert_eq!(sanitize_package_name_component("MyPackage"), "my-package");
- assert_eq!(
- sanitize_package_name_component("CamelCaseTest"),
- "camel-case-test"
- );
- assert_eq!(sanitize_package_name_component("already-ok"), "already-ok");
- assert_eq!(sanitize_package_name_component("__bad__"), "bad");
- }
-
- #[test]
- fn test_parse_require_string() {
- let (name, ver) = parse_require_string("foo/bar:^1.0").unwrap();
- assert_eq!(name, "foo/bar");
- assert_eq!(ver, "^1.0");
-
- let (name, ver) = parse_require_string("foo/bar=^1.0").unwrap();
- assert_eq!(name, "foo/bar");
- assert_eq!(ver, "^1.0");
-
- let (name, ver) = parse_require_string("foo/bar ^1.0").unwrap();
- assert_eq!(name, "foo/bar");
- assert_eq!(ver, "^1.0");
-
- assert!(parse_require_string("invalid").is_err());
- }
-}
diff --git a/crates/mozart/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<u64>, Option<String>) {
- // Split off any pre-release suffix
- let (base, suffix) = if let Some(pos) = v.find('-') {
- (&v[..pos], Some(v[pos + 1..].to_string()))
- } else {
- (v, None)
- };
- let parts: Vec<u64> = base.split('.').filter_map(|p| p.parse().ok()).collect();
- (parts, suffix)
- };
-
- let (a_parts, a_suffix) = parse(a);
- let (b_parts, b_suffix) = parse(b);
-
- // Compare numeric parts
- let max_len = a_parts.len().max(b_parts.len());
- for i in 0..max_len {
- let a_val = a_parts.get(i).copied().unwrap_or(0);
- let b_val = b_parts.get(i).copied().unwrap_or(0);
- match a_val.cmp(&b_val) {
- Ordering::Equal => continue,
- other => return other,
- }
- }
-
- // If numeric parts are equal, compare stability
- // A stable version (no suffix) is greater than a pre-release
- match (&a_suffix, &b_suffix) {
- (None, None) => Ordering::Equal,
- (None, Some(_)) => Ordering::Greater, // stable > pre-release
- (Some(_), None) => Ordering::Less, // pre-release < stable
- (Some(a_s), Some(b_s)) => {
- let stab_a = stability_of(&format!("0.0.0.0-{a_s}"));
- let stab_b = stability_of(&format!("0.0.0.0-{b_s}"));
- // Lower stability value = more stable = greater version
- match stab_a.cmp(&stab_b) {
- Ordering::Equal => a_s.cmp(b_s),
- // Stability enum: Stable(0) < RC(5) < Beta(10) < Alpha(15) < Dev(20)
- // But more stable = higher version, so we reverse
- Ordering::Less => Ordering::Greater,
- Ordering::Greater => Ordering::Less,
- }
- }
- }
-}
-
-/// Find the best version candidate given a preferred minimum stability.
-///
-/// Returns the highest version whose stability is at least as stable as
-/// the preferred stability (i.e., stability value <= preferred value).
-pub fn find_best_candidate(
- versions: &[PackagistVersion],
- preferred_stability: Stability,
-) -> Option<&PackagistVersion> {
- versions
- .iter()
- .filter(|v| stability_of(&v.version_normalized) <= preferred_stability)
- .max_by(|a, b| compare_normalized_versions(&a.version_normalized, &b.version_normalized))
-}
-
-/// Generate a recommended version constraint string from a concrete version.
-///
-/// Examples:
-/// - `"1.2.1"` (stable) → `"^1.2"`
-/// - `"0.3.5"` (stable) → `"^0.3"`
-/// - `"2.0.0-beta.1"` (beta) → `"^2.0@beta"`
-/// - `"dev-master"` (dev) → `"dev-master"`
-pub fn find_recommended_require_version(
- version: &str,
- version_normalized: &str,
- stability: Stability,
-) -> String {
- // dev branches are returned as-is
- if stability == Stability::Dev {
- return version.to_string();
- }
-
- // Extract major.minor from the normalized version (e.g. "1.2.3.0" → "1.2")
- let base = if let Some(pos) = version_normalized.find('-') {
- &version_normalized[..pos]
- } else {
- version_normalized
- };
-
- let parts: Vec<&str> = base.split('.').collect();
- let major = parts.first().copied().unwrap_or("0");
- let minor = parts.get(1).copied().unwrap_or("0");
-
- let constraint = format!("^{major}.{minor}");
-
- match stability {
- Stability::Stable => constraint,
- Stability::RC => format!("{constraint}@RC"),
- Stability::Beta => format!("{constraint}@beta"),
- Stability::Alpha => format!("{constraint}@alpha"),
- Stability::Dev => unreachable!(),
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn test_stability_of() {
- assert_eq!(stability_of("1.0.0.0"), Stability::Stable);
- assert_eq!(stability_of("2.3.1.0"), Stability::Stable);
- assert_eq!(stability_of("1.0.0.0-alpha1"), Stability::Alpha);
- assert_eq!(stability_of("1.0.0.0-beta2"), Stability::Beta);
- assert_eq!(stability_of("1.0.0.0-RC1"), Stability::RC);
- assert_eq!(stability_of("dev-master"), Stability::Dev);
- assert_eq!(stability_of("dev-feature/foo"), Stability::Dev);
- assert_eq!(stability_of("1.0.0.0-dev"), Stability::Dev);
- }
-
- #[test]
- fn test_compare_normalized_versions() {
- assert_eq!(
- compare_normalized_versions("1.0.0.0", "1.0.0.0"),
- Ordering::Equal
- );
- assert_eq!(
- compare_normalized_versions("2.0.0.0", "1.0.0.0"),
- Ordering::Greater
- );
- assert_eq!(
- compare_normalized_versions("1.0.0.0", "2.0.0.0"),
- Ordering::Less
- );
- assert_eq!(
- compare_normalized_versions("1.2.0.0", "1.1.0.0"),
- Ordering::Greater
- );
- assert_eq!(
- compare_normalized_versions("1.0.0.0", "1.0.0.0-beta1"),
- Ordering::Greater
- );
- assert_eq!(
- compare_normalized_versions("1.0.0.0-RC1", "1.0.0.0-beta1"),
- Ordering::Greater
- );
- }
-
- fn make_pv(version: &str, version_normalized: &str) -> PackagistVersion {
- PackagistVersion {
- version: version.to_string(),
- version_normalized: version_normalized.to_string(),
- require: Default::default(),
- replace: Default::default(),
- provide: Default::default(),
- conflict: Default::default(),
- dist: None,
- source: None,
- require_dev: Default::default(),
- suggest: None,
- package_type: None,
- autoload: None,
- autoload_dev: None,
- license: None,
- description: None,
- homepage: None,
- keywords: None,
- authors: None,
- support: None,
- funding: None,
- time: None,
- extra: None,
- notification_url: None,
- }
- }
-
- #[test]
- fn test_find_best_candidate_stable() {
- let versions = vec![
- make_pv("dev-master", "dev-master"),
- make_pv("2.0.0-beta.1", "2.0.0.0-beta1"),
- make_pv("1.5.0", "1.5.0.0"),
- make_pv("1.4.0", "1.4.0.0"),
- ];
-
- let best = find_best_candidate(&versions, Stability::Stable).unwrap();
- assert_eq!(best.version, "1.5.0");
- }
-
- #[test]
- fn test_find_best_candidate_beta() {
- let versions = vec![
- make_pv("dev-master", "dev-master"),
- make_pv("2.0.0-beta.1", "2.0.0.0-beta1"),
- make_pv("1.5.0", "1.5.0.0"),
- ];
-
- let best = find_best_candidate(&versions, Stability::Beta).unwrap();
- assert_eq!(best.version, "2.0.0-beta.1");
- }
-
- #[test]
- fn test_find_best_candidate_no_match() {
- let versions = vec![make_pv("dev-master", "dev-master")];
-
- let best = find_best_candidate(&versions, Stability::Stable);
- assert!(best.is_none());
- }
-
- #[test]
- fn test_find_recommended_require_version() {
- // Stable
- assert_eq!(
- find_recommended_require_version("1.2.1", "1.2.1.0", Stability::Stable),
- "^1.2"
- );
- assert_eq!(
- find_recommended_require_version("0.3.5", "0.3.5.0", Stability::Stable),
- "^0.3"
- );
-
- // Beta
- assert_eq!(
- find_recommended_require_version("2.0.0-beta.1", "2.0.0.0-beta1", Stability::Beta),
- "^2.0@beta"
- );
-
- // RC
- assert_eq!(
- find_recommended_require_version("3.0.0-RC1", "3.0.0.0-RC1", Stability::RC),
- "^3.0@RC"
- );
-
- // Dev
- assert_eq!(
- find_recommended_require_version("dev-master", "dev-master", Stability::Dev),
- "dev-master"
- );
- }
-}
diff --git a/crates/mozart/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<String> {
- let constraint = constraint_str.trim();
-
- // Strip and preserve stability flag (@dev, @beta, etc.)
- let (constraint_body, stability_flag) = strip_stability_flag(constraint);
-
- // Dev constraints (dev-master, dev-main, etc.) are left unchanged
- if constraint_body.trim().starts_with("dev-") {
- return None;
- }
-
- // Skip dev installed versions that have no alias
- // An alias looks like "dev-master as 1.0.0" — the version string in the lock
- // would be "dev-master" without " as ".
- if pretty_version.starts_with("dev-") && !pretty_version.contains(" as ") {
- return None;
- }
- if let Some(norm) = version_normalized
- && norm.starts_with("dev-")
- && !pretty_version.contains(" as ")
- {
- return None;
- }
-
- // Resolve the actual version string to use for bumping.
- // If the pretty_version contains an inline alias (e.g. "dev-master as 1.0.0"),
- // take the alias target. Otherwise use pretty_version directly.
- let installed_version = resolve_installed_version(pretty_version, version_normalized);
-
- // Handle OR constraints (^1.0 || ^2.0)
- if constraint_body.contains("||") {
- return bump_or_constraint(constraint_body, &installed_version, stability_flag);
- }
-
- // Single constraint
- bump_single(constraint_body.trim(), &installed_version, stability_flag)
-}
-
-// ─── OR constraint handling ───────────────────────────────────────────────────
-
-fn bump_or_constraint(
- constraint_body: &str,
- installed_version: &str,
- stability_flag: Option<&str>,
-) -> Option<String> {
- let parts: Vec<&str> = constraint_body.split("||").map(str::trim).collect();
-
- // Determine which major the installed version belongs to
- let installed_major = parse_major(installed_version);
-
- let mut changed = false;
- let mut new_parts: Vec<String> = Vec::new();
-
- for part in &parts {
- let part_trimmed = part.trim();
- // Determine the major range this disjunct covers
- let part_major = constraint_major(part_trimmed);
-
- // Only bump the disjunct whose major matches the installed version's major
- if part_major == installed_major {
- if let Some(bumped) = bump_single(part_trimmed, installed_version, None) {
- new_parts.push(bumped);
- changed = true;
- } else {
- new_parts.push(part_trimmed.to_string());
- }
- } else {
- new_parts.push(part_trimmed.to_string());
- }
- }
-
- if !changed {
- return None;
- }
-
- let joined = new_parts.join(" || ");
- let result = append_stability_flag(&joined, stability_flag);
- Some(result)
-}
-
-// ─── Single constraint handling ───────────────────────────────────────────────
-
-fn bump_single(
- constraint: &str,
- installed_version: &str,
- stability_flag: Option<&str>,
-) -> Option<String> {
- // AND constraints (space-separated multiple operators like ">=1.0 <2.0" or
- // comma-separated like ">=1.0,<2.0") are not supported for bumping — leave unchanged.
- // We detect them by checking for a space or comma after the version spec begins.
- // Quick check: if the constraint contains a space (ignoring leading operators),
- // it's likely a multi-part AND constraint.
- let after_op = constraint
- .trim_start_matches('^')
- .trim_start_matches('~')
- .trim_start_matches(">=")
- .trim_start_matches("<=")
- .trim_start_matches("!=")
- .trim_start_matches('>')
- .trim_start_matches('<')
- .trim_start_matches('=');
- if after_op.contains(' ') || after_op.contains(',') {
- return None;
- }
-
- // Caret: ^X.Y.Z
- if let Some(rest) = constraint.strip_prefix('^') {
- return bump_caret(rest.trim(), installed_version, stability_flag);
- }
-
- // Tilde: ~X.Y.Z
- if let Some(rest) = constraint.strip_prefix('~') {
- return bump_tilde(rest.trim(), installed_version, stability_flag);
- }
-
- // Wildcard: * or X.*
- if constraint == "*" || constraint.ends_with(".*") {
- return bump_wildcard(constraint, installed_version, stability_flag);
- }
-
- // Greater-or-equal: >=X.Y
- if let Some(rest) = constraint.strip_prefix(">=") {
- return bump_gte(rest.trim(), installed_version, stability_flag);
- }
-
- // Other operators (exact, <, <=, >, !=, range) — leave unchanged
- None
-}
-
-// ─── Caret bump ───────────────────────────────────────────────────────────────
-
-/// `^X.Y.Z` → bump to installed version if it is greater.
-///
-/// The caret prefix is preserved; segments from installed version replace
-/// those in the constraint (trimming trailing zeros appropriately).
-fn bump_caret(rest: &str, installed_version: &str, stability_flag: Option<&str>) -> Option<String> {
- let constraint_segments = parse_version_segments(rest);
- let installed_segments = parse_version_segments(installed_version);
-
- // The constraint length determines how many segments to compare/output
- let n_constraint = constraint_segments.len().max(1);
-
- // Compare: if installed <= current lower bound, no change needed
- // We compare as many segments as the installed version has
- let current_lower: Vec<u64> = constraint_segments
- .iter()
- .copied()
- .chain(std::iter::repeat(0))
- .take(4)
- .collect();
- let installed: Vec<u64> = installed_segments
- .iter()
- .copied()
- .chain(std::iter::repeat(0))
- .take(4)
- .collect();
-
- if installed <= current_lower {
- return None;
- }
-
- // Build new constraint segments: use installed version, but only up to
- // the number of non-trivial segments needed.
- // We output at least as many segments as the original constraint had,
- // but trim trailing zeros.
- let mut new_segs: Vec<u64> = installed_segments
- .iter()
- .copied()
- .chain(std::iter::repeat(0))
- .take(n_constraint.max(installed_segments.len()))
- .collect();
-
- // Trim trailing zeros (but keep at least n_constraint segments, minimum 1)
- while new_segs.len() > n_constraint && new_segs.last() == Some(&0) {
- new_segs.pop();
- }
- // Also trim trailing zeros beyond 1 segment
- while new_segs.len() > 1 && new_segs.last() == Some(&0) {
- new_segs.pop();
- }
-
- let version_str = new_segs
- .iter()
- .map(|n| n.to_string())
- .collect::<Vec<_>>()
- .join(".");
-
- let new_constraint = format!("^{version_str}");
- let result = append_stability_flag(&new_constraint, stability_flag);
- Some(result)
-}
-
-// ─── Tilde bump ───────────────────────────────────────────────────────────────
-
-/// `~X.Y.Z` (3 segments) → bump patch: `~X.Y.new_patch`
-/// `~X.Y` (2 segments) → convert to caret: `^X.Y.new_patch`
-fn bump_tilde(rest: &str, installed_version: &str, stability_flag: Option<&str>) -> Option<String> {
- let constraint_segments = parse_version_segments(rest);
- let installed_segments = parse_version_segments(installed_version);
-
- let current_lower: Vec<u64> = constraint_segments
- .iter()
- .copied()
- .chain(std::iter::repeat(0))
- .take(4)
- .collect();
- let installed: Vec<u64> = installed_segments
- .iter()
- .copied()
- .chain(std::iter::repeat(0))
- .take(4)
- .collect();
-
- if installed <= current_lower {
- return None;
- }
-
- let major = installed_segments.first().copied().unwrap_or(0);
- let minor = installed_segments.get(1).copied().unwrap_or(0);
- let patch = installed_segments.get(2).copied().unwrap_or(0);
-
- let new_constraint = if constraint_segments.len() >= 3 {
- // ~X.Y.Z → keep tilde, bump patch
- if patch == 0 {
- format!("~{major}.{minor}.0")
- } else {
- format!("~{major}.{minor}.{patch}")
- }
- } else {
- // ~X.Y → convert to caret
- if patch == 0 {
- format!("^{major}.{minor}")
- } else {
- format!("^{major}.{minor}.{patch}")
- }
- };
-
- let result = append_stability_flag(&new_constraint, stability_flag);
- Some(result)
-}
-
-// ─── Wildcard bump ────────────────────────────────────────────────────────────
-
-/// `*` → `>=installed`
-/// `X.*` → `>=installed` (trimming trailing zeros)
-fn bump_wildcard(
- constraint: &str,
- installed_version: &str,
- stability_flag: Option<&str>,
-) -> Option<String> {
- let installed_segments = parse_version_segments(installed_version);
-
- // Trim trailing zeros
- let mut segs = installed_segments.clone();
- while segs.len() > 1 && segs.last() == Some(&0) {
- segs.pop();
- }
-
- let version_str = segs
- .iter()
- .map(|n| n.to_string())
- .collect::<Vec<_>>()
- .join(".");
-
- // For plain wildcard "*", always produce >=installed
- if constraint == "*" {
- let new_constraint = format!(">={version_str}");
- return Some(append_stability_flag(&new_constraint, stability_flag));
- }
-
- // For "X.*", if installed is at that major, produce >=installed
- let base = constraint.trim_end_matches(".*");
- let base_segs = parse_version_segments(base);
- let current_lower: Vec<u64> = base_segs
- .iter()
- .copied()
- .chain(std::iter::repeat(0))
- .take(4)
- .collect();
- let installed: Vec<u64> = installed_segments
- .iter()
- .copied()
- .chain(std::iter::repeat(0))
- .take(4)
- .collect();
-
- if installed <= current_lower {
- return None;
- }
-
- let new_constraint = format!(">={version_str}");
- Some(append_stability_flag(&new_constraint, stability_flag))
-}
-
-// ─── GTE bump ─────────────────────────────────────────────────────────────────
-
-/// `>=X.Y` → raise to installed version (trimming trailing zeros)
-fn bump_gte(rest: &str, installed_version: &str, stability_flag: Option<&str>) -> Option<String> {
- let constraint_segments = parse_version_segments(rest);
- let installed_segments = parse_version_segments(installed_version);
-
- let current_lower: Vec<u64> = constraint_segments
- .iter()
- .copied()
- .chain(std::iter::repeat(0))
- .take(4)
- .collect();
- let installed: Vec<u64> = installed_segments
- .iter()
- .copied()
- .chain(std::iter::repeat(0))
- .take(4)
- .collect();
-
- if installed <= current_lower {
- return None;
- }
-
- // Trim trailing zeros from installed version
- let mut segs = installed_segments.clone();
- while segs.len() > 1 && segs.last() == Some(&0) {
- segs.pop();
- }
-
- let version_str = segs
- .iter()
- .map(|n| n.to_string())
- .collect::<Vec<_>>()
- .join(".");
-
- let new_constraint = format!(">={version_str}");
- let result = append_stability_flag(&new_constraint, stability_flag);
- Some(result)
-}
-
-// ─── Helpers ──────────────────────────────────────────────────────────────────
-
-/// Strip a trailing `@stability` flag from a constraint string.
-/// Returns (body, flag) where flag is the `@...` suffix (without the `@`).
-fn strip_stability_flag(constraint: &str) -> (&str, Option<&str>) {
- let known = ["@dev", "@alpha", "@beta", "@RC", "@rc", "@stable"];
- for flag in &known {
- if let Some(body) = constraint.strip_suffix(flag) {
- let flag_str = &constraint[body.len()..];
- return (body.trim_end(), Some(flag_str));
- }
- }
- (constraint, None)
-}
-
-/// Append an optional stability flag to a constraint string.
-fn append_stability_flag(constraint: &str, flag: Option<&str>) -> String {
- match flag {
- Some(f) => format!("{constraint}{f}"),
- None => constraint.to_string(),
- }
-}
-
-/// Parse a version string into numeric segments.
-/// Handles "1.2.3", "1.2", "1", etc.
-/// Stops at any non-numeric/non-dot character.
-fn parse_version_segments(version: &str) -> Vec<u64> {
- // Strip inline alias: "dev-master as 1.0.0" → "1.0.0"
- let version = if let Some(pos) = version.find(" as ") {
- &version[pos + 4..]
- } else {
- version
- };
-
- // Strip leading v/V
- let version = version
- .strip_prefix('v')
- .or_else(|| version.strip_prefix('V'))
- .unwrap_or(version);
-
- // Take up to any pre-release suffix (first '-' or '+')
- let version = version.split(['-', '+']).next().unwrap_or(version);
-
- version
- .split('.')
- .filter_map(|s| s.parse::<u64>().ok())
- .collect()
-}
-
-/// Parse the major version number from a version string.
-fn parse_major(version: &str) -> Option<u64> {
- parse_version_segments(version).into_iter().next()
-}
-
-/// Determine the major version that a single disjunct constraint covers.
-/// For `^1.2`, returns `Some(1)`. For `^0.3`, returns `Some(0)`.
-fn constraint_major(constraint: &str) -> Option<u64> {
- if let Some(rest) = constraint.strip_prefix('^') {
- return parse_version_segments(rest).into_iter().next();
- }
- if let Some(rest) = constraint.strip_prefix('~') {
- return parse_version_segments(rest).into_iter().next();
- }
- if let Some(rest) = constraint.strip_prefix(">=") {
- return parse_version_segments(rest).into_iter().next();
- }
- // Try as plain version
- parse_version_segments(constraint).into_iter().next()
-}
-
-/// Resolve the installed version string to use for comparison.
-/// Handles inline aliases (e.g., "dev-main as 2.1.0" → "2.1.0").
-fn resolve_installed_version<'a>(
- pretty_version: &'a str,
- _version_normalized: Option<&'a str>,
-) -> String {
- // If pretty_version contains an inline alias, use the alias target
- if let Some(pos) = pretty_version.find(" as ") {
- return pretty_version[pos + 4..].trim().to_string();
- }
-
- // If version_normalized is available and not a dev branch, prefer it
- // for more precise comparison, but use pretty_version for output
- // Actually we use pretty_version for building constraint strings
- // since normalized versions have extra .0 suffixes
-
- // Use pretty_version as-is (strip leading 'v' for normalization)
- pretty_version
- .strip_prefix('v')
- .unwrap_or(pretty_version)
- .to_string()
-}
-
-// ─── Tests ────────────────────────────────────────────────────────────────────
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- // ── Caret bumps ───────────────────────────────────────────────────────────
-
- #[test]
- fn test_caret_bump_basic() {
- // ^1.0 + 1.2.1 → ^1.2.1
- let result = bump_requirement("^1.0", "1.2.1", Some("1.2.1.0"));
- assert_eq!(result, Some("^1.2.1".to_string()));
- }
-
- #[test]
- fn test_caret_no_change_at_lower_bound() {
- // ^1.2 + 1.2.0 → None (already at lower bound)
- let result = bump_requirement("^1.2", "1.2.0", Some("1.2.0.0"));
- assert_eq!(result, None);
- }
-
- #[test]
- fn test_caret_no_change_exact_match() {
- // ^1.2.1 + 1.2.1 → None
- let result = bump_requirement("^1.2.1", "1.2.1", Some("1.2.1.0"));
- assert_eq!(result, None);
- }
-
- #[test]
- fn test_caret_bump_zero_major() {
- // ^0.3 + 0.3.5 → ^0.3.5
- let result = bump_requirement("^0.3", "0.3.5", Some("0.3.5.0"));
- assert_eq!(result, Some("^0.3.5".to_string()));
- }
-
- #[test]
- fn test_caret_bump_three_segments() {
- // ^1.0.0 + 1.2.1 → ^1.2.1
- let result = bump_requirement("^1.0.0", "1.2.1", Some("1.2.1.0"));
- assert_eq!(result, Some("^1.2.1".to_string()));
- }
-
- #[test]
- fn test_caret_bump_minor_only() {
- // ^1.2 + 1.5.0 → ^1.5 (trailing zero trimmed)
- let result = bump_requirement("^1.2", "1.5.0", Some("1.5.0.0"));
- assert_eq!(result, Some("^1.5".to_string()));
- }
-
- // ── Tilde bumps ───────────────────────────────────────────────────────────
-
- #[test]
- fn test_tilde_three_segment_bump() {
- // ~2.0.0 + 2.0.3 → ~2.0.3
- let result = bump_requirement("~2.0.0", "2.0.3", Some("2.0.3.0"));
- assert_eq!(result, Some("~2.0.3".to_string()));
- }
-
- #[test]
- fn test_tilde_two_segment_becomes_caret() {
- // ~2.0 + 2.0.3 → ^2.0.3
- let result = bump_requirement("~2.0", "2.0.3", Some("2.0.3.0"));
- assert_eq!(result, Some("^2.0.3".to_string()));
- }
-
- #[test]
- fn test_tilde_no_change() {
- // ~2.0.3 + 2.0.3 → None
- let result = bump_requirement("~2.0.3", "2.0.3", Some("2.0.3.0"));
- assert_eq!(result, None);
- }
-
- #[test]
- fn test_tilde_two_segment_no_patch() {
- // ~2.3 + 2.5.0 → ^2.5 (patch is 0, trimmed)
- let result = bump_requirement("~2.3", "2.5.0", Some("2.5.0.0"));
- assert_eq!(result, Some("^2.5".to_string()));
- }
-
- // ── Wildcard bumps ────────────────────────────────────────────────────────
-
- #[test]
- fn test_wildcard_star() {
- // * + 1.2.3 → >=1.2.3
- let result = bump_requirement("*", "1.2.3", Some("1.2.3.0"));
- assert_eq!(result, Some(">=1.2.3".to_string()));
- }
-
- #[test]
- fn test_wildcard_major_star() {
- // 2.* + 2.5.0 → >=2.5
- let result = bump_requirement("2.*", "2.5.0", Some("2.5.0.0"));
- assert_eq!(result, Some(">=2.5".to_string()));
- }
-
- #[test]
- fn test_wildcard_no_change() {
- // 2.* + 2.0.0 → None (installed is at lower bound)
- let result = bump_requirement("2.*", "2.0.0", Some("2.0.0.0"));
- assert_eq!(result, None);
- }
-
- // ── GTE bumps ─────────────────────────────────────────────────────────────
-
- #[test]
- fn test_gte_bump() {
- // >=1.2 + 1.5.0 → >=1.5
- let result = bump_requirement(">=1.2", "1.5.0", Some("1.5.0.0"));
- assert_eq!(result, Some(">=1.5".to_string()));
- }
-
- #[test]
- fn test_gte_no_change() {
- // >=1.5 + 1.5.0 → None
- let result = bump_requirement(">=1.5", "1.5.0", Some("1.5.0.0"));
- assert_eq!(result, None);
- }
-
- #[test]
- fn test_gte_with_patch() {
- // >=1.2.0 + 1.5.3 → >=1.5.3
- let result = bump_requirement(">=1.2.0", "1.5.3", Some("1.5.3.0"));
- assert_eq!(result, Some(">=1.5.3".to_string()));
- }
-
- // ── OR constraints ────────────────────────────────────────────────────────
-
- #[test]
- fn test_or_constraint_bumps_matching_major() {
- // ^1.2 || ^2.3 + 1.3.0 → ^1.3 || ^2.3
- let result = bump_requirement("^1.2 || ^2.3", "1.3.0", Some("1.3.0.0"));
- assert_eq!(result, Some("^1.3 || ^2.3".to_string()));
- }
-
- #[test]
- fn test_or_constraint_bumps_second_major() {
- // ^1.2 || ^2.3 + 2.5.0 → ^1.2 || ^2.5
- let result = bump_requirement("^1.2 || ^2.3", "2.5.0", Some("2.5.0.0"));
- assert_eq!(result, Some("^1.2 || ^2.5".to_string()));
- }
-
- #[test]
- fn test_or_constraint_no_change() {
- // ^1.2 || ^2.3 + 1.2.0 → None
- let result = bump_requirement("^1.2 || ^2.3", "1.2.0", Some("1.2.0.0"));
- assert_eq!(result, None);
- }
-
- // ── Dev constraints ───────────────────────────────────────────────────────
-
- #[test]
- fn test_dev_constraint_unchanged() {
- // dev-master → None
- let result = bump_requirement("dev-master", "dev-master", None);
- assert_eq!(result, None);
- }
-
- #[test]
- fn test_dev_installed_no_alias_unchanged() {
- // Installed is dev-main without alias → None
- let result = bump_requirement("^1.0", "dev-main", None);
- assert_eq!(result, None);
- }
-
- #[test]
- fn test_dev_installed_with_alias() {
- // Installed is "dev-main as 1.2.0" → bump based on alias
- let result = bump_requirement("^1.0", "dev-main as 1.2.0", None);
- assert_eq!(result, Some("^1.2".to_string()));
- }
-
- // ── Stability flags ───────────────────────────────────────────────────────
-
- #[test]
- fn test_stability_flag_preserved() {
- // ^1.0@dev + 1.2.0 → ^1.2@dev
- let result = bump_requirement("^1.0@dev", "1.2.0", Some("1.2.0.0"));
- assert_eq!(result, Some("^1.2@dev".to_string()));
- }
-
- #[test]
- fn test_stability_flag_beta_preserved() {
- // ^1.0@beta + 1.2.1 → ^1.2.1@beta
- let result = bump_requirement("^1.0@beta", "1.2.1", Some("1.2.1.0"));
- assert_eq!(result, Some("^1.2.1@beta".to_string()));
- }
-
- // ── Edge cases ────────────────────────────────────────────────────────────
-
- #[test]
- fn test_exact_constraint_no_bump() {
- // 1.2.3 → None (exact version, not bumped)
- let result = bump_requirement("1.2.3", "1.3.0", Some("1.3.0.0"));
- assert_eq!(result, None);
- }
-
- #[test]
- fn test_complex_range_no_bump() {
- // >=1.0 <2.0 → None (complex range, not bumped)
- let result = bump_requirement(">=1.0 <2.0", "1.5.0", Some("1.5.0.0"));
- assert_eq!(result, None);
- }
-
- #[test]
- fn test_parse_version_segments_basic() {
- assert_eq!(parse_version_segments("1.2.3"), vec![1, 2, 3]);
- assert_eq!(parse_version_segments("1.2"), vec![1, 2]);
- assert_eq!(parse_version_segments("1"), vec![1]);
- }
-
- #[test]
- fn test_parse_version_segments_with_prerelease() {
- assert_eq!(parse_version_segments("1.2.3-beta1"), vec![1, 2, 3]);
- }
-
- #[test]
- fn test_parse_version_segments_with_v_prefix() {
- assert_eq!(parse_version_segments("v1.2.3"), vec![1, 2, 3]);
- }
-
- #[test]
- fn test_parse_version_segments_alias() {
- // "dev-master as 1.0.0" → segments of "1.0.0"
- assert_eq!(parse_version_segments("dev-master as 1.0.0"), vec![1, 0, 0]);
- }
-}