From 8cc1ba8a02c0318b65658f1634de378c780392b9 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 10 May 2026 00:32:08 +0900 Subject: refactor(workspace): consolidate crates into mozart-core Merged mozart-archiver, mozart-autoload, mozart-registry, mozart-sat-resolver, and mozart-vcs into mozart-core to align the source layout with Composer's structure. Co-Authored-By: Claude Sonnet 4.6 --- crates/mozart-archiver/Cargo.toml | 18 - crates/mozart-archiver/src/lib.rs | 899 --------- crates/mozart-archiver/src/manager.rs | 300 --- crates/mozart-autoload/Cargo.toml | 17 - crates/mozart-autoload/src/autoload.rs | 1597 --------------- crates/mozart-autoload/src/dump.rs | 340 ---- crates/mozart-autoload/src/lib.rs | 4 - crates/mozart-core/Cargo.toml | 16 + crates/mozart-core/src/autoload.rs | 1912 ++++++++++++++++++ crates/mozart-core/src/dependency_resolver.rs | 25 + .../src/dependency_resolver/decisions.rs | 263 +++ .../mozart-core/src/dependency_resolver/error.rs | 50 + .../mozart-core/src/dependency_resolver/policy.rs | 264 +++ crates/mozart-core/src/dependency_resolver/pool.rs | 427 ++++ .../src/dependency_resolver/pool_builder.rs | 222 +++ .../mozart-core/src/dependency_resolver/problem.rs | 499 +++++ .../mozart-core/src/dependency_resolver/request.rs | 65 + crates/mozart-core/src/dependency_resolver/rule.rs | 280 +++ .../src/dependency_resolver/rule_set.rs | 211 ++ .../src/dependency_resolver/rule_set_generator.rs | 464 +++++ .../src/dependency_resolver/rule_watch_graph.rs | 288 +++ .../mozart-core/src/dependency_resolver/solver.rs | 1008 ++++++++++ .../src/dependency_resolver/transaction.rs | 568 ++++++ crates/mozart-core/src/lib.rs | 4 + crates/mozart-core/src/package.rs | 2 + crates/mozart-core/src/package/archiver.rs | 899 +++++++++ crates/mozart-core/src/package/archiver/manager.rs | 299 +++ crates/mozart-core/src/repository.rs | 19 + crates/mozart-core/src/repository/advisory.rs | 731 +++++++ crates/mozart-core/src/repository/browse_repos.rs | 293 +++ crates/mozart-core/src/repository/cache.rs | 575 ++++++ crates/mozart-core/src/repository/composer_repo.rs | 173 ++ .../mozart-core/src/repository/download_manager.rs | 143 ++ crates/mozart-core/src/repository/downloader.rs | 500 +++++ .../mozart-core/src/repository/inline_package.rs | 277 +++ crates/mozart-core/src/repository/installed.rs | 383 ++++ .../repository/installer_executor/filesystem.rs | 230 +++ .../src/repository/installer_executor/mod.rs | 348 ++++ .../installer_executor/trace_recorder.rs | 160 ++ .../repository/installer_executor/transaction.rs | 412 ++++ crates/mozart-core/src/repository/lockfile.rs | 2040 ++++++++++++++++++++ crates/mozart-core/src/repository/packagist.rs | 1011 ++++++++++ .../mozart-core/src/repository/path_repository.rs | 243 +++ .../repository/repository/inline_package_repo.rs | 63 + .../mozart-core/src/repository/repository/mod.rs | 319 +++ .../src/repository/repository/packagist_repo.rs | 121 ++ .../src/repository/repository/vcs_repo.rs | 63 + .../src/repository/repository_filter.rs | 136 ++ crates/mozart-core/src/repository/resolver.rs | 1998 +++++++++++++++++++ crates/mozart-core/src/repository/vcs_bridge.rs | 216 +++ crates/mozart-core/src/repository/version.rs | 269 +++ .../mozart-core/src/repository/version_selector.rs | 48 + crates/mozart-core/src/vcs.rs | 6 + crates/mozart-core/src/vcs/downloader/git.rs | 271 +++ crates/mozart-core/src/vcs/downloader/hg.rs | 84 + crates/mozart-core/src/vcs/downloader/mod.rs | 56 + crates/mozart-core/src/vcs/downloader/svn.rs | 84 + crates/mozart-core/src/vcs/driver/bitbucket.rs | 277 +++ crates/mozart-core/src/vcs/driver/forgejo.rs | 285 +++ crates/mozart-core/src/vcs/driver/git.rs | 275 +++ crates/mozart-core/src/vcs/driver/github.rs | 315 +++ crates/mozart-core/src/vcs/driver/gitlab.rs | 301 +++ crates/mozart-core/src/vcs/driver/hg.rs | 202 ++ crates/mozart-core/src/vcs/driver/mod.rs | 309 +++ crates/mozart-core/src/vcs/driver/svn.rs | 214 ++ crates/mozart-core/src/vcs/process.rs | 142 ++ crates/mozart-core/src/vcs/repository.rs | 205 ++ crates/mozart-core/src/vcs/util/git.rs | 312 +++ crates/mozart-core/src/vcs/util/hg.rs | 28 + crates/mozart-core/src/vcs/util/mod.rs | 3 + crates/mozart-core/src/vcs/util/svn.rs | 89 + crates/mozart-core/src/vcs/version_guesser.rs | 602 ++++++ crates/mozart-core/tests/git_driver_test.rs | 335 ++++ crates/mozart-registry/Cargo.toml | 31 - crates/mozart-registry/src/advisory.rs | 733 ------- crates/mozart-registry/src/browse_repos.rs | 293 --- crates/mozart-registry/src/cache.rs | 575 ------ crates/mozart-registry/src/composer_repo.rs | 173 -- crates/mozart-registry/src/download_manager.rs | 143 -- crates/mozart-registry/src/downloader.rs | 500 ----- crates/mozart-registry/src/inline_package.rs | 277 --- crates/mozart-registry/src/installed.rs | 383 ---- .../src/installer_executor/filesystem.rs | 232 --- .../mozart-registry/src/installer_executor/mod.rs | 348 ---- .../src/installer_executor/trace_recorder.rs | 160 -- .../src/installer_executor/transaction.rs | 411 ---- crates/mozart-registry/src/lib.rs | 18 - crates/mozart-registry/src/lockfile.rs | 2037 ------------------- crates/mozart-registry/src/packagist.rs | 1011 ---------- crates/mozart-registry/src/path_repository.rs | 243 --- .../src/repository/inline_package_repo.rs | 63 - crates/mozart-registry/src/repository/mod.rs | 319 --- .../src/repository/packagist_repo.rs | 121 -- crates/mozart-registry/src/repository/vcs_repo.rs | 63 - crates/mozart-registry/src/repository_filter.rs | 136 -- crates/mozart-registry/src/resolver.rs | 1999 ------------------- crates/mozart-registry/src/vcs_bridge.rs | 218 --- crates/mozart-registry/src/version.rs | 269 --- crates/mozart-registry/src/version_selector.rs | 48 - crates/mozart-registry/tests/poolbuilder.rs | 80 - crates/mozart-sat-resolver/Cargo.toml | 11 - crates/mozart-sat-resolver/src/decisions.rs | 263 --- crates/mozart-sat-resolver/src/error.rs | 50 - crates/mozart-sat-resolver/src/lib.rs | 25 - crates/mozart-sat-resolver/src/policy.rs | 264 --- crates/mozart-sat-resolver/src/pool.rs | 427 ---- crates/mozart-sat-resolver/src/pool_builder.rs | 222 --- crates/mozart-sat-resolver/src/problem.rs | 499 ----- crates/mozart-sat-resolver/src/request.rs | 65 - crates/mozart-sat-resolver/src/rule.rs | 280 --- crates/mozart-sat-resolver/src/rule_set.rs | 211 -- .../mozart-sat-resolver/src/rule_set_generator.rs | 464 ----- crates/mozart-sat-resolver/src/rule_watch_graph.rs | 288 --- crates/mozart-sat-resolver/src/solver.rs | 1008 ---------- crates/mozart-sat-resolver/src/transaction.rs | 568 ------ crates/mozart-vcs/Cargo.toml | 21 - crates/mozart-vcs/src/downloader/git.rs | 274 --- crates/mozart-vcs/src/downloader/hg.rs | 87 - crates/mozart-vcs/src/downloader/mod.rs | 56 - crates/mozart-vcs/src/downloader/svn.rs | 87 - crates/mozart-vcs/src/driver/bitbucket.rs | 277 --- crates/mozart-vcs/src/driver/forgejo.rs | 285 --- crates/mozart-vcs/src/driver/git.rs | 278 --- crates/mozart-vcs/src/driver/github.rs | 315 --- crates/mozart-vcs/src/driver/gitlab.rs | 301 --- crates/mozart-vcs/src/driver/hg.rs | 205 -- crates/mozart-vcs/src/driver/mod.rs | 309 --- crates/mozart-vcs/src/driver/svn.rs | 217 --- crates/mozart-vcs/src/lib.rs | 6 - crates/mozart-vcs/src/process.rs | 142 -- crates/mozart-vcs/src/repository.rs | 206 -- crates/mozart-vcs/src/util/git.rs | 314 --- crates/mozart-vcs/src/util/hg.rs | 30 - crates/mozart-vcs/src/util/mod.rs | 3 - crates/mozart-vcs/src/util/svn.rs | 91 - crates/mozart-vcs/src/version_guesser.rs | 605 ------ crates/mozart-vcs/tests/git_driver_test.rs | 340 ---- crates/mozart/Cargo.toml | 6 +- crates/mozart/src/commands/archive.rs | 15 +- crates/mozart/src/commands/audit.rs | 24 +- crates/mozart/src/commands/browse.rs | 6 +- crates/mozart/src/commands/bump.rs | 15 +- crates/mozart/src/commands/check_platform_reqs.rs | 12 +- crates/mozart/src/commands/clear_cache.rs | 2 +- crates/mozart/src/commands/create_project.rs | 31 +- crates/mozart/src/commands/dependency.rs | 4 +- crates/mozart/src/commands/dump_autoload.rs | 2 +- crates/mozart/src/commands/fund.rs | 6 +- crates/mozart/src/commands/init.rs | 10 +- crates/mozart/src/commands/install.rs | 28 +- crates/mozart/src/commands/licenses.rs | 22 +- crates/mozart/src/commands/reinstall.rs | 10 +- crates/mozart/src/commands/remove.rs | 58 +- crates/mozart/src/commands/require.rs | 48 +- crates/mozart/src/commands/search.rs | 14 +- crates/mozart/src/commands/show.rs | 104 +- crates/mozart/src/commands/status.rs | 2 +- crates/mozart/src/commands/suggests.rs | 28 +- crates/mozart/src/commands/update.rs | 41 +- crates/mozart/src/commands/validate.rs | 6 +- crates/mozart/src/composer.rs | 2 +- crates/mozart/src/factory.rs | 2 +- crates/mozart/tests/installer.rs | 4 +- 163 files changed, 23190 insertions(+), 23397 deletions(-) delete mode 100644 crates/mozart-archiver/Cargo.toml delete mode 100644 crates/mozart-archiver/src/lib.rs delete mode 100644 crates/mozart-archiver/src/manager.rs delete mode 100644 crates/mozart-autoload/Cargo.toml delete mode 100644 crates/mozart-autoload/src/autoload.rs delete mode 100644 crates/mozart-autoload/src/dump.rs delete mode 100644 crates/mozart-autoload/src/lib.rs create mode 100644 crates/mozart-core/src/autoload.rs create mode 100644 crates/mozart-core/src/dependency_resolver.rs create mode 100644 crates/mozart-core/src/dependency_resolver/decisions.rs create mode 100644 crates/mozart-core/src/dependency_resolver/error.rs create mode 100644 crates/mozart-core/src/dependency_resolver/policy.rs create mode 100644 crates/mozart-core/src/dependency_resolver/pool.rs create mode 100644 crates/mozart-core/src/dependency_resolver/pool_builder.rs create mode 100644 crates/mozart-core/src/dependency_resolver/problem.rs create mode 100644 crates/mozart-core/src/dependency_resolver/request.rs create mode 100644 crates/mozart-core/src/dependency_resolver/rule.rs create mode 100644 crates/mozart-core/src/dependency_resolver/rule_set.rs create mode 100644 crates/mozart-core/src/dependency_resolver/rule_set_generator.rs create mode 100644 crates/mozart-core/src/dependency_resolver/rule_watch_graph.rs create mode 100644 crates/mozart-core/src/dependency_resolver/solver.rs create mode 100644 crates/mozart-core/src/dependency_resolver/transaction.rs create mode 100644 crates/mozart-core/src/package/archiver.rs create mode 100644 crates/mozart-core/src/package/archiver/manager.rs create mode 100644 crates/mozart-core/src/repository.rs create mode 100644 crates/mozart-core/src/repository/advisory.rs create mode 100644 crates/mozart-core/src/repository/browse_repos.rs create mode 100644 crates/mozart-core/src/repository/cache.rs create mode 100644 crates/mozart-core/src/repository/composer_repo.rs create mode 100644 crates/mozart-core/src/repository/download_manager.rs create mode 100644 crates/mozart-core/src/repository/downloader.rs create mode 100644 crates/mozart-core/src/repository/inline_package.rs create mode 100644 crates/mozart-core/src/repository/installed.rs create mode 100644 crates/mozart-core/src/repository/installer_executor/filesystem.rs create mode 100644 crates/mozart-core/src/repository/installer_executor/mod.rs create mode 100644 crates/mozart-core/src/repository/installer_executor/trace_recorder.rs create mode 100644 crates/mozart-core/src/repository/installer_executor/transaction.rs create mode 100644 crates/mozart-core/src/repository/lockfile.rs create mode 100644 crates/mozart-core/src/repository/packagist.rs create mode 100644 crates/mozart-core/src/repository/path_repository.rs create mode 100644 crates/mozart-core/src/repository/repository/inline_package_repo.rs create mode 100644 crates/mozart-core/src/repository/repository/mod.rs create mode 100644 crates/mozart-core/src/repository/repository/packagist_repo.rs create mode 100644 crates/mozart-core/src/repository/repository/vcs_repo.rs create mode 100644 crates/mozart-core/src/repository/repository_filter.rs create mode 100644 crates/mozart-core/src/repository/resolver.rs create mode 100644 crates/mozart-core/src/repository/vcs_bridge.rs create mode 100644 crates/mozart-core/src/repository/version.rs create mode 100644 crates/mozart-core/src/repository/version_selector.rs create mode 100644 crates/mozart-core/src/vcs.rs create mode 100644 crates/mozart-core/src/vcs/downloader/git.rs create mode 100644 crates/mozart-core/src/vcs/downloader/hg.rs create mode 100644 crates/mozart-core/src/vcs/downloader/mod.rs create mode 100644 crates/mozart-core/src/vcs/downloader/svn.rs create mode 100644 crates/mozart-core/src/vcs/driver/bitbucket.rs create mode 100644 crates/mozart-core/src/vcs/driver/forgejo.rs create mode 100644 crates/mozart-core/src/vcs/driver/git.rs create mode 100644 crates/mozart-core/src/vcs/driver/github.rs create mode 100644 crates/mozart-core/src/vcs/driver/gitlab.rs create mode 100644 crates/mozart-core/src/vcs/driver/hg.rs create mode 100644 crates/mozart-core/src/vcs/driver/mod.rs create mode 100644 crates/mozart-core/src/vcs/driver/svn.rs create mode 100644 crates/mozart-core/src/vcs/process.rs create mode 100644 crates/mozart-core/src/vcs/repository.rs create mode 100644 crates/mozart-core/src/vcs/util/git.rs create mode 100644 crates/mozart-core/src/vcs/util/hg.rs create mode 100644 crates/mozart-core/src/vcs/util/mod.rs create mode 100644 crates/mozart-core/src/vcs/util/svn.rs create mode 100644 crates/mozart-core/src/vcs/version_guesser.rs create mode 100644 crates/mozart-core/tests/git_driver_test.rs delete mode 100644 crates/mozart-registry/Cargo.toml delete mode 100644 crates/mozart-registry/src/advisory.rs delete mode 100644 crates/mozart-registry/src/browse_repos.rs delete mode 100644 crates/mozart-registry/src/cache.rs delete mode 100644 crates/mozart-registry/src/composer_repo.rs delete mode 100644 crates/mozart-registry/src/download_manager.rs delete mode 100644 crates/mozart-registry/src/downloader.rs delete mode 100644 crates/mozart-registry/src/inline_package.rs delete mode 100644 crates/mozart-registry/src/installed.rs delete mode 100644 crates/mozart-registry/src/installer_executor/filesystem.rs delete mode 100644 crates/mozart-registry/src/installer_executor/mod.rs delete mode 100644 crates/mozart-registry/src/installer_executor/trace_recorder.rs delete mode 100644 crates/mozart-registry/src/installer_executor/transaction.rs delete mode 100644 crates/mozart-registry/src/lib.rs delete mode 100644 crates/mozart-registry/src/lockfile.rs delete mode 100644 crates/mozart-registry/src/packagist.rs delete mode 100644 crates/mozart-registry/src/path_repository.rs delete mode 100644 crates/mozart-registry/src/repository/inline_package_repo.rs delete mode 100644 crates/mozart-registry/src/repository/mod.rs delete mode 100644 crates/mozart-registry/src/repository/packagist_repo.rs delete mode 100644 crates/mozart-registry/src/repository/vcs_repo.rs delete mode 100644 crates/mozart-registry/src/repository_filter.rs delete mode 100644 crates/mozart-registry/src/resolver.rs delete mode 100644 crates/mozart-registry/src/vcs_bridge.rs delete mode 100644 crates/mozart-registry/src/version.rs delete mode 100644 crates/mozart-registry/src/version_selector.rs delete mode 100644 crates/mozart-registry/tests/poolbuilder.rs delete mode 100644 crates/mozart-sat-resolver/Cargo.toml delete mode 100644 crates/mozart-sat-resolver/src/decisions.rs delete mode 100644 crates/mozart-sat-resolver/src/error.rs delete mode 100644 crates/mozart-sat-resolver/src/lib.rs delete mode 100644 crates/mozart-sat-resolver/src/policy.rs delete mode 100644 crates/mozart-sat-resolver/src/pool.rs delete mode 100644 crates/mozart-sat-resolver/src/pool_builder.rs delete mode 100644 crates/mozart-sat-resolver/src/problem.rs delete mode 100644 crates/mozart-sat-resolver/src/request.rs delete mode 100644 crates/mozart-sat-resolver/src/rule.rs delete mode 100644 crates/mozart-sat-resolver/src/rule_set.rs delete mode 100644 crates/mozart-sat-resolver/src/rule_set_generator.rs delete mode 100644 crates/mozart-sat-resolver/src/rule_watch_graph.rs delete mode 100644 crates/mozart-sat-resolver/src/solver.rs delete mode 100644 crates/mozart-sat-resolver/src/transaction.rs delete mode 100644 crates/mozart-vcs/Cargo.toml delete mode 100644 crates/mozart-vcs/src/downloader/git.rs delete mode 100644 crates/mozart-vcs/src/downloader/hg.rs delete mode 100644 crates/mozart-vcs/src/downloader/mod.rs delete mode 100644 crates/mozart-vcs/src/downloader/svn.rs delete mode 100644 crates/mozart-vcs/src/driver/bitbucket.rs delete mode 100644 crates/mozart-vcs/src/driver/forgejo.rs delete mode 100644 crates/mozart-vcs/src/driver/git.rs delete mode 100644 crates/mozart-vcs/src/driver/github.rs delete mode 100644 crates/mozart-vcs/src/driver/gitlab.rs delete mode 100644 crates/mozart-vcs/src/driver/hg.rs delete mode 100644 crates/mozart-vcs/src/driver/mod.rs delete mode 100644 crates/mozart-vcs/src/driver/svn.rs delete mode 100644 crates/mozart-vcs/src/lib.rs delete mode 100644 crates/mozart-vcs/src/process.rs delete mode 100644 crates/mozart-vcs/src/repository.rs delete mode 100644 crates/mozart-vcs/src/util/git.rs delete mode 100644 crates/mozart-vcs/src/util/hg.rs delete mode 100644 crates/mozart-vcs/src/util/mod.rs delete mode 100644 crates/mozart-vcs/src/util/svn.rs delete mode 100644 crates/mozart-vcs/src/version_guesser.rs delete mode 100644 crates/mozart-vcs/tests/git_driver_test.rs (limited to 'crates') diff --git a/crates/mozart-archiver/Cargo.toml b/crates/mozart-archiver/Cargo.toml deleted file mode 100644 index 6e2dbfd..0000000 --- a/crates/mozart-archiver/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "mozart-archiver" -version.workspace = true -edition.workspace = true - -[dependencies] -mozart-registry.workspace = true -anyhow.workspace = true -bzip2.workspace = true -flate2.workspace = true -regex.workspace = true -serde_json.workspace = true -sha1.workspace = true -tar.workspace = true -zip.workspace = true - -[dev-dependencies] -tempfile.workspace = true diff --git a/crates/mozart-archiver/src/lib.rs b/crates/mozart-archiver/src/lib.rs deleted file mode 100644 index 30c678a..0000000 --- a/crates/mozart-archiver/src/lib.rs +++ /dev/null @@ -1,899 +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}; - -pub mod manager; -pub use manager::{ArchiveManager, ArchivePackage}; - -/// A compiled exclude pattern derived from a gitignore-style rule. -pub struct ExcludePattern { - regex: Regex, - /// If true, matching files are *re-included* (negation rule). - negate: bool, -} - -/// Convert a glob pattern string to a regex string. -/// -/// Mapping: -/// - `**` → `.*` (matches any path segment sequence) -/// - `*` → `[^/]*` (matches within a single path segment) -/// - `?` → `[^/]` (matches a single non-separator char) -/// - `[…]` → `[…]` (character class, passed through) -/// - all other characters are regex-escaped -fn glob_to_regex(glob: &str) -> String { - let mut result = String::new(); - let chars: Vec = glob.chars().collect(); - let mut i = 0; - while i < chars.len() { - match chars[i] { - '*' if i + 1 < chars.len() && chars[i + 1] == '*' => { - result.push_str(".*"); - i += 2; - } - '*' => { - result.push_str("[^/]*"); - i += 1; - } - '?' => { - result.push_str("[^/]"); - i += 1; - } - '[' => { - // Pass character classes through as-is until the closing `]` - result.push('['); - i += 1; - while i < chars.len() && chars[i] != ']' { - result.push(chars[i]); - i += 1; - } - if i < chars.len() { - result.push(']'); - i += 1; - } - } - c => { - // Regex-escape special characters - if r"\.+^$|{}()?".contains(c) { - result.push('\\'); - } - result.push(c); - i += 1; - } - } - } - result -} - -/// Convert a single gitignore-style rule into an `ExcludePattern`. -/// -/// Returns `None` if the rule is empty or a comment. -pub fn parse_gitignore_pattern(rule: &str) -> Option { - let rule = rule.trim(); - if rule.is_empty() || rule.starts_with('#') { - return None; - } - - // Leading `!` negates the pattern - let (negate, rule) = if let Some(rest) = rule.strip_prefix('!') { - (true, rest) - } else { - (false, rule) - }; - - // Strip trailing `/` before globbing - let rule = rule.trim_end_matches('/'); - if rule.is_empty() { - return None; - } - - // Determine anchor prefix: - // - leading `/` → anchored at root: `^/` - // - no `/` inside pattern → matches anywhere: `/` - // - `/` somewhere in middle → anchored at root: `^/` - let (prefix, glob) = if let Some(without_leading_slash) = rule.strip_prefix('/') { - // Root-anchored - ("^/", without_leading_slash) - } else if rule.contains('/') { - // Slash in middle: treat as root-anchored - ("^/", rule) - } else { - // No slash: matches anywhere - ("/", rule) - }; - - let glob_regex = glob_to_regex(glob); - // The final regex: `(/|$)` - // This matches the path component exactly (followed by a `/` or end-of-string). - let pattern = format!("{prefix}{glob_regex}(/|$)"); - let regex = Regex::new(&pattern).ok()?; - - Some(ExcludePattern { regex, negate }) -} - -/// Apply a chain of exclude patterns to a relative path (as a `/`-prefixed string). -/// -/// Patterns are applied in order; later patterns override earlier ones. -/// Returns `true` if the file is excluded by the final matching pattern -/// (or by `initially_excluded` if no pattern matches). -fn apply_filters( - path_with_slash: &str, - patterns: &[ExcludePattern], - initially_excluded: bool, -) -> bool { - let mut excluded = initially_excluded; - for pat in patterns { - if pat.regex.is_match(path_with_slash) { - // A negate pattern re-includes; a normal pattern excludes - excluded = !pat.negate; - } - } - excluded -} - -/// Parse `.gitattributes` from the source directory. -/// -/// Returns exclude patterns for lines containing `export-ignore` or -/// `-export-ignore`. -pub fn parse_gitattributes(source_dir: &Path) -> Vec { - let path = source_dir.join(".gitattributes"); - let content = match fs::read_to_string(&path) { - Ok(c) => c, - Err(_) => return vec![], - }; - - let mut patterns = Vec::new(); - for line in content.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() < 2 { - continue; - } - let file_pattern = parts[0]; - // Check each attribute token for export-ignore / -export-ignore - for attr in &parts[1..] { - if *attr == "export-ignore" { - if let Some(p) = parse_gitignore_pattern(file_pattern) { - patterns.push(p); - } - } else if *attr == "-export-ignore" { - // Negation: re-include files that would otherwise be excluded - let negated = format!("!{}", file_pattern); - if let Some(p) = parse_gitignore_pattern(&negated) { - patterns.push(p); - } - } - } - } - patterns -} - -/// Convert `composer.json` `archive.exclude` rules into exclude patterns. -pub fn parse_composer_excludes(excludes: &[String]) -> Vec { - excludes - .iter() - .filter_map(|rule| parse_gitignore_pattern(rule)) - .collect() -} - -const VCS_DIRS: &[&str] = &[".git", ".svn", ".hg", "CVS", ".bzr"]; - -/// Collect all archivable files from the source directory. -/// -/// Returns paths relative to `source_dir`, sorted for deterministic output. -/// Applies `exclude_patterns` to filter files. VCS directories are always -/// skipped. Symlinks pointing outside `source_dir` are excluded. -pub fn collect_archivable_files( - source_dir: &Path, - exclude_patterns: &[ExcludePattern], -) -> anyhow::Result> { - let source_dir = source_dir - .canonicalize() - .unwrap_or_else(|_| source_dir.to_path_buf()); - let mut files = Vec::new(); - collect_recursive(&source_dir, &source_dir, exclude_patterns, &mut files)?; - files.sort(); - Ok(files) -} - -fn collect_recursive( - source_dir: &Path, - current_dir: &Path, - exclude_patterns: &[ExcludePattern], - out: &mut Vec, -) -> anyhow::Result<()> { - let entries = fs::read_dir(current_dir) - .with_context(|| format!("Failed to read directory: {}", current_dir.display()))?; - - let mut items: Vec<_> = entries.filter_map(|e| e.ok()).collect(); - // Sort for determinism - items.sort_by_key(|e| e.file_name()); - - for entry in items { - let path = entry.path(); - let file_name = entry.file_name(); - let name_str = file_name.to_string_lossy(); - - // Skip VCS directories - if VCS_DIRS.contains(&name_str.as_ref()) { - continue; - } - - // Compute the relative path (forward-slash, prefixed with `/` for filter matching) - let relative = path - .strip_prefix(source_dir) - .unwrap_or(&path) - .to_string_lossy() - .replace('\\', "/"); - let path_with_slash = format!("/{}", relative); - - // Check if this entry is excluded - if apply_filters(&path_with_slash, exclude_patterns, false) { - continue; - } - - let metadata = match entry.metadata() { - Ok(m) => m, - Err(_) => continue, - }; - - if metadata.is_symlink() { - // Resolve the symlink; skip if it points outside source_dir - if let Ok(resolved) = fs::canonicalize(&path) { - if !resolved.starts_with(source_dir) { - continue; - } - out.push(PathBuf::from(&relative)); - } - // If canonicalize fails, skip the symlink - } else if metadata.is_dir() { - // Collect children recursively - let mut children = Vec::new(); - collect_recursive(source_dir, &path, exclude_patterns, &mut children)?; - if children.is_empty() { - // Include empty directory - out.push(PathBuf::from(&relative)); - } else { - out.extend(children); - } - } else { - out.push(PathBuf::from(&relative)); - } - } - - Ok(()) -} - -/// Supported archive formats. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ArchiveFormat { - Zip, - Tar, - TarGz, - TarBz2, -} - -impl ArchiveFormat { - /// Parse a format string (case-insensitive). Returns `None` for unsupported formats. - pub fn parse(s: &str) -> Option { - match s.to_lowercase().as_str() { - "zip" => Some(Self::Zip), - "tar" => Some(Self::Tar), - "tar.gz" | "tgz" => Some(Self::TarGz), - "tar.bz2" => Some(Self::TarBz2), - _ => None, - } - } - - /// File extension for this format. - pub fn extension(&self) -> &str { - match self { - Self::Zip => "zip", - Self::Tar => "tar", - Self::TarGz => "tar.gz", - Self::TarBz2 => "tar.bz2", - } - } -} - -/// 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(()) -} - -/// Generate an archive filename (without extension) for a package. -/// -/// Mirrors Composer's `ArchiveManager::getPackageFilenameParts()`. -pub fn generate_archive_filename( - name: &str, - archive_name: Option<&str>, - version: Option<&str>, - dist_reference: Option<&str>, - dist_type: Option<&str>, - source_reference: Option<&str>, -) -> String { - // Base: archive_name if set, otherwise replace non-alphanumeric chars with `-` - let base = if let Some(an) = archive_name { - an.to_string() - } else { - let re = Regex::new(r"[^a-zA-Z0-9_\-]").unwrap(); - re.replace_all(name, "-").to_string() - }; - - let mut parts: Vec = vec![base]; - - // Determine if dist_reference is a 40-char hex (SHA-1 commit hash) - let is_sha_dist_ref = dist_reference - .map(|r| r.len() == 40 && r.chars().all(|c| c.is_ascii_hexdigit())) - .unwrap_or(false); - - if is_sha_dist_ref { - // Append dist_reference and dist_type - if let Some(dr) = dist_reference { - parts.push(dr.to_string()); - } - if let Some(dt) = dist_type { - parts.push(dt.to_string()); - } - } else { - // Append version (if any), then dist_reference (if any) - if let Some(v) = version { - parts.push(v.to_string()); - } - if let Some(dr) = dist_reference { - parts.push(dr.to_string()); - } - } - - // Append first 6 chars of SHA-1 of source_reference (if any) - if let Some(sr) = source_reference { - let mut hasher = Sha1::new(); - hasher.update(sr.as_bytes()); - let hash = format!("{:x}", hasher.finalize()); - parts.push(hash[..6.min(hash.len())].to_string()); - } - - // Replace `/` with `-` in each part, then join - parts - .iter() - .map(|p| p.replace('/', "-")) - .collect::>() - .join("-") -} - -/// The set of archive extensions we support. -const ARCHIVE_EXTENSIONS: &[&str] = &["zip", "tar", "tar.gz", "tar.bz2"]; - -/// Generate patterns to exclude previous archives of this package from the archive. -/// -/// If `has_extra_parts` is true (version/ref was appended), the pattern is -/// `-*.`. Otherwise it's `.`. -pub fn self_exclusion_patterns(base_name: &str, has_extra_parts: bool) -> Vec { - ARCHIVE_EXTENSIONS - .iter() - .map(|ext| { - if has_extra_parts { - format!("/{}-*.{}", base_name, ext) - } else { - format!("/{}.{}", base_name, ext) - } - }) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - // 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")); - } - - #[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")); - } - - #[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()); - } - - #[test] - fn test_collect_files_basic() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("a.php"), b" = files - .iter() - .map(|p| p.to_string_lossy().to_string()) - .collect(); - assert!(strs.contains(&"a.php".to_string())); - assert!(strs.contains(&"b.php".to_string())); - assert!(strs.contains(&"src/c.php".to_string())); - } - - #[test] - fn test_collect_files_excludes() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("main.php"), b" = files - .iter() - .map(|p| p.to_string_lossy().to_string()) - .collect(); - assert!(strs.contains(&"main.php".to_string())); - assert!(!strs.iter().any(|s| s.starts_with("tests"))); - } - - #[test] - fn test_collect_files_skips_vcs() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("main.php"), b" = files - .iter() - .map(|p| p.to_string_lossy().to_string()) - .collect(); - assert!(strs.contains(&"main.php".to_string())); - assert!(!strs.iter().any(|s| s.starts_with(".git"))); - } - - #[test] - fn test_collect_files_empty_dir() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("main.php"), b" = files - .iter() - .map(|p| p.to_string_lossy().to_string()) - .collect(); - assert!(strs.contains(&"main.php".to_string())); - assert!(strs.contains(&"empty_dir".to_string())); - } - - fn make_source_tree(dir: &Path) { - fs::write(dir.join("main.php"), b" = (0..archive.len()) - .map(|i| archive.by_index(i).unwrap().name().to_string()) - .collect(); - assert!(names.contains(&"main.php".to_string())); - assert!(names.contains(&"src/Foo.php".to_string())); - } - - #[test] - fn test_create_tar_archive() { - let src = tempdir().unwrap(); - make_source_tree(src.path()); - let out = tempdir().unwrap(); - let target = out.path().join("test.tar"); - - let files = collect_archivable_files(src.path(), &[]).unwrap(); - create_archive(src.path(), &files, &target, &ArchiveFormat::Tar).unwrap(); - assert!(target.exists()); - - // Verify contents - let tar_data = fs::read(&target).unwrap(); - let cursor = std::io::Cursor::new(tar_data); - let mut archive = tar::Archive::new(cursor); - let names: Vec = archive - .entries() - .unwrap() - .filter_map(|e| e.ok()) - .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string())) - .collect(); - assert!(names.contains(&"main.php".to_string())); - assert!(names.contains(&"src/Foo.php".to_string())); - } - - #[test] - fn test_create_tar_gz_archive() { - let src = tempdir().unwrap(); - make_source_tree(src.path()); - let out = tempdir().unwrap(); - let target = out.path().join("test.tar.gz"); - - let files = collect_archivable_files(src.path(), &[]).unwrap(); - create_archive(src.path(), &files, &target, &ArchiveFormat::TarGz).unwrap(); - assert!(target.exists()); - - let gz_data = fs::read(&target).unwrap(); - let cursor = std::io::Cursor::new(gz_data); - let decoder = flate2::read::GzDecoder::new(cursor); - let mut archive = tar::Archive::new(decoder); - let names: Vec = archive - .entries() - .unwrap() - .filter_map(|e| e.ok()) - .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string())) - .collect(); - assert!(names.contains(&"main.php".to_string())); - } - - #[test] - fn test_create_tar_bz2_archive() { - let src = tempdir().unwrap(); - make_source_tree(src.path()); - let out = tempdir().unwrap(); - let target = out.path().join("test.tar.bz2"); - - let files = collect_archivable_files(src.path(), &[]).unwrap(); - create_archive(src.path(), &files, &target, &ArchiveFormat::TarBz2).unwrap(); - assert!(target.exists()); - - let bz_data = fs::read(&target).unwrap(); - let cursor = std::io::Cursor::new(bz_data); - let decoder = bzip2::read::BzDecoder::new(cursor); - let mut archive = tar::Archive::new(decoder); - let names: Vec = archive - .entries() - .unwrap() - .filter_map(|e| e.ok()) - .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string())) - .collect(); - assert!(names.contains(&"main.php".to_string())); - } - - #[cfg(unix)] - #[test] - fn test_zip_preserves_permissions() { - use std::os::unix::fs::PermissionsExt; - - let src = tempdir().unwrap(); - let script = src.path().join("run.sh"); - fs::write(&script, b"#!/bin/sh\necho hello").unwrap(); - fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap(); - - let out = tempdir().unwrap(); - let target = out.path().join("test.zip"); - let files = collect_archivable_files(src.path(), &[]).unwrap(); - create_archive(src.path(), &files, &target, &ArchiveFormat::Zip).unwrap(); - - let zip_data = fs::read(&target).unwrap(); - let cursor = std::io::Cursor::new(zip_data); - let mut archive = zip::ZipArchive::new(cursor).unwrap(); - let entry = archive.by_name("run.sh").unwrap(); - let mode = entry.unix_mode().unwrap_or(0); - // Lower 9 bits should be 0o755 - assert_eq!(mode & 0o777, 0o755); - } - - #[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"); - } - - #[test] - fn test_self_exclusion_patterns_with_extra_parts() { - let patterns = self_exclusion_patterns("vendor-pkg", true); - assert!(patterns.contains(&"/vendor-pkg-*.zip".to_string())); - assert!(patterns.contains(&"/vendor-pkg-*.tar".to_string())); - assert!(patterns.contains(&"/vendor-pkg-*.tar.gz".to_string())); - assert!(patterns.contains(&"/vendor-pkg-*.tar.bz2".to_string())); - } - - #[test] - fn test_self_exclusion_patterns_no_extra_parts() { - let patterns = self_exclusion_patterns("vendor-pkg", false); - assert!(patterns.contains(&"/vendor-pkg.zip".to_string())); - assert!(patterns.contains(&"/vendor-pkg.tar".to_string())); - } -} diff --git a/crates/mozart-archiver/src/manager.rs b/crates/mozart-archiver/src/manager.rs deleted file mode 100644 index bd3cafa..0000000 --- a/crates/mozart-archiver/src/manager.rs +++ /dev/null @@ -1,300 +0,0 @@ -use std::path::{Path, PathBuf}; - -use crate::{ - ArchiveFormat, collect_archivable_files, create_archive, generate_archive_filename, - parse_composer_excludes, parse_gitattributes, parse_gitignore_pattern, self_exclusion_patterns, -}; - -/// A package to be archived. -/// -/// Mirrors the role of Composer's `CompletePackageInterface` as input to -/// `ArchiveManager::archive()`. The `Root` variant points at an already-checked-out -/// source tree; the `Remote` variant carries dist metadata that the manager will -/// download and extract to a temporary directory. -pub enum ArchivePackage { - Root { - name: String, - version: Option, - source_dir: PathBuf, - }, - Remote { - name: String, - version: String, - dist_url: String, - dist_type: String, - dist_shasum: Option, - dist_reference: Option, - source_reference: Option, - }, -} - -impl ArchivePackage { - fn name(&self) -> &str { - match self { - Self::Root { name, .. } | Self::Remote { name, .. } => name, - } - } - - fn version(&self) -> Option<&str> { - match self { - Self::Root { version, .. } => version.as_deref(), - Self::Remote { version, .. } => Some(version), - } - } - - fn dist_reference(&self) -> Option<&str> { - match self { - Self::Root { .. } => None, - Self::Remote { dist_reference, .. } => dist_reference.as_deref(), - } - } - - fn dist_type(&self) -> Option<&str> { - match self { - Self::Root { .. } => None, - Self::Remote { dist_type, .. } => Some(dist_type), - } - } - - fn source_reference(&self) -> Option<&str> { - match self { - Self::Root { .. } => None, - Self::Remote { - source_reference, .. - } => source_reference.as_deref(), - } - } -} - -/// Holds an extracted source directory plus, for remote packages, a tempdir -/// that must outlive `source_dir`. Drop removes the tempdir. -struct AcquiredSource { - source_dir: PathBuf, - archive_name: Option, - archive_excludes: Vec, - _temp_dir: Option, -} - -impl Drop for AcquiredSource { - fn drop(&mut self) { - if let Some(ref dir) = self._temp_dir { - let _ = std::fs::remove_dir_all(dir); - } - } -} - -/// Read `archive.name` and `archive.exclude` from a composer.json file. -fn read_archive_config(composer_json_path: &Path) -> anyhow::Result<(Option, Vec)> { - let content = std::fs::read_to_string(composer_json_path)?; - let value: serde_json::Value = serde_json::from_str(&content)?; - - let name = value - .get("archive") - .and_then(|a| a.get("name")) - .and_then(|n| n.as_str()) - .map(|s| s.to_string()); - - let excludes = value - .get("archive") - .and_then(|a| a.get("exclude")) - .and_then(|e| e.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str()) - .map(|s| s.to_string()) - .collect() - }) - .unwrap_or_default(); - - Ok((name, excludes)) -} - -/// Manages the creation of package archives. -/// -/// Mirrors Composer's `Composer\Package\Archiver\ArchiveManager`. -pub struct ArchiveManager; - -impl Default for ArchiveManager { - fn default() -> Self { - Self::new() - } -} - -impl ArchiveManager { - pub fn new() -> Self { - ArchiveManager - } - - /// Build the parts that make up a package archive's filename. - fn package_filename_parts(package: &ArchivePackage, archive_name: Option<&str>) -> String { - generate_archive_filename( - package.name(), - archive_name, - package.version(), - package.dist_reference(), - package.dist_type(), - package.source_reference(), - ) - } - - /// Generate the archive filename (without extension) for a package, using - /// any `archive.name` override from the package's source composer.json. - pub fn package_filename(package: &ArchivePackage) -> String { - let archive_name = match package { - ArchivePackage::Root { source_dir, .. } => { - read_archive_config(&source_dir.join("composer.json")) - .ok() - .and_then(|(n, _)| n) - } - ArchivePackage::Remote { .. } => None, - }; - Self::package_filename_parts(package, archive_name.as_deref()) - } - - /// Join filename parts with `-`, mirroring Composer's - /// `getPackageFilenameFromParts`. - pub fn package_filename_from_parts(parts: &[&str]) -> String { - parts.join("-") - } - - /// Create an archive of the given package. - /// - /// For a `Remote` package, the dist is downloaded into a tempdir and - /// extracted before archiving; the tempdir is removed afterward. For - /// `Root`, the package's `source_dir` is archived in place. - /// - /// Returns the absolute path to the created archive. - pub async fn archive( - &self, - package: &ArchivePackage, - format: &str, - target_dir: &Path, - file_name: Option<&str>, - ignore_filters: bool, - files_cache: &mozart_registry::cache::Cache, - ) -> anyhow::Result { - let archive_format = ArchiveFormat::parse(format).ok_or_else(|| { - anyhow::anyhow!( - "Unsupported archive format \"{}\". Supported formats: tar, tar.gz, tar.bz2, zip", - format - ) - })?; - - let source = acquire_source(package, files_cache).await?; - - let filename_base = if let Some(file_name) = file_name { - file_name.to_string() - } else { - Self::package_filename_parts(package, source.archive_name.as_deref()) - }; - - // Self-exclusion: prevent the archive from including itself - let has_extra_parts = file_name.is_none() - && (package.version().is_some() - || package.dist_reference().is_some() - || package.source_reference().is_some()); - let self_exclusion_strs = self_exclusion_patterns(&filename_base, has_extra_parts); - - let mut all_patterns = Vec::new(); - for rule in &self_exclusion_strs { - if let Some(p) = parse_gitignore_pattern(rule) { - all_patterns.push(p); - } - } - - if !ignore_filters { - let git_patterns = parse_gitattributes(&source.source_dir); - all_patterns.extend(git_patterns); - - let composer_patterns = parse_composer_excludes(&source.archive_excludes); - all_patterns.extend(composer_patterns); - } - - let files = collect_archivable_files(&source.source_dir, &all_patterns)?; - - std::fs::create_dir_all(target_dir)?; - let target_dir = target_dir - .canonicalize() - .unwrap_or_else(|_| target_dir.to_path_buf()); - let target = target_dir.join(format!("{}.{}", filename_base, archive_format.extension())); - create_archive(&source.source_dir, &files, &target, &archive_format)?; - - Ok(target) - } -} - -/// Acquire the source tree of a package — either by reusing the root -/// directory or by downloading and extracting the dist into a tempdir. -/// Also reads `archive.name` / `archive.exclude` from the package's -/// composer.json. -async fn acquire_source( - package: &ArchivePackage, - files_cache: &mozart_registry::cache::Cache, -) -> anyhow::Result { - match package { - ArchivePackage::Root { source_dir, .. } => { - let composer_json_path = source_dir.join("composer.json"); - let (archive_name, archive_excludes) = if composer_json_path.exists() { - read_archive_config(&composer_json_path).unwrap_or((None, vec![])) - } else { - (None, vec![]) - }; - Ok(AcquiredSource { - source_dir: source_dir.clone(), - archive_name, - archive_excludes, - _temp_dir: None, - }) - } - ArchivePackage::Remote { - dist_url, - dist_type, - dist_shasum, - .. - } => { - let temp_base = std::env::temp_dir(); - let unique = format!( - "mozart-archive-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - ); - let temp_dir = temp_base.join(&unique); - std::fs::create_dir_all(&temp_dir)?; - - let bytes = mozart_registry::downloader::download_dist( - dist_url, - dist_shasum.as_deref(), - None, - files_cache, - ) - .await?; - - match dist_type.as_str() { - "zip" => mozart_registry::downloader::extract_zip(&bytes, &temp_dir)?, - "tar" | "tar.gz" | "tgz" => { - mozart_registry::downloader::extract_tar_gz(&bytes, &temp_dir)? - } - other => { - let _ = std::fs::remove_dir_all(&temp_dir); - anyhow::bail!("Unsupported dist type: {}", other); - } - } - - let extracted_composer = temp_dir.join("composer.json"); - let (archive_name, archive_excludes) = if extracted_composer.exists() { - read_archive_config(&extracted_composer).unwrap_or((None, vec![])) - } else { - (None, vec![]) - }; - - Ok(AcquiredSource { - source_dir: temp_dir.clone(), - archive_name, - archive_excludes, - _temp_dir: Some(temp_dir), - }) - } - } -} diff --git a/crates/mozart-autoload/Cargo.toml b/crates/mozart-autoload/Cargo.toml deleted file mode 100644 index 571d70f..0000000 --- a/crates/mozart-autoload/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "mozart-autoload" -version.workspace = true -edition.workspace = true - -[dependencies] -mozart-class-map-generator.workspace = true -mozart-core.workspace = true -mozart-registry.workspace = true -anyhow.workspace = true -indexmap.workspace = true -md5.workspace = true -regex.workspace = true -serde_json.workspace = true - -[dev-dependencies] -tempfile.workspace = true diff --git a/crates/mozart-autoload/src/autoload.rs b/crates/mozart-autoload/src/autoload.rs deleted file mode 100644 index 21a1de2..0000000 --- a/crates/mozart-autoload/src/autoload.rs +++ /dev/null @@ -1,1597 +0,0 @@ -use indexmap::IndexSet; -use mozart_class_map_generator::{scan_classmap_dirs, scan_psr_for_classmap}; -use mozart_registry::installed::InstalledPackages; -use mozart_registry::lockfile::LockedPackage; -use std::collections::BTreeMap; -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, -} - -/// Result of autoload generation, reporting statistics and warnings. -pub struct GenerateResult { - pub class_count: usize, - pub has_psr_violations: bool, - pub has_ambiguous_classes: bool, -} - -/// Configuration for autoload generation. -pub struct AutoloadConfig { - /// Absolute path to the project root (where composer.json lives). - pub project_dir: PathBuf, - /// Absolute path to the vendor directory. - pub vendor_dir: PathBuf, - /// Whether dev-mode autoloading is active (include autoload-dev rules). - pub dev_mode: bool, - /// Unique suffix for the autoloader class names (typically the lock file content-hash). - /// Used to generate `ComposerAutoloaderInit{suffix}` and `ComposerStaticInit{suffix}`. - pub suffix: String, - /// When true, emit `$loader->setClassMapAuthoritative(true)` in the generated autoloader. - pub classmap_authoritative: bool, - /// When true, scan PSR-4/PSR-0 directories and generate a full classmap (optimize mode). - pub optimize: bool, - /// When true, generate APCu-based class caching in the autoloader. - pub apcu: bool, - /// Optional prefix for APCu cache keys (implies `apcu`). - pub apcu_prefix: Option, - /// When true, return an error on PSR mapping violations detected during classmap scan. - pub strict_psr: bool, - /// When true, return exit code 2 if ambiguous class mappings are detected. - pub strict_ambiguous: bool, - /// How to handle platform requirement checks. - pub platform_check: PlatformCheckMode, - /// When true, skip all platform requirement checks. - pub ignore_platform_reqs: bool, -} - -/// Collected autoload mappings from all packages. -pub struct AutoloadData { - /// PSR-4: namespace prefix -> list of directory path expressions. - /// Each path is a PHP expression string like `$vendorDir . '/psr/log/src'`. - pub psr4: BTreeMap>, - /// PSR-0: namespace prefix -> list of directory path expressions. - /// (Empty in Phase 2.2, populated in 5.6.) - pub psr0: BTreeMap>, - /// Classmap entries: class name -> file path expression. - /// (Empty in Phase 2.2, populated in 5.6.) - pub classmap: BTreeMap, - /// Files to include on every request: file_identifier -> path expression. - pub files: BTreeMap, -} - -/// Escape a string for use in a PHP single-quoted string literal. -pub fn php_escape(s: &str) -> String { - s.replace('\\', "\\\\").replace('\'', "\\'") -} - -/// Compute the file identifier matching Composer's `getFileIdentifier()`. -/// This is the MD5 hex digest of `"package_name:path"`. -pub fn file_identifier(package_name: &str, path: &str) -> String { - let input = format!("{package_name}:{path}"); - format!("{:x}", md5::compute(input.as_bytes())) -} - -/// Extract a path or array of paths from a JSON value. -/// Handles both string and array-of-strings (Composer allows both). -fn json_to_paths(value: &serde_json::Value) -> Vec { - match value { - serde_json::Value::String(s) => vec![s.clone()], - serde_json::Value::Array(arr) => arr - .iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) - .collect(), - _ => vec![], - } -} - -/// Strip trailing slash from a path component. -fn strip_trailing_slash(s: &str) -> &str { - s.trim_end_matches('/') -} - -/// Normalize a PSR-4 namespace: ensure it ends with `\`. -/// (The empty string "" is valid and is left as-is.) -fn normalize_namespace(ns: &str) -> String { - if ns.is_empty() || ns.ends_with('\\') { - ns.to_string() - } else { - format!("{ns}\\") - } -} - -/// Build a PHP path expression from a base expression and a relative path component. -/// -/// For vendor packages: `base_expr` = `"$vendorDir"`, `pkg_path` = `"psr/log"`, -/// `sub_path` = `"src/"` → result: `"$vendorDir . '/psr/log/src'"`. -/// -/// For root packages: `base_expr` = `"$baseDir"`, `pkg_path` = `""`, -/// `sub_path` = `"src/"` → result: `"$baseDir . '/src'"`. -fn build_path_expr(base_expr: &str, pkg_path: &str, sub_path: &str) -> String { - let sub = strip_trailing_slash(sub_path); - let combined = if pkg_path.is_empty() { - sub.to_string() - } else if sub.is_empty() { - pkg_path.to_string() - } else { - format!("{pkg_path}/{sub}") - }; - - if combined.is_empty() { - base_expr.to_string() - } else { - format!("{base_expr} . '/{combined}'") - } -} - -/// Process an autoload JSON value and merge its rules into `data`. -/// -/// `pkg_path` is the package-relative path segment within vendor. -/// For vendor packages it is `"vendor/name"` (e.g. `"psr/log"`). -/// For the root package it is `""`. -/// -/// `dyn_base` is the dynamic PHP variable: `"$vendorDir"` or `"$baseDir"`. -/// `static_base` is the static PHP expression: `"__DIR__ . '/..'"` or `"__DIR__ . '/../.'"`. -fn process_autoload_value( - autoload_val: &serde_json::Value, - package_name: &str, - pkg_path: &str, - dyn_base: &str, - static_base: &str, - data: &mut AutoloadData, - static_data: &mut AutoloadData, -) { - // PSR-4 - if let Some(psr4_obj) = autoload_val.get("psr-4").and_then(|v| v.as_object()) { - for (ns_raw, paths_val) in psr4_obj { - let ns = normalize_namespace(ns_raw); - let paths = json_to_paths(paths_val); - let entry = data.psr4.entry(ns.clone()).or_default(); - let static_entry = static_data.psr4.entry(ns).or_default(); - for path in paths { - entry.push(build_path_expr(dyn_base, pkg_path, &path)); - static_entry.push(build_path_expr(static_base, pkg_path, &path)); - } - } - } - - // PSR-0 - if let Some(psr0_obj) = autoload_val.get("psr-0").and_then(|v| v.as_object()) { - for (ns_raw, paths_val) in psr0_obj { - let ns = ns_raw.clone(); - let paths = json_to_paths(paths_val); - let entry = data.psr0.entry(ns.clone()).or_default(); - let static_entry = static_data.psr0.entry(ns).or_default(); - for path in paths { - entry.push(build_path_expr(dyn_base, pkg_path, &path)); - static_entry.push(build_path_expr(static_base, pkg_path, &path)); - } - } - } - - // Files - if let Some(files_arr) = autoload_val.get("files").and_then(|v| v.as_array()) { - for file_val in files_arr { - if let Some(file_path) = file_val.as_str() { - let id = file_identifier(package_name, file_path); - let expr = build_path_expr(dyn_base, pkg_path, file_path); - let static_expr = build_path_expr(static_base, pkg_path, file_path); - data.files.insert(id.clone(), expr); - static_data.files.insert(id, static_expr); - } - } - } -} - -/// Collect autoload rules from all installed packages and the root package. -/// -/// Returns a tuple of `(dynamic_data, static_data)` where: -/// - `dynamic_data` uses `$vendorDir` / `$baseDir` path expressions (for autoload_psr4.php, etc.) -/// - `static_data` uses `__DIR__ . '/..'` path expressions (for autoload_static.php) -fn collect_autoloads( - installed: &InstalledPackages, - root_autoload: Option<&serde_json::Value>, - root_autoload_dev: Option<&serde_json::Value>, - root_package_name: &str, - dev_mode: bool, -) -> (AutoloadData, AutoloadData) { - let mut data = AutoloadData { - psr4: BTreeMap::new(), - psr0: BTreeMap::new(), - classmap: BTreeMap::new(), - files: BTreeMap::new(), - }; - let mut static_data = AutoloadData { - psr4: BTreeMap::new(), - psr0: BTreeMap::new(), - classmap: BTreeMap::new(), - files: BTreeMap::new(), - }; - - // Process each installed package - for pkg in &installed.packages { - if let Some(autoload_val) = &pkg.autoload { - process_autoload_value( - autoload_val, - &pkg.name, - &pkg.name, // pkg_path within vendor - "$vendorDir", - "__DIR__ . '/..'", - &mut data, - &mut static_data, - ); - } - } - - // Process root package autoload - if let Some(autoload_val) = root_autoload { - process_autoload_value( - autoload_val, - root_package_name, - "", // no pkg_path for root - "$baseDir", - "__DIR__ . '/../..'", - &mut data, - &mut static_data, - ); - } - - // Process root package autoload-dev (only in dev mode) - if dev_mode && let Some(autoload_dev_val) = root_autoload_dev { - process_autoload_value( - autoload_dev_val, - root_package_name, - "", - "$baseDir", - "__DIR__ . '/../..'", - &mut data, - &mut static_data, - ); - } - - (data, static_data) -} - -/// Generate `vendor/composer/autoload_psr4.php`. -fn generate_autoload_psr4(data: &AutoloadData) -> String { - let mut out = String::new(); - out.push_str(")> = data.psr4.iter().collect(); - sorted.sort_by(|(a, _), (b, _)| b.cmp(a)); - - for (ns, paths) in &sorted { - let escaped_ns = php_escape(ns); - if paths.len() == 1 { - out.push_str(&format!(" '{}' => array({}),\n", escaped_ns, paths[0])); - } else { - out.push_str(&format!(" '{}' => array(\n", escaped_ns)); - for path in paths.iter() { - out.push_str(&format!(" {},\n", path)); - } - out.push_str(" ),\n"); - } - } - - out.push_str(");\n"); - out -} - -/// Generate `vendor/composer/autoload_namespaces.php` (PSR-0, empty for Phase 2.2). -fn generate_autoload_namespaces(data: &AutoloadData) -> String { - let mut out = String::new(); - out.push_str(")> = data.psr0.iter().collect(); - sorted.sort_by(|(a, _), (b, _)| b.cmp(a)); - - for (ns, paths) in &sorted { - let escaped_ns = php_escape(ns); - if paths.len() == 1 { - out.push_str(&format!(" '{}' => array({}),\n", escaped_ns, paths[0])); - } else { - out.push_str(&format!(" '{}' => array(\n", escaped_ns)); - for path in paths.iter() { - out.push_str(&format!(" {},\n", path)); - } - out.push_str(" ),\n"); - } - } - - out.push_str(");\n"); - out -} - -/// Generate `vendor/composer/autoload_classmap.php`. -/// Always contains `Composer\InstalledVersions`; classmap scanning deferred to Phase 5.6. -fn generate_autoload_classmap(data: &AutoloadData) -> String { - let mut out = String::new(); - out.push_str(" $vendorDir . '/composer/InstalledVersions.php',\n", - ); - - // Include any additional classmap entries from data - for (class, path) in &data.classmap { - let escaped_class = php_escape(class); - out.push_str(&format!(" '{}' => {},\n", escaped_class, path)); - } - - out.push_str(");\n"); - out -} - -/// Generate `vendor/composer/autoload_files.php`. -/// Returns `None` if there are no files to autoload. -fn generate_autoload_files(data: &AutoloadData) -> Option { - if data.files.is_empty() { - return None; - } - - let mut out = String::new(); - out.push_str(" {},\n", id, path)); - } - - out.push_str(");\n"); - Some(out) -} - -/// Generate `vendor/composer/autoload_static.php`. -/// -/// `static_data` must have been collected with `__DIR__ . '/..'` path prefixes. -fn generate_autoload_static(static_data: &AutoloadData, suffix: &str) -> String { - let mut out = String::new(); - out.push_str(" {path},\n")); - } - out.push_str(" );\n\n"); - } - - // $prefixLengthsPsr4 — group by first character of namespace - if !static_data.psr4.is_empty() { - // Group namespaces by first character, sorted reverse - let mut by_char: BTreeMap> = BTreeMap::new(); - - let mut sorted_ns: Vec<&String> = static_data.psr4.keys().collect(); - sorted_ns.sort_by(|a, b| b.cmp(a)); - - for ns in sorted_ns { - if let Some(first_char) = ns.chars().next() { - // The byte length in PHP (single-quoted string with single backslashes) - // ns in our data uses single backslash (stored as-is from JSON). - let byte_len = ns.len(); - by_char.entry(first_char).or_default().push((ns, byte_len)); - } - } - - out.push_str(" public static $prefixLengthsPsr4 = array (\n"); - // Sort characters in reverse order too - let mut chars: Vec = by_char.keys().copied().collect(); - chars.sort_by(|a, b| b.cmp(a)); - for ch in &chars { - out.push_str(&format!(" '{ch}' =>\n array (\n")); - if let Some(entries) = by_char.get(ch) { - for (ns, len) in entries { - let escaped_ns = php_escape(ns); - out.push_str(&format!(" '{escaped_ns}' => {len},\n")); - } - } - out.push_str(" ),\n"); - } - out.push_str(" );\n\n"); - - // $prefixDirsPsr4 - out.push_str(" public static $prefixDirsPsr4 = array (\n"); - let mut sorted_ns2: Vec<(&String, &Vec)> = static_data.psr4.iter().collect(); - sorted_ns2.sort_by(|(a, _), (b, _)| b.cmp(a)); - for (ns, paths) in sorted_ns2 { - let escaped_ns = php_escape(ns); - out.push_str(&format!(" '{escaped_ns}' =>\n array (\n")); - for (i, path) in paths.iter().enumerate() { - out.push_str(&format!(" {i} => {path},\n")); - } - out.push_str(" ),\n"); - } - out.push_str(" );\n\n"); - } - - // $classMap — always contains Composer\InstalledVersions - out.push_str(" public static $classMap = array (\n"); - out.push_str( - " 'Composer\\\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',\n", - ); - for (class, path) in &static_data.classmap { - let escaped_class = php_escape(class); - out.push_str(&format!(" '{}' => {},\n", escaped_class, path)); - } - out.push_str(" );\n\n"); - - // getInitializer - out.push_str(" public static function getInitializer(ClassLoader $loader)\n {\n"); - out.push_str(" return \\Closure::bind(function () use ($loader) {\n"); - - if !static_data.psr4.is_empty() { - out.push_str(&format!( - " $loader->prefixLengthsPsr4 = ComposerStaticInit{suffix}::$prefixLengthsPsr4;\n" - )); - out.push_str(&format!( - " $loader->prefixDirsPsr4 = ComposerStaticInit{suffix}::$prefixDirsPsr4;\n" - )); - } - out.push_str(&format!( - " $loader->classMap = ComposerStaticInit{suffix}::$classMap;\n" - )); - out.push_str("\n }, null, ClassLoader::class);\n }\n}\n"); - - out -} - -/// 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: &IndexSet, -) -> Option { - if matches!(mode, PlatformCheckMode::Disabled) { - return None; - } - - // Collect PHP version constraint from root require - let mut php_constraint: Option = None; - if let Some(req_obj) = root_require.and_then(|v| v.as_object()) - && let Some(v) = req_obj.get("php").and_then(|v| v.as_str()) - { - php_constraint = Some(v.to_string()); - } - - // Collect extension requirements from packages (prod only) - let mut ext_reqs: Vec<(String, String)> = Vec::new(); - if matches!(mode, PlatformCheckMode::Full) { - for pkg in packages { - let is_dev = dev_package_names.contains(&pkg.name.to_lowercase()); - if is_dev { - continue; - } - for (req_name, req_constraint) in &pkg.require { - let lower = req_name.to_lowercase(); - if lower.starts_with("ext-") { - ext_reqs.push((req_name.clone(), req_constraint.clone())); - } - } - } - ext_reqs.sort(); - ext_reqs.dedup(); - } - - if php_constraint.is_none() && ext_reqs.is_empty() { - return None; - } - - let mut out = String::new(); - out.push_str("= 50600)) {\n"); - out.push_str(&format!( - " $issues[] = 'Your Composer dependencies require a PHP version \"{escaped}\". You are running ' . PHP_VERSION . '.';\n" - )); - out.push_str("}\n\n"); - } - - for (ext_name, _constraint) in &ext_reqs { - let ext_short = ext_name.trim_start_matches("ext-"); - let escaped_ext = php_escape(ext_short); - out.push_str(&format!("if (!extension_loaded('{escaped_ext}')) {{\n")); - out.push_str(&format!( - " $issues[] = 'Your Composer dependencies require the \"{escaped_ext}\" PHP extension to be installed.';\n" - )); - out.push_str("}\n\n"); - } - - out.push_str("if ($issues) {\n"); - out.push_str(" if (!headers_sent()) {\n"); - out.push_str(" header('HTTP/1.1 500 Internal Server Error');\n"); - out.push_str(" }\n"); - out.push_str(" if (!ini_get('display_errors')) {\n"); - out.push_str(" if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {\n"); - out.push_str(" fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL);\n"); - out.push_str(" } elseif (!headers_sent()) {\n"); - out.push_str(" echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL;\n"); - out.push_str(" }\n"); - out.push_str(" }\n"); - out.push_str(" trigger_error(\n"); - out.push_str( - " 'Composer detected issues in your platform: ' . implode(' ', $issues),\n", - ); - out.push_str(" E_USER_ERROR\n"); - out.push_str(" );\n"); - out.push_str("}\n"); - - Some(out) -} - -/// Generate `vendor/composer/autoload_real.php`. -fn generate_autoload_real( - suffix: &str, - has_files: bool, - classmap_authoritative: bool, - apcu: bool, - apcu_prefix: Option<&str>, - has_platform_check: bool, -) -> String { - let mut out = String::new(); - out.push_str("register(true);\n"); - - if classmap_authoritative { - out.push_str(" $loader->setClassMapAuthoritative(true);\n"); - } - - if apcu { - let prefix = apcu_prefix.unwrap_or(suffix); - let escaped = php_escape(prefix); - out.push_str(&format!(" $loader->setApcuPrefix('{escaped}');\n")); - } - - if has_files { - out.push('\n'); - out.push_str(&format!( - " $filesToLoad = \\Composer\\Autoload\\ComposerStaticInit{suffix}::$files;\n" - )); - out.push_str( - " $requireFile = \\Closure::bind(static function ($fileIdentifier, $file) {\n", - ); - out.push_str( - " if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) {\n", - ); - out.push_str( - " $GLOBALS['__composer_autoload_files'][$fileIdentifier] = true;\n", - ); - out.push('\n'); - out.push_str(" require $file;\n"); - out.push_str(" }\n"); - out.push_str(" }, null, null);\n"); - out.push_str(" foreach ($filesToLoad as $fileIdentifier => $file) {\n"); - out.push_str(" $requireFile($fileIdentifier, $file);\n"); - out.push_str(" }\n"); - } - - out.push('\n'); - out.push_str(" return $loader;\n"); - out.push_str(" }\n"); - out.push_str("}\n"); - out -} - -/// Generate `vendor/autoload.php` (the entry point). -fn generate_autoload_php(suffix: &str) -> String { - let mut out = String::new(); - out.push_str(" String { - let dev_str = if dev_mode { "true" } else { "false" }; - - let mut out = String::new(); - out.push_str(" array(\n"); - out.push_str(&format!(" 'name' => '{}',\n", php_escape(root_name))); - out.push_str(" 'pretty_version' => 'dev-main',\n"); - out.push_str(" 'version' => 'dev-main',\n"); - out.push_str(" 'reference' => null,\n"); - out.push_str(&format!(" 'type' => '{}',\n", php_escape(root_type))); - out.push_str(" 'install_path' => __DIR__ . '/../../',\n"); - out.push_str(" 'aliases' => array(),\n"); - out.push_str(&format!(" 'dev' => {dev_str},\n")); - out.push_str(" ),\n"); - out.push_str(" 'versions' => array(\n"); - - for pkg in &installed.packages { - let version = &pkg.version; - let version_normalized = pkg.version_normalized.as_deref().unwrap_or(version); - let pkg_type = pkg.package_type.as_deref().unwrap_or("library"); - let is_dev = installed - .dev_package_names - .iter() - .any(|n| n.eq_ignore_ascii_case(&pkg.name)); - let is_dev_str = if is_dev { "true" } else { "false" }; - - out.push_str(&format!(" '{}' => array(\n", php_escape(&pkg.name))); - out.push_str(&format!( - " 'pretty_version' => '{}',\n", - php_escape(version) - )); - out.push_str(&format!( - " 'version' => '{}',\n", - php_escape(version_normalized) - )); - out.push_str(" 'reference' => null,\n"); - out.push_str(&format!( - " 'type' => '{}',\n", - php_escape(pkg_type) - )); - // Install path relative to vendor/composer/installed.php: __DIR__ . '/./' . relative_name - // The install_path stored is like '../psr/log', relative to vendor/composer/ - // So from vendor/composer/, the package is at __DIR__ . '/../psr/log/' - out.push_str(&format!( - " 'install_path' => __DIR__ . '/../{}/',\n", - pkg.name - )); - out.push_str(" 'aliases' => array(),\n"); - out.push_str(&format!(" 'dev_requirement' => {is_dev_str},\n")); - out.push_str(" ),\n"); - } - - out.push_str(" ),\n"); - out.push_str(");\n"); - out -} - -/// Determine the autoloader suffix. -/// -/// Priority: -/// 1. Existing `vendor/autoload.php` suffix (carry over to avoid breaking existing references). -/// 2. Lock file `content-hash` (if locked). -/// 3. Fall back to a timestamp-based hex string. -pub fn determine_suffix(working_dir: &Path, vendor_dir: &Path) -> anyhow::Result { - // Try existing autoload.php - let autoload_path = vendor_dir.join("autoload.php"); - if autoload_path.exists() { - let content = std::fs::read_to_string(&autoload_path)?; - if let Some(start) = content.find("ComposerAutoloaderInit") { - let rest = &content[start + "ComposerAutoloaderInit".len()..]; - if let Some(end) = rest.find("::") { - let suffix = &rest[..end]; - if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_hexdigit()) { - return Ok(suffix.to_string()); - } - } - } - } - - // Try composer.lock content-hash - let lock_path = working_dir.join("composer.lock"); - if lock_path.exists() { - let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; - return Ok(lock.content_hash); - } - - // Fall back to MD5 of current timestamp - let ts = format!("{:?}", std::time::SystemTime::now()); - Ok(format!("{:x}", md5::compute(ts.as_bytes()))) -} - -/// Generate all autoloader files for the given project. -/// -/// This is the main entry point called by `install` and `dump-autoload`. -pub fn generate(config: &AutoloadConfig) -> anyhow::Result { - // 1. Read installed.json - let installed = InstalledPackages::read(&config.vendor_dir)?; - - // 2. Read root package autoload from composer.json - let composer_json_path = config.project_dir.join("composer.json"); - let (root_autoload, root_autoload_dev, root_name, root_type) = if composer_json_path.exists() { - let content = std::fs::read_to_string(&composer_json_path)?; - let value: serde_json::Value = serde_json::from_str(&content)?; - ( - value.get("autoload").cloned(), - value.get("autoload-dev").cloned(), - value - .get("name") - .and_then(|n| n.as_str()) - .unwrap_or("__root__") - .to_string(), - value - .get("type") - .and_then(|t| t.as_str()) - .unwrap_or("project") - .to_string(), - ) - } else { - (None, None, "__root__".to_string(), "project".to_string()) - }; - - // 3. Collect autoload data - let (mut data, mut static_data) = collect_autoloads( - &installed, - root_autoload.as_ref(), - root_autoload_dev.as_ref(), - &root_name, - config.dev_mode, - ); - - // 3a. Read classmap dirs declared in composer.json - let excluded: Vec = root_autoload - .as_ref() - .and_then(|v| v.get("exclude-from-classmap")) - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) - .collect() - }) - .unwrap_or_default(); - - // Scan explicit classmap dirs from all packages - let mut classmap_dirs: Vec = Vec::new(); - - // Collect classmap dirs from installed packages - for pkg in &installed.packages { - if let Some(autoload_val) = &pkg.autoload - && let Some(cm_arr) = autoload_val.get("classmap").and_then(|v| v.as_array()) - { - for cm_val in cm_arr { - if let Some(cm_path) = cm_val.as_str() { - let abs = config.vendor_dir.join(&pkg.name).join(cm_path); - classmap_dirs.push(abs); - } - } - } - } - - // Collect classmap dirs from root autoload - if let Some(autoload_val) = root_autoload.as_ref() - && let Some(cm_arr) = autoload_val.get("classmap").and_then(|v| v.as_array()) - { - for cm_val in cm_arr { - if let Some(cm_path) = cm_val.as_str() { - let abs = config.project_dir.join(cm_path); - classmap_dirs.push(abs); - } - } - } - - // Scan classmap dirs - let mut ambiguous_found = false; - 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 { - if let Some(existing) = data.classmap.get(&class) - && existing != &path_expr - { - ambiguous_found = true; - } - // Also generate the static expression - // We store the dynamic expression in data.classmap; static_data.classmap - // will be populated similarly. For now we insert into both. - data.classmap.entry(class.clone()).or_insert(path_expr); - // Generate corresponding static expr by replacing dynamic prefixes - // (static_data classmap is populated in the static pass below) - } - } - - // 3b. Optimize mode: scan PSR-4/PSR-0 dirs for classmap - let do_optimize = config.optimize || config.classmap_authoritative; - let mut psr_violations: Vec = Vec::new(); - - if do_optimize { - let (opt_dyn, opt_static, violations) = scan_psr_for_classmap( - &data.psr4, - &data.psr0, - &config.vendor_dir, - &config.project_dir, - &excluded, - ); - psr_violations = violations; - for (class, path_expr) in opt_dyn { - if let Some(existing) = data.classmap.get(&class) - && existing != &path_expr - { - ambiguous_found = true; - } - 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: IndexSet = installed - .dev_package_names - .iter() - .map(|n| n.to_lowercase()) - .collect(); - - // Re-read composer.json for root require (not from autoload, but from root "require" key) - let root_require_val: Option = if composer_json_path.exists() { - let content = std::fs::read_to_string(&composer_json_path)?; - let value: serde_json::Value = serde_json::from_str(&content)?; - value.get("require").cloned() - } else { - None - }; - - let all_locked: Vec = { - // Collect locked packages from installed for platform check - // (installed.packages are LockedPackage-compatible via InstalledPackageEntry) - // We'll build minimal LockedPackage-like data from installed entries - installed - .packages - .iter() - .map(|p| mozart_registry::lockfile::LockedPackage { - name: p.name.clone(), - version: p.version.clone(), - version_normalized: p.version_normalized.clone(), - source: None, - dist: None, - require: std::collections::BTreeMap::new(), - require_dev: std::collections::BTreeMap::new(), - conflict: std::collections::BTreeMap::new(), - provide: std::collections::BTreeMap::new(), - replace: 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(GenerateResult { - class_count: data.classmap.len(), - has_psr_violations: !psr_violations.is_empty(), - has_ambiguous_classes: ambiguous_found, - }) -} - -#[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![], - homepage: None, - support: None, - extra_fields: BTreeMap::new(), - } - } - - fn make_installed_pkg_with_autoload( - name: &str, - version: &str, - autoload: serde_json::Value, - ) -> InstalledPackageEntry { - let mut entry = make_installed_pkg(name, version); - entry.autoload = Some(autoload); - entry - } - - // ------------------------------------------------------------------------- - // Helper function tests - // ------------------------------------------------------------------------- - - #[test] - fn test_php_escape_backslash() { - assert_eq!(php_escape("Psr\\Log\\"), "Psr\\\\Log\\\\"); - } - - #[test] - fn test_php_escape_quote() { - assert_eq!(php_escape("don't"), "don\\'t"); - } - - #[test] - fn test_php_escape_mixed() { - assert_eq!(php_escape("A\\B'C"), "A\\\\B\\'C"); - } - - #[test] - fn test_file_identifier_known_vector() { - // Known test vector from Composer docs: - // md5("symfony/polyfill-php80:bootstrap.php") = "a4a119a56e50fbb293281d9a48007e0e" - let id = file_identifier("symfony/polyfill-php80", "bootstrap.php"); - assert_eq!(id, "a4a119a56e50fbb293281d9a48007e0e"); - } - - #[test] - fn test_file_identifier_format() { - let id = file_identifier("psr/log", "src/functions.php"); - // Should be 32 hex chars (MD5) - assert_eq!(id.len(), 32); - assert!(id.chars().all(|c| c.is_ascii_hexdigit())); - } - - #[test] - fn test_json_to_paths_string() { - let v = serde_json::json!("src/"); - assert_eq!(json_to_paths(&v), vec!["src/"]); - } - - #[test] - fn test_json_to_paths_array() { - let v = serde_json::json!(["src/", "lib/"]); - assert_eq!(json_to_paths(&v), vec!["src/", "lib/"]); - } - - #[test] - fn test_json_to_paths_invalid() { - let v = serde_json::json!(42); - assert!(json_to_paths(&v).is_empty()); - } - - // ------------------------------------------------------------------------- - // collect_autoloads tests - // ------------------------------------------------------------------------- - - #[test] - fn test_collect_autoloads_psr4_basic() { - let mut installed = InstalledPackages::new(); - installed.upsert(make_installed_pkg_with_autoload( - "psr/log", - "3.0.2", - serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}), - )); - - let (data, _static_data) = collect_autoloads(&installed, None, None, "__root__", false); - - assert!(data.psr4.contains_key("Psr\\Log\\")); - let paths = &data.psr4["Psr\\Log\\"]; - assert_eq!(paths.len(), 1); - assert_eq!(paths[0], "$vendorDir . '/psr/log/src'"); - } - - #[test] - fn test_collect_autoloads_psr4_multiple_dirs() { - let mut installed = InstalledPackages::new(); - installed.upsert(make_installed_pkg_with_autoload( - "monolog/monolog", - "3.8.0", - serde_json::json!({"psr-4": {"Monolog\\": ["src/Monolog", "lib/"]}}), - )); - - let (data, _static_data) = collect_autoloads(&installed, None, None, "__root__", false); - - let paths = &data.psr4["Monolog\\"]; - assert_eq!(paths.len(), 2); - assert_eq!(paths[0], "$vendorDir . '/monolog/monolog/src/Monolog'"); - assert_eq!(paths[1], "$vendorDir . '/monolog/monolog/lib'"); - } - - #[test] - fn test_collect_autoloads_files() { - let mut installed = InstalledPackages::new(); - installed.upsert(make_installed_pkg_with_autoload( - "symfony/polyfill-php80", - "1.32.0", - serde_json::json!({"files": ["bootstrap.php"]}), - )); - - let (data, _static_data) = collect_autoloads(&installed, None, None, "__root__", false); - - // The identifier should match Composer's MD5 computation - let expected_id = "a4a119a56e50fbb293281d9a48007e0e"; - assert!(data.files.contains_key(expected_id)); - assert_eq!( - data.files[expected_id], - "$vendorDir . '/symfony/polyfill-php80/bootstrap.php'" - ); - } - - #[test] - fn test_collect_autoloads_root_package() { - let installed = InstalledPackages::new(); - let root_autoload = serde_json::json!({"psr-4": {"App\\": "src/"}}); - - let (data, _static_data) = collect_autoloads( - &installed, - Some(&root_autoload), - None, - "myproject/app", - false, - ); - - assert!(data.psr4.contains_key("App\\")); - let paths = &data.psr4["App\\"]; - assert_eq!(paths[0], "$baseDir . '/src'"); - } - - #[test] - fn test_collect_autoloads_root_autoload_dev_included_when_dev() { - let installed = InstalledPackages::new(); - let root_autoload_dev = serde_json::json!({"psr-4": {"Tests\\": "tests/"}}); - - let (data, _) = collect_autoloads( - &installed, - None, - Some(&root_autoload_dev), - "myproject/app", - true, // dev_mode = true - ); - - assert!(data.psr4.contains_key("Tests\\")); - } - - #[test] - fn test_collect_autoloads_root_autoload_dev_excluded_when_no_dev() { - let installed = InstalledPackages::new(); - let root_autoload_dev = serde_json::json!({"psr-4": {"Tests\\": "tests/"}}); - - let (data, _) = collect_autoloads( - &installed, - None, - Some(&root_autoload_dev), - "myproject/app", - false, // dev_mode = false - ); - - assert!(!data.psr4.contains_key("Tests\\")); - } - - // ------------------------------------------------------------------------- - // generate_autoload_psr4 tests - // ------------------------------------------------------------------------- - - #[test] - fn test_generate_autoload_psr4_output() { - let mut installed = InstalledPackages::new(); - installed.upsert(make_installed_pkg_with_autoload( - "psr/log", - "3.0.2", - serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}), - )); - - let (data, _) = collect_autoloads(&installed, None, None, "__root__", false); - let output = generate_autoload_psr4(&data); - - assert!(output.contains(" 8")); - } - - // ------------------------------------------------------------------------- - // generate_autoload_real tests - // ------------------------------------------------------------------------- - - #[test] - fn test_generate_autoload_real_with_files() { - let output = generate_autoload_real("abc123", true, false, false, None, false); - assert!(output.contains("class ComposerAutoloaderInitabc123")); - assert!(output.contains("ComposerStaticInitabc123::$files")); - assert!(output.contains("$requireFile")); - assert!(output.contains("__composer_autoload_files")); - } - - #[test] - fn test_generate_autoload_real_without_files() { - let output = generate_autoload_real("abc123", false, false, false, None, false); - assert!(output.contains("class ComposerAutoloaderInitabc123")); - assert!(!output.contains("$filesToLoad")); - assert!(!output.contains("__composer_autoload_files")); - } - - #[test] - fn test_generate_autoload_real_apcu() { - let output = generate_autoload_real("abc123", false, false, true, None, false); - assert!(output.contains("setApcuPrefix('abc123')")); - } - - #[test] - fn test_generate_autoload_real_apcu_custom_prefix() { - let output = generate_autoload_real("abc123", false, false, true, Some("myprefix"), false); - assert!(output.contains("setApcuPrefix('myprefix')")); - } - - #[test] - fn test_generate_autoload_real_platform_check() { - let output = generate_autoload_real("abc123", false, false, false, None, true); - assert!(output.contains("require __DIR__ . '/platform_check.php'")); - } - - #[test] - fn test_generate_autoload_real_no_platform_check() { - let output = generate_autoload_real("abc123", false, false, false, None, false); - assert!(!output.contains("platform_check.php")); - } - - // ------------------------------------------------------------------------- - // generate_installed_php tests - // ------------------------------------------------------------------------- - - #[test] - fn test_generate_installed_php() { - let mut installed = InstalledPackages::new(); - let mut pkg = make_installed_pkg("psr/log", "3.0.2"); - pkg.version_normalized = Some("3.0.2.0".to_string()); - installed.upsert(pkg); - - let output = generate_installed_php("myproject/app", "project", &installed, true); - - assert!(output.contains("'name' => 'myproject/app'")); - assert!(output.contains("'type' => 'project'")); - assert!(output.contains("'dev' => true")); - assert!(output.contains("'psr/log'")); - assert!(output.contains("'pretty_version' => '3.0.2'")); - assert!(output.contains("'version' => '3.0.2.0'")); - assert!(output.contains("__DIR__ . '/../psr/log/'")); - assert!(output.contains("'dev_requirement' => false")); - } - - #[test] - fn test_generate_installed_php_dev_package() { - let mut installed = InstalledPackages::new(); - installed.upsert(make_installed_pkg("phpunit/phpunit", "11.0.0")); - installed - .dev_package_names - .push("phpunit/phpunit".to_string()); - - let output = generate_installed_php("test/project", "project", &installed, true); - - assert!(output.contains("'dev_requirement' => true")); - } - - // ------------------------------------------------------------------------- - // generate() integration test - // ------------------------------------------------------------------------- - - #[test] - fn test_generate_full_roundtrip() { - let dir = tempdir().unwrap(); - let project_dir = dir.path().to_path_buf(); - let vendor_dir = project_dir.join("vendor"); - - // Write a minimal composer.json - std::fs::write( - project_dir.join("composer.json"), - r#"{"name": "test/project", "type": "project", "autoload": {"psr-4": {"App\\": "src/"}}}"#, - ) - .unwrap(); - - // Write a minimal installed.json - let mut installed = InstalledPackages::new(); - installed.upsert(make_installed_pkg_with_autoload( - "psr/log", - "3.0.2", - serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}), - )); - installed.write(&vendor_dir).unwrap(); - - let config = AutoloadConfig { - project_dir: project_dir.clone(), - vendor_dir: vendor_dir.clone(), - dev_mode: false, - suffix: "abc123def456".to_string(), - classmap_authoritative: false, - optimize: false, - apcu: false, - apcu_prefix: None, - strict_psr: false, - strict_ambiguous: 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, - strict_ambiguous: false, - platform_check: PlatformCheckMode::Disabled, - ignore_platform_reqs: false, - }; - - generate(&config).unwrap(); - - // autoload_files.php SHOULD exist - assert!( - vendor_dir.join("composer/autoload_files.php").exists(), - "autoload_files.php should exist when files are present" - ); - - let files_php = - std::fs::read_to_string(vendor_dir.join("composer/autoload_files.php")).unwrap(); - assert!(files_php.contains("a4a119a56e50fbb293281d9a48007e0e")); - assert!(files_php.contains("$vendorDir . '/symfony/polyfill-php80/bootstrap.php'")); - - // autoload_real.php should contain the files loading block - let real_php = - std::fs::read_to_string(vendor_dir.join("composer/autoload_real.php")).unwrap(); - assert!(real_php.contains("$filesToLoad")); - } -} diff --git a/crates/mozart-autoload/src/dump.rs b/crates/mozart-autoload/src/dump.rs deleted file mode 100644 index 103c683..0000000 --- a/crates/mozart-autoload/src/dump.rs +++ /dev/null @@ -1,340 +0,0 @@ -//! `Composer\Autoload\AutoloadGenerator::dump` extension. -//! -//! [`mozart_core::composer::AutoloadGenerator`] is a state container in -//! `mozart-core`; the dumping algorithm itself sits here in -//! `mozart-autoload` because it pulls in the classmap scanner, -//! installed.json reader, and PHP-emission helpers. This module hangs -//! `dump()` off the generator via [`AutoloadGeneratorExt`] so callers -//! can still write `composer.autoload_generator().dump(...)`, matching -//! `$composer->getAutoloadGenerator()->dump(...)` in PHP. -//! -//! Bring [`AutoloadGeneratorExt`] into scope at the call site: -//! -//! ```ignore -//! use mozart_autoload::AutoloadGeneratorExt; -//! ``` -//! -//! See `Composer\Autoload\AutoloadGenerator::dump()` (the ~500-line -//! implementation in `composer/src/Composer/Autoload/AutoloadGenerator.php`) -//! for the upstream semantics. - -use std::collections::BTreeMap; -use std::path::PathBuf; - -use mozart_core::composer::{ - AutoloadDumpOptions, AutoloadGenerator, InstallationManager, LocalRepository, Locker, - PlatformRequirementFilter, -}; -use mozart_core::config::Config; -use mozart_core::package::RawPackageData; - -use crate::autoload::{AutoloadConfig, PlatformCheckMode, generate}; - -/// Mirror of `Composer\ClassMapGenerator\ClassMap` — the return value -/// of `AutoloadGenerator::dump`. PHP's class is a `Countable` carrying -/// the discovered class map plus PSR-violation and ambiguous-class -/// records; Mozart only models the slice that command handlers need to -/// branch on today (`count`, `has_psr_violations`, `has_ambiguous_classes`). -/// -/// The `map` / `psr_violations` / `ambiguous_classes` fields are -/// currently populated from the existing [`generate`]'s coarse -/// summary — once `generate` is refactored to expose the full classmap -/// these fields will hold the real entries. -pub struct ClassMap { - map: BTreeMap, - psr_violations: Vec, - ambiguous_classes: BTreeMap>, -} - -impl ClassMap { - /// Mirror of `ClassMap::count`. - pub fn count(&self) -> usize { - self.map.len() - } - - /// Mirror of `count($classMap->getPsrViolations()) > 0`. PHP returns - /// the violation strings; commands typically only need the boolean. - pub fn has_psr_violations(&self) -> bool { - !self.psr_violations.is_empty() - } - - /// Mirror of `count($classMap->getAmbiguousClasses($filter)) > 0`. - /// `with_filter = true` applies PHP's default test/fixture/example - /// path filter; `false` skips it (the `$duplicatesFilter = false` - /// branch upstream). - pub fn has_ambiguous_classes(&self, with_filter: bool) -> bool { - if !with_filter { - return !self.ambiguous_classes.is_empty(); - } - let pattern = regex_filter_default(); - self.ambiguous_classes.values().any(|paths| { - paths - .iter() - .any(|p| !pattern.is_match(&p.replace('\\', "/"))) - }) - } - - /// Read access to the underlying map (`getMap()` upstream). - pub fn map(&self) -> &BTreeMap { - &self.map - } - - /// Read access to the PSR-violation warnings. - pub fn psr_violations(&self) -> &[String] { - &self.psr_violations - } - - /// Read access to the ambiguous-class records. - pub fn ambiguous_classes(&self) -> &BTreeMap> { - &self.ambiguous_classes - } -} - -fn regex_filter_default() -> regex::Regex { - use std::sync::OnceLock; - static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| { - // `{/(test|fixture|example|stub)s?/}i` from PHP's - // ClassMap::getAmbiguousClasses default. - regex::Regex::new(r"(?i)/(test|fixture|example|stub)s?/") - .expect("default ambiguous filter compiles") - }) - .clone() -} - -/// Extension trait hanging `dump()` off -/// [`mozart_core::composer::AutoloadGenerator`]. Mirrors -/// `Composer\Autoload\AutoloadGenerator::dump()`. -/// -/// Bring this trait into scope (`use mozart_autoload::AutoloadGeneratorExt;`) -/// to make the method visible. -/// -/// Diverges from PHP in one place: the per-call toggles PHP fixes via -/// `setDryRun` / `setDevMode` / … on the generator are passed in here -/// as an [`AutoloadDumpOptions`] argument, because Mozart's -/// [`AutoloadGenerator`] is stateless. -pub trait AutoloadGeneratorExt { - /// Mirror of `AutoloadGenerator::dump(Config $config, - /// InstalledRepositoryInterface $localRepo, RootPackageInterface - /// $rootPackage, InstallationManager $installationManager, string - /// $targetDir, bool $scanPsrPackages = false, ?string $suffix = null, - /// ?Locker $locker = null, bool $strictAmbiguous = false)`. - /// - /// Mozart-specific notes: - /// - `options` carries the toggles PHP fixes via setters on the - /// generator (`setDryRun`, `setDevMode`, `setApcu`, …). - /// - `target_dir` is currently unused (the underlying [`generate`] - /// always writes into `vendor_dir/composer`); the parameter is - /// kept on the signature so the call site mirrors PHP and we can - /// honour it once the writer is parameterised. - /// - `local_repo` and `root_package` are accepted to mirror the - /// PHP signature, but [`generate`] currently re-reads them from - /// `installed.json` / `composer.json`. Refactoring to consume the - /// passed-in values lives in a follow-up. - #[allow(clippy::too_many_arguments)] - fn dump( - &self, - options: &AutoloadDumpOptions, - config: &Config, - local_repo: &LocalRepository, - root_package: &RawPackageData, - installation_manager: &InstallationManager, - target_dir: &str, - scan_psr_packages: bool, - suffix: Option<&str>, - locker: &Locker, - strict_ambiguous: bool, - ) -> anyhow::Result; -} - -impl AutoloadGeneratorExt for AutoloadGenerator { - fn dump( - &self, - options: &AutoloadDumpOptions, - config: &Config, - _local_repo: &LocalRepository, - _root_package: &RawPackageData, - installation_manager: &InstallationManager, - _target_dir: &str, - scan_psr_packages: bool, - suffix: Option<&str>, - locker: &Locker, - strict_ambiguous: bool, - ) -> anyhow::Result { - // Mirrors PHP: classmap-authoritative implies PSR scanning so - // every class gets a fixed map entry. - let scan = scan_psr_packages || options.class_map_authoritative; - - // Mirrors PHP's `if (null === $this->devMode)` branch: read the - // `dev` flag from `vendor/composer/installed.json` when no - // explicit dev-mode has been set on the options. - let dev_mode = match options.dev_mode { - Some(m) => m, - None => read_installed_dev_flag(installation_manager.vendor_dir()), - }; - - // Mirrors PHP's suffix resolution chain in `dump()`: - // 1. explicit argument - // 2. `Config::get('autoloader-suffix')` - // 3. existing `vendor/autoload.php`'s `ComposerAutoloaderInit{X}` - // 4. `composer.lock`'s `content-hash` (when locked) - // 5. random hex - let resolved_suffix = resolve_suffix(suffix, config, installation_manager, locker)?; - - // Mirrors PHP: `$basePath = realpath(getcwd())`. We don't have - // an explicit project_dir on the generator, but `vendor_dir`'s - // parent matches the project root for the common - // `vendor-dir = "vendor"` layout. When the user points - // `vendor-dir` outside the project we fall back to `.`. - let project_dir = installation_manager - .vendor_dir() - .parent() - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| PathBuf::from(".")); - - // Mirrors PHP's `$checkPlatform = $config->get('platform-check') !== - // false && !($filter instanceof IgnoreAllPlatformRequirementFilter)`. - let platform_check = if matches!( - options.platform_requirement_filter, - PlatformRequirementFilter::IgnoreAll - ) { - PlatformCheckMode::Disabled - } else { - platform_check_mode_from_config(&config.platform_check) - }; - - let cfg = AutoloadConfig { - project_dir, - vendor_dir: installation_manager.vendor_dir().to_path_buf(), - dev_mode, - suffix: resolved_suffix, - classmap_authoritative: options.class_map_authoritative, - optimize: scan, - apcu: options.apcu, - apcu_prefix: options.apcu_prefix.clone(), - // `dump()` does not surface a `--strict-psr` option (that's - // a separate command-line flag on `dump-autoload`); the - // generator only reports violations via `ClassMap`. - strict_psr: false, - strict_ambiguous, - platform_check, - ignore_platform_reqs: matches!( - options.platform_requirement_filter, - PlatformRequirementFilter::IgnoreAll - ), - }; - - if options.dry_run { - // PHP's dry-run still scans and returns the classmap but - // skips file writes. The current [`generate`] does not - // expose a dry-run hook, so we return an empty ClassMap - // for now and surface the limitation here rather than - // silently writing files. - return Ok(ClassMap { - map: BTreeMap::new(), - psr_violations: Vec::new(), - ambiguous_classes: BTreeMap::new(), - }); - } - - let result = generate(&cfg)?; - - // Mozart's `GenerateResult` only carries summary flags - // (`class_count`, `has_psr_violations`, `has_ambiguous_classes`), - // not the actual class-name / path entries that PHP's `ClassMap` - // exposes. We project the summary onto a `ClassMap` shape so - // command code that only branches on `count()` / `has_*()` works - // today; refactoring `generate` to surface the full map is - // tracked as follow-up work. - let mut map = BTreeMap::new(); - for i in 0..result.class_count { - map.insert(format!("__mozart_placeholder_{i}"), String::new()); - } - let psr_violations = if result.has_psr_violations { - vec![String::from( - "PSR-0/4 violation detected (details not yet surfaced)", - )] - } else { - Vec::new() - }; - let mut ambiguous_classes = BTreeMap::new(); - if result.has_ambiguous_classes { - ambiguous_classes.insert("__mozart_placeholder".to_string(), Vec::new()); - } - - Ok(ClassMap { - map, - psr_violations, - ambiguous_classes, - }) - } -} - -fn read_installed_dev_flag(vendor_dir: &std::path::Path) -> bool { - let path = vendor_dir.join("composer/installed.json"); - if !path.exists() { - return false; - } - let Ok(content) = std::fs::read_to_string(&path) else { - return false; - }; - let Ok(value) = serde_json::from_str::(&content) else { - return false; - }; - value.get("dev").and_then(|v| v.as_bool()).unwrap_or(false) -} - -fn resolve_suffix( - explicit: Option<&str>, - config: &Config, - installation_manager: &InstallationManager, - locker: &Locker, -) -> anyhow::Result { - if let Some(s) = explicit - && !s.is_empty() - { - return Ok(s.to_string()); - } - if let Some(s) = config.autoloader_suffix.as_ref() - && !s.is_empty() - { - return Ok(s.clone()); - } - let vendor_path = installation_manager.vendor_dir(); - let autoload_path = vendor_path.join("autoload.php"); - if autoload_path.exists() - && let Ok(content) = std::fs::read_to_string(&autoload_path) - && let Some(start) = content.find("ComposerAutoloaderInit") - { - let rest = &content[start + "ComposerAutoloaderInit".len()..]; - if let Some(end) = rest.find("::") { - let candidate = &rest[..end]; - if !candidate.is_empty() && candidate.chars().all(|c| c.is_ascii_hexdigit()) { - return Ok(candidate.to_string()); - } - } - } - if locker.is_locked() - && let Some(data) = locker.lock_data()? - && !data.content_hash.is_empty() - { - return Ok(data.content_hash); - } - // Fall back to MD5 of the current timestamp (mirrors PHP's - // `bin2hex(random_bytes(16))` — both produce a 32-char hex token - // that participates only in classloader naming). - let ts = format!("{:?}", std::time::SystemTime::now()); - Ok(format!("{:x}", md5::compute(ts.as_bytes()))) -} - -fn platform_check_mode_from_config(platform_check: &serde_json::Value) -> PlatformCheckMode { - match platform_check { - serde_json::Value::Bool(false) => PlatformCheckMode::Disabled, - serde_json::Value::Bool(true) => PlatformCheckMode::Full, - serde_json::Value::String(s) if s == "php-only" => PlatformCheckMode::PhpOnly, - // Anything else (including JSON null / unknown strings) falls - // through to `Full` — the safe default that PHP also picks - // when the value is truthy-but-not-`"php-only"`. - _ => PlatformCheckMode::Full, - } -} diff --git a/crates/mozart-autoload/src/lib.rs b/crates/mozart-autoload/src/lib.rs deleted file mode 100644 index 0ee48fe..0000000 --- a/crates/mozart-autoload/src/lib.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod autoload; -pub mod dump; - -pub use dump::{AutoloadGeneratorExt, ClassMap}; diff --git a/crates/mozart-core/Cargo.toml b/crates/mozart-core/Cargo.toml index 716a2d5..cca9499 100644 --- a/crates/mozart-core/Cargo.toml +++ b/crates/mozart-core/Cargo.toml @@ -4,17 +4,33 @@ version.workspace = true edition.workspace = true [dependencies] +mozart-class-map-generator.workspace = true mozart-console-macros.workspace = true +mozart-metadata-minifier.workspace = true +mozart-php-serialize.workspace = true +mozart-semver.workspace = true mozart-spdx-licenses.workspace = true anyhow.workspace = true +async-trait.workspace = true +base64.workspace = true +bzip2.workspace = true colored.workspace = true dialoguer.workspace = true +filetime.workspace = true +flate2.workspace = true indexmap.workspace = true +md5.workspace = true regex.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true +sha1.workspace = true +tar.workspace = true +tempfile.workspace = true +tokio.workspace = true tracing.workspace = true +url.workspace = true +zip.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/mozart-core/src/autoload.rs b/crates/mozart-core/src/autoload.rs new file mode 100644 index 0000000..0d15900 --- /dev/null +++ b/crates/mozart-core/src/autoload.rs @@ -0,0 +1,1912 @@ +use crate::composer::{ + AutoloadDumpOptions, AutoloadGenerator, InstallationManager, LocalRepository, Locker, + PlatformRequirementFilter, +}; +use crate::config::Config; +use crate::package::RawPackageData; +use crate::repository::installed::InstalledPackages; +use crate::repository::lockfile::LockedPackage; +use indexmap::IndexSet; +use mozart_class_map_generator::{scan_classmap_dirs, scan_psr_for_classmap}; +use std::collections::BTreeMap; +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, +} + +/// Result of autoload generation, reporting statistics and warnings. +pub struct GenerateResult { + pub class_count: usize, + pub has_psr_violations: bool, + pub has_ambiguous_classes: bool, +} + +/// Configuration for autoload generation. +pub struct AutoloadConfig { + /// Absolute path to the project root (where composer.json lives). + pub project_dir: PathBuf, + /// Absolute path to the vendor directory. + pub vendor_dir: PathBuf, + /// Whether dev-mode autoloading is active (include autoload-dev rules). + pub dev_mode: bool, + /// Unique suffix for the autoloader class names (typically the lock file content-hash). + /// Used to generate `ComposerAutoloaderInit{suffix}` and `ComposerStaticInit{suffix}`. + pub suffix: String, + /// When true, emit `$loader->setClassMapAuthoritative(true)` in the generated autoloader. + pub classmap_authoritative: bool, + /// When true, scan PSR-4/PSR-0 directories and generate a full classmap (optimize mode). + pub optimize: bool, + /// When true, generate APCu-based class caching in the autoloader. + pub apcu: bool, + /// Optional prefix for APCu cache keys (implies `apcu`). + pub apcu_prefix: Option, + /// When true, return an error on PSR mapping violations detected during classmap scan. + pub strict_psr: bool, + /// When true, return exit code 2 if ambiguous class mappings are detected. + pub strict_ambiguous: bool, + /// How to handle platform requirement checks. + pub platform_check: PlatformCheckMode, + /// When true, skip all platform requirement checks. + pub ignore_platform_reqs: bool, +} + +/// Collected autoload mappings from all packages. +pub struct AutoloadData { + /// PSR-4: namespace prefix -> list of directory path expressions. + /// Each path is a PHP expression string like `$vendorDir . '/psr/log/src'`. + pub psr4: BTreeMap>, + /// PSR-0: namespace prefix -> list of directory path expressions. + /// (Empty in Phase 2.2, populated in 5.6.) + pub psr0: BTreeMap>, + /// Classmap entries: class name -> file path expression. + /// (Empty in Phase 2.2, populated in 5.6.) + pub classmap: BTreeMap, + /// Files to include on every request: file_identifier -> path expression. + pub files: BTreeMap, +} + +/// Mirror of `Composer\ClassMapGenerator\ClassMap` — the return value +/// of `AutoloadGenerator::dump`. PHP's class is a `Countable` carrying +/// the discovered class map plus PSR-violation and ambiguous-class +/// records; Mozart only models the slice that command handlers need to +/// branch on today (`count`, `has_psr_violations`, `has_ambiguous_classes`). +/// +/// The `map` / `psr_violations` / `ambiguous_classes` fields are +/// currently populated from the existing [`generate`]'s coarse +/// summary — once `generate` is refactored to expose the full classmap +/// these fields will hold the real entries. +pub struct ClassMap { + map: BTreeMap, + psr_violations: Vec, + ambiguous_classes: BTreeMap>, +} + +impl ClassMap { + /// Mirror of `ClassMap::count`. + pub fn count(&self) -> usize { + self.map.len() + } + + /// Mirror of `count($classMap->getPsrViolations()) > 0`. PHP returns + /// the violation strings; commands typically only need the boolean. + pub fn has_psr_violations(&self) -> bool { + !self.psr_violations.is_empty() + } + + /// Mirror of `count($classMap->getAmbiguousClasses($filter)) > 0`. + /// `with_filter = true` applies PHP's default test/fixture/example + /// path filter; `false` skips it (the `$duplicatesFilter = false` + /// branch upstream). + pub fn has_ambiguous_classes(&self, with_filter: bool) -> bool { + if !with_filter { + return !self.ambiguous_classes.is_empty(); + } + let pattern = regex_filter_default(); + self.ambiguous_classes.values().any(|paths| { + paths + .iter() + .any(|p| !pattern.is_match(&p.replace('\\', "/"))) + }) + } + + /// Read access to the underlying map (`getMap()` upstream). + pub fn map(&self) -> &BTreeMap { + &self.map + } + + /// Read access to the PSR-violation warnings. + pub fn psr_violations(&self) -> &[String] { + &self.psr_violations + } + + /// Read access to the ambiguous-class records. + pub fn ambiguous_classes(&self) -> &BTreeMap> { + &self.ambiguous_classes + } +} + +fn regex_filter_default() -> regex::Regex { + use std::sync::OnceLock; + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + // `{/(test|fixture|example|stub)s?/}i` from PHP's + // ClassMap::getAmbiguousClasses default. + regex::Regex::new(r"(?i)/(test|fixture|example|stub)s?/") + .expect("default ambiguous filter compiles") + }) + .clone() +} + +/// Extension trait hanging `dump()` off +/// [`crate::composer::AutoloadGenerator`]. Mirrors +/// `Composer\Autoload\AutoloadGenerator::dump()`. +/// +/// Bring this trait into scope (`use mozart_autoload::AutoloadGeneratorExt;`) +/// to make the method visible. +/// +/// Diverges from PHP in one place: the per-call toggles PHP fixes via +/// `setDryRun` / `setDevMode` / … on the generator are passed in here +/// as an [`AutoloadDumpOptions`] argument, because Mozart's +/// [`AutoloadGenerator`] is stateless. +pub trait AutoloadGeneratorExt { + /// Mirror of `AutoloadGenerator::dump(Config $config, + /// InstalledRepositoryInterface $localRepo, RootPackageInterface + /// $rootPackage, InstallationManager $installationManager, string + /// $targetDir, bool $scanPsrPackages = false, ?string $suffix = null, + /// ?Locker $locker = null, bool $strictAmbiguous = false)`. + /// + /// Mozart-specific notes: + /// - `options` carries the toggles PHP fixes via setters on the + /// generator (`setDryRun`, `setDevMode`, `setApcu`, …). + /// - `target_dir` is currently unused (the underlying [`generate`] + /// always writes into `vendor_dir/composer`); the parameter is + /// kept on the signature so the call site mirrors PHP and we can + /// honour it once the writer is parameterised. + /// - `local_repo` and `root_package` are accepted to mirror the + /// PHP signature, but [`generate`] currently re-reads them from + /// `installed.json` / `composer.json`. Refactoring to consume the + /// passed-in values lives in a follow-up. + #[allow(clippy::too_many_arguments)] + fn dump( + &self, + options: &AutoloadDumpOptions, + config: &Config, + local_repo: &LocalRepository, + root_package: &RawPackageData, + installation_manager: &InstallationManager, + target_dir: &str, + scan_psr_packages: bool, + suffix: Option<&str>, + locker: &Locker, + strict_ambiguous: bool, + ) -> anyhow::Result; +} + +impl AutoloadGeneratorExt for AutoloadGenerator { + fn dump( + &self, + options: &AutoloadDumpOptions, + config: &Config, + _local_repo: &LocalRepository, + _root_package: &RawPackageData, + installation_manager: &InstallationManager, + _target_dir: &str, + scan_psr_packages: bool, + suffix: Option<&str>, + locker: &Locker, + strict_ambiguous: bool, + ) -> anyhow::Result { + // Mirrors PHP: classmap-authoritative implies PSR scanning so + // every class gets a fixed map entry. + let scan = scan_psr_packages || options.class_map_authoritative; + + // Mirrors PHP's `if (null === $this->devMode)` branch: read the + // `dev` flag from `vendor/composer/installed.json` when no + // explicit dev-mode has been set on the options. + let dev_mode = match options.dev_mode { + Some(m) => m, + None => read_installed_dev_flag(installation_manager.vendor_dir()), + }; + + // Mirrors PHP's suffix resolution chain in `dump()`: + // 1. explicit argument + // 2. `Config::get('autoloader-suffix')` + // 3. existing `vendor/autoload.php`'s `ComposerAutoloaderInit{X}` + // 4. `composer.lock`'s `content-hash` (when locked) + // 5. random hex + let resolved_suffix = resolve_suffix(suffix, config, installation_manager, locker)?; + + // Mirrors PHP: `$basePath = realpath(getcwd())`. We don't have + // an explicit project_dir on the generator, but `vendor_dir`'s + // parent matches the project root for the common + // `vendor-dir = "vendor"` layout. When the user points + // `vendor-dir` outside the project we fall back to `.`. + let project_dir = installation_manager + .vendor_dir() + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")); + + // Mirrors PHP's `$checkPlatform = $config->get('platform-check') !== + // false && !($filter instanceof IgnoreAllPlatformRequirementFilter)`. + let platform_check = if matches!( + options.platform_requirement_filter, + PlatformRequirementFilter::IgnoreAll + ) { + PlatformCheckMode::Disabled + } else { + platform_check_mode_from_config(&config.platform_check) + }; + + let cfg = AutoloadConfig { + project_dir, + vendor_dir: installation_manager.vendor_dir().to_path_buf(), + dev_mode, + suffix: resolved_suffix, + classmap_authoritative: options.class_map_authoritative, + optimize: scan, + apcu: options.apcu, + apcu_prefix: options.apcu_prefix.clone(), + // `dump()` does not surface a `--strict-psr` option (that's + // a separate command-line flag on `dump-autoload`); the + // generator only reports violations via `ClassMap`. + strict_psr: false, + strict_ambiguous, + platform_check, + ignore_platform_reqs: matches!( + options.platform_requirement_filter, + PlatformRequirementFilter::IgnoreAll + ), + }; + + if options.dry_run { + // PHP's dry-run still scans and returns the classmap but + // skips file writes. The current [`generate`] does not + // expose a dry-run hook, so we return an empty ClassMap + // for now and surface the limitation here rather than + // silently writing files. + return Ok(ClassMap { + map: BTreeMap::new(), + psr_violations: Vec::new(), + ambiguous_classes: BTreeMap::new(), + }); + } + + let result = generate(&cfg)?; + + // Mozart's `GenerateResult` only carries summary flags + // (`class_count`, `has_psr_violations`, `has_ambiguous_classes`), + // not the actual class-name / path entries that PHP's `ClassMap` + // exposes. We project the summary onto a `ClassMap` shape so + // command code that only branches on `count()` / `has_*()` works + // today; refactoring `generate` to surface the full map is + // tracked as follow-up work. + let mut map = BTreeMap::new(); + for i in 0..result.class_count { + map.insert(format!("__mozart_placeholder_{i}"), String::new()); + } + let psr_violations = if result.has_psr_violations { + vec![String::from( + "PSR-0/4 violation detected (details not yet surfaced)", + )] + } else { + Vec::new() + }; + let mut ambiguous_classes = BTreeMap::new(); + if result.has_ambiguous_classes { + ambiguous_classes.insert("__mozart_placeholder".to_string(), Vec::new()); + } + + Ok(ClassMap { + map, + psr_violations, + ambiguous_classes, + }) + } +} + +fn read_installed_dev_flag(vendor_dir: &std::path::Path) -> bool { + let path = vendor_dir.join("composer/installed.json"); + if !path.exists() { + return false; + } + let Ok(content) = std::fs::read_to_string(&path) else { + return false; + }; + let Ok(value) = serde_json::from_str::(&content) else { + return false; + }; + value.get("dev").and_then(|v| v.as_bool()).unwrap_or(false) +} + +fn resolve_suffix( + explicit: Option<&str>, + config: &Config, + installation_manager: &InstallationManager, + locker: &Locker, +) -> anyhow::Result { + if let Some(s) = explicit + && !s.is_empty() + { + return Ok(s.to_string()); + } + if let Some(s) = config.autoloader_suffix.as_ref() + && !s.is_empty() + { + return Ok(s.clone()); + } + let vendor_path = installation_manager.vendor_dir(); + let autoload_path = vendor_path.join("autoload.php"); + if autoload_path.exists() + && let Ok(content) = std::fs::read_to_string(&autoload_path) + && let Some(start) = content.find("ComposerAutoloaderInit") + { + let rest = &content[start + "ComposerAutoloaderInit".len()..]; + if let Some(end) = rest.find("::") { + let candidate = &rest[..end]; + if !candidate.is_empty() && candidate.chars().all(|c| c.is_ascii_hexdigit()) { + return Ok(candidate.to_string()); + } + } + } + if locker.is_locked() + && let Some(data) = locker.lock_data()? + && !data.content_hash.is_empty() + { + return Ok(data.content_hash); + } + // Fall back to MD5 of the current timestamp (mirrors PHP's + // `bin2hex(random_bytes(16))` — both produce a 32-char hex token + // that participates only in classloader naming). + let ts = format!("{:?}", std::time::SystemTime::now()); + Ok(format!("{:x}", md5::compute(ts.as_bytes()))) +} + +fn platform_check_mode_from_config(platform_check: &serde_json::Value) -> PlatformCheckMode { + match platform_check { + serde_json::Value::Bool(false) => PlatformCheckMode::Disabled, + serde_json::Value::Bool(true) => PlatformCheckMode::Full, + serde_json::Value::String(s) if s == "php-only" => PlatformCheckMode::PhpOnly, + // Anything else (including JSON null / unknown strings) falls + // through to `Full` — the safe default that PHP also picks + // when the value is truthy-but-not-`"php-only"`. + _ => PlatformCheckMode::Full, + } +} + +/// Escape a string for use in a PHP single-quoted string literal. +pub fn php_escape(s: &str) -> String { + s.replace('\\', "\\\\").replace('\'', "\\'") +} + +/// Compute the file identifier matching Composer's `getFileIdentifier()`. +/// This is the MD5 hex digest of `"package_name:path"`. +pub fn file_identifier(package_name: &str, path: &str) -> String { + let input = format!("{package_name}:{path}"); + format!("{:x}", md5::compute(input.as_bytes())) +} + +/// Extract a path or array of paths from a JSON value. +/// Handles both string and array-of-strings (Composer allows both). +fn json_to_paths(value: &serde_json::Value) -> Vec { + match value { + serde_json::Value::String(s) => vec![s.clone()], + serde_json::Value::Array(arr) => arr + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(), + _ => vec![], + } +} + +/// Strip trailing slash from a path component. +fn strip_trailing_slash(s: &str) -> &str { + s.trim_end_matches('/') +} + +/// Normalize a PSR-4 namespace: ensure it ends with `\`. +/// (The empty string "" is valid and is left as-is.) +fn normalize_namespace(ns: &str) -> String { + if ns.is_empty() || ns.ends_with('\\') { + ns.to_string() + } else { + format!("{ns}\\") + } +} + +/// Build a PHP path expression from a base expression and a relative path component. +/// +/// For vendor packages: `base_expr` = `"$vendorDir"`, `pkg_path` = `"psr/log"`, +/// `sub_path` = `"src/"` → result: `"$vendorDir . '/psr/log/src'"`. +/// +/// For root packages: `base_expr` = `"$baseDir"`, `pkg_path` = `""`, +/// `sub_path` = `"src/"` → result: `"$baseDir . '/src'"`. +fn build_path_expr(base_expr: &str, pkg_path: &str, sub_path: &str) -> String { + let sub = strip_trailing_slash(sub_path); + let combined = if pkg_path.is_empty() { + sub.to_string() + } else if sub.is_empty() { + pkg_path.to_string() + } else { + format!("{pkg_path}/{sub}") + }; + + if combined.is_empty() { + base_expr.to_string() + } else { + format!("{base_expr} . '/{combined}'") + } +} + +/// Process an autoload JSON value and merge its rules into `data`. +/// +/// `pkg_path` is the package-relative path segment within vendor. +/// For vendor packages it is `"vendor/name"` (e.g. `"psr/log"`). +/// For the root package it is `""`. +/// +/// `dyn_base` is the dynamic PHP variable: `"$vendorDir"` or `"$baseDir"`. +/// `static_base` is the static PHP expression: `"__DIR__ . '/..'"` or `"__DIR__ . '/../.'"`. +fn process_autoload_value( + autoload_val: &serde_json::Value, + package_name: &str, + pkg_path: &str, + dyn_base: &str, + static_base: &str, + data: &mut AutoloadData, + static_data: &mut AutoloadData, +) { + // PSR-4 + if let Some(psr4_obj) = autoload_val.get("psr-4").and_then(|v| v.as_object()) { + for (ns_raw, paths_val) in psr4_obj { + let ns = normalize_namespace(ns_raw); + let paths = json_to_paths(paths_val); + let entry = data.psr4.entry(ns.clone()).or_default(); + let static_entry = static_data.psr4.entry(ns).or_default(); + for path in paths { + entry.push(build_path_expr(dyn_base, pkg_path, &path)); + static_entry.push(build_path_expr(static_base, pkg_path, &path)); + } + } + } + + // PSR-0 + if let Some(psr0_obj) = autoload_val.get("psr-0").and_then(|v| v.as_object()) { + for (ns_raw, paths_val) in psr0_obj { + let ns = ns_raw.clone(); + let paths = json_to_paths(paths_val); + let entry = data.psr0.entry(ns.clone()).or_default(); + let static_entry = static_data.psr0.entry(ns).or_default(); + for path in paths { + entry.push(build_path_expr(dyn_base, pkg_path, &path)); + static_entry.push(build_path_expr(static_base, pkg_path, &path)); + } + } + } + + // Files + if let Some(files_arr) = autoload_val.get("files").and_then(|v| v.as_array()) { + for file_val in files_arr { + if let Some(file_path) = file_val.as_str() { + let id = file_identifier(package_name, file_path); + let expr = build_path_expr(dyn_base, pkg_path, file_path); + let static_expr = build_path_expr(static_base, pkg_path, file_path); + data.files.insert(id.clone(), expr); + static_data.files.insert(id, static_expr); + } + } + } +} + +/// Collect autoload rules from all installed packages and the root package. +/// +/// Returns a tuple of `(dynamic_data, static_data)` where: +/// - `dynamic_data` uses `$vendorDir` / `$baseDir` path expressions (for autoload_psr4.php, etc.) +/// - `static_data` uses `__DIR__ . '/..'` path expressions (for autoload_static.php) +fn collect_autoloads( + installed: &InstalledPackages, + root_autoload: Option<&serde_json::Value>, + root_autoload_dev: Option<&serde_json::Value>, + root_package_name: &str, + dev_mode: bool, +) -> (AutoloadData, AutoloadData) { + let mut data = AutoloadData { + psr4: BTreeMap::new(), + psr0: BTreeMap::new(), + classmap: BTreeMap::new(), + files: BTreeMap::new(), + }; + let mut static_data = AutoloadData { + psr4: BTreeMap::new(), + psr0: BTreeMap::new(), + classmap: BTreeMap::new(), + files: BTreeMap::new(), + }; + + // Process each installed package + for pkg in &installed.packages { + if let Some(autoload_val) = &pkg.autoload { + process_autoload_value( + autoload_val, + &pkg.name, + &pkg.name, // pkg_path within vendor + "$vendorDir", + "__DIR__ . '/..'", + &mut data, + &mut static_data, + ); + } + } + + // Process root package autoload + if let Some(autoload_val) = root_autoload { + process_autoload_value( + autoload_val, + root_package_name, + "", // no pkg_path for root + "$baseDir", + "__DIR__ . '/../..'", + &mut data, + &mut static_data, + ); + } + + // Process root package autoload-dev (only in dev mode) + if dev_mode && let Some(autoload_dev_val) = root_autoload_dev { + process_autoload_value( + autoload_dev_val, + root_package_name, + "", + "$baseDir", + "__DIR__ . '/../..'", + &mut data, + &mut static_data, + ); + } + + (data, static_data) +} + +/// Generate `vendor/composer/autoload_psr4.php`. +fn generate_autoload_psr4(data: &AutoloadData) -> String { + let mut out = String::new(); + out.push_str(")> = data.psr4.iter().collect(); + sorted.sort_by(|(a, _), (b, _)| b.cmp(a)); + + for (ns, paths) in &sorted { + let escaped_ns = php_escape(ns); + if paths.len() == 1 { + out.push_str(&format!(" '{}' => array({}),\n", escaped_ns, paths[0])); + } else { + out.push_str(&format!(" '{}' => array(\n", escaped_ns)); + for path in paths.iter() { + out.push_str(&format!(" {},\n", path)); + } + out.push_str(" ),\n"); + } + } + + out.push_str(");\n"); + out +} + +/// Generate `vendor/composer/autoload_namespaces.php` (PSR-0, empty for Phase 2.2). +fn generate_autoload_namespaces(data: &AutoloadData) -> String { + let mut out = String::new(); + out.push_str(")> = data.psr0.iter().collect(); + sorted.sort_by(|(a, _), (b, _)| b.cmp(a)); + + for (ns, paths) in &sorted { + let escaped_ns = php_escape(ns); + if paths.len() == 1 { + out.push_str(&format!(" '{}' => array({}),\n", escaped_ns, paths[0])); + } else { + out.push_str(&format!(" '{}' => array(\n", escaped_ns)); + for path in paths.iter() { + out.push_str(&format!(" {},\n", path)); + } + out.push_str(" ),\n"); + } + } + + out.push_str(");\n"); + out +} + +/// Generate `vendor/composer/autoload_classmap.php`. +/// Always contains `Composer\InstalledVersions`; classmap scanning deferred to Phase 5.6. +fn generate_autoload_classmap(data: &AutoloadData) -> String { + let mut out = String::new(); + out.push_str(" $vendorDir . '/composer/InstalledVersions.php',\n", + ); + + // Include any additional classmap entries from data + for (class, path) in &data.classmap { + let escaped_class = php_escape(class); + out.push_str(&format!(" '{}' => {},\n", escaped_class, path)); + } + + out.push_str(");\n"); + out +} + +/// Generate `vendor/composer/autoload_files.php`. +/// Returns `None` if there are no files to autoload. +fn generate_autoload_files(data: &AutoloadData) -> Option { + if data.files.is_empty() { + return None; + } + + let mut out = String::new(); + out.push_str(" {},\n", id, path)); + } + + out.push_str(");\n"); + Some(out) +} + +/// Generate `vendor/composer/autoload_static.php`. +/// +/// `static_data` must have been collected with `__DIR__ . '/..'` path prefixes. +fn generate_autoload_static(static_data: &AutoloadData, suffix: &str) -> String { + let mut out = String::new(); + out.push_str(" {path},\n")); + } + out.push_str(" );\n\n"); + } + + // $prefixLengthsPsr4 — group by first character of namespace + if !static_data.psr4.is_empty() { + // Group namespaces by first character, sorted reverse + let mut by_char: BTreeMap> = BTreeMap::new(); + + let mut sorted_ns: Vec<&String> = static_data.psr4.keys().collect(); + sorted_ns.sort_by(|a, b| b.cmp(a)); + + for ns in sorted_ns { + if let Some(first_char) = ns.chars().next() { + // The byte length in PHP (single-quoted string with single backslashes) + // ns in our data uses single backslash (stored as-is from JSON). + let byte_len = ns.len(); + by_char.entry(first_char).or_default().push((ns, byte_len)); + } + } + + out.push_str(" public static $prefixLengthsPsr4 = array (\n"); + // Sort characters in reverse order too + let mut chars: Vec = by_char.keys().copied().collect(); + chars.sort_by(|a, b| b.cmp(a)); + for ch in &chars { + out.push_str(&format!(" '{ch}' =>\n array (\n")); + if let Some(entries) = by_char.get(ch) { + for (ns, len) in entries { + let escaped_ns = php_escape(ns); + out.push_str(&format!(" '{escaped_ns}' => {len},\n")); + } + } + out.push_str(" ),\n"); + } + out.push_str(" );\n\n"); + + // $prefixDirsPsr4 + out.push_str(" public static $prefixDirsPsr4 = array (\n"); + let mut sorted_ns2: Vec<(&String, &Vec)> = static_data.psr4.iter().collect(); + sorted_ns2.sort_by(|(a, _), (b, _)| b.cmp(a)); + for (ns, paths) in sorted_ns2 { + let escaped_ns = php_escape(ns); + out.push_str(&format!(" '{escaped_ns}' =>\n array (\n")); + for (i, path) in paths.iter().enumerate() { + out.push_str(&format!(" {i} => {path},\n")); + } + out.push_str(" ),\n"); + } + out.push_str(" );\n\n"); + } + + // $classMap — always contains Composer\InstalledVersions + out.push_str(" public static $classMap = array (\n"); + out.push_str( + " 'Composer\\\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',\n", + ); + for (class, path) in &static_data.classmap { + let escaped_class = php_escape(class); + out.push_str(&format!(" '{}' => {},\n", escaped_class, path)); + } + out.push_str(" );\n\n"); + + // getInitializer + out.push_str(" public static function getInitializer(ClassLoader $loader)\n {\n"); + out.push_str(" return \\Closure::bind(function () use ($loader) {\n"); + + if !static_data.psr4.is_empty() { + out.push_str(&format!( + " $loader->prefixLengthsPsr4 = ComposerStaticInit{suffix}::$prefixLengthsPsr4;\n" + )); + out.push_str(&format!( + " $loader->prefixDirsPsr4 = ComposerStaticInit{suffix}::$prefixDirsPsr4;\n" + )); + } + out.push_str(&format!( + " $loader->classMap = ComposerStaticInit{suffix}::$classMap;\n" + )); + out.push_str("\n }, null, ClassLoader::class);\n }\n}\n"); + + out +} + +/// 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: &IndexSet, +) -> Option { + if matches!(mode, PlatformCheckMode::Disabled) { + return None; + } + + // Collect PHP version constraint from root require + let mut php_constraint: Option = None; + if let Some(req_obj) = root_require.and_then(|v| v.as_object()) + && let Some(v) = req_obj.get("php").and_then(|v| v.as_str()) + { + php_constraint = Some(v.to_string()); + } + + // Collect extension requirements from packages (prod only) + let mut ext_reqs: Vec<(String, String)> = Vec::new(); + if matches!(mode, PlatformCheckMode::Full) { + for pkg in packages { + let is_dev = dev_package_names.contains(&pkg.name.to_lowercase()); + if is_dev { + continue; + } + for (req_name, req_constraint) in &pkg.require { + let lower = req_name.to_lowercase(); + if lower.starts_with("ext-") { + ext_reqs.push((req_name.clone(), req_constraint.clone())); + } + } + } + ext_reqs.sort(); + ext_reqs.dedup(); + } + + if php_constraint.is_none() && ext_reqs.is_empty() { + return None; + } + + let mut out = String::new(); + out.push_str("= 50600)) {\n"); + out.push_str(&format!( + " $issues[] = 'Your Composer dependencies require a PHP version \"{escaped}\". You are running ' . PHP_VERSION . '.';\n" + )); + out.push_str("}\n\n"); + } + + for (ext_name, _constraint) in &ext_reqs { + let ext_short = ext_name.trim_start_matches("ext-"); + let escaped_ext = php_escape(ext_short); + out.push_str(&format!("if (!extension_loaded('{escaped_ext}')) {{\n")); + out.push_str(&format!( + " $issues[] = 'Your Composer dependencies require the \"{escaped_ext}\" PHP extension to be installed.';\n" + )); + out.push_str("}\n\n"); + } + + out.push_str("if ($issues) {\n"); + out.push_str(" if (!headers_sent()) {\n"); + out.push_str(" header('HTTP/1.1 500 Internal Server Error');\n"); + out.push_str(" }\n"); + out.push_str(" if (!ini_get('display_errors')) {\n"); + out.push_str(" if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {\n"); + out.push_str(" fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL);\n"); + out.push_str(" } elseif (!headers_sent()) {\n"); + out.push_str(" echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL;\n"); + out.push_str(" }\n"); + out.push_str(" }\n"); + out.push_str(" trigger_error(\n"); + out.push_str( + " 'Composer detected issues in your platform: ' . implode(' ', $issues),\n", + ); + out.push_str(" E_USER_ERROR\n"); + out.push_str(" );\n"); + out.push_str("}\n"); + + Some(out) +} + +/// Generate `vendor/composer/autoload_real.php`. +fn generate_autoload_real( + suffix: &str, + has_files: bool, + classmap_authoritative: bool, + apcu: bool, + apcu_prefix: Option<&str>, + has_platform_check: bool, +) -> String { + let mut out = String::new(); + out.push_str("register(true);\n"); + + if classmap_authoritative { + out.push_str(" $loader->setClassMapAuthoritative(true);\n"); + } + + if apcu { + let prefix = apcu_prefix.unwrap_or(suffix); + let escaped = php_escape(prefix); + out.push_str(&format!(" $loader->setApcuPrefix('{escaped}');\n")); + } + + if has_files { + out.push('\n'); + out.push_str(&format!( + " $filesToLoad = \\Composer\\Autoload\\ComposerStaticInit{suffix}::$files;\n" + )); + out.push_str( + " $requireFile = \\Closure::bind(static function ($fileIdentifier, $file) {\n", + ); + out.push_str( + " if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) {\n", + ); + out.push_str( + " $GLOBALS['__composer_autoload_files'][$fileIdentifier] = true;\n", + ); + out.push('\n'); + out.push_str(" require $file;\n"); + out.push_str(" }\n"); + out.push_str(" }, null, null);\n"); + out.push_str(" foreach ($filesToLoad as $fileIdentifier => $file) {\n"); + out.push_str(" $requireFile($fileIdentifier, $file);\n"); + out.push_str(" }\n"); + } + + out.push('\n'); + out.push_str(" return $loader;\n"); + out.push_str(" }\n"); + out.push_str("}\n"); + out +} + +/// Generate `vendor/autoload.php` (the entry point). +fn generate_autoload_php(suffix: &str) -> String { + let mut out = String::new(); + out.push_str(" String { + let dev_str = if dev_mode { "true" } else { "false" }; + + let mut out = String::new(); + out.push_str(" array(\n"); + out.push_str(&format!(" 'name' => '{}',\n", php_escape(root_name))); + out.push_str(" 'pretty_version' => 'dev-main',\n"); + out.push_str(" 'version' => 'dev-main',\n"); + out.push_str(" 'reference' => null,\n"); + out.push_str(&format!(" 'type' => '{}',\n", php_escape(root_type))); + out.push_str(" 'install_path' => __DIR__ . '/../../',\n"); + out.push_str(" 'aliases' => array(),\n"); + out.push_str(&format!(" 'dev' => {dev_str},\n")); + out.push_str(" ),\n"); + out.push_str(" 'versions' => array(\n"); + + for pkg in &installed.packages { + let version = &pkg.version; + let version_normalized = pkg.version_normalized.as_deref().unwrap_or(version); + let pkg_type = pkg.package_type.as_deref().unwrap_or("library"); + let is_dev = installed + .dev_package_names + .iter() + .any(|n| n.eq_ignore_ascii_case(&pkg.name)); + let is_dev_str = if is_dev { "true" } else { "false" }; + + out.push_str(&format!(" '{}' => array(\n", php_escape(&pkg.name))); + out.push_str(&format!( + " 'pretty_version' => '{}',\n", + php_escape(version) + )); + out.push_str(&format!( + " 'version' => '{}',\n", + php_escape(version_normalized) + )); + out.push_str(" 'reference' => null,\n"); + out.push_str(&format!( + " 'type' => '{}',\n", + php_escape(pkg_type) + )); + // Install path relative to vendor/composer/installed.php: __DIR__ . '/./' . relative_name + // The install_path stored is like '../psr/log', relative to vendor/composer/ + // So from vendor/composer/, the package is at __DIR__ . '/../psr/log/' + out.push_str(&format!( + " 'install_path' => __DIR__ . '/../{}/',\n", + pkg.name + )); + out.push_str(" 'aliases' => array(),\n"); + out.push_str(&format!(" 'dev_requirement' => {is_dev_str},\n")); + out.push_str(" ),\n"); + } + + out.push_str(" ),\n"); + out.push_str(");\n"); + out +} + +/// Determine the autoloader suffix. +/// +/// Priority: +/// 1. Existing `vendor/autoload.php` suffix (carry over to avoid breaking existing references). +/// 2. Lock file `content-hash` (if locked). +/// 3. Fall back to a timestamp-based hex string. +pub fn determine_suffix(working_dir: &Path, vendor_dir: &Path) -> anyhow::Result { + // Try existing autoload.php + let autoload_path = vendor_dir.join("autoload.php"); + if autoload_path.exists() { + let content = std::fs::read_to_string(&autoload_path)?; + if let Some(start) = content.find("ComposerAutoloaderInit") { + let rest = &content[start + "ComposerAutoloaderInit".len()..]; + if let Some(end) = rest.find("::") { + let suffix = &rest[..end]; + if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_hexdigit()) { + return Ok(suffix.to_string()); + } + } + } + } + + // Try composer.lock content-hash + let lock_path = working_dir.join("composer.lock"); + if lock_path.exists() { + let lock = crate::repository::lockfile::LockFile::read_from_file(&lock_path)?; + return Ok(lock.content_hash); + } + + // Fall back to MD5 of current timestamp + let ts = format!("{:?}", std::time::SystemTime::now()); + Ok(format!("{:x}", md5::compute(ts.as_bytes()))) +} + +/// Generate all autoloader files for the given project. +/// +/// This is the main entry point called by `install` and `dump-autoload`. +pub fn generate(config: &AutoloadConfig) -> anyhow::Result { + // 1. Read installed.json + let installed = InstalledPackages::read(&config.vendor_dir)?; + + // 2. Read root package autoload from composer.json + let composer_json_path = config.project_dir.join("composer.json"); + let (root_autoload, root_autoload_dev, root_name, root_type) = if composer_json_path.exists() { + let content = std::fs::read_to_string(&composer_json_path)?; + let value: serde_json::Value = serde_json::from_str(&content)?; + ( + value.get("autoload").cloned(), + value.get("autoload-dev").cloned(), + value + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or("__root__") + .to_string(), + value + .get("type") + .and_then(|t| t.as_str()) + .unwrap_or("project") + .to_string(), + ) + } else { + (None, None, "__root__".to_string(), "project".to_string()) + }; + + // 3. Collect autoload data + let (mut data, mut static_data) = collect_autoloads( + &installed, + root_autoload.as_ref(), + root_autoload_dev.as_ref(), + &root_name, + config.dev_mode, + ); + + // 3a. Read classmap dirs declared in composer.json + let excluded: Vec = root_autoload + .as_ref() + .and_then(|v| v.get("exclude-from-classmap")) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + + // Scan explicit classmap dirs from all packages + let mut classmap_dirs: Vec = Vec::new(); + + // Collect classmap dirs from installed packages + for pkg in &installed.packages { + if let Some(autoload_val) = &pkg.autoload + && let Some(cm_arr) = autoload_val.get("classmap").and_then(|v| v.as_array()) + { + for cm_val in cm_arr { + if let Some(cm_path) = cm_val.as_str() { + let abs = config.vendor_dir.join(&pkg.name).join(cm_path); + classmap_dirs.push(abs); + } + } + } + } + + // Collect classmap dirs from root autoload + if let Some(autoload_val) = root_autoload.as_ref() + && let Some(cm_arr) = autoload_val.get("classmap").and_then(|v| v.as_array()) + { + for cm_val in cm_arr { + if let Some(cm_path) = cm_val.as_str() { + let abs = config.project_dir.join(cm_path); + classmap_dirs.push(abs); + } + } + } + + // Scan classmap dirs + let mut ambiguous_found = false; + 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 { + if let Some(existing) = data.classmap.get(&class) + && existing != &path_expr + { + ambiguous_found = true; + } + // Also generate the static expression + // We store the dynamic expression in data.classmap; static_data.classmap + // will be populated similarly. For now we insert into both. + data.classmap.entry(class.clone()).or_insert(path_expr); + // Generate corresponding static expr by replacing dynamic prefixes + // (static_data classmap is populated in the static pass below) + } + } + + // 3b. Optimize mode: scan PSR-4/PSR-0 dirs for classmap + let do_optimize = config.optimize || config.classmap_authoritative; + let mut psr_violations: Vec = Vec::new(); + + if do_optimize { + let (opt_dyn, opt_static, violations) = scan_psr_for_classmap( + &data.psr4, + &data.psr0, + &config.vendor_dir, + &config.project_dir, + &excluded, + ); + psr_violations = violations; + for (class, path_expr) in opt_dyn { + if let Some(existing) = data.classmap.get(&class) + && existing != &path_expr + { + ambiguous_found = true; + } + 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: IndexSet = installed + .dev_package_names + .iter() + .map(|n| n.to_lowercase()) + .collect(); + + // Re-read composer.json for root require (not from autoload, but from root "require" key) + let root_require_val: Option = if composer_json_path.exists() { + let content = std::fs::read_to_string(&composer_json_path)?; + let value: serde_json::Value = serde_json::from_str(&content)?; + value.get("require").cloned() + } else { + None + }; + + let all_locked: Vec = { + // Collect locked packages from installed for platform check + // (installed.packages are LockedPackage-compatible via InstalledPackageEntry) + // We'll build minimal LockedPackage-like data from installed entries + installed + .packages + .iter() + .map(|p| crate::repository::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(), + provide: std::collections::BTreeMap::new(), + replace: 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(GenerateResult { + class_count: data.classmap.len(), + has_psr_violations: !psr_violations.is_empty(), + has_ambiguous_classes: ambiguous_found, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repository::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![], + homepage: None, + support: None, + extra_fields: BTreeMap::new(), + } + } + + fn make_installed_pkg_with_autoload( + name: &str, + version: &str, + autoload: serde_json::Value, + ) -> InstalledPackageEntry { + let mut entry = make_installed_pkg(name, version); + entry.autoload = Some(autoload); + entry + } + + // ------------------------------------------------------------------------- + // Helper function tests + // ------------------------------------------------------------------------- + + #[test] + fn test_php_escape_backslash() { + assert_eq!(php_escape("Psr\\Log\\"), "Psr\\\\Log\\\\"); + } + + #[test] + fn test_php_escape_quote() { + assert_eq!(php_escape("don't"), "don\\'t"); + } + + #[test] + fn test_php_escape_mixed() { + assert_eq!(php_escape("A\\B'C"), "A\\\\B\\'C"); + } + + #[test] + fn test_file_identifier_known_vector() { + // Known test vector from Composer docs: + // md5("symfony/polyfill-php80:bootstrap.php") = "a4a119a56e50fbb293281d9a48007e0e" + let id = file_identifier("symfony/polyfill-php80", "bootstrap.php"); + assert_eq!(id, "a4a119a56e50fbb293281d9a48007e0e"); + } + + #[test] + fn test_file_identifier_format() { + let id = file_identifier("psr/log", "src/functions.php"); + // Should be 32 hex chars (MD5) + assert_eq!(id.len(), 32); + assert!(id.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_json_to_paths_string() { + let v = serde_json::json!("src/"); + assert_eq!(json_to_paths(&v), vec!["src/"]); + } + + #[test] + fn test_json_to_paths_array() { + let v = serde_json::json!(["src/", "lib/"]); + assert_eq!(json_to_paths(&v), vec!["src/", "lib/"]); + } + + #[test] + fn test_json_to_paths_invalid() { + let v = serde_json::json!(42); + assert!(json_to_paths(&v).is_empty()); + } + + // ------------------------------------------------------------------------- + // collect_autoloads tests + // ------------------------------------------------------------------------- + + #[test] + fn test_collect_autoloads_psr4_basic() { + let mut installed = InstalledPackages::new(); + installed.upsert(make_installed_pkg_with_autoload( + "psr/log", + "3.0.2", + serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}), + )); + + let (data, _static_data) = collect_autoloads(&installed, None, None, "__root__", false); + + assert!(data.psr4.contains_key("Psr\\Log\\")); + let paths = &data.psr4["Psr\\Log\\"]; + assert_eq!(paths.len(), 1); + assert_eq!(paths[0], "$vendorDir . '/psr/log/src'"); + } + + #[test] + fn test_collect_autoloads_psr4_multiple_dirs() { + let mut installed = InstalledPackages::new(); + installed.upsert(make_installed_pkg_with_autoload( + "monolog/monolog", + "3.8.0", + serde_json::json!({"psr-4": {"Monolog\\": ["src/Monolog", "lib/"]}}), + )); + + let (data, _static_data) = collect_autoloads(&installed, None, None, "__root__", false); + + let paths = &data.psr4["Monolog\\"]; + assert_eq!(paths.len(), 2); + assert_eq!(paths[0], "$vendorDir . '/monolog/monolog/src/Monolog'"); + assert_eq!(paths[1], "$vendorDir . '/monolog/monolog/lib'"); + } + + #[test] + fn test_collect_autoloads_files() { + let mut installed = InstalledPackages::new(); + installed.upsert(make_installed_pkg_with_autoload( + "symfony/polyfill-php80", + "1.32.0", + serde_json::json!({"files": ["bootstrap.php"]}), + )); + + let (data, _static_data) = collect_autoloads(&installed, None, None, "__root__", false); + + // The identifier should match Composer's MD5 computation + let expected_id = "a4a119a56e50fbb293281d9a48007e0e"; + assert!(data.files.contains_key(expected_id)); + assert_eq!( + data.files[expected_id], + "$vendorDir . '/symfony/polyfill-php80/bootstrap.php'" + ); + } + + #[test] + fn test_collect_autoloads_root_package() { + let installed = InstalledPackages::new(); + let root_autoload = serde_json::json!({"psr-4": {"App\\": "src/"}}); + + let (data, _static_data) = collect_autoloads( + &installed, + Some(&root_autoload), + None, + "myproject/app", + false, + ); + + assert!(data.psr4.contains_key("App\\")); + let paths = &data.psr4["App\\"]; + assert_eq!(paths[0], "$baseDir . '/src'"); + } + + #[test] + fn test_collect_autoloads_root_autoload_dev_included_when_dev() { + let installed = InstalledPackages::new(); + let root_autoload_dev = serde_json::json!({"psr-4": {"Tests\\": "tests/"}}); + + let (data, _) = collect_autoloads( + &installed, + None, + Some(&root_autoload_dev), + "myproject/app", + true, // dev_mode = true + ); + + assert!(data.psr4.contains_key("Tests\\")); + } + + #[test] + fn test_collect_autoloads_root_autoload_dev_excluded_when_no_dev() { + let installed = InstalledPackages::new(); + let root_autoload_dev = serde_json::json!({"psr-4": {"Tests\\": "tests/"}}); + + let (data, _) = collect_autoloads( + &installed, + None, + Some(&root_autoload_dev), + "myproject/app", + false, // dev_mode = false + ); + + assert!(!data.psr4.contains_key("Tests\\")); + } + + // ------------------------------------------------------------------------- + // generate_autoload_psr4 tests + // ------------------------------------------------------------------------- + + #[test] + fn test_generate_autoload_psr4_output() { + let mut installed = InstalledPackages::new(); + installed.upsert(make_installed_pkg_with_autoload( + "psr/log", + "3.0.2", + serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}), + )); + + let (data, _) = collect_autoloads(&installed, None, None, "__root__", false); + let output = generate_autoload_psr4(&data); + + assert!(output.contains(" 8")); + } + + // ------------------------------------------------------------------------- + // generate_autoload_real tests + // ------------------------------------------------------------------------- + + #[test] + fn test_generate_autoload_real_with_files() { + let output = generate_autoload_real("abc123", true, false, false, None, false); + assert!(output.contains("class ComposerAutoloaderInitabc123")); + assert!(output.contains("ComposerStaticInitabc123::$files")); + assert!(output.contains("$requireFile")); + assert!(output.contains("__composer_autoload_files")); + } + + #[test] + fn test_generate_autoload_real_without_files() { + let output = generate_autoload_real("abc123", false, false, false, None, false); + assert!(output.contains("class ComposerAutoloaderInitabc123")); + assert!(!output.contains("$filesToLoad")); + assert!(!output.contains("__composer_autoload_files")); + } + + #[test] + fn test_generate_autoload_real_apcu() { + let output = generate_autoload_real("abc123", false, false, true, None, false); + assert!(output.contains("setApcuPrefix('abc123')")); + } + + #[test] + fn test_generate_autoload_real_apcu_custom_prefix() { + let output = generate_autoload_real("abc123", false, false, true, Some("myprefix"), false); + assert!(output.contains("setApcuPrefix('myprefix')")); + } + + #[test] + fn test_generate_autoload_real_platform_check() { + let output = generate_autoload_real("abc123", false, false, false, None, true); + assert!(output.contains("require __DIR__ . '/platform_check.php'")); + } + + #[test] + fn test_generate_autoload_real_no_platform_check() { + let output = generate_autoload_real("abc123", false, false, false, None, false); + assert!(!output.contains("platform_check.php")); + } + + // ------------------------------------------------------------------------- + // generate_installed_php tests + // ------------------------------------------------------------------------- + + #[test] + fn test_generate_installed_php() { + let mut installed = InstalledPackages::new(); + let mut pkg = make_installed_pkg("psr/log", "3.0.2"); + pkg.version_normalized = Some("3.0.2.0".to_string()); + installed.upsert(pkg); + + let output = generate_installed_php("myproject/app", "project", &installed, true); + + assert!(output.contains("'name' => 'myproject/app'")); + assert!(output.contains("'type' => 'project'")); + assert!(output.contains("'dev' => true")); + assert!(output.contains("'psr/log'")); + assert!(output.contains("'pretty_version' => '3.0.2'")); + assert!(output.contains("'version' => '3.0.2.0'")); + assert!(output.contains("__DIR__ . '/../psr/log/'")); + assert!(output.contains("'dev_requirement' => false")); + } + + #[test] + fn test_generate_installed_php_dev_package() { + let mut installed = InstalledPackages::new(); + installed.upsert(make_installed_pkg("phpunit/phpunit", "11.0.0")); + installed + .dev_package_names + .push("phpunit/phpunit".to_string()); + + let output = generate_installed_php("test/project", "project", &installed, true); + + assert!(output.contains("'dev_requirement' => true")); + } + + // ------------------------------------------------------------------------- + // generate() integration test + // ------------------------------------------------------------------------- + + #[test] + fn test_generate_full_roundtrip() { + let dir = tempdir().unwrap(); + let project_dir = dir.path().to_path_buf(); + let vendor_dir = project_dir.join("vendor"); + + // Write a minimal composer.json + std::fs::write( + project_dir.join("composer.json"), + r#"{"name": "test/project", "type": "project", "autoload": {"psr-4": {"App\\": "src/"}}}"#, + ) + .unwrap(); + + // Write a minimal installed.json + let mut installed = InstalledPackages::new(); + installed.upsert(make_installed_pkg_with_autoload( + "psr/log", + "3.0.2", + serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}), + )); + installed.write(&vendor_dir).unwrap(); + + let config = AutoloadConfig { + project_dir: project_dir.clone(), + vendor_dir: vendor_dir.clone(), + dev_mode: false, + suffix: "abc123def456".to_string(), + classmap_authoritative: false, + optimize: false, + apcu: false, + apcu_prefix: None, + strict_psr: false, + strict_ambiguous: 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, + strict_ambiguous: 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-core/src/dependency_resolver.rs b/crates/mozart-core/src/dependency_resolver.rs new file mode 100644 index 0000000..2e3fefb --- /dev/null +++ b/crates/mozart-core/src/dependency_resolver.rs @@ -0,0 +1,25 @@ +pub mod decisions; +pub mod error; +pub mod policy; +pub mod pool; +pub mod pool_builder; +pub mod problem; +pub mod request; +pub mod rule; +pub mod rule_set; +pub mod rule_set_generator; +pub mod rule_watch_graph; +pub mod solver; +pub mod transaction; + +// Re-export key types for public API +pub use error::SolverError; +pub use policy::DefaultPolicy; +pub use pool::{Literal, PackageId, Pool, PoolLink, PoolPackage, PoolPackageInput}; +pub use pool_builder::{PoolBuilder, make_pool_links}; +pub use request::Request; +pub use rule::{ReasonData, Rule, RuleReason}; +pub use rule_set::RuleSet; +pub use rule_set_generator::RuleSetGenerator; +pub use solver::{Solver, SolverResult}; +pub use transaction::{LockTransaction, Operation, Transaction}; diff --git a/crates/mozart-core/src/dependency_resolver/decisions.rs b/crates/mozart-core/src/dependency_resolver/decisions.rs new file mode 100644 index 0000000..510092f --- /dev/null +++ b/crates/mozart-core/src/dependency_resolver/decisions.rs @@ -0,0 +1,263 @@ +use super::error::SolverBugError; +use super::pool::{Literal, PackageId, literal_to_package_id}; +use super::rule_set::RuleId; +use indexmap::IndexMap; + +/// A decision entry: which literal was decided and which rule caused it. +#[derive(Debug, Clone)] +pub struct Decision { + pub literal: Literal, + pub rule_id: RuleId, +} + +/// Tracks all decisions (variable assignments) made during solving. +/// +/// Port of Composer's Decisions.php. +pub struct Decisions { + /// Package ID → signed level. Positive = install, negative = uninstall. + /// The absolute value is the decision level. + decision_map: IndexMap, + /// Queue of decisions in order. + decision_queue: Vec, +} + +impl Decisions { + pub fn new() -> Self { + Decisions { + decision_map: IndexMap::new(), + decision_queue: Vec::new(), + } + } + + /// Record a decision. + pub fn decide( + &mut self, + literal: Literal, + level: i32, + rule_id: RuleId, + ) -> Result<(), SolverBugError> { + let package_id = literal_to_package_id(literal); + let previous = self.decision_map.get(&package_id).copied().unwrap_or(0); + if previous != 0 { + return Err(SolverBugError { + message: format!( + "Trying to decide literal {literal} on level {level}, \ + even though package {package_id} was previously decided as {previous}." + ), + }); + } + + if literal > 0 { + self.decision_map.insert(package_id, level); + } else { + self.decision_map.insert(package_id, -level); + } + + self.decision_queue.push(Decision { literal, rule_id }); + Ok(()) + } + + /// Check if literal is satisfied (true in current assignment). + pub fn satisfy(&self, literal: Literal) -> bool { + let package_id = literal_to_package_id(literal); + match self.decision_map.get(&package_id) { + Some(&val) => (literal > 0 && val > 0) || (literal < 0 && val < 0), + None => false, + } + } + + /// Check if literal conflicts with current assignment. + pub fn conflict(&self, literal: Literal) -> bool { + let package_id = literal_to_package_id(literal); + match self.decision_map.get(&package_id) { + Some(&val) => (val > 0 && literal < 0) || (val < 0 && literal > 0), + None => false, + } + } + + /// Check if package has been decided. + pub fn decided(&self, literal_or_id: i32) -> bool { + let package_id = literal_or_id.unsigned_abs(); + self.decision_map.get(&package_id).copied().unwrap_or(0) != 0 + } + + /// Check if package is undecided. + pub fn undecided(&self, literal_or_id: i32) -> bool { + !self.decided(literal_or_id) + } + + /// Check if package is decided for installation. + pub fn decided_install(&self, literal_or_id: i32) -> bool { + let package_id = literal_or_id.unsigned_abs(); + self.decision_map.get(&package_id).copied().unwrap_or(0) > 0 + } + + /// Get the decision level for a package (0 if undecided). + pub fn decision_level(&self, literal_or_id: i32) -> i32 { + let package_id = literal_or_id.unsigned_abs(); + self.decision_map + .get(&package_id) + .copied() + .unwrap_or(0) + .abs() + } + + /// Get the rule ID that caused a decision for a package. + pub fn decision_rule(&self, literal_or_id: i32) -> Result { + let package_id = literal_or_id.unsigned_abs(); + for decision in &self.decision_queue { + if literal_to_package_id(decision.literal) == package_id { + return Ok(decision.rule_id); + } + } + Err(SolverBugError { + message: format!("Did not find a decision rule for {literal_or_id}"), + }) + } + + /// Get decision at a specific offset in the queue. + pub fn at_offset(&self, offset: usize) -> &Decision { + &self.decision_queue[offset] + } + + /// Check if an offset is valid. + pub fn valid_offset(&self, offset: usize) -> bool { + offset < self.decision_queue.len() + } + + /// Get the rule ID of the last decision. + pub fn last_reason(&self) -> RuleId { + self.decision_queue.last().unwrap().rule_id + } + + /// Get the literal of the last decision. + pub fn last_literal(&self) -> Literal { + self.decision_queue.last().unwrap().literal + } + + /// Clear all decisions. + pub fn reset(&mut self) { + while let Some(decision) = self.decision_queue.pop() { + let pkg_id = literal_to_package_id(decision.literal); + self.decision_map.insert(pkg_id, 0); + } + } + + /// Remove decisions after the given offset (keep offset+1 items). + pub fn reset_to_offset(&mut self, offset: usize) { + while self.decision_queue.len() > offset + 1 { + let decision = self.decision_queue.pop().unwrap(); + let pkg_id = literal_to_package_id(decision.literal); + self.decision_map.insert(pkg_id, 0); + } + } + + /// Remove the last decision. + pub fn revert_last(&mut self) { + let decision = self.decision_queue.pop().unwrap(); + let pkg_id = literal_to_package_id(decision.literal); + self.decision_map.insert(pkg_id, 0); + } + + /// Number of decisions. + pub fn len(&self) -> usize { + self.decision_queue.len() + } + + /// Whether there are no decisions. + pub fn is_empty(&self) -> bool { + self.decision_queue.is_empty() + } + + /// Iterate decisions in reverse order (newest first). + /// Used by analyzeUnsolvable in Composer. + pub fn iter_reverse(&self) -> impl Iterator { + self.decision_queue.iter().rev() + } +} + +impl Default for Decisions { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_decide_and_satisfy() { + let mut d = Decisions::new(); + d.decide(1, 1, 0).unwrap(); // install package 1 at level 1 + + assert!(d.satisfy(1)); + assert!(!d.satisfy(-1)); + assert!(d.conflict(-1)); + assert!(!d.conflict(1)); + assert!(d.decided(1)); + assert!(d.decided_install(1)); + } + + #[test] + fn test_decide_negative() { + let mut d = Decisions::new(); + d.decide(-1, 1, 0).unwrap(); // don't install package 1 + + assert!(d.satisfy(-1)); + assert!(!d.satisfy(1)); + assert!(d.conflict(1)); + assert!(d.decided(1)); + assert!(!d.decided_install(1)); + } + + #[test] + fn test_undecided() { + let d = Decisions::new(); + assert!(d.undecided(1)); + assert!(!d.decided(1)); + assert!(!d.satisfy(1)); + assert!(!d.conflict(1)); + } + + #[test] + fn test_revert_last() { + let mut d = Decisions::new(); + d.decide(1, 1, 0).unwrap(); + d.decide(2, 2, 1).unwrap(); + + assert!(d.decided(2)); + d.revert_last(); + assert!(d.undecided(2)); + assert!(d.decided(1)); + } + + #[test] + fn test_reset_to_offset() { + let mut d = Decisions::new(); + d.decide(1, 1, 0).unwrap(); + d.decide(2, 2, 1).unwrap(); + d.decide(3, 3, 2).unwrap(); + + d.reset_to_offset(0); // keep only first decision + assert_eq!(d.len(), 1); + assert!(d.decided(1)); + assert!(d.undecided(2)); + assert!(d.undecided(3)); + } + + #[test] + fn test_double_decide_error() { + let mut d = Decisions::new(); + d.decide(1, 1, 0).unwrap(); + assert!(d.decide(1, 2, 1).is_err()); + } + + #[test] + fn test_decision_level() { + let mut d = Decisions::new(); + d.decide(1, 3, 0).unwrap(); + assert_eq!(d.decision_level(1), 3); + assert_eq!(d.decision_level(2), 0); // undecided + } +} diff --git a/crates/mozart-core/src/dependency_resolver/error.rs b/crates/mozart-core/src/dependency_resolver/error.rs new file mode 100644 index 0000000..e4b9841 --- /dev/null +++ b/crates/mozart-core/src/dependency_resolver/error.rs @@ -0,0 +1,50 @@ +use std::fmt; + +/// A bug in the solver itself (should never happen in normal operation). +/// Equivalent to Composer's SolverBugException. +#[derive(Debug, Clone)] +pub struct SolverBugError { + pub message: String, +} + +impl fmt::Display for SolverBugError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Solver bug: {}", self.message) + } +} + +impl std::error::Error for SolverBugError {} + +/// Errors produced by the SAT solver. +#[derive(Debug)] +pub enum SolverError { + /// Internal solver bug (should never happen). + Bug(SolverBugError), + /// The dependency set is unsolvable. Contains problem descriptions. + Unsolvable(Vec), +} + +impl fmt::Display for SolverError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SolverError::Bug(e) => write!(f, "{e}"), + SolverError::Unsolvable(problems) => { + for (i, problem) in problems.iter().enumerate() { + if i > 0 { + writeln!(f)?; + } + write!(f, " Problem {}: {problem}", i + 1)?; + } + Ok(()) + } + } + } +} + +impl std::error::Error for SolverError {} + +impl From for SolverError { + fn from(e: SolverBugError) -> Self { + SolverError::Bug(e) + } +} diff --git a/crates/mozart-core/src/dependency_resolver/policy.rs b/crates/mozart-core/src/dependency_resolver/policy.rs new file mode 100644 index 0000000..d761d58 --- /dev/null +++ b/crates/mozart-core/src/dependency_resolver/policy.rs @@ -0,0 +1,264 @@ +use super::pool::{Literal, Pool}; +use indexmap::IndexMap; + +/// Version selection policy: decides which version to prefer when multiple +/// candidates satisfy a requirement. +/// +/// Port of Composer's DefaultPolicy.php. +pub struct DefaultPolicy { + /// Whether to prefer stable versions. + pub prefer_stable: bool, + /// Whether to prefer lowest versions. + pub prefer_lowest: bool, + /// `name → normalized version` overrides used when more than one + /// candidate could satisfy a requirement: a literal pinned at the + /// preferred version wins outright over the usual highest/lowest pick. + /// Mirrors Composer's `DefaultPolicy::pruneToBestVersion` behavior under + /// `--minimal-changes`, where the lock's previously-installed versions + /// are passed in so the solver only moves a package when a constraint + /// actually forces a different version. + pub preferred_versions: Option>, +} + +impl DefaultPolicy { + pub fn new(prefer_stable: bool, prefer_lowest: bool) -> Self { + DefaultPolicy { + prefer_stable, + prefer_lowest, + preferred_versions: None, + } + } + + pub fn with_preferred( + prefer_stable: bool, + prefer_lowest: bool, + preferred_versions: IndexMap, + ) -> Self { + DefaultPolicy { + prefer_stable, + prefer_lowest, + preferred_versions: Some(preferred_versions), + } + } + + /// Select preferred packages from a list of candidate literals. + /// Returns the literals sorted by preference (most preferred first). + /// + /// Port of Composer's DefaultPolicy::selectPreferredPackages. + pub fn select_preferred_packages( + &self, + pool: &Pool, + literals: &[Literal], + _required_package: Option<&str>, + ) -> Vec { + if literals.is_empty() { + return vec![]; + } + + // Group literals by package name + let mut groups: IndexMap<&str, Vec> = IndexMap::new(); + for &lit in literals { + let pkg = pool.literal_to_package(lit); + groups.entry(pkg.name.as_str()).or_default().push(lit); + } + + // Sort each group by version preference + for lits in groups.values_mut() { + lits.sort_by(|&a, &b| self.compare_by_priority(pool, a, b)); + } + + // Prune to best version within each group + for lits in groups.values_mut() { + *lits = self.prune_to_best_version(pool, lits); + } + + // Merge and sort across all packages + let mut selected: Vec = groups.into_values().flatten().collect(); + selected.sort_by(|&a, &b| self.compare_by_priority(pool, a, b)); + + selected + } + + /// Compare two package literals by priority. + /// Returns Ordering: negative means a is preferred. + fn compare_by_priority(&self, pool: &Pool, a: Literal, b: Literal) -> std::cmp::Ordering { + let pkg_a = pool.literal_to_package(a); + let pkg_b = pool.literal_to_package(b); + + // If same name, apply Composer's policy ordering. Mirrors + // `DefaultPolicy::versionCompare`: when `prefer_stable` is on and + // the two candidates have different stabilities, the more-stable + // one wins outright — `prefer_lowest` only kicks in within the same + // stability tier. Otherwise sort by version (asc for prefer_lowest, + // desc otherwise). + if pkg_a.name == pkg_b.name { + if self.prefer_stable { + let stab_a = stability_priority(&pkg_a.version); + let stab_b = stability_priority(&pkg_b.version); + if stab_a != stab_b { + return stab_a.cmp(&stab_b); + } + } + let cmp = self.compare_versions(&pkg_a.version, &pkg_b.version); + return if self.prefer_lowest { + cmp + } else { + cmp.reverse() + }; + } + + // Different names: when one package replaces the other, prefer the + // *replaced* original. Mirrors the `replaces()` shortcut in + // Composer's `DefaultPolicy::compareByPriority` (the cross-package + // `ignoreReplace=false` pass). Without this, a request like + // `update a/installed` where the pool also contains an + // `a/replacer` declaring `replace: { "a/installed": "dev-master" }` + // could fall through to package-id tie-break and land on the + // replacer instead of the package the user actually asked for. + if pkg_a.replaces.iter().any(|link| link.target == pkg_b.name) { + return std::cmp::Ordering::Greater; + } + if pkg_b.replaces.iter().any(|link| link.target == pkg_a.name) { + return std::cmp::Ordering::Less; + } + + // Different names, no replace relationship: sort by package ID + // for reproducibility. + pkg_a.id.cmp(&pkg_b.id) + } + + /// Compare two normalized version strings. + fn compare_versions(&self, a: &str, b: &str) -> std::cmp::Ordering { + match ( + mozart_semver::Version::parse(a), + mozart_semver::Version::parse(b), + ) { + (Ok(va), Ok(vb)) => va.cmp(&vb), + _ => a.cmp(b), + } + } + + /// Prune to the best version among a sorted list of literals for the same package. + fn prune_to_best_version(&self, pool: &Pool, literals: &[Literal]) -> Vec { + if literals.is_empty() { + return vec![]; + } + + // Mirror Composer's `DefaultPolicy::pruneToBestVersion` short-circuit: + // when a preferred version is set for this package and one of the + // candidates matches it exactly, that wins over the regular + // highest/lowest pick. Falls through otherwise (e.g. the locked + // version no longer satisfies the constraint and was filtered out + // before reaching this method). + if let Some(ref preferred) = self.preferred_versions { + let name = pool.literal_to_package(literals[0]).name.clone(); + if let Some(preferred_ver) = preferred.get(&name) { + let preferred_lits: Vec = literals + .iter() + .filter(|&&lit| pool.literal_to_package(lit).version == *preferred_ver) + .copied() + .collect(); + if !preferred_lits.is_empty() { + return preferred_lits; + } + } + } + + // The first literal is the best after sorting + let best_version = &pool.literal_to_package(literals[0]).version; + literals + .iter() + .filter(|&&lit| pool.literal_to_package(lit).version == *best_version) + .copied() + .collect() + } +} + +impl Default for DefaultPolicy { + fn default() -> Self { + DefaultPolicy::new(false, false) + } +} + +/// Map a normalized version string to Composer's stability priority +/// (`BasePackage::STABILITIES`). Lower = more stable. Stable=0, RC=5, beta=10, +/// alpha=15, dev=20. Mirrors `DefaultPolicy::versionCompare`'s comparison +/// when `prefer_stable` is set. +fn stability_priority(version: &str) -> u8 { + let Ok(v) = mozart_semver::Version::parse(version) else { + return 0; + }; + if v.is_dev_branch { + return 20; + } + match v.pre_release.as_deref() { + None => 0, + Some(pre) => { + let lower = pre.to_lowercase(); + if lower.starts_with("dev") { + 20 + } else if lower.starts_with("alpha") || lower == "a" { + 15 + } else if lower.starts_with("beta") || lower == "b" { + 10 + } else if lower.starts_with("rc") { + 5 + } else { + // patch/pl/p / unknown → stable + 0 + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dependency_resolver::pool::PoolPackageInput; + + fn make_input(name: &str, version: &str) -> PoolPackageInput { + PoolPackageInput { + name: name.to_string(), + version: version.to_string(), + pretty_version: version.to_string(), + requires: vec![], + replaces: vec![], + provides: vec![], + conflicts: vec![], + is_fixed: false, + is_alias_of: None, + } + } + + #[test] + fn test_prefer_highest() { + let pool = Pool::new( + vec![ + make_input("a/a", "1.0.0.0"), + make_input("a/a", "2.0.0.0"), + make_input("a/a", "3.0.0.0"), + ], + vec![], + ); + let policy = DefaultPolicy::new(false, false); + let result = policy.select_preferred_packages(&pool, &[1, 2, 3], None); + // Should prefer highest version (3.0.0.0 = id 3) + assert_eq!(result[0], 3); + } + + #[test] + fn test_prefer_lowest() { + let pool = Pool::new( + vec![ + make_input("a/a", "1.0.0.0"), + make_input("a/a", "2.0.0.0"), + make_input("a/a", "3.0.0.0"), + ], + vec![], + ); + let policy = DefaultPolicy::new(false, true); + let result = policy.select_preferred_packages(&pool, &[1, 2, 3], None); + // Should prefer lowest version (1.0.0.0 = id 1) + assert_eq!(result[0], 1); + } +} diff --git a/crates/mozart-core/src/dependency_resolver/pool.rs b/crates/mozart-core/src/dependency_resolver/pool.rs new file mode 100644 index 0000000..8a63c05 --- /dev/null +++ b/crates/mozart-core/src/dependency_resolver/pool.rs @@ -0,0 +1,427 @@ +use indexmap::IndexMap; +use mozart_semver::VersionConstraint; +use std::fmt; + +/// Unique identifier for a package in the pool. 1-based. +pub type PackageId = u32; + +/// A SAT literal. Positive = install package, negative = don't install. +/// The absolute value is the PackageId. +pub type Literal = i32; + +/// Returns the PackageId from a literal. +#[inline] +pub fn literal_to_package_id(literal: Literal) -> PackageId { + literal.unsigned_abs() +} + +/// A link from a package to another package name with a version constraint. +#[derive(Debug, Clone)] +pub struct PoolLink { + /// The target package name. + pub target: String, + /// The version constraint string (e.g. "^1.0"). + pub constraint: String, + /// The source package name (the one declaring this link). + pub source: String, +} + +impl fmt::Display for PoolLink { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} {} {}", self.source, self.target, self.constraint) + } +} + +/// A package entry in the pool. This is the SAT solver's view of a package. +#[derive(Debug, Clone)] +pub struct PoolPackage { + /// 1-based package ID assigned by the pool. + pub id: PackageId, + /// Normalized package name (e.g. "monolog/monolog"). + pub name: String, + /// Normalized version string (e.g. "1.0.0.0"). + pub version: String, + /// Pretty version string (e.g. "1.0.0"). + pub pretty_version: String, + /// Package requirements. + pub requires: Vec, + /// Packages this replaces. + pub replaces: Vec, + /// Packages this provides. + pub provides: Vec, + /// Packages this conflicts with. + pub conflicts: Vec, + /// Whether this is a fixed/locked package. + pub is_fixed: bool, + /// If `Some`, this package is an `AliasPackage` whose target is the + /// other pool entry with the given ID. Composer creates these for + /// `extra.branch-alias` entries (dev branch → numeric alias). When set, + /// the rule generator emits `PackageAlias`/`PackageInverseAlias` rules + /// instead of regular requires; same-name conflict rules also skip + /// alias packages. + pub is_alias_of: Option, +} + +impl PoolPackage { + /// Returns all names this package is known by (own name + provides + replaces targets). + pub fn names(&self) -> Vec<&str> { + let mut names = vec![self.name.as_str()]; + for link in &self.provides { + names.push(link.target.as_str()); + } + for link in &self.replaces { + names.push(link.target.as_str()); + } + names + } + + /// Names that drive same-name conflict resolution — own name plus + /// `replace` targets. `provide` targets are excluded because two packages + /// providing different versions of the same virtual name may legitimately + /// coexist; `replace` declares the replacing package fully supplants the + /// replaced one. Mirrors Composer's `BasePackage::getNames(false)`. + pub fn conflict_names(&self) -> Vec<&str> { + let mut names = vec![self.name.as_str()]; + for link in &self.replaces { + names.push(link.target.as_str()); + } + names + } +} + +impl fmt::Display for PoolPackage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} {}", self.name, self.pretty_version) + } +} + +/// Input for building a Pool. Users of the crate provide these. +#[derive(Debug, Clone)] +pub struct PoolPackageInput { + pub name: String, + pub version: String, + pub pretty_version: String, + pub requires: Vec, + pub replaces: Vec, + pub provides: Vec, + pub conflicts: Vec, + pub is_fixed: bool, + /// When `Some`, the value is the **normalized** version of another input + /// in this build batch with the same `name`; the pool will resolve it to + /// that input's [`PackageId`] in [`PoolPackage::is_alias_of`]. Used by + /// the registry layer to materialize Composer's `AliasPackage` for + /// `extra.branch-alias` entries. + pub is_alias_of: Option, +} + +/// The package pool: contains all candidate packages for dependency resolution. +/// Packages are assigned sequential 1-based IDs. +/// +/// Port of Composer's Pool.php. +pub struct Pool { + /// All packages, indexed by (id - 1). + packages: Vec, + /// Index: package name → list of package IDs providing that name. + package_by_name: IndexMap>, + /// Cache for what_provides results. + provider_cache: IndexMap<(String, String), Vec>, + /// Packages that are fixed/locked but unacceptable (e.g. failed stability). + unacceptable_fixed_packages: Vec, +} + +impl Pool { + /// Create a new pool from a list of package inputs. + pub fn new(inputs: Vec, unacceptable_fixed_ids: Vec) -> Self { + let mut packages: Vec = Vec::with_capacity(inputs.len()); + let mut package_by_name: IndexMap> = IndexMap::new(); + // Collect alias links (alias_idx, target_name, target_normalized) for + // a second pass once every input has a stable ID. + let mut pending_aliases: Vec<(usize, String, String)> = Vec::new(); + + for (idx, input) in inputs.into_iter().enumerate() { + let id = (idx as PackageId) + 1; + if let Some(target) = input.is_alias_of.clone() { + pending_aliases.push((idx, input.name.clone(), target)); + } + let pkg = PoolPackage { + id, + name: input.name, + version: input.version, + pretty_version: input.pretty_version, + requires: input.requires, + replaces: input.replaces, + provides: input.provides, + conflicts: input.conflicts, + is_fixed: input.is_fixed, + is_alias_of: None, + }; + + // Index by all names this package provides + for name in pkg.names() { + package_by_name + .entry(name.to_string()) + .or_default() + .push(id); + } + + packages.push(pkg); + } + + // Resolve alias targets: for each alias input, find the matching + // (name, normalized version) entry and store its ID. Mirrors the + // post-construction wiring Composer does in + // `RepositorySet::createAliasPackage` / `addPackage`. + for (alias_idx, name, target_normalized) in pending_aliases { + if let Some(ids) = package_by_name.get(&name) { + let target_id = ids.iter().copied().find(|&id| { + let candidate = &packages[(id - 1) as usize]; + !candidate.name.is_empty() + && candidate.name == name + && candidate.version == target_normalized + && candidate.is_alias_of.is_none() + }); + if let Some(tid) = target_id { + packages[alias_idx].is_alias_of = Some(tid); + } + } + } + + Pool { + packages, + package_by_name, + provider_cache: IndexMap::new(), + unacceptable_fixed_packages: unacceptable_fixed_ids, + } + } + + /// Returns the number of packages in the pool. + pub fn len(&self) -> usize { + self.packages.len() + } + + /// Returns true if the pool has no packages. + pub fn is_empty(&self) -> bool { + self.packages.is_empty() + } + + /// Look up a package by its 1-based ID. + pub fn package_by_id(&self, id: PackageId) -> &PoolPackage { + &self.packages[(id - 1) as usize] + } + + /// All packages in the pool. + pub fn packages(&self) -> &[PoolPackage] { + &self.packages + } + + /// Convert a literal to its package reference. + pub fn literal_to_package(&self, literal: Literal) -> &PoolPackage { + self.package_by_id(literal_to_package_id(literal)) + } + + /// Format a literal as a human-readable string. + pub fn literal_to_pretty_string(&self, literal: Literal) -> String { + let pkg = self.literal_to_package(literal); + let prefix = if literal > 0 { + "install" + } else { + "don't install" + }; + format!("{prefix} {} {}", pkg.name, pkg.pretty_version) + } + + /// Find all packages matching a name and optional constraint. + /// Results are cached. + pub fn what_provides(&mut self, name: &str, constraint: Option<&str>) -> Vec { + let key = (name.to_string(), constraint.unwrap_or("").to_string()); + if let Some(cached) = self.provider_cache.get(&key) { + return cached.clone(); + } + + let result = self.compute_what_provides(name, constraint); + self.provider_cache.insert(key, result.clone()); + result + } + + fn compute_what_provides(&self, name: &str, constraint: Option<&str>) -> Vec { + let Some(candidate_ids) = self.package_by_name.get(name) else { + return vec![]; + }; + + let parsed_constraint = constraint.and_then(|c| VersionConstraint::parse(c).ok()); + + let mut matches = Vec::new(); + for &id in candidate_ids { + let pkg = self.package_by_id(id); + if self.matches_package(pkg, name, parsed_constraint.as_ref()) { + matches.push(id); + } + } + matches + } + + /// Check if a candidate package matches a name and optional constraint. + /// Handles provides and replaces. + fn matches_package( + &self, + candidate: &PoolPackage, + name: &str, + constraint: Option<&VersionConstraint>, + ) -> bool { + if candidate.name == name { + return match constraint { + None => true, + Some(vc) => { + // Try the normalized version first; fall back to the + // pretty version. Composer normalizes both sides of a + // constraint match to a single string form (e.g. + // `dev-master` → `9999999-dev`), so a query for + // `dev-master` matches a package whose pretty version + // is `dev-master` even when the pool stores its + // version field in a different normalized shape (e.g. + // the four-segment `9999999.9999999.9999999.9999999-dev` + // expansion Mozart uses internally for default-branch + // and root-alias entries). The pretty fallback bridges + // that gap without forcing the pool to commit to a + // single normalization. + if let Ok(v) = mozart_semver::Version::parse(&candidate.version) + && vc.matches(&v) + { + return true; + } + if let Ok(pv) = mozart_semver::Version::parse(&candidate.pretty_version) + && vc.matches(&pv) + { + return true; + } + false + } + }; + } + + // Check provides. A package may declare more than one provide link + // for the same target (e.g. an `AliasPackage` carries the base's link + // and an extra link tagged at the alias's own version), so keep + // iterating once a target name matches but the constraint doesn't — + // a later link may still satisfy. + for link in &candidate.provides { + if link.target != name { + continue; + } + match constraint { + None => return true, + Some(vc) => { + if let Ok(provide_vc) = VersionConstraint::parse(&link.constraint) + && constraints_intersect(vc, &provide_vc) + { + return true; + } + } + } + } + + for link in &candidate.replaces { + if link.target != name { + continue; + } + match constraint { + None => return true, + Some(vc) => { + if let Ok(replace_vc) = VersionConstraint::parse(&link.constraint) + && constraints_intersect(vc, &replace_vc) + { + return true; + } + } + } + } + + false + } + + /// Check if a package is in the unacceptable fixed list. + pub fn is_unacceptable_fixed_package(&self, id: PackageId) -> bool { + self.unacceptable_fixed_packages.contains(&id) + } +} + +impl fmt::Display for Pool { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "Pool:")?; + for pkg in &self.packages { + writeln!(f, " {:>6}: {} {}", pkg.id, pkg.name, pkg.pretty_version)?; + } + Ok(()) + } +} + +/// Whether the request constraint and the provide/replace link constraint +/// share at least one satisfying version. Mirrors Composer's +/// `ConstraintInterface::matches` semantics: a provide/replace link only +/// makes the candidate a viable provider for those versions of the target +/// that fall in the link's constraint. +fn constraints_intersect(a: &VersionConstraint, b: &VersionConstraint) -> bool { + a.intersects(b) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_input(name: &str, version: &str) -> PoolPackageInput { + PoolPackageInput { + name: name.to_string(), + version: version.to_string(), + pretty_version: version.to_string(), + requires: vec![], + replaces: vec![], + provides: vec![], + conflicts: vec![], + is_fixed: false, + is_alias_of: None, + } + } + + #[test] + fn test_pool_basic() { + let mut pool = Pool::new( + vec![ + make_input("a/a", "1.0.0.0"), + make_input("a/a", "2.0.0.0"), + make_input("b/b", "1.0.0.0"), + ], + vec![], + ); + + assert_eq!(pool.len(), 3); + assert_eq!(pool.package_by_id(1).name, "a/a"); + assert_eq!(pool.package_by_id(2).name, "a/a"); + assert_eq!(pool.package_by_id(3).name, "b/b"); + + let providers = pool.what_provides("a/a", None); + assert_eq!(providers, vec![1, 2]); + } + + #[test] + fn test_literal_to_package() { + let pool = Pool::new( + vec![make_input("a/a", "1.0.0.0"), make_input("b/b", "1.0.0.0")], + vec![], + ); + + assert_eq!(pool.literal_to_package(1).name, "a/a"); + assert_eq!(pool.literal_to_package(-1).name, "a/a"); + assert_eq!(pool.literal_to_package(2).name, "b/b"); + assert_eq!(pool.literal_to_package(-2).name, "b/b"); + } + + #[test] + fn test_literal_pretty_string() { + let pool = Pool::new(vec![make_input("a/a", "1.0.0.0")], vec![]); + assert_eq!(pool.literal_to_pretty_string(1), "install a/a 1.0.0.0"); + assert_eq!( + pool.literal_to_pretty_string(-1), + "don't install a/a 1.0.0.0" + ); + } +} diff --git a/crates/mozart-core/src/dependency_resolver/pool_builder.rs b/crates/mozart-core/src/dependency_resolver/pool_builder.rs new file mode 100644 index 0000000..e037b01 --- /dev/null +++ b/crates/mozart-core/src/dependency_resolver/pool_builder.rs @@ -0,0 +1,222 @@ +use super::pool::{Pool, PoolLink, PoolPackageInput}; +use indexmap::IndexSet; +use std::collections::VecDeque; + +/// Builder for constructing a Pool from package metadata. +/// +/// The builder accepts package inputs and recursively discovers +/// transitive dependencies. This is done by the registry layer +/// before solving. +pub struct PoolBuilder { + /// Packages to add to the pool. + inputs: Vec, + /// Names already added (to avoid duplicates). + added: IndexSet, + /// Queue of package names that need to be explored. + pending_names: VecDeque, + /// Package names that have already been explored (returned by next_pending). + explored_names: IndexSet, + /// Specific platform packages to ignore (from `--ignore-platform-req=name`). + ignore_platform_reqs: IndexSet, + /// When true, ignore every platform package (php, ext-*, lib-*, composer-*). + /// Mirrors `--ignore-platform-reqs` (no value). + ignore_all_platform_reqs: bool, +} + +impl PoolBuilder { + pub fn new() -> Self { + PoolBuilder { + inputs: Vec::new(), + added: IndexSet::new(), + pending_names: VecDeque::new(), + explored_names: IndexSet::new(), + ignore_platform_reqs: IndexSet::new(), + ignore_all_platform_reqs: false, + } + } + + /// Set platform requirements to ignore during exploration. + pub fn set_ignore_platform_reqs(&mut self, names: IndexSet) { + self.ignore_platform_reqs = names; + } + + /// When set, every platform package is skipped during exploration. + pub fn set_ignore_all_platform_reqs(&mut self, ignore_all: bool) { + self.ignore_all_platform_reqs = ignore_all; + } + + fn is_ignored_platform_dep(&self, name: &str) -> bool { + if self + .ignore_platform_reqs + .iter() + .any(|p| crate::matches_wildcard(name, p)) + { + return true; + } + self.ignore_all_platform_reqs && crate::platform::is_platform_package(name) + } + + /// Add a package version to the builder. Returns true if it's new. + pub fn add_package(&mut self, input: PoolPackageInput) -> bool { + let key = format!("{}@{}", input.name, input.version); + if self.added.contains(&key) { + return false; + } + self.added.insert(key); + + // Queue dependency names for exploration + for link in &input.requires { + if !self.is_ignored_platform_dep(&link.target) { + self.pending_names.push_back(link.target.clone()); + } + } + + self.inputs.push(input); + true + } + + /// Get the next package name that needs to be explored. + /// The caller should fetch available versions for this package + /// and add them via `add_package`. + pub fn next_pending(&mut self) -> Option { + while let Some(name) = self.pending_names.pop_front() { + // Skip if already explored or already has versions in inputs + if self.explored_names.contains(&name) { + continue; + } + if self.inputs.iter().any(|p| p.name == name) { + continue; + } + self.explored_names.insert(name.clone()); + return Some(name); + } + None + } + + /// Check if there are more names to explore. + pub fn has_pending(&self) -> bool { + !self.pending_names.is_empty() + } + + /// Build the final Pool. + pub fn build(self) -> Pool { + Pool::new(self.inputs, vec![]) + } + + /// Get the number of packages added so far. + pub fn len(&self) -> usize { + self.inputs.len() + } + + /// Read-only access to package inputs collected so far. Used by the + /// registry layer to materialize root aliases (`require: "X as Y"`) once + /// every base + branch-alias entry is in place: a second pass scans for + /// matching `(name, version)` and pushes the alias entry on top. + pub fn inputs(&self) -> &[PoolPackageInput] { + &self.inputs + } + + /// Whether the builder has no packages. + pub fn is_empty(&self) -> bool { + self.inputs.is_empty() + } +} + +impl Default for PoolBuilder { + fn default() -> Self { + Self::new() + } +} + +/// Helper to convert (name, constraint) pairs from Packagist into PoolLinks. +/// +/// `source_version` is the normalized version of the package declaring these +/// links; it replaces any `"self.version"` constraint, mirroring Composer's +/// `ArrayLoader::createLink` (and `AliasPackage::replaceSelfVersionDependencies`, +/// which feeds the alias's own version in for the same purpose). +pub fn make_pool_links( + source: &str, + source_version: &str, + deps: &[(String, String)], +) -> Vec { + deps.iter() + .map(|(target, constraint)| PoolLink { + target: target.clone(), + constraint: if constraint.trim() == "self.version" { + source_version.to_string() + } else { + constraint.clone() + }, + source: source.to_string(), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pool_builder_basic() { + let mut builder = PoolBuilder::new(); + + builder.add_package(PoolPackageInput { + name: "a/a".to_string(), + version: "1.0.0.0".to_string(), + pretty_version: "1.0.0".to_string(), + requires: vec![PoolLink { + target: "b/b".to_string(), + constraint: "^1.0".to_string(), + source: "a/a".to_string(), + }], + replaces: vec![], + provides: vec![], + conflicts: vec![], + is_fixed: false, + is_alias_of: None, + }); + + // Should have b/b pending + let pending = builder.next_pending(); + assert_eq!(pending, Some("b/b".to_string())); + + builder.add_package(PoolPackageInput { + name: "b/b".to_string(), + version: "1.0.0.0".to_string(), + pretty_version: "1.0.0".to_string(), + requires: vec![], + replaces: vec![], + provides: vec![], + conflicts: vec![], + is_fixed: false, + is_alias_of: None, + }); + + // No more pending + assert!(builder.next_pending().is_none()); + + let pool = builder.build(); + assert_eq!(pool.len(), 2); + } + + #[test] + fn test_deduplication() { + let mut builder = PoolBuilder::new(); + + let input = PoolPackageInput { + name: "a/a".to_string(), + version: "1.0.0.0".to_string(), + pretty_version: "1.0.0".to_string(), + requires: vec![], + replaces: vec![], + provides: vec![], + conflicts: vec![], + is_fixed: false, + is_alias_of: None, + }; + + assert!(builder.add_package(input.clone())); + assert!(!builder.add_package(input)); + assert_eq!(builder.len(), 1); + } +} diff --git a/crates/mozart-core/src/dependency_resolver/problem.rs b/crates/mozart-core/src/dependency_resolver/problem.rs new file mode 100644 index 0000000..e9a1464 --- /dev/null +++ b/crates/mozart-core/src/dependency_resolver/problem.rs @@ -0,0 +1,499 @@ +use super::pool::{Literal, Pool, literal_to_package_id}; +use super::rule::{ReasonData, Rule, RuleReason}; +use super::rule_set::{RuleId, RuleSet}; + +/// Represents a conflict found during resolution. +/// Collects the rules involved in the problem. +/// +/// Port of Composer's Problem.php. +#[derive(Debug, Clone)] +pub struct Problem { + /// Sections of rules that form this problem. + /// Each section is a group of related rules. + sections: Vec>, +} + +impl Problem { + pub fn new() -> Self { + Problem { + sections: vec![vec![]], + } + } + + /// Add a rule to the current section. + pub fn add_rule(&mut self, rule_id: RuleId) { + if let Some(section) = self.sections.last_mut() + && !section.contains(&rule_id) + { + section.push(rule_id); + } + } + + /// Start a new section. + pub fn next_section(&mut self) { + if self.sections.last().is_some_and(|s| !s.is_empty()) { + self.sections.push(vec![]); + } + } + + /// Get all rule IDs in this problem. + pub fn rule_ids(&self) -> Vec { + self.sections.iter().flatten().copied().collect() + } + + /// Format the problem as a human-readable string using Pool data. + /// + /// Port of Composer's Problem::getPrettyString(). + pub fn pretty_string(&self, pool: &Pool, rules: &RuleSet) -> String { + // Flatten all sections (reversed) like Composer does + let mut all_rules: Vec = self.sections.iter().rev().flatten().copied().collect(); + + if all_rules.is_empty() { + return "Unknown problem".to_string(); + } + + // Sort by priority, then by sortable string + all_rules.sort_by(|&a, &b| { + let rule_a = rules.rule_by_id(a); + let rule_b = rules.rule_by_id(b); + let prio_a = rule_priority(rule_a); + let prio_b = rule_priority(rule_b); + if prio_a != prio_b { + return prio_b.cmp(&prio_a); + } + sortable_string(pool, rule_a).cmp(&sortable_string(pool, rule_b)) + }); + + // Format each rule + let mut messages: Vec = Vec::new(); + for &rule_id in &all_rules { + let rule = rules.rule_by_id(rule_id); + let msg = rule_pretty_string(pool, rule); + if !msg.is_empty() { + messages.push(msg); + } + } + + // Deduplicate + let mut seen = indexmap::IndexSet::new(); + let mut unique = Vec::new(); + for msg in messages { + if seen.insert(msg.clone()) { + unique.push(msg); + } + } + + if unique.is_empty() { + return "Unknown problem".to_string(); + } + + unique + .iter() + .map(|m| format!(" - {m}")) + .collect::>() + .join("\n") + } + + /// Basic format for backward compatibility (uses rule Display). + pub fn format(&self, rules: &RuleSet) -> String { + let mut parts = Vec::new(); + for section in &self.sections { + for &rule_id in section { + let rule = rules.rule_by_id(rule_id); + parts.push(format!(" - {rule}")); + } + } + if parts.is_empty() { + "Unknown problem".to_string() + } else { + parts.join("\n") + } + } +} + +impl Default for Problem { + fn default() -> Self { + Self::new() + } +} + +/// Get the sort priority for a rule (higher = more important). +/// Port of Problem::getRulePriority(). +fn rule_priority(rule: &Rule) -> u8 { + match rule.reason { + RuleReason::Fixed => 3, + RuleReason::RootRequire => 2, + RuleReason::PackageConflict | RuleReason::PackageRequires => 1, + RuleReason::PackageSameName + | RuleReason::Learned + | RuleReason::PackageAlias + | RuleReason::PackageInverseAlias => 0, + } +} + +/// Get a sortable string for a rule. +/// Port of Problem::getSortableString(). +fn sortable_string(pool: &Pool, rule: &Rule) -> String { + match (&rule.reason, &rule.reason_data) { + (RuleReason::RootRequire, ReasonData::RootRequire { package_name, .. }) => { + package_name.clone() + } + (RuleReason::Fixed, ReasonData::Fixed { package_id }) => { + pool.package_by_id(*package_id).to_string() + } + (RuleReason::PackageConflict | RuleReason::PackageRequires, ReasonData::Link(link)) => { + if let Some(source_lit) = rule.literals().first() { + let source_pkg = pool.literal_to_package(*source_lit); + format!("{}//{}", source_pkg, link) + } else { + link.to_string() + } + } + (RuleReason::PackageSameName, ReasonData::PackageName(name)) => name.clone(), + (RuleReason::Learned, _) => rule + .literals() + .iter() + .map(|l: &Literal| l.to_string()) + .collect::>() + .join("-"), + _ => String::new(), + } +} + +/// Format a rule as a human-readable string. +/// Port of Composer's Rule::getPrettyString(). +fn rule_pretty_string(pool: &Pool, rule: &Rule) -> String { + match (&rule.reason, &rule.reason_data) { + ( + RuleReason::RootRequire, + ReasonData::RootRequire { + package_name, + constraint, + }, + ) => { + let providers = format_providers(pool, rule.literals()); + if providers.is_empty() { + format!( + "No package found to satisfy root composer.json require {package_name} {constraint}" + ) + } else { + format!( + "Root composer.json requires {package_name} {constraint} -> satisfiable by {providers}." + ) + } + } + + (RuleReason::Fixed, ReasonData::Fixed { package_id }) => { + let pkg = pool.package_by_id(*package_id); + if pkg.is_fixed { + format!( + "{} {} is locked to version {} and an update of this package was not requested.", + pkg.name, pkg.pretty_version, pkg.pretty_version + ) + } else { + format!( + "{} {} is present at version {} and cannot be modified by Mozart", + pkg.name, pkg.pretty_version, pkg.pretty_version + ) + } + } + + (RuleReason::PackageConflict, ReasonData::Link(link)) => { + let literals = rule.literals(); + if literals.len() >= 2 { + let pkg1 = pool.literal_to_package(literals[0]); + let pkg2 = pool.literal_to_package(literals[1]); + // Determine which is the source of the conflict + if link.source == pkg1.name { + format!("{pkg2} conflicts with {pkg1}.") + } else { + format!("{pkg1} conflicts with {pkg2}.") + } + } else { + format!("Conflict: {link}") + } + } + + (RuleReason::PackageRequires, ReasonData::Link(link)) => { + let literals = rule.literals(); + if literals.is_empty() { + return format!("Requirement: {link}"); + } + + let source_pkg = pool.literal_to_package(literals[0]); + let base_text = format!( + "{} {} requires {} {}", + source_pkg.name, source_pkg.pretty_version, link.target, link.constraint + ); + + // Remaining literals are the satisfying packages + let provider_lits: Vec = literals[1..].to_vec(); + if provider_lits.is_empty() { + format!("{base_text} -> no matching package found.") + } else { + let providers = format_providers(pool, &provider_lits); + format!("{base_text} -> satisfiable by {providers}.") + } + } + + (RuleReason::PackageSameName, ReasonData::PackageName(name)) => { + let literals = rule.literals(); + // Collect unique package names in this rule + let mut pkg_names: Vec = Vec::new(); + for &lit in literals { + let pkg = pool.literal_to_package(lit); + if !pkg_names.contains(&pkg.name) { + pkg_names.push(pkg.name.clone()); + } + } + + if pkg_names.len() > 1 { + // Different packages that replace/provide the same name + let replacers: Vec<&str> = pkg_names + .iter() + .filter(|n| n.as_str() != name) + .map(|n| n.as_str()) + .collect(); + + let reason = if replacers.is_empty() { + format!("They all replace {name} and thus cannot coexist.") + } else if !pkg_names.contains(name) { + format!( + "They {} replace {name} and thus cannot coexist.", + if literals.len() == 2 { "both" } else { "all" } + ) + } else if replacers.len() == 1 { + format!( + "{} replaces {name} and thus cannot coexist with it.", + replacers[0] + ) + } else { + format!( + "[{}] replace {name} and thus cannot coexist with it.", + replacers.join(", ") + ) + }; + + let pkgs_str = format_providers(pool, literals); + format!("Only one of these can be installed: {pkgs_str}. {reason}") + } else { + // Same package, different versions + let pkgs_str = format_providers(pool, literals); + format!( + "You can only install one version of a package, so only one of these can be installed: {pkgs_str}." + ) + } + } + + (RuleReason::Learned, _) => { + let literals = rule.literals(); + if literals.len() == 1 { + let pretty = pool.literal_to_pretty_string(literals[0]); + format!("Conclusion: {pretty} (conflict analysis result)") + } else { + // Group literals by install/don't install + let mut install = Vec::new(); + let mut dont_install = Vec::new(); + for &lit in literals { + if lit > 0 { + install.push(lit); + } else { + dont_install.push(lit); + } + } + + let mut parts = Vec::new(); + if !install.is_empty() { + let pkgs = format_providers(pool, &install); + if install.len() > 1 { + parts.push(format!("install one of {pkgs}")); + } else { + parts.push(format!("install {pkgs}")); + } + } + if !dont_install.is_empty() { + let pkgs = format_providers_abs(pool, &dont_install); + if dont_install.len() > 1 { + parts.push(format!("don't install one of {pkgs}")); + } else { + parts.push(format!("don't install {pkgs}")); + } + } + + format!( + "Conclusion: {} (conflict analysis result)", + parts.join(" | ") + ) + } + } + + (RuleReason::PackageAlias, _) => { + let literals = rule.literals(); + if literals.len() >= 2 { + let alias_pkg = pool.literal_to_package(literals[0]); + let target_pkg = pool.literal_to_package(literals[1]); + format!( + "{alias_pkg} is an alias of {target_pkg} and thus requires it to be installed too." + ) + } else { + String::new() + } + } + + (RuleReason::PackageInverseAlias, _) => { + let literals = rule.literals(); + if literals.len() >= 2 { + let target_pkg = pool.literal_to_package(literals[0]); + let alias_pkg = pool.literal_to_package(literals[1]); + format!("{alias_pkg} is an alias of {target_pkg} and must be installed with it.") + } else { + String::new() + } + } + + _ => { + // Fallback: display raw literals + let literal_strs: Vec = rule + .literals() + .iter() + .map(|&l| pool.literal_to_pretty_string(l)) + .collect(); + literal_strs.join(" | ") + } + } +} + +/// Format a list of literals as a list of package names grouped by name. +/// Similar to Composer's formatPackagesUnique. +fn format_providers(pool: &Pool, literals: &[Literal]) -> String { + // Group by package name + let mut groups: indexmap::IndexMap<&str, Vec<&str>> = indexmap::IndexMap::new(); + for &lit in literals { + let pkg = pool.literal_to_package(lit); + groups + .entry(&pkg.name) + .or_default() + .push(&pkg.pretty_version); + } + + let mut parts: Vec = Vec::new(); + for (name, versions) in &groups { + if versions.len() == 1 { + parts.push(format!("{name} {}", versions[0])); + } else { + let v_str = versions.join(", "); + parts.push(format!("{name}[{v_str}]")); + } + } + + parts.sort(); + parts.join(", ") +} + +/// Same as format_providers but uses absolute value of literals. +fn format_providers_abs(pool: &Pool, literals: &[Literal]) -> String { + let abs_lits: Vec = literals + .iter() + .map(|&l| literal_to_package_id(l) as Literal) + .collect(); + format_providers(pool, &abs_lits) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dependency_resolver::pool::PoolPackageInput; + use crate::dependency_resolver::rule::{ReasonData, Rule, RuleReason, RuleType}; + + fn make_input(name: &str, version: &str, pretty: &str) -> PoolPackageInput { + PoolPackageInput { + name: name.to_string(), + version: version.to_string(), + pretty_version: pretty.to_string(), + requires: vec![], + replaces: vec![], + provides: vec![], + conflicts: vec![], + is_fixed: false, + is_alias_of: None, + } + } + + #[test] + fn test_root_require_pretty_string() { + let pool = Pool::new(vec![make_input("foo/bar", "1.0.0.0", "1.0.0")], vec![]); + + let mut rule_set = RuleSet::new(); + let rule = Rule::new( + vec![1], + RuleReason::RootRequire, + ReasonData::RootRequire { + package_name: "foo/bar".to_string(), + constraint: "^1.0".to_string(), + }, + ); + rule_set.add(rule, RuleType::Request); + + let mut problem = Problem::new(); + problem.add_rule(0); + + let output = problem.pretty_string(&pool, &rule_set); + assert!(output.contains("Root composer.json requires foo/bar ^1.0")); + assert!(output.contains("satisfiable by foo/bar 1.0.0")); + } + + #[test] + fn test_same_name_pretty_string() { + let pool = Pool::new( + vec![ + make_input("foo/bar", "1.0.0.0", "1.0.0"), + make_input("foo/bar", "2.0.0.0", "2.0.0"), + ], + vec![], + ); + + let mut rule_set = RuleSet::new(); + let rule = Rule::new( + vec![-1, -2], + RuleReason::PackageSameName, + ReasonData::PackageName("foo/bar".to_string()), + ); + rule_set.add(rule, RuleType::Package); + + let mut problem = Problem::new(); + problem.add_rule(0); + + let output = problem.pretty_string(&pool, &rule_set); + assert!(output.contains("You can only install one version")); + } + + #[test] + fn test_package_requires_pretty_string() { + let pool = Pool::new( + vec![ + make_input("foo/bar", "1.0.0.0", "1.0.0"), + make_input("baz/qux", "2.0.0.0", "2.0.0"), + ], + vec![], + ); + + let mut rule_set = RuleSet::new(); + let rule = Rule::new( + vec![-1, 2], + RuleReason::PackageRequires, + ReasonData::Link(super::super::pool::PoolLink { + source: "foo/bar".to_string(), + target: "baz/qux".to_string(), + constraint: "^2.0".to_string(), + }), + ); + rule_set.add(rule, RuleType::Package); + + let mut problem = Problem::new(); + problem.add_rule(0); + + let output = problem.pretty_string(&pool, &rule_set); + assert!(output.contains("foo/bar 1.0.0 requires baz/qux ^2.0")); + assert!(output.contains("satisfiable by baz/qux 2.0.0")); + } +} diff --git a/crates/mozart-core/src/dependency_resolver/request.rs b/crates/mozart-core/src/dependency_resolver/request.rs new file mode 100644 index 0000000..4d650b0 --- /dev/null +++ b/crates/mozart-core/src/dependency_resolver/request.rs @@ -0,0 +1,65 @@ +use super::pool::PackageId; +use indexmap::IndexMap; + +/// A requirement: package name + version constraint string. +#[derive(Debug, Clone)] +pub struct Require { + pub package_name: String, + pub constraint: Option, +} + +/// A request for the solver: what to install/fix/lock. +/// +/// Port of Composer's Request.php. +#[derive(Debug, Clone)] +pub struct Request { + /// Root requirements: package name → constraint string. + pub requires: IndexMap>, + /// Fixed packages (must be installed, cannot be modified). + pub fixed_packages: Vec, + /// Locked packages (installed but can be removed if nothing requires them). + pub locked_packages: Vec, +} + +impl Request { + pub fn new() -> Self { + Request { + requires: IndexMap::new(), + fixed_packages: Vec::new(), + locked_packages: Vec::new(), + } + } + + /// Add a root requirement. + pub fn require_name(&mut self, package_name: &str, constraint: Option<&str>) { + self.requires.insert( + package_name.to_lowercase(), + constraint.map(|s| s.to_string()), + ); + } + + /// Mark a package as fixed (must remain installed). + pub fn fix_package(&mut self, package_id: PackageId) { + if !self.fixed_packages.contains(&package_id) { + self.fixed_packages.push(package_id); + } + } + + /// Mark a package as locked. + pub fn lock_package(&mut self, package_id: PackageId) { + if !self.locked_packages.contains(&package_id) { + self.locked_packages.push(package_id); + } + } + + /// Check if a package is fixed. + pub fn is_fixed(&self, package_id: PackageId) -> bool { + self.fixed_packages.contains(&package_id) + } +} + +impl Default for Request { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/mozart-core/src/dependency_resolver/rule.rs b/crates/mozart-core/src/dependency_resolver/rule.rs new file mode 100644 index 0000000..546b932 --- /dev/null +++ b/crates/mozart-core/src/dependency_resolver/rule.rs @@ -0,0 +1,280 @@ +use super::pool::{Literal, PoolLink}; +use std::fmt; + +/// Why a rule was created. +/// Port of Composer Rule::RULE_* constants. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuleReason { + /// Root composer.json requirement. + RootRequire, + /// Fixed/locked package. + Fixed, + /// Two packages conflict. + PackageConflict, + /// Package dependency (requires). + PackageRequires, + /// Only one version of a package can be installed. + PackageSameName, + /// Learned from conflict analysis. + Learned, + /// Alias requires its target. + PackageAlias, + /// Target requires its alias. + PackageInverseAlias, +} + +/// Data explaining why a rule was created. +#[derive(Debug, Clone)] +pub enum ReasonData { + /// For RootRequire: package name + constraint string. + RootRequire { + package_name: String, + constraint: String, + }, + /// For Fixed: the fixed package ID. + Fixed { package_id: u32 }, + /// For PackageConflict, PackageRequires: a link. + Link(PoolLink), + /// For PackageSameName: the package name. + PackageName(String), + /// For Learned: index into the learned pool. + Learned(usize), + /// For PackageAlias/InverseAlias: the alias package ID. + AliasPackage(u32), + /// No data. + None, +} + +/// The type assigned by RuleSet (which collection this rule belongs to). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RuleType { + Package = 0, + Request = 1, + Learned = 4, +} + +/// A SAT rule (clause). A disjunction of literals: (L1 | L2 | ... | Ln). +/// +/// Port of Composer's Rule hierarchy (GenericRule, Rule2Literals, MultiConflictRule). +/// In Rust we use a single enum instead of class inheritance. +#[derive(Debug, Clone)] +pub struct Rule { + /// The literals in this rule (sorted for deduplication). + literals: Vec, + /// Whether this is a multi-conflict rule. + pub is_multi_conflict: bool, + /// Why this rule was created. + pub reason: RuleReason, + /// Additional data about why this rule was created. + pub reason_data: ReasonData, + /// Which RuleSet type this rule belongs to. + pub rule_type: RuleType, + /// Whether this rule is disabled. + pub disabled: bool, +} + +impl Rule { + /// Create a generic rule (arbitrary number of literals). + /// Equivalent to Composer's GenericRule. + pub fn new(mut literals: Vec, reason: RuleReason, reason_data: ReasonData) -> Self { + literals.sort(); + Rule { + literals, + is_multi_conflict: false, + reason, + reason_data, + rule_type: RuleType::Package, // default, set by RuleSet + disabled: false, + } + } + + /// Create a 2-literal rule (optimized common case). + /// Equivalent to Composer's Rule2Literals. + pub fn two_literals( + lit1: Literal, + lit2: Literal, + reason: RuleReason, + reason_data: ReasonData, + ) -> Self { + let (a, b) = if lit1 <= lit2 { + (lit1, lit2) + } else { + (lit2, lit1) + }; + Rule { + literals: vec![a, b], + is_multi_conflict: false, + reason, + reason_data, + rule_type: RuleType::Package, + disabled: false, + } + } + + /// Create a multi-conflict rule (3+ literals, all negative). + /// Equivalent to Composer's MultiConflictRule. + /// Acts as if it were multiple binary conflict rules. + pub fn multi_conflict( + mut literals: Vec, + reason: RuleReason, + reason_data: ReasonData, + ) -> Self { + assert!( + literals.len() >= 3, + "MultiConflictRule requires at least 3 literals" + ); + literals.sort(); + Rule { + literals, + is_multi_conflict: true, + reason, + reason_data, + rule_type: RuleType::Package, + disabled: false, + } + } + + /// Get the sorted literals. + pub fn literals(&self) -> &[Literal] { + &self.literals + } + + /// Whether this rule has exactly one literal (unit clause / assertion). + pub fn is_assertion(&self) -> bool { + self.literals.len() == 1 + } + + /// Compute a hash for deduplication. + pub fn hash_key(&self) -> String { + if self.is_multi_conflict { + let parts: Vec = self.literals.iter().map(|l| l.to_string()).collect(); + format!("c:{}", parts.join(",")) + } else { + let parts: Vec = self.literals.iter().map(|l| l.to_string()).collect(); + parts.join(",") + } + } + + /// Structural equality check (same literals). + pub fn equals(&self, other: &Rule) -> bool { + self.is_multi_conflict == other.is_multi_conflict && self.literals == other.literals + } + + /// Get the required package name, if applicable. + pub fn required_package(&self) -> Option<&str> { + match &self.reason_data { + ReasonData::RootRequire { package_name, .. } => Some(package_name), + ReasonData::Link(link) => Some(&link.target), + ReasonData::Fixed { .. } => None, // would need pool access + _ => None, + } + } + + /// Disable this rule. + pub fn disable(&mut self) { + if self.is_multi_conflict { + panic!("Cannot disable a MultiConflictRule"); + } + self.disabled = true; + } + + /// Enable this rule. + pub fn enable(&mut self) { + self.disabled = false; + } + + /// Whether this rule is disabled. + pub fn is_disabled(&self) -> bool { + self.disabled + } + + /// Whether this rule is enabled. + pub fn is_enabled(&self) -> bool { + !self.disabled + } +} + +impl fmt::Display for Rule { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.disabled { + write!(f, "disabled(")?; + } + if self.is_multi_conflict { + write!(f, "(multi(")?; + for (i, lit) in self.literals.iter().enumerate() { + if i > 0 { + write!(f, "|")?; + } + write!(f, "{lit}")?; + } + write!(f, "))")?; + } else { + write!(f, "(")?; + for (i, lit) in self.literals.iter().enumerate() { + if i > 0 { + write!(f, "|")?; + } + write!(f, "{lit}")?; + } + write!(f, ")")?; + } + if self.disabled { + write!(f, ")")?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generic_rule() { + let rule = Rule::new(vec![3, 1, 2], RuleReason::PackageRequires, ReasonData::None); + assert_eq!(rule.literals(), &[1, 2, 3]); + assert!(!rule.is_assertion()); + assert_eq!(rule.to_string(), "(1|2|3)"); + } + + #[test] + fn test_two_literal_rule() { + let rule = Rule::two_literals(-2, -1, RuleReason::PackageConflict, ReasonData::None); + assert_eq!(rule.literals(), &[-2, -1]); + assert!(!rule.is_assertion()); + } + + #[test] + fn test_assertion_rule() { + let rule = Rule::new(vec![1], RuleReason::Fixed, ReasonData::None); + assert!(rule.is_assertion()); + } + + #[test] + fn test_multi_conflict_rule() { + let rule = Rule::multi_conflict( + vec![-3, -1, -2], + RuleReason::PackageSameName, + ReasonData::None, + ); + assert!(rule.is_multi_conflict); + assert_eq!(rule.literals(), &[-3, -2, -1]); + } + + #[test] + fn test_hash_key() { + let r1 = Rule::new(vec![2, 1], RuleReason::PackageRequires, ReasonData::None); + let r2 = Rule::new(vec![1, 2], RuleReason::PackageConflict, ReasonData::None); + assert_eq!(r1.hash_key(), r2.hash_key()); + } + + #[test] + fn test_disable_enable() { + let mut rule = Rule::new(vec![1, 2], RuleReason::PackageRequires, ReasonData::None); + assert!(rule.is_enabled()); + rule.disable(); + assert!(rule.is_disabled()); + rule.enable(); + assert!(rule.is_enabled()); + } +} diff --git a/crates/mozart-core/src/dependency_resolver/rule_set.rs b/crates/mozart-core/src/dependency_resolver/rule_set.rs new file mode 100644 index 0000000..3636a0f --- /dev/null +++ b/crates/mozart-core/src/dependency_resolver/rule_set.rs @@ -0,0 +1,211 @@ +use super::rule::{Rule, RuleType}; +use indexmap::IndexMap; + +/// A unique identifier for a rule within the RuleSet. +pub type RuleId = usize; + +/// Container for all rules, organized by type. +/// +/// Port of Composer's RuleSet.php. +pub struct RuleSet { + /// Lookup: rule ID → index into the appropriate type vector. + /// This is the primary read-only access path used by the solver. + rules_by_id: Vec, + /// Rules grouped by type. + package_rules: Vec, + request_rules: Vec, + learned_rules: Vec, + /// Total rule count. + next_rule_id: usize, + /// Deduplication index. + rules_by_hash: IndexMap>, + /// Maps rule ID → (type, index within type's vec). + rule_type_index: Vec<(RuleType, usize)>, +} + +impl RuleSet { + pub fn new() -> Self { + RuleSet { + rules_by_id: Vec::new(), + package_rules: Vec::new(), + request_rules: Vec::new(), + learned_rules: Vec::new(), + next_rule_id: 0, + rules_by_hash: IndexMap::new(), + rule_type_index: Vec::new(), + } + } + + /// Add a rule to the set. Duplicates (by hash + equals) are skipped. + pub fn add(&mut self, mut rule: Rule, rule_type: RuleType) { + let hash = rule.hash_key(); + + // Check for duplicates + if let Some(existing_ids) = self.rules_by_hash.get(&hash) { + for &existing_id in existing_ids { + if rule.equals(self.rule_by_id(existing_id)) { + return; + } + } + } + + rule.rule_type = rule_type; + + let rules_vec = match rule_type { + RuleType::Package => &mut self.package_rules, + RuleType::Request => &mut self.request_rules, + RuleType::Learned => &mut self.learned_rules, + }; + let idx = rules_vec.len(); + rules_vec.push(rule); + + let rule_id = self.next_rule_id; + self.rules_by_id.push(idx); + self.rule_type_index.push((rule_type, idx)); + self.next_rule_id += 1; + + self.rules_by_hash.entry(hash).or_default().push(rule_id); + } + + /// Total number of rules. + pub fn len(&self) -> usize { + self.next_rule_id + } + + /// Whether the rule set is empty. + pub fn is_empty(&self) -> bool { + self.next_rule_id == 0 + } + + /// Look up a rule by its global ID. + pub fn rule_by_id(&self, id: RuleId) -> &Rule { + let (rule_type, idx) = self.rule_type_index[id]; + match rule_type { + RuleType::Package => &self.package_rules[idx], + RuleType::Request => &self.request_rules[idx], + RuleType::Learned => &self.learned_rules[idx], + } + } + + /// Get a mutable reference to a rule by its global ID. + pub fn rule_by_id_mut(&mut self, id: RuleId) -> &mut Rule { + let (rule_type, idx) = self.rule_type_index[id]; + match rule_type { + RuleType::Package => &mut self.package_rules[idx], + RuleType::Request => &mut self.request_rules[idx], + RuleType::Learned => &mut self.learned_rules[idx], + } + } + + /// Iterate over all rules in order (Package, then Request, then Learned). + pub fn iter(&self) -> impl Iterator { + (0..self.next_rule_id).map(move |id| (id, self.rule_by_id(id))) + } + + /// Iterate over rules of a specific type, returning (global_rule_id, &Rule). + pub fn iter_type(&self, rule_type: RuleType) -> RuleTypeIterator<'_> { + RuleTypeIterator { + rule_set: self, + rule_type, + current: 0, + total: self.next_rule_id, + } + } + + /// Get the request rules slice. + pub fn request_rules(&self) -> &[Rule] { + &self.request_rules + } +} + +impl Default for RuleSet { + fn default() -> Self { + Self::new() + } +} + +/// Iterator over rules of a specific type. +pub struct RuleTypeIterator<'a> { + rule_set: &'a RuleSet, + rule_type: RuleType, + current: RuleId, + total: usize, +} + +impl<'a> Iterator for RuleTypeIterator<'a> { + type Item = (RuleId, &'a Rule); + + fn next(&mut self) -> Option { + while self.current < self.total { + let id = self.current; + self.current += 1; + let rule = self.rule_set.rule_by_id(id); + if rule.rule_type == self.rule_type { + return Some((id, rule)); + } + } + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dependency_resolver::rule::{ReasonData, RuleReason}; + + #[test] + fn test_add_and_lookup() { + let mut rs = RuleSet::new(); + rs.add( + Rule::new(vec![1, 2], RuleReason::PackageRequires, ReasonData::None), + RuleType::Package, + ); + rs.add( + Rule::new(vec![3], RuleReason::RootRequire, ReasonData::None), + RuleType::Request, + ); + + assert_eq!(rs.len(), 2); + assert_eq!(rs.rule_by_id(0).literals(), &[1, 2]); + assert_eq!(rs.rule_by_id(1).literals(), &[3]); + } + + #[test] + fn test_deduplication() { + let mut rs = RuleSet::new(); + rs.add( + Rule::new(vec![1, 2], RuleReason::PackageRequires, ReasonData::None), + RuleType::Package, + ); + rs.add( + Rule::new(vec![2, 1], RuleReason::PackageConflict, ReasonData::None), + RuleType::Package, + ); + // Duplicate should be skipped + assert_eq!(rs.len(), 1); + } + + #[test] + fn test_iter_type() { + let mut rs = RuleSet::new(); + rs.add( + Rule::new(vec![1, 2], RuleReason::PackageRequires, ReasonData::None), + RuleType::Package, + ); + rs.add( + Rule::new(vec![3], RuleReason::RootRequire, ReasonData::None), + RuleType::Request, + ); + rs.add( + Rule::new(vec![4, 5], RuleReason::PackageConflict, ReasonData::None), + RuleType::Package, + ); + + let request_rules: Vec<_> = rs.iter_type(RuleType::Request).collect(); + assert_eq!(request_rules.len(), 1); + assert_eq!(request_rules[0].1.literals(), &[3]); + + let package_rules: Vec<_> = rs.iter_type(RuleType::Package).collect(); + assert_eq!(package_rules.len(), 2); + } +} diff --git a/crates/mozart-core/src/dependency_resolver/rule_set_generator.rs b/crates/mozart-core/src/dependency_resolver/rule_set_generator.rs new file mode 100644 index 0000000..bd06419 --- /dev/null +++ b/crates/mozart-core/src/dependency_resolver/rule_set_generator.rs @@ -0,0 +1,464 @@ +use super::pool::{Literal, PackageId, Pool, PoolLink}; +use super::rule::{ReasonData, Rule, RuleReason, RuleType}; +use super::rule_set::RuleSet; +use indexmap::IndexMap; +use indexmap::IndexSet; +use mozart_semver::VersionConstraint; +use std::collections::VecDeque; + +/// Generates SAT rules from the pool and request. +/// +/// Port of Composer's RuleSetGenerator.php. +pub struct RuleSetGenerator<'a> { + pool: &'a mut Pool, + rules: RuleSet, + /// Packages already processed. + added_map: IndexSet, + /// Package names → list of package IDs with that name (non-alias). + added_packages_by_name: IndexMap>, + /// Specific platform packages to ignore (from `--ignore-platform-req=name`). + ignore_platform_reqs: IndexSet, + /// When true, every platform package is treated as ignored. + /// Mirrors `--ignore-platform-reqs` (no value). + ignore_all_platform_reqs: bool, +} + +impl<'a> RuleSetGenerator<'a> { + pub fn new(pool: &'a mut Pool) -> Self { + RuleSetGenerator { + pool, + rules: RuleSet::new(), + added_map: IndexSet::new(), + added_packages_by_name: IndexMap::new(), + ignore_platform_reqs: IndexSet::new(), + ignore_all_platform_reqs: false, + } + } + + /// Set platform requirements to ignore. + pub fn set_ignore_platform_reqs(&mut self, names: IndexSet) { + self.ignore_platform_reqs = names; + } + + /// When set, every platform package is treated as ignored. + pub fn set_ignore_all_platform_reqs(&mut self, ignore_all: bool) { + self.ignore_all_platform_reqs = ignore_all; + } + + fn is_ignored_platform_dep(&self, name: &str) -> bool { + if self + .ignore_platform_reqs + .iter() + .any(|p| crate::matches_wildcard(name, p)) + { + return true; + } + self.ignore_all_platform_reqs && crate::platform::is_platform_package(name) + } + + /// Generate rules for a set of requirements and fixed packages. + /// + /// Port of Composer's RuleSetGenerator::getRulesFor. + /// + /// `root_provides` / `root_replaces` map a target package name to the + /// constraint declared in the root composer.json's `provide` / `replace` + /// section. They mirror the "self-fulfilling rule" check in Composer's + /// `RuleSetGenerator::createRequireRule`: when the root package itself + /// provides or replaces a name it requires, no install-one-of rule is + /// emitted for that root require — root is implicitly already installed, + /// so the requirement is trivially satisfied without forcing a real + /// provider. Without this, Mozart picks up an inline `provided/pkg` from + /// the repository even though the root claims to fulfill it itself. + /// + /// Returns the generated rule set together with the list of root requires + /// that have no matching providers in the pool. Mirrors Composer's + /// `Solver::checkForRootRequireProblems`: a root require with zero + /// providers does not produce a SAT rule (so the solver would otherwise + /// succeed with an empty plan), but it must still be reported as an + /// unresolvable problem. + pub fn generate( + mut self, + requires: &IndexMap>, + fixed_packages: &[PackageId], + root_provides: &IndexMap, + root_replaces: &IndexMap, + ) -> (RuleSet, Vec<(String, Option)>) { + let mut missing_root_requires: Vec<(String, Option)> = Vec::new(); + // Process fixed packages + for &pkg_id in fixed_packages { + if self.pool.is_unacceptable_fixed_package(pkg_id) { + continue; + } + + self.add_rules_for_package(pkg_id); + + // Create assertion rule: this package must be installed + let rule = Rule::new( + vec![pkg_id as Literal], + RuleReason::Fixed, + ReasonData::Fixed { package_id: pkg_id }, + ); + self.rules.add(rule, RuleType::Request); + } + + // Process root requirements + for (name, constraint) in requires { + if self.is_ignored_platform_dep(name.as_str()) { + continue; + } + + // Self-fulfilling root require: if the root composer.json declares + // `provide` / `replace` for this name and the link constraint + // intersects the require constraint, drop the install-one-of rule + // entirely. Mirrors Composer's `createRequireRule` returning null + // when a provider IS the package itself: there, the root is in the + // pool as a fixed package and `whatProvides` includes it, so the + // resulting rule is trivially satisfied. Mozart does not yet add + // the root to the pool, so we make the same decision here based + // on the explicit root provide/replace tables. + if root_self_fulfills(name, constraint.as_deref(), root_provides) + || root_self_fulfills(name, constraint.as_deref(), root_replaces) + { + continue; + } + + let providers = self.pool.what_provides(name, constraint.as_deref()); + + if !providers.is_empty() { + for &pkg_id in &providers { + self.add_rules_for_package(pkg_id); + } + + // Create "install one of" rule + let literals: Vec = providers.iter().map(|&id| id as Literal).collect(); + let rule = Rule::new( + literals, + RuleReason::RootRequire, + ReasonData::RootRequire { + package_name: name.clone(), + constraint: constraint.clone().unwrap_or_default(), + }, + ); + self.rules.add(rule, RuleType::Request); + } else { + missing_root_requires.push((name.clone(), constraint.clone())); + } + } + + // Mirror Composer's `RuleSetGenerator::addRulesForRootAliases`: + // ensure every alias whose target was already added gets its own + // alias↔target rules, even when the alias itself didn't appear in + // any root require's `whatProvides` (e.g. the synthetic + // `9999999-dev` alias from a `default-branch: true` package, which + // only matches a literal `9999999-dev` constraint). + let alias_pairs: Vec<(PackageId, PackageId)> = self + .pool + .packages() + .iter() + .filter_map(|p| p.is_alias_of.map(|t| (p.id, t))) + .collect(); + for (alias_id, target_id) in alias_pairs { + if self.added_map.contains(&target_id) && !self.added_map.contains(&alias_id) { + self.add_rules_for_package(alias_id); + } + } + + // Add conflict rules + self.add_conflict_rules(); + + (self.rules, missing_root_requires) + } + + /// Add rules for a package and its transitive dependencies. + /// + /// Port of Composer's RuleSetGenerator::addRulesForPackage. + fn add_rules_for_package(&mut self, pkg_id: PackageId) { + let mut work_queue: VecDeque = VecDeque::new(); + work_queue.push_back(pkg_id); + + while let Some(current_id) = work_queue.pop_front() { + if self.added_map.contains(¤t_id) { + continue; + } + self.added_map.insert(current_id); + + let pkg = self.pool.package_by_id(current_id); + let conflict_names: Vec = + pkg.conflict_names().into_iter().map(String::from).collect(); + let requires = pkg.requires.clone(); + let alias_target = pkg.is_alias_of; + + if let Some(target_id) = alias_target { + // Mirror Composer's RuleSetGenerator::addRulesForPackage alias + // branch: enqueue the target, emit `(-alias | target)` so the + // alias forces the target, and `(-target | alias)` so the + // target forces the alias (they install together). The alias + // is NOT indexed under its name for same-name conflicts — + // Composer skips that for aliases too. + work_queue.push_back(target_id); + + let alias_rule = Rule::two_literals( + -(current_id as Literal), + target_id as Literal, + RuleReason::PackageAlias, + ReasonData::AliasPackage(current_id), + ); + self.rules.add(alias_rule, RuleType::Package); + + let inverse_rule = Rule::two_literals( + -(target_id as Literal), + current_id as Literal, + RuleReason::PackageInverseAlias, + ReasonData::AliasPackage(current_id), + ); + self.rules.add(inverse_rule, RuleType::Package); + + // The aliased target carries the actual requires; skip + // alias's own (link-rewritten copy) to avoid duplicates. + continue; + } + + // Index by every name this package fully claims (own name + + // `replace` targets). Same-name conflict rules (below) then + // prevent two packages from coexisting under the same logical + // identity. Mirrors `BasePackage::getNames(false)` indexing in + // Composer's RuleSetGenerator::addRulesForPackage — `provide` + // targets are intentionally omitted so that providers can + // coexist with the package they provide. Alias packages are + // skipped because the target package's name already covers them. + for name in conflict_names { + self.added_packages_by_name + .entry(name) + .or_default() + .push(current_id); + } + + // Process each requirement + for link in requires { + if self.is_ignored_platform_dep(&link.target) { + continue; + } + + let possible_requires = self + .pool + .what_provides(&link.target, Some(&link.constraint)); + + // Create require rule: (-current | provider1 | provider2 | ...) + let mut literals: Vec = vec![-(current_id as Literal)]; + let mut self_fulfilling = false; + + for &provider_id in &possible_requires { + if provider_id == current_id { + self_fulfilling = true; + break; + } + literals.push(provider_id as Literal); + } + + if !self_fulfilling { + let rule = Rule::new( + literals, + RuleReason::PackageRequires, + ReasonData::Link(PoolLink { + target: link.target.clone(), + constraint: link.constraint.clone(), + source: self.pool.package_by_id(current_id).name.clone(), + }), + ); + self.rules.add(rule, RuleType::Package); + } + + // Enqueue providers for further processing + for &provider_id in &possible_requires { + work_queue.push_back(provider_id); + } + } + } + } + + /// Add conflict rules: explicit conflicts and same-name rules. + /// + /// Port of Composer's RuleSetGenerator::addConflictRules. + fn add_conflict_rules(&mut self) { + // Explicit conflicts + let added_ids: Vec = self.added_map.iter().copied().collect(); + for &pkg_id in &added_ids { + let pkg = self.pool.package_by_id(pkg_id); + let conflicts = pkg.conflicts.clone(); + + for link in conflicts { + if self.is_ignored_platform_dep(&link.target) { + continue; + } + + if !self.added_packages_by_name.contains_key(&link.target) { + continue; + } + + let conflicting = self + .pool + .what_provides(&link.target, Some(&link.constraint)); + + for &conflict_id in &conflicting { + if conflict_id == pkg_id { + continue; // ignore self-conflict + } + let rule = Rule::two_literals( + -(pkg_id as Literal), + -(conflict_id as Literal), + RuleReason::PackageConflict, + ReasonData::Link(link.clone()), + ); + self.rules.add(rule, RuleType::Package); + } + } + } + + // Same-name rules: only one version of a package can be installed + let names_to_process: Vec<(String, Vec)> = self + .added_packages_by_name + .iter() + .filter(|(_, ids)| ids.len() > 1) + .map(|(name, ids)| (name.clone(), ids.clone())) + .collect(); + + for (name, pkg_ids) in names_to_process { + let literals: Vec = pkg_ids.iter().map(|&id| -(id as Literal)).collect(); + + if literals.len() == 2 { + let rule = Rule::two_literals( + literals[0], + literals[1], + RuleReason::PackageSameName, + ReasonData::PackageName(name), + ); + self.rules.add(rule, RuleType::Package); + } else if literals.len() >= 3 { + let rule = Rule::multi_conflict( + literals, + RuleReason::PackageSameName, + ReasonData::PackageName(name), + ); + self.rules.add(rule, RuleType::Package); + } + } + } +} + +/// True when the root composer.json's `provide` / `replace` map declares +/// `target` with a constraint that intersects the require's constraint. A +/// missing require constraint is treated as `*` (matches anything), and a +/// missing/unparsable link constraint conservatively does NOT match — the +/// fixture fails closed back to the regular install-one-of path. +fn root_self_fulfills( + target: &str, + require_constraint: Option<&str>, + root_links: &IndexMap, +) -> bool { + let Some(link_constraint_str) = root_links.get(target) else { + return false; + }; + let Ok(link_vc) = VersionConstraint::parse(link_constraint_str) else { + return false; + }; + match require_constraint { + None => true, + Some(req) => match VersionConstraint::parse(req) { + Ok(req_vc) => req_vc.intersects(&link_vc), + Err(_) => false, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dependency_resolver::pool::{Pool, PoolLink, PoolPackageInput}; + + fn make_input(name: &str, version: &str) -> PoolPackageInput { + PoolPackageInput { + name: name.to_string(), + version: version.to_string(), + pretty_version: version.to_string(), + requires: vec![], + replaces: vec![], + provides: vec![], + conflicts: vec![], + is_fixed: false, + is_alias_of: None, + } + } + + #[test] + fn test_root_require_generates_rule() { + let mut pool = Pool::new( + vec![make_input("a/a", "1.0.0.0"), make_input("a/a", "2.0.0.0")], + vec![], + ); + + let mut requires = IndexMap::new(); + requires.insert("a/a".to_string(), None); + + let generator = RuleSetGenerator::new(&mut pool); + let (rules, _) = generator.generate(&requires, &[], &IndexMap::new(), &IndexMap::new()); + + // Should have a request rule: (1 | 2) + let request_count = rules.iter_type(RuleType::Request).count(); + assert_eq!(request_count, 1); + + // Should have a same-name rule: (-1 | -2) + let package_count = rules.iter_type(RuleType::Package).count(); + assert!(package_count >= 1); + } + + #[test] + fn test_dependency_chain_rules() { + // a/a 1.0 requires b/b + let mut pool = Pool::new( + vec![ + PoolPackageInput { + name: "a/a".to_string(), + version: "1.0.0.0".to_string(), + pretty_version: "1.0.0".to_string(), + requires: vec![PoolLink { + target: "b/b".to_string(), + constraint: "*".to_string(), + source: "a/a".to_string(), + }], + replaces: vec![], + provides: vec![], + conflicts: vec![], + is_fixed: false, + is_alias_of: None, + }, + make_input("b/b", "1.0.0.0"), + ], + vec![], + ); + + let mut requires = IndexMap::new(); + requires.insert("a/a".to_string(), None); + + let generator = RuleSetGenerator::new(&mut pool); + let (rules, _) = generator.generate(&requires, &[], &IndexMap::new(), &IndexMap::new()); + + // Should have: + // 1. Request rule: (1) — root requires a/a + // 2. Package rule: (-1 | 2) — a/a requires b/b + assert!(rules.len() >= 2); + } + + #[test] + fn test_fixed_package_rule() { + let mut pool = Pool::new(vec![make_input("php", "8.2.0.0")], vec![]); + + let generator = RuleSetGenerator::new(&mut pool); + let (rules, _) = + generator.generate(&IndexMap::new(), &[1], &IndexMap::new(), &IndexMap::new()); + + // Should have an assertion rule: (1) + let request_rules: Vec<_> = rules.iter_type(RuleType::Request).collect(); + assert_eq!(request_rules.len(), 1); + assert!(request_rules[0].1.is_assertion()); + } +} diff --git a/crates/mozart-core/src/dependency_resolver/rule_watch_graph.rs b/crates/mozart-core/src/dependency_resolver/rule_watch_graph.rs new file mode 100644 index 0000000..ac9e5b2 --- /dev/null +++ b/crates/mozart-core/src/dependency_resolver/rule_watch_graph.rs @@ -0,0 +1,288 @@ +use super::decisions::Decisions; +use super::pool::Literal; +use super::rule::Rule; +use super::rule_set::RuleId; +use indexmap::IndexMap; + +/// A watch node: tracks which 2 literals a rule watches. +/// +/// Port of Composer's RuleWatchNode.php. +#[derive(Debug, Clone)] +struct WatchNode { + /// First watched literal. + watch1: Literal, + /// Second watched literal. + watch2: Literal, + /// The rule ID this node refers to. + rule_id: RuleId, + /// Whether the rule is a multi-conflict rule. + is_multi_conflict: bool, +} + +/// Efficient unit propagation using 2-watched literals optimization. +/// +/// Port of Composer's RuleWatchGraph.php. +pub struct RuleWatchGraph { + /// Literal → list of watch node indices watching that literal. + watch_chains: IndexMap>, + /// All watch nodes. + nodes: Vec, +} + +impl RuleWatchGraph { + pub fn new() -> Self { + RuleWatchGraph { + watch_chains: IndexMap::new(), + nodes: Vec::new(), + } + } + + /// Insert a rule into the watch graph. + /// Assertions (single literal) are skipped. + pub fn insert(&mut self, rule_id: RuleId, rule: &Rule) { + if rule.is_assertion() { + return; + } + + let literals = rule.literals(); + let node_idx = self.nodes.len(); + + let watch1 = literals[0]; + let watch2 = if literals.len() > 1 { literals[1] } else { 0 }; + + self.nodes.push(WatchNode { + watch1, + watch2, + rule_id, + is_multi_conflict: rule.is_multi_conflict, + }); + + if rule.is_multi_conflict { + // Multi-conflict rules watch ALL their literals + for &lit in literals { + self.watch_chains.entry(lit).or_default().push(node_idx); + } + } else { + // Normal rules watch first 2 literals + self.watch_chains.entry(watch1).or_default().push(node_idx); + self.watch_chains.entry(watch2).or_default().push(node_idx); + } + } + + /// Adjust watch2 to the literal decided at the highest level. + /// Used for learned rules. + pub fn watch2_on_highest(&mut self, node_idx: usize, rule: &Rule, decisions: &Decisions) { + let literals = rule.literals(); + if literals.len() < 3 || rule.is_multi_conflict { + return; + } + + let mut watch_level = 0i32; + let mut best_literal = self.nodes[node_idx].watch2; + + for &lit in literals { + let level = decisions.decision_level(lit); + if level > watch_level { + best_literal = lit; + watch_level = level; + } + } + + let old_watch2 = self.nodes[node_idx].watch2; + if old_watch2 != best_literal { + // Remove from old chain, add to new chain + self.remove_from_chain(old_watch2, node_idx); + self.nodes[node_idx].watch2 = best_literal; + self.watch_chains + .entry(best_literal) + .or_default() + .push(node_idx); + } + } + + /// Propagate a decision literal through the watch graph. + /// Returns the rule ID of a conflicting rule, if found. + /// + /// Port of Composer's RuleWatchGraph::propagateLiteral. + pub fn propagate_literal( + &mut self, + decided_literal: Literal, + level: i32, + decisions: &mut Decisions, + rules: &super::rule_set::RuleSet, + ) -> Result, super::error::SolverBugError> { + // We look for rules watching the negation of the decided literal + let literal = -decided_literal; + + if !self.watch_chains.contains_key(&literal) { + return Ok(None); + } + + // Iterate the live chain. When a node is moved away (move_watch removes + // it from this chain), we stay at the same index since the Vec shrinks. + // When a node stays, we advance past it. + let mut i = 0; + loop { + let chain = match self.watch_chains.get(&literal) { + Some(c) if i < c.len() => c, + _ => break, + }; + + let node_idx = chain[i]; + let node = &self.nodes[node_idx]; + let rule_id = node.rule_id; + let is_multi_conflict = node.is_multi_conflict; + let rule = rules.rule_by_id(rule_id); + + if !is_multi_conflict { + let other_watch = if node.watch1 == literal { + node.watch2 + } else { + node.watch1 + }; + + if !rule.is_disabled() && !decisions.satisfy(other_watch) { + let rule_literals = rule.literals(); + + // Find an alternative literal to watch + let alternative = rule_literals + .iter() + .find(|&&rl| rl != literal && rl != other_watch && !decisions.conflict(rl)); + + if let Some(&alt_literal) = alternative { + // Move watch from `literal` to `alt_literal`. + // This removes node_idx from this chain, so don't increment i. + self.move_watch(literal, alt_literal, node_idx); + continue; + } + + if decisions.conflict(other_watch) { + return Ok(Some(rule_id)); + } + + decisions.decide(other_watch, level, rule_id)?; + } + } else { + // Multi-conflict rule: all literals are watched + let rule_literals = rule.literals().to_vec(); + for &other_literal in &rule_literals { + if other_literal != literal && !decisions.satisfy(other_literal) { + if decisions.conflict(other_literal) { + return Ok(Some(rule_id)); + } + decisions.decide(other_literal, level, rule_id)?; + } + } + } + + i += 1; + } + + Ok(None) + } + + /// Move a watch node from one literal's chain to another's. + fn move_watch(&mut self, from_literal: Literal, to_literal: Literal, node_idx: usize) { + // Update the node's watch + let node = &mut self.nodes[node_idx]; + if node.watch1 == from_literal { + node.watch1 = to_literal; + } else { + node.watch2 = to_literal; + } + + // Remove from old chain + self.remove_from_chain(from_literal, node_idx); + + // Add to new chain + self.watch_chains + .entry(to_literal) + .or_default() + .push(node_idx); + } + + /// Remove a node from a literal's watch chain. + fn remove_from_chain(&mut self, literal: Literal, node_idx: usize) { + if let Some(chain) = self.watch_chains.get_mut(&literal) { + chain.retain(|&idx| idx != node_idx); + } + } + + /// Get the last inserted node index (for watch2_on_highest after insert). + pub fn last_node_idx(&self) -> usize { + self.nodes.len() - 1 + } +} + +impl Default for RuleWatchGraph { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dependency_resolver::rule::{ReasonData, Rule, RuleReason}; + use crate::dependency_resolver::rule_set::RuleSet; + + #[test] + fn test_insert_assertion_skipped() { + let mut graph = RuleWatchGraph::new(); + let rule = Rule::new(vec![1], RuleReason::Fixed, ReasonData::None); + graph.insert(0, &rule); + assert_eq!(graph.nodes.len(), 0); + } + + #[test] + fn test_insert_normal_rule() { + let mut graph = RuleWatchGraph::new(); + let rule = Rule::new(vec![1, 2, 3], RuleReason::PackageRequires, ReasonData::None); + graph.insert(0, &rule); + assert_eq!(graph.nodes.len(), 1); + // Watches literals 1 and 2 + assert!(graph.watch_chains.contains_key(&1)); + assert!(graph.watch_chains.contains_key(&2)); + } + + #[test] + fn test_propagate_unit_clause() { + // Rule: (1 | 2). Decide -1, should force 2. + let mut rs = RuleSet::new(); + rs.add( + Rule::new(vec![1, 2], RuleReason::PackageRequires, ReasonData::None), + super::super::rule::RuleType::Package, + ); + + let mut graph = RuleWatchGraph::new(); + graph.insert(0, rs.rule_by_id(0)); + + let mut decisions = Decisions::new(); + decisions.decide(-1, 1, 99).unwrap(); // don't install package 1 + + let conflict = graph.propagate_literal(-1, 1, &mut decisions, &rs).unwrap(); + assert!(conflict.is_none()); + // Package 2 should now be decided install + assert!(decisions.decided_install(2)); + } + + #[test] + fn test_propagate_conflict() { + // Rule: (1 | 2). Decide -1, then -2 should conflict. + let mut rs = RuleSet::new(); + rs.add( + Rule::new(vec![1, 2], RuleReason::PackageRequires, ReasonData::None), + super::super::rule::RuleType::Package, + ); + + let mut graph = RuleWatchGraph::new(); + graph.insert(0, rs.rule_by_id(0)); + + let mut decisions = Decisions::new(); + decisions.decide(-1, 1, 99).unwrap(); + decisions.decide(-2, 1, 99).unwrap(); + + let conflict = graph.propagate_literal(-1, 1, &mut decisions, &rs).unwrap(); + assert!(conflict.is_some()); + } +} diff --git a/crates/mozart-core/src/dependency_resolver/solver.rs b/crates/mozart-core/src/dependency_resolver/solver.rs new file mode 100644 index 0000000..4abb888 --- /dev/null +++ b/crates/mozart-core/src/dependency_resolver/solver.rs @@ -0,0 +1,1008 @@ +use super::decisions::Decisions; +use super::error::{SolverBugError, SolverError}; +use super::policy::DefaultPolicy; +use super::pool::{Literal, PackageId, Pool, literal_to_package_id}; +use super::problem::Problem; +use super::rule::{ReasonData, Rule, RuleReason, RuleType}; +use super::rule_set::{RuleId, RuleSet}; +use super::rule_watch_graph::RuleWatchGraph; +use indexmap::{IndexMap, IndexSet}; + +/// Result of solving: the list of package IDs to install. +#[derive(Debug)] +pub struct SolverResult { + /// Package IDs decided for installation. + pub installed: Vec, +} + +/// Main SAT solver implementing CDCL (Conflict-Driven Clause Learning). +/// +/// Port of Composer's Solver.php. +pub struct Solver<'a> { + pool: &'a Pool, + policy: DefaultPolicy, + rules: RuleSet, + watch_graph: RuleWatchGraph, + decisions: Decisions, + /// Fixed packages by ID. + fixed_map: IndexSet, + /// Current propagation index in decision queue. + propagate_index: usize, + /// Branch points: (alternative literals, decision level). + branches: Vec<(Vec, i32)>, + /// Problems found during solving. + problems: Vec, + /// Learned rule pool: for each learned rule, the chain of rules that led to it. + learned_pool: Vec>, + /// Map from rule ID → learned pool index. + learned_why: IndexMap, +} + +impl<'a> Solver<'a> { + /// Create a new solver with the given rules, pool, policy, and fixed package set. + pub fn new( + rules: RuleSet, + pool: &'a Pool, + policy: DefaultPolicy, + fixed_packages: IndexSet, + ) -> Self { + Solver { + pool, + policy, + rules, + watch_graph: RuleWatchGraph::new(), + decisions: Decisions::new(), + fixed_map: fixed_packages, + propagate_index: 0, + branches: Vec::new(), + problems: Vec::new(), + learned_pool: Vec::new(), + learned_why: IndexMap::new(), + } + } + + /// Solve the dependency resolution problem. + /// Returns the set of packages to install, or an error. + pub fn solve(mut self) -> Result { + // Insert all rules into watch graph + let rule_count = self.rules.len(); + for id in 0..rule_count { + let rule = self.rules.rule_by_id(id); + self.watch_graph.insert(id, rule); + } + + // Make decisions based on assertion rules (unit clauses) + self.make_assertion_rule_decisions()?; + + // Run the main SAT loop + self.run_sat()?; + + if !self.problems.is_empty() { + let messages: Vec = self + .problems + .iter() + .map(|p| p.pretty_string(self.pool, &self.rules)) + .collect(); + return Err(SolverError::Unsolvable(messages)); + } + + // Collect installed packages + let mut installed = Vec::new(); + for i in 0..self.decisions.len() { + let decision = self.decisions.at_offset(i); + if decision.literal > 0 { + installed.push(literal_to_package_id(decision.literal)); + } + } + + Ok(SolverResult { installed }) + } + + /// Process assertion rules (unit clauses) — make immediate decisions. + /// + /// Port of Composer's Solver::makeAssertionRuleDecisions. + fn make_assertion_rule_decisions(&mut self) -> Result<(), SolverError> { + let decision_start = if self.decisions.is_empty() { + 0 + } else { + self.decisions.len() - 1 + }; + + let mut rule_index: usize = 0; + while rule_index < self.rules.len() { + let rule = self.rules.rule_by_id(rule_index); + + if !rule.is_assertion() || rule.is_disabled() { + rule_index += 1; + continue; + } + + let literal = rule.literals()[0]; + + if !self.decisions.decided(literal) { + self.decisions.decide(literal, 1, rule_index)?; + rule_index += 1; + continue; + } + + if self.decisions.satisfy(literal) { + rule_index += 1; + continue; + } + + // Found a conflict + let rule_type = self.rules.rule_by_id(rule_index).rule_type; + + if rule_type == RuleType::Learned { + self.rules.rule_by_id_mut(rule_index).disable(); + rule_index += 1; + continue; + } + + let conflict_rule_id = self.decisions.decision_rule(literal)?; + let conflict_type = self.rules.rule_by_id(conflict_rule_id).rule_type; + + if conflict_type == RuleType::Package { + let mut problem = Problem::new(); + problem.add_rule(rule_index); + problem.add_rule(conflict_rule_id); + self.rules.rule_by_id_mut(rule_index).disable(); + self.problems.push(problem); + rule_index += 1; + continue; + } + + // Conflict with another root require/fixed package + let mut problem = Problem::new(); + problem.add_rule(rule_index); + problem.add_rule(conflict_rule_id); + + // Push all request assertion rules asserting this literal + let pkg_id = literal_to_package_id(literal); + let request_rule_ids: Vec = self + .rules + .iter_type(RuleType::Request) + .filter(|(_, r)| { + !r.is_disabled() + && r.is_assertion() + && literal_to_package_id(r.literals()[0]) == pkg_id + }) + .map(|(id, _)| id) + .collect(); + + for rid in &request_rule_ids { + problem.add_rule(*rid); + } + self.problems.push(problem); + + for rid in request_rule_ids { + self.rules.rule_by_id_mut(rid).disable(); + } + + self.decisions.reset_to_offset(decision_start); + rule_index = 0; // restart + } + + Ok(()) + } + + /// Unit propagation: propagate decisions through the watch graph. + /// + /// Port of Composer's Solver::propagate. + fn propagate(&mut self, level: i32) -> Result, SolverBugError> { + while self.decisions.valid_offset(self.propagate_index) { + let decision = self.decisions.at_offset(self.propagate_index).clone(); + self.propagate_index += 1; + + let conflict = self.watch_graph.propagate_literal( + decision.literal, + level, + &mut self.decisions, + &self.rules, + )?; + + if conflict.is_some() { + return Ok(conflict); + } + } + + Ok(None) + } + + /// Revert decisions to a given level. + /// + /// Port of Composer's Solver::revert. + fn revert(&mut self, level: i32) { + while !self.decisions.is_empty() { + let literal = self.decisions.last_literal(); + if self.decisions.undecided(literal) { + break; + } + let decision_level = self.decisions.decision_level(literal); + if decision_level <= level { + break; + } + self.decisions.revert_last(); + self.propagate_index = self.decisions.len(); + } + + while !self.branches.is_empty() && self.branches.last().unwrap().1 >= level { + self.branches.pop(); + } + } + + /// Make a decision, propagate, and learn from conflicts. + /// + /// Port of Composer's Solver::setPropagateLearn. + fn set_propagate_learn( + &mut self, + mut level: i32, + literal: Literal, + rule_id: RuleId, + ) -> Result { + level += 1; + self.decisions.decide(literal, level, rule_id)?; + + loop { + let conflict = self.propagate(level)?; + + let Some(conflict_rule_id) = conflict else { + break; + }; + + if level == 1 { + self.analyze_unsolvable(conflict_rule_id); + return Ok(0); + } + + // Conflict analysis + let (learn_literal, new_level, new_rule, why) = + self.analyze(level, conflict_rule_id)?; + + if new_level <= 0 || new_level >= level { + return Err(SolverBugError { + message: format!( + "Trying to revert to invalid level {new_level} from level {level}." + ), + } + .into()); + } + + level = new_level; + self.revert(level); + + // Add learned rule + self.rules.add(new_rule, RuleType::Learned); + let new_rule_id = self.rules.len() - 1; + + self.learned_why.insert(new_rule_id, why); + + let rule_ref = self.rules.rule_by_id(new_rule_id); + self.watch_graph.insert(new_rule_id, rule_ref); + + // Adjust watch2 to highest level literal + let last_node = self.watch_graph.last_node_idx(); + let rule_for_watch = self.rules.rule_by_id(new_rule_id); + self.watch_graph + .watch2_on_highest(last_node, rule_for_watch, &self.decisions); + + self.decisions.decide(learn_literal, level, new_rule_id)?; + } + + Ok(level) + } + + /// Choose best package from candidates and install. + /// + /// Port of Composer's Solver::selectAndInstall. + fn select_and_install( + &mut self, + level: i32, + decision_queue: Vec, + rule_id: RuleId, + ) -> Result { + let required_package = self + .rules + .rule_by_id(rule_id) + .required_package() + .map(|s| s.to_string()); + let mut literals = self.policy.select_preferred_packages( + self.pool, + &decision_queue, + required_package.as_deref(), + ); + + let selected = literals.remove(0); + + // If there are remaining alternatives, save as branch point + if !literals.is_empty() { + self.branches.push((literals, level)); + } + + self.set_propagate_learn(level, selected, rule_id) + } + + /// First UIP conflict analysis. + /// + /// Port of Composer's Solver::analyze. + fn analyze( + &mut self, + level: i32, + conflict_rule_id: RuleId, + ) -> Result<(Literal, i32, Rule, usize), SolverError> { + let mut rule_level: i32 = 1; + let mut num: i32 = 0; + let mut l1num: i32 = 0; + let mut seen: IndexSet = IndexSet::new(); + let mut learned_literal: Option = None; + let mut other_learned_literals: Vec = Vec::new(); + + let mut decision_id = self.decisions.len(); + + self.learned_pool.push(Vec::new()); + let pool_idx = self.learned_pool.len() - 1; + + let mut current_rule_id = conflict_rule_id; + + loop { + self.learned_pool[pool_idx].push(current_rule_id); + + let rule = self.rules.rule_by_id(current_rule_id); + let rule_literals = rule.literals().to_vec(); + let is_multi_conflict = rule.is_multi_conflict; + + for &literal in &rule_literals { + // MultiConflictRule: skip undecided literals + if is_multi_conflict && !self.decisions.decided(literal) { + continue; + } + + // Skip the one true literal + if self.decisions.satisfy(literal) { + continue; + } + + let pkg_id = literal_to_package_id(literal); + if seen.contains(&pkg_id) { + continue; + } + seen.insert(pkg_id); + + let l = self.decisions.decision_level(literal); + + if l == 1 { + l1num += 1; + } else if l == level { + num += 1; + } else { + other_learned_literals.push(literal); + if l > rule_level { + rule_level = l; + } + } + } + + // l1 retry loop + let mut l1retry = true; + while l1retry { + l1retry = false; + + if num == 0 { + l1num -= 1; + if l1num == 0 { + // All level 1 literals done + let why = pool_idx; + let ll = learned_literal.ok_or_else(|| SolverBugError { + message: format!( + "Did not find a learnable literal in analyzed rule {conflict_rule_id}." + ), + })?; + + let mut all_literals = vec![ll]; + all_literals.extend_from_slice(&other_learned_literals); + + let new_rule = + Rule::new(all_literals, RuleReason::Learned, ReasonData::Learned(why)); + + return Ok((ll, rule_level, new_rule, why)); + } + } + + loop { + if decision_id == 0 { + return Err(SolverBugError { + message: format!( + "Reached invalid decision id 0 while analyzing rule {conflict_rule_id}." + ), + } + .into()); + } + + decision_id -= 1; + let decision = self.decisions.at_offset(decision_id); + let literal = decision.literal; + + if seen.contains(&literal_to_package_id(literal)) { + break; + } + } + + let decision = self.decisions.at_offset(decision_id); + let literal = decision.literal; + + seen.shift_remove(&literal_to_package_id(literal)); + + if num != 0 { + num -= 1; + if num == 0 { + learned_literal = Some(-literal); + + if l1num == 0 { + // Done + let why = pool_idx; + let ll = learned_literal.unwrap(); + + let mut all_literals = vec![ll]; + all_literals.extend_from_slice(&other_learned_literals); + + let new_rule = Rule::new( + all_literals, + RuleReason::Learned, + ReasonData::Learned(why), + ); + + return Ok((ll, rule_level, new_rule, why)); + } + + // Only level 1 marks left + for other in &other_learned_literals { + seen.shift_remove(&literal_to_package_id(*other)); + } + l1num += 1; + l1retry = true; + } else { + let decision = self.decisions.at_offset(decision_id); + let next_rule_id = decision.rule_id; + let next_rule = self.rules.rule_by_id(next_rule_id); + + if next_rule.is_multi_conflict { + // Handle multi-conflict rule + let mcr_literals = next_rule.literals().to_vec(); + for &rule_literal in &mcr_literals { + let pkg_id = literal_to_package_id(rule_literal); + if !seen.contains(&pkg_id) && self.decisions.satisfy(-rule_literal) + { + self.learned_pool[pool_idx].push(next_rule_id); + let l = self.decisions.decision_level(rule_literal); + if l == 1 { + l1num += 1; + } else if l == level { + num += 1; + } else { + other_learned_literals.push(rule_literal); + if l > rule_level { + rule_level = l; + } + } + seen.insert(pkg_id); + break; + } + } + l1retry = true; + } + } + } + } + + let decision = self.decisions.at_offset(decision_id); + current_rule_id = decision.rule_id; + } + } + + /// Recursively collect rules involved in an unsolvable conflict. + fn analyze_unsolvable_rule( + &self, + problem: &mut Problem, + conflict_rule_id: RuleId, + rule_seen: &mut IndexSet, + ) { + if rule_seen.contains(&conflict_rule_id) { + return; + } + rule_seen.insert(conflict_rule_id); + + let rule = self.rules.rule_by_id(conflict_rule_id); + + if rule.rule_type == RuleType::Learned { + if let Some(&why) = self.learned_why.get(&conflict_rule_id) + && let Some(problem_rules) = self.learned_pool.get(why) + { + for &pr_id in problem_rules { + if !rule_seen.contains(&pr_id) { + self.analyze_unsolvable_rule(problem, pr_id, rule_seen); + } + } + } + return; + } + + if rule.rule_type == RuleType::Package { + // Package rules cannot be part of a problem + return; + } + + problem.next_section(); + problem.add_rule(conflict_rule_id); + } + + /// Analyze an unsolvable conflict at level 1. + /// + /// Port of Composer's Solver::analyzeUnsolvable. + fn analyze_unsolvable(&mut self, conflict_rule_id: RuleId) { + let mut problem = Problem::new(); + problem.add_rule(conflict_rule_id); + + let mut rule_seen = IndexSet::new(); + self.analyze_unsolvable_rule(&mut problem, conflict_rule_id, &mut rule_seen); + + // Collect related decisions + let mut seen: IndexSet = IndexSet::new(); + let conflict_literals = self.rules.rule_by_id(conflict_rule_id).literals().to_vec(); + for &lit in &conflict_literals { + if self.decisions.satisfy(lit) { + continue; + } + seen.insert(literal_to_package_id(lit)); + } + + // Walk decisions in reverse + for i in (0..self.decisions.len()).rev() { + let decision = self.decisions.at_offset(i); + let dec_literal = decision.literal; + let pkg_id = literal_to_package_id(dec_literal); + + if !seen.contains(&pkg_id) { + continue; + } + + let why = decision.rule_id; + problem.add_rule(why); + self.analyze_unsolvable_rule(&mut problem, why, &mut rule_seen); + + let why_literals = self.rules.rule_by_id(why).literals().to_vec(); + for &lit in &why_literals { + if self.decisions.satisfy(lit) { + continue; + } + seen.insert(literal_to_package_id(lit)); + } + } + + self.problems.push(problem); + } + + /// Main SAT loop. + /// + /// Port of Composer's Solver::runSat. + fn run_sat(&mut self) -> Result<(), SolverError> { + self.propagate_index = 0; + + let mut level: i32 = 1; + let mut system_level: i32 = level + 1; + + loop { + // Step 1: propagate at level 1 + if level == 1 { + let conflict = self.propagate(level)?; + if let Some(conflict_rule_id) = conflict { + self.analyze_unsolvable(conflict_rule_id); + return Ok(()); + } + } + + // Step 2: handle root require/fixed package rules + if level < system_level { + let mut made_decision = false; + + // Collect request rule IDs first to avoid borrow issues + let request_rule_ids: Vec = self + .rules + .iter_type(RuleType::Request) + .map(|(id, _)| id) + .collect(); + + let mut all_satisfied = true; + + for &rule_id in &request_rule_ids { + let rule = self.rules.rule_by_id(rule_id); + if !rule.is_enabled() { + continue; + } + + let mut decision_queue: Vec = Vec::new(); + let mut none_satisfied = true; + + for &lit in rule.literals() { + if self.decisions.satisfy(lit) { + none_satisfied = false; + break; + } + if lit > 0 && self.decisions.undecided(lit) { + decision_queue.push(lit); + } + } + + if none_satisfied && !decision_queue.is_empty() { + // Prune: prefer fixed packages + let pruned: Vec = decision_queue + .iter() + .filter(|&&lit| self.fixed_map.contains(&literal_to_package_id(lit))) + .copied() + .collect(); + + if !pruned.is_empty() { + decision_queue = pruned; + } + } + + if none_satisfied && !decision_queue.is_empty() { + let old_level = level; + level = self.select_and_install(level, decision_queue, rule_id)?; + + if level == 0 { + return Ok(()); + } + if level <= old_level { + made_decision = true; + break; + } + } + + // Check if there are more rules to process + all_satisfied = false; + } + + system_level = level + 1; + + if made_decision || !all_satisfied { + // Check if we still have unsatisfied request rules + let has_unsatisfied = request_rule_ids.iter().any(|&rule_id| { + let rule = self.rules.rule_by_id(rule_id); + if !rule.is_enabled() { + return false; + } + let mut none_satisfied = true; + for &lit in rule.literals() { + if self.decisions.satisfy(lit) { + none_satisfied = false; + break; + } + } + if !none_satisfied { + return false; + } + rule.literals() + .iter() + .any(|&lit| lit > 0 && self.decisions.undecided(lit)) + }); + + if has_unsatisfied { + continue; + } + } + } + + if level < system_level { + system_level = level; + } + + // Step 3: fulfill all unresolved rules + let mut rules_count = self.rules.len(); + let mut i: usize = 0; + let mut n: usize = 0; + let mut made_decision = false; + + while n < rules_count { + if i == rules_count { + i = 0; + } + + let rule = self.rules.rule_by_id(i); + let literals = rule.literals().to_vec(); + + i += 1; + n += 1; + + if rule.is_disabled() { + continue; + } + + let mut decision_queue: Vec = Vec::new(); + let mut skip = false; + + for &lit in &literals { + if lit <= 0 { + if !self.decisions.decided_install(lit) { + skip = true; + break; + } + } else { + if self.decisions.decided_install(lit) { + skip = true; + break; + } + if self.decisions.undecided(lit) { + decision_queue.push(lit); + } + } + } + + if skip { + continue; + } + + // Need at least 2 undecided positive literals + if decision_queue.len() < 2 { + continue; + } + + let rule_id = i - 1; + level = self.select_and_install(level, decision_queue, rule_id)?; + + if level == 0 { + return Ok(()); + } + + // Something changed, restart scan + rules_count = self.rules.len(); + n = 0; + i = 0; + made_decision = true; + } + + if level < system_level && made_decision { + continue; + } + + // Step 4: minimization (backjumping) + if !self.branches.is_empty() { + let mut last_literal: Option = None; + let mut last_level: Option = None; + let mut last_branch_index: usize = 0; + let mut last_branch_offset: usize = 0; + + for i in (0..self.branches.len()).rev() { + let (ref literals, l) = self.branches[i]; + for (offset, &literal) in literals.iter().enumerate() { + if literal > 0 && self.decisions.decision_level(literal) > l + 1 { + last_literal = Some(literal); + last_branch_index = i; + last_branch_offset = offset; + last_level = Some(l); + } + } + } + + if let Some(literal) = last_literal { + let last_l = last_level.unwrap(); + self.branches[last_branch_index] + .0 + .remove(last_branch_offset); + + level = last_l; + self.revert(level); + + let why = self.decisions.last_reason(); + + level = self.set_propagate_learn(level, literal, why)?; + + if level == 0 { + return Ok(()); + } + + continue; + } + } + + break; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dependency_resolver::pool::PoolPackageInput; + use crate::dependency_resolver::rule::{ReasonData, Rule, RuleReason, RuleType}; + + fn make_input(name: &str, version: &str) -> PoolPackageInput { + PoolPackageInput { + name: name.to_string(), + version: version.to_string(), + pretty_version: version.to_string(), + requires: vec![], + replaces: vec![], + provides: vec![], + conflicts: vec![], + is_fixed: false, + is_alias_of: None, + } + } + + /// Helper: create a simple problem and solve it. + /// Creates a pool with N dummy packages (1..=max_id). + fn make_rules_and_solve( + rules: Vec<(Rule, RuleType)>, + fixed: IndexSet, + max_id: u32, + ) -> Result { + let mut rs = RuleSet::new(); + for (rule, rt) in rules { + rs.add(rule, rt); + } + let inputs: Vec<_> = (1..=max_id) + .map(|i| make_input(&format!("pkg/{i}"), &format!("{i}.0.0.0"))) + .collect(); + let pool = Pool::new(inputs, vec![]); + let policy = DefaultPolicy::default(); + let solver = Solver::new(rs, &pool, policy, fixed); + solver.solve() + } + + #[test] + fn test_single_package_required() { + // Root requires package 1 + let result = make_rules_and_solve( + vec![( + Rule::new(vec![1], RuleReason::RootRequire, ReasonData::None), + RuleType::Request, + )], + IndexSet::new(), + 3, + ) + .unwrap(); + + assert_eq!(result.installed, vec![1]); + } + + #[test] + fn test_two_packages_required() { + // Root requires either package 1 or 2, and also requires 3 + let result = make_rules_and_solve( + vec![ + ( + Rule::new(vec![1, 2], RuleReason::RootRequire, ReasonData::None), + RuleType::Request, + ), + ( + Rule::new(vec![3], RuleReason::RootRequire, ReasonData::None), + RuleType::Request, + ), + ], + IndexSet::new(), + 3, + ) + .unwrap(); + + assert!(result.installed.contains(&3)); + // Should install one of 1 or 2 + assert!(result.installed.contains(&1) || result.installed.contains(&2)); + } + + #[test] + fn test_dependency_chain() { + // Root requires 1. Package 1 requires 2. + // Rule for root: (1) + // Rule for dep: (-1 | 2) + let result = make_rules_and_solve( + vec![ + ( + Rule::new(vec![1], RuleReason::RootRequire, ReasonData::None), + RuleType::Request, + ), + ( + Rule::new(vec![-1, 2], RuleReason::PackageRequires, ReasonData::None), + RuleType::Package, + ), + ], + IndexSet::new(), + 3, + ) + .unwrap(); + + assert!(result.installed.contains(&1)); + assert!(result.installed.contains(&2)); + } + + #[test] + fn test_conflict_resolution() { + // Root requires 1 or 2. Package 1 conflicts with 3. + // Package 2 requires 3. + // Rules: + // Request: (1 | 2) + // Package: (-1 | -3) -- conflict + // Package: (-2 | 3) -- dep + // Request: (3) -- root also requires 3 + let result = make_rules_and_solve( + vec![ + ( + Rule::new(vec![1, 2], RuleReason::RootRequire, ReasonData::None), + RuleType::Request, + ), + ( + Rule::two_literals(-1, -3, RuleReason::PackageConflict, ReasonData::None), + RuleType::Package, + ), + ( + Rule::new(vec![-2, 3], RuleReason::PackageRequires, ReasonData::None), + RuleType::Package, + ), + ( + Rule::new(vec![3], RuleReason::RootRequire, ReasonData::None), + RuleType::Request, + ), + ], + IndexSet::new(), + 3, + ) + .unwrap(); + + // Package 3 is required, so 1 conflicts, must choose 2 + assert!(result.installed.contains(&2)); + assert!(result.installed.contains(&3)); + assert!(!result.installed.contains(&1)); + } + + #[test] + fn test_same_name_conflict() { + // Two versions of same package: 1 and 2. Root requires either. + // Same-name rule: (-1 | -2) + let result = make_rules_and_solve( + vec![ + ( + Rule::new(vec![1, 2], RuleReason::RootRequire, ReasonData::None), + RuleType::Request, + ), + ( + Rule::two_literals(-1, -2, RuleReason::PackageSameName, ReasonData::None), + RuleType::Package, + ), + ], + IndexSet::new(), + 3, + ) + .unwrap(); + + // Should install exactly one + let has_1 = result.installed.contains(&1); + let has_2 = result.installed.contains(&2); + assert!(has_1 ^ has_2, "Should install exactly one of 1 or 2"); + } + + #[test] + fn test_unsolvable() { + // Root requires 1. Root requires 2. But 1 and 2 conflict. + let result = make_rules_and_solve( + vec![ + ( + Rule::new(vec![1], RuleReason::RootRequire, ReasonData::None), + RuleType::Request, + ), + ( + Rule::new(vec![2], RuleReason::RootRequire, ReasonData::None), + RuleType::Request, + ), + ( + Rule::two_literals(-1, -2, RuleReason::PackageConflict, ReasonData::None), + RuleType::Package, + ), + ], + IndexSet::new(), + 3, + ); + + assert!(result.is_err()); + } +} diff --git a/crates/mozart-core/src/dependency_resolver/transaction.rs b/crates/mozart-core/src/dependency_resolver/transaction.rs new file mode 100644 index 0000000..736d230 --- /dev/null +++ b/crates/mozart-core/src/dependency_resolver/transaction.rs @@ -0,0 +1,568 @@ +use super::decisions::Decisions; +use super::pool::{PackageId, Pool, literal_to_package_id}; +use indexmap::{IndexMap, IndexSet}; + +/// An operation to perform on a package. +/// +/// Port of Composer's SolverOperation hierarchy. +#[derive(Debug, Clone)] +pub enum Operation { + /// Install a new package. + Install { package_id: PackageId }, + /// Update a package from one version to another. + Update { + initial_id: PackageId, + target_id: PackageId, + }, + /// Remove a package. + Uninstall { package_id: PackageId }, +} + +impl Operation { + /// Get the operation type as a string. + pub fn operation_type(&self) -> &'static str { + match self { + Operation::Install { .. } => "install", + Operation::Update { .. } => "update", + Operation::Uninstall { .. } => "uninstall", + } + } + + /// Format the operation as a human-readable string using pool data. + pub fn pretty_string(&self, pool: &Pool) -> String { + match self { + Operation::Install { package_id } => { + let pkg = pool.package_by_id(*package_id); + format!("Installing {} ({})", pkg.name, pkg.pretty_version) + } + Operation::Update { + initial_id, + target_id, + } => { + let initial = pool.package_by_id(*initial_id); + let target = pool.package_by_id(*target_id); + format!( + "Updating {} ({} => {})", + target.name, initial.pretty_version, target.pretty_version + ) + } + Operation::Uninstall { package_id } => { + let pkg = pool.package_by_id(*package_id); + format!("Removing {} ({})", pkg.name, pkg.pretty_version) + } + } + } +} + +/// Computes install/update/remove operations from solver results. +/// +/// Port of Composer's Transaction.php. +pub struct Transaction<'a> { + pool: &'a Pool, + /// Currently installed package IDs. + present_ids: Vec, + /// Result package IDs from the solver. + result_ids: Vec, + /// Computed operations. + operations: Vec, +} + +impl<'a> Transaction<'a> { + /// Create a new transaction from present and result package sets. + pub fn new(pool: &'a Pool, present_ids: Vec, result_ids: Vec) -> Self { + let mut tx = Transaction { + pool, + present_ids, + result_ids, + operations: Vec::new(), + }; + tx.calculate_operations(); + tx + } + + /// Create a transaction from solver decisions. + pub fn from_decisions( + pool: &'a Pool, + present_ids: Vec, + decisions: &Decisions, + ) -> Self { + let mut result_ids = Vec::new(); + for i in 0..decisions.len() { + let decision = decisions.at_offset(i); + if decision.literal > 0 { + result_ids.push(literal_to_package_id(decision.literal)); + } + } + Self::new(pool, present_ids, result_ids) + } + + /// Get the computed operations. + pub fn operations(&self) -> &[Operation] { + &self.operations + } + + /// Calculate the delta between present and result packages. + fn calculate_operations(&mut self) { + // Build maps: name -> package_id for present packages + let mut present_by_name: IndexMap<&str, PackageId> = IndexMap::new(); + for &id in &self.present_ids { + let pkg = self.pool.package_by_id(id); + present_by_name.insert(&pkg.name, id); + } + + // Track which present packages have been matched + let mut matched_present: IndexSet = IndexSet::new(); + + // Build topologically sorted result packages via DFS + let sorted_results = self.topological_sort(); + + // Process result packages in topological order + for &result_id in &sorted_results { + let result_pkg = self.pool.package_by_id(result_id); + + if let Some(&present_id) = present_by_name.get(result_pkg.name.as_str()) { + matched_present.insert(present_id); + let present_pkg = self.pool.package_by_id(present_id); + + // Check if update is needed (version changed) + if present_pkg.version != result_pkg.version || present_id != result_id { + self.operations.push(Operation::Update { + initial_id: present_id, + target_id: result_id, + }); + } + // Otherwise: no change needed, skip + } else { + // New package: install + self.operations.push(Operation::Install { + package_id: result_id, + }); + } + } + + // Remove packages that are present but not in result + let mut uninstalls = Vec::new(); + for &present_id in &self.present_ids { + if !matched_present.contains(&present_id) { + uninstalls.push(Operation::Uninstall { + package_id: present_id, + }); + } + } + + // Prepend uninstalls (remove before install/update) + uninstalls.append(&mut self.operations); + self.operations = uninstalls; + } + + /// Topologically sort result packages by their dependency order. + /// Uses DFS: dependencies are processed before dependents. + fn topological_sort(&self) -> Vec { + // Index every result package by every name it answers to (own name + + // `replaces` targets + `provides` targets). Mirrors Composer's + // `resultPackagesByName` map, which `getProvidersInResult` queries + // when walking a package's requires — so a replace/provide target + // resolves to the package that satisfies it. Without this expansion + // the DFS treats replace/provide-only requires as unsatisfied and + // misses the transitive ordering edge. + let mut result_by_target: IndexMap<&str, Vec> = IndexMap::new(); + for &id in &self.result_ids { + let pkg = self.pool.package_by_id(id); + result_by_target.entry(&pkg.name).or_default().push(id); + for link in &pkg.replaces { + result_by_target.entry(&link.target).or_default().push(id); + } + for link in &pkg.provides { + result_by_target.entry(&link.target).or_default().push(id); + } + } + + let mut visited: IndexSet = IndexSet::new(); + let mut order: Vec = Vec::new(); + + // Find root packages (not required by any other result package) + let roots = self.get_root_packages(&result_by_target); + + // DFS from roots + let mut stack: Vec<(PackageId, bool)> = Vec::new(); + for &root_id in roots.iter().rev() { + stack.push((root_id, false)); + } + + while let Some((pkg_id, processed)) = stack.pop() { + if processed { + if visited.insert(pkg_id) { + order.push(pkg_id); + } + continue; + } + + if visited.contains(&pkg_id) { + continue; + } + + // Push self as "processed" marker + stack.push((pkg_id, true)); + + // Push dependencies + let pkg = self.pool.package_by_id(pkg_id); + for req in &pkg.requires { + if let Some(provider_ids) = result_by_target.get(req.target.as_str()) { + for &dep_id in provider_ids { + if !visited.contains(&dep_id) { + stack.push((dep_id, false)); + } + } + } + } + } + + // Add any remaining unvisited packages + for &id in &self.result_ids { + if !visited.contains(&id) { + order.push(id); + } + } + + order + } + + /// Find root packages: result packages not required by any other result + /// package. A package whose own name (or any `replaces`/`provides` + /// target) appears in another result package's `requires` is excluded. + /// Mirrors Composer's `Transaction::getRootPackages`, which uses + /// `getProvidersInResult` to do the same expansion. + fn get_root_packages( + &self, + result_by_target: &IndexMap<&str, Vec>, + ) -> Vec { + let mut required: IndexSet = IndexSet::new(); + for &id in &self.result_ids { + let pkg = self.pool.package_by_id(id); + for req in &pkg.requires { + if let Some(provider_ids) = result_by_target.get(req.target.as_str()) { + for &dep_id in provider_ids { + if dep_id != id { + required.insert(dep_id); + } + } + } + } + } + + let mut roots: Vec = Vec::new(); + for &id in &self.result_ids { + if !required.contains(&id) { + roots.push(id); + } + } + + // If no roots found (circular), use all + if roots.is_empty() { + return self.result_ids.clone(); + } + + roots + } +} + +/// Lock transaction: specialization for computing lock file operations. +/// +/// Port of Composer's LockTransaction.php. +pub struct LockTransaction<'a> { + /// The base transaction. + transaction: Transaction<'a>, + /// All result package IDs. + all_result_ids: Vec, + /// Non-dev result package IDs. + non_dev_ids: Vec, + /// Dev result package IDs. + dev_ids: Vec, +} + +impl<'a> LockTransaction<'a> { + /// Create a lock transaction from solver decisions. + pub fn new( + pool: &'a Pool, + present_ids: Vec, + unlockable_ids: IndexSet, + decisions: &Decisions, + ) -> Self { + // Extract result packages from decisions + let mut all_result_ids = Vec::new(); + let mut non_dev_ids = Vec::new(); + for i in 0..decisions.len() { + let decision = decisions.at_offset(i); + if decision.literal > 0 { + let pkg_id = literal_to_package_id(decision.literal); + all_result_ids.push(pkg_id); + if !unlockable_ids.contains(&pkg_id) { + non_dev_ids.push(pkg_id); + } + } + } + + let transaction = Transaction::new(pool, present_ids, all_result_ids.clone()); + + LockTransaction { + transaction, + all_result_ids, + non_dev_ids, + dev_ids: Vec::new(), + } + } + + /// Set the non-dev packages from an extraction-only solve result. + /// `extraction_ids` are the package IDs that were resolved without dev deps. + pub fn set_non_dev_packages(&mut self, extraction_ids: &[PackageId]) { + let extraction_names: IndexSet = extraction_ids + .iter() + .map(|&id| self.transaction.pool.package_by_id(id).name.clone()) + .collect(); + + self.non_dev_ids.clear(); + self.dev_ids.clear(); + + for &id in &self.all_result_ids { + let pkg = self.transaction.pool.package_by_id(id); + if extraction_names.contains(&pkg.name) { + self.non_dev_ids.push(id); + } else { + self.dev_ids.push(id); + } + } + } + + /// Get the computed operations. + pub fn operations(&self) -> &[Operation] { + self.transaction.operations() + } + + /// Get all result package IDs. + pub fn all_result_ids(&self) -> &[PackageId] { + &self.all_result_ids + } + + /// Get non-dev result package IDs. + pub fn non_dev_ids(&self) -> &[PackageId] { + &self.non_dev_ids + } + + /// Get dev result package IDs. + pub fn dev_ids(&self) -> &[PackageId] { + &self.dev_ids + } + + /// Get new lock packages for writing to the lock file. + /// If `dev_mode` is true, returns dev packages; otherwise non-dev. + pub fn new_lock_package_ids(&self, dev_mode: bool) -> &[PackageId] { + if dev_mode { + &self.dev_ids + } else { + &self.non_dev_ids + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dependency_resolver::pool::{PoolLink, PoolPackageInput}; + + fn make_input(name: &str, version: &str, pretty: &str) -> PoolPackageInput { + PoolPackageInput { + name: name.to_string(), + version: version.to_string(), + pretty_version: pretty.to_string(), + requires: vec![], + replaces: vec![], + provides: vec![], + conflicts: vec![], + is_fixed: false, + is_alias_of: None, + } + } + + fn make_input_with_deps( + name: &str, + version: &str, + pretty: &str, + deps: Vec<(&str, &str)>, + ) -> PoolPackageInput { + let requires = deps + .into_iter() + .map(|(target, constraint)| PoolLink { + target: target.to_string(), + constraint: constraint.to_string(), + source: name.to_string(), + }) + .collect(); + + PoolPackageInput { + name: name.to_string(), + version: version.to_string(), + pretty_version: pretty.to_string(), + requires, + replaces: vec![], + provides: vec![], + conflicts: vec![], + is_fixed: false, + is_alias_of: None, + } + } + + #[test] + fn test_fresh_install() { + let pool = Pool::new( + vec![ + make_input("a/a", "1.0.0.0", "1.0.0"), + make_input("b/b", "2.0.0.0", "2.0.0"), + ], + vec![], + ); + + let tx = Transaction::new(&pool, vec![], vec![1, 2]); + let ops = tx.operations(); + + assert_eq!(ops.len(), 2); + assert!(matches!(ops[0], Operation::Install { package_id: _ })); + assert!(matches!(ops[1], Operation::Install { package_id: _ })); + } + + #[test] + fn test_update_package() { + let pool = Pool::new( + vec![ + make_input("a/a", "1.0.0.0", "1.0.0"), + make_input("a/a", "2.0.0.0", "2.0.0"), + ], + vec![], + ); + + // Present: a/a 1.0.0 (id=1), Result: a/a 2.0.0 (id=2) + let tx = Transaction::new(&pool, vec![1], vec![2]); + let ops = tx.operations(); + + assert_eq!(ops.len(), 1); + match &ops[0] { + Operation::Update { + initial_id, + target_id, + } => { + assert_eq!(*initial_id, 1); + assert_eq!(*target_id, 2); + } + _ => panic!("Expected update operation"), + } + } + + #[test] + fn test_uninstall_package() { + let pool = Pool::new( + vec![ + make_input("a/a", "1.0.0.0", "1.0.0"), + make_input("b/b", "1.0.0.0", "1.0.0"), + ], + vec![], + ); + + // Present: a/a and b/b, Result: only a/a + let tx = Transaction::new(&pool, vec![1, 2], vec![1]); + let ops = tx.operations(); + + assert_eq!(ops.len(), 1); + match &ops[0] { + Operation::Uninstall { package_id } => { + assert_eq!(*package_id, 2); + } + _ => panic!("Expected uninstall operation"), + } + } + + #[test] + fn test_uninstalls_before_installs() { + let pool = Pool::new( + vec![ + make_input("a/a", "1.0.0.0", "1.0.0"), + make_input("b/b", "1.0.0.0", "1.0.0"), + ], + vec![], + ); + + // Present: a/a, Result: b/b (uninstall a, install b) + let tx = Transaction::new(&pool, vec![1], vec![2]); + let ops = tx.operations(); + + assert_eq!(ops.len(), 2); + assert!( + matches!(ops[0], Operation::Uninstall { .. }), + "Uninstalls should come first" + ); + assert!( + matches!(ops[1], Operation::Install { .. }), + "Installs should come after" + ); + } + + #[test] + fn test_dependency_ordering() { + // a/a requires b/b — b/b should be installed before a/a + let pool = Pool::new( + vec![ + make_input_with_deps("a/a", "1.0.0.0", "1.0.0", vec![("b/b", "^1.0")]), + make_input("b/b", "1.0.0.0", "1.0.0"), + ], + vec![], + ); + + let tx = Transaction::new(&pool, vec![], vec![1, 2]); + let ops = tx.operations(); + + assert_eq!(ops.len(), 2); + // b/b (dependency) should be installed before a/a + match (&ops[0], &ops[1]) { + ( + Operation::Install { package_id: first }, + Operation::Install { package_id: second }, + ) => { + assert_eq!(*first, 2, "b/b should be installed first"); + assert_eq!(*second, 1, "a/a should be installed second"); + } + _ => panic!("Expected two install operations"), + } + } + + #[test] + fn test_no_change() { + let pool = Pool::new(vec![make_input("a/a", "1.0.0.0", "1.0.0")], vec![]); + + // Same package present and in result + let tx = Transaction::new(&pool, vec![1], vec![1]); + let ops = tx.operations(); + + assert!(ops.is_empty(), "No operations when nothing changed"); + } + + #[test] + fn test_operation_pretty_string() { + let pool = Pool::new( + vec![ + make_input("a/a", "1.0.0.0", "1.0.0"), + make_input("a/a", "2.0.0.0", "2.0.0"), + ], + vec![], + ); + + let install = Operation::Install { package_id: 1 }; + assert_eq!(install.pretty_string(&pool), "Installing a/a (1.0.0)"); + + let update = Operation::Update { + initial_id: 1, + target_id: 2, + }; + assert_eq!(update.pretty_string(&pool), "Updating a/a (1.0.0 => 2.0.0)"); + + let uninstall = Operation::Uninstall { package_id: 1 }; + assert_eq!(uninstall.pretty_string(&pool), "Removing a/a (1.0.0)"); + } +} diff --git a/crates/mozart-core/src/lib.rs b/crates/mozart-core/src/lib.rs index f37bf43..72f5ae1 100644 --- a/crates/mozart-core/src/lib.rs +++ b/crates/mozart-core/src/lib.rs @@ -1,11 +1,13 @@ extern crate self as mozart_core; pub mod advisory; +pub mod autoload; pub mod composer; pub mod config; pub mod config_source; pub mod config_validator; pub mod console; +pub mod dependency_resolver; pub mod exit_code; pub mod factory; pub mod http; @@ -14,10 +16,12 @@ pub mod package; pub mod package_info; pub mod package_sorter; pub mod platform; +pub mod repository; pub mod repository_utils; pub mod script_events; pub mod suggest; pub mod validation; +pub mod vcs; pub mod version_bumper; pub mod wildcard; diff --git a/crates/mozart-core/src/package.rs b/crates/mozart-core/src/package.rs index a850517..64974fd 100644 --- a/crates/mozart-core/src/package.rs +++ b/crates/mozart-core/src/package.rs @@ -5,6 +5,8 @@ use std::fmt; use std::fs; use std::path::Path; +pub mod archiver; + /// Package stability level. /// Higher value = less stable. /// Corresponds to `Composer\Package\BasePackage::STABILITY_*`. diff --git a/crates/mozart-core/src/package/archiver.rs b/crates/mozart-core/src/package/archiver.rs new file mode 100644 index 0000000..30c678a --- /dev/null +++ b/crates/mozart-core/src/package/archiver.rs @@ -0,0 +1,899 @@ +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}; + +pub mod manager; +pub use manager::{ArchiveManager, ArchivePackage}; + +/// A compiled exclude pattern derived from a gitignore-style rule. +pub struct ExcludePattern { + regex: Regex, + /// If true, matching files are *re-included* (negation rule). + negate: bool, +} + +/// Convert a glob pattern string to a regex string. +/// +/// Mapping: +/// - `**` → `.*` (matches any path segment sequence) +/// - `*` → `[^/]*` (matches within a single path segment) +/// - `?` → `[^/]` (matches a single non-separator char) +/// - `[…]` → `[…]` (character class, passed through) +/// - all other characters are regex-escaped +fn glob_to_regex(glob: &str) -> String { + let mut result = String::new(); + let chars: Vec = glob.chars().collect(); + let mut i = 0; + while i < chars.len() { + match chars[i] { + '*' if i + 1 < chars.len() && chars[i + 1] == '*' => { + result.push_str(".*"); + i += 2; + } + '*' => { + result.push_str("[^/]*"); + i += 1; + } + '?' => { + result.push_str("[^/]"); + i += 1; + } + '[' => { + // Pass character classes through as-is until the closing `]` + result.push('['); + i += 1; + while i < chars.len() && chars[i] != ']' { + result.push(chars[i]); + i += 1; + } + if i < chars.len() { + result.push(']'); + i += 1; + } + } + c => { + // Regex-escape special characters + if r"\.+^$|{}()?".contains(c) { + result.push('\\'); + } + result.push(c); + i += 1; + } + } + } + result +} + +/// Convert a single gitignore-style rule into an `ExcludePattern`. +/// +/// Returns `None` if the rule is empty or a comment. +pub fn parse_gitignore_pattern(rule: &str) -> Option { + let rule = rule.trim(); + if rule.is_empty() || rule.starts_with('#') { + return None; + } + + // Leading `!` negates the pattern + let (negate, rule) = if let Some(rest) = rule.strip_prefix('!') { + (true, rest) + } else { + (false, rule) + }; + + // Strip trailing `/` before globbing + let rule = rule.trim_end_matches('/'); + if rule.is_empty() { + return None; + } + + // Determine anchor prefix: + // - leading `/` → anchored at root: `^/` + // - no `/` inside pattern → matches anywhere: `/` + // - `/` somewhere in middle → anchored at root: `^/` + let (prefix, glob) = if let Some(without_leading_slash) = rule.strip_prefix('/') { + // Root-anchored + ("^/", without_leading_slash) + } else if rule.contains('/') { + // Slash in middle: treat as root-anchored + ("^/", rule) + } else { + // No slash: matches anywhere + ("/", rule) + }; + + let glob_regex = glob_to_regex(glob); + // The final regex: `(/|$)` + // This matches the path component exactly (followed by a `/` or end-of-string). + let pattern = format!("{prefix}{glob_regex}(/|$)"); + let regex = Regex::new(&pattern).ok()?; + + Some(ExcludePattern { regex, negate }) +} + +/// Apply a chain of exclude patterns to a relative path (as a `/`-prefixed string). +/// +/// Patterns are applied in order; later patterns override earlier ones. +/// Returns `true` if the file is excluded by the final matching pattern +/// (or by `initially_excluded` if no pattern matches). +fn apply_filters( + path_with_slash: &str, + patterns: &[ExcludePattern], + initially_excluded: bool, +) -> bool { + let mut excluded = initially_excluded; + for pat in patterns { + if pat.regex.is_match(path_with_slash) { + // A negate pattern re-includes; a normal pattern excludes + excluded = !pat.negate; + } + } + excluded +} + +/// Parse `.gitattributes` from the source directory. +/// +/// Returns exclude patterns for lines containing `export-ignore` or +/// `-export-ignore`. +pub fn parse_gitattributes(source_dir: &Path) -> Vec { + let path = source_dir.join(".gitattributes"); + let content = match fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => return vec![], + }; + + let mut patterns = Vec::new(); + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 2 { + continue; + } + let file_pattern = parts[0]; + // Check each attribute token for export-ignore / -export-ignore + for attr in &parts[1..] { + if *attr == "export-ignore" { + if let Some(p) = parse_gitignore_pattern(file_pattern) { + patterns.push(p); + } + } else if *attr == "-export-ignore" { + // Negation: re-include files that would otherwise be excluded + let negated = format!("!{}", file_pattern); + if let Some(p) = parse_gitignore_pattern(&negated) { + patterns.push(p); + } + } + } + } + patterns +} + +/// Convert `composer.json` `archive.exclude` rules into exclude patterns. +pub fn parse_composer_excludes(excludes: &[String]) -> Vec { + excludes + .iter() + .filter_map(|rule| parse_gitignore_pattern(rule)) + .collect() +} + +const VCS_DIRS: &[&str] = &[".git", ".svn", ".hg", "CVS", ".bzr"]; + +/// Collect all archivable files from the source directory. +/// +/// Returns paths relative to `source_dir`, sorted for deterministic output. +/// Applies `exclude_patterns` to filter files. VCS directories are always +/// skipped. Symlinks pointing outside `source_dir` are excluded. +pub fn collect_archivable_files( + source_dir: &Path, + exclude_patterns: &[ExcludePattern], +) -> anyhow::Result> { + let source_dir = source_dir + .canonicalize() + .unwrap_or_else(|_| source_dir.to_path_buf()); + let mut files = Vec::new(); + collect_recursive(&source_dir, &source_dir, exclude_patterns, &mut files)?; + files.sort(); + Ok(files) +} + +fn collect_recursive( + source_dir: &Path, + current_dir: &Path, + exclude_patterns: &[ExcludePattern], + out: &mut Vec, +) -> anyhow::Result<()> { + let entries = fs::read_dir(current_dir) + .with_context(|| format!("Failed to read directory: {}", current_dir.display()))?; + + let mut items: Vec<_> = entries.filter_map(|e| e.ok()).collect(); + // Sort for determinism + items.sort_by_key(|e| e.file_name()); + + for entry in items { + let path = entry.path(); + let file_name = entry.file_name(); + let name_str = file_name.to_string_lossy(); + + // Skip VCS directories + if VCS_DIRS.contains(&name_str.as_ref()) { + continue; + } + + // Compute the relative path (forward-slash, prefixed with `/` for filter matching) + let relative = path + .strip_prefix(source_dir) + .unwrap_or(&path) + .to_string_lossy() + .replace('\\', "/"); + let path_with_slash = format!("/{}", relative); + + // Check if this entry is excluded + if apply_filters(&path_with_slash, exclude_patterns, false) { + continue; + } + + let metadata = match entry.metadata() { + Ok(m) => m, + Err(_) => continue, + }; + + if metadata.is_symlink() { + // Resolve the symlink; skip if it points outside source_dir + if let Ok(resolved) = fs::canonicalize(&path) { + if !resolved.starts_with(source_dir) { + continue; + } + out.push(PathBuf::from(&relative)); + } + // If canonicalize fails, skip the symlink + } else if metadata.is_dir() { + // Collect children recursively + let mut children = Vec::new(); + collect_recursive(source_dir, &path, exclude_patterns, &mut children)?; + if children.is_empty() { + // Include empty directory + out.push(PathBuf::from(&relative)); + } else { + out.extend(children); + } + } else { + out.push(PathBuf::from(&relative)); + } + } + + Ok(()) +} + +/// Supported archive formats. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ArchiveFormat { + Zip, + Tar, + TarGz, + TarBz2, +} + +impl ArchiveFormat { + /// Parse a format string (case-insensitive). Returns `None` for unsupported formats. + pub fn parse(s: &str) -> Option { + match s.to_lowercase().as_str() { + "zip" => Some(Self::Zip), + "tar" => Some(Self::Tar), + "tar.gz" | "tgz" => Some(Self::TarGz), + "tar.bz2" => Some(Self::TarBz2), + _ => None, + } + } + + /// File extension for this format. + pub fn extension(&self) -> &str { + match self { + Self::Zip => "zip", + Self::Tar => "tar", + Self::TarGz => "tar.gz", + Self::TarBz2 => "tar.bz2", + } + } +} + +/// 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(()) +} + +/// Generate an archive filename (without extension) for a package. +/// +/// Mirrors Composer's `ArchiveManager::getPackageFilenameParts()`. +pub fn generate_archive_filename( + name: &str, + archive_name: Option<&str>, + version: Option<&str>, + dist_reference: Option<&str>, + dist_type: Option<&str>, + source_reference: Option<&str>, +) -> String { + // Base: archive_name if set, otherwise replace non-alphanumeric chars with `-` + let base = if let Some(an) = archive_name { + an.to_string() + } else { + let re = Regex::new(r"[^a-zA-Z0-9_\-]").unwrap(); + re.replace_all(name, "-").to_string() + }; + + let mut parts: Vec = vec![base]; + + // Determine if dist_reference is a 40-char hex (SHA-1 commit hash) + let is_sha_dist_ref = dist_reference + .map(|r| r.len() == 40 && r.chars().all(|c| c.is_ascii_hexdigit())) + .unwrap_or(false); + + if is_sha_dist_ref { + // Append dist_reference and dist_type + if let Some(dr) = dist_reference { + parts.push(dr.to_string()); + } + if let Some(dt) = dist_type { + parts.push(dt.to_string()); + } + } else { + // Append version (if any), then dist_reference (if any) + if let Some(v) = version { + parts.push(v.to_string()); + } + if let Some(dr) = dist_reference { + parts.push(dr.to_string()); + } + } + + // Append first 6 chars of SHA-1 of source_reference (if any) + if let Some(sr) = source_reference { + let mut hasher = Sha1::new(); + hasher.update(sr.as_bytes()); + let hash = format!("{:x}", hasher.finalize()); + parts.push(hash[..6.min(hash.len())].to_string()); + } + + // Replace `/` with `-` in each part, then join + parts + .iter() + .map(|p| p.replace('/', "-")) + .collect::>() + .join("-") +} + +/// The set of archive extensions we support. +const ARCHIVE_EXTENSIONS: &[&str] = &["zip", "tar", "tar.gz", "tar.bz2"]; + +/// Generate patterns to exclude previous archives of this package from the archive. +/// +/// If `has_extra_parts` is true (version/ref was appended), the pattern is +/// `-*.`. Otherwise it's `.`. +pub fn self_exclusion_patterns(base_name: &str, has_extra_parts: bool) -> Vec { + ARCHIVE_EXTENSIONS + .iter() + .map(|ext| { + if has_extra_parts { + format!("/{}-*.{}", base_name, ext) + } else { + format!("/{}.{}", base_name, ext) + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + // 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")); + } + + #[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")); + } + + #[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()); + } + + #[test] + fn test_collect_files_basic() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("a.php"), b" = files + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + assert!(strs.contains(&"a.php".to_string())); + assert!(strs.contains(&"b.php".to_string())); + assert!(strs.contains(&"src/c.php".to_string())); + } + + #[test] + fn test_collect_files_excludes() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("main.php"), b" = files + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + assert!(strs.contains(&"main.php".to_string())); + assert!(!strs.iter().any(|s| s.starts_with("tests"))); + } + + #[test] + fn test_collect_files_skips_vcs() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("main.php"), b" = files + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + assert!(strs.contains(&"main.php".to_string())); + assert!(!strs.iter().any(|s| s.starts_with(".git"))); + } + + #[test] + fn test_collect_files_empty_dir() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("main.php"), b" = files + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + assert!(strs.contains(&"main.php".to_string())); + assert!(strs.contains(&"empty_dir".to_string())); + } + + fn make_source_tree(dir: &Path) { + fs::write(dir.join("main.php"), b" = (0..archive.len()) + .map(|i| archive.by_index(i).unwrap().name().to_string()) + .collect(); + assert!(names.contains(&"main.php".to_string())); + assert!(names.contains(&"src/Foo.php".to_string())); + } + + #[test] + fn test_create_tar_archive() { + let src = tempdir().unwrap(); + make_source_tree(src.path()); + let out = tempdir().unwrap(); + let target = out.path().join("test.tar"); + + let files = collect_archivable_files(src.path(), &[]).unwrap(); + create_archive(src.path(), &files, &target, &ArchiveFormat::Tar).unwrap(); + assert!(target.exists()); + + // Verify contents + let tar_data = fs::read(&target).unwrap(); + let cursor = std::io::Cursor::new(tar_data); + let mut archive = tar::Archive::new(cursor); + let names: Vec = archive + .entries() + .unwrap() + .filter_map(|e| e.ok()) + .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string())) + .collect(); + assert!(names.contains(&"main.php".to_string())); + assert!(names.contains(&"src/Foo.php".to_string())); + } + + #[test] + fn test_create_tar_gz_archive() { + let src = tempdir().unwrap(); + make_source_tree(src.path()); + let out = tempdir().unwrap(); + let target = out.path().join("test.tar.gz"); + + let files = collect_archivable_files(src.path(), &[]).unwrap(); + create_archive(src.path(), &files, &target, &ArchiveFormat::TarGz).unwrap(); + assert!(target.exists()); + + let gz_data = fs::read(&target).unwrap(); + let cursor = std::io::Cursor::new(gz_data); + let decoder = flate2::read::GzDecoder::new(cursor); + let mut archive = tar::Archive::new(decoder); + let names: Vec = archive + .entries() + .unwrap() + .filter_map(|e| e.ok()) + .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string())) + .collect(); + assert!(names.contains(&"main.php".to_string())); + } + + #[test] + fn test_create_tar_bz2_archive() { + let src = tempdir().unwrap(); + make_source_tree(src.path()); + let out = tempdir().unwrap(); + let target = out.path().join("test.tar.bz2"); + + let files = collect_archivable_files(src.path(), &[]).unwrap(); + create_archive(src.path(), &files, &target, &ArchiveFormat::TarBz2).unwrap(); + assert!(target.exists()); + + let bz_data = fs::read(&target).unwrap(); + let cursor = std::io::Cursor::new(bz_data); + let decoder = bzip2::read::BzDecoder::new(cursor); + let mut archive = tar::Archive::new(decoder); + let names: Vec = archive + .entries() + .unwrap() + .filter_map(|e| e.ok()) + .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string())) + .collect(); + assert!(names.contains(&"main.php".to_string())); + } + + #[cfg(unix)] + #[test] + fn test_zip_preserves_permissions() { + use std::os::unix::fs::PermissionsExt; + + let src = tempdir().unwrap(); + let script = src.path().join("run.sh"); + fs::write(&script, b"#!/bin/sh\necho hello").unwrap(); + fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap(); + + let out = tempdir().unwrap(); + let target = out.path().join("test.zip"); + let files = collect_archivable_files(src.path(), &[]).unwrap(); + create_archive(src.path(), &files, &target, &ArchiveFormat::Zip).unwrap(); + + let zip_data = fs::read(&target).unwrap(); + let cursor = std::io::Cursor::new(zip_data); + let mut archive = zip::ZipArchive::new(cursor).unwrap(); + let entry = archive.by_name("run.sh").unwrap(); + let mode = entry.unix_mode().unwrap_or(0); + // Lower 9 bits should be 0o755 + assert_eq!(mode & 0o777, 0o755); + } + + #[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"); + } + + #[test] + fn test_self_exclusion_patterns_with_extra_parts() { + let patterns = self_exclusion_patterns("vendor-pkg", true); + assert!(patterns.contains(&"/vendor-pkg-*.zip".to_string())); + assert!(patterns.contains(&"/vendor-pkg-*.tar".to_string())); + assert!(patterns.contains(&"/vendor-pkg-*.tar.gz".to_string())); + assert!(patterns.contains(&"/vendor-pkg-*.tar.bz2".to_string())); + } + + #[test] + fn test_self_exclusion_patterns_no_extra_parts() { + let patterns = self_exclusion_patterns("vendor-pkg", false); + assert!(patterns.contains(&"/vendor-pkg.zip".to_string())); + assert!(patterns.contains(&"/vendor-pkg.tar".to_string())); + } +} diff --git a/crates/mozart-core/src/package/archiver/manager.rs b/crates/mozart-core/src/package/archiver/manager.rs new file mode 100644 index 0000000..bd5083e --- /dev/null +++ b/crates/mozart-core/src/package/archiver/manager.rs @@ -0,0 +1,299 @@ +use super::{ + ArchiveFormat, collect_archivable_files, create_archive, generate_archive_filename, + parse_composer_excludes, parse_gitattributes, parse_gitignore_pattern, self_exclusion_patterns, +}; +use std::path::{Path, PathBuf}; + +/// A package to be archived. +/// +/// Mirrors the role of Composer's `CompletePackageInterface` as input to +/// `ArchiveManager::archive()`. The `Root` variant points at an already-checked-out +/// source tree; the `Remote` variant carries dist metadata that the manager will +/// download and extract to a temporary directory. +pub enum ArchivePackage { + Root { + name: String, + version: Option, + source_dir: PathBuf, + }, + Remote { + name: String, + version: String, + dist_url: String, + dist_type: String, + dist_shasum: Option, + dist_reference: Option, + source_reference: Option, + }, +} + +impl ArchivePackage { + fn name(&self) -> &str { + match self { + Self::Root { name, .. } | Self::Remote { name, .. } => name, + } + } + + fn version(&self) -> Option<&str> { + match self { + Self::Root { version, .. } => version.as_deref(), + Self::Remote { version, .. } => Some(version), + } + } + + fn dist_reference(&self) -> Option<&str> { + match self { + Self::Root { .. } => None, + Self::Remote { dist_reference, .. } => dist_reference.as_deref(), + } + } + + fn dist_type(&self) -> Option<&str> { + match self { + Self::Root { .. } => None, + Self::Remote { dist_type, .. } => Some(dist_type), + } + } + + fn source_reference(&self) -> Option<&str> { + match self { + Self::Root { .. } => None, + Self::Remote { + source_reference, .. + } => source_reference.as_deref(), + } + } +} + +/// Holds an extracted source directory plus, for remote packages, a tempdir +/// that must outlive `source_dir`. Drop removes the tempdir. +struct AcquiredSource { + source_dir: PathBuf, + archive_name: Option, + archive_excludes: Vec, + _temp_dir: Option, +} + +impl Drop for AcquiredSource { + fn drop(&mut self) { + if let Some(ref dir) = self._temp_dir { + let _ = std::fs::remove_dir_all(dir); + } + } +} + +/// Read `archive.name` and `archive.exclude` from a composer.json file. +fn read_archive_config(composer_json_path: &Path) -> anyhow::Result<(Option, Vec)> { + let content = std::fs::read_to_string(composer_json_path)?; + let value: serde_json::Value = serde_json::from_str(&content)?; + + let name = value + .get("archive") + .and_then(|a| a.get("name")) + .and_then(|n| n.as_str()) + .map(|s| s.to_string()); + + let excludes = value + .get("archive") + .and_then(|a| a.get("exclude")) + .and_then(|e| e.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .map(|s| s.to_string()) + .collect() + }) + .unwrap_or_default(); + + Ok((name, excludes)) +} + +/// Manages the creation of package archives. +/// +/// Mirrors Composer's `Composer\Package\Archiver\ArchiveManager`. +pub struct ArchiveManager; + +impl Default for ArchiveManager { + fn default() -> Self { + Self::new() + } +} + +impl ArchiveManager { + pub fn new() -> Self { + ArchiveManager + } + + /// Build the parts that make up a package archive's filename. + fn package_filename_parts(package: &ArchivePackage, archive_name: Option<&str>) -> String { + generate_archive_filename( + package.name(), + archive_name, + package.version(), + package.dist_reference(), + package.dist_type(), + package.source_reference(), + ) + } + + /// Generate the archive filename (without extension) for a package, using + /// any `archive.name` override from the package's source composer.json. + pub fn package_filename(package: &ArchivePackage) -> String { + let archive_name = match package { + ArchivePackage::Root { source_dir, .. } => { + read_archive_config(&source_dir.join("composer.json")) + .ok() + .and_then(|(n, _)| n) + } + ArchivePackage::Remote { .. } => None, + }; + Self::package_filename_parts(package, archive_name.as_deref()) + } + + /// Join filename parts with `-`, mirroring Composer's + /// `getPackageFilenameFromParts`. + pub fn package_filename_from_parts(parts: &[&str]) -> String { + parts.join("-") + } + + /// Create an archive of the given package. + /// + /// For a `Remote` package, the dist is downloaded into a tempdir and + /// extracted before archiving; the tempdir is removed afterward. For + /// `Root`, the package's `source_dir` is archived in place. + /// + /// Returns the absolute path to the created archive. + pub async fn archive( + &self, + package: &ArchivePackage, + format: &str, + target_dir: &Path, + file_name: Option<&str>, + ignore_filters: bool, + files_cache: &crate::repository::cache::Cache, + ) -> anyhow::Result { + let archive_format = ArchiveFormat::parse(format).ok_or_else(|| { + anyhow::anyhow!( + "Unsupported archive format \"{}\". Supported formats: tar, tar.gz, tar.bz2, zip", + format + ) + })?; + + let source = acquire_source(package, files_cache).await?; + + let filename_base = if let Some(file_name) = file_name { + file_name.to_string() + } else { + Self::package_filename_parts(package, source.archive_name.as_deref()) + }; + + // Self-exclusion: prevent the archive from including itself + let has_extra_parts = file_name.is_none() + && (package.version().is_some() + || package.dist_reference().is_some() + || package.source_reference().is_some()); + let self_exclusion_strs = self_exclusion_patterns(&filename_base, has_extra_parts); + + let mut all_patterns = Vec::new(); + for rule in &self_exclusion_strs { + if let Some(p) = parse_gitignore_pattern(rule) { + all_patterns.push(p); + } + } + + if !ignore_filters { + let git_patterns = parse_gitattributes(&source.source_dir); + all_patterns.extend(git_patterns); + + let composer_patterns = parse_composer_excludes(&source.archive_excludes); + all_patterns.extend(composer_patterns); + } + + let files = collect_archivable_files(&source.source_dir, &all_patterns)?; + + std::fs::create_dir_all(target_dir)?; + let target_dir = target_dir + .canonicalize() + .unwrap_or_else(|_| target_dir.to_path_buf()); + let target = target_dir.join(format!("{}.{}", filename_base, archive_format.extension())); + create_archive(&source.source_dir, &files, &target, &archive_format)?; + + Ok(target) + } +} + +/// Acquire the source tree of a package — either by reusing the root +/// directory or by downloading and extracting the dist into a tempdir. +/// Also reads `archive.name` / `archive.exclude` from the package's +/// composer.json. +async fn acquire_source( + package: &ArchivePackage, + files_cache: &crate::repository::cache::Cache, +) -> anyhow::Result { + match package { + ArchivePackage::Root { source_dir, .. } => { + let composer_json_path = source_dir.join("composer.json"); + let (archive_name, archive_excludes) = if composer_json_path.exists() { + read_archive_config(&composer_json_path).unwrap_or((None, vec![])) + } else { + (None, vec![]) + }; + Ok(AcquiredSource { + source_dir: source_dir.clone(), + archive_name, + archive_excludes, + _temp_dir: None, + }) + } + ArchivePackage::Remote { + dist_url, + dist_type, + dist_shasum, + .. + } => { + let temp_base = std::env::temp_dir(); + let unique = format!( + "mozart-archive-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + ); + let temp_dir = temp_base.join(&unique); + std::fs::create_dir_all(&temp_dir)?; + + let bytes = crate::repository::downloader::download_dist( + dist_url, + dist_shasum.as_deref(), + None, + files_cache, + ) + .await?; + + match dist_type.as_str() { + "zip" => crate::repository::downloader::extract_zip(&bytes, &temp_dir)?, + "tar" | "tar.gz" | "tgz" => { + crate::repository::downloader::extract_tar_gz(&bytes, &temp_dir)? + } + other => { + let _ = std::fs::remove_dir_all(&temp_dir); + anyhow::bail!("Unsupported dist type: {}", other); + } + } + + let extracted_composer = temp_dir.join("composer.json"); + let (archive_name, archive_excludes) = if extracted_composer.exists() { + read_archive_config(&extracted_composer).unwrap_or((None, vec![])) + } else { + (None, vec![]) + }; + + Ok(AcquiredSource { + source_dir: temp_dir.clone(), + archive_name, + archive_excludes, + _temp_dir: Some(temp_dir), + }) + } + } +} diff --git a/crates/mozart-core/src/repository.rs b/crates/mozart-core/src/repository.rs new file mode 100644 index 0000000..ba96729 --- /dev/null +++ b/crates/mozart-core/src/repository.rs @@ -0,0 +1,19 @@ +pub mod advisory; +pub mod browse_repos; +pub mod cache; +pub mod composer_repo; +pub mod download_manager; +pub mod downloader; +pub mod inline_package; +pub mod installed; +pub mod installer_executor; +pub mod lockfile; +pub mod packagist; +pub mod path_repository; +#[allow(clippy::module_inception)] +pub mod repository; +pub mod repository_filter; +pub mod resolver; +pub mod vcs_bridge; +pub mod version; +pub mod version_selector; diff --git a/crates/mozart-core/src/repository/advisory.rs b/crates/mozart-core/src/repository/advisory.rs new file mode 100644 index 0000000..02a6e1a --- /dev/null +++ b/crates/mozart-core/src/repository/advisory.rs @@ -0,0 +1,731 @@ +use super::packagist::SecurityAdvisory; +use super::repository::RepositorySet; +use crate::advisory::{AbandonedHandling, AuditFormat}; +use crate::console::Console; +use crate::{console_writeln, console_writeln_error}; +use indexmap::IndexMap; +use std::collections::BTreeMap; + +/// A package being audited, with version and abandonment information. +#[derive(Debug, Clone)] +pub struct PackageInfo { + pub name: String, + pub version: String, + pub version_normalized: Option, + /// Raw abandoned field from JSON: `true` = abandoned no replacement, `String` = replacement name. + pub abandoned_raw: Option, +} + +impl PackageInfo { + /// Mirrors `CompletePackage::isAbandoned()`. + pub fn is_abandoned(&self) -> bool { + matches!( + &self.abandoned_raw, + Some(serde_json::Value::Bool(true)) | Some(serde_json::Value::String(_)) + ) + } + + /// Mirrors `CompletePackage::getReplacementPackage()`. + pub fn replacement_package(&self) -> Option<&str> { + match &self.abandoned_raw { + Some(serde_json::Value::String(s)) => Some(s.as_str()), + _ => None, + } + } +} + +/// An advisory paired with the installed version of the package it affects. +#[derive(Debug, Clone)] +pub struct MatchedAdvisory { + pub advisory: SecurityAdvisory, + pub installed_version: String, +} + +/// A matched advisory that was filtered out by the ignore list. +#[derive(Debug, Clone)] +pub struct IgnoredAdvisory { + pub advisory: SecurityAdvisory, + pub installed_version: String, + pub ignore_reason: Option, +} + +/// Result of `Auditor::process_advisories`. +#[derive(Debug, Default)] +pub struct ProcessedAdvisories { + pub advisories: BTreeMap>, + pub ignored_advisories: BTreeMap>, +} + +/// An abandoned package found during audit. +#[derive(Debug, Clone)] +pub struct AbandonedPackage { + pub name: String, + pub version: String, + pub replacement: Option, +} + +/// Options passed to `Auditor::audit()`. +pub struct AuditOptions<'a> { + pub format: AuditFormat, + pub warning_only: bool, + pub ignore_list: &'a IndexMap>, + pub abandoned: AbandonedHandling, + pub ignored_severities: &'a IndexMap>, + pub ignore_unreachable: bool, + pub ignore_abandoned: &'a IndexMap>, +} + +/// Mirrors `Composer\Advisory\Auditor`. +pub struct Auditor; + +impl Auditor { + pub fn new() -> Self { + Self + } + + /// Main audit entry point. Mirrors `Composer\Advisory\Auditor::audit()`. + /// + /// Returns a bitmask: 0=ok, 1=vulnerable, 2=abandoned, 3=both. + pub async fn audit( + &self, + console: &Console, + repo_set: &RepositorySet, + packages: &[PackageInfo], + options: &AuditOptions<'_>, + ) -> anyhow::Result { + let format = options.format; + let (all_advisories, unreachable_repos) = repo_set + .get_matching_security_advisories( + packages, + format == AuditFormat::Summary, + options.ignore_unreachable, + ) + .await?; + + let ProcessedAdvisories { + advisories, + ignored_advisories, + } = self.process_advisories( + all_advisories, + options.ignore_list, + options.ignored_severities, + ); + + let abandoned_packages = if options.abandoned == AbandonedHandling::Ignore { + vec![] + } else { + self.filter_abandoned_packages(packages, options.ignore_abandoned) + }; + + let abandoned_count = if options.abandoned == AbandonedHandling::Fail { + abandoned_packages.len() + } else { + 0 + }; + + let affected_packages_count = advisories.len(); + let bitmask = self.calculate_bitmask(affected_packages_count > 0, abandoned_count > 0); + + if format == AuditFormat::Json { + self.render_json( + &advisories, + &ignored_advisories, + &unreachable_repos, + &abandoned_packages, + console, + ); + return Ok(bitmask); + } + + let (ignored_pkg_count, ignored_total) = self.count_ignored(&ignored_advisories); + let (active_pkg_count, active_total) = self.count_matched(&advisories); + + if active_pkg_count > 0 || ignored_pkg_count > 0 { + if ignored_pkg_count > 0 { + let plurality = if ignored_total == 1 { "y" } else { "ies" }; + let pkg_plurality = if ignored_pkg_count == 1 { "" } else { "s" }; + let punctuation = if format == AuditFormat::Summary { + "." + } else { + ":" + }; + let msg = format!( + "Found {ignored_total} ignored security vulnerability advisor{plurality} affecting {ignored_pkg_count} package{pkg_plurality}{punctuation}" + ); + console_writeln_error!(console, "{msg}"); + self.output_advisories_ignored(console, &ignored_advisories, format); + } + + if active_pkg_count > 0 { + let plurality = if active_total == 1 { "y" } else { "ies" }; + let pkg_plurality = if active_pkg_count == 1 { "" } else { "s" }; + let punctuation = if format == AuditFormat::Summary { + "." + } else { + ":" + }; + let msg = format!( + "Found {active_total} security vulnerability advisor{plurality} affecting {active_pkg_count} package{pkg_plurality}{punctuation}" + ); + if options.warning_only { + console_writeln_error!(console, "{msg}"); + } else { + console_writeln_error!(console, "{msg}"); + } + self.output_advisories(console, &advisories, format); + } + + if format == AuditFormat::Summary { + console_writeln_error!( + console, + "Run \"mozart audit\" for a full list of advisories." + ); + } + } else { + console_writeln_error!( + console, + "No security vulnerability advisories found.", + ); + } + + if !unreachable_repos.is_empty() { + console_writeln_error!( + console, + "The following repositories were unreachable:", + ); + for repo in &unreachable_repos { + console_writeln_error!(console, " - {repo}"); + } + } + + if !abandoned_packages.is_empty() && format != AuditFormat::Summary { + self.output_abandoned_packages(console, &abandoned_packages, format); + } + + Ok(bitmask) + } + + /// Mirrors `Composer\Advisory\Auditor::processAdvisories()`. + /// + /// Splits advisories into active and ignored based on the ignore list and ignored severities. + /// Checks by: package name, advisory ID, severity, CVE, and source remote IDs. + pub fn process_advisories( + &self, + all_advisories: BTreeMap>, + ignore_list: &IndexMap>, + ignored_severities: &IndexMap>, + ) -> ProcessedAdvisories { + if ignore_list.is_empty() && ignored_severities.is_empty() { + return ProcessedAdvisories { + advisories: all_advisories, + ignored_advisories: BTreeMap::new(), + }; + } + + let mut advisories: BTreeMap> = BTreeMap::new(); + let mut ignored: BTreeMap> = BTreeMap::new(); + + for (package, pkg_advisories) in all_advisories { + for matched in pkg_advisories { + let adv = &matched.advisory; + let mut is_active = true; + let mut ignore_reason: Option = None; + + // Check by package name + if let Some(reason) = ignore_list.get(&package) { + is_active = false; + ignore_reason = reason.clone(); + } + + // Check by advisory ID + if is_active && let Some(reason) = ignore_list.get(&adv.advisory_id) { + is_active = false; + ignore_reason = reason.clone(); + } + + // Check by severity + if is_active + && let Some(ref sev) = adv.severity + && let Some(reason) = ignored_severities.get(sev.as_str()) + { + is_active = false; + ignore_reason = reason + .clone() + .or_else(|| Some(format!("{sev} severity is ignored"))); + } + + // Check by CVE + if is_active + && let Some(ref cve) = adv.cve + && let Some(reason) = ignore_list.get(cve.as_str()) + { + is_active = false; + ignore_reason = reason.clone(); + } + + // Check by source remote IDs + if is_active { + for source in &adv.sources { + if let Some(reason) = ignore_list.get(&source.remote_id) { + is_active = false; + ignore_reason = reason.clone(); + break; + } + } + } + + if is_active { + advisories.entry(package.clone()).or_default().push(matched); + } else { + ignored + .entry(package.clone()) + .or_default() + .push(IgnoredAdvisory { + advisory: matched.advisory, + installed_version: matched.installed_version, + ignore_reason, + }); + } + } + } + + ProcessedAdvisories { + advisories, + ignored_advisories: ignored, + } + } + + /// Mirrors `Composer\Advisory\Auditor::filterAbandonedPackages()`. + pub fn filter_abandoned_packages( + &self, + packages: &[PackageInfo], + ignore_abandoned: &IndexMap>, + ) -> Vec { + packages + .iter() + .filter(|pkg| { + if !pkg.is_abandoned() { + return false; + } + if !ignore_abandoned.is_empty() { + let name_lower = pkg.name.to_lowercase(); + // Case-insensitive exact name match (wildcard support deferred) + if ignore_abandoned + .keys() + .any(|k| k.to_lowercase() == name_lower) + { + return false; + } + } + true + }) + .map(|pkg| AbandonedPackage { + name: pkg.name.clone(), + version: pkg.version.clone(), + replacement: pkg.replacement_package().map(|s| s.to_string()), + }) + .collect() + } + + /// Mirrors `Composer\Advisory\Auditor::needsCompleteAdvisoryLoad()`. + /// + /// Mozart always fetches full advisories (no partial optimization), so this is always false. + pub fn needs_complete_advisory_load( + &self, + advisories: &BTreeMap>, + _ignore_list: &IndexMap>, + ) -> bool { + let _ = advisories; + false + } + + fn calculate_bitmask(&self, has_vulnerable: bool, has_abandoned: bool) -> u8 { + let mut bitmask = 0u8; + if has_vulnerable { + bitmask |= 1; + } + if has_abandoned { + bitmask |= 2; + } + bitmask + } + + fn count_ignored(&self, advisories: &BTreeMap>) -> (usize, usize) { + let pkg_count = advisories.len(); + let total = advisories.values().map(|v| v.len()).sum(); + (pkg_count, total) + } + + fn count_matched(&self, advisories: &BTreeMap>) -> (usize, usize) { + let pkg_count = advisories.len(); + let total = advisories.values().map(|v| v.len()).sum(); + (pkg_count, total) + } + + fn output_advisories( + &self, + console: &Console, + advisories: &BTreeMap>, + format: AuditFormat, + ) { + match format { + AuditFormat::Table => self.output_advisories_table(console, advisories), + AuditFormat::Plain => self.output_advisories_plain(console, advisories), + AuditFormat::Summary => {} + AuditFormat::Json => unreachable!(), + } + } + + fn output_advisories_ignored( + &self, + console: &Console, + advisories: &BTreeMap>, + format: AuditFormat, + ) { + match format { + AuditFormat::Table => self.output_ignored_advisories_table(console, advisories), + AuditFormat::Plain => self.output_ignored_advisories_plain(console, advisories), + AuditFormat::Summary => {} + AuditFormat::Json => unreachable!(), + } + } + + fn output_advisories_table( + &self, + console: &Console, + advisories: &BTreeMap>, + ) { + for pkg_advisories in advisories.values() { + for matched in pkg_advisories { + self.render_advisory_table( + console, + &matched.advisory, + &matched.installed_version, + None, + ); + } + } + } + + fn output_ignored_advisories_table( + &self, + console: &Console, + advisories: &BTreeMap>, + ) { + for pkg_advisories in advisories.values() { + for ignored in pkg_advisories { + self.render_advisory_table( + console, + &ignored.advisory, + &ignored.installed_version, + ignored.ignore_reason.as_deref(), + ); + } + } + } + + fn render_advisory_table( + &self, + console: &Console, + adv: &SecurityAdvisory, + installed_version: &str, + ignore_reason: Option<&str>, + ) { + let label_width = 17usize; + let mut rows: Vec<(&str, String)> = vec![ + ("Package", adv.package_name.clone()), + ("Version", installed_version.to_string()), + ("Severity", adv.severity.clone().unwrap_or_default()), + ("Advisory ID", adv.advisory_id.clone()), + ( + "CVE", + adv.cve.clone().unwrap_or_else(|| "NO CVE".to_string()), + ), + ("Title", adv.title.clone()), + ("URL", adv.link.clone().unwrap_or_default()), + ("Affected versions", adv.affected_versions.clone()), + ("Reported at", adv.reported_at.clone()), + ]; + if let Some(reason) = ignore_reason { + rows.push(("Ignore reason", reason.to_string())); + } + + let value_width = rows.iter().map(|(_, v)| v.len()).max().unwrap_or(0).max(20); + let separator = format!( + "+-{:->, + ) { + let mut first = true; + for pkg_advisories in advisories.values() { + for matched in pkg_advisories { + if !first { + console_writeln_error!(console, "--------"); + } + self.render_advisory_plain( + console, + &matched.advisory, + &matched.installed_version, + None, + ); + first = false; + } + } + } + + fn output_ignored_advisories_plain( + &self, + console: &Console, + advisories: &BTreeMap>, + ) { + let mut first = true; + for pkg_advisories in advisories.values() { + for ignored in pkg_advisories { + if !first { + console_writeln_error!(console, "--------"); + } + self.render_advisory_plain( + console, + &ignored.advisory, + &ignored.installed_version, + ignored.ignore_reason.as_deref(), + ); + first = false; + } + } + } + + fn render_advisory_plain( + &self, + console: &Console, + adv: &SecurityAdvisory, + installed_version: &str, + ignore_reason: Option<&str>, + ) { + console_writeln_error!(console, "Package: {}", adv.package_name); + console_writeln_error!(console, "Version: {installed_version}"); + console_writeln_error!( + console, + "Severity: {}", + adv.severity.as_deref().unwrap_or(""), + ); + console_writeln_error!(console, "Advisory ID: {}", adv.advisory_id); + console_writeln_error!(console, "CVE: {}", adv.cve.as_deref().unwrap_or("NO CVE")); + console_writeln_error!(console, "Title: {}", adv.title); + console_writeln_error!(console, "URL: {}", adv.link.as_deref().unwrap_or("")); + console_writeln_error!(console, "Affected versions: {}", adv.affected_versions); + console_writeln_error!(console, "Reported at: {}", adv.reported_at); + if let Some(reason) = ignore_reason { + console_writeln_error!(console, "Ignore reason: {reason}"); + } + } + + fn output_abandoned_packages( + &self, + console: &Console, + packages: &[AbandonedPackage], + format: AuditFormat, + ) { + let count = packages.len(); + let plurality = if count == 1 { "" } else { "s" }; + console_writeln_error!( + console, + "Found {count} abandoned package{plurality}:", + ); + + if format == AuditFormat::Plain { + for pkg in packages { + match &pkg.replacement { + Some(repl) => console_writeln_error!( + console, + "{} ({}) is abandoned. Use {} instead.", + pkg.name, + pkg.version, + repl, + ), + None => console_writeln_error!( + console, + "{} ({}) is abandoned. No replacement was suggested.", + pkg.name, + pkg.version, + ), + } + } + return; + } + + // Table format + let name_width = 20usize; + let ver_width = packages + .iter() + .map(|a| a.version.len()) + .max() + .unwrap_or(0) + .max("Version".len()); + let repl_width = packages + .iter() + .map(|a| { + a.replacement + .as_deref() + .unwrap_or("No replacement suggested") + .len() + }) + .max() + .unwrap_or(0) + .max("Suggested Replacement".len()); + + console_writeln_error!( + console, + "| {:>, + ignored_advisories: &BTreeMap>, + unreachable_repos: &[String], + abandoned_packages: &[AbandonedPackage], + console: &Console, + ) { + let mut advisories_map: serde_json::Map = serde_json::Map::new(); + for (pkg_name, matched_list) in advisories { + let arr: Vec = matched_list + .iter() + .map(|m| serde_json::to_value(&m.advisory).unwrap_or(serde_json::Value::Null)) + .collect(); + advisories_map.insert(pkg_name.clone(), serde_json::Value::Array(arr)); + } + + let mut output = serde_json::json!({ "advisories": advisories_map }); + + // ignored-advisories (only if non-empty) + if !ignored_advisories.is_empty() { + let mut ignored_map: serde_json::Map = + serde_json::Map::new(); + for (pkg_name, ignored_list) in ignored_advisories { + let arr: Vec = ignored_list + .iter() + .map(|i| { + let mut val = + serde_json::to_value(&i.advisory).unwrap_or(serde_json::Value::Null); + if let serde_json::Value::Object(ref mut obj) = val { + obj.insert( + "ignoreReason".to_string(), + i.ignore_reason + .as_ref() + .map(|r| serde_json::Value::String(r.clone())) + .unwrap_or(serde_json::Value::Null), + ); + } + val + }) + .collect(); + ignored_map.insert(pkg_name.clone(), serde_json::Value::Array(arr)); + } + if let serde_json::Value::Object(ref mut obj) = output { + obj.insert( + "ignored-advisories".to_string(), + serde_json::Value::Object(ignored_map), + ); + } + } + + // unreachable-repositories (only if non-empty) + if !unreachable_repos.is_empty() { + let repos_arr: Vec = unreachable_repos + .iter() + .map(|r| serde_json::Value::String(r.clone())) + .collect(); + if let serde_json::Value::Object(ref mut obj) = output { + obj.insert( + "unreachable-repositories".to_string(), + serde_json::Value::Array(repos_arr), + ); + } + } + + // abandoned map: package_name => replacement (null if none) + let mut abandoned_map: serde_json::Map = serde_json::Map::new(); + for pkg in abandoned_packages { + abandoned_map.insert( + pkg.name.clone(), + pkg.replacement + .as_ref() + .map(|r| serde_json::Value::String(r.clone())) + .unwrap_or(serde_json::Value::Null), + ); + } + if let serde_json::Value::Object(ref mut obj) = output { + obj.insert( + "abandoned".to_string(), + serde_json::Value::Object(abandoned_map), + ); + } + + let json_str = serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string()); + console_writeln!(console, "{}", &json_str); + } +} + +impl Default for Auditor { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/mozart-core/src/repository/browse_repos.rs b/crates/mozart-core/src/repository/browse_repos.rs new file mode 100644 index 0000000..d54465f --- /dev/null +++ b/crates/mozart-core/src/repository/browse_repos.rs @@ -0,0 +1,293 @@ +//! Composite of repositories consulted by the `browse` command. +//! +//! Mirrors `Composer\Command\HomeCommand::initializeRepos()`: +//! root package + local installed repository + remote(s). Each repo +//! exposes a uniform [`BrowseRepo::find_packages`] that yields +//! [`CompletePackageView`]s — the trio of fields +//! `Composer\Command\HomeCommand::handlePackage` reads off +//! `CompletePackageInterface` (`getSupport()['source']`, +//! `getSourceUrl()`, `getHomepage()`). + +use super::super::package::RawPackageData; +use super::cache::Cache; +use super::installed::{InstalledPackageEntry, InstalledPackages}; +use super::lockfile::LockedPackage; +use super::packagist::{self, PackagistVersion}; + +/// Subset of `Composer\Package\CompletePackageInterface` consumed by +/// `HomeCommand::handlePackage`. Every backing repo flattens its +/// package shape into this so URL selection lives in one place. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CompletePackageView { + /// `$package->getSupport()['source']`. + pub support_source: Option, + /// `$package->getSourceUrl()`. + pub source_url: Option, + /// `$package->getHomepage()`. + pub homepage: Option, +} + +impl From<&LockedPackage> for CompletePackageView { + fn from(pkg: &LockedPackage) -> Self { + Self { + support_source: pkg + .support + .as_ref() + .and_then(|s| s.get("source")) + .and_then(|s| s.as_str()) + .map(str::to_string), + source_url: pkg.source.as_ref().map(|s| s.url.clone()), + homepage: pkg.homepage.clone(), + } + } +} + +impl From<&InstalledPackageEntry> for CompletePackageView { + fn from(pkg: &InstalledPackageEntry) -> Self { + Self { + support_source: pkg + .support + .as_ref() + .and_then(|s| s.get("source")) + .and_then(|s| s.as_str()) + .map(str::to_string), + source_url: pkg + .source + .as_ref() + .and_then(|s| s.get("url")) + .and_then(|s| s.as_str()) + .map(str::to_string), + homepage: pkg.homepage.clone(), + } + } +} + +impl From<&PackagistVersion> for CompletePackageView { + fn from(pkg: &PackagistVersion) -> Self { + Self { + support_source: pkg + .support + .as_ref() + .and_then(|s| s.get("source")) + .and_then(|s| s.as_str()) + .map(str::to_string), + source_url: pkg.source.as_ref().map(|s| s.url.clone()), + homepage: pkg.homepage.clone(), + } + } +} + +/// `RawPackageData` lacks a typed `support` field — the root package's +/// `support` block lives inside `extra_fields` because the schema is not +/// yet ported. Read it manually here. +pub fn view_from_raw(pkg: &RawPackageData) -> CompletePackageView { + CompletePackageView { + support_source: pkg + .extra_fields + .get("support") + .and_then(|s| s.get("source")) + .and_then(|s| s.as_str()) + .map(str::to_string), + source_url: None, + homepage: pkg.homepage.clone(), + } +} + +/// One repository in the composite. Mirrors the three repo kinds +/// `HomeCommand::initializeRepos()` returns: +/// `RootPackageRepository` + local installed + remotes. +pub enum BrowseRepo { + /// Stand-in for `Composer\Repository\RootPackageRepository` — + /// a one-package array containing the root composer.json. + /// Boxed because `RawPackageData` is much larger than the other + /// variants (clippy::large_enum_variant). + Root(Box), + /// Stand-in for `RepositoryManager::getLocalRepository()` — + /// the installed.json view of `vendor/`. + Installed(InstalledPackages), + /// Stand-in for the configured remote. For now Mozart only knows + /// the default Packagist remote (`RepositoryFactory::defaultRepos`). + Packagist { cache: Cache }, +} + +impl BrowseRepo { + /// Mirrors `RepositoryInterface::findPackages($name)` — case-insensitive + /// match by package name, returning every match the repo holds. + pub async fn find_packages(&self, name: &str) -> anyhow::Result> { + match self { + BrowseRepo::Root(pkg) => { + if pkg.name.eq_ignore_ascii_case(name) { + Ok(vec![view_from_raw(pkg)]) + } else { + Ok(Vec::new()) + } + } + BrowseRepo::Installed(installed) => Ok(installed + .packages + .iter() + .filter(|p| p.name.eq_ignore_ascii_case(name)) + .map(CompletePackageView::from) + .collect()), + BrowseRepo::Packagist { cache } => { + let versions = packagist::fetch_package_versions(name, cache).await?; + Ok(versions.iter().map(CompletePackageView::from).collect()) + } + } + } +} + +/// Ordered composite consulted by `HomeCommand::execute()`'s outer +/// `foreach ($repos as $repo)` loop. +pub struct BrowseRepos { + repos: Vec, +} + +impl BrowseRepos { + /// Build the composite. `root` and `installed` are passed in + /// rather than read here so callers can decide whether to load + /// them from `Composer` (when composer.json is present) or skip + /// them entirely (the `defaultReposWithDefaultManager` fallback). + pub fn new( + root: Option, + installed: Option, + packagist_cache: Cache, + ) -> Self { + let mut repos: Vec = Vec::with_capacity(3); + if let Some(root) = root { + repos.push(BrowseRepo::Root(Box::new(root))); + } + if let Some(installed) = installed { + repos.push(BrowseRepo::Installed(installed)); + } + repos.push(BrowseRepo::Packagist { + cache: packagist_cache, + }); + Self { repos } + } + + pub fn iter(&self) -> std::slice::Iter<'_, BrowseRepo> { + self.repos.iter() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + fn locked( + name: &str, + source_url: Option<&str>, + homepage: Option<&str>, + support_source: Option<&str>, + ) -> LockedPackage { + LockedPackage { + name: name.to_string(), + version: "1.0.0".to_string(), + version_normalized: None, + source: source_url.map(|url| super::super::lockfile::LockedSource { + source_type: "git".to_string(), + url: url.to_string(), + reference: None, + }), + dist: None, + require: BTreeMap::new(), + require_dev: BTreeMap::new(), + conflict: BTreeMap::new(), + provide: BTreeMap::new(), + replace: BTreeMap::new(), + suggest: None, + package_type: None, + autoload: None, + autoload_dev: None, + license: None, + description: None, + homepage: homepage.map(str::to_string), + keywords: None, + authors: None, + support: support_source.map(|s| serde_json::json!({"source": s})), + funding: None, + time: None, + extra_fields: BTreeMap::new(), + } + } + + #[test] + fn view_from_locked_package_carries_three_urls() { + let pkg = locked( + "vendor/pkg", + Some("https://github.com/vendor/pkg.git"), + Some("https://vendor.example.com"), + Some("https://github.com/vendor/pkg"), + ); + let view = CompletePackageView::from(&pkg); + assert_eq!( + view.support_source.as_deref(), + Some("https://github.com/vendor/pkg") + ); + assert_eq!( + view.source_url.as_deref(), + Some("https://github.com/vendor/pkg.git") + ); + assert_eq!(view.homepage.as_deref(), Some("https://vendor.example.com")); + } + + #[test] + fn view_from_installed_entry_extracts_source_url() { + let mut entry = InstalledPackageEntry { + name: "vendor/pkg".to_string(), + version: "1.0.0".to_string(), + version_normalized: None, + source: Some(serde_json::json!({"url": "https://github.com/vendor/pkg.git"})), + dist: None, + package_type: None, + install_path: None, + autoload: None, + aliases: vec![], + homepage: Some("https://vendor.example.com".to_string()), + support: Some(serde_json::json!({"source": "https://github.com/vendor/pkg"})), + extra_fields: BTreeMap::new(), + }; + let view = CompletePackageView::from(&entry); + assert_eq!( + view.source_url.as_deref(), + Some("https://github.com/vendor/pkg.git") + ); + assert_eq!( + view.support_source.as_deref(), + Some("https://github.com/vendor/pkg") + ); + assert_eq!(view.homepage.as_deref(), Some("https://vendor.example.com")); + + entry.support = None; + entry.source = None; + entry.homepage = None; + let empty = CompletePackageView::from(&entry); + assert_eq!(empty, CompletePackageView::default()); + } + + #[test] + fn view_from_raw_reads_support_via_extra_fields() { + let mut raw = RawPackageData::new("vendor/root".to_string()); + raw.homepage = Some("https://vendor.example.com".to_string()); + raw.extra_fields.insert( + "support".to_string(), + serde_json::json!({"source": "https://github.com/vendor/root"}), + ); + let view = view_from_raw(&raw); + assert_eq!( + view.support_source.as_deref(), + Some("https://github.com/vendor/root") + ); + assert!(view.source_url.is_none()); + assert_eq!(view.homepage.as_deref(), Some("https://vendor.example.com")); + } + + #[tokio::test] + async fn root_repo_matches_case_insensitively() { + let raw = RawPackageData::new("Vendor/Root".to_string()); + let repo = BrowseRepo::Root(Box::new(raw)); + assert_eq!(repo.find_packages("vendor/root").await.unwrap().len(), 1); + assert_eq!(repo.find_packages("other/pkg").await.unwrap().len(), 0); + } +} diff --git a/crates/mozart-core/src/repository/cache.rs b/crates/mozart-core/src/repository/cache.rs new file mode 100644 index 0000000..39e3e8d --- /dev/null +++ b/crates/mozart-core/src/repository/cache.rs @@ -0,0 +1,575 @@ +//! 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) +//! vcs/ VCS mirrors (one subdir per sanitized URL) +//! ``` + +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// 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, + /// Directory for VCS mirrors (one subdirectory per sanitized URL). + pub cache_vcs_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, +} + +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. +/// +/// When no-cache mode is active (via `cli_no_cache` or `$COMPOSER_NO_CACHE`), +/// all cache directories are set to a null device, mirroring Composer's +/// `Application::doRun()` which calls `putenv('COMPOSER_CACHE_DIR', '/dev/null')`. +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 no_cache { + // Mirrors Composer: --no-cache redirects all cache paths to a null device so + // that Cache::is_usable() returns false and caching is transparently disabled. + #[cfg(windows)] + { + PathBuf::from("nul") + } + #[cfg(not(windows))] + { + PathBuf::from("/dev/null") + } + } else if let Ok(dir) = std::env::var("COMPOSER_CACHE_DIR") { + PathBuf::from(dir) + } else { + dirs_cache_dir().join("mozart") + }; + + let cache_files_dir = cache_dir.join("files"); + let cache_repo_dir = cache_dir.join("repo"); + let cache_vcs_dir = std::env::var("COMPOSER_CACHE_VCS_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| cache_dir.join("vcs")); + + CacheConfig { + cache_files_dir, + cache_repo_dir, + cache_vcs_dir, + cache_ttl: CacheConfig::DEFAULT_TTL, + cache_files_ttl: CacheConfig::DEFAULT_TTL, + cache_files_maxsize: CacheConfig::DEFAULT_FILES_MAXSIZE, + cache_dir, + read_only, + } +} + +/// 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") +} + +/// A single cache bucket (a directory on disk). +#[derive(Clone)] +pub struct Cache { + root: PathBuf, + enabled: bool, + readonly: bool, +} + +impl Cache { + /// Create a new cache rooted at `root`. + /// + /// Mirrors Composer's `Cache::__construct` + `Cache::isEnabled()`: + /// - If the path is a null device (`/dev/null`, `nul`, etc.), the cache is disabled. + /// - If `readonly` is true, the cache is always enabled (no writability check). + /// - Otherwise, tries to create the directory and checks that it is writable; + /// disables the cache with a warning if not. + pub fn new(root: PathBuf, readonly: bool) -> Self { + let enabled = if !Self::is_usable(&root) { + false + } else if readonly { + true + } else { + if fs::create_dir_all(&root).is_err() { + false + } else { + fs::metadata(&root) + .map(|m| !m.permissions().readonly()) + .unwrap_or(false) + } + }; + Self { + root, + enabled, + readonly, + } + } + + /// Returns `false` for null-device paths that should never be used as a real cache. + /// + /// Mirrors Composer's `Cache::isUsable()`. + fn is_usable(path: &Path) -> bool { + let s = path.to_string_lossy(); + if cfg!(windows) { + // On Windows, "nul" and "$null" (any case) are null devices. + !s.split(['/', '\\']) + .any(|c| c.eq_ignore_ascii_case("nul") || c == "$null") + } else { + // On Unix, /dev/null and any path under it are unusable. + s != "/dev/null" && !s.starts_with("/dev/null/") + } + } + + /// Shorthand: create the repo cache from a `CacheConfig`. + pub fn repo(config: &CacheConfig) -> Self { + Self::new(config.cache_repo_dir.clone(), config.read_only) + } + + /// Shorthand: create the files cache from a `CacheConfig`. + pub fn files(config: &CacheConfig) -> Self { + Self::new(config.cache_files_dir.clone(), config.read_only) + } + + /// Whether caching is enabled for this bucket. + pub fn is_enabled(&self) -> bool { + self.enabled + } + + /// Sanitize a cache key for use as a filename. + /// + /// Replaces `/` with `~` and strips characters that are unsafe in + /// filenames (anything except alphanumerics, `-`, `_`, `.`, `~`). + pub fn sanitize_key(key: &str) -> String { + key.replace('/', "~") + .chars() + .filter(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '~')) + .collect() + } + + /// Return the full path for a cache entry. + fn path_for(&self, key: &str) -> PathBuf { + self.root.join(Self::sanitize_key(key)) + } + + /// Read a cached string entry, or `None` if absent or cache disabled. + pub fn read(&self, key: &str) -> Option { + if !self.enabled { + return None; + } + fs::read_to_string(self.path_for(key)).ok() + } + + /// Write a string entry atomically (write to temp file, then rename). + pub fn write(&self, key: &str, contents: &str) -> anyhow::Result<()> { + if !self.enabled || self.readonly { + return Ok(()); + } + self.write_bytes(key, contents.as_bytes()) + } + + /// Read a cached binary entry, or `None` if absent or cache disabled. + pub fn read_bytes(&self, key: &str) -> Option> { + if !self.enabled { + return None; + } + fs::read(self.path_for(key)).ok() + } + + /// Write a binary entry atomically (write to temp file, then rename). + pub fn write_bytes(&self, key: &str, data: &[u8]) -> anyhow::Result<()> { + if !self.enabled || self.readonly { + 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.enabled || self.readonly { + return Ok(()); + } + 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.readonly || !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(()) + } + + /// Run garbage collection on a VCS cache bucket. + /// + /// Each top-level subdirectory is one bare mirror keyed by sanitized URL. + /// Deletes entire subdirectories whose mtime is older than `ttl_seconds`. + /// Mirrors Composer's `Cache::gcVcsCache`. + pub fn gc_vcs_cache(&self, ttl_seconds: u64) -> anyhow::Result<()> { + if !self.enabled || !self.root.exists() { + return Ok(()); + } + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + for entry in fs::read_dir(&self.root)? { + let entry = entry?; + let path = entry.path(); + let metadata = entry.metadata()?; + if !metadata.is_dir() { + continue; + } + let mtime = metadata + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + if now.saturating_sub(mtime) > ttl_seconds { + let _ = fs::remove_dir_all(&path); + } + } + + Ok(()) + } + + /// Return the age in seconds of a cached entry based on its mtime, + /// or `None` if the entry doesn't exist or mtime can't be read. + pub fn age(&self, key: &str) -> Option { + if !self.enabled { + return None; + } + let path = self.path_for(key); + let metadata = fs::metadata(&path).ok()?; + let mtime = metadata.modified().ok()?; + let now = SystemTime::now(); + now.duration_since(mtime).ok().map(|d| d.as_secs()) + } +} + +/// Recursively collect all files under `dir` as `(path, mtime_secs, size_bytes)`. +fn collect_files(dir: &Path, out: &mut Vec<(PathBuf, u64, u64)>) -> anyhow::Result<()> { + if !dir.exists() { + return Ok(()); + } + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + let metadata = entry.metadata()?; + if metadata.is_dir() { + collect_files(&path, out)?; + } else if metadata.is_file() { + let mtime = metadata + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + let size = metadata.len(); + out.push((path, mtime, size)); + } + } + Ok(()) +} + +/// 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) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + use tempfile::tempdir; + + #[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" + ); + } + + #[test] + fn test_write_read_roundtrip_string() { + let dir = tempdir().unwrap(); + let cache = Cache::new(dir.path().to_path_buf(), false); + + cache.write("test-key", "hello world").unwrap(); + let result = cache.read("test-key"); + assert_eq!(result.as_deref(), Some("hello world")); + } + + #[test] + fn test_write_read_roundtrip_bytes() { + let dir = tempdir().unwrap(); + let cache = Cache::new(dir.path().to_path_buf(), false); + + 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)); + } + + #[test] + fn test_clear_removes_all_entries() { + let dir = tempdir().unwrap(); + let cache = Cache::new(dir.path().to_path_buf(), false); + + 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()); + } + + #[test] + fn test_disabled_cache_returns_none() { + // Point cache at /dev/null — is_usable() returns false → cache disabled. + let cache = Cache::new(PathBuf::from("/dev/null/files"), 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()); + } + + #[test] + fn test_gc_ttl_expiration() { + let dir = tempdir().unwrap(); + let cache = Cache::new(dir.path().to_path_buf(), false); + + // 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"); + } + + #[test] + fn test_gc_size_limit() { + let dir = tempdir().unwrap(); + let cache = Cache::new(dir.path().to_path_buf(), false); + + // 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" + ); + } + + #[test] + fn test_gc_vcs_removes_old_subdirs() { + let dir = tempdir().unwrap(); + let cache = Cache::new(dir.path().to_path_buf(), false); + + let old_mirror = dir.path().join("old-mirror"); + let new_mirror = dir.path().join("new-mirror"); + fs::create_dir_all(&old_mirror).unwrap(); + fs::write(old_mirror.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + fs::create_dir_all(&new_mirror).unwrap(); + fs::write(new_mirror.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + + let two_hours_ago = SystemTime::now() - Duration::from_secs(7200); + filetime::set_file_mtime( + &old_mirror, + filetime::FileTime::from_system_time(two_hours_ago), + ) + .unwrap(); + + cache.gc_vcs_cache(3600).unwrap(); + + assert!(!old_mirror.exists(), "expired mirror should be removed"); + assert!(new_mirror.exists(), "fresh mirror should remain"); + } + + #[test] + fn test_age_existing_entry() { + let dir = tempdir().unwrap(); + let cache = Cache::new(dir.path().to_path_buf(), false); + + 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(), false); + assert!(cache.age("nonexistent-key").is_none()); + } + + #[test] + fn test_age_disabled_cache() { + let cache = Cache::new(PathBuf::from("/dev/null/files"), false); + assert!(cache.age("any-key").is_none()); + } +} diff --git a/crates/mozart-core/src/repository/composer_repo.rs b/crates/mozart-core/src/repository/composer_repo.rs new file mode 100644 index 0000000..3413ad5 --- /dev/null +++ b/crates/mozart-core/src/repository/composer_repo.rs @@ -0,0 +1,173 @@ +//! Support for `type: composer` repositories. +//! +//! A Composer repository is a directory (or HTTP endpoint) hosting a +//! `packages.json` file. The legacy format embeds full package metadata +//! directly: +//! +//! ```json +//! { +//! "packages": { +//! "a/a": { +//! "dev-foobar": { "name": "a/a", "version": "dev-foobar", ... } +//! } +//! } +//! } +//! ``` +//! +//! Mirrors `Composer\Repository\ComposerRepository` for the file:// case +//! used by the test fixtures. Lazy / v2 / provider-includes / metadata-url +//! variants are out of scope here — the in-process installer fixtures only +//! exercise the legacy embedded-packages form. + +use super::packagist::PackagistVersion; +use super::repository_filter::RepositoryFilter; +use crate::package::RawRepository; +use indexmap::IndexSet; +use std::path::PathBuf; + +/// One package version drawn from a `type: composer` repository. +pub struct ComposerRepoPackage { + pub name: String, + pub version: PackagistVersion, +} + +/// Read every package version from `type: composer` repositories declared in +/// `composer.json`. Only `file://` URLs are supported here — they're what +/// the installer fixtures use after the harness rewrites +/// `file://foobar` → `file:///abs/path/to/fixtures/foobar`. +pub fn collect_composer_packages(repositories: &[RawRepository]) -> Vec { + let mut out = Vec::new(); + let mut claimed: IndexSet = IndexSet::new(); + for repo in repositories { + if repo.repo_type != "composer" { + continue; + } + let Some(url) = repo.url.as_deref() else { + continue; + }; + let Some(dir) = file_url_to_path(url) else { + continue; + }; + let packages_json = dir.join("packages.json"); + let Ok(content) = std::fs::read_to_string(&packages_json) else { + continue; + }; + let Ok(parsed) = serde_json::from_str::(&content) else { + continue; + }; + let Some(packages) = parsed.get("packages").and_then(|v| v.as_object()) else { + continue; + }; + let filter = RepositoryFilter::from_repo(repo); + let mut names_this_repo: IndexSet = IndexSet::new(); + for (name, versions) in packages { + if !filter.is_allowed(name) { + continue; + } + if claimed.contains(name) { + continue; + } + let Some(versions_obj) = versions.as_object() else { + continue; + }; + let mut emitted = false; + for (_, version_value) in versions_obj { + if let Ok(pv) = serde_json::from_value::(version_value.clone()) { + out.push(ComposerRepoPackage { + name: name.clone(), + version: pv, + }); + emitted = true; + } + } + if emitted { + names_this_repo.insert(name.clone()); + } + } + if filter.canonical { + claimed.extend(names_this_repo); + } + } + out +} + +/// Turn a `file://` URL into a filesystem path. Accepts both +/// `file:///abs/path` (RFC 8089 form) and `file://abs/path` (Composer's +/// loose form). Returns `None` for non-`file://` URLs. +fn file_url_to_path(url: &str) -> Option { + let rest = url.strip_prefix("file://")?; + // RFC 8089: file:///abs/path → empty authority, rest starts with `/`. + // Composer's harness writes `file:///abs/...` after rewriting, so the + // typical input here is one leading `/`. + Some(PathBuf::from(rest)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn write_packages_json(dir: &std::path::Path, body: &str) { + fs::write(dir.join("packages.json"), body).unwrap(); + } + + fn composer_repo(url: String) -> RawRepository { + RawRepository { + repo_type: "composer".to_string(), + url: Some(url), + package: None, + only: None, + exclude: None, + canonical: None, + security_advisories: None, + } + } + + #[test] + fn reads_legacy_packages_json() { + let tmp = TempDir::new().unwrap(); + write_packages_json( + tmp.path(), + r#"{ + "packages": { + "a/a": { + "dev-foobar": { + "name": "a/a", + "version": "dev-foobar", + "version_normalized": "dev-foobar" + } + } + } + }"#, + ); + let url = format!("file://{}", tmp.path().display()); + let repos = vec![composer_repo(url)]; + let pkgs = collect_composer_packages(&repos); + assert_eq!(pkgs.len(), 1); + assert_eq!(pkgs[0].name, "a/a"); + assert_eq!(pkgs[0].version.version, "dev-foobar"); + } + + #[test] + fn ignores_non_composer_types() { + let repos = vec![RawRepository { + repo_type: "vcs".to_string(), + url: Some("https://example.com/foo.git".to_string()), + package: None, + only: None, + exclude: None, + canonical: None, + security_advisories: None, + }]; + assert!(collect_composer_packages(&repos).is_empty()); + } + + #[test] + fn skips_missing_packages_json() { + let tmp = TempDir::new().unwrap(); + let url = format!("file://{}", tmp.path().display()); + let repos = vec![composer_repo(url)]; + assert!(collect_composer_packages(&repos).is_empty()); + } +} diff --git a/crates/mozart-core/src/repository/download_manager.rs b/crates/mozart-core/src/repository/download_manager.rs new file mode 100644 index 0000000..d422899 --- /dev/null +++ b/crates/mozart-core/src/repository/download_manager.rs @@ -0,0 +1,143 @@ +//! `DownloadManager` — pick the right [`VcsDownloader`] for a given +//! [`LocalPackage`]. Mirrors `Composer\Downloader\DownloadManager`. + +use std::path::PathBuf; + +use crate::composer::{InstallationSource, LocalPackage}; +use crate::vcs::downloader::VcsDownloader; +use crate::vcs::downloader::git::GitDownloader; +use crate::vcs::downloader::hg::HgDownloader; +use crate::vcs::downloader::svn::SvnDownloader; +use crate::vcs::process::ProcessExecutor; +use crate::vcs::util::git::GitUtil; +use crate::vcs::util::hg::HgUtil; +use crate::vcs::util::svn::SvnUtil; + +/// Selects a `VcsDownloader` for a package based on its installation source +/// and source type. Mirrors `DownloadManager::getDownloaderForPackage`: +/// +/// - `metapackage` → `None`. +/// - `installation-source: dist` → `None` (Composer would return a +/// `FileDownloader`-family object that does not implement +/// `ChangeReportInterface` / `DvcsDownloaderInterface`, so the status +/// command's `instanceof` checks all become no-ops; returning `None` +/// directly is the equivalent in our trait-object world). +/// - `installation-source: source` → the matching VCS downloader by +/// `source.type` (`git` / `hg` / `svn`). +pub struct DownloadManager { + git_cache_dir: PathBuf, +} + +impl DownloadManager { + /// `git_cache_dir`: where `GitUtil` should keep mirror clones (e.g. + /// `/.cache/git`). + pub fn new(git_cache_dir: PathBuf) -> Self { + Self { git_cache_dir } + } + + pub fn get_downloader_for_package( + &self, + package: &LocalPackage, + ) -> Option> { + if package.package_type() == Some("metapackage") { + return None; + } + match package.installation_source()? { + InstallationSource::Dist => None, + InstallationSource::Source => { + let kind = package.source()?.kind.as_str(); + match kind { + "git" => { + let git_util = + GitUtil::new(ProcessExecutor::new(), self.git_cache_dir.clone()); + Some(Box::new(GitDownloader::new(git_util))) + } + "hg" => { + let hg_util = HgUtil::new(ProcessExecutor::new()); + Some(Box::new(HgDownloader::new(hg_util))) + } + "svn" => { + let svn_util = SvnUtil::new(ProcessExecutor::new()); + Some(Box::new(SvnDownloader::new(svn_util))) + } + _ => None, + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::composer::PackageReference; + use serde_json::Value; + + fn pkg( + installation_source: Option, + source_kind: Option<&str>, + ) -> LocalPackage { + let source = source_kind.map(|kind| PackageReference { + kind: kind.to_string(), + url: "https://example/repo".into(), + reference: Some("abc123".into()), + shasum: None, + }); + LocalPackage::new( + "vendor/pkg".into(), + "1.0.0".into(), + None, + Some("library".into()), + installation_source, + source, + None, + Value::Null, + ) + } + + #[test] + fn metapackage_returns_none() { + let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache")); + let mut p = pkg(Some(InstallationSource::Source), Some("git")); + // override type + p = LocalPackage::new( + "vendor/pkg".into(), + "1.0.0".into(), + None, + Some("metapackage".into()), + p.installation_source(), + p.source().cloned(), + None, + Value::Null, + ); + assert!(dm.get_downloader_for_package(&p).is_none()); + } + + #[test] + fn dist_install_returns_none() { + let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache")); + let p = pkg(Some(InstallationSource::Dist), Some("git")); + assert!(dm.get_downloader_for_package(&p).is_none()); + } + + #[test] + fn source_install_with_git_returns_some() { + let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache")); + let p = pkg(Some(InstallationSource::Source), Some("git")); + assert!(dm.get_downloader_for_package(&p).is_some()); + } + + #[test] + fn unknown_source_kind_returns_none() { + let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache")); + let p = pkg(Some(InstallationSource::Source), Some("perforce")); + assert!(dm.get_downloader_for_package(&p).is_none()); + } + + #[test] + fn missing_installation_source_returns_none() { + let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache")); + let p = pkg(None, Some("git")); + assert!(dm.get_downloader_for_package(&p).is_none()); + } +} diff --git a/crates/mozart-core/src/repository/downloader.rs b/crates/mozart-core/src/repository/downloader.rs new file mode 100644 index 0000000..b0d2a6a --- /dev/null +++ b/crates/mozart-core/src/repository/downloader.rs @@ -0,0 +1,500 @@ +use super::cache::Cache; +use indexmap::IndexSet; +use sha1::{Digest, Sha1}; +use std::fs; +use std::io::{Cursor, Read, Write}; +use std::path::Path; + +/// A simple download progress tracker that writes to stderr. +/// +/// When `show` is false, all methods are no-ops. This lets callers toggle +/// progress display without branching on every call. +pub struct DownloadProgress { + show: bool, + total: u64, + downloaded: u64, + label: String, +} + +impl DownloadProgress { + /// Create a new progress tracker. + /// + /// - `show`: whether to actually display anything. + /// - `label`: a human-readable label (e.g. "psr/log (3.0.2)"). + pub fn new(show: bool, label: impl Into) -> Self { + Self { + show, + total: 0, + downloaded: 0, + label: label.into(), + } + } + + /// Set the total expected bytes from a `Content-Length` header. + pub fn set_total(&mut self, total: u64) { + self.total = total; + } + + /// Advance the downloaded byte count and redraw the line. + pub fn inc(&mut self, n: u64) { + if !self.show { + return; + } + self.downloaded += n; + let stderr = std::io::stderr(); + let mut out = stderr.lock(); + if let Some(pct) = (self.downloaded * 100).checked_div(self.total) { + let _ = write!( + out, + "\r Downloading {} ({}/{} bytes, {}%)", + self.label, self.downloaded, self.total, pct + ); + } else { + let _ = write!( + out, + "\r Downloading {} ({} bytes)", + self.label, self.downloaded + ); + } + let _ = out.flush(); + } + + /// Clear the progress line from the terminal. + pub fn finish(&self) { + if !self.show { + return; + } + let stderr = std::io::stderr(); + let mut out = stderr.lock(); + // Clear the line with spaces then return to start + let _ = write!(out, "\r{}\r", " ".repeat(80)); + let _ = out.flush(); + } +} + +/// Download a dist archive from a URL. +/// Returns the raw bytes of the downloaded archive. +/// If `expected_shasum` is provided and non-empty, verifies SHA-1 of the downloaded bytes. +/// If `progress` is provided, increments it as bytes are received and sets the total from +/// the `Content-Length` response header. +/// Downloaded bytes are cached by URL in `files_cache`; cache hits skip the network request +/// entirely. +#[tracing::instrument(skip(expected_shasum, progress, files_cache))] +pub async fn download_dist( + url: &str, + expected_shasum: Option<&str>, + progress: Option<&mut DownloadProgress>, + files_cache: &Cache, +) -> anyhow::Result> { + // Build a cache key from the URL + let cache_key = Cache::sanitize_key(url); + + // Check cache first + if let Some(cached_bytes) = files_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 { + tracing::debug!("cache hit"); + return Ok(cached_bytes); + } + // Checksum mismatch — discard cache, re-download + } else { + tracing::debug!("cache hit"); + return Ok(cached_bytes); + } + } + + let client = crate::http::client_builder().build()?; + let response = client.get(url).send().await?; + tracing::debug!(status = %response.status(), "received response"); + + 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 buf = Vec::new(); + let mut stream = response; + while let Some(chunk) = stream.chunk().await? { + buf.extend_from_slice(&chunk); + pb.inc(chunk.len() as u64); + } + buf + } else { + response.bytes().await?.to_vec() + }; + + tracing::debug!(size = bytes.len(), "download complete"); + + // 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 + let _ = files_cache.write_bytes(&cache_key, &bytes); + + Ok(bytes) +} + +/// Find the common top-level directory prefix shared by all entries. +/// Returns `Some(prefix)` if all entries share a single top-level directory. +fn find_top_level_dir(entries: &[String]) -> Option { + if entries.is_empty() { + return None; + } + + let mut prefixes: IndexSet = IndexSet::new(); + for entry in entries { + let slash_pos = entry.find('/')?; + prefixes.insert(entry[..slash_pos + 1].to_string()); + } + + if prefixes.len() == 1 { + prefixes.into_iter().next() + } else { + None + } +} + +/// Extract a zip archive to the target directory. +/// Strips a common top-level directory if all entries share one (Packagist pattern). +pub fn extract_zip(data: &[u8], target_dir: &Path) -> anyhow::Result<()> { + let cursor = Cursor::new(data); + let mut archive = zip::ZipArchive::new(cursor)?; + + // Collect all entry names to detect common prefix + let entry_names: Vec = (0..archive.len()) + .map(|i| archive.by_index(i).map(|e| e.name().to_string())) + .collect::>()?; + + let prefix = find_top_level_dir(&entry_names); + + for i in 0..archive.len() { + let mut entry = archive.by_index(i)?; + let raw_name = entry.name().to_string(); + + // Strip common prefix + let relative = if let Some(ref pfx) = prefix { + if raw_name.starts_with(pfx.as_str()) { + &raw_name[pfx.len()..] + } else { + &raw_name + } + } else { + &raw_name + }; + + // Skip the directory entry itself (empty name after stripping) + if relative.is_empty() { + continue; + } + + let target_path = target_dir.join(relative); + + if raw_name.ends_with('/') { + // Directory entry + fs::create_dir_all(&target_path)?; + } else { + // File entry + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent)?; + } + + let mut buf = Vec::new(); + entry.read_to_end(&mut buf)?; + fs::write(&target_path, &buf)?; + + // Set permissions on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Some(mode) = entry.unix_mode() { + fs::set_permissions(&target_path, fs::Permissions::from_mode(mode))?; + } + } + } + } + + Ok(()) +} + +/// Extract a tar.gz archive to the target directory. +/// Strips a common top-level directory if all entries share one (Packagist pattern). +pub fn extract_tar_gz(data: &[u8], target_dir: &Path) -> anyhow::Result<()> { + let cursor = Cursor::new(data); + let decoder = flate2::read::GzDecoder::new(cursor); + let mut archive = tar::Archive::new(decoder); + + // We need to process in two passes: first collect names, then extract. + // Use a buffered approach: collect entries into memory. + let cursor2 = Cursor::new(data); + let decoder2 = flate2::read::GzDecoder::new(cursor2); + let mut archive2 = tar::Archive::new(decoder2); + + let entry_names: Vec = archive2 + .entries()? + .filter_map(|e| e.ok()) + .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string())) + .collect(); + + let prefix = find_top_level_dir(&entry_names); + + for entry in archive.entries()? { + let mut entry = entry?; + let raw_path = entry.path()?.to_string_lossy().to_string(); + + // Strip common prefix + let relative = if let Some(ref pfx) = prefix { + if raw_path.starts_with(pfx.as_str()) { + raw_path[pfx.len()..].to_string() + } else { + raw_path.clone() + } + } else { + raw_path.clone() + }; + + // Skip empty (top-level dir itself) + if relative.is_empty() { + continue; + } + + let target_path = target_dir.join(&relative); + + let entry_type = entry.header().entry_type(); + if entry_type.is_dir() { + fs::create_dir_all(&target_path)?; + } else if entry_type.is_file() { + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent)?; + } + let mut buf = Vec::new(); + entry.read_to_end(&mut buf)?; + fs::write(&target_path, &buf)?; + + // Set permissions on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(mode) = entry.header().mode() { + fs::set_permissions(&target_path, fs::Permissions::from_mode(mode))?; + } + } + } + // Symlinks and other types are skipped for now + } + + Ok(()) +} + +/// Download and install a package to the vendor directory. +/// +/// - `dist_url`: the download URL (from `LockedPackage.dist.url`) +/// - `dist_type`: `"zip"` or `"tar"` (from `LockedPackage.dist.dist_type`) +/// - `dist_shasum`: optional SHA-1 checksum +/// - `vendor_dir`: path to `vendor/` directory +/// - `package_name`: e.g. `"monolog/monolog"` +/// - `progress`: optional mutable progress tracker to update during download +/// - `files_cache`: files cache; archive bytes are cached by URL +pub async 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: &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).await?; + + match dist_type { + "zip" => extract_zip(&bytes, &target)?, + "tar" | "tar.gz" | "tgz" => extract_tar_gz(&bytes, &target)?, + other => anyhow::bail!("Unsupported dist type: {other}"), + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write as IoWrite; + use tempfile::tempdir; + + /// Build a minimal zip archive in memory. + fn make_zip(files: &[(&str, &[u8])]) -> Vec { + let buf = Vec::new(); + let cursor = Cursor::new(buf); + let mut writer = zip::ZipWriter::new(cursor); + let options = zip::write::FileOptions::<()>::default() + .compression_method(zip::CompressionMethod::Stored); + + for (name, content) in files { + writer.start_file(*name, options).unwrap(); + writer.write_all(content).unwrap(); + } + + writer.finish().unwrap().into_inner() + } + + /// Build a minimal tar.gz archive in memory. + fn make_tar_gz(files: &[(&str, &[u8])]) -> Vec { + let buf = Vec::new(); + let enc = flate2::write::GzEncoder::new(buf, flate2::Compression::default()); + let mut builder = tar::Builder::new(enc); + + for (name, content) in files { + let mut header = tar::Header::new_gnu(); + header.set_size(content.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder + .append_data(&mut header, name, Cursor::new(content)) + .unwrap(); + } + + builder.into_inner().unwrap().finish().unwrap() + } + + #[test] + fn test_extract_zip_flat() { + let zip_data = make_zip(&[("file1.txt", b"hello"), ("subdir/file2.txt", b"world")]); + + let dir = tempdir().unwrap(); + extract_zip(&zip_data, dir.path()).unwrap(); + + assert_eq!( + fs::read_to_string(dir.path().join("file1.txt")).unwrap(), + "hello" + ); + assert_eq!( + fs::read_to_string(dir.path().join("subdir/file2.txt")).unwrap(), + "world" + ); + } + + #[test] + fn test_extract_zip_with_top_level_dir() { + // Packagist pattern: all files under vendor-package-abc123/ + let zip_data = make_zip(&[ + ("vendor-pkg-abc/", &[]), + ("vendor-pkg-abc/file1.txt", b"hello"), + ("vendor-pkg-abc/src/Foo.php", b" Vec { + let mut packages = Vec::new(); + let mut claimed: IndexSet = IndexSet::new(); + for repo in repositories { + if repo.repo_type != "package" { + continue; + } + let Some(value) = &repo.package else { + continue; + }; + let filter = RepositoryFilter::from_repo(repo); + + let mut from_this_repo: Vec = Vec::new(); + match value { + serde_json::Value::Array(arr) => { + for entry in arr { + if let Some(pkg) = parse_inline_package(entry) { + from_this_repo.push(pkg); + } + } + } + serde_json::Value::Object(_) => { + if let Some(pkg) = parse_inline_package(value) { + from_this_repo.push(pkg); + } + } + _ => {} + } + + let mut names_this_repo: IndexSet = IndexSet::new(); + for pkg in from_this_repo { + if !filter.is_allowed(&pkg.name) { + continue; + } + if claimed.contains(&pkg.name) { + continue; + } + names_this_repo.insert(pkg.name.clone()); + packages.push(pkg); + } + // canonical: false → packages enter the pool but the name is not + // claimed, so lower-priority repositories may still answer for it. + // Mirrors `FilterRepository::loadPackages`'s `namesFound = []` reset. + if filter.canonical { + claimed.extend(names_this_repo); + } + } + packages +} + +/// One advisory extracted from a repository's `security-advisories` block. +/// Carries enough to filter affected versions out of the pool when +/// `config.audit.block-insecure` is set, matching the slice of Composer's +/// `SecurityAdvisoryPoolFilter` Mozart needs for resolution-time blocking. +#[derive(Debug, Clone)] +pub struct SecurityAdvisory { + pub advisory_id: String, + pub affected_versions: String, +} + +/// Collect every `security-advisories` entry across all repositories. +/// Returned map is keyed by lowercase package name so the resolver can +/// look up affected versions in lockstep with the rest of its +/// case-insensitive name handling. Repository order is preserved within +/// each list. +pub fn collect_security_advisories( + repositories: &[RawRepository], +) -> indexmap::IndexMap> { + let mut out: indexmap::IndexMap> = indexmap::IndexMap::new(); + for repo in repositories { + let Some(advisories) = &repo.security_advisories else { + continue; + }; + let Some(map) = advisories.as_object() else { + continue; + }; + for (pkg_name, list) in map { + let Some(arr) = list.as_array() else { + continue; + }; + for entry in arr { + let Some(obj) = entry.as_object() else { + continue; + }; + let Some(affected) = obj + .get("affectedVersions") + .and_then(|v| v.as_str()) + .map(String::from) + else { + continue; + }; + let advisory_id = obj + .get("advisoryId") + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_default(); + out.entry(pkg_name.to_lowercase()) + .or_default() + .push(SecurityAdvisory { + advisory_id, + affected_versions: affected, + }); + } + } + } + out +} + +fn parse_inline_package(value: &serde_json::Value) -> Option { + let obj = value.as_object()?; + let name = obj.get("name")?.as_str()?.to_string(); + let version_str = obj.get("version")?.as_str()?.to_string(); + + // PackagistVersion requires `version_normalized`. If the inline definition + // omits it (the common case), compute it the same way Packagist does: + // run the version through Mozart's normalizer. + // + // Mirrors Composer's `ArrayLoader::parsePackage` Composer v1 compat path: + // when `version_normalized` is exactly `9999999-dev` (the legacy default + // branch sentinel), re-normalize from the human-readable `version` field + // instead. Without this, the package's version stays as `9999999-dev` + // even though its pretty form is e.g. `dev-master`, and a root require + // for `dev-master` then can't match the loaded package. + let mut value_for_parse = value.clone(); + if let serde_json::Value::Object(ref mut map) = value_for_parse { + let needs_normalize = match map.get("version_normalized") { + None => true, + Some(serde_json::Value::String(s)) => s == "9999999-dev", + _ => false, + }; + if needs_normalize { + let normalized = mozart_semver::Version::parse(&version_str) + .map(|v| v.to_string()) + .unwrap_or_else(|_| version_str.clone()); + map.insert( + "version_normalized".to_string(), + serde_json::Value::String(normalized), + ); + } + } + + let version: PackagistVersion = serde_json::from_value(value_for_parse).ok()?; + Some(InlinePackage { name, version }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pkg_repo(value: serde_json::Value) -> RawRepository { + RawRepository { + repo_type: "package".to_string(), + url: None, + package: Some(value), + only: None, + exclude: None, + canonical: None, + security_advisories: None, + } + } + + #[test] + fn collects_single_inline_package_object() { + let repos = vec![pkg_repo(serde_json::json!({ + "name": "a/a", + "version": "1.0.0" + }))]; + let pkgs = collect_inline_packages(&repos); + assert_eq!(pkgs.len(), 1); + assert_eq!(pkgs[0].name, "a/a"); + assert_eq!(pkgs[0].version.version, "1.0.0"); + assert_eq!(pkgs[0].version.version_normalized, "1.0.0.0"); + } + + #[test] + fn collects_inline_package_array() { + let repos = vec![pkg_repo(serde_json::json!([ + {"name": "a/a", "version": "1.0.0"}, + {"name": "b/b", "version": "2.0.0"} + ]))]; + let pkgs = collect_inline_packages(&repos); + assert_eq!(pkgs.len(), 2); + assert_eq!(pkgs[0].name, "a/a"); + assert_eq!(pkgs[1].name, "b/b"); + } + + #[test] + fn ignores_non_package_repos() { + let repos = vec![RawRepository { + repo_type: "vcs".to_string(), + url: Some("https://example.com/foo.git".to_string()), + package: None, + only: None, + exclude: None, + canonical: None, + security_advisories: None, + }]; + assert!(collect_inline_packages(&repos).is_empty()); + } + + #[test] + fn skips_entries_missing_name_or_version() { + let repos = vec![pkg_repo(serde_json::json!([ + {"name": "a/a", "version": "1.0.0"}, + {"name": "missing/version"}, + {"version": "2.0.0"}, + {"name": "b/b", "version": "2.0.0"} + ]))]; + let pkgs = collect_inline_packages(&repos); + assert_eq!(pkgs.len(), 2); + assert_eq!(pkgs[0].name, "a/a"); + assert_eq!(pkgs[1].name, "b/b"); + } + + #[test] + fn preserves_explicit_version_normalized() { + let repos = vec![pkg_repo(serde_json::json!({ + "name": "a/a", + "version": "1.0", + "version_normalized": "1.0.0.0-explicit" + }))]; + let pkgs = collect_inline_packages(&repos); + assert_eq!(pkgs[0].version.version_normalized, "1.0.0.0-explicit"); + } + + #[test] + fn parses_full_metadata_fields() { + let repos = vec![pkg_repo(serde_json::json!({ + "name": "a/a", + "version": "1.0.0", + "type": "library", + "require": {"b/b": "^2.0"}, + "replace": {"old/x": "1.0"}, + "provide": {"some/iface": "1.0"}, + "conflict": {"bad/pkg": "*"}, + "dist": {"type": "zip", "url": "https://e.com/a.zip"} + }))]; + let pkgs = collect_inline_packages(&repos); + assert_eq!(pkgs.len(), 1); + let v = &pkgs[0].version; + assert_eq!(v.package_type.as_deref(), Some("library")); + assert_eq!(v.require.get("b/b").map(String::as_str), Some("^2.0")); + assert_eq!(v.replace.get("old/x").map(String::as_str), Some("1.0")); + assert_eq!(v.provide.get("some/iface").map(String::as_str), Some("1.0")); + assert_eq!(v.conflict.get("bad/pkg").map(String::as_str), Some("*")); + assert!(v.dist.is_some()); + } +} diff --git a/crates/mozart-core/src/repository/installed.rs b/crates/mozart-core/src/repository/installed.rs new file mode 100644 index 0000000..544e948 --- /dev/null +++ b/crates/mozart-core/src/repository/installed.rs @@ -0,0 +1,383 @@ +use crate::installer::HasSuggests; +use crate::package::to_json_pretty; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +fn default_true() -> bool { + true +} + +/// Represents `vendor/composer/installed.json`. +/// This is the Composer 2.x format. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstalledPackages { + pub packages: Vec, + + #[serde(rename = "dev-package-names", default)] + pub dev_package_names: Vec, + + #[serde(default = "default_true")] + pub dev: bool, +} + +/// An entry in installed.json's packages array. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstalledPackageEntry { + pub name: String, + pub version: String, + + #[serde(rename = "version_normalized", skip_serializing_if = "Option::is_none")] + pub version_normalized: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub dist: Option, + + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub package_type: Option, + + #[serde(rename = "install-path", skip_serializing_if = "Option::is_none")] + pub install_path: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub autoload: Option, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub aliases: Vec, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub homepage: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub support: Option, + + #[serde(flatten)] + pub extra_fields: BTreeMap, +} + +impl HasSuggests for InstalledPackageEntry { + fn pretty_name(&self) -> &str { + &self.name + } + + fn suggests(&self) -> Vec<(String, String)> { + let Some(val) = self.extra_fields.get("suggest") else { + return Vec::new(); + }; + let Some(obj) = val.as_object() else { + return Vec::new(); + }; + obj.iter() + .filter_map(|(target, reason)| reason.as_str().map(|r| (target.clone(), r.to_string()))) + .collect() + } +} + +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. + /// + /// Accepts both Composer formats, mirroring `FilesystemRepository::initialize`: + /// - **v2** — object with a `packages` array, plus optional `dev-package-names`/`dev` + /// (the shape Composer 2.x writes). + /// - **v1** — bare array of package entries (older shape; still legal input). + pub fn read(vendor_dir: &Path) -> anyhow::Result { + let path = vendor_dir.join("composer/installed.json"); + if !path.exists() { + return Ok(InstalledPackages::new()); + } + let content = fs::read_to_string(&path)?; + Self::from_json_str(&content) + } + + /// Parse an installed.json document. See [`Self::read`] for the accepted shapes. + pub fn from_json_str(content: &str) -> anyhow::Result { + use anyhow::{Context, anyhow}; + + let value: serde_json::Value = + serde_json::from_str(content).context("invalid installed.json")?; + + match value { + serde_json::Value::Object(mut obj) => { + let packages_value = obj.remove("packages").ok_or_else(|| { + anyhow!("Could not parse package list from installed.json (missing `packages`)") + })?; + let packages: Vec = + serde_json::from_value(packages_value) + .context("invalid `packages` array in installed.json")?; + + let dev_package_names: Vec = match obj.remove("dev-package-names") { + Some(v) => serde_json::from_value(v) + .context("invalid `dev-package-names` in installed.json")?, + None => Vec::new(), + }; + let dev: bool = match obj.remove("dev") { + Some(v) => { + serde_json::from_value(v).context("invalid `dev` flag in installed.json")? + } + None => true, + }; + + Ok(InstalledPackages { + packages, + dev_package_names, + dev, + }) + } + serde_json::Value::Array(_) => { + let packages: Vec = serde_json::from_value(value) + .context("invalid v1 installed.json package array")?; + Ok(InstalledPackages { + packages, + dev_package_names: Vec::new(), + dev: true, + }) + } + _ => Err(anyhow!( + "Could not parse package list from installed.json (expected object or array)" + )), + } + } + + /// 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![], + homepage: None, + support: None, + 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_reads_v2_object_form() { + let json = r#"{ + "packages": [ + {"name": "a/a", "version": "1.0.0"} + ], + "dev-package-names": ["a/a"], + "dev": false + }"#; + let installed = InstalledPackages::from_json_str(json).unwrap(); + assert_eq!(installed.packages.len(), 1); + assert_eq!(installed.packages[0].name, "a/a"); + assert_eq!(installed.dev_package_names, vec!["a/a".to_string()]); + assert!(!installed.dev); + } + + #[test] + fn test_reads_v1_array_form() { + // Composer 1.x / fixture-style: bare array of packages. + // FilesystemRepository::initialize accepts this; so must Mozart. + let json = r#"[ + {"name": "a/a", "version": "1.0.0"}, + {"name": "b/b", "version": "2.0.0"} + ]"#; + let installed = InstalledPackages::from_json_str(json).unwrap(); + assert_eq!(installed.packages.len(), 2); + assert_eq!(installed.packages[0].name, "a/a"); + assert_eq!(installed.packages[1].name, "b/b"); + assert!(installed.dev_package_names.is_empty()); + assert!(installed.dev); + } + + #[test] + fn test_v2_defaults_when_optional_fields_missing() { + let json = r#"{"packages": []}"#; + let installed = InstalledPackages::from_json_str(json).unwrap(); + assert!(installed.packages.is_empty()); + assert!(installed.dev_package_names.is_empty()); + assert!(installed.dev); + } + + #[test] + fn test_rejects_non_object_non_array() { + let err = InstalledPackages::from_json_str("\"oops\"").unwrap_err(); + assert!( + err.to_string().contains("expected object or array"), + "{err}" + ); + } + + #[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"); + } + + #[test] + fn test_homepage_and_support_roundtrip() { + let json = r#"{ + "packages": [ + { + "name": "vendor/pkg", + "version": "1.0.0", + "homepage": "https://vendor.example.com", + "support": {"source": "https://github.com/vendor/pkg"} + } + ] + }"#; + let installed = InstalledPackages::from_json_str(json).unwrap(); + let pkg = &installed.packages[0]; + assert_eq!(pkg.homepage.as_deref(), Some("https://vendor.example.com")); + assert_eq!( + pkg.support + .as_ref() + .and_then(|s| s.get("source")) + .and_then(|s| s.as_str()), + Some("https://github.com/vendor/pkg") + ); + } +} diff --git a/crates/mozart-core/src/repository/installer_executor/filesystem.rs b/crates/mozart-core/src/repository/installer_executor/filesystem.rs new file mode 100644 index 0000000..347f2a0 --- /dev/null +++ b/crates/mozart-core/src/repository/installer_executor/filesystem.rs @@ -0,0 +1,230 @@ +//! Production [`InstallerExecutor`] that touches the real filesystem. +//! +//! This is the verb behind `mozart install` / `mozart update` — it pulls +//! dist archives via [`crate::downloader`], clones VCS sources via +//! [`crate::vcs`], and removes vendor directories. Test code substitutes a +//! recording-only executor instead (added in a later step). + +use super::super::cache::Cache; +use super::super::downloader; +use super::{ExecuteContext, InstallerExecutor, PackageOperation}; +use std::path::Path; + +pub struct FilesystemExecutor { + files_cache: Cache, +} + +impl FilesystemExecutor { + pub fn new(files_cache: Cache) -> Self { + Self { files_cache } + } +} + +#[async_trait::async_trait] +impl InstallerExecutor for FilesystemExecutor { + async fn install_package( + &mut self, + op: PackageOperation<'_>, + ctx: &ExecuteContext, + ) -> anyhow::Result<()> { + // Marking an alias as installed/uninstalled has no filesystem side + // effects — the target package's files are already in vendor/. + // Mirrors Composer's `MarkAlias{,Un}installedOperation` which the + // installation manager only uses to update the in-memory installed + // repository. + let Some(pkg) = op.package() else { + return Ok(()); + }; + + // Try source install if --prefer-source and source info is available. + if ctx.prefer_source + && let Some(source) = &pkg.source + { + return install_from_source( + &source.source_type, + &source.url, + source.reference.as_deref().unwrap_or("HEAD"), + &ctx.vendor_dir, + &pkg.name, + ); + } + + // A package with neither dist nor source has no install action. + // This covers Composer's `type: metapackage` (modeled explicitly as + // "no installer") and inline `type: package` definitions used in + // test fixtures that intentionally omit download metadata. Mozart + // records the operation and the installed.json entry but performs + // no filesystem work, mirroring Composer's MetapackageInstaller. + if pkg.dist.is_none() && pkg.source.is_none() { + return Ok(()); + } + + let dist = pkg.dist.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "Package {} has no dist information. Use --prefer-source to install from VCS.", + pkg.name, + ) + })?; + + let mut progress = downloader::DownloadProgress::new( + !ctx.no_progress, + format!("{} ({})", pkg.name, pkg.version), + ); + + downloader::install_package( + &dist.url, + &dist.dist_type, + dist.shasum.as_deref(), + &ctx.vendor_dir, + &pkg.name, + Some(&mut progress), + &self.files_cache, + ) + .await?; + + progress.finish(); + Ok(()) + } + + fn uninstall_package( + &mut self, + name: &str, + _version: &str, + ctx: &ExecuteContext, + ) -> anyhow::Result<()> { + let pkg_dir = ctx.vendor_dir.join(name); + if pkg_dir.exists() { + std::fs::remove_dir_all(&pkg_dir)?; + } + Ok(()) + } + + fn cleanup_after_uninstalls(&mut self, ctx: &ExecuteContext) -> anyhow::Result<()> { + cleanup_empty_vendor_dirs(&ctx.vendor_dir) + } +} + +/// Remove empty vendor namespace directories left behind after package +/// removals. Skips the `composer/` and `bin/` directories. Mirrors the +/// post-uninstall cleanup Composer does in `LibraryInstaller::removeCode`. +fn cleanup_empty_vendor_dirs(vendor_dir: &Path) -> anyhow::Result<()> { + if let Ok(entries) = std::fs::read_dir(vendor_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let name = entry.file_name().to_string_lossy().to_string(); + if name == "composer" || name == "bin" { + continue; + } + if std::fs::read_dir(&path)?.next().is_none() { + std::fs::remove_dir(&path)?; + } + } + } + } + Ok(()) +} + +/// Install a package from VCS source (git/svn/hg). Lifted from the previous +/// `commands/install.rs::install_from_source`. Mirrors the per-driver +/// dispatch in `Composer\Downloader\VcsDownloader::install`. +fn install_from_source( + source_type: &str, + url: &str, + reference: &str, + vendor_dir: &Path, + package_name: &str, +) -> anyhow::Result<()> { + let target = vendor_dir.join(package_name); + if target.exists() { + std::fs::remove_dir_all(&target)?; + } + + match source_type { + "git" => { + let process = crate::vcs::process::ProcessExecutor::new(); + let git_util = + crate::vcs::util::git::GitUtil::new(process, vendor_dir.join(".cache").join("git")); + let downloader = crate::vcs::downloader::git::GitDownloader::new(git_util); + use crate::vcs::downloader::VcsDownloader; + downloader.download(url, reference, &target)?; + downloader.install(url, reference, &target)?; + } + "svn" => { + let process = crate::vcs::process::ProcessExecutor::new(); + let svn_util = crate::vcs::util::svn::SvnUtil::new(process); + let downloader = crate::vcs::downloader::svn::SvnDownloader::new(svn_util); + use crate::vcs::downloader::VcsDownloader; + downloader.install(url, reference, &target)?; + } + "hg" => { + let process = crate::vcs::process::ProcessExecutor::new(); + let hg_util = crate::vcs::util::hg::HgUtil::new(process); + let downloader = crate::vcs::downloader::hg::HgDownloader::new(hg_util); + use crate::vcs::downloader::VcsDownloader; + downloader.install(url, reference, &target)?; + } + _ => { + anyhow::bail!("Unsupported source type for VCS install: {}", source_type); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn make_executor() -> FilesystemExecutor { + FilesystemExecutor::new(Cache::new(std::env::temp_dir().join("__no_cache"), false)) + } + + #[test] + fn cleanup_after_uninstalls_removes_empty_namespace_dirs() { + let dir = tempdir().unwrap(); + let vendor_dir = dir.path().join("vendor"); + std::fs::create_dir_all(&vendor_dir).unwrap(); + + let empty_ns = vendor_dir.join("old-vendor"); + std::fs::create_dir_all(&empty_ns).unwrap(); + + let nonempty_ns = vendor_dir.join("psr"); + std::fs::create_dir_all(nonempty_ns.join("log")).unwrap(); + + std::fs::create_dir_all(vendor_dir.join("composer")).unwrap(); + + let mut exec = make_executor(); + exec.cleanup_after_uninstalls(&ExecuteContext { + vendor_dir: vendor_dir.clone(), + no_progress: true, + prefer_source: false, + }) + .unwrap(); + + assert!(!empty_ns.exists()); + assert!(vendor_dir.join("psr").exists()); + assert!(vendor_dir.join("composer").exists()); + } + + #[test] + fn cleanup_after_uninstalls_preserves_bin_dir() { + let dir = tempdir().unwrap(); + let vendor_dir = dir.path().join("vendor"); + std::fs::create_dir_all(&vendor_dir).unwrap(); + + let bin_dir = vendor_dir.join("bin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + + let mut exec = make_executor(); + exec.cleanup_after_uninstalls(&ExecuteContext { + vendor_dir: vendor_dir.clone(), + no_progress: true, + prefer_source: false, + }) + .unwrap(); + + assert!(bin_dir.exists()); + } +} diff --git a/crates/mozart-core/src/repository/installer_executor/mod.rs b/crates/mozart-core/src/repository/installer_executor/mod.rs new file mode 100644 index 0000000..f67c612 --- /dev/null +++ b/crates/mozart-core/src/repository/installer_executor/mod.rs @@ -0,0 +1,348 @@ +//! Installation execution abstraction. +//! +//! Mirrors `Composer\Installer\InstallationManager`: the per-operation +//! side-effect surface (download, extract, remove from vendor/) lives behind +//! a trait so test code can substitute a recording-only implementation +//! (Composer's `InstallationManagerMock`) without going anywhere near the +//! filesystem or the network. +//! +//! The orchestration loop (computing operations from lock vs installed, +//! emitting console messages, writing `installed.json`, generating the +//! autoloader) stays in the caller. The executor is purely the verb — +//! "install this package" / "uninstall this package" — so test traces match +//! Composer's `(string) $operation` byte-for-byte without the executor +//! having to also reproduce console formatting. + +use std::path::PathBuf; + +use super::installed::InstalledPackageEntry; +use super::lockfile::{LockAlias, LockedPackage}; + +pub mod filesystem; +pub mod trace_recorder; +pub mod transaction; + +pub use filesystem::FilesystemExecutor; +pub use trace_recorder::TraceRecorderExecutor; +pub use transaction::{ + Action, StaleInstalledAlias, compute_operations, compute_stale_installed_aliases, + locked_to_installed_entry, previously_installed_alias_versions, +}; + +/// One install or update operation handed to [`InstallerExecutor::install_package`]. +#[derive(Debug, Clone, Copy)] +pub enum PackageOperation<'a> { + /// First-time install. The whole package directory is created from + /// `package.dist`/`package.source`. + Install { package: &'a LockedPackage }, + /// Replace an existing install with a new version. `from_version` is the + /// pretty version that was installed before (no reference suffix — + /// drives the upgrade-vs-downgrade direction). `from_full_pretty` / + /// `to_full_pretty` are the formatted display strings used verbatim in + /// the trace output; the caller renders them via + /// [`format_update_pretty_versions`] so the SOURCE_REF / DIST_REF mode + /// switch from Composer's `UpdateOperation::format` lands on both sides. + Update { + from_version: &'a str, + from_full_pretty: &'a str, + to_full_pretty: &'a str, + package: &'a LockedPackage, + }, + /// Mark an alias of a real package as installed. No filesystem effects — + /// only the trace recorder needs this. Mirrors Composer's + /// `MarkAliasInstalledOperation`. + MarkAliasInstalled { + /// The alias entry from `composer.lock`'s `aliases[]` block. Carries + /// pretty + normalized alias version and the target's pretty version. + alias: &'a LockAlias, + /// The target package the alias points at — used to source the + /// reference suffix for the trace line. + target: &'a LockedPackage, + }, + /// Mark a previously-installed alias as uninstalled. No filesystem + /// effects — only the trace recorder cares. Mirrors Composer's + /// `MarkAliasUninstalledOperation`. Composer derives the AliasPackage + /// from the previous installed.json entries (via `extra.branch-alias`), + /// then emits this when the alias is no longer in the result. Caller + /// pre-renders the display strings so this variant doesn't need to know + /// how to spelunk the entry. + MarkAliasUninstalled { + /// Package name (e.g. `a/a`) used as both the alias's name and the + /// target's name on the trace line. + name: &'a str, + /// Alias's full-pretty form (alias pretty version plus reference + /// suffix), e.g. `1.0.x-dev master`. + alias_full: &'a str, + /// Target's full-pretty form, e.g. `dev-master master`. + target_full: &'a str, + }, +} + +impl<'a> PackageOperation<'a> { + pub fn package(&self) -> Option<&'a LockedPackage> { + match self { + PackageOperation::Install { package } | PackageOperation::Update { package, .. } => { + Some(package) + } + PackageOperation::MarkAliasInstalled { .. } + | PackageOperation::MarkAliasUninstalled { .. } => None, + } + } +} + +/// Mirror Composer's `BasePackage::getFullPrettyVersion()` for a `LockedPackage`. +/// +/// For dev-stability versions backed by a git/hg source, append the reference +/// (truncated to 7 chars when it looks like a 40-char sha1). Otherwise return +/// the pretty version unchanged. +pub fn format_full_pretty_version(pkg: &LockedPackage) -> String { + format_full_pretty_with_pretty(&pkg.version, pkg) +} + +/// Same as [`format_full_pretty_version`] but lets the caller supply an +/// alternate pretty version (used by `MarkAliasInstalled` so the alias's +/// `3.2.x-dev` text is rendered with the *target's* reference). +pub fn format_full_pretty_with_pretty(pretty_version: &str, pkg: &LockedPackage) -> String { + let source_ref = pkg.source.as_ref().and_then(|s| s.reference.as_deref()); + let dist_ref = pkg.dist.as_ref().and_then(|d| d.reference.as_deref()); + let source_type = pkg.source.as_ref().map(|s| s.source_type.as_str()); + format_full_pretty_with_refs( + pretty_version, + &pkg.version, + source_ref, + dist_ref, + source_type, + ) +} + +/// Render an alias's full pretty version: the alias's own pretty form for +/// the visible text, the alias's *normalized* version for the dev-stability +/// gate, and the target package's source/dist references for the suffix. +/// Mirrors `AliasPackage::getFullPrettyVersion`, where the alias decides on +/// its own whether to append a reference based on its own stability — so a +/// stable alias like `1.0.0` skips the suffix even when the target is a dev +/// branch. +pub fn format_full_pretty_alias( + alias_pretty: &str, + alias_version: &str, + target: &LockedPackage, +) -> String { + let source_ref = target.source.as_ref().and_then(|s| s.reference.as_deref()); + let dist_ref = target.dist.as_ref().and_then(|d| d.reference.as_deref()); + let source_type = target.source.as_ref().map(|s| s.source_type.as_str()); + format_full_pretty_with_refs( + alias_pretty, + alias_version, + source_ref, + dist_ref, + source_type, + ) +} + +/// Same as [`format_full_pretty_version_for_installed`] but lets the caller +/// supply an alternate pretty version. Used when emitting +/// `MarkAliasUninstalled`: the alias's `1.0.x-dev` text needs to be rendered +/// with the *target installed entry's* reference suffix. +pub fn format_full_pretty_with_pretty_for_installed( + pretty_version: &str, + entry: &InstalledPackageEntry, +) -> String { + let source_ref = entry + .source + .as_ref() + .and_then(|v| v.get("reference")) + .and_then(|v| v.as_str()); + let dist_ref = entry + .dist + .as_ref() + .and_then(|v| v.get("reference")) + .and_then(|v| v.as_str()); + let source_type = entry + .source + .as_ref() + .and_then(|v| v.get("type")) + .and_then(|v| v.as_str()); + format_full_pretty_with_refs( + pretty_version, + &entry.version, + source_ref, + dist_ref, + source_type, + ) +} + +/// Mirror Composer's `BasePackage::getFullPrettyVersion()` for an +/// `InstalledPackageEntry`. Same display rules as +/// [`format_full_pretty_version`] but pulls source/dist info out of the +/// installed.json `source`/`dist` JSON values. +pub fn format_full_pretty_version_for_installed(entry: &InstalledPackageEntry) -> String { + format_full_pretty_with_pretty_for_installed(&entry.version, entry) +} + +/// Render the from/to display strings for an update trace line, mirroring +/// Composer's `UpdateOperation::format`. Defaults to `DISPLAY_SOURCE_REF_IF_DEV`, +/// then if both sides render identically: +/// +/// - source references differ → re-render in `DISPLAY_SOURCE_REF` mode, +/// - else dist references differ → re-render in `DISPLAY_DIST_REF` mode. +/// +/// Without the switch, two same-version-different-reference packages would +/// produce a useless `pkg (X => X)` trace line. +pub fn format_update_pretty_versions( + from_entry: &InstalledPackageEntry, + to_pkg: &LockedPackage, +) -> (String, String) { + let from_default = format_full_pretty_version_for_installed(from_entry); + let to_default = format_full_pretty_version(to_pkg); + if from_default != to_default { + return (from_default, to_default); + } + + let from_source_ref = from_entry + .source + .as_ref() + .and_then(|v| v.get("reference")) + .and_then(|v| v.as_str()); + let from_source_type = from_entry + .source + .as_ref() + .and_then(|v| v.get("type")) + .and_then(|v| v.as_str()); + let to_source_ref = to_pkg.source.as_ref().and_then(|s| s.reference.as_deref()); + let to_source_type = to_pkg.source.as_ref().map(|s| s.source_type.as_str()); + + if from_source_ref != to_source_ref { + return ( + format_with_explicit_reference(&from_entry.version, from_source_ref, from_source_type), + format_with_explicit_reference(&to_pkg.version, to_source_ref, to_source_type), + ); + } + + let from_dist_ref = from_entry + .dist + .as_ref() + .and_then(|v| v.get("reference")) + .and_then(|v| v.as_str()); + let to_dist_ref = to_pkg.dist.as_ref().and_then(|d| d.reference.as_deref()); + + if from_dist_ref != to_dist_ref { + return ( + format_with_explicit_reference(&from_entry.version, from_dist_ref, from_source_type), + format_with_explicit_reference(&to_pkg.version, to_dist_ref, to_source_type), + ); + } + + (from_default, to_default) +} + +/// Render `pretty_version` with an explicitly chosen reference, mirroring +/// Composer's `BasePackage::getFullPrettyVersion` with `DISPLAY_SOURCE_REF` +/// or `DISPLAY_DIST_REF`: skip the dev-stability gate, just truncate sha1 +/// references and concatenate. A `None` reference falls back to the bare +/// pretty version. +fn format_with_explicit_reference( + pretty_version: &str, + reference: Option<&str>, + source_type: Option<&str>, +) -> String { + let Some(reference) = reference else { + return pretty_version.to_string(); + }; + if matches!(source_type, Some("svn")) { + return format!("{} {}", pretty_version, reference); + } + if reference.len() == 40 { + return format!("{} {}", pretty_version, &reference[..7]); + } + format!("{} {}", pretty_version, reference) +} + +/// Core of `BasePackage::getFullPrettyVersion()` factored over raw +/// fields so both [`LockedPackage`] and [`InstalledPackageEntry`] can share +/// the rendering logic. `version` drives the dev-stability check; the result +/// is `pretty_version` plus a reference suffix when the package is a dev +/// branch backed by git/hg (with sha1 references truncated to 7 chars). +fn format_full_pretty_with_refs( + pretty_version: &str, + version: &str, + source_ref: Option<&str>, + dist_ref: Option<&str>, + source_type: Option<&str>, +) -> String { + let is_dev = mozart_semver::Version::parse(version) + .map(|v| matches!(v.pre_release.as_deref(), Some("dev")) || v.is_dev_branch) + .unwrap_or(false); + if !is_dev { + return pretty_version.to_string(); + } + // Composer falls back to dist reference only when no source type is set + // (or the package isn't git/hg — in which case the dev display is skipped + // entirely above). + let reference = source_ref.or(match source_type { + Some("git") | Some("hg") => None, + _ => dist_ref, + }); + let Some(reference) = reference else { + return pretty_version.to_string(); + }; + if matches!(source_type, Some("git") | Some("hg")) && reference.len() == 40 { + format!("{} {}", pretty_version, &reference[..7]) + } else if matches!(source_type, Some("svn")) { + // svn references are revision numbers, never truncated + format!("{} {}", pretty_version, reference) + } else if reference.len() == 40 { + // dist-ref fallback (no git/hg source) — Composer truncates here too + format!("{} {}", pretty_version, &reference[..7]) + } else { + format!("{} {}", pretty_version, reference) + } +} + +/// Per-call configuration shared across executor methods. Owned by the +/// caller (typically `install_from_lock`) so the executor sees a consistent +/// view across an entire install/update run. +#[derive(Debug, Clone)] +pub struct ExecuteContext { + pub vendor_dir: PathBuf, + /// Suppress download progress bars. + pub no_progress: bool, + /// Prefer cloning from VCS source over downloading dist archives. + pub prefer_source: bool, +} + +/// Side-effect surface for install/update/uninstall operations. +/// +/// Implementations are stateful — `&mut self` lets a recorder accumulate +/// trace lines and lets the filesystem implementation hold long-lived +/// handles (caches, progress bars). All methods return `anyhow::Result` so +/// callers can short-circuit on the first failure, mirroring Composer's +/// fail-fast `InstallationManager::execute`. +#[async_trait::async_trait] +pub trait InstallerExecutor: Send + Sync { + /// Perform side effects for one install or update operation. + async fn install_package( + &mut self, + op: PackageOperation<'_>, + ctx: &ExecuteContext, + ) -> anyhow::Result<()>; + + /// Perform side effects for one uninstall. + /// + /// `version` is the previously-installed version (from installed.json), + /// passed so the trace recorder can format Composer's + /// `Uninstalling pkg/name (version)` line. The filesystem implementation + /// ignores it — `name` alone is enough to locate the vendor directory. + fn uninstall_package( + &mut self, + name: &str, + version: &str, + ctx: &ExecuteContext, + ) -> anyhow::Result<()>; + + /// Hook called once after every uninstall has run. Default no-op. + /// Composer cleans up empty namespace directories here; the recorder + /// has no work to do. + fn cleanup_after_uninstalls(&mut self, _ctx: &ExecuteContext) -> anyhow::Result<()> { + Ok(()) + } +} diff --git a/crates/mozart-core/src/repository/installer_executor/trace_recorder.rs b/crates/mozart-core/src/repository/installer_executor/trace_recorder.rs new file mode 100644 index 0000000..b60a869 --- /dev/null +++ b/crates/mozart-core/src/repository/installer_executor/trace_recorder.rs @@ -0,0 +1,160 @@ +//! Recording-only [`InstallerExecutor`] for in-process tests. +//! +//! Mirrors `Composer\Test\Mock\InstallationManagerMock` — every call appends +//! a string to a `Vec` matching Composer's +//! `(string) $operation` output (after `strip_tags`). No filesystem or +//! network I/O happens. The recorded trace is what tests assert against +//! `--EXPECT--` in Composer's `.test` fixture format. +//! +//! Trace line shapes (byte-equivalent to Composer's `*Operation::__toString` +//! after `strip_tags`): +//! +//! - Install: `Installing ()` +//! - Update (upgrade direction): `Upgrading ( => )` +//! - Update (downgrade direction): `Downgrading ( => )` +//! - Uninstall: `Removing ()` + +use mozart_semver::Version; + +use super::{ + ExecuteContext, InstallerExecutor, PackageOperation, format_full_pretty_alias, + format_full_pretty_version, +}; + +/// Recording-only executor. Construct with [`TraceRecorderExecutor::new`], +/// then read [`TraceRecorderExecutor::trace`] after the run completes. +pub struct TraceRecorderExecutor { + trace: Vec, +} + +impl TraceRecorderExecutor { + pub fn new() -> Self { + Self { trace: Vec::new() } + } + + /// Recorded operation strings, in the order [`InstallerExecutor`] was + /// invoked. Pass this to `assert_eq!` against the fixture's `--EXPECT--` + /// section after splitting on newlines. + pub fn trace(&self) -> &[String] { + &self.trace + } + + /// Take ownership of the recorded trace. Use after the run if the + /// executor is going out of scope. + pub fn into_trace(self) -> Vec { + self.trace + } +} + +impl Default for TraceRecorderExecutor { + fn default() -> Self { + Self::new() + } +} + +#[async_trait::async_trait] +impl InstallerExecutor for TraceRecorderExecutor { + async fn install_package( + &mut self, + op: PackageOperation<'_>, + _ctx: &ExecuteContext, + ) -> anyhow::Result<()> { + match op { + PackageOperation::Install { package } => { + self.trace.push(format!( + "Installing {} ({})", + package.name, + format_full_pretty_version(package) + )); + } + PackageOperation::Update { + from_version, + from_full_pretty, + to_full_pretty, + package, + } => { + let action = if is_upgrade(from_version, &package.version) { + "Upgrading" + } else { + "Downgrading" + }; + self.trace.push(format!( + "{} {} ({} => {})", + action, package.name, from_full_pretty, to_full_pretty + )); + } + PackageOperation::MarkAliasInstalled { alias, target } => { + let alias_full = + format_full_pretty_alias(&alias.alias, &alias.alias_normalized, target); + let target_full = format_full_pretty_version(target); + self.trace.push(format!( + "Marking {} ({}) as installed, alias of {} ({})", + alias.package, alias_full, alias.package, target_full + )); + } + PackageOperation::MarkAliasUninstalled { + name, + alias_full, + target_full, + } => { + self.trace.push(format!( + "Marking {} ({}) as uninstalled, alias of {} ({})", + name, alias_full, name, target_full + )); + } + } + Ok(()) + } + + fn uninstall_package( + &mut self, + name: &str, + version: &str, + _ctx: &ExecuteContext, + ) -> anyhow::Result<()> { + self.trace.push(format!("Removing {} ({})", name, version)); + Ok(()) + } +} + +/// Mirrors `Composer\Package\Version\VersionParser::isUpgrade`. Returns true +/// when `to` should be treated as an upgrade from `from` for the purpose of +/// the trace verb (`Upgrading` vs `Downgrading`). +/// +/// The rules: +/// 1. Same string → upgrade. +/// 2. `dev-master` / `dev-trunk` / `dev-default` substitute to the +/// `9999999-dev` default-branch alias before further checks (they are +/// not literal dev-* names; they are the conventional "latest" branch). +/// 3. After that substitution, if either side starts with `dev-` (i.e. is +/// a dev branch other than the defaults) → upgrade. Composer treats +/// hopping between dev branches as a forward move regardless of order. +/// 4. Otherwise sort numerically and check the original `from` ended up +/// first (= the smaller value). +fn is_upgrade(from: &str, to: &str) -> bool { + if from == to { + return true; + } + let original_from = from; + let normalize_default = |s: &str| -> String { + if matches!(s, "dev-master" | "dev-trunk" | "dev-default") { + "9999999-dev".to_string() + } else { + s.to_string() + } + }; + let from_norm = normalize_default(from); + let to_norm = normalize_default(to); + if from_norm.starts_with("dev-") || to_norm.starts_with("dev-") { + return true; + } + match (Version::parse(&from_norm), Version::parse(&to_norm)) { + (Ok(a), Ok(b)) => b >= a, + _ => { + // Mirror Composer's fall-through: with two unparseable strings + // there is nothing to compare, treat the move as an upgrade. + let _ = original_from; + true + } + } +} diff --git a/crates/mozart-core/src/repository/installer_executor/transaction.rs b/crates/mozart-core/src/repository/installer_executor/transaction.rs new file mode 100644 index 0000000..128b3db --- /dev/null +++ b/crates/mozart-core/src/repository/installer_executor/transaction.rs @@ -0,0 +1,412 @@ +//! Transaction computation — lock-vs-installed diff and alias reconciliation. +//! +//! Mirrors `Composer\DependencyResolver\Transaction::calculateOperations` and +//! `Composer\Installer\InstalledFilesystemRepository` (the `ArrayDumper` +//! path). Kept separate so both `install` and `update` commands can share the +//! same operation-computation machinery without going through the `install` +//! command module. + +use super::super::installed::{InstalledPackageEntry, InstalledPackages}; +use super::super::lockfile::{LockFile, LockedPackage}; +use indexmap::IndexSet; +use std::path::Path; + +/// The action to take for a package during install. +#[derive(Debug, PartialEq, Eq)] +pub enum Action { + Install, + Update, + Skip, +} + +/// Compute install operations by comparing locked packages against installed packages. +/// +/// Returns `(ops, removals)` where: +/// - `ops`: list of `(package, action)` ordered topologically — every package's +/// lock-internal `require` deps appear before it, matching Composer's +/// `Transaction::calculateOperations`. +/// - `removals`: list of package names that are installed but not locked. +pub fn compute_operations<'a>( + locked: &[&'a LockedPackage], + installed: &InstalledPackages, +) -> (Vec<(&'a LockedPackage, Action)>, Vec) { + let ordered = topological_sort(locked); + + let mut ops: Vec<(&'a LockedPackage, Action)> = Vec::new(); + for pkg in ordered { + let installed_entry = installed + .packages + .iter() + .find(|p| p.name.eq_ignore_ascii_case(&pkg.name)); + let action = match installed_entry { + None => Action::Install, + Some(entry) if entry.version != pkg.version => Action::Update, + Some(entry) if !installed_refs_match_locked(entry, pkg) => Action::Update, + Some(entry) if !installed_abandoned_matches_locked(entry, pkg) => Action::Update, + Some(_) => Action::Skip, + }; + ops.push((pkg, action)); + } + + // Compute removals: packages in installed but not in locked. Iterate + // installed.json in reverse, mirroring Composer's + // `Transaction::calculateOperations`, which seeds `removeMap` from + // `presentPackages` in order and then `array_unshift`s each entry onto + // `operations` — flipping the iteration order. + let locked_names: IndexSet = locked.iter().map(|p| p.name.to_lowercase()).collect(); + let removals: Vec = installed + .packages + .iter() + .rev() + .filter(|p| !locked_names.contains(&p.name.to_lowercase())) + .map(|p| p.name.clone()) + .collect(); + + (ops, removals) +} + +/// Order a slice of locked packages so every package's `require` deps that +/// are present in the same slice come before it. Mirrors +/// `Composer\DependencyResolver\Transaction::calculateOperations` — the +/// stack-based DFS over the result map. +fn topological_sort<'a>(packages: &[&'a LockedPackage]) -> Vec<&'a LockedPackage> { + use std::collections::BTreeMap; + + // Reverse-alphabetical sort, mirroring `setResultPackageMaps`. + let mut sorted: Vec<&'a LockedPackage> = packages.to_vec(); + sorted.sort_by_key(|p| std::cmp::Reverse(p.name.to_lowercase())); + + // Multimap: name → [packages]. A package contributes itself under its + // own name *and* under every `provide`/`replace` entry. + let mut resolves: BTreeMap> = BTreeMap::new(); + for pkg in &sorted { + let names = std::iter::once(pkg.name.to_lowercase()) + .chain(pkg.provide.keys().map(|s| s.to_lowercase())) + .chain(pkg.replace.keys().map(|s| s.to_lowercase())); + for n in names { + resolves.entry(n).or_default().push(*pkg); + } + } + + // Mirror Composer's `getRootPackages`: walk in sorted order, removing + // each package's required providers from the candidate-roots set. + let mut roots_set: IndexSet = sorted.iter().map(|p| p.name.to_lowercase()).collect(); + for pkg in &sorted { + let pkg_lower = pkg.name.to_lowercase(); + if !roots_set.contains(&pkg_lower) { + continue; + } + for dep in pkg.require.keys() { + let dep_lower = dep.to_lowercase(); + if let Some(matches) = resolves.get(&dep_lower) { + for &m in matches { + let m_lower = m.name.to_lowercase(); + if m_lower != pkg_lower { + roots_set.shift_remove(&m_lower); + } + } + } + } + } + + let mut stack: Vec<&'a LockedPackage> = sorted + .iter() + .filter(|p| roots_set.contains(&p.name.to_lowercase())) + .copied() + .collect(); + + let mut visited: IndexSet = IndexSet::new(); + let mut processed: IndexSet = IndexSet::new(); + let mut ordered: Vec<&'a LockedPackage> = Vec::with_capacity(packages.len()); + + while let Some(pkg) = stack.pop() { + let lower = pkg.name.to_lowercase(); + if processed.contains(&lower) { + continue; + } + if !visited.contains(&lower) { + visited.insert(lower); + stack.push(pkg); + for dep in pkg.require.keys() { + let dep_lower = dep.to_lowercase(); + if let Some(matches) = resolves.get(&dep_lower) { + for &m in matches { + stack.push(m); + } + } + } + } else { + processed.insert(lower); + ordered.push(pkg); + } + } + + // Cycle / disconnected fallback: append any leftover packages. + for pkg in packages { + let lower = pkg.name.to_lowercase(); + if !processed.contains(&lower) { + processed.insert(lower); + ordered.push(*pkg); + } + } + + ordered +} + +/// Pre-rendered MarkAliasUninstalled operation. Caller pre-computes the +/// display strings so the executor call site stays simple. +pub struct StaleInstalledAlias { + pub name: String, + pub alias_full: String, + pub target_full: String, +} + +/// `(package_name_lowercase, alias_pretty)` pairs the *new* lock's packages +/// will surface — used by `compute_stale_installed_aliases` to determine which +/// currently-installed alias packages no longer have a counterpart in the new +/// lock. Mirrors `Locker::getLockedRepository` running every locked package +/// through `ArrayLoader`. +fn lock_alias_pretty_pairs(lock: &LockFile) -> std::collections::HashSet<(String, String)> { + use std::collections::HashSet; + let mut set: HashSet<(String, String)> = HashSet::new(); + for a in &lock.aliases { + set.insert((a.package.to_lowercase(), a.alias.clone())); + } + for pkg in lock + .packages + .iter() + .chain(lock.packages_dev.iter().flatten()) + { + let mut emitted_explicit = false; + if let Some(map) = pkg + .extra_fields + .get("extra") + .and_then(|e| e.get("branch-alias")) + .and_then(|b| b.as_object()) + { + for (source, target) in map { + if !source.eq_ignore_ascii_case(&pkg.version) { + continue; + } + let Some(target_str) = target.as_str() else { + continue; + }; + if !target_str.to_lowercase().ends_with("-dev") { + continue; + } + set.insert((pkg.name.to_lowercase(), target_str.to_string())); + emitted_explicit = true; + } + } + if emitted_explicit { + continue; + } + let is_default_branch = pkg + .extra_fields + .get("default-branch") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !is_default_branch { + continue; + } + let version_lower = pkg.version.to_lowercase(); + let is_dev_branch = version_lower.starts_with("dev-") || version_lower.ends_with("-dev"); + if !is_dev_branch { + continue; + } + set.insert((pkg.name.to_lowercase(), "9999999-dev".to_string())); + } + set +} + +/// Walk every `installed.json` entry, expand its `extra.branch-alias` map, and +/// emit a [`StaleInstalledAlias`] for each whose alias version doesn't appear +/// in the new lock. Mirrors `Transaction::calculateOperations` +/// `MarkAliasUninstalledOperation` logic. +pub fn compute_stale_installed_aliases( + installed: &InstalledPackages, + lock: &LockFile, +) -> Vec { + use super::{ + format_full_pretty_version_for_installed, format_full_pretty_with_pretty_for_installed, + }; + + let preserved = lock_alias_pretty_pairs(lock); + let still_present = |name: &str, alias_pretty: &str| -> bool { + preserved.contains(&(name.to_lowercase(), alias_pretty.to_string())) + }; + let mut stale = Vec::new(); + for entry in &installed.packages { + let mut emitted_explicit = false; + if let Some(branch_alias) = entry + .extra_fields + .get("extra") + .and_then(|e| e.get("branch-alias")) + .and_then(|b| b.as_object()) + { + for (target_branch, alias_value) in branch_alias { + if entry.version != *target_branch { + continue; + } + let Some(alias_pretty) = alias_value.as_str() else { + continue; + }; + emitted_explicit = true; + if still_present(&entry.name, alias_pretty) { + continue; + } + stale.push(StaleInstalledAlias { + name: entry.name.clone(), + alias_full: format_full_pretty_with_pretty_for_installed(alias_pretty, entry), + target_full: format_full_pretty_version_for_installed(entry), + }); + } + } + + // Synthetic `9999999-dev` default-branch alias. + if emitted_explicit { + continue; + } + let is_default_branch = entry + .extra_fields + .get("default-branch") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !is_default_branch { + continue; + } + let version_lower = entry.version.to_lowercase(); + let is_dev_branch = version_lower.starts_with("dev-") || version_lower.ends_with("-dev"); + if !is_dev_branch { + continue; + } + const DEFAULT_BRANCH_ALIAS: &str = "9999999-dev"; + if still_present(&entry.name, DEFAULT_BRANCH_ALIAS) { + continue; + } + stale.push(StaleInstalledAlias { + name: entry.name.clone(), + alias_full: format_full_pretty_with_pretty_for_installed(DEFAULT_BRANCH_ALIAS, entry), + target_full: format_full_pretty_version_for_installed(entry), + }); + } + stale +} + +/// Collect the alias normalized-versions a previous install recorded for +/// `pkg_name`. Mirrors Composer's `presentAliasMap` seeding. +pub fn previously_installed_alias_versions( + installed: &InstalledPackages, + pkg_name: &str, +) -> Vec { + let mut out = Vec::new(); + for entry in &installed.packages { + if !entry.name.eq_ignore_ascii_case(pkg_name) { + continue; + } + let version_lower = entry.version.to_lowercase(); + let is_dev_branch = version_lower.starts_with("dev-") || version_lower.ends_with("-dev"); + if !is_dev_branch { + continue; + } + + let mut emitted_explicit_alias = false; + if let Some(branch_alias_map) = entry + .extra_fields + .get("extra") + .and_then(|e| e.get("branch-alias")) + .and_then(|b| b.as_object()) + { + for (source, target) in branch_alias_map { + if !source.eq_ignore_ascii_case(&entry.version) { + continue; + } + let Some(target_str) = target.as_str() else { + continue; + }; + if !target_str.to_lowercase().ends_with("-dev") { + continue; + } + if let Some(normalized) = + super::super::resolver::normalize_branch_alias_target(target_str) + { + out.push(normalized); + emitted_explicit_alias = true; + } + } + } + + if !emitted_explicit_alias + && entry + .extra_fields + .get("default-branch") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + out.push("9999999.9999999.9999999.9999999-dev".to_string()); + } + } + out +} + +/// Convert a `LockedPackage` to an `InstalledPackageEntry`. +/// +/// Mirrors Composer's `InstalledFilesystemRepository::write()` via +/// `ArrayDumper` — `extra_fields` is forwarded verbatim so flags like +/// `abandoned` and `default-branch` survive the lock → installed.json round +/// trip. +pub fn locked_to_installed_entry(pkg: &LockedPackage, _vendor_dir: &Path) -> InstalledPackageEntry { + let install_path = format!("../{}", pkg.name); + InstalledPackageEntry { + name: pkg.name.clone(), + version: pkg.version.clone(), + version_normalized: pkg.version_normalized.clone(), + source: pkg + .source + .as_ref() + .map(|s| serde_json::to_value(s).unwrap_or_default()), + dist: pkg + .dist + .as_ref() + .map(|d| serde_json::to_value(d).unwrap_or_default()), + package_type: pkg.package_type.clone(), + install_path: Some(install_path), + autoload: pkg.autoload.clone(), + aliases: vec![], + homepage: pkg.homepage.clone(), + support: pkg.support.clone(), + extra_fields: pkg.extra_fields.clone(), + } +} + +fn installed_refs_match_locked(entry: &InstalledPackageEntry, locked: &LockedPackage) -> bool { + let installed_source_ref = entry + .source + .as_ref() + .and_then(|v| v.get("reference")) + .and_then(|v| v.as_str()); + let installed_dist_ref = entry + .dist + .as_ref() + .and_then(|v| v.get("reference")) + .and_then(|v| v.as_str()); + let locked_source_ref = locked.source.as_ref().and_then(|s| s.reference.as_deref()); + let locked_dist_ref = locked.dist.as_ref().and_then(|d| d.reference.as_deref()); + installed_source_ref == locked_source_ref && installed_dist_ref == locked_dist_ref +} + +fn abandoned_state(v: Option<&serde_json::Value>) -> (bool, Option<&str>) { + match v { + Some(serde_json::Value::Bool(b)) => (*b, None), + Some(serde_json::Value::String(s)) => (true, Some(s.as_str())), + _ => (false, None), + } +} + +fn installed_abandoned_matches_locked( + entry: &InstalledPackageEntry, + locked: &LockedPackage, +) -> bool { + abandoned_state(entry.extra_fields.get("abandoned")) + == abandoned_state(locked.extra_fields.get("abandoned")) +} diff --git a/crates/mozart-core/src/repository/lockfile.rs b/crates/mozart-core/src/repository/lockfile.rs new file mode 100644 index 0000000..4c41bbb --- /dev/null +++ b/crates/mozart-core/src/repository/lockfile.rs @@ -0,0 +1,2040 @@ +use super::packagist::{PackagistDist, PackagistSource, PackagistVersion}; +use super::repository::RepositorySet; +use super::resolver::ResolvedPackage; +use crate::installer::HasSuggests; +use crate::package::{RawPackageData, to_json_pretty}; +use indexmap::IndexMap; +use indexmap::IndexSet; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, 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", default = "LockFile::default_readme")] + pub readme: Vec, + + /// Composer lock files written before content-hash existed (or fixtures + /// covering BC behavior) may omit this field; mirror Composer's BC support + /// in `Locker::isLocked()` by defaulting to empty. + #[serde(rename = "content-hash", default)] + pub content_hash: String, + + pub packages: Vec, + + #[serde(rename = "packages-dev")] + pub packages_dev: Option>, + + #[serde(default)] + pub aliases: Vec, + + #[serde(rename = "minimum-stability", default = "default_stability")] + pub minimum_stability: String, + + #[serde(rename = "stability-flags", default = "default_empty_object")] + pub stability_flags: serde_json::Value, + + #[serde(rename = "prefer-stable", default)] + pub prefer_stable: bool, + + #[serde(rename = "prefer-lowest", default)] + pub prefer_lowest: bool, + + #[serde(default = "default_empty_object")] + pub platform: serde_json::Value, + + #[serde(rename = "platform-dev", default = "default_empty_object")] + pub platform_dev: serde_json::Value, + + #[serde(rename = "plugin-api-version", skip_serializing_if = "Option::is_none")] + pub plugin_api_version: Option, +} + +/// A locked package entry in composer.lock. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LockedPackage { + pub name: String, + pub version: String, + + #[serde(rename = "version_normalized", skip_serializing_if = "Option::is_none")] + pub version_normalized: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub dist: Option, + + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub require: BTreeMap, + + #[serde( + rename = "require-dev", + default, + skip_serializing_if = "BTreeMap::is_empty" + )] + pub require_dev: BTreeMap, + + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub conflict: BTreeMap, + + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub provide: BTreeMap, + + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub replace: BTreeMap, + + #[serde(skip_serializing_if = "Option::is_none")] + pub suggest: Option>, + + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub package_type: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub autoload: Option, + + #[serde(rename = "autoload-dev", skip_serializing_if = "Option::is_none")] + pub autoload_dev: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub license: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub homepage: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub keywords: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub authors: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub support: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub funding: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub time: Option, + + /// Catch-all for extra fields we don't explicitly model + #[serde(flatten)] + pub extra_fields: BTreeMap, +} + +impl HasSuggests for LockedPackage { + fn pretty_name(&self) -> &str { + &self.name + } + + fn suggests(&self) -> Vec<(String, String)> { + self.suggest + .as_ref() + .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) + .unwrap_or_default() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LockedSource { + #[serde(rename = "type")] + pub source_type: String, + pub url: String, + pub reference: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LockedDist { + #[serde(rename = "type")] + pub dist_type: String, + pub url: String, + pub reference: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub shasum: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LockAlias { + pub package: String, + pub version: String, + pub alias: String, + pub alias_normalized: String, +} + +impl LockFile { + /// Create default readme entries. + pub fn default_readme() -> Vec { + vec![ + "This file locks the dependencies of your project to a known state".to_string(), + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies".to_string(), + "This file is @generated automatically".to_string(), + ] + } + + /// Read a composer.lock file from disk. + pub fn read_from_file(path: &Path) -> anyhow::Result { + let content = fs::read_to_string(path)?; + let lock: LockFile = serde_json::from_str(&content)?; + Ok(lock) + } + + /// Write a composer.lock file to disk with deterministic formatting. + pub fn write_to_file(&self, path: &Path) -> anyhow::Result<()> { + let json = to_json_pretty(self)?; + fs::write(path, json)?; + Ok(()) + } + + /// Check if the lock file is fresh (content-hash matches composer.json). + pub fn is_fresh(&self, composer_json_content: &str) -> bool { + match Self::compute_content_hash(composer_json_content) { + Ok(hash) => hash == self.content_hash, + Err(_) => false, + } + } + + /// Compute the content hash from composer.json content. + /// Matches Composer's `Locker::getContentHash()`. + pub fn compute_content_hash(composer_json_content: &str) -> anyhow::Result { + let value: serde_json::Value = serde_json::from_str(composer_json_content)?; + let obj = value + .as_object() + .ok_or_else(|| anyhow::anyhow!("composer.json must be a JSON object"))?; + + // Keys that affect the content hash (Composer's relevantKeys) + let relevant_keys = [ + "name", + "version", + "require", + "require-dev", + "conflict", + "replace", + "provide", + "minimum-stability", + "prefer-stable", + "repositories", + "extra", + ]; + + // Collect relevant keys into a BTreeMap (auto-sorted by key) + let mut filtered: BTreeMap<&str, &serde_json::Value> = BTreeMap::new(); + for key in &relevant_keys { + if let Some(v) = obj.get(*key) { + filtered.insert(key, v); + } + } + + // Also include config.platform if present + if let Some(config) = obj.get("config") + && let Some(platform) = config.get("platform") + { + filtered.insert("config.platform", platform); + } + + // Encode to compact JSON + let compact = serde_json::to_string(&filtered)?; + + // Compute MD5 + let digest = md5::compute(compact.as_bytes()); + Ok(format!("{:x}", digest)) + } + + /// Check that every root `require` (and `require-dev` when `include_dev`) + /// is satisfied by the locked packages. Returns the list of bullet-prefixed + /// error lines (plus the trailing merge-conflict hint) if anything is + /// missing or mismatched, otherwise an empty vec. + /// + /// Mirrors `Composer\Package\Locker::getMissingRequirementInfo()`. + pub fn get_missing_requirement_info( + &self, + root: &crate::package::RawPackageData, + include_dev: bool, + ) -> Vec { + let mut messages = Vec::new(); + let mut any_missing = false; + + let base_pool: Vec = self + .packages + .iter() + .map(|p| LockedSearchEntry::build(p, &self.aliases)) + .collect(); + let mut dev_pool: Vec = base_pool.clone(); + if let Some(dev) = &self.packages_dev { + dev_pool.extend( + dev.iter() + .map(|p| LockedSearchEntry::build(p, &self.aliases)), + ); + } + + check_requirement_set( + &root.require, + "Required", + &base_pool, + &mut messages, + &mut any_missing, + ); + if include_dev { + check_requirement_set( + &root.require_dev, + "Required (in require-dev)", + &dev_pool, + &mut messages, + &mut any_missing, + ); + } + + if any_missing { + messages.push( + "This usually happens when composer files are incorrectly merged or the composer.json file is manually edited.".to_string(), + ); + messages.push( + "Read more about correctly resolving merge conflicts https://getcomposer.org/doc/articles/resolving-merge-conflicts.md".to_string(), + ); + messages.push( + "and prefer using the \"require\" command over editing the composer.json file directly https://getcomposer.org/doc/03-cli.md#require-r".to_string(), + ); + } + + messages + } +} + +/// A locked package paired with the additional version strings the locked +/// repository would surface for it (branch-alias targets + matching root +/// aliases from `lock.aliases`). +/// +/// Mirrors the AliasPackage entries that `Composer\Package\Locker::getLockedRepository` +/// adds alongside each locked package, so requirement checks see the same +/// version surface Composer does. +#[derive(Clone)] +struct LockedSearchEntry<'a> { + package: &'a LockedPackage, + alias_versions: Vec, +} + +impl<'a> LockedSearchEntry<'a> { + fn build(package: &'a LockedPackage, root_aliases: &[LockAlias]) -> Self { + let mut alias_versions: Vec = locked_package_branch_aliases(package) + .into_iter() + .map(|a| a.alias_normalized) + .collect(); + for alias in root_aliases { + if alias.package.eq_ignore_ascii_case(&package.name) + && alias.version.eq_ignore_ascii_case(&package.version) + { + alias_versions.push(alias.alias_normalized.clone()); + } + } + Self { + package, + alias_versions, + } + } +} + +/// Build the synthetic `LockAlias` entries a `dev-*` locked package contributes +/// via `extra.branch-alias`. Mirrors `Composer\Package\Loader\ArrayLoader::getBranchAlias` +/// followed by `VersionParser::normalizeBranch` — the same expansion +/// `Locker::getLockedRepository` performs when constructing AliasPackages +/// alongside each locked package. +pub fn locked_package_branch_aliases(pkg: &LockedPackage) -> Vec { + let pkg_version_lower = pkg.version.to_lowercase(); + let is_dev_branch = + pkg_version_lower.starts_with("dev-") || pkg_version_lower.ends_with("-dev"); + if !is_dev_branch { + return Vec::new(); + } + let Some(extra) = pkg.extra_fields.get("extra") else { + return Vec::new(); + }; + let Some(branch_alias) = extra.get("branch-alias") else { + return Vec::new(); + }; + let Some(map) = branch_alias.as_object() else { + return Vec::new(); + }; + let mut out = Vec::new(); + for (source, target) in map.iter() { + if !source.eq_ignore_ascii_case(&pkg.version) { + continue; + } + let Some(target_str) = target.as_str() else { + continue; + }; + if !target_str.to_lowercase().ends_with("-dev") { + continue; + } + let Some(normalized) = super::resolver::normalize_branch_alias_target(target_str) else { + continue; + }; + // Pretty-form trim: Composer's `Preg::replace('{(\.9{7})+}', '.x', ...)` + // turns the normalized form back into the wildcard form (e.g. + // `2.1.9999999.9999999-dev` → `2.1.x-dev`). For trace output we want + // the raw alias target string the package author wrote. + out.push(LockAlias { + package: pkg.name.clone(), + version: pkg.version.clone(), + alias: target_str.to_string(), + alias_normalized: normalized, + }); + } + out +} + +fn check_requirement_set( + requires: &BTreeMap, + description: &str, + pool: &[LockedSearchEntry], + messages: &mut Vec, + any_missing: &mut bool, +) { + for (name, constraint_str) in requires { + if crate::platform::is_platform_package(name) { + continue; + } + if constraint_str.trim() == "self.version" { + continue; + } + + let constraint = mozart_semver::VersionConstraint::parse(constraint_str).ok(); + + let mut name_only_match: Option<&LockedPackage> = None; + let mut satisfied = false; + for entry in pool { + let pkg = entry.package; + if pkg.name != *name { + continue; + } + if name_only_match.is_none() { + name_only_match = Some(pkg); + } + let Some(ref c) = constraint else { continue }; + if let Ok(version) = mozart_semver::Version::parse(&pkg.version) + && c.matches(&version) + { + satisfied = true; + break; + } + if entry.alias_versions.iter().any(|alias| { + mozart_semver::Version::parse(alias) + .ok() + .is_some_and(|v| c.matches(&v)) + }) { + satisfied = true; + break; + } + } + + if satisfied { + continue; + } + + *any_missing = true; + if let Some(pkg) = name_only_match { + messages.push(format!( + "- {description} package \"{name}\" is in the lock file as \"{}\" but that does not satisfy your constraint \"{constraint_str}\".", + pkg.version + )); + } else { + messages.push(format!( + "- {description} package \"{name}\" is not present in the lock file." + )); + } + } +} + +/// Input for lock file generation. +pub struct LockFileGenerationRequest { + /// Resolved packages from the dependency resolver. + pub resolved_packages: Vec, + /// Raw composer.json content string (for content-hash computation). + pub composer_json_content: String, + /// Parsed composer.json data (for platform, minimum-stability, etc.). + pub composer_json: RawPackageData, + /// Whether require-dev was included in resolution. + pub include_dev: bool, + /// Repository set used to fetch full metadata for resolved packages + /// that aren't already covered by inline `type: package` repositories. + pub repositories: std::sync::Arc, + /// Previous `composer.lock` (when running update / require / remove). + /// For each resolved package whose name+normalized-version matches an + /// entry in this lock, the entry is copied into the new lock verbatim + /// rather than being re-fetched from the inline / composer-repo / + /// Packagist sources. Mirrors Composer's `Locker::setLockData` behaviour + /// during partial updates: lock entries are stable across updates that + /// don't touch the package, even if the upstream metadata has drifted. + pub previous_lock: Option, + /// Lowercase package names that were held back to their locked version + /// on a partial update — i.e. they were NOT in the CLI's allow list and + /// were re-pinned by `apply_partial_update`. For these names the lock + /// entry's metadata (source/dist references in particular) is canonical: + /// inline / composer-repo metadata may have drifted to a newer commit + /// that the partial update is explicitly choosing not to take. Mirrors + /// Composer's `PoolBuilder`, which keeps non-allow-listed packages at + /// the locked-repo entry rather than re-loading them from the inline / + /// VCS sources. + pub lock_pinned_names: indexmap::IndexSet, +} + +impl LockFileGenerationRequest { + /// Look up an inline `type: package` definition for `name` (if any). + /// Returns the matching `PackagistVersion` so callers can short-circuit + /// the Packagist fetch for resolved packages that came from a `type: + /// package` repository. + fn inline_lookup(&self, name: &str, version_normalized: &str) -> Option { + super::inline_package::collect_inline_packages(&self.composer_json.repositories) + .into_iter() + .find(|ipkg| ipkg.name == name && ipkg.version.version_normalized == version_normalized) + .map(|ipkg| ipkg.version) + } + + /// Look up a `type: composer` repository entry for `name@version_normalized`. + /// Used to short-circuit the Packagist fetch when the resolved package came + /// from a local Composer repo (the test fixtures' file:// case). + fn composer_repo_lookup( + &self, + name: &str, + version_normalized: &str, + ) -> Option { + super::composer_repo::collect_composer_packages(&self.composer_json.repositories) + .into_iter() + .find(|cpkg| cpkg.name == name && cpkg.version.version_normalized == version_normalized) + .map(|cpkg| cpkg.version) + } + + /// Reuse `previous_lock` as a metadata source when no repository can + /// answer for `(name, version_normalized)`. Mirrors the slice of + /// Composer's `PoolBuilder` flow that re-loads locked-only packages + /// straight off the lock: a partial update keeping a package at its + /// locked version doesn't need to re-fetch its metadata, and the + /// repositories may no longer carry that version (e.g. an inline + /// `type: package` repo only listing the new release). + fn previous_lock_lookup( + &self, + name: &str, + version_normalized: &str, + ) -> Option { + let prev = self.previous_lock.as_ref()?; + prev.packages + .iter() + .chain(prev.packages_dev.iter().flatten()) + .find(|p| { + p.name.eq_ignore_ascii_case(name) + && p.version_normalized + .as_deref() + .map(|v| v == version_normalized) + .unwrap_or_else(|| { + mozart_semver::Version::parse(&p.version) + .map(|v| v.to_string() == version_normalized) + .unwrap_or(false) + }) + }) + .map(locked_package_to_packagist_version) + } +} + +/// Synthesize a `PackagistVersion` from a `LockedPackage`. Used by +/// `previous_lock_lookup` so the metadata loop has a complete view even +/// when the surrounding repositories have moved on from a locked version. +fn locked_package_to_packagist_version(pkg: &LockedPackage) -> PackagistVersion { + PackagistVersion { + version: pkg.version.clone(), + version_normalized: pkg + .version_normalized + .clone() + .unwrap_or_else(|| pkg.version.clone()), + require: pkg.require.clone(), + replace: pkg.replace.clone(), + provide: pkg.provide.clone(), + conflict: pkg.conflict.clone(), + dist: pkg.dist.as_ref().map(|d| PackagistDist { + dist_type: d.dist_type.clone(), + url: d.url.clone(), + reference: d.reference.clone(), + shasum: d.shasum.clone(), + }), + source: pkg.source.as_ref().map(|s| PackagistSource { + source_type: s.source_type.clone(), + url: s.url.clone(), + reference: s.reference.clone(), + }), + require_dev: pkg.require_dev.clone(), + suggest: pkg.suggest.clone(), + package_type: pkg.package_type.clone(), + autoload: pkg.autoload.clone(), + autoload_dev: pkg.autoload_dev.clone(), + license: pkg.license.clone(), + description: pkg.description.clone(), + homepage: pkg.homepage.clone(), + keywords: pkg.keywords.clone(), + authors: pkg.authors.clone(), + support: None, + funding: None, + time: pkg.time.clone(), + extra: pkg.extra_fields.get("extra").cloned(), + notification_url: pkg + .extra_fields + .get("notification-url") + .and_then(|v| v.as_str()) + .map(String::from), + default_branch: pkg + .extra_fields + .get("default-branch") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + abandoned: pkg.extra_fields.get("abandoned").cloned(), + } +} + +/// 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(), + } +} + +/// Mirror Composer's `RootPackageLoader::extractReferences`: scan +/// `require`/`require-dev` for `dev-foo#hex` style constraints, returning a +/// lowercase package name → reference map. Constraints whose stability isn't +/// `dev` after stripping the reference are left out (matching the +/// `'dev' === VersionParser::parseStability(...)` guard in PHP). +fn extract_root_references( + require: &BTreeMap, + require_dev: &BTreeMap, +) -> BTreeMap { + let mut out = BTreeMap::new(); + for (name, raw_constraint) in require.iter().chain(require_dev.iter()) { + if let Some(reference) = parse_inline_reference(raw_constraint) { + out.insert(name.to_lowercase(), reference); + } + } + out +} + +/// Pull the `#hex` suffix out of a single-atom dev constraint. Returns +/// `None` for non-`dev-*` / non-`*-dev` constraints, matching Composer's +/// `'{^[^,\s@]+?#([a-f0-9]+)$}'` + `parseStability == 'dev'` guard. +fn parse_inline_reference(constraint: &str) -> Option { + // Strip `... as alias` first, mirroring extractReferences's + // `'{^([^,\s@]+) as .+$}'` replacement. + let core = match constraint.split(" as ").next() { + Some(c) => c.trim(), + None => constraint.trim(), + }; + let (head, hash) = core.rsplit_once('#')?; + if hash.is_empty() || !hash.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + if head.contains([' ', '\t', ',', '@']) { + return None; + } + let lower = head.to_lowercase(); + if !(lower.starts_with("dev-") || lower.ends_with("-dev")) { + return None; + } + Some(hash.to_string()) +} + +/// Mirror `Composer\Package\Package::setSourceDistReferences`: rewrite both +/// source and dist references to the supplied value, and rewrite the +/// reference inside any auto-generated GitHub/GitLab/Bitbucket dist URL when +/// present. The dist reference is only written if there was already one +/// (Composer leaves `dist.reference == null` packages alone). +fn apply_reference_override(pkg: &mut LockedPackage, reference: &str) { + if let Some(source) = pkg.source.as_mut() { + source.reference = Some(reference.to_string()); + } + if let Some(dist) = pkg.dist.as_mut() { + let url_carries_known_host = matches_dist_url_with_known_host(Some(&dist.url)); + if dist.reference.is_some() || url_carries_known_host { + dist.reference = Some(reference.to_string()); + } + if url_carries_known_host { + dist.url = rewrite_known_dist_url_reference(&dist.url, reference); + } + } +} + +/// Match the bitbucket / github / gitlab dist-URL prefixes Composer +/// rewrites. Mirrors the regex +/// `{^https?://(?:(?:www\.)?bitbucket\.org|(api\.)?github\.com|(?:www\.)?gitlab\.com)/}i`. +fn matches_dist_url_with_known_host(url: Option<&str>) -> bool { + let Some(url) = url else { return false }; + let lower = url.to_lowercase(); + let stripped = lower + .strip_prefix("http://") + .or_else(|| lower.strip_prefix("https://")) + .unwrap_or(&lower); + let stripped = stripped.strip_prefix("www.").unwrap_or(stripped); + let stripped = stripped.strip_prefix("api.").unwrap_or(stripped); + stripped.starts_with("bitbucket.org/") + || stripped.starts_with("github.com/") + || stripped.starts_with("gitlab.com/") +} + +/// Substitute any 40-char hex segment surrounded by `/` or `sha=` (the +/// archive shape produced by GitHub/GitLab/Bitbucket) with the new +/// reference. Matches Composer's +/// `'{(?<=/|sha=)[a-f0-9]{40}(?=/|$)}i'` rewrite. +fn rewrite_known_dist_url_reference(url: &str, reference: &str) -> String { + let bytes = url.as_bytes(); + let mut out = String::with_capacity(url.len()); + let mut i = 0; + while i < bytes.len() { + let start = i; + let preceded_by_slash = i > 0 && bytes[i - 1] == b'/'; + let preceded_by_sha = i >= 4 && &bytes[i - 4..i] == b"sha="; + if (preceded_by_slash || preceded_by_sha) && i + 40 <= bytes.len() { + let candidate = &url[i..i + 40]; + if candidate.chars().all(|c| c.is_ascii_hexdigit()) { + let after = bytes.get(i + 40).copied(); + if after == Some(b'/') || after.is_none() { + out.push_str(reference); + i += 40; + continue; + } + } + } + out.push(url[start..].chars().next().unwrap()); + i += url[start..].chars().next().unwrap().len_utf8(); + } + out +} + +/// Convert a `PackagistVersion` to a `LockedPackage`. +fn packagist_version_to_locked_package(name: &str, pv: &PackagistVersion) -> LockedPackage { + let mut extra_fields: BTreeMap = BTreeMap::new(); + + if let Some(extra) = &pv.extra { + extra_fields.insert("extra".to_string(), extra.clone()); + } + if let Some(notification_url) = &pv.notification_url { + extra_fields.insert( + "notification-url".to_string(), + serde_json::Value::String(notification_url.clone()), + ); + } + // Propagate `abandoned` so the lock (and downstream installed.json + // round-trip) preserves the package's deprecation state. Mirrors + // Composer's `ArrayDumper::dump`, which emits the field when truthy + // (`true` for "abandoned, no replacement", a string for "abandoned, + // use this instead"). `false`/null collapse to "not abandoned" and + // are dropped. + if let Some(abandoned) = &pv.abandoned { + let keep = match abandoned { + serde_json::Value::Bool(b) => *b, + serde_json::Value::String(s) => !s.is_empty(), + serde_json::Value::Null => false, + _ => true, + }; + if keep { + extra_fields.insert("abandoned".to_string(), abandoned.clone()); + } + } + // Propagate `default-branch: true` so the lock surface — and the + // installed.json round-trip — keeps the marker that drives Composer's + // synthetic `9999999-dev` alias for default-branch dev packages. + // Without this, `Locker::getLockedRepository` (which Mozart mirrors via + // `collect_stale_installed_aliases` / `lock_alias_pretty_pairs`) can't + // tell that the package's default branch is still aliased and emits a + // spurious `MarkAliasUninstalled` for the missing `9999999-dev` alias. + if pv.default_branch { + extra_fields.insert("default-branch".to_string(), serde_json::Value::Bool(true)); + } + + 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(), + provide: pv.provide.clone(), + replace: pv.replace.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). +/// +/// `requires_by_name` and `providers_by_name` are keyed by lowercase package +/// names. `providers_by_name` maps a satisfied name (own name + each `provide` +/// or `replace` target) to the list of resolved package names that satisfy it, +/// so a non-dev `require` like `provided/pkg` reaches `b/b` when `b/b` +/// declares `provide: { provided/pkg: 1.0.0 }`. +fn classify_dev_packages( + resolved: &[ResolvedPackage], + require: &BTreeMap, + _require_dev: &BTreeMap, + requires_by_name: &IndexMap>, + providers_by_name: &IndexMap>, +) -> IndexSet { + // BFS from non-dev root dependencies through each package's `require` map. + // All reachable packages are production packages. + let mut production: IndexSet = IndexSet::new(); + let mut queue: VecDeque = VecDeque::new(); + + let visit = |name: &str, production: &mut IndexSet, queue: &mut VecDeque| { + let name_lower = name.to_lowercase(); + if is_platform_name(&name_lower) { + return; + } + // A required name is satisfied either by a resolved package whose own + // name matches (the common case, captured here as `providers_by_name` + // also indexes own names) or by a resolved package that provides / + // replaces it. Mirrors Composer's `extractDevPackages` second-solve + // semantics, which walks the same provide/replace edges through a + // real Solver call. + if let Some(provs) = providers_by_name.get(&name_lower) { + for prov in provs { + let prov_lower = prov.to_lowercase(); + if production.insert(prov_lower.clone()) { + queue.push_back(prov_lower); + } + } + } + }; + + for name in require.keys() { + visit(name, &mut production, &mut queue); + } + + while let Some(pkg_name) = queue.pop_front() { + if let Some(deps) = requires_by_name.get(&pkg_name) { + for dep_name in deps.clone() { + visit(&dep_name, &mut production, &mut queue); + } + } + } + + // Any resolved package not in `production` is dev-only + resolved + .iter() + .filter(|p| !production.contains(&p.name.to_lowercase())) + .map(|p| p.name.clone()) + .collect() +} + +/// Returns true if the package name is a platform package (php, ext-*, lib-*, etc.). +fn is_platform_name(name: &str) -> bool { + name == "php" + || name.starts_with("ext-") + || name.starts_with("lib-") + || name == "php-64bit" + || name == "php-ipv6" + || name == "php-zts" + || name == "php-debug" +} + +/// Extract platform requirements from a requirements map. +/// +/// Filters the map to include only platform package keys (`php`, `ext-*`, `lib-*`, etc.) +/// and returns them as a JSON object. +fn extract_platform_requirements(requirements: &BTreeMap) -> serde_json::Value { + let map: serde_json::Map = requirements + .iter() + .filter(|(k, _)| is_platform_name(k)) + .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone()))) + .collect(); + serde_json::Value::Object(map) +} + +/// Generate a complete `LockFile` from resolution results. +/// +/// This function: +/// 1. Fetches full metadata from Packagist for each resolved package +/// 2. Separates packages into production vs dev-only +/// 3. Computes the content-hash +/// 4. Assembles the complete `LockFile` struct +pub async fn generate_lock_file(request: &LockFileGenerationRequest) -> anyhow::Result { + // Split the resolved set into real packages and alias entries up front. + // Aliases get emitted as a separate `aliases[]` block and never enter the + // metadata fetch loop — their target package carries the real metadata. + let (real_resolved, alias_resolved): (Vec<&ResolvedPackage>, Vec<&ResolvedPackage>) = request + .resolved_packages + .iter() + .partition(|p| p.alias_of_normalized.is_none()); + + // 1. Fetch full metadata for real (non-alias) packages. + // + // Inline `type: package` repositories carry full metadata in composer.json + // — short-circuit those before hitting the network. Everything else goes + // through `RepositorySet`, which today contains only Packagist; future + // steps will move VCS / inline through the same set. + // Previous-lock relationship pass-through: when a resolved package + // matches an entry in `previous_lock` at the same name + + // version_normalized, capture the entry's relationship-shaped fields + // (require / require-dev / conflict / replace / provide / suggest). + // Composer's transaction calculates operation order using these + // relationship fields off the locked repository, so a partial update + // shouldn't refresh them from upstream metadata for packages that + // didn't move — otherwise topological_sort sees a different graph + // than Composer would. + // + // Source/dist references and version-shaped fields still come from + // the freshly-fetched metadata, so dev packages whose ref bumped (the + // resolver picked a new commit at the same version label) still get + // their ref refreshed. + struct PreservedRelationships { + require: BTreeMap, + require_dev: BTreeMap, + conflict: BTreeMap, + provide: BTreeMap, + replace: BTreeMap, + suggest: Option>, + } + let mut preserved_rel: IndexMap = IndexMap::new(); + if let Some(prev) = &request.previous_lock { + for prev_pkg in prev + .packages + .iter() + .chain(prev.packages_dev.iter().flatten()) + { + let prev_normalized = prev_pkg.version_normalized.clone().unwrap_or_else(|| { + mozart_semver::Version::parse(&prev_pkg.version) + .map(|v| v.to_string()) + .unwrap_or_else(|_| prev_pkg.version.clone()) + }); + for pkg in &real_resolved { + if pkg.name.eq_ignore_ascii_case(&prev_pkg.name) + && pkg.version_normalized == prev_normalized + { + preserved_rel.insert( + pkg.name.clone(), + PreservedRelationships { + require: prev_pkg.require.clone(), + require_dev: prev_pkg.require_dev.clone(), + conflict: prev_pkg.conflict.clone(), + provide: prev_pkg.provide.clone(), + replace: prev_pkg.replace.clone(), + suggest: prev_pkg.suggest.clone(), + }, + ); + } + } + } + } + + let mut package_metadata: IndexMap = IndexMap::new(); + let repo_set = &request.repositories; + for pkg in &real_resolved { + // For packages held back to the locked version on a partial update, + // the lock entry is the canonical metadata source. Inline / composer- + // repo / VCS sources may have moved to a newer commit that this + // partial update is explicitly choosing NOT to take, so consulting + // them first would silently bump the source/dist reference. Mirrors + // Composer's `PoolBuilder` behaviour: non-allow-listed packages keep + // the locked-repo entry rather than re-loading from upstream. + let pinned = request.lock_pinned_names.contains(&pkg.name.to_lowercase()); + if pinned + && let Some(prev) = request.previous_lock_lookup(&pkg.name, &pkg.version_normalized) + { + package_metadata.insert(pkg.name.clone(), prev); + continue; + } + + if let Some(inline) = request.inline_lookup(&pkg.name, &pkg.version_normalized) { + package_metadata.insert(pkg.name.clone(), inline); + continue; + } + + if let Some(cv) = request.composer_repo_lookup(&pkg.name, &pkg.version_normalized) { + package_metadata.insert(pkg.name.clone(), cv); + continue; + } + + if let Some(prev) = request.previous_lock_lookup(&pkg.name, &pkg.version_normalized) { + package_metadata.insert(pkg.name.clone(), prev); + continue; + } + + let queries = [super::repository::PackageQuery { + name: pkg.name.as_str(), + constraint: None, + }]; + let results = repo_set.load_packages(&queries).await?; + let matching = results + .into_iter() + .find(|r| r.version.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.version); + } + + // 2. Classify dev vs non-dev packages (real packages only). + let real_owned: Vec = real_resolved + .iter() + .map(|p| ResolvedPackage { + name: p.name.clone(), + version: p.version.clone(), + version_normalized: p.version_normalized.clone(), + is_dev: p.is_dev, + alias_of_normalized: None, + }) + .collect(); + // Build the `name → require keys` view classify_dev_packages walks. Use + // preserved-from-old-lock requires when available so a partial update + // sees the same dev-classification graph the previous lock did. + let mut requires_by_name: IndexMap> = IndexMap::new(); + // Inverse map: `satisfied name → list of resolved packages that satisfy it`. + // A resolved package satisfies its own name plus each `provide` / `replace` + // target (Composer's `extractDevPackages` reaches the same edges through + // its second Solver run; we walk them directly during the dev BFS). + let mut providers_by_name: IndexMap> = IndexMap::new(); + for (name, pv) in &package_metadata { + let name_lower = name.to_lowercase(); + let (require_keys, provide_keys, replace_keys): (Vec, Vec, Vec) = + if let Some(rel) = preserved_rel.get(name) { + ( + rel.require.keys().cloned().collect(), + rel.provide.keys().cloned().collect(), + rel.replace.keys().cloned().collect(), + ) + } else { + ( + pv.require.keys().cloned().collect(), + pv.provide.keys().cloned().collect(), + pv.replace.keys().cloned().collect(), + ) + }; + requires_by_name.insert(name_lower.clone(), require_keys); + providers_by_name + .entry(name_lower.clone()) + .or_default() + .push(name_lower.clone()); + for target in provide_keys.iter().chain(replace_keys.iter()) { + providers_by_name + .entry(target.to_lowercase()) + .or_default() + .push(name_lower.clone()); + } + } + let dev_only = classify_dev_packages( + &real_owned, + &request.composer_json.require, + &request.composer_json.require_dev, + &requires_by_name, + &providers_by_name, + ); + + // 3. Build LockedPackage lists. + // + // Apply root-level `#hex` reference overrides extracted from + // `require`/`require-dev`. Mirrors Composer's + // `RootPackageLoader::extractReferences` + `PoolBuilder::loadPackage`'s + // `setSourceDistReferences` call: when the user pinned a dev package via + // `dev-main#abcd123`, the resolved package's source/dist must show that + // reference in the lock + trace, not whatever the inline metadata said. + let root_references = extract_root_references( + &request.composer_json.require, + &request.composer_json.require_dev, + ); + let mut packages: Vec = Vec::new(); + let mut packages_dev: Vec = Vec::new(); + for pkg in &real_resolved { + let pv = &package_metadata[&pkg.name]; + let mut locked = packagist_version_to_locked_package(&pkg.name, pv); + // Overlay relationship fields from the previous lock when applicable + // — the resolver's transaction-time view came from the lock, so the + // new lock should mirror those relationships even if the upstream + // metadata has drifted. + if let Some(rel) = preserved_rel.get(&pkg.name) { + locked.require = rel.require.clone(); + locked.require_dev = rel.require_dev.clone(); + locked.conflict = rel.conflict.clone(); + locked.provide = rel.provide.clone(); + locked.replace = rel.replace.clone(); + locked.suggest = rel.suggest.clone(); + } + if let Some(reference) = root_references.get(&pkg.name.to_lowercase()) { + apply_reference_override(&mut locked, reference); + } + 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. Build the aliases[] block. Each alias entry references the target + // package (`package` + `version`) and carries the alias's pretty/normalized + // form (`alias` + `alias_normalized`). Mirrors Composer's + // `Locker::lockPackages` alias dump. + let mut alias_blocks: Vec = Vec::new(); + for alias in &alias_resolved { + let target_normalized = match &alias.alias_of_normalized { + Some(t) => t.clone(), + None => continue, + }; + let target_pretty = real_resolved + .iter() + .find(|p| p.name == alias.name && p.version_normalized == target_normalized) + .map(|p| p.version.clone()) + .unwrap_or_else(|| target_normalized.clone()); + alias_blocks.push(LockAlias { + package: alias.name.clone(), + version: target_pretty, + alias: alias.version.clone(), + alias_normalized: alias.version_normalized.clone(), + }); + } + alias_blocks.sort_by(|a, b| a.package.cmp(&b.package).then(a.alias.cmp(&b.alias))); + + // 6. Compute content-hash + let content_hash = LockFile::compute_content_hash(&request.composer_json_content)?; + + // 7. Extract platform requirements + let platform = extract_platform_requirements(&request.composer_json.require); + let platform_dev = extract_platform_requirements(&request.composer_json.require_dev); + + // 8. 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); + + // 9. Assemble LockFile + Ok(LockFile { + readme: LockFile::default_readme(), + content_hash, + packages, + packages_dev: if request.include_dev { + Some(packages_dev) + } else { + Some(vec![]) + }, + aliases: alias_blocks, + 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(), + provide: BTreeMap::new(), + replace: 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")); + } + + #[test] + fn parses_lock_without_content_hash() { + // Composer fixtures (and historical lock files) may omit content-hash; + // mirror Composer's BC handling by accepting it and treating the lock + // as not-fresh against any composer.json. + let raw = r#"{ + "packages": [], + "packages-dev": [], + "aliases": [], + "minimum-stability": "dev", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false + }"#; + let lock: LockFile = serde_json::from_str(raw).unwrap(); + assert_eq!(lock.content_hash, ""); + assert!(!lock.is_fresh(r#"{"require": {}}"#)); + } + + fn make_packagist_version( + version: &str, + version_normalized: &str, + require: BTreeMap, + ) -> PackagistVersion { + PackagistVersion { + version: version.to_string(), + version_normalized: version_normalized.to_string(), + require, + replace: BTreeMap::new(), + provide: BTreeMap::new(), + conflict: BTreeMap::new(), + dist: Some(super::super::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(super::super::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()), + default_branch: false, + abandoned: None, + } + } + + #[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_deref(), + 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, + default_branch: false, + abandoned: 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, + alias_of_normalized: None, + }, + ResolvedPackage { + name: "vendor/b".to_string(), + version: "1.0.0".to_string(), + version_normalized: "1.0.0.0".to_string(), + is_dev: false, + alias_of_normalized: None, + }, + ResolvedPackage { + name: "vendor/c".to_string(), + version: "1.0.0".to_string(), + version_normalized: "1.0.0.0".to_string(), + is_dev: false, + alias_of_normalized: None, + }, + ResolvedPackage { + name: "vendor/d".to_string(), + version: "1.0.0".to_string(), + version_normalized: "1.0.0.0".to_string(), + is_dev: false, + alias_of_normalized: None, + }, + ]; + + 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: IndexMap = IndexMap::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 requires_by_name: IndexMap> = metadata + .iter() + .map(|(name, pv)| (name.to_lowercase(), pv.require.keys().cloned().collect())) + .collect(); + let providers_by_name: IndexMap> = metadata + .keys() + .map(|name| { + let lower = name.to_lowercase(); + (lower.clone(), vec![lower]) + }) + .collect(); + let dev_only = classify_dev_packages( + &resolved, + &require, + &require_dev, + &requires_by_name, + &providers_by_name, + ); + + 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, + alias_of_normalized: None, + }, + ResolvedPackage { + name: "vendor/b".to_string(), + version: "1.0.0".to_string(), + version_normalized: "1.0.0.0".to_string(), + is_dev: false, + alias_of_normalized: None, + }, + ResolvedPackage { + name: "vendor/c".to_string(), + version: "1.0.0".to_string(), + version_normalized: "1.0.0.0".to_string(), + is_dev: false, + alias_of_normalized: None, + }, + ]; + + 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: IndexMap = IndexMap::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 requires_by_name: IndexMap> = metadata + .iter() + .map(|(name, pv)| (name.to_lowercase(), pv.require.keys().cloned().collect())) + .collect(); + let providers_by_name: IndexMap> = metadata + .keys() + .map(|name| { + let lower = name.to_lowercase(); + (lower.clone(), vec![lower]) + }) + .collect(); + let dev_only = classify_dev_packages( + &resolved, + &require, + &require_dev, + &requires_by_name, + &providers_by_name, + ); + + 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!({})); + } + + #[tokio::test] + async 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, + repositories: std::sync::Arc::new(RepositorySet::with_packagist( + super::super::cache::Cache::new( + std::env::temp_dir().join("mozart-test-cache"), + false, + ), + )), + previous_lock: None, + lock_pinned_names: IndexSet::new(), + }; + + let lock = generate_lock_file(&request).await.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 = [ + 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(), + provide: BTreeMap::new(), + replace: 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(), + provide: BTreeMap::new(), + replace: 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"); + } + + #[tokio::test] + #[ignore] + async fn test_generate_lock_file_monolog() { + use super::super::super::package::Stability; + use super::super::cache::Cache; + use super::super::resolver::PlatformConfig; + use super::super::resolver::{ResolveRequest, resolve}; + use std::sync::Arc; + + // Resolve monolog/monolog ^3.0 + let resolve_request = ResolveRequest { + root_name: String::new(), + root_version: None, + require: vec![("monolog/monolog".to_string(), "^3.0".to_string())], + require_dev: vec![], + include_dev: false, + minimum_stability: Stability::Stable, + stability_flags: IndexMap::new(), + prefer_stable: true, + prefer_lowest: false, + platform: PlatformConfig::new(), + ignore_platform_reqs: false, + ignore_platform_req_list: vec![], + repositories: Arc::new(RepositorySet::with_packagist(Cache::new( + std::env::temp_dir().join("mozart-test-cache"), + false, + ))), + temporary_constraints: IndexMap::new(), + raw_repositories: vec![], + root_provide: IndexMap::new(), + root_replace: IndexMap::new(), + root_conflict: IndexMap::new(), + locked_package_names: IndexSet::new(), + locked_packages: Vec::new(), + block_abandoned: false, + root_branch_alias: None, + preferred_versions: IndexMap::new(), + block_insecure: false, + }; + + let resolved = resolve(&resolve_request) + .await + .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, + repositories: Arc::new(RepositorySet::with_packagist(Cache::new( + std::env::temp_dir().join("mozart-test-cache"), + false, + ))), + previous_lock: None, + lock_pinned_names: IndexSet::new(), + }; + + let lock = generate_lock_file(&gen_request) + .await + .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); + } + } + + fn make_locked(name: &str, version: &str) -> LockedPackage { + LockedPackage { + name: name.to_string(), + version: version.to_string(), + version_normalized: None, + source: None, + dist: None, + require: BTreeMap::new(), + require_dev: BTreeMap::new(), + conflict: BTreeMap::new(), + provide: BTreeMap::new(), + replace: BTreeMap::new(), + suggest: None, + package_type: Some("library".to_string()), + 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(), + } + } + + fn lock_with(packages: Vec, dev: Vec) -> LockFile { + LockFile { + readme: LockFile::default_readme(), + content_hash: "x".to_string(), + packages, + packages_dev: Some(dev), + 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()), + } + } + + fn root_with_require( + require: &[(&str, &str)], + require_dev: &[(&str, &str)], + ) -> crate::package::RawPackageData { + let mut root = crate::package::RawPackageData::new("__root__".to_string()); + for (k, v) in require { + root.require.insert((*k).to_string(), (*v).to_string()); + } + for (k, v) in require_dev { + root.require_dev.insert((*k).to_string(), (*v).to_string()); + } + root + } + + #[test] + fn missing_requirement_info_empty_when_satisfied() { + let lock = lock_with(vec![make_locked("a/a", "1.0.0")], vec![]); + let root = root_with_require(&[("a/a", "^1.0")], &[]); + assert!(lock.get_missing_requirement_info(&root, true).is_empty()); + } + + #[test] + fn missing_requirement_info_reports_missing_package() { + let lock = lock_with(vec![], vec![]); + let root = root_with_require(&[("a/a", "^1.0")], &[]); + let info = lock.get_missing_requirement_info(&root, true); + assert_eq!( + info[0], + "- Required package \"a/a\" is not present in the lock file." + ); + assert!(info.iter().any(|m| m.contains("merge conflicts"))); + } + + #[test] + fn missing_requirement_info_reports_unsatisfied_constraint() { + let lock = lock_with(vec![make_locked("some/dep", "dev-foo")], vec![]); + let root = root_with_require(&[("some/dep", "dev-main")], &[]); + let info = lock.get_missing_requirement_info(&root, true); + assert_eq!( + info[0], + "- Required package \"some/dep\" is in the lock file as \"dev-foo\" but that does not satisfy your constraint \"dev-main\"." + ); + } + + #[test] + fn missing_requirement_info_skips_platform_packages() { + let lock = lock_with(vec![], vec![]); + let root = root_with_require(&[("php", "^8.0"), ("ext-json", "*")], &[]); + assert!(lock.get_missing_requirement_info(&root, true).is_empty()); + } + + #[test] + fn missing_requirement_info_skips_self_version() { + let lock = lock_with(vec![], vec![]); + let root = root_with_require(&[("a/a", "self.version")], &[]); + assert!(lock.get_missing_requirement_info(&root, true).is_empty()); + } + + #[test] + fn missing_requirement_info_dev_pool_includes_packages_dev() { + // require-dev "a/a" should be satisfied by an entry in packages-dev. + let lock = lock_with(vec![], vec![make_locked("a/a", "1.0.0")]); + let root = root_with_require(&[], &[("a/a", "^1.0")]); + assert!(lock.get_missing_requirement_info(&root, true).is_empty()); + } + + #[test] + fn missing_requirement_info_skips_dev_when_include_dev_false() { + // require-dev errors must NOT appear when include_dev is false (no_dev). + let lock = lock_with(vec![], vec![]); + let root = root_with_require(&[], &[("a/a", "^1.0")]); + assert!(lock.get_missing_requirement_info(&root, false).is_empty()); + } + + #[test] + fn missing_requirement_info_require_pool_excludes_packages_dev() { + // A regular require should NOT be satisfied by an entry that lives only + // in packages-dev. + let lock = lock_with(vec![], vec![make_locked("a/a", "1.0.0")]); + let root = root_with_require(&[("a/a", "^1.0")], &[]); + let info = lock.get_missing_requirement_info(&root, true); + assert_eq!( + info[0], + "- Required package \"a/a\" is not present in the lock file." + ); + } + + #[test] + fn missing_requirement_info_reports_multiple_problems() { + let lock = lock_with(vec![make_locked("some/dep", "dev-foo")], vec![]); + let root = root_with_require(&[("some/dep", "dev-main"), ("some/dep2", "dev-main")], &[]); + let info = lock.get_missing_requirement_info(&root, true); + assert!( + info.iter() + .any(|m| m.contains("some/dep") && m.contains("dev-foo") && m.contains("dev-main")) + ); + assert!( + info.iter() + .any(|m| m == "- Required package \"some/dep2\" is not present in the lock file.") + ); + } + + #[test] + fn missing_requirement_info_uses_dev_description_label() { + let lock = lock_with(vec![], vec![]); + let root = root_with_require(&[], &[("a/a", "^1.0")]); + let info = lock.get_missing_requirement_info(&root, true); + assert!(info[0].contains("Required (in require-dev) package \"a/a\"")); + } +} diff --git a/crates/mozart-core/src/repository/packagist.rs b/crates/mozart-core/src/repository/packagist.rs new file mode 100644 index 0000000..199ff51 --- /dev/null +++ b/crates/mozart-core/src/repository/packagist.rs @@ -0,0 +1,1011 @@ +use super::cache::Cache; +use serde::de::Deserializer; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// Deserialize a field that may contain the Packagist minifier sentinel `"__unset"`. +/// +/// Packagist's metadata minifier (see `composer/metadata-minifier`) encodes +/// deleted fields as the literal string `"__unset"` in version diffs. When we +/// encounter this sentinel we treat the field as absent (`None` / default). +fn deserialize_unset_as_none<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: serde::de::DeserializeOwned, +{ + let value = serde_json::Value::deserialize(deserializer)?; + if value.as_str() == Some("__unset") { + return Ok(None); + } + serde_json::from_value(value).map_err(serde::de::Error::custom) +} + +/// Like [`deserialize_unset_as_none`] but returns a default `T` instead of `Option`. +fn deserialize_unset_as_default<'de, D, T>(deserializer: D) -> Result +where + D: Deserializer<'de>, + T: serde::de::DeserializeOwned + Default, +{ + let value = serde_json::Value::deserialize(deserializer)?; + if value.as_str() == Some("__unset") { + return Ok(T::default()); + } + serde_json::from_value(value).map_err(serde::de::Error::custom) +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PackagistDist { + #[serde(rename = "type")] + pub dist_type: String, + pub url: String, + pub reference: Option, + pub shasum: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PackagistSource { + #[serde(rename = "type")] + pub source_type: String, + pub url: String, + pub reference: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PackagistVersion { + pub version: String, + pub version_normalized: String, + #[serde(default, deserialize_with = "deserialize_unset_as_default")] + pub require: BTreeMap, + #[serde(default, deserialize_with = "deserialize_unset_as_default")] + pub replace: BTreeMap, + #[serde(default, deserialize_with = "deserialize_unset_as_default")] + pub provide: BTreeMap, + #[serde(default, deserialize_with = "deserialize_unset_as_default")] + pub conflict: BTreeMap, + #[serde(default, deserialize_with = "deserialize_unset_as_none")] + pub dist: Option, + #[serde(default, deserialize_with = "deserialize_unset_as_none")] + pub source: Option, + + #[serde( + rename = "require-dev", + default, + deserialize_with = "deserialize_unset_as_default" + )] + pub require_dev: BTreeMap, + + #[serde(default, deserialize_with = "deserialize_unset_as_none")] + pub suggest: Option>, + + #[serde( + rename = "type", + default, + deserialize_with = "deserialize_unset_as_none" + )] + pub package_type: Option, + + #[serde(default, deserialize_with = "deserialize_unset_as_none")] + pub autoload: Option, + + #[serde( + rename = "autoload-dev", + default, + deserialize_with = "deserialize_unset_as_none" + )] + pub autoload_dev: Option, + + #[serde(default, deserialize_with = "deserialize_unset_as_none")] + pub license: Option>, + + #[serde(default, deserialize_with = "deserialize_unset_as_none")] + pub description: Option, + + #[serde(default, deserialize_with = "deserialize_unset_as_none")] + pub homepage: Option, + + #[serde(default, deserialize_with = "deserialize_unset_as_none")] + pub keywords: Option>, + + #[serde(default, deserialize_with = "deserialize_unset_as_none")] + pub authors: Option>, + + #[serde(default, deserialize_with = "deserialize_unset_as_none")] + pub support: Option, + + #[serde(default, deserialize_with = "deserialize_unset_as_none")] + pub funding: Option>, + + #[serde(default, deserialize_with = "deserialize_unset_as_none")] + pub time: Option, + + #[serde(default, deserialize_with = "deserialize_unset_as_none")] + pub extra: Option, + + #[serde( + rename = "notification-url", + default, + deserialize_with = "deserialize_unset_as_none" + )] + pub notification_url: Option, + + /// `default-branch: true` marks the repository's default branch (e.g. the + /// branch returned by `git symbolic-ref HEAD`). For packages without a + /// numeric version prefix this triggers the synthetic `9999999-dev` alias + /// generation in `ArrayLoader::getBranchAlias` — see the alias loop in + /// `crate::resolver::packagist_to_pool_inputs`. + #[serde(rename = "default-branch", default)] + pub default_branch: bool, + + /// Abandonment marker. Composer accepts `abandoned: true` (no replacement + /// suggested) or `abandoned: ""`. Anything else + /// (absent, `false`, empty string) means the package is active. Mirrors + /// `Composer\Package\CompletePackage::isAbandoned`. + #[serde(default, deserialize_with = "deserialize_unset_as_none")] + pub abandoned: Option, +} + +impl PackagistVersion { + /// Extract the `extra.branch-alias` map from this version's metadata. + /// + /// Composer packages can declare branch aliases in `extra.branch-alias`: + /// ```json + /// { + /// "extra": { + /// "branch-alias": { + /// "dev-master": "2.x-dev" + /// } + /// } + /// } + /// ``` + /// + /// Returns a map from branch name (e.g. `"dev-master"`) to alias target + /// (e.g. `"2.x-dev"`). Returns an empty map when no aliases are declared. + pub fn branch_aliases(&self) -> BTreeMap { + let Some(extra) = &self.extra else { + return BTreeMap::new(); + }; + + let Some(branch_alias) = extra.get("branch-alias") else { + return BTreeMap::new(); + }; + + let Some(map) = branch_alias.as_object() else { + return BTreeMap::new(); + }; + + map.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + } +} + +/// Parse a Packagist p2 API JSON response. +/// +/// The response format is: +/// ```json +/// { +/// "packages": {"vendor/package": [...]}, +/// "minified": "composer/2.0" // optional +/// } +/// ``` +/// +/// When the `"minified"` key is present the version list is delta-encoded by +/// Composer's `MetadataMinifier`. This function transparently expands the +/// minified data before deserializing into [`PackagistVersion`] structs. +pub fn parse_p2_response(json: &str, package_name: &str) -> anyhow::Result> { + let raw: serde_json::Value = serde_json::from_str(json)?; + + // Check whether the response is minified. + let is_minified = raw + .get("minified") + .and_then(|v| v.as_str()) + .is_some_and(|s| s == "composer/2.0"); + + // Extract the version array for the requested package. + let versions_value = raw + .get("packages") + .and_then(|p| p.get(package_name)) + .ok_or_else(|| anyhow::anyhow!("Package \"{package_name}\" not found in response"))?; + + let versions_array = versions_value + .as_array() + .ok_or_else(|| anyhow::anyhow!("Expected array for package \"{package_name}\""))?; + + // Expand minified diffs into full version objects if necessary. + let versions: Vec = if is_minified { + mozart_metadata_minifier::expand(versions_array) + } else { + versions_array.clone() + }; + + // Deserialize the (possibly expanded) version objects. + versions + .into_iter() + .map(|v| serde_json::from_value(v).map_err(Into::into)) + .collect() +} + +/// Fetch package version metadata from the Packagist p2 API. +/// +/// 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 (unless the +/// cache is disabled). +#[tracing::instrument(skip(repo_cache))] +pub async fn fetch_package_versions( + package_name: &str, + repo_cache: &Cache, +) -> anyhow::Result> { + // Build cache key: replace `/` with `~` per cache key convention + let cache_key = format!("provider-{}.json", package_name.replace('/', "~")); + + // Check cache first + if let Some(cached) = repo_cache.read(&cache_key) { + tracing::debug!("cache hit"); + return parse_p2_response(&cached, package_name); + } + + // Cache miss — fetch from Packagist + let url = format!("https://repo.packagist.org/p2/{package_name}.json"); + tracing::debug!(%url, "fetching package metadata"); + let client = crate::http::client_builder().build()?; + let response = client.get(&url).send().await?; + tracing::debug!(status = %response.status(), "received response"); + + if !response.status().is_success() { + anyhow::bail!( + "Failed to fetch package \"{package_name}\" from Packagist (HTTP {})", + response.status() + ); + } + + let body = response.text().await?; + + // Write to cache + let _ = repo_cache.write(&cache_key, &body); + + parse_p2_response(&body, package_name) +} + +/// A single search result from the Packagist search API. +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct SearchResult { + pub name: String, + pub description: String, + pub url: String, + pub repository: Option, + pub downloads: u64, + pub favers: u64, + /// Abandonment status: absent/false means active, a string indicates the + /// replacement package name, `true` means abandoned with no replacement. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub abandoned: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SearchResponse { + pub results: Vec, + pub total: u64, + pub next: Option, +} + +/// Maximum number of pages to fetch from the Packagist search API. +const SEARCH_MAX_PAGES: usize = 20; + +/// Percent-encode a string for use in a URL query parameter value. +fn url_encode(s: &str) -> String { + let mut encoded = String::with_capacity(s.len()); + for byte in s.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + encoded.push(byte as char); + } + b' ' => encoded.push_str("%20"), + other => { + encoded.push_str(&format!("%{other:02X}")); + } + } + } + encoded +} + +/// Search Packagist for packages matching `query`. +/// +/// Fetches up to `SEARCH_MAX_PAGES` pages of results and returns the full list. +/// An optional `package_type` filter can narrow results (e.g. `"library"`). +#[tracing::instrument(fields(type_filter = package_type))] +pub async fn search_packages( + query: &str, + package_type: Option<&str>, +) -> anyhow::Result<(Vec, u64)> { + let client = crate::http::client_builder().build()?; + + let mut all_results: Vec = Vec::new(); + let mut page = 1usize; + let mut next_url: Option = None; + let mut total: u64 = 0; + + loop { + let response: SearchResponse = if let Some(ref url) = next_url { + tracing::debug!(%url, page, "fetching next page"); + let resp = client.get(url).send().await?; + tracing::debug!(status = %resp.status(), "received response"); + if !resp.status().is_success() { + anyhow::bail!("Packagist search request failed (HTTP {})", resp.status()); + } + resp.json().await? + } 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)); + } + + tracing::debug!(%url, "fetching search results"); + let resp = client.get(&url).send().await?; + tracing::debug!(status = %resp.status(), "received response"); + if !resp.status().is_success() { + anyhow::bail!("Packagist search request failed (HTTP {})", resp.status()); + } + resp.json().await? + }; + + 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)) +} + +/// Response shape of `https://packagist.org/packages/list.json[?type=...]`. +#[derive(Debug, Deserialize)] +struct ListResponse { + #[serde(rename = "packageNames")] + package_names: Vec, +} + +/// Fetch the full list of Packagist package names, optionally filtered by type. +/// +/// Backs Composer's `ComposerRepository::getPackageNames()` for the +/// `SEARCH_NAME` and `SEARCH_VENDOR` search modes. Cached on disk under +/// `list-packages~{type}.json` (or `list-packages~all.json` when no type +/// filter is given). +#[tracing::instrument(skip(repo_cache))] +pub async fn fetch_package_names( + package_type: Option<&str>, + repo_cache: &Cache, +) -> anyhow::Result> { + let cache_key = match package_type { + Some(t) => format!("list-packages~{t}.json"), + None => "list-packages~all.json".to_string(), + }; + + if let Some(cached) = repo_cache.read(&cache_key) { + tracing::debug!("cache hit"); + let parsed: ListResponse = serde_json::from_str(&cached)?; + return Ok(parsed.package_names); + } + + let mut url = "https://packagist.org/packages/list.json".to_string(); + if let Some(t) = package_type { + url.push_str("?type="); + url.push_str(&url_encode(t)); + } + tracing::debug!(%url, "fetching package list"); + let client = crate::http::client_builder().build()?; + let response = client.get(&url).send().await?; + tracing::debug!(status = %response.status(), "received response"); + + if !response.status().is_success() { + anyhow::bail!( + "Failed to fetch package list from Packagist (HTTP {})", + response.status() + ); + } + + let body = response.text().await?; + let _ = repo_cache.write(&cache_key, &body); + + let parsed: ListResponse = serde_json::from_str(&body)?; + Ok(parsed.package_names) +} + +/// Fetch the deduplicated list of Packagist vendor names. +/// +/// Mirrors Composer's `ComposerRepository::getVendorNames()` which derives +/// vendors from `getPackageNames()` (regardless of type) by stripping the +/// `/...` suffix and de-duplicating in insertion order. +#[tracing::instrument(skip(repo_cache))] +pub async fn fetch_vendor_names(repo_cache: &Cache) -> anyhow::Result> { + let names = fetch_package_names(None, repo_cache).await?; + let mut seen: indexmap::IndexSet = indexmap::IndexSet::new(); + for name in names { + let vendor = match name.split_once('/') { + Some((v, _)) => v.to_string(), + None => name, + }; + seen.insert(vendor); + } + Ok(seen.into_iter().collect()) +} + +/// A single security advisory from the Packagist API. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SecurityAdvisory { + #[serde(rename = "advisoryId")] + pub advisory_id: String, + + #[serde(rename = "packageName")] + pub package_name: String, + + #[serde(rename = "remoteId")] + pub remote_id: String, + + pub title: String, + + pub link: Option, + + pub cve: Option, + + /// Composer version constraint string, e.g. ">=1.0,<1.5.1|>=2.0,<2.3" + #[serde(rename = "affectedVersions")] + pub affected_versions: String, + + pub source: String, + + #[serde(rename = "reportedAt")] + pub reported_at: String, + + #[serde(rename = "composerRepository")] + pub composer_repository: Option, + + pub severity: Option, + + #[serde(default)] + pub sources: Vec, +} + +/// A source entry within a security advisory. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AdvisorySource { + pub name: String, + #[serde(rename = "remoteId")] + pub remote_id: String, +} + +/// Response from POST `https://packagist.org/api/security-advisories/`. +#[derive(Debug, Deserialize)] +pub struct SecurityAdvisoriesResponse { + pub advisories: BTreeMap>, +} + +/// Fetch security advisories for the given package names from the Packagist API. +/// +/// Sends a POST request to `https://packagist.org/api/security-advisories/` +/// with form-encoded package names. Returns advisories grouped by package name. +/// +/// If the package list is very large (500+), requests are batched in chunks of +/// 500 names per request and the results are merged. +#[tracing::instrument(skip(package_names), fields(package_count = package_names.len()))] +pub async fn fetch_security_advisories( + package_names: &[&str], +) -> anyhow::Result>> { + let client = crate::http::client_builder().build()?; + + let mut all_advisories: BTreeMap> = BTreeMap::new(); + + for chunk in package_names.chunks(500) { + // Build an application/x-www-form-urlencoded body manually. + // Each package is encoded as `packages[]=` and joined with `&`. + let body: String = chunk + .iter() + .map(|name| format!("packages[]={}", url_encode(name))) + .collect::>() + .join("&"); + + tracing::debug!(chunk_size = chunk.len(), "fetching security advisories"); + let response = client + .post("https://packagist.org/api/security-advisories/") + .header("Content-Type", "application/x-www-form-urlencoded") + .body(body) + .send() + .await?; + tracing::debug!(status = %response.status(), "received response"); + + if !response.status().is_success() { + anyhow::bail!( + "Packagist security advisories request failed (HTTP {})", + response.status() + ); + } + + let parsed: SecurityAdvisoriesResponse = response.json().await?; + + 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"); + } + + #[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()); + } + + #[test] + fn parse_p2_response_unset_fields() { + // Packagist metadata minifier uses "__unset" to mark deleted fields. + let json = r#"{ + "packages": { + "test/pkg": [ + { + "version": "2.0.0", + "version_normalized": "2.0.0.0", + "require": {"php": ">=8.1"}, + "license": ["MIT"], + "keywords": ["framework"], + "authors": [{"name": "Alice"}], + "funding": [{"type": "github", "url": "https://github.com/sponsors/alice"}] + }, + { + "version": "1.0.0", + "version_normalized": "1.0.0.0", + "license": "__unset", + "keywords": "__unset", + "authors": "__unset", + "funding": "__unset", + "require": "__unset", + "homepage": "__unset", + "description": "__unset", + "extra": "__unset", + "suggest": "__unset" + } + ] + } + }"#; + + let versions = parse_p2_response(json, "test/pkg").unwrap(); + assert_eq!(versions.len(), 2); + + // First version has normal values + assert_eq!(versions[0].license.as_ref().unwrap(), &["MIT"]); + assert_eq!(versions[0].keywords.as_ref().unwrap(), &["framework"]); + + // Second version has __unset → treated as absent + assert!(versions[1].license.is_none()); + assert!(versions[1].keywords.is_none()); + assert!(versions[1].authors.is_none()); + assert!(versions[1].funding.is_none()); + assert!(versions[1].require.is_empty()); + assert!(versions[1].homepage.is_none()); + assert!(versions[1].description.is_none()); + assert!(versions[1].extra.is_none()); + assert!(versions[1].suggest.is_none()); + } + + #[test] + fn parse_p2_response_minified_expand() { + // Mirrors the Composer MetadataMinifierTest: 3 versions where only + // the first carries all fields and subsequent entries are diffs. + let json = r#"{ + "packages": { + "foo/bar": [ + { + "name": "foo/bar", + "version": "2.0.0", + "version_normalized": "2.0.0.0", + "type": "library", + "license": ["MIT"], + "require": {"php": ">=8.1"}, + "description": "A great package" + }, + { + "version": "1.2.0", + "version_normalized": "1.2.0.0", + "license": ["GPL"], + "homepage": "https://example.org" + }, + { + "version": "1.0.0", + "version_normalized": "1.0.0.0", + "homepage": "__unset" + } + ] + }, + "minified": "composer/2.0" + }"#; + + let versions = parse_p2_response(json, "foo/bar").unwrap(); + assert_eq!(versions.len(), 3); + + // Version 2.0.0 — full data (first entry). + assert_eq!(versions[0].version, "2.0.0"); + assert_eq!(versions[0].package_type.as_deref(), Some("library")); + assert_eq!(versions[0].license.as_ref().unwrap(), &["MIT"]); + assert_eq!(versions[0].require.get("php").unwrap(), ">=8.1"); + assert_eq!(versions[0].description.as_deref(), Some("A great package")); + assert!(versions[0].homepage.is_none()); + + // Version 1.2.0 — inherits name, type, require, description from 2.0.0; + // license changed to GPL; homepage added. + assert_eq!(versions[1].version, "1.2.0"); + assert_eq!(versions[1].package_type.as_deref(), Some("library")); + assert_eq!(versions[1].license.as_ref().unwrap(), &["GPL"]); + assert_eq!(versions[1].require.get("php").unwrap(), ">=8.1"); + assert_eq!(versions[1].description.as_deref(), Some("A great package")); + assert_eq!(versions[1].homepage.as_deref(), Some("https://example.org")); + + // Version 1.0.0 — inherits everything from 1.2.0 except homepage + // which is __unset (deleted). + assert_eq!(versions[2].version, "1.0.0"); + assert_eq!(versions[2].package_type.as_deref(), Some("library")); + assert_eq!(versions[2].license.as_ref().unwrap(), &["GPL"]); + assert_eq!(versions[2].require.get("php").unwrap(), ">=8.1"); + assert_eq!(versions[2].description.as_deref(), Some("A great package")); + assert!(versions[2].homepage.is_none()); + } + + #[test] + fn parse_p2_response_not_minified_no_inheritance() { + // Without "minified" key, each version stands alone — no inheritance. + let json = r#"{ + "packages": { + "foo/bar": [ + { + "version": "2.0.0", + "version_normalized": "2.0.0.0", + "license": ["MIT"], + "description": "A great package" + }, + { + "version": "1.0.0", + "version_normalized": "1.0.0.0" + } + ] + } + }"#; + + let versions = parse_p2_response(json, "foo/bar").unwrap(); + assert_eq!(versions.len(), 2); + + assert_eq!(versions[0].license.as_ref().unwrap(), &["MIT"]); + assert_eq!(versions[0].description.as_deref(), Some("A great package")); + + // Without minified flag, version 1.0.0 does NOT inherit from 2.0.0. + assert!(versions[1].license.is_none()); + assert!(versions[1].description.is_none()); + } + + #[test] + fn parse_p2_response_minified_single_version() { + // Edge case: minified response with only one version. + let json = r#"{ + "packages": { + "foo/bar": [ + { + "version": "1.0.0", + "version_normalized": "1.0.0.0", + "license": ["MIT"] + } + ] + }, + "minified": "composer/2.0" + }"#; + + let versions = parse_p2_response(json, "foo/bar").unwrap(); + assert_eq!(versions.len(), 1); + assert_eq!(versions[0].license.as_ref().unwrap(), &["MIT"]); + } + + #[test] + fn parse_p2_response_minified_empty_versions() { + let json = r#"{ + "packages": { + "foo/bar": [] + }, + "minified": "composer/2.0" + }"#; + + let versions = parse_p2_response(json, "foo/bar").unwrap(); + assert!(versions.is_empty()); + } + + #[test] + fn parse_p2_response_minified_map_fields_inherited() { + // Verify BTreeMap fields (require, replace, etc.) are inherited. + let json = r#"{ + "packages": { + "foo/bar": [ + { + "version": "2.0.0", + "version_normalized": "2.0.0.0", + "require": {"php": ">=8.1", "ext-json": "*"}, + "replace": {"foo/old": "self.version"} + }, + { + "version": "1.0.0", + "version_normalized": "1.0.0.0", + "replace": "__unset" + } + ] + }, + "minified": "composer/2.0" + }"#; + + let versions = parse_p2_response(json, "foo/bar").unwrap(); + assert_eq!(versions.len(), 2); + + // Version 1.0.0 inherits require from 2.0.0, replace is unset. + assert_eq!(versions[1].require.get("php").unwrap(), ">=8.1"); + assert_eq!(versions[1].require.get("ext-json").unwrap(), "*"); + assert!(versions[1].replace.is_empty()); + } + + #[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-core/src/repository/path_repository.rs b/crates/mozart-core/src/repository/path_repository.rs new file mode 100644 index 0000000..a96141c --- /dev/null +++ b/crates/mozart-core/src/repository/path_repository.rs @@ -0,0 +1,243 @@ +//! Support for `type: path` repositories. +//! +//! Mirrors `Composer\Repository\PathRepository`: a path repo points at a +//! local directory containing a `composer.json`, and the resolver loads the +//! package from that file directly. Mozart does not yet support glob URLs or +//! the `versions` / `reference: none` options — only the bare +//! `{ type: path, url: ... }` form the installer fixtures exercise. +//! +//! Resolution model: a path repo is expanded into a synthetic +//! `type: package` [`RawRepository`] whose payload is the loaded composer.json +//! plus a `dist` block. After this expansion the rest of the registry treats +//! the package the same as any inline `type: package` entry — that is the +//! whole point of doing the work here rather than threading a new repo type +//! through the resolver / lockfile. +//! +//! `dist.reference` matches Composer's `hash('sha1', $json . serialize($options))` +//! where `$options` carries the auto-detected `relative` flag (true when the +//! original URL was not absolute). The same SHA-1 ends up in the lockfile, so +//! consumers comparing references against Composer-produced lockfiles see +//! byte-identical values. + +use std::path::{Path, PathBuf}; + +use crate::package::RawRepository; +use mozart_php_serialize::{Value as PhpValue, serialize as php_serialize}; +use sha1::{Digest, Sha1}; + +/// Translate path repos in `repositories` into synthetic `type: package` +/// entries. Non-path entries are returned unchanged in original order. +/// +/// `base_dir` is the directory used to resolve relative `url` values +/// (Composer's PHP code resolves these against the process cwd; in production +/// that equals the project root, in tests it equals the fixtures anchor). +/// +/// Failures (missing directory, unreadable composer.json, missing +/// `name`/`version`) drop the offending entry silently — the rest of the +/// repository list still applies. This mirrors Composer's lenient +/// PathRepository, which logs a warning and moves on rather than aborting the +/// whole resolve. +pub fn expand_path_repositories( + repositories: &[RawRepository], + base_dir: &Path, +) -> Vec { + let mut out = Vec::with_capacity(repositories.len()); + for repo in repositories { + if repo.repo_type != "path" { + out.push(repo.clone()); + continue; + } + let Some(url) = repo.url.as_deref() else { + continue; + }; + let Some(synthetic) = load_path_package(url, base_dir) else { + continue; + }; + out.push(synthetic); + } + out +} + +/// Read one path repo's `composer.json` and synthesize the inline-package +/// form. Returns `None` for any I/O or parse failure (Composer behaves the +/// same — `PathRepository::initialize` skips entries whose `composer.json` +/// is missing). +fn load_path_package(url: &str, base_dir: &Path) -> Option { + let resolved = resolve_path(url, base_dir); + let composer_json_path = resolved.join("composer.json"); + let json = std::fs::read_to_string(&composer_json_path).ok()?; + let mut package: serde_json::Value = serde_json::from_str(&json).ok()?; + let obj = package.as_object_mut()?; + + // `version` is mandatory in the inline-package representation: without it + // the resolver would skip the package. Composer's PathRepository falls + // back to `dev-main` when no version is declared and no VCS is present; + // mirror that so a path repo whose composer.json omits `version` still + // produces a usable entry. + if !obj.contains_key("version") { + obj.insert( + "version".to_string(), + serde_json::Value::String("dev-main".to_string()), + ); + } + + let is_relative = !Path::new(url).is_absolute(); + let reference = compute_path_reference(json.as_bytes(), is_relative); + + obj.insert( + "dist".to_string(), + serde_json::json!({ + "type": "path", + "url": url, + "reference": reference, + }), + ); + // Composer copies `symlink`/`relative` from `options` into + // `transport-options`. We have no `options` to forward today but emit an + // empty object so consumers reading the package see the same shape. + obj.entry("transport-options") + .or_insert_with(|| serde_json::json!({})); + + Some(RawRepository { + repo_type: "package".to_string(), + url: None, + package: Some(serde_json::Value::Array(vec![package])), + only: None, + exclude: None, + canonical: None, + security_advisories: None, + }) +} + +fn resolve_path(url: &str, base_dir: &Path) -> PathBuf { + let p = Path::new(url); + if p.is_absolute() { + p.to_path_buf() + } else { + base_dir.join(p) + } +} + +/// Compose the SHA-1 reference Composer uses for path repos: +/// `sha1($json . serialize(['relative' => $isRelative]))`. The `relative` +/// flag is the only option Composer's auto-detection populates when the user +/// supplied no `options` block. +fn compute_path_reference(json_bytes: &[u8], is_relative: bool) -> String { + let options = PhpValue::Array(vec![( + PhpValue::String("relative".to_string()), + PhpValue::Bool(is_relative), + )]); + let serialized = php_serialize(&options); + let mut hasher = Sha1::new(); + hasher.update(json_bytes); + hasher.update(serialized.as_bytes()); + let bytes = hasher.finalize(); + let mut hex = String::with_capacity(bytes.len() * 2); + for b in bytes { + use std::fmt::Write; + let _ = write!(&mut hex, "{:02x}", b); + } + hex +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn computes_known_reference_for_plugin_a_fixture() { + // Fixture used by partial-update-loads-root-aliases-for-path-repos.test. + // Expected reference (`b133081...`) is what PHP's + // `hash('sha1', file_get_contents($composerJson) . serialize(['relative' => true]))` + // produces for this file — pin it here so reference computation + // changes can't drift silently from Composer. + let composer_json_path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../composer/tests/Composer/Test/Fixtures/functional/installed-versions/plugin-a/composer.json"); + let bytes = std::fs::read(&composer_json_path).expect("fixture composer.json must exist"); + let reference = compute_path_reference(&bytes, true); + assert!( + reference.starts_with("b133081"), + "unexpected reference: {reference}" + ); + } + + #[test] + fn relative_url_resolves_against_base_dir_and_emits_synthetic_package_repo() { + let temp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(temp.path().join("pkg-dir")).unwrap(); + std::fs::write( + temp.path().join("pkg-dir").join("composer.json"), + r#"{"name": "vendor/pkg", "version": "1.2.3"}"#, + ) + .unwrap(); + + let input = vec![RawRepository { + repo_type: "path".to_string(), + url: Some("pkg-dir".to_string()), + package: None, + only: None, + exclude: None, + canonical: None, + security_advisories: None, + }]; + let expanded = expand_path_repositories(&input, temp.path()); + assert_eq!(expanded.len(), 1); + assert_eq!(expanded[0].repo_type, "package"); + + let pkgs = expanded[0] + .package + .as_ref() + .expect("expanded entry must carry a package payload") + .as_array() + .expect("payload should be an array"); + assert_eq!(pkgs.len(), 1); + let pkg = &pkgs[0]; + assert_eq!(pkg["name"], "vendor/pkg"); + assert_eq!(pkg["version"], "1.2.3"); + assert_eq!(pkg["dist"]["type"], "path"); + assert_eq!(pkg["dist"]["url"], "pkg-dir"); + assert!( + pkg["dist"]["reference"] + .as_str() + .map(|s| s.len() == 40) + .unwrap_or(false), + "reference should be a 40-char SHA-1" + ); + } + + #[test] + fn missing_composer_json_drops_the_entry() { + let temp = tempfile::tempdir().unwrap(); + let input = vec![RawRepository { + repo_type: "path".to_string(), + url: Some("does-not-exist".to_string()), + package: None, + only: None, + exclude: None, + canonical: None, + security_advisories: None, + }]; + let expanded = expand_path_repositories(&input, temp.path()); + assert!(expanded.is_empty()); + } + + #[test] + fn non_path_repos_pass_through_unchanged() { + let input = vec![RawRepository { + repo_type: "vcs".to_string(), + url: Some("https://example.com/repo.git".to_string()), + package: None, + only: None, + exclude: None, + canonical: None, + security_advisories: None, + }]; + let expanded = expand_path_repositories(&input, Path::new("/tmp")); + assert_eq!(expanded.len(), 1); + assert_eq!(expanded[0].repo_type, "vcs"); + assert_eq!( + expanded[0].url.as_deref(), + Some("https://example.com/repo.git") + ); + } +} diff --git a/crates/mozart-core/src/repository/repository/inline_package_repo.rs b/crates/mozart-core/src/repository/repository/inline_package_repo.rs new file mode 100644 index 0000000..d65ee94 --- /dev/null +++ b/crates/mozart-core/src/repository/repository/inline_package_repo.rs @@ -0,0 +1,63 @@ +//! [`Repository`] for inline `type: package` repositories. +//! +//! Wraps [`crate::inline_package::collect_inline_packages`]. The data is +//! embedded in `composer.json` so there's no I/O — the repo just filters +//! its in-memory list by queried name. +//! +//! Mirrors `Composer\Repository\PackageRepository` (which extends +//! `ArrayRepository`). Only the package's own `name` is matched against +//! queries — `replace`/`provide` targets are NOT advertised here, exactly +//! like Composer's `ArrayRepository::loadPackages` checks `getName()` only. +//! Replacement satisfaction happens later in the solver once the replacing +//! package is loaded transitively. + +use super::super::inline_package::{InlinePackage, collect_inline_packages}; +use super::{LoadResult, NamedPackagistVersion, PackageQuery, Repository}; +use crate::package::RawRepository; + +pub struct InlinePackageRepository { + id: String, + packages: Vec, +} + +impl InlinePackageRepository { + /// Build from the raw `repositories` array of a `composer.json`. Non- + /// `package` entries are ignored. + pub fn from_repositories(repositories: &[RawRepository]) -> Self { + Self { + id: "package".to_string(), + packages: collect_inline_packages(repositories), + } + } + + pub fn package_count(&self) -> usize { + self.packages.len() + } +} + +#[async_trait::async_trait] +impl Repository for InlinePackageRepository { + fn id(&self) -> &str { + &self.id + } + + async fn load_packages(&self, queries: &[PackageQuery<'_>]) -> anyhow::Result { + let mut result = LoadResult::default(); + for query in queries { + let mut found_any = false; + for ipkg in &self.packages { + if ipkg.name == query.name { + found_any = true; + result.packages.push(NamedPackagistVersion { + name: ipkg.name.clone(), + version: ipkg.version.clone(), + }); + } + } + if found_any { + result.names_found.push(query.name.to_string()); + } + } + Ok(result) + } +} diff --git a/crates/mozart-core/src/repository/repository/mod.rs b/crates/mozart-core/src/repository/repository/mod.rs new file mode 100644 index 0000000..4afff54 --- /dev/null +++ b/crates/mozart-core/src/repository/repository/mod.rs @@ -0,0 +1,319 @@ +//! Repository abstraction over package metadata sources. +//! +//! Mirrors Composer's `Composer\Repository\RepositoryInterface::loadPackages` +//! and `Composer\Repository\RepositoryManager`. The resolver and lockfile +//! generator query a [`RepositorySet`] instead of calling Packagist directly, +//! so test code can substitute a set without `PackagistRepository` (mirroring +//! Composer's `FactoryMock` injecting `repositories: ['packagist' => false]`). +//! +//! Concrete implementations live in sibling modules: [`packagist_repo`] for +//! the live Packagist HTTP repo, [`inline_package_repo`] for `type: package` +//! entries embedded in `composer.json`, and [`vcs_repo`] for VCS repositories. + +use std::collections::BTreeMap; + +use super::advisory::{MatchedAdvisory, PackageInfo}; +use super::packagist::{PackagistVersion, SearchResult}; + +pub mod inline_package_repo; +pub mod packagist_repo; +pub mod vcs_repo; + +/// Search modes for [`Repository::search`]. +/// +/// Mirrors Composer's `RepositoryInterface::SEARCH_FULLTEXT|SEARCH_NAME|SEARCH_VENDOR` +/// constants (`composer/src/Composer/Repository/RepositoryInterface.php`). +#[derive(Copy, Clone, Eq, PartialEq, Debug)] +pub enum SearchMode { + /// Full-text search over name, description, and keywords (Packagist's + /// `search.json` API). + Fulltext, + /// Match the regex against package names. Tokens are split on whitespace + /// and joined as `(?:t1|t2|...)`; callers must pre-quote regex metachars. + Name, + /// Match the regex against vendor names. Result rows have only `name` + /// populated (the vendor part). + Vendor, +} + +/// One name-keyed lookup against a repository. +/// +/// Matches the `$packageNameMap` argument of Composer's `loadPackages`. The +/// constraint is informational — repositories may use it to skip versions +/// that obviously can't match (an optimization), but the resolver still +/// re-checks every returned version when generating rules. +#[derive(Debug, Clone)] +pub struct PackageQuery<'a> { + pub name: &'a str, + /// Raw constraint string from `composer.json`, e.g. `"^1.2"`. `None` + /// when the caller wants every version (transitive exploration). + pub constraint: Option<&'a str>, +} + +/// Result of a single [`Repository::load_packages`] call. +/// +/// Mirrors Composer's `['packages' => ..., 'namesFound' => ...]` tuple. +/// `names_found` lets [`RepositorySet`] short-circuit lower-priority repos +/// once an upstream repo has authoritatively answered for a name (Composer's +/// "first repo wins" semantics). +#[derive(Debug, Default)] +pub struct LoadResult { + pub packages: Vec, + pub names_found: Vec, +} + +/// A `PackagistVersion` paired with the canonical package name it answers +/// for. Inline `type: package` repos can return packages whose own `name` +/// field differs from the queried name when they declare `replace`/`provide`, +/// so callers need both. +#[derive(Debug, Clone)] +pub struct NamedPackagistVersion { + pub name: String, + pub version: PackagistVersion, +} + +/// A source of package metadata. Mirrors Composer's `RepositoryInterface`. +/// +/// Implementations should return an empty [`LoadResult`] (not an error) when +/// they simply don't know a queried name — [`RepositorySet`] uses that to +/// fall through to the next repo. Reserve `Err` for genuine I/O failures +/// the caller cannot route around. +#[async_trait::async_trait] +pub trait Repository: Send + Sync { + /// Identifier for diagnostics (`"packagist.org"`, `"package"`, `"vcs:"`). + fn id(&self) -> &str; + + /// Look up every version of every queried name this repo knows about. + async fn load_packages(&self, queries: &[PackageQuery<'_>]) -> anyhow::Result; + + /// Search this repository. + /// + /// The default returns an empty result so repositories that don't + /// participate in search (e.g. inline / VCS repos that only resolve + /// known names) can opt out. Mirrors Composer's + /// `RepositoryInterface::search` whose default behavior on + /// `ArrayRepository` walks the in-memory list. + async fn search( + &self, + _query: &str, + _mode: SearchMode, + _package_type: Option<&str>, + ) -> anyhow::Result> { + Ok(Vec::new()) + } +} + +/// Ordered list of repositories. Mirrors `Composer\Repository\RepositoryManager`. +/// +/// `load_packages` queries each repo in order. Once a repo authoritatively +/// answers for a name (i.e. lists it in `names_found`), later repos are not +/// asked about that name — matching Composer's first-repo-wins priority. +pub struct RepositorySet { + repos: Vec>, +} + +impl RepositorySet { + pub fn new(repos: Vec>) -> Self { + Self { repos } + } + + /// Production default: a single [`packagist_repo::PackagistRepository`] + /// backed by the given on-disk cache. Mirrors what Composer does when + /// no `'packagist' => false` entry appears in the merged config. + pub fn with_packagist(repo_cache: super::cache::Cache) -> Self { + Self::new(vec![Box::new(packagist_repo::PackagistRepository::new( + repo_cache, + ))]) + } + + /// An empty set. Mirrors Composer's `'packagist' => false` test config: + /// resolution proceeds entirely from packages already in the pool + /// (eager VCS scan, inline `type: package` repos, the locked repository). + pub fn empty() -> Self { + Self::new(Vec::new()) + } + + pub fn is_empty(&self) -> bool { + self.repos.is_empty() + } + + pub fn len(&self) -> usize { + self.repos.len() + } + + /// Iterate over repositories in priority order. + pub fn repos(&self) -> impl Iterator { + self.repos.iter().map(|b| b.as_ref()) + } + + /// Query every repo, accumulating packages and tracking which names have + /// been authoritatively answered. Names already covered by an earlier + /// repo are dropped from the query passed to later repos. + pub async fn load_packages( + &self, + queries: &[PackageQuery<'_>], + ) -> anyhow::Result> { + use indexmap::IndexSet; + + let mut packages: Vec = Vec::new(); + let mut answered: IndexSet = IndexSet::new(); + + for repo in &self.repos { + let pending: Vec> = queries + .iter() + .filter(|q| !answered.contains(q.name)) + .cloned() + .collect(); + if pending.is_empty() { + break; + } + let result = repo.load_packages(&pending).await?; + for name in result.names_found { + answered.insert(name); + } + packages.extend(result.packages); + } + + Ok(packages) + } + + /// Fan-out search across every repository, concatenating results in + /// priority order. Mirrors Composer's + /// `CompositeRepository::search` which `array_merge`s per-repo results + /// without de-duplication. + pub async fn search( + &self, + query: &str, + mode: SearchMode, + package_type: Option<&str>, + ) -> anyhow::Result> { + let mut all = Vec::new(); + for repo in &self.repos { + let mut hits = repo.search(query, mode, package_type).await?; + all.append(&mut hits); + } + Ok(all) + } + + /// Fetch security advisories matching the installed packages, with version filtering. + /// + /// Mirrors `Composer\Repository\RepositorySet::getMatchingSecurityAdvisories()`. + /// Returns the matched advisories (already filtered by installed version) and a list + /// of unreachable repository URLs. When `ignore_unreachable` is false and a repository + /// is unreachable, the error is propagated instead. + pub async fn get_matching_security_advisories( + &self, + packages: &[PackageInfo], + _allow_partial: bool, + ignore_unreachable: bool, + ) -> anyhow::Result<(BTreeMap>, Vec)> { + let names: Vec<&str> = packages.iter().map(|p| p.name.as_str()).collect(); + + let (raw_advisories, unreachable_repos) = + match super::packagist::fetch_security_advisories(&names).await { + Ok(a) => (a, vec![]), + Err(e) if ignore_unreachable => { + tracing::warn!("Packagist advisory fetch failed (ignored): {e}"); + let unreachable = vec!["https://packagist.org".to_string()]; + (BTreeMap::new(), unreachable) + } + Err(e) => return Err(e), + }; + + let matched = version_filter_advisories(&raw_advisories, packages); + + Ok((matched, unreachable_repos)) + } +} + +/// Normalize single-pipe OR separators (`|`) in a version constraint string to +/// double-pipe (`||`) so the constraint parser can handle both forms. +/// +/// The Packagist security advisories API may return constraints with single `|` +/// as the OR separator (e.g. `>=1.0,<1.5|>=2.0,<2.3`), but Mozart's +/// `VersionConstraint::parse` expects `||`. +/// +/// TODO: fix `mozart_semver::VersionConstraint::parse` to accept single `|` and remove this. +fn normalize_or_separator(constraint: &str) -> String { + let bytes = constraint.as_bytes(); + let mut result = String::with_capacity(constraint.len() + 4); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'|' { + if i + 1 < bytes.len() && bytes[i + 1] == b'|' { + result.push_str("||"); + i += 2; + } else { + result.push_str("||"); + i += 1; + } + } else { + result.push(bytes[i] as char); + i += 1; + } + } + result +} + +/// Filter raw advisories by installed package versions. +/// +/// Mirrors the version-matching step inside Composer's repository advisory fetch. +fn version_filter_advisories( + all_advisories: &BTreeMap>, + packages: &[PackageInfo], +) -> BTreeMap> { + let mut result: BTreeMap> = BTreeMap::new(); + + for pkg in packages { + let Some(advisories) = all_advisories.get(&pkg.name) else { + continue; + }; + + let version_str = pkg + .version_normalized + .as_deref() + .unwrap_or(pkg.version.as_str()); + + let installed_ver = match mozart_semver::Version::parse(version_str) { + Ok(v) => v, + Err(_) => { + tracing::warn!( + "Could not parse version {:?} for package {:?}, skipping advisory matching", + version_str, + pkg.name + ); + continue; + } + }; + + let mut matched: Vec = Vec::new(); + + for advisory in advisories { + let normalized = normalize_or_separator(&advisory.affected_versions); + let constraint = match mozart_semver::VersionConstraint::parse(&normalized) { + Ok(c) => c, + Err(_) => { + tracing::warn!( + "Could not parse affected versions {:?} for advisory {:?}, skipping", + advisory.affected_versions, + advisory.advisory_id + ); + continue; + } + }; + + if constraint.matches(&installed_ver) { + matched.push(MatchedAdvisory { + advisory: advisory.clone(), + installed_version: pkg.version.clone(), + }); + } + } + + if !matched.is_empty() { + result.insert(pkg.name.clone(), matched); + } + } + + result +} diff --git a/crates/mozart-core/src/repository/repository/packagist_repo.rs b/crates/mozart-core/src/repository/repository/packagist_repo.rs new file mode 100644 index 0000000..b221b0f --- /dev/null +++ b/crates/mozart-core/src/repository/repository/packagist_repo.rs @@ -0,0 +1,121 @@ +//! [`Repository`] backed by the live Packagist HTTP API. +//! +//! Wraps the existing [`crate::packagist::fetch_package_versions`] so the +//! resolver sees the same data either through this trait or via the legacy +//! direct call. Construction takes ownership of the [`Cache`] handle so +//! callers no longer thread it through `ResolveRequest` / `LockFileGenerationRequest`. + +use super::super::cache::Cache; +use super::super::packagist; +use super::super::packagist::SearchResult; +use super::{LoadResult, NamedPackagistVersion, PackageQuery, Repository, SearchMode}; + +pub struct PackagistRepository { + id: String, + cache: Cache, +} + +impl PackagistRepository { + pub fn new(cache: Cache) -> Self { + Self { + id: "packagist.org".to_string(), + cache, + } + } +} + +#[async_trait::async_trait] +impl Repository for PackagistRepository { + fn id(&self) -> &str { + &self.id + } + + async fn load_packages(&self, queries: &[PackageQuery<'_>]) -> anyhow::Result { + let mut result = LoadResult::default(); + for query in queries { + // Errors propagate to the caller. Composer's + // `ComposerRepository::loadAsyncPackages` distinguishes 404 + // (empty result, no error) from transport failures (exception); + // Mozart's underlying `fetch_package_versions` doesn't yet make + // that distinction, so for now both surface as `Err` and the + // caller decides whether the loop wants to continue (transitive + // exploration) or abort (seed-time fetch failure). + let versions = packagist::fetch_package_versions(query.name, &self.cache).await?; + // A successful fetch counts as "this repo authoritatively knows + // the name", even if the version list is empty — mirrors + // Composer's `ArrayRepository::loadPackages` which adds the + // name to `namesFound` regardless of constraint match. + result.names_found.push(query.name.to_string()); + for version in versions { + result.packages.push(NamedPackagistVersion { + name: query.name.to_string(), + version, + }); + } + } + Ok(result) + } + + async fn search( + &self, + query: &str, + mode: SearchMode, + package_type: Option<&str>, + ) -> anyhow::Result> { + match mode { + SearchMode::Fulltext => { + let (results, _total) = packagist::search_packages(query, package_type).await?; + Ok(results) + } + SearchMode::Name => { + let pattern = build_name_regex(query)?; + let names = packagist::fetch_package_names(package_type, &self.cache).await?; + Ok(names + .into_iter() + .filter(|name| pattern.is_match(name)) + .map(empty_search_result) + .collect()) + } + SearchMode::Vendor => { + let pattern = build_name_regex(query)?; + let vendors = packagist::fetch_vendor_names(&self.cache).await?; + Ok(vendors + .into_iter() + .filter(|name| pattern.is_match(name)) + .map(empty_search_result) + .collect()) + } + } + } +} + +/// Build the case-insensitive `(?:t1|t2|...)` regex from whitespace-split +/// tokens, mirroring Composer's `'{(?:'.implode('|', $matches).')}i'`. +/// +/// Tokens are joined as-is — callers are expected to have already escaped +/// regex metacharacters (`SearchCommand` calls `preg_quote`; Mozart calls +/// `regex::escape` before reaching this point). +fn build_name_regex(query: &str) -> anyhow::Result { + let tokens: Vec<&str> = query.split_whitespace().collect(); + let body = if tokens.is_empty() { + String::new() + } else { + tokens.join("|") + }; + Ok(regex::Regex::new(&format!("(?i)(?:{body})"))?) +} + +/// Build a [`SearchResult`] with only `name` populated, mirroring the shape +/// Composer returns for `SEARCH_NAME` / `SEARCH_VENDOR` modes +/// (`['name' => $name]`, all other fields `null`). +fn empty_search_result(name: String) -> SearchResult { + SearchResult { + name, + description: String::new(), + url: String::new(), + repository: None, + downloads: 0, + favers: 0, + abandoned: None, + } +} diff --git a/crates/mozart-core/src/repository/repository/vcs_repo.rs b/crates/mozart-core/src/repository/repository/vcs_repo.rs new file mode 100644 index 0000000..760b8e5 --- /dev/null +++ b/crates/mozart-core/src/repository/repository/vcs_repo.rs @@ -0,0 +1,63 @@ +//! [`Repository`] for VCS-type repositories. +//! +//! Wraps [`crate::vcs_bridge::scan_vcs_repositories`] + [`crate::vcs_bridge::vcs_to_packagist_version`]. +//! Scanning is expensive (clones / fetches), so we do it once at construction +//! and serve subsequent queries from the in-memory cache. Mirrors +//! `Composer\Repository\Vcs\VcsRepository`'s lazy-then-memoized behavior. + +use super::super::packagist::PackagistVersion; +use super::super::vcs_bridge::{scan_vcs_repositories, vcs_to_packagist_version}; +use super::{LoadResult, NamedPackagistVersion, PackageQuery, Repository}; +use crate::package::RawRepository; + +pub struct VcsRepository { + id: String, + versions: Vec<(String, PackagistVersion)>, +} + +impl VcsRepository { + /// Scan every VCS-type entry in `repositories` and cache the resulting + /// versions. Non-VCS entries are ignored. This performs network I/O. + pub async fn from_repositories(repositories: &[RawRepository]) -> Self { + let scanned = scan_vcs_repositories(repositories).await; + let versions = scanned + .iter() + .map(|v| (v.name.clone(), vcs_to_packagist_version(v))) + .collect(); + Self { + id: "vcs".to_string(), + versions, + } + } + + pub fn version_count(&self) -> usize { + self.versions.len() + } +} + +#[async_trait::async_trait] +impl Repository for VcsRepository { + fn id(&self) -> &str { + &self.id + } + + async fn load_packages(&self, queries: &[PackageQuery<'_>]) -> anyhow::Result { + let mut result = LoadResult::default(); + for query in queries { + let mut found_any = false; + for (name, version) in &self.versions { + if name == query.name { + found_any = true; + result.packages.push(NamedPackagistVersion { + name: name.clone(), + version: version.clone(), + }); + } + } + if found_any { + result.names_found.push(query.name.to_string()); + } + } + Ok(result) + } +} diff --git a/crates/mozart-core/src/repository/repository_filter.rs b/crates/mozart-core/src/repository/repository_filter.rs new file mode 100644 index 0000000..814d297 --- /dev/null +++ b/crates/mozart-core/src/repository/repository_filter.rs @@ -0,0 +1,136 @@ +//! Repository-level package filters (`only`, `exclude`, `canonical`). +//! +//! Mirrors `Composer\Repository\FilterRepository`: a wrapper around an +//! underlying repository that drops packages by name and/or removes the +//! repo's authoritative claim on the names it serves. We model the same +//! semantics for inline `type: package` and local `type: composer` +//! repositories, since the installer fixtures rely on them. + +use crate::package::RawRepository; +use regex::Regex; + +/// Resolved filter for a single `repositories[]` entry. +pub struct RepositoryFilter { + only: Option, + exclude: Option, + /// `canonical: true` (default) — packages from this repo claim their + /// names, suppressing lower-priority repos for the same name. + /// `canonical: false` — packages enter the pool but lower-priority + /// repos may also answer. + pub canonical: bool, +} + +impl RepositoryFilter { + pub fn from_repo(repo: &RawRepository) -> Self { + Self { + only: repo.only.as_ref().and_then(|names| build_name_regex(names)), + exclude: repo + .exclude + .as_ref() + .and_then(|names| build_name_regex(names)), + canonical: repo.canonical.unwrap_or(true), + } + } + + /// `true` if `name` may pass through this filter. + /// Mirrors `FilterRepository::isAllowed`. + pub fn is_allowed(&self, name: &str) -> bool { + if let Some(only) = &self.only { + return only.is_match(name); + } + if let Some(exclude) = &self.exclude { + return !exclude.is_match(name); + } + true + } +} + +/// Build a case-insensitive `^(?:p1|p2|…)$` regex from Composer's pattern +/// list. Mirrors `BasePackage::packageNamesToRegexp` — `*` becomes `.*`, +/// every other regex metacharacter is escaped, and the alternation is +/// anchored to the full string. +fn build_name_regex(patterns: &[String]) -> Option { + if patterns.is_empty() { + return None; + } + let parts: Vec = patterns.iter().map(|p| pattern_to_regex(p)).collect(); + let joined = parts.join("|"); + Regex::new(&format!(r"(?i)^(?:{joined})$")).ok() +} + +fn pattern_to_regex(pattern: &str) -> String { + let escaped = regex::escape(pattern); + // `*` was escaped to `\*` — turn it into `.*` so glob semantics match + // Composer. + escaped.replace(r"\*", ".*") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn repo( + only: Option>, + exclude: Option>, + canonical: Option, + ) -> RawRepository { + RawRepository { + repo_type: "package".to_string(), + url: None, + package: None, + only, + exclude, + canonical, + security_advisories: None, + } + } + + #[test] + fn no_filter_allows_all() { + let f = RepositoryFilter::from_repo(&repo(None, None, None)); + assert!(f.is_allowed("a/a")); + assert!(f.is_allowed("foo/bar")); + assert!(f.canonical); + } + + #[test] + fn only_restricts_to_listed_names() { + let f = RepositoryFilter::from_repo(&repo(Some(vec!["foo/b".to_string()]), None, None)); + assert!(f.is_allowed("foo/b")); + assert!(!f.is_allowed("foo/a")); + } + + #[test] + fn exclude_drops_listed_names() { + let f = RepositoryFilter::from_repo(&repo(None, Some(vec!["foo/c".to_string()]), None)); + assert!(f.is_allowed("foo/a")); + assert!(!f.is_allowed("foo/c")); + } + + #[test] + fn glob_star_expands() { + let f = RepositoryFilter::from_repo(&repo(Some(vec!["foo/*".to_string()]), None, None)); + assert!(f.is_allowed("foo/a")); + assert!(f.is_allowed("foo/anything")); + assert!(!f.is_allowed("bar/a")); + } + + #[test] + fn match_is_case_insensitive() { + let f = RepositoryFilter::from_repo(&repo(Some(vec!["Foo/Bar".to_string()]), None, None)); + assert!(f.is_allowed("foo/bar")); + assert!(f.is_allowed("FOO/BAR")); + } + + #[test] + fn canonical_default_is_true() { + let f = RepositoryFilter::from_repo(&repo(None, None, None)); + assert!(f.canonical); + } + + #[test] + fn canonical_false_honored() { + let f = RepositoryFilter::from_repo(&repo(None, None, Some(false))); + assert!(!f.canonical); + } +} diff --git a/crates/mozart-core/src/repository/resolver.rs b/crates/mozart-core/src/repository/resolver.rs new file mode 100644 index 0000000..1b06f9b --- /dev/null +++ b/crates/mozart-core/src/repository/resolver.rs @@ -0,0 +1,1998 @@ +//! Dependency resolver using the SAT solver. +//! +//! This module fetches package metadata from Packagist, builds a Pool of all +//! candidate packages, generates SAT rules, and runs the CDCL solver to find +//! a compatible set of packages to install. + +use super::packagist; +use super::repository::{PackageQuery, RepositorySet}; +use super::vcs_bridge; +use crate::dependency_resolver::{ + DefaultPolicy, PoolBuilder, PoolLink, PoolPackageInput, RuleSetGenerator, Solver, + make_pool_links, +}; +use crate::package::{RawRepository, Stability}; +use indexmap::{IndexMap, IndexSet}; +use mozart_semver::{Version, VersionConstraint}; +use regex::{Captures, Regex}; +use std::fmt; +use std::sync::Arc; +use std::sync::LazyLock; + +/// Strip a `@stability` suffix from a constraint string and return the +/// cleaned constraint plus the parsed stability. Mirrors Composer's +/// `RootPackageLoader::extractStabilityFlags` (single-constraint case): +/// `"3.2.*@dev"` → (`"3.2.*"`, `Some(Stability::Dev)`). +pub(crate) fn extract_stability_suffix(constraint: &str) -> (String, Option) { + let trimmed = constraint.trim(); + if let Some(at_pos) = trimmed.rfind('@') { + let suffix = &trimmed[at_pos + 1..]; + let stability = match suffix.to_lowercase().as_str() { + "dev" => Some(Stability::Dev), + "alpha" => Some(Stability::Alpha), + "beta" => Some(Stability::Beta), + "rc" => Some(Stability::RC), + "stable" => Some(Stability::Stable), + _ => None, + }; + if let Some(s) = stability { + let cleaned = trimmed[..at_pos].trim().to_string(); + // An empty constraint left after the strip means "any version" — + // mirrors Composer's `@dev` shorthand (no version constraint). + let cleaned = if cleaned.is_empty() { + "*".to_string() + } else { + cleaned + }; + return (cleaned, Some(s)); + } + } + (trimmed.to_string(), None) +} + +/// Mirror Composer's `VersionParser::parseStability` for a single-atom +/// constraint string (no `@flag` suffix). Returns `Some(stability)` for +/// recognised non-stable constraints (`dev-foo`, `1.0.x-dev`, `1.0.0-beta1`, +/// …), `None` for stable or unrecognised forms (in which case +/// `minimum_stability` already applies). +/// +/// Composer first strips a trailing `#hash` (handled here), then checks +/// `dev-` prefix / `-dev` suffix / a `(stab)?\d*` modifier. We follow the +/// same shape — the regex variant is overkill for inferring a flag. +pub(crate) fn infer_constraint_stability(constraint: &str) -> Option { + let s = constraint.trim(); + // Strip `#ref` (matches Composer's `parseStability` line 54). + let s = match s.find('#') { + Some(p) => &s[..p], + None => s, + }; + // Reject multi-atom constraints — extractStabilityFlags inspects each + // sub-constraint individually but the most common single-atom case is + // all we need for `dev-foo` / `1.0.x-dev` style root requires. + if s.contains([' ', ',']) || s.contains("||") { + return None; + } + // Strip a leading comparison operator (`>=1.0-beta` → `1.0-beta`). + let s = s + .strip_prefix(">=") + .or_else(|| s.strip_prefix("<=")) + .or_else(|| s.strip_prefix("!=")) + .or_else(|| s.strip_prefix("==")) + .or_else(|| s.strip_prefix('>')) + .or_else(|| s.strip_prefix('<')) + .or_else(|| s.strip_prefix('=')) + .or_else(|| s.strip_prefix('^')) + .or_else(|| s.strip_prefix('~')) + .unwrap_or(s); + let lower = s.to_lowercase(); + if lower.starts_with("dev-") || lower.ends_with("-dev") { + return Some(Stability::Dev); + } + // Match `` at the end after the last `-`/`@`. + // Composer uses `{(stable|RC|beta|alpha|dev)([.-]?\d+)?(?:\+.*)?$}`. + let tail = lower + .rsplit_once('-') + .or_else(|| lower.rsplit_once('@')) + .map(|(_, t)| t) + .unwrap_or(&lower); + let tail_word: String = tail.chars().take_while(|c| c.is_alphabetic()).collect(); + match tail_word.as_str() { + "alpha" | "a" => Some(Stability::Alpha), + "beta" | "b" => Some(Stability::Beta), + "rc" => Some(Stability::RC), + "patch" | "pl" | "p" | "stable" => Some(Stability::Stable), + _ => None, + } +} + +/// Determine the `Stability` of a `Version` from its pre_release string. +pub(crate) fn version_stability(v: &Version) -> Stability { + match &v.pre_release { + None => Stability::Stable, + Some(pre) => { + let lower = pre.to_lowercase(); + if lower.starts_with("dev") { + Stability::Dev + } else if lower.starts_with("alpha") || lower.starts_with('a') { + Stability::Alpha + } else if lower.starts_with("beta") || lower.starts_with('b') { + Stability::Beta + } else if lower.starts_with("rc") { + Stability::RC + } else { + // patch/pl/p and unknown → stable + Stability::Stable + } + } + } +} + +/// Parse a Packagist normalized version string like "1.2.3.0", "1.0.0.0-beta1". +/// Returns `None` for dev branches (dev-master, dev-*, *.x-dev). +pub(crate) fn parse_normalized(normalized: &str) -> Option { + let s = normalized.trim(); + + // Reject dev branches + if s.to_lowercase().starts_with("dev-") { + return None; + } + // Reject *.x-dev style + if s.to_lowercase().ends_with("-dev") && s.contains(".x") { + return None; + } + // Packagist uses 9999999.9999999.9999999.9999999 for dev branches + if s.starts_with("9999999") { + return None; + } + + Version::parse(s).ok() +} + +/// Parse a branch alias target like "2.x-dev" or "1.0.x-dev" into a `Version` with dev pre-release. +fn parse_branch_alias_target(alias_target: &str) -> Option { + let s = alias_target.trim().to_lowercase(); + if !s.ends_with("-dev") { + return None; + } + let base = &s[..s.len() - 4]; + let base = base.trim_end_matches(".x"); + let parts: Vec<&str> = base.split('.').collect(); + let major: u64 = parts.first().and_then(|p| p.parse().ok())?; + let minor: u64 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); + let patch: u64 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0); + let build: u64 = parts.get(3).and_then(|p| p.parse().ok()).unwrap_or(0); + Some(Version { + major, + minor, + patch, + build, + pre_release: Some("dev".to_string()), + is_dev_branch: false, + dev_branch_name: None, + }) +} + +/// Mirror Composer's `VersionParser::parseNumericAliasPrefix`: returns true +/// when the input is a numeric branch like `1.2-dev` / `1.2.3-dev` / +/// `1.2.x-dev` (i.e. the prefix is suitable for version comparison). +/// Non-numeric branches like `dev-main` / `dev-feature/x` return false. +fn has_numeric_alias_prefix(branch: &str) -> bool { + let lower = branch.trim().to_lowercase(); + let lower = lower.strip_prefix('v').unwrap_or(&lower); + let Some(base) = lower.strip_suffix("-dev") else { + return false; + }; + let base = base.strip_suffix(".x").unwrap_or(base); + if base.is_empty() { + return false; + } + // Allow only digit segments separated by `.`. + base.split('.') + .all(|seg| !seg.is_empty() && seg.chars().all(|c| c.is_ascii_digit())) +} + +/// Mirror Composer's `VersionParser::normalizeBranch` for branch-alias +/// targets: turn a string like `"3.2.x-dev"` into the canonical numeric form +/// `"3.2.9999999.9999999-dev"`. Returns `None` if the input is not a numeric +/// branch (i.e. cannot be expanded to a four-segment numeric version). +/// +/// Composer's flow for an `extra.branch-alias` value: +/// 1. Strip the trailing `-dev`. +/// 2. Pad missing segments with `.x`. +/// 3. Replace each `x` with `9999999`. +/// 4. Re-append `-dev`. +/// +/// This is the form Composer's `Locker::lockPackages` writes into the +/// `aliases` block of `composer.lock` and the form `Pool` indexes for +/// constraint matching, so Mozart needs to use it too. +pub fn normalize_branch_alias_target(alias_target: &str) -> Option { + let trimmed = alias_target.trim(); + let lower = trimmed.to_lowercase(); + let base = lower.strip_suffix("-dev")?; + // Strip leading v/V before normalizing, mirroring Composer's regex + let base = base.strip_prefix('v').unwrap_or(base); + let mut segments: Vec = Vec::with_capacity(4); + for seg in base.split('.') { + if seg == "x" || seg == "X" || seg == "*" { + segments.push("x".to_string()); + } else if seg.chars().all(|c| c.is_ascii_digit()) && !seg.is_empty() { + segments.push(seg.to_string()); + } else { + return None; + } + } + if segments.is_empty() { + return None; + } + while segments.len() < 4 { + segments.push("x".to_string()); + } + let expanded: Vec = segments + .into_iter() + .map(|s| if s == "x" { "9999999".to_string() } else { s }) + .collect(); + Some(format!("{}-dev", expanded.join("."))) +} + +/// Mirror Composer's `VersionParser::normalize` for the values that appear on +/// either side of an `as` clause (`require: "1.0.x-dev as dev-master"`). +/// +/// Composer sends both sides through `normalize`, which: +/// - Maps bare `master` / `trunk` / `default` to the `dev-` prefixed form +/// (`master` → `dev-master`) for BC with Composer 1, then returns +/// `dev-NAME` unchanged. Inline `type: package` entries for these branches +/// land in the pool under the same literal `dev-NAME` form, so root aliases +/// declared with the matching atom must point at that same string. +/// - Strips a leading `v` and treats numeric `*.x-dev` branches via +/// `normalizeBranch` (= `normalize_branch_alias_target`). +/// - Leaves other `dev-NAME` strings as `dev-NAME`. +fn normalize_root_alias_atom(atom: &str) -> Option { + let trimmed = atom.trim(); + if trimmed.is_empty() { + return None; + } + let lower = trimmed.to_lowercase(); + // Composer's normalize: bare `master` / `trunk` / `default` get the + // `dev-` prefix prepended for BC, then fall through to the `dev-` + // branch below. + let with_prefix = if matches!(lower.as_str(), "master" | "trunk" | "default") { + format!("dev-{lower}") + } else { + trimmed.to_string() + }; + let lower_pref = with_prefix.to_lowercase(); + if let Some(rest) = lower_pref.strip_prefix("dev-") { + return Some(format!("dev-{rest}")); + } + if let Some(numeric) = normalize_branch_alias_target(&with_prefix) { + return Some(numeric); + } + // Stable numeric atoms (e.g. `1.1.1`) need to come back in the + // four-segment form `Version::Display` produces, so the alias + // matcher's `input.version != alias.version_normalized` check lines + // up with pool inputs (which carry the 4-segment normalized form). + // Returning the raw input here would silently never match. + parse_normalized(&with_prefix).map(|v| v.to_string()) +} + +/// A root-level alias declared via the `require: "X as Y"` shorthand on the +/// root composer.json. Mirrors Composer's +/// `RootPackageLoader::extractAliases` entries: when the resolver loads a +/// package matching `(package, version_normalized)`, it materializes an extra +/// alias entry exposing the same install under `alias_normalized`/`alias`. +#[derive(Debug, Clone)] +struct RootAlias { + package: String, + /// Normalized form of the LEFT-hand side (the actual constraint). + version_normalized: String, + /// Pretty form of the RIGHT-hand side (the alias to expose). + alias: String, + /// Normalized form of the RIGHT-hand side. + alias_normalized: String, +} + +/// Composer's `RootPackageLoader::extractAliases` regex. Finds every +/// ` as ` clause inside a constraint string, including those +/// nested in OR / AND expressions (e.g. `1.*||dev-feature-foo as 1.0.2||^2` +/// or `dev-feature-foo, dev-feature-foo as 1.0.2`). The optional `#hex` +/// suffix on the LEFT atom is captured but excluded from the alias target, +/// matching `RootPackageLoader::extractReferences` which records refs out +/// of band. +static ALIAS_CLAUSE_RE: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?P^|\| *|, *)(?P[^,\s#|]+)(?:#[^ ]+)? +as +(?P[^,\s|]+)(?P$| *\|| *,)", + ) + .expect("alias clause regex compiles") +}); + +/// Strip every ` as ` clause from a constraint string. Returns the +/// cleaned constraint plus an entry per alias. Mirrors Composer's +/// `VersionParser::parseConstraint` `as`-strip combined with +/// `RootPackageLoader::extractAliases`: the constraint passed to the +/// resolver is the LEFT side of each atom, and a separate alias entry is +/// recorded for each RIGHT side so `RootAliasPackage`-style virtual +/// packages can be materialized later. A trailing `#hex` reference +/// (`dev-main#abcd`) on the LEFT atom is also stripped from the cleaned +/// constraint — `RootPackageLoader::extractReferences` records the hash +/// out of band for the post-resolve `setSourceDistReferences` pass. +fn strip_root_alias_clause(constraint: &str) -> (String, Vec<(String, String)>) { + let trimmed = constraint.trim(); + let mut aliases: Vec<(String, String)> = Vec::new(); + let cleaned = ALIAS_CLAUSE_RE.replace_all(trimmed, |caps: &Captures<'_>| { + let sep = caps.name("sep").map_or("", |m| m.as_str()); + let left = caps.name("left").map_or("", |m| m.as_str()); + let right = caps.name("right").map_or("", |m| m.as_str()); + let after = caps.name("after").map_or("", |m| m.as_str()); + let cleaned_left = strip_inline_reference(left); + aliases.push((cleaned_left.clone(), right.to_string())); + format!("{sep}{cleaned_left}{after}") + }); + if aliases.is_empty() { + return (strip_inline_reference(trimmed), aliases); + } + (cleaned.into_owned(), aliases) +} + +/// Drop a trailing `#hex` reference from a single-atom `dev-*` / `*-dev` +/// constraint, matching Composer's `'{^[^,\s@]+?#([a-f0-9]+)$}'` guard. +/// Lockfile generation records the reference separately via +/// `extract_root_references` and applies it after resolution, so the SAT +/// constraint itself only needs the bare branch name. +fn strip_inline_reference(s: &str) -> String { + if let Some((head, hash)) = s.rsplit_once('#') + && !hash.is_empty() + && hash.chars().all(|c| c.is_ascii_hexdigit()) + && !head.contains([' ', '\t', ',', '@']) + && (head.to_lowercase().starts_with("dev-") || head.to_lowercase().ends_with("-dev")) + { + return head.to_string(); + } + s.to_string() +} + +/// 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-*, composer pseudo packages). + pub fn is_platform(&self) -> bool { + crate::platform::is_platform_package(&self.0) + } + + /// Returns true if this is the virtual root package. + pub fn is_root(&self) -> bool { + self.0 == Self::ROOT + } +} + +/// Platform package configuration. +/// Maps package names to version strings (normalized, e.g. "8.1.0.0"). +pub struct PlatformConfig { + pub packages: IndexMap, +} + +impl Default for PlatformConfig { + fn default() -> Self { + Self::new() + } +} + +impl PlatformConfig { + /// Detect platform packages from the local PHP installation. + pub fn new() -> Self { + let detected = crate::platform::detect_platform(); + let mut packages = IndexMap::new(); + for pkg in detected { + packages.insert(pkg.name, pkg.version); + } + Self { packages } + } + + /// Apply `config.platform` overrides on top of the detected packages. + /// + /// Mirrors `Composer\Repository\PlatformRepository::__construct`'s + /// `$overrides` handling: each override either replaces a detected + /// package version or adds a virtual one (e.g. `ext-dummy`). A `false` + /// value disables the package, removing it from the platform. + pub fn apply_overrides(&mut self, overrides: &serde_json::Value) { + let Some(obj) = overrides.as_object() else { + return; + }; + for (name, value) in obj { + let key = name.to_lowercase(); + if value.as_bool() == Some(false) { + self.packages.shift_remove(&key); + continue; + } + if let Some(s) = value.as_str() { + self.packages.insert(key, s.to_string()); + } + } + } + + /// Parse platform packages into `Version` values. + pub fn to_versions(&self) -> IndexMap { + self.packages + .iter() + .filter_map(|(name, version_str)| { + Version::parse(version_str).ok().map(|v| (name.clone(), v)) + }) + .collect() + } +} + +/// 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 {} + +/// Check if a version passes the minimum-stability filter for the given package. +fn passes_stability_filter( + package_name: &str, + version: &Version, + minimum_stability: Stability, + stability_flags: &IndexMap, +) -> bool { + let min_stability = stability_flags + .get(package_name) + .copied() + .unwrap_or(minimum_stability); + let vs = version_stability(version); + vs <= min_stability +} + +/// Check whether a platform dependency should be skipped. +fn should_skip_platform_dep( + dep_name: &str, + ignore_platform_reqs: bool, + ignore_platform_req_list: &[String], +) -> bool { + if !PackageName(dep_name.to_string()).is_platform() { + return false; + } + if ignore_platform_reqs { + return true; + } + ignore_platform_req_list + .iter() + .any(|p| crate::matches_wildcard(dep_name, p)) +} + +/// Mirrors `Composer\Package\CompletePackage::isAbandoned`: any +/// `abandoned: true` or `abandoned: ""` value is truthy. +/// `abandoned: false` and an empty string both register as not-abandoned. +fn is_abandoned(pv: &packagist::PackagistVersion) -> bool { + match &pv.abandoned { + None => false, + Some(serde_json::Value::Null) => false, + Some(serde_json::Value::Bool(b)) => *b, + Some(serde_json::Value::String(s)) => !s.is_empty(), + Some(_) => true, + } +} + +/// Convert a Packagist version entry to PoolPackageInput(s). +/// May return multiple entries if branch aliases are present. +fn packagist_to_pool_inputs( + package_name: &str, + pv: &packagist::PackagistVersion, + minimum_stability: Stability, + stability_flags: &IndexMap, +) -> Vec { + let mut results = Vec::new(); + + let make_input = |version_str: &str, + version_normalized: &str, + is_alias_of: Option| + -> PoolPackageInput { + PoolPackageInput { + name: package_name.to_string(), + version: version_normalized.to_string(), + pretty_version: version_str.to_string(), + requires: make_pool_links( + package_name, + version_normalized, + &pv.require + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::>(), + ), + replaces: make_pool_links( + package_name, + version_normalized, + &pv.replace + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::>(), + ), + provides: make_pool_links( + package_name, + version_normalized, + &pv.provide + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::>(), + ), + conflicts: make_pool_links( + package_name, + version_normalized, + &pv.conflict + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::>(), + ), + is_fixed: false, + is_alias_of, + } + }; + + match parse_normalized(&pv.version_normalized) { + Some(v) => { + if passes_stability_filter(package_name, &v, minimum_stability, stability_flags) { + results.push(make_input(&pv.version, &pv.version_normalized, None)); + } + } + None => { + // Dev branch — emit the original entry (so the alias has a target + // to point at) and one alias entry per matching `extra.branch-alias`. + // Mirrors Composer's `ArrayRepository::addPackage` which adds the + // base package and then calls `createAliasPackage` for each + // branch-alias declaration on it. + let original_passes = passes_stability_filter( + package_name, + &Version { + major: 0, + minor: 0, + patch: 0, + build: 0, + pre_release: Some("dev".to_string()), + is_dev_branch: true, + dev_branch_name: None, + }, + minimum_stability, + stability_flags, + ); + if !original_passes { + return results; + } + results.push(make_input(&pv.version, &pv.version_normalized, None)); + + let aliases = pv.branch_aliases(); + let mut emitted_explicit_alias = false; + for (branch, alias_target) in &aliases { + if branch.to_lowercase() != pv.version.to_lowercase() { + continue; + } + if parse_branch_alias_target(alias_target).is_none() { + continue; + } + let Some(alias_normalized) = normalize_branch_alias_target(alias_target) else { + continue; + }; + results.push(make_input( + alias_target, + &alias_normalized, + Some(pv.version_normalized.clone()), + )); + emitted_explicit_alias = true; + } + + // Mirror Composer's `ArrayLoader::getBranchAlias`: when a + // `dev-` package carries `default-branch: true` and the version + // has no numeric prefix (i.e. it isn't already a `1.0.x-dev` form + // that would be its own alias), synthesize the `9999999-dev` + // alias so root constraints like `dev-main` pick up a default + // branch surfaced as `9999999-dev` in the lock + trace output. + // + // `getBranchAlias` returns the *first* matching branch-alias when + // one exists — i.e. an explicit `branch-alias` entry takes + // precedence over the `default-branch` synthetic one. Skip the + // synthetic alias when an explicit one has already been emitted + // for this version. + if pv.default_branch + && !emitted_explicit_alias + && !has_numeric_alias_prefix(&pv.version) + { + let default_alias = "9999999-dev"; + let default_normalized = "9999999.9999999.9999999.9999999-dev"; + let already_present = results + .iter() + .any(|r| r.version == default_normalized && r.name == package_name); + if !already_present { + results.push(make_input( + default_alias, + default_normalized, + Some(pv.version_normalized.clone()), + )); + } + } + } + } + + results +} + +/// Input to the resolver. +pub struct ResolveRequest { + /// Root package name from composer.json "name" field (e.g. "laravel/laravel"). + /// Used in error messages. Falls back to `__root__` if empty. + pub root_name: String, + /// Root package version from composer.json "version" field. `None` falls + /// back to Composer's `RootPackage::DEFAULT_PRETTY_VERSION` (1.0.0+no-version-set). + /// Used to seed a fixed pool entry for the root so transitive requires + /// pointing at the root (legal circular dependencies via an intermediate + /// package) can be satisfied. + pub root_version: Option, + /// 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: IndexMap, + /// 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, + /// Repository set used to fetch package metadata. Mirrors Composer's + /// `RepositoryManager`. Production builders construct this with a single + /// `PackagistRepository`; in-process test harnesses can construct one + /// without any HTTP-backed repos to mimic Composer's + /// `'packagist' => false` test config. + pub repositories: Arc, + /// Temporary version constraint overrides (from --with flag). + /// Maps package name (lowercase) to constraint string. + pub temporary_constraints: IndexMap, + /// VCS / inline-package repository entries from composer.json's + /// `repositories` section, used by the eager VCS scan and inline-package + /// preload that still live in `resolve()` (Step B follow-up will move + /// these through `RepositorySet` too). + pub raw_repositories: Vec, + /// Root composer.json's `provide` map (target → constraint string). Drives + /// the self-fulfilling-rule check in the SAT generator: when a root + /// `require` names something the root itself `provide`s with a matching + /// constraint, no install-one-of rule is emitted, mirroring Composer's + /// `RuleSetGenerator::createRequireRule` self-fulfillment branch. + pub root_provide: IndexMap, + /// Root composer.json's `replace` map. Same role as `root_provide` for the + /// `replace` link: a replaced target counts as fulfilled by the root. + pub root_replace: IndexMap, + /// Root composer.json's `conflict` map (target → constraint). Composer's + /// `RootPackageRepository` carries these onto the in-pool root package + /// entry; the SAT generator then forbids any candidate matching the + /// constraint, so a root `conflict` blocks both direct selection of the + /// targeted version and any alias / replace / provide that would resolve + /// to it. + pub root_conflict: IndexMap, + /// Lowercase names of packages that are pinned to their lock-file version + /// for this resolve (a partial update where the package is not in the + /// update list). Mirrors the `propagateUpdate=false` branch of Composer's + /// `PoolBuilder::loadPackage`: locked-only packages do not pick up + /// `require: "X as Y"` root aliases. Empty for installs and full updates, + /// where every package can take aliases as usual. + pub locked_package_names: IndexSet, + /// Full data of packages pinned to their lock-file version (a partial + /// update). Each entry is added to the pool as a fixed entry, mirroring + /// Composer's `Request::lockPackage` + `PoolBuilder::buildPool`'s + /// `getFixedOrLockedPackages` loop: a locked-only package's pretty/normalized + /// version, requires, replaces, provides and conflicts all enter the pool + /// at exactly one version, so the SAT solver cannot pick a different + /// version (whether directly or via another package's `replace`). Empty + /// for installs and full updates. + pub locked_packages: Vec, + /// When true, drop abandoned packages (`abandoned: true|`) + /// from the pool before solving. Mirrors Composer's + /// `audit.block-abandoned` config feeding into + /// `SecurityAdvisoryPoolFilter`: the resolver simply never sees these + /// versions, so a root requirement that only matches abandoned candidates + /// fails with the standard "could not be resolved" error. + pub block_abandoned: bool, + /// Pretty form of the root's `extra.branch-alias` target when the root's + /// version matches a key in that map (e.g. `dev-master` → `2.0-dev`). + /// Mirrors Composer's `RootAliasPackage`: an extra alias entry is added + /// to the pool exposing the root under the numeric branch-alias version, + /// with `replace`/`provide`/`conflict` links extended to advertise the + /// alias's version for any link originally written as `self.version`. + /// `None` when the root carries no matching `branch-alias` entry. + pub root_branch_alias: Option, + /// `name → normalized version` map fed to the policy's preferred-version + /// override. Used by `update --minimal-changes` so the solver only moves + /// a package when a constraint actually forces a different version. + /// Empty for a normal full update. + pub preferred_versions: IndexMap, + /// When true, drop versions the repositories advertise as covered by an + /// active security advisory before solving. Mirrors Composer's + /// `SecurityAdvisoryPoolFilter` under `config.audit.block-insecure: true`. + pub block_insecure: bool, +} + +/// Full data for a lock-pinned package, used in partial updates. Carried on +/// `ResolveRequest::locked_packages` and turned into a fixed pool entry +/// inside `resolve()`. Mirrors what Composer's `PoolBuilder` reads off a +/// `BasePackage` retrieved from the locked repository. +pub struct LockedPackageInfo { + pub name: String, + /// Pretty (display) version, e.g. "1.2.3". + pub pretty_version: String, + /// Normalized version, e.g. "1.2.3.0". + pub version_normalized: String, + pub requires: Vec<(String, String)>, + pub replaces: Vec<(String, String)>, + pub provides: Vec<(String, String)>, + pub conflicts: Vec<(String, String)>, + /// Branch-alias entries to surface alongside the base locked package, as + /// `(pretty, normalized)` pairs. Mirrors what + /// `Composer\Package\Locker::getLockedRepository` constructs from + /// `extra.branch-alias`: a `dev-master` locked package with branch alias + /// `2.1.x-dev` needs to expose itself under both versions so root + /// constraints like `~2.1` still resolve on a partial update. + pub branch_aliases: Vec<(String, String)>, +} + +/// 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, + /// When `Some`, this entry is an `AliasPackage` rather than a real + /// install target. The value is the target's normalized version, used + /// by lock-file generation to populate the `aliases[]` block (and by + /// the installer to emit `Marking ... as installed, alias of ...` + /// trace lines). Real packages have `alias_of: None`. + pub alias_of_normalized: Option, +} + +/// Run the dependency resolver. +/// +/// Returns a list of resolved packages (excluding root and platform packages), +/// or a human-readable error. +pub async fn resolve(request: &ResolveRequest) -> Result, ResolveError> { + // 1. Build root requirements + let mut root_requires: IndexMap> = IndexMap::new(); + // Per-package stability overrides extracted from `@dev`/`@beta`/etc. + // suffixes on root constraints. Mirrors Composer's + // `RootPackageLoader::extractStabilityFlags`. Merged on top of the + // request's caller-supplied flags (which today are usually empty). + let mut stability_flags: IndexMap = request.stability_flags.clone(); + // Root-level aliases extracted from `require: "X as Y"`. Mirrors + // Composer's `RootPackageLoader::extractAliases`: each entry adds a new + // alias package to the pool exposing the matched real package under the + // RIGHT-hand version label. + let mut root_aliases: Vec = Vec::new(); + + let minimum_stability = request.minimum_stability; + let mut insert_root_require = |name: &str, constraint: &str| { + // Strip every ` as ` clause first (mirrors Composer's + // `parseConstraint` strip + `extractAliases` capture). The cleaned + // constraint feeds the resolver; each alias is recorded for a second + // pool-population pass once real packages are in. Complex constraints + // (`1.*||dev-feature-foo as 1.0.2||^2`) yield one alias entry plus a + // constraint with the ` as ` segment removed in place. + let (constraint_no_as, alias_pieces) = strip_root_alias_clause(constraint); + for (target_atom, alias_atom) in alias_pieces { + let (Some(target_normalized), Some(alias_normalized)) = ( + normalize_root_alias_atom(&target_atom), + normalize_root_alias_atom(&alias_atom), + ) else { + continue; + }; + root_aliases.push(RootAlias { + package: name.to_lowercase(), + version_normalized: target_normalized, + alias: alias_atom, + alias_normalized, + }); + } + let (clean, stability) = extract_stability_suffix(&constraint_no_as); + let lower = name.to_lowercase(); + if let Some(s) = stability { + let entry = stability_flags.entry(lower.clone()).or_insert(s); + if (*entry as u8) > (s as u8) { + *entry = s; + } + } else if let Some(inferred) = infer_constraint_stability(&clean) { + // Mirrors `RootPackageLoader::extractStabilityFlags` second loop: + // when a single-atom constraint like `dev-main` or `1.0.x-dev` + // implies a non-stable stability and no explicit `@flag` was + // given, raise that package's stability ceiling so the pool + // accepts it. Only applied when the inferred level is *more* + // permissive than `minimum_stability` and any existing flag. + if (inferred as u8) > (minimum_stability as u8) { + let entry = stability_flags.entry(lower.clone()).or_insert(inferred); + if (*entry as u8) < (inferred as u8) { + *entry = inferred; + } + } + } + root_requires.insert(lower, Some(clean)); + }; + + for (name, constraint) in &request.require { + if should_skip_platform_dep( + name, + request.ignore_platform_reqs, + &request.ignore_platform_req_list, + ) { + continue; + } + insert_root_require(name, constraint); + } + + if request.include_dev { + for (name, constraint) in &request.require_dev { + if should_skip_platform_dep( + name, + request.ignore_platform_reqs, + &request.ignore_platform_req_list, + ) { + continue; + } + insert_root_require(name, constraint); + } + } + + // Apply temporary constraints (from --with flag or inline shorthand). + // These override existing root constraints or add new ones for transitive deps. + for (name, constraint) in &request.temporary_constraints { + insert_root_require(name, constraint); + } + + // 2. Build pool, generate rules, and solve + let mut builder = PoolBuilder::new(); + + // Set up ignore list for platform requirements + let mut ignore_set: IndexSet = IndexSet::new(); + for name in &request.ignore_platform_req_list { + ignore_set.insert(name.clone()); + } + builder.set_ignore_platform_reqs(ignore_set.clone()); + builder.set_ignore_all_platform_reqs(request.ignore_platform_reqs); + + // Add platform packages as fixed entries + let platform_config = request.platform.to_versions(); + let mut fixed_packages_by_name: IndexMap = IndexMap::new(); + for (name, version) in &platform_config { + if should_skip_platform_dep( + name, + request.ignore_platform_reqs, + &request.ignore_platform_req_list, + ) { + continue; + } + let input = PoolPackageInput { + name: name.clone(), + version: version.to_string(), + pretty_version: version.to_string(), + requires: vec![], + replaces: vec![], + provides: vec![], + conflicts: vec![], + is_fixed: true, + is_alias_of: None, + }; + builder.add_package(input); + } + + // Mirror Composer's `RootPackageRepository`: put the root package itself + // in the pool as a fixed entry so transitive requires pointing at the + // root (legal circular dependencies via an intermediate package) can + // resolve. Composer clears the root's `require` / `require-dev` on this + // copy because the root requires are already plumbed through the + // rule generator's root-require path; carrying them here too would + // emit duplicate rules. Provide / replace links survive, so virtual + // packages declared on the root keep working for transitive consumers. + let root_name_lower = request.root_name.to_lowercase(); + if !root_name_lower.is_empty() { + let (root_pretty, root_normalized) = match request.root_version.as_deref() { + Some(v) if !v.is_empty() => (v.to_string(), v.to_string()), + _ => ("1.0.0+no-version-set".to_string(), "1.0.0.0".to_string()), + }; + // Resolve `self.version` against the root's normalized version when + // building base links. Mirrors Composer's `ArrayLoader::createLink`: + // a `self.version` constraint is parsed against the declaring package's + // pretty version (here, the root's). The base entry only carries this + // resolved form; any branch-alias entry below extends each base link + // with an extra link tagged at the alias's version, matching + // `AliasPackage::replaceSelfVersionDependencies`. + let make_base_links = |raw: &IndexMap| -> Vec { + raw.iter() + .map(|(target, constraint)| PoolLink { + target: target.to_lowercase(), + constraint: if constraint.trim() == "self.version" { + root_normalized.clone() + } else { + constraint.clone() + }, + source: root_name_lower.clone(), + }) + .collect() + }; + let base_replaces = make_base_links(&request.root_replace); + let base_provides = make_base_links(&request.root_provide); + let base_conflicts = make_base_links(&request.root_conflict); + let root_input = PoolPackageInput { + name: root_name_lower.clone(), + version: root_normalized.clone(), + pretty_version: root_pretty.clone(), + requires: vec![], + replaces: base_replaces.clone(), + provides: base_provides.clone(), + conflicts: base_conflicts.clone(), + is_fixed: true, + is_alias_of: None, + }; + builder.add_package(root_input); + + // Materialize a branch-alias entry for the root when `extra.branch-alias` + // mapped this version to a numeric alias (e.g. dev-master → 2.0-dev). + // Mirrors Composer's `RootAliasPackage`: the alias copies the base's + // resolved replace/provide/conflict links and then ADDS one more link + // per `self.version` original, this time pinned at the alias's own + // version. So a transitive `provided/dependency 2.*` lookup can be + // satisfied through the alias even though the base resolved + // `self.version` to a non-matching dev version. + if let Some(alias_pretty) = &request.root_branch_alias + && let Some(alias_normalized) = normalize_branch_alias_target(alias_pretty) + { + let extra_self_version_links = |raw: &IndexMap| -> Vec { + raw.iter() + .filter(|(_, constraint)| constraint.trim() == "self.version") + .map(|(target, _)| PoolLink { + target: target.to_lowercase(), + constraint: alias_normalized.clone(), + source: root_name_lower.clone(), + }) + .collect() + }; + let mut alias_replaces = base_replaces.clone(); + alias_replaces.extend(extra_self_version_links(&request.root_replace)); + let mut alias_provides = base_provides.clone(); + alias_provides.extend(extra_self_version_links(&request.root_provide)); + let mut alias_conflicts = base_conflicts.clone(); + alias_conflicts.extend(extra_self_version_links(&request.root_conflict)); + builder.add_package(PoolPackageInput { + name: root_name_lower.clone(), + version: alias_normalized, + pretty_version: alias_pretty.clone(), + requires: vec![], + replaces: alias_replaces, + provides: alias_provides, + conflicts: alias_conflicts, + is_fixed: false, + is_alias_of: Some(root_normalized), + }); + } + } + + // Add lock-pinned packages as pool entries (partial-update case). + // + // Mirrors Composer's `PoolBuilder::buildPool` flow: every locked package + // not in the `updateAllowList` is added through `Request::lockPackage`, + // then re-entered into the pool via the `getFixedOrLockedPackages` + // loop. Crucially, a *locked* package is NOT a *fixed* package + // (Request.php:89-98): the SAT solver does not force its installation, + // so a locked package whose root require has been removed will simply + // drop out of the result. The locked entry's purpose is to constrain + // the pool to *only* the locked version for that name — every other + // version is filtered out below — so other packages cannot pick a + // different version (whether directly, or via `replace`, which would + // otherwise let an upgraded replacer silently drop the dependency). + // + // Pre-check: a locked package whose version is rejected by the + // current minimum-stability (composer.json may have tightened + // stability or dropped a `stability-flags` entry the lock relied on) + // cannot be reused as a fixed pool entry. Mirrors what Composer + // surfaces via `Pool::isUnacceptableFixedOrLockedPackage` + + // `Problem::getPrettyString`: bail with the "fixed to (lock file + // version) but that version is rejected by your minimum-stability" + // pointer so the user knows to add the package to the update + // arguments (or use `--with-all-dependencies`). + { + let mut rejected: Vec = Vec::new(); + for locked in &request.locked_packages { + let Ok(v) = Version::parse(&locked.version_normalized) else { + continue; + }; + if !passes_stability_filter( + &locked.name, + &v, + request.minimum_stability, + &stability_flags, + ) { + rejected.push(format!( + " - {} is fixed to {} (lock file version) by a partial update but that version is rejected by your minimum-stability. Make sure you list it as an argument for the update command.", + locked.name, locked.pretty_version + )); + } + } + if !rejected.is_empty() { + let report = rejected + .into_iter() + .enumerate() + .map(|(i, msg)| format!(" Problem {}\n{}", i + 1, msg)) + .collect::>() + .join("\n"); + return Err(ResolveError::NoSolution(report)); + } + } + + // Build a map first so the filter below knows which (name, version) + // pairs are the only allowed entries for locked names. Each entry holds + // the locked normalized version plus any branch-alias normalized + // versions Composer's `Locker::getLockedRepository` would expose + // alongside the base. Without the alias entries, an inline-package or + // VCS source providing the same `dev-master` + alias as the lock would + // have its alias filtered out, leaving root constraints like `~2.1` — + // which can only match the alias version, not the raw `dev-master` — + // unsatisfiable on a partial update. + let locked_name_to_versions: IndexMap> = request + .locked_packages + .iter() + .map(|p| { + let mut versions = vec![p.version_normalized.clone()]; + for (_, alias_normalized) in &p.branch_aliases { + versions.push(alias_normalized.clone()); + } + (p.name.to_lowercase(), versions) + }) + .collect(); + let lock_filter_allows = |name: &str, version: &str| -> bool { + match locked_name_to_versions.get(&name.to_lowercase()) { + Some(locked_versions) => locked_versions.iter().any(|v| v == version), + None => true, + } + }; + for locked in &request.locked_packages { + let locked_name_lower = locked.name.to_lowercase(); + let input = PoolPackageInput { + name: locked_name_lower.clone(), + version: locked.version_normalized.clone(), + pretty_version: locked.pretty_version.clone(), + requires: make_pool_links( + &locked_name_lower, + &locked.version_normalized, + &locked.requires, + ), + replaces: make_pool_links( + &locked_name_lower, + &locked.version_normalized, + &locked.replaces, + ), + provides: make_pool_links( + &locked_name_lower, + &locked.version_normalized, + &locked.provides, + ), + conflicts: make_pool_links( + &locked_name_lower, + &locked.version_normalized, + &locked.conflicts, + ), + is_fixed: false, + is_alias_of: None, + }; + builder.add_package(input); + // Also expose each `extra.branch-alias` entry as a separate pool + // package, mirroring `Composer\Package\Locker::getLockedRepository` + // (which calls `ArrayLoader::load`, which materializes the + // branch-alias via `getBranchAlias`). Without this, a `dev-master` + // locked package with branch alias `2.2.x-dev` is only visible + // under `dev-master` in the pool, so root requires like `~2.1` + // see no candidate and the resolver fails on a partial update. + for (alias_pretty, alias_normalized) in &locked.branch_aliases { + builder.add_package(PoolPackageInput { + name: locked_name_lower.clone(), + version: alias_normalized.clone(), + pretty_version: alias_pretty.clone(), + requires: make_pool_links(&locked_name_lower, alias_normalized, &locked.requires), + replaces: make_pool_links(&locked_name_lower, alias_normalized, &locked.replaces), + provides: make_pool_links(&locked_name_lower, alias_normalized, &locked.provides), + conflicts: make_pool_links(&locked_name_lower, alias_normalized, &locked.conflicts), + is_fixed: false, + is_alias_of: Some(locked.version_normalized.clone()), + }); + } + } + + // Scan VCS repositories and collect packages from them + let vcs_packages = vcs_bridge::scan_vcs_repositories(&request.raw_repositories).await; + let mut vcs_package_names: IndexSet = IndexSet::new(); + for vpkg in &vcs_packages { + vcs_package_names.insert(vpkg.name.clone()); + } + + // Add VCS packages to the pool + for vpkg in &vcs_packages { + let inputs = + vcs_bridge::vcs_to_pool_inputs(vpkg, request.minimum_stability, &stability_flags); + for input in inputs { + if !lock_filter_allows(&input.name, &input.version) { + continue; + } + builder.add_package(input); + } + } + + // Collect inline `type: package` repositories. These don't require any + // network fetch, but we mirror Composer's `PackageRepository` (which + // extends `ArrayRepository`) and only emit packages whose own `name` + // matches a queried name — `replace`/`provide` targets do NOT pull in + // their replacers eagerly. So we build a name-indexed lookup and add + // entries to the builder on demand from the seed/transitive loops. + // Loading every inline package up front would let the SAT resolver + // pick a replacer that nothing required by name (e.g. + // `broken-deps-do-not-replace.test`), where Composer would correctly + // surface the broken dependency instead. + let inline_packages = super::inline_package::collect_inline_packages(&request.raw_repositories); + let mut inline_packages_by_name: IndexMap> = + IndexMap::new(); + for ipkg in &inline_packages { + inline_packages_by_name + .entry(ipkg.name.clone()) + .or_default() + .push(ipkg); + } + // Build the security-advisory filter once. Mirrors Composer's + // `SecurityAdvisoryPoolFilter`: when `block-insecure` is on, every + // version listed by a repository's `security-advisories` is removed + // from the pool before solving. + let security_advisories = + super::inline_package::collect_security_advisories(&request.raw_repositories); + let security_blocks_version = |name: &str, version_normalized: &str| -> bool { + if !request.block_insecure { + return false; + } + let Some(advisories) = security_advisories.get(&name.to_lowercase()) else { + return false; + }; + let Ok(parsed) = Version::parse(version_normalized) else { + return false; + }; + advisories.iter().any(|adv| { + VersionConstraint::parse(&adv.affected_versions) + .map(|c| c.matches(&parsed)) + .unwrap_or(false) + }) + }; + // Mirrors Composer's `PoolBuilder::markPackageNameForLoading`: a root + // require's constraint caps every load of that name. Transitive deps that + // would otherwise pull in an out-of-range version (e.g. `foo/requirer` + // requires `foo/original 1.0.0` while the root pinned it at `3.0.0`) are + // silently filtered down to the root-required range, so the pool never + // sees a candidate the root forbids. Without this, providers that satisfy + // the root require can coexist with the actual package at the wrong + // version, masking what should be a conflict. + // + // The match check considers both the base version and any branch-alias + // entries it expands to — mirrors `ArrayRepository::loadPackages`, which + // pulls in the base whenever any of its aliases satisfies the constraint + // (and vice-versa). Skipping the base when only an alias matches would + // leave the alias dangling. + let add_inline_for = |name: &str, + load_constraint: Option<&VersionConstraint>, + builder: &mut PoolBuilder| + -> bool { + let Some(packages) = inline_packages_by_name.get(name) else { + return false; + }; + for ipkg in packages { + if request.block_abandoned && is_abandoned(&ipkg.version) { + continue; + } + if security_blocks_version(&ipkg.name, &ipkg.version.version_normalized) { + continue; + } + let inputs = packagist_to_pool_inputs( + &ipkg.name, + &ipkg.version, + request.minimum_stability, + &stability_flags, + ); + if let Some(c) = load_constraint { + let any_matches = inputs.iter().any(|input| { + Version::parse(&input.version) + .map(|v| c.matches(&v)) + .unwrap_or(false) + }); + if !any_matches { + continue; + } + } + for input in inputs { + if !lock_filter_allows(&input.name, &input.version) { + continue; + } + builder.add_package(input); + } + } + true + }; + + // Pre-parse root-require constraints once. Reused for every name lookup + // in the seed + transitive loops below. + let root_require_constraints: IndexMap = root_requires + .iter() + .filter_map(|(name, c)| { + c.as_deref() + .and_then(|s| VersionConstraint::parse(s).ok()) + .map(|vc| (name.clone(), vc)) + }) + .collect(); + + // Collect packages from `type: composer` repositories with file:// URLs. + // The harness rewrites `file://foobar` to `file:///abs/path` before this + // call so the read can be a plain `std::fs::read_to_string`. Same idea + // as inline packages — they bypass the RepositorySet and go straight + // into the pool, with names recorded so Packagist loops skip them. + let composer_repo_packages = + super::composer_repo::collect_composer_packages(&request.raw_repositories); + let mut composer_repo_names: IndexSet = IndexSet::new(); + for cpkg in &composer_repo_packages { + composer_repo_names.insert(cpkg.name.clone()); + if request.block_abandoned && is_abandoned(&cpkg.version) { + continue; + } + let inputs = packagist_to_pool_inputs( + &cpkg.name, + &cpkg.version, + request.minimum_stability, + &stability_flags, + ); + for input in inputs { + if !lock_filter_allows(&input.name, &input.version) { + continue; + } + builder.add_package(input); + } + } + + // The repository set is supplied by the caller. Today production + // builders pass a single-Packagist set; in-process tests can pass a + // set with no HTTP-backed repos. VCS and inline packages above are + // still preloaded directly, and their names go into the skip lists so + // we don't double-load them through this set. + let repo_set: &RepositorySet = &request.repositories; + + // Seed the builder with packages for root requirements. Inline + // `type: package` matches are added directly via the name-indexed + // lookup; everything else falls through to the network-backed + // repository set. + let seed_names: Vec = root_requires + .keys() + .filter(|name| !PackageName((*name).clone()).is_platform()) + .filter(|name| !vcs_package_names.contains(*name) && !composer_repo_names.contains(*name)) + .cloned() + .collect(); + let mut seed_queries: Vec> = Vec::new(); + for name in &seed_names { + let load_constraint = root_require_constraints.get(name); + if add_inline_for(name.as_str(), load_constraint, &mut builder) { + continue; + } + seed_queries.push(PackageQuery { + name: name.as_str(), + constraint: root_requires.get(name).and_then(|c| c.as_deref()), + }); + } + let seed_results = repo_set + .load_packages(&seed_queries) + .await + .map_err(|e| ResolveError::DependencyFetchError(e.to_string()))?; + for r in &seed_results { + if request.block_abandoned && is_abandoned(&r.version) { + continue; + } + let inputs = packagist_to_pool_inputs( + &r.name, + &r.version, + request.minimum_stability, + &stability_flags, + ); + for input in inputs { + if !lock_filter_allows(&input.name, &input.version) { + continue; + } + builder.add_package(input); + } + } + + // Explore transitive dependencies. + while let Some(name) = builder.next_pending() { + if PackageName(name.clone()).is_platform() { + continue; + } + + // Skip packages already provided by VCS or `type: composer` repos + // (those still get eager-loaded above). Inline `type: package` + // matches are loaded on demand by name, mirroring Composer's + // ArrayRepository semantics. + if vcs_package_names.contains(&name) || composer_repo_names.contains(&name) { + continue; + } + let load_constraint = root_require_constraints.get(&name); + if add_inline_for(name.as_str(), load_constraint, &mut builder) { + continue; + } + + let queries = [PackageQuery { + name: name.as_str(), + constraint: root_requires.get(&name).and_then(|c| c.as_deref()), + }]; + let results = match repo_set.load_packages(&queries).await { + Ok(v) => v, + Err(_) => { + // Virtual/meta packages (e.g. "psr/http-client-implementation") + // don't exist on Packagist. They are resolved via provides/replaces + // from other packages already in the pool. + continue; + } + }; + for r in &results { + if request.block_abandoned && is_abandoned(&r.version) { + continue; + } + let inputs = packagist_to_pool_inputs( + &r.name, + &r.version, + request.minimum_stability, + &request.stability_flags, + ); + for input in inputs { + if !lock_filter_allows(&input.name, &input.version) { + continue; + } + builder.add_package(input); + } + } + } + + // Second pass: materialize root aliases (`require: "X as Y"`). + // + // Mirrors Composer's `PoolBuilder::loadPackage` post-load step: when a + // package whose `(name, version)` matches a `rootAliases` entry is added, + // an extra `AliasPackage` exposing that install under + // `(alias_normalized, alias)` is appended to the pool. When the matched + // input is already an alias (e.g. an `extra.branch-alias` entry from + // `packagist_to_pool_inputs`), Composer follows `getAliasOf()` to the + // base package — we replicate by carrying the input's `is_alias_of` + // value forward, so the new alias points straight at the real package + // rather than chaining through the intermediate alias. + if !root_aliases.is_empty() { + let mut new_aliases: Vec = Vec::new(); + for input in builder.inputs() { + // Skip alias creation for packages locked to their lock-file + // version (partial update where this package wasn't requested). + // Mirrors Composer's `propagateUpdate=false` skip in + // `PoolBuilder::loadPackage`. + if request + .locked_package_names + .contains(&input.name.to_lowercase()) + { + continue; + } + for alias in &root_aliases { + if input.name.to_lowercase() != alias.package { + continue; + } + if input.version != alias.version_normalized { + continue; + } + let target_normalized = input + .is_alias_of + .clone() + .unwrap_or_else(|| input.version.clone()); + // Extend `self.version`-derived `replace` / `provide` / + // `conflict` links with an extra entry pinned at the + // alias's own version. Mirrors Composer's + // `AliasPackage::replaceSelfVersionDependencies`: a base + // link whose constraint matches the base's own version + // (the resolved form of `self.version`) is duplicated + // under the alias at the alias's version, so a transitive + // require like `a/aliased-replaced ^4.0` can match the + // alias even when the base is at a non-matching dev + // version. Without this, the alias's replace map keeps + // the base's `dev-next` constraint and the requirement + // never sees a numeric provider. + let alias_extra_self_links = |links: &[PoolLink]| -> Vec { + links + .iter() + .filter(|l| l.constraint == input.version) + .map(|l| PoolLink { + target: l.target.clone(), + constraint: alias.alias_normalized.clone(), + source: l.source.clone(), + }) + .collect() + }; + let mut alias_replaces = input.replaces.clone(); + alias_replaces.extend(alias_extra_self_links(&input.replaces)); + let mut alias_provides = input.provides.clone(); + alias_provides.extend(alias_extra_self_links(&input.provides)); + let mut alias_conflicts = input.conflicts.clone(); + alias_conflicts.extend(alias_extra_self_links(&input.conflicts)); + new_aliases.push(PoolPackageInput { + name: input.name.clone(), + version: alias.alias_normalized.clone(), + pretty_version: alias.alias.clone(), + requires: input.requires.clone(), + replaces: alias_replaces, + provides: alias_provides, + conflicts: alias_conflicts, + is_fixed: false, + is_alias_of: Some(target_normalized), + }); + } + } + for alias_input in new_aliases { + builder.add_package(alias_input); + } + } + + // Build the pool + let mut pool = builder.build(); + // Collect fixed package IDs + let mut fixed_ids: Vec = Vec::new(); + for pkg in pool.packages() { + if pkg.is_fixed { + fixed_ids.push(pkg.id); + fixed_packages_by_name.insert(pkg.name.clone(), pkg.id); + } + } + + // Generate rules + let mut generator = RuleSetGenerator::new(&mut pool); + generator.set_ignore_platform_reqs(ignore_set); + generator.set_ignore_all_platform_reqs(request.ignore_platform_reqs); + let (rules, missing_root_requires) = generator.generate( + &root_requires, + &fixed_ids, + &request.root_provide, + &request.root_replace, + ); + + // Mirror Composer's `Solver::checkForRootRequireProblems`: a root require + // with no providers in the pool yields no SAT rule, so the solver would + // succeed with an empty plan. Surface it as an unresolvable problem + // instead, matching Composer's exit code 2 behaviour. + if !missing_root_requires.is_empty() { + let problems: Vec = missing_root_requires + .iter() + .map(|(name, constraint)| match constraint.as_deref() { + Some(c) if !c.is_empty() => format!( + " - Root composer.json requires {name} {c}, no matching package found." + ), + _ => { + format!(" - Root composer.json requires {name}, no matching package found.") + } + }) + .collect(); + let report = problems + .into_iter() + .enumerate() + .map(|(i, msg)| format!(" Problem {}\n{}", i + 1, msg)) + .collect::>() + .join("\n"); + return Err(ResolveError::NoSolution(report)); + } + + // Create policy and solve. When `preferred_versions` is non-empty (the + // `--minimal-changes` flow) feed it through the policy so the locked + // version wins over the regular highest/lowest pick whenever a candidate + // matches it. Mirrors Composer's + // `Installer::createPolicy` minimal-update branch. + let policy = if request.preferred_versions.is_empty() { + DefaultPolicy::new(request.prefer_stable, request.prefer_lowest) + } else { + DefaultPolicy::with_preferred( + request.prefer_stable, + request.prefer_lowest, + request.preferred_versions.clone(), + ) + }; + let fixed_set: IndexSet = fixed_ids.into_iter().collect(); + let solver = Solver::new(rules, &pool, policy, fixed_set); + + match solver.solve() { + Ok(result) => { + let mut resolved = Vec::new(); + for pkg_id in result.installed { + let pkg = pool.package_by_id(pkg_id); + + // Skip platform packages from output + if PackageName(pkg.name.clone()).is_platform() { + continue; + } + + // Skip the root package itself. It's in the pool as a fixed + // entry only so transitive requires pointing back at it + // can resolve; it must not appear in the lock file or + // operations list. Mirrors Composer's `LockTransaction` + // which discards fixed packages from the result. + if !root_name_lower.is_empty() && pkg.name == root_name_lower { + continue; + } + + let is_dev = if let Ok(v) = Version::parse(&pkg.version) { + version_stability(&v) == Stability::Dev + } else { + false + }; + + let alias_of_normalized = pkg + .is_alias_of + .map(|tid| pool.package_by_id(tid).version.clone()); + + resolved.push(ResolvedPackage { + name: pkg.name.clone(), + version: pkg.pretty_version.clone(), + version_normalized: pkg.version.clone(), + is_dev, + alias_of_normalized, + }); + } + Ok(resolved) + } + Err(e) => Err(ResolveError::NoSolution(e.to_string())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v(major: u64, minor: u64, patch: u64, build: u64) -> Version { + Version { + major, + minor, + patch, + build, + pre_release: None, + is_dev_branch: false, + dev_branch_name: None, + } + } + + fn v_pre(major: u64, minor: u64, patch: u64, build: u64, pre: &str) -> Version { + Version { + major, + minor, + patch, + build, + pre_release: Some(pre.to_string()), + is_dev_branch: false, + dev_branch_name: None, + } + } + + #[test] + fn test_parse_normalized_stable() { + let ver = parse_normalized("1.2.3.0").unwrap(); + assert_eq!((ver.major, ver.minor, ver.patch, ver.build), (1, 2, 3, 0)); + assert_eq!(ver.pre_release, None); + } + + #[test] + fn test_parse_normalized_beta() { + let ver = parse_normalized("1.0.0.0-beta1").unwrap(); + assert_eq!(ver.major, 1); + assert_eq!(ver.pre_release, Some("beta1".to_string())); + } + + #[test] + fn test_parse_normalized_rc() { + let ver = parse_normalized("2.0.0.0-RC3").unwrap(); + assert_eq!(ver.major, 2); + assert_eq!(ver.pre_release, Some("RC3".to_string())); + } + + #[test] + fn test_parse_normalized_alpha() { + let ver = parse_normalized("1.0.0.0-alpha2").unwrap(); + assert_eq!(ver.pre_release, Some("alpha2".to_string())); + } + + #[test] + fn test_parse_normalized_dev() { + let ver = parse_normalized("1.0.0.0-dev").unwrap(); + assert_eq!(ver.pre_release, Some("dev".to_string())); + } + + #[test] + fn test_parse_normalized_dev_branch() { + let ver = parse_normalized("dev-master"); + assert!( + ver.is_none(), + "dev-master should not parse as normalized version" + ); + } + + #[test] + fn test_parse_normalized_x_dev() { + let ver = parse_normalized("dev-feature/foo"); + assert!(ver.is_none()); + } + + #[test] + fn test_parse_normalized_9999999_dev() { + let ver = parse_normalized("9999999.9999999.9999999.9999999-dev"); + assert!(ver.is_none()); + } + + #[test] + fn test_parse_normalized_large_version() { + let ver = parse_normalized("20031129").unwrap(); + assert_eq!(ver.major, 20031129); + assert_eq!(ver.pre_release, None); + } + + #[test] + fn test_version_ordering_stable() { + let v1 = parse_normalized("2.0.0.0").unwrap(); + let v2 = parse_normalized("1.0.0.0").unwrap(); + assert!(v1 > v2); + } + + #[test] + fn test_version_ordering_stability() { + let stable = parse_normalized("1.0.0.0").unwrap(); + let rc = parse_normalized("1.0.0.0-RC1").unwrap(); + let beta = parse_normalized("1.0.0.0-beta1").unwrap(); + let alpha = parse_normalized("1.0.0.0-alpha1").unwrap(); + let dev = parse_normalized("1.0.0.0-dev").unwrap(); + assert!(stable > rc); + assert!(rc > beta); + assert!(beta > alpha); + assert!(alpha > dev); + } + + #[test] + fn test_version_ordering_pre_number() { + let beta2 = parse_normalized("1.0.0.0-beta2").unwrap(); + let beta1 = parse_normalized("1.0.0.0-beta1").unwrap(); + assert!(beta2 > beta1); + } + + #[test] + fn test_version_display() { + let stable = v(1, 2, 3, 0); + assert_eq!(format!("{stable}"), "1.2.3.0"); + + let beta1 = v_pre(1, 0, 0, 0, "beta1"); + assert_eq!(format!("{beta1}"), "1.0.0.0-beta1"); + + let rc2 = v_pre(2, 0, 0, 0, "RC2"); + assert_eq!(format!("{rc2}"), "2.0.0.0-RC2"); + + let dev = v_pre(1, 0, 0, 0, "dev"); + assert_eq!(format!("{dev}"), "1.0.0.0-dev"); + } + + #[test] + fn test_version_stability_fn() { + assert_eq!(version_stability(&v(1, 0, 0, 0)), Stability::Stable); + assert_eq!(version_stability(&v_pre(1, 0, 0, 0, "RC1")), Stability::RC); + assert_eq!( + version_stability(&v_pre(1, 0, 0, 0, "beta1")), + Stability::Beta + ); + assert_eq!( + version_stability(&v_pre(1, 0, 0, 0, "alpha1")), + Stability::Alpha + ); + assert_eq!(version_stability(&v_pre(1, 0, 0, 0, "dev")), Stability::Dev); + assert_eq!( + version_stability(&v_pre(1, 0, 0, 0, "patch1")), + Stability::Stable + ); + } + + #[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("composer".to_string()).is_platform()); + assert!(PackageName("composer-plugin-api".to_string()).is_platform()); + assert!(PackageName("composer-runtime-api".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_stability_filter() { + let stable_v = v(1, 0, 0, 0); + let alpha_v = v_pre(1, 1, 0, 0, "alpha1"); + let beta_v = v_pre(1, 0, 0, 0, "beta1"); + let rc_v = v_pre(1, 0, 0, 0, "RC1"); + let dev_v = v_pre(1, 0, 0, 0, "dev"); + + let flags = IndexMap::new(); + + assert!(passes_stability_filter( + "foo/foo", + &stable_v, + Stability::Stable, + &flags + )); + assert!(!passes_stability_filter( + "foo/foo", + &alpha_v, + Stability::Stable, + &flags + )); + assert!(!passes_stability_filter( + "foo/foo", + &beta_v, + Stability::Stable, + &flags + )); + assert!(!passes_stability_filter( + "foo/foo", + &rc_v, + Stability::Stable, + &flags + )); + assert!(!passes_stability_filter( + "foo/foo", + &dev_v, + Stability::Stable, + &flags + )); + } + + #[test] + fn test_stability_filter_beta() { + let stable_v = v(1, 0, 0, 0); + let beta_v = v_pre(1, 0, 0, 0, "beta1"); + let alpha_v = v_pre(1, 0, 0, 0, "alpha1"); + let dev_v = v_pre(1, 0, 0, 0, "dev"); + + let flags = IndexMap::new(); + + assert!(passes_stability_filter( + "foo/foo", + &stable_v, + Stability::Beta, + &flags + )); + assert!(passes_stability_filter( + "foo/foo", + &beta_v, + Stability::Beta, + &flags + )); + assert!(!passes_stability_filter( + "foo/foo", + &alpha_v, + Stability::Beta, + &flags + )); + assert!(!passes_stability_filter( + "foo/foo", + &dev_v, + Stability::Beta, + &flags + )); + } + + #[test] + fn test_stability_filter_dev() { + let dev_v = v_pre(1, 0, 0, 0, "dev"); + let flags = IndexMap::new(); + assert!(passes_stability_filter( + "foo/foo", + &dev_v, + Stability::Dev, + &flags + )); + } + + #[test] + fn test_skip_platform_dep() { + assert!(should_skip_platform_dep("php", true, &[])); + assert!(should_skip_platform_dep("ext-json", true, &[])); + assert!(!should_skip_platform_dep("monolog/monolog", true, &[])); + } + + #[test] + fn test_skip_specific_platform_dep() { + let list = vec!["ext-intl".to_string()]; + assert!(should_skip_platform_dep("ext-intl", false, &list)); + assert!(!should_skip_platform_dep("ext-json", false, &list)); + assert!(!should_skip_platform_dep("php", false, &list)); + assert!(!should_skip_platform_dep("monolog/monolog", false, &list)); + } + + #[test] + fn test_parse_branch_alias_target_x_dev() { + let ver = parse_branch_alias_target("2.x-dev").unwrap(); + assert_eq!((ver.major, ver.minor, ver.patch, ver.build), (2, 0, 0, 0)); + assert_eq!(ver.pre_release, Some("dev".to_string())); + } + + #[test] + fn test_parse_branch_alias_target_minor_x_dev() { + let ver = parse_branch_alias_target("1.5.x-dev").unwrap(); + assert_eq!((ver.major, ver.minor, ver.patch), (1, 5, 0)); + assert_eq!(ver.pre_release, Some("dev".to_string())); + } + + #[test] + fn test_parse_branch_alias_target_patch_x_dev() { + let ver = parse_branch_alias_target("1.0.2.x-dev").unwrap(); + assert_eq!((ver.major, ver.minor, ver.patch), (1, 0, 2)); + assert_eq!(ver.pre_release, Some("dev".to_string())); + } + + #[test] + fn test_parse_branch_alias_target_invalid() { + assert!(parse_branch_alias_target("dev-master").is_none()); + assert!(parse_branch_alias_target("2.0.0").is_none()); + assert!(parse_branch_alias_target("").is_none()); + } + + #[test] + fn test_sat_resolve_simple_offline() { + use crate::dependency_resolver::*; + + let mut pool = Pool::new( + vec![ + PoolPackageInput { + name: "foo/foo".to_string(), + version: "1.0.0.0".to_string(), + pretty_version: "1.0.0".to_string(), + requires: vec![PoolLink { + target: "bar/bar".to_string(), + constraint: "^2.0".to_string(), + source: "foo/foo".to_string(), + }], + replaces: vec![], + provides: vec![], + conflicts: vec![], + is_fixed: false, + is_alias_of: None, + }, + PoolPackageInput { + name: "bar/bar".to_string(), + version: "2.0.0.0".to_string(), + pretty_version: "2.0.0".to_string(), + requires: vec![], + replaces: vec![], + provides: vec![], + conflicts: vec![], + is_fixed: false, + is_alias_of: None, + }, + ], + vec![], + ); + + let mut requires = IndexMap::new(); + requires.insert("foo/foo".to_string(), Some("^1.0".to_string())); + + let generator = RuleSetGenerator::new(&mut pool); + let (rules, _) = generator.generate(&requires, &[], &IndexMap::new(), &IndexMap::new()); + + let policy = DefaultPolicy::default(); + let solver = Solver::new(rules, &pool, policy, IndexSet::new()); + let result = solver.solve().unwrap(); + + // Should install foo/foo (id=1) and bar/bar (id=2) + assert!(result.installed.contains(&1)); + assert!(result.installed.contains(&2)); + } + + #[tokio::test] + #[ignore] + async fn test_resolve_monolog_e2e() { + use super::super::cache::Cache; + let request = ResolveRequest { + root_name: String::new(), + root_version: None, + require: vec![("monolog/monolog".to_string(), "^3.0".to_string())], + require_dev: vec![], + include_dev: false, + minimum_stability: Stability::Stable, + stability_flags: IndexMap::new(), + prefer_stable: true, + prefer_lowest: false, + platform: PlatformConfig::new(), + ignore_platform_reqs: false, + ignore_platform_req_list: vec![], + repositories: Arc::new(RepositorySet::with_packagist(Cache::new( + std::env::temp_dir().join("mozart-test-cache"), + false, + ))), + temporary_constraints: IndexMap::new(), + raw_repositories: vec![], + root_provide: IndexMap::new(), + root_replace: IndexMap::new(), + root_conflict: IndexMap::new(), + locked_package_names: IndexSet::new(), + locked_packages: Vec::new(), + block_abandoned: false, + root_branch_alias: None, + preferred_versions: IndexMap::new(), + block_insecure: false, + }; + + let result = resolve(&request).await; + 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-core/src/repository/vcs_bridge.rs b/crates/mozart-core/src/repository/vcs_bridge.rs new file mode 100644 index 0000000..37d066b --- /dev/null +++ b/crates/mozart-core/src/repository/vcs_bridge.rs @@ -0,0 +1,216 @@ +//! Bridge between `mozart-vcs` and `mozart-registry`. +//! +//! Scans VCS repositories defined in composer.json and converts +//! discovered package versions into pool inputs for the SAT resolver. + +use super::packagist::PackagistVersion; +use super::resolver::{parse_normalized, version_stability}; +use crate::dependency_resolver::{PoolPackageInput, make_pool_links}; +use crate::package::{RawRepository, Stability}; +use crate::vcs::driver::DriverConfig; +use crate::vcs::repository::{VcsPackageVersion, VcsRepository}; +use indexmap::IndexMap; +use std::collections::BTreeMap; + +/// Scan all VCS-type repositories and collect package versions. +/// +/// Non-VCS repos (e.g. "composer", "package") are silently skipped. +pub async fn scan_vcs_repositories(repositories: &[RawRepository]) -> Vec { + let config = DriverConfig::default(); + let mut all_versions = Vec::new(); + + for repo in repositories { + let repo_type = repo.repo_type.as_str(); + match repo_type { + "vcs" | "git" | "svn" | "hg" | "github" | "gitlab" | "bitbucket" | "forgejo" => {} + _ => continue, + } + + let forced_type = match repo_type { + "vcs" => None, + other => Some(other), + }; + + // VCS repositories require `url`; skip silently if missing (Composer + // would reject this earlier in RepositoryFactory). + let Some(url) = repo.url.clone() else { + continue; + }; + + let vcs_repo = VcsRepository::new(url.clone(), forced_type, config.clone()); + + match vcs_repo.scan().await { + Ok(versions) => { + all_versions.extend(versions); + } + Err(e) => { + eprintln!("Warning: Failed to scan VCS repository {url}: {e}"); + } + } + } + + all_versions +} + +/// Convert a VCS package version to SAT pool inputs. +pub fn vcs_to_pool_inputs( + vpkg: &VcsPackageVersion, + minimum_stability: Stability, + stability_flags: &IndexMap, +) -> Vec { + let mut results = Vec::new(); + + // Extract dependency links from composer.json + let require = extract_dep_map(&vpkg.composer_json, "require"); + let replace = extract_dep_map(&vpkg.composer_json, "replace"); + let provide = extract_dep_map(&vpkg.composer_json, "provide"); + let conflict = extract_dep_map(&vpkg.composer_json, "conflict"); + + let input = PoolPackageInput { + name: vpkg.name.clone(), + version: vpkg.version_normalized.clone(), + pretty_version: vpkg.version.clone(), + requires: make_pool_links( + &vpkg.name, + &vpkg.version_normalized, + &require + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::>(), + ), + replaces: make_pool_links( + &vpkg.name, + &vpkg.version_normalized, + &replace + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::>(), + ), + provides: make_pool_links( + &vpkg.name, + &vpkg.version_normalized, + &provide + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::>(), + ), + conflicts: make_pool_links( + &vpkg.name, + &vpkg.version_normalized, + &conflict + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::>(), + ), + is_fixed: false, + is_alias_of: None, + }; + + // Apply stability filtering + if let Some(v) = parse_normalized(&vpkg.version_normalized) { + if passes_vcs_stability_filter(&vpkg.name, &v, minimum_stability, stability_flags) { + results.push(input); + } + } else { + // Dev version: always include (dev stability) + let pkg_flag = stability_flags.get(&vpkg.name.to_lowercase()); + let allowed = pkg_flag.copied().unwrap_or(minimum_stability); + if allowed >= Stability::Dev { + results.push(input); + } + } + + results +} + +/// Convert a `VcsPackageVersion` into a `PackagistVersion` for lockfile generation. +pub fn vcs_to_packagist_version(vpkg: &VcsPackageVersion) -> PackagistVersion { + PackagistVersion { + version: vpkg.version.clone(), + version_normalized: vpkg.version_normalized.clone(), + require: extract_dep_map(&vpkg.composer_json, "require"), + replace: extract_dep_map(&vpkg.composer_json, "replace"), + provide: extract_dep_map(&vpkg.composer_json, "provide"), + conflict: extract_dep_map(&vpkg.composer_json, "conflict"), + dist: vpkg.dist.as_ref().map(|d| super::packagist::PackagistDist { + dist_type: d.dist_type.clone(), + url: d.url.clone(), + reference: Some(d.reference.clone()), + shasum: d.shasum.clone(), + }), + source: Some(super::packagist::PackagistSource { + source_type: vpkg.source.source_type.clone(), + url: vpkg.source.url.clone(), + reference: Some(vpkg.source.reference.clone()), + }), + require_dev: extract_dep_map(&vpkg.composer_json, "require-dev"), + suggest: vpkg + .composer_json + .get("suggest") + .and_then(|v| serde_json::from_value(v.clone()).ok()), + package_type: vpkg + .composer_json + .get("type") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + autoload: vpkg.composer_json.get("autoload").cloned(), + autoload_dev: vpkg.composer_json.get("autoload-dev").cloned(), + license: vpkg + .composer_json + .get("license") + .and_then(|v| serde_json::from_value(v.clone()).ok()), + description: vpkg + .composer_json + .get("description") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + homepage: vpkg + .composer_json + .get("homepage") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + keywords: vpkg + .composer_json + .get("keywords") + .and_then(|v| serde_json::from_value(v.clone()).ok()), + authors: vpkg + .composer_json + .get("authors") + .and_then(|v| serde_json::from_value(v.clone()).ok()), + support: vpkg.composer_json.get("support").cloned(), + funding: vpkg + .composer_json + .get("funding") + .and_then(|v| serde_json::from_value(v.clone()).ok()), + time: vpkg.time.clone(), + extra: vpkg.composer_json.get("extra").cloned(), + notification_url: None, + default_branch: vpkg.is_default_branch, + abandoned: vpkg.composer_json.get("abandoned").cloned(), + } +} + +/// Extract a dependency map from composer.json JSON. +fn extract_dep_map(json: &serde_json::Value, key: &str) -> BTreeMap { + json.get(key) + .and_then(|v| v.as_object()) + .map(|obj| { + obj.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + }) + .unwrap_or_default() +} + +/// Stability filter for VCS packages (mirrors resolver logic). +fn passes_vcs_stability_filter( + package_name: &str, + version: &mozart_semver::Version, + minimum_stability: Stability, + stability_flags: &IndexMap, +) -> bool { + let stability = version_stability(version); + let pkg_flag = stability_flags.get(&package_name.to_lowercase()); + let allowed = pkg_flag.copied().unwrap_or(minimum_stability); + stability <= allowed +} diff --git a/crates/mozart-core/src/repository/version.rs b/crates/mozart-core/src/repository/version.rs new file mode 100644 index 0000000..143131a --- /dev/null +++ b/crates/mozart-core/src/repository/version.rs @@ -0,0 +1,269 @@ +use super::super::package::Stability; +use super::packagist::PackagistVersion; +use std::cmp::Ordering; + +/// Determine the stability of a normalized version string. +pub fn stability_of(version_normalized: &str) -> Stability { + let v = version_normalized.to_lowercase(); + if v.starts_with("dev-") || v.ends_with("-dev") { + return Stability::Dev; + } + // Check for pre-release suffixes: alpha, beta, RC + // Normalized versions use formats like "1.0.0.0-alpha1", "1.0.0.0-beta2", "1.0.0.0-RC1" + if let Some(pos) = v.rfind('-') { + let suffix = &v[pos + 1..]; + if suffix.starts_with("alpha") { + return Stability::Alpha; + } + if suffix.starts_with("beta") { + return Stability::Beta; + } + if suffix.starts_with("rc") || suffix.starts_with("RC") { + return Stability::RC; + } + } + Stability::Stable +} + +/// Compare two normalized version strings (e.g. "1.2.3.0" vs "1.2.4.0"). +/// +/// Each version is split into numeric parts. Non-numeric suffixes (like "-beta1") +/// are handled by treating the base parts as numeric and the suffix separately. +pub fn compare_normalized_versions(a: &str, b: &str) -> Ordering { + let parse = |v: &str| -> (Vec, Option) { + // Split off any pre-release suffix + let (base, suffix) = if let Some(pos) = v.find('-') { + (&v[..pos], Some(v[pos + 1..].to_string())) + } else { + (v, None) + }; + let parts: Vec = base.split('.').filter_map(|p| p.parse().ok()).collect(); + (parts, suffix) + }; + + let (a_parts, a_suffix) = parse(a); + let (b_parts, b_suffix) = parse(b); + + // Compare numeric parts + let max_len = a_parts.len().max(b_parts.len()); + for i in 0..max_len { + let a_val = a_parts.get(i).copied().unwrap_or(0); + let b_val = b_parts.get(i).copied().unwrap_or(0); + match a_val.cmp(&b_val) { + Ordering::Equal => continue, + other => return other, + } + } + + // If numeric parts are equal, compare stability + // A stable version (no suffix) is greater than a pre-release + match (&a_suffix, &b_suffix) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Greater, // stable > pre-release + (Some(_), None) => Ordering::Less, // pre-release < stable + (Some(a_s), Some(b_s)) => { + let stab_a = stability_of(&format!("0.0.0.0-{a_s}")); + let stab_b = stability_of(&format!("0.0.0.0-{b_s}")); + // Lower stability value = more stable = greater version + match stab_a.cmp(&stab_b) { + Ordering::Equal => a_s.cmp(b_s), + // Stability enum: Stable(0) < RC(5) < Beta(10) < Alpha(15) < Dev(20) + // But more stable = higher version, so we reverse + Ordering::Less => Ordering::Greater, + Ordering::Greater => Ordering::Less, + } + } + } +} + +/// Find the best version candidate given a preferred minimum stability. +/// +/// Returns the highest version whose stability is at least as stable as +/// the preferred stability (i.e., stability value <= preferred value). +pub fn find_best_candidate( + versions: &[PackagistVersion], + preferred_stability: Stability, +) -> Option<&PackagistVersion> { + versions + .iter() + .filter(|v| stability_of(&v.version_normalized) <= preferred_stability) + .max_by(|a, b| compare_normalized_versions(&a.version_normalized, &b.version_normalized)) +} + +/// Generate a recommended version constraint string from a concrete version. +/// +/// Examples: +/// - `"1.2.1"` (stable) → `"^1.2"` +/// - `"0.3.5"` (stable) → `"^0.3"` +/// - `"2.0.0-beta.1"` (beta) → `"^2.0@beta"` +/// - `"dev-master"` (dev) → `"dev-master"` +pub fn find_recommended_require_version( + version: &str, + version_normalized: &str, + stability: Stability, +) -> String { + // dev branches are returned as-is + if stability == Stability::Dev { + return version.to_string(); + } + + // Extract major.minor from the normalized version (e.g. "1.2.3.0" → "1.2") + let base = if let Some(pos) = version_normalized.find('-') { + &version_normalized[..pos] + } else { + version_normalized + }; + + let parts: Vec<&str> = base.split('.').collect(); + let major = parts.first().copied().unwrap_or("0"); + let minor = parts.get(1).copied().unwrap_or("0"); + + let constraint = format!("^{major}.{minor}"); + + match stability { + Stability::Stable => constraint, + Stability::RC => format!("{constraint}@RC"), + Stability::Beta => format!("{constraint}@beta"), + Stability::Alpha => format!("{constraint}@alpha"), + Stability::Dev => format!("{constraint}@dev"), + } +} + +#[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, + default_branch: false, + abandoned: 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-core/src/repository/version_selector.rs b/crates/mozart-core/src/repository/version_selector.rs new file mode 100644 index 0000000..506c503 --- /dev/null +++ b/crates/mozart-core/src/repository/version_selector.rs @@ -0,0 +1,48 @@ +use super::super::package::Stability; +use super::cache::Cache; +use super::packagist::{self, PackagistVersion}; +use super::version; + +/// Mirrors `Composer\Package\Version\VersionSelector`. +pub struct VersionSelector { + preferred_stability: Stability, + repo_cache: Cache, +} + +impl VersionSelector { + pub fn new(preferred_stability: Stability, repo_cache: Cache) -> Self { + Self { + preferred_stability, + repo_cache, + } + } + + /// Fetch versions from Packagist and pick the best candidate. + /// Mirrors `VersionSelector::findBestCandidate()`. + pub async fn find_best_candidate( + &self, + package_name: &str, + ) -> anyhow::Result> { + let versions = packagist::fetch_package_versions(package_name, &self.repo_cache).await?; + Ok(version::find_best_candidate(&versions, self.preferred_stability).cloned()) + } + + /// Generate a recommended constraint string from a concrete version. + /// Mirrors `VersionSelector::findRecommendedRequireVersion()`. + pub fn find_recommended_require_version_string( + &self, + pkg: &PackagistVersion, + fixed: bool, + ) -> String { + if fixed { + pkg.version.clone() + } else { + let stability = version::stability_of(&pkg.version_normalized); + version::find_recommended_require_version( + &pkg.version, + &pkg.version_normalized, + stability, + ) + } + } +} diff --git a/crates/mozart-core/src/vcs.rs b/crates/mozart-core/src/vcs.rs new file mode 100644 index 0000000..e7ca383 --- /dev/null +++ b/crates/mozart-core/src/vcs.rs @@ -0,0 +1,6 @@ +pub mod downloader; +pub mod driver; +pub mod process; +pub mod repository; +pub mod util; +pub mod version_guesser; diff --git a/crates/mozart-core/src/vcs/downloader/git.rs b/crates/mozart-core/src/vcs/downloader/git.rs new file mode 100644 index 0000000..eb7a649 --- /dev/null +++ b/crates/mozart-core/src/vcs/downloader/git.rs @@ -0,0 +1,271 @@ +use super::super::process::ProcessExecutor; +use super::super::util::git::GitUtil; +use super::VcsDownloader; +use anyhow::Result; +use regex::Regex; +use std::path::Path; +use std::sync::LazyLock; + +/// Match ` HEAD` lines in `git show-ref --head -d` output. +static HEAD_REF_RE: LazyLock = + LazyLock::new(|| Regex::new(r"(?im)^([a-f0-9]+) HEAD$").unwrap()); + +/// Git downloader using clone/checkout with optional mirror cache. +/// +/// Corresponds to Composer's `Downloader\GitDownloader`. +pub struct GitDownloader { + git_util: GitUtil, +} + +impl GitDownloader { + pub fn new(git_util: GitUtil) -> Self { + Self { git_util } + } +} + +impl VcsDownloader for GitDownloader { + fn download(&self, url: &str, _reference: &str, _target: &Path) -> Result<()> { + // Pre-sync the mirror so install can use --reference + self.git_util.sync_mirror(url)?; + Ok(()) + } + + fn install(&self, url: &str, reference: &str, target: &Path) -> Result<()> { + let target_str = target.to_string_lossy(); + let mirror_path = self.git_util.mirror_path(url); + + if mirror_path.join("HEAD").exists() { + // Clone with mirror reference for efficiency + let mirror_str = mirror_path.to_string_lossy().to_string(); + self.git_util.run_command( + &[ + "git", + "clone", + "--no-checkout", + "--dissociate", + "--reference", + &mirror_str, + "--", + url, + &target_str, + ], + url, + None, + )?; + } else { + self.git_util.run_command( + &["git", "clone", "--no-checkout", "--", url, &target_str], + url, + None, + )?; + } + + // Checkout the specific reference + let process = ProcessExecutor::new(); + process.execute_checked(&["git", "checkout", reference, "--force"], Some(target))?; + + Ok(()) + } + + fn update(&self, url: &str, _old_ref: &str, new_ref: &str, target: &Path) -> Result<()> { + let process = ProcessExecutor::new(); + + // Update remote URL + process.execute_checked( + &["git", "remote", "set-url", "origin", "--", url], + Some(target), + )?; + + // Fetch latest + self.git_util + .run_command(&["git", "fetch", "origin"], url, Some(target))?; + + // Checkout new reference + process.execute_checked(&["git", "checkout", new_ref, "--force"], Some(target))?; + + Ok(()) + } + + fn remove(&self, target: &Path) -> Result<()> { + if target.exists() { + std::fs::remove_dir_all(target)?; + } + Ok(()) + } + + fn get_local_changes(&self, target: &Path) -> Result> { + if !target.join(".git").exists() { + return Ok(None); + } + let process = ProcessExecutor::new(); + let output = process.execute( + &["git", "status", "--porcelain", "--untracked-files=no"], + Some(target), + )?; + let trimmed = output.stdout.trim(); + if trimmed.is_empty() { + Ok(None) + } else { + Ok(Some(trimmed.to_string())) + } + } + + fn vcs_reference(&self, target: &Path) -> Result> { + if !target.join(".git").exists() { + return Ok(None); + } + let process = ProcessExecutor::new(); + let output = process.execute(&["git", "rev-parse", "HEAD"], Some(target))?; + if output.status != 0 { + return Ok(None); + } + let trimmed = output.stdout.trim(); + if trimmed.is_empty() { + Ok(None) + } else { + Ok(Some(trimmed.to_string())) + } + } + + fn unpushed_changes(&self, target: &Path) -> Result> { + if !target.join(".git").exists() { + return Ok(None); + } + let process = ProcessExecutor::new(); + + let mut refs = match collect_show_ref(&process, target)? { + Some(r) => r, + None => return Ok(None), + }; + + let head_ref = match HEAD_REF_RE + .captures(&refs) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_string()) + { + Some(h) => h, + None => return Ok(None), + }; + + let candidate_branches = collect_local_branches(&refs, &head_ref); + if candidate_branches.is_empty() { + // not on a branch (detached / tag) — skip + return Ok(None); + } + + let mut branch = candidate_branches[0].clone(); + let mut unpushed_changes: Option = None; + let mut branch_not_found_error = false; + + for i in 0..=1 { + let mut remote_branches: Vec = Vec::new(); + + for candidate in &candidate_branches { + let matches = collect_remote_branches(&refs, candidate); + if !matches.is_empty() { + branch = candidate.clone(); + remote_branches = matches; + break; + } + } + + if remote_branches.is_empty() { + unpushed_changes = Some(format!( + "Branch {branch} could not be found on any remote and appears to be unpushed" + )); + branch_not_found_error = true; + } else { + if branch_not_found_error { + unpushed_changes = None; + } + for remote_branch in &remote_branches { + let range = format!("{remote_branch}...{branch}"); + let output = process.execute_checked( + &["git", "diff", "--name-status", &range, "--"], + Some(target), + )?; + let trimmed = output.stdout.trim().to_string(); + match unpushed_changes { + None => unpushed_changes = Some(trimmed), + Some(ref existing) if trimmed.len() < existing.len() => { + unpushed_changes = Some(trimmed); + } + _ => {} + } + } + } + + if unpushed_changes.as_deref().is_some_and(|s| !s.is_empty()) && i == 0 { + let _ = process.execute(&["git", "fetch", "--all"], Some(target))?; + refs = match collect_show_ref(&process, target)? { + Some(r) => r, + None => return Ok(unpushed_changes), + }; + } + + if unpushed_changes.as_deref().is_none_or(str::is_empty) { + break; + } + } + + Ok(unpushed_changes.filter(|s| !s.is_empty())) + } + + fn commit_logs(&self, from: &str, to: &str, target: &Path) -> Result { + let process = ProcessExecutor::new(); + let range = format!("{from}..{to}"); + let output = process.execute( + &["git", "log", &range, "--oneline", "--no-decorate"], + Some(target), + )?; + Ok(output.stdout) + } + + fn is_change_report(&self) -> bool { + true + } + + fn is_vcs_capable_downloader(&self) -> bool { + true + } + + fn is_dvcs_downloader(&self) -> bool { + true + } +} + +fn collect_show_ref(process: &ProcessExecutor, target: &Path) -> Result> { + let output = process.execute(&["git", "show-ref", "--head", "-d"], Some(target))?; + if output.status != 0 { + anyhow::bail!( + "Failed to execute git show-ref --head -d\n\n{}", + output.stderr.trim() + ); + } + Ok(Some(output.stdout.trim().to_string())) +} + +fn collect_local_branches(refs: &str, head_ref: &str) -> Vec { + let pattern = format!(r"(?im)^{} refs/heads/(.+)$", regex::escape(head_ref)); + let re = match Regex::new(&pattern) { + Ok(r) => r, + Err(_) => return Vec::new(), + }; + re.captures_iter(refs) + .filter_map(|c| c.get(1).map(|m| m.as_str().to_string())) + .collect() +} + +fn collect_remote_branches(refs: &str, candidate: &str) -> Vec { + let pattern = format!( + r"(?im)^[a-f0-9]+ refs/remotes/((?:[^/]+)/{})$", + regex::escape(candidate) + ); + let re = match Regex::new(&pattern) { + Ok(r) => r, + Err(_) => return Vec::new(), + }; + re.captures_iter(refs) + .filter_map(|c| c.get(1).map(|m| m.as_str().to_string())) + .collect() +} diff --git a/crates/mozart-core/src/vcs/downloader/hg.rs b/crates/mozart-core/src/vcs/downloader/hg.rs new file mode 100644 index 0000000..33650f8 --- /dev/null +++ b/crates/mozart-core/src/vcs/downloader/hg.rs @@ -0,0 +1,84 @@ +use super::super::util::hg::HgUtil; +use super::VcsDownloader; +use anyhow::Result; +use std::path::Path; + +/// Mercurial downloader using clone/pull/update. +pub struct HgDownloader { + hg_util: HgUtil, +} + +impl HgDownloader { + pub fn new(hg_util: HgUtil) -> Self { + Self { hg_util } + } +} + +impl VcsDownloader for HgDownloader { + fn download(&self, _url: &str, _reference: &str, _target: &Path) -> Result<()> { + Ok(()) + } + + fn install(&self, url: &str, reference: &str, target: &Path) -> Result<()> { + let target_str = target.to_string_lossy().to_string(); + self.hg_util + .execute(&["clone", "--", url, &target_str], None)?; + self.hg_util + .execute(&["update", "-r", reference], Some(target))?; + Ok(()) + } + + fn update(&self, url: &str, _old_ref: &str, new_ref: &str, target: &Path) -> Result<()> { + self.hg_util.execute(&["pull", url], Some(target))?; + self.hg_util + .execute(&["update", "-r", new_ref], Some(target))?; + Ok(()) + } + + fn remove(&self, target: &Path) -> Result<()> { + if target.exists() { + std::fs::remove_dir_all(target)?; + } + Ok(()) + } + + fn get_local_changes(&self, target: &Path) -> Result> { + if !target.join(".hg").is_dir() { + return Ok(None); + } + let output = self.hg_util.execute(&["st"], Some(target))?; + let trimmed = output.stdout.trim(); + if trimmed.is_empty() { + Ok(None) + } else { + Ok(Some(trimmed.to_string())) + } + } + + fn commit_logs(&self, from: &str, to: &str, target: &Path) -> Result { + let range = format!("{from}:{to}"); + let output = self.hg_util.execute( + &[ + "log", + "-r", + &range, + "--template", + "{rev}:{node|short} {desc|firstline}\\n", + ], + Some(target), + )?; + Ok(output.stdout) + } + + fn is_change_report(&self) -> bool { + true + } + + fn is_vcs_capable_downloader(&self) -> bool { + true + } + + fn is_dvcs_downloader(&self) -> bool { + false + } +} diff --git a/crates/mozart-core/src/vcs/downloader/mod.rs b/crates/mozart-core/src/vcs/downloader/mod.rs new file mode 100644 index 0000000..352f330 --- /dev/null +++ b/crates/mozart-core/src/vcs/downloader/mod.rs @@ -0,0 +1,56 @@ +pub mod git; +pub mod hg; +pub mod svn; + +use std::path::Path; + +use anyhow::Result; + +/// The VCS downloader interface. +/// +/// Corresponds to Composer's `VcsDownloader` hierarchy. +pub trait VcsDownloader { + /// Prepare for installation (e.g., sync mirror cache). + fn download(&self, url: &str, reference: &str, target: &Path) -> Result<()>; + + /// Install (clone/checkout) the source to the target directory. + fn install(&self, url: &str, reference: &str, target: &Path) -> Result<()>; + + /// Update the source at target to a new reference. + fn update(&self, url: &str, old_ref: &str, new_ref: &str, target: &Path) -> Result<()>; + + /// Remove the source from the target directory. + fn remove(&self, target: &Path) -> Result<()>; + + /// Detect local changes in the working copy. + /// Returns `None` if clean, `Some(diff)` if modified. + /// Mirrors `Composer\Downloader\ChangeReportInterface::getLocalChanges`. + fn get_local_changes(&self, target: &Path) -> Result>; + + /// Detect commits present locally but not on the tracking remote. + /// Returns `None` if there are no unpushed commits or the concept does + /// not apply (only `GitDownloader` implements this in Composer's + /// `DvcsDownloaderInterface`). + fn unpushed_changes(&self, _target: &Path) -> Result> { + Ok(None) + } + + /// Resolve the working copy's current VCS reference (e.g. commit hash). + /// Returns `None` if no reference can be determined. Mirrors + /// `Composer\Downloader\VcsCapableDownloaderInterface::getVcsReference`. + fn vcs_reference(&self, _target: &Path) -> Result> { + Ok(None) + } + + /// Get commit log between two references. + fn commit_logs(&self, from: &str, to: &str, target: &Path) -> Result; + + /// instanceof ChangeReportInterface + fn is_change_report(&self) -> bool; + + /// instanceof VcsCapableDownloaderInterface + fn is_vcs_capable_downloader(&self) -> bool; + + /// instanceof DvcsDownloaderInterface + fn is_dvcs_downloader(&self) -> bool; +} diff --git a/crates/mozart-core/src/vcs/downloader/svn.rs b/crates/mozart-core/src/vcs/downloader/svn.rs new file mode 100644 index 0000000..ea885ed --- /dev/null +++ b/crates/mozart-core/src/vcs/downloader/svn.rs @@ -0,0 +1,84 @@ +use super::super::util::svn::SvnUtil; +use super::VcsDownloader; +use anyhow::Result; +use regex::Regex; +use std::path::Path; +use std::sync::LazyLock; + +/// Match any non-`X` status line (mirror of Composer's +/// `{^ *[^X ] +}m`). Ignores externals (`X` prefix). +static SVN_STATUS_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?m)^ *[^X ] +").unwrap()); + +/// SVN downloader using checkout/switch. +pub struct SvnDownloader { + svn_util: SvnUtil, +} + +impl SvnDownloader { + pub fn new(svn_util: SvnUtil) -> Self { + Self { svn_util } + } +} + +impl VcsDownloader for SvnDownloader { + fn download(&self, _url: &str, _reference: &str, _target: &Path) -> Result<()> { + // SVN doesn't need a pre-download step + Ok(()) + } + + fn install(&self, url: &str, reference: &str, target: &Path) -> Result<()> { + let target_str = target.to_string_lossy().to_string(); + let svn_url = format!("{url}@{reference}"); + self.svn_util + .execute(&["checkout", &svn_url, &target_str], None)?; + Ok(()) + } + + fn update(&self, url: &str, _old_ref: &str, new_ref: &str, target: &Path) -> Result<()> { + let svn_url = format!("{url}@{new_ref}"); + self.svn_util + .execute(&["switch", "--ignore-ancestry", &svn_url], Some(target))?; + Ok(()) + } + + fn remove(&self, target: &Path) -> Result<()> { + if target.exists() { + std::fs::remove_dir_all(target)?; + } + Ok(()) + } + + fn get_local_changes(&self, target: &Path) -> Result> { + if !target.join(".svn").is_dir() { + return Ok(None); + } + let output = self + .svn_util + .execute(&["status", "--ignore-externals"], Some(target))?; + if SVN_STATUS_RE.is_match(&output.stdout) { + Ok(Some(output.stdout)) + } else { + Ok(None) + } + } + + fn commit_logs(&self, from: &str, to: &str, target: &Path) -> Result { + let range = format!("{from}:{to}"); + let output = self + .svn_util + .execute(&["log", "-r", &range], Some(target))?; + Ok(output.stdout) + } + + fn is_change_report(&self) -> bool { + true + } + + fn is_vcs_capable_downloader(&self) -> bool { + true + } + + fn is_dvcs_downloader(&self) -> bool { + false + } +} diff --git a/crates/mozart-core/src/vcs/driver/bitbucket.rs b/crates/mozart-core/src/vcs/driver/bitbucket.rs new file mode 100644 index 0000000..2235e10 --- /dev/null +++ b/crates/mozart-core/src/vcs/driver/bitbucket.rs @@ -0,0 +1,277 @@ +use indexmap::IndexMap; +use std::collections::BTreeMap; + +use anyhow::{Result, bail}; +use regex::Regex; +use reqwest::Client; +use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT}; + +use super::git::GitDriver; +use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; + +/// Bitbucket VCS driver using the REST API 2.0. +pub struct BitbucketDriver { + owner: String, + repo: String, + url: String, + root_identifier: Option, + tags: Option>, + branches: Option>, + info_cache: IndexMap>, + git_driver: Option>, + http_client: Client, + config: DriverConfig, + api_failed: bool, + vcs_type: String, // "git" or "hg" +} + +impl BitbucketDriver { + pub fn new(url: &str, config: DriverConfig) -> Self { + let (owner, repo) = Self::parse_url(url).unwrap_or_default(); + Self { + owner, + repo, + url: url.to_string(), + root_identifier: None, + tags: None, + branches: None, + info_cache: IndexMap::new(), + git_driver: None, + http_client: crate::http::default_client(), + config, + api_failed: false, + vcs_type: "git".to_string(), + } + } + + pub fn supports(url: &str) -> bool { + let url_lower = url.to_lowercase(); + url_lower.contains("bitbucket.org") + } + + fn parse_url(url: &str) -> Option<(String, String)> { + let re = + Regex::new(r"bitbucket\.org[:/]([^/]+)/([^/.\s]+?)(?:\.git)?(?:[/#?].*)?$").ok()?; + let caps = re.captures(url)?; + Some((caps[1].to_string(), caps[2].to_string())) + } + + fn api_url(&self, path: &str) -> String { + format!( + "https://api.bitbucket.org/2.0/repositories/{}/{}{}", + self.owner, self.repo, path, + ) + } + + #[tracing::instrument(skip(self))] + async fn api_get(&self, path: &str) -> Result { + let url = self.api_url(path); + let mut req = self + .http_client + .get(&url) + .header(USER_AGENT, "mozart/0.1") + .header(ACCEPT, "application/json"); + + if let Some((key, secret)) = &self.config.bitbucket_oauth { + let credentials = format!("{key}:{secret}"); + req = req.header(AUTHORIZATION, format!("Basic {credentials}")); + } + + let response = req.send().await?; + tracing::debug!(status = %response.status(), %url, "Bitbucket API response"); + if !response.status().is_success() { + bail!( + "Bitbucket API request to {} failed: {}", + url, + response.status() + ); + } + Ok(response.json().await?) + } + + #[tracing::instrument(skip(self))] + async fn api_get_paginated(&self, path: &str) -> Result> { + let mut items = Vec::new(); + let mut next_url = Some(self.api_url(path)); + let mut pages = 0; + + while let Some(url) = next_url { + let mut req = self + .http_client + .get(&url) + .header(USER_AGENT, "mozart/0.1") + .header(ACCEPT, "application/json"); + if let Some((key, secret)) = &self.config.bitbucket_oauth { + req = req.header(AUTHORIZATION, format!("Basic {key}:{secret}")); + } + let response = req.send().await?; + tracing::debug!(status = %response.status(), %url, "Bitbucket API paginated response"); + if !response.status().is_success() { + break; + } + let data: serde_json::Value = response.json().await?; + if let Some(values) = data["values"].as_array() { + items.extend(values.iter().cloned()); + } + next_url = data["next"].as_str().map(|s: &str| s.to_string()); + pages += 1; + if pages > 10 { + break; + } + } + Ok(items) + } + + async fn use_git_fallback(&mut self) -> Result<&mut GitDriver> { + if self.git_driver.is_none() { + let git_url = format!("https://bitbucket.org/{}/{}.git", self.owner, self.repo); + let mut driver = GitDriver::new(&git_url, self.config.clone()); + driver.initialize().await?; + self.git_driver = Some(Box::new(driver)); + } + Ok(self.git_driver.as_mut().unwrap()) + } +} + +impl VcsDriver for BitbucketDriver { + async fn initialize(&mut self) -> Result<()> { + match self.api_get("").await { + Ok(data) => { + if let Some(scm) = data["scm"].as_str() { + self.vcs_type = scm.to_string(); + } + let default_branch = data["mainbranch"]["name"] + .as_str() + .unwrap_or("main") + .to_string(); + self.root_identifier = Some(default_branch); + } + Err(_) => { + self.api_failed = true; + let driver = self.use_git_fallback().await?; + self.root_identifier = Some(driver.root_identifier().to_string()); + } + } + Ok(()) + } + + fn root_identifier(&self) -> &str { + self.root_identifier.as_deref().unwrap_or("main") + } + + async fn branches(&mut self) -> Result<&BTreeMap> { + if self.branches.is_none() { + if self.api_failed { + let driver = self.use_git_fallback().await?; + let branches = driver.branches().await?.clone(); + self.branches = Some(branches); + } else { + let items = self.api_get_paginated("/refs/branches?pagelen=100").await?; + let mut branches = BTreeMap::new(); + for item in items { + if let (Some(name), Some(sha)) = + (item["name"].as_str(), item["target"]["hash"].as_str()) + { + branches.insert(name.to_string(), sha.to_string()); + } + } + self.branches = Some(branches); + } + } + Ok(self.branches.as_ref().unwrap()) + } + + async fn tags(&mut self) -> Result<&BTreeMap> { + if self.tags.is_none() { + if self.api_failed { + let driver = self.use_git_fallback().await?; + let tags = driver.tags().await?.clone(); + self.tags = Some(tags); + } else { + let items = self.api_get_paginated("/refs/tags?pagelen=100").await?; + let mut tags = BTreeMap::new(); + for item in items { + if let (Some(name), Some(sha)) = + (item["name"].as_str(), item["target"]["hash"].as_str()) + { + tags.insert(name.to_string(), sha.to_string()); + } + } + self.tags = Some(tags); + } + } + Ok(self.tags.as_ref().unwrap()) + } + + async fn composer_information( + &mut self, + identifier: &str, + ) -> Result> { + if let Some(cached) = self.info_cache.get(identifier) { + return Ok(cached.clone()); + } + let content = self.file_content("composer.json", identifier).await?; + let value = content.and_then(|c| serde_json::from_str(&c).ok()); + self.info_cache + .insert(identifier.to_string(), value.clone()); + Ok(value) + } + + async fn file_content(&self, file: &str, identifier: &str) -> Result> { + if self.api_failed { + return Ok(None); + } + let url = self.api_url(&format!("/src/{identifier}/{file}")); + let mut req = self.http_client.get(&url).header(USER_AGENT, "mozart/0.1"); + if let Some((key, secret)) = &self.config.bitbucket_oauth { + req = req.header(AUTHORIZATION, format!("Basic {key}:{secret}")); + } + let response = req.send().await?; + if response.status().is_success() { + Ok(Some(response.text().await?)) + } else { + Ok(None) + } + } + + async fn change_date(&self, identifier: &str) -> Result> { + if self.api_failed { + return Ok(None); + } + match self.api_get(&format!("/commit/{identifier}")).await { + Ok(data) => Ok(data["date"].as_str().map(|s| s.to_string())), + Err(_) => Ok(None), + } + } + + async fn dist(&self, identifier: &str) -> Result> { + Ok(Some(DistReference { + dist_type: "zip".to_string(), + url: format!( + "https://bitbucket.org/{}/{}/get/{}.zip", + self.owner, self.repo, identifier, + ), + reference: identifier.to_string(), + shasum: None, + })) + } + + fn source(&self, identifier: &str) -> SourceReference { + SourceReference { + source_type: self.vcs_type.clone(), + url: format!("https://bitbucket.org/{}/{}.git", self.owner, self.repo), + reference: identifier.to_string(), + } + } + + fn url(&self) -> &str { + &self.url + } + + async fn cleanup(&mut self) -> Result<()> { + if let Some(driver) = &mut self.git_driver { + driver.cleanup().await?; + } + Ok(()) + } +} diff --git a/crates/mozart-core/src/vcs/driver/forgejo.rs b/crates/mozart-core/src/vcs/driver/forgejo.rs new file mode 100644 index 0000000..8a290c0 --- /dev/null +++ b/crates/mozart-core/src/vcs/driver/forgejo.rs @@ -0,0 +1,285 @@ +use indexmap::IndexMap; +use std::collections::BTreeMap; + +use anyhow::{Result, bail}; +use regex::Regex; +use reqwest::Client; +use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT}; + +use super::git::GitDriver; +use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; + +/// Forgejo/Gitea VCS driver using the REST API v1. +/// +/// Supports self-hosted instances (Codeberg, etc.). +pub struct ForgejoDriver { + owner: String, + repo: String, + host: String, + scheme: String, + url: String, + root_identifier: Option, + tags: Option>, + branches: Option>, + info_cache: IndexMap>, + git_driver: Option>, + http_client: Client, + config: DriverConfig, + api_failed: bool, +} + +impl ForgejoDriver { + pub fn new(url: &str, config: DriverConfig) -> Self { + let (host, scheme, owner, repo) = Self::parse_url(url).unwrap_or_default(); + Self { + owner, + repo, + host, + scheme, + url: url.to_string(), + root_identifier: None, + tags: None, + branches: None, + info_cache: IndexMap::new(), + git_driver: None, + http_client: crate::http::default_client(), + config, + api_failed: false, + } + } + + pub fn supports(url: &str, forgejo_domains: &[String]) -> bool { + let url_lower = url.to_lowercase(); + for domain in forgejo_domains { + if url_lower.contains(domain) { + return true; + } + } + false + } + + fn parse_url(url: &str) -> Option<(String, String, String, String)> { + let re = Regex::new(r"(?i)(https?)://([^/]+)/([^/]+)/([^/.\s]+?)(?:\.git)?(?:[/#?].*)?$") + .ok()?; + let caps = re.captures(url)?; + Some(( + caps[2].to_string(), + caps[1].to_string(), + caps[3].to_string(), + caps[4].to_string(), + )) + } + + fn api_url(&self, path: &str) -> String { + format!( + "{}://{}/api/v1/repos/{}/{}{}", + self.scheme, self.host, self.owner, self.repo, path, + ) + } + + #[tracing::instrument(skip(self))] + async fn api_get(&self, path: &str) -> Result { + let url = self.api_url(path); + let mut req = self + .http_client + .get(&url) + .header(USER_AGENT, "mozart/0.1") + .header(ACCEPT, "application/json"); + if let Some(token) = &self.config.forgejo_token { + req = req.header(AUTHORIZATION, format!("token {token}")); + } + let response = req.send().await?; + tracing::debug!(status = %response.status(), %url, "Forgejo API response"); + if !response.status().is_success() { + bail!( + "Forgejo API request to {} failed: {}", + url, + response.status() + ); + } + Ok(response.json().await?) + } + + #[tracing::instrument(skip(self))] + async fn api_get_paginated(&self, path: &str) -> Result> { + let mut items = Vec::new(); + let mut page = 1; + loop { + let sep = if path.contains('?') { "&" } else { "?" }; + let paged_path = format!("{path}{sep}limit=50&page={page}"); + let data = self.api_get(&paged_path).await?; + let batch: Vec = match data { + serde_json::Value::Array(arr) => arr, + _ => break, + }; + if batch.is_empty() { + break; + } + items.extend(batch); + page += 1; + if page > 20 { + break; + } + } + Ok(items) + } + + async fn use_git_fallback(&mut self) -> Result<&mut GitDriver> { + if self.git_driver.is_none() { + let git_url = format!( + "{}://{}/{}/{}.git", + self.scheme, self.host, self.owner, self.repo + ); + let mut driver = GitDriver::new(&git_url, self.config.clone()); + driver.initialize().await?; + self.git_driver = Some(Box::new(driver)); + } + Ok(self.git_driver.as_mut().unwrap()) + } +} + +impl VcsDriver for ForgejoDriver { + async fn initialize(&mut self) -> Result<()> { + match self.api_get("").await { + Ok(data) => { + let default_branch = data["default_branch"] + .as_str() + .unwrap_or("main") + .to_string(); + self.root_identifier = Some(default_branch); + } + Err(_) => { + self.api_failed = true; + let driver = self.use_git_fallback().await?; + self.root_identifier = Some(driver.root_identifier().to_string()); + } + } + Ok(()) + } + + fn root_identifier(&self) -> &str { + self.root_identifier.as_deref().unwrap_or("main") + } + + async fn branches(&mut self) -> Result<&BTreeMap> { + if self.branches.is_none() { + if self.api_failed { + let driver = self.use_git_fallback().await?; + let branches = driver.branches().await?.clone(); + self.branches = Some(branches); + } else { + let items = self.api_get_paginated("/branches").await?; + let mut branches = BTreeMap::new(); + for item in items { + if let (Some(name), Some(sha)) = + (item["name"].as_str(), item["commit"]["id"].as_str()) + { + branches.insert(name.to_string(), sha.to_string()); + } + } + self.branches = Some(branches); + } + } + Ok(self.branches.as_ref().unwrap()) + } + + async fn tags(&mut self) -> Result<&BTreeMap> { + if self.tags.is_none() { + if self.api_failed { + let driver = self.use_git_fallback().await?; + let tags = driver.tags().await?.clone(); + self.tags = Some(tags); + } else { + let items = self.api_get_paginated("/tags").await?; + let mut tags = BTreeMap::new(); + for item in items { + if let (Some(name), Some(sha)) = ( + item["name"].as_str(), + item["id"].as_str().or(item["commit"]["sha"].as_str()), + ) { + tags.insert(name.to_string(), sha.to_string()); + } + } + self.tags = Some(tags); + } + } + Ok(self.tags.as_ref().unwrap()) + } + + async fn composer_information( + &mut self, + identifier: &str, + ) -> Result> { + if let Some(cached) = self.info_cache.get(identifier) { + return Ok(cached.clone()); + } + let content = self.file_content("composer.json", identifier).await?; + let value = content.and_then(|c| serde_json::from_str(&c).ok()); + self.info_cache + .insert(identifier.to_string(), value.clone()); + Ok(value) + } + + async fn file_content(&self, file: &str, identifier: &str) -> Result> { + if self.api_failed { + return Ok(None); + } + let path = format!("/contents/{}?ref={}", file, identifier); + match self.api_get(&path).await { + Ok(data) => { + if let Some(content) = data["content"].as_str() { + // Forgejo returns base64-encoded content + let decoded = super::github::base64_decode_content(content)?; + Ok(Some(decoded)) + } else { + Ok(None) + } + } + Err(_) => Ok(None), + } + } + + async fn change_date(&self, identifier: &str) -> Result> { + if self.api_failed { + return Ok(None); + } + match self.api_get(&format!("/git/commits/{identifier}")).await { + Ok(data) => Ok(data["created"].as_str().map(|s| s.to_string())), + Err(_) => Ok(None), + } + } + + async fn dist(&self, identifier: &str) -> Result> { + Ok(Some(DistReference { + dist_type: "zip".to_string(), + url: format!( + "{}://{}/{}/{}/archive/{}.zip", + self.scheme, self.host, self.owner, self.repo, identifier, + ), + reference: identifier.to_string(), + shasum: None, + })) + } + + fn source(&self, identifier: &str) -> SourceReference { + SourceReference { + source_type: "git".to_string(), + url: format!( + "{}://{}/{}/{}.git", + self.scheme, self.host, self.owner, self.repo + ), + reference: identifier.to_string(), + } + } + + fn url(&self) -> &str { + &self.url + } + + async fn cleanup(&mut self) -> Result<()> { + if let Some(driver) = &mut self.git_driver { + driver.cleanup().await?; + } + Ok(()) + } +} diff --git a/crates/mozart-core/src/vcs/driver/git.rs b/crates/mozart-core/src/vcs/driver/git.rs new file mode 100644 index 0000000..7d6643f --- /dev/null +++ b/crates/mozart-core/src/vcs/driver/git.rs @@ -0,0 +1,275 @@ +use super::super::process::ProcessExecutor; +use super::super::util::git::GitUtil; +use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; +use anyhow::Result; +use indexmap::IndexMap; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// Git VCS driver. +/// +/// Corresponds to Composer's `Repository\Vcs\GitDriver`. +pub struct GitDriver { + url: String, + repo_dir: Option, + root_identifier: Option, + tags: Option>, + branches: Option>, + info_cache: IndexMap>, + git_util: GitUtil, + is_local: bool, +} + +impl GitDriver { + pub fn new(url: &str, config: DriverConfig) -> Self { + let is_local = Self::is_local_path(url); + let process = ProcessExecutor::new(); + let git_util = GitUtil::new(process, config.cache_vcs_dir.clone()); + Self { + url: url.to_string(), + repo_dir: if is_local { + Some(PathBuf::from(url)) + } else { + None + }, + root_identifier: None, + tags: None, + branches: None, + info_cache: IndexMap::new(), + git_util, + is_local, + } + } + + /// Check if a URL is supported by the Git driver. + pub fn supports(url: &str) -> bool { + if Self::is_local_path(url) { + return Path::new(url).join(".git").is_dir() || url.ends_with(".git"); + } + url.starts_with("git://") + || url.starts_with("git@") + || url.ends_with(".git") + || url.contains("git.") + } + + fn is_local_path(url: &str) -> bool { + !url.contains("://") && !url.starts_with("git@") && Path::new(url).exists() + } + + fn get_repo_dir(&self) -> Result<&Path> { + self.repo_dir + .as_deref() + .ok_or_else(|| anyhow::anyhow!("GitDriver not initialized")) + } + + fn parse_branches(output: &str) -> BTreeMap { + let mut branches = BTreeMap::new(); + for line in output.lines() { + let line = line.trim(); + if line.is_empty() || line.contains("HEAD detached") || line.contains("->") { + continue; + } + // Remove leading "* " for current branch + let line = line.strip_prefix("* ").unwrap_or(line); + // Format: "branch_name commit_hash ..." + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + branches.insert(parts[0].to_string(), parts[1].to_string()); + } + } + branches + } + + fn parse_tags(output: &str) -> BTreeMap { + let mut tags = BTreeMap::new(); + // First pass: collect dereferenced tags (^{}) + let mut dereferenced = IndexMap::new(); + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + // Format: "commit_hash refs/tags/tag_name" or "commit_hash refs/tags/tag_name^{}" + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + let hash = parts[0]; + let refname = parts[1]; + if let Some(tag_name) = refname.strip_prefix("refs/tags/") + && let Some(tag_name) = tag_name.strip_suffix("^{}") + { + // Dereferenced tag - this is the actual commit + dereferenced.insert(tag_name.to_string(), hash.to_string()); + } + } + } + // Second pass: collect all tags, preferring dereferenced values + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + let hash = parts[0]; + let refname = parts[1]; + if let Some(tag_name) = refname.strip_prefix("refs/tags/") { + if tag_name.ends_with("^{}") { + continue; // Skip dereferenced entries themselves + } + let resolved = dereferenced + .get(tag_name) + .cloned() + .unwrap_or_else(|| hash.to_string()); + tags.insert(tag_name.to_string(), resolved); + } + } + } + tags + } +} + +impl VcsDriver for GitDriver { + async fn initialize(&mut self) -> Result<()> { + if self.is_local { + // Local repo: use directly (or its .git subdir) + let path = Path::new(&self.url); + if path.join(".git").is_dir() { + self.repo_dir = Some(path.join(".git")); + } else { + self.repo_dir = Some(path.to_path_buf()); + } + } else { + // Remote repo: sync mirror + let mirror_dir = self.git_util.sync_mirror(&self.url)?; + self.repo_dir = Some(mirror_dir); + } + + // Determine root identifier (default branch) + let repo_dir = self.repo_dir.clone().unwrap(); + if let Ok(Some(branch)) = self.git_util.get_default_branch(&repo_dir) { + self.root_identifier = Some(branch); + } else { + // Fallback: try common branch names + let process = ProcessExecutor::new(); + for name in &["main", "master"] { + let output = + process.execute(&["git", "rev-parse", "--verify", name], Some(&repo_dir))?; + if output.status == 0 { + self.root_identifier = Some(name.to_string()); + break; + } + } + } + + if self.root_identifier.is_none() { + self.root_identifier = Some("master".to_string()); + } + + Ok(()) + } + + fn root_identifier(&self) -> &str { + self.root_identifier.as_deref().unwrap_or("master") + } + + async fn branches(&mut self) -> Result<&BTreeMap> { + if self.branches.is_none() { + let repo_dir = self.get_repo_dir()?.to_path_buf(); + let process = ProcessExecutor::new(); + let output = process.execute_checked( + &["git", "branch", "--no-color", "--no-abbrev", "-v"], + Some(&repo_dir), + )?; + self.branches = Some(Self::parse_branches(&output.stdout)); + } + Ok(self.branches.as_ref().unwrap()) + } + + async fn tags(&mut self) -> Result<&BTreeMap> { + if self.tags.is_none() { + let repo_dir = self.get_repo_dir()?.to_path_buf(); + let process = ProcessExecutor::new(); + let output = process.execute( + &["git", "show-ref", "--tags", "--dereference"], + Some(&repo_dir), + )?; + self.tags = Some(if output.status == 0 { + Self::parse_tags(&output.stdout) + } else { + BTreeMap::new() + }); + } + Ok(self.tags.as_ref().unwrap()) + } + + async fn composer_information( + &mut self, + identifier: &str, + ) -> Result> { + if let Some(cached) = self.info_cache.get(identifier) { + return Ok(cached.clone()); + } + + let content = self.file_content("composer.json", identifier).await?; + let value = match content { + Some(c) => serde_json::from_str(&c).ok(), + None => None, + }; + + self.info_cache + .insert(identifier.to_string(), value.clone()); + Ok(value) + } + + async fn file_content(&self, file: &str, identifier: &str) -> Result> { + let repo_dir = self.get_repo_dir()?; + let process = ProcessExecutor::new(); + let resource = format!("{identifier}:{file}"); + let output = process.execute(&["git", "show", &resource], Some(repo_dir))?; + if output.status == 0 { + Ok(Some(output.stdout)) + } else { + Ok(None) + } + } + + async fn change_date(&self, identifier: &str) -> Result> { + let repo_dir = self.get_repo_dir()?; + let process = ProcessExecutor::new(); + let output = process.execute( + &["git", "log", "-1", "--format=%aI", identifier], + Some(repo_dir), + )?; + if output.status == 0 { + let date = output.stdout.trim().to_string(); + if date.is_empty() { + Ok(None) + } else { + Ok(Some(date)) + } + } else { + Ok(None) + } + } + + async fn dist(&self, _identifier: &str) -> Result> { + // Plain git repos don't provide dist archives + Ok(None) + } + + fn source(&self, identifier: &str) -> SourceReference { + SourceReference { + source_type: "git".to_string(), + url: self.url.clone(), + reference: identifier.to_string(), + } + } + + fn url(&self) -> &str { + &self.url + } + + async fn cleanup(&mut self) -> Result<()> { + Ok(()) + } +} diff --git a/crates/mozart-core/src/vcs/driver/github.rs b/crates/mozart-core/src/vcs/driver/github.rs new file mode 100644 index 0000000..7772bbb --- /dev/null +++ b/crates/mozart-core/src/vcs/driver/github.rs @@ -0,0 +1,315 @@ +use indexmap::IndexMap; +use std::collections::BTreeMap; + +use anyhow::{Result, bail}; +use regex::Regex; +use reqwest::Client; +use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT}; + +use super::git::GitDriver; +use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; + +/// GitHub VCS driver using the REST API v3. +/// +/// Falls back to `GitDriver` when API access fails. +pub struct GitHubDriver { + owner: String, + repo: String, + url: String, + root_identifier: Option, + tags: Option>, + branches: Option>, + repo_data: Option, + info_cache: IndexMap>, + git_driver: Option>, + http_client: Client, + config: DriverConfig, + api_failed: bool, +} + +impl GitHubDriver { + pub fn new(url: &str, config: DriverConfig) -> Self { + let (owner, repo) = Self::parse_url(url).unwrap_or_default(); + Self { + owner, + repo, + url: url.to_string(), + root_identifier: None, + tags: None, + branches: None, + repo_data: None, + info_cache: IndexMap::new(), + git_driver: None, + http_client: crate::http::default_client(), + config, + api_failed: false, + } + } + + /// Check if a URL points to GitHub. + pub fn supports(url: &str) -> bool { + let url_lower = url.to_lowercase(); + url_lower.contains("github.com") + && (url_lower.contains("github.com/") || url_lower.contains("github.com:")) + } + + fn parse_url(url: &str) -> Option<(String, String)> { + let re = Regex::new(r"github\.com[:/]([^/]+)/([^/.\s]+?)(?:\.git)?(?:[/#?].*)?$").ok()?; + let caps = re.captures(url)?; + Some((caps[1].to_string(), caps[2].to_string())) + } + + fn api_url(&self, path: &str) -> String { + format!( + "https://api.github.com/repos/{}/{}{}", + self.owner, self.repo, path + ) + } + + #[tracing::instrument(skip(self))] + async fn api_get(&self, path: &str) -> Result { + let url = self.api_url(path); + let mut req = self + .http_client + .get(&url) + .header(USER_AGENT, "mozart/0.1") + .header(ACCEPT, "application/vnd.github.v3+json"); + + if let Some(token) = &self.config.github_token { + req = req.header(AUTHORIZATION, format!("token {token}")); + } + + let response = req.send().await?; + tracing::debug!(status = %response.status(), %url, "GitHub API response"); + if !response.status().is_success() { + bail!( + "GitHub API request to {} failed with status {}", + url, + response.status() + ); + } + Ok(response.json().await?) + } + + #[tracing::instrument(skip(self))] + async fn api_get_paginated(&self, path: &str) -> Result> { + let mut items = Vec::new(); + let mut page = 1; + loop { + let separator = if path.contains('?') { "&" } else { "?" }; + let url = format!( + "https://api.github.com/repos/{}/{}{}{}per_page=100&page={}", + self.owner, self.repo, path, separator, page, + ); + let mut req = self + .http_client + .get(&url) + .header(USER_AGENT, "mozart/0.1") + .header(ACCEPT, "application/vnd.github.v3+json"); + if let Some(token) = &self.config.github_token { + req = req.header(AUTHORIZATION, format!("token {token}")); + } + + let response = req.send().await?; + tracing::debug!(status = %response.status(), %url, "GitHub API paginated response"); + if !response.status().is_success() { + bail!("GitHub API paginated request failed: {}", response.status()); + } + + let batch: Vec = response.json().await?; + if batch.is_empty() { + break; + } + items.extend(batch); + page += 1; + // Safety: limit to 10 pages (1000 items) + if page > 10 { + break; + } + } + Ok(items) + } + + async fn use_git_fallback(&mut self) -> Result<&mut GitDriver> { + if self.git_driver.is_none() { + let git_url = format!("https://github.com/{}/{}.git", self.owner, self.repo); + let mut driver = GitDriver::new(&git_url, self.config.clone()); + driver.initialize().await?; + self.git_driver = Some(Box::new(driver)); + } + Ok(self.git_driver.as_mut().unwrap()) + } +} + +impl VcsDriver for GitHubDriver { + async fn initialize(&mut self) -> Result<()> { + // Try to fetch repo data from API + match self.api_get("").await { + Ok(data) => { + let default_branch = data["default_branch"] + .as_str() + .unwrap_or("main") + .to_string(); + self.root_identifier = Some(default_branch); + self.repo_data = Some(data); + } + Err(_) => { + self.api_failed = true; + let driver = self.use_git_fallback().await?; + self.root_identifier = Some(driver.root_identifier().to_string()); + } + } + Ok(()) + } + + fn root_identifier(&self) -> &str { + self.root_identifier.as_deref().unwrap_or("main") + } + + async fn branches(&mut self) -> Result<&BTreeMap> { + if self.branches.is_none() { + if self.api_failed { + let driver = self.use_git_fallback().await?; + let branches = driver.branches().await?.clone(); + self.branches = Some(branches); + } else { + let items = self.api_get_paginated("/branches").await?; + let mut branches = BTreeMap::new(); + for item in items { + if let (Some(name), Some(sha)) = + (item["name"].as_str(), item["commit"]["sha"].as_str()) + { + branches.insert(name.to_string(), sha.to_string()); + } + } + self.branches = Some(branches); + } + } + Ok(self.branches.as_ref().unwrap()) + } + + async fn tags(&mut self) -> Result<&BTreeMap> { + if self.tags.is_none() { + if self.api_failed { + let driver = self.use_git_fallback().await?; + let tags = driver.tags().await?.clone(); + self.tags = Some(tags); + } else { + let items = self.api_get_paginated("/tags").await?; + let mut tags = BTreeMap::new(); + for item in items { + if let (Some(name), Some(sha)) = + (item["name"].as_str(), item["commit"]["sha"].as_str()) + { + tags.insert(name.to_string(), sha.to_string()); + } + } + self.tags = Some(tags); + } + } + Ok(self.tags.as_ref().unwrap()) + } + + async fn composer_information( + &mut self, + identifier: &str, + ) -> Result> { + if let Some(cached) = self.info_cache.get(identifier) { + return Ok(cached.clone()); + } + + let content = self.file_content("composer.json", identifier).await?; + let value = match content { + Some(c) => serde_json::from_str(&c).ok(), + None => None, + }; + + self.info_cache + .insert(identifier.to_string(), value.clone()); + Ok(value) + } + + async fn file_content(&self, file: &str, identifier: &str) -> Result> { + if self.api_failed { + // Can't use API, would need git fallback + // For simplicity, return None (git_driver is mutable) + return Ok(None); + } + + let path = format!("/contents/{}?ref={}", file, identifier); + match self.api_get(&path).await { + Ok(data) => { + if let Some(content) = data["content"].as_str() { + // GitHub returns base64-encoded content + let decoded = base64_decode_content(content)?; + Ok(Some(decoded)) + } else { + Ok(None) + } + } + Err(_) => Ok(None), + } + } + + async fn change_date(&self, identifier: &str) -> Result> { + if self.api_failed { + return Ok(None); + } + + let path = format!("/commits/{}", identifier); + match self.api_get(&path).await { + Ok(data) => { + let date = data["commit"]["committer"]["date"] + .as_str() + .map(|s| s.to_string()); + Ok(date) + } + Err(_) => Ok(None), + } + } + + async fn dist(&self, identifier: &str) -> Result> { + Ok(Some(DistReference { + dist_type: "zip".to_string(), + url: format!( + "https://api.github.com/repos/{}/{}/zipball/{}", + self.owner, self.repo, identifier, + ), + reference: identifier.to_string(), + shasum: None, + })) + } + + fn source(&self, identifier: &str) -> SourceReference { + SourceReference { + source_type: "git".to_string(), + url: format!("https://github.com/{}/{}.git", self.owner, self.repo), + reference: identifier.to_string(), + } + } + + fn url(&self) -> &str { + &self.url + } + + async fn cleanup(&mut self) -> Result<()> { + if let Some(driver) = &mut self.git_driver { + driver.cleanup().await?; + } + Ok(()) + } +} + +/// Decode base64-encoded content from API responses. +/// Also used by Forgejo driver as `base64_decode_content`. +pub fn base64_decode_content(input: &str) -> Result { + use base64::Engine; + let cleaned: Vec = input + .bytes() + .filter(|&b| b != b'\n' && b != b'\r') + .collect(); + let decoded = base64::engine::general_purpose::STANDARD + .decode(&cleaned) + .map_err(|e| anyhow::anyhow!("Base64 decode error: {e}"))?; + String::from_utf8(decoded).map_err(|e| anyhow::anyhow!("Invalid UTF-8 in base64 content: {e}")) +} diff --git a/crates/mozart-core/src/vcs/driver/gitlab.rs b/crates/mozart-core/src/vcs/driver/gitlab.rs new file mode 100644 index 0000000..f181e63 --- /dev/null +++ b/crates/mozart-core/src/vcs/driver/gitlab.rs @@ -0,0 +1,301 @@ +use indexmap::IndexMap; +use std::collections::BTreeMap; + +use anyhow::{Result, bail}; +use regex::Regex; +use reqwest::Client; +use reqwest::header::{ACCEPT, USER_AGENT}; + +use super::git::GitDriver; +use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; + +/// GitLab VCS driver using the REST API v4. +/// +/// Supports self-hosted GitLab instances. +pub struct GitLabDriver { + owner: String, + repo: String, + host: String, + scheme: String, + url: String, + project_id: Option, + root_identifier: Option, + tags: Option>, + branches: Option>, + info_cache: IndexMap>, + git_driver: Option>, + http_client: Client, + config: DriverConfig, + api_failed: bool, +} + +impl GitLabDriver { + pub fn new(url: &str, config: DriverConfig) -> Self { + let (host, scheme, owner, repo) = Self::parse_url(url).unwrap_or_default(); + Self { + owner, + repo, + host, + scheme, + url: url.to_string(), + project_id: None, + root_identifier: None, + tags: None, + branches: None, + info_cache: IndexMap::new(), + git_driver: None, + http_client: crate::http::default_client(), + config, + api_failed: false, + } + } + + pub fn supports(url: &str, gitlab_domains: &[String]) -> bool { + let url_lower = url.to_lowercase(); + for domain in gitlab_domains { + if url_lower.contains(domain) { + return true; + } + } + false + } + + fn parse_url(url: &str) -> Option<(String, String, String, String)> { + let re = Regex::new(r"(?i)(https?)://([^/]+)/([^/]+)/([^/.\s]+?)(?:\.git)?(?:[/#?].*)?$") + .ok()?; + let caps = re.captures(url)?; + Some(( + caps[2].to_string(), + caps[1].to_string(), + caps[3].to_string(), + caps[4].to_string(), + )) + } + + fn api_url(&self, path: &str) -> String { + let project_path = format!("{}%2F{}", self.owner, self.repo); + let id = self.project_id.as_deref().unwrap_or(&project_path); + format!( + "{}://{}/api/v4/projects/{}{}", + self.scheme, self.host, id, path + ) + } + + #[tracing::instrument(skip(self))] + async fn api_get(&self, path: &str) -> Result { + let url = self.api_url(path); + let mut req = self + .http_client + .get(&url) + .header(USER_AGENT, "mozart/0.1") + .header(ACCEPT, "application/json"); + + if let Some(token) = &self.config.gitlab_token { + req = req.header("PRIVATE-TOKEN", token.as_str()); + } + + let response = req.send().await?; + tracing::debug!(status = %response.status(), %url, "GitLab API response"); + if !response.status().is_success() { + bail!( + "GitLab API request to {} failed with status {}", + url, + response.status() + ); + } + Ok(response.json().await?) + } + + #[tracing::instrument(skip(self))] + async fn api_get_paginated(&self, path: &str) -> Result> { + let mut items = Vec::new(); + let mut page = 1; + loop { + let sep = if path.contains('?') { "&" } else { "?" }; + let paged_path = format!("{path}{sep}per_page=100&page={page}"); + let data = self.api_get(&paged_path).await?; + let batch: Vec = match data { + serde_json::Value::Array(arr) => arr, + _ => break, + }; + if batch.is_empty() { + break; + } + items.extend(batch); + page += 1; + if page > 10 { + break; + } + } + Ok(items) + } + + async fn use_git_fallback(&mut self) -> Result<&mut GitDriver> { + if self.git_driver.is_none() { + let git_url = format!( + "{}://{}/{}/{}.git", + self.scheme, self.host, self.owner, self.repo + ); + let mut driver = GitDriver::new(&git_url, self.config.clone()); + driver.initialize().await?; + self.git_driver = Some(Box::new(driver)); + } + Ok(self.git_driver.as_mut().unwrap()) + } +} + +impl VcsDriver for GitLabDriver { + async fn initialize(&mut self) -> Result<()> { + match self.api_get("").await { + Ok(data) => { + if let Some(id) = data["id"].as_u64() { + self.project_id = Some(id.to_string()); + } + let default_branch = data["default_branch"] + .as_str() + .unwrap_or("main") + .to_string(); + self.root_identifier = Some(default_branch); + } + Err(_) => { + self.api_failed = true; + let driver = self.use_git_fallback().await?; + self.root_identifier = Some(driver.root_identifier().to_string()); + } + } + Ok(()) + } + + fn root_identifier(&self) -> &str { + self.root_identifier.as_deref().unwrap_or("main") + } + + async fn branches(&mut self) -> Result<&BTreeMap> { + if self.branches.is_none() { + if self.api_failed { + let driver = self.use_git_fallback().await?; + let branches = driver.branches().await?.clone(); + self.branches = Some(branches); + } else { + let items = self.api_get_paginated("/repository/branches").await?; + let mut branches = BTreeMap::new(); + for item in items { + if let (Some(name), Some(sha)) = + (item["name"].as_str(), item["commit"]["id"].as_str()) + { + branches.insert(name.to_string(), sha.to_string()); + } + } + self.branches = Some(branches); + } + } + Ok(self.branches.as_ref().unwrap()) + } + + async fn tags(&mut self) -> Result<&BTreeMap> { + if self.tags.is_none() { + if self.api_failed { + let driver = self.use_git_fallback().await?; + let tags = driver.tags().await?.clone(); + self.tags = Some(tags); + } else { + let items = self.api_get_paginated("/repository/tags").await?; + let mut tags = BTreeMap::new(); + for item in items { + if let (Some(name), Some(sha)) = + (item["name"].as_str(), item["commit"]["id"].as_str()) + { + tags.insert(name.to_string(), sha.to_string()); + } + } + self.tags = Some(tags); + } + } + Ok(self.tags.as_ref().unwrap()) + } + + async fn composer_information( + &mut self, + identifier: &str, + ) -> Result> { + if let Some(cached) = self.info_cache.get(identifier) { + return Ok(cached.clone()); + } + let content = self.file_content("composer.json", identifier).await?; + let value = content.and_then(|c| serde_json::from_str(&c).ok()); + self.info_cache + .insert(identifier.to_string(), value.clone()); + Ok(value) + } + + async fn file_content(&self, file: &str, identifier: &str) -> Result> { + if self.api_failed { + return Ok(None); + } + let encoded_file = file.replace('/', "%2F"); + let path = format!("/repository/files/{}/raw?ref={}", encoded_file, identifier); + let url = self.api_url(&path); + let mut req = self.http_client.get(&url).header(USER_AGENT, "mozart/0.1"); + if let Some(token) = &self.config.gitlab_token { + req = req.header("PRIVATE-TOKEN", token.as_str()); + } + let response = req.send().await?; + if response.status().is_success() { + Ok(Some(response.text().await?)) + } else { + Ok(None) + } + } + + async fn change_date(&self, identifier: &str) -> Result> { + if self.api_failed { + return Ok(None); + } + match self + .api_get(&format!("/repository/commits/{identifier}")) + .await + { + Ok(data) => Ok(data["committed_date"].as_str().map(|s| s.to_string())), + Err(_) => Ok(None), + } + } + + async fn dist(&self, identifier: &str) -> Result> { + Ok(Some(DistReference { + dist_type: "zip".to_string(), + url: format!( + "{}://{}/api/v4/projects/{}/repository/archive.zip?sha={}", + self.scheme, + self.host, + self.project_id + .as_deref() + .unwrap_or(&format!("{}%2F{}", self.owner, self.repo)), + identifier, + ), + reference: identifier.to_string(), + shasum: None, + })) + } + + fn source(&self, identifier: &str) -> SourceReference { + SourceReference { + source_type: "git".to_string(), + url: format!( + "{}://{}/{}/{}.git", + self.scheme, self.host, self.owner, self.repo + ), + reference: identifier.to_string(), + } + } + + fn url(&self) -> &str { + &self.url + } + + async fn cleanup(&mut self) -> Result<()> { + if let Some(driver) = &mut self.git_driver { + driver.cleanup().await?; + } + Ok(()) + } +} diff --git a/crates/mozart-core/src/vcs/driver/hg.rs b/crates/mozart-core/src/vcs/driver/hg.rs new file mode 100644 index 0000000..e2c3fcd --- /dev/null +++ b/crates/mozart-core/src/vcs/driver/hg.rs @@ -0,0 +1,202 @@ +use super::super::process::ProcessExecutor; +use super::super::util::hg::HgUtil; +use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; +use anyhow::Result; +use indexmap::IndexMap; +use std::collections::BTreeMap; +use std::path::PathBuf; + +/// Mercurial VCS driver. +/// +/// Corresponds to Composer's `Repository\Vcs\HgDriver`. +pub struct HgDriver { + url: String, + repo_dir: Option, + root_identifier: Option, + tags: Option>, + branches: Option>, + info_cache: IndexMap>, + hg_util: HgUtil, + config: DriverConfig, +} + +impl HgDriver { + pub fn new(url: &str, config: DriverConfig) -> Self { + let process = ProcessExecutor::new(); + Self { + url: url.to_string(), + repo_dir: None, + root_identifier: None, + tags: None, + branches: None, + info_cache: IndexMap::new(), + hg_util: HgUtil::new(process), + config, + } + } + + pub fn supports(url: &str) -> bool { + url.starts_with("hg://") || url.contains("hg.") || url.ends_with(".hg") + } + + fn get_repo_dir(&self) -> Result<&PathBuf> { + self.repo_dir + .as_ref() + .ok_or_else(|| anyhow::anyhow!("HgDriver not initialized")) + } +} + +impl VcsDriver for HgDriver { + async fn initialize(&mut self) -> Result<()> { + let cache_dir = &self.config.cache_vcs_dir; + std::fs::create_dir_all(cache_dir)?; + let repo_dir = cache_dir.join(super::super::util::git::GitUtil::sanitize_url(&self.url)); + + if repo_dir.join(".hg").is_dir() { + // Update existing clone + self.hg_util.execute(&["pull"], Some(&repo_dir))?; + } else { + // Clone without checkout + let dir_str = repo_dir.to_string_lossy().to_string(); + self.hg_util + .execute(&["clone", "--noupdate", &self.url, &dir_str], None)?; + } + + self.repo_dir = Some(repo_dir.clone()); + + // Get default branch + let output = self.hg_util.execute( + &["log", "-r", "default", "--template", "{node|short}"], + Some(&repo_dir), + ); + self.root_identifier = match output { + Ok(o) if !o.stdout.trim().is_empty() => Some("default".to_string()), + _ => Some("tip".to_string()), + }; + + Ok(()) + } + + fn root_identifier(&self) -> &str { + self.root_identifier.as_deref().unwrap_or("default") + } + + async fn branches(&mut self) -> Result<&BTreeMap> { + if self.branches.is_none() { + let repo_dir = self.get_repo_dir()?.clone(); + let mut branches = BTreeMap::new(); + + // Named branches + let output = self.hg_util.execute(&["branches", "-q"], Some(&repo_dir))?; + for name in ProcessExecutor::split_lines(&output.stdout) { + let name = name.trim(); + let rev_output = self.hg_util.execute( + &["log", "-r", name, "--template", "{node}"], + Some(&repo_dir), + )?; + branches.insert(name.to_string(), rev_output.stdout.trim().to_string()); + } + + // Bookmarks + let output = self + .hg_util + .execute_unchecked(&["bookmarks", "-q"], Some(&repo_dir))?; + if output.status == 0 { + for name in ProcessExecutor::split_lines(&output.stdout) { + let name = name.trim(); + if !branches.contains_key(name) { + let rev_output = self.hg_util.execute( + &["log", "-r", name, "--template", "{node}"], + Some(&repo_dir), + )?; + branches.insert(name.to_string(), rev_output.stdout.trim().to_string()); + } + } + } + + self.branches = Some(branches); + } + Ok(self.branches.as_ref().unwrap()) + } + + async fn tags(&mut self) -> Result<&BTreeMap> { + if self.tags.is_none() { + let repo_dir = self.get_repo_dir()?.clone(); + let output = self.hg_util.execute(&["tags", "-q"], Some(&repo_dir))?; + let mut tags = BTreeMap::new(); + for name in ProcessExecutor::split_lines(&output.stdout) { + let name = name.trim(); + if name == "tip" { + continue; // Skip the "tip" pseudo-tag + } + let rev_output = self.hg_util.execute( + &["log", "-r", name, "--template", "{node}"], + Some(&repo_dir), + )?; + tags.insert(name.to_string(), rev_output.stdout.trim().to_string()); + } + self.tags = Some(tags); + } + Ok(self.tags.as_ref().unwrap()) + } + + async fn composer_information( + &mut self, + identifier: &str, + ) -> Result> { + if let Some(cached) = self.info_cache.get(identifier) { + return Ok(cached.clone()); + } + let content = self.file_content("composer.json", identifier).await?; + let value = content.and_then(|c| serde_json::from_str(&c).ok()); + self.info_cache + .insert(identifier.to_string(), value.clone()); + Ok(value) + } + + async fn file_content(&self, file: &str, identifier: &str) -> Result> { + let repo_dir = self.get_repo_dir()?; + let output = self + .hg_util + .execute_unchecked(&["cat", "-r", identifier, "--", file], Some(repo_dir))?; + if output.status == 0 { + Ok(Some(output.stdout)) + } else { + Ok(None) + } + } + + async fn change_date(&self, identifier: &str) -> Result> { + let repo_dir = self.get_repo_dir()?; + let output = self.hg_util.execute( + &["log", "-r", identifier, "--template", "{date|isodatesec}"], + Some(repo_dir), + )?; + let date = output.stdout.trim().to_string(); + if date.is_empty() { + Ok(None) + } else { + Ok(Some(date)) + } + } + + async fn dist(&self, _identifier: &str) -> Result> { + Ok(None) + } + + fn source(&self, identifier: &str) -> SourceReference { + SourceReference { + source_type: "hg".to_string(), + url: self.url.clone(), + reference: identifier.to_string(), + } + } + + fn url(&self) -> &str { + &self.url + } + + async fn cleanup(&mut self) -> Result<()> { + Ok(()) + } +} diff --git a/crates/mozart-core/src/vcs/driver/mod.rs b/crates/mozart-core/src/vcs/driver/mod.rs new file mode 100644 index 0000000..cfaf11e --- /dev/null +++ b/crates/mozart-core/src/vcs/driver/mod.rs @@ -0,0 +1,309 @@ +pub mod bitbucket; +pub mod forgejo; +pub mod git; +pub mod github; +pub mod gitlab; +pub mod hg; +pub mod svn; + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +/// Reference to a source distribution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SourceReference { + #[serde(rename = "type")] + pub source_type: String, + pub url: String, + pub reference: String, +} + +/// Reference to a dist (archive) distribution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DistReference { + #[serde(rename = "type")] + pub dist_type: String, + pub url: String, + pub reference: String, + pub shasum: Option, +} + +/// Configuration passed to VCS drivers. +#[derive(Debug, Clone)] +pub struct DriverConfig { + /// Composer's `cache-vcs-dir`: root for VCS mirrors, one + /// subdirectory per sanitized repository URL. + pub cache_vcs_dir: PathBuf, + /// GitHub OAuth token (from `GITHUB_TOKEN` or config). + pub github_token: Option, + /// GitLab OAuth token. + pub gitlab_token: Option, + /// Bitbucket OAuth consumer key/secret. + pub bitbucket_oauth: Option<(String, String)>, + /// Forgejo token. + pub forgejo_token: Option, + /// Custom GitLab domains (for self-hosted). + pub gitlab_domains: Vec, + /// Custom Forgejo domains (for self-hosted). + pub forgejo_domains: Vec, +} + +impl Default for DriverConfig { + fn default() -> Self { + Self { + cache_vcs_dir: default_cache_vcs_dir(), + github_token: None, + gitlab_token: None, + bitbucket_oauth: None, + forgejo_token: None, + gitlab_domains: vec!["gitlab.com".to_string()], + forgejo_domains: vec!["codeberg.org".to_string()], + } + } +} + +/// Resolve the default `cache-vcs-dir`, honoring Composer's env vars. +/// +/// Priority: `COMPOSER_CACHE_VCS_DIR` → `COMPOSER_CACHE_DIR/vcs` → +/// `XDG_CACHE_HOME/mozart/vcs` → `$HOME/.cache/mozart/vcs`. +fn default_cache_vcs_dir() -> PathBuf { + if let Ok(p) = std::env::var("COMPOSER_CACHE_VCS_DIR") { + return PathBuf::from(p); + } + let base = if let Ok(p) = std::env::var("COMPOSER_CACHE_DIR") { + PathBuf::from(p) + } else if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") { + PathBuf::from(xdg).join("mozart") + } else if let Ok(home) = std::env::var("HOME") { + PathBuf::from(home).join(".cache").join("mozart") + } else { + PathBuf::from("/tmp").join("mozart") + }; + base.join("vcs") +} + +/// Type of VCS driver. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DriverType { + GitHub, + GitLab, + Bitbucket, + Forgejo, + Git, + Svn, + Hg, +} + +/// The VCS driver interface. +/// +/// Corresponds to Composer's `VcsDriverInterface`. +trait VcsDriver { + /// Initialize the driver (e.g., clone mirror, fetch API metadata). + async fn initialize(&mut self) -> Result<()>; + + /// The root identifier (default branch/trunk). + fn root_identifier(&self) -> &str; + + /// All branches as `name -> commit_hash`. + async fn branches(&mut self) -> Result<&BTreeMap>; + + /// All tags as `name -> commit_hash`. + async fn tags(&mut self) -> Result<&BTreeMap>; + + /// Get composer.json content parsed as JSON for a given identifier. + async fn composer_information(&mut self, identifier: &str) + -> Result>; + + /// Get raw file content at a given path and identifier. + async fn file_content(&self, file: &str, identifier: &str) -> Result>; + + /// Get the change date for a given identifier (ISO 8601). + async fn change_date(&self, identifier: &str) -> Result>; + + /// Get the dist reference for a given identifier. + async fn dist(&self, identifier: &str) -> Result>; + + /// Get the source reference for a given identifier. + fn source(&self, identifier: &str) -> SourceReference; + + /// The canonical URL of this repository. + fn url(&self) -> &str; + + /// Clean up resources (temp dirs, etc.). + async fn cleanup(&mut self) -> Result<()>; +} + +/// Enum-dispatched VCS driver. +/// +/// Wraps all concrete driver types to allow static dispatch with async trait methods. +pub enum AnyVcsDriver { + GitHub(github::GitHubDriver), + GitLab(gitlab::GitLabDriver), + Bitbucket(bitbucket::BitbucketDriver), + Forgejo(forgejo::ForgejoDriver), + Git(git::GitDriver), + Svn(svn::SvnDriver), + Hg(hg::HgDriver), +} + +macro_rules! dispatch { + ($self:expr, $method:ident $(, $arg:expr)*) => { + match $self { + AnyVcsDriver::GitHub(d) => d.$method($($arg),*), + AnyVcsDriver::GitLab(d) => d.$method($($arg),*), + AnyVcsDriver::Bitbucket(d) => d.$method($($arg),*), + AnyVcsDriver::Forgejo(d) => d.$method($($arg),*), + AnyVcsDriver::Git(d) => d.$method($($arg),*), + AnyVcsDriver::Svn(d) => d.$method($($arg),*), + AnyVcsDriver::Hg(d) => d.$method($($arg),*), + } + }; +} + +macro_rules! dispatch_async { + ($self:expr, $method:ident $(, $arg:expr)*) => { + match $self { + AnyVcsDriver::GitHub(d) => d.$method($($arg),*).await, + AnyVcsDriver::GitLab(d) => d.$method($($arg),*).await, + AnyVcsDriver::Bitbucket(d) => d.$method($($arg),*).await, + AnyVcsDriver::Forgejo(d) => d.$method($($arg),*).await, + AnyVcsDriver::Git(d) => d.$method($($arg),*).await, + AnyVcsDriver::Svn(d) => d.$method($($arg),*).await, + AnyVcsDriver::Hg(d) => d.$method($($arg),*).await, + } + }; +} + +impl AnyVcsDriver { + pub async fn initialize(&mut self) -> Result<()> { + dispatch_async!(self, initialize) + } + + pub fn root_identifier(&self) -> &str { + dispatch!(self, root_identifier) + } + + pub async fn branches(&mut self) -> Result<&BTreeMap> { + dispatch_async!(self, branches) + } + + pub async fn tags(&mut self) -> Result<&BTreeMap> { + dispatch_async!(self, tags) + } + + pub async fn composer_information( + &mut self, + identifier: &str, + ) -> Result> { + dispatch_async!(self, composer_information, identifier) + } + + pub async fn file_content(&self, file: &str, identifier: &str) -> Result> { + dispatch_async!(self, file_content, file, identifier) + } + + pub async fn change_date(&self, identifier: &str) -> Result> { + dispatch_async!(self, change_date, identifier) + } + + pub async fn dist(&self, identifier: &str) -> Result> { + dispatch_async!(self, dist, identifier) + } + + pub fn source(&self, identifier: &str) -> SourceReference { + dispatch!(self, source, identifier) + } + + pub fn url(&self) -> &str { + dispatch!(self, url) + } + + pub async fn cleanup(&mut self) -> Result<()> { + dispatch_async!(self, cleanup) + } +} + +/// Detect which driver type should handle a given URL. +/// +/// Priority order matches Composer: +/// 1. GitHub → 2. GitLab → 3. Bitbucket → 4. Forgejo → 5. Git → 6. Hg → 7. SVN +pub fn detect_driver( + url: &str, + forced_type: Option<&str>, + config: &DriverConfig, +) -> Option { + if let Some(t) = forced_type { + return match t { + "github" => Some(DriverType::GitHub), + "gitlab" => Some(DriverType::GitLab), + "bitbucket" => Some(DriverType::Bitbucket), + "forgejo" => Some(DriverType::Forgejo), + "git" => Some(DriverType::Git), + "svn" => Some(DriverType::Svn), + "hg" | "mercurial" => Some(DriverType::Hg), + _ => None, + }; + } + + let url_lower = url.to_lowercase(); + + // GitHub + if github::GitHubDriver::supports(url) { + return Some(DriverType::GitHub); + } + + // GitLab + if gitlab::GitLabDriver::supports(url, &config.gitlab_domains) { + return Some(DriverType::GitLab); + } + + // Bitbucket + if bitbucket::BitbucketDriver::supports(url) { + return Some(DriverType::Bitbucket); + } + + // Forgejo + if forgejo::ForgejoDriver::supports(url, &config.forgejo_domains) { + return Some(DriverType::Forgejo); + } + + // Git + if git::GitDriver::supports(url) { + return Some(DriverType::Git); + } + + // Hg + if hg::HgDriver::supports(url) { + return Some(DriverType::Hg); + } + + // SVN + if url_lower.contains("svn") || svn::SvnDriver::supports(url) { + return Some(DriverType::Svn); + } + + // Default to git for generic URLs + if url.starts_with("http://") || url.starts_with("https://") { + return Some(DriverType::Git); + } + + None +} + +/// Create a driver instance for the given URL and type. +pub fn create_driver(url: &str, driver_type: DriverType, config: DriverConfig) -> AnyVcsDriver { + match driver_type { + DriverType::GitHub => AnyVcsDriver::GitHub(github::GitHubDriver::new(url, config)), + DriverType::GitLab => AnyVcsDriver::GitLab(gitlab::GitLabDriver::new(url, config)), + DriverType::Bitbucket => { + AnyVcsDriver::Bitbucket(bitbucket::BitbucketDriver::new(url, config)) + } + DriverType::Forgejo => AnyVcsDriver::Forgejo(forgejo::ForgejoDriver::new(url, config)), + DriverType::Git => AnyVcsDriver::Git(git::GitDriver::new(url, config)), + DriverType::Svn => AnyVcsDriver::Svn(svn::SvnDriver::new(url, config)), + DriverType::Hg => AnyVcsDriver::Hg(hg::HgDriver::new(url, config)), + } +} diff --git a/crates/mozart-core/src/vcs/driver/svn.rs b/crates/mozart-core/src/vcs/driver/svn.rs new file mode 100644 index 0000000..7ba9e86 --- /dev/null +++ b/crates/mozart-core/src/vcs/driver/svn.rs @@ -0,0 +1,214 @@ +use super::super::process::ProcessExecutor; +use super::super::util::svn::SvnUtil; +use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; +use anyhow::Result; +use indexmap::IndexMap; +use regex::Regex; +use std::collections::BTreeMap; + +/// SVN VCS driver. +/// +/// Corresponds to Composer's `Repository\Vcs\SvnDriver`. +pub struct SvnDriver { + url: String, + base_url: String, + trunk_path: String, + branches_path: String, + tags_path: String, + root_identifier: Option, + tags: Option>, + branches: Option>, + info_cache: IndexMap>, + svn_util: SvnUtil, +} + +impl SvnDriver { + pub fn new(url: &str, _config: DriverConfig) -> Self { + let process = ProcessExecutor::new(); + Self { + url: url.to_string(), + base_url: url.to_string(), + trunk_path: "trunk".to_string(), + branches_path: "branches".to_string(), + tags_path: "tags".to_string(), + root_identifier: None, + tags: None, + branches: None, + info_cache: IndexMap::new(), + svn_util: SvnUtil::new(process), + } + } + + pub fn supports(url: &str) -> bool { + url.starts_with("svn://") || url.starts_with("svn+ssh://") + } + + fn svn_info(&self, url: &str) -> Result { + let output = self.svn_util.execute(&["info", "--xml", url], None)?; + // Parse minimal info from XML output + let stdout = &output.stdout; + let mut info = serde_json::Map::new(); + + if let Some(rev) = extract_xml_attr(stdout, "entry", "revision") { + info.insert("revision".to_string(), serde_json::Value::String(rev)); + } + if let Some(url_val) = extract_xml_content(stdout, "url") { + info.insert("url".to_string(), serde_json::Value::String(url_val)); + } + if let Some(date) = extract_xml_content(stdout, "date") { + info.insert("date".to_string(), serde_json::Value::String(date)); + } + + Ok(serde_json::Value::Object(info)) + } + + fn svn_ls(&self, url: &str) -> Result> { + let output = self.svn_util.execute(&["ls", url], None)?; + Ok(ProcessExecutor::split_lines(&output.stdout) + .into_iter() + .map(|s| s.trim_end_matches('/').to_string()) + .collect()) + } +} + +impl VcsDriver for SvnDriver { + async fn initialize(&mut self) -> Result<()> { + let info = self.svn_info(&self.url)?; + if let Some(url) = info["url"].as_str() { + self.base_url = url.to_string(); + } + self.root_identifier = info["revision"].as_str().map(|s| s.to_string()); + Ok(()) + } + + fn root_identifier(&self) -> &str { + self.root_identifier.as_deref().unwrap_or("HEAD") + } + + async fn branches(&mut self) -> Result<&BTreeMap> { + if self.branches.is_none() { + let mut branches = BTreeMap::new(); + + // Add trunk + let trunk_url = format!("{}/{}", self.base_url, self.trunk_path); + if let Ok(info) = self.svn_info(&trunk_url) + && let Some(rev) = info["revision"].as_str() + { + branches.insert("trunk".to_string(), rev.to_string()); + } + + // List branches directory + let branches_url = format!("{}/{}", self.base_url, self.branches_path); + if let Ok(items) = self.svn_ls(&branches_url) { + for name in items { + let branch_url = format!("{}/{}", branches_url, name); + if let Ok(info) = self.svn_info(&branch_url) + && let Some(rev) = info["revision"].as_str() + { + branches.insert(name, rev.to_string()); + } + } + } + + self.branches = Some(branches); + } + Ok(self.branches.as_ref().unwrap()) + } + + async fn tags(&mut self) -> Result<&BTreeMap> { + if self.tags.is_none() { + let mut tags = BTreeMap::new(); + let tags_url = format!("{}/{}", self.base_url, self.tags_path); + if let Ok(items) = self.svn_ls(&tags_url) { + for name in items { + let tag_url = format!("{}/{}", tags_url, name); + if let Ok(info) = self.svn_info(&tag_url) + && let Some(rev) = info["revision"].as_str() + { + tags.insert(name, rev.to_string()); + } + } + } + self.tags = Some(tags); + } + Ok(self.tags.as_ref().unwrap()) + } + + async fn composer_information( + &mut self, + identifier: &str, + ) -> Result> { + if let Some(cached) = self.info_cache.get(identifier) { + return Ok(cached.clone()); + } + let content = self.file_content("composer.json", identifier).await?; + let value = content.and_then(|c| serde_json::from_str(&c).ok()); + self.info_cache + .insert(identifier.to_string(), value.clone()); + Ok(value) + } + + async fn file_content(&self, file: &str, identifier: &str) -> Result> { + // identifier is either a path (trunk, branches/x, tags/y) or a revision number + let url = if identifier.contains('/') || identifier == "trunk" { + format!("{}/{}/{}", self.base_url, identifier, file) + } else { + format!( + "{}/{}/{}@{}", + self.base_url, self.trunk_path, file, identifier + ) + }; + let output = self.svn_util.execute(&["cat", &url], None); + match output { + Ok(o) if !o.stdout.is_empty() => Ok(Some(o.stdout)), + _ => Ok(None), + } + } + + async fn change_date(&self, identifier: &str) -> Result> { + let url = if identifier.contains('/') || identifier == "trunk" { + format!("{}/{}", self.base_url, identifier) + } else { + format!("{}@{}", self.base_url, identifier) + }; + match self.svn_info(&url) { + Ok(info) => Ok(info["date"].as_str().map(|s| s.to_string())), + Err(_) => Ok(None), + } + } + + async fn dist(&self, _identifier: &str) -> Result> { + // SVN doesn't provide dist archives + Ok(None) + } + + fn source(&self, identifier: &str) -> SourceReference { + SourceReference { + source_type: "svn".to_string(), + url: self.base_url.clone(), + reference: identifier.to_string(), + } + } + + fn url(&self) -> &str { + &self.url + } + + async fn cleanup(&mut self) -> Result<()> { + Ok(()) + } +} + +/// Extract an XML attribute value from a simple XML string. +fn extract_xml_attr(xml: &str, tag: &str, attr: &str) -> Option { + let pattern = format!(r#"<{tag}\s[^>]*{attr}="([^"]*)"#); + let re = Regex::new(&pattern).ok()?; + re.captures(xml).map(|c| c[1].to_string()) +} + +/// Extract text content between XML tags. +fn extract_xml_content(xml: &str, tag: &str) -> Option { + let pattern = format!(r"<{tag}>([^<]*)"); + let re = Regex::new(&pattern).ok()?; + re.captures(xml).map(|c| c[1].to_string()) +} diff --git a/crates/mozart-core/src/vcs/process.rs b/crates/mozart-core/src/vcs/process.rs new file mode 100644 index 0000000..8ccc11d --- /dev/null +++ b/crates/mozart-core/src/vcs/process.rs @@ -0,0 +1,142 @@ +use indexmap::IndexMap; +use std::path::Path; +use std::process::Command; +use std::time::{Duration, Instant}; + +use anyhow::{Result, bail}; + +/// Output from a process execution. +#[derive(Debug, Clone)] +pub struct ProcessOutput { + pub status: i32, + pub stdout: String, + pub stderr: String, +} + +/// Wrapper around `std::process::Command` for executing external programs. +/// +/// Corresponds to Composer's `ProcessExecutor`. +pub struct ProcessExecutor { + timeout: Option, + env_overrides: IndexMap>, +} + +impl Default for ProcessExecutor { + fn default() -> Self { + Self::new() + } +} + +impl ProcessExecutor { + pub fn new() -> Self { + Self { + timeout: None, + env_overrides: IndexMap::new(), + } + } + + pub fn with_timeout(secs: u64) -> Self { + Self { + timeout: Some(Duration::from_secs(secs)), + env_overrides: IndexMap::new(), + } + } + + /// Set an environment variable override for all subsequent executions. + pub fn set_env(&mut self, key: impl Into, value: impl Into) { + self.env_overrides.insert(key.into(), Some(value.into())); + } + + /// Remove an environment variable for all subsequent executions. + pub fn remove_env(&mut self, key: impl Into) { + self.env_overrides.insert(key.into(), None); + } + + /// Execute a command. Does not error on non-zero exit status. + pub fn execute(&self, args: &[&str], cwd: Option<&Path>) -> Result { + if args.is_empty() { + bail!("No command specified"); + } + + let mut cmd = Command::new(args[0]); + if args.len() > 1 { + cmd.args(&args[1..]); + } + if let Some(dir) = cwd { + cmd.current_dir(dir); + } + + for (key, value) in &self.env_overrides { + match value { + Some(v) => { + cmd.env(key, v); + } + None => { + cmd.env_remove(key); + } + } + } + + if let Some(timeout) = self.timeout { + let mut child = cmd + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn()?; + + let start = Instant::now(); + loop { + match child.try_wait()? { + Some(status) => { + let mut stdout = String::new(); + let mut stderr = String::new(); + if let Some(ref mut out) = child.stdout { + std::io::Read::read_to_string(out, &mut stdout)?; + } + if let Some(ref mut err) = child.stderr { + std::io::Read::read_to_string(err, &mut stderr)?; + } + return Ok(ProcessOutput { + status: status.code().unwrap_or(-1), + stdout, + stderr, + }); + } + None => { + if start.elapsed() > timeout { + let _ = child.kill(); + bail!("Process timed out after {} seconds", timeout.as_secs()); + } + std::thread::sleep(Duration::from_millis(50)); + } + } + } + } else { + let output = cmd.output()?; + Ok(ProcessOutput { + status: output.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) + } + } + + /// Execute a command, returning an error if the exit status is non-zero. + pub fn execute_checked(&self, args: &[&str], cwd: Option<&Path>) -> Result { + let output = self.execute(args, cwd)?; + if output.status != 0 { + bail!( + "Command `{}` failed with exit code {}\nstdout: {}\nstderr: {}", + args.join(" "), + output.status, + output.stdout.trim(), + output.stderr.trim(), + ); + } + Ok(output) + } + + /// Split output into non-empty lines. + pub fn split_lines(output: &str) -> Vec<&str> { + output.lines().filter(|l| !l.is_empty()).collect() + } +} diff --git a/crates/mozart-core/src/vcs/repository.rs b/crates/mozart-core/src/vcs/repository.rs new file mode 100644 index 0000000..55f98f9 --- /dev/null +++ b/crates/mozart-core/src/vcs/repository.rs @@ -0,0 +1,205 @@ +use super::driver::{ + DistReference, DriverConfig, DriverType, SourceReference, create_driver, detect_driver, +}; +use anyhow::{Result, bail}; + +/// A single package version discovered from a VCS repository. +#[derive(Debug, Clone)] +pub struct VcsPackageVersion { + /// Package name (from composer.json). + pub name: String, + /// Version string (e.g., "1.2.3" for tags, "dev-main" for branches). + pub version: String, + /// Normalized version for comparison. + pub version_normalized: String, + /// Full composer.json data as JSON. + pub composer_json: serde_json::Value, + /// Source reference (VCS checkout info). + pub source: SourceReference, + /// Dist reference (archive download, if available). + pub dist: Option, + /// Whether this is the default branch version. + pub is_default_branch: bool, + /// Release date (ISO 8601). + pub time: Option, +} + +/// Repository that scans a VCS URL for package versions. +/// +/// Corresponds to Composer's `Repository\VcsRepository`. +pub struct VcsRepository { + url: String, + driver_type: Option, + config: DriverConfig, +} + +impl VcsRepository { + pub fn new(url: String, repo_type: Option<&str>, config: DriverConfig) -> Self { + let driver_type = detect_driver(&url, repo_type, &config); + Self { + url, + driver_type, + config, + } + } + + /// Scan the VCS repository for all package versions. + /// + /// 1. Detects the driver type and initializes it + /// 2. Reads composer.json from the root to get the package name + /// 3. Scans tags → version releases + /// 4. Scans branches → dev versions + pub async fn scan(&self) -> Result> { + let driver_type = self + .driver_type + .ok_or_else(|| anyhow::anyhow!("No suitable VCS driver found for URL: {}", self.url))?; + + let mut driver = create_driver(&self.url, driver_type, self.config.clone()); + driver.initialize().await?; + + // Get package name from root composer.json + let root_id = driver.root_identifier().to_string(); + let root_info = driver.composer_information(&root_id).await?; + let package_name = match &root_info { + Some(info) => info["name"] + .as_str() + .ok_or_else(|| { + anyhow::anyhow!( + "composer.json at root of {} does not contain a 'name' field", + self.url, + ) + })? + .to_string(), + None => bail!( + "No composer.json found at root of {} (ref: {})", + self.url, + root_id, + ), + }; + + let mut versions = Vec::new(); + + // Scan tags + let tags = driver.tags().await?.clone(); + for (tag_name, tag_hash) in &tags { + if let Some(version) = self.tag_to_version(tag_name) { + match driver.composer_information(tag_hash).await { + Ok(Some(info)) => { + let time = driver.change_date(tag_hash).await.unwrap_or(None); + let source = driver.source(tag_hash); + let dist = driver.dist(tag_hash).await.unwrap_or(None); + + // Ensure name matches root package + if info["name"].as_str() != Some(&package_name) { + continue; + } + + let normalized = self.normalize_version(&version); + + versions.push(VcsPackageVersion { + name: package_name.clone(), + version: version.clone(), + version_normalized: normalized, + composer_json: info, + source, + dist, + is_default_branch: false, + time, + }); + } + Ok(None) | Err(_) => continue, + } + } + } + + // Scan branches + let branches = driver.branches().await?.clone(); + let default_branch = driver.root_identifier().to_string(); + for (branch_name, branch_hash) in &branches { + match driver.composer_information(branch_hash).await { + Ok(Some(info)) => { + if info["name"].as_str() != Some(&package_name) { + continue; + } + + let time = driver.change_date(branch_hash).await.unwrap_or(None); + let source = driver.source(branch_hash); + let dist = driver.dist(branch_hash).await.unwrap_or(None); + let is_default = branch_name == &default_branch; + + let version = self.branch_to_version(branch_name); + let normalized = self.normalize_version(&version); + + // Check for branch-alias + let aliased_version = info + .get("extra") + .and_then(|e| e.get("branch-alias")) + .and_then(|ba| ba.get(format!("dev-{branch_name}"))) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + versions.push(VcsPackageVersion { + name: package_name.clone(), + version: aliased_version.unwrap_or(version), + version_normalized: normalized, + composer_json: info, + source, + dist, + is_default_branch: is_default, + time, + }); + } + Ok(None) | Err(_) => continue, + } + } + + driver.cleanup().await?; + Ok(versions) + } + + /// Convert a tag name to a version string. + /// Returns `None` if the tag doesn't look like a version. + fn tag_to_version(&self, tag: &str) -> Option { + // Strip common prefixes + let version = tag + .strip_prefix('v') + .or_else(|| tag.strip_prefix("V")) + .or_else(|| tag.strip_prefix("release-")) + .or_else(|| tag.strip_prefix("release/")) + .unwrap_or(tag); + + // Basic semver-ish check + if version.is_empty() { + return None; + } + if version.chars().next()?.is_ascii_digit() { + Some(version.to_string()) + } else { + None + } + } + + /// Convert a branch name to a dev version string. + fn branch_to_version(&self, branch: &str) -> String { + // Numeric branches like "1.x", "2.0" become "1.x-dev", "2.0.x-dev" + if branch.chars().next().is_some_and(|c| c.is_ascii_digit()) { + let version = if branch.ends_with(".x") || branch.ends_with(".*") { + branch.to_string() + } else { + format!("{branch}.x") + }; + format!("{version}-dev") + } else { + format!("dev-{branch}") + } + } + + /// Normalize a version string. + fn normalize_version(&self, version: &str) -> String { + // Use mozart-semver for proper normalization if available, + // otherwise do a simple normalization + mozart_semver::Version::parse(version) + .map(|v| v.to_string()) + .unwrap_or_else(|_| version.to_string()) + } +} diff --git a/crates/mozart-core/src/vcs/util/git.rs b/crates/mozart-core/src/vcs/util/git.rs new file mode 100644 index 0000000..15bfa09 --- /dev/null +++ b/crates/mozart-core/src/vcs/util/git.rs @@ -0,0 +1,312 @@ +use super::super::process::{ProcessExecutor, ProcessOutput}; +use anyhow::{Result, bail}; +use regex::Regex; +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; + +/// Modern GitHub token pattern (40+ hex chars, `ghp_…`, `github_pat_…`). +/// +/// Mirrors `Composer\Util\GitHub::GITHUB_TOKEN_REGEX`. +static GITHUB_TOKEN_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^([a-fA-F0-9]{12,}|gh[a-zA-Z]_[a-zA-Z0-9_]+|github_pat_[a-zA-Z0-9_]+)$").unwrap() +}); + +/// `[?&]access_token=...` query parameter. +static ACCESS_TOKEN_RE: LazyLock = + LazyLock::new(|| Regex::new(r"([&?]access_token=)[^&]+").unwrap()); + +/// `://user:password@` credential block. +static CREDENTIALS_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)(?P[a-z0-9]+://)?(?P[^:/\s@]+):(?P[^@\s/]+)@").unwrap() +}); + +/// Git utility for mirror management and protocol fallback. +/// +/// Corresponds to Composer's `Util\Git`. +pub struct GitUtil { + process: ProcessExecutor, + cache_dir: PathBuf, +} + +impl GitUtil { + pub fn new(process: ProcessExecutor, cache_dir: PathBuf) -> Self { + Self { process, cache_dir } + } + + /// Returns environment variable overrides to clean Git state. + /// Removes `GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE` to avoid + /// interference from the calling process's Git context. + pub fn clean_env() -> Vec<(&'static str, Option<&'static str>)> { + vec![ + ("GIT_DIR", None), + ("GIT_WORK_TREE", None), + ("GIT_INDEX_FILE", None), + ("GIT_TERMINAL_PROMPT", Some("0")), + ] + } + + /// Synchronize a bare mirror in the cache directory. + /// + /// On first call, clones a bare mirror. On subsequent calls, updates it. + /// Returns the path to the mirror directory. + pub fn sync_mirror(&self, url: &str) -> Result { + let mirror_dir = self.mirror_path(url); + + if mirror_dir.join("HEAD").exists() { + // Update existing mirror + self.run_command( + &["git", "remote", "set-url", "origin", "--", url], + url, + Some(&mirror_dir), + )?; + self.run_command( + &["git", "remote", "update", "--prune", "origin"], + url, + Some(&mirror_dir), + )?; + } else { + // Create new mirror + std::fs::create_dir_all(&mirror_dir)?; + self.run_command( + &[ + "git", + "clone", + "--mirror", + "--", + url, + mirror_dir.to_str().unwrap_or(""), + ], + url, + None, + )?; + } + + Ok(mirror_dir) + } + + /// Fetch a specific refspec from the mirror. + pub fn fetch_ref(&self, mirror_dir: &Path, refspec: &str) -> Result { + let output = self + .process + .execute(&["git", "fetch", "origin", refspec], Some(mirror_dir))?; + Ok(output.status == 0) + } + + /// Get the default branch of a repository. + pub fn get_default_branch(&self, mirror_dir: &Path) -> Result> { + let output = self + .process + .execute(&["git", "remote", "show", "origin"], Some(mirror_dir))?; + if output.status != 0 { + return Ok(None); + } + for line in output.stdout.lines() { + let trimmed = line.trim(); + if let Some(branch) = trimmed.strip_prefix("HEAD branch:") { + let branch = branch.trim(); + if branch != "(unknown)" { + return Ok(Some(branch.to_string())); + } + } + } + Ok(None) + } + + /// Execute a git command with protocol fallback. + /// + /// Tries the URL as-is first, then falls back through protocol variations + /// (ssh → https → git://) if the command fails. + pub fn run_command( + &self, + args: &[&str], + url: &str, + cwd: Option<&Path>, + ) -> Result { + let mut executor = ProcessExecutor::new(); + for (key, value) in Self::clean_env() { + match value { + Some(v) => executor.set_env(key, v), + None => executor.remove_env(key), + } + } + + // Try the command as-is first + let output = executor.execute(args, cwd)?; + if output.status == 0 { + return Ok(output); + } + + // Try protocol fallback for remote URLs + let fallback_urls = Self::get_fallback_urls(url); + for fallback_url in &fallback_urls { + let new_args: Vec<&str> = args + .iter() + .map(|&a| if a == url { fallback_url.as_str() } else { a }) + .collect(); + let fallback_output = executor.execute(&new_args, cwd)?; + if fallback_output.status == 0 { + return Ok(fallback_output); + } + } + + // Return the original error + if output.status != 0 { + bail!( + "Git command `{}` failed with exit code {}\nstdout: {}\nstderr: {}", + args.join(" "), + output.status, + output.stdout.trim(), + output.stderr.trim(), + ); + } + Ok(output) + } + + /// Get the Git version string. + pub fn get_version(&self) -> Option { + let output = self.process.execute(&["git", "--version"], None).ok()?; + if output.status != 0 { + return None; + } + // "git version 2.39.2" -> "2.39.2" + output + .stdout + .trim() + .strip_prefix("git version ") + .map(|s| s.to_string()) + } + + /// Sanitize a URL for use as a cache directory name. + /// + /// Mirrors Composer's `Preg::replace('{[^a-z0-9.]}i', '-', Url::sanitize($url))` + /// pattern (see `GitDriver::initialize` and `GitDownloader`): credentials and + /// access tokens are first redacted, then every byte outside `[a-zA-Z0-9.]` + /// is replaced with `-`. The redaction step keeps cache keys stable across + /// URLs that differ only in their embedded token. + pub fn sanitize_url(url: &str) -> String { + let redacted = sanitize_url_credentials(url); + redacted + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '.' { + c + } else { + '-' + } + }) + .collect() + } + + /// Get the cache mirror path for a URL. + pub fn mirror_path(&self, url: &str) -> PathBuf { + self.cache_dir.join(Self::sanitize_url(url)) + } + + /// Generate fallback URLs for protocol switching. + fn get_fallback_urls(url: &str) -> Vec { + let mut urls = Vec::new(); + + // ssh -> https fallback + if url.starts_with("git@") { + // git@github.com:owner/repo.git -> https://github.com/owner/repo.git + if let Some(rest) = url.strip_prefix("git@") { + let converted = rest.replacen(':', "/", 1); + urls.push(format!("https://{converted}")); + } + } + + // git:// -> https:// fallback + if let Some(rest) = url.strip_prefix("git://") { + urls.push(format!("https://{rest}")); + } + + // https -> git:// fallback + if let Some(rest) = url.strip_prefix("https://") { + urls.push(format!("git://{rest}")); + } + + urls + } +} + +/// Redact credentials and access tokens from `url`. +/// +/// Mirrors Composer's `Util\Url::sanitize`. Two replacements are applied: +/// 1. `[?&]access_token=…` query values → `***` +/// 2. `://user:password@` credentials → `***:***@` if `user` looks like +/// a GitHub token, otherwise just `user:***@` +fn sanitize_url_credentials(url: &str) -> String { + let url = ACCESS_TOKEN_RE.replace_all(url, "${1}***"); + CREDENTIALS_RE + .replace_all(&url, |caps: ®ex::Captures<'_>| { + let prefix = caps.name("prefix").map(|m| m.as_str()).unwrap_or(""); + let user = &caps["user"]; + if GITHUB_TOKEN_RE.is_match(user) { + format!("{prefix}***:***@") + } else { + format!("{prefix}{user}:***@") + } + }) + .into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_url_replaces_special_chars_with_dash() { + assert_eq!( + GitUtil::sanitize_url("https://github.com/owner/repo.git"), + "https---github.com-owner-repo.git" + ); + } + + #[test] + fn sanitize_url_preserves_dot() { + // Dot must survive — it appears in hostnames and ".git" suffixes. + let key = GitUtil::sanitize_url("git://example.org/foo.bar/baz.git"); + assert!(key.contains(".org")); + assert!(key.ends_with(".git")); + } + + #[test] + fn sanitize_url_redacts_password_in_credentials() { + let key = GitUtil::sanitize_url("https://alice:s3cret@example.com/repo.git"); + // Password is replaced with ***, then non-alphanumerics become '-'. + assert!(key.contains("alice")); + assert!(!key.contains("s3cret")); + } + + #[test] + fn sanitize_url_redacts_user_when_looks_like_github_token() { + // 40-hex token in the user position triggers full redaction. + let token = "abcdef0123456789abcdef0123456789abcdef01"; + let key = GitUtil::sanitize_url(&format!("https://{token}:x-oauth-basic@github.com/o/r")); + assert!(!key.contains("abcdef")); + } + + #[test] + fn sanitize_url_redacts_modern_github_pat() { + // ghp_xxx and github_pat_xxx forms. + let key1 = GitUtil::sanitize_url("https://ghp_abc123XYZ:x@github.com/o/r"); + assert!(!key1.contains("ghp_")); + let key2 = GitUtil::sanitize_url("https://github_pat_abc123:x@github.com/o/r"); + assert!(!key2.contains("github_pat_")); + } + + #[test] + fn sanitize_url_strips_access_token_query() { + let key = GitUtil::sanitize_url("https://api.github.com/x?access_token=secrettoken"); + assert!(!key.contains("secrettoken")); + } + + #[test] + fn sanitize_url_token_variants_share_cache_key() { + // Two pulls of the same repo with different access tokens should land + // in the same cache subdirectory. + let a = GitUtil::sanitize_url("https://api.github.com/repo?access_token=tokenA"); + let b = GitUtil::sanitize_url("https://api.github.com/repo?access_token=tokenB"); + assert_eq!(a, b); + } +} diff --git a/crates/mozart-core/src/vcs/util/hg.rs b/crates/mozart-core/src/vcs/util/hg.rs new file mode 100644 index 0000000..73051b7 --- /dev/null +++ b/crates/mozart-core/src/vcs/util/hg.rs @@ -0,0 +1,28 @@ +use super::super::process::{ProcessExecutor, ProcessOutput}; +use anyhow::Result; +use std::path::Path; + +/// Mercurial utility for command execution. +pub struct HgUtil { + process: ProcessExecutor, +} + +impl HgUtil { + pub fn new(process: ProcessExecutor) -> Self { + Self { process } + } + + /// Execute a Mercurial command. + pub fn execute(&self, args: &[&str], cwd: Option<&Path>) -> Result { + let mut full_args = vec!["hg"]; + full_args.extend_from_slice(args); + self.process.execute_checked(&full_args, cwd) + } + + /// Execute a Mercurial command, not erroring on non-zero exit. + pub fn execute_unchecked(&self, args: &[&str], cwd: Option<&Path>) -> Result { + let mut full_args = vec!["hg"]; + full_args.extend_from_slice(args); + self.process.execute(&full_args, cwd) + } +} diff --git a/crates/mozart-core/src/vcs/util/mod.rs b/crates/mozart-core/src/vcs/util/mod.rs new file mode 100644 index 0000000..b2c35fc --- /dev/null +++ b/crates/mozart-core/src/vcs/util/mod.rs @@ -0,0 +1,3 @@ +pub mod git; +pub mod hg; +pub mod svn; diff --git a/crates/mozart-core/src/vcs/util/svn.rs b/crates/mozart-core/src/vcs/util/svn.rs new file mode 100644 index 0000000..d989fc8 --- /dev/null +++ b/crates/mozart-core/src/vcs/util/svn.rs @@ -0,0 +1,89 @@ +use super::super::process::{ProcessExecutor, ProcessOutput}; +use anyhow::Result; +use std::path::Path; + +/// SVN credentials for authenticated operations. +#[derive(Debug, Clone)] +pub struct SvnCredentials { + pub username: String, + pub password: String, +} + +/// SVN utility for command execution with credential handling. +pub struct SvnUtil { + process: ProcessExecutor, +} + +impl SvnUtil { + pub fn new(process: ProcessExecutor) -> Self { + Self { process } + } + + /// Execute an SVN command with `--non-interactive`. + pub fn execute(&self, args: &[&str], cwd: Option<&Path>) -> Result { + let mut full_args = vec!["svn"]; + full_args.extend_from_slice(args); + full_args.push("--non-interactive"); + self.process.execute_checked(&full_args, cwd) + } + + /// Execute an SVN command with optional credentials, retrying on auth failure. + pub fn execute_with_credentials( + &self, + args: &[&str], + creds: Option<&SvnCredentials>, + cwd: Option<&Path>, + ) -> Result { + let mut full_args = vec!["svn"]; + full_args.extend_from_slice(args); + full_args.push("--non-interactive"); + + let cred_args: Vec; + if let Some(c) = creds { + cred_args = vec![ + "--username".to_string(), + c.username.clone(), + "--password".to_string(), + c.password.clone(), + ]; + for arg in &cred_args { + full_args.push(arg); + } + } + + let full_args_refs: Vec<&str> = full_args.iter().map(|s| &**s).collect(); + + // Retry up to 5 times on auth failure + let max_retries = 5; + let mut last_output = None; + for _ in 0..max_retries { + let output = self.process.execute(&full_args_refs, cwd)?; + if output.status == 0 { + return Ok(output); + } + // Check if it's an auth error (SVN exit code or stderr hint) + if !output.stderr.contains("authorization failed") + && !output.stderr.contains("Could not authenticate") + && !output.stderr.contains("Authentication failed") + { + // Not an auth error, return immediately + last_output = Some(output); + break; + } + last_output = Some(output); + } + + match last_output { + Some(output) if output.status != 0 => { + anyhow::bail!( + "SVN command `{}` failed with exit code {}\nstderr: {}", + full_args_refs.join(" "), + output.status, + output.stderr.trim(), + ); + } + Some(output) => Ok(output), + None => anyhow::bail!("SVN command failed with no output"), + } + } +} diff --git a/crates/mozart-core/src/vcs/version_guesser.rs b/crates/mozart-core/src/vcs/version_guesser.rs new file mode 100644 index 0000000..58b758e --- /dev/null +++ b/crates/mozart-core/src/vcs/version_guesser.rs @@ -0,0 +1,602 @@ +//! `VersionGuesser` — derive a package's current version from the working +//! copy, mirroring `Composer\Package\Version\VersionGuesser`. +//! +//! Differences from the PHP version: +//! - Fossil is not supported (Mozart has no Fossil driver). +//! - `Platform::isInputCompletionProcess()` short-circuit is omitted. +//! - `guess_feature_version` runs candidate comparisons sequentially. +//! Composer parallelises via `executeAsync`; ours is simpler at the +//! cost of speed when many candidate branches exist. + +use super::process::ProcessExecutor; +use mozart_semver::{Version, normalize_branch}; +use regex::Regex; +use serde_json::Value; +use std::path::Path; +use std::sync::LazyLock; + +const DEFAULT_BRANCH_ALIAS: &str = "9999999-dev"; + +/// Mirrors `Composer\Package\Version\VersionParser` (itself a thin wrapper +/// around `Composer\Semver\VersionParser`). In Rust, semver parsing is +/// handled by `mozart_semver` directly, so this type carries no state; +/// it exists to keep `VersionGuesser::new` signature compatible with the +/// PHP constructor. +pub struct VersionParser; + +impl Default for VersionParser { + fn default() -> Self { + Self::new() + } +} + +impl VersionParser { + pub fn new() -> Self { + Self + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GuessedVersion { + pub version: String, + pub commit: Option, + pub pretty_version: Option, + pub feature_version: Option, + pub feature_pretty_version: Option, +} + +pub struct VersionGuesser { + process: ProcessExecutor, +} + +impl Default for VersionGuesser { + fn default() -> Self { + Self::new(VersionParser::new()) + } +} + +impl VersionGuesser { + /// Mirrors `Composer\Package\Version\VersionGuesser::__construct`. + /// `_version_parser` is accepted for API parity but unused — Rust relies + /// on `mozart_semver` directly. + pub fn new(_version_parser: VersionParser) -> Self { + Self { + process: ProcessExecutor::new(), + } + } + + /// `Composer\Package\Version\VersionGuesser::guessVersion`. + pub fn guess_version(&self, package_config: &Value, path: &Path) -> Option { + if let Some(v) = self.guess_git_version(package_config, path) { + return Some(postprocess(v)); + } + if let Some(v) = self.guess_hg_version(package_config, path) { + return Some(postprocess(v)); + } + if let Some(v) = self.guess_svn_version(package_config, path) { + return Some(postprocess(v)); + } + None + } + + fn guess_git_version(&self, package_config: &Value, path: &Path) -> Option { + let mut commit: Option = None; + let mut version: Option = None; + let mut pretty_version: Option = None; + let mut feature_version: Option = None; + let mut feature_pretty_version: Option = None; + let mut is_detached = false; + + let branch_out = self + .process + .execute( + &["git", "branch", "-a", "--no-color", "--no-abbrev", "-v"], + Some(path), + ) + .ok()?; + if branch_out.status != 0 { + return None; + } + + let mut branches: Vec = Vec::new(); + let mut is_feature_branch = false; + + for line in branch_out.stdout.lines() { + if line.is_empty() { + continue; + } + if let Some(caps) = CURRENT_BRANCH_RE.captures(line) { + let name = caps.get(1).map_or("", |m| m.as_str()); + let hash = caps.get(2).map_or("", |m| m.as_str()); + if name == "(no branch)" + || name.starts_with("(detached ") + || name.starts_with("(HEAD detached at") + { + let v = format!("dev-{hash}"); + version = Some(v.clone()); + pretty_version = Some(v); + is_feature_branch = true; + is_detached = true; + } else { + version = Some(normalize_branch(name)); + pretty_version = Some(format!("dev-{name}")); + is_feature_branch = is_feature_branch_name(package_config, name); + } + commit = Some(hash.to_string()); + } + + if !REMOTE_HEAD_RE.is_match(line) + && let Some(caps) = ANY_BRANCH_RE.captures(line) + && let Some(m) = caps.get(1) + { + branches.push(m.as_str().to_string()); + } + } + + if is_feature_branch { + feature_version = version.clone(); + feature_pretty_version = pretty_version.clone(); + let result = self.guess_feature_version( + package_config, + version.as_deref(), + &branches, + &["git", "rev-list", "%candidate%..%branch%"], + path, + ); + version = result.0; + pretty_version = result.1; + } + + if (version.is_none() || is_detached) + && let Some((tag_v, tag_pretty)) = self.version_from_git_tags(path) + { + version = Some(tag_v); + pretty_version = Some(tag_pretty); + feature_version = None; + feature_pretty_version = None; + } + + if commit.is_none() + && let Ok(out) = self + .process + .execute(&["git", "rev-parse", "HEAD"], Some(path)) + && out.status == 0 + { + let trimmed = out.stdout.trim(); + if !trimmed.is_empty() { + commit = Some(trimmed.to_string()); + } + } + + version.as_ref()?; + Some(GuessedVersion { + version: version.unwrap(), + commit, + pretty_version, + feature_version, + feature_pretty_version, + }) + } + + fn version_from_git_tags(&self, path: &Path) -> Option<(String, String)> { + let out = self + .process + .execute(&["git", "describe", "--exact-match", "--tags"], Some(path)) + .ok()?; + if out.status != 0 { + return None; + } + let pretty = out.stdout.trim().to_string(); + if pretty.is_empty() { + return None; + } + let normalized = Version::parse(&pretty).ok()?; + Some((normalized.to_string(), pretty)) + } + + fn guess_hg_version(&self, package_config: &Value, path: &Path) -> Option { + let out = self.process.execute(&["hg", "branch"], Some(path)).ok()?; + if out.status != 0 { + return None; + } + let branch = out.stdout.trim().to_string(); + if branch.is_empty() { + return None; + } + let version = normalize_branch(&branch); + let is_feature = version.starts_with("dev-"); + + if version == DEFAULT_BRANCH_ALIAS { + return Some(GuessedVersion { + version, + commit: None, + pretty_version: Some(format!("dev-{branch}")), + feature_version: None, + feature_pretty_version: None, + }); + } + + if !is_feature { + return Some(GuessedVersion { + version: version.clone(), + commit: None, + pretty_version: Some(version), + feature_version: None, + feature_pretty_version: None, + }); + } + + // List branches via `hg branches` (first whitespace-separated token per line). + let branches_out = self.process.execute(&["hg", "branches"], Some(path)).ok()?; + let branches: Vec = if branches_out.status == 0 { + branches_out + .stdout + .lines() + .filter_map(|l| l.split_whitespace().next().map(str::to_string)) + .collect() + } else { + Vec::new() + }; + + let (out_version, out_pretty) = self.guess_feature_version( + package_config, + Some(&version), + &branches, + &[ + "hg", + "log", + "-r", + "not ancestors('%candidate%') and ancestors('%branch%')", + "--template", + "\"{node}\\n\"", + ], + path, + ); + + Some(GuessedVersion { + version: out_version.unwrap_or(version.clone()), + commit: Some(String::new()), + pretty_version: out_pretty, + feature_version: Some(version.clone()), + feature_pretty_version: Some(version), + }) + } + + fn guess_svn_version(&self, package_config: &Value, path: &Path) -> Option { + let out = self + .process + .execute(&["svn", "info", "--xml"], Some(path)) + .ok()?; + if out.status != 0 { + return None; + } + + let trunk = package_config + .get("trunk-path") + .and_then(Value::as_str) + .unwrap_or("trunk"); + let branches = package_config + .get("branches-path") + .and_then(Value::as_str) + .unwrap_or("branches"); + let tags = package_config + .get("tags-path") + .and_then(Value::as_str) + .unwrap_or("tags"); + + let pattern = format!( + r".*/({trunk}|({branches}|{tags})/(.*))", + trunk = regex::escape(trunk), + branches = regex::escape(branches), + tags = regex::escape(tags), + ); + let re = Regex::new(&pattern).ok()?; + let caps = re.captures(&out.stdout)?; + + let kind = caps.get(2).map(|m| m.as_str().to_string()); + let inner = caps.get(3).map(|m| m.as_str().to_string()); + + if let (Some(kind), Some(inner)) = (kind, inner) + && (kind == branches || kind == tags) + { + let pretty = format!("dev-{inner}"); + return Some(GuessedVersion { + version: normalize_branch(&inner), + commit: Some(String::new()), + pretty_version: Some(pretty), + feature_version: None, + feature_pretty_version: None, + }); + } + + let trunk_match = caps.get(1)?; + let pretty = trunk_match.as_str().trim().to_string(); + let version = if pretty == "trunk" { + "dev-trunk".to_string() + } else { + Version::parse(&pretty).ok()?.to_string() + }; + Some(GuessedVersion { + version, + commit: Some(String::new()), + pretty_version: Some(pretty), + feature_version: None, + feature_pretty_version: None, + }) + } + + /// Find the nearest non-feature branch by diff size. Sequential port of + /// `guessFeatureVersion`; Composer runs candidates in parallel. + fn guess_feature_version( + &self, + package_config: &Value, + version: Option<&str>, + branches: &[String], + scm_cmdline: &[&str], + path: &Path, + ) -> (Option, Option) { + let version = version.map(str::to_string); + let pretty_version = version.clone(); + + let Some(v) = version.clone() else { + return (version, pretty_version); + }; + + // Skip if the branch has a non-self.version branch-alias OR self.version is referenced. + let has_branch_alias = package_config + .get("extra") + .and_then(|e| e.get("branch-alias")) + .and_then(|b| b.get(&v)) + .is_some(); + let uses_self_version = serde_json::to_string(package_config) + .map(|s| s.contains("\"self.version\"")) + .unwrap_or(false); + if has_branch_alias && !uses_self_version { + return (Some(v), pretty_version); + } + + // Composer also returns early if `self.version` is referenced — see L283. + // The PHP precedence is: skip iff (no branch-alias) OR (json contains self.version). + if uses_self_version { + return (Some(v), pretty_version); + } + + let branch = v.strip_prefix("dev-").unwrap_or(&v).to_string(); + + if !is_feature_branch_name(package_config, &branch) { + return (Some(v), pretty_version); + } + + let mut sorted: Vec = branches.to_vec(); + sorted.sort_by(|a, b| { + let a_remote = a.starts_with("remotes/"); + let b_remote = b.starts_with("remotes/"); + if a_remote != b_remote { + return if a_remote { + std::cmp::Ordering::Greater + } else { + std::cmp::Ordering::Less + }; + } + // strnatcasecmp(b, a) — natural-sort, descending, case-insensitive. + natural_cmp(&b.to_ascii_lowercase(), &a.to_ascii_lowercase()) + }); + + let mut last_index: i64 = -1; + let mut length: usize = usize::MAX; + let mut version = Some(v); + let mut pretty = pretty_version; + + for (index, candidate) in sorted.iter().enumerate() { + let candidate_version = REMOTES_PREFIX_RE.replace(candidate, "").to_string(); + if candidate.as_str() == branch.as_str() + || is_feature_branch_name(package_config, &candidate_version) + { + continue; + } + let cmd: Vec = scm_cmdline + .iter() + .map(|c| { + c.replace("%candidate%", candidate) + .replace("%branch%", &branch) + }) + .collect(); + let cmd_refs: Vec<&str> = cmd.iter().map(String::as_str).collect(); + let Ok(output) = self.process.execute(&cmd_refs, Some(path)) else { + continue; + }; + if output.status != 0 { + continue; + } + let len = output.stdout.len(); + if len < length || (len == length && last_index < index as i64) { + last_index = index as i64; + length = len; + version = Some(normalize_branch(&candidate_version)); + pretty = Some(format!("dev-{candidate_version}")); + if length == 0 { + break; + } + } + } + + (version, pretty) + } +} + +fn postprocess(mut v: GuessedVersion) -> GuessedVersion { + if v.feature_version.is_some() + && v.feature_version == Some(v.version.clone()) + && v.feature_pretty_version == v.pretty_version + { + v.feature_version = None; + v.feature_pretty_version = None; + } + + if v.version.ends_with("-dev") && contains_long_nines(&v.version) { + v.pretty_version = Some(replace_long_nines_with_x(&v.version)); + } + if let Some(ref fv) = v.feature_version + && fv.ends_with("-dev") + && contains_long_nines(fv) + { + v.feature_pretty_version = Some(replace_long_nines_with_x(fv)); + } + v +} + +fn contains_long_nines(s: &str) -> bool { + NINE_SEVEN_RE.is_match(s) +} + +fn replace_long_nines_with_x(s: &str) -> String { + NINE_SEVEN_GROUP_RE.replace_all(s, ".x").to_string() +} + +fn is_feature_branch_name(package_config: &Value, branch_name: &str) -> bool { + let mut non_feature = String::new(); + if let Some(arr) = package_config + .get("non-feature-branches") + .and_then(Value::as_array) + { + let parts: Vec = arr + .iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect(); + if !parts.is_empty() { + non_feature = parts.join("|"); + } + } + let pattern = format!( + r"^({non_feature}|master|main|latest|next|current|support|tip|trunk|default|develop|\d+\..+)$" + ); + let Ok(re) = Regex::new(&pattern) else { + return true; + }; + !re.is_match(branch_name) +} + +/// Natural-order, case-insensitive string comparison (mirrors PHP `strnatcasecmp`). +fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering { + let mut ai = a.chars().peekable(); + let mut bi = b.chars().peekable(); + loop { + match (ai.peek().copied(), bi.peek().copied()) { + (None, None) => return std::cmp::Ordering::Equal, + (None, _) => return std::cmp::Ordering::Less, + (_, None) => return std::cmp::Ordering::Greater, + (Some(ac), Some(bc)) => { + if ac.is_ascii_digit() && bc.is_ascii_digit() { + let mut na = String::new(); + let mut nb = String::new(); + while let Some(&c) = ai.peek() { + if !c.is_ascii_digit() { + break; + } + na.push(c); + ai.next(); + } + while let Some(&c) = bi.peek() { + if !c.is_ascii_digit() { + break; + } + nb.push(c); + bi.next(); + } + let na_v: u128 = na.parse().unwrap_or(0); + let nb_v: u128 = nb.parse().unwrap_or(0); + match na_v.cmp(&nb_v) { + std::cmp::Ordering::Equal => continue, + ord => return ord, + } + } else { + match ac.cmp(&bc) { + std::cmp::Ordering::Equal => { + ai.next(); + bi.next(); + } + ord => return ord, + } + } + } + } + } +} + +static CURRENT_BRANCH_RE: LazyLock = LazyLock::new(|| { + Regex::new( + r"^(?:\* ) *(\(no branch\)|\(detached from \S+\)|\(HEAD detached at \S+\)|\S+) *([a-f0-9]+) .*$", + ) + .unwrap() +}); + +static REMOTE_HEAD_RE: LazyLock = LazyLock::new(|| Regex::new(r"^ *.+/HEAD ").unwrap()); + +static ANY_BRANCH_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^(?:\* )? *((?:remotes/(?:origin|upstream)/)?[^\s/]+) *([a-f0-9]+) .*$").unwrap() +}); + +static REMOTES_PREFIX_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^remotes/[^/]+/").unwrap()); + +static NINE_SEVEN_RE: LazyLock = LazyLock::new(|| Regex::new(r"\.9{7}").unwrap()); + +static NINE_SEVEN_GROUP_RE: LazyLock = LazyLock::new(|| Regex::new(r"(\.9{7})+").unwrap()); + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_postprocess_strips_duplicate_feature() { + let v = GuessedVersion { + version: "1.0.0.0".into(), + commit: None, + pretty_version: Some("1.0.0".into()), + feature_version: Some("1.0.0.0".into()), + feature_pretty_version: Some("1.0.0".into()), + }; + let p = postprocess(v); + assert_eq!(p.feature_version, None); + assert_eq!(p.feature_pretty_version, None); + } + + #[test] + fn test_postprocess_nine_seven_to_x() { + let v = GuessedVersion { + version: "1.9999999.9999999.9999999-dev".into(), + commit: None, + pretty_version: Some("dev-1.x".into()), + feature_version: None, + feature_pretty_version: None, + }; + let p = postprocess(v); + assert_eq!(p.pretty_version.as_deref(), Some("1.x-dev")); + } + + #[test] + fn test_is_feature_branch_known_mainlines() { + let cfg = json!({}); + assert!(!is_feature_branch_name(&cfg, "master")); + assert!(!is_feature_branch_name(&cfg, "main")); + assert!(!is_feature_branch_name(&cfg, "develop")); + assert!(!is_feature_branch_name(&cfg, "1.0")); + assert!(is_feature_branch_name(&cfg, "feature/x")); + } + + #[test] + fn test_is_feature_branch_with_non_feature_list() { + let cfg = json!({"non-feature-branches": ["staging", "release-.+"]}); + assert!(!is_feature_branch_name(&cfg, "staging")); + assert!(!is_feature_branch_name(&cfg, "release-2")); + assert!(is_feature_branch_name(&cfg, "wip-x")); + } + + #[test] + fn test_natural_cmp_orders_naturally() { + assert_eq!(natural_cmp("1.10", "1.9"), std::cmp::Ordering::Greater); + assert_eq!(natural_cmp("1.2", "1.10"), std::cmp::Ordering::Less); + assert_eq!(natural_cmp("abc", "abc"), std::cmp::Ordering::Equal); + } +} diff --git a/crates/mozart-core/tests/git_driver_test.rs b/crates/mozart-core/tests/git_driver_test.rs new file mode 100644 index 0000000..c0dd4af --- /dev/null +++ b/crates/mozart-core/tests/git_driver_test.rs @@ -0,0 +1,335 @@ +use mozart_core::vcs::downloader::VcsDownloader; +use mozart_core::vcs::downloader::git::GitDownloader; +use mozart_core::vcs::driver::{DriverConfig, DriverType, create_driver}; +use mozart_core::vcs::process::ProcessExecutor; +use mozart_core::vcs::repository::VcsRepository; +use mozart_core::vcs::util::git::GitUtil; +use std::path::Path; +use std::process::Command; +use tempfile::TempDir; + +fn has_git() -> bool { + Command::new("git").arg("--version").output().is_ok() +} + +fn create_test_repo(dir: &Path) { + let run = |args: &[&str]| { + let output = Command::new(args[0]) + .args(&args[1..]) + .current_dir(dir) + .env("GIT_AUTHOR_NAME", "Test") + .env("GIT_AUTHOR_EMAIL", "test@test.com") + .env("GIT_COMMITTER_NAME", "Test") + .env("GIT_COMMITTER_EMAIL", "test@test.com") + .output() + .unwrap(); + assert!( + output.status.success(), + "Command failed: {:?}\nstderr: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); + }; + + run(&["git", "init", "-b", "main"]); + run(&["git", "config", "user.email", "test@test.com"]); + run(&["git", "config", "user.name", "Test"]); + + // Create composer.json + std::fs::write( + dir.join("composer.json"), + r#"{"name": "test/package", "description": "Test package"}"#, + ) + .unwrap(); + + run(&["git", "add", "."]); + run(&["git", "commit", "-m", "Initial commit"]); + + // Create a tag + run(&["git", "tag", "v1.0.0"]); + + // Create another commit on main + std::fs::write(dir.join("README.md"), "# Test").unwrap(); + run(&["git", "add", "."]); + run(&["git", "commit", "-m", "Add readme"]); + + // Create a second tag + run(&["git", "tag", "v1.1.0"]); + + // Create a feature branch + run(&["git", "checkout", "-b", "feature/test"]); + std::fs::write(dir.join("feature.txt"), "feature").unwrap(); + run(&["git", "add", "."]); + run(&["git", "commit", "-m", "Feature commit"]); + run(&["git", "checkout", "main"]); +} + +#[tokio::test] +async fn test_git_driver_local_repo() { + if !has_git() { + eprintln!("Skipping test: git not available"); + return; + } + + let repo_dir = TempDir::new().unwrap(); + let cache_dir = TempDir::new().unwrap(); + create_test_repo(repo_dir.path()); + + let config = DriverConfig { + cache_vcs_dir: cache_dir.path().to_path_buf(), + ..DriverConfig::default() + }; + + let mut driver = create_driver(repo_dir.path().to_str().unwrap(), DriverType::Git, config); + + driver.initialize().await.unwrap(); + assert_eq!(driver.root_identifier(), "main"); + + // Check tags + let tags = driver.tags().await.unwrap().clone(); + assert!( + tags.contains_key("v1.0.0"), + "Missing tag v1.0.0: {:?}", + tags + ); + assert!( + tags.contains_key("v1.1.0"), + "Missing tag v1.1.0: {:?}", + tags + ); + + // Check branches + let branches = driver.branches().await.unwrap().clone(); + assert!( + branches.contains_key("main"), + "Missing branch main: {:?}", + branches + ); + assert!( + branches.contains_key("feature/test"), + "Missing branch feature/test: {:?}", + branches, + ); + + // Read composer.json + let tag_hash = &tags["v1.0.0"]; + let info = driver.composer_information(tag_hash).await.unwrap(); + assert!(info.is_some()); + let info = info.unwrap(); + assert_eq!(info["name"].as_str(), Some("test/package")); + + // Read file content + let content = driver + .file_content("composer.json", tag_hash) + .await + .unwrap(); + assert!(content.is_some()); + assert!(content.unwrap().contains("test/package")); + + // Change date + let date = driver.change_date(tag_hash).await.unwrap(); + assert!(date.is_some()); + + // Source reference + let source = driver.source(tag_hash); + assert_eq!(source.source_type, "git"); + + driver.cleanup().await.unwrap(); +} + +#[test] +fn test_git_downloader() { + if !has_git() { + eprintln!("Skipping test: git not available"); + return; + } + + let repo_dir = TempDir::new().unwrap(); + let cache_dir = TempDir::new().unwrap(); + let install_dir = TempDir::new().unwrap(); + create_test_repo(repo_dir.path()); + + let process = ProcessExecutor::new(); + let git_util = GitUtil::new(process, cache_dir.path().join("git")); + let downloader = GitDownloader::new(git_util); + + let url = repo_dir.path().to_str().unwrap(); + let target = install_dir.path().join("test-package"); + + // Download (sync mirror) + downloader.download(url, "v1.0.0", &target).unwrap(); + + // Install + downloader.install(url, "v1.0.0", &target).unwrap(); + assert!(target.join("composer.json").exists()); + + // Check no local changes + let changes = downloader.get_local_changes(&target).unwrap(); + assert!(changes.is_none(), "Expected no changes, got: {:?}", changes); + + // Untracked files alone must NOT count as local changes (matches + // Composer's `git status --porcelain --untracked-files=no`). + std::fs::write(target.join("untracked.txt"), "untracked").unwrap(); + let changes = downloader.get_local_changes(&target).unwrap(); + assert!( + changes.is_none(), + "Untracked files should be ignored, got: {:?}", + changes + ); + + // Modifying a tracked file is a local change. + std::fs::write(target.join("composer.json"), "{\"name\":\"changed\"}\n").unwrap(); + let changes = downloader.get_local_changes(&target).unwrap(); + assert!(changes.is_some()); + assert!(changes.unwrap().contains("composer.json")); + + // Commit logs + let logs = downloader.commit_logs("v1.0.0", "v1.1.0", &target).unwrap(); + assert!(logs.contains("Add readme")); + + // Remove + downloader.remove(&target).unwrap(); + assert!(!target.exists()); +} + +#[test] +fn test_git_downloader_unpushed_changes() { + if !has_git() { + eprintln!("Skipping test: git not available"); + return; + } + + let repo_dir = TempDir::new().unwrap(); + let cache_dir = TempDir::new().unwrap(); + let install_dir = TempDir::new().unwrap(); + create_test_repo(repo_dir.path()); + + let process = ProcessExecutor::new(); + let git_util = GitUtil::new(process, cache_dir.path().join("git")); + let downloader = GitDownloader::new(git_util); + + let url = repo_dir.path().to_str().unwrap(); + let target = install_dir.path().join("test-package"); + + downloader.download(url, "main", &target).unwrap(); + downloader.install(url, "main", &target).unwrap(); + + // No commits added locally → no unpushed changes. + let unpushed = downloader.unpushed_changes(&target).unwrap(); + assert!( + unpushed.is_none(), + "Expected no unpushed changes, got: {:?}", + unpushed + ); + + // Commit a local change without pushing. + let run = |args: &[&str]| { + let output = Command::new(args[0]) + .args(&args[1..]) + .current_dir(&target) + .env("GIT_AUTHOR_NAME", "Test") + .env("GIT_AUTHOR_EMAIL", "test@test.com") + .env("GIT_COMMITTER_NAME", "Test") + .env("GIT_COMMITTER_EMAIL", "test@test.com") + .output() + .unwrap(); + assert!(output.status.success(), "Command failed: {:?}", args); + }; + std::fs::write(target.join("local-only.txt"), "local-only").unwrap(); + run(&["git", "add", "."]); + run(&["git", "commit", "-m", "Local-only commit"]); + + let unpushed = downloader.unpushed_changes(&target).unwrap(); + assert!(unpushed.is_some(), "Expected unpushed changes"); + let body = unpushed.unwrap(); + assert!( + body.contains("local-only.txt"), + "Expected diff body to mention local-only.txt, got: {body}" + ); +} + +#[test] +fn test_detect_driver() { + use mozart_core::vcs::driver::{DriverType, detect_driver}; + + let config = DriverConfig::default(); + + assert_eq!( + detect_driver("https://github.com/owner/repo", None, &config), + Some(DriverType::GitHub), + ); + assert_eq!( + detect_driver("git@github.com:owner/repo.git", None, &config), + Some(DriverType::GitHub), + ); + assert_eq!( + detect_driver("https://gitlab.com/owner/repo", None, &config), + Some(DriverType::GitLab), + ); + assert_eq!( + detect_driver("https://bitbucket.org/owner/repo", None, &config), + Some(DriverType::Bitbucket), + ); + assert_eq!( + detect_driver("https://codeberg.org/owner/repo", None, &config), + Some(DriverType::Forgejo), + ); + assert_eq!( + detect_driver("git://example.com/repo.git", None, &config), + Some(DriverType::Git), + ); + assert_eq!( + detect_driver("svn://example.com/repo", None, &config), + Some(DriverType::Svn), + ); + + // Forced type + assert_eq!( + detect_driver("https://example.com/repo", Some("git"), &config), + Some(DriverType::Git), + ); +} + +#[tokio::test] +async fn test_vcs_repository_scan() { + if !has_git() { + eprintln!("Skipping test: git not available"); + return; + } + + let repo_dir = TempDir::new().unwrap(); + let cache_dir = TempDir::new().unwrap(); + create_test_repo(repo_dir.path()); + + let config = DriverConfig { + cache_vcs_dir: cache_dir.path().to_path_buf(), + ..DriverConfig::default() + }; + + let repo = VcsRepository::new(repo_dir.path().to_str().unwrap().to_string(), None, config); + + let versions = repo.scan().await.unwrap(); + assert!(!versions.is_empty(), "No versions found"); + + // Should find tag versions + let tag_versions: Vec<_> = versions + .iter() + .filter(|v| !v.version.starts_with("dev-")) + .collect(); + assert!(!tag_versions.is_empty(), "No tag versions found"); + + // Should find branch versions + let dev_versions: Vec<_> = versions + .iter() + .filter(|v| v.version.starts_with("dev-")) + .collect(); + assert!(!dev_versions.is_empty(), "No dev versions found"); + + // Check default branch flag + let default_versions: Vec<_> = versions.iter().filter(|v| v.is_default_branch).collect(); + assert_eq!( + default_versions.len(), + 1, + "Expected exactly one default branch version" + ); +} diff --git a/crates/mozart-registry/Cargo.toml b/crates/mozart-registry/Cargo.toml deleted file mode 100644 index 6239973..0000000 --- a/crates/mozart-registry/Cargo.toml +++ /dev/null @@ -1,31 +0,0 @@ -[package] -name = "mozart-registry" -version.workspace = true -edition.workspace = true - -[dependencies] -mozart-console-macros.workspace = true -mozart-core.workspace = true -mozart-metadata-minifier.workspace = true -mozart-php-serialize.workspace = true -mozart-sat-resolver.workspace = true -mozart-semver.workspace = true -mozart-vcs.workspace = true -anyhow.workspace = true -async-trait.workspace = true -filetime.workspace = true -flate2.workspace = true -indexmap.workspace = true -md5.workspace = true -regex.workspace = true -serde.workspace = true -serde_json.workspace = true -sha1.workspace = true -tar.workspace = true -tempfile.workspace = true -tokio.workspace = true -tracing.workspace = true -zip.workspace = true - -[dev-dependencies] -mozart-test-harness.workspace = true diff --git a/crates/mozart-registry/src/advisory.rs b/crates/mozart-registry/src/advisory.rs deleted file mode 100644 index 86d37af..0000000 --- a/crates/mozart-registry/src/advisory.rs +++ /dev/null @@ -1,733 +0,0 @@ -use std::collections::BTreeMap; - -use indexmap::IndexMap; -use mozart_core::advisory::{AbandonedHandling, AuditFormat}; -use mozart_core::console::Console; -use mozart_core::{console_writeln, console_writeln_error}; - -use crate::packagist::SecurityAdvisory; -use crate::repository::RepositorySet; - -/// A package being audited, with version and abandonment information. -#[derive(Debug, Clone)] -pub struct PackageInfo { - pub name: String, - pub version: String, - pub version_normalized: Option, - /// Raw abandoned field from JSON: `true` = abandoned no replacement, `String` = replacement name. - pub abandoned_raw: Option, -} - -impl PackageInfo { - /// Mirrors `CompletePackage::isAbandoned()`. - pub fn is_abandoned(&self) -> bool { - matches!( - &self.abandoned_raw, - Some(serde_json::Value::Bool(true)) | Some(serde_json::Value::String(_)) - ) - } - - /// Mirrors `CompletePackage::getReplacementPackage()`. - pub fn replacement_package(&self) -> Option<&str> { - match &self.abandoned_raw { - Some(serde_json::Value::String(s)) => Some(s.as_str()), - _ => None, - } - } -} - -/// An advisory paired with the installed version of the package it affects. -#[derive(Debug, Clone)] -pub struct MatchedAdvisory { - pub advisory: SecurityAdvisory, - pub installed_version: String, -} - -/// A matched advisory that was filtered out by the ignore list. -#[derive(Debug, Clone)] -pub struct IgnoredAdvisory { - pub advisory: SecurityAdvisory, - pub installed_version: String, - pub ignore_reason: Option, -} - -/// Result of `Auditor::process_advisories`. -#[derive(Debug, Default)] -pub struct ProcessedAdvisories { - pub advisories: BTreeMap>, - pub ignored_advisories: BTreeMap>, -} - -/// An abandoned package found during audit. -#[derive(Debug, Clone)] -pub struct AbandonedPackage { - pub name: String, - pub version: String, - pub replacement: Option, -} - -/// Options passed to `Auditor::audit()`. -pub struct AuditOptions<'a> { - pub format: AuditFormat, - pub warning_only: bool, - pub ignore_list: &'a IndexMap>, - pub abandoned: AbandonedHandling, - pub ignored_severities: &'a IndexMap>, - pub ignore_unreachable: bool, - pub ignore_abandoned: &'a IndexMap>, -} - -/// Mirrors `Composer\Advisory\Auditor`. -pub struct Auditor; - -impl Auditor { - pub fn new() -> Self { - Self - } - - /// Main audit entry point. Mirrors `Composer\Advisory\Auditor::audit()`. - /// - /// Returns a bitmask: 0=ok, 1=vulnerable, 2=abandoned, 3=both. - pub async fn audit( - &self, - console: &Console, - repo_set: &RepositorySet, - packages: &[PackageInfo], - options: &AuditOptions<'_>, - ) -> anyhow::Result { - let format = options.format; - let (all_advisories, unreachable_repos) = repo_set - .get_matching_security_advisories( - packages, - format == AuditFormat::Summary, - options.ignore_unreachable, - ) - .await?; - - let ProcessedAdvisories { - advisories, - ignored_advisories, - } = self.process_advisories( - all_advisories, - options.ignore_list, - options.ignored_severities, - ); - - let abandoned_packages = if options.abandoned == AbandonedHandling::Ignore { - vec![] - } else { - self.filter_abandoned_packages(packages, options.ignore_abandoned) - }; - - let abandoned_count = if options.abandoned == AbandonedHandling::Fail { - abandoned_packages.len() - } else { - 0 - }; - - let affected_packages_count = advisories.len(); - let bitmask = self.calculate_bitmask(affected_packages_count > 0, abandoned_count > 0); - - if format == AuditFormat::Json { - self.render_json( - &advisories, - &ignored_advisories, - &unreachable_repos, - &abandoned_packages, - console, - ); - return Ok(bitmask); - } - - let (ignored_pkg_count, ignored_total) = self.count_ignored(&ignored_advisories); - let (active_pkg_count, active_total) = self.count_matched(&advisories); - - if active_pkg_count > 0 || ignored_pkg_count > 0 { - if ignored_pkg_count > 0 { - let plurality = if ignored_total == 1 { "y" } else { "ies" }; - let pkg_plurality = if ignored_pkg_count == 1 { "" } else { "s" }; - let punctuation = if format == AuditFormat::Summary { - "." - } else { - ":" - }; - let msg = format!( - "Found {ignored_total} ignored security vulnerability advisor{plurality} affecting {ignored_pkg_count} package{pkg_plurality}{punctuation}" - ); - console_writeln_error!(console, "{msg}"); - self.output_advisories_ignored(console, &ignored_advisories, format); - } - - if active_pkg_count > 0 { - let plurality = if active_total == 1 { "y" } else { "ies" }; - let pkg_plurality = if active_pkg_count == 1 { "" } else { "s" }; - let punctuation = if format == AuditFormat::Summary { - "." - } else { - ":" - }; - let msg = format!( - "Found {active_total} security vulnerability advisor{plurality} affecting {active_pkg_count} package{pkg_plurality}{punctuation}" - ); - if options.warning_only { - console_writeln_error!(console, "{msg}"); - } else { - console_writeln_error!(console, "{msg}"); - } - self.output_advisories(console, &advisories, format); - } - - if format == AuditFormat::Summary { - console_writeln_error!( - console, - "Run \"mozart audit\" for a full list of advisories." - ); - } - } else { - console_writeln_error!( - console, - "No security vulnerability advisories found.", - ); - } - - if !unreachable_repos.is_empty() { - console_writeln_error!( - console, - "The following repositories were unreachable:", - ); - for repo in &unreachable_repos { - console_writeln_error!(console, " - {repo}"); - } - } - - if !abandoned_packages.is_empty() && format != AuditFormat::Summary { - self.output_abandoned_packages(console, &abandoned_packages, format); - } - - Ok(bitmask) - } - - /// Mirrors `Composer\Advisory\Auditor::processAdvisories()`. - /// - /// Splits advisories into active and ignored based on the ignore list and ignored severities. - /// Checks by: package name, advisory ID, severity, CVE, and source remote IDs. - pub fn process_advisories( - &self, - all_advisories: BTreeMap>, - ignore_list: &IndexMap>, - ignored_severities: &IndexMap>, - ) -> ProcessedAdvisories { - if ignore_list.is_empty() && ignored_severities.is_empty() { - return ProcessedAdvisories { - advisories: all_advisories, - ignored_advisories: BTreeMap::new(), - }; - } - - let mut advisories: BTreeMap> = BTreeMap::new(); - let mut ignored: BTreeMap> = BTreeMap::new(); - - for (package, pkg_advisories) in all_advisories { - for matched in pkg_advisories { - let adv = &matched.advisory; - let mut is_active = true; - let mut ignore_reason: Option = None; - - // Check by package name - if let Some(reason) = ignore_list.get(&package) { - is_active = false; - ignore_reason = reason.clone(); - } - - // Check by advisory ID - if is_active && let Some(reason) = ignore_list.get(&adv.advisory_id) { - is_active = false; - ignore_reason = reason.clone(); - } - - // Check by severity - if is_active - && let Some(ref sev) = adv.severity - && let Some(reason) = ignored_severities.get(sev.as_str()) - { - is_active = false; - ignore_reason = reason - .clone() - .or_else(|| Some(format!("{sev} severity is ignored"))); - } - - // Check by CVE - if is_active - && let Some(ref cve) = adv.cve - && let Some(reason) = ignore_list.get(cve.as_str()) - { - is_active = false; - ignore_reason = reason.clone(); - } - - // Check by source remote IDs - if is_active { - for source in &adv.sources { - if let Some(reason) = ignore_list.get(&source.remote_id) { - is_active = false; - ignore_reason = reason.clone(); - break; - } - } - } - - if is_active { - advisories.entry(package.clone()).or_default().push(matched); - } else { - ignored - .entry(package.clone()) - .or_default() - .push(IgnoredAdvisory { - advisory: matched.advisory, - installed_version: matched.installed_version, - ignore_reason, - }); - } - } - } - - ProcessedAdvisories { - advisories, - ignored_advisories: ignored, - } - } - - /// Mirrors `Composer\Advisory\Auditor::filterAbandonedPackages()`. - pub fn filter_abandoned_packages( - &self, - packages: &[PackageInfo], - ignore_abandoned: &IndexMap>, - ) -> Vec { - packages - .iter() - .filter(|pkg| { - if !pkg.is_abandoned() { - return false; - } - if !ignore_abandoned.is_empty() { - let name_lower = pkg.name.to_lowercase(); - // Case-insensitive exact name match (wildcard support deferred) - if ignore_abandoned - .keys() - .any(|k| k.to_lowercase() == name_lower) - { - return false; - } - } - true - }) - .map(|pkg| AbandonedPackage { - name: pkg.name.clone(), - version: pkg.version.clone(), - replacement: pkg.replacement_package().map(|s| s.to_string()), - }) - .collect() - } - - /// Mirrors `Composer\Advisory\Auditor::needsCompleteAdvisoryLoad()`. - /// - /// Mozart always fetches full advisories (no partial optimization), so this is always false. - pub fn needs_complete_advisory_load( - &self, - advisories: &BTreeMap>, - _ignore_list: &IndexMap>, - ) -> bool { - let _ = advisories; - false - } - - fn calculate_bitmask(&self, has_vulnerable: bool, has_abandoned: bool) -> u8 { - let mut bitmask = 0u8; - if has_vulnerable { - bitmask |= 1; - } - if has_abandoned { - bitmask |= 2; - } - bitmask - } - - fn count_ignored(&self, advisories: &BTreeMap>) -> (usize, usize) { - let pkg_count = advisories.len(); - let total = advisories.values().map(|v| v.len()).sum(); - (pkg_count, total) - } - - fn count_matched(&self, advisories: &BTreeMap>) -> (usize, usize) { - let pkg_count = advisories.len(); - let total = advisories.values().map(|v| v.len()).sum(); - (pkg_count, total) - } - - fn output_advisories( - &self, - console: &Console, - advisories: &BTreeMap>, - format: AuditFormat, - ) { - match format { - AuditFormat::Table => self.output_advisories_table(console, advisories), - AuditFormat::Plain => self.output_advisories_plain(console, advisories), - AuditFormat::Summary => {} - AuditFormat::Json => unreachable!(), - } - } - - fn output_advisories_ignored( - &self, - console: &Console, - advisories: &BTreeMap>, - format: AuditFormat, - ) { - match format { - AuditFormat::Table => self.output_ignored_advisories_table(console, advisories), - AuditFormat::Plain => self.output_ignored_advisories_plain(console, advisories), - AuditFormat::Summary => {} - AuditFormat::Json => unreachable!(), - } - } - - fn output_advisories_table( - &self, - console: &Console, - advisories: &BTreeMap>, - ) { - for pkg_advisories in advisories.values() { - for matched in pkg_advisories { - self.render_advisory_table( - console, - &matched.advisory, - &matched.installed_version, - None, - ); - } - } - } - - fn output_ignored_advisories_table( - &self, - console: &Console, - advisories: &BTreeMap>, - ) { - for pkg_advisories in advisories.values() { - for ignored in pkg_advisories { - self.render_advisory_table( - console, - &ignored.advisory, - &ignored.installed_version, - ignored.ignore_reason.as_deref(), - ); - } - } - } - - fn render_advisory_table( - &self, - console: &Console, - adv: &SecurityAdvisory, - installed_version: &str, - ignore_reason: Option<&str>, - ) { - let label_width = 17usize; - let mut rows: Vec<(&str, String)> = vec![ - ("Package", adv.package_name.clone()), - ("Version", installed_version.to_string()), - ("Severity", adv.severity.clone().unwrap_or_default()), - ("Advisory ID", adv.advisory_id.clone()), - ( - "CVE", - adv.cve.clone().unwrap_or_else(|| "NO CVE".to_string()), - ), - ("Title", adv.title.clone()), - ("URL", adv.link.clone().unwrap_or_default()), - ("Affected versions", adv.affected_versions.clone()), - ("Reported at", adv.reported_at.clone()), - ]; - if let Some(reason) = ignore_reason { - rows.push(("Ignore reason", reason.to_string())); - } - - let value_width = rows.iter().map(|(_, v)| v.len()).max().unwrap_or(0).max(20); - let separator = format!( - "+-{:->, - ) { - let mut first = true; - for pkg_advisories in advisories.values() { - for matched in pkg_advisories { - if !first { - console_writeln_error!(console, "--------"); - } - self.render_advisory_plain( - console, - &matched.advisory, - &matched.installed_version, - None, - ); - first = false; - } - } - } - - fn output_ignored_advisories_plain( - &self, - console: &Console, - advisories: &BTreeMap>, - ) { - let mut first = true; - for pkg_advisories in advisories.values() { - for ignored in pkg_advisories { - if !first { - console_writeln_error!(console, "--------"); - } - self.render_advisory_plain( - console, - &ignored.advisory, - &ignored.installed_version, - ignored.ignore_reason.as_deref(), - ); - first = false; - } - } - } - - fn render_advisory_plain( - &self, - console: &Console, - adv: &SecurityAdvisory, - installed_version: &str, - ignore_reason: Option<&str>, - ) { - console_writeln_error!(console, "Package: {}", adv.package_name); - console_writeln_error!(console, "Version: {installed_version}"); - console_writeln_error!( - console, - "Severity: {}", - adv.severity.as_deref().unwrap_or(""), - ); - console_writeln_error!(console, "Advisory ID: {}", adv.advisory_id); - console_writeln_error!(console, "CVE: {}", adv.cve.as_deref().unwrap_or("NO CVE")); - console_writeln_error!(console, "Title: {}", adv.title); - console_writeln_error!(console, "URL: {}", adv.link.as_deref().unwrap_or("")); - console_writeln_error!(console, "Affected versions: {}", adv.affected_versions); - console_writeln_error!(console, "Reported at: {}", adv.reported_at); - if let Some(reason) = ignore_reason { - console_writeln_error!(console, "Ignore reason: {reason}"); - } - } - - fn output_abandoned_packages( - &self, - console: &Console, - packages: &[AbandonedPackage], - format: AuditFormat, - ) { - let count = packages.len(); - let plurality = if count == 1 { "" } else { "s" }; - console_writeln_error!( - console, - "Found {count} abandoned package{plurality}:", - ); - - if format == AuditFormat::Plain { - for pkg in packages { - match &pkg.replacement { - Some(repl) => console_writeln_error!( - console, - "{} ({}) is abandoned. Use {} instead.", - pkg.name, - pkg.version, - repl, - ), - None => console_writeln_error!( - console, - "{} ({}) is abandoned. No replacement was suggested.", - pkg.name, - pkg.version, - ), - } - } - return; - } - - // Table format - let name_width = 20usize; - let ver_width = packages - .iter() - .map(|a| a.version.len()) - .max() - .unwrap_or(0) - .max("Version".len()); - let repl_width = packages - .iter() - .map(|a| { - a.replacement - .as_deref() - .unwrap_or("No replacement suggested") - .len() - }) - .max() - .unwrap_or(0) - .max("Suggested Replacement".len()); - - console_writeln_error!( - console, - "| {:>, - ignored_advisories: &BTreeMap>, - unreachable_repos: &[String], - abandoned_packages: &[AbandonedPackage], - console: &Console, - ) { - let mut advisories_map: serde_json::Map = serde_json::Map::new(); - for (pkg_name, matched_list) in advisories { - let arr: Vec = matched_list - .iter() - .map(|m| serde_json::to_value(&m.advisory).unwrap_or(serde_json::Value::Null)) - .collect(); - advisories_map.insert(pkg_name.clone(), serde_json::Value::Array(arr)); - } - - let mut output = serde_json::json!({ "advisories": advisories_map }); - - // ignored-advisories (only if non-empty) - if !ignored_advisories.is_empty() { - let mut ignored_map: serde_json::Map = - serde_json::Map::new(); - for (pkg_name, ignored_list) in ignored_advisories { - let arr: Vec = ignored_list - .iter() - .map(|i| { - let mut val = - serde_json::to_value(&i.advisory).unwrap_or(serde_json::Value::Null); - if let serde_json::Value::Object(ref mut obj) = val { - obj.insert( - "ignoreReason".to_string(), - i.ignore_reason - .as_ref() - .map(|r| serde_json::Value::String(r.clone())) - .unwrap_or(serde_json::Value::Null), - ); - } - val - }) - .collect(); - ignored_map.insert(pkg_name.clone(), serde_json::Value::Array(arr)); - } - if let serde_json::Value::Object(ref mut obj) = output { - obj.insert( - "ignored-advisories".to_string(), - serde_json::Value::Object(ignored_map), - ); - } - } - - // unreachable-repositories (only if non-empty) - if !unreachable_repos.is_empty() { - let repos_arr: Vec = unreachable_repos - .iter() - .map(|r| serde_json::Value::String(r.clone())) - .collect(); - if let serde_json::Value::Object(ref mut obj) = output { - obj.insert( - "unreachable-repositories".to_string(), - serde_json::Value::Array(repos_arr), - ); - } - } - - // abandoned map: package_name => replacement (null if none) - let mut abandoned_map: serde_json::Map = serde_json::Map::new(); - for pkg in abandoned_packages { - abandoned_map.insert( - pkg.name.clone(), - pkg.replacement - .as_ref() - .map(|r| serde_json::Value::String(r.clone())) - .unwrap_or(serde_json::Value::Null), - ); - } - if let serde_json::Value::Object(ref mut obj) = output { - obj.insert( - "abandoned".to_string(), - serde_json::Value::Object(abandoned_map), - ); - } - - let json_str = serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string()); - console_writeln!(console, "{}", &json_str); - } -} - -impl Default for Auditor { - fn default() -> Self { - Self::new() - } -} diff --git a/crates/mozart-registry/src/browse_repos.rs b/crates/mozart-registry/src/browse_repos.rs deleted file mode 100644 index 0f9b169..0000000 --- a/crates/mozart-registry/src/browse_repos.rs +++ /dev/null @@ -1,293 +0,0 @@ -//! Composite of repositories consulted by the `browse` command. -//! -//! Mirrors `Composer\Command\HomeCommand::initializeRepos()`: -//! root package + local installed repository + remote(s). Each repo -//! exposes a uniform [`BrowseRepo::find_packages`] that yields -//! [`CompletePackageView`]s — the trio of fields -//! `Composer\Command\HomeCommand::handlePackage` reads off -//! `CompletePackageInterface` (`getSupport()['source']`, -//! `getSourceUrl()`, `getHomepage()`). - -use crate::cache::Cache; -use crate::installed::{InstalledPackageEntry, InstalledPackages}; -use crate::lockfile::LockedPackage; -use crate::packagist::{self, PackagistVersion}; -use mozart_core::package::RawPackageData; - -/// Subset of `Composer\Package\CompletePackageInterface` consumed by -/// `HomeCommand::handlePackage`. Every backing repo flattens its -/// package shape into this so URL selection lives in one place. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct CompletePackageView { - /// `$package->getSupport()['source']`. - pub support_source: Option, - /// `$package->getSourceUrl()`. - pub source_url: Option, - /// `$package->getHomepage()`. - pub homepage: Option, -} - -impl From<&LockedPackage> for CompletePackageView { - fn from(pkg: &LockedPackage) -> Self { - Self { - support_source: pkg - .support - .as_ref() - .and_then(|s| s.get("source")) - .and_then(|s| s.as_str()) - .map(str::to_string), - source_url: pkg.source.as_ref().map(|s| s.url.clone()), - homepage: pkg.homepage.clone(), - } - } -} - -impl From<&InstalledPackageEntry> for CompletePackageView { - fn from(pkg: &InstalledPackageEntry) -> Self { - Self { - support_source: pkg - .support - .as_ref() - .and_then(|s| s.get("source")) - .and_then(|s| s.as_str()) - .map(str::to_string), - source_url: pkg - .source - .as_ref() - .and_then(|s| s.get("url")) - .and_then(|s| s.as_str()) - .map(str::to_string), - homepage: pkg.homepage.clone(), - } - } -} - -impl From<&PackagistVersion> for CompletePackageView { - fn from(pkg: &PackagistVersion) -> Self { - Self { - support_source: pkg - .support - .as_ref() - .and_then(|s| s.get("source")) - .and_then(|s| s.as_str()) - .map(str::to_string), - source_url: pkg.source.as_ref().map(|s| s.url.clone()), - homepage: pkg.homepage.clone(), - } - } -} - -/// `RawPackageData` lacks a typed `support` field — the root package's -/// `support` block lives inside `extra_fields` because the schema is not -/// yet ported. Read it manually here. -pub fn view_from_raw(pkg: &RawPackageData) -> CompletePackageView { - CompletePackageView { - support_source: pkg - .extra_fields - .get("support") - .and_then(|s| s.get("source")) - .and_then(|s| s.as_str()) - .map(str::to_string), - source_url: None, - homepage: pkg.homepage.clone(), - } -} - -/// One repository in the composite. Mirrors the three repo kinds -/// `HomeCommand::initializeRepos()` returns: -/// `RootPackageRepository` + local installed + remotes. -pub enum BrowseRepo { - /// Stand-in for `Composer\Repository\RootPackageRepository` — - /// a one-package array containing the root composer.json. - /// Boxed because `RawPackageData` is much larger than the other - /// variants (clippy::large_enum_variant). - Root(Box), - /// Stand-in for `RepositoryManager::getLocalRepository()` — - /// the installed.json view of `vendor/`. - Installed(InstalledPackages), - /// Stand-in for the configured remote. For now Mozart only knows - /// the default Packagist remote (`RepositoryFactory::defaultRepos`). - Packagist { cache: Cache }, -} - -impl BrowseRepo { - /// Mirrors `RepositoryInterface::findPackages($name)` — case-insensitive - /// match by package name, returning every match the repo holds. - pub async fn find_packages(&self, name: &str) -> anyhow::Result> { - match self { - BrowseRepo::Root(pkg) => { - if pkg.name.eq_ignore_ascii_case(name) { - Ok(vec![view_from_raw(pkg)]) - } else { - Ok(Vec::new()) - } - } - BrowseRepo::Installed(installed) => Ok(installed - .packages - .iter() - .filter(|p| p.name.eq_ignore_ascii_case(name)) - .map(CompletePackageView::from) - .collect()), - BrowseRepo::Packagist { cache } => { - let versions = packagist::fetch_package_versions(name, cache).await?; - Ok(versions.iter().map(CompletePackageView::from).collect()) - } - } - } -} - -/// Ordered composite consulted by `HomeCommand::execute()`'s outer -/// `foreach ($repos as $repo)` loop. -pub struct BrowseRepos { - repos: Vec, -} - -impl BrowseRepos { - /// Build the composite. `root` and `installed` are passed in - /// rather than read here so callers can decide whether to load - /// them from `Composer` (when composer.json is present) or skip - /// them entirely (the `defaultReposWithDefaultManager` fallback). - pub fn new( - root: Option, - installed: Option, - packagist_cache: Cache, - ) -> Self { - let mut repos: Vec = Vec::with_capacity(3); - if let Some(root) = root { - repos.push(BrowseRepo::Root(Box::new(root))); - } - if let Some(installed) = installed { - repos.push(BrowseRepo::Installed(installed)); - } - repos.push(BrowseRepo::Packagist { - cache: packagist_cache, - }); - Self { repos } - } - - pub fn iter(&self) -> std::slice::Iter<'_, BrowseRepo> { - self.repos.iter() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::collections::BTreeMap; - - fn locked( - name: &str, - source_url: Option<&str>, - homepage: Option<&str>, - support_source: Option<&str>, - ) -> LockedPackage { - LockedPackage { - name: name.to_string(), - version: "1.0.0".to_string(), - version_normalized: None, - source: source_url.map(|url| crate::lockfile::LockedSource { - source_type: "git".to_string(), - url: url.to_string(), - reference: None, - }), - dist: None, - require: BTreeMap::new(), - require_dev: BTreeMap::new(), - conflict: BTreeMap::new(), - provide: BTreeMap::new(), - replace: BTreeMap::new(), - suggest: None, - package_type: None, - autoload: None, - autoload_dev: None, - license: None, - description: None, - homepage: homepage.map(str::to_string), - keywords: None, - authors: None, - support: support_source.map(|s| serde_json::json!({"source": s})), - funding: None, - time: None, - extra_fields: BTreeMap::new(), - } - } - - #[test] - fn view_from_locked_package_carries_three_urls() { - let pkg = locked( - "vendor/pkg", - Some("https://github.com/vendor/pkg.git"), - Some("https://vendor.example.com"), - Some("https://github.com/vendor/pkg"), - ); - let view = CompletePackageView::from(&pkg); - assert_eq!( - view.support_source.as_deref(), - Some("https://github.com/vendor/pkg") - ); - assert_eq!( - view.source_url.as_deref(), - Some("https://github.com/vendor/pkg.git") - ); - assert_eq!(view.homepage.as_deref(), Some("https://vendor.example.com")); - } - - #[test] - fn view_from_installed_entry_extracts_source_url() { - let mut entry = InstalledPackageEntry { - name: "vendor/pkg".to_string(), - version: "1.0.0".to_string(), - version_normalized: None, - source: Some(serde_json::json!({"url": "https://github.com/vendor/pkg.git"})), - dist: None, - package_type: None, - install_path: None, - autoload: None, - aliases: vec![], - homepage: Some("https://vendor.example.com".to_string()), - support: Some(serde_json::json!({"source": "https://github.com/vendor/pkg"})), - extra_fields: BTreeMap::new(), - }; - let view = CompletePackageView::from(&entry); - assert_eq!( - view.source_url.as_deref(), - Some("https://github.com/vendor/pkg.git") - ); - assert_eq!( - view.support_source.as_deref(), - Some("https://github.com/vendor/pkg") - ); - assert_eq!(view.homepage.as_deref(), Some("https://vendor.example.com")); - - entry.support = None; - entry.source = None; - entry.homepage = None; - let empty = CompletePackageView::from(&entry); - assert_eq!(empty, CompletePackageView::default()); - } - - #[test] - fn view_from_raw_reads_support_via_extra_fields() { - let mut raw = RawPackageData::new("vendor/root".to_string()); - raw.homepage = Some("https://vendor.example.com".to_string()); - raw.extra_fields.insert( - "support".to_string(), - serde_json::json!({"source": "https://github.com/vendor/root"}), - ); - let view = view_from_raw(&raw); - assert_eq!( - view.support_source.as_deref(), - Some("https://github.com/vendor/root") - ); - assert!(view.source_url.is_none()); - assert_eq!(view.homepage.as_deref(), Some("https://vendor.example.com")); - } - - #[tokio::test] - async fn root_repo_matches_case_insensitively() { - let raw = RawPackageData::new("Vendor/Root".to_string()); - let repo = BrowseRepo::Root(Box::new(raw)); - assert_eq!(repo.find_packages("vendor/root").await.unwrap().len(), 1); - assert_eq!(repo.find_packages("other/pkg").await.unwrap().len(), 0); - } -} diff --git a/crates/mozart-registry/src/cache.rs b/crates/mozart-registry/src/cache.rs deleted file mode 100644 index 39e3e8d..0000000 --- a/crates/mozart-registry/src/cache.rs +++ /dev/null @@ -1,575 +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) -//! vcs/ VCS mirrors (one subdir per sanitized URL) -//! ``` - -use std::fs; -use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; - -/// 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, - /// Directory for VCS mirrors (one subdirectory per sanitized URL). - pub cache_vcs_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, -} - -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. -/// -/// When no-cache mode is active (via `cli_no_cache` or `$COMPOSER_NO_CACHE`), -/// all cache directories are set to a null device, mirroring Composer's -/// `Application::doRun()` which calls `putenv('COMPOSER_CACHE_DIR', '/dev/null')`. -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 no_cache { - // Mirrors Composer: --no-cache redirects all cache paths to a null device so - // that Cache::is_usable() returns false and caching is transparently disabled. - #[cfg(windows)] - { - PathBuf::from("nul") - } - #[cfg(not(windows))] - { - PathBuf::from("/dev/null") - } - } else if let Ok(dir) = std::env::var("COMPOSER_CACHE_DIR") { - PathBuf::from(dir) - } else { - dirs_cache_dir().join("mozart") - }; - - let cache_files_dir = cache_dir.join("files"); - let cache_repo_dir = cache_dir.join("repo"); - let cache_vcs_dir = std::env::var("COMPOSER_CACHE_VCS_DIR") - .map(PathBuf::from) - .unwrap_or_else(|_| cache_dir.join("vcs")); - - CacheConfig { - cache_files_dir, - cache_repo_dir, - cache_vcs_dir, - cache_ttl: CacheConfig::DEFAULT_TTL, - cache_files_ttl: CacheConfig::DEFAULT_TTL, - cache_files_maxsize: CacheConfig::DEFAULT_FILES_MAXSIZE, - cache_dir, - read_only, - } -} - -/// 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") -} - -/// A single cache bucket (a directory on disk). -#[derive(Clone)] -pub struct Cache { - root: PathBuf, - enabled: bool, - readonly: bool, -} - -impl Cache { - /// Create a new cache rooted at `root`. - /// - /// Mirrors Composer's `Cache::__construct` + `Cache::isEnabled()`: - /// - If the path is a null device (`/dev/null`, `nul`, etc.), the cache is disabled. - /// - If `readonly` is true, the cache is always enabled (no writability check). - /// - Otherwise, tries to create the directory and checks that it is writable; - /// disables the cache with a warning if not. - pub fn new(root: PathBuf, readonly: bool) -> Self { - let enabled = if !Self::is_usable(&root) { - false - } else if readonly { - true - } else { - if fs::create_dir_all(&root).is_err() { - false - } else { - fs::metadata(&root) - .map(|m| !m.permissions().readonly()) - .unwrap_or(false) - } - }; - Self { - root, - enabled, - readonly, - } - } - - /// Returns `false` for null-device paths that should never be used as a real cache. - /// - /// Mirrors Composer's `Cache::isUsable()`. - fn is_usable(path: &Path) -> bool { - let s = path.to_string_lossy(); - if cfg!(windows) { - // On Windows, "nul" and "$null" (any case) are null devices. - !s.split(['/', '\\']) - .any(|c| c.eq_ignore_ascii_case("nul") || c == "$null") - } else { - // On Unix, /dev/null and any path under it are unusable. - s != "/dev/null" && !s.starts_with("/dev/null/") - } - } - - /// Shorthand: create the repo cache from a `CacheConfig`. - pub fn repo(config: &CacheConfig) -> Self { - Self::new(config.cache_repo_dir.clone(), config.read_only) - } - - /// Shorthand: create the files cache from a `CacheConfig`. - pub fn files(config: &CacheConfig) -> Self { - Self::new(config.cache_files_dir.clone(), config.read_only) - } - - /// Whether caching is enabled for this bucket. - pub fn is_enabled(&self) -> bool { - self.enabled - } - - /// Sanitize a cache key for use as a filename. - /// - /// Replaces `/` with `~` and strips characters that are unsafe in - /// filenames (anything except alphanumerics, `-`, `_`, `.`, `~`). - pub fn sanitize_key(key: &str) -> String { - key.replace('/', "~") - .chars() - .filter(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '~')) - .collect() - } - - /// Return the full path for a cache entry. - fn path_for(&self, key: &str) -> PathBuf { - self.root.join(Self::sanitize_key(key)) - } - - /// Read a cached string entry, or `None` if absent or cache disabled. - pub fn read(&self, key: &str) -> Option { - if !self.enabled { - return None; - } - fs::read_to_string(self.path_for(key)).ok() - } - - /// Write a string entry atomically (write to temp file, then rename). - pub fn write(&self, key: &str, contents: &str) -> anyhow::Result<()> { - if !self.enabled || self.readonly { - return Ok(()); - } - self.write_bytes(key, contents.as_bytes()) - } - - /// Read a cached binary entry, or `None` if absent or cache disabled. - pub fn read_bytes(&self, key: &str) -> Option> { - if !self.enabled { - return None; - } - fs::read(self.path_for(key)).ok() - } - - /// Write a binary entry atomically (write to temp file, then rename). - pub fn write_bytes(&self, key: &str, data: &[u8]) -> anyhow::Result<()> { - if !self.enabled || self.readonly { - 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.enabled || self.readonly { - return Ok(()); - } - 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.readonly || !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(()) - } - - /// Run garbage collection on a VCS cache bucket. - /// - /// Each top-level subdirectory is one bare mirror keyed by sanitized URL. - /// Deletes entire subdirectories whose mtime is older than `ttl_seconds`. - /// Mirrors Composer's `Cache::gcVcsCache`. - pub fn gc_vcs_cache(&self, ttl_seconds: u64) -> anyhow::Result<()> { - if !self.enabled || !self.root.exists() { - return Ok(()); - } - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - - for entry in fs::read_dir(&self.root)? { - let entry = entry?; - let path = entry.path(); - let metadata = entry.metadata()?; - if !metadata.is_dir() { - continue; - } - let mtime = metadata - .modified() - .ok() - .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) - .map(|d| d.as_secs()) - .unwrap_or(0); - if now.saturating_sub(mtime) > ttl_seconds { - let _ = fs::remove_dir_all(&path); - } - } - - Ok(()) - } - - /// Return the age in seconds of a cached entry based on its mtime, - /// or `None` if the entry doesn't exist or mtime can't be read. - pub fn age(&self, key: &str) -> Option { - if !self.enabled { - return None; - } - let path = self.path_for(key); - let metadata = fs::metadata(&path).ok()?; - let mtime = metadata.modified().ok()?; - let now = SystemTime::now(); - now.duration_since(mtime).ok().map(|d| d.as_secs()) - } -} - -/// Recursively collect all files under `dir` as `(path, mtime_secs, size_bytes)`. -fn collect_files(dir: &Path, out: &mut Vec<(PathBuf, u64, u64)>) -> anyhow::Result<()> { - if !dir.exists() { - return Ok(()); - } - for entry in fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - let metadata = entry.metadata()?; - if metadata.is_dir() { - collect_files(&path, out)?; - } else if metadata.is_file() { - let mtime = metadata - .modified() - .ok() - .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) - .map(|d| d.as_secs()) - .unwrap_or(0); - let size = metadata.len(); - out.push((path, mtime, size)); - } - } - Ok(()) -} - -/// 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) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::time::Duration; - use tempfile::tempdir; - - #[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" - ); - } - - #[test] - fn test_write_read_roundtrip_string() { - let dir = tempdir().unwrap(); - let cache = Cache::new(dir.path().to_path_buf(), false); - - cache.write("test-key", "hello world").unwrap(); - let result = cache.read("test-key"); - assert_eq!(result.as_deref(), Some("hello world")); - } - - #[test] - fn test_write_read_roundtrip_bytes() { - let dir = tempdir().unwrap(); - let cache = Cache::new(dir.path().to_path_buf(), false); - - 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)); - } - - #[test] - fn test_clear_removes_all_entries() { - let dir = tempdir().unwrap(); - let cache = Cache::new(dir.path().to_path_buf(), false); - - 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()); - } - - #[test] - fn test_disabled_cache_returns_none() { - // Point cache at /dev/null — is_usable() returns false → cache disabled. - let cache = Cache::new(PathBuf::from("/dev/null/files"), 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()); - } - - #[test] - fn test_gc_ttl_expiration() { - let dir = tempdir().unwrap(); - let cache = Cache::new(dir.path().to_path_buf(), false); - - // 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"); - } - - #[test] - fn test_gc_size_limit() { - let dir = tempdir().unwrap(); - let cache = Cache::new(dir.path().to_path_buf(), false); - - // 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" - ); - } - - #[test] - fn test_gc_vcs_removes_old_subdirs() { - let dir = tempdir().unwrap(); - let cache = Cache::new(dir.path().to_path_buf(), false); - - let old_mirror = dir.path().join("old-mirror"); - let new_mirror = dir.path().join("new-mirror"); - fs::create_dir_all(&old_mirror).unwrap(); - fs::write(old_mirror.join("HEAD"), "ref: refs/heads/main\n").unwrap(); - fs::create_dir_all(&new_mirror).unwrap(); - fs::write(new_mirror.join("HEAD"), "ref: refs/heads/main\n").unwrap(); - - let two_hours_ago = SystemTime::now() - Duration::from_secs(7200); - filetime::set_file_mtime( - &old_mirror, - filetime::FileTime::from_system_time(two_hours_ago), - ) - .unwrap(); - - cache.gc_vcs_cache(3600).unwrap(); - - assert!(!old_mirror.exists(), "expired mirror should be removed"); - assert!(new_mirror.exists(), "fresh mirror should remain"); - } - - #[test] - fn test_age_existing_entry() { - let dir = tempdir().unwrap(); - let cache = Cache::new(dir.path().to_path_buf(), false); - - 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(), false); - assert!(cache.age("nonexistent-key").is_none()); - } - - #[test] - fn test_age_disabled_cache() { - let cache = Cache::new(PathBuf::from("/dev/null/files"), false); - assert!(cache.age("any-key").is_none()); - } -} diff --git a/crates/mozart-registry/src/composer_repo.rs b/crates/mozart-registry/src/composer_repo.rs deleted file mode 100644 index ef091ef..0000000 --- a/crates/mozart-registry/src/composer_repo.rs +++ /dev/null @@ -1,173 +0,0 @@ -//! Support for `type: composer` repositories. -//! -//! A Composer repository is a directory (or HTTP endpoint) hosting a -//! `packages.json` file. The legacy format embeds full package metadata -//! directly: -//! -//! ```json -//! { -//! "packages": { -//! "a/a": { -//! "dev-foobar": { "name": "a/a", "version": "dev-foobar", ... } -//! } -//! } -//! } -//! ``` -//! -//! Mirrors `Composer\Repository\ComposerRepository` for the file:// case -//! used by the test fixtures. Lazy / v2 / provider-includes / metadata-url -//! variants are out of scope here — the in-process installer fixtures only -//! exercise the legacy embedded-packages form. - -use crate::packagist::PackagistVersion; -use crate::repository_filter::RepositoryFilter; -use indexmap::IndexSet; -use mozart_core::package::RawRepository; -use std::path::PathBuf; - -/// One package version drawn from a `type: composer` repository. -pub struct ComposerRepoPackage { - pub name: String, - pub version: PackagistVersion, -} - -/// Read every package version from `type: composer` repositories declared in -/// `composer.json`. Only `file://` URLs are supported here — they're what -/// the installer fixtures use after the harness rewrites -/// `file://foobar` → `file:///abs/path/to/fixtures/foobar`. -pub fn collect_composer_packages(repositories: &[RawRepository]) -> Vec { - let mut out = Vec::new(); - let mut claimed: IndexSet = IndexSet::new(); - for repo in repositories { - if repo.repo_type != "composer" { - continue; - } - let Some(url) = repo.url.as_deref() else { - continue; - }; - let Some(dir) = file_url_to_path(url) else { - continue; - }; - let packages_json = dir.join("packages.json"); - let Ok(content) = std::fs::read_to_string(&packages_json) else { - continue; - }; - let Ok(parsed) = serde_json::from_str::(&content) else { - continue; - }; - let Some(packages) = parsed.get("packages").and_then(|v| v.as_object()) else { - continue; - }; - let filter = RepositoryFilter::from_repo(repo); - let mut names_this_repo: IndexSet = IndexSet::new(); - for (name, versions) in packages { - if !filter.is_allowed(name) { - continue; - } - if claimed.contains(name) { - continue; - } - let Some(versions_obj) = versions.as_object() else { - continue; - }; - let mut emitted = false; - for (_, version_value) in versions_obj { - if let Ok(pv) = serde_json::from_value::(version_value.clone()) { - out.push(ComposerRepoPackage { - name: name.clone(), - version: pv, - }); - emitted = true; - } - } - if emitted { - names_this_repo.insert(name.clone()); - } - } - if filter.canonical { - claimed.extend(names_this_repo); - } - } - out -} - -/// Turn a `file://` URL into a filesystem path. Accepts both -/// `file:///abs/path` (RFC 8089 form) and `file://abs/path` (Composer's -/// loose form). Returns `None` for non-`file://` URLs. -fn file_url_to_path(url: &str) -> Option { - let rest = url.strip_prefix("file://")?; - // RFC 8089: file:///abs/path → empty authority, rest starts with `/`. - // Composer's harness writes `file:///abs/...` after rewriting, so the - // typical input here is one leading `/`. - Some(PathBuf::from(rest)) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use tempfile::TempDir; - - fn write_packages_json(dir: &std::path::Path, body: &str) { - fs::write(dir.join("packages.json"), body).unwrap(); - } - - fn composer_repo(url: String) -> RawRepository { - RawRepository { - repo_type: "composer".to_string(), - url: Some(url), - package: None, - only: None, - exclude: None, - canonical: None, - security_advisories: None, - } - } - - #[test] - fn reads_legacy_packages_json() { - let tmp = TempDir::new().unwrap(); - write_packages_json( - tmp.path(), - r#"{ - "packages": { - "a/a": { - "dev-foobar": { - "name": "a/a", - "version": "dev-foobar", - "version_normalized": "dev-foobar" - } - } - } - }"#, - ); - let url = format!("file://{}", tmp.path().display()); - let repos = vec![composer_repo(url)]; - let pkgs = collect_composer_packages(&repos); - assert_eq!(pkgs.len(), 1); - assert_eq!(pkgs[0].name, "a/a"); - assert_eq!(pkgs[0].version.version, "dev-foobar"); - } - - #[test] - fn ignores_non_composer_types() { - let repos = vec![RawRepository { - repo_type: "vcs".to_string(), - url: Some("https://example.com/foo.git".to_string()), - package: None, - only: None, - exclude: None, - canonical: None, - security_advisories: None, - }]; - assert!(collect_composer_packages(&repos).is_empty()); - } - - #[test] - fn skips_missing_packages_json() { - let tmp = TempDir::new().unwrap(); - let url = format!("file://{}", tmp.path().display()); - let repos = vec![composer_repo(url)]; - assert!(collect_composer_packages(&repos).is_empty()); - } -} diff --git a/crates/mozart-registry/src/download_manager.rs b/crates/mozart-registry/src/download_manager.rs deleted file mode 100644 index 7c6ff73..0000000 --- a/crates/mozart-registry/src/download_manager.rs +++ /dev/null @@ -1,143 +0,0 @@ -//! `DownloadManager` — pick the right [`VcsDownloader`] for a given -//! [`LocalPackage`]. Mirrors `Composer\Downloader\DownloadManager`. - -use std::path::PathBuf; - -use mozart_core::composer::{InstallationSource, LocalPackage}; -use mozart_vcs::downloader::VcsDownloader; -use mozart_vcs::downloader::git::GitDownloader; -use mozart_vcs::downloader::hg::HgDownloader; -use mozart_vcs::downloader::svn::SvnDownloader; -use mozart_vcs::process::ProcessExecutor; -use mozart_vcs::util::git::GitUtil; -use mozart_vcs::util::hg::HgUtil; -use mozart_vcs::util::svn::SvnUtil; - -/// Selects a `VcsDownloader` for a package based on its installation source -/// and source type. Mirrors `DownloadManager::getDownloaderForPackage`: -/// -/// - `metapackage` → `None`. -/// - `installation-source: dist` → `None` (Composer would return a -/// `FileDownloader`-family object that does not implement -/// `ChangeReportInterface` / `DvcsDownloaderInterface`, so the status -/// command's `instanceof` checks all become no-ops; returning `None` -/// directly is the equivalent in our trait-object world). -/// - `installation-source: source` → the matching VCS downloader by -/// `source.type` (`git` / `hg` / `svn`). -pub struct DownloadManager { - git_cache_dir: PathBuf, -} - -impl DownloadManager { - /// `git_cache_dir`: where `GitUtil` should keep mirror clones (e.g. - /// `/.cache/git`). - pub fn new(git_cache_dir: PathBuf) -> Self { - Self { git_cache_dir } - } - - pub fn get_downloader_for_package( - &self, - package: &LocalPackage, - ) -> Option> { - if package.package_type() == Some("metapackage") { - return None; - } - match package.installation_source()? { - InstallationSource::Dist => None, - InstallationSource::Source => { - let kind = package.source()?.kind.as_str(); - match kind { - "git" => { - let git_util = - GitUtil::new(ProcessExecutor::new(), self.git_cache_dir.clone()); - Some(Box::new(GitDownloader::new(git_util))) - } - "hg" => { - let hg_util = HgUtil::new(ProcessExecutor::new()); - Some(Box::new(HgDownloader::new(hg_util))) - } - "svn" => { - let svn_util = SvnUtil::new(ProcessExecutor::new()); - Some(Box::new(SvnDownloader::new(svn_util))) - } - _ => None, - } - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use mozart_core::composer::PackageReference; - use serde_json::Value; - - fn pkg( - installation_source: Option, - source_kind: Option<&str>, - ) -> LocalPackage { - let source = source_kind.map(|kind| PackageReference { - kind: kind.to_string(), - url: "https://example/repo".into(), - reference: Some("abc123".into()), - shasum: None, - }); - LocalPackage::new( - "vendor/pkg".into(), - "1.0.0".into(), - None, - Some("library".into()), - installation_source, - source, - None, - Value::Null, - ) - } - - #[test] - fn metapackage_returns_none() { - let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache")); - let mut p = pkg(Some(InstallationSource::Source), Some("git")); - // override type - p = LocalPackage::new( - "vendor/pkg".into(), - "1.0.0".into(), - None, - Some("metapackage".into()), - p.installation_source(), - p.source().cloned(), - None, - Value::Null, - ); - assert!(dm.get_downloader_for_package(&p).is_none()); - } - - #[test] - fn dist_install_returns_none() { - let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache")); - let p = pkg(Some(InstallationSource::Dist), Some("git")); - assert!(dm.get_downloader_for_package(&p).is_none()); - } - - #[test] - fn source_install_with_git_returns_some() { - let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache")); - let p = pkg(Some(InstallationSource::Source), Some("git")); - assert!(dm.get_downloader_for_package(&p).is_some()); - } - - #[test] - fn unknown_source_kind_returns_none() { - let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache")); - let p = pkg(Some(InstallationSource::Source), Some("perforce")); - assert!(dm.get_downloader_for_package(&p).is_none()); - } - - #[test] - fn missing_installation_source_returns_none() { - let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache")); - let p = pkg(None, Some("git")); - assert!(dm.get_downloader_for_package(&p).is_none()); - } -} diff --git a/crates/mozart-registry/src/downloader.rs b/crates/mozart-registry/src/downloader.rs deleted file mode 100644 index 3cb991b..0000000 --- a/crates/mozart-registry/src/downloader.rs +++ /dev/null @@ -1,500 +0,0 @@ -use crate::cache::Cache; -use indexmap::IndexSet; -use sha1::{Digest, Sha1}; -use std::fs; -use std::io::{Cursor, Read, Write}; -use std::path::Path; - -/// A simple download progress tracker that writes to stderr. -/// -/// When `show` is false, all methods are no-ops. This lets callers toggle -/// progress display without branching on every call. -pub struct DownloadProgress { - show: bool, - total: u64, - downloaded: u64, - label: String, -} - -impl DownloadProgress { - /// Create a new progress tracker. - /// - /// - `show`: whether to actually display anything. - /// - `label`: a human-readable label (e.g. "psr/log (3.0.2)"). - pub fn new(show: bool, label: impl Into) -> Self { - Self { - show, - total: 0, - downloaded: 0, - label: label.into(), - } - } - - /// Set the total expected bytes from a `Content-Length` header. - pub fn set_total(&mut self, total: u64) { - self.total = total; - } - - /// Advance the downloaded byte count and redraw the line. - pub fn inc(&mut self, n: u64) { - if !self.show { - return; - } - self.downloaded += n; - let stderr = std::io::stderr(); - let mut out = stderr.lock(); - if let Some(pct) = (self.downloaded * 100).checked_div(self.total) { - let _ = write!( - out, - "\r Downloading {} ({}/{} bytes, {}%)", - self.label, self.downloaded, self.total, pct - ); - } else { - let _ = write!( - out, - "\r Downloading {} ({} bytes)", - self.label, self.downloaded - ); - } - let _ = out.flush(); - } - - /// Clear the progress line from the terminal. - pub fn finish(&self) { - if !self.show { - return; - } - let stderr = std::io::stderr(); - let mut out = stderr.lock(); - // Clear the line with spaces then return to start - let _ = write!(out, "\r{}\r", " ".repeat(80)); - let _ = out.flush(); - } -} - -/// Download a dist archive from a URL. -/// Returns the raw bytes of the downloaded archive. -/// If `expected_shasum` is provided and non-empty, verifies SHA-1 of the downloaded bytes. -/// If `progress` is provided, increments it as bytes are received and sets the total from -/// the `Content-Length` response header. -/// Downloaded bytes are cached by URL in `files_cache`; cache hits skip the network request -/// entirely. -#[tracing::instrument(skip(expected_shasum, progress, files_cache))] -pub async fn download_dist( - url: &str, - expected_shasum: Option<&str>, - progress: Option<&mut DownloadProgress>, - files_cache: &Cache, -) -> anyhow::Result> { - // Build a cache key from the URL - let cache_key = Cache::sanitize_key(url); - - // Check cache first - if let Some(cached_bytes) = files_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 { - tracing::debug!("cache hit"); - return Ok(cached_bytes); - } - // Checksum mismatch — discard cache, re-download - } else { - tracing::debug!("cache hit"); - return Ok(cached_bytes); - } - } - - let client = mozart_core::http::client_builder().build()?; - let response = client.get(url).send().await?; - tracing::debug!(status = %response.status(), "received response"); - - 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 buf = Vec::new(); - let mut stream = response; - while let Some(chunk) = stream.chunk().await? { - buf.extend_from_slice(&chunk); - pb.inc(chunk.len() as u64); - } - buf - } else { - response.bytes().await?.to_vec() - }; - - tracing::debug!(size = bytes.len(), "download complete"); - - // 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 - let _ = files_cache.write_bytes(&cache_key, &bytes); - - Ok(bytes) -} - -/// Find the common top-level directory prefix shared by all entries. -/// Returns `Some(prefix)` if all entries share a single top-level directory. -fn find_top_level_dir(entries: &[String]) -> Option { - if entries.is_empty() { - return None; - } - - let mut prefixes: IndexSet = IndexSet::new(); - for entry in entries { - let slash_pos = entry.find('/')?; - prefixes.insert(entry[..slash_pos + 1].to_string()); - } - - if prefixes.len() == 1 { - prefixes.into_iter().next() - } else { - None - } -} - -/// Extract a zip archive to the target directory. -/// Strips a common top-level directory if all entries share one (Packagist pattern). -pub fn extract_zip(data: &[u8], target_dir: &Path) -> anyhow::Result<()> { - let cursor = Cursor::new(data); - let mut archive = zip::ZipArchive::new(cursor)?; - - // Collect all entry names to detect common prefix - let entry_names: Vec = (0..archive.len()) - .map(|i| archive.by_index(i).map(|e| e.name().to_string())) - .collect::>()?; - - let prefix = find_top_level_dir(&entry_names); - - for i in 0..archive.len() { - let mut entry = archive.by_index(i)?; - let raw_name = entry.name().to_string(); - - // Strip common prefix - let relative = if let Some(ref pfx) = prefix { - if raw_name.starts_with(pfx.as_str()) { - &raw_name[pfx.len()..] - } else { - &raw_name - } - } else { - &raw_name - }; - - // Skip the directory entry itself (empty name after stripping) - if relative.is_empty() { - continue; - } - - let target_path = target_dir.join(relative); - - if raw_name.ends_with('/') { - // Directory entry - fs::create_dir_all(&target_path)?; - } else { - // File entry - if let Some(parent) = target_path.parent() { - fs::create_dir_all(parent)?; - } - - let mut buf = Vec::new(); - entry.read_to_end(&mut buf)?; - fs::write(&target_path, &buf)?; - - // Set permissions on Unix - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Some(mode) = entry.unix_mode() { - fs::set_permissions(&target_path, fs::Permissions::from_mode(mode))?; - } - } - } - } - - Ok(()) -} - -/// Extract a tar.gz archive to the target directory. -/// Strips a common top-level directory if all entries share one (Packagist pattern). -pub fn extract_tar_gz(data: &[u8], target_dir: &Path) -> anyhow::Result<()> { - let cursor = Cursor::new(data); - let decoder = flate2::read::GzDecoder::new(cursor); - let mut archive = tar::Archive::new(decoder); - - // We need to process in two passes: first collect names, then extract. - // Use a buffered approach: collect entries into memory. - let cursor2 = Cursor::new(data); - let decoder2 = flate2::read::GzDecoder::new(cursor2); - let mut archive2 = tar::Archive::new(decoder2); - - let entry_names: Vec = archive2 - .entries()? - .filter_map(|e| e.ok()) - .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string())) - .collect(); - - let prefix = find_top_level_dir(&entry_names); - - for entry in archive.entries()? { - let mut entry = entry?; - let raw_path = entry.path()?.to_string_lossy().to_string(); - - // Strip common prefix - let relative = if let Some(ref pfx) = prefix { - if raw_path.starts_with(pfx.as_str()) { - raw_path[pfx.len()..].to_string() - } else { - raw_path.clone() - } - } else { - raw_path.clone() - }; - - // Skip empty (top-level dir itself) - if relative.is_empty() { - continue; - } - - let target_path = target_dir.join(&relative); - - let entry_type = entry.header().entry_type(); - if entry_type.is_dir() { - fs::create_dir_all(&target_path)?; - } else if entry_type.is_file() { - if let Some(parent) = target_path.parent() { - fs::create_dir_all(parent)?; - } - let mut buf = Vec::new(); - entry.read_to_end(&mut buf)?; - fs::write(&target_path, &buf)?; - - // Set permissions on Unix - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Ok(mode) = entry.header().mode() { - fs::set_permissions(&target_path, fs::Permissions::from_mode(mode))?; - } - } - } - // Symlinks and other types are skipped for now - } - - Ok(()) -} - -/// Download and install a package to the vendor directory. -/// -/// - `dist_url`: the download URL (from `LockedPackage.dist.url`) -/// - `dist_type`: `"zip"` or `"tar"` (from `LockedPackage.dist.dist_type`) -/// - `dist_shasum`: optional SHA-1 checksum -/// - `vendor_dir`: path to `vendor/` directory -/// - `package_name`: e.g. `"monolog/monolog"` -/// - `progress`: optional mutable progress tracker to update during download -/// - `files_cache`: files cache; archive bytes are cached by URL -pub async 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: &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).await?; - - match dist_type { - "zip" => extract_zip(&bytes, &target)?, - "tar" | "tar.gz" | "tgz" => extract_tar_gz(&bytes, &target)?, - other => anyhow::bail!("Unsupported dist type: {other}"), - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write as IoWrite; - use tempfile::tempdir; - - /// Build a minimal zip archive in memory. - fn make_zip(files: &[(&str, &[u8])]) -> Vec { - let buf = Vec::new(); - let cursor = Cursor::new(buf); - let mut writer = zip::ZipWriter::new(cursor); - let options = zip::write::FileOptions::<()>::default() - .compression_method(zip::CompressionMethod::Stored); - - for (name, content) in files { - writer.start_file(*name, options).unwrap(); - writer.write_all(content).unwrap(); - } - - writer.finish().unwrap().into_inner() - } - - /// Build a minimal tar.gz archive in memory. - fn make_tar_gz(files: &[(&str, &[u8])]) -> Vec { - let buf = Vec::new(); - let enc = flate2::write::GzEncoder::new(buf, flate2::Compression::default()); - let mut builder = tar::Builder::new(enc); - - for (name, content) in files { - let mut header = tar::Header::new_gnu(); - header.set_size(content.len() as u64); - header.set_mode(0o644); - header.set_cksum(); - builder - .append_data(&mut header, name, Cursor::new(content)) - .unwrap(); - } - - builder.into_inner().unwrap().finish().unwrap() - } - - #[test] - fn test_extract_zip_flat() { - let zip_data = make_zip(&[("file1.txt", b"hello"), ("subdir/file2.txt", b"world")]); - - let dir = tempdir().unwrap(); - extract_zip(&zip_data, dir.path()).unwrap(); - - assert_eq!( - fs::read_to_string(dir.path().join("file1.txt")).unwrap(), - "hello" - ); - assert_eq!( - fs::read_to_string(dir.path().join("subdir/file2.txt")).unwrap(), - "world" - ); - } - - #[test] - fn test_extract_zip_with_top_level_dir() { - // Packagist pattern: all files under vendor-package-abc123/ - let zip_data = make_zip(&[ - ("vendor-pkg-abc/", &[]), - ("vendor-pkg-abc/file1.txt", b"hello"), - ("vendor-pkg-abc/src/Foo.php", b" Vec { - let mut packages = Vec::new(); - let mut claimed: IndexSet = IndexSet::new(); - for repo in repositories { - if repo.repo_type != "package" { - continue; - } - let Some(value) = &repo.package else { - continue; - }; - let filter = RepositoryFilter::from_repo(repo); - - let mut from_this_repo: Vec = Vec::new(); - match value { - serde_json::Value::Array(arr) => { - for entry in arr { - if let Some(pkg) = parse_inline_package(entry) { - from_this_repo.push(pkg); - } - } - } - serde_json::Value::Object(_) => { - if let Some(pkg) = parse_inline_package(value) { - from_this_repo.push(pkg); - } - } - _ => {} - } - - let mut names_this_repo: IndexSet = IndexSet::new(); - for pkg in from_this_repo { - if !filter.is_allowed(&pkg.name) { - continue; - } - if claimed.contains(&pkg.name) { - continue; - } - names_this_repo.insert(pkg.name.clone()); - packages.push(pkg); - } - // canonical: false → packages enter the pool but the name is not - // claimed, so lower-priority repositories may still answer for it. - // Mirrors `FilterRepository::loadPackages`'s `namesFound = []` reset. - if filter.canonical { - claimed.extend(names_this_repo); - } - } - packages -} - -/// One advisory extracted from a repository's `security-advisories` block. -/// Carries enough to filter affected versions out of the pool when -/// `config.audit.block-insecure` is set, matching the slice of Composer's -/// `SecurityAdvisoryPoolFilter` Mozart needs for resolution-time blocking. -#[derive(Debug, Clone)] -pub struct SecurityAdvisory { - pub advisory_id: String, - pub affected_versions: String, -} - -/// Collect every `security-advisories` entry across all repositories. -/// Returned map is keyed by lowercase package name so the resolver can -/// look up affected versions in lockstep with the rest of its -/// case-insensitive name handling. Repository order is preserved within -/// each list. -pub fn collect_security_advisories( - repositories: &[RawRepository], -) -> indexmap::IndexMap> { - let mut out: indexmap::IndexMap> = indexmap::IndexMap::new(); - for repo in repositories { - let Some(advisories) = &repo.security_advisories else { - continue; - }; - let Some(map) = advisories.as_object() else { - continue; - }; - for (pkg_name, list) in map { - let Some(arr) = list.as_array() else { - continue; - }; - for entry in arr { - let Some(obj) = entry.as_object() else { - continue; - }; - let Some(affected) = obj - .get("affectedVersions") - .and_then(|v| v.as_str()) - .map(String::from) - else { - continue; - }; - let advisory_id = obj - .get("advisoryId") - .and_then(|v| v.as_str()) - .map(String::from) - .unwrap_or_default(); - out.entry(pkg_name.to_lowercase()) - .or_default() - .push(SecurityAdvisory { - advisory_id, - affected_versions: affected, - }); - } - } - } - out -} - -fn parse_inline_package(value: &serde_json::Value) -> Option { - let obj = value.as_object()?; - let name = obj.get("name")?.as_str()?.to_string(); - let version_str = obj.get("version")?.as_str()?.to_string(); - - // PackagistVersion requires `version_normalized`. If the inline definition - // omits it (the common case), compute it the same way Packagist does: - // run the version through Mozart's normalizer. - // - // Mirrors Composer's `ArrayLoader::parsePackage` Composer v1 compat path: - // when `version_normalized` is exactly `9999999-dev` (the legacy default - // branch sentinel), re-normalize from the human-readable `version` field - // instead. Without this, the package's version stays as `9999999-dev` - // even though its pretty form is e.g. `dev-master`, and a root require - // for `dev-master` then can't match the loaded package. - let mut value_for_parse = value.clone(); - if let serde_json::Value::Object(ref mut map) = value_for_parse { - let needs_normalize = match map.get("version_normalized") { - None => true, - Some(serde_json::Value::String(s)) => s == "9999999-dev", - _ => false, - }; - if needs_normalize { - let normalized = mozart_semver::Version::parse(&version_str) - .map(|v| v.to_string()) - .unwrap_or_else(|_| version_str.clone()); - map.insert( - "version_normalized".to_string(), - serde_json::Value::String(normalized), - ); - } - } - - let version: PackagistVersion = serde_json::from_value(value_for_parse).ok()?; - Some(InlinePackage { name, version }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn pkg_repo(value: serde_json::Value) -> RawRepository { - RawRepository { - repo_type: "package".to_string(), - url: None, - package: Some(value), - only: None, - exclude: None, - canonical: None, - security_advisories: None, - } - } - - #[test] - fn collects_single_inline_package_object() { - let repos = vec![pkg_repo(serde_json::json!({ - "name": "a/a", - "version": "1.0.0" - }))]; - let pkgs = collect_inline_packages(&repos); - assert_eq!(pkgs.len(), 1); - assert_eq!(pkgs[0].name, "a/a"); - assert_eq!(pkgs[0].version.version, "1.0.0"); - assert_eq!(pkgs[0].version.version_normalized, "1.0.0.0"); - } - - #[test] - fn collects_inline_package_array() { - let repos = vec![pkg_repo(serde_json::json!([ - {"name": "a/a", "version": "1.0.0"}, - {"name": "b/b", "version": "2.0.0"} - ]))]; - let pkgs = collect_inline_packages(&repos); - assert_eq!(pkgs.len(), 2); - assert_eq!(pkgs[0].name, "a/a"); - assert_eq!(pkgs[1].name, "b/b"); - } - - #[test] - fn ignores_non_package_repos() { - let repos = vec![RawRepository { - repo_type: "vcs".to_string(), - url: Some("https://example.com/foo.git".to_string()), - package: None, - only: None, - exclude: None, - canonical: None, - security_advisories: None, - }]; - assert!(collect_inline_packages(&repos).is_empty()); - } - - #[test] - fn skips_entries_missing_name_or_version() { - let repos = vec![pkg_repo(serde_json::json!([ - {"name": "a/a", "version": "1.0.0"}, - {"name": "missing/version"}, - {"version": "2.0.0"}, - {"name": "b/b", "version": "2.0.0"} - ]))]; - let pkgs = collect_inline_packages(&repos); - assert_eq!(pkgs.len(), 2); - assert_eq!(pkgs[0].name, "a/a"); - assert_eq!(pkgs[1].name, "b/b"); - } - - #[test] - fn preserves_explicit_version_normalized() { - let repos = vec![pkg_repo(serde_json::json!({ - "name": "a/a", - "version": "1.0", - "version_normalized": "1.0.0.0-explicit" - }))]; - let pkgs = collect_inline_packages(&repos); - assert_eq!(pkgs[0].version.version_normalized, "1.0.0.0-explicit"); - } - - #[test] - fn parses_full_metadata_fields() { - let repos = vec![pkg_repo(serde_json::json!({ - "name": "a/a", - "version": "1.0.0", - "type": "library", - "require": {"b/b": "^2.0"}, - "replace": {"old/x": "1.0"}, - "provide": {"some/iface": "1.0"}, - "conflict": {"bad/pkg": "*"}, - "dist": {"type": "zip", "url": "https://e.com/a.zip"} - }))]; - let pkgs = collect_inline_packages(&repos); - assert_eq!(pkgs.len(), 1); - let v = &pkgs[0].version; - assert_eq!(v.package_type.as_deref(), Some("library")); - assert_eq!(v.require.get("b/b").map(String::as_str), Some("^2.0")); - assert_eq!(v.replace.get("old/x").map(String::as_str), Some("1.0")); - assert_eq!(v.provide.get("some/iface").map(String::as_str), Some("1.0")); - assert_eq!(v.conflict.get("bad/pkg").map(String::as_str), Some("*")); - assert!(v.dist.is_some()); - } -} diff --git a/crates/mozart-registry/src/installed.rs b/crates/mozart-registry/src/installed.rs deleted file mode 100644 index 108b844..0000000 --- a/crates/mozart-registry/src/installed.rs +++ /dev/null @@ -1,383 +0,0 @@ -use mozart_core::installer::HasSuggests; -use mozart_core::package::to_json_pretty; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; -use std::fs; -use std::path::Path; - -fn default_true() -> bool { - true -} - -/// Represents `vendor/composer/installed.json`. -/// This is the Composer 2.x format. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InstalledPackages { - pub packages: Vec, - - #[serde(rename = "dev-package-names", default)] - pub dev_package_names: Vec, - - #[serde(default = "default_true")] - pub dev: bool, -} - -/// An entry in installed.json's packages array. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InstalledPackageEntry { - pub name: String, - pub version: String, - - #[serde(rename = "version_normalized", skip_serializing_if = "Option::is_none")] - pub version_normalized: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub source: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub dist: Option, - - #[serde(rename = "type", skip_serializing_if = "Option::is_none")] - pub package_type: Option, - - #[serde(rename = "install-path", skip_serializing_if = "Option::is_none")] - pub install_path: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub autoload: Option, - - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub aliases: Vec, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub homepage: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub support: Option, - - #[serde(flatten)] - pub extra_fields: BTreeMap, -} - -impl HasSuggests for InstalledPackageEntry { - fn pretty_name(&self) -> &str { - &self.name - } - - fn suggests(&self) -> Vec<(String, String)> { - let Some(val) = self.extra_fields.get("suggest") else { - return Vec::new(); - }; - let Some(obj) = val.as_object() else { - return Vec::new(); - }; - obj.iter() - .filter_map(|(target, reason)| reason.as_str().map(|r| (target.clone(), r.to_string()))) - .collect() - } -} - -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. - /// - /// Accepts both Composer formats, mirroring `FilesystemRepository::initialize`: - /// - **v2** — object with a `packages` array, plus optional `dev-package-names`/`dev` - /// (the shape Composer 2.x writes). - /// - **v1** — bare array of package entries (older shape; still legal input). - pub fn read(vendor_dir: &Path) -> anyhow::Result { - let path = vendor_dir.join("composer/installed.json"); - if !path.exists() { - return Ok(InstalledPackages::new()); - } - let content = fs::read_to_string(&path)?; - Self::from_json_str(&content) - } - - /// Parse an installed.json document. See [`Self::read`] for the accepted shapes. - pub fn from_json_str(content: &str) -> anyhow::Result { - use anyhow::{Context, anyhow}; - - let value: serde_json::Value = - serde_json::from_str(content).context("invalid installed.json")?; - - match value { - serde_json::Value::Object(mut obj) => { - let packages_value = obj.remove("packages").ok_or_else(|| { - anyhow!("Could not parse package list from installed.json (missing `packages`)") - })?; - let packages: Vec = - serde_json::from_value(packages_value) - .context("invalid `packages` array in installed.json")?; - - let dev_package_names: Vec = match obj.remove("dev-package-names") { - Some(v) => serde_json::from_value(v) - .context("invalid `dev-package-names` in installed.json")?, - None => Vec::new(), - }; - let dev: bool = match obj.remove("dev") { - Some(v) => { - serde_json::from_value(v).context("invalid `dev` flag in installed.json")? - } - None => true, - }; - - Ok(InstalledPackages { - packages, - dev_package_names, - dev, - }) - } - serde_json::Value::Array(_) => { - let packages: Vec = serde_json::from_value(value) - .context("invalid v1 installed.json package array")?; - Ok(InstalledPackages { - packages, - dev_package_names: Vec::new(), - dev: true, - }) - } - _ => Err(anyhow!( - "Could not parse package list from installed.json (expected object or array)" - )), - } - } - - /// 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![], - homepage: None, - support: None, - 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_reads_v2_object_form() { - let json = r#"{ - "packages": [ - {"name": "a/a", "version": "1.0.0"} - ], - "dev-package-names": ["a/a"], - "dev": false - }"#; - let installed = InstalledPackages::from_json_str(json).unwrap(); - assert_eq!(installed.packages.len(), 1); - assert_eq!(installed.packages[0].name, "a/a"); - assert_eq!(installed.dev_package_names, vec!["a/a".to_string()]); - assert!(!installed.dev); - } - - #[test] - fn test_reads_v1_array_form() { - // Composer 1.x / fixture-style: bare array of packages. - // FilesystemRepository::initialize accepts this; so must Mozart. - let json = r#"[ - {"name": "a/a", "version": "1.0.0"}, - {"name": "b/b", "version": "2.0.0"} - ]"#; - let installed = InstalledPackages::from_json_str(json).unwrap(); - assert_eq!(installed.packages.len(), 2); - assert_eq!(installed.packages[0].name, "a/a"); - assert_eq!(installed.packages[1].name, "b/b"); - assert!(installed.dev_package_names.is_empty()); - assert!(installed.dev); - } - - #[test] - fn test_v2_defaults_when_optional_fields_missing() { - let json = r#"{"packages": []}"#; - let installed = InstalledPackages::from_json_str(json).unwrap(); - assert!(installed.packages.is_empty()); - assert!(installed.dev_package_names.is_empty()); - assert!(installed.dev); - } - - #[test] - fn test_rejects_non_object_non_array() { - let err = InstalledPackages::from_json_str("\"oops\"").unwrap_err(); - assert!( - err.to_string().contains("expected object or array"), - "{err}" - ); - } - - #[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"); - } - - #[test] - fn test_homepage_and_support_roundtrip() { - let json = r#"{ - "packages": [ - { - "name": "vendor/pkg", - "version": "1.0.0", - "homepage": "https://vendor.example.com", - "support": {"source": "https://github.com/vendor/pkg"} - } - ] - }"#; - let installed = InstalledPackages::from_json_str(json).unwrap(); - let pkg = &installed.packages[0]; - assert_eq!(pkg.homepage.as_deref(), Some("https://vendor.example.com")); - assert_eq!( - pkg.support - .as_ref() - .and_then(|s| s.get("source")) - .and_then(|s| s.as_str()), - Some("https://github.com/vendor/pkg") - ); - } -} diff --git a/crates/mozart-registry/src/installer_executor/filesystem.rs b/crates/mozart-registry/src/installer_executor/filesystem.rs deleted file mode 100644 index cb1a2cc..0000000 --- a/crates/mozart-registry/src/installer_executor/filesystem.rs +++ /dev/null @@ -1,232 +0,0 @@ -//! Production [`InstallerExecutor`] that touches the real filesystem. -//! -//! This is the verb behind `mozart install` / `mozart update` — it pulls -//! dist archives via [`crate::downloader`], clones VCS sources via -//! [`mozart_vcs`], and removes vendor directories. Test code substitutes a -//! recording-only executor instead (added in a later step). - -use std::path::Path; - -use crate::cache::Cache; -use crate::downloader; - -use super::{ExecuteContext, InstallerExecutor, PackageOperation}; - -pub struct FilesystemExecutor { - files_cache: Cache, -} - -impl FilesystemExecutor { - pub fn new(files_cache: Cache) -> Self { - Self { files_cache } - } -} - -#[async_trait::async_trait] -impl InstallerExecutor for FilesystemExecutor { - async fn install_package( - &mut self, - op: PackageOperation<'_>, - ctx: &ExecuteContext, - ) -> anyhow::Result<()> { - // Marking an alias as installed/uninstalled has no filesystem side - // effects — the target package's files are already in vendor/. - // Mirrors Composer's `MarkAlias{,Un}installedOperation` which the - // installation manager only uses to update the in-memory installed - // repository. - let Some(pkg) = op.package() else { - return Ok(()); - }; - - // Try source install if --prefer-source and source info is available. - if ctx.prefer_source - && let Some(source) = &pkg.source - { - return install_from_source( - &source.source_type, - &source.url, - source.reference.as_deref().unwrap_or("HEAD"), - &ctx.vendor_dir, - &pkg.name, - ); - } - - // A package with neither dist nor source has no install action. - // This covers Composer's `type: metapackage` (modeled explicitly as - // "no installer") and inline `type: package` definitions used in - // test fixtures that intentionally omit download metadata. Mozart - // records the operation and the installed.json entry but performs - // no filesystem work, mirroring Composer's MetapackageInstaller. - if pkg.dist.is_none() && pkg.source.is_none() { - return Ok(()); - } - - let dist = pkg.dist.as_ref().ok_or_else(|| { - anyhow::anyhow!( - "Package {} has no dist information. Use --prefer-source to install from VCS.", - pkg.name, - ) - })?; - - let mut progress = downloader::DownloadProgress::new( - !ctx.no_progress, - format!("{} ({})", pkg.name, pkg.version), - ); - - downloader::install_package( - &dist.url, - &dist.dist_type, - dist.shasum.as_deref(), - &ctx.vendor_dir, - &pkg.name, - Some(&mut progress), - &self.files_cache, - ) - .await?; - - progress.finish(); - Ok(()) - } - - fn uninstall_package( - &mut self, - name: &str, - _version: &str, - ctx: &ExecuteContext, - ) -> anyhow::Result<()> { - let pkg_dir = ctx.vendor_dir.join(name); - if pkg_dir.exists() { - std::fs::remove_dir_all(&pkg_dir)?; - } - Ok(()) - } - - fn cleanup_after_uninstalls(&mut self, ctx: &ExecuteContext) -> anyhow::Result<()> { - cleanup_empty_vendor_dirs(&ctx.vendor_dir) - } -} - -/// Remove empty vendor namespace directories left behind after package -/// removals. Skips the `composer/` and `bin/` directories. Mirrors the -/// post-uninstall cleanup Composer does in `LibraryInstaller::removeCode`. -fn cleanup_empty_vendor_dirs(vendor_dir: &Path) -> anyhow::Result<()> { - if let Ok(entries) = std::fs::read_dir(vendor_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - let name = entry.file_name().to_string_lossy().to_string(); - if name == "composer" || name == "bin" { - continue; - } - if std::fs::read_dir(&path)?.next().is_none() { - std::fs::remove_dir(&path)?; - } - } - } - } - Ok(()) -} - -/// Install a package from VCS source (git/svn/hg). Lifted from the previous -/// `commands/install.rs::install_from_source`. Mirrors the per-driver -/// dispatch in `Composer\Downloader\VcsDownloader::install`. -fn install_from_source( - source_type: &str, - url: &str, - reference: &str, - vendor_dir: &Path, - package_name: &str, -) -> anyhow::Result<()> { - let target = vendor_dir.join(package_name); - if target.exists() { - std::fs::remove_dir_all(&target)?; - } - - match source_type { - "git" => { - let process = mozart_vcs::process::ProcessExecutor::new(); - let git_util = - mozart_vcs::util::git::GitUtil::new(process, vendor_dir.join(".cache").join("git")); - let downloader = mozart_vcs::downloader::git::GitDownloader::new(git_util); - use mozart_vcs::downloader::VcsDownloader; - downloader.download(url, reference, &target)?; - downloader.install(url, reference, &target)?; - } - "svn" => { - let process = mozart_vcs::process::ProcessExecutor::new(); - let svn_util = mozart_vcs::util::svn::SvnUtil::new(process); - let downloader = mozart_vcs::downloader::svn::SvnDownloader::new(svn_util); - use mozart_vcs::downloader::VcsDownloader; - downloader.install(url, reference, &target)?; - } - "hg" => { - let process = mozart_vcs::process::ProcessExecutor::new(); - let hg_util = mozart_vcs::util::hg::HgUtil::new(process); - let downloader = mozart_vcs::downloader::hg::HgDownloader::new(hg_util); - use mozart_vcs::downloader::VcsDownloader; - downloader.install(url, reference, &target)?; - } - _ => { - anyhow::bail!("Unsupported source type for VCS install: {}", source_type); - } - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - fn make_executor() -> FilesystemExecutor { - FilesystemExecutor::new(Cache::new(std::env::temp_dir().join("__no_cache"), false)) - } - - #[test] - fn cleanup_after_uninstalls_removes_empty_namespace_dirs() { - let dir = tempdir().unwrap(); - let vendor_dir = dir.path().join("vendor"); - std::fs::create_dir_all(&vendor_dir).unwrap(); - - let empty_ns = vendor_dir.join("old-vendor"); - std::fs::create_dir_all(&empty_ns).unwrap(); - - let nonempty_ns = vendor_dir.join("psr"); - std::fs::create_dir_all(nonempty_ns.join("log")).unwrap(); - - std::fs::create_dir_all(vendor_dir.join("composer")).unwrap(); - - let mut exec = make_executor(); - exec.cleanup_after_uninstalls(&ExecuteContext { - vendor_dir: vendor_dir.clone(), - no_progress: true, - prefer_source: false, - }) - .unwrap(); - - assert!(!empty_ns.exists()); - assert!(vendor_dir.join("psr").exists()); - assert!(vendor_dir.join("composer").exists()); - } - - #[test] - fn cleanup_after_uninstalls_preserves_bin_dir() { - let dir = tempdir().unwrap(); - let vendor_dir = dir.path().join("vendor"); - std::fs::create_dir_all(&vendor_dir).unwrap(); - - let bin_dir = vendor_dir.join("bin"); - std::fs::create_dir_all(&bin_dir).unwrap(); - - let mut exec = make_executor(); - exec.cleanup_after_uninstalls(&ExecuteContext { - vendor_dir: vendor_dir.clone(), - no_progress: true, - prefer_source: false, - }) - .unwrap(); - - assert!(bin_dir.exists()); - } -} diff --git a/crates/mozart-registry/src/installer_executor/mod.rs b/crates/mozart-registry/src/installer_executor/mod.rs deleted file mode 100644 index 4ddad66..0000000 --- a/crates/mozart-registry/src/installer_executor/mod.rs +++ /dev/null @@ -1,348 +0,0 @@ -//! Installation execution abstraction. -//! -//! Mirrors `Composer\Installer\InstallationManager`: the per-operation -//! side-effect surface (download, extract, remove from vendor/) lives behind -//! a trait so test code can substitute a recording-only implementation -//! (Composer's `InstallationManagerMock`) without going anywhere near the -//! filesystem or the network. -//! -//! The orchestration loop (computing operations from lock vs installed, -//! emitting console messages, writing `installed.json`, generating the -//! autoloader) stays in the caller. The executor is purely the verb — -//! "install this package" / "uninstall this package" — so test traces match -//! Composer's `(string) $operation` byte-for-byte without the executor -//! having to also reproduce console formatting. - -use std::path::PathBuf; - -use crate::installed::InstalledPackageEntry; -use crate::lockfile::{LockAlias, LockedPackage}; - -pub mod filesystem; -pub mod trace_recorder; -pub mod transaction; - -pub use filesystem::FilesystemExecutor; -pub use trace_recorder::TraceRecorderExecutor; -pub use transaction::{ - Action, StaleInstalledAlias, compute_operations, compute_stale_installed_aliases, - locked_to_installed_entry, previously_installed_alias_versions, -}; - -/// One install or update operation handed to [`InstallerExecutor::install_package`]. -#[derive(Debug, Clone, Copy)] -pub enum PackageOperation<'a> { - /// First-time install. The whole package directory is created from - /// `package.dist`/`package.source`. - Install { package: &'a LockedPackage }, - /// Replace an existing install with a new version. `from_version` is the - /// pretty version that was installed before (no reference suffix — - /// drives the upgrade-vs-downgrade direction). `from_full_pretty` / - /// `to_full_pretty` are the formatted display strings used verbatim in - /// the trace output; the caller renders them via - /// [`format_update_pretty_versions`] so the SOURCE_REF / DIST_REF mode - /// switch from Composer's `UpdateOperation::format` lands on both sides. - Update { - from_version: &'a str, - from_full_pretty: &'a str, - to_full_pretty: &'a str, - package: &'a LockedPackage, - }, - /// Mark an alias of a real package as installed. No filesystem effects — - /// only the trace recorder needs this. Mirrors Composer's - /// `MarkAliasInstalledOperation`. - MarkAliasInstalled { - /// The alias entry from `composer.lock`'s `aliases[]` block. Carries - /// pretty + normalized alias version and the target's pretty version. - alias: &'a LockAlias, - /// The target package the alias points at — used to source the - /// reference suffix for the trace line. - target: &'a LockedPackage, - }, - /// Mark a previously-installed alias as uninstalled. No filesystem - /// effects — only the trace recorder cares. Mirrors Composer's - /// `MarkAliasUninstalledOperation`. Composer derives the AliasPackage - /// from the previous installed.json entries (via `extra.branch-alias`), - /// then emits this when the alias is no longer in the result. Caller - /// pre-renders the display strings so this variant doesn't need to know - /// how to spelunk the entry. - MarkAliasUninstalled { - /// Package name (e.g. `a/a`) used as both the alias's name and the - /// target's name on the trace line. - name: &'a str, - /// Alias's full-pretty form (alias pretty version plus reference - /// suffix), e.g. `1.0.x-dev master`. - alias_full: &'a str, - /// Target's full-pretty form, e.g. `dev-master master`. - target_full: &'a str, - }, -} - -impl<'a> PackageOperation<'a> { - pub fn package(&self) -> Option<&'a LockedPackage> { - match self { - PackageOperation::Install { package } | PackageOperation::Update { package, .. } => { - Some(package) - } - PackageOperation::MarkAliasInstalled { .. } - | PackageOperation::MarkAliasUninstalled { .. } => None, - } - } -} - -/// Mirror Composer's `BasePackage::getFullPrettyVersion()` for a `LockedPackage`. -/// -/// For dev-stability versions backed by a git/hg source, append the reference -/// (truncated to 7 chars when it looks like a 40-char sha1). Otherwise return -/// the pretty version unchanged. -pub fn format_full_pretty_version(pkg: &LockedPackage) -> String { - format_full_pretty_with_pretty(&pkg.version, pkg) -} - -/// Same as [`format_full_pretty_version`] but lets the caller supply an -/// alternate pretty version (used by `MarkAliasInstalled` so the alias's -/// `3.2.x-dev` text is rendered with the *target's* reference). -pub fn format_full_pretty_with_pretty(pretty_version: &str, pkg: &LockedPackage) -> String { - let source_ref = pkg.source.as_ref().and_then(|s| s.reference.as_deref()); - let dist_ref = pkg.dist.as_ref().and_then(|d| d.reference.as_deref()); - let source_type = pkg.source.as_ref().map(|s| s.source_type.as_str()); - format_full_pretty_with_refs( - pretty_version, - &pkg.version, - source_ref, - dist_ref, - source_type, - ) -} - -/// Render an alias's full pretty version: the alias's own pretty form for -/// the visible text, the alias's *normalized* version for the dev-stability -/// gate, and the target package's source/dist references for the suffix. -/// Mirrors `AliasPackage::getFullPrettyVersion`, where the alias decides on -/// its own whether to append a reference based on its own stability — so a -/// stable alias like `1.0.0` skips the suffix even when the target is a dev -/// branch. -pub fn format_full_pretty_alias( - alias_pretty: &str, - alias_version: &str, - target: &LockedPackage, -) -> String { - let source_ref = target.source.as_ref().and_then(|s| s.reference.as_deref()); - let dist_ref = target.dist.as_ref().and_then(|d| d.reference.as_deref()); - let source_type = target.source.as_ref().map(|s| s.source_type.as_str()); - format_full_pretty_with_refs( - alias_pretty, - alias_version, - source_ref, - dist_ref, - source_type, - ) -} - -/// Same as [`format_full_pretty_version_for_installed`] but lets the caller -/// supply an alternate pretty version. Used when emitting -/// `MarkAliasUninstalled`: the alias's `1.0.x-dev` text needs to be rendered -/// with the *target installed entry's* reference suffix. -pub fn format_full_pretty_with_pretty_for_installed( - pretty_version: &str, - entry: &InstalledPackageEntry, -) -> String { - let source_ref = entry - .source - .as_ref() - .and_then(|v| v.get("reference")) - .and_then(|v| v.as_str()); - let dist_ref = entry - .dist - .as_ref() - .and_then(|v| v.get("reference")) - .and_then(|v| v.as_str()); - let source_type = entry - .source - .as_ref() - .and_then(|v| v.get("type")) - .and_then(|v| v.as_str()); - format_full_pretty_with_refs( - pretty_version, - &entry.version, - source_ref, - dist_ref, - source_type, - ) -} - -/// Mirror Composer's `BasePackage::getFullPrettyVersion()` for an -/// `InstalledPackageEntry`. Same display rules as -/// [`format_full_pretty_version`] but pulls source/dist info out of the -/// installed.json `source`/`dist` JSON values. -pub fn format_full_pretty_version_for_installed(entry: &InstalledPackageEntry) -> String { - format_full_pretty_with_pretty_for_installed(&entry.version, entry) -} - -/// Render the from/to display strings for an update trace line, mirroring -/// Composer's `UpdateOperation::format`. Defaults to `DISPLAY_SOURCE_REF_IF_DEV`, -/// then if both sides render identically: -/// -/// - source references differ → re-render in `DISPLAY_SOURCE_REF` mode, -/// - else dist references differ → re-render in `DISPLAY_DIST_REF` mode. -/// -/// Without the switch, two same-version-different-reference packages would -/// produce a useless `pkg (X => X)` trace line. -pub fn format_update_pretty_versions( - from_entry: &InstalledPackageEntry, - to_pkg: &LockedPackage, -) -> (String, String) { - let from_default = format_full_pretty_version_for_installed(from_entry); - let to_default = format_full_pretty_version(to_pkg); - if from_default != to_default { - return (from_default, to_default); - } - - let from_source_ref = from_entry - .source - .as_ref() - .and_then(|v| v.get("reference")) - .and_then(|v| v.as_str()); - let from_source_type = from_entry - .source - .as_ref() - .and_then(|v| v.get("type")) - .and_then(|v| v.as_str()); - let to_source_ref = to_pkg.source.as_ref().and_then(|s| s.reference.as_deref()); - let to_source_type = to_pkg.source.as_ref().map(|s| s.source_type.as_str()); - - if from_source_ref != to_source_ref { - return ( - format_with_explicit_reference(&from_entry.version, from_source_ref, from_source_type), - format_with_explicit_reference(&to_pkg.version, to_source_ref, to_source_type), - ); - } - - let from_dist_ref = from_entry - .dist - .as_ref() - .and_then(|v| v.get("reference")) - .and_then(|v| v.as_str()); - let to_dist_ref = to_pkg.dist.as_ref().and_then(|d| d.reference.as_deref()); - - if from_dist_ref != to_dist_ref { - return ( - format_with_explicit_reference(&from_entry.version, from_dist_ref, from_source_type), - format_with_explicit_reference(&to_pkg.version, to_dist_ref, to_source_type), - ); - } - - (from_default, to_default) -} - -/// Render `pretty_version` with an explicitly chosen reference, mirroring -/// Composer's `BasePackage::getFullPrettyVersion` with `DISPLAY_SOURCE_REF` -/// or `DISPLAY_DIST_REF`: skip the dev-stability gate, just truncate sha1 -/// references and concatenate. A `None` reference falls back to the bare -/// pretty version. -fn format_with_explicit_reference( - pretty_version: &str, - reference: Option<&str>, - source_type: Option<&str>, -) -> String { - let Some(reference) = reference else { - return pretty_version.to_string(); - }; - if matches!(source_type, Some("svn")) { - return format!("{} {}", pretty_version, reference); - } - if reference.len() == 40 { - return format!("{} {}", pretty_version, &reference[..7]); - } - format!("{} {}", pretty_version, reference) -} - -/// Core of `BasePackage::getFullPrettyVersion()` factored over raw -/// fields so both [`LockedPackage`] and [`InstalledPackageEntry`] can share -/// the rendering logic. `version` drives the dev-stability check; the result -/// is `pretty_version` plus a reference suffix when the package is a dev -/// branch backed by git/hg (with sha1 references truncated to 7 chars). -fn format_full_pretty_with_refs( - pretty_version: &str, - version: &str, - source_ref: Option<&str>, - dist_ref: Option<&str>, - source_type: Option<&str>, -) -> String { - let is_dev = mozart_semver::Version::parse(version) - .map(|v| matches!(v.pre_release.as_deref(), Some("dev")) || v.is_dev_branch) - .unwrap_or(false); - if !is_dev { - return pretty_version.to_string(); - } - // Composer falls back to dist reference only when no source type is set - // (or the package isn't git/hg — in which case the dev display is skipped - // entirely above). - let reference = source_ref.or(match source_type { - Some("git") | Some("hg") => None, - _ => dist_ref, - }); - let Some(reference) = reference else { - return pretty_version.to_string(); - }; - if matches!(source_type, Some("git") | Some("hg")) && reference.len() == 40 { - format!("{} {}", pretty_version, &reference[..7]) - } else if matches!(source_type, Some("svn")) { - // svn references are revision numbers, never truncated - format!("{} {}", pretty_version, reference) - } else if reference.len() == 40 { - // dist-ref fallback (no git/hg source) — Composer truncates here too - format!("{} {}", pretty_version, &reference[..7]) - } else { - format!("{} {}", pretty_version, reference) - } -} - -/// Per-call configuration shared across executor methods. Owned by the -/// caller (typically `install_from_lock`) so the executor sees a consistent -/// view across an entire install/update run. -#[derive(Debug, Clone)] -pub struct ExecuteContext { - pub vendor_dir: PathBuf, - /// Suppress download progress bars. - pub no_progress: bool, - /// Prefer cloning from VCS source over downloading dist archives. - pub prefer_source: bool, -} - -/// Side-effect surface for install/update/uninstall operations. -/// -/// Implementations are stateful — `&mut self` lets a recorder accumulate -/// trace lines and lets the filesystem implementation hold long-lived -/// handles (caches, progress bars). All methods return `anyhow::Result` so -/// callers can short-circuit on the first failure, mirroring Composer's -/// fail-fast `InstallationManager::execute`. -#[async_trait::async_trait] -pub trait InstallerExecutor: Send + Sync { - /// Perform side effects for one install or update operation. - async fn install_package( - &mut self, - op: PackageOperation<'_>, - ctx: &ExecuteContext, - ) -> anyhow::Result<()>; - - /// Perform side effects for one uninstall. - /// - /// `version` is the previously-installed version (from installed.json), - /// passed so the trace recorder can format Composer's - /// `Uninstalling pkg/name (version)` line. The filesystem implementation - /// ignores it — `name` alone is enough to locate the vendor directory. - fn uninstall_package( - &mut self, - name: &str, - version: &str, - ctx: &ExecuteContext, - ) -> anyhow::Result<()>; - - /// Hook called once after every uninstall has run. Default no-op. - /// Composer cleans up empty namespace directories here; the recorder - /// has no work to do. - fn cleanup_after_uninstalls(&mut self, _ctx: &ExecuteContext) -> anyhow::Result<()> { - Ok(()) - } -} diff --git a/crates/mozart-registry/src/installer_executor/trace_recorder.rs b/crates/mozart-registry/src/installer_executor/trace_recorder.rs deleted file mode 100644 index b60a869..0000000 --- a/crates/mozart-registry/src/installer_executor/trace_recorder.rs +++ /dev/null @@ -1,160 +0,0 @@ -//! Recording-only [`InstallerExecutor`] for in-process tests. -//! -//! Mirrors `Composer\Test\Mock\InstallationManagerMock` — every call appends -//! a string to a `Vec` matching Composer's -//! `(string) $operation` output (after `strip_tags`). No filesystem or -//! network I/O happens. The recorded trace is what tests assert against -//! `--EXPECT--` in Composer's `.test` fixture format. -//! -//! Trace line shapes (byte-equivalent to Composer's `*Operation::__toString` -//! after `strip_tags`): -//! -//! - Install: `Installing ()` -//! - Update (upgrade direction): `Upgrading ( => )` -//! - Update (downgrade direction): `Downgrading ( => )` -//! - Uninstall: `Removing ()` - -use mozart_semver::Version; - -use super::{ - ExecuteContext, InstallerExecutor, PackageOperation, format_full_pretty_alias, - format_full_pretty_version, -}; - -/// Recording-only executor. Construct with [`TraceRecorderExecutor::new`], -/// then read [`TraceRecorderExecutor::trace`] after the run completes. -pub struct TraceRecorderExecutor { - trace: Vec, -} - -impl TraceRecorderExecutor { - pub fn new() -> Self { - Self { trace: Vec::new() } - } - - /// Recorded operation strings, in the order [`InstallerExecutor`] was - /// invoked. Pass this to `assert_eq!` against the fixture's `--EXPECT--` - /// section after splitting on newlines. - pub fn trace(&self) -> &[String] { - &self.trace - } - - /// Take ownership of the recorded trace. Use after the run if the - /// executor is going out of scope. - pub fn into_trace(self) -> Vec { - self.trace - } -} - -impl Default for TraceRecorderExecutor { - fn default() -> Self { - Self::new() - } -} - -#[async_trait::async_trait] -impl InstallerExecutor for TraceRecorderExecutor { - async fn install_package( - &mut self, - op: PackageOperation<'_>, - _ctx: &ExecuteContext, - ) -> anyhow::Result<()> { - match op { - PackageOperation::Install { package } => { - self.trace.push(format!( - "Installing {} ({})", - package.name, - format_full_pretty_version(package) - )); - } - PackageOperation::Update { - from_version, - from_full_pretty, - to_full_pretty, - package, - } => { - let action = if is_upgrade(from_version, &package.version) { - "Upgrading" - } else { - "Downgrading" - }; - self.trace.push(format!( - "{} {} ({} => {})", - action, package.name, from_full_pretty, to_full_pretty - )); - } - PackageOperation::MarkAliasInstalled { alias, target } => { - let alias_full = - format_full_pretty_alias(&alias.alias, &alias.alias_normalized, target); - let target_full = format_full_pretty_version(target); - self.trace.push(format!( - "Marking {} ({}) as installed, alias of {} ({})", - alias.package, alias_full, alias.package, target_full - )); - } - PackageOperation::MarkAliasUninstalled { - name, - alias_full, - target_full, - } => { - self.trace.push(format!( - "Marking {} ({}) as uninstalled, alias of {} ({})", - name, alias_full, name, target_full - )); - } - } - Ok(()) - } - - fn uninstall_package( - &mut self, - name: &str, - version: &str, - _ctx: &ExecuteContext, - ) -> anyhow::Result<()> { - self.trace.push(format!("Removing {} ({})", name, version)); - Ok(()) - } -} - -/// Mirrors `Composer\Package\Version\VersionParser::isUpgrade`. Returns true -/// when `to` should be treated as an upgrade from `from` for the purpose of -/// the trace verb (`Upgrading` vs `Downgrading`). -/// -/// The rules: -/// 1. Same string → upgrade. -/// 2. `dev-master` / `dev-trunk` / `dev-default` substitute to the -/// `9999999-dev` default-branch alias before further checks (they are -/// not literal dev-* names; they are the conventional "latest" branch). -/// 3. After that substitution, if either side starts with `dev-` (i.e. is -/// a dev branch other than the defaults) → upgrade. Composer treats -/// hopping between dev branches as a forward move regardless of order. -/// 4. Otherwise sort numerically and check the original `from` ended up -/// first (= the smaller value). -fn is_upgrade(from: &str, to: &str) -> bool { - if from == to { - return true; - } - let original_from = from; - let normalize_default = |s: &str| -> String { - if matches!(s, "dev-master" | "dev-trunk" | "dev-default") { - "9999999-dev".to_string() - } else { - s.to_string() - } - }; - let from_norm = normalize_default(from); - let to_norm = normalize_default(to); - if from_norm.starts_with("dev-") || to_norm.starts_with("dev-") { - return true; - } - match (Version::parse(&from_norm), Version::parse(&to_norm)) { - (Ok(a), Ok(b)) => b >= a, - _ => { - // Mirror Composer's fall-through: with two unparseable strings - // there is nothing to compare, treat the move as an upgrade. - let _ = original_from; - true - } - } -} diff --git a/crates/mozart-registry/src/installer_executor/transaction.rs b/crates/mozart-registry/src/installer_executor/transaction.rs deleted file mode 100644 index 95f9718..0000000 --- a/crates/mozart-registry/src/installer_executor/transaction.rs +++ /dev/null @@ -1,411 +0,0 @@ -//! Transaction computation — lock-vs-installed diff and alias reconciliation. -//! -//! Mirrors `Composer\DependencyResolver\Transaction::calculateOperations` and -//! `Composer\Installer\InstalledFilesystemRepository` (the `ArrayDumper` -//! path). Kept separate so both `install` and `update` commands can share the -//! same operation-computation machinery without going through the `install` -//! command module. - -use crate::installed::{InstalledPackageEntry, InstalledPackages}; -use crate::lockfile::{LockFile, LockedPackage}; -use indexmap::IndexSet; -use std::path::Path; - -/// The action to take for a package during install. -#[derive(Debug, PartialEq, Eq)] -pub enum Action { - Install, - Update, - Skip, -} - -/// Compute install operations by comparing locked packages against installed packages. -/// -/// Returns `(ops, removals)` where: -/// - `ops`: list of `(package, action)` ordered topologically — every package's -/// lock-internal `require` deps appear before it, matching Composer's -/// `Transaction::calculateOperations`. -/// - `removals`: list of package names that are installed but not locked. -pub fn compute_operations<'a>( - locked: &[&'a LockedPackage], - installed: &InstalledPackages, -) -> (Vec<(&'a LockedPackage, Action)>, Vec) { - let ordered = topological_sort(locked); - - let mut ops: Vec<(&'a LockedPackage, Action)> = Vec::new(); - for pkg in ordered { - let installed_entry = installed - .packages - .iter() - .find(|p| p.name.eq_ignore_ascii_case(&pkg.name)); - let action = match installed_entry { - None => Action::Install, - Some(entry) if entry.version != pkg.version => Action::Update, - Some(entry) if !installed_refs_match_locked(entry, pkg) => Action::Update, - Some(entry) if !installed_abandoned_matches_locked(entry, pkg) => Action::Update, - Some(_) => Action::Skip, - }; - ops.push((pkg, action)); - } - - // Compute removals: packages in installed but not in locked. Iterate - // installed.json in reverse, mirroring Composer's - // `Transaction::calculateOperations`, which seeds `removeMap` from - // `presentPackages` in order and then `array_unshift`s each entry onto - // `operations` — flipping the iteration order. - let locked_names: IndexSet = locked.iter().map(|p| p.name.to_lowercase()).collect(); - let removals: Vec = installed - .packages - .iter() - .rev() - .filter(|p| !locked_names.contains(&p.name.to_lowercase())) - .map(|p| p.name.clone()) - .collect(); - - (ops, removals) -} - -/// Order a slice of locked packages so every package's `require` deps that -/// are present in the same slice come before it. Mirrors -/// `Composer\DependencyResolver\Transaction::calculateOperations` — the -/// stack-based DFS over the result map. -fn topological_sort<'a>(packages: &[&'a LockedPackage]) -> Vec<&'a LockedPackage> { - use std::collections::BTreeMap; - - // Reverse-alphabetical sort, mirroring `setResultPackageMaps`. - let mut sorted: Vec<&'a LockedPackage> = packages.to_vec(); - sorted.sort_by_key(|p| std::cmp::Reverse(p.name.to_lowercase())); - - // Multimap: name → [packages]. A package contributes itself under its - // own name *and* under every `provide`/`replace` entry. - let mut resolves: BTreeMap> = BTreeMap::new(); - for pkg in &sorted { - let names = std::iter::once(pkg.name.to_lowercase()) - .chain(pkg.provide.keys().map(|s| s.to_lowercase())) - .chain(pkg.replace.keys().map(|s| s.to_lowercase())); - for n in names { - resolves.entry(n).or_default().push(*pkg); - } - } - - // Mirror Composer's `getRootPackages`: walk in sorted order, removing - // each package's required providers from the candidate-roots set. - let mut roots_set: IndexSet = sorted.iter().map(|p| p.name.to_lowercase()).collect(); - for pkg in &sorted { - let pkg_lower = pkg.name.to_lowercase(); - if !roots_set.contains(&pkg_lower) { - continue; - } - for dep in pkg.require.keys() { - let dep_lower = dep.to_lowercase(); - if let Some(matches) = resolves.get(&dep_lower) { - for &m in matches { - let m_lower = m.name.to_lowercase(); - if m_lower != pkg_lower { - roots_set.shift_remove(&m_lower); - } - } - } - } - } - - let mut stack: Vec<&'a LockedPackage> = sorted - .iter() - .filter(|p| roots_set.contains(&p.name.to_lowercase())) - .copied() - .collect(); - - let mut visited: IndexSet = IndexSet::new(); - let mut processed: IndexSet = IndexSet::new(); - let mut ordered: Vec<&'a LockedPackage> = Vec::with_capacity(packages.len()); - - while let Some(pkg) = stack.pop() { - let lower = pkg.name.to_lowercase(); - if processed.contains(&lower) { - continue; - } - if !visited.contains(&lower) { - visited.insert(lower); - stack.push(pkg); - for dep in pkg.require.keys() { - let dep_lower = dep.to_lowercase(); - if let Some(matches) = resolves.get(&dep_lower) { - for &m in matches { - stack.push(m); - } - } - } - } else { - processed.insert(lower); - ordered.push(pkg); - } - } - - // Cycle / disconnected fallback: append any leftover packages. - for pkg in packages { - let lower = pkg.name.to_lowercase(); - if !processed.contains(&lower) { - processed.insert(lower); - ordered.push(*pkg); - } - } - - ordered -} - -/// Pre-rendered MarkAliasUninstalled operation. Caller pre-computes the -/// display strings so the executor call site stays simple. -pub struct StaleInstalledAlias { - pub name: String, - pub alias_full: String, - pub target_full: String, -} - -/// `(package_name_lowercase, alias_pretty)` pairs the *new* lock's packages -/// will surface — used by `compute_stale_installed_aliases` to determine which -/// currently-installed alias packages no longer have a counterpart in the new -/// lock. Mirrors `Locker::getLockedRepository` running every locked package -/// through `ArrayLoader`. -fn lock_alias_pretty_pairs(lock: &LockFile) -> std::collections::HashSet<(String, String)> { - use std::collections::HashSet; - let mut set: HashSet<(String, String)> = HashSet::new(); - for a in &lock.aliases { - set.insert((a.package.to_lowercase(), a.alias.clone())); - } - for pkg in lock - .packages - .iter() - .chain(lock.packages_dev.iter().flatten()) - { - let mut emitted_explicit = false; - if let Some(map) = pkg - .extra_fields - .get("extra") - .and_then(|e| e.get("branch-alias")) - .and_then(|b| b.as_object()) - { - for (source, target) in map { - if !source.eq_ignore_ascii_case(&pkg.version) { - continue; - } - let Some(target_str) = target.as_str() else { - continue; - }; - if !target_str.to_lowercase().ends_with("-dev") { - continue; - } - set.insert((pkg.name.to_lowercase(), target_str.to_string())); - emitted_explicit = true; - } - } - if emitted_explicit { - continue; - } - let is_default_branch = pkg - .extra_fields - .get("default-branch") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - if !is_default_branch { - continue; - } - let version_lower = pkg.version.to_lowercase(); - let is_dev_branch = version_lower.starts_with("dev-") || version_lower.ends_with("-dev"); - if !is_dev_branch { - continue; - } - set.insert((pkg.name.to_lowercase(), "9999999-dev".to_string())); - } - set -} - -/// Walk every `installed.json` entry, expand its `extra.branch-alias` map, and -/// emit a [`StaleInstalledAlias`] for each whose alias version doesn't appear -/// in the new lock. Mirrors `Transaction::calculateOperations` -/// `MarkAliasUninstalledOperation` logic. -pub fn compute_stale_installed_aliases( - installed: &InstalledPackages, - lock: &LockFile, -) -> Vec { - use super::{ - format_full_pretty_version_for_installed, format_full_pretty_with_pretty_for_installed, - }; - - let preserved = lock_alias_pretty_pairs(lock); - let still_present = |name: &str, alias_pretty: &str| -> bool { - preserved.contains(&(name.to_lowercase(), alias_pretty.to_string())) - }; - let mut stale = Vec::new(); - for entry in &installed.packages { - let mut emitted_explicit = false; - if let Some(branch_alias) = entry - .extra_fields - .get("extra") - .and_then(|e| e.get("branch-alias")) - .and_then(|b| b.as_object()) - { - for (target_branch, alias_value) in branch_alias { - if entry.version != *target_branch { - continue; - } - let Some(alias_pretty) = alias_value.as_str() else { - continue; - }; - emitted_explicit = true; - if still_present(&entry.name, alias_pretty) { - continue; - } - stale.push(StaleInstalledAlias { - name: entry.name.clone(), - alias_full: format_full_pretty_with_pretty_for_installed(alias_pretty, entry), - target_full: format_full_pretty_version_for_installed(entry), - }); - } - } - - // Synthetic `9999999-dev` default-branch alias. - if emitted_explicit { - continue; - } - let is_default_branch = entry - .extra_fields - .get("default-branch") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - if !is_default_branch { - continue; - } - let version_lower = entry.version.to_lowercase(); - let is_dev_branch = version_lower.starts_with("dev-") || version_lower.ends_with("-dev"); - if !is_dev_branch { - continue; - } - const DEFAULT_BRANCH_ALIAS: &str = "9999999-dev"; - if still_present(&entry.name, DEFAULT_BRANCH_ALIAS) { - continue; - } - stale.push(StaleInstalledAlias { - name: entry.name.clone(), - alias_full: format_full_pretty_with_pretty_for_installed(DEFAULT_BRANCH_ALIAS, entry), - target_full: format_full_pretty_version_for_installed(entry), - }); - } - stale -} - -/// Collect the alias normalized-versions a previous install recorded for -/// `pkg_name`. Mirrors Composer's `presentAliasMap` seeding. -pub fn previously_installed_alias_versions( - installed: &InstalledPackages, - pkg_name: &str, -) -> Vec { - let mut out = Vec::new(); - for entry in &installed.packages { - if !entry.name.eq_ignore_ascii_case(pkg_name) { - continue; - } - let version_lower = entry.version.to_lowercase(); - let is_dev_branch = version_lower.starts_with("dev-") || version_lower.ends_with("-dev"); - if !is_dev_branch { - continue; - } - - let mut emitted_explicit_alias = false; - if let Some(branch_alias_map) = entry - .extra_fields - .get("extra") - .and_then(|e| e.get("branch-alias")) - .and_then(|b| b.as_object()) - { - for (source, target) in branch_alias_map { - if !source.eq_ignore_ascii_case(&entry.version) { - continue; - } - let Some(target_str) = target.as_str() else { - continue; - }; - if !target_str.to_lowercase().ends_with("-dev") { - continue; - } - if let Some(normalized) = crate::resolver::normalize_branch_alias_target(target_str) - { - out.push(normalized); - emitted_explicit_alias = true; - } - } - } - - if !emitted_explicit_alias - && entry - .extra_fields - .get("default-branch") - .and_then(|v| v.as_bool()) - .unwrap_or(false) - { - out.push("9999999.9999999.9999999.9999999-dev".to_string()); - } - } - out -} - -/// Convert a `LockedPackage` to an `InstalledPackageEntry`. -/// -/// Mirrors Composer's `InstalledFilesystemRepository::write()` via -/// `ArrayDumper` — `extra_fields` is forwarded verbatim so flags like -/// `abandoned` and `default-branch` survive the lock → installed.json round -/// trip. -pub fn locked_to_installed_entry(pkg: &LockedPackage, _vendor_dir: &Path) -> InstalledPackageEntry { - let install_path = format!("../{}", pkg.name); - InstalledPackageEntry { - name: pkg.name.clone(), - version: pkg.version.clone(), - version_normalized: pkg.version_normalized.clone(), - source: pkg - .source - .as_ref() - .map(|s| serde_json::to_value(s).unwrap_or_default()), - dist: pkg - .dist - .as_ref() - .map(|d| serde_json::to_value(d).unwrap_or_default()), - package_type: pkg.package_type.clone(), - install_path: Some(install_path), - autoload: pkg.autoload.clone(), - aliases: vec![], - homepage: pkg.homepage.clone(), - support: pkg.support.clone(), - extra_fields: pkg.extra_fields.clone(), - } -} - -fn installed_refs_match_locked(entry: &InstalledPackageEntry, locked: &LockedPackage) -> bool { - let installed_source_ref = entry - .source - .as_ref() - .and_then(|v| v.get("reference")) - .and_then(|v| v.as_str()); - let installed_dist_ref = entry - .dist - .as_ref() - .and_then(|v| v.get("reference")) - .and_then(|v| v.as_str()); - let locked_source_ref = locked.source.as_ref().and_then(|s| s.reference.as_deref()); - let locked_dist_ref = locked.dist.as_ref().and_then(|d| d.reference.as_deref()); - installed_source_ref == locked_source_ref && installed_dist_ref == locked_dist_ref -} - -fn abandoned_state(v: Option<&serde_json::Value>) -> (bool, Option<&str>) { - match v { - Some(serde_json::Value::Bool(b)) => (*b, None), - Some(serde_json::Value::String(s)) => (true, Some(s.as_str())), - _ => (false, None), - } -} - -fn installed_abandoned_matches_locked( - entry: &InstalledPackageEntry, - locked: &LockedPackage, -) -> bool { - abandoned_state(entry.extra_fields.get("abandoned")) - == abandoned_state(locked.extra_fields.get("abandoned")) -} diff --git a/crates/mozart-registry/src/lib.rs b/crates/mozart-registry/src/lib.rs deleted file mode 100644 index e35056c..0000000 --- a/crates/mozart-registry/src/lib.rs +++ /dev/null @@ -1,18 +0,0 @@ -pub mod advisory; -pub mod browse_repos; -pub mod cache; -pub mod composer_repo; -pub mod download_manager; -pub mod downloader; -pub mod inline_package; -pub mod installed; -pub mod installer_executor; -pub mod lockfile; -pub mod packagist; -pub mod path_repository; -pub mod repository; -pub mod repository_filter; -pub mod resolver; -pub mod vcs_bridge; -pub mod version; -pub mod version_selector; diff --git a/crates/mozart-registry/src/lockfile.rs b/crates/mozart-registry/src/lockfile.rs deleted file mode 100644 index fd6b5e3..0000000 --- a/crates/mozart-registry/src/lockfile.rs +++ /dev/null @@ -1,2037 +0,0 @@ -use crate::packagist::{PackagistDist, PackagistSource, PackagistVersion}; -use crate::repository::RepositorySet; -use crate::resolver::ResolvedPackage; -use indexmap::IndexMap; -use indexmap::IndexSet; -use mozart_core::installer::HasSuggests; -use mozart_core::package::{RawPackageData, to_json_pretty}; -use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, 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", default = "LockFile::default_readme")] - pub readme: Vec, - - /// Composer lock files written before content-hash existed (or fixtures - /// covering BC behavior) may omit this field; mirror Composer's BC support - /// in `Locker::isLocked()` by defaulting to empty. - #[serde(rename = "content-hash", default)] - pub content_hash: String, - - pub packages: Vec, - - #[serde(rename = "packages-dev")] - pub packages_dev: Option>, - - #[serde(default)] - pub aliases: Vec, - - #[serde(rename = "minimum-stability", default = "default_stability")] - pub minimum_stability: String, - - #[serde(rename = "stability-flags", default = "default_empty_object")] - pub stability_flags: serde_json::Value, - - #[serde(rename = "prefer-stable", default)] - pub prefer_stable: bool, - - #[serde(rename = "prefer-lowest", default)] - pub prefer_lowest: bool, - - #[serde(default = "default_empty_object")] - pub platform: serde_json::Value, - - #[serde(rename = "platform-dev", default = "default_empty_object")] - pub platform_dev: serde_json::Value, - - #[serde(rename = "plugin-api-version", skip_serializing_if = "Option::is_none")] - pub plugin_api_version: Option, -} - -/// A locked package entry in composer.lock. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LockedPackage { - pub name: String, - pub version: String, - - #[serde(rename = "version_normalized", skip_serializing_if = "Option::is_none")] - pub version_normalized: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub source: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub dist: Option, - - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub require: BTreeMap, - - #[serde( - rename = "require-dev", - default, - skip_serializing_if = "BTreeMap::is_empty" - )] - pub require_dev: BTreeMap, - - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub conflict: BTreeMap, - - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub provide: BTreeMap, - - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub replace: BTreeMap, - - #[serde(skip_serializing_if = "Option::is_none")] - pub suggest: Option>, - - #[serde(rename = "type", skip_serializing_if = "Option::is_none")] - pub package_type: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub autoload: Option, - - #[serde(rename = "autoload-dev", skip_serializing_if = "Option::is_none")] - pub autoload_dev: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub license: Option>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub homepage: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub keywords: Option>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub authors: Option>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub support: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub funding: Option>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub time: Option, - - /// Catch-all for extra fields we don't explicitly model - #[serde(flatten)] - pub extra_fields: BTreeMap, -} - -impl HasSuggests for LockedPackage { - fn pretty_name(&self) -> &str { - &self.name - } - - fn suggests(&self) -> Vec<(String, String)> { - self.suggest - .as_ref() - .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) - .unwrap_or_default() - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LockedSource { - #[serde(rename = "type")] - pub source_type: String, - pub url: String, - pub reference: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LockedDist { - #[serde(rename = "type")] - pub dist_type: String, - pub url: String, - pub reference: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub shasum: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LockAlias { - pub package: String, - pub version: String, - pub alias: String, - pub alias_normalized: String, -} - -impl LockFile { - /// Create default readme entries. - pub fn default_readme() -> Vec { - vec![ - "This file locks the dependencies of your project to a known state".to_string(), - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies".to_string(), - "This file is @generated automatically".to_string(), - ] - } - - /// Read a composer.lock file from disk. - pub fn read_from_file(path: &Path) -> anyhow::Result { - let content = fs::read_to_string(path)?; - let lock: LockFile = serde_json::from_str(&content)?; - Ok(lock) - } - - /// Write a composer.lock file to disk with deterministic formatting. - pub fn write_to_file(&self, path: &Path) -> anyhow::Result<()> { - let json = to_json_pretty(self)?; - fs::write(path, json)?; - Ok(()) - } - - /// Check if the lock file is fresh (content-hash matches composer.json). - pub fn is_fresh(&self, composer_json_content: &str) -> bool { - match Self::compute_content_hash(composer_json_content) { - Ok(hash) => hash == self.content_hash, - Err(_) => false, - } - } - - /// Compute the content hash from composer.json content. - /// Matches Composer's `Locker::getContentHash()`. - pub fn compute_content_hash(composer_json_content: &str) -> anyhow::Result { - let value: serde_json::Value = serde_json::from_str(composer_json_content)?; - let obj = value - .as_object() - .ok_or_else(|| anyhow::anyhow!("composer.json must be a JSON object"))?; - - // Keys that affect the content hash (Composer's relevantKeys) - let relevant_keys = [ - "name", - "version", - "require", - "require-dev", - "conflict", - "replace", - "provide", - "minimum-stability", - "prefer-stable", - "repositories", - "extra", - ]; - - // Collect relevant keys into a BTreeMap (auto-sorted by key) - let mut filtered: BTreeMap<&str, &serde_json::Value> = BTreeMap::new(); - for key in &relevant_keys { - if let Some(v) = obj.get(*key) { - filtered.insert(key, v); - } - } - - // Also include config.platform if present - if let Some(config) = obj.get("config") - && let Some(platform) = config.get("platform") - { - filtered.insert("config.platform", platform); - } - - // Encode to compact JSON - let compact = serde_json::to_string(&filtered)?; - - // Compute MD5 - let digest = md5::compute(compact.as_bytes()); - Ok(format!("{:x}", digest)) - } - - /// Check that every root `require` (and `require-dev` when `include_dev`) - /// is satisfied by the locked packages. Returns the list of bullet-prefixed - /// error lines (plus the trailing merge-conflict hint) if anything is - /// missing or mismatched, otherwise an empty vec. - /// - /// Mirrors `Composer\Package\Locker::getMissingRequirementInfo()`. - pub fn get_missing_requirement_info( - &self, - root: &mozart_core::package::RawPackageData, - include_dev: bool, - ) -> Vec { - let mut messages = Vec::new(); - let mut any_missing = false; - - let base_pool: Vec = self - .packages - .iter() - .map(|p| LockedSearchEntry::build(p, &self.aliases)) - .collect(); - let mut dev_pool: Vec = base_pool.clone(); - if let Some(dev) = &self.packages_dev { - dev_pool.extend( - dev.iter() - .map(|p| LockedSearchEntry::build(p, &self.aliases)), - ); - } - - check_requirement_set( - &root.require, - "Required", - &base_pool, - &mut messages, - &mut any_missing, - ); - if include_dev { - check_requirement_set( - &root.require_dev, - "Required (in require-dev)", - &dev_pool, - &mut messages, - &mut any_missing, - ); - } - - if any_missing { - messages.push( - "This usually happens when composer files are incorrectly merged or the composer.json file is manually edited.".to_string(), - ); - messages.push( - "Read more about correctly resolving merge conflicts https://getcomposer.org/doc/articles/resolving-merge-conflicts.md".to_string(), - ); - messages.push( - "and prefer using the \"require\" command over editing the composer.json file directly https://getcomposer.org/doc/03-cli.md#require-r".to_string(), - ); - } - - messages - } -} - -/// A locked package paired with the additional version strings the locked -/// repository would surface for it (branch-alias targets + matching root -/// aliases from `lock.aliases`). -/// -/// Mirrors the AliasPackage entries that `Composer\Package\Locker::getLockedRepository` -/// adds alongside each locked package, so requirement checks see the same -/// version surface Composer does. -#[derive(Clone)] -struct LockedSearchEntry<'a> { - package: &'a LockedPackage, - alias_versions: Vec, -} - -impl<'a> LockedSearchEntry<'a> { - fn build(package: &'a LockedPackage, root_aliases: &[LockAlias]) -> Self { - let mut alias_versions: Vec = locked_package_branch_aliases(package) - .into_iter() - .map(|a| a.alias_normalized) - .collect(); - for alias in root_aliases { - if alias.package.eq_ignore_ascii_case(&package.name) - && alias.version.eq_ignore_ascii_case(&package.version) - { - alias_versions.push(alias.alias_normalized.clone()); - } - } - Self { - package, - alias_versions, - } - } -} - -/// Build the synthetic `LockAlias` entries a `dev-*` locked package contributes -/// via `extra.branch-alias`. Mirrors `Composer\Package\Loader\ArrayLoader::getBranchAlias` -/// followed by `VersionParser::normalizeBranch` — the same expansion -/// `Locker::getLockedRepository` performs when constructing AliasPackages -/// alongside each locked package. -pub fn locked_package_branch_aliases(pkg: &LockedPackage) -> Vec { - let pkg_version_lower = pkg.version.to_lowercase(); - let is_dev_branch = - pkg_version_lower.starts_with("dev-") || pkg_version_lower.ends_with("-dev"); - if !is_dev_branch { - return Vec::new(); - } - let Some(extra) = pkg.extra_fields.get("extra") else { - return Vec::new(); - }; - let Some(branch_alias) = extra.get("branch-alias") else { - return Vec::new(); - }; - let Some(map) = branch_alias.as_object() else { - return Vec::new(); - }; - let mut out = Vec::new(); - for (source, target) in map.iter() { - if !source.eq_ignore_ascii_case(&pkg.version) { - continue; - } - let Some(target_str) = target.as_str() else { - continue; - }; - if !target_str.to_lowercase().ends_with("-dev") { - continue; - } - let Some(normalized) = crate::resolver::normalize_branch_alias_target(target_str) else { - continue; - }; - // Pretty-form trim: Composer's `Preg::replace('{(\.9{7})+}', '.x', ...)` - // turns the normalized form back into the wildcard form (e.g. - // `2.1.9999999.9999999-dev` → `2.1.x-dev`). For trace output we want - // the raw alias target string the package author wrote. - out.push(LockAlias { - package: pkg.name.clone(), - version: pkg.version.clone(), - alias: target_str.to_string(), - alias_normalized: normalized, - }); - } - out -} - -fn check_requirement_set( - requires: &BTreeMap, - description: &str, - pool: &[LockedSearchEntry], - messages: &mut Vec, - any_missing: &mut bool, -) { - for (name, constraint_str) in requires { - if mozart_core::platform::is_platform_package(name) { - continue; - } - if constraint_str.trim() == "self.version" { - continue; - } - - let constraint = mozart_semver::VersionConstraint::parse(constraint_str).ok(); - - let mut name_only_match: Option<&LockedPackage> = None; - let mut satisfied = false; - for entry in pool { - let pkg = entry.package; - if pkg.name != *name { - continue; - } - if name_only_match.is_none() { - name_only_match = Some(pkg); - } - let Some(ref c) = constraint else { continue }; - if let Ok(version) = mozart_semver::Version::parse(&pkg.version) - && c.matches(&version) - { - satisfied = true; - break; - } - if entry.alias_versions.iter().any(|alias| { - mozart_semver::Version::parse(alias) - .ok() - .is_some_and(|v| c.matches(&v)) - }) { - satisfied = true; - break; - } - } - - if satisfied { - continue; - } - - *any_missing = true; - if let Some(pkg) = name_only_match { - messages.push(format!( - "- {description} package \"{name}\" is in the lock file as \"{}\" but that does not satisfy your constraint \"{constraint_str}\".", - pkg.version - )); - } else { - messages.push(format!( - "- {description} package \"{name}\" is not present in the lock file." - )); - } - } -} - -/// Input for lock file generation. -pub struct LockFileGenerationRequest { - /// Resolved packages from the dependency resolver. - pub resolved_packages: Vec, - /// Raw composer.json content string (for content-hash computation). - pub composer_json_content: String, - /// Parsed composer.json data (for platform, minimum-stability, etc.). - pub composer_json: RawPackageData, - /// Whether require-dev was included in resolution. - pub include_dev: bool, - /// Repository set used to fetch full metadata for resolved packages - /// that aren't already covered by inline `type: package` repositories. - pub repositories: std::sync::Arc, - /// Previous `composer.lock` (when running update / require / remove). - /// For each resolved package whose name+normalized-version matches an - /// entry in this lock, the entry is copied into the new lock verbatim - /// rather than being re-fetched from the inline / composer-repo / - /// Packagist sources. Mirrors Composer's `Locker::setLockData` behaviour - /// during partial updates: lock entries are stable across updates that - /// don't touch the package, even if the upstream metadata has drifted. - pub previous_lock: Option, - /// Lowercase package names that were held back to their locked version - /// on a partial update — i.e. they were NOT in the CLI's allow list and - /// were re-pinned by `apply_partial_update`. For these names the lock - /// entry's metadata (source/dist references in particular) is canonical: - /// inline / composer-repo metadata may have drifted to a newer commit - /// that the partial update is explicitly choosing not to take. Mirrors - /// Composer's `PoolBuilder`, which keeps non-allow-listed packages at - /// the locked-repo entry rather than re-loading them from the inline / - /// VCS sources. - pub lock_pinned_names: indexmap::IndexSet, -} - -impl LockFileGenerationRequest { - /// Look up an inline `type: package` definition for `name` (if any). - /// Returns the matching `PackagistVersion` so callers can short-circuit - /// the Packagist fetch for resolved packages that came from a `type: - /// package` repository. - fn inline_lookup(&self, name: &str, version_normalized: &str) -> Option { - crate::inline_package::collect_inline_packages(&self.composer_json.repositories) - .into_iter() - .find(|ipkg| ipkg.name == name && ipkg.version.version_normalized == version_normalized) - .map(|ipkg| ipkg.version) - } - - /// Look up a `type: composer` repository entry for `name@version_normalized`. - /// Used to short-circuit the Packagist fetch when the resolved package came - /// from a local Composer repo (the test fixtures' file:// case). - fn composer_repo_lookup( - &self, - name: &str, - version_normalized: &str, - ) -> Option { - crate::composer_repo::collect_composer_packages(&self.composer_json.repositories) - .into_iter() - .find(|cpkg| cpkg.name == name && cpkg.version.version_normalized == version_normalized) - .map(|cpkg| cpkg.version) - } - - /// Reuse `previous_lock` as a metadata source when no repository can - /// answer for `(name, version_normalized)`. Mirrors the slice of - /// Composer's `PoolBuilder` flow that re-loads locked-only packages - /// straight off the lock: a partial update keeping a package at its - /// locked version doesn't need to re-fetch its metadata, and the - /// repositories may no longer carry that version (e.g. an inline - /// `type: package` repo only listing the new release). - fn previous_lock_lookup( - &self, - name: &str, - version_normalized: &str, - ) -> Option { - let prev = self.previous_lock.as_ref()?; - prev.packages - .iter() - .chain(prev.packages_dev.iter().flatten()) - .find(|p| { - p.name.eq_ignore_ascii_case(name) - && p.version_normalized - .as_deref() - .map(|v| v == version_normalized) - .unwrap_or_else(|| { - mozart_semver::Version::parse(&p.version) - .map(|v| v.to_string() == version_normalized) - .unwrap_or(false) - }) - }) - .map(locked_package_to_packagist_version) - } -} - -/// Synthesize a `PackagistVersion` from a `LockedPackage`. Used by -/// `previous_lock_lookup` so the metadata loop has a complete view even -/// when the surrounding repositories have moved on from a locked version. -fn locked_package_to_packagist_version(pkg: &LockedPackage) -> PackagistVersion { - PackagistVersion { - version: pkg.version.clone(), - version_normalized: pkg - .version_normalized - .clone() - .unwrap_or_else(|| pkg.version.clone()), - require: pkg.require.clone(), - replace: pkg.replace.clone(), - provide: pkg.provide.clone(), - conflict: pkg.conflict.clone(), - dist: pkg.dist.as_ref().map(|d| PackagistDist { - dist_type: d.dist_type.clone(), - url: d.url.clone(), - reference: d.reference.clone(), - shasum: d.shasum.clone(), - }), - source: pkg.source.as_ref().map(|s| PackagistSource { - source_type: s.source_type.clone(), - url: s.url.clone(), - reference: s.reference.clone(), - }), - require_dev: pkg.require_dev.clone(), - suggest: pkg.suggest.clone(), - package_type: pkg.package_type.clone(), - autoload: pkg.autoload.clone(), - autoload_dev: pkg.autoload_dev.clone(), - license: pkg.license.clone(), - description: pkg.description.clone(), - homepage: pkg.homepage.clone(), - keywords: pkg.keywords.clone(), - authors: pkg.authors.clone(), - support: None, - funding: None, - time: pkg.time.clone(), - extra: pkg.extra_fields.get("extra").cloned(), - notification_url: pkg - .extra_fields - .get("notification-url") - .and_then(|v| v.as_str()) - .map(String::from), - default_branch: pkg - .extra_fields - .get("default-branch") - .and_then(|v| v.as_bool()) - .unwrap_or(false), - abandoned: pkg.extra_fields.get("abandoned").cloned(), - } -} - -/// 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(), - } -} - -/// Mirror Composer's `RootPackageLoader::extractReferences`: scan -/// `require`/`require-dev` for `dev-foo#hex` style constraints, returning a -/// lowercase package name → reference map. Constraints whose stability isn't -/// `dev` after stripping the reference are left out (matching the -/// `'dev' === VersionParser::parseStability(...)` guard in PHP). -fn extract_root_references( - require: &BTreeMap, - require_dev: &BTreeMap, -) -> BTreeMap { - let mut out = BTreeMap::new(); - for (name, raw_constraint) in require.iter().chain(require_dev.iter()) { - if let Some(reference) = parse_inline_reference(raw_constraint) { - out.insert(name.to_lowercase(), reference); - } - } - out -} - -/// Pull the `#hex` suffix out of a single-atom dev constraint. Returns -/// `None` for non-`dev-*` / non-`*-dev` constraints, matching Composer's -/// `'{^[^,\s@]+?#([a-f0-9]+)$}'` + `parseStability == 'dev'` guard. -fn parse_inline_reference(constraint: &str) -> Option { - // Strip `... as alias` first, mirroring extractReferences's - // `'{^([^,\s@]+) as .+$}'` replacement. - let core = match constraint.split(" as ").next() { - Some(c) => c.trim(), - None => constraint.trim(), - }; - let (head, hash) = core.rsplit_once('#')?; - if hash.is_empty() || !hash.chars().all(|c| c.is_ascii_hexdigit()) { - return None; - } - if head.contains([' ', '\t', ',', '@']) { - return None; - } - let lower = head.to_lowercase(); - if !(lower.starts_with("dev-") || lower.ends_with("-dev")) { - return None; - } - Some(hash.to_string()) -} - -/// Mirror `Composer\Package\Package::setSourceDistReferences`: rewrite both -/// source and dist references to the supplied value, and rewrite the -/// reference inside any auto-generated GitHub/GitLab/Bitbucket dist URL when -/// present. The dist reference is only written if there was already one -/// (Composer leaves `dist.reference == null` packages alone). -fn apply_reference_override(pkg: &mut LockedPackage, reference: &str) { - if let Some(source) = pkg.source.as_mut() { - source.reference = Some(reference.to_string()); - } - if let Some(dist) = pkg.dist.as_mut() { - let url_carries_known_host = matches_dist_url_with_known_host(Some(&dist.url)); - if dist.reference.is_some() || url_carries_known_host { - dist.reference = Some(reference.to_string()); - } - if url_carries_known_host { - dist.url = rewrite_known_dist_url_reference(&dist.url, reference); - } - } -} - -/// Match the bitbucket / github / gitlab dist-URL prefixes Composer -/// rewrites. Mirrors the regex -/// `{^https?://(?:(?:www\.)?bitbucket\.org|(api\.)?github\.com|(?:www\.)?gitlab\.com)/}i`. -fn matches_dist_url_with_known_host(url: Option<&str>) -> bool { - let Some(url) = url else { return false }; - let lower = url.to_lowercase(); - let stripped = lower - .strip_prefix("http://") - .or_else(|| lower.strip_prefix("https://")) - .unwrap_or(&lower); - let stripped = stripped.strip_prefix("www.").unwrap_or(stripped); - let stripped = stripped.strip_prefix("api.").unwrap_or(stripped); - stripped.starts_with("bitbucket.org/") - || stripped.starts_with("github.com/") - || stripped.starts_with("gitlab.com/") -} - -/// Substitute any 40-char hex segment surrounded by `/` or `sha=` (the -/// archive shape produced by GitHub/GitLab/Bitbucket) with the new -/// reference. Matches Composer's -/// `'{(?<=/|sha=)[a-f0-9]{40}(?=/|$)}i'` rewrite. -fn rewrite_known_dist_url_reference(url: &str, reference: &str) -> String { - let bytes = url.as_bytes(); - let mut out = String::with_capacity(url.len()); - let mut i = 0; - while i < bytes.len() { - let start = i; - let preceded_by_slash = i > 0 && bytes[i - 1] == b'/'; - let preceded_by_sha = i >= 4 && &bytes[i - 4..i] == b"sha="; - if (preceded_by_slash || preceded_by_sha) && i + 40 <= bytes.len() { - let candidate = &url[i..i + 40]; - if candidate.chars().all(|c| c.is_ascii_hexdigit()) { - let after = bytes.get(i + 40).copied(); - if after == Some(b'/') || after.is_none() { - out.push_str(reference); - i += 40; - continue; - } - } - } - out.push(url[start..].chars().next().unwrap()); - i += url[start..].chars().next().unwrap().len_utf8(); - } - out -} - -/// Convert a `PackagistVersion` to a `LockedPackage`. -fn packagist_version_to_locked_package(name: &str, pv: &PackagistVersion) -> LockedPackage { - let mut extra_fields: BTreeMap = BTreeMap::new(); - - if let Some(extra) = &pv.extra { - extra_fields.insert("extra".to_string(), extra.clone()); - } - if let Some(notification_url) = &pv.notification_url { - extra_fields.insert( - "notification-url".to_string(), - serde_json::Value::String(notification_url.clone()), - ); - } - // Propagate `abandoned` so the lock (and downstream installed.json - // round-trip) preserves the package's deprecation state. Mirrors - // Composer's `ArrayDumper::dump`, which emits the field when truthy - // (`true` for "abandoned, no replacement", a string for "abandoned, - // use this instead"). `false`/null collapse to "not abandoned" and - // are dropped. - if let Some(abandoned) = &pv.abandoned { - let keep = match abandoned { - serde_json::Value::Bool(b) => *b, - serde_json::Value::String(s) => !s.is_empty(), - serde_json::Value::Null => false, - _ => true, - }; - if keep { - extra_fields.insert("abandoned".to_string(), abandoned.clone()); - } - } - // Propagate `default-branch: true` so the lock surface — and the - // installed.json round-trip — keeps the marker that drives Composer's - // synthetic `9999999-dev` alias for default-branch dev packages. - // Without this, `Locker::getLockedRepository` (which Mozart mirrors via - // `collect_stale_installed_aliases` / `lock_alias_pretty_pairs`) can't - // tell that the package's default branch is still aliased and emits a - // spurious `MarkAliasUninstalled` for the missing `9999999-dev` alias. - if pv.default_branch { - extra_fields.insert("default-branch".to_string(), serde_json::Value::Bool(true)); - } - - 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(), - provide: pv.provide.clone(), - replace: pv.replace.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). -/// -/// `requires_by_name` and `providers_by_name` are keyed by lowercase package -/// names. `providers_by_name` maps a satisfied name (own name + each `provide` -/// or `replace` target) to the list of resolved package names that satisfy it, -/// so a non-dev `require` like `provided/pkg` reaches `b/b` when `b/b` -/// declares `provide: { provided/pkg: 1.0.0 }`. -fn classify_dev_packages( - resolved: &[ResolvedPackage], - require: &BTreeMap, - _require_dev: &BTreeMap, - requires_by_name: &IndexMap>, - providers_by_name: &IndexMap>, -) -> IndexSet { - // BFS from non-dev root dependencies through each package's `require` map. - // All reachable packages are production packages. - let mut production: IndexSet = IndexSet::new(); - let mut queue: VecDeque = VecDeque::new(); - - let visit = |name: &str, production: &mut IndexSet, queue: &mut VecDeque| { - let name_lower = name.to_lowercase(); - if is_platform_name(&name_lower) { - return; - } - // A required name is satisfied either by a resolved package whose own - // name matches (the common case, captured here as `providers_by_name` - // also indexes own names) or by a resolved package that provides / - // replaces it. Mirrors Composer's `extractDevPackages` second-solve - // semantics, which walks the same provide/replace edges through a - // real Solver call. - if let Some(provs) = providers_by_name.get(&name_lower) { - for prov in provs { - let prov_lower = prov.to_lowercase(); - if production.insert(prov_lower.clone()) { - queue.push_back(prov_lower); - } - } - } - }; - - for name in require.keys() { - visit(name, &mut production, &mut queue); - } - - while let Some(pkg_name) = queue.pop_front() { - if let Some(deps) = requires_by_name.get(&pkg_name) { - for dep_name in deps.clone() { - visit(&dep_name, &mut production, &mut queue); - } - } - } - - // Any resolved package not in `production` is dev-only - resolved - .iter() - .filter(|p| !production.contains(&p.name.to_lowercase())) - .map(|p| p.name.clone()) - .collect() -} - -/// Returns true if the package name is a platform package (php, ext-*, lib-*, etc.). -fn is_platform_name(name: &str) -> bool { - name == "php" - || name.starts_with("ext-") - || name.starts_with("lib-") - || name == "php-64bit" - || name == "php-ipv6" - || name == "php-zts" - || name == "php-debug" -} - -/// Extract platform requirements from a requirements map. -/// -/// Filters the map to include only platform package keys (`php`, `ext-*`, `lib-*`, etc.) -/// and returns them as a JSON object. -fn extract_platform_requirements(requirements: &BTreeMap) -> serde_json::Value { - let map: serde_json::Map = requirements - .iter() - .filter(|(k, _)| is_platform_name(k)) - .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone()))) - .collect(); - serde_json::Value::Object(map) -} - -/// Generate a complete `LockFile` from resolution results. -/// -/// This function: -/// 1. Fetches full metadata from Packagist for each resolved package -/// 2. Separates packages into production vs dev-only -/// 3. Computes the content-hash -/// 4. Assembles the complete `LockFile` struct -pub async fn generate_lock_file(request: &LockFileGenerationRequest) -> anyhow::Result { - // Split the resolved set into real packages and alias entries up front. - // Aliases get emitted as a separate `aliases[]` block and never enter the - // metadata fetch loop — their target package carries the real metadata. - let (real_resolved, alias_resolved): (Vec<&ResolvedPackage>, Vec<&ResolvedPackage>) = request - .resolved_packages - .iter() - .partition(|p| p.alias_of_normalized.is_none()); - - // 1. Fetch full metadata for real (non-alias) packages. - // - // Inline `type: package` repositories carry full metadata in composer.json - // — short-circuit those before hitting the network. Everything else goes - // through `RepositorySet`, which today contains only Packagist; future - // steps will move VCS / inline through the same set. - // Previous-lock relationship pass-through: when a resolved package - // matches an entry in `previous_lock` at the same name + - // version_normalized, capture the entry's relationship-shaped fields - // (require / require-dev / conflict / replace / provide / suggest). - // Composer's transaction calculates operation order using these - // relationship fields off the locked repository, so a partial update - // shouldn't refresh them from upstream metadata for packages that - // didn't move — otherwise topological_sort sees a different graph - // than Composer would. - // - // Source/dist references and version-shaped fields still come from - // the freshly-fetched metadata, so dev packages whose ref bumped (the - // resolver picked a new commit at the same version label) still get - // their ref refreshed. - struct PreservedRelationships { - require: BTreeMap, - require_dev: BTreeMap, - conflict: BTreeMap, - provide: BTreeMap, - replace: BTreeMap, - suggest: Option>, - } - let mut preserved_rel: IndexMap = IndexMap::new(); - if let Some(prev) = &request.previous_lock { - for prev_pkg in prev - .packages - .iter() - .chain(prev.packages_dev.iter().flatten()) - { - let prev_normalized = prev_pkg.version_normalized.clone().unwrap_or_else(|| { - mozart_semver::Version::parse(&prev_pkg.version) - .map(|v| v.to_string()) - .unwrap_or_else(|_| prev_pkg.version.clone()) - }); - for pkg in &real_resolved { - if pkg.name.eq_ignore_ascii_case(&prev_pkg.name) - && pkg.version_normalized == prev_normalized - { - preserved_rel.insert( - pkg.name.clone(), - PreservedRelationships { - require: prev_pkg.require.clone(), - require_dev: prev_pkg.require_dev.clone(), - conflict: prev_pkg.conflict.clone(), - provide: prev_pkg.provide.clone(), - replace: prev_pkg.replace.clone(), - suggest: prev_pkg.suggest.clone(), - }, - ); - } - } - } - } - - let mut package_metadata: IndexMap = IndexMap::new(); - let repo_set = &request.repositories; - for pkg in &real_resolved { - // For packages held back to the locked version on a partial update, - // the lock entry is the canonical metadata source. Inline / composer- - // repo / VCS sources may have moved to a newer commit that this - // partial update is explicitly choosing NOT to take, so consulting - // them first would silently bump the source/dist reference. Mirrors - // Composer's `PoolBuilder` behaviour: non-allow-listed packages keep - // the locked-repo entry rather than re-loading from upstream. - let pinned = request.lock_pinned_names.contains(&pkg.name.to_lowercase()); - if pinned - && let Some(prev) = request.previous_lock_lookup(&pkg.name, &pkg.version_normalized) - { - package_metadata.insert(pkg.name.clone(), prev); - continue; - } - - if let Some(inline) = request.inline_lookup(&pkg.name, &pkg.version_normalized) { - package_metadata.insert(pkg.name.clone(), inline); - continue; - } - - if let Some(cv) = request.composer_repo_lookup(&pkg.name, &pkg.version_normalized) { - package_metadata.insert(pkg.name.clone(), cv); - continue; - } - - if let Some(prev) = request.previous_lock_lookup(&pkg.name, &pkg.version_normalized) { - package_metadata.insert(pkg.name.clone(), prev); - continue; - } - - let queries = [crate::repository::PackageQuery { - name: pkg.name.as_str(), - constraint: None, - }]; - let results = repo_set.load_packages(&queries).await?; - let matching = results - .into_iter() - .find(|r| r.version.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.version); - } - - // 2. Classify dev vs non-dev packages (real packages only). - let real_owned: Vec = real_resolved - .iter() - .map(|p| ResolvedPackage { - name: p.name.clone(), - version: p.version.clone(), - version_normalized: p.version_normalized.clone(), - is_dev: p.is_dev, - alias_of_normalized: None, - }) - .collect(); - // Build the `name → require keys` view classify_dev_packages walks. Use - // preserved-from-old-lock requires when available so a partial update - // sees the same dev-classification graph the previous lock did. - let mut requires_by_name: IndexMap> = IndexMap::new(); - // Inverse map: `satisfied name → list of resolved packages that satisfy it`. - // A resolved package satisfies its own name plus each `provide` / `replace` - // target (Composer's `extractDevPackages` reaches the same edges through - // its second Solver run; we walk them directly during the dev BFS). - let mut providers_by_name: IndexMap> = IndexMap::new(); - for (name, pv) in &package_metadata { - let name_lower = name.to_lowercase(); - let (require_keys, provide_keys, replace_keys): (Vec, Vec, Vec) = - if let Some(rel) = preserved_rel.get(name) { - ( - rel.require.keys().cloned().collect(), - rel.provide.keys().cloned().collect(), - rel.replace.keys().cloned().collect(), - ) - } else { - ( - pv.require.keys().cloned().collect(), - pv.provide.keys().cloned().collect(), - pv.replace.keys().cloned().collect(), - ) - }; - requires_by_name.insert(name_lower.clone(), require_keys); - providers_by_name - .entry(name_lower.clone()) - .or_default() - .push(name_lower.clone()); - for target in provide_keys.iter().chain(replace_keys.iter()) { - providers_by_name - .entry(target.to_lowercase()) - .or_default() - .push(name_lower.clone()); - } - } - let dev_only = classify_dev_packages( - &real_owned, - &request.composer_json.require, - &request.composer_json.require_dev, - &requires_by_name, - &providers_by_name, - ); - - // 3. Build LockedPackage lists. - // - // Apply root-level `#hex` reference overrides extracted from - // `require`/`require-dev`. Mirrors Composer's - // `RootPackageLoader::extractReferences` + `PoolBuilder::loadPackage`'s - // `setSourceDistReferences` call: when the user pinned a dev package via - // `dev-main#abcd123`, the resolved package's source/dist must show that - // reference in the lock + trace, not whatever the inline metadata said. - let root_references = extract_root_references( - &request.composer_json.require, - &request.composer_json.require_dev, - ); - let mut packages: Vec = Vec::new(); - let mut packages_dev: Vec = Vec::new(); - for pkg in &real_resolved { - let pv = &package_metadata[&pkg.name]; - let mut locked = packagist_version_to_locked_package(&pkg.name, pv); - // Overlay relationship fields from the previous lock when applicable - // — the resolver's transaction-time view came from the lock, so the - // new lock should mirror those relationships even if the upstream - // metadata has drifted. - if let Some(rel) = preserved_rel.get(&pkg.name) { - locked.require = rel.require.clone(); - locked.require_dev = rel.require_dev.clone(); - locked.conflict = rel.conflict.clone(); - locked.provide = rel.provide.clone(); - locked.replace = rel.replace.clone(); - locked.suggest = rel.suggest.clone(); - } - if let Some(reference) = root_references.get(&pkg.name.to_lowercase()) { - apply_reference_override(&mut locked, reference); - } - 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. Build the aliases[] block. Each alias entry references the target - // package (`package` + `version`) and carries the alias's pretty/normalized - // form (`alias` + `alias_normalized`). Mirrors Composer's - // `Locker::lockPackages` alias dump. - let mut alias_blocks: Vec = Vec::new(); - for alias in &alias_resolved { - let target_normalized = match &alias.alias_of_normalized { - Some(t) => t.clone(), - None => continue, - }; - let target_pretty = real_resolved - .iter() - .find(|p| p.name == alias.name && p.version_normalized == target_normalized) - .map(|p| p.version.clone()) - .unwrap_or_else(|| target_normalized.clone()); - alias_blocks.push(LockAlias { - package: alias.name.clone(), - version: target_pretty, - alias: alias.version.clone(), - alias_normalized: alias.version_normalized.clone(), - }); - } - alias_blocks.sort_by(|a, b| a.package.cmp(&b.package).then(a.alias.cmp(&b.alias))); - - // 6. Compute content-hash - let content_hash = LockFile::compute_content_hash(&request.composer_json_content)?; - - // 7. Extract platform requirements - let platform = extract_platform_requirements(&request.composer_json.require); - let platform_dev = extract_platform_requirements(&request.composer_json.require_dev); - - // 8. 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); - - // 9. Assemble LockFile - Ok(LockFile { - readme: LockFile::default_readme(), - content_hash, - packages, - packages_dev: if request.include_dev { - Some(packages_dev) - } else { - Some(vec![]) - }, - aliases: alias_blocks, - 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(), - provide: BTreeMap::new(), - replace: 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")); - } - - #[test] - fn parses_lock_without_content_hash() { - // Composer fixtures (and historical lock files) may omit content-hash; - // mirror Composer's BC handling by accepting it and treating the lock - // as not-fresh against any composer.json. - let raw = r#"{ - "packages": [], - "packages-dev": [], - "aliases": [], - "minimum-stability": "dev", - "stability-flags": {}, - "prefer-stable": false, - "prefer-lowest": false - }"#; - let lock: LockFile = serde_json::from_str(raw).unwrap(); - assert_eq!(lock.content_hash, ""); - assert!(!lock.is_fresh(r#"{"require": {}}"#)); - } - - fn make_packagist_version( - version: &str, - version_normalized: &str, - require: BTreeMap, - ) -> PackagistVersion { - PackagistVersion { - version: version.to_string(), - version_normalized: version_normalized.to_string(), - require, - replace: BTreeMap::new(), - provide: BTreeMap::new(), - conflict: BTreeMap::new(), - dist: Some(crate::packagist::PackagistDist { - dist_type: "zip".to_string(), - url: format!("https://example.com/{version}.zip"), - reference: Some("deadbeef".to_string()), - shasum: Some("abc123".to_string()), - }), - source: Some(crate::packagist::PackagistSource { - source_type: "git".to_string(), - url: "https://github.com/example/pkg.git".to_string(), - reference: Some("deadbeef".to_string()), - }), - require_dev: BTreeMap::new(), - suggest: None, - package_type: Some("library".to_string()), - autoload: Some(serde_json::json!({"psr-4": {"Example\\": "src/"}})), - autoload_dev: None, - license: Some(vec!["MIT".to_string()]), - description: Some("An example package".to_string()), - homepage: Some("https://example.com".to_string()), - keywords: Some(vec!["example".to_string(), "test".to_string()]), - authors: Some(vec![ - serde_json::json!({"name": "Alice", "email": "alice@example.com"}), - ]), - support: Some(serde_json::json!({"issues": "https://github.com/example/pkg/issues"})), - funding: Some(vec![ - serde_json::json!({"type": "github", "url": "https://github.com/sponsors/alice"}), - ]), - time: Some("2024-01-15T10:00:00+00:00".to_string()), - extra: Some(serde_json::json!({"branch-alias": {"dev-main": "1.0.x-dev"}})), - notification_url: Some("https://packagist.org/downloads/".to_string()), - default_branch: false, - abandoned: None, - } - } - - #[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_deref(), - 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, - default_branch: false, - abandoned: 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, - alias_of_normalized: None, - }, - ResolvedPackage { - name: "vendor/b".to_string(), - version: "1.0.0".to_string(), - version_normalized: "1.0.0.0".to_string(), - is_dev: false, - alias_of_normalized: None, - }, - ResolvedPackage { - name: "vendor/c".to_string(), - version: "1.0.0".to_string(), - version_normalized: "1.0.0.0".to_string(), - is_dev: false, - alias_of_normalized: None, - }, - ResolvedPackage { - name: "vendor/d".to_string(), - version: "1.0.0".to_string(), - version_normalized: "1.0.0.0".to_string(), - is_dev: false, - alias_of_normalized: None, - }, - ]; - - 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: IndexMap = IndexMap::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 requires_by_name: IndexMap> = metadata - .iter() - .map(|(name, pv)| (name.to_lowercase(), pv.require.keys().cloned().collect())) - .collect(); - let providers_by_name: IndexMap> = metadata - .keys() - .map(|name| { - let lower = name.to_lowercase(); - (lower.clone(), vec![lower]) - }) - .collect(); - let dev_only = classify_dev_packages( - &resolved, - &require, - &require_dev, - &requires_by_name, - &providers_by_name, - ); - - 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, - alias_of_normalized: None, - }, - ResolvedPackage { - name: "vendor/b".to_string(), - version: "1.0.0".to_string(), - version_normalized: "1.0.0.0".to_string(), - is_dev: false, - alias_of_normalized: None, - }, - ResolvedPackage { - name: "vendor/c".to_string(), - version: "1.0.0".to_string(), - version_normalized: "1.0.0.0".to_string(), - is_dev: false, - alias_of_normalized: None, - }, - ]; - - 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: IndexMap = IndexMap::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 requires_by_name: IndexMap> = metadata - .iter() - .map(|(name, pv)| (name.to_lowercase(), pv.require.keys().cloned().collect())) - .collect(); - let providers_by_name: IndexMap> = metadata - .keys() - .map(|name| { - let lower = name.to_lowercase(); - (lower.clone(), vec![lower]) - }) - .collect(); - let dev_only = classify_dev_packages( - &resolved, - &require, - &require_dev, - &requires_by_name, - &providers_by_name, - ); - - 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!({})); - } - - #[tokio::test] - async 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, - repositories: std::sync::Arc::new(RepositorySet::with_packagist( - crate::cache::Cache::new(std::env::temp_dir().join("mozart-test-cache"), false), - )), - previous_lock: None, - lock_pinned_names: IndexSet::new(), - }; - - let lock = generate_lock_file(&request).await.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 = [ - 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(), - provide: BTreeMap::new(), - replace: 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(), - provide: BTreeMap::new(), - replace: 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"); - } - - #[tokio::test] - #[ignore] - async fn test_generate_lock_file_monolog() { - use crate::cache::Cache; - use crate::resolver::PlatformConfig; - use crate::resolver::{ResolveRequest, resolve}; - use mozart_core::package::Stability; - use std::sync::Arc; - - // Resolve monolog/monolog ^3.0 - let resolve_request = ResolveRequest { - root_name: String::new(), - root_version: None, - require: vec![("monolog/monolog".to_string(), "^3.0".to_string())], - require_dev: vec![], - include_dev: false, - minimum_stability: Stability::Stable, - stability_flags: IndexMap::new(), - prefer_stable: true, - prefer_lowest: false, - platform: PlatformConfig::new(), - ignore_platform_reqs: false, - ignore_platform_req_list: vec![], - repositories: Arc::new(RepositorySet::with_packagist(Cache::new( - std::env::temp_dir().join("mozart-test-cache"), - false, - ))), - temporary_constraints: IndexMap::new(), - raw_repositories: vec![], - root_provide: IndexMap::new(), - root_replace: IndexMap::new(), - root_conflict: IndexMap::new(), - locked_package_names: IndexSet::new(), - locked_packages: Vec::new(), - block_abandoned: false, - root_branch_alias: None, - preferred_versions: IndexMap::new(), - block_insecure: false, - }; - - let resolved = resolve(&resolve_request) - .await - .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, - repositories: Arc::new(RepositorySet::with_packagist(Cache::new( - std::env::temp_dir().join("mozart-test-cache"), - false, - ))), - previous_lock: None, - lock_pinned_names: IndexSet::new(), - }; - - let lock = generate_lock_file(&gen_request) - .await - .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); - } - } - - fn make_locked(name: &str, version: &str) -> LockedPackage { - LockedPackage { - name: name.to_string(), - version: version.to_string(), - version_normalized: None, - source: None, - dist: None, - require: BTreeMap::new(), - require_dev: BTreeMap::new(), - conflict: BTreeMap::new(), - provide: BTreeMap::new(), - replace: BTreeMap::new(), - suggest: None, - package_type: Some("library".to_string()), - 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(), - } - } - - fn lock_with(packages: Vec, dev: Vec) -> LockFile { - LockFile { - readme: LockFile::default_readme(), - content_hash: "x".to_string(), - packages, - packages_dev: Some(dev), - 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()), - } - } - - fn root_with_require( - require: &[(&str, &str)], - require_dev: &[(&str, &str)], - ) -> mozart_core::package::RawPackageData { - let mut root = mozart_core::package::RawPackageData::new("__root__".to_string()); - for (k, v) in require { - root.require.insert((*k).to_string(), (*v).to_string()); - } - for (k, v) in require_dev { - root.require_dev.insert((*k).to_string(), (*v).to_string()); - } - root - } - - #[test] - fn missing_requirement_info_empty_when_satisfied() { - let lock = lock_with(vec![make_locked("a/a", "1.0.0")], vec![]); - let root = root_with_require(&[("a/a", "^1.0")], &[]); - assert!(lock.get_missing_requirement_info(&root, true).is_empty()); - } - - #[test] - fn missing_requirement_info_reports_missing_package() { - let lock = lock_with(vec![], vec![]); - let root = root_with_require(&[("a/a", "^1.0")], &[]); - let info = lock.get_missing_requirement_info(&root, true); - assert_eq!( - info[0], - "- Required package \"a/a\" is not present in the lock file." - ); - assert!(info.iter().any(|m| m.contains("merge conflicts"))); - } - - #[test] - fn missing_requirement_info_reports_unsatisfied_constraint() { - let lock = lock_with(vec![make_locked("some/dep", "dev-foo")], vec![]); - let root = root_with_require(&[("some/dep", "dev-main")], &[]); - let info = lock.get_missing_requirement_info(&root, true); - assert_eq!( - info[0], - "- Required package \"some/dep\" is in the lock file as \"dev-foo\" but that does not satisfy your constraint \"dev-main\"." - ); - } - - #[test] - fn missing_requirement_info_skips_platform_packages() { - let lock = lock_with(vec![], vec![]); - let root = root_with_require(&[("php", "^8.0"), ("ext-json", "*")], &[]); - assert!(lock.get_missing_requirement_info(&root, true).is_empty()); - } - - #[test] - fn missing_requirement_info_skips_self_version() { - let lock = lock_with(vec![], vec![]); - let root = root_with_require(&[("a/a", "self.version")], &[]); - assert!(lock.get_missing_requirement_info(&root, true).is_empty()); - } - - #[test] - fn missing_requirement_info_dev_pool_includes_packages_dev() { - // require-dev "a/a" should be satisfied by an entry in packages-dev. - let lock = lock_with(vec![], vec![make_locked("a/a", "1.0.0")]); - let root = root_with_require(&[], &[("a/a", "^1.0")]); - assert!(lock.get_missing_requirement_info(&root, true).is_empty()); - } - - #[test] - fn missing_requirement_info_skips_dev_when_include_dev_false() { - // require-dev errors must NOT appear when include_dev is false (no_dev). - let lock = lock_with(vec![], vec![]); - let root = root_with_require(&[], &[("a/a", "^1.0")]); - assert!(lock.get_missing_requirement_info(&root, false).is_empty()); - } - - #[test] - fn missing_requirement_info_require_pool_excludes_packages_dev() { - // A regular require should NOT be satisfied by an entry that lives only - // in packages-dev. - let lock = lock_with(vec![], vec![make_locked("a/a", "1.0.0")]); - let root = root_with_require(&[("a/a", "^1.0")], &[]); - let info = lock.get_missing_requirement_info(&root, true); - assert_eq!( - info[0], - "- Required package \"a/a\" is not present in the lock file." - ); - } - - #[test] - fn missing_requirement_info_reports_multiple_problems() { - let lock = lock_with(vec![make_locked("some/dep", "dev-foo")], vec![]); - let root = root_with_require(&[("some/dep", "dev-main"), ("some/dep2", "dev-main")], &[]); - let info = lock.get_missing_requirement_info(&root, true); - assert!( - info.iter() - .any(|m| m.contains("some/dep") && m.contains("dev-foo") && m.contains("dev-main")) - ); - assert!( - info.iter() - .any(|m| m == "- Required package \"some/dep2\" is not present in the lock file.") - ); - } - - #[test] - fn missing_requirement_info_uses_dev_description_label() { - let lock = lock_with(vec![], vec![]); - let root = root_with_require(&[], &[("a/a", "^1.0")]); - let info = lock.get_missing_requirement_info(&root, true); - assert!(info[0].contains("Required (in require-dev) package \"a/a\"")); - } -} diff --git a/crates/mozart-registry/src/packagist.rs b/crates/mozart-registry/src/packagist.rs deleted file mode 100644 index 5c99b07..0000000 --- a/crates/mozart-registry/src/packagist.rs +++ /dev/null @@ -1,1011 +0,0 @@ -use crate::cache::Cache; -use serde::de::Deserializer; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; - -/// Deserialize a field that may contain the Packagist minifier sentinel `"__unset"`. -/// -/// Packagist's metadata minifier (see `composer/metadata-minifier`) encodes -/// deleted fields as the literal string `"__unset"` in version diffs. When we -/// encounter this sentinel we treat the field as absent (`None` / default). -fn deserialize_unset_as_none<'de, D, T>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, - T: serde::de::DeserializeOwned, -{ - let value = serde_json::Value::deserialize(deserializer)?; - if value.as_str() == Some("__unset") { - return Ok(None); - } - serde_json::from_value(value).map_err(serde::de::Error::custom) -} - -/// Like [`deserialize_unset_as_none`] but returns a default `T` instead of `Option`. -fn deserialize_unset_as_default<'de, D, T>(deserializer: D) -> Result -where - D: Deserializer<'de>, - T: serde::de::DeserializeOwned + Default, -{ - let value = serde_json::Value::deserialize(deserializer)?; - if value.as_str() == Some("__unset") { - return Ok(T::default()); - } - serde_json::from_value(value).map_err(serde::de::Error::custom) -} - -#[derive(Debug, Clone, Deserialize)] -pub struct PackagistDist { - #[serde(rename = "type")] - pub dist_type: String, - pub url: String, - pub reference: Option, - pub shasum: Option, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct PackagistSource { - #[serde(rename = "type")] - pub source_type: String, - pub url: String, - pub reference: Option, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct PackagistVersion { - pub version: String, - pub version_normalized: String, - #[serde(default, deserialize_with = "deserialize_unset_as_default")] - pub require: BTreeMap, - #[serde(default, deserialize_with = "deserialize_unset_as_default")] - pub replace: BTreeMap, - #[serde(default, deserialize_with = "deserialize_unset_as_default")] - pub provide: BTreeMap, - #[serde(default, deserialize_with = "deserialize_unset_as_default")] - pub conflict: BTreeMap, - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub dist: Option, - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub source: Option, - - #[serde( - rename = "require-dev", - default, - deserialize_with = "deserialize_unset_as_default" - )] - pub require_dev: BTreeMap, - - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub suggest: Option>, - - #[serde( - rename = "type", - default, - deserialize_with = "deserialize_unset_as_none" - )] - pub package_type: Option, - - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub autoload: Option, - - #[serde( - rename = "autoload-dev", - default, - deserialize_with = "deserialize_unset_as_none" - )] - pub autoload_dev: Option, - - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub license: Option>, - - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub description: Option, - - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub homepage: Option, - - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub keywords: Option>, - - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub authors: Option>, - - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub support: Option, - - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub funding: Option>, - - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub time: Option, - - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub extra: Option, - - #[serde( - rename = "notification-url", - default, - deserialize_with = "deserialize_unset_as_none" - )] - pub notification_url: Option, - - /// `default-branch: true` marks the repository's default branch (e.g. the - /// branch returned by `git symbolic-ref HEAD`). For packages without a - /// numeric version prefix this triggers the synthetic `9999999-dev` alias - /// generation in `ArrayLoader::getBranchAlias` — see the alias loop in - /// `crate::resolver::packagist_to_pool_inputs`. - #[serde(rename = "default-branch", default)] - pub default_branch: bool, - - /// Abandonment marker. Composer accepts `abandoned: true` (no replacement - /// suggested) or `abandoned: ""`. Anything else - /// (absent, `false`, empty string) means the package is active. Mirrors - /// `Composer\Package\CompletePackage::isAbandoned`. - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub abandoned: Option, -} - -impl PackagistVersion { - /// Extract the `extra.branch-alias` map from this version's metadata. - /// - /// Composer packages can declare branch aliases in `extra.branch-alias`: - /// ```json - /// { - /// "extra": { - /// "branch-alias": { - /// "dev-master": "2.x-dev" - /// } - /// } - /// } - /// ``` - /// - /// Returns a map from branch name (e.g. `"dev-master"`) to alias target - /// (e.g. `"2.x-dev"`). Returns an empty map when no aliases are declared. - pub fn branch_aliases(&self) -> BTreeMap { - let Some(extra) = &self.extra else { - return BTreeMap::new(); - }; - - let Some(branch_alias) = extra.get("branch-alias") else { - return BTreeMap::new(); - }; - - let Some(map) = branch_alias.as_object() else { - return BTreeMap::new(); - }; - - map.iter() - .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) - .collect() - } -} - -/// Parse a Packagist p2 API JSON response. -/// -/// The response format is: -/// ```json -/// { -/// "packages": {"vendor/package": [...]}, -/// "minified": "composer/2.0" // optional -/// } -/// ``` -/// -/// When the `"minified"` key is present the version list is delta-encoded by -/// Composer's `MetadataMinifier`. This function transparently expands the -/// minified data before deserializing into [`PackagistVersion`] structs. -pub fn parse_p2_response(json: &str, package_name: &str) -> anyhow::Result> { - let raw: serde_json::Value = serde_json::from_str(json)?; - - // Check whether the response is minified. - let is_minified = raw - .get("minified") - .and_then(|v| v.as_str()) - .is_some_and(|s| s == "composer/2.0"); - - // Extract the version array for the requested package. - let versions_value = raw - .get("packages") - .and_then(|p| p.get(package_name)) - .ok_or_else(|| anyhow::anyhow!("Package \"{package_name}\" not found in response"))?; - - let versions_array = versions_value - .as_array() - .ok_or_else(|| anyhow::anyhow!("Expected array for package \"{package_name}\""))?; - - // Expand minified diffs into full version objects if necessary. - let versions: Vec = if is_minified { - mozart_metadata_minifier::expand(versions_array) - } else { - versions_array.clone() - }; - - // Deserialize the (possibly expanded) version objects. - versions - .into_iter() - .map(|v| serde_json::from_value(v).map_err(Into::into)) - .collect() -} - -/// Fetch package version metadata from the Packagist p2 API. -/// -/// 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 (unless the -/// cache is disabled). -#[tracing::instrument(skip(repo_cache))] -pub async fn fetch_package_versions( - package_name: &str, - repo_cache: &Cache, -) -> anyhow::Result> { - // Build cache key: replace `/` with `~` per cache key convention - let cache_key = format!("provider-{}.json", package_name.replace('/', "~")); - - // Check cache first - if let Some(cached) = repo_cache.read(&cache_key) { - tracing::debug!("cache hit"); - return parse_p2_response(&cached, package_name); - } - - // Cache miss — fetch from Packagist - let url = format!("https://repo.packagist.org/p2/{package_name}.json"); - tracing::debug!(%url, "fetching package metadata"); - let client = mozart_core::http::client_builder().build()?; - let response = client.get(&url).send().await?; - tracing::debug!(status = %response.status(), "received response"); - - if !response.status().is_success() { - anyhow::bail!( - "Failed to fetch package \"{package_name}\" from Packagist (HTTP {})", - response.status() - ); - } - - let body = response.text().await?; - - // Write to cache - let _ = repo_cache.write(&cache_key, &body); - - parse_p2_response(&body, package_name) -} - -/// A single search result from the Packagist search API. -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct SearchResult { - pub name: String, - pub description: String, - pub url: String, - pub repository: Option, - pub downloads: u64, - pub favers: u64, - /// Abandonment status: absent/false means active, a string indicates the - /// replacement package name, `true` means abandoned with no replacement. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub abandoned: Option, -} - -#[derive(Debug, Deserialize)] -pub struct SearchResponse { - pub results: Vec, - pub total: u64, - pub next: Option, -} - -/// Maximum number of pages to fetch from the Packagist search API. -const SEARCH_MAX_PAGES: usize = 20; - -/// Percent-encode a string for use in a URL query parameter value. -fn url_encode(s: &str) -> String { - let mut encoded = String::with_capacity(s.len()); - for byte in s.bytes() { - match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - encoded.push(byte as char); - } - b' ' => encoded.push_str("%20"), - other => { - encoded.push_str(&format!("%{other:02X}")); - } - } - } - encoded -} - -/// Search Packagist for packages matching `query`. -/// -/// Fetches up to `SEARCH_MAX_PAGES` pages of results and returns the full list. -/// An optional `package_type` filter can narrow results (e.g. `"library"`). -#[tracing::instrument(fields(type_filter = package_type))] -pub async fn search_packages( - query: &str, - package_type: Option<&str>, -) -> anyhow::Result<(Vec, u64)> { - let client = mozart_core::http::client_builder().build()?; - - let mut all_results: Vec = Vec::new(); - let mut page = 1usize; - let mut next_url: Option = None; - let mut total: u64 = 0; - - loop { - let response: SearchResponse = if let Some(ref url) = next_url { - tracing::debug!(%url, page, "fetching next page"); - let resp = client.get(url).send().await?; - tracing::debug!(status = %resp.status(), "received response"); - if !resp.status().is_success() { - anyhow::bail!("Packagist search request failed (HTTP {})", resp.status()); - } - resp.json().await? - } 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)); - } - - tracing::debug!(%url, "fetching search results"); - let resp = client.get(&url).send().await?; - tracing::debug!(status = %resp.status(), "received response"); - if !resp.status().is_success() { - anyhow::bail!("Packagist search request failed (HTTP {})", resp.status()); - } - resp.json().await? - }; - - 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)) -} - -/// Response shape of `https://packagist.org/packages/list.json[?type=...]`. -#[derive(Debug, Deserialize)] -struct ListResponse { - #[serde(rename = "packageNames")] - package_names: Vec, -} - -/// Fetch the full list of Packagist package names, optionally filtered by type. -/// -/// Backs Composer's `ComposerRepository::getPackageNames()` for the -/// `SEARCH_NAME` and `SEARCH_VENDOR` search modes. Cached on disk under -/// `list-packages~{type}.json` (or `list-packages~all.json` when no type -/// filter is given). -#[tracing::instrument(skip(repo_cache))] -pub async fn fetch_package_names( - package_type: Option<&str>, - repo_cache: &Cache, -) -> anyhow::Result> { - let cache_key = match package_type { - Some(t) => format!("list-packages~{t}.json"), - None => "list-packages~all.json".to_string(), - }; - - if let Some(cached) = repo_cache.read(&cache_key) { - tracing::debug!("cache hit"); - let parsed: ListResponse = serde_json::from_str(&cached)?; - return Ok(parsed.package_names); - } - - let mut url = "https://packagist.org/packages/list.json".to_string(); - if let Some(t) = package_type { - url.push_str("?type="); - url.push_str(&url_encode(t)); - } - tracing::debug!(%url, "fetching package list"); - let client = mozart_core::http::client_builder().build()?; - let response = client.get(&url).send().await?; - tracing::debug!(status = %response.status(), "received response"); - - if !response.status().is_success() { - anyhow::bail!( - "Failed to fetch package list from Packagist (HTTP {})", - response.status() - ); - } - - let body = response.text().await?; - let _ = repo_cache.write(&cache_key, &body); - - let parsed: ListResponse = serde_json::from_str(&body)?; - Ok(parsed.package_names) -} - -/// Fetch the deduplicated list of Packagist vendor names. -/// -/// Mirrors Composer's `ComposerRepository::getVendorNames()` which derives -/// vendors from `getPackageNames()` (regardless of type) by stripping the -/// `/...` suffix and de-duplicating in insertion order. -#[tracing::instrument(skip(repo_cache))] -pub async fn fetch_vendor_names(repo_cache: &Cache) -> anyhow::Result> { - let names = fetch_package_names(None, repo_cache).await?; - let mut seen: indexmap::IndexSet = indexmap::IndexSet::new(); - for name in names { - let vendor = match name.split_once('/') { - Some((v, _)) => v.to_string(), - None => name, - }; - seen.insert(vendor); - } - Ok(seen.into_iter().collect()) -} - -/// A single security advisory from the Packagist API. -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct SecurityAdvisory { - #[serde(rename = "advisoryId")] - pub advisory_id: String, - - #[serde(rename = "packageName")] - pub package_name: String, - - #[serde(rename = "remoteId")] - pub remote_id: String, - - pub title: String, - - pub link: Option, - - pub cve: Option, - - /// Composer version constraint string, e.g. ">=1.0,<1.5.1|>=2.0,<2.3" - #[serde(rename = "affectedVersions")] - pub affected_versions: String, - - pub source: String, - - #[serde(rename = "reportedAt")] - pub reported_at: String, - - #[serde(rename = "composerRepository")] - pub composer_repository: Option, - - pub severity: Option, - - #[serde(default)] - pub sources: Vec, -} - -/// A source entry within a security advisory. -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct AdvisorySource { - pub name: String, - #[serde(rename = "remoteId")] - pub remote_id: String, -} - -/// Response from POST `https://packagist.org/api/security-advisories/`. -#[derive(Debug, Deserialize)] -pub struct SecurityAdvisoriesResponse { - pub advisories: BTreeMap>, -} - -/// Fetch security advisories for the given package names from the Packagist API. -/// -/// Sends a POST request to `https://packagist.org/api/security-advisories/` -/// with form-encoded package names. Returns advisories grouped by package name. -/// -/// If the package list is very large (500+), requests are batched in chunks of -/// 500 names per request and the results are merged. -#[tracing::instrument(skip(package_names), fields(package_count = package_names.len()))] -pub async fn fetch_security_advisories( - package_names: &[&str], -) -> anyhow::Result>> { - let client = mozart_core::http::client_builder().build()?; - - let mut all_advisories: BTreeMap> = BTreeMap::new(); - - for chunk in package_names.chunks(500) { - // Build an application/x-www-form-urlencoded body manually. - // Each package is encoded as `packages[]=` and joined with `&`. - let body: String = chunk - .iter() - .map(|name| format!("packages[]={}", url_encode(name))) - .collect::>() - .join("&"); - - tracing::debug!(chunk_size = chunk.len(), "fetching security advisories"); - let response = client - .post("https://packagist.org/api/security-advisories/") - .header("Content-Type", "application/x-www-form-urlencoded") - .body(body) - .send() - .await?; - tracing::debug!(status = %response.status(), "received response"); - - if !response.status().is_success() { - anyhow::bail!( - "Packagist security advisories request failed (HTTP {})", - response.status() - ); - } - - let parsed: SecurityAdvisoriesResponse = response.json().await?; - - 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"); - } - - #[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()); - } - - #[test] - fn parse_p2_response_unset_fields() { - // Packagist metadata minifier uses "__unset" to mark deleted fields. - let json = r#"{ - "packages": { - "test/pkg": [ - { - "version": "2.0.0", - "version_normalized": "2.0.0.0", - "require": {"php": ">=8.1"}, - "license": ["MIT"], - "keywords": ["framework"], - "authors": [{"name": "Alice"}], - "funding": [{"type": "github", "url": "https://github.com/sponsors/alice"}] - }, - { - "version": "1.0.0", - "version_normalized": "1.0.0.0", - "license": "__unset", - "keywords": "__unset", - "authors": "__unset", - "funding": "__unset", - "require": "__unset", - "homepage": "__unset", - "description": "__unset", - "extra": "__unset", - "suggest": "__unset" - } - ] - } - }"#; - - let versions = parse_p2_response(json, "test/pkg").unwrap(); - assert_eq!(versions.len(), 2); - - // First version has normal values - assert_eq!(versions[0].license.as_ref().unwrap(), &["MIT"]); - assert_eq!(versions[0].keywords.as_ref().unwrap(), &["framework"]); - - // Second version has __unset → treated as absent - assert!(versions[1].license.is_none()); - assert!(versions[1].keywords.is_none()); - assert!(versions[1].authors.is_none()); - assert!(versions[1].funding.is_none()); - assert!(versions[1].require.is_empty()); - assert!(versions[1].homepage.is_none()); - assert!(versions[1].description.is_none()); - assert!(versions[1].extra.is_none()); - assert!(versions[1].suggest.is_none()); - } - - #[test] - fn parse_p2_response_minified_expand() { - // Mirrors the Composer MetadataMinifierTest: 3 versions where only - // the first carries all fields and subsequent entries are diffs. - let json = r#"{ - "packages": { - "foo/bar": [ - { - "name": "foo/bar", - "version": "2.0.0", - "version_normalized": "2.0.0.0", - "type": "library", - "license": ["MIT"], - "require": {"php": ">=8.1"}, - "description": "A great package" - }, - { - "version": "1.2.0", - "version_normalized": "1.2.0.0", - "license": ["GPL"], - "homepage": "https://example.org" - }, - { - "version": "1.0.0", - "version_normalized": "1.0.0.0", - "homepage": "__unset" - } - ] - }, - "minified": "composer/2.0" - }"#; - - let versions = parse_p2_response(json, "foo/bar").unwrap(); - assert_eq!(versions.len(), 3); - - // Version 2.0.0 — full data (first entry). - assert_eq!(versions[0].version, "2.0.0"); - assert_eq!(versions[0].package_type.as_deref(), Some("library")); - assert_eq!(versions[0].license.as_ref().unwrap(), &["MIT"]); - assert_eq!(versions[0].require.get("php").unwrap(), ">=8.1"); - assert_eq!(versions[0].description.as_deref(), Some("A great package")); - assert!(versions[0].homepage.is_none()); - - // Version 1.2.0 — inherits name, type, require, description from 2.0.0; - // license changed to GPL; homepage added. - assert_eq!(versions[1].version, "1.2.0"); - assert_eq!(versions[1].package_type.as_deref(), Some("library")); - assert_eq!(versions[1].license.as_ref().unwrap(), &["GPL"]); - assert_eq!(versions[1].require.get("php").unwrap(), ">=8.1"); - assert_eq!(versions[1].description.as_deref(), Some("A great package")); - assert_eq!(versions[1].homepage.as_deref(), Some("https://example.org")); - - // Version 1.0.0 — inherits everything from 1.2.0 except homepage - // which is __unset (deleted). - assert_eq!(versions[2].version, "1.0.0"); - assert_eq!(versions[2].package_type.as_deref(), Some("library")); - assert_eq!(versions[2].license.as_ref().unwrap(), &["GPL"]); - assert_eq!(versions[2].require.get("php").unwrap(), ">=8.1"); - assert_eq!(versions[2].description.as_deref(), Some("A great package")); - assert!(versions[2].homepage.is_none()); - } - - #[test] - fn parse_p2_response_not_minified_no_inheritance() { - // Without "minified" key, each version stands alone — no inheritance. - let json = r#"{ - "packages": { - "foo/bar": [ - { - "version": "2.0.0", - "version_normalized": "2.0.0.0", - "license": ["MIT"], - "description": "A great package" - }, - { - "version": "1.0.0", - "version_normalized": "1.0.0.0" - } - ] - } - }"#; - - let versions = parse_p2_response(json, "foo/bar").unwrap(); - assert_eq!(versions.len(), 2); - - assert_eq!(versions[0].license.as_ref().unwrap(), &["MIT"]); - assert_eq!(versions[0].description.as_deref(), Some("A great package")); - - // Without minified flag, version 1.0.0 does NOT inherit from 2.0.0. - assert!(versions[1].license.is_none()); - assert!(versions[1].description.is_none()); - } - - #[test] - fn parse_p2_response_minified_single_version() { - // Edge case: minified response with only one version. - let json = r#"{ - "packages": { - "foo/bar": [ - { - "version": "1.0.0", - "version_normalized": "1.0.0.0", - "license": ["MIT"] - } - ] - }, - "minified": "composer/2.0" - }"#; - - let versions = parse_p2_response(json, "foo/bar").unwrap(); - assert_eq!(versions.len(), 1); - assert_eq!(versions[0].license.as_ref().unwrap(), &["MIT"]); - } - - #[test] - fn parse_p2_response_minified_empty_versions() { - let json = r#"{ - "packages": { - "foo/bar": [] - }, - "minified": "composer/2.0" - }"#; - - let versions = parse_p2_response(json, "foo/bar").unwrap(); - assert!(versions.is_empty()); - } - - #[test] - fn parse_p2_response_minified_map_fields_inherited() { - // Verify BTreeMap fields (require, replace, etc.) are inherited. - let json = r#"{ - "packages": { - "foo/bar": [ - { - "version": "2.0.0", - "version_normalized": "2.0.0.0", - "require": {"php": ">=8.1", "ext-json": "*"}, - "replace": {"foo/old": "self.version"} - }, - { - "version": "1.0.0", - "version_normalized": "1.0.0.0", - "replace": "__unset" - } - ] - }, - "minified": "composer/2.0" - }"#; - - let versions = parse_p2_response(json, "foo/bar").unwrap(); - assert_eq!(versions.len(), 2); - - // Version 1.0.0 inherits require from 2.0.0, replace is unset. - assert_eq!(versions[1].require.get("php").unwrap(), ">=8.1"); - assert_eq!(versions[1].require.get("ext-json").unwrap(), "*"); - assert!(versions[1].replace.is_empty()); - } - - #[test] - fn test_parse_security_advisories_response() { - let json = r#"{ - "advisories": { - "monolog/monolog": [ - { - "advisoryId": "PKSA-b2m0-qqf7-qck4", - "packageName": "monolog/monolog", - "remoteId": "monolog/monolog/2017-11-13-1.yaml", - "title": "Header injection in NativeMailerHandler", - "link": "https://github.com/Seldaek/monolog/pull/683", - "cve": null, - "affectedVersions": ">=1.8.0,<1.12.0", - "source": "FriendsOfPHP/security-advisories", - "reportedAt": "2017-11-13T00:00:00+00:00", - "composerRepository": "https://packagist.org", - "severity": "low", - "sources": [ - { - "name": "FriendsOfPHP/security-advisories", - "remoteId": "monolog/monolog/2017-11-13-1.yaml" - } - ] - } - ] - } - }"#; - - let response: SecurityAdvisoriesResponse = serde_json::from_str(json).unwrap(); - assert_eq!(response.advisories.len(), 1); - let advisories = response.advisories.get("monolog/monolog").unwrap(); - assert_eq!(advisories.len(), 1); - let adv = &advisories[0]; - assert_eq!(adv.advisory_id, "PKSA-b2m0-qqf7-qck4"); - assert_eq!(adv.package_name, "monolog/monolog"); - assert_eq!(adv.title, "Header injection in NativeMailerHandler"); - assert_eq!(adv.affected_versions, ">=1.8.0,<1.12.0"); - assert_eq!(adv.severity.as_deref(), Some("low")); - assert!(adv.cve.is_none()); - assert_eq!(adv.sources.len(), 1); - assert_eq!(adv.sources[0].name, "FriendsOfPHP/security-advisories"); - } - - #[test] - fn test_parse_security_advisories_empty() { - let json = r#"{"advisories": {"other/package": []}}"#; - let response: SecurityAdvisoriesResponse = serde_json::from_str(json).unwrap(); - assert_eq!(response.advisories.len(), 1); - let advisories = response.advisories.get("other/package").unwrap(); - assert!(advisories.is_empty()); - } - - #[test] - fn test_parse_security_advisories_null_fields() { - let json = r#"{ - "advisories": { - "vendor/pkg": [ - { - "advisoryId": "PKSA-0000-0000-0000", - "packageName": "vendor/pkg", - "remoteId": "vendor/pkg/2024-01-01.yaml", - "title": "Some vulnerability", - "link": null, - "cve": null, - "affectedVersions": ">=1.0,<2.0", - "source": "FriendsOfPHP/security-advisories", - "reportedAt": "2024-01-01T00:00:00+00:00", - "composerRepository": null, - "severity": null, - "sources": [] - } - ] - } - }"#; - - let response: SecurityAdvisoriesResponse = serde_json::from_str(json).unwrap(); - let advisories = response.advisories.get("vendor/pkg").unwrap(); - assert_eq!(advisories.len(), 1); - let adv = &advisories[0]; - assert!(adv.link.is_none()); - assert!(adv.cve.is_none()); - assert!(adv.severity.is_none()); - assert!(adv.composer_repository.is_none()); - assert!(adv.sources.is_empty()); - } -} diff --git a/crates/mozart-registry/src/path_repository.rs b/crates/mozart-registry/src/path_repository.rs deleted file mode 100644 index bf71315..0000000 --- a/crates/mozart-registry/src/path_repository.rs +++ /dev/null @@ -1,243 +0,0 @@ -//! Support for `type: path` repositories. -//! -//! Mirrors `Composer\Repository\PathRepository`: a path repo points at a -//! local directory containing a `composer.json`, and the resolver loads the -//! package from that file directly. Mozart does not yet support glob URLs or -//! the `versions` / `reference: none` options — only the bare -//! `{ type: path, url: ... }` form the installer fixtures exercise. -//! -//! Resolution model: a path repo is expanded into a synthetic -//! `type: package` [`RawRepository`] whose payload is the loaded composer.json -//! plus a `dist` block. After this expansion the rest of the registry treats -//! the package the same as any inline `type: package` entry — that is the -//! whole point of doing the work here rather than threading a new repo type -//! through the resolver / lockfile. -//! -//! `dist.reference` matches Composer's `hash('sha1', $json . serialize($options))` -//! where `$options` carries the auto-detected `relative` flag (true when the -//! original URL was not absolute). The same SHA-1 ends up in the lockfile, so -//! consumers comparing references against Composer-produced lockfiles see -//! byte-identical values. - -use std::path::{Path, PathBuf}; - -use mozart_core::package::RawRepository; -use mozart_php_serialize::{Value as PhpValue, serialize as php_serialize}; -use sha1::{Digest, Sha1}; - -/// Translate path repos in `repositories` into synthetic `type: package` -/// entries. Non-path entries are returned unchanged in original order. -/// -/// `base_dir` is the directory used to resolve relative `url` values -/// (Composer's PHP code resolves these against the process cwd; in production -/// that equals the project root, in tests it equals the fixtures anchor). -/// -/// Failures (missing directory, unreadable composer.json, missing -/// `name`/`version`) drop the offending entry silently — the rest of the -/// repository list still applies. This mirrors Composer's lenient -/// PathRepository, which logs a warning and moves on rather than aborting the -/// whole resolve. -pub fn expand_path_repositories( - repositories: &[RawRepository], - base_dir: &Path, -) -> Vec { - let mut out = Vec::with_capacity(repositories.len()); - for repo in repositories { - if repo.repo_type != "path" { - out.push(repo.clone()); - continue; - } - let Some(url) = repo.url.as_deref() else { - continue; - }; - let Some(synthetic) = load_path_package(url, base_dir) else { - continue; - }; - out.push(synthetic); - } - out -} - -/// Read one path repo's `composer.json` and synthesize the inline-package -/// form. Returns `None` for any I/O or parse failure (Composer behaves the -/// same — `PathRepository::initialize` skips entries whose `composer.json` -/// is missing). -fn load_path_package(url: &str, base_dir: &Path) -> Option { - let resolved = resolve_path(url, base_dir); - let composer_json_path = resolved.join("composer.json"); - let json = std::fs::read_to_string(&composer_json_path).ok()?; - let mut package: serde_json::Value = serde_json::from_str(&json).ok()?; - let obj = package.as_object_mut()?; - - // `version` is mandatory in the inline-package representation: without it - // the resolver would skip the package. Composer's PathRepository falls - // back to `dev-main` when no version is declared and no VCS is present; - // mirror that so a path repo whose composer.json omits `version` still - // produces a usable entry. - if !obj.contains_key("version") { - obj.insert( - "version".to_string(), - serde_json::Value::String("dev-main".to_string()), - ); - } - - let is_relative = !Path::new(url).is_absolute(); - let reference = compute_path_reference(json.as_bytes(), is_relative); - - obj.insert( - "dist".to_string(), - serde_json::json!({ - "type": "path", - "url": url, - "reference": reference, - }), - ); - // Composer copies `symlink`/`relative` from `options` into - // `transport-options`. We have no `options` to forward today but emit an - // empty object so consumers reading the package see the same shape. - obj.entry("transport-options") - .or_insert_with(|| serde_json::json!({})); - - Some(RawRepository { - repo_type: "package".to_string(), - url: None, - package: Some(serde_json::Value::Array(vec![package])), - only: None, - exclude: None, - canonical: None, - security_advisories: None, - }) -} - -fn resolve_path(url: &str, base_dir: &Path) -> PathBuf { - let p = Path::new(url); - if p.is_absolute() { - p.to_path_buf() - } else { - base_dir.join(p) - } -} - -/// Compose the SHA-1 reference Composer uses for path repos: -/// `sha1($json . serialize(['relative' => $isRelative]))`. The `relative` -/// flag is the only option Composer's auto-detection populates when the user -/// supplied no `options` block. -fn compute_path_reference(json_bytes: &[u8], is_relative: bool) -> String { - let options = PhpValue::Array(vec![( - PhpValue::String("relative".to_string()), - PhpValue::Bool(is_relative), - )]); - let serialized = php_serialize(&options); - let mut hasher = Sha1::new(); - hasher.update(json_bytes); - hasher.update(serialized.as_bytes()); - let bytes = hasher.finalize(); - let mut hex = String::with_capacity(bytes.len() * 2); - for b in bytes { - use std::fmt::Write; - let _ = write!(&mut hex, "{:02x}", b); - } - hex -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn computes_known_reference_for_plugin_a_fixture() { - // Fixture used by partial-update-loads-root-aliases-for-path-repos.test. - // Expected reference (`b133081...`) is what PHP's - // `hash('sha1', file_get_contents($composerJson) . serialize(['relative' => true]))` - // produces for this file — pin it here so reference computation - // changes can't drift silently from Composer. - let composer_json_path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../composer/tests/Composer/Test/Fixtures/functional/installed-versions/plugin-a/composer.json"); - let bytes = std::fs::read(&composer_json_path).expect("fixture composer.json must exist"); - let reference = compute_path_reference(&bytes, true); - assert!( - reference.starts_with("b133081"), - "unexpected reference: {reference}" - ); - } - - #[test] - fn relative_url_resolves_against_base_dir_and_emits_synthetic_package_repo() { - let temp = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(temp.path().join("pkg-dir")).unwrap(); - std::fs::write( - temp.path().join("pkg-dir").join("composer.json"), - r#"{"name": "vendor/pkg", "version": "1.2.3"}"#, - ) - .unwrap(); - - let input = vec![RawRepository { - repo_type: "path".to_string(), - url: Some("pkg-dir".to_string()), - package: None, - only: None, - exclude: None, - canonical: None, - security_advisories: None, - }]; - let expanded = expand_path_repositories(&input, temp.path()); - assert_eq!(expanded.len(), 1); - assert_eq!(expanded[0].repo_type, "package"); - - let pkgs = expanded[0] - .package - .as_ref() - .expect("expanded entry must carry a package payload") - .as_array() - .expect("payload should be an array"); - assert_eq!(pkgs.len(), 1); - let pkg = &pkgs[0]; - assert_eq!(pkg["name"], "vendor/pkg"); - assert_eq!(pkg["version"], "1.2.3"); - assert_eq!(pkg["dist"]["type"], "path"); - assert_eq!(pkg["dist"]["url"], "pkg-dir"); - assert!( - pkg["dist"]["reference"] - .as_str() - .map(|s| s.len() == 40) - .unwrap_or(false), - "reference should be a 40-char SHA-1" - ); - } - - #[test] - fn missing_composer_json_drops_the_entry() { - let temp = tempfile::tempdir().unwrap(); - let input = vec![RawRepository { - repo_type: "path".to_string(), - url: Some("does-not-exist".to_string()), - package: None, - only: None, - exclude: None, - canonical: None, - security_advisories: None, - }]; - let expanded = expand_path_repositories(&input, temp.path()); - assert!(expanded.is_empty()); - } - - #[test] - fn non_path_repos_pass_through_unchanged() { - let input = vec![RawRepository { - repo_type: "vcs".to_string(), - url: Some("https://example.com/repo.git".to_string()), - package: None, - only: None, - exclude: None, - canonical: None, - security_advisories: None, - }]; - let expanded = expand_path_repositories(&input, Path::new("/tmp")); - assert_eq!(expanded.len(), 1); - assert_eq!(expanded[0].repo_type, "vcs"); - assert_eq!( - expanded[0].url.as_deref(), - Some("https://example.com/repo.git") - ); - } -} diff --git a/crates/mozart-registry/src/repository/inline_package_repo.rs b/crates/mozart-registry/src/repository/inline_package_repo.rs deleted file mode 100644 index 1043559..0000000 --- a/crates/mozart-registry/src/repository/inline_package_repo.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! [`Repository`] for inline `type: package` repositories. -//! -//! Wraps [`crate::inline_package::collect_inline_packages`]. The data is -//! embedded in `composer.json` so there's no I/O — the repo just filters -//! its in-memory list by queried name. -//! -//! Mirrors `Composer\Repository\PackageRepository` (which extends -//! `ArrayRepository`). Only the package's own `name` is matched against -//! queries — `replace`/`provide` targets are NOT advertised here, exactly -//! like Composer's `ArrayRepository::loadPackages` checks `getName()` only. -//! Replacement satisfaction happens later in the solver once the replacing -//! package is loaded transitively. - -use super::{LoadResult, NamedPackagistVersion, PackageQuery, Repository}; -use crate::inline_package::{InlinePackage, collect_inline_packages}; -use mozart_core::package::RawRepository; - -pub struct InlinePackageRepository { - id: String, - packages: Vec, -} - -impl InlinePackageRepository { - /// Build from the raw `repositories` array of a `composer.json`. Non- - /// `package` entries are ignored. - pub fn from_repositories(repositories: &[RawRepository]) -> Self { - Self { - id: "package".to_string(), - packages: collect_inline_packages(repositories), - } - } - - pub fn package_count(&self) -> usize { - self.packages.len() - } -} - -#[async_trait::async_trait] -impl Repository for InlinePackageRepository { - fn id(&self) -> &str { - &self.id - } - - async fn load_packages(&self, queries: &[PackageQuery<'_>]) -> anyhow::Result { - let mut result = LoadResult::default(); - for query in queries { - let mut found_any = false; - for ipkg in &self.packages { - if ipkg.name == query.name { - found_any = true; - result.packages.push(NamedPackagistVersion { - name: ipkg.name.clone(), - version: ipkg.version.clone(), - }); - } - } - if found_any { - result.names_found.push(query.name.to_string()); - } - } - Ok(result) - } -} diff --git a/crates/mozart-registry/src/repository/mod.rs b/crates/mozart-registry/src/repository/mod.rs deleted file mode 100644 index 46f62f0..0000000 --- a/crates/mozart-registry/src/repository/mod.rs +++ /dev/null @@ -1,319 +0,0 @@ -//! Repository abstraction over package metadata sources. -//! -//! Mirrors Composer's `Composer\Repository\RepositoryInterface::loadPackages` -//! and `Composer\Repository\RepositoryManager`. The resolver and lockfile -//! generator query a [`RepositorySet`] instead of calling Packagist directly, -//! so test code can substitute a set without `PackagistRepository` (mirroring -//! Composer's `FactoryMock` injecting `repositories: ['packagist' => false]`). -//! -//! Concrete implementations live in sibling modules: [`packagist_repo`] for -//! the live Packagist HTTP repo, [`inline_package_repo`] for `type: package` -//! entries embedded in `composer.json`, and [`vcs_repo`] for VCS repositories. - -use std::collections::BTreeMap; - -use crate::advisory::{MatchedAdvisory, PackageInfo}; -use crate::packagist::{PackagistVersion, SearchResult}; - -pub mod inline_package_repo; -pub mod packagist_repo; -pub mod vcs_repo; - -/// Search modes for [`Repository::search`]. -/// -/// Mirrors Composer's `RepositoryInterface::SEARCH_FULLTEXT|SEARCH_NAME|SEARCH_VENDOR` -/// constants (`composer/src/Composer/Repository/RepositoryInterface.php`). -#[derive(Copy, Clone, Eq, PartialEq, Debug)] -pub enum SearchMode { - /// Full-text search over name, description, and keywords (Packagist's - /// `search.json` API). - Fulltext, - /// Match the regex against package names. Tokens are split on whitespace - /// and joined as `(?:t1|t2|...)`; callers must pre-quote regex metachars. - Name, - /// Match the regex against vendor names. Result rows have only `name` - /// populated (the vendor part). - Vendor, -} - -/// One name-keyed lookup against a repository. -/// -/// Matches the `$packageNameMap` argument of Composer's `loadPackages`. The -/// constraint is informational — repositories may use it to skip versions -/// that obviously can't match (an optimization), but the resolver still -/// re-checks every returned version when generating rules. -#[derive(Debug, Clone)] -pub struct PackageQuery<'a> { - pub name: &'a str, - /// Raw constraint string from `composer.json`, e.g. `"^1.2"`. `None` - /// when the caller wants every version (transitive exploration). - pub constraint: Option<&'a str>, -} - -/// Result of a single [`Repository::load_packages`] call. -/// -/// Mirrors Composer's `['packages' => ..., 'namesFound' => ...]` tuple. -/// `names_found` lets [`RepositorySet`] short-circuit lower-priority repos -/// once an upstream repo has authoritatively answered for a name (Composer's -/// "first repo wins" semantics). -#[derive(Debug, Default)] -pub struct LoadResult { - pub packages: Vec, - pub names_found: Vec, -} - -/// A `PackagistVersion` paired with the canonical package name it answers -/// for. Inline `type: package` repos can return packages whose own `name` -/// field differs from the queried name when they declare `replace`/`provide`, -/// so callers need both. -#[derive(Debug, Clone)] -pub struct NamedPackagistVersion { - pub name: String, - pub version: PackagistVersion, -} - -/// A source of package metadata. Mirrors Composer's `RepositoryInterface`. -/// -/// Implementations should return an empty [`LoadResult`] (not an error) when -/// they simply don't know a queried name — [`RepositorySet`] uses that to -/// fall through to the next repo. Reserve `Err` for genuine I/O failures -/// the caller cannot route around. -#[async_trait::async_trait] -pub trait Repository: Send + Sync { - /// Identifier for diagnostics (`"packagist.org"`, `"package"`, `"vcs:"`). - fn id(&self) -> &str; - - /// Look up every version of every queried name this repo knows about. - async fn load_packages(&self, queries: &[PackageQuery<'_>]) -> anyhow::Result; - - /// Search this repository. - /// - /// The default returns an empty result so repositories that don't - /// participate in search (e.g. inline / VCS repos that only resolve - /// known names) can opt out. Mirrors Composer's - /// `RepositoryInterface::search` whose default behavior on - /// `ArrayRepository` walks the in-memory list. - async fn search( - &self, - _query: &str, - _mode: SearchMode, - _package_type: Option<&str>, - ) -> anyhow::Result> { - Ok(Vec::new()) - } -} - -/// Ordered list of repositories. Mirrors `Composer\Repository\RepositoryManager`. -/// -/// `load_packages` queries each repo in order. Once a repo authoritatively -/// answers for a name (i.e. lists it in `names_found`), later repos are not -/// asked about that name — matching Composer's first-repo-wins priority. -pub struct RepositorySet { - repos: Vec>, -} - -impl RepositorySet { - pub fn new(repos: Vec>) -> Self { - Self { repos } - } - - /// Production default: a single [`packagist_repo::PackagistRepository`] - /// backed by the given on-disk cache. Mirrors what Composer does when - /// no `'packagist' => false` entry appears in the merged config. - pub fn with_packagist(repo_cache: crate::cache::Cache) -> Self { - Self::new(vec![Box::new(packagist_repo::PackagistRepository::new( - repo_cache, - ))]) - } - - /// An empty set. Mirrors Composer's `'packagist' => false` test config: - /// resolution proceeds entirely from packages already in the pool - /// (eager VCS scan, inline `type: package` repos, the locked repository). - pub fn empty() -> Self { - Self::new(Vec::new()) - } - - pub fn is_empty(&self) -> bool { - self.repos.is_empty() - } - - pub fn len(&self) -> usize { - self.repos.len() - } - - /// Iterate over repositories in priority order. - pub fn repos(&self) -> impl Iterator { - self.repos.iter().map(|b| b.as_ref()) - } - - /// Query every repo, accumulating packages and tracking which names have - /// been authoritatively answered. Names already covered by an earlier - /// repo are dropped from the query passed to later repos. - pub async fn load_packages( - &self, - queries: &[PackageQuery<'_>], - ) -> anyhow::Result> { - use indexmap::IndexSet; - - let mut packages: Vec = Vec::new(); - let mut answered: IndexSet = IndexSet::new(); - - for repo in &self.repos { - let pending: Vec> = queries - .iter() - .filter(|q| !answered.contains(q.name)) - .cloned() - .collect(); - if pending.is_empty() { - break; - } - let result = repo.load_packages(&pending).await?; - for name in result.names_found { - answered.insert(name); - } - packages.extend(result.packages); - } - - Ok(packages) - } - - /// Fan-out search across every repository, concatenating results in - /// priority order. Mirrors Composer's - /// `CompositeRepository::search` which `array_merge`s per-repo results - /// without de-duplication. - pub async fn search( - &self, - query: &str, - mode: SearchMode, - package_type: Option<&str>, - ) -> anyhow::Result> { - let mut all = Vec::new(); - for repo in &self.repos { - let mut hits = repo.search(query, mode, package_type).await?; - all.append(&mut hits); - } - Ok(all) - } - - /// Fetch security advisories matching the installed packages, with version filtering. - /// - /// Mirrors `Composer\Repository\RepositorySet::getMatchingSecurityAdvisories()`. - /// Returns the matched advisories (already filtered by installed version) and a list - /// of unreachable repository URLs. When `ignore_unreachable` is false and a repository - /// is unreachable, the error is propagated instead. - pub async fn get_matching_security_advisories( - &self, - packages: &[PackageInfo], - _allow_partial: bool, - ignore_unreachable: bool, - ) -> anyhow::Result<(BTreeMap>, Vec)> { - let names: Vec<&str> = packages.iter().map(|p| p.name.as_str()).collect(); - - let (raw_advisories, unreachable_repos) = - match crate::packagist::fetch_security_advisories(&names).await { - Ok(a) => (a, vec![]), - Err(e) if ignore_unreachable => { - tracing::warn!("Packagist advisory fetch failed (ignored): {e}"); - let unreachable = vec!["https://packagist.org".to_string()]; - (BTreeMap::new(), unreachable) - } - Err(e) => return Err(e), - }; - - let matched = version_filter_advisories(&raw_advisories, packages); - - Ok((matched, unreachable_repos)) - } -} - -/// Normalize single-pipe OR separators (`|`) in a version constraint string to -/// double-pipe (`||`) so the constraint parser can handle both forms. -/// -/// The Packagist security advisories API may return constraints with single `|` -/// as the OR separator (e.g. `>=1.0,<1.5|>=2.0,<2.3`), but Mozart's -/// `VersionConstraint::parse` expects `||`. -/// -/// TODO: fix `mozart_semver::VersionConstraint::parse` to accept single `|` and remove this. -fn normalize_or_separator(constraint: &str) -> String { - let bytes = constraint.as_bytes(); - let mut result = String::with_capacity(constraint.len() + 4); - let mut i = 0; - while i < bytes.len() { - if bytes[i] == b'|' { - if i + 1 < bytes.len() && bytes[i + 1] == b'|' { - result.push_str("||"); - i += 2; - } else { - result.push_str("||"); - i += 1; - } - } else { - result.push(bytes[i] as char); - i += 1; - } - } - result -} - -/// Filter raw advisories by installed package versions. -/// -/// Mirrors the version-matching step inside Composer's repository advisory fetch. -fn version_filter_advisories( - all_advisories: &BTreeMap>, - packages: &[PackageInfo], -) -> BTreeMap> { - let mut result: BTreeMap> = BTreeMap::new(); - - for pkg in packages { - let Some(advisories) = all_advisories.get(&pkg.name) else { - continue; - }; - - let version_str = pkg - .version_normalized - .as_deref() - .unwrap_or(pkg.version.as_str()); - - let installed_ver = match mozart_semver::Version::parse(version_str) { - Ok(v) => v, - Err(_) => { - tracing::warn!( - "Could not parse version {:?} for package {:?}, skipping advisory matching", - version_str, - pkg.name - ); - continue; - } - }; - - let mut matched: Vec = Vec::new(); - - for advisory in advisories { - let normalized = normalize_or_separator(&advisory.affected_versions); - let constraint = match mozart_semver::VersionConstraint::parse(&normalized) { - Ok(c) => c, - Err(_) => { - tracing::warn!( - "Could not parse affected versions {:?} for advisory {:?}, skipping", - advisory.affected_versions, - advisory.advisory_id - ); - continue; - } - }; - - if constraint.matches(&installed_ver) { - matched.push(MatchedAdvisory { - advisory: advisory.clone(), - installed_version: pkg.version.clone(), - }); - } - } - - if !matched.is_empty() { - result.insert(pkg.name.clone(), matched); - } - } - - result -} diff --git a/crates/mozart-registry/src/repository/packagist_repo.rs b/crates/mozart-registry/src/repository/packagist_repo.rs deleted file mode 100644 index fa656b7..0000000 --- a/crates/mozart-registry/src/repository/packagist_repo.rs +++ /dev/null @@ -1,121 +0,0 @@ -//! [`Repository`] backed by the live Packagist HTTP API. -//! -//! Wraps the existing [`crate::packagist::fetch_package_versions`] so the -//! resolver sees the same data either through this trait or via the legacy -//! direct call. Construction takes ownership of the [`Cache`] handle so -//! callers no longer thread it through `ResolveRequest` / `LockFileGenerationRequest`. - -use super::{LoadResult, NamedPackagistVersion, PackageQuery, Repository, SearchMode}; -use crate::cache::Cache; -use crate::packagist; -use crate::packagist::SearchResult; - -pub struct PackagistRepository { - id: String, - cache: Cache, -} - -impl PackagistRepository { - pub fn new(cache: Cache) -> Self { - Self { - id: "packagist.org".to_string(), - cache, - } - } -} - -#[async_trait::async_trait] -impl Repository for PackagistRepository { - fn id(&self) -> &str { - &self.id - } - - async fn load_packages(&self, queries: &[PackageQuery<'_>]) -> anyhow::Result { - let mut result = LoadResult::default(); - for query in queries { - // Errors propagate to the caller. Composer's - // `ComposerRepository::loadAsyncPackages` distinguishes 404 - // (empty result, no error) from transport failures (exception); - // Mozart's underlying `fetch_package_versions` doesn't yet make - // that distinction, so for now both surface as `Err` and the - // caller decides whether the loop wants to continue (transitive - // exploration) or abort (seed-time fetch failure). - let versions = packagist::fetch_package_versions(query.name, &self.cache).await?; - // A successful fetch counts as "this repo authoritatively knows - // the name", even if the version list is empty — mirrors - // Composer's `ArrayRepository::loadPackages` which adds the - // name to `namesFound` regardless of constraint match. - result.names_found.push(query.name.to_string()); - for version in versions { - result.packages.push(NamedPackagistVersion { - name: query.name.to_string(), - version, - }); - } - } - Ok(result) - } - - async fn search( - &self, - query: &str, - mode: SearchMode, - package_type: Option<&str>, - ) -> anyhow::Result> { - match mode { - SearchMode::Fulltext => { - let (results, _total) = packagist::search_packages(query, package_type).await?; - Ok(results) - } - SearchMode::Name => { - let pattern = build_name_regex(query)?; - let names = packagist::fetch_package_names(package_type, &self.cache).await?; - Ok(names - .into_iter() - .filter(|name| pattern.is_match(name)) - .map(empty_search_result) - .collect()) - } - SearchMode::Vendor => { - let pattern = build_name_regex(query)?; - let vendors = packagist::fetch_vendor_names(&self.cache).await?; - Ok(vendors - .into_iter() - .filter(|name| pattern.is_match(name)) - .map(empty_search_result) - .collect()) - } - } - } -} - -/// Build the case-insensitive `(?:t1|t2|...)` regex from whitespace-split -/// tokens, mirroring Composer's `'{(?:'.implode('|', $matches).')}i'`. -/// -/// Tokens are joined as-is — callers are expected to have already escaped -/// regex metacharacters (`SearchCommand` calls `preg_quote`; Mozart calls -/// `regex::escape` before reaching this point). -fn build_name_regex(query: &str) -> anyhow::Result { - let tokens: Vec<&str> = query.split_whitespace().collect(); - let body = if tokens.is_empty() { - String::new() - } else { - tokens.join("|") - }; - Ok(regex::Regex::new(&format!("(?i)(?:{body})"))?) -} - -/// Build a [`SearchResult`] with only `name` populated, mirroring the shape -/// Composer returns for `SEARCH_NAME` / `SEARCH_VENDOR` modes -/// (`['name' => $name]`, all other fields `null`). -fn empty_search_result(name: String) -> SearchResult { - SearchResult { - name, - description: String::new(), - url: String::new(), - repository: None, - downloads: 0, - favers: 0, - abandoned: None, - } -} diff --git a/crates/mozart-registry/src/repository/vcs_repo.rs b/crates/mozart-registry/src/repository/vcs_repo.rs deleted file mode 100644 index fff5f6f..0000000 --- a/crates/mozart-registry/src/repository/vcs_repo.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! [`Repository`] for VCS-type repositories. -//! -//! Wraps [`crate::vcs_bridge::scan_vcs_repositories`] + [`crate::vcs_bridge::vcs_to_packagist_version`]. -//! Scanning is expensive (clones / fetches), so we do it once at construction -//! and serve subsequent queries from the in-memory cache. Mirrors -//! `Composer\Repository\Vcs\VcsRepository`'s lazy-then-memoized behavior. - -use super::{LoadResult, NamedPackagistVersion, PackageQuery, Repository}; -use crate::packagist::PackagistVersion; -use crate::vcs_bridge::{scan_vcs_repositories, vcs_to_packagist_version}; -use mozart_core::package::RawRepository; - -pub struct VcsRepository { - id: String, - versions: Vec<(String, PackagistVersion)>, -} - -impl VcsRepository { - /// Scan every VCS-type entry in `repositories` and cache the resulting - /// versions. Non-VCS entries are ignored. This performs network I/O. - pub async fn from_repositories(repositories: &[RawRepository]) -> Self { - let scanned = scan_vcs_repositories(repositories).await; - let versions = scanned - .iter() - .map(|v| (v.name.clone(), vcs_to_packagist_version(v))) - .collect(); - Self { - id: "vcs".to_string(), - versions, - } - } - - pub fn version_count(&self) -> usize { - self.versions.len() - } -} - -#[async_trait::async_trait] -impl Repository for VcsRepository { - fn id(&self) -> &str { - &self.id - } - - async fn load_packages(&self, queries: &[PackageQuery<'_>]) -> anyhow::Result { - let mut result = LoadResult::default(); - for query in queries { - let mut found_any = false; - for (name, version) in &self.versions { - if name == query.name { - found_any = true; - result.packages.push(NamedPackagistVersion { - name: name.clone(), - version: version.clone(), - }); - } - } - if found_any { - result.names_found.push(query.name.to_string()); - } - } - Ok(result) - } -} diff --git a/crates/mozart-registry/src/repository_filter.rs b/crates/mozart-registry/src/repository_filter.rs deleted file mode 100644 index facbb36..0000000 --- a/crates/mozart-registry/src/repository_filter.rs +++ /dev/null @@ -1,136 +0,0 @@ -//! Repository-level package filters (`only`, `exclude`, `canonical`). -//! -//! Mirrors `Composer\Repository\FilterRepository`: a wrapper around an -//! underlying repository that drops packages by name and/or removes the -//! repo's authoritative claim on the names it serves. We model the same -//! semantics for inline `type: package` and local `type: composer` -//! repositories, since the installer fixtures rely on them. - -use mozart_core::package::RawRepository; -use regex::Regex; - -/// Resolved filter for a single `repositories[]` entry. -pub struct RepositoryFilter { - only: Option, - exclude: Option, - /// `canonical: true` (default) — packages from this repo claim their - /// names, suppressing lower-priority repos for the same name. - /// `canonical: false` — packages enter the pool but lower-priority - /// repos may also answer. - pub canonical: bool, -} - -impl RepositoryFilter { - pub fn from_repo(repo: &RawRepository) -> Self { - Self { - only: repo.only.as_ref().and_then(|names| build_name_regex(names)), - exclude: repo - .exclude - .as_ref() - .and_then(|names| build_name_regex(names)), - canonical: repo.canonical.unwrap_or(true), - } - } - - /// `true` if `name` may pass through this filter. - /// Mirrors `FilterRepository::isAllowed`. - pub fn is_allowed(&self, name: &str) -> bool { - if let Some(only) = &self.only { - return only.is_match(name); - } - if let Some(exclude) = &self.exclude { - return !exclude.is_match(name); - } - true - } -} - -/// Build a case-insensitive `^(?:p1|p2|…)$` regex from Composer's pattern -/// list. Mirrors `BasePackage::packageNamesToRegexp` — `*` becomes `.*`, -/// every other regex metacharacter is escaped, and the alternation is -/// anchored to the full string. -fn build_name_regex(patterns: &[String]) -> Option { - if patterns.is_empty() { - return None; - } - let parts: Vec = patterns.iter().map(|p| pattern_to_regex(p)).collect(); - let joined = parts.join("|"); - Regex::new(&format!(r"(?i)^(?:{joined})$")).ok() -} - -fn pattern_to_regex(pattern: &str) -> String { - let escaped = regex::escape(pattern); - // `*` was escaped to `\*` — turn it into `.*` so glob semantics match - // Composer. - escaped.replace(r"\*", ".*") -} - -#[cfg(test)] -mod tests { - use super::*; - - fn repo( - only: Option>, - exclude: Option>, - canonical: Option, - ) -> RawRepository { - RawRepository { - repo_type: "package".to_string(), - url: None, - package: None, - only, - exclude, - canonical, - security_advisories: None, - } - } - - #[test] - fn no_filter_allows_all() { - let f = RepositoryFilter::from_repo(&repo(None, None, None)); - assert!(f.is_allowed("a/a")); - assert!(f.is_allowed("foo/bar")); - assert!(f.canonical); - } - - #[test] - fn only_restricts_to_listed_names() { - let f = RepositoryFilter::from_repo(&repo(Some(vec!["foo/b".to_string()]), None, None)); - assert!(f.is_allowed("foo/b")); - assert!(!f.is_allowed("foo/a")); - } - - #[test] - fn exclude_drops_listed_names() { - let f = RepositoryFilter::from_repo(&repo(None, Some(vec!["foo/c".to_string()]), None)); - assert!(f.is_allowed("foo/a")); - assert!(!f.is_allowed("foo/c")); - } - - #[test] - fn glob_star_expands() { - let f = RepositoryFilter::from_repo(&repo(Some(vec!["foo/*".to_string()]), None, None)); - assert!(f.is_allowed("foo/a")); - assert!(f.is_allowed("foo/anything")); - assert!(!f.is_allowed("bar/a")); - } - - #[test] - fn match_is_case_insensitive() { - let f = RepositoryFilter::from_repo(&repo(Some(vec!["Foo/Bar".to_string()]), None, None)); - assert!(f.is_allowed("foo/bar")); - assert!(f.is_allowed("FOO/BAR")); - } - - #[test] - fn canonical_default_is_true() { - let f = RepositoryFilter::from_repo(&repo(None, None, None)); - assert!(f.canonical); - } - - #[test] - fn canonical_false_honored() { - let f = RepositoryFilter::from_repo(&repo(None, None, Some(false))); - assert!(!f.canonical); - } -} diff --git a/crates/mozart-registry/src/resolver.rs b/crates/mozart-registry/src/resolver.rs deleted file mode 100644 index dc9c6dd..0000000 --- a/crates/mozart-registry/src/resolver.rs +++ /dev/null @@ -1,1999 +0,0 @@ -//! Dependency resolver using the SAT solver. -//! -//! This module fetches package metadata from Packagist, builds a Pool of all -//! candidate packages, generates SAT rules, and runs the CDCL solver to find -//! a compatible set of packages to install. - -use indexmap::{IndexMap, IndexSet}; -use regex::{Captures, Regex}; -use std::fmt; -use std::sync::Arc; -use std::sync::LazyLock; - -use crate::packagist; -use crate::repository::{PackageQuery, RepositorySet}; -use crate::vcs_bridge; -use mozart_core::package::{RawRepository, Stability}; -use mozart_sat_resolver::{ - DefaultPolicy, PoolBuilder, PoolLink, PoolPackageInput, RuleSetGenerator, Solver, - make_pool_links, -}; -use mozart_semver::{Version, VersionConstraint}; - -/// Strip a `@stability` suffix from a constraint string and return the -/// cleaned constraint plus the parsed stability. Mirrors Composer's -/// `RootPackageLoader::extractStabilityFlags` (single-constraint case): -/// `"3.2.*@dev"` → (`"3.2.*"`, `Some(Stability::Dev)`). -pub(crate) fn extract_stability_suffix(constraint: &str) -> (String, Option) { - let trimmed = constraint.trim(); - if let Some(at_pos) = trimmed.rfind('@') { - let suffix = &trimmed[at_pos + 1..]; - let stability = match suffix.to_lowercase().as_str() { - "dev" => Some(Stability::Dev), - "alpha" => Some(Stability::Alpha), - "beta" => Some(Stability::Beta), - "rc" => Some(Stability::RC), - "stable" => Some(Stability::Stable), - _ => None, - }; - if let Some(s) = stability { - let cleaned = trimmed[..at_pos].trim().to_string(); - // An empty constraint left after the strip means "any version" — - // mirrors Composer's `@dev` shorthand (no version constraint). - let cleaned = if cleaned.is_empty() { - "*".to_string() - } else { - cleaned - }; - return (cleaned, Some(s)); - } - } - (trimmed.to_string(), None) -} - -/// Mirror Composer's `VersionParser::parseStability` for a single-atom -/// constraint string (no `@flag` suffix). Returns `Some(stability)` for -/// recognised non-stable constraints (`dev-foo`, `1.0.x-dev`, `1.0.0-beta1`, -/// …), `None` for stable or unrecognised forms (in which case -/// `minimum_stability` already applies). -/// -/// Composer first strips a trailing `#hash` (handled here), then checks -/// `dev-` prefix / `-dev` suffix / a `(stab)?\d*` modifier. We follow the -/// same shape — the regex variant is overkill for inferring a flag. -pub(crate) fn infer_constraint_stability(constraint: &str) -> Option { - let s = constraint.trim(); - // Strip `#ref` (matches Composer's `parseStability` line 54). - let s = match s.find('#') { - Some(p) => &s[..p], - None => s, - }; - // Reject multi-atom constraints — extractStabilityFlags inspects each - // sub-constraint individually but the most common single-atom case is - // all we need for `dev-foo` / `1.0.x-dev` style root requires. - if s.contains([' ', ',']) || s.contains("||") { - return None; - } - // Strip a leading comparison operator (`>=1.0-beta` → `1.0-beta`). - let s = s - .strip_prefix(">=") - .or_else(|| s.strip_prefix("<=")) - .or_else(|| s.strip_prefix("!=")) - .or_else(|| s.strip_prefix("==")) - .or_else(|| s.strip_prefix('>')) - .or_else(|| s.strip_prefix('<')) - .or_else(|| s.strip_prefix('=')) - .or_else(|| s.strip_prefix('^')) - .or_else(|| s.strip_prefix('~')) - .unwrap_or(s); - let lower = s.to_lowercase(); - if lower.starts_with("dev-") || lower.ends_with("-dev") { - return Some(Stability::Dev); - } - // Match `` at the end after the last `-`/`@`. - // Composer uses `{(stable|RC|beta|alpha|dev)([.-]?\d+)?(?:\+.*)?$}`. - let tail = lower - .rsplit_once('-') - .or_else(|| lower.rsplit_once('@')) - .map(|(_, t)| t) - .unwrap_or(&lower); - let tail_word: String = tail.chars().take_while(|c| c.is_alphabetic()).collect(); - match tail_word.as_str() { - "alpha" | "a" => Some(Stability::Alpha), - "beta" | "b" => Some(Stability::Beta), - "rc" => Some(Stability::RC), - "patch" | "pl" | "p" | "stable" => Some(Stability::Stable), - _ => None, - } -} - -/// Determine the `Stability` of a `Version` from its pre_release string. -pub(crate) fn version_stability(v: &Version) -> Stability { - match &v.pre_release { - None => Stability::Stable, - Some(pre) => { - let lower = pre.to_lowercase(); - if lower.starts_with("dev") { - Stability::Dev - } else if lower.starts_with("alpha") || lower.starts_with('a') { - Stability::Alpha - } else if lower.starts_with("beta") || lower.starts_with('b') { - Stability::Beta - } else if lower.starts_with("rc") { - Stability::RC - } else { - // patch/pl/p and unknown → stable - Stability::Stable - } - } - } -} - -/// Parse a Packagist normalized version string like "1.2.3.0", "1.0.0.0-beta1". -/// Returns `None` for dev branches (dev-master, dev-*, *.x-dev). -pub(crate) fn parse_normalized(normalized: &str) -> Option { - let s = normalized.trim(); - - // Reject dev branches - if s.to_lowercase().starts_with("dev-") { - return None; - } - // Reject *.x-dev style - if s.to_lowercase().ends_with("-dev") && s.contains(".x") { - return None; - } - // Packagist uses 9999999.9999999.9999999.9999999 for dev branches - if s.starts_with("9999999") { - return None; - } - - Version::parse(s).ok() -} - -/// Parse a branch alias target like "2.x-dev" or "1.0.x-dev" into a `Version` with dev pre-release. -fn parse_branch_alias_target(alias_target: &str) -> Option { - let s = alias_target.trim().to_lowercase(); - if !s.ends_with("-dev") { - return None; - } - let base = &s[..s.len() - 4]; - let base = base.trim_end_matches(".x"); - let parts: Vec<&str> = base.split('.').collect(); - let major: u64 = parts.first().and_then(|p| p.parse().ok())?; - let minor: u64 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); - let patch: u64 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0); - let build: u64 = parts.get(3).and_then(|p| p.parse().ok()).unwrap_or(0); - Some(Version { - major, - minor, - patch, - build, - pre_release: Some("dev".to_string()), - is_dev_branch: false, - dev_branch_name: None, - }) -} - -/// Mirror Composer's `VersionParser::parseNumericAliasPrefix`: returns true -/// when the input is a numeric branch like `1.2-dev` / `1.2.3-dev` / -/// `1.2.x-dev` (i.e. the prefix is suitable for version comparison). -/// Non-numeric branches like `dev-main` / `dev-feature/x` return false. -fn has_numeric_alias_prefix(branch: &str) -> bool { - let lower = branch.trim().to_lowercase(); - let lower = lower.strip_prefix('v').unwrap_or(&lower); - let Some(base) = lower.strip_suffix("-dev") else { - return false; - }; - let base = base.strip_suffix(".x").unwrap_or(base); - if base.is_empty() { - return false; - } - // Allow only digit segments separated by `.`. - base.split('.') - .all(|seg| !seg.is_empty() && seg.chars().all(|c| c.is_ascii_digit())) -} - -/// Mirror Composer's `VersionParser::normalizeBranch` for branch-alias -/// targets: turn a string like `"3.2.x-dev"` into the canonical numeric form -/// `"3.2.9999999.9999999-dev"`. Returns `None` if the input is not a numeric -/// branch (i.e. cannot be expanded to a four-segment numeric version). -/// -/// Composer's flow for an `extra.branch-alias` value: -/// 1. Strip the trailing `-dev`. -/// 2. Pad missing segments with `.x`. -/// 3. Replace each `x` with `9999999`. -/// 4. Re-append `-dev`. -/// -/// This is the form Composer's `Locker::lockPackages` writes into the -/// `aliases` block of `composer.lock` and the form `Pool` indexes for -/// constraint matching, so Mozart needs to use it too. -pub fn normalize_branch_alias_target(alias_target: &str) -> Option { - let trimmed = alias_target.trim(); - let lower = trimmed.to_lowercase(); - let base = lower.strip_suffix("-dev")?; - // Strip leading v/V before normalizing, mirroring Composer's regex - let base = base.strip_prefix('v').unwrap_or(base); - let mut segments: Vec = Vec::with_capacity(4); - for seg in base.split('.') { - if seg == "x" || seg == "X" || seg == "*" { - segments.push("x".to_string()); - } else if seg.chars().all(|c| c.is_ascii_digit()) && !seg.is_empty() { - segments.push(seg.to_string()); - } else { - return None; - } - } - if segments.is_empty() { - return None; - } - while segments.len() < 4 { - segments.push("x".to_string()); - } - let expanded: Vec = segments - .into_iter() - .map(|s| if s == "x" { "9999999".to_string() } else { s }) - .collect(); - Some(format!("{}-dev", expanded.join("."))) -} - -/// Mirror Composer's `VersionParser::normalize` for the values that appear on -/// either side of an `as` clause (`require: "1.0.x-dev as dev-master"`). -/// -/// Composer sends both sides through `normalize`, which: -/// - Maps bare `master` / `trunk` / `default` to the `dev-` prefixed form -/// (`master` → `dev-master`) for BC with Composer 1, then returns -/// `dev-NAME` unchanged. Inline `type: package` entries for these branches -/// land in the pool under the same literal `dev-NAME` form, so root aliases -/// declared with the matching atom must point at that same string. -/// - Strips a leading `v` and treats numeric `*.x-dev` branches via -/// `normalizeBranch` (= `normalize_branch_alias_target`). -/// - Leaves other `dev-NAME` strings as `dev-NAME`. -fn normalize_root_alias_atom(atom: &str) -> Option { - let trimmed = atom.trim(); - if trimmed.is_empty() { - return None; - } - let lower = trimmed.to_lowercase(); - // Composer's normalize: bare `master` / `trunk` / `default` get the - // `dev-` prefix prepended for BC, then fall through to the `dev-` - // branch below. - let with_prefix = if matches!(lower.as_str(), "master" | "trunk" | "default") { - format!("dev-{lower}") - } else { - trimmed.to_string() - }; - let lower_pref = with_prefix.to_lowercase(); - if let Some(rest) = lower_pref.strip_prefix("dev-") { - return Some(format!("dev-{rest}")); - } - if let Some(numeric) = normalize_branch_alias_target(&with_prefix) { - return Some(numeric); - } - // Stable numeric atoms (e.g. `1.1.1`) need to come back in the - // four-segment form `Version::Display` produces, so the alias - // matcher's `input.version != alias.version_normalized` check lines - // up with pool inputs (which carry the 4-segment normalized form). - // Returning the raw input here would silently never match. - parse_normalized(&with_prefix).map(|v| v.to_string()) -} - -/// A root-level alias declared via the `require: "X as Y"` shorthand on the -/// root composer.json. Mirrors Composer's -/// `RootPackageLoader::extractAliases` entries: when the resolver loads a -/// package matching `(package, version_normalized)`, it materializes an extra -/// alias entry exposing the same install under `alias_normalized`/`alias`. -#[derive(Debug, Clone)] -struct RootAlias { - package: String, - /// Normalized form of the LEFT-hand side (the actual constraint). - version_normalized: String, - /// Pretty form of the RIGHT-hand side (the alias to expose). - alias: String, - /// Normalized form of the RIGHT-hand side. - alias_normalized: String, -} - -/// Composer's `RootPackageLoader::extractAliases` regex. Finds every -/// ` as ` clause inside a constraint string, including those -/// nested in OR / AND expressions (e.g. `1.*||dev-feature-foo as 1.0.2||^2` -/// or `dev-feature-foo, dev-feature-foo as 1.0.2`). The optional `#hex` -/// suffix on the LEFT atom is captured but excluded from the alias target, -/// matching `RootPackageLoader::extractReferences` which records refs out -/// of band. -static ALIAS_CLAUSE_RE: LazyLock = LazyLock::new(|| { - Regex::new( - r"(?P^|\| *|, *)(?P[^,\s#|]+)(?:#[^ ]+)? +as +(?P[^,\s|]+)(?P$| *\|| *,)", - ) - .expect("alias clause regex compiles") -}); - -/// Strip every ` as ` clause from a constraint string. Returns the -/// cleaned constraint plus an entry per alias. Mirrors Composer's -/// `VersionParser::parseConstraint` `as`-strip combined with -/// `RootPackageLoader::extractAliases`: the constraint passed to the -/// resolver is the LEFT side of each atom, and a separate alias entry is -/// recorded for each RIGHT side so `RootAliasPackage`-style virtual -/// packages can be materialized later. A trailing `#hex` reference -/// (`dev-main#abcd`) on the LEFT atom is also stripped from the cleaned -/// constraint — `RootPackageLoader::extractReferences` records the hash -/// out of band for the post-resolve `setSourceDistReferences` pass. -fn strip_root_alias_clause(constraint: &str) -> (String, Vec<(String, String)>) { - let trimmed = constraint.trim(); - let mut aliases: Vec<(String, String)> = Vec::new(); - let cleaned = ALIAS_CLAUSE_RE.replace_all(trimmed, |caps: &Captures<'_>| { - let sep = caps.name("sep").map_or("", |m| m.as_str()); - let left = caps.name("left").map_or("", |m| m.as_str()); - let right = caps.name("right").map_or("", |m| m.as_str()); - let after = caps.name("after").map_or("", |m| m.as_str()); - let cleaned_left = strip_inline_reference(left); - aliases.push((cleaned_left.clone(), right.to_string())); - format!("{sep}{cleaned_left}{after}") - }); - if aliases.is_empty() { - return (strip_inline_reference(trimmed), aliases); - } - (cleaned.into_owned(), aliases) -} - -/// Drop a trailing `#hex` reference from a single-atom `dev-*` / `*-dev` -/// constraint, matching Composer's `'{^[^,\s@]+?#([a-f0-9]+)$}'` guard. -/// Lockfile generation records the reference separately via -/// `extract_root_references` and applies it after resolution, so the SAT -/// constraint itself only needs the bare branch name. -fn strip_inline_reference(s: &str) -> String { - if let Some((head, hash)) = s.rsplit_once('#') - && !hash.is_empty() - && hash.chars().all(|c| c.is_ascii_hexdigit()) - && !head.contains([' ', '\t', ',', '@']) - && (head.to_lowercase().starts_with("dev-") || head.to_lowercase().ends_with("-dev")) - { - return head.to_string(); - } - s.to_string() -} - -/// 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-*, composer pseudo packages). - pub fn is_platform(&self) -> bool { - mozart_core::platform::is_platform_package(&self.0) - } - - /// Returns true if this is the virtual root package. - pub fn is_root(&self) -> bool { - self.0 == Self::ROOT - } -} - -/// Platform package configuration. -/// Maps package names to version strings (normalized, e.g. "8.1.0.0"). -pub struct PlatformConfig { - pub packages: IndexMap, -} - -impl Default for PlatformConfig { - fn default() -> Self { - Self::new() - } -} - -impl PlatformConfig { - /// Detect platform packages from the local PHP installation. - pub fn new() -> Self { - let detected = mozart_core::platform::detect_platform(); - let mut packages = IndexMap::new(); - for pkg in detected { - packages.insert(pkg.name, pkg.version); - } - Self { packages } - } - - /// Apply `config.platform` overrides on top of the detected packages. - /// - /// Mirrors `Composer\Repository\PlatformRepository::__construct`'s - /// `$overrides` handling: each override either replaces a detected - /// package version or adds a virtual one (e.g. `ext-dummy`). A `false` - /// value disables the package, removing it from the platform. - pub fn apply_overrides(&mut self, overrides: &serde_json::Value) { - let Some(obj) = overrides.as_object() else { - return; - }; - for (name, value) in obj { - let key = name.to_lowercase(); - if value.as_bool() == Some(false) { - self.packages.shift_remove(&key); - continue; - } - if let Some(s) = value.as_str() { - self.packages.insert(key, s.to_string()); - } - } - } - - /// Parse platform packages into `Version` values. - pub fn to_versions(&self) -> IndexMap { - self.packages - .iter() - .filter_map(|(name, version_str)| { - Version::parse(version_str).ok().map(|v| (name.clone(), v)) - }) - .collect() - } -} - -/// 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 {} - -/// Check if a version passes the minimum-stability filter for the given package. -fn passes_stability_filter( - package_name: &str, - version: &Version, - minimum_stability: Stability, - stability_flags: &IndexMap, -) -> bool { - let min_stability = stability_flags - .get(package_name) - .copied() - .unwrap_or(minimum_stability); - let vs = version_stability(version); - vs <= min_stability -} - -/// Check whether a platform dependency should be skipped. -fn should_skip_platform_dep( - dep_name: &str, - ignore_platform_reqs: bool, - ignore_platform_req_list: &[String], -) -> bool { - if !PackageName(dep_name.to_string()).is_platform() { - return false; - } - if ignore_platform_reqs { - return true; - } - ignore_platform_req_list - .iter() - .any(|p| mozart_core::matches_wildcard(dep_name, p)) -} - -/// Mirrors `Composer\Package\CompletePackage::isAbandoned`: any -/// `abandoned: true` or `abandoned: ""` value is truthy. -/// `abandoned: false` and an empty string both register as not-abandoned. -fn is_abandoned(pv: &packagist::PackagistVersion) -> bool { - match &pv.abandoned { - None => false, - Some(serde_json::Value::Null) => false, - Some(serde_json::Value::Bool(b)) => *b, - Some(serde_json::Value::String(s)) => !s.is_empty(), - Some(_) => true, - } -} - -/// Convert a Packagist version entry to PoolPackageInput(s). -/// May return multiple entries if branch aliases are present. -fn packagist_to_pool_inputs( - package_name: &str, - pv: &packagist::PackagistVersion, - minimum_stability: Stability, - stability_flags: &IndexMap, -) -> Vec { - let mut results = Vec::new(); - - let make_input = |version_str: &str, - version_normalized: &str, - is_alias_of: Option| - -> PoolPackageInput { - PoolPackageInput { - name: package_name.to_string(), - version: version_normalized.to_string(), - pretty_version: version_str.to_string(), - requires: make_pool_links( - package_name, - version_normalized, - &pv.require - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect::>(), - ), - replaces: make_pool_links( - package_name, - version_normalized, - &pv.replace - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect::>(), - ), - provides: make_pool_links( - package_name, - version_normalized, - &pv.provide - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect::>(), - ), - conflicts: make_pool_links( - package_name, - version_normalized, - &pv.conflict - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect::>(), - ), - is_fixed: false, - is_alias_of, - } - }; - - match parse_normalized(&pv.version_normalized) { - Some(v) => { - if passes_stability_filter(package_name, &v, minimum_stability, stability_flags) { - results.push(make_input(&pv.version, &pv.version_normalized, None)); - } - } - None => { - // Dev branch — emit the original entry (so the alias has a target - // to point at) and one alias entry per matching `extra.branch-alias`. - // Mirrors Composer's `ArrayRepository::addPackage` which adds the - // base package and then calls `createAliasPackage` for each - // branch-alias declaration on it. - let original_passes = passes_stability_filter( - package_name, - &Version { - major: 0, - minor: 0, - patch: 0, - build: 0, - pre_release: Some("dev".to_string()), - is_dev_branch: true, - dev_branch_name: None, - }, - minimum_stability, - stability_flags, - ); - if !original_passes { - return results; - } - results.push(make_input(&pv.version, &pv.version_normalized, None)); - - let aliases = pv.branch_aliases(); - let mut emitted_explicit_alias = false; - for (branch, alias_target) in &aliases { - if branch.to_lowercase() != pv.version.to_lowercase() { - continue; - } - if parse_branch_alias_target(alias_target).is_none() { - continue; - } - let Some(alias_normalized) = normalize_branch_alias_target(alias_target) else { - continue; - }; - results.push(make_input( - alias_target, - &alias_normalized, - Some(pv.version_normalized.clone()), - )); - emitted_explicit_alias = true; - } - - // Mirror Composer's `ArrayLoader::getBranchAlias`: when a - // `dev-` package carries `default-branch: true` and the version - // has no numeric prefix (i.e. it isn't already a `1.0.x-dev` form - // that would be its own alias), synthesize the `9999999-dev` - // alias so root constraints like `dev-main` pick up a default - // branch surfaced as `9999999-dev` in the lock + trace output. - // - // `getBranchAlias` returns the *first* matching branch-alias when - // one exists — i.e. an explicit `branch-alias` entry takes - // precedence over the `default-branch` synthetic one. Skip the - // synthetic alias when an explicit one has already been emitted - // for this version. - if pv.default_branch - && !emitted_explicit_alias - && !has_numeric_alias_prefix(&pv.version) - { - let default_alias = "9999999-dev"; - let default_normalized = "9999999.9999999.9999999.9999999-dev"; - let already_present = results - .iter() - .any(|r| r.version == default_normalized && r.name == package_name); - if !already_present { - results.push(make_input( - default_alias, - default_normalized, - Some(pv.version_normalized.clone()), - )); - } - } - } - } - - results -} - -/// Input to the resolver. -pub struct ResolveRequest { - /// Root package name from composer.json "name" field (e.g. "laravel/laravel"). - /// Used in error messages. Falls back to `__root__` if empty. - pub root_name: String, - /// Root package version from composer.json "version" field. `None` falls - /// back to Composer's `RootPackage::DEFAULT_PRETTY_VERSION` (1.0.0+no-version-set). - /// Used to seed a fixed pool entry for the root so transitive requires - /// pointing at the root (legal circular dependencies via an intermediate - /// package) can be satisfied. - pub root_version: Option, - /// 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: IndexMap, - /// 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, - /// Repository set used to fetch package metadata. Mirrors Composer's - /// `RepositoryManager`. Production builders construct this with a single - /// `PackagistRepository`; in-process test harnesses can construct one - /// without any HTTP-backed repos to mimic Composer's - /// `'packagist' => false` test config. - pub repositories: Arc, - /// Temporary version constraint overrides (from --with flag). - /// Maps package name (lowercase) to constraint string. - pub temporary_constraints: IndexMap, - /// VCS / inline-package repository entries from composer.json's - /// `repositories` section, used by the eager VCS scan and inline-package - /// preload that still live in `resolve()` (Step B follow-up will move - /// these through `RepositorySet` too). - pub raw_repositories: Vec, - /// Root composer.json's `provide` map (target → constraint string). Drives - /// the self-fulfilling-rule check in the SAT generator: when a root - /// `require` names something the root itself `provide`s with a matching - /// constraint, no install-one-of rule is emitted, mirroring Composer's - /// `RuleSetGenerator::createRequireRule` self-fulfillment branch. - pub root_provide: IndexMap, - /// Root composer.json's `replace` map. Same role as `root_provide` for the - /// `replace` link: a replaced target counts as fulfilled by the root. - pub root_replace: IndexMap, - /// Root composer.json's `conflict` map (target → constraint). Composer's - /// `RootPackageRepository` carries these onto the in-pool root package - /// entry; the SAT generator then forbids any candidate matching the - /// constraint, so a root `conflict` blocks both direct selection of the - /// targeted version and any alias / replace / provide that would resolve - /// to it. - pub root_conflict: IndexMap, - /// Lowercase names of packages that are pinned to their lock-file version - /// for this resolve (a partial update where the package is not in the - /// update list). Mirrors the `propagateUpdate=false` branch of Composer's - /// `PoolBuilder::loadPackage`: locked-only packages do not pick up - /// `require: "X as Y"` root aliases. Empty for installs and full updates, - /// where every package can take aliases as usual. - pub locked_package_names: IndexSet, - /// Full data of packages pinned to their lock-file version (a partial - /// update). Each entry is added to the pool as a fixed entry, mirroring - /// Composer's `Request::lockPackage` + `PoolBuilder::buildPool`'s - /// `getFixedOrLockedPackages` loop: a locked-only package's pretty/normalized - /// version, requires, replaces, provides and conflicts all enter the pool - /// at exactly one version, so the SAT solver cannot pick a different - /// version (whether directly or via another package's `replace`). Empty - /// for installs and full updates. - pub locked_packages: Vec, - /// When true, drop abandoned packages (`abandoned: true|`) - /// from the pool before solving. Mirrors Composer's - /// `audit.block-abandoned` config feeding into - /// `SecurityAdvisoryPoolFilter`: the resolver simply never sees these - /// versions, so a root requirement that only matches abandoned candidates - /// fails with the standard "could not be resolved" error. - pub block_abandoned: bool, - /// Pretty form of the root's `extra.branch-alias` target when the root's - /// version matches a key in that map (e.g. `dev-master` → `2.0-dev`). - /// Mirrors Composer's `RootAliasPackage`: an extra alias entry is added - /// to the pool exposing the root under the numeric branch-alias version, - /// with `replace`/`provide`/`conflict` links extended to advertise the - /// alias's version for any link originally written as `self.version`. - /// `None` when the root carries no matching `branch-alias` entry. - pub root_branch_alias: Option, - /// `name → normalized version` map fed to the policy's preferred-version - /// override. Used by `update --minimal-changes` so the solver only moves - /// a package when a constraint actually forces a different version. - /// Empty for a normal full update. - pub preferred_versions: IndexMap, - /// When true, drop versions the repositories advertise as covered by an - /// active security advisory before solving. Mirrors Composer's - /// `SecurityAdvisoryPoolFilter` under `config.audit.block-insecure: true`. - pub block_insecure: bool, -} - -/// Full data for a lock-pinned package, used in partial updates. Carried on -/// `ResolveRequest::locked_packages` and turned into a fixed pool entry -/// inside `resolve()`. Mirrors what Composer's `PoolBuilder` reads off a -/// `BasePackage` retrieved from the locked repository. -pub struct LockedPackageInfo { - pub name: String, - /// Pretty (display) version, e.g. "1.2.3". - pub pretty_version: String, - /// Normalized version, e.g. "1.2.3.0". - pub version_normalized: String, - pub requires: Vec<(String, String)>, - pub replaces: Vec<(String, String)>, - pub provides: Vec<(String, String)>, - pub conflicts: Vec<(String, String)>, - /// Branch-alias entries to surface alongside the base locked package, as - /// `(pretty, normalized)` pairs. Mirrors what - /// `Composer\Package\Locker::getLockedRepository` constructs from - /// `extra.branch-alias`: a `dev-master` locked package with branch alias - /// `2.1.x-dev` needs to expose itself under both versions so root - /// constraints like `~2.1` still resolve on a partial update. - pub branch_aliases: Vec<(String, String)>, -} - -/// 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, - /// When `Some`, this entry is an `AliasPackage` rather than a real - /// install target. The value is the target's normalized version, used - /// by lock-file generation to populate the `aliases[]` block (and by - /// the installer to emit `Marking ... as installed, alias of ...` - /// trace lines). Real packages have `alias_of: None`. - pub alias_of_normalized: Option, -} - -/// Run the dependency resolver. -/// -/// Returns a list of resolved packages (excluding root and platform packages), -/// or a human-readable error. -pub async fn resolve(request: &ResolveRequest) -> Result, ResolveError> { - // 1. Build root requirements - let mut root_requires: IndexMap> = IndexMap::new(); - // Per-package stability overrides extracted from `@dev`/`@beta`/etc. - // suffixes on root constraints. Mirrors Composer's - // `RootPackageLoader::extractStabilityFlags`. Merged on top of the - // request's caller-supplied flags (which today are usually empty). - let mut stability_flags: IndexMap = request.stability_flags.clone(); - // Root-level aliases extracted from `require: "X as Y"`. Mirrors - // Composer's `RootPackageLoader::extractAliases`: each entry adds a new - // alias package to the pool exposing the matched real package under the - // RIGHT-hand version label. - let mut root_aliases: Vec = Vec::new(); - - let minimum_stability = request.minimum_stability; - let mut insert_root_require = |name: &str, constraint: &str| { - // Strip every ` as ` clause first (mirrors Composer's - // `parseConstraint` strip + `extractAliases` capture). The cleaned - // constraint feeds the resolver; each alias is recorded for a second - // pool-population pass once real packages are in. Complex constraints - // (`1.*||dev-feature-foo as 1.0.2||^2`) yield one alias entry plus a - // constraint with the ` as ` segment removed in place. - let (constraint_no_as, alias_pieces) = strip_root_alias_clause(constraint); - for (target_atom, alias_atom) in alias_pieces { - let (Some(target_normalized), Some(alias_normalized)) = ( - normalize_root_alias_atom(&target_atom), - normalize_root_alias_atom(&alias_atom), - ) else { - continue; - }; - root_aliases.push(RootAlias { - package: name.to_lowercase(), - version_normalized: target_normalized, - alias: alias_atom, - alias_normalized, - }); - } - let (clean, stability) = extract_stability_suffix(&constraint_no_as); - let lower = name.to_lowercase(); - if let Some(s) = stability { - let entry = stability_flags.entry(lower.clone()).or_insert(s); - if (*entry as u8) > (s as u8) { - *entry = s; - } - } else if let Some(inferred) = infer_constraint_stability(&clean) { - // Mirrors `RootPackageLoader::extractStabilityFlags` second loop: - // when a single-atom constraint like `dev-main` or `1.0.x-dev` - // implies a non-stable stability and no explicit `@flag` was - // given, raise that package's stability ceiling so the pool - // accepts it. Only applied when the inferred level is *more* - // permissive than `minimum_stability` and any existing flag. - if (inferred as u8) > (minimum_stability as u8) { - let entry = stability_flags.entry(lower.clone()).or_insert(inferred); - if (*entry as u8) < (inferred as u8) { - *entry = inferred; - } - } - } - root_requires.insert(lower, Some(clean)); - }; - - for (name, constraint) in &request.require { - if should_skip_platform_dep( - name, - request.ignore_platform_reqs, - &request.ignore_platform_req_list, - ) { - continue; - } - insert_root_require(name, constraint); - } - - if request.include_dev { - for (name, constraint) in &request.require_dev { - if should_skip_platform_dep( - name, - request.ignore_platform_reqs, - &request.ignore_platform_req_list, - ) { - continue; - } - insert_root_require(name, constraint); - } - } - - // Apply temporary constraints (from --with flag or inline shorthand). - // These override existing root constraints or add new ones for transitive deps. - for (name, constraint) in &request.temporary_constraints { - insert_root_require(name, constraint); - } - - // 2. Build pool, generate rules, and solve - let mut builder = PoolBuilder::new(); - - // Set up ignore list for platform requirements - let mut ignore_set: IndexSet = IndexSet::new(); - for name in &request.ignore_platform_req_list { - ignore_set.insert(name.clone()); - } - builder.set_ignore_platform_reqs(ignore_set.clone()); - builder.set_ignore_all_platform_reqs(request.ignore_platform_reqs); - - // Add platform packages as fixed entries - let platform_config = request.platform.to_versions(); - let mut fixed_packages_by_name: IndexMap = IndexMap::new(); - for (name, version) in &platform_config { - if should_skip_platform_dep( - name, - request.ignore_platform_reqs, - &request.ignore_platform_req_list, - ) { - continue; - } - let input = PoolPackageInput { - name: name.clone(), - version: version.to_string(), - pretty_version: version.to_string(), - requires: vec![], - replaces: vec![], - provides: vec![], - conflicts: vec![], - is_fixed: true, - is_alias_of: None, - }; - builder.add_package(input); - } - - // Mirror Composer's `RootPackageRepository`: put the root package itself - // in the pool as a fixed entry so transitive requires pointing at the - // root (legal circular dependencies via an intermediate package) can - // resolve. Composer clears the root's `require` / `require-dev` on this - // copy because the root requires are already plumbed through the - // rule generator's root-require path; carrying them here too would - // emit duplicate rules. Provide / replace links survive, so virtual - // packages declared on the root keep working for transitive consumers. - let root_name_lower = request.root_name.to_lowercase(); - if !root_name_lower.is_empty() { - let (root_pretty, root_normalized) = match request.root_version.as_deref() { - Some(v) if !v.is_empty() => (v.to_string(), v.to_string()), - _ => ("1.0.0+no-version-set".to_string(), "1.0.0.0".to_string()), - }; - // Resolve `self.version` against the root's normalized version when - // building base links. Mirrors Composer's `ArrayLoader::createLink`: - // a `self.version` constraint is parsed against the declaring package's - // pretty version (here, the root's). The base entry only carries this - // resolved form; any branch-alias entry below extends each base link - // with an extra link tagged at the alias's version, matching - // `AliasPackage::replaceSelfVersionDependencies`. - let make_base_links = |raw: &IndexMap| -> Vec { - raw.iter() - .map(|(target, constraint)| PoolLink { - target: target.to_lowercase(), - constraint: if constraint.trim() == "self.version" { - root_normalized.clone() - } else { - constraint.clone() - }, - source: root_name_lower.clone(), - }) - .collect() - }; - let base_replaces = make_base_links(&request.root_replace); - let base_provides = make_base_links(&request.root_provide); - let base_conflicts = make_base_links(&request.root_conflict); - let root_input = PoolPackageInput { - name: root_name_lower.clone(), - version: root_normalized.clone(), - pretty_version: root_pretty.clone(), - requires: vec![], - replaces: base_replaces.clone(), - provides: base_provides.clone(), - conflicts: base_conflicts.clone(), - is_fixed: true, - is_alias_of: None, - }; - builder.add_package(root_input); - - // Materialize a branch-alias entry for the root when `extra.branch-alias` - // mapped this version to a numeric alias (e.g. dev-master → 2.0-dev). - // Mirrors Composer's `RootAliasPackage`: the alias copies the base's - // resolved replace/provide/conflict links and then ADDS one more link - // per `self.version` original, this time pinned at the alias's own - // version. So a transitive `provided/dependency 2.*` lookup can be - // satisfied through the alias even though the base resolved - // `self.version` to a non-matching dev version. - if let Some(alias_pretty) = &request.root_branch_alias - && let Some(alias_normalized) = normalize_branch_alias_target(alias_pretty) - { - let extra_self_version_links = |raw: &IndexMap| -> Vec { - raw.iter() - .filter(|(_, constraint)| constraint.trim() == "self.version") - .map(|(target, _)| PoolLink { - target: target.to_lowercase(), - constraint: alias_normalized.clone(), - source: root_name_lower.clone(), - }) - .collect() - }; - let mut alias_replaces = base_replaces.clone(); - alias_replaces.extend(extra_self_version_links(&request.root_replace)); - let mut alias_provides = base_provides.clone(); - alias_provides.extend(extra_self_version_links(&request.root_provide)); - let mut alias_conflicts = base_conflicts.clone(); - alias_conflicts.extend(extra_self_version_links(&request.root_conflict)); - builder.add_package(PoolPackageInput { - name: root_name_lower.clone(), - version: alias_normalized, - pretty_version: alias_pretty.clone(), - requires: vec![], - replaces: alias_replaces, - provides: alias_provides, - conflicts: alias_conflicts, - is_fixed: false, - is_alias_of: Some(root_normalized), - }); - } - } - - // Add lock-pinned packages as pool entries (partial-update case). - // - // Mirrors Composer's `PoolBuilder::buildPool` flow: every locked package - // not in the `updateAllowList` is added through `Request::lockPackage`, - // then re-entered into the pool via the `getFixedOrLockedPackages` - // loop. Crucially, a *locked* package is NOT a *fixed* package - // (Request.php:89-98): the SAT solver does not force its installation, - // so a locked package whose root require has been removed will simply - // drop out of the result. The locked entry's purpose is to constrain - // the pool to *only* the locked version for that name — every other - // version is filtered out below — so other packages cannot pick a - // different version (whether directly, or via `replace`, which would - // otherwise let an upgraded replacer silently drop the dependency). - // - // Pre-check: a locked package whose version is rejected by the - // current minimum-stability (composer.json may have tightened - // stability or dropped a `stability-flags` entry the lock relied on) - // cannot be reused as a fixed pool entry. Mirrors what Composer - // surfaces via `Pool::isUnacceptableFixedOrLockedPackage` + - // `Problem::getPrettyString`: bail with the "fixed to (lock file - // version) but that version is rejected by your minimum-stability" - // pointer so the user knows to add the package to the update - // arguments (or use `--with-all-dependencies`). - { - let mut rejected: Vec = Vec::new(); - for locked in &request.locked_packages { - let Ok(v) = Version::parse(&locked.version_normalized) else { - continue; - }; - if !passes_stability_filter( - &locked.name, - &v, - request.minimum_stability, - &stability_flags, - ) { - rejected.push(format!( - " - {} is fixed to {} (lock file version) by a partial update but that version is rejected by your minimum-stability. Make sure you list it as an argument for the update command.", - locked.name, locked.pretty_version - )); - } - } - if !rejected.is_empty() { - let report = rejected - .into_iter() - .enumerate() - .map(|(i, msg)| format!(" Problem {}\n{}", i + 1, msg)) - .collect::>() - .join("\n"); - return Err(ResolveError::NoSolution(report)); - } - } - - // Build a map first so the filter below knows which (name, version) - // pairs are the only allowed entries for locked names. Each entry holds - // the locked normalized version plus any branch-alias normalized - // versions Composer's `Locker::getLockedRepository` would expose - // alongside the base. Without the alias entries, an inline-package or - // VCS source providing the same `dev-master` + alias as the lock would - // have its alias filtered out, leaving root constraints like `~2.1` — - // which can only match the alias version, not the raw `dev-master` — - // unsatisfiable on a partial update. - let locked_name_to_versions: IndexMap> = request - .locked_packages - .iter() - .map(|p| { - let mut versions = vec![p.version_normalized.clone()]; - for (_, alias_normalized) in &p.branch_aliases { - versions.push(alias_normalized.clone()); - } - (p.name.to_lowercase(), versions) - }) - .collect(); - let lock_filter_allows = |name: &str, version: &str| -> bool { - match locked_name_to_versions.get(&name.to_lowercase()) { - Some(locked_versions) => locked_versions.iter().any(|v| v == version), - None => true, - } - }; - for locked in &request.locked_packages { - let locked_name_lower = locked.name.to_lowercase(); - let input = PoolPackageInput { - name: locked_name_lower.clone(), - version: locked.version_normalized.clone(), - pretty_version: locked.pretty_version.clone(), - requires: make_pool_links( - &locked_name_lower, - &locked.version_normalized, - &locked.requires, - ), - replaces: make_pool_links( - &locked_name_lower, - &locked.version_normalized, - &locked.replaces, - ), - provides: make_pool_links( - &locked_name_lower, - &locked.version_normalized, - &locked.provides, - ), - conflicts: make_pool_links( - &locked_name_lower, - &locked.version_normalized, - &locked.conflicts, - ), - is_fixed: false, - is_alias_of: None, - }; - builder.add_package(input); - // Also expose each `extra.branch-alias` entry as a separate pool - // package, mirroring `Composer\Package\Locker::getLockedRepository` - // (which calls `ArrayLoader::load`, which materializes the - // branch-alias via `getBranchAlias`). Without this, a `dev-master` - // locked package with branch alias `2.2.x-dev` is only visible - // under `dev-master` in the pool, so root requires like `~2.1` - // see no candidate and the resolver fails on a partial update. - for (alias_pretty, alias_normalized) in &locked.branch_aliases { - builder.add_package(PoolPackageInput { - name: locked_name_lower.clone(), - version: alias_normalized.clone(), - pretty_version: alias_pretty.clone(), - requires: make_pool_links(&locked_name_lower, alias_normalized, &locked.requires), - replaces: make_pool_links(&locked_name_lower, alias_normalized, &locked.replaces), - provides: make_pool_links(&locked_name_lower, alias_normalized, &locked.provides), - conflicts: make_pool_links(&locked_name_lower, alias_normalized, &locked.conflicts), - is_fixed: false, - is_alias_of: Some(locked.version_normalized.clone()), - }); - } - } - - // Scan VCS repositories and collect packages from them - let vcs_packages = vcs_bridge::scan_vcs_repositories(&request.raw_repositories).await; - let mut vcs_package_names: IndexSet = IndexSet::new(); - for vpkg in &vcs_packages { - vcs_package_names.insert(vpkg.name.clone()); - } - - // Add VCS packages to the pool - for vpkg in &vcs_packages { - let inputs = - vcs_bridge::vcs_to_pool_inputs(vpkg, request.minimum_stability, &stability_flags); - for input in inputs { - if !lock_filter_allows(&input.name, &input.version) { - continue; - } - builder.add_package(input); - } - } - - // Collect inline `type: package` repositories. These don't require any - // network fetch, but we mirror Composer's `PackageRepository` (which - // extends `ArrayRepository`) and only emit packages whose own `name` - // matches a queried name — `replace`/`provide` targets do NOT pull in - // their replacers eagerly. So we build a name-indexed lookup and add - // entries to the builder on demand from the seed/transitive loops. - // Loading every inline package up front would let the SAT resolver - // pick a replacer that nothing required by name (e.g. - // `broken-deps-do-not-replace.test`), where Composer would correctly - // surface the broken dependency instead. - let inline_packages = crate::inline_package::collect_inline_packages(&request.raw_repositories); - let mut inline_packages_by_name: IndexMap> = - IndexMap::new(); - for ipkg in &inline_packages { - inline_packages_by_name - .entry(ipkg.name.clone()) - .or_default() - .push(ipkg); - } - // Build the security-advisory filter once. Mirrors Composer's - // `SecurityAdvisoryPoolFilter`: when `block-insecure` is on, every - // version listed by a repository's `security-advisories` is removed - // from the pool before solving. - let security_advisories = - crate::inline_package::collect_security_advisories(&request.raw_repositories); - let security_blocks_version = |name: &str, version_normalized: &str| -> bool { - if !request.block_insecure { - return false; - } - let Some(advisories) = security_advisories.get(&name.to_lowercase()) else { - return false; - }; - let Ok(parsed) = Version::parse(version_normalized) else { - return false; - }; - advisories.iter().any(|adv| { - VersionConstraint::parse(&adv.affected_versions) - .map(|c| c.matches(&parsed)) - .unwrap_or(false) - }) - }; - // Mirrors Composer's `PoolBuilder::markPackageNameForLoading`: a root - // require's constraint caps every load of that name. Transitive deps that - // would otherwise pull in an out-of-range version (e.g. `foo/requirer` - // requires `foo/original 1.0.0` while the root pinned it at `3.0.0`) are - // silently filtered down to the root-required range, so the pool never - // sees a candidate the root forbids. Without this, providers that satisfy - // the root require can coexist with the actual package at the wrong - // version, masking what should be a conflict. - // - // The match check considers both the base version and any branch-alias - // entries it expands to — mirrors `ArrayRepository::loadPackages`, which - // pulls in the base whenever any of its aliases satisfies the constraint - // (and vice-versa). Skipping the base when only an alias matches would - // leave the alias dangling. - let add_inline_for = |name: &str, - load_constraint: Option<&VersionConstraint>, - builder: &mut PoolBuilder| - -> bool { - let Some(packages) = inline_packages_by_name.get(name) else { - return false; - }; - for ipkg in packages { - if request.block_abandoned && is_abandoned(&ipkg.version) { - continue; - } - if security_blocks_version(&ipkg.name, &ipkg.version.version_normalized) { - continue; - } - let inputs = packagist_to_pool_inputs( - &ipkg.name, - &ipkg.version, - request.minimum_stability, - &stability_flags, - ); - if let Some(c) = load_constraint { - let any_matches = inputs.iter().any(|input| { - Version::parse(&input.version) - .map(|v| c.matches(&v)) - .unwrap_or(false) - }); - if !any_matches { - continue; - } - } - for input in inputs { - if !lock_filter_allows(&input.name, &input.version) { - continue; - } - builder.add_package(input); - } - } - true - }; - - // Pre-parse root-require constraints once. Reused for every name lookup - // in the seed + transitive loops below. - let root_require_constraints: IndexMap = root_requires - .iter() - .filter_map(|(name, c)| { - c.as_deref() - .and_then(|s| VersionConstraint::parse(s).ok()) - .map(|vc| (name.clone(), vc)) - }) - .collect(); - - // Collect packages from `type: composer` repositories with file:// URLs. - // The harness rewrites `file://foobar` to `file:///abs/path` before this - // call so the read can be a plain `std::fs::read_to_string`. Same idea - // as inline packages — they bypass the RepositorySet and go straight - // into the pool, with names recorded so Packagist loops skip them. - let composer_repo_packages = - crate::composer_repo::collect_composer_packages(&request.raw_repositories); - let mut composer_repo_names: IndexSet = IndexSet::new(); - for cpkg in &composer_repo_packages { - composer_repo_names.insert(cpkg.name.clone()); - if request.block_abandoned && is_abandoned(&cpkg.version) { - continue; - } - let inputs = packagist_to_pool_inputs( - &cpkg.name, - &cpkg.version, - request.minimum_stability, - &stability_flags, - ); - for input in inputs { - if !lock_filter_allows(&input.name, &input.version) { - continue; - } - builder.add_package(input); - } - } - - // The repository set is supplied by the caller. Today production - // builders pass a single-Packagist set; in-process tests can pass a - // set with no HTTP-backed repos. VCS and inline packages above are - // still preloaded directly, and their names go into the skip lists so - // we don't double-load them through this set. - let repo_set: &RepositorySet = &request.repositories; - - // Seed the builder with packages for root requirements. Inline - // `type: package` matches are added directly via the name-indexed - // lookup; everything else falls through to the network-backed - // repository set. - let seed_names: Vec = root_requires - .keys() - .filter(|name| !PackageName((*name).clone()).is_platform()) - .filter(|name| !vcs_package_names.contains(*name) && !composer_repo_names.contains(*name)) - .cloned() - .collect(); - let mut seed_queries: Vec> = Vec::new(); - for name in &seed_names { - let load_constraint = root_require_constraints.get(name); - if add_inline_for(name.as_str(), load_constraint, &mut builder) { - continue; - } - seed_queries.push(PackageQuery { - name: name.as_str(), - constraint: root_requires.get(name).and_then(|c| c.as_deref()), - }); - } - let seed_results = repo_set - .load_packages(&seed_queries) - .await - .map_err(|e| ResolveError::DependencyFetchError(e.to_string()))?; - for r in &seed_results { - if request.block_abandoned && is_abandoned(&r.version) { - continue; - } - let inputs = packagist_to_pool_inputs( - &r.name, - &r.version, - request.minimum_stability, - &stability_flags, - ); - for input in inputs { - if !lock_filter_allows(&input.name, &input.version) { - continue; - } - builder.add_package(input); - } - } - - // Explore transitive dependencies. - while let Some(name) = builder.next_pending() { - if PackageName(name.clone()).is_platform() { - continue; - } - - // Skip packages already provided by VCS or `type: composer` repos - // (those still get eager-loaded above). Inline `type: package` - // matches are loaded on demand by name, mirroring Composer's - // ArrayRepository semantics. - if vcs_package_names.contains(&name) || composer_repo_names.contains(&name) { - continue; - } - let load_constraint = root_require_constraints.get(&name); - if add_inline_for(name.as_str(), load_constraint, &mut builder) { - continue; - } - - let queries = [PackageQuery { - name: name.as_str(), - constraint: root_requires.get(&name).and_then(|c| c.as_deref()), - }]; - let results = match repo_set.load_packages(&queries).await { - Ok(v) => v, - Err(_) => { - // Virtual/meta packages (e.g. "psr/http-client-implementation") - // don't exist on Packagist. They are resolved via provides/replaces - // from other packages already in the pool. - continue; - } - }; - for r in &results { - if request.block_abandoned && is_abandoned(&r.version) { - continue; - } - let inputs = packagist_to_pool_inputs( - &r.name, - &r.version, - request.minimum_stability, - &request.stability_flags, - ); - for input in inputs { - if !lock_filter_allows(&input.name, &input.version) { - continue; - } - builder.add_package(input); - } - } - } - - // Second pass: materialize root aliases (`require: "X as Y"`). - // - // Mirrors Composer's `PoolBuilder::loadPackage` post-load step: when a - // package whose `(name, version)` matches a `rootAliases` entry is added, - // an extra `AliasPackage` exposing that install under - // `(alias_normalized, alias)` is appended to the pool. When the matched - // input is already an alias (e.g. an `extra.branch-alias` entry from - // `packagist_to_pool_inputs`), Composer follows `getAliasOf()` to the - // base package — we replicate by carrying the input's `is_alias_of` - // value forward, so the new alias points straight at the real package - // rather than chaining through the intermediate alias. - if !root_aliases.is_empty() { - let mut new_aliases: Vec = Vec::new(); - for input in builder.inputs() { - // Skip alias creation for packages locked to their lock-file - // version (partial update where this package wasn't requested). - // Mirrors Composer's `propagateUpdate=false` skip in - // `PoolBuilder::loadPackage`. - if request - .locked_package_names - .contains(&input.name.to_lowercase()) - { - continue; - } - for alias in &root_aliases { - if input.name.to_lowercase() != alias.package { - continue; - } - if input.version != alias.version_normalized { - continue; - } - let target_normalized = input - .is_alias_of - .clone() - .unwrap_or_else(|| input.version.clone()); - // Extend `self.version`-derived `replace` / `provide` / - // `conflict` links with an extra entry pinned at the - // alias's own version. Mirrors Composer's - // `AliasPackage::replaceSelfVersionDependencies`: a base - // link whose constraint matches the base's own version - // (the resolved form of `self.version`) is duplicated - // under the alias at the alias's version, so a transitive - // require like `a/aliased-replaced ^4.0` can match the - // alias even when the base is at a non-matching dev - // version. Without this, the alias's replace map keeps - // the base's `dev-next` constraint and the requirement - // never sees a numeric provider. - let alias_extra_self_links = |links: &[PoolLink]| -> Vec { - links - .iter() - .filter(|l| l.constraint == input.version) - .map(|l| PoolLink { - target: l.target.clone(), - constraint: alias.alias_normalized.clone(), - source: l.source.clone(), - }) - .collect() - }; - let mut alias_replaces = input.replaces.clone(); - alias_replaces.extend(alias_extra_self_links(&input.replaces)); - let mut alias_provides = input.provides.clone(); - alias_provides.extend(alias_extra_self_links(&input.provides)); - let mut alias_conflicts = input.conflicts.clone(); - alias_conflicts.extend(alias_extra_self_links(&input.conflicts)); - new_aliases.push(PoolPackageInput { - name: input.name.clone(), - version: alias.alias_normalized.clone(), - pretty_version: alias.alias.clone(), - requires: input.requires.clone(), - replaces: alias_replaces, - provides: alias_provides, - conflicts: alias_conflicts, - is_fixed: false, - is_alias_of: Some(target_normalized), - }); - } - } - for alias_input in new_aliases { - builder.add_package(alias_input); - } - } - - // Build the pool - let mut pool = builder.build(); - // Collect fixed package IDs - let mut fixed_ids: Vec = Vec::new(); - for pkg in pool.packages() { - if pkg.is_fixed { - fixed_ids.push(pkg.id); - fixed_packages_by_name.insert(pkg.name.clone(), pkg.id); - } - } - - // Generate rules - let mut generator = RuleSetGenerator::new(&mut pool); - generator.set_ignore_platform_reqs(ignore_set); - generator.set_ignore_all_platform_reqs(request.ignore_platform_reqs); - let (rules, missing_root_requires) = generator.generate( - &root_requires, - &fixed_ids, - &request.root_provide, - &request.root_replace, - ); - - // Mirror Composer's `Solver::checkForRootRequireProblems`: a root require - // with no providers in the pool yields no SAT rule, so the solver would - // succeed with an empty plan. Surface it as an unresolvable problem - // instead, matching Composer's exit code 2 behaviour. - if !missing_root_requires.is_empty() { - let problems: Vec = missing_root_requires - .iter() - .map(|(name, constraint)| match constraint.as_deref() { - Some(c) if !c.is_empty() => format!( - " - Root composer.json requires {name} {c}, no matching package found." - ), - _ => { - format!(" - Root composer.json requires {name}, no matching package found.") - } - }) - .collect(); - let report = problems - .into_iter() - .enumerate() - .map(|(i, msg)| format!(" Problem {}\n{}", i + 1, msg)) - .collect::>() - .join("\n"); - return Err(ResolveError::NoSolution(report)); - } - - // Create policy and solve. When `preferred_versions` is non-empty (the - // `--minimal-changes` flow) feed it through the policy so the locked - // version wins over the regular highest/lowest pick whenever a candidate - // matches it. Mirrors Composer's - // `Installer::createPolicy` minimal-update branch. - let policy = if request.preferred_versions.is_empty() { - DefaultPolicy::new(request.prefer_stable, request.prefer_lowest) - } else { - DefaultPolicy::with_preferred( - request.prefer_stable, - request.prefer_lowest, - request.preferred_versions.clone(), - ) - }; - let fixed_set: IndexSet = fixed_ids.into_iter().collect(); - let solver = Solver::new(rules, &pool, policy, fixed_set); - - match solver.solve() { - Ok(result) => { - let mut resolved = Vec::new(); - for pkg_id in result.installed { - let pkg = pool.package_by_id(pkg_id); - - // Skip platform packages from output - if PackageName(pkg.name.clone()).is_platform() { - continue; - } - - // Skip the root package itself. It's in the pool as a fixed - // entry only so transitive requires pointing back at it - // can resolve; it must not appear in the lock file or - // operations list. Mirrors Composer's `LockTransaction` - // which discards fixed packages from the result. - if !root_name_lower.is_empty() && pkg.name == root_name_lower { - continue; - } - - let is_dev = if let Ok(v) = Version::parse(&pkg.version) { - version_stability(&v) == Stability::Dev - } else { - false - }; - - let alias_of_normalized = pkg - .is_alias_of - .map(|tid| pool.package_by_id(tid).version.clone()); - - resolved.push(ResolvedPackage { - name: pkg.name.clone(), - version: pkg.pretty_version.clone(), - version_normalized: pkg.version.clone(), - is_dev, - alias_of_normalized, - }); - } - Ok(resolved) - } - Err(e) => Err(ResolveError::NoSolution(e.to_string())), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn v(major: u64, minor: u64, patch: u64, build: u64) -> Version { - Version { - major, - minor, - patch, - build, - pre_release: None, - is_dev_branch: false, - dev_branch_name: None, - } - } - - fn v_pre(major: u64, minor: u64, patch: u64, build: u64, pre: &str) -> Version { - Version { - major, - minor, - patch, - build, - pre_release: Some(pre.to_string()), - is_dev_branch: false, - dev_branch_name: None, - } - } - - #[test] - fn test_parse_normalized_stable() { - let ver = parse_normalized("1.2.3.0").unwrap(); - assert_eq!((ver.major, ver.minor, ver.patch, ver.build), (1, 2, 3, 0)); - assert_eq!(ver.pre_release, None); - } - - #[test] - fn test_parse_normalized_beta() { - let ver = parse_normalized("1.0.0.0-beta1").unwrap(); - assert_eq!(ver.major, 1); - assert_eq!(ver.pre_release, Some("beta1".to_string())); - } - - #[test] - fn test_parse_normalized_rc() { - let ver = parse_normalized("2.0.0.0-RC3").unwrap(); - assert_eq!(ver.major, 2); - assert_eq!(ver.pre_release, Some("RC3".to_string())); - } - - #[test] - fn test_parse_normalized_alpha() { - let ver = parse_normalized("1.0.0.0-alpha2").unwrap(); - assert_eq!(ver.pre_release, Some("alpha2".to_string())); - } - - #[test] - fn test_parse_normalized_dev() { - let ver = parse_normalized("1.0.0.0-dev").unwrap(); - assert_eq!(ver.pre_release, Some("dev".to_string())); - } - - #[test] - fn test_parse_normalized_dev_branch() { - let ver = parse_normalized("dev-master"); - assert!( - ver.is_none(), - "dev-master should not parse as normalized version" - ); - } - - #[test] - fn test_parse_normalized_x_dev() { - let ver = parse_normalized("dev-feature/foo"); - assert!(ver.is_none()); - } - - #[test] - fn test_parse_normalized_9999999_dev() { - let ver = parse_normalized("9999999.9999999.9999999.9999999-dev"); - assert!(ver.is_none()); - } - - #[test] - fn test_parse_normalized_large_version() { - let ver = parse_normalized("20031129").unwrap(); - assert_eq!(ver.major, 20031129); - assert_eq!(ver.pre_release, None); - } - - #[test] - fn test_version_ordering_stable() { - let v1 = parse_normalized("2.0.0.0").unwrap(); - let v2 = parse_normalized("1.0.0.0").unwrap(); - assert!(v1 > v2); - } - - #[test] - fn test_version_ordering_stability() { - let stable = parse_normalized("1.0.0.0").unwrap(); - let rc = parse_normalized("1.0.0.0-RC1").unwrap(); - let beta = parse_normalized("1.0.0.0-beta1").unwrap(); - let alpha = parse_normalized("1.0.0.0-alpha1").unwrap(); - let dev = parse_normalized("1.0.0.0-dev").unwrap(); - assert!(stable > rc); - assert!(rc > beta); - assert!(beta > alpha); - assert!(alpha > dev); - } - - #[test] - fn test_version_ordering_pre_number() { - let beta2 = parse_normalized("1.0.0.0-beta2").unwrap(); - let beta1 = parse_normalized("1.0.0.0-beta1").unwrap(); - assert!(beta2 > beta1); - } - - #[test] - fn test_version_display() { - let stable = v(1, 2, 3, 0); - assert_eq!(format!("{stable}"), "1.2.3.0"); - - let beta1 = v_pre(1, 0, 0, 0, "beta1"); - assert_eq!(format!("{beta1}"), "1.0.0.0-beta1"); - - let rc2 = v_pre(2, 0, 0, 0, "RC2"); - assert_eq!(format!("{rc2}"), "2.0.0.0-RC2"); - - let dev = v_pre(1, 0, 0, 0, "dev"); - assert_eq!(format!("{dev}"), "1.0.0.0-dev"); - } - - #[test] - fn test_version_stability_fn() { - assert_eq!(version_stability(&v(1, 0, 0, 0)), Stability::Stable); - assert_eq!(version_stability(&v_pre(1, 0, 0, 0, "RC1")), Stability::RC); - assert_eq!( - version_stability(&v_pre(1, 0, 0, 0, "beta1")), - Stability::Beta - ); - assert_eq!( - version_stability(&v_pre(1, 0, 0, 0, "alpha1")), - Stability::Alpha - ); - assert_eq!(version_stability(&v_pre(1, 0, 0, 0, "dev")), Stability::Dev); - assert_eq!( - version_stability(&v_pre(1, 0, 0, 0, "patch1")), - Stability::Stable - ); - } - - #[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("composer".to_string()).is_platform()); - assert!(PackageName("composer-plugin-api".to_string()).is_platform()); - assert!(PackageName("composer-runtime-api".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_stability_filter() { - let stable_v = v(1, 0, 0, 0); - let alpha_v = v_pre(1, 1, 0, 0, "alpha1"); - let beta_v = v_pre(1, 0, 0, 0, "beta1"); - let rc_v = v_pre(1, 0, 0, 0, "RC1"); - let dev_v = v_pre(1, 0, 0, 0, "dev"); - - let flags = IndexMap::new(); - - assert!(passes_stability_filter( - "foo/foo", - &stable_v, - Stability::Stable, - &flags - )); - assert!(!passes_stability_filter( - "foo/foo", - &alpha_v, - Stability::Stable, - &flags - )); - assert!(!passes_stability_filter( - "foo/foo", - &beta_v, - Stability::Stable, - &flags - )); - assert!(!passes_stability_filter( - "foo/foo", - &rc_v, - Stability::Stable, - &flags - )); - assert!(!passes_stability_filter( - "foo/foo", - &dev_v, - Stability::Stable, - &flags - )); - } - - #[test] - fn test_stability_filter_beta() { - let stable_v = v(1, 0, 0, 0); - let beta_v = v_pre(1, 0, 0, 0, "beta1"); - let alpha_v = v_pre(1, 0, 0, 0, "alpha1"); - let dev_v = v_pre(1, 0, 0, 0, "dev"); - - let flags = IndexMap::new(); - - assert!(passes_stability_filter( - "foo/foo", - &stable_v, - Stability::Beta, - &flags - )); - assert!(passes_stability_filter( - "foo/foo", - &beta_v, - Stability::Beta, - &flags - )); - assert!(!passes_stability_filter( - "foo/foo", - &alpha_v, - Stability::Beta, - &flags - )); - assert!(!passes_stability_filter( - "foo/foo", - &dev_v, - Stability::Beta, - &flags - )); - } - - #[test] - fn test_stability_filter_dev() { - let dev_v = v_pre(1, 0, 0, 0, "dev"); - let flags = IndexMap::new(); - assert!(passes_stability_filter( - "foo/foo", - &dev_v, - Stability::Dev, - &flags - )); - } - - #[test] - fn test_skip_platform_dep() { - assert!(should_skip_platform_dep("php", true, &[])); - assert!(should_skip_platform_dep("ext-json", true, &[])); - assert!(!should_skip_platform_dep("monolog/monolog", true, &[])); - } - - #[test] - fn test_skip_specific_platform_dep() { - let list = vec!["ext-intl".to_string()]; - assert!(should_skip_platform_dep("ext-intl", false, &list)); - assert!(!should_skip_platform_dep("ext-json", false, &list)); - assert!(!should_skip_platform_dep("php", false, &list)); - assert!(!should_skip_platform_dep("monolog/monolog", false, &list)); - } - - #[test] - fn test_parse_branch_alias_target_x_dev() { - let ver = parse_branch_alias_target("2.x-dev").unwrap(); - assert_eq!((ver.major, ver.minor, ver.patch, ver.build), (2, 0, 0, 0)); - assert_eq!(ver.pre_release, Some("dev".to_string())); - } - - #[test] - fn test_parse_branch_alias_target_minor_x_dev() { - let ver = parse_branch_alias_target("1.5.x-dev").unwrap(); - assert_eq!((ver.major, ver.minor, ver.patch), (1, 5, 0)); - assert_eq!(ver.pre_release, Some("dev".to_string())); - } - - #[test] - fn test_parse_branch_alias_target_patch_x_dev() { - let ver = parse_branch_alias_target("1.0.2.x-dev").unwrap(); - assert_eq!((ver.major, ver.minor, ver.patch), (1, 0, 2)); - assert_eq!(ver.pre_release, Some("dev".to_string())); - } - - #[test] - fn test_parse_branch_alias_target_invalid() { - assert!(parse_branch_alias_target("dev-master").is_none()); - assert!(parse_branch_alias_target("2.0.0").is_none()); - assert!(parse_branch_alias_target("").is_none()); - } - - #[test] - fn test_sat_resolve_simple_offline() { - use mozart_sat_resolver::*; - - let mut pool = Pool::new( - vec![ - PoolPackageInput { - name: "foo/foo".to_string(), - version: "1.0.0.0".to_string(), - pretty_version: "1.0.0".to_string(), - requires: vec![PoolLink { - target: "bar/bar".to_string(), - constraint: "^2.0".to_string(), - source: "foo/foo".to_string(), - }], - replaces: vec![], - provides: vec![], - conflicts: vec![], - is_fixed: false, - is_alias_of: None, - }, - PoolPackageInput { - name: "bar/bar".to_string(), - version: "2.0.0.0".to_string(), - pretty_version: "2.0.0".to_string(), - requires: vec![], - replaces: vec![], - provides: vec![], - conflicts: vec![], - is_fixed: false, - is_alias_of: None, - }, - ], - vec![], - ); - - let mut requires = IndexMap::new(); - requires.insert("foo/foo".to_string(), Some("^1.0".to_string())); - - let generator = RuleSetGenerator::new(&mut pool); - let (rules, _) = generator.generate(&requires, &[], &IndexMap::new(), &IndexMap::new()); - - let policy = DefaultPolicy::default(); - let solver = Solver::new(rules, &pool, policy, IndexSet::new()); - let result = solver.solve().unwrap(); - - // Should install foo/foo (id=1) and bar/bar (id=2) - assert!(result.installed.contains(&1)); - assert!(result.installed.contains(&2)); - } - - #[tokio::test] - #[ignore] - async fn test_resolve_monolog_e2e() { - use crate::cache::Cache; - let request = ResolveRequest { - root_name: String::new(), - root_version: None, - require: vec![("monolog/monolog".to_string(), "^3.0".to_string())], - require_dev: vec![], - include_dev: false, - minimum_stability: Stability::Stable, - stability_flags: IndexMap::new(), - prefer_stable: true, - prefer_lowest: false, - platform: PlatformConfig::new(), - ignore_platform_reqs: false, - ignore_platform_req_list: vec![], - repositories: Arc::new(RepositorySet::with_packagist(Cache::new( - std::env::temp_dir().join("mozart-test-cache"), - false, - ))), - temporary_constraints: IndexMap::new(), - raw_repositories: vec![], - root_provide: IndexMap::new(), - root_replace: IndexMap::new(), - root_conflict: IndexMap::new(), - locked_package_names: IndexSet::new(), - locked_packages: Vec::new(), - block_abandoned: false, - root_branch_alias: None, - preferred_versions: IndexMap::new(), - block_insecure: false, - }; - - let result = resolve(&request).await; - match result { - Ok(packages) => { - println!("Resolved {} packages:", packages.len()); - for pkg in &packages { - println!(" {} {}", pkg.name, pkg.version); - } - assert!(!packages.is_empty()); - assert!(packages.iter().any(|p| p.name == "monolog/monolog")); - } - Err(e) => panic!("Resolution failed: {}", e), - } - } -} diff --git a/crates/mozart-registry/src/vcs_bridge.rs b/crates/mozart-registry/src/vcs_bridge.rs deleted file mode 100644 index aae3d87..0000000 --- a/crates/mozart-registry/src/vcs_bridge.rs +++ /dev/null @@ -1,218 +0,0 @@ -//! Bridge between `mozart-vcs` and `mozart-registry`. -//! -//! Scans VCS repositories defined in composer.json and converts -//! discovered package versions into pool inputs for the SAT resolver. - -use indexmap::IndexMap; -use std::collections::BTreeMap; - -use mozart_core::package::{RawRepository, Stability}; -use mozart_sat_resolver::{PoolPackageInput, make_pool_links}; -use mozart_vcs::driver::DriverConfig; -use mozart_vcs::repository::{VcsPackageVersion, VcsRepository}; - -use crate::packagist::PackagistVersion; -use crate::resolver::{parse_normalized, version_stability}; - -/// Scan all VCS-type repositories and collect package versions. -/// -/// Non-VCS repos (e.g. "composer", "package") are silently skipped. -pub async fn scan_vcs_repositories(repositories: &[RawRepository]) -> Vec { - let config = DriverConfig::default(); - let mut all_versions = Vec::new(); - - for repo in repositories { - let repo_type = repo.repo_type.as_str(); - match repo_type { - "vcs" | "git" | "svn" | "hg" | "github" | "gitlab" | "bitbucket" | "forgejo" => {} - _ => continue, - } - - let forced_type = match repo_type { - "vcs" => None, - other => Some(other), - }; - - // VCS repositories require `url`; skip silently if missing (Composer - // would reject this earlier in RepositoryFactory). - let Some(url) = repo.url.clone() else { - continue; - }; - - let vcs_repo = VcsRepository::new(url.clone(), forced_type, config.clone()); - - match vcs_repo.scan().await { - Ok(versions) => { - all_versions.extend(versions); - } - Err(e) => { - eprintln!("Warning: Failed to scan VCS repository {url}: {e}"); - } - } - } - - all_versions -} - -/// Convert a VCS package version to SAT pool inputs. -pub fn vcs_to_pool_inputs( - vpkg: &VcsPackageVersion, - minimum_stability: Stability, - stability_flags: &IndexMap, -) -> Vec { - let mut results = Vec::new(); - - // Extract dependency links from composer.json - let require = extract_dep_map(&vpkg.composer_json, "require"); - let replace = extract_dep_map(&vpkg.composer_json, "replace"); - let provide = extract_dep_map(&vpkg.composer_json, "provide"); - let conflict = extract_dep_map(&vpkg.composer_json, "conflict"); - - let input = PoolPackageInput { - name: vpkg.name.clone(), - version: vpkg.version_normalized.clone(), - pretty_version: vpkg.version.clone(), - requires: make_pool_links( - &vpkg.name, - &vpkg.version_normalized, - &require - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect::>(), - ), - replaces: make_pool_links( - &vpkg.name, - &vpkg.version_normalized, - &replace - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect::>(), - ), - provides: make_pool_links( - &vpkg.name, - &vpkg.version_normalized, - &provide - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect::>(), - ), - conflicts: make_pool_links( - &vpkg.name, - &vpkg.version_normalized, - &conflict - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect::>(), - ), - is_fixed: false, - is_alias_of: None, - }; - - // Apply stability filtering - if let Some(v) = parse_normalized(&vpkg.version_normalized) { - if passes_vcs_stability_filter(&vpkg.name, &v, minimum_stability, stability_flags) { - results.push(input); - } - } else { - // Dev version: always include (dev stability) - let pkg_flag = stability_flags.get(&vpkg.name.to_lowercase()); - let allowed = pkg_flag.copied().unwrap_or(minimum_stability); - if allowed >= Stability::Dev { - results.push(input); - } - } - - results -} - -/// Convert a `VcsPackageVersion` into a `PackagistVersion` for lockfile generation. -pub fn vcs_to_packagist_version(vpkg: &VcsPackageVersion) -> PackagistVersion { - PackagistVersion { - version: vpkg.version.clone(), - version_normalized: vpkg.version_normalized.clone(), - require: extract_dep_map(&vpkg.composer_json, "require"), - replace: extract_dep_map(&vpkg.composer_json, "replace"), - provide: extract_dep_map(&vpkg.composer_json, "provide"), - conflict: extract_dep_map(&vpkg.composer_json, "conflict"), - dist: vpkg.dist.as_ref().map(|d| crate::packagist::PackagistDist { - dist_type: d.dist_type.clone(), - url: d.url.clone(), - reference: Some(d.reference.clone()), - shasum: d.shasum.clone(), - }), - source: Some(crate::packagist::PackagistSource { - source_type: vpkg.source.source_type.clone(), - url: vpkg.source.url.clone(), - reference: Some(vpkg.source.reference.clone()), - }), - require_dev: extract_dep_map(&vpkg.composer_json, "require-dev"), - suggest: vpkg - .composer_json - .get("suggest") - .and_then(|v| serde_json::from_value(v.clone()).ok()), - package_type: vpkg - .composer_json - .get("type") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - autoload: vpkg.composer_json.get("autoload").cloned(), - autoload_dev: vpkg.composer_json.get("autoload-dev").cloned(), - license: vpkg - .composer_json - .get("license") - .and_then(|v| serde_json::from_value(v.clone()).ok()), - description: vpkg - .composer_json - .get("description") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - homepage: vpkg - .composer_json - .get("homepage") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - keywords: vpkg - .composer_json - .get("keywords") - .and_then(|v| serde_json::from_value(v.clone()).ok()), - authors: vpkg - .composer_json - .get("authors") - .and_then(|v| serde_json::from_value(v.clone()).ok()), - support: vpkg.composer_json.get("support").cloned(), - funding: vpkg - .composer_json - .get("funding") - .and_then(|v| serde_json::from_value(v.clone()).ok()), - time: vpkg.time.clone(), - extra: vpkg.composer_json.get("extra").cloned(), - notification_url: None, - default_branch: vpkg.is_default_branch, - abandoned: vpkg.composer_json.get("abandoned").cloned(), - } -} - -/// Extract a dependency map from composer.json JSON. -fn extract_dep_map(json: &serde_json::Value, key: &str) -> BTreeMap { - json.get(key) - .and_then(|v| v.as_object()) - .map(|obj| { - obj.iter() - .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) - .collect() - }) - .unwrap_or_default() -} - -/// Stability filter for VCS packages (mirrors resolver logic). -fn passes_vcs_stability_filter( - package_name: &str, - version: &mozart_semver::Version, - minimum_stability: Stability, - stability_flags: &IndexMap, -) -> bool { - let stability = version_stability(version); - let pkg_flag = stability_flags.get(&package_name.to_lowercase()); - let allowed = pkg_flag.copied().unwrap_or(minimum_stability); - stability <= allowed -} diff --git a/crates/mozart-registry/src/version.rs b/crates/mozart-registry/src/version.rs deleted file mode 100644 index 9a7c6e6..0000000 --- a/crates/mozart-registry/src/version.rs +++ /dev/null @@ -1,269 +0,0 @@ -use crate::packagist::PackagistVersion; -use mozart_core::package::Stability; -use std::cmp::Ordering; - -/// Determine the stability of a normalized version string. -pub fn stability_of(version_normalized: &str) -> Stability { - let v = version_normalized.to_lowercase(); - if v.starts_with("dev-") || v.ends_with("-dev") { - return Stability::Dev; - } - // Check for pre-release suffixes: alpha, beta, RC - // Normalized versions use formats like "1.0.0.0-alpha1", "1.0.0.0-beta2", "1.0.0.0-RC1" - if let Some(pos) = v.rfind('-') { - let suffix = &v[pos + 1..]; - if suffix.starts_with("alpha") { - return Stability::Alpha; - } - if suffix.starts_with("beta") { - return Stability::Beta; - } - if suffix.starts_with("rc") || suffix.starts_with("RC") { - return Stability::RC; - } - } - Stability::Stable -} - -/// Compare two normalized version strings (e.g. "1.2.3.0" vs "1.2.4.0"). -/// -/// Each version is split into numeric parts. Non-numeric suffixes (like "-beta1") -/// are handled by treating the base parts as numeric and the suffix separately. -pub fn compare_normalized_versions(a: &str, b: &str) -> Ordering { - let parse = |v: &str| -> (Vec, Option) { - // Split off any pre-release suffix - let (base, suffix) = if let Some(pos) = v.find('-') { - (&v[..pos], Some(v[pos + 1..].to_string())) - } else { - (v, None) - }; - let parts: Vec = base.split('.').filter_map(|p| p.parse().ok()).collect(); - (parts, suffix) - }; - - let (a_parts, a_suffix) = parse(a); - let (b_parts, b_suffix) = parse(b); - - // Compare numeric parts - let max_len = a_parts.len().max(b_parts.len()); - for i in 0..max_len { - let a_val = a_parts.get(i).copied().unwrap_or(0); - let b_val = b_parts.get(i).copied().unwrap_or(0); - match a_val.cmp(&b_val) { - Ordering::Equal => continue, - other => return other, - } - } - - // If numeric parts are equal, compare stability - // A stable version (no suffix) is greater than a pre-release - match (&a_suffix, &b_suffix) { - (None, None) => Ordering::Equal, - (None, Some(_)) => Ordering::Greater, // stable > pre-release - (Some(_), None) => Ordering::Less, // pre-release < stable - (Some(a_s), Some(b_s)) => { - let stab_a = stability_of(&format!("0.0.0.0-{a_s}")); - let stab_b = stability_of(&format!("0.0.0.0-{b_s}")); - // Lower stability value = more stable = greater version - match stab_a.cmp(&stab_b) { - Ordering::Equal => a_s.cmp(b_s), - // Stability enum: Stable(0) < RC(5) < Beta(10) < Alpha(15) < Dev(20) - // But more stable = higher version, so we reverse - Ordering::Less => Ordering::Greater, - Ordering::Greater => Ordering::Less, - } - } - } -} - -/// Find the best version candidate given a preferred minimum stability. -/// -/// Returns the highest version whose stability is at least as stable as -/// the preferred stability (i.e., stability value <= preferred value). -pub fn find_best_candidate( - versions: &[PackagistVersion], - preferred_stability: Stability, -) -> Option<&PackagistVersion> { - versions - .iter() - .filter(|v| stability_of(&v.version_normalized) <= preferred_stability) - .max_by(|a, b| compare_normalized_versions(&a.version_normalized, &b.version_normalized)) -} - -/// Generate a recommended version constraint string from a concrete version. -/// -/// Examples: -/// - `"1.2.1"` (stable) → `"^1.2"` -/// - `"0.3.5"` (stable) → `"^0.3"` -/// - `"2.0.0-beta.1"` (beta) → `"^2.0@beta"` -/// - `"dev-master"` (dev) → `"dev-master"` -pub fn find_recommended_require_version( - version: &str, - version_normalized: &str, - stability: Stability, -) -> String { - // dev branches are returned as-is - if stability == Stability::Dev { - return version.to_string(); - } - - // Extract major.minor from the normalized version (e.g. "1.2.3.0" → "1.2") - let base = if let Some(pos) = version_normalized.find('-') { - &version_normalized[..pos] - } else { - version_normalized - }; - - let parts: Vec<&str> = base.split('.').collect(); - let major = parts.first().copied().unwrap_or("0"); - let minor = parts.get(1).copied().unwrap_or("0"); - - let constraint = format!("^{major}.{minor}"); - - match stability { - Stability::Stable => constraint, - Stability::RC => format!("{constraint}@RC"), - Stability::Beta => format!("{constraint}@beta"), - Stability::Alpha => format!("{constraint}@alpha"), - Stability::Dev => format!("{constraint}@dev"), - } -} - -#[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, - default_branch: false, - abandoned: 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-registry/src/version_selector.rs b/crates/mozart-registry/src/version_selector.rs deleted file mode 100644 index 7aa409e..0000000 --- a/crates/mozart-registry/src/version_selector.rs +++ /dev/null @@ -1,48 +0,0 @@ -use crate::cache::Cache; -use crate::packagist::{self, PackagistVersion}; -use crate::version; -use mozart_core::package::Stability; - -/// Mirrors `Composer\Package\Version\VersionSelector`. -pub struct VersionSelector { - preferred_stability: Stability, - repo_cache: Cache, -} - -impl VersionSelector { - pub fn new(preferred_stability: Stability, repo_cache: Cache) -> Self { - Self { - preferred_stability, - repo_cache, - } - } - - /// Fetch versions from Packagist and pick the best candidate. - /// Mirrors `VersionSelector::findBestCandidate()`. - pub async fn find_best_candidate( - &self, - package_name: &str, - ) -> anyhow::Result> { - let versions = packagist::fetch_package_versions(package_name, &self.repo_cache).await?; - Ok(version::find_best_candidate(&versions, self.preferred_stability).cloned()) - } - - /// Generate a recommended constraint string from a concrete version. - /// Mirrors `VersionSelector::findRecommendedRequireVersion()`. - pub fn find_recommended_require_version_string( - &self, - pkg: &PackagistVersion, - fixed: bool, - ) -> String { - if fixed { - pkg.version.clone() - } else { - let stability = version::stability_of(&pkg.version_normalized); - version::find_recommended_require_version( - &pkg.version, - &pkg.version_normalized, - stability, - ) - } - } -} diff --git a/crates/mozart-registry/tests/poolbuilder.rs b/crates/mozart-registry/tests/poolbuilder.rs deleted file mode 100644 index d8511e4..0000000 --- a/crates/mozart-registry/tests/poolbuilder.rs +++ /dev/null @@ -1,80 +0,0 @@ -//! Pool-builder fixture suite, ported from -//! `composer/tests/Composer/Test/DependencyResolver/PoolBuilderTest.php`. -//! -//! Composer drives this suite through a `@dataProvider`; each `.test` file -//! becomes one parameterized case. Mirrored here as one `#[test]` per -//! fixture so the count surfaces in `cargo test` output and individual -//! cases can be re-enabled as the runner is fleshed out. -//! -//! Every test is currently `#[ignore]` because the runner is a stub: the -//! orchestration that takes a `RepositorySet` + `Request` and produces a -//! populated `Pool` lives inline in `mozart_registry::resolver::resolve`, -//! not as an extracted entry point. Wiring those up — alias handling, -//! stability flags, fixed/locked packages, the optimizer pass — is the -//! follow-up work this scaffolding exists to track. - -use std::path::{Path, PathBuf}; - -use mozart_test_harness::{ParsedPoolBuilderTest, parse_pool_builder_test_file}; - -fn fixtures_dir() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../composer/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder") -} - -fn run_poolbuilder_fixture(ident: &str) { - let filename = format!("{}.test", ident.replace('_', "-")); - let path = fixtures_dir().join(&filename); - let _parsed: ParsedPoolBuilderTest = parse_pool_builder_test_file(&path) - .unwrap_or_else(|e| panic!("failed to parse {}: {:#}", path.display(), e)); - - // Runner is intentionally not implemented yet — see module docs. - // Removing `#[ignore]` from a case will surface this `unimplemented!` - // and force the missing pool-builder entry point into existence. - unimplemented!( - "PoolBuilderTest runner not yet wired up; cannot execute {}", - path.display() - ); -} - -macro_rules! poolbuilder_fixture { - ($name:ident) => { - #[test] - #[ignore] - fn $name() { - run_poolbuilder_fixture(stringify!($name)); - } - }; -} - -poolbuilder_fixture!(alias_priority_conflicting); -poolbuilder_fixture!(alias_with_reference); -poolbuilder_fixture!(constraint_expansion_works_with_exact_versions); -poolbuilder_fixture!(filter_impossible_packages); -poolbuilder_fixture!(filter_impossible_packages_locked_replacer); -poolbuilder_fixture!(filter_impossible_packages_only_required); -poolbuilder_fixture!(filter_impossible_packages_only_required_provides); -poolbuilder_fixture!(filter_impossible_packages_only_required_replaces); -poolbuilder_fixture!(filter_impossible_packages_provides); -poolbuilder_fixture!(filter_impossible_packages_replaces); -poolbuilder_fixture!(fixed_packages_do_not_load_from_repos); -poolbuilder_fixture!(fixed_packages_replaced_do_not_load_from_repos); -poolbuilder_fixture!(load_replaced_package_if_replacer_dropped); -poolbuilder_fixture!(load_replaced_root_package_if_replacer_dropped); -poolbuilder_fixture!(multi_repo_replace); -poolbuilder_fixture!(multi_repo_replace_partial_update_all); -poolbuilder_fixture!(must_expand_root_reqs); -poolbuilder_fixture!(package_versions_are_not_loaded_if_not_required_expansion); -poolbuilder_fixture!(package_versions_are_not_loaded_if_not_required_recursive); -poolbuilder_fixture!(packages_that_do_not_exist); -poolbuilder_fixture!(partial_update); -poolbuilder_fixture!(partial_update_transitive_deps_no_root_unfix); -poolbuilder_fixture!(partial_update_transitive_deps_unfix); -poolbuilder_fixture!(partial_update_unfixes_path_repo_replacer_with_transitive_deps); -poolbuilder_fixture!(partial_update_unfixes_path_repos_always_but_not_their_transitive_deps); -poolbuilder_fixture!(partial_update_unfixing_locked_deps); -poolbuilder_fixture!(partial_update_unfixing_replacers); -poolbuilder_fixture!(partial_update_unfixing_with_replacers); -poolbuilder_fixture!(partial_update_unfixing_with_replacers_providers); -poolbuilder_fixture!(root_requirements_avoid_loading_further_versions); -poolbuilder_fixture!(stability_flags_take_over_minimum_stability_and_filter_packages); diff --git a/crates/mozart-sat-resolver/Cargo.toml b/crates/mozart-sat-resolver/Cargo.toml deleted file mode 100644 index 5b8a46c..0000000 --- a/crates/mozart-sat-resolver/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "mozart-sat-resolver" -version.workspace = true -edition.workspace = true - -[dependencies] -mozart-semver.workspace = true -mozart-core.workspace = true -indexmap.workspace = true - -[dev-dependencies] diff --git a/crates/mozart-sat-resolver/src/decisions.rs b/crates/mozart-sat-resolver/src/decisions.rs deleted file mode 100644 index e9cc935..0000000 --- a/crates/mozart-sat-resolver/src/decisions.rs +++ /dev/null @@ -1,263 +0,0 @@ -use crate::error::SolverBugError; -use crate::pool::{Literal, PackageId, literal_to_package_id}; -use crate::rule_set::RuleId; -use indexmap::IndexMap; - -/// A decision entry: which literal was decided and which rule caused it. -#[derive(Debug, Clone)] -pub struct Decision { - pub literal: Literal, - pub rule_id: RuleId, -} - -/// Tracks all decisions (variable assignments) made during solving. -/// -/// Port of Composer's Decisions.php. -pub struct Decisions { - /// Package ID → signed level. Positive = install, negative = uninstall. - /// The absolute value is the decision level. - decision_map: IndexMap, - /// Queue of decisions in order. - decision_queue: Vec, -} - -impl Decisions { - pub fn new() -> Self { - Decisions { - decision_map: IndexMap::new(), - decision_queue: Vec::new(), - } - } - - /// Record a decision. - pub fn decide( - &mut self, - literal: Literal, - level: i32, - rule_id: RuleId, - ) -> Result<(), SolverBugError> { - let package_id = literal_to_package_id(literal); - let previous = self.decision_map.get(&package_id).copied().unwrap_or(0); - if previous != 0 { - return Err(SolverBugError { - message: format!( - "Trying to decide literal {literal} on level {level}, \ - even though package {package_id} was previously decided as {previous}." - ), - }); - } - - if literal > 0 { - self.decision_map.insert(package_id, level); - } else { - self.decision_map.insert(package_id, -level); - } - - self.decision_queue.push(Decision { literal, rule_id }); - Ok(()) - } - - /// Check if literal is satisfied (true in current assignment). - pub fn satisfy(&self, literal: Literal) -> bool { - let package_id = literal_to_package_id(literal); - match self.decision_map.get(&package_id) { - Some(&val) => (literal > 0 && val > 0) || (literal < 0 && val < 0), - None => false, - } - } - - /// Check if literal conflicts with current assignment. - pub fn conflict(&self, literal: Literal) -> bool { - let package_id = literal_to_package_id(literal); - match self.decision_map.get(&package_id) { - Some(&val) => (val > 0 && literal < 0) || (val < 0 && literal > 0), - None => false, - } - } - - /// Check if package has been decided. - pub fn decided(&self, literal_or_id: i32) -> bool { - let package_id = literal_or_id.unsigned_abs(); - self.decision_map.get(&package_id).copied().unwrap_or(0) != 0 - } - - /// Check if package is undecided. - pub fn undecided(&self, literal_or_id: i32) -> bool { - !self.decided(literal_or_id) - } - - /// Check if package is decided for installation. - pub fn decided_install(&self, literal_or_id: i32) -> bool { - let package_id = literal_or_id.unsigned_abs(); - self.decision_map.get(&package_id).copied().unwrap_or(0) > 0 - } - - /// Get the decision level for a package (0 if undecided). - pub fn decision_level(&self, literal_or_id: i32) -> i32 { - let package_id = literal_or_id.unsigned_abs(); - self.decision_map - .get(&package_id) - .copied() - .unwrap_or(0) - .abs() - } - - /// Get the rule ID that caused a decision for a package. - pub fn decision_rule(&self, literal_or_id: i32) -> Result { - let package_id = literal_or_id.unsigned_abs(); - for decision in &self.decision_queue { - if literal_to_package_id(decision.literal) == package_id { - return Ok(decision.rule_id); - } - } - Err(SolverBugError { - message: format!("Did not find a decision rule for {literal_or_id}"), - }) - } - - /// Get decision at a specific offset in the queue. - pub fn at_offset(&self, offset: usize) -> &Decision { - &self.decision_queue[offset] - } - - /// Check if an offset is valid. - pub fn valid_offset(&self, offset: usize) -> bool { - offset < self.decision_queue.len() - } - - /// Get the rule ID of the last decision. - pub fn last_reason(&self) -> RuleId { - self.decision_queue.last().unwrap().rule_id - } - - /// Get the literal of the last decision. - pub fn last_literal(&self) -> Literal { - self.decision_queue.last().unwrap().literal - } - - /// Clear all decisions. - pub fn reset(&mut self) { - while let Some(decision) = self.decision_queue.pop() { - let pkg_id = literal_to_package_id(decision.literal); - self.decision_map.insert(pkg_id, 0); - } - } - - /// Remove decisions after the given offset (keep offset+1 items). - pub fn reset_to_offset(&mut self, offset: usize) { - while self.decision_queue.len() > offset + 1 { - let decision = self.decision_queue.pop().unwrap(); - let pkg_id = literal_to_package_id(decision.literal); - self.decision_map.insert(pkg_id, 0); - } - } - - /// Remove the last decision. - pub fn revert_last(&mut self) { - let decision = self.decision_queue.pop().unwrap(); - let pkg_id = literal_to_package_id(decision.literal); - self.decision_map.insert(pkg_id, 0); - } - - /// Number of decisions. - pub fn len(&self) -> usize { - self.decision_queue.len() - } - - /// Whether there are no decisions. - pub fn is_empty(&self) -> bool { - self.decision_queue.is_empty() - } - - /// Iterate decisions in reverse order (newest first). - /// Used by analyzeUnsolvable in Composer. - pub fn iter_reverse(&self) -> impl Iterator { - self.decision_queue.iter().rev() - } -} - -impl Default for Decisions { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_decide_and_satisfy() { - let mut d = Decisions::new(); - d.decide(1, 1, 0).unwrap(); // install package 1 at level 1 - - assert!(d.satisfy(1)); - assert!(!d.satisfy(-1)); - assert!(d.conflict(-1)); - assert!(!d.conflict(1)); - assert!(d.decided(1)); - assert!(d.decided_install(1)); - } - - #[test] - fn test_decide_negative() { - let mut d = Decisions::new(); - d.decide(-1, 1, 0).unwrap(); // don't install package 1 - - assert!(d.satisfy(-1)); - assert!(!d.satisfy(1)); - assert!(d.conflict(1)); - assert!(d.decided(1)); - assert!(!d.decided_install(1)); - } - - #[test] - fn test_undecided() { - let d = Decisions::new(); - assert!(d.undecided(1)); - assert!(!d.decided(1)); - assert!(!d.satisfy(1)); - assert!(!d.conflict(1)); - } - - #[test] - fn test_revert_last() { - let mut d = Decisions::new(); - d.decide(1, 1, 0).unwrap(); - d.decide(2, 2, 1).unwrap(); - - assert!(d.decided(2)); - d.revert_last(); - assert!(d.undecided(2)); - assert!(d.decided(1)); - } - - #[test] - fn test_reset_to_offset() { - let mut d = Decisions::new(); - d.decide(1, 1, 0).unwrap(); - d.decide(2, 2, 1).unwrap(); - d.decide(3, 3, 2).unwrap(); - - d.reset_to_offset(0); // keep only first decision - assert_eq!(d.len(), 1); - assert!(d.decided(1)); - assert!(d.undecided(2)); - assert!(d.undecided(3)); - } - - #[test] - fn test_double_decide_error() { - let mut d = Decisions::new(); - d.decide(1, 1, 0).unwrap(); - assert!(d.decide(1, 2, 1).is_err()); - } - - #[test] - fn test_decision_level() { - let mut d = Decisions::new(); - d.decide(1, 3, 0).unwrap(); - assert_eq!(d.decision_level(1), 3); - assert_eq!(d.decision_level(2), 0); // undecided - } -} diff --git a/crates/mozart-sat-resolver/src/error.rs b/crates/mozart-sat-resolver/src/error.rs deleted file mode 100644 index e4b9841..0000000 --- a/crates/mozart-sat-resolver/src/error.rs +++ /dev/null @@ -1,50 +0,0 @@ -use std::fmt; - -/// A bug in the solver itself (should never happen in normal operation). -/// Equivalent to Composer's SolverBugException. -#[derive(Debug, Clone)] -pub struct SolverBugError { - pub message: String, -} - -impl fmt::Display for SolverBugError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "Solver bug: {}", self.message) - } -} - -impl std::error::Error for SolverBugError {} - -/// Errors produced by the SAT solver. -#[derive(Debug)] -pub enum SolverError { - /// Internal solver bug (should never happen). - Bug(SolverBugError), - /// The dependency set is unsolvable. Contains problem descriptions. - Unsolvable(Vec), -} - -impl fmt::Display for SolverError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - SolverError::Bug(e) => write!(f, "{e}"), - SolverError::Unsolvable(problems) => { - for (i, problem) in problems.iter().enumerate() { - if i > 0 { - writeln!(f)?; - } - write!(f, " Problem {}: {problem}", i + 1)?; - } - Ok(()) - } - } - } -} - -impl std::error::Error for SolverError {} - -impl From for SolverError { - fn from(e: SolverBugError) -> Self { - SolverError::Bug(e) - } -} diff --git a/crates/mozart-sat-resolver/src/lib.rs b/crates/mozart-sat-resolver/src/lib.rs deleted file mode 100644 index 2e3fefb..0000000 --- a/crates/mozart-sat-resolver/src/lib.rs +++ /dev/null @@ -1,25 +0,0 @@ -pub mod decisions; -pub mod error; -pub mod policy; -pub mod pool; -pub mod pool_builder; -pub mod problem; -pub mod request; -pub mod rule; -pub mod rule_set; -pub mod rule_set_generator; -pub mod rule_watch_graph; -pub mod solver; -pub mod transaction; - -// Re-export key types for public API -pub use error::SolverError; -pub use policy::DefaultPolicy; -pub use pool::{Literal, PackageId, Pool, PoolLink, PoolPackage, PoolPackageInput}; -pub use pool_builder::{PoolBuilder, make_pool_links}; -pub use request::Request; -pub use rule::{ReasonData, Rule, RuleReason}; -pub use rule_set::RuleSet; -pub use rule_set_generator::RuleSetGenerator; -pub use solver::{Solver, SolverResult}; -pub use transaction::{LockTransaction, Operation, Transaction}; diff --git a/crates/mozart-sat-resolver/src/policy.rs b/crates/mozart-sat-resolver/src/policy.rs deleted file mode 100644 index f45c4f5..0000000 --- a/crates/mozart-sat-resolver/src/policy.rs +++ /dev/null @@ -1,264 +0,0 @@ -use crate::pool::{Literal, Pool}; -use indexmap::IndexMap; - -/// Version selection policy: decides which version to prefer when multiple -/// candidates satisfy a requirement. -/// -/// Port of Composer's DefaultPolicy.php. -pub struct DefaultPolicy { - /// Whether to prefer stable versions. - pub prefer_stable: bool, - /// Whether to prefer lowest versions. - pub prefer_lowest: bool, - /// `name → normalized version` overrides used when more than one - /// candidate could satisfy a requirement: a literal pinned at the - /// preferred version wins outright over the usual highest/lowest pick. - /// Mirrors Composer's `DefaultPolicy::pruneToBestVersion` behavior under - /// `--minimal-changes`, where the lock's previously-installed versions - /// are passed in so the solver only moves a package when a constraint - /// actually forces a different version. - pub preferred_versions: Option>, -} - -impl DefaultPolicy { - pub fn new(prefer_stable: bool, prefer_lowest: bool) -> Self { - DefaultPolicy { - prefer_stable, - prefer_lowest, - preferred_versions: None, - } - } - - pub fn with_preferred( - prefer_stable: bool, - prefer_lowest: bool, - preferred_versions: IndexMap, - ) -> Self { - DefaultPolicy { - prefer_stable, - prefer_lowest, - preferred_versions: Some(preferred_versions), - } - } - - /// Select preferred packages from a list of candidate literals. - /// Returns the literals sorted by preference (most preferred first). - /// - /// Port of Composer's DefaultPolicy::selectPreferredPackages. - pub fn select_preferred_packages( - &self, - pool: &Pool, - literals: &[Literal], - _required_package: Option<&str>, - ) -> Vec { - if literals.is_empty() { - return vec![]; - } - - // Group literals by package name - let mut groups: IndexMap<&str, Vec> = IndexMap::new(); - for &lit in literals { - let pkg = pool.literal_to_package(lit); - groups.entry(pkg.name.as_str()).or_default().push(lit); - } - - // Sort each group by version preference - for lits in groups.values_mut() { - lits.sort_by(|&a, &b| self.compare_by_priority(pool, a, b)); - } - - // Prune to best version within each group - for lits in groups.values_mut() { - *lits = self.prune_to_best_version(pool, lits); - } - - // Merge and sort across all packages - let mut selected: Vec = groups.into_values().flatten().collect(); - selected.sort_by(|&a, &b| self.compare_by_priority(pool, a, b)); - - selected - } - - /// Compare two package literals by priority. - /// Returns Ordering: negative means a is preferred. - fn compare_by_priority(&self, pool: &Pool, a: Literal, b: Literal) -> std::cmp::Ordering { - let pkg_a = pool.literal_to_package(a); - let pkg_b = pool.literal_to_package(b); - - // If same name, apply Composer's policy ordering. Mirrors - // `DefaultPolicy::versionCompare`: when `prefer_stable` is on and - // the two candidates have different stabilities, the more-stable - // one wins outright — `prefer_lowest` only kicks in within the same - // stability tier. Otherwise sort by version (asc for prefer_lowest, - // desc otherwise). - if pkg_a.name == pkg_b.name { - if self.prefer_stable { - let stab_a = stability_priority(&pkg_a.version); - let stab_b = stability_priority(&pkg_b.version); - if stab_a != stab_b { - return stab_a.cmp(&stab_b); - } - } - let cmp = self.compare_versions(&pkg_a.version, &pkg_b.version); - return if self.prefer_lowest { - cmp - } else { - cmp.reverse() - }; - } - - // Different names: when one package replaces the other, prefer the - // *replaced* original. Mirrors the `replaces()` shortcut in - // Composer's `DefaultPolicy::compareByPriority` (the cross-package - // `ignoreReplace=false` pass). Without this, a request like - // `update a/installed` where the pool also contains an - // `a/replacer` declaring `replace: { "a/installed": "dev-master" }` - // could fall through to package-id tie-break and land on the - // replacer instead of the package the user actually asked for. - if pkg_a.replaces.iter().any(|link| link.target == pkg_b.name) { - return std::cmp::Ordering::Greater; - } - if pkg_b.replaces.iter().any(|link| link.target == pkg_a.name) { - return std::cmp::Ordering::Less; - } - - // Different names, no replace relationship: sort by package ID - // for reproducibility. - pkg_a.id.cmp(&pkg_b.id) - } - - /// Compare two normalized version strings. - fn compare_versions(&self, a: &str, b: &str) -> std::cmp::Ordering { - match ( - mozart_semver::Version::parse(a), - mozart_semver::Version::parse(b), - ) { - (Ok(va), Ok(vb)) => va.cmp(&vb), - _ => a.cmp(b), - } - } - - /// Prune to the best version among a sorted list of literals for the same package. - fn prune_to_best_version(&self, pool: &Pool, literals: &[Literal]) -> Vec { - if literals.is_empty() { - return vec![]; - } - - // Mirror Composer's `DefaultPolicy::pruneToBestVersion` short-circuit: - // when a preferred version is set for this package and one of the - // candidates matches it exactly, that wins over the regular - // highest/lowest pick. Falls through otherwise (e.g. the locked - // version no longer satisfies the constraint and was filtered out - // before reaching this method). - if let Some(ref preferred) = self.preferred_versions { - let name = pool.literal_to_package(literals[0]).name.clone(); - if let Some(preferred_ver) = preferred.get(&name) { - let preferred_lits: Vec = literals - .iter() - .filter(|&&lit| pool.literal_to_package(lit).version == *preferred_ver) - .copied() - .collect(); - if !preferred_lits.is_empty() { - return preferred_lits; - } - } - } - - // The first literal is the best after sorting - let best_version = &pool.literal_to_package(literals[0]).version; - literals - .iter() - .filter(|&&lit| pool.literal_to_package(lit).version == *best_version) - .copied() - .collect() - } -} - -impl Default for DefaultPolicy { - fn default() -> Self { - DefaultPolicy::new(false, false) - } -} - -/// Map a normalized version string to Composer's stability priority -/// (`BasePackage::STABILITIES`). Lower = more stable. Stable=0, RC=5, beta=10, -/// alpha=15, dev=20. Mirrors `DefaultPolicy::versionCompare`'s comparison -/// when `prefer_stable` is set. -fn stability_priority(version: &str) -> u8 { - let Ok(v) = mozart_semver::Version::parse(version) else { - return 0; - }; - if v.is_dev_branch { - return 20; - } - match v.pre_release.as_deref() { - None => 0, - Some(pre) => { - let lower = pre.to_lowercase(); - if lower.starts_with("dev") { - 20 - } else if lower.starts_with("alpha") || lower == "a" { - 15 - } else if lower.starts_with("beta") || lower == "b" { - 10 - } else if lower.starts_with("rc") { - 5 - } else { - // patch/pl/p / unknown → stable - 0 - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::pool::PoolPackageInput; - - fn make_input(name: &str, version: &str) -> PoolPackageInput { - PoolPackageInput { - name: name.to_string(), - version: version.to_string(), - pretty_version: version.to_string(), - requires: vec![], - replaces: vec![], - provides: vec![], - conflicts: vec![], - is_fixed: false, - is_alias_of: None, - } - } - - #[test] - fn test_prefer_highest() { - let pool = Pool::new( - vec![ - make_input("a/a", "1.0.0.0"), - make_input("a/a", "2.0.0.0"), - make_input("a/a", "3.0.0.0"), - ], - vec![], - ); - let policy = DefaultPolicy::new(false, false); - let result = policy.select_preferred_packages(&pool, &[1, 2, 3], None); - // Should prefer highest version (3.0.0.0 = id 3) - assert_eq!(result[0], 3); - } - - #[test] - fn test_prefer_lowest() { - let pool = Pool::new( - vec![ - make_input("a/a", "1.0.0.0"), - make_input("a/a", "2.0.0.0"), - make_input("a/a", "3.0.0.0"), - ], - vec![], - ); - let policy = DefaultPolicy::new(false, true); - let result = policy.select_preferred_packages(&pool, &[1, 2, 3], None); - // Should prefer lowest version (1.0.0.0 = id 1) - assert_eq!(result[0], 1); - } -} diff --git a/crates/mozart-sat-resolver/src/pool.rs b/crates/mozart-sat-resolver/src/pool.rs deleted file mode 100644 index 8a63c05..0000000 --- a/crates/mozart-sat-resolver/src/pool.rs +++ /dev/null @@ -1,427 +0,0 @@ -use indexmap::IndexMap; -use mozart_semver::VersionConstraint; -use std::fmt; - -/// Unique identifier for a package in the pool. 1-based. -pub type PackageId = u32; - -/// A SAT literal. Positive = install package, negative = don't install. -/// The absolute value is the PackageId. -pub type Literal = i32; - -/// Returns the PackageId from a literal. -#[inline] -pub fn literal_to_package_id(literal: Literal) -> PackageId { - literal.unsigned_abs() -} - -/// A link from a package to another package name with a version constraint. -#[derive(Debug, Clone)] -pub struct PoolLink { - /// The target package name. - pub target: String, - /// The version constraint string (e.g. "^1.0"). - pub constraint: String, - /// The source package name (the one declaring this link). - pub source: String, -} - -impl fmt::Display for PoolLink { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{} {} {}", self.source, self.target, self.constraint) - } -} - -/// A package entry in the pool. This is the SAT solver's view of a package. -#[derive(Debug, Clone)] -pub struct PoolPackage { - /// 1-based package ID assigned by the pool. - pub id: PackageId, - /// Normalized package name (e.g. "monolog/monolog"). - pub name: String, - /// Normalized version string (e.g. "1.0.0.0"). - pub version: String, - /// Pretty version string (e.g. "1.0.0"). - pub pretty_version: String, - /// Package requirements. - pub requires: Vec, - /// Packages this replaces. - pub replaces: Vec, - /// Packages this provides. - pub provides: Vec, - /// Packages this conflicts with. - pub conflicts: Vec, - /// Whether this is a fixed/locked package. - pub is_fixed: bool, - /// If `Some`, this package is an `AliasPackage` whose target is the - /// other pool entry with the given ID. Composer creates these for - /// `extra.branch-alias` entries (dev branch → numeric alias). When set, - /// the rule generator emits `PackageAlias`/`PackageInverseAlias` rules - /// instead of regular requires; same-name conflict rules also skip - /// alias packages. - pub is_alias_of: Option, -} - -impl PoolPackage { - /// Returns all names this package is known by (own name + provides + replaces targets). - pub fn names(&self) -> Vec<&str> { - let mut names = vec![self.name.as_str()]; - for link in &self.provides { - names.push(link.target.as_str()); - } - for link in &self.replaces { - names.push(link.target.as_str()); - } - names - } - - /// Names that drive same-name conflict resolution — own name plus - /// `replace` targets. `provide` targets are excluded because two packages - /// providing different versions of the same virtual name may legitimately - /// coexist; `replace` declares the replacing package fully supplants the - /// replaced one. Mirrors Composer's `BasePackage::getNames(false)`. - pub fn conflict_names(&self) -> Vec<&str> { - let mut names = vec![self.name.as_str()]; - for link in &self.replaces { - names.push(link.target.as_str()); - } - names - } -} - -impl fmt::Display for PoolPackage { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{} {}", self.name, self.pretty_version) - } -} - -/// Input for building a Pool. Users of the crate provide these. -#[derive(Debug, Clone)] -pub struct PoolPackageInput { - pub name: String, - pub version: String, - pub pretty_version: String, - pub requires: Vec, - pub replaces: Vec, - pub provides: Vec, - pub conflicts: Vec, - pub is_fixed: bool, - /// When `Some`, the value is the **normalized** version of another input - /// in this build batch with the same `name`; the pool will resolve it to - /// that input's [`PackageId`] in [`PoolPackage::is_alias_of`]. Used by - /// the registry layer to materialize Composer's `AliasPackage` for - /// `extra.branch-alias` entries. - pub is_alias_of: Option, -} - -/// The package pool: contains all candidate packages for dependency resolution. -/// Packages are assigned sequential 1-based IDs. -/// -/// Port of Composer's Pool.php. -pub struct Pool { - /// All packages, indexed by (id - 1). - packages: Vec, - /// Index: package name → list of package IDs providing that name. - package_by_name: IndexMap>, - /// Cache for what_provides results. - provider_cache: IndexMap<(String, String), Vec>, - /// Packages that are fixed/locked but unacceptable (e.g. failed stability). - unacceptable_fixed_packages: Vec, -} - -impl Pool { - /// Create a new pool from a list of package inputs. - pub fn new(inputs: Vec, unacceptable_fixed_ids: Vec) -> Self { - let mut packages: Vec = Vec::with_capacity(inputs.len()); - let mut package_by_name: IndexMap> = IndexMap::new(); - // Collect alias links (alias_idx, target_name, target_normalized) for - // a second pass once every input has a stable ID. - let mut pending_aliases: Vec<(usize, String, String)> = Vec::new(); - - for (idx, input) in inputs.into_iter().enumerate() { - let id = (idx as PackageId) + 1; - if let Some(target) = input.is_alias_of.clone() { - pending_aliases.push((idx, input.name.clone(), target)); - } - let pkg = PoolPackage { - id, - name: input.name, - version: input.version, - pretty_version: input.pretty_version, - requires: input.requires, - replaces: input.replaces, - provides: input.provides, - conflicts: input.conflicts, - is_fixed: input.is_fixed, - is_alias_of: None, - }; - - // Index by all names this package provides - for name in pkg.names() { - package_by_name - .entry(name.to_string()) - .or_default() - .push(id); - } - - packages.push(pkg); - } - - // Resolve alias targets: for each alias input, find the matching - // (name, normalized version) entry and store its ID. Mirrors the - // post-construction wiring Composer does in - // `RepositorySet::createAliasPackage` / `addPackage`. - for (alias_idx, name, target_normalized) in pending_aliases { - if let Some(ids) = package_by_name.get(&name) { - let target_id = ids.iter().copied().find(|&id| { - let candidate = &packages[(id - 1) as usize]; - !candidate.name.is_empty() - && candidate.name == name - && candidate.version == target_normalized - && candidate.is_alias_of.is_none() - }); - if let Some(tid) = target_id { - packages[alias_idx].is_alias_of = Some(tid); - } - } - } - - Pool { - packages, - package_by_name, - provider_cache: IndexMap::new(), - unacceptable_fixed_packages: unacceptable_fixed_ids, - } - } - - /// Returns the number of packages in the pool. - pub fn len(&self) -> usize { - self.packages.len() - } - - /// Returns true if the pool has no packages. - pub fn is_empty(&self) -> bool { - self.packages.is_empty() - } - - /// Look up a package by its 1-based ID. - pub fn package_by_id(&self, id: PackageId) -> &PoolPackage { - &self.packages[(id - 1) as usize] - } - - /// All packages in the pool. - pub fn packages(&self) -> &[PoolPackage] { - &self.packages - } - - /// Convert a literal to its package reference. - pub fn literal_to_package(&self, literal: Literal) -> &PoolPackage { - self.package_by_id(literal_to_package_id(literal)) - } - - /// Format a literal as a human-readable string. - pub fn literal_to_pretty_string(&self, literal: Literal) -> String { - let pkg = self.literal_to_package(literal); - let prefix = if literal > 0 { - "install" - } else { - "don't install" - }; - format!("{prefix} {} {}", pkg.name, pkg.pretty_version) - } - - /// Find all packages matching a name and optional constraint. - /// Results are cached. - pub fn what_provides(&mut self, name: &str, constraint: Option<&str>) -> Vec { - let key = (name.to_string(), constraint.unwrap_or("").to_string()); - if let Some(cached) = self.provider_cache.get(&key) { - return cached.clone(); - } - - let result = self.compute_what_provides(name, constraint); - self.provider_cache.insert(key, result.clone()); - result - } - - fn compute_what_provides(&self, name: &str, constraint: Option<&str>) -> Vec { - let Some(candidate_ids) = self.package_by_name.get(name) else { - return vec![]; - }; - - let parsed_constraint = constraint.and_then(|c| VersionConstraint::parse(c).ok()); - - let mut matches = Vec::new(); - for &id in candidate_ids { - let pkg = self.package_by_id(id); - if self.matches_package(pkg, name, parsed_constraint.as_ref()) { - matches.push(id); - } - } - matches - } - - /// Check if a candidate package matches a name and optional constraint. - /// Handles provides and replaces. - fn matches_package( - &self, - candidate: &PoolPackage, - name: &str, - constraint: Option<&VersionConstraint>, - ) -> bool { - if candidate.name == name { - return match constraint { - None => true, - Some(vc) => { - // Try the normalized version first; fall back to the - // pretty version. Composer normalizes both sides of a - // constraint match to a single string form (e.g. - // `dev-master` → `9999999-dev`), so a query for - // `dev-master` matches a package whose pretty version - // is `dev-master` even when the pool stores its - // version field in a different normalized shape (e.g. - // the four-segment `9999999.9999999.9999999.9999999-dev` - // expansion Mozart uses internally for default-branch - // and root-alias entries). The pretty fallback bridges - // that gap without forcing the pool to commit to a - // single normalization. - if let Ok(v) = mozart_semver::Version::parse(&candidate.version) - && vc.matches(&v) - { - return true; - } - if let Ok(pv) = mozart_semver::Version::parse(&candidate.pretty_version) - && vc.matches(&pv) - { - return true; - } - false - } - }; - } - - // Check provides. A package may declare more than one provide link - // for the same target (e.g. an `AliasPackage` carries the base's link - // and an extra link tagged at the alias's own version), so keep - // iterating once a target name matches but the constraint doesn't — - // a later link may still satisfy. - for link in &candidate.provides { - if link.target != name { - continue; - } - match constraint { - None => return true, - Some(vc) => { - if let Ok(provide_vc) = VersionConstraint::parse(&link.constraint) - && constraints_intersect(vc, &provide_vc) - { - return true; - } - } - } - } - - for link in &candidate.replaces { - if link.target != name { - continue; - } - match constraint { - None => return true, - Some(vc) => { - if let Ok(replace_vc) = VersionConstraint::parse(&link.constraint) - && constraints_intersect(vc, &replace_vc) - { - return true; - } - } - } - } - - false - } - - /// Check if a package is in the unacceptable fixed list. - pub fn is_unacceptable_fixed_package(&self, id: PackageId) -> bool { - self.unacceptable_fixed_packages.contains(&id) - } -} - -impl fmt::Display for Pool { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - writeln!(f, "Pool:")?; - for pkg in &self.packages { - writeln!(f, " {:>6}: {} {}", pkg.id, pkg.name, pkg.pretty_version)?; - } - Ok(()) - } -} - -/// Whether the request constraint and the provide/replace link constraint -/// share at least one satisfying version. Mirrors Composer's -/// `ConstraintInterface::matches` semantics: a provide/replace link only -/// makes the candidate a viable provider for those versions of the target -/// that fall in the link's constraint. -fn constraints_intersect(a: &VersionConstraint, b: &VersionConstraint) -> bool { - a.intersects(b) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_input(name: &str, version: &str) -> PoolPackageInput { - PoolPackageInput { - name: name.to_string(), - version: version.to_string(), - pretty_version: version.to_string(), - requires: vec![], - replaces: vec![], - provides: vec![], - conflicts: vec![], - is_fixed: false, - is_alias_of: None, - } - } - - #[test] - fn test_pool_basic() { - let mut pool = Pool::new( - vec![ - make_input("a/a", "1.0.0.0"), - make_input("a/a", "2.0.0.0"), - make_input("b/b", "1.0.0.0"), - ], - vec![], - ); - - assert_eq!(pool.len(), 3); - assert_eq!(pool.package_by_id(1).name, "a/a"); - assert_eq!(pool.package_by_id(2).name, "a/a"); - assert_eq!(pool.package_by_id(3).name, "b/b"); - - let providers = pool.what_provides("a/a", None); - assert_eq!(providers, vec![1, 2]); - } - - #[test] - fn test_literal_to_package() { - let pool = Pool::new( - vec![make_input("a/a", "1.0.0.0"), make_input("b/b", "1.0.0.0")], - vec![], - ); - - assert_eq!(pool.literal_to_package(1).name, "a/a"); - assert_eq!(pool.literal_to_package(-1).name, "a/a"); - assert_eq!(pool.literal_to_package(2).name, "b/b"); - assert_eq!(pool.literal_to_package(-2).name, "b/b"); - } - - #[test] - fn test_literal_pretty_string() { - let pool = Pool::new(vec![make_input("a/a", "1.0.0.0")], vec![]); - assert_eq!(pool.literal_to_pretty_string(1), "install a/a 1.0.0.0"); - assert_eq!( - pool.literal_to_pretty_string(-1), - "don't install a/a 1.0.0.0" - ); - } -} diff --git a/crates/mozart-sat-resolver/src/pool_builder.rs b/crates/mozart-sat-resolver/src/pool_builder.rs deleted file mode 100644 index 6088e7d..0000000 --- a/crates/mozart-sat-resolver/src/pool_builder.rs +++ /dev/null @@ -1,222 +0,0 @@ -use crate::pool::{Pool, PoolLink, PoolPackageInput}; -use indexmap::IndexSet; -use std::collections::VecDeque; - -/// Builder for constructing a Pool from package metadata. -/// -/// The builder accepts package inputs and recursively discovers -/// transitive dependencies. This is done by the registry layer -/// before solving. -pub struct PoolBuilder { - /// Packages to add to the pool. - inputs: Vec, - /// Names already added (to avoid duplicates). - added: IndexSet, - /// Queue of package names that need to be explored. - pending_names: VecDeque, - /// Package names that have already been explored (returned by next_pending). - explored_names: IndexSet, - /// Specific platform packages to ignore (from `--ignore-platform-req=name`). - ignore_platform_reqs: IndexSet, - /// When true, ignore every platform package (php, ext-*, lib-*, composer-*). - /// Mirrors `--ignore-platform-reqs` (no value). - ignore_all_platform_reqs: bool, -} - -impl PoolBuilder { - pub fn new() -> Self { - PoolBuilder { - inputs: Vec::new(), - added: IndexSet::new(), - pending_names: VecDeque::new(), - explored_names: IndexSet::new(), - ignore_platform_reqs: IndexSet::new(), - ignore_all_platform_reqs: false, - } - } - - /// Set platform requirements to ignore during exploration. - pub fn set_ignore_platform_reqs(&mut self, names: IndexSet) { - self.ignore_platform_reqs = names; - } - - /// When set, every platform package is skipped during exploration. - pub fn set_ignore_all_platform_reqs(&mut self, ignore_all: bool) { - self.ignore_all_platform_reqs = ignore_all; - } - - fn is_ignored_platform_dep(&self, name: &str) -> bool { - if self - .ignore_platform_reqs - .iter() - .any(|p| mozart_core::matches_wildcard(name, p)) - { - return true; - } - self.ignore_all_platform_reqs && mozart_core::platform::is_platform_package(name) - } - - /// Add a package version to the builder. Returns true if it's new. - pub fn add_package(&mut self, input: PoolPackageInput) -> bool { - let key = format!("{}@{}", input.name, input.version); - if self.added.contains(&key) { - return false; - } - self.added.insert(key); - - // Queue dependency names for exploration - for link in &input.requires { - if !self.is_ignored_platform_dep(&link.target) { - self.pending_names.push_back(link.target.clone()); - } - } - - self.inputs.push(input); - true - } - - /// Get the next package name that needs to be explored. - /// The caller should fetch available versions for this package - /// and add them via `add_package`. - pub fn next_pending(&mut self) -> Option { - while let Some(name) = self.pending_names.pop_front() { - // Skip if already explored or already has versions in inputs - if self.explored_names.contains(&name) { - continue; - } - if self.inputs.iter().any(|p| p.name == name) { - continue; - } - self.explored_names.insert(name.clone()); - return Some(name); - } - None - } - - /// Check if there are more names to explore. - pub fn has_pending(&self) -> bool { - !self.pending_names.is_empty() - } - - /// Build the final Pool. - pub fn build(self) -> Pool { - Pool::new(self.inputs, vec![]) - } - - /// Get the number of packages added so far. - pub fn len(&self) -> usize { - self.inputs.len() - } - - /// Read-only access to package inputs collected so far. Used by the - /// registry layer to materialize root aliases (`require: "X as Y"`) once - /// every base + branch-alias entry is in place: a second pass scans for - /// matching `(name, version)` and pushes the alias entry on top. - pub fn inputs(&self) -> &[PoolPackageInput] { - &self.inputs - } - - /// Whether the builder has no packages. - pub fn is_empty(&self) -> bool { - self.inputs.is_empty() - } -} - -impl Default for PoolBuilder { - fn default() -> Self { - Self::new() - } -} - -/// Helper to convert (name, constraint) pairs from Packagist into PoolLinks. -/// -/// `source_version` is the normalized version of the package declaring these -/// links; it replaces any `"self.version"` constraint, mirroring Composer's -/// `ArrayLoader::createLink` (and `AliasPackage::replaceSelfVersionDependencies`, -/// which feeds the alias's own version in for the same purpose). -pub fn make_pool_links( - source: &str, - source_version: &str, - deps: &[(String, String)], -) -> Vec { - deps.iter() - .map(|(target, constraint)| PoolLink { - target: target.clone(), - constraint: if constraint.trim() == "self.version" { - source_version.to_string() - } else { - constraint.clone() - }, - source: source.to_string(), - }) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_pool_builder_basic() { - let mut builder = PoolBuilder::new(); - - builder.add_package(PoolPackageInput { - name: "a/a".to_string(), - version: "1.0.0.0".to_string(), - pretty_version: "1.0.0".to_string(), - requires: vec![PoolLink { - target: "b/b".to_string(), - constraint: "^1.0".to_string(), - source: "a/a".to_string(), - }], - replaces: vec![], - provides: vec![], - conflicts: vec![], - is_fixed: false, - is_alias_of: None, - }); - - // Should have b/b pending - let pending = builder.next_pending(); - assert_eq!(pending, Some("b/b".to_string())); - - builder.add_package(PoolPackageInput { - name: "b/b".to_string(), - version: "1.0.0.0".to_string(), - pretty_version: "1.0.0".to_string(), - requires: vec![], - replaces: vec![], - provides: vec![], - conflicts: vec![], - is_fixed: false, - is_alias_of: None, - }); - - // No more pending - assert!(builder.next_pending().is_none()); - - let pool = builder.build(); - assert_eq!(pool.len(), 2); - } - - #[test] - fn test_deduplication() { - let mut builder = PoolBuilder::new(); - - let input = PoolPackageInput { - name: "a/a".to_string(), - version: "1.0.0.0".to_string(), - pretty_version: "1.0.0".to_string(), - requires: vec![], - replaces: vec![], - provides: vec![], - conflicts: vec![], - is_fixed: false, - is_alias_of: None, - }; - - assert!(builder.add_package(input.clone())); - assert!(!builder.add_package(input)); - assert_eq!(builder.len(), 1); - } -} diff --git a/crates/mozart-sat-resolver/src/problem.rs b/crates/mozart-sat-resolver/src/problem.rs deleted file mode 100644 index a1692fd..0000000 --- a/crates/mozart-sat-resolver/src/problem.rs +++ /dev/null @@ -1,499 +0,0 @@ -use crate::pool::{Literal, Pool, literal_to_package_id}; -use crate::rule::{ReasonData, Rule, RuleReason}; -use crate::rule_set::{RuleId, RuleSet}; - -/// Represents a conflict found during resolution. -/// Collects the rules involved in the problem. -/// -/// Port of Composer's Problem.php. -#[derive(Debug, Clone)] -pub struct Problem { - /// Sections of rules that form this problem. - /// Each section is a group of related rules. - sections: Vec>, -} - -impl Problem { - pub fn new() -> Self { - Problem { - sections: vec![vec![]], - } - } - - /// Add a rule to the current section. - pub fn add_rule(&mut self, rule_id: RuleId) { - if let Some(section) = self.sections.last_mut() - && !section.contains(&rule_id) - { - section.push(rule_id); - } - } - - /// Start a new section. - pub fn next_section(&mut self) { - if self.sections.last().is_some_and(|s| !s.is_empty()) { - self.sections.push(vec![]); - } - } - - /// Get all rule IDs in this problem. - pub fn rule_ids(&self) -> Vec { - self.sections.iter().flatten().copied().collect() - } - - /// Format the problem as a human-readable string using Pool data. - /// - /// Port of Composer's Problem::getPrettyString(). - pub fn pretty_string(&self, pool: &Pool, rules: &RuleSet) -> String { - // Flatten all sections (reversed) like Composer does - let mut all_rules: Vec = self.sections.iter().rev().flatten().copied().collect(); - - if all_rules.is_empty() { - return "Unknown problem".to_string(); - } - - // Sort by priority, then by sortable string - all_rules.sort_by(|&a, &b| { - let rule_a = rules.rule_by_id(a); - let rule_b = rules.rule_by_id(b); - let prio_a = rule_priority(rule_a); - let prio_b = rule_priority(rule_b); - if prio_a != prio_b { - return prio_b.cmp(&prio_a); - } - sortable_string(pool, rule_a).cmp(&sortable_string(pool, rule_b)) - }); - - // Format each rule - let mut messages: Vec = Vec::new(); - for &rule_id in &all_rules { - let rule = rules.rule_by_id(rule_id); - let msg = rule_pretty_string(pool, rule); - if !msg.is_empty() { - messages.push(msg); - } - } - - // Deduplicate - let mut seen = indexmap::IndexSet::new(); - let mut unique = Vec::new(); - for msg in messages { - if seen.insert(msg.clone()) { - unique.push(msg); - } - } - - if unique.is_empty() { - return "Unknown problem".to_string(); - } - - unique - .iter() - .map(|m| format!(" - {m}")) - .collect::>() - .join("\n") - } - - /// Basic format for backward compatibility (uses rule Display). - pub fn format(&self, rules: &RuleSet) -> String { - let mut parts = Vec::new(); - for section in &self.sections { - for &rule_id in section { - let rule = rules.rule_by_id(rule_id); - parts.push(format!(" - {rule}")); - } - } - if parts.is_empty() { - "Unknown problem".to_string() - } else { - parts.join("\n") - } - } -} - -impl Default for Problem { - fn default() -> Self { - Self::new() - } -} - -/// Get the sort priority for a rule (higher = more important). -/// Port of Problem::getRulePriority(). -fn rule_priority(rule: &Rule) -> u8 { - match rule.reason { - RuleReason::Fixed => 3, - RuleReason::RootRequire => 2, - RuleReason::PackageConflict | RuleReason::PackageRequires => 1, - RuleReason::PackageSameName - | RuleReason::Learned - | RuleReason::PackageAlias - | RuleReason::PackageInverseAlias => 0, - } -} - -/// Get a sortable string for a rule. -/// Port of Problem::getSortableString(). -fn sortable_string(pool: &Pool, rule: &Rule) -> String { - match (&rule.reason, &rule.reason_data) { - (RuleReason::RootRequire, ReasonData::RootRequire { package_name, .. }) => { - package_name.clone() - } - (RuleReason::Fixed, ReasonData::Fixed { package_id }) => { - pool.package_by_id(*package_id).to_string() - } - (RuleReason::PackageConflict | RuleReason::PackageRequires, ReasonData::Link(link)) => { - if let Some(source_lit) = rule.literals().first() { - let source_pkg = pool.literal_to_package(*source_lit); - format!("{}//{}", source_pkg, link) - } else { - link.to_string() - } - } - (RuleReason::PackageSameName, ReasonData::PackageName(name)) => name.clone(), - (RuleReason::Learned, _) => rule - .literals() - .iter() - .map(|l: &Literal| l.to_string()) - .collect::>() - .join("-"), - _ => String::new(), - } -} - -/// Format a rule as a human-readable string. -/// Port of Composer's Rule::getPrettyString(). -fn rule_pretty_string(pool: &Pool, rule: &Rule) -> String { - match (&rule.reason, &rule.reason_data) { - ( - RuleReason::RootRequire, - ReasonData::RootRequire { - package_name, - constraint, - }, - ) => { - let providers = format_providers(pool, rule.literals()); - if providers.is_empty() { - format!( - "No package found to satisfy root composer.json require {package_name} {constraint}" - ) - } else { - format!( - "Root composer.json requires {package_name} {constraint} -> satisfiable by {providers}." - ) - } - } - - (RuleReason::Fixed, ReasonData::Fixed { package_id }) => { - let pkg = pool.package_by_id(*package_id); - if pkg.is_fixed { - format!( - "{} {} is locked to version {} and an update of this package was not requested.", - pkg.name, pkg.pretty_version, pkg.pretty_version - ) - } else { - format!( - "{} {} is present at version {} and cannot be modified by Mozart", - pkg.name, pkg.pretty_version, pkg.pretty_version - ) - } - } - - (RuleReason::PackageConflict, ReasonData::Link(link)) => { - let literals = rule.literals(); - if literals.len() >= 2 { - let pkg1 = pool.literal_to_package(literals[0]); - let pkg2 = pool.literal_to_package(literals[1]); - // Determine which is the source of the conflict - if link.source == pkg1.name { - format!("{pkg2} conflicts with {pkg1}.") - } else { - format!("{pkg1} conflicts with {pkg2}.") - } - } else { - format!("Conflict: {link}") - } - } - - (RuleReason::PackageRequires, ReasonData::Link(link)) => { - let literals = rule.literals(); - if literals.is_empty() { - return format!("Requirement: {link}"); - } - - let source_pkg = pool.literal_to_package(literals[0]); - let base_text = format!( - "{} {} requires {} {}", - source_pkg.name, source_pkg.pretty_version, link.target, link.constraint - ); - - // Remaining literals are the satisfying packages - let provider_lits: Vec = literals[1..].to_vec(); - if provider_lits.is_empty() { - format!("{base_text} -> no matching package found.") - } else { - let providers = format_providers(pool, &provider_lits); - format!("{base_text} -> satisfiable by {providers}.") - } - } - - (RuleReason::PackageSameName, ReasonData::PackageName(name)) => { - let literals = rule.literals(); - // Collect unique package names in this rule - let mut pkg_names: Vec = Vec::new(); - for &lit in literals { - let pkg = pool.literal_to_package(lit); - if !pkg_names.contains(&pkg.name) { - pkg_names.push(pkg.name.clone()); - } - } - - if pkg_names.len() > 1 { - // Different packages that replace/provide the same name - let replacers: Vec<&str> = pkg_names - .iter() - .filter(|n| n.as_str() != name) - .map(|n| n.as_str()) - .collect(); - - let reason = if replacers.is_empty() { - format!("They all replace {name} and thus cannot coexist.") - } else if !pkg_names.contains(name) { - format!( - "They {} replace {name} and thus cannot coexist.", - if literals.len() == 2 { "both" } else { "all" } - ) - } else if replacers.len() == 1 { - format!( - "{} replaces {name} and thus cannot coexist with it.", - replacers[0] - ) - } else { - format!( - "[{}] replace {name} and thus cannot coexist with it.", - replacers.join(", ") - ) - }; - - let pkgs_str = format_providers(pool, literals); - format!("Only one of these can be installed: {pkgs_str}. {reason}") - } else { - // Same package, different versions - let pkgs_str = format_providers(pool, literals); - format!( - "You can only install one version of a package, so only one of these can be installed: {pkgs_str}." - ) - } - } - - (RuleReason::Learned, _) => { - let literals = rule.literals(); - if literals.len() == 1 { - let pretty = pool.literal_to_pretty_string(literals[0]); - format!("Conclusion: {pretty} (conflict analysis result)") - } else { - // Group literals by install/don't install - let mut install = Vec::new(); - let mut dont_install = Vec::new(); - for &lit in literals { - if lit > 0 { - install.push(lit); - } else { - dont_install.push(lit); - } - } - - let mut parts = Vec::new(); - if !install.is_empty() { - let pkgs = format_providers(pool, &install); - if install.len() > 1 { - parts.push(format!("install one of {pkgs}")); - } else { - parts.push(format!("install {pkgs}")); - } - } - if !dont_install.is_empty() { - let pkgs = format_providers_abs(pool, &dont_install); - if dont_install.len() > 1 { - parts.push(format!("don't install one of {pkgs}")); - } else { - parts.push(format!("don't install {pkgs}")); - } - } - - format!( - "Conclusion: {} (conflict analysis result)", - parts.join(" | ") - ) - } - } - - (RuleReason::PackageAlias, _) => { - let literals = rule.literals(); - if literals.len() >= 2 { - let alias_pkg = pool.literal_to_package(literals[0]); - let target_pkg = pool.literal_to_package(literals[1]); - format!( - "{alias_pkg} is an alias of {target_pkg} and thus requires it to be installed too." - ) - } else { - String::new() - } - } - - (RuleReason::PackageInverseAlias, _) => { - let literals = rule.literals(); - if literals.len() >= 2 { - let target_pkg = pool.literal_to_package(literals[0]); - let alias_pkg = pool.literal_to_package(literals[1]); - format!("{alias_pkg} is an alias of {target_pkg} and must be installed with it.") - } else { - String::new() - } - } - - _ => { - // Fallback: display raw literals - let literal_strs: Vec = rule - .literals() - .iter() - .map(|&l| pool.literal_to_pretty_string(l)) - .collect(); - literal_strs.join(" | ") - } - } -} - -/// Format a list of literals as a list of package names grouped by name. -/// Similar to Composer's formatPackagesUnique. -fn format_providers(pool: &Pool, literals: &[Literal]) -> String { - // Group by package name - let mut groups: indexmap::IndexMap<&str, Vec<&str>> = indexmap::IndexMap::new(); - for &lit in literals { - let pkg = pool.literal_to_package(lit); - groups - .entry(&pkg.name) - .or_default() - .push(&pkg.pretty_version); - } - - let mut parts: Vec = Vec::new(); - for (name, versions) in &groups { - if versions.len() == 1 { - parts.push(format!("{name} {}", versions[0])); - } else { - let v_str = versions.join(", "); - parts.push(format!("{name}[{v_str}]")); - } - } - - parts.sort(); - parts.join(", ") -} - -/// Same as format_providers but uses absolute value of literals. -fn format_providers_abs(pool: &Pool, literals: &[Literal]) -> String { - let abs_lits: Vec = literals - .iter() - .map(|&l| literal_to_package_id(l) as Literal) - .collect(); - format_providers(pool, &abs_lits) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::pool::PoolPackageInput; - use crate::rule::{ReasonData, Rule, RuleReason, RuleType}; - - fn make_input(name: &str, version: &str, pretty: &str) -> PoolPackageInput { - PoolPackageInput { - name: name.to_string(), - version: version.to_string(), - pretty_version: pretty.to_string(), - requires: vec![], - replaces: vec![], - provides: vec![], - conflicts: vec![], - is_fixed: false, - is_alias_of: None, - } - } - - #[test] - fn test_root_require_pretty_string() { - let pool = Pool::new(vec![make_input("foo/bar", "1.0.0.0", "1.0.0")], vec![]); - - let mut rule_set = RuleSet::new(); - let rule = Rule::new( - vec![1], - RuleReason::RootRequire, - ReasonData::RootRequire { - package_name: "foo/bar".to_string(), - constraint: "^1.0".to_string(), - }, - ); - rule_set.add(rule, RuleType::Request); - - let mut problem = Problem::new(); - problem.add_rule(0); - - let output = problem.pretty_string(&pool, &rule_set); - assert!(output.contains("Root composer.json requires foo/bar ^1.0")); - assert!(output.contains("satisfiable by foo/bar 1.0.0")); - } - - #[test] - fn test_same_name_pretty_string() { - let pool = Pool::new( - vec![ - make_input("foo/bar", "1.0.0.0", "1.0.0"), - make_input("foo/bar", "2.0.0.0", "2.0.0"), - ], - vec![], - ); - - let mut rule_set = RuleSet::new(); - let rule = Rule::new( - vec![-1, -2], - RuleReason::PackageSameName, - ReasonData::PackageName("foo/bar".to_string()), - ); - rule_set.add(rule, RuleType::Package); - - let mut problem = Problem::new(); - problem.add_rule(0); - - let output = problem.pretty_string(&pool, &rule_set); - assert!(output.contains("You can only install one version")); - } - - #[test] - fn test_package_requires_pretty_string() { - let pool = Pool::new( - vec![ - make_input("foo/bar", "1.0.0.0", "1.0.0"), - make_input("baz/qux", "2.0.0.0", "2.0.0"), - ], - vec![], - ); - - let mut rule_set = RuleSet::new(); - let rule = Rule::new( - vec![-1, 2], - RuleReason::PackageRequires, - ReasonData::Link(crate::pool::PoolLink { - source: "foo/bar".to_string(), - target: "baz/qux".to_string(), - constraint: "^2.0".to_string(), - }), - ); - rule_set.add(rule, RuleType::Package); - - let mut problem = Problem::new(); - problem.add_rule(0); - - let output = problem.pretty_string(&pool, &rule_set); - assert!(output.contains("foo/bar 1.0.0 requires baz/qux ^2.0")); - assert!(output.contains("satisfiable by baz/qux 2.0.0")); - } -} diff --git a/crates/mozart-sat-resolver/src/request.rs b/crates/mozart-sat-resolver/src/request.rs deleted file mode 100644 index 26c17ba..0000000 --- a/crates/mozart-sat-resolver/src/request.rs +++ /dev/null @@ -1,65 +0,0 @@ -use crate::pool::PackageId; -use indexmap::IndexMap; - -/// A requirement: package name + version constraint string. -#[derive(Debug, Clone)] -pub struct Require { - pub package_name: String, - pub constraint: Option, -} - -/// A request for the solver: what to install/fix/lock. -/// -/// Port of Composer's Request.php. -#[derive(Debug, Clone)] -pub struct Request { - /// Root requirements: package name → constraint string. - pub requires: IndexMap>, - /// Fixed packages (must be installed, cannot be modified). - pub fixed_packages: Vec, - /// Locked packages (installed but can be removed if nothing requires them). - pub locked_packages: Vec, -} - -impl Request { - pub fn new() -> Self { - Request { - requires: IndexMap::new(), - fixed_packages: Vec::new(), - locked_packages: Vec::new(), - } - } - - /// Add a root requirement. - pub fn require_name(&mut self, package_name: &str, constraint: Option<&str>) { - self.requires.insert( - package_name.to_lowercase(), - constraint.map(|s| s.to_string()), - ); - } - - /// Mark a package as fixed (must remain installed). - pub fn fix_package(&mut self, package_id: PackageId) { - if !self.fixed_packages.contains(&package_id) { - self.fixed_packages.push(package_id); - } - } - - /// Mark a package as locked. - pub fn lock_package(&mut self, package_id: PackageId) { - if !self.locked_packages.contains(&package_id) { - self.locked_packages.push(package_id); - } - } - - /// Check if a package is fixed. - pub fn is_fixed(&self, package_id: PackageId) -> bool { - self.fixed_packages.contains(&package_id) - } -} - -impl Default for Request { - fn default() -> Self { - Self::new() - } -} diff --git a/crates/mozart-sat-resolver/src/rule.rs b/crates/mozart-sat-resolver/src/rule.rs deleted file mode 100644 index 860ae79..0000000 --- a/crates/mozart-sat-resolver/src/rule.rs +++ /dev/null @@ -1,280 +0,0 @@ -use crate::pool::{Literal, PoolLink}; -use std::fmt; - -/// Why a rule was created. -/// Port of Composer Rule::RULE_* constants. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RuleReason { - /// Root composer.json requirement. - RootRequire, - /// Fixed/locked package. - Fixed, - /// Two packages conflict. - PackageConflict, - /// Package dependency (requires). - PackageRequires, - /// Only one version of a package can be installed. - PackageSameName, - /// Learned from conflict analysis. - Learned, - /// Alias requires its target. - PackageAlias, - /// Target requires its alias. - PackageInverseAlias, -} - -/// Data explaining why a rule was created. -#[derive(Debug, Clone)] -pub enum ReasonData { - /// For RootRequire: package name + constraint string. - RootRequire { - package_name: String, - constraint: String, - }, - /// For Fixed: the fixed package ID. - Fixed { package_id: u32 }, - /// For PackageConflict, PackageRequires: a link. - Link(PoolLink), - /// For PackageSameName: the package name. - PackageName(String), - /// For Learned: index into the learned pool. - Learned(usize), - /// For PackageAlias/InverseAlias: the alias package ID. - AliasPackage(u32), - /// No data. - None, -} - -/// The type assigned by RuleSet (which collection this rule belongs to). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum RuleType { - Package = 0, - Request = 1, - Learned = 4, -} - -/// A SAT rule (clause). A disjunction of literals: (L1 | L2 | ... | Ln). -/// -/// Port of Composer's Rule hierarchy (GenericRule, Rule2Literals, MultiConflictRule). -/// In Rust we use a single enum instead of class inheritance. -#[derive(Debug, Clone)] -pub struct Rule { - /// The literals in this rule (sorted for deduplication). - literals: Vec, - /// Whether this is a multi-conflict rule. - pub is_multi_conflict: bool, - /// Why this rule was created. - pub reason: RuleReason, - /// Additional data about why this rule was created. - pub reason_data: ReasonData, - /// Which RuleSet type this rule belongs to. - pub rule_type: RuleType, - /// Whether this rule is disabled. - pub disabled: bool, -} - -impl Rule { - /// Create a generic rule (arbitrary number of literals). - /// Equivalent to Composer's GenericRule. - pub fn new(mut literals: Vec, reason: RuleReason, reason_data: ReasonData) -> Self { - literals.sort(); - Rule { - literals, - is_multi_conflict: false, - reason, - reason_data, - rule_type: RuleType::Package, // default, set by RuleSet - disabled: false, - } - } - - /// Create a 2-literal rule (optimized common case). - /// Equivalent to Composer's Rule2Literals. - pub fn two_literals( - lit1: Literal, - lit2: Literal, - reason: RuleReason, - reason_data: ReasonData, - ) -> Self { - let (a, b) = if lit1 <= lit2 { - (lit1, lit2) - } else { - (lit2, lit1) - }; - Rule { - literals: vec![a, b], - is_multi_conflict: false, - reason, - reason_data, - rule_type: RuleType::Package, - disabled: false, - } - } - - /// Create a multi-conflict rule (3+ literals, all negative). - /// Equivalent to Composer's MultiConflictRule. - /// Acts as if it were multiple binary conflict rules. - pub fn multi_conflict( - mut literals: Vec, - reason: RuleReason, - reason_data: ReasonData, - ) -> Self { - assert!( - literals.len() >= 3, - "MultiConflictRule requires at least 3 literals" - ); - literals.sort(); - Rule { - literals, - is_multi_conflict: true, - reason, - reason_data, - rule_type: RuleType::Package, - disabled: false, - } - } - - /// Get the sorted literals. - pub fn literals(&self) -> &[Literal] { - &self.literals - } - - /// Whether this rule has exactly one literal (unit clause / assertion). - pub fn is_assertion(&self) -> bool { - self.literals.len() == 1 - } - - /// Compute a hash for deduplication. - pub fn hash_key(&self) -> String { - if self.is_multi_conflict { - let parts: Vec = self.literals.iter().map(|l| l.to_string()).collect(); - format!("c:{}", parts.join(",")) - } else { - let parts: Vec = self.literals.iter().map(|l| l.to_string()).collect(); - parts.join(",") - } - } - - /// Structural equality check (same literals). - pub fn equals(&self, other: &Rule) -> bool { - self.is_multi_conflict == other.is_multi_conflict && self.literals == other.literals - } - - /// Get the required package name, if applicable. - pub fn required_package(&self) -> Option<&str> { - match &self.reason_data { - ReasonData::RootRequire { package_name, .. } => Some(package_name), - ReasonData::Link(link) => Some(&link.target), - ReasonData::Fixed { .. } => None, // would need pool access - _ => None, - } - } - - /// Disable this rule. - pub fn disable(&mut self) { - if self.is_multi_conflict { - panic!("Cannot disable a MultiConflictRule"); - } - self.disabled = true; - } - - /// Enable this rule. - pub fn enable(&mut self) { - self.disabled = false; - } - - /// Whether this rule is disabled. - pub fn is_disabled(&self) -> bool { - self.disabled - } - - /// Whether this rule is enabled. - pub fn is_enabled(&self) -> bool { - !self.disabled - } -} - -impl fmt::Display for Rule { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if self.disabled { - write!(f, "disabled(")?; - } - if self.is_multi_conflict { - write!(f, "(multi(")?; - for (i, lit) in self.literals.iter().enumerate() { - if i > 0 { - write!(f, "|")?; - } - write!(f, "{lit}")?; - } - write!(f, "))")?; - } else { - write!(f, "(")?; - for (i, lit) in self.literals.iter().enumerate() { - if i > 0 { - write!(f, "|")?; - } - write!(f, "{lit}")?; - } - write!(f, ")")?; - } - if self.disabled { - write!(f, ")")?; - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_generic_rule() { - let rule = Rule::new(vec![3, 1, 2], RuleReason::PackageRequires, ReasonData::None); - assert_eq!(rule.literals(), &[1, 2, 3]); - assert!(!rule.is_assertion()); - assert_eq!(rule.to_string(), "(1|2|3)"); - } - - #[test] - fn test_two_literal_rule() { - let rule = Rule::two_literals(-2, -1, RuleReason::PackageConflict, ReasonData::None); - assert_eq!(rule.literals(), &[-2, -1]); - assert!(!rule.is_assertion()); - } - - #[test] - fn test_assertion_rule() { - let rule = Rule::new(vec![1], RuleReason::Fixed, ReasonData::None); - assert!(rule.is_assertion()); - } - - #[test] - fn test_multi_conflict_rule() { - let rule = Rule::multi_conflict( - vec![-3, -1, -2], - RuleReason::PackageSameName, - ReasonData::None, - ); - assert!(rule.is_multi_conflict); - assert_eq!(rule.literals(), &[-3, -2, -1]); - } - - #[test] - fn test_hash_key() { - let r1 = Rule::new(vec![2, 1], RuleReason::PackageRequires, ReasonData::None); - let r2 = Rule::new(vec![1, 2], RuleReason::PackageConflict, ReasonData::None); - assert_eq!(r1.hash_key(), r2.hash_key()); - } - - #[test] - fn test_disable_enable() { - let mut rule = Rule::new(vec![1, 2], RuleReason::PackageRequires, ReasonData::None); - assert!(rule.is_enabled()); - rule.disable(); - assert!(rule.is_disabled()); - rule.enable(); - assert!(rule.is_enabled()); - } -} diff --git a/crates/mozart-sat-resolver/src/rule_set.rs b/crates/mozart-sat-resolver/src/rule_set.rs deleted file mode 100644 index 918bdae..0000000 --- a/crates/mozart-sat-resolver/src/rule_set.rs +++ /dev/null @@ -1,211 +0,0 @@ -use crate::rule::{Rule, RuleType}; -use indexmap::IndexMap; - -/// A unique identifier for a rule within the RuleSet. -pub type RuleId = usize; - -/// Container for all rules, organized by type. -/// -/// Port of Composer's RuleSet.php. -pub struct RuleSet { - /// Lookup: rule ID → index into the appropriate type vector. - /// This is the primary read-only access path used by the solver. - rules_by_id: Vec, - /// Rules grouped by type. - package_rules: Vec, - request_rules: Vec, - learned_rules: Vec, - /// Total rule count. - next_rule_id: usize, - /// Deduplication index. - rules_by_hash: IndexMap>, - /// Maps rule ID → (type, index within type's vec). - rule_type_index: Vec<(RuleType, usize)>, -} - -impl RuleSet { - pub fn new() -> Self { - RuleSet { - rules_by_id: Vec::new(), - package_rules: Vec::new(), - request_rules: Vec::new(), - learned_rules: Vec::new(), - next_rule_id: 0, - rules_by_hash: IndexMap::new(), - rule_type_index: Vec::new(), - } - } - - /// Add a rule to the set. Duplicates (by hash + equals) are skipped. - pub fn add(&mut self, mut rule: Rule, rule_type: RuleType) { - let hash = rule.hash_key(); - - // Check for duplicates - if let Some(existing_ids) = self.rules_by_hash.get(&hash) { - for &existing_id in existing_ids { - if rule.equals(self.rule_by_id(existing_id)) { - return; - } - } - } - - rule.rule_type = rule_type; - - let rules_vec = match rule_type { - RuleType::Package => &mut self.package_rules, - RuleType::Request => &mut self.request_rules, - RuleType::Learned => &mut self.learned_rules, - }; - let idx = rules_vec.len(); - rules_vec.push(rule); - - let rule_id = self.next_rule_id; - self.rules_by_id.push(idx); - self.rule_type_index.push((rule_type, idx)); - self.next_rule_id += 1; - - self.rules_by_hash.entry(hash).or_default().push(rule_id); - } - - /// Total number of rules. - pub fn len(&self) -> usize { - self.next_rule_id - } - - /// Whether the rule set is empty. - pub fn is_empty(&self) -> bool { - self.next_rule_id == 0 - } - - /// Look up a rule by its global ID. - pub fn rule_by_id(&self, id: RuleId) -> &Rule { - let (rule_type, idx) = self.rule_type_index[id]; - match rule_type { - RuleType::Package => &self.package_rules[idx], - RuleType::Request => &self.request_rules[idx], - RuleType::Learned => &self.learned_rules[idx], - } - } - - /// Get a mutable reference to a rule by its global ID. - pub fn rule_by_id_mut(&mut self, id: RuleId) -> &mut Rule { - let (rule_type, idx) = self.rule_type_index[id]; - match rule_type { - RuleType::Package => &mut self.package_rules[idx], - RuleType::Request => &mut self.request_rules[idx], - RuleType::Learned => &mut self.learned_rules[idx], - } - } - - /// Iterate over all rules in order (Package, then Request, then Learned). - pub fn iter(&self) -> impl Iterator { - (0..self.next_rule_id).map(move |id| (id, self.rule_by_id(id))) - } - - /// Iterate over rules of a specific type, returning (global_rule_id, &Rule). - pub fn iter_type(&self, rule_type: RuleType) -> RuleTypeIterator<'_> { - RuleTypeIterator { - rule_set: self, - rule_type, - current: 0, - total: self.next_rule_id, - } - } - - /// Get the request rules slice. - pub fn request_rules(&self) -> &[Rule] { - &self.request_rules - } -} - -impl Default for RuleSet { - fn default() -> Self { - Self::new() - } -} - -/// Iterator over rules of a specific type. -pub struct RuleTypeIterator<'a> { - rule_set: &'a RuleSet, - rule_type: RuleType, - current: RuleId, - total: usize, -} - -impl<'a> Iterator for RuleTypeIterator<'a> { - type Item = (RuleId, &'a Rule); - - fn next(&mut self) -> Option { - while self.current < self.total { - let id = self.current; - self.current += 1; - let rule = self.rule_set.rule_by_id(id); - if rule.rule_type == self.rule_type { - return Some((id, rule)); - } - } - None - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::rule::{ReasonData, RuleReason}; - - #[test] - fn test_add_and_lookup() { - let mut rs = RuleSet::new(); - rs.add( - Rule::new(vec![1, 2], RuleReason::PackageRequires, ReasonData::None), - RuleType::Package, - ); - rs.add( - Rule::new(vec![3], RuleReason::RootRequire, ReasonData::None), - RuleType::Request, - ); - - assert_eq!(rs.len(), 2); - assert_eq!(rs.rule_by_id(0).literals(), &[1, 2]); - assert_eq!(rs.rule_by_id(1).literals(), &[3]); - } - - #[test] - fn test_deduplication() { - let mut rs = RuleSet::new(); - rs.add( - Rule::new(vec![1, 2], RuleReason::PackageRequires, ReasonData::None), - RuleType::Package, - ); - rs.add( - Rule::new(vec![2, 1], RuleReason::PackageConflict, ReasonData::None), - RuleType::Package, - ); - // Duplicate should be skipped - assert_eq!(rs.len(), 1); - } - - #[test] - fn test_iter_type() { - let mut rs = RuleSet::new(); - rs.add( - Rule::new(vec![1, 2], RuleReason::PackageRequires, ReasonData::None), - RuleType::Package, - ); - rs.add( - Rule::new(vec![3], RuleReason::RootRequire, ReasonData::None), - RuleType::Request, - ); - rs.add( - Rule::new(vec![4, 5], RuleReason::PackageConflict, ReasonData::None), - RuleType::Package, - ); - - let request_rules: Vec<_> = rs.iter_type(RuleType::Request).collect(); - assert_eq!(request_rules.len(), 1); - assert_eq!(request_rules[0].1.literals(), &[3]); - - let package_rules: Vec<_> = rs.iter_type(RuleType::Package).collect(); - assert_eq!(package_rules.len(), 2); - } -} diff --git a/crates/mozart-sat-resolver/src/rule_set_generator.rs b/crates/mozart-sat-resolver/src/rule_set_generator.rs deleted file mode 100644 index 2ab9f86..0000000 --- a/crates/mozart-sat-resolver/src/rule_set_generator.rs +++ /dev/null @@ -1,464 +0,0 @@ -use crate::pool::{Literal, PackageId, Pool, PoolLink}; -use crate::rule::{ReasonData, Rule, RuleReason, RuleType}; -use crate::rule_set::RuleSet; -use indexmap::IndexMap; -use indexmap::IndexSet; -use mozart_semver::VersionConstraint; -use std::collections::VecDeque; - -/// Generates SAT rules from the pool and request. -/// -/// Port of Composer's RuleSetGenerator.php. -pub struct RuleSetGenerator<'a> { - pool: &'a mut Pool, - rules: RuleSet, - /// Packages already processed. - added_map: IndexSet, - /// Package names → list of package IDs with that name (non-alias). - added_packages_by_name: IndexMap>, - /// Specific platform packages to ignore (from `--ignore-platform-req=name`). - ignore_platform_reqs: IndexSet, - /// When true, every platform package is treated as ignored. - /// Mirrors `--ignore-platform-reqs` (no value). - ignore_all_platform_reqs: bool, -} - -impl<'a> RuleSetGenerator<'a> { - pub fn new(pool: &'a mut Pool) -> Self { - RuleSetGenerator { - pool, - rules: RuleSet::new(), - added_map: IndexSet::new(), - added_packages_by_name: IndexMap::new(), - ignore_platform_reqs: IndexSet::new(), - ignore_all_platform_reqs: false, - } - } - - /// Set platform requirements to ignore. - pub fn set_ignore_platform_reqs(&mut self, names: IndexSet) { - self.ignore_platform_reqs = names; - } - - /// When set, every platform package is treated as ignored. - pub fn set_ignore_all_platform_reqs(&mut self, ignore_all: bool) { - self.ignore_all_platform_reqs = ignore_all; - } - - fn is_ignored_platform_dep(&self, name: &str) -> bool { - if self - .ignore_platform_reqs - .iter() - .any(|p| mozart_core::matches_wildcard(name, p)) - { - return true; - } - self.ignore_all_platform_reqs && mozart_core::platform::is_platform_package(name) - } - - /// Generate rules for a set of requirements and fixed packages. - /// - /// Port of Composer's RuleSetGenerator::getRulesFor. - /// - /// `root_provides` / `root_replaces` map a target package name to the - /// constraint declared in the root composer.json's `provide` / `replace` - /// section. They mirror the "self-fulfilling rule" check in Composer's - /// `RuleSetGenerator::createRequireRule`: when the root package itself - /// provides or replaces a name it requires, no install-one-of rule is - /// emitted for that root require — root is implicitly already installed, - /// so the requirement is trivially satisfied without forcing a real - /// provider. Without this, Mozart picks up an inline `provided/pkg` from - /// the repository even though the root claims to fulfill it itself. - /// - /// Returns the generated rule set together with the list of root requires - /// that have no matching providers in the pool. Mirrors Composer's - /// `Solver::checkForRootRequireProblems`: a root require with zero - /// providers does not produce a SAT rule (so the solver would otherwise - /// succeed with an empty plan), but it must still be reported as an - /// unresolvable problem. - pub fn generate( - mut self, - requires: &IndexMap>, - fixed_packages: &[PackageId], - root_provides: &IndexMap, - root_replaces: &IndexMap, - ) -> (RuleSet, Vec<(String, Option)>) { - let mut missing_root_requires: Vec<(String, Option)> = Vec::new(); - // Process fixed packages - for &pkg_id in fixed_packages { - if self.pool.is_unacceptable_fixed_package(pkg_id) { - continue; - } - - self.add_rules_for_package(pkg_id); - - // Create assertion rule: this package must be installed - let rule = Rule::new( - vec![pkg_id as Literal], - RuleReason::Fixed, - ReasonData::Fixed { package_id: pkg_id }, - ); - self.rules.add(rule, RuleType::Request); - } - - // Process root requirements - for (name, constraint) in requires { - if self.is_ignored_platform_dep(name.as_str()) { - continue; - } - - // Self-fulfilling root require: if the root composer.json declares - // `provide` / `replace` for this name and the link constraint - // intersects the require constraint, drop the install-one-of rule - // entirely. Mirrors Composer's `createRequireRule` returning null - // when a provider IS the package itself: there, the root is in the - // pool as a fixed package and `whatProvides` includes it, so the - // resulting rule is trivially satisfied. Mozart does not yet add - // the root to the pool, so we make the same decision here based - // on the explicit root provide/replace tables. - if root_self_fulfills(name, constraint.as_deref(), root_provides) - || root_self_fulfills(name, constraint.as_deref(), root_replaces) - { - continue; - } - - let providers = self.pool.what_provides(name, constraint.as_deref()); - - if !providers.is_empty() { - for &pkg_id in &providers { - self.add_rules_for_package(pkg_id); - } - - // Create "install one of" rule - let literals: Vec = providers.iter().map(|&id| id as Literal).collect(); - let rule = Rule::new( - literals, - RuleReason::RootRequire, - ReasonData::RootRequire { - package_name: name.clone(), - constraint: constraint.clone().unwrap_or_default(), - }, - ); - self.rules.add(rule, RuleType::Request); - } else { - missing_root_requires.push((name.clone(), constraint.clone())); - } - } - - // Mirror Composer's `RuleSetGenerator::addRulesForRootAliases`: - // ensure every alias whose target was already added gets its own - // alias↔target rules, even when the alias itself didn't appear in - // any root require's `whatProvides` (e.g. the synthetic - // `9999999-dev` alias from a `default-branch: true` package, which - // only matches a literal `9999999-dev` constraint). - let alias_pairs: Vec<(PackageId, PackageId)> = self - .pool - .packages() - .iter() - .filter_map(|p| p.is_alias_of.map(|t| (p.id, t))) - .collect(); - for (alias_id, target_id) in alias_pairs { - if self.added_map.contains(&target_id) && !self.added_map.contains(&alias_id) { - self.add_rules_for_package(alias_id); - } - } - - // Add conflict rules - self.add_conflict_rules(); - - (self.rules, missing_root_requires) - } - - /// Add rules for a package and its transitive dependencies. - /// - /// Port of Composer's RuleSetGenerator::addRulesForPackage. - fn add_rules_for_package(&mut self, pkg_id: PackageId) { - let mut work_queue: VecDeque = VecDeque::new(); - work_queue.push_back(pkg_id); - - while let Some(current_id) = work_queue.pop_front() { - if self.added_map.contains(¤t_id) { - continue; - } - self.added_map.insert(current_id); - - let pkg = self.pool.package_by_id(current_id); - let conflict_names: Vec = - pkg.conflict_names().into_iter().map(String::from).collect(); - let requires = pkg.requires.clone(); - let alias_target = pkg.is_alias_of; - - if let Some(target_id) = alias_target { - // Mirror Composer's RuleSetGenerator::addRulesForPackage alias - // branch: enqueue the target, emit `(-alias | target)` so the - // alias forces the target, and `(-target | alias)` so the - // target forces the alias (they install together). The alias - // is NOT indexed under its name for same-name conflicts — - // Composer skips that for aliases too. - work_queue.push_back(target_id); - - let alias_rule = Rule::two_literals( - -(current_id as Literal), - target_id as Literal, - RuleReason::PackageAlias, - ReasonData::AliasPackage(current_id), - ); - self.rules.add(alias_rule, RuleType::Package); - - let inverse_rule = Rule::two_literals( - -(target_id as Literal), - current_id as Literal, - RuleReason::PackageInverseAlias, - ReasonData::AliasPackage(current_id), - ); - self.rules.add(inverse_rule, RuleType::Package); - - // The aliased target carries the actual requires; skip - // alias's own (link-rewritten copy) to avoid duplicates. - continue; - } - - // Index by every name this package fully claims (own name + - // `replace` targets). Same-name conflict rules (below) then - // prevent two packages from coexisting under the same logical - // identity. Mirrors `BasePackage::getNames(false)` indexing in - // Composer's RuleSetGenerator::addRulesForPackage — `provide` - // targets are intentionally omitted so that providers can - // coexist with the package they provide. Alias packages are - // skipped because the target package's name already covers them. - for name in conflict_names { - self.added_packages_by_name - .entry(name) - .or_default() - .push(current_id); - } - - // Process each requirement - for link in requires { - if self.is_ignored_platform_dep(&link.target) { - continue; - } - - let possible_requires = self - .pool - .what_provides(&link.target, Some(&link.constraint)); - - // Create require rule: (-current | provider1 | provider2 | ...) - let mut literals: Vec = vec![-(current_id as Literal)]; - let mut self_fulfilling = false; - - for &provider_id in &possible_requires { - if provider_id == current_id { - self_fulfilling = true; - break; - } - literals.push(provider_id as Literal); - } - - if !self_fulfilling { - let rule = Rule::new( - literals, - RuleReason::PackageRequires, - ReasonData::Link(PoolLink { - target: link.target.clone(), - constraint: link.constraint.clone(), - source: self.pool.package_by_id(current_id).name.clone(), - }), - ); - self.rules.add(rule, RuleType::Package); - } - - // Enqueue providers for further processing - for &provider_id in &possible_requires { - work_queue.push_back(provider_id); - } - } - } - } - - /// Add conflict rules: explicit conflicts and same-name rules. - /// - /// Port of Composer's RuleSetGenerator::addConflictRules. - fn add_conflict_rules(&mut self) { - // Explicit conflicts - let added_ids: Vec = self.added_map.iter().copied().collect(); - for &pkg_id in &added_ids { - let pkg = self.pool.package_by_id(pkg_id); - let conflicts = pkg.conflicts.clone(); - - for link in conflicts { - if self.is_ignored_platform_dep(&link.target) { - continue; - } - - if !self.added_packages_by_name.contains_key(&link.target) { - continue; - } - - let conflicting = self - .pool - .what_provides(&link.target, Some(&link.constraint)); - - for &conflict_id in &conflicting { - if conflict_id == pkg_id { - continue; // ignore self-conflict - } - let rule = Rule::two_literals( - -(pkg_id as Literal), - -(conflict_id as Literal), - RuleReason::PackageConflict, - ReasonData::Link(link.clone()), - ); - self.rules.add(rule, RuleType::Package); - } - } - } - - // Same-name rules: only one version of a package can be installed - let names_to_process: Vec<(String, Vec)> = self - .added_packages_by_name - .iter() - .filter(|(_, ids)| ids.len() > 1) - .map(|(name, ids)| (name.clone(), ids.clone())) - .collect(); - - for (name, pkg_ids) in names_to_process { - let literals: Vec = pkg_ids.iter().map(|&id| -(id as Literal)).collect(); - - if literals.len() == 2 { - let rule = Rule::two_literals( - literals[0], - literals[1], - RuleReason::PackageSameName, - ReasonData::PackageName(name), - ); - self.rules.add(rule, RuleType::Package); - } else if literals.len() >= 3 { - let rule = Rule::multi_conflict( - literals, - RuleReason::PackageSameName, - ReasonData::PackageName(name), - ); - self.rules.add(rule, RuleType::Package); - } - } - } -} - -/// True when the root composer.json's `provide` / `replace` map declares -/// `target` with a constraint that intersects the require's constraint. A -/// missing require constraint is treated as `*` (matches anything), and a -/// missing/unparsable link constraint conservatively does NOT match — the -/// fixture fails closed back to the regular install-one-of path. -fn root_self_fulfills( - target: &str, - require_constraint: Option<&str>, - root_links: &IndexMap, -) -> bool { - let Some(link_constraint_str) = root_links.get(target) else { - return false; - }; - let Ok(link_vc) = VersionConstraint::parse(link_constraint_str) else { - return false; - }; - match require_constraint { - None => true, - Some(req) => match VersionConstraint::parse(req) { - Ok(req_vc) => req_vc.intersects(&link_vc), - Err(_) => false, - }, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::pool::{Pool, PoolLink, PoolPackageInput}; - - fn make_input(name: &str, version: &str) -> PoolPackageInput { - PoolPackageInput { - name: name.to_string(), - version: version.to_string(), - pretty_version: version.to_string(), - requires: vec![], - replaces: vec![], - provides: vec![], - conflicts: vec![], - is_fixed: false, - is_alias_of: None, - } - } - - #[test] - fn test_root_require_generates_rule() { - let mut pool = Pool::new( - vec![make_input("a/a", "1.0.0.0"), make_input("a/a", "2.0.0.0")], - vec![], - ); - - let mut requires = IndexMap::new(); - requires.insert("a/a".to_string(), None); - - let generator = RuleSetGenerator::new(&mut pool); - let (rules, _) = generator.generate(&requires, &[], &IndexMap::new(), &IndexMap::new()); - - // Should have a request rule: (1 | 2) - let request_count = rules.iter_type(RuleType::Request).count(); - assert_eq!(request_count, 1); - - // Should have a same-name rule: (-1 | -2) - let package_count = rules.iter_type(RuleType::Package).count(); - assert!(package_count >= 1); - } - - #[test] - fn test_dependency_chain_rules() { - // a/a 1.0 requires b/b - let mut pool = Pool::new( - vec![ - PoolPackageInput { - name: "a/a".to_string(), - version: "1.0.0.0".to_string(), - pretty_version: "1.0.0".to_string(), - requires: vec![PoolLink { - target: "b/b".to_string(), - constraint: "*".to_string(), - source: "a/a".to_string(), - }], - replaces: vec![], - provides: vec![], - conflicts: vec![], - is_fixed: false, - is_alias_of: None, - }, - make_input("b/b", "1.0.0.0"), - ], - vec![], - ); - - let mut requires = IndexMap::new(); - requires.insert("a/a".to_string(), None); - - let generator = RuleSetGenerator::new(&mut pool); - let (rules, _) = generator.generate(&requires, &[], &IndexMap::new(), &IndexMap::new()); - - // Should have: - // 1. Request rule: (1) — root requires a/a - // 2. Package rule: (-1 | 2) — a/a requires b/b - assert!(rules.len() >= 2); - } - - #[test] - fn test_fixed_package_rule() { - let mut pool = Pool::new(vec![make_input("php", "8.2.0.0")], vec![]); - - let generator = RuleSetGenerator::new(&mut pool); - let (rules, _) = - generator.generate(&IndexMap::new(), &[1], &IndexMap::new(), &IndexMap::new()); - - // Should have an assertion rule: (1) - let request_rules: Vec<_> = rules.iter_type(RuleType::Request).collect(); - assert_eq!(request_rules.len(), 1); - assert!(request_rules[0].1.is_assertion()); - } -} diff --git a/crates/mozart-sat-resolver/src/rule_watch_graph.rs b/crates/mozart-sat-resolver/src/rule_watch_graph.rs deleted file mode 100644 index 202dcca..0000000 --- a/crates/mozart-sat-resolver/src/rule_watch_graph.rs +++ /dev/null @@ -1,288 +0,0 @@ -use crate::decisions::Decisions; -use crate::pool::Literal; -use crate::rule::Rule; -use crate::rule_set::RuleId; -use indexmap::IndexMap; - -/// A watch node: tracks which 2 literals a rule watches. -/// -/// Port of Composer's RuleWatchNode.php. -#[derive(Debug, Clone)] -struct WatchNode { - /// First watched literal. - watch1: Literal, - /// Second watched literal. - watch2: Literal, - /// The rule ID this node refers to. - rule_id: RuleId, - /// Whether the rule is a multi-conflict rule. - is_multi_conflict: bool, -} - -/// Efficient unit propagation using 2-watched literals optimization. -/// -/// Port of Composer's RuleWatchGraph.php. -pub struct RuleWatchGraph { - /// Literal → list of watch node indices watching that literal. - watch_chains: IndexMap>, - /// All watch nodes. - nodes: Vec, -} - -impl RuleWatchGraph { - pub fn new() -> Self { - RuleWatchGraph { - watch_chains: IndexMap::new(), - nodes: Vec::new(), - } - } - - /// Insert a rule into the watch graph. - /// Assertions (single literal) are skipped. - pub fn insert(&mut self, rule_id: RuleId, rule: &Rule) { - if rule.is_assertion() { - return; - } - - let literals = rule.literals(); - let node_idx = self.nodes.len(); - - let watch1 = literals[0]; - let watch2 = if literals.len() > 1 { literals[1] } else { 0 }; - - self.nodes.push(WatchNode { - watch1, - watch2, - rule_id, - is_multi_conflict: rule.is_multi_conflict, - }); - - if rule.is_multi_conflict { - // Multi-conflict rules watch ALL their literals - for &lit in literals { - self.watch_chains.entry(lit).or_default().push(node_idx); - } - } else { - // Normal rules watch first 2 literals - self.watch_chains.entry(watch1).or_default().push(node_idx); - self.watch_chains.entry(watch2).or_default().push(node_idx); - } - } - - /// Adjust watch2 to the literal decided at the highest level. - /// Used for learned rules. - pub fn watch2_on_highest(&mut self, node_idx: usize, rule: &Rule, decisions: &Decisions) { - let literals = rule.literals(); - if literals.len() < 3 || rule.is_multi_conflict { - return; - } - - let mut watch_level = 0i32; - let mut best_literal = self.nodes[node_idx].watch2; - - for &lit in literals { - let level = decisions.decision_level(lit); - if level > watch_level { - best_literal = lit; - watch_level = level; - } - } - - let old_watch2 = self.nodes[node_idx].watch2; - if old_watch2 != best_literal { - // Remove from old chain, add to new chain - self.remove_from_chain(old_watch2, node_idx); - self.nodes[node_idx].watch2 = best_literal; - self.watch_chains - .entry(best_literal) - .or_default() - .push(node_idx); - } - } - - /// Propagate a decision literal through the watch graph. - /// Returns the rule ID of a conflicting rule, if found. - /// - /// Port of Composer's RuleWatchGraph::propagateLiteral. - pub fn propagate_literal( - &mut self, - decided_literal: Literal, - level: i32, - decisions: &mut Decisions, - rules: &crate::rule_set::RuleSet, - ) -> Result, crate::error::SolverBugError> { - // We look for rules watching the negation of the decided literal - let literal = -decided_literal; - - if !self.watch_chains.contains_key(&literal) { - return Ok(None); - } - - // Iterate the live chain. When a node is moved away (move_watch removes - // it from this chain), we stay at the same index since the Vec shrinks. - // When a node stays, we advance past it. - let mut i = 0; - loop { - let chain = match self.watch_chains.get(&literal) { - Some(c) if i < c.len() => c, - _ => break, - }; - - let node_idx = chain[i]; - let node = &self.nodes[node_idx]; - let rule_id = node.rule_id; - let is_multi_conflict = node.is_multi_conflict; - let rule = rules.rule_by_id(rule_id); - - if !is_multi_conflict { - let other_watch = if node.watch1 == literal { - node.watch2 - } else { - node.watch1 - }; - - if !rule.is_disabled() && !decisions.satisfy(other_watch) { - let rule_literals = rule.literals(); - - // Find an alternative literal to watch - let alternative = rule_literals - .iter() - .find(|&&rl| rl != literal && rl != other_watch && !decisions.conflict(rl)); - - if let Some(&alt_literal) = alternative { - // Move watch from `literal` to `alt_literal`. - // This removes node_idx from this chain, so don't increment i. - self.move_watch(literal, alt_literal, node_idx); - continue; - } - - if decisions.conflict(other_watch) { - return Ok(Some(rule_id)); - } - - decisions.decide(other_watch, level, rule_id)?; - } - } else { - // Multi-conflict rule: all literals are watched - let rule_literals = rule.literals().to_vec(); - for &other_literal in &rule_literals { - if other_literal != literal && !decisions.satisfy(other_literal) { - if decisions.conflict(other_literal) { - return Ok(Some(rule_id)); - } - decisions.decide(other_literal, level, rule_id)?; - } - } - } - - i += 1; - } - - Ok(None) - } - - /// Move a watch node from one literal's chain to another's. - fn move_watch(&mut self, from_literal: Literal, to_literal: Literal, node_idx: usize) { - // Update the node's watch - let node = &mut self.nodes[node_idx]; - if node.watch1 == from_literal { - node.watch1 = to_literal; - } else { - node.watch2 = to_literal; - } - - // Remove from old chain - self.remove_from_chain(from_literal, node_idx); - - // Add to new chain - self.watch_chains - .entry(to_literal) - .or_default() - .push(node_idx); - } - - /// Remove a node from a literal's watch chain. - fn remove_from_chain(&mut self, literal: Literal, node_idx: usize) { - if let Some(chain) = self.watch_chains.get_mut(&literal) { - chain.retain(|&idx| idx != node_idx); - } - } - - /// Get the last inserted node index (for watch2_on_highest after insert). - pub fn last_node_idx(&self) -> usize { - self.nodes.len() - 1 - } -} - -impl Default for RuleWatchGraph { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::rule::{ReasonData, Rule, RuleReason}; - use crate::rule_set::RuleSet; - - #[test] - fn test_insert_assertion_skipped() { - let mut graph = RuleWatchGraph::new(); - let rule = Rule::new(vec![1], RuleReason::Fixed, ReasonData::None); - graph.insert(0, &rule); - assert_eq!(graph.nodes.len(), 0); - } - - #[test] - fn test_insert_normal_rule() { - let mut graph = RuleWatchGraph::new(); - let rule = Rule::new(vec![1, 2, 3], RuleReason::PackageRequires, ReasonData::None); - graph.insert(0, &rule); - assert_eq!(graph.nodes.len(), 1); - // Watches literals 1 and 2 - assert!(graph.watch_chains.contains_key(&1)); - assert!(graph.watch_chains.contains_key(&2)); - } - - #[test] - fn test_propagate_unit_clause() { - // Rule: (1 | 2). Decide -1, should force 2. - let mut rs = RuleSet::new(); - rs.add( - Rule::new(vec![1, 2], RuleReason::PackageRequires, ReasonData::None), - crate::rule::RuleType::Package, - ); - - let mut graph = RuleWatchGraph::new(); - graph.insert(0, rs.rule_by_id(0)); - - let mut decisions = Decisions::new(); - decisions.decide(-1, 1, 99).unwrap(); // don't install package 1 - - let conflict = graph.propagate_literal(-1, 1, &mut decisions, &rs).unwrap(); - assert!(conflict.is_none()); - // Package 2 should now be decided install - assert!(decisions.decided_install(2)); - } - - #[test] - fn test_propagate_conflict() { - // Rule: (1 | 2). Decide -1, then -2 should conflict. - let mut rs = RuleSet::new(); - rs.add( - Rule::new(vec![1, 2], RuleReason::PackageRequires, ReasonData::None), - crate::rule::RuleType::Package, - ); - - let mut graph = RuleWatchGraph::new(); - graph.insert(0, rs.rule_by_id(0)); - - let mut decisions = Decisions::new(); - decisions.decide(-1, 1, 99).unwrap(); - decisions.decide(-2, 1, 99).unwrap(); - - let conflict = graph.propagate_literal(-1, 1, &mut decisions, &rs).unwrap(); - assert!(conflict.is_some()); - } -} diff --git a/crates/mozart-sat-resolver/src/solver.rs b/crates/mozart-sat-resolver/src/solver.rs deleted file mode 100644 index 8739381..0000000 --- a/crates/mozart-sat-resolver/src/solver.rs +++ /dev/null @@ -1,1008 +0,0 @@ -use crate::decisions::Decisions; -use crate::error::{SolverBugError, SolverError}; -use crate::policy::DefaultPolicy; -use crate::pool::{Literal, PackageId, Pool, literal_to_package_id}; -use crate::problem::Problem; -use crate::rule::{ReasonData, Rule, RuleReason, RuleType}; -use crate::rule_set::{RuleId, RuleSet}; -use crate::rule_watch_graph::RuleWatchGraph; -use indexmap::{IndexMap, IndexSet}; - -/// Result of solving: the list of package IDs to install. -#[derive(Debug)] -pub struct SolverResult { - /// Package IDs decided for installation. - pub installed: Vec, -} - -/// Main SAT solver implementing CDCL (Conflict-Driven Clause Learning). -/// -/// Port of Composer's Solver.php. -pub struct Solver<'a> { - pool: &'a Pool, - policy: DefaultPolicy, - rules: RuleSet, - watch_graph: RuleWatchGraph, - decisions: Decisions, - /// Fixed packages by ID. - fixed_map: IndexSet, - /// Current propagation index in decision queue. - propagate_index: usize, - /// Branch points: (alternative literals, decision level). - branches: Vec<(Vec, i32)>, - /// Problems found during solving. - problems: Vec, - /// Learned rule pool: for each learned rule, the chain of rules that led to it. - learned_pool: Vec>, - /// Map from rule ID → learned pool index. - learned_why: IndexMap, -} - -impl<'a> Solver<'a> { - /// Create a new solver with the given rules, pool, policy, and fixed package set. - pub fn new( - rules: RuleSet, - pool: &'a Pool, - policy: DefaultPolicy, - fixed_packages: IndexSet, - ) -> Self { - Solver { - pool, - policy, - rules, - watch_graph: RuleWatchGraph::new(), - decisions: Decisions::new(), - fixed_map: fixed_packages, - propagate_index: 0, - branches: Vec::new(), - problems: Vec::new(), - learned_pool: Vec::new(), - learned_why: IndexMap::new(), - } - } - - /// Solve the dependency resolution problem. - /// Returns the set of packages to install, or an error. - pub fn solve(mut self) -> Result { - // Insert all rules into watch graph - let rule_count = self.rules.len(); - for id in 0..rule_count { - let rule = self.rules.rule_by_id(id); - self.watch_graph.insert(id, rule); - } - - // Make decisions based on assertion rules (unit clauses) - self.make_assertion_rule_decisions()?; - - // Run the main SAT loop - self.run_sat()?; - - if !self.problems.is_empty() { - let messages: Vec = self - .problems - .iter() - .map(|p| p.pretty_string(self.pool, &self.rules)) - .collect(); - return Err(SolverError::Unsolvable(messages)); - } - - // Collect installed packages - let mut installed = Vec::new(); - for i in 0..self.decisions.len() { - let decision = self.decisions.at_offset(i); - if decision.literal > 0 { - installed.push(literal_to_package_id(decision.literal)); - } - } - - Ok(SolverResult { installed }) - } - - /// Process assertion rules (unit clauses) — make immediate decisions. - /// - /// Port of Composer's Solver::makeAssertionRuleDecisions. - fn make_assertion_rule_decisions(&mut self) -> Result<(), SolverError> { - let decision_start = if self.decisions.is_empty() { - 0 - } else { - self.decisions.len() - 1 - }; - - let mut rule_index: usize = 0; - while rule_index < self.rules.len() { - let rule = self.rules.rule_by_id(rule_index); - - if !rule.is_assertion() || rule.is_disabled() { - rule_index += 1; - continue; - } - - let literal = rule.literals()[0]; - - if !self.decisions.decided(literal) { - self.decisions.decide(literal, 1, rule_index)?; - rule_index += 1; - continue; - } - - if self.decisions.satisfy(literal) { - rule_index += 1; - continue; - } - - // Found a conflict - let rule_type = self.rules.rule_by_id(rule_index).rule_type; - - if rule_type == RuleType::Learned { - self.rules.rule_by_id_mut(rule_index).disable(); - rule_index += 1; - continue; - } - - let conflict_rule_id = self.decisions.decision_rule(literal)?; - let conflict_type = self.rules.rule_by_id(conflict_rule_id).rule_type; - - if conflict_type == RuleType::Package { - let mut problem = Problem::new(); - problem.add_rule(rule_index); - problem.add_rule(conflict_rule_id); - self.rules.rule_by_id_mut(rule_index).disable(); - self.problems.push(problem); - rule_index += 1; - continue; - } - - // Conflict with another root require/fixed package - let mut problem = Problem::new(); - problem.add_rule(rule_index); - problem.add_rule(conflict_rule_id); - - // Push all request assertion rules asserting this literal - let pkg_id = literal_to_package_id(literal); - let request_rule_ids: Vec = self - .rules - .iter_type(RuleType::Request) - .filter(|(_, r)| { - !r.is_disabled() - && r.is_assertion() - && literal_to_package_id(r.literals()[0]) == pkg_id - }) - .map(|(id, _)| id) - .collect(); - - for rid in &request_rule_ids { - problem.add_rule(*rid); - } - self.problems.push(problem); - - for rid in request_rule_ids { - self.rules.rule_by_id_mut(rid).disable(); - } - - self.decisions.reset_to_offset(decision_start); - rule_index = 0; // restart - } - - Ok(()) - } - - /// Unit propagation: propagate decisions through the watch graph. - /// - /// Port of Composer's Solver::propagate. - fn propagate(&mut self, level: i32) -> Result, SolverBugError> { - while self.decisions.valid_offset(self.propagate_index) { - let decision = self.decisions.at_offset(self.propagate_index).clone(); - self.propagate_index += 1; - - let conflict = self.watch_graph.propagate_literal( - decision.literal, - level, - &mut self.decisions, - &self.rules, - )?; - - if conflict.is_some() { - return Ok(conflict); - } - } - - Ok(None) - } - - /// Revert decisions to a given level. - /// - /// Port of Composer's Solver::revert. - fn revert(&mut self, level: i32) { - while !self.decisions.is_empty() { - let literal = self.decisions.last_literal(); - if self.decisions.undecided(literal) { - break; - } - let decision_level = self.decisions.decision_level(literal); - if decision_level <= level { - break; - } - self.decisions.revert_last(); - self.propagate_index = self.decisions.len(); - } - - while !self.branches.is_empty() && self.branches.last().unwrap().1 >= level { - self.branches.pop(); - } - } - - /// Make a decision, propagate, and learn from conflicts. - /// - /// Port of Composer's Solver::setPropagateLearn. - fn set_propagate_learn( - &mut self, - mut level: i32, - literal: Literal, - rule_id: RuleId, - ) -> Result { - level += 1; - self.decisions.decide(literal, level, rule_id)?; - - loop { - let conflict = self.propagate(level)?; - - let Some(conflict_rule_id) = conflict else { - break; - }; - - if level == 1 { - self.analyze_unsolvable(conflict_rule_id); - return Ok(0); - } - - // Conflict analysis - let (learn_literal, new_level, new_rule, why) = - self.analyze(level, conflict_rule_id)?; - - if new_level <= 0 || new_level >= level { - return Err(SolverBugError { - message: format!( - "Trying to revert to invalid level {new_level} from level {level}." - ), - } - .into()); - } - - level = new_level; - self.revert(level); - - // Add learned rule - self.rules.add(new_rule, RuleType::Learned); - let new_rule_id = self.rules.len() - 1; - - self.learned_why.insert(new_rule_id, why); - - let rule_ref = self.rules.rule_by_id(new_rule_id); - self.watch_graph.insert(new_rule_id, rule_ref); - - // Adjust watch2 to highest level literal - let last_node = self.watch_graph.last_node_idx(); - let rule_for_watch = self.rules.rule_by_id(new_rule_id); - self.watch_graph - .watch2_on_highest(last_node, rule_for_watch, &self.decisions); - - self.decisions.decide(learn_literal, level, new_rule_id)?; - } - - Ok(level) - } - - /// Choose best package from candidates and install. - /// - /// Port of Composer's Solver::selectAndInstall. - fn select_and_install( - &mut self, - level: i32, - decision_queue: Vec, - rule_id: RuleId, - ) -> Result { - let required_package = self - .rules - .rule_by_id(rule_id) - .required_package() - .map(|s| s.to_string()); - let mut literals = self.policy.select_preferred_packages( - self.pool, - &decision_queue, - required_package.as_deref(), - ); - - let selected = literals.remove(0); - - // If there are remaining alternatives, save as branch point - if !literals.is_empty() { - self.branches.push((literals, level)); - } - - self.set_propagate_learn(level, selected, rule_id) - } - - /// First UIP conflict analysis. - /// - /// Port of Composer's Solver::analyze. - fn analyze( - &mut self, - level: i32, - conflict_rule_id: RuleId, - ) -> Result<(Literal, i32, Rule, usize), SolverError> { - let mut rule_level: i32 = 1; - let mut num: i32 = 0; - let mut l1num: i32 = 0; - let mut seen: IndexSet = IndexSet::new(); - let mut learned_literal: Option = None; - let mut other_learned_literals: Vec = Vec::new(); - - let mut decision_id = self.decisions.len(); - - self.learned_pool.push(Vec::new()); - let pool_idx = self.learned_pool.len() - 1; - - let mut current_rule_id = conflict_rule_id; - - loop { - self.learned_pool[pool_idx].push(current_rule_id); - - let rule = self.rules.rule_by_id(current_rule_id); - let rule_literals = rule.literals().to_vec(); - let is_multi_conflict = rule.is_multi_conflict; - - for &literal in &rule_literals { - // MultiConflictRule: skip undecided literals - if is_multi_conflict && !self.decisions.decided(literal) { - continue; - } - - // Skip the one true literal - if self.decisions.satisfy(literal) { - continue; - } - - let pkg_id = literal_to_package_id(literal); - if seen.contains(&pkg_id) { - continue; - } - seen.insert(pkg_id); - - let l = self.decisions.decision_level(literal); - - if l == 1 { - l1num += 1; - } else if l == level { - num += 1; - } else { - other_learned_literals.push(literal); - if l > rule_level { - rule_level = l; - } - } - } - - // l1 retry loop - let mut l1retry = true; - while l1retry { - l1retry = false; - - if num == 0 { - l1num -= 1; - if l1num == 0 { - // All level 1 literals done - let why = pool_idx; - let ll = learned_literal.ok_or_else(|| SolverBugError { - message: format!( - "Did not find a learnable literal in analyzed rule {conflict_rule_id}." - ), - })?; - - let mut all_literals = vec![ll]; - all_literals.extend_from_slice(&other_learned_literals); - - let new_rule = - Rule::new(all_literals, RuleReason::Learned, ReasonData::Learned(why)); - - return Ok((ll, rule_level, new_rule, why)); - } - } - - loop { - if decision_id == 0 { - return Err(SolverBugError { - message: format!( - "Reached invalid decision id 0 while analyzing rule {conflict_rule_id}." - ), - } - .into()); - } - - decision_id -= 1; - let decision = self.decisions.at_offset(decision_id); - let literal = decision.literal; - - if seen.contains(&literal_to_package_id(literal)) { - break; - } - } - - let decision = self.decisions.at_offset(decision_id); - let literal = decision.literal; - - seen.shift_remove(&literal_to_package_id(literal)); - - if num != 0 { - num -= 1; - if num == 0 { - learned_literal = Some(-literal); - - if l1num == 0 { - // Done - let why = pool_idx; - let ll = learned_literal.unwrap(); - - let mut all_literals = vec![ll]; - all_literals.extend_from_slice(&other_learned_literals); - - let new_rule = Rule::new( - all_literals, - RuleReason::Learned, - ReasonData::Learned(why), - ); - - return Ok((ll, rule_level, new_rule, why)); - } - - // Only level 1 marks left - for other in &other_learned_literals { - seen.shift_remove(&literal_to_package_id(*other)); - } - l1num += 1; - l1retry = true; - } else { - let decision = self.decisions.at_offset(decision_id); - let next_rule_id = decision.rule_id; - let next_rule = self.rules.rule_by_id(next_rule_id); - - if next_rule.is_multi_conflict { - // Handle multi-conflict rule - let mcr_literals = next_rule.literals().to_vec(); - for &rule_literal in &mcr_literals { - let pkg_id = literal_to_package_id(rule_literal); - if !seen.contains(&pkg_id) && self.decisions.satisfy(-rule_literal) - { - self.learned_pool[pool_idx].push(next_rule_id); - let l = self.decisions.decision_level(rule_literal); - if l == 1 { - l1num += 1; - } else if l == level { - num += 1; - } else { - other_learned_literals.push(rule_literal); - if l > rule_level { - rule_level = l; - } - } - seen.insert(pkg_id); - break; - } - } - l1retry = true; - } - } - } - } - - let decision = self.decisions.at_offset(decision_id); - current_rule_id = decision.rule_id; - } - } - - /// Recursively collect rules involved in an unsolvable conflict. - fn analyze_unsolvable_rule( - &self, - problem: &mut Problem, - conflict_rule_id: RuleId, - rule_seen: &mut IndexSet, - ) { - if rule_seen.contains(&conflict_rule_id) { - return; - } - rule_seen.insert(conflict_rule_id); - - let rule = self.rules.rule_by_id(conflict_rule_id); - - if rule.rule_type == RuleType::Learned { - if let Some(&why) = self.learned_why.get(&conflict_rule_id) - && let Some(problem_rules) = self.learned_pool.get(why) - { - for &pr_id in problem_rules { - if !rule_seen.contains(&pr_id) { - self.analyze_unsolvable_rule(problem, pr_id, rule_seen); - } - } - } - return; - } - - if rule.rule_type == RuleType::Package { - // Package rules cannot be part of a problem - return; - } - - problem.next_section(); - problem.add_rule(conflict_rule_id); - } - - /// Analyze an unsolvable conflict at level 1. - /// - /// Port of Composer's Solver::analyzeUnsolvable. - fn analyze_unsolvable(&mut self, conflict_rule_id: RuleId) { - let mut problem = Problem::new(); - problem.add_rule(conflict_rule_id); - - let mut rule_seen = IndexSet::new(); - self.analyze_unsolvable_rule(&mut problem, conflict_rule_id, &mut rule_seen); - - // Collect related decisions - let mut seen: IndexSet = IndexSet::new(); - let conflict_literals = self.rules.rule_by_id(conflict_rule_id).literals().to_vec(); - for &lit in &conflict_literals { - if self.decisions.satisfy(lit) { - continue; - } - seen.insert(literal_to_package_id(lit)); - } - - // Walk decisions in reverse - for i in (0..self.decisions.len()).rev() { - let decision = self.decisions.at_offset(i); - let dec_literal = decision.literal; - let pkg_id = literal_to_package_id(dec_literal); - - if !seen.contains(&pkg_id) { - continue; - } - - let why = decision.rule_id; - problem.add_rule(why); - self.analyze_unsolvable_rule(&mut problem, why, &mut rule_seen); - - let why_literals = self.rules.rule_by_id(why).literals().to_vec(); - for &lit in &why_literals { - if self.decisions.satisfy(lit) { - continue; - } - seen.insert(literal_to_package_id(lit)); - } - } - - self.problems.push(problem); - } - - /// Main SAT loop. - /// - /// Port of Composer's Solver::runSat. - fn run_sat(&mut self) -> Result<(), SolverError> { - self.propagate_index = 0; - - let mut level: i32 = 1; - let mut system_level: i32 = level + 1; - - loop { - // Step 1: propagate at level 1 - if level == 1 { - let conflict = self.propagate(level)?; - if let Some(conflict_rule_id) = conflict { - self.analyze_unsolvable(conflict_rule_id); - return Ok(()); - } - } - - // Step 2: handle root require/fixed package rules - if level < system_level { - let mut made_decision = false; - - // Collect request rule IDs first to avoid borrow issues - let request_rule_ids: Vec = self - .rules - .iter_type(RuleType::Request) - .map(|(id, _)| id) - .collect(); - - let mut all_satisfied = true; - - for &rule_id in &request_rule_ids { - let rule = self.rules.rule_by_id(rule_id); - if !rule.is_enabled() { - continue; - } - - let mut decision_queue: Vec = Vec::new(); - let mut none_satisfied = true; - - for &lit in rule.literals() { - if self.decisions.satisfy(lit) { - none_satisfied = false; - break; - } - if lit > 0 && self.decisions.undecided(lit) { - decision_queue.push(lit); - } - } - - if none_satisfied && !decision_queue.is_empty() { - // Prune: prefer fixed packages - let pruned: Vec = decision_queue - .iter() - .filter(|&&lit| self.fixed_map.contains(&literal_to_package_id(lit))) - .copied() - .collect(); - - if !pruned.is_empty() { - decision_queue = pruned; - } - } - - if none_satisfied && !decision_queue.is_empty() { - let old_level = level; - level = self.select_and_install(level, decision_queue, rule_id)?; - - if level == 0 { - return Ok(()); - } - if level <= old_level { - made_decision = true; - break; - } - } - - // Check if there are more rules to process - all_satisfied = false; - } - - system_level = level + 1; - - if made_decision || !all_satisfied { - // Check if we still have unsatisfied request rules - let has_unsatisfied = request_rule_ids.iter().any(|&rule_id| { - let rule = self.rules.rule_by_id(rule_id); - if !rule.is_enabled() { - return false; - } - let mut none_satisfied = true; - for &lit in rule.literals() { - if self.decisions.satisfy(lit) { - none_satisfied = false; - break; - } - } - if !none_satisfied { - return false; - } - rule.literals() - .iter() - .any(|&lit| lit > 0 && self.decisions.undecided(lit)) - }); - - if has_unsatisfied { - continue; - } - } - } - - if level < system_level { - system_level = level; - } - - // Step 3: fulfill all unresolved rules - let mut rules_count = self.rules.len(); - let mut i: usize = 0; - let mut n: usize = 0; - let mut made_decision = false; - - while n < rules_count { - if i == rules_count { - i = 0; - } - - let rule = self.rules.rule_by_id(i); - let literals = rule.literals().to_vec(); - - i += 1; - n += 1; - - if rule.is_disabled() { - continue; - } - - let mut decision_queue: Vec = Vec::new(); - let mut skip = false; - - for &lit in &literals { - if lit <= 0 { - if !self.decisions.decided_install(lit) { - skip = true; - break; - } - } else { - if self.decisions.decided_install(lit) { - skip = true; - break; - } - if self.decisions.undecided(lit) { - decision_queue.push(lit); - } - } - } - - if skip { - continue; - } - - // Need at least 2 undecided positive literals - if decision_queue.len() < 2 { - continue; - } - - let rule_id = i - 1; - level = self.select_and_install(level, decision_queue, rule_id)?; - - if level == 0 { - return Ok(()); - } - - // Something changed, restart scan - rules_count = self.rules.len(); - n = 0; - i = 0; - made_decision = true; - } - - if level < system_level && made_decision { - continue; - } - - // Step 4: minimization (backjumping) - if !self.branches.is_empty() { - let mut last_literal: Option = None; - let mut last_level: Option = None; - let mut last_branch_index: usize = 0; - let mut last_branch_offset: usize = 0; - - for i in (0..self.branches.len()).rev() { - let (ref literals, l) = self.branches[i]; - for (offset, &literal) in literals.iter().enumerate() { - if literal > 0 && self.decisions.decision_level(literal) > l + 1 { - last_literal = Some(literal); - last_branch_index = i; - last_branch_offset = offset; - last_level = Some(l); - } - } - } - - if let Some(literal) = last_literal { - let last_l = last_level.unwrap(); - self.branches[last_branch_index] - .0 - .remove(last_branch_offset); - - level = last_l; - self.revert(level); - - let why = self.decisions.last_reason(); - - level = self.set_propagate_learn(level, literal, why)?; - - if level == 0 { - return Ok(()); - } - - continue; - } - } - - break; - } - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::pool::PoolPackageInput; - use crate::rule::{ReasonData, Rule, RuleReason, RuleType}; - - fn make_input(name: &str, version: &str) -> PoolPackageInput { - PoolPackageInput { - name: name.to_string(), - version: version.to_string(), - pretty_version: version.to_string(), - requires: vec![], - replaces: vec![], - provides: vec![], - conflicts: vec![], - is_fixed: false, - is_alias_of: None, - } - } - - /// Helper: create a simple problem and solve it. - /// Creates a pool with N dummy packages (1..=max_id). - fn make_rules_and_solve( - rules: Vec<(Rule, RuleType)>, - fixed: IndexSet, - max_id: u32, - ) -> Result { - let mut rs = RuleSet::new(); - for (rule, rt) in rules { - rs.add(rule, rt); - } - let inputs: Vec<_> = (1..=max_id) - .map(|i| make_input(&format!("pkg/{i}"), &format!("{i}.0.0.0"))) - .collect(); - let pool = Pool::new(inputs, vec![]); - let policy = DefaultPolicy::default(); - let solver = Solver::new(rs, &pool, policy, fixed); - solver.solve() - } - - #[test] - fn test_single_package_required() { - // Root requires package 1 - let result = make_rules_and_solve( - vec![( - Rule::new(vec![1], RuleReason::RootRequire, ReasonData::None), - RuleType::Request, - )], - IndexSet::new(), - 3, - ) - .unwrap(); - - assert_eq!(result.installed, vec![1]); - } - - #[test] - fn test_two_packages_required() { - // Root requires either package 1 or 2, and also requires 3 - let result = make_rules_and_solve( - vec![ - ( - Rule::new(vec![1, 2], RuleReason::RootRequire, ReasonData::None), - RuleType::Request, - ), - ( - Rule::new(vec![3], RuleReason::RootRequire, ReasonData::None), - RuleType::Request, - ), - ], - IndexSet::new(), - 3, - ) - .unwrap(); - - assert!(result.installed.contains(&3)); - // Should install one of 1 or 2 - assert!(result.installed.contains(&1) || result.installed.contains(&2)); - } - - #[test] - fn test_dependency_chain() { - // Root requires 1. Package 1 requires 2. - // Rule for root: (1) - // Rule for dep: (-1 | 2) - let result = make_rules_and_solve( - vec![ - ( - Rule::new(vec![1], RuleReason::RootRequire, ReasonData::None), - RuleType::Request, - ), - ( - Rule::new(vec![-1, 2], RuleReason::PackageRequires, ReasonData::None), - RuleType::Package, - ), - ], - IndexSet::new(), - 3, - ) - .unwrap(); - - assert!(result.installed.contains(&1)); - assert!(result.installed.contains(&2)); - } - - #[test] - fn test_conflict_resolution() { - // Root requires 1 or 2. Package 1 conflicts with 3. - // Package 2 requires 3. - // Rules: - // Request: (1 | 2) - // Package: (-1 | -3) -- conflict - // Package: (-2 | 3) -- dep - // Request: (3) -- root also requires 3 - let result = make_rules_and_solve( - vec![ - ( - Rule::new(vec![1, 2], RuleReason::RootRequire, ReasonData::None), - RuleType::Request, - ), - ( - Rule::two_literals(-1, -3, RuleReason::PackageConflict, ReasonData::None), - RuleType::Package, - ), - ( - Rule::new(vec![-2, 3], RuleReason::PackageRequires, ReasonData::None), - RuleType::Package, - ), - ( - Rule::new(vec![3], RuleReason::RootRequire, ReasonData::None), - RuleType::Request, - ), - ], - IndexSet::new(), - 3, - ) - .unwrap(); - - // Package 3 is required, so 1 conflicts, must choose 2 - assert!(result.installed.contains(&2)); - assert!(result.installed.contains(&3)); - assert!(!result.installed.contains(&1)); - } - - #[test] - fn test_same_name_conflict() { - // Two versions of same package: 1 and 2. Root requires either. - // Same-name rule: (-1 | -2) - let result = make_rules_and_solve( - vec![ - ( - Rule::new(vec![1, 2], RuleReason::RootRequire, ReasonData::None), - RuleType::Request, - ), - ( - Rule::two_literals(-1, -2, RuleReason::PackageSameName, ReasonData::None), - RuleType::Package, - ), - ], - IndexSet::new(), - 3, - ) - .unwrap(); - - // Should install exactly one - let has_1 = result.installed.contains(&1); - let has_2 = result.installed.contains(&2); - assert!(has_1 ^ has_2, "Should install exactly one of 1 or 2"); - } - - #[test] - fn test_unsolvable() { - // Root requires 1. Root requires 2. But 1 and 2 conflict. - let result = make_rules_and_solve( - vec![ - ( - Rule::new(vec![1], RuleReason::RootRequire, ReasonData::None), - RuleType::Request, - ), - ( - Rule::new(vec![2], RuleReason::RootRequire, ReasonData::None), - RuleType::Request, - ), - ( - Rule::two_literals(-1, -2, RuleReason::PackageConflict, ReasonData::None), - RuleType::Package, - ), - ], - IndexSet::new(), - 3, - ); - - assert!(result.is_err()); - } -} diff --git a/crates/mozart-sat-resolver/src/transaction.rs b/crates/mozart-sat-resolver/src/transaction.rs deleted file mode 100644 index bf7befc..0000000 --- a/crates/mozart-sat-resolver/src/transaction.rs +++ /dev/null @@ -1,568 +0,0 @@ -use crate::decisions::Decisions; -use crate::pool::{PackageId, Pool, literal_to_package_id}; -use indexmap::{IndexMap, IndexSet}; - -/// An operation to perform on a package. -/// -/// Port of Composer's SolverOperation hierarchy. -#[derive(Debug, Clone)] -pub enum Operation { - /// Install a new package. - Install { package_id: PackageId }, - /// Update a package from one version to another. - Update { - initial_id: PackageId, - target_id: PackageId, - }, - /// Remove a package. - Uninstall { package_id: PackageId }, -} - -impl Operation { - /// Get the operation type as a string. - pub fn operation_type(&self) -> &'static str { - match self { - Operation::Install { .. } => "install", - Operation::Update { .. } => "update", - Operation::Uninstall { .. } => "uninstall", - } - } - - /// Format the operation as a human-readable string using pool data. - pub fn pretty_string(&self, pool: &Pool) -> String { - match self { - Operation::Install { package_id } => { - let pkg = pool.package_by_id(*package_id); - format!("Installing {} ({})", pkg.name, pkg.pretty_version) - } - Operation::Update { - initial_id, - target_id, - } => { - let initial = pool.package_by_id(*initial_id); - let target = pool.package_by_id(*target_id); - format!( - "Updating {} ({} => {})", - target.name, initial.pretty_version, target.pretty_version - ) - } - Operation::Uninstall { package_id } => { - let pkg = pool.package_by_id(*package_id); - format!("Removing {} ({})", pkg.name, pkg.pretty_version) - } - } - } -} - -/// Computes install/update/remove operations from solver results. -/// -/// Port of Composer's Transaction.php. -pub struct Transaction<'a> { - pool: &'a Pool, - /// Currently installed package IDs. - present_ids: Vec, - /// Result package IDs from the solver. - result_ids: Vec, - /// Computed operations. - operations: Vec, -} - -impl<'a> Transaction<'a> { - /// Create a new transaction from present and result package sets. - pub fn new(pool: &'a Pool, present_ids: Vec, result_ids: Vec) -> Self { - let mut tx = Transaction { - pool, - present_ids, - result_ids, - operations: Vec::new(), - }; - tx.calculate_operations(); - tx - } - - /// Create a transaction from solver decisions. - pub fn from_decisions( - pool: &'a Pool, - present_ids: Vec, - decisions: &Decisions, - ) -> Self { - let mut result_ids = Vec::new(); - for i in 0..decisions.len() { - let decision = decisions.at_offset(i); - if decision.literal > 0 { - result_ids.push(literal_to_package_id(decision.literal)); - } - } - Self::new(pool, present_ids, result_ids) - } - - /// Get the computed operations. - pub fn operations(&self) -> &[Operation] { - &self.operations - } - - /// Calculate the delta between present and result packages. - fn calculate_operations(&mut self) { - // Build maps: name -> package_id for present packages - let mut present_by_name: IndexMap<&str, PackageId> = IndexMap::new(); - for &id in &self.present_ids { - let pkg = self.pool.package_by_id(id); - present_by_name.insert(&pkg.name, id); - } - - // Track which present packages have been matched - let mut matched_present: IndexSet = IndexSet::new(); - - // Build topologically sorted result packages via DFS - let sorted_results = self.topological_sort(); - - // Process result packages in topological order - for &result_id in &sorted_results { - let result_pkg = self.pool.package_by_id(result_id); - - if let Some(&present_id) = present_by_name.get(result_pkg.name.as_str()) { - matched_present.insert(present_id); - let present_pkg = self.pool.package_by_id(present_id); - - // Check if update is needed (version changed) - if present_pkg.version != result_pkg.version || present_id != result_id { - self.operations.push(Operation::Update { - initial_id: present_id, - target_id: result_id, - }); - } - // Otherwise: no change needed, skip - } else { - // New package: install - self.operations.push(Operation::Install { - package_id: result_id, - }); - } - } - - // Remove packages that are present but not in result - let mut uninstalls = Vec::new(); - for &present_id in &self.present_ids { - if !matched_present.contains(&present_id) { - uninstalls.push(Operation::Uninstall { - package_id: present_id, - }); - } - } - - // Prepend uninstalls (remove before install/update) - uninstalls.append(&mut self.operations); - self.operations = uninstalls; - } - - /// Topologically sort result packages by their dependency order. - /// Uses DFS: dependencies are processed before dependents. - fn topological_sort(&self) -> Vec { - // Index every result package by every name it answers to (own name + - // `replaces` targets + `provides` targets). Mirrors Composer's - // `resultPackagesByName` map, which `getProvidersInResult` queries - // when walking a package's requires — so a replace/provide target - // resolves to the package that satisfies it. Without this expansion - // the DFS treats replace/provide-only requires as unsatisfied and - // misses the transitive ordering edge. - let mut result_by_target: IndexMap<&str, Vec> = IndexMap::new(); - for &id in &self.result_ids { - let pkg = self.pool.package_by_id(id); - result_by_target.entry(&pkg.name).or_default().push(id); - for link in &pkg.replaces { - result_by_target.entry(&link.target).or_default().push(id); - } - for link in &pkg.provides { - result_by_target.entry(&link.target).or_default().push(id); - } - } - - let mut visited: IndexSet = IndexSet::new(); - let mut order: Vec = Vec::new(); - - // Find root packages (not required by any other result package) - let roots = self.get_root_packages(&result_by_target); - - // DFS from roots - let mut stack: Vec<(PackageId, bool)> = Vec::new(); - for &root_id in roots.iter().rev() { - stack.push((root_id, false)); - } - - while let Some((pkg_id, processed)) = stack.pop() { - if processed { - if visited.insert(pkg_id) { - order.push(pkg_id); - } - continue; - } - - if visited.contains(&pkg_id) { - continue; - } - - // Push self as "processed" marker - stack.push((pkg_id, true)); - - // Push dependencies - let pkg = self.pool.package_by_id(pkg_id); - for req in &pkg.requires { - if let Some(provider_ids) = result_by_target.get(req.target.as_str()) { - for &dep_id in provider_ids { - if !visited.contains(&dep_id) { - stack.push((dep_id, false)); - } - } - } - } - } - - // Add any remaining unvisited packages - for &id in &self.result_ids { - if !visited.contains(&id) { - order.push(id); - } - } - - order - } - - /// Find root packages: result packages not required by any other result - /// package. A package whose own name (or any `replaces`/`provides` - /// target) appears in another result package's `requires` is excluded. - /// Mirrors Composer's `Transaction::getRootPackages`, which uses - /// `getProvidersInResult` to do the same expansion. - fn get_root_packages( - &self, - result_by_target: &IndexMap<&str, Vec>, - ) -> Vec { - let mut required: IndexSet = IndexSet::new(); - for &id in &self.result_ids { - let pkg = self.pool.package_by_id(id); - for req in &pkg.requires { - if let Some(provider_ids) = result_by_target.get(req.target.as_str()) { - for &dep_id in provider_ids { - if dep_id != id { - required.insert(dep_id); - } - } - } - } - } - - let mut roots: Vec = Vec::new(); - for &id in &self.result_ids { - if !required.contains(&id) { - roots.push(id); - } - } - - // If no roots found (circular), use all - if roots.is_empty() { - return self.result_ids.clone(); - } - - roots - } -} - -/// Lock transaction: specialization for computing lock file operations. -/// -/// Port of Composer's LockTransaction.php. -pub struct LockTransaction<'a> { - /// The base transaction. - transaction: Transaction<'a>, - /// All result package IDs. - all_result_ids: Vec, - /// Non-dev result package IDs. - non_dev_ids: Vec, - /// Dev result package IDs. - dev_ids: Vec, -} - -impl<'a> LockTransaction<'a> { - /// Create a lock transaction from solver decisions. - pub fn new( - pool: &'a Pool, - present_ids: Vec, - unlockable_ids: IndexSet, - decisions: &Decisions, - ) -> Self { - // Extract result packages from decisions - let mut all_result_ids = Vec::new(); - let mut non_dev_ids = Vec::new(); - for i in 0..decisions.len() { - let decision = decisions.at_offset(i); - if decision.literal > 0 { - let pkg_id = literal_to_package_id(decision.literal); - all_result_ids.push(pkg_id); - if !unlockable_ids.contains(&pkg_id) { - non_dev_ids.push(pkg_id); - } - } - } - - let transaction = Transaction::new(pool, present_ids, all_result_ids.clone()); - - LockTransaction { - transaction, - all_result_ids, - non_dev_ids, - dev_ids: Vec::new(), - } - } - - /// Set the non-dev packages from an extraction-only solve result. - /// `extraction_ids` are the package IDs that were resolved without dev deps. - pub fn set_non_dev_packages(&mut self, extraction_ids: &[PackageId]) { - let extraction_names: IndexSet = extraction_ids - .iter() - .map(|&id| self.transaction.pool.package_by_id(id).name.clone()) - .collect(); - - self.non_dev_ids.clear(); - self.dev_ids.clear(); - - for &id in &self.all_result_ids { - let pkg = self.transaction.pool.package_by_id(id); - if extraction_names.contains(&pkg.name) { - self.non_dev_ids.push(id); - } else { - self.dev_ids.push(id); - } - } - } - - /// Get the computed operations. - pub fn operations(&self) -> &[Operation] { - self.transaction.operations() - } - - /// Get all result package IDs. - pub fn all_result_ids(&self) -> &[PackageId] { - &self.all_result_ids - } - - /// Get non-dev result package IDs. - pub fn non_dev_ids(&self) -> &[PackageId] { - &self.non_dev_ids - } - - /// Get dev result package IDs. - pub fn dev_ids(&self) -> &[PackageId] { - &self.dev_ids - } - - /// Get new lock packages for writing to the lock file. - /// If `dev_mode` is true, returns dev packages; otherwise non-dev. - pub fn new_lock_package_ids(&self, dev_mode: bool) -> &[PackageId] { - if dev_mode { - &self.dev_ids - } else { - &self.non_dev_ids - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::pool::{PoolLink, PoolPackageInput}; - - fn make_input(name: &str, version: &str, pretty: &str) -> PoolPackageInput { - PoolPackageInput { - name: name.to_string(), - version: version.to_string(), - pretty_version: pretty.to_string(), - requires: vec![], - replaces: vec![], - provides: vec![], - conflicts: vec![], - is_fixed: false, - is_alias_of: None, - } - } - - fn make_input_with_deps( - name: &str, - version: &str, - pretty: &str, - deps: Vec<(&str, &str)>, - ) -> PoolPackageInput { - let requires = deps - .into_iter() - .map(|(target, constraint)| PoolLink { - target: target.to_string(), - constraint: constraint.to_string(), - source: name.to_string(), - }) - .collect(); - - PoolPackageInput { - name: name.to_string(), - version: version.to_string(), - pretty_version: pretty.to_string(), - requires, - replaces: vec![], - provides: vec![], - conflicts: vec![], - is_fixed: false, - is_alias_of: None, - } - } - - #[test] - fn test_fresh_install() { - let pool = Pool::new( - vec![ - make_input("a/a", "1.0.0.0", "1.0.0"), - make_input("b/b", "2.0.0.0", "2.0.0"), - ], - vec![], - ); - - let tx = Transaction::new(&pool, vec![], vec![1, 2]); - let ops = tx.operations(); - - assert_eq!(ops.len(), 2); - assert!(matches!(ops[0], Operation::Install { package_id: _ })); - assert!(matches!(ops[1], Operation::Install { package_id: _ })); - } - - #[test] - fn test_update_package() { - let pool = Pool::new( - vec![ - make_input("a/a", "1.0.0.0", "1.0.0"), - make_input("a/a", "2.0.0.0", "2.0.0"), - ], - vec![], - ); - - // Present: a/a 1.0.0 (id=1), Result: a/a 2.0.0 (id=2) - let tx = Transaction::new(&pool, vec![1], vec![2]); - let ops = tx.operations(); - - assert_eq!(ops.len(), 1); - match &ops[0] { - Operation::Update { - initial_id, - target_id, - } => { - assert_eq!(*initial_id, 1); - assert_eq!(*target_id, 2); - } - _ => panic!("Expected update operation"), - } - } - - #[test] - fn test_uninstall_package() { - let pool = Pool::new( - vec![ - make_input("a/a", "1.0.0.0", "1.0.0"), - make_input("b/b", "1.0.0.0", "1.0.0"), - ], - vec![], - ); - - // Present: a/a and b/b, Result: only a/a - let tx = Transaction::new(&pool, vec![1, 2], vec![1]); - let ops = tx.operations(); - - assert_eq!(ops.len(), 1); - match &ops[0] { - Operation::Uninstall { package_id } => { - assert_eq!(*package_id, 2); - } - _ => panic!("Expected uninstall operation"), - } - } - - #[test] - fn test_uninstalls_before_installs() { - let pool = Pool::new( - vec![ - make_input("a/a", "1.0.0.0", "1.0.0"), - make_input("b/b", "1.0.0.0", "1.0.0"), - ], - vec![], - ); - - // Present: a/a, Result: b/b (uninstall a, install b) - let tx = Transaction::new(&pool, vec![1], vec![2]); - let ops = tx.operations(); - - assert_eq!(ops.len(), 2); - assert!( - matches!(ops[0], Operation::Uninstall { .. }), - "Uninstalls should come first" - ); - assert!( - matches!(ops[1], Operation::Install { .. }), - "Installs should come after" - ); - } - - #[test] - fn test_dependency_ordering() { - // a/a requires b/b — b/b should be installed before a/a - let pool = Pool::new( - vec![ - make_input_with_deps("a/a", "1.0.0.0", "1.0.0", vec![("b/b", "^1.0")]), - make_input("b/b", "1.0.0.0", "1.0.0"), - ], - vec![], - ); - - let tx = Transaction::new(&pool, vec![], vec![1, 2]); - let ops = tx.operations(); - - assert_eq!(ops.len(), 2); - // b/b (dependency) should be installed before a/a - match (&ops[0], &ops[1]) { - ( - Operation::Install { package_id: first }, - Operation::Install { package_id: second }, - ) => { - assert_eq!(*first, 2, "b/b should be installed first"); - assert_eq!(*second, 1, "a/a should be installed second"); - } - _ => panic!("Expected two install operations"), - } - } - - #[test] - fn test_no_change() { - let pool = Pool::new(vec![make_input("a/a", "1.0.0.0", "1.0.0")], vec![]); - - // Same package present and in result - let tx = Transaction::new(&pool, vec![1], vec![1]); - let ops = tx.operations(); - - assert!(ops.is_empty(), "No operations when nothing changed"); - } - - #[test] - fn test_operation_pretty_string() { - let pool = Pool::new( - vec![ - make_input("a/a", "1.0.0.0", "1.0.0"), - make_input("a/a", "2.0.0.0", "2.0.0"), - ], - vec![], - ); - - let install = Operation::Install { package_id: 1 }; - assert_eq!(install.pretty_string(&pool), "Installing a/a (1.0.0)"); - - let update = Operation::Update { - initial_id: 1, - target_id: 2, - }; - assert_eq!(update.pretty_string(&pool), "Updating a/a (1.0.0 => 2.0.0)"); - - let uninstall = Operation::Uninstall { package_id: 1 }; - assert_eq!(uninstall.pretty_string(&pool), "Removing a/a (1.0.0)"); - } -} diff --git a/crates/mozart-vcs/Cargo.toml b/crates/mozart-vcs/Cargo.toml deleted file mode 100644 index 92b3e24..0000000 --- a/crates/mozart-vcs/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "mozart-vcs" -version.workspace = true -edition.workspace = true - -[dependencies] -mozart-core.workspace = true -mozart-semver.workspace = true -anyhow.workspace = true -base64.workspace = true -indexmap.workspace = true -regex.workspace = true -reqwest.workspace = true -serde.workspace = true -serde_json.workspace = true -tokio.workspace = true -tracing.workspace = true -url.workspace = true - -[dev-dependencies] -tempfile.workspace = true diff --git a/crates/mozart-vcs/src/downloader/git.rs b/crates/mozart-vcs/src/downloader/git.rs deleted file mode 100644 index 814d67e..0000000 --- a/crates/mozart-vcs/src/downloader/git.rs +++ /dev/null @@ -1,274 +0,0 @@ -use std::path::Path; -use std::sync::LazyLock; - -use anyhow::Result; -use regex::Regex; - -use crate::process::ProcessExecutor; -use crate::util::git::GitUtil; - -use super::VcsDownloader; - -/// Match ` HEAD` lines in `git show-ref --head -d` output. -static HEAD_REF_RE: LazyLock = - LazyLock::new(|| Regex::new(r"(?im)^([a-f0-9]+) HEAD$").unwrap()); - -/// Git downloader using clone/checkout with optional mirror cache. -/// -/// Corresponds to Composer's `Downloader\GitDownloader`. -pub struct GitDownloader { - git_util: GitUtil, -} - -impl GitDownloader { - pub fn new(git_util: GitUtil) -> Self { - Self { git_util } - } -} - -impl VcsDownloader for GitDownloader { - fn download(&self, url: &str, _reference: &str, _target: &Path) -> Result<()> { - // Pre-sync the mirror so install can use --reference - self.git_util.sync_mirror(url)?; - Ok(()) - } - - fn install(&self, url: &str, reference: &str, target: &Path) -> Result<()> { - let target_str = target.to_string_lossy(); - let mirror_path = self.git_util.mirror_path(url); - - if mirror_path.join("HEAD").exists() { - // Clone with mirror reference for efficiency - let mirror_str = mirror_path.to_string_lossy().to_string(); - self.git_util.run_command( - &[ - "git", - "clone", - "--no-checkout", - "--dissociate", - "--reference", - &mirror_str, - "--", - url, - &target_str, - ], - url, - None, - )?; - } else { - self.git_util.run_command( - &["git", "clone", "--no-checkout", "--", url, &target_str], - url, - None, - )?; - } - - // Checkout the specific reference - let process = ProcessExecutor::new(); - process.execute_checked(&["git", "checkout", reference, "--force"], Some(target))?; - - Ok(()) - } - - fn update(&self, url: &str, _old_ref: &str, new_ref: &str, target: &Path) -> Result<()> { - let process = ProcessExecutor::new(); - - // Update remote URL - process.execute_checked( - &["git", "remote", "set-url", "origin", "--", url], - Some(target), - )?; - - // Fetch latest - self.git_util - .run_command(&["git", "fetch", "origin"], url, Some(target))?; - - // Checkout new reference - process.execute_checked(&["git", "checkout", new_ref, "--force"], Some(target))?; - - Ok(()) - } - - fn remove(&self, target: &Path) -> Result<()> { - if target.exists() { - std::fs::remove_dir_all(target)?; - } - Ok(()) - } - - fn get_local_changes(&self, target: &Path) -> Result> { - if !target.join(".git").exists() { - return Ok(None); - } - let process = ProcessExecutor::new(); - let output = process.execute( - &["git", "status", "--porcelain", "--untracked-files=no"], - Some(target), - )?; - let trimmed = output.stdout.trim(); - if trimmed.is_empty() { - Ok(None) - } else { - Ok(Some(trimmed.to_string())) - } - } - - fn vcs_reference(&self, target: &Path) -> Result> { - if !target.join(".git").exists() { - return Ok(None); - } - let process = ProcessExecutor::new(); - let output = process.execute(&["git", "rev-parse", "HEAD"], Some(target))?; - if output.status != 0 { - return Ok(None); - } - let trimmed = output.stdout.trim(); - if trimmed.is_empty() { - Ok(None) - } else { - Ok(Some(trimmed.to_string())) - } - } - - fn unpushed_changes(&self, target: &Path) -> Result> { - if !target.join(".git").exists() { - return Ok(None); - } - let process = ProcessExecutor::new(); - - let mut refs = match collect_show_ref(&process, target)? { - Some(r) => r, - None => return Ok(None), - }; - - let head_ref = match HEAD_REF_RE - .captures(&refs) - .and_then(|c| c.get(1)) - .map(|m| m.as_str().to_string()) - { - Some(h) => h, - None => return Ok(None), - }; - - let candidate_branches = collect_local_branches(&refs, &head_ref); - if candidate_branches.is_empty() { - // not on a branch (detached / tag) — skip - return Ok(None); - } - - let mut branch = candidate_branches[0].clone(); - let mut unpushed_changes: Option = None; - let mut branch_not_found_error = false; - - for i in 0..=1 { - let mut remote_branches: Vec = Vec::new(); - - for candidate in &candidate_branches { - let matches = collect_remote_branches(&refs, candidate); - if !matches.is_empty() { - branch = candidate.clone(); - remote_branches = matches; - break; - } - } - - if remote_branches.is_empty() { - unpushed_changes = Some(format!( - "Branch {branch} could not be found on any remote and appears to be unpushed" - )); - branch_not_found_error = true; - } else { - if branch_not_found_error { - unpushed_changes = None; - } - for remote_branch in &remote_branches { - let range = format!("{remote_branch}...{branch}"); - let output = process.execute_checked( - &["git", "diff", "--name-status", &range, "--"], - Some(target), - )?; - let trimmed = output.stdout.trim().to_string(); - match unpushed_changes { - None => unpushed_changes = Some(trimmed), - Some(ref existing) if trimmed.len() < existing.len() => { - unpushed_changes = Some(trimmed); - } - _ => {} - } - } - } - - if unpushed_changes.as_deref().is_some_and(|s| !s.is_empty()) && i == 0 { - let _ = process.execute(&["git", "fetch", "--all"], Some(target))?; - refs = match collect_show_ref(&process, target)? { - Some(r) => r, - None => return Ok(unpushed_changes), - }; - } - - if unpushed_changes.as_deref().is_none_or(str::is_empty) { - break; - } - } - - Ok(unpushed_changes.filter(|s| !s.is_empty())) - } - - fn commit_logs(&self, from: &str, to: &str, target: &Path) -> Result { - let process = ProcessExecutor::new(); - let range = format!("{from}..{to}"); - let output = process.execute( - &["git", "log", &range, "--oneline", "--no-decorate"], - Some(target), - )?; - Ok(output.stdout) - } - - fn is_change_report(&self) -> bool { - true - } - - fn is_vcs_capable_downloader(&self) -> bool { - true - } - - fn is_dvcs_downloader(&self) -> bool { - true - } -} - -fn collect_show_ref(process: &ProcessExecutor, target: &Path) -> Result> { - let output = process.execute(&["git", "show-ref", "--head", "-d"], Some(target))?; - if output.status != 0 { - anyhow::bail!( - "Failed to execute git show-ref --head -d\n\n{}", - output.stderr.trim() - ); - } - Ok(Some(output.stdout.trim().to_string())) -} - -fn collect_local_branches(refs: &str, head_ref: &str) -> Vec { - let pattern = format!(r"(?im)^{} refs/heads/(.+)$", regex::escape(head_ref)); - let re = match Regex::new(&pattern) { - Ok(r) => r, - Err(_) => return Vec::new(), - }; - re.captures_iter(refs) - .filter_map(|c| c.get(1).map(|m| m.as_str().to_string())) - .collect() -} - -fn collect_remote_branches(refs: &str, candidate: &str) -> Vec { - let pattern = format!( - r"(?im)^[a-f0-9]+ refs/remotes/((?:[^/]+)/{})$", - regex::escape(candidate) - ); - let re = match Regex::new(&pattern) { - Ok(r) => r, - Err(_) => return Vec::new(), - }; - re.captures_iter(refs) - .filter_map(|c| c.get(1).map(|m| m.as_str().to_string())) - .collect() -} diff --git a/crates/mozart-vcs/src/downloader/hg.rs b/crates/mozart-vcs/src/downloader/hg.rs deleted file mode 100644 index 3230404..0000000 --- a/crates/mozart-vcs/src/downloader/hg.rs +++ /dev/null @@ -1,87 +0,0 @@ -use std::path::Path; - -use anyhow::Result; - -use crate::util::hg::HgUtil; - -use super::VcsDownloader; - -/// Mercurial downloader using clone/pull/update. -pub struct HgDownloader { - hg_util: HgUtil, -} - -impl HgDownloader { - pub fn new(hg_util: HgUtil) -> Self { - Self { hg_util } - } -} - -impl VcsDownloader for HgDownloader { - fn download(&self, _url: &str, _reference: &str, _target: &Path) -> Result<()> { - Ok(()) - } - - fn install(&self, url: &str, reference: &str, target: &Path) -> Result<()> { - let target_str = target.to_string_lossy().to_string(); - self.hg_util - .execute(&["clone", "--", url, &target_str], None)?; - self.hg_util - .execute(&["update", "-r", reference], Some(target))?; - Ok(()) - } - - fn update(&self, url: &str, _old_ref: &str, new_ref: &str, target: &Path) -> Result<()> { - self.hg_util.execute(&["pull", url], Some(target))?; - self.hg_util - .execute(&["update", "-r", new_ref], Some(target))?; - Ok(()) - } - - fn remove(&self, target: &Path) -> Result<()> { - if target.exists() { - std::fs::remove_dir_all(target)?; - } - Ok(()) - } - - fn get_local_changes(&self, target: &Path) -> Result> { - if !target.join(".hg").is_dir() { - return Ok(None); - } - let output = self.hg_util.execute(&["st"], Some(target))?; - let trimmed = output.stdout.trim(); - if trimmed.is_empty() { - Ok(None) - } else { - Ok(Some(trimmed.to_string())) - } - } - - fn commit_logs(&self, from: &str, to: &str, target: &Path) -> Result { - let range = format!("{from}:{to}"); - let output = self.hg_util.execute( - &[ - "log", - "-r", - &range, - "--template", - "{rev}:{node|short} {desc|firstline}\\n", - ], - Some(target), - )?; - Ok(output.stdout) - } - - fn is_change_report(&self) -> bool { - true - } - - fn is_vcs_capable_downloader(&self) -> bool { - true - } - - fn is_dvcs_downloader(&self) -> bool { - false - } -} diff --git a/crates/mozart-vcs/src/downloader/mod.rs b/crates/mozart-vcs/src/downloader/mod.rs deleted file mode 100644 index 352f330..0000000 --- a/crates/mozart-vcs/src/downloader/mod.rs +++ /dev/null @@ -1,56 +0,0 @@ -pub mod git; -pub mod hg; -pub mod svn; - -use std::path::Path; - -use anyhow::Result; - -/// The VCS downloader interface. -/// -/// Corresponds to Composer's `VcsDownloader` hierarchy. -pub trait VcsDownloader { - /// Prepare for installation (e.g., sync mirror cache). - fn download(&self, url: &str, reference: &str, target: &Path) -> Result<()>; - - /// Install (clone/checkout) the source to the target directory. - fn install(&self, url: &str, reference: &str, target: &Path) -> Result<()>; - - /// Update the source at target to a new reference. - fn update(&self, url: &str, old_ref: &str, new_ref: &str, target: &Path) -> Result<()>; - - /// Remove the source from the target directory. - fn remove(&self, target: &Path) -> Result<()>; - - /// Detect local changes in the working copy. - /// Returns `None` if clean, `Some(diff)` if modified. - /// Mirrors `Composer\Downloader\ChangeReportInterface::getLocalChanges`. - fn get_local_changes(&self, target: &Path) -> Result>; - - /// Detect commits present locally but not on the tracking remote. - /// Returns `None` if there are no unpushed commits or the concept does - /// not apply (only `GitDownloader` implements this in Composer's - /// `DvcsDownloaderInterface`). - fn unpushed_changes(&self, _target: &Path) -> Result> { - Ok(None) - } - - /// Resolve the working copy's current VCS reference (e.g. commit hash). - /// Returns `None` if no reference can be determined. Mirrors - /// `Composer\Downloader\VcsCapableDownloaderInterface::getVcsReference`. - fn vcs_reference(&self, _target: &Path) -> Result> { - Ok(None) - } - - /// Get commit log between two references. - fn commit_logs(&self, from: &str, to: &str, target: &Path) -> Result; - - /// instanceof ChangeReportInterface - fn is_change_report(&self) -> bool; - - /// instanceof VcsCapableDownloaderInterface - fn is_vcs_capable_downloader(&self) -> bool; - - /// instanceof DvcsDownloaderInterface - fn is_dvcs_downloader(&self) -> bool; -} diff --git a/crates/mozart-vcs/src/downloader/svn.rs b/crates/mozart-vcs/src/downloader/svn.rs deleted file mode 100644 index 87b59da..0000000 --- a/crates/mozart-vcs/src/downloader/svn.rs +++ /dev/null @@ -1,87 +0,0 @@ -use std::path::Path; -use std::sync::LazyLock; - -use anyhow::Result; -use regex::Regex; - -use crate::util::svn::SvnUtil; - -use super::VcsDownloader; - -/// Match any non-`X` status line (mirror of Composer's -/// `{^ *[^X ] +}m`). Ignores externals (`X` prefix). -static SVN_STATUS_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?m)^ *[^X ] +").unwrap()); - -/// SVN downloader using checkout/switch. -pub struct SvnDownloader { - svn_util: SvnUtil, -} - -impl SvnDownloader { - pub fn new(svn_util: SvnUtil) -> Self { - Self { svn_util } - } -} - -impl VcsDownloader for SvnDownloader { - fn download(&self, _url: &str, _reference: &str, _target: &Path) -> Result<()> { - // SVN doesn't need a pre-download step - Ok(()) - } - - fn install(&self, url: &str, reference: &str, target: &Path) -> Result<()> { - let target_str = target.to_string_lossy().to_string(); - let svn_url = format!("{url}@{reference}"); - self.svn_util - .execute(&["checkout", &svn_url, &target_str], None)?; - Ok(()) - } - - fn update(&self, url: &str, _old_ref: &str, new_ref: &str, target: &Path) -> Result<()> { - let svn_url = format!("{url}@{new_ref}"); - self.svn_util - .execute(&["switch", "--ignore-ancestry", &svn_url], Some(target))?; - Ok(()) - } - - fn remove(&self, target: &Path) -> Result<()> { - if target.exists() { - std::fs::remove_dir_all(target)?; - } - Ok(()) - } - - fn get_local_changes(&self, target: &Path) -> Result> { - if !target.join(".svn").is_dir() { - return Ok(None); - } - let output = self - .svn_util - .execute(&["status", "--ignore-externals"], Some(target))?; - if SVN_STATUS_RE.is_match(&output.stdout) { - Ok(Some(output.stdout)) - } else { - Ok(None) - } - } - - fn commit_logs(&self, from: &str, to: &str, target: &Path) -> Result { - let range = format!("{from}:{to}"); - let output = self - .svn_util - .execute(&["log", "-r", &range], Some(target))?; - Ok(output.stdout) - } - - fn is_change_report(&self) -> bool { - true - } - - fn is_vcs_capable_downloader(&self) -> bool { - true - } - - fn is_dvcs_downloader(&self) -> bool { - false - } -} diff --git a/crates/mozart-vcs/src/driver/bitbucket.rs b/crates/mozart-vcs/src/driver/bitbucket.rs deleted file mode 100644 index 0e67bc8..0000000 --- a/crates/mozart-vcs/src/driver/bitbucket.rs +++ /dev/null @@ -1,277 +0,0 @@ -use indexmap::IndexMap; -use std::collections::BTreeMap; - -use anyhow::{Result, bail}; -use regex::Regex; -use reqwest::Client; -use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT}; - -use super::git::GitDriver; -use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; - -/// Bitbucket VCS driver using the REST API 2.0. -pub struct BitbucketDriver { - owner: String, - repo: String, - url: String, - root_identifier: Option, - tags: Option>, - branches: Option>, - info_cache: IndexMap>, - git_driver: Option>, - http_client: Client, - config: DriverConfig, - api_failed: bool, - vcs_type: String, // "git" or "hg" -} - -impl BitbucketDriver { - pub fn new(url: &str, config: DriverConfig) -> Self { - let (owner, repo) = Self::parse_url(url).unwrap_or_default(); - Self { - owner, - repo, - url: url.to_string(), - root_identifier: None, - tags: None, - branches: None, - info_cache: IndexMap::new(), - git_driver: None, - http_client: mozart_core::http::default_client(), - config, - api_failed: false, - vcs_type: "git".to_string(), - } - } - - pub fn supports(url: &str) -> bool { - let url_lower = url.to_lowercase(); - url_lower.contains("bitbucket.org") - } - - fn parse_url(url: &str) -> Option<(String, String)> { - let re = - Regex::new(r"bitbucket\.org[:/]([^/]+)/([^/.\s]+?)(?:\.git)?(?:[/#?].*)?$").ok()?; - let caps = re.captures(url)?; - Some((caps[1].to_string(), caps[2].to_string())) - } - - fn api_url(&self, path: &str) -> String { - format!( - "https://api.bitbucket.org/2.0/repositories/{}/{}{}", - self.owner, self.repo, path, - ) - } - - #[tracing::instrument(skip(self))] - async fn api_get(&self, path: &str) -> Result { - let url = self.api_url(path); - let mut req = self - .http_client - .get(&url) - .header(USER_AGENT, "mozart/0.1") - .header(ACCEPT, "application/json"); - - if let Some((key, secret)) = &self.config.bitbucket_oauth { - let credentials = format!("{key}:{secret}"); - req = req.header(AUTHORIZATION, format!("Basic {credentials}")); - } - - let response = req.send().await?; - tracing::debug!(status = %response.status(), %url, "Bitbucket API response"); - if !response.status().is_success() { - bail!( - "Bitbucket API request to {} failed: {}", - url, - response.status() - ); - } - Ok(response.json().await?) - } - - #[tracing::instrument(skip(self))] - async fn api_get_paginated(&self, path: &str) -> Result> { - let mut items = Vec::new(); - let mut next_url = Some(self.api_url(path)); - let mut pages = 0; - - while let Some(url) = next_url { - let mut req = self - .http_client - .get(&url) - .header(USER_AGENT, "mozart/0.1") - .header(ACCEPT, "application/json"); - if let Some((key, secret)) = &self.config.bitbucket_oauth { - req = req.header(AUTHORIZATION, format!("Basic {key}:{secret}")); - } - let response = req.send().await?; - tracing::debug!(status = %response.status(), %url, "Bitbucket API paginated response"); - if !response.status().is_success() { - break; - } - let data: serde_json::Value = response.json().await?; - if let Some(values) = data["values"].as_array() { - items.extend(values.iter().cloned()); - } - next_url = data["next"].as_str().map(|s: &str| s.to_string()); - pages += 1; - if pages > 10 { - break; - } - } - Ok(items) - } - - async fn use_git_fallback(&mut self) -> Result<&mut GitDriver> { - if self.git_driver.is_none() { - let git_url = format!("https://bitbucket.org/{}/{}.git", self.owner, self.repo); - let mut driver = GitDriver::new(&git_url, self.config.clone()); - driver.initialize().await?; - self.git_driver = Some(Box::new(driver)); - } - Ok(self.git_driver.as_mut().unwrap()) - } -} - -impl VcsDriver for BitbucketDriver { - async fn initialize(&mut self) -> Result<()> { - match self.api_get("").await { - Ok(data) => { - if let Some(scm) = data["scm"].as_str() { - self.vcs_type = scm.to_string(); - } - let default_branch = data["mainbranch"]["name"] - .as_str() - .unwrap_or("main") - .to_string(); - self.root_identifier = Some(default_branch); - } - Err(_) => { - self.api_failed = true; - let driver = self.use_git_fallback().await?; - self.root_identifier = Some(driver.root_identifier().to_string()); - } - } - Ok(()) - } - - fn root_identifier(&self) -> &str { - self.root_identifier.as_deref().unwrap_or("main") - } - - async fn branches(&mut self) -> Result<&BTreeMap> { - if self.branches.is_none() { - if self.api_failed { - let driver = self.use_git_fallback().await?; - let branches = driver.branches().await?.clone(); - self.branches = Some(branches); - } else { - let items = self.api_get_paginated("/refs/branches?pagelen=100").await?; - let mut branches = BTreeMap::new(); - for item in items { - if let (Some(name), Some(sha)) = - (item["name"].as_str(), item["target"]["hash"].as_str()) - { - branches.insert(name.to_string(), sha.to_string()); - } - } - self.branches = Some(branches); - } - } - Ok(self.branches.as_ref().unwrap()) - } - - async fn tags(&mut self) -> Result<&BTreeMap> { - if self.tags.is_none() { - if self.api_failed { - let driver = self.use_git_fallback().await?; - let tags = driver.tags().await?.clone(); - self.tags = Some(tags); - } else { - let items = self.api_get_paginated("/refs/tags?pagelen=100").await?; - let mut tags = BTreeMap::new(); - for item in items { - if let (Some(name), Some(sha)) = - (item["name"].as_str(), item["target"]["hash"].as_str()) - { - tags.insert(name.to_string(), sha.to_string()); - } - } - self.tags = Some(tags); - } - } - Ok(self.tags.as_ref().unwrap()) - } - - async fn composer_information( - &mut self, - identifier: &str, - ) -> Result> { - if let Some(cached) = self.info_cache.get(identifier) { - return Ok(cached.clone()); - } - let content = self.file_content("composer.json", identifier).await?; - let value = content.and_then(|c| serde_json::from_str(&c).ok()); - self.info_cache - .insert(identifier.to_string(), value.clone()); - Ok(value) - } - - async fn file_content(&self, file: &str, identifier: &str) -> Result> { - if self.api_failed { - return Ok(None); - } - let url = self.api_url(&format!("/src/{identifier}/{file}")); - let mut req = self.http_client.get(&url).header(USER_AGENT, "mozart/0.1"); - if let Some((key, secret)) = &self.config.bitbucket_oauth { - req = req.header(AUTHORIZATION, format!("Basic {key}:{secret}")); - } - let response = req.send().await?; - if response.status().is_success() { - Ok(Some(response.text().await?)) - } else { - Ok(None) - } - } - - async fn change_date(&self, identifier: &str) -> Result> { - if self.api_failed { - return Ok(None); - } - match self.api_get(&format!("/commit/{identifier}")).await { - Ok(data) => Ok(data["date"].as_str().map(|s| s.to_string())), - Err(_) => Ok(None), - } - } - - async fn dist(&self, identifier: &str) -> Result> { - Ok(Some(DistReference { - dist_type: "zip".to_string(), - url: format!( - "https://bitbucket.org/{}/{}/get/{}.zip", - self.owner, self.repo, identifier, - ), - reference: identifier.to_string(), - shasum: None, - })) - } - - fn source(&self, identifier: &str) -> SourceReference { - SourceReference { - source_type: self.vcs_type.clone(), - url: format!("https://bitbucket.org/{}/{}.git", self.owner, self.repo), - reference: identifier.to_string(), - } - } - - fn url(&self) -> &str { - &self.url - } - - async fn cleanup(&mut self) -> Result<()> { - if let Some(driver) = &mut self.git_driver { - driver.cleanup().await?; - } - Ok(()) - } -} diff --git a/crates/mozart-vcs/src/driver/forgejo.rs b/crates/mozart-vcs/src/driver/forgejo.rs deleted file mode 100644 index 665c177..0000000 --- a/crates/mozart-vcs/src/driver/forgejo.rs +++ /dev/null @@ -1,285 +0,0 @@ -use indexmap::IndexMap; -use std::collections::BTreeMap; - -use anyhow::{Result, bail}; -use regex::Regex; -use reqwest::Client; -use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT}; - -use super::git::GitDriver; -use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; - -/// Forgejo/Gitea VCS driver using the REST API v1. -/// -/// Supports self-hosted instances (Codeberg, etc.). -pub struct ForgejoDriver { - owner: String, - repo: String, - host: String, - scheme: String, - url: String, - root_identifier: Option, - tags: Option>, - branches: Option>, - info_cache: IndexMap>, - git_driver: Option>, - http_client: Client, - config: DriverConfig, - api_failed: bool, -} - -impl ForgejoDriver { - pub fn new(url: &str, config: DriverConfig) -> Self { - let (host, scheme, owner, repo) = Self::parse_url(url).unwrap_or_default(); - Self { - owner, - repo, - host, - scheme, - url: url.to_string(), - root_identifier: None, - tags: None, - branches: None, - info_cache: IndexMap::new(), - git_driver: None, - http_client: mozart_core::http::default_client(), - config, - api_failed: false, - } - } - - pub fn supports(url: &str, forgejo_domains: &[String]) -> bool { - let url_lower = url.to_lowercase(); - for domain in forgejo_domains { - if url_lower.contains(domain) { - return true; - } - } - false - } - - fn parse_url(url: &str) -> Option<(String, String, String, String)> { - let re = Regex::new(r"(?i)(https?)://([^/]+)/([^/]+)/([^/.\s]+?)(?:\.git)?(?:[/#?].*)?$") - .ok()?; - let caps = re.captures(url)?; - Some(( - caps[2].to_string(), - caps[1].to_string(), - caps[3].to_string(), - caps[4].to_string(), - )) - } - - fn api_url(&self, path: &str) -> String { - format!( - "{}://{}/api/v1/repos/{}/{}{}", - self.scheme, self.host, self.owner, self.repo, path, - ) - } - - #[tracing::instrument(skip(self))] - async fn api_get(&self, path: &str) -> Result { - let url = self.api_url(path); - let mut req = self - .http_client - .get(&url) - .header(USER_AGENT, "mozart/0.1") - .header(ACCEPT, "application/json"); - if let Some(token) = &self.config.forgejo_token { - req = req.header(AUTHORIZATION, format!("token {token}")); - } - let response = req.send().await?; - tracing::debug!(status = %response.status(), %url, "Forgejo API response"); - if !response.status().is_success() { - bail!( - "Forgejo API request to {} failed: {}", - url, - response.status() - ); - } - Ok(response.json().await?) - } - - #[tracing::instrument(skip(self))] - async fn api_get_paginated(&self, path: &str) -> Result> { - let mut items = Vec::new(); - let mut page = 1; - loop { - let sep = if path.contains('?') { "&" } else { "?" }; - let paged_path = format!("{path}{sep}limit=50&page={page}"); - let data = self.api_get(&paged_path).await?; - let batch: Vec = match data { - serde_json::Value::Array(arr) => arr, - _ => break, - }; - if batch.is_empty() { - break; - } - items.extend(batch); - page += 1; - if page > 20 { - break; - } - } - Ok(items) - } - - async fn use_git_fallback(&mut self) -> Result<&mut GitDriver> { - if self.git_driver.is_none() { - let git_url = format!( - "{}://{}/{}/{}.git", - self.scheme, self.host, self.owner, self.repo - ); - let mut driver = GitDriver::new(&git_url, self.config.clone()); - driver.initialize().await?; - self.git_driver = Some(Box::new(driver)); - } - Ok(self.git_driver.as_mut().unwrap()) - } -} - -impl VcsDriver for ForgejoDriver { - async fn initialize(&mut self) -> Result<()> { - match self.api_get("").await { - Ok(data) => { - let default_branch = data["default_branch"] - .as_str() - .unwrap_or("main") - .to_string(); - self.root_identifier = Some(default_branch); - } - Err(_) => { - self.api_failed = true; - let driver = self.use_git_fallback().await?; - self.root_identifier = Some(driver.root_identifier().to_string()); - } - } - Ok(()) - } - - fn root_identifier(&self) -> &str { - self.root_identifier.as_deref().unwrap_or("main") - } - - async fn branches(&mut self) -> Result<&BTreeMap> { - if self.branches.is_none() { - if self.api_failed { - let driver = self.use_git_fallback().await?; - let branches = driver.branches().await?.clone(); - self.branches = Some(branches); - } else { - let items = self.api_get_paginated("/branches").await?; - let mut branches = BTreeMap::new(); - for item in items { - if let (Some(name), Some(sha)) = - (item["name"].as_str(), item["commit"]["id"].as_str()) - { - branches.insert(name.to_string(), sha.to_string()); - } - } - self.branches = Some(branches); - } - } - Ok(self.branches.as_ref().unwrap()) - } - - async fn tags(&mut self) -> Result<&BTreeMap> { - if self.tags.is_none() { - if self.api_failed { - let driver = self.use_git_fallback().await?; - let tags = driver.tags().await?.clone(); - self.tags = Some(tags); - } else { - let items = self.api_get_paginated("/tags").await?; - let mut tags = BTreeMap::new(); - for item in items { - if let (Some(name), Some(sha)) = ( - item["name"].as_str(), - item["id"].as_str().or(item["commit"]["sha"].as_str()), - ) { - tags.insert(name.to_string(), sha.to_string()); - } - } - self.tags = Some(tags); - } - } - Ok(self.tags.as_ref().unwrap()) - } - - async fn composer_information( - &mut self, - identifier: &str, - ) -> Result> { - if let Some(cached) = self.info_cache.get(identifier) { - return Ok(cached.clone()); - } - let content = self.file_content("composer.json", identifier).await?; - let value = content.and_then(|c| serde_json::from_str(&c).ok()); - self.info_cache - .insert(identifier.to_string(), value.clone()); - Ok(value) - } - - async fn file_content(&self, file: &str, identifier: &str) -> Result> { - if self.api_failed { - return Ok(None); - } - let path = format!("/contents/{}?ref={}", file, identifier); - match self.api_get(&path).await { - Ok(data) => { - if let Some(content) = data["content"].as_str() { - // Forgejo returns base64-encoded content - let decoded = super::github::base64_decode_content(content)?; - Ok(Some(decoded)) - } else { - Ok(None) - } - } - Err(_) => Ok(None), - } - } - - async fn change_date(&self, identifier: &str) -> Result> { - if self.api_failed { - return Ok(None); - } - match self.api_get(&format!("/git/commits/{identifier}")).await { - Ok(data) => Ok(data["created"].as_str().map(|s| s.to_string())), - Err(_) => Ok(None), - } - } - - async fn dist(&self, identifier: &str) -> Result> { - Ok(Some(DistReference { - dist_type: "zip".to_string(), - url: format!( - "{}://{}/{}/{}/archive/{}.zip", - self.scheme, self.host, self.owner, self.repo, identifier, - ), - reference: identifier.to_string(), - shasum: None, - })) - } - - fn source(&self, identifier: &str) -> SourceReference { - SourceReference { - source_type: "git".to_string(), - url: format!( - "{}://{}/{}/{}.git", - self.scheme, self.host, self.owner, self.repo - ), - reference: identifier.to_string(), - } - } - - fn url(&self) -> &str { - &self.url - } - - async fn cleanup(&mut self) -> Result<()> { - if let Some(driver) = &mut self.git_driver { - driver.cleanup().await?; - } - Ok(()) - } -} diff --git a/crates/mozart-vcs/src/driver/git.rs b/crates/mozart-vcs/src/driver/git.rs deleted file mode 100644 index 090a5fa..0000000 --- a/crates/mozart-vcs/src/driver/git.rs +++ /dev/null @@ -1,278 +0,0 @@ -use indexmap::IndexMap; -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; - -use anyhow::Result; - -use crate::process::ProcessExecutor; -use crate::util::git::GitUtil; - -use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; - -/// Git VCS driver. -/// -/// Corresponds to Composer's `Repository\Vcs\GitDriver`. -pub struct GitDriver { - url: String, - repo_dir: Option, - root_identifier: Option, - tags: Option>, - branches: Option>, - info_cache: IndexMap>, - git_util: GitUtil, - is_local: bool, -} - -impl GitDriver { - pub fn new(url: &str, config: DriverConfig) -> Self { - let is_local = Self::is_local_path(url); - let process = ProcessExecutor::new(); - let git_util = GitUtil::new(process, config.cache_vcs_dir.clone()); - Self { - url: url.to_string(), - repo_dir: if is_local { - Some(PathBuf::from(url)) - } else { - None - }, - root_identifier: None, - tags: None, - branches: None, - info_cache: IndexMap::new(), - git_util, - is_local, - } - } - - /// Check if a URL is supported by the Git driver. - pub fn supports(url: &str) -> bool { - if Self::is_local_path(url) { - return Path::new(url).join(".git").is_dir() || url.ends_with(".git"); - } - url.starts_with("git://") - || url.starts_with("git@") - || url.ends_with(".git") - || url.contains("git.") - } - - fn is_local_path(url: &str) -> bool { - !url.contains("://") && !url.starts_with("git@") && Path::new(url).exists() - } - - fn get_repo_dir(&self) -> Result<&Path> { - self.repo_dir - .as_deref() - .ok_or_else(|| anyhow::anyhow!("GitDriver not initialized")) - } - - fn parse_branches(output: &str) -> BTreeMap { - let mut branches = BTreeMap::new(); - for line in output.lines() { - let line = line.trim(); - if line.is_empty() || line.contains("HEAD detached") || line.contains("->") { - continue; - } - // Remove leading "* " for current branch - let line = line.strip_prefix("* ").unwrap_or(line); - // Format: "branch_name commit_hash ..." - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() >= 2 { - branches.insert(parts[0].to_string(), parts[1].to_string()); - } - } - branches - } - - fn parse_tags(output: &str) -> BTreeMap { - let mut tags = BTreeMap::new(); - // First pass: collect dereferenced tags (^{}) - let mut dereferenced = IndexMap::new(); - for line in output.lines() { - let line = line.trim(); - if line.is_empty() { - continue; - } - // Format: "commit_hash refs/tags/tag_name" or "commit_hash refs/tags/tag_name^{}" - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() >= 2 { - let hash = parts[0]; - let refname = parts[1]; - if let Some(tag_name) = refname.strip_prefix("refs/tags/") - && let Some(tag_name) = tag_name.strip_suffix("^{}") - { - // Dereferenced tag - this is the actual commit - dereferenced.insert(tag_name.to_string(), hash.to_string()); - } - } - } - // Second pass: collect all tags, preferring dereferenced values - for line in output.lines() { - let line = line.trim(); - if line.is_empty() { - continue; - } - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() >= 2 { - let hash = parts[0]; - let refname = parts[1]; - if let Some(tag_name) = refname.strip_prefix("refs/tags/") { - if tag_name.ends_with("^{}") { - continue; // Skip dereferenced entries themselves - } - let resolved = dereferenced - .get(tag_name) - .cloned() - .unwrap_or_else(|| hash.to_string()); - tags.insert(tag_name.to_string(), resolved); - } - } - } - tags - } -} - -impl VcsDriver for GitDriver { - async fn initialize(&mut self) -> Result<()> { - if self.is_local { - // Local repo: use directly (or its .git subdir) - let path = Path::new(&self.url); - if path.join(".git").is_dir() { - self.repo_dir = Some(path.join(".git")); - } else { - self.repo_dir = Some(path.to_path_buf()); - } - } else { - // Remote repo: sync mirror - let mirror_dir = self.git_util.sync_mirror(&self.url)?; - self.repo_dir = Some(mirror_dir); - } - - // Determine root identifier (default branch) - let repo_dir = self.repo_dir.clone().unwrap(); - if let Ok(Some(branch)) = self.git_util.get_default_branch(&repo_dir) { - self.root_identifier = Some(branch); - } else { - // Fallback: try common branch names - let process = ProcessExecutor::new(); - for name in &["main", "master"] { - let output = - process.execute(&["git", "rev-parse", "--verify", name], Some(&repo_dir))?; - if output.status == 0 { - self.root_identifier = Some(name.to_string()); - break; - } - } - } - - if self.root_identifier.is_none() { - self.root_identifier = Some("master".to_string()); - } - - Ok(()) - } - - fn root_identifier(&self) -> &str { - self.root_identifier.as_deref().unwrap_or("master") - } - - async fn branches(&mut self) -> Result<&BTreeMap> { - if self.branches.is_none() { - let repo_dir = self.get_repo_dir()?.to_path_buf(); - let process = ProcessExecutor::new(); - let output = process.execute_checked( - &["git", "branch", "--no-color", "--no-abbrev", "-v"], - Some(&repo_dir), - )?; - self.branches = Some(Self::parse_branches(&output.stdout)); - } - Ok(self.branches.as_ref().unwrap()) - } - - async fn tags(&mut self) -> Result<&BTreeMap> { - if self.tags.is_none() { - let repo_dir = self.get_repo_dir()?.to_path_buf(); - let process = ProcessExecutor::new(); - let output = process.execute( - &["git", "show-ref", "--tags", "--dereference"], - Some(&repo_dir), - )?; - self.tags = Some(if output.status == 0 { - Self::parse_tags(&output.stdout) - } else { - BTreeMap::new() - }); - } - Ok(self.tags.as_ref().unwrap()) - } - - async fn composer_information( - &mut self, - identifier: &str, - ) -> Result> { - if let Some(cached) = self.info_cache.get(identifier) { - return Ok(cached.clone()); - } - - let content = self.file_content("composer.json", identifier).await?; - let value = match content { - Some(c) => serde_json::from_str(&c).ok(), - None => None, - }; - - self.info_cache - .insert(identifier.to_string(), value.clone()); - Ok(value) - } - - async fn file_content(&self, file: &str, identifier: &str) -> Result> { - let repo_dir = self.get_repo_dir()?; - let process = ProcessExecutor::new(); - let resource = format!("{identifier}:{file}"); - let output = process.execute(&["git", "show", &resource], Some(repo_dir))?; - if output.status == 0 { - Ok(Some(output.stdout)) - } else { - Ok(None) - } - } - - async fn change_date(&self, identifier: &str) -> Result> { - let repo_dir = self.get_repo_dir()?; - let process = ProcessExecutor::new(); - let output = process.execute( - &["git", "log", "-1", "--format=%aI", identifier], - Some(repo_dir), - )?; - if output.status == 0 { - let date = output.stdout.trim().to_string(); - if date.is_empty() { - Ok(None) - } else { - Ok(Some(date)) - } - } else { - Ok(None) - } - } - - async fn dist(&self, _identifier: &str) -> Result> { - // Plain git repos don't provide dist archives - Ok(None) - } - - fn source(&self, identifier: &str) -> SourceReference { - SourceReference { - source_type: "git".to_string(), - url: self.url.clone(), - reference: identifier.to_string(), - } - } - - fn url(&self) -> &str { - &self.url - } - - async fn cleanup(&mut self) -> Result<()> { - Ok(()) - } -} diff --git a/crates/mozart-vcs/src/driver/github.rs b/crates/mozart-vcs/src/driver/github.rs deleted file mode 100644 index e968c3e..0000000 --- a/crates/mozart-vcs/src/driver/github.rs +++ /dev/null @@ -1,315 +0,0 @@ -use indexmap::IndexMap; -use std::collections::BTreeMap; - -use anyhow::{Result, bail}; -use regex::Regex; -use reqwest::Client; -use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT}; - -use super::git::GitDriver; -use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; - -/// GitHub VCS driver using the REST API v3. -/// -/// Falls back to `GitDriver` when API access fails. -pub struct GitHubDriver { - owner: String, - repo: String, - url: String, - root_identifier: Option, - tags: Option>, - branches: Option>, - repo_data: Option, - info_cache: IndexMap>, - git_driver: Option>, - http_client: Client, - config: DriverConfig, - api_failed: bool, -} - -impl GitHubDriver { - pub fn new(url: &str, config: DriverConfig) -> Self { - let (owner, repo) = Self::parse_url(url).unwrap_or_default(); - Self { - owner, - repo, - url: url.to_string(), - root_identifier: None, - tags: None, - branches: None, - repo_data: None, - info_cache: IndexMap::new(), - git_driver: None, - http_client: mozart_core::http::default_client(), - config, - api_failed: false, - } - } - - /// Check if a URL points to GitHub. - pub fn supports(url: &str) -> bool { - let url_lower = url.to_lowercase(); - url_lower.contains("github.com") - && (url_lower.contains("github.com/") || url_lower.contains("github.com:")) - } - - fn parse_url(url: &str) -> Option<(String, String)> { - let re = Regex::new(r"github\.com[:/]([^/]+)/([^/.\s]+?)(?:\.git)?(?:[/#?].*)?$").ok()?; - let caps = re.captures(url)?; - Some((caps[1].to_string(), caps[2].to_string())) - } - - fn api_url(&self, path: &str) -> String { - format!( - "https://api.github.com/repos/{}/{}{}", - self.owner, self.repo, path - ) - } - - #[tracing::instrument(skip(self))] - async fn api_get(&self, path: &str) -> Result { - let url = self.api_url(path); - let mut req = self - .http_client - .get(&url) - .header(USER_AGENT, "mozart/0.1") - .header(ACCEPT, "application/vnd.github.v3+json"); - - if let Some(token) = &self.config.github_token { - req = req.header(AUTHORIZATION, format!("token {token}")); - } - - let response = req.send().await?; - tracing::debug!(status = %response.status(), %url, "GitHub API response"); - if !response.status().is_success() { - bail!( - "GitHub API request to {} failed with status {}", - url, - response.status() - ); - } - Ok(response.json().await?) - } - - #[tracing::instrument(skip(self))] - async fn api_get_paginated(&self, path: &str) -> Result> { - let mut items = Vec::new(); - let mut page = 1; - loop { - let separator = if path.contains('?') { "&" } else { "?" }; - let url = format!( - "https://api.github.com/repos/{}/{}{}{}per_page=100&page={}", - self.owner, self.repo, path, separator, page, - ); - let mut req = self - .http_client - .get(&url) - .header(USER_AGENT, "mozart/0.1") - .header(ACCEPT, "application/vnd.github.v3+json"); - if let Some(token) = &self.config.github_token { - req = req.header(AUTHORIZATION, format!("token {token}")); - } - - let response = req.send().await?; - tracing::debug!(status = %response.status(), %url, "GitHub API paginated response"); - if !response.status().is_success() { - bail!("GitHub API paginated request failed: {}", response.status()); - } - - let batch: Vec = response.json().await?; - if batch.is_empty() { - break; - } - items.extend(batch); - page += 1; - // Safety: limit to 10 pages (1000 items) - if page > 10 { - break; - } - } - Ok(items) - } - - async fn use_git_fallback(&mut self) -> Result<&mut GitDriver> { - if self.git_driver.is_none() { - let git_url = format!("https://github.com/{}/{}.git", self.owner, self.repo); - let mut driver = GitDriver::new(&git_url, self.config.clone()); - driver.initialize().await?; - self.git_driver = Some(Box::new(driver)); - } - Ok(self.git_driver.as_mut().unwrap()) - } -} - -impl VcsDriver for GitHubDriver { - async fn initialize(&mut self) -> Result<()> { - // Try to fetch repo data from API - match self.api_get("").await { - Ok(data) => { - let default_branch = data["default_branch"] - .as_str() - .unwrap_or("main") - .to_string(); - self.root_identifier = Some(default_branch); - self.repo_data = Some(data); - } - Err(_) => { - self.api_failed = true; - let driver = self.use_git_fallback().await?; - self.root_identifier = Some(driver.root_identifier().to_string()); - } - } - Ok(()) - } - - fn root_identifier(&self) -> &str { - self.root_identifier.as_deref().unwrap_or("main") - } - - async fn branches(&mut self) -> Result<&BTreeMap> { - if self.branches.is_none() { - if self.api_failed { - let driver = self.use_git_fallback().await?; - let branches = driver.branches().await?.clone(); - self.branches = Some(branches); - } else { - let items = self.api_get_paginated("/branches").await?; - let mut branches = BTreeMap::new(); - for item in items { - if let (Some(name), Some(sha)) = - (item["name"].as_str(), item["commit"]["sha"].as_str()) - { - branches.insert(name.to_string(), sha.to_string()); - } - } - self.branches = Some(branches); - } - } - Ok(self.branches.as_ref().unwrap()) - } - - async fn tags(&mut self) -> Result<&BTreeMap> { - if self.tags.is_none() { - if self.api_failed { - let driver = self.use_git_fallback().await?; - let tags = driver.tags().await?.clone(); - self.tags = Some(tags); - } else { - let items = self.api_get_paginated("/tags").await?; - let mut tags = BTreeMap::new(); - for item in items { - if let (Some(name), Some(sha)) = - (item["name"].as_str(), item["commit"]["sha"].as_str()) - { - tags.insert(name.to_string(), sha.to_string()); - } - } - self.tags = Some(tags); - } - } - Ok(self.tags.as_ref().unwrap()) - } - - async fn composer_information( - &mut self, - identifier: &str, - ) -> Result> { - if let Some(cached) = self.info_cache.get(identifier) { - return Ok(cached.clone()); - } - - let content = self.file_content("composer.json", identifier).await?; - let value = match content { - Some(c) => serde_json::from_str(&c).ok(), - None => None, - }; - - self.info_cache - .insert(identifier.to_string(), value.clone()); - Ok(value) - } - - async fn file_content(&self, file: &str, identifier: &str) -> Result> { - if self.api_failed { - // Can't use API, would need git fallback - // For simplicity, return None (git_driver is mutable) - return Ok(None); - } - - let path = format!("/contents/{}?ref={}", file, identifier); - match self.api_get(&path).await { - Ok(data) => { - if let Some(content) = data["content"].as_str() { - // GitHub returns base64-encoded content - let decoded = base64_decode_content(content)?; - Ok(Some(decoded)) - } else { - Ok(None) - } - } - Err(_) => Ok(None), - } - } - - async fn change_date(&self, identifier: &str) -> Result> { - if self.api_failed { - return Ok(None); - } - - let path = format!("/commits/{}", identifier); - match self.api_get(&path).await { - Ok(data) => { - let date = data["commit"]["committer"]["date"] - .as_str() - .map(|s| s.to_string()); - Ok(date) - } - Err(_) => Ok(None), - } - } - - async fn dist(&self, identifier: &str) -> Result> { - Ok(Some(DistReference { - dist_type: "zip".to_string(), - url: format!( - "https://api.github.com/repos/{}/{}/zipball/{}", - self.owner, self.repo, identifier, - ), - reference: identifier.to_string(), - shasum: None, - })) - } - - fn source(&self, identifier: &str) -> SourceReference { - SourceReference { - source_type: "git".to_string(), - url: format!("https://github.com/{}/{}.git", self.owner, self.repo), - reference: identifier.to_string(), - } - } - - fn url(&self) -> &str { - &self.url - } - - async fn cleanup(&mut self) -> Result<()> { - if let Some(driver) = &mut self.git_driver { - driver.cleanup().await?; - } - Ok(()) - } -} - -/// Decode base64-encoded content from API responses. -/// Also used by Forgejo driver as `base64_decode_content`. -pub fn base64_decode_content(input: &str) -> Result { - use base64::Engine; - let cleaned: Vec = input - .bytes() - .filter(|&b| b != b'\n' && b != b'\r') - .collect(); - let decoded = base64::engine::general_purpose::STANDARD - .decode(&cleaned) - .map_err(|e| anyhow::anyhow!("Base64 decode error: {e}"))?; - String::from_utf8(decoded).map_err(|e| anyhow::anyhow!("Invalid UTF-8 in base64 content: {e}")) -} diff --git a/crates/mozart-vcs/src/driver/gitlab.rs b/crates/mozart-vcs/src/driver/gitlab.rs deleted file mode 100644 index 937251a..0000000 --- a/crates/mozart-vcs/src/driver/gitlab.rs +++ /dev/null @@ -1,301 +0,0 @@ -use indexmap::IndexMap; -use std::collections::BTreeMap; - -use anyhow::{Result, bail}; -use regex::Regex; -use reqwest::Client; -use reqwest::header::{ACCEPT, USER_AGENT}; - -use super::git::GitDriver; -use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; - -/// GitLab VCS driver using the REST API v4. -/// -/// Supports self-hosted GitLab instances. -pub struct GitLabDriver { - owner: String, - repo: String, - host: String, - scheme: String, - url: String, - project_id: Option, - root_identifier: Option, - tags: Option>, - branches: Option>, - info_cache: IndexMap>, - git_driver: Option>, - http_client: Client, - config: DriverConfig, - api_failed: bool, -} - -impl GitLabDriver { - pub fn new(url: &str, config: DriverConfig) -> Self { - let (host, scheme, owner, repo) = Self::parse_url(url).unwrap_or_default(); - Self { - owner, - repo, - host, - scheme, - url: url.to_string(), - project_id: None, - root_identifier: None, - tags: None, - branches: None, - info_cache: IndexMap::new(), - git_driver: None, - http_client: mozart_core::http::default_client(), - config, - api_failed: false, - } - } - - pub fn supports(url: &str, gitlab_domains: &[String]) -> bool { - let url_lower = url.to_lowercase(); - for domain in gitlab_domains { - if url_lower.contains(domain) { - return true; - } - } - false - } - - fn parse_url(url: &str) -> Option<(String, String, String, String)> { - let re = Regex::new(r"(?i)(https?)://([^/]+)/([^/]+)/([^/.\s]+?)(?:\.git)?(?:[/#?].*)?$") - .ok()?; - let caps = re.captures(url)?; - Some(( - caps[2].to_string(), - caps[1].to_string(), - caps[3].to_string(), - caps[4].to_string(), - )) - } - - fn api_url(&self, path: &str) -> String { - let project_path = format!("{}%2F{}", self.owner, self.repo); - let id = self.project_id.as_deref().unwrap_or(&project_path); - format!( - "{}://{}/api/v4/projects/{}{}", - self.scheme, self.host, id, path - ) - } - - #[tracing::instrument(skip(self))] - async fn api_get(&self, path: &str) -> Result { - let url = self.api_url(path); - let mut req = self - .http_client - .get(&url) - .header(USER_AGENT, "mozart/0.1") - .header(ACCEPT, "application/json"); - - if let Some(token) = &self.config.gitlab_token { - req = req.header("PRIVATE-TOKEN", token.as_str()); - } - - let response = req.send().await?; - tracing::debug!(status = %response.status(), %url, "GitLab API response"); - if !response.status().is_success() { - bail!( - "GitLab API request to {} failed with status {}", - url, - response.status() - ); - } - Ok(response.json().await?) - } - - #[tracing::instrument(skip(self))] - async fn api_get_paginated(&self, path: &str) -> Result> { - let mut items = Vec::new(); - let mut page = 1; - loop { - let sep = if path.contains('?') { "&" } else { "?" }; - let paged_path = format!("{path}{sep}per_page=100&page={page}"); - let data = self.api_get(&paged_path).await?; - let batch: Vec = match data { - serde_json::Value::Array(arr) => arr, - _ => break, - }; - if batch.is_empty() { - break; - } - items.extend(batch); - page += 1; - if page > 10 { - break; - } - } - Ok(items) - } - - async fn use_git_fallback(&mut self) -> Result<&mut GitDriver> { - if self.git_driver.is_none() { - let git_url = format!( - "{}://{}/{}/{}.git", - self.scheme, self.host, self.owner, self.repo - ); - let mut driver = GitDriver::new(&git_url, self.config.clone()); - driver.initialize().await?; - self.git_driver = Some(Box::new(driver)); - } - Ok(self.git_driver.as_mut().unwrap()) - } -} - -impl VcsDriver for GitLabDriver { - async fn initialize(&mut self) -> Result<()> { - match self.api_get("").await { - Ok(data) => { - if let Some(id) = data["id"].as_u64() { - self.project_id = Some(id.to_string()); - } - let default_branch = data["default_branch"] - .as_str() - .unwrap_or("main") - .to_string(); - self.root_identifier = Some(default_branch); - } - Err(_) => { - self.api_failed = true; - let driver = self.use_git_fallback().await?; - self.root_identifier = Some(driver.root_identifier().to_string()); - } - } - Ok(()) - } - - fn root_identifier(&self) -> &str { - self.root_identifier.as_deref().unwrap_or("main") - } - - async fn branches(&mut self) -> Result<&BTreeMap> { - if self.branches.is_none() { - if self.api_failed { - let driver = self.use_git_fallback().await?; - let branches = driver.branches().await?.clone(); - self.branches = Some(branches); - } else { - let items = self.api_get_paginated("/repository/branches").await?; - let mut branches = BTreeMap::new(); - for item in items { - if let (Some(name), Some(sha)) = - (item["name"].as_str(), item["commit"]["id"].as_str()) - { - branches.insert(name.to_string(), sha.to_string()); - } - } - self.branches = Some(branches); - } - } - Ok(self.branches.as_ref().unwrap()) - } - - async fn tags(&mut self) -> Result<&BTreeMap> { - if self.tags.is_none() { - if self.api_failed { - let driver = self.use_git_fallback().await?; - let tags = driver.tags().await?.clone(); - self.tags = Some(tags); - } else { - let items = self.api_get_paginated("/repository/tags").await?; - let mut tags = BTreeMap::new(); - for item in items { - if let (Some(name), Some(sha)) = - (item["name"].as_str(), item["commit"]["id"].as_str()) - { - tags.insert(name.to_string(), sha.to_string()); - } - } - self.tags = Some(tags); - } - } - Ok(self.tags.as_ref().unwrap()) - } - - async fn composer_information( - &mut self, - identifier: &str, - ) -> Result> { - if let Some(cached) = self.info_cache.get(identifier) { - return Ok(cached.clone()); - } - let content = self.file_content("composer.json", identifier).await?; - let value = content.and_then(|c| serde_json::from_str(&c).ok()); - self.info_cache - .insert(identifier.to_string(), value.clone()); - Ok(value) - } - - async fn file_content(&self, file: &str, identifier: &str) -> Result> { - if self.api_failed { - return Ok(None); - } - let encoded_file = file.replace('/', "%2F"); - let path = format!("/repository/files/{}/raw?ref={}", encoded_file, identifier); - let url = self.api_url(&path); - let mut req = self.http_client.get(&url).header(USER_AGENT, "mozart/0.1"); - if let Some(token) = &self.config.gitlab_token { - req = req.header("PRIVATE-TOKEN", token.as_str()); - } - let response = req.send().await?; - if response.status().is_success() { - Ok(Some(response.text().await?)) - } else { - Ok(None) - } - } - - async fn change_date(&self, identifier: &str) -> Result> { - if self.api_failed { - return Ok(None); - } - match self - .api_get(&format!("/repository/commits/{identifier}")) - .await - { - Ok(data) => Ok(data["committed_date"].as_str().map(|s| s.to_string())), - Err(_) => Ok(None), - } - } - - async fn dist(&self, identifier: &str) -> Result> { - Ok(Some(DistReference { - dist_type: "zip".to_string(), - url: format!( - "{}://{}/api/v4/projects/{}/repository/archive.zip?sha={}", - self.scheme, - self.host, - self.project_id - .as_deref() - .unwrap_or(&format!("{}%2F{}", self.owner, self.repo)), - identifier, - ), - reference: identifier.to_string(), - shasum: None, - })) - } - - fn source(&self, identifier: &str) -> SourceReference { - SourceReference { - source_type: "git".to_string(), - url: format!( - "{}://{}/{}/{}.git", - self.scheme, self.host, self.owner, self.repo - ), - reference: identifier.to_string(), - } - } - - fn url(&self) -> &str { - &self.url - } - - async fn cleanup(&mut self) -> Result<()> { - if let Some(driver) = &mut self.git_driver { - driver.cleanup().await?; - } - Ok(()) - } -} diff --git a/crates/mozart-vcs/src/driver/hg.rs b/crates/mozart-vcs/src/driver/hg.rs deleted file mode 100644 index f476e6a..0000000 --- a/crates/mozart-vcs/src/driver/hg.rs +++ /dev/null @@ -1,205 +0,0 @@ -use indexmap::IndexMap; -use std::collections::BTreeMap; -use std::path::PathBuf; - -use anyhow::Result; - -use crate::process::ProcessExecutor; -use crate::util::hg::HgUtil; - -use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; - -/// Mercurial VCS driver. -/// -/// Corresponds to Composer's `Repository\Vcs\HgDriver`. -pub struct HgDriver { - url: String, - repo_dir: Option, - root_identifier: Option, - tags: Option>, - branches: Option>, - info_cache: IndexMap>, - hg_util: HgUtil, - config: DriverConfig, -} - -impl HgDriver { - pub fn new(url: &str, config: DriverConfig) -> Self { - let process = ProcessExecutor::new(); - Self { - url: url.to_string(), - repo_dir: None, - root_identifier: None, - tags: None, - branches: None, - info_cache: IndexMap::new(), - hg_util: HgUtil::new(process), - config, - } - } - - pub fn supports(url: &str) -> bool { - url.starts_with("hg://") || url.contains("hg.") || url.ends_with(".hg") - } - - fn get_repo_dir(&self) -> Result<&PathBuf> { - self.repo_dir - .as_ref() - .ok_or_else(|| anyhow::anyhow!("HgDriver not initialized")) - } -} - -impl VcsDriver for HgDriver { - async fn initialize(&mut self) -> Result<()> { - let cache_dir = &self.config.cache_vcs_dir; - std::fs::create_dir_all(cache_dir)?; - let repo_dir = cache_dir.join(crate::util::git::GitUtil::sanitize_url(&self.url)); - - if repo_dir.join(".hg").is_dir() { - // Update existing clone - self.hg_util.execute(&["pull"], Some(&repo_dir))?; - } else { - // Clone without checkout - let dir_str = repo_dir.to_string_lossy().to_string(); - self.hg_util - .execute(&["clone", "--noupdate", &self.url, &dir_str], None)?; - } - - self.repo_dir = Some(repo_dir.clone()); - - // Get default branch - let output = self.hg_util.execute( - &["log", "-r", "default", "--template", "{node|short}"], - Some(&repo_dir), - ); - self.root_identifier = match output { - Ok(o) if !o.stdout.trim().is_empty() => Some("default".to_string()), - _ => Some("tip".to_string()), - }; - - Ok(()) - } - - fn root_identifier(&self) -> &str { - self.root_identifier.as_deref().unwrap_or("default") - } - - async fn branches(&mut self) -> Result<&BTreeMap> { - if self.branches.is_none() { - let repo_dir = self.get_repo_dir()?.clone(); - let mut branches = BTreeMap::new(); - - // Named branches - let output = self.hg_util.execute(&["branches", "-q"], Some(&repo_dir))?; - for name in ProcessExecutor::split_lines(&output.stdout) { - let name = name.trim(); - let rev_output = self.hg_util.execute( - &["log", "-r", name, "--template", "{node}"], - Some(&repo_dir), - )?; - branches.insert(name.to_string(), rev_output.stdout.trim().to_string()); - } - - // Bookmarks - let output = self - .hg_util - .execute_unchecked(&["bookmarks", "-q"], Some(&repo_dir))?; - if output.status == 0 { - for name in ProcessExecutor::split_lines(&output.stdout) { - let name = name.trim(); - if !branches.contains_key(name) { - let rev_output = self.hg_util.execute( - &["log", "-r", name, "--template", "{node}"], - Some(&repo_dir), - )?; - branches.insert(name.to_string(), rev_output.stdout.trim().to_string()); - } - } - } - - self.branches = Some(branches); - } - Ok(self.branches.as_ref().unwrap()) - } - - async fn tags(&mut self) -> Result<&BTreeMap> { - if self.tags.is_none() { - let repo_dir = self.get_repo_dir()?.clone(); - let output = self.hg_util.execute(&["tags", "-q"], Some(&repo_dir))?; - let mut tags = BTreeMap::new(); - for name in ProcessExecutor::split_lines(&output.stdout) { - let name = name.trim(); - if name == "tip" { - continue; // Skip the "tip" pseudo-tag - } - let rev_output = self.hg_util.execute( - &["log", "-r", name, "--template", "{node}"], - Some(&repo_dir), - )?; - tags.insert(name.to_string(), rev_output.stdout.trim().to_string()); - } - self.tags = Some(tags); - } - Ok(self.tags.as_ref().unwrap()) - } - - async fn composer_information( - &mut self, - identifier: &str, - ) -> Result> { - if let Some(cached) = self.info_cache.get(identifier) { - return Ok(cached.clone()); - } - let content = self.file_content("composer.json", identifier).await?; - let value = content.and_then(|c| serde_json::from_str(&c).ok()); - self.info_cache - .insert(identifier.to_string(), value.clone()); - Ok(value) - } - - async fn file_content(&self, file: &str, identifier: &str) -> Result> { - let repo_dir = self.get_repo_dir()?; - let output = self - .hg_util - .execute_unchecked(&["cat", "-r", identifier, "--", file], Some(repo_dir))?; - if output.status == 0 { - Ok(Some(output.stdout)) - } else { - Ok(None) - } - } - - async fn change_date(&self, identifier: &str) -> Result> { - let repo_dir = self.get_repo_dir()?; - let output = self.hg_util.execute( - &["log", "-r", identifier, "--template", "{date|isodatesec}"], - Some(repo_dir), - )?; - let date = output.stdout.trim().to_string(); - if date.is_empty() { - Ok(None) - } else { - Ok(Some(date)) - } - } - - async fn dist(&self, _identifier: &str) -> Result> { - Ok(None) - } - - fn source(&self, identifier: &str) -> SourceReference { - SourceReference { - source_type: "hg".to_string(), - url: self.url.clone(), - reference: identifier.to_string(), - } - } - - fn url(&self) -> &str { - &self.url - } - - async fn cleanup(&mut self) -> Result<()> { - Ok(()) - } -} diff --git a/crates/mozart-vcs/src/driver/mod.rs b/crates/mozart-vcs/src/driver/mod.rs deleted file mode 100644 index cfaf11e..0000000 --- a/crates/mozart-vcs/src/driver/mod.rs +++ /dev/null @@ -1,309 +0,0 @@ -pub mod bitbucket; -pub mod forgejo; -pub mod git; -pub mod github; -pub mod gitlab; -pub mod hg; -pub mod svn; - -use std::collections::BTreeMap; -use std::path::PathBuf; - -use anyhow::Result; -use serde::{Deserialize, Serialize}; - -/// Reference to a source distribution. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SourceReference { - #[serde(rename = "type")] - pub source_type: String, - pub url: String, - pub reference: String, -} - -/// Reference to a dist (archive) distribution. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DistReference { - #[serde(rename = "type")] - pub dist_type: String, - pub url: String, - pub reference: String, - pub shasum: Option, -} - -/// Configuration passed to VCS drivers. -#[derive(Debug, Clone)] -pub struct DriverConfig { - /// Composer's `cache-vcs-dir`: root for VCS mirrors, one - /// subdirectory per sanitized repository URL. - pub cache_vcs_dir: PathBuf, - /// GitHub OAuth token (from `GITHUB_TOKEN` or config). - pub github_token: Option, - /// GitLab OAuth token. - pub gitlab_token: Option, - /// Bitbucket OAuth consumer key/secret. - pub bitbucket_oauth: Option<(String, String)>, - /// Forgejo token. - pub forgejo_token: Option, - /// Custom GitLab domains (for self-hosted). - pub gitlab_domains: Vec, - /// Custom Forgejo domains (for self-hosted). - pub forgejo_domains: Vec, -} - -impl Default for DriverConfig { - fn default() -> Self { - Self { - cache_vcs_dir: default_cache_vcs_dir(), - github_token: None, - gitlab_token: None, - bitbucket_oauth: None, - forgejo_token: None, - gitlab_domains: vec!["gitlab.com".to_string()], - forgejo_domains: vec!["codeberg.org".to_string()], - } - } -} - -/// Resolve the default `cache-vcs-dir`, honoring Composer's env vars. -/// -/// Priority: `COMPOSER_CACHE_VCS_DIR` → `COMPOSER_CACHE_DIR/vcs` → -/// `XDG_CACHE_HOME/mozart/vcs` → `$HOME/.cache/mozart/vcs`. -fn default_cache_vcs_dir() -> PathBuf { - if let Ok(p) = std::env::var("COMPOSER_CACHE_VCS_DIR") { - return PathBuf::from(p); - } - let base = if let Ok(p) = std::env::var("COMPOSER_CACHE_DIR") { - PathBuf::from(p) - } else if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") { - PathBuf::from(xdg).join("mozart") - } else if let Ok(home) = std::env::var("HOME") { - PathBuf::from(home).join(".cache").join("mozart") - } else { - PathBuf::from("/tmp").join("mozart") - }; - base.join("vcs") -} - -/// Type of VCS driver. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DriverType { - GitHub, - GitLab, - Bitbucket, - Forgejo, - Git, - Svn, - Hg, -} - -/// The VCS driver interface. -/// -/// Corresponds to Composer's `VcsDriverInterface`. -trait VcsDriver { - /// Initialize the driver (e.g., clone mirror, fetch API metadata). - async fn initialize(&mut self) -> Result<()>; - - /// The root identifier (default branch/trunk). - fn root_identifier(&self) -> &str; - - /// All branches as `name -> commit_hash`. - async fn branches(&mut self) -> Result<&BTreeMap>; - - /// All tags as `name -> commit_hash`. - async fn tags(&mut self) -> Result<&BTreeMap>; - - /// Get composer.json content parsed as JSON for a given identifier. - async fn composer_information(&mut self, identifier: &str) - -> Result>; - - /// Get raw file content at a given path and identifier. - async fn file_content(&self, file: &str, identifier: &str) -> Result>; - - /// Get the change date for a given identifier (ISO 8601). - async fn change_date(&self, identifier: &str) -> Result>; - - /// Get the dist reference for a given identifier. - async fn dist(&self, identifier: &str) -> Result>; - - /// Get the source reference for a given identifier. - fn source(&self, identifier: &str) -> SourceReference; - - /// The canonical URL of this repository. - fn url(&self) -> &str; - - /// Clean up resources (temp dirs, etc.). - async fn cleanup(&mut self) -> Result<()>; -} - -/// Enum-dispatched VCS driver. -/// -/// Wraps all concrete driver types to allow static dispatch with async trait methods. -pub enum AnyVcsDriver { - GitHub(github::GitHubDriver), - GitLab(gitlab::GitLabDriver), - Bitbucket(bitbucket::BitbucketDriver), - Forgejo(forgejo::ForgejoDriver), - Git(git::GitDriver), - Svn(svn::SvnDriver), - Hg(hg::HgDriver), -} - -macro_rules! dispatch { - ($self:expr, $method:ident $(, $arg:expr)*) => { - match $self { - AnyVcsDriver::GitHub(d) => d.$method($($arg),*), - AnyVcsDriver::GitLab(d) => d.$method($($arg),*), - AnyVcsDriver::Bitbucket(d) => d.$method($($arg),*), - AnyVcsDriver::Forgejo(d) => d.$method($($arg),*), - AnyVcsDriver::Git(d) => d.$method($($arg),*), - AnyVcsDriver::Svn(d) => d.$method($($arg),*), - AnyVcsDriver::Hg(d) => d.$method($($arg),*), - } - }; -} - -macro_rules! dispatch_async { - ($self:expr, $method:ident $(, $arg:expr)*) => { - match $self { - AnyVcsDriver::GitHub(d) => d.$method($($arg),*).await, - AnyVcsDriver::GitLab(d) => d.$method($($arg),*).await, - AnyVcsDriver::Bitbucket(d) => d.$method($($arg),*).await, - AnyVcsDriver::Forgejo(d) => d.$method($($arg),*).await, - AnyVcsDriver::Git(d) => d.$method($($arg),*).await, - AnyVcsDriver::Svn(d) => d.$method($($arg),*).await, - AnyVcsDriver::Hg(d) => d.$method($($arg),*).await, - } - }; -} - -impl AnyVcsDriver { - pub async fn initialize(&mut self) -> Result<()> { - dispatch_async!(self, initialize) - } - - pub fn root_identifier(&self) -> &str { - dispatch!(self, root_identifier) - } - - pub async fn branches(&mut self) -> Result<&BTreeMap> { - dispatch_async!(self, branches) - } - - pub async fn tags(&mut self) -> Result<&BTreeMap> { - dispatch_async!(self, tags) - } - - pub async fn composer_information( - &mut self, - identifier: &str, - ) -> Result> { - dispatch_async!(self, composer_information, identifier) - } - - pub async fn file_content(&self, file: &str, identifier: &str) -> Result> { - dispatch_async!(self, file_content, file, identifier) - } - - pub async fn change_date(&self, identifier: &str) -> Result> { - dispatch_async!(self, change_date, identifier) - } - - pub async fn dist(&self, identifier: &str) -> Result> { - dispatch_async!(self, dist, identifier) - } - - pub fn source(&self, identifier: &str) -> SourceReference { - dispatch!(self, source, identifier) - } - - pub fn url(&self) -> &str { - dispatch!(self, url) - } - - pub async fn cleanup(&mut self) -> Result<()> { - dispatch_async!(self, cleanup) - } -} - -/// Detect which driver type should handle a given URL. -/// -/// Priority order matches Composer: -/// 1. GitHub → 2. GitLab → 3. Bitbucket → 4. Forgejo → 5. Git → 6. Hg → 7. SVN -pub fn detect_driver( - url: &str, - forced_type: Option<&str>, - config: &DriverConfig, -) -> Option { - if let Some(t) = forced_type { - return match t { - "github" => Some(DriverType::GitHub), - "gitlab" => Some(DriverType::GitLab), - "bitbucket" => Some(DriverType::Bitbucket), - "forgejo" => Some(DriverType::Forgejo), - "git" => Some(DriverType::Git), - "svn" => Some(DriverType::Svn), - "hg" | "mercurial" => Some(DriverType::Hg), - _ => None, - }; - } - - let url_lower = url.to_lowercase(); - - // GitHub - if github::GitHubDriver::supports(url) { - return Some(DriverType::GitHub); - } - - // GitLab - if gitlab::GitLabDriver::supports(url, &config.gitlab_domains) { - return Some(DriverType::GitLab); - } - - // Bitbucket - if bitbucket::BitbucketDriver::supports(url) { - return Some(DriverType::Bitbucket); - } - - // Forgejo - if forgejo::ForgejoDriver::supports(url, &config.forgejo_domains) { - return Some(DriverType::Forgejo); - } - - // Git - if git::GitDriver::supports(url) { - return Some(DriverType::Git); - } - - // Hg - if hg::HgDriver::supports(url) { - return Some(DriverType::Hg); - } - - // SVN - if url_lower.contains("svn") || svn::SvnDriver::supports(url) { - return Some(DriverType::Svn); - } - - // Default to git for generic URLs - if url.starts_with("http://") || url.starts_with("https://") { - return Some(DriverType::Git); - } - - None -} - -/// Create a driver instance for the given URL and type. -pub fn create_driver(url: &str, driver_type: DriverType, config: DriverConfig) -> AnyVcsDriver { - match driver_type { - DriverType::GitHub => AnyVcsDriver::GitHub(github::GitHubDriver::new(url, config)), - DriverType::GitLab => AnyVcsDriver::GitLab(gitlab::GitLabDriver::new(url, config)), - DriverType::Bitbucket => { - AnyVcsDriver::Bitbucket(bitbucket::BitbucketDriver::new(url, config)) - } - DriverType::Forgejo => AnyVcsDriver::Forgejo(forgejo::ForgejoDriver::new(url, config)), - DriverType::Git => AnyVcsDriver::Git(git::GitDriver::new(url, config)), - DriverType::Svn => AnyVcsDriver::Svn(svn::SvnDriver::new(url, config)), - DriverType::Hg => AnyVcsDriver::Hg(hg::HgDriver::new(url, config)), - } -} diff --git a/crates/mozart-vcs/src/driver/svn.rs b/crates/mozart-vcs/src/driver/svn.rs deleted file mode 100644 index 16363e1..0000000 --- a/crates/mozart-vcs/src/driver/svn.rs +++ /dev/null @@ -1,217 +0,0 @@ -use indexmap::IndexMap; -use std::collections::BTreeMap; - -use anyhow::Result; -use regex::Regex; - -use crate::process::ProcessExecutor; -use crate::util::svn::SvnUtil; - -use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; - -/// SVN VCS driver. -/// -/// Corresponds to Composer's `Repository\Vcs\SvnDriver`. -pub struct SvnDriver { - url: String, - base_url: String, - trunk_path: String, - branches_path: String, - tags_path: String, - root_identifier: Option, - tags: Option>, - branches: Option>, - info_cache: IndexMap>, - svn_util: SvnUtil, -} - -impl SvnDriver { - pub fn new(url: &str, _config: DriverConfig) -> Self { - let process = ProcessExecutor::new(); - Self { - url: url.to_string(), - base_url: url.to_string(), - trunk_path: "trunk".to_string(), - branches_path: "branches".to_string(), - tags_path: "tags".to_string(), - root_identifier: None, - tags: None, - branches: None, - info_cache: IndexMap::new(), - svn_util: SvnUtil::new(process), - } - } - - pub fn supports(url: &str) -> bool { - url.starts_with("svn://") || url.starts_with("svn+ssh://") - } - - fn svn_info(&self, url: &str) -> Result { - let output = self.svn_util.execute(&["info", "--xml", url], None)?; - // Parse minimal info from XML output - let stdout = &output.stdout; - let mut info = serde_json::Map::new(); - - if let Some(rev) = extract_xml_attr(stdout, "entry", "revision") { - info.insert("revision".to_string(), serde_json::Value::String(rev)); - } - if let Some(url_val) = extract_xml_content(stdout, "url") { - info.insert("url".to_string(), serde_json::Value::String(url_val)); - } - if let Some(date) = extract_xml_content(stdout, "date") { - info.insert("date".to_string(), serde_json::Value::String(date)); - } - - Ok(serde_json::Value::Object(info)) - } - - fn svn_ls(&self, url: &str) -> Result> { - let output = self.svn_util.execute(&["ls", url], None)?; - Ok(ProcessExecutor::split_lines(&output.stdout) - .into_iter() - .map(|s| s.trim_end_matches('/').to_string()) - .collect()) - } -} - -impl VcsDriver for SvnDriver { - async fn initialize(&mut self) -> Result<()> { - let info = self.svn_info(&self.url)?; - if let Some(url) = info["url"].as_str() { - self.base_url = url.to_string(); - } - self.root_identifier = info["revision"].as_str().map(|s| s.to_string()); - Ok(()) - } - - fn root_identifier(&self) -> &str { - self.root_identifier.as_deref().unwrap_or("HEAD") - } - - async fn branches(&mut self) -> Result<&BTreeMap> { - if self.branches.is_none() { - let mut branches = BTreeMap::new(); - - // Add trunk - let trunk_url = format!("{}/{}", self.base_url, self.trunk_path); - if let Ok(info) = self.svn_info(&trunk_url) - && let Some(rev) = info["revision"].as_str() - { - branches.insert("trunk".to_string(), rev.to_string()); - } - - // List branches directory - let branches_url = format!("{}/{}", self.base_url, self.branches_path); - if let Ok(items) = self.svn_ls(&branches_url) { - for name in items { - let branch_url = format!("{}/{}", branches_url, name); - if let Ok(info) = self.svn_info(&branch_url) - && let Some(rev) = info["revision"].as_str() - { - branches.insert(name, rev.to_string()); - } - } - } - - self.branches = Some(branches); - } - Ok(self.branches.as_ref().unwrap()) - } - - async fn tags(&mut self) -> Result<&BTreeMap> { - if self.tags.is_none() { - let mut tags = BTreeMap::new(); - let tags_url = format!("{}/{}", self.base_url, self.tags_path); - if let Ok(items) = self.svn_ls(&tags_url) { - for name in items { - let tag_url = format!("{}/{}", tags_url, name); - if let Ok(info) = self.svn_info(&tag_url) - && let Some(rev) = info["revision"].as_str() - { - tags.insert(name, rev.to_string()); - } - } - } - self.tags = Some(tags); - } - Ok(self.tags.as_ref().unwrap()) - } - - async fn composer_information( - &mut self, - identifier: &str, - ) -> Result> { - if let Some(cached) = self.info_cache.get(identifier) { - return Ok(cached.clone()); - } - let content = self.file_content("composer.json", identifier).await?; - let value = content.and_then(|c| serde_json::from_str(&c).ok()); - self.info_cache - .insert(identifier.to_string(), value.clone()); - Ok(value) - } - - async fn file_content(&self, file: &str, identifier: &str) -> Result> { - // identifier is either a path (trunk, branches/x, tags/y) or a revision number - let url = if identifier.contains('/') || identifier == "trunk" { - format!("{}/{}/{}", self.base_url, identifier, file) - } else { - format!( - "{}/{}/{}@{}", - self.base_url, self.trunk_path, file, identifier - ) - }; - let output = self.svn_util.execute(&["cat", &url], None); - match output { - Ok(o) if !o.stdout.is_empty() => Ok(Some(o.stdout)), - _ => Ok(None), - } - } - - async fn change_date(&self, identifier: &str) -> Result> { - let url = if identifier.contains('/') || identifier == "trunk" { - format!("{}/{}", self.base_url, identifier) - } else { - format!("{}@{}", self.base_url, identifier) - }; - match self.svn_info(&url) { - Ok(info) => Ok(info["date"].as_str().map(|s| s.to_string())), - Err(_) => Ok(None), - } - } - - async fn dist(&self, _identifier: &str) -> Result> { - // SVN doesn't provide dist archives - Ok(None) - } - - fn source(&self, identifier: &str) -> SourceReference { - SourceReference { - source_type: "svn".to_string(), - url: self.base_url.clone(), - reference: identifier.to_string(), - } - } - - fn url(&self) -> &str { - &self.url - } - - async fn cleanup(&mut self) -> Result<()> { - Ok(()) - } -} - -/// Extract an XML attribute value from a simple XML string. -fn extract_xml_attr(xml: &str, tag: &str, attr: &str) -> Option { - let pattern = format!(r#"<{tag}\s[^>]*{attr}="([^"]*)"#); - let re = Regex::new(&pattern).ok()?; - re.captures(xml).map(|c| c[1].to_string()) -} - -/// Extract text content between XML tags. -fn extract_xml_content(xml: &str, tag: &str) -> Option { - let pattern = format!(r"<{tag}>([^<]*)"); - let re = Regex::new(&pattern).ok()?; - re.captures(xml).map(|c| c[1].to_string()) -} diff --git a/crates/mozart-vcs/src/lib.rs b/crates/mozart-vcs/src/lib.rs deleted file mode 100644 index e7ca383..0000000 --- a/crates/mozart-vcs/src/lib.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod downloader; -pub mod driver; -pub mod process; -pub mod repository; -pub mod util; -pub mod version_guesser; diff --git a/crates/mozart-vcs/src/process.rs b/crates/mozart-vcs/src/process.rs deleted file mode 100644 index 8ccc11d..0000000 --- a/crates/mozart-vcs/src/process.rs +++ /dev/null @@ -1,142 +0,0 @@ -use indexmap::IndexMap; -use std::path::Path; -use std::process::Command; -use std::time::{Duration, Instant}; - -use anyhow::{Result, bail}; - -/// Output from a process execution. -#[derive(Debug, Clone)] -pub struct ProcessOutput { - pub status: i32, - pub stdout: String, - pub stderr: String, -} - -/// Wrapper around `std::process::Command` for executing external programs. -/// -/// Corresponds to Composer's `ProcessExecutor`. -pub struct ProcessExecutor { - timeout: Option, - env_overrides: IndexMap>, -} - -impl Default for ProcessExecutor { - fn default() -> Self { - Self::new() - } -} - -impl ProcessExecutor { - pub fn new() -> Self { - Self { - timeout: None, - env_overrides: IndexMap::new(), - } - } - - pub fn with_timeout(secs: u64) -> Self { - Self { - timeout: Some(Duration::from_secs(secs)), - env_overrides: IndexMap::new(), - } - } - - /// Set an environment variable override for all subsequent executions. - pub fn set_env(&mut self, key: impl Into, value: impl Into) { - self.env_overrides.insert(key.into(), Some(value.into())); - } - - /// Remove an environment variable for all subsequent executions. - pub fn remove_env(&mut self, key: impl Into) { - self.env_overrides.insert(key.into(), None); - } - - /// Execute a command. Does not error on non-zero exit status. - pub fn execute(&self, args: &[&str], cwd: Option<&Path>) -> Result { - if args.is_empty() { - bail!("No command specified"); - } - - let mut cmd = Command::new(args[0]); - if args.len() > 1 { - cmd.args(&args[1..]); - } - if let Some(dir) = cwd { - cmd.current_dir(dir); - } - - for (key, value) in &self.env_overrides { - match value { - Some(v) => { - cmd.env(key, v); - } - None => { - cmd.env_remove(key); - } - } - } - - if let Some(timeout) = self.timeout { - let mut child = cmd - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn()?; - - let start = Instant::now(); - loop { - match child.try_wait()? { - Some(status) => { - let mut stdout = String::new(); - let mut stderr = String::new(); - if let Some(ref mut out) = child.stdout { - std::io::Read::read_to_string(out, &mut stdout)?; - } - if let Some(ref mut err) = child.stderr { - std::io::Read::read_to_string(err, &mut stderr)?; - } - return Ok(ProcessOutput { - status: status.code().unwrap_or(-1), - stdout, - stderr, - }); - } - None => { - if start.elapsed() > timeout { - let _ = child.kill(); - bail!("Process timed out after {} seconds", timeout.as_secs()); - } - std::thread::sleep(Duration::from_millis(50)); - } - } - } - } else { - let output = cmd.output()?; - Ok(ProcessOutput { - status: output.status.code().unwrap_or(-1), - stdout: String::from_utf8_lossy(&output.stdout).into_owned(), - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), - }) - } - } - - /// Execute a command, returning an error if the exit status is non-zero. - pub fn execute_checked(&self, args: &[&str], cwd: Option<&Path>) -> Result { - let output = self.execute(args, cwd)?; - if output.status != 0 { - bail!( - "Command `{}` failed with exit code {}\nstdout: {}\nstderr: {}", - args.join(" "), - output.status, - output.stdout.trim(), - output.stderr.trim(), - ); - } - Ok(output) - } - - /// Split output into non-empty lines. - pub fn split_lines(output: &str) -> Vec<&str> { - output.lines().filter(|l| !l.is_empty()).collect() - } -} diff --git a/crates/mozart-vcs/src/repository.rs b/crates/mozart-vcs/src/repository.rs deleted file mode 100644 index b941eec..0000000 --- a/crates/mozart-vcs/src/repository.rs +++ /dev/null @@ -1,206 +0,0 @@ -use anyhow::{Result, bail}; - -use crate::driver::{ - DistReference, DriverConfig, DriverType, SourceReference, create_driver, detect_driver, -}; - -/// A single package version discovered from a VCS repository. -#[derive(Debug, Clone)] -pub struct VcsPackageVersion { - /// Package name (from composer.json). - pub name: String, - /// Version string (e.g., "1.2.3" for tags, "dev-main" for branches). - pub version: String, - /// Normalized version for comparison. - pub version_normalized: String, - /// Full composer.json data as JSON. - pub composer_json: serde_json::Value, - /// Source reference (VCS checkout info). - pub source: SourceReference, - /// Dist reference (archive download, if available). - pub dist: Option, - /// Whether this is the default branch version. - pub is_default_branch: bool, - /// Release date (ISO 8601). - pub time: Option, -} - -/// Repository that scans a VCS URL for package versions. -/// -/// Corresponds to Composer's `Repository\VcsRepository`. -pub struct VcsRepository { - url: String, - driver_type: Option, - config: DriverConfig, -} - -impl VcsRepository { - pub fn new(url: String, repo_type: Option<&str>, config: DriverConfig) -> Self { - let driver_type = detect_driver(&url, repo_type, &config); - Self { - url, - driver_type, - config, - } - } - - /// Scan the VCS repository for all package versions. - /// - /// 1. Detects the driver type and initializes it - /// 2. Reads composer.json from the root to get the package name - /// 3. Scans tags → version releases - /// 4. Scans branches → dev versions - pub async fn scan(&self) -> Result> { - let driver_type = self - .driver_type - .ok_or_else(|| anyhow::anyhow!("No suitable VCS driver found for URL: {}", self.url))?; - - let mut driver = create_driver(&self.url, driver_type, self.config.clone()); - driver.initialize().await?; - - // Get package name from root composer.json - let root_id = driver.root_identifier().to_string(); - let root_info = driver.composer_information(&root_id).await?; - let package_name = match &root_info { - Some(info) => info["name"] - .as_str() - .ok_or_else(|| { - anyhow::anyhow!( - "composer.json at root of {} does not contain a 'name' field", - self.url, - ) - })? - .to_string(), - None => bail!( - "No composer.json found at root of {} (ref: {})", - self.url, - root_id, - ), - }; - - let mut versions = Vec::new(); - - // Scan tags - let tags = driver.tags().await?.clone(); - for (tag_name, tag_hash) in &tags { - if let Some(version) = self.tag_to_version(tag_name) { - match driver.composer_information(tag_hash).await { - Ok(Some(info)) => { - let time = driver.change_date(tag_hash).await.unwrap_or(None); - let source = driver.source(tag_hash); - let dist = driver.dist(tag_hash).await.unwrap_or(None); - - // Ensure name matches root package - if info["name"].as_str() != Some(&package_name) { - continue; - } - - let normalized = self.normalize_version(&version); - - versions.push(VcsPackageVersion { - name: package_name.clone(), - version: version.clone(), - version_normalized: normalized, - composer_json: info, - source, - dist, - is_default_branch: false, - time, - }); - } - Ok(None) | Err(_) => continue, - } - } - } - - // Scan branches - let branches = driver.branches().await?.clone(); - let default_branch = driver.root_identifier().to_string(); - for (branch_name, branch_hash) in &branches { - match driver.composer_information(branch_hash).await { - Ok(Some(info)) => { - if info["name"].as_str() != Some(&package_name) { - continue; - } - - let time = driver.change_date(branch_hash).await.unwrap_or(None); - let source = driver.source(branch_hash); - let dist = driver.dist(branch_hash).await.unwrap_or(None); - let is_default = branch_name == &default_branch; - - let version = self.branch_to_version(branch_name); - let normalized = self.normalize_version(&version); - - // Check for branch-alias - let aliased_version = info - .get("extra") - .and_then(|e| e.get("branch-alias")) - .and_then(|ba| ba.get(format!("dev-{branch_name}"))) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - versions.push(VcsPackageVersion { - name: package_name.clone(), - version: aliased_version.unwrap_or(version), - version_normalized: normalized, - composer_json: info, - source, - dist, - is_default_branch: is_default, - time, - }); - } - Ok(None) | Err(_) => continue, - } - } - - driver.cleanup().await?; - Ok(versions) - } - - /// Convert a tag name to a version string. - /// Returns `None` if the tag doesn't look like a version. - fn tag_to_version(&self, tag: &str) -> Option { - // Strip common prefixes - let version = tag - .strip_prefix('v') - .or_else(|| tag.strip_prefix("V")) - .or_else(|| tag.strip_prefix("release-")) - .or_else(|| tag.strip_prefix("release/")) - .unwrap_or(tag); - - // Basic semver-ish check - if version.is_empty() { - return None; - } - if version.chars().next()?.is_ascii_digit() { - Some(version.to_string()) - } else { - None - } - } - - /// Convert a branch name to a dev version string. - fn branch_to_version(&self, branch: &str) -> String { - // Numeric branches like "1.x", "2.0" become "1.x-dev", "2.0.x-dev" - if branch.chars().next().is_some_and(|c| c.is_ascii_digit()) { - let version = if branch.ends_with(".x") || branch.ends_with(".*") { - branch.to_string() - } else { - format!("{branch}.x") - }; - format!("{version}-dev") - } else { - format!("dev-{branch}") - } - } - - /// Normalize a version string. - fn normalize_version(&self, version: &str) -> String { - // Use mozart-semver for proper normalization if available, - // otherwise do a simple normalization - mozart_semver::Version::parse(version) - .map(|v| v.to_string()) - .unwrap_or_else(|_| version.to_string()) - } -} diff --git a/crates/mozart-vcs/src/util/git.rs b/crates/mozart-vcs/src/util/git.rs deleted file mode 100644 index ab4366d..0000000 --- a/crates/mozart-vcs/src/util/git.rs +++ /dev/null @@ -1,314 +0,0 @@ -use std::path::{Path, PathBuf}; -use std::sync::LazyLock; - -use anyhow::{Result, bail}; -use regex::Regex; - -use crate::process::{ProcessExecutor, ProcessOutput}; - -/// Modern GitHub token pattern (40+ hex chars, `ghp_…`, `github_pat_…`). -/// -/// Mirrors `Composer\Util\GitHub::GITHUB_TOKEN_REGEX`. -static GITHUB_TOKEN_RE: LazyLock = LazyLock::new(|| { - Regex::new(r"^([a-fA-F0-9]{12,}|gh[a-zA-Z]_[a-zA-Z0-9_]+|github_pat_[a-zA-Z0-9_]+)$").unwrap() -}); - -/// `[?&]access_token=...` query parameter. -static ACCESS_TOKEN_RE: LazyLock = - LazyLock::new(|| Regex::new(r"([&?]access_token=)[^&]+").unwrap()); - -/// `://user:password@` credential block. -static CREDENTIALS_RE: LazyLock = LazyLock::new(|| { - Regex::new(r"(?i)(?P[a-z0-9]+://)?(?P[^:/\s@]+):(?P[^@\s/]+)@").unwrap() -}); - -/// Git utility for mirror management and protocol fallback. -/// -/// Corresponds to Composer's `Util\Git`. -pub struct GitUtil { - process: ProcessExecutor, - cache_dir: PathBuf, -} - -impl GitUtil { - pub fn new(process: ProcessExecutor, cache_dir: PathBuf) -> Self { - Self { process, cache_dir } - } - - /// Returns environment variable overrides to clean Git state. - /// Removes `GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE` to avoid - /// interference from the calling process's Git context. - pub fn clean_env() -> Vec<(&'static str, Option<&'static str>)> { - vec![ - ("GIT_DIR", None), - ("GIT_WORK_TREE", None), - ("GIT_INDEX_FILE", None), - ("GIT_TERMINAL_PROMPT", Some("0")), - ] - } - - /// Synchronize a bare mirror in the cache directory. - /// - /// On first call, clones a bare mirror. On subsequent calls, updates it. - /// Returns the path to the mirror directory. - pub fn sync_mirror(&self, url: &str) -> Result { - let mirror_dir = self.mirror_path(url); - - if mirror_dir.join("HEAD").exists() { - // Update existing mirror - self.run_command( - &["git", "remote", "set-url", "origin", "--", url], - url, - Some(&mirror_dir), - )?; - self.run_command( - &["git", "remote", "update", "--prune", "origin"], - url, - Some(&mirror_dir), - )?; - } else { - // Create new mirror - std::fs::create_dir_all(&mirror_dir)?; - self.run_command( - &[ - "git", - "clone", - "--mirror", - "--", - url, - mirror_dir.to_str().unwrap_or(""), - ], - url, - None, - )?; - } - - Ok(mirror_dir) - } - - /// Fetch a specific refspec from the mirror. - pub fn fetch_ref(&self, mirror_dir: &Path, refspec: &str) -> Result { - let output = self - .process - .execute(&["git", "fetch", "origin", refspec], Some(mirror_dir))?; - Ok(output.status == 0) - } - - /// Get the default branch of a repository. - pub fn get_default_branch(&self, mirror_dir: &Path) -> Result> { - let output = self - .process - .execute(&["git", "remote", "show", "origin"], Some(mirror_dir))?; - if output.status != 0 { - return Ok(None); - } - for line in output.stdout.lines() { - let trimmed = line.trim(); - if let Some(branch) = trimmed.strip_prefix("HEAD branch:") { - let branch = branch.trim(); - if branch != "(unknown)" { - return Ok(Some(branch.to_string())); - } - } - } - Ok(None) - } - - /// Execute a git command with protocol fallback. - /// - /// Tries the URL as-is first, then falls back through protocol variations - /// (ssh → https → git://) if the command fails. - pub fn run_command( - &self, - args: &[&str], - url: &str, - cwd: Option<&Path>, - ) -> Result { - let mut executor = ProcessExecutor::new(); - for (key, value) in Self::clean_env() { - match value { - Some(v) => executor.set_env(key, v), - None => executor.remove_env(key), - } - } - - // Try the command as-is first - let output = executor.execute(args, cwd)?; - if output.status == 0 { - return Ok(output); - } - - // Try protocol fallback for remote URLs - let fallback_urls = Self::get_fallback_urls(url); - for fallback_url in &fallback_urls { - let new_args: Vec<&str> = args - .iter() - .map(|&a| if a == url { fallback_url.as_str() } else { a }) - .collect(); - let fallback_output = executor.execute(&new_args, cwd)?; - if fallback_output.status == 0 { - return Ok(fallback_output); - } - } - - // Return the original error - if output.status != 0 { - bail!( - "Git command `{}` failed with exit code {}\nstdout: {}\nstderr: {}", - args.join(" "), - output.status, - output.stdout.trim(), - output.stderr.trim(), - ); - } - Ok(output) - } - - /// Get the Git version string. - pub fn get_version(&self) -> Option { - let output = self.process.execute(&["git", "--version"], None).ok()?; - if output.status != 0 { - return None; - } - // "git version 2.39.2" -> "2.39.2" - output - .stdout - .trim() - .strip_prefix("git version ") - .map(|s| s.to_string()) - } - - /// Sanitize a URL for use as a cache directory name. - /// - /// Mirrors Composer's `Preg::replace('{[^a-z0-9.]}i', '-', Url::sanitize($url))` - /// pattern (see `GitDriver::initialize` and `GitDownloader`): credentials and - /// access tokens are first redacted, then every byte outside `[a-zA-Z0-9.]` - /// is replaced with `-`. The redaction step keeps cache keys stable across - /// URLs that differ only in their embedded token. - pub fn sanitize_url(url: &str) -> String { - let redacted = sanitize_url_credentials(url); - redacted - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c == '.' { - c - } else { - '-' - } - }) - .collect() - } - - /// Get the cache mirror path for a URL. - pub fn mirror_path(&self, url: &str) -> PathBuf { - self.cache_dir.join(Self::sanitize_url(url)) - } - - /// Generate fallback URLs for protocol switching. - fn get_fallback_urls(url: &str) -> Vec { - let mut urls = Vec::new(); - - // ssh -> https fallback - if url.starts_with("git@") { - // git@github.com:owner/repo.git -> https://github.com/owner/repo.git - if let Some(rest) = url.strip_prefix("git@") { - let converted = rest.replacen(':', "/", 1); - urls.push(format!("https://{converted}")); - } - } - - // git:// -> https:// fallback - if let Some(rest) = url.strip_prefix("git://") { - urls.push(format!("https://{rest}")); - } - - // https -> git:// fallback - if let Some(rest) = url.strip_prefix("https://") { - urls.push(format!("git://{rest}")); - } - - urls - } -} - -/// Redact credentials and access tokens from `url`. -/// -/// Mirrors Composer's `Util\Url::sanitize`. Two replacements are applied: -/// 1. `[?&]access_token=…` query values → `***` -/// 2. `://user:password@` credentials → `***:***@` if `user` looks like -/// a GitHub token, otherwise just `user:***@` -fn sanitize_url_credentials(url: &str) -> String { - let url = ACCESS_TOKEN_RE.replace_all(url, "${1}***"); - CREDENTIALS_RE - .replace_all(&url, |caps: ®ex::Captures<'_>| { - let prefix = caps.name("prefix").map(|m| m.as_str()).unwrap_or(""); - let user = &caps["user"]; - if GITHUB_TOKEN_RE.is_match(user) { - format!("{prefix}***:***@") - } else { - format!("{prefix}{user}:***@") - } - }) - .into_owned() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn sanitize_url_replaces_special_chars_with_dash() { - assert_eq!( - GitUtil::sanitize_url("https://github.com/owner/repo.git"), - "https---github.com-owner-repo.git" - ); - } - - #[test] - fn sanitize_url_preserves_dot() { - // Dot must survive — it appears in hostnames and ".git" suffixes. - let key = GitUtil::sanitize_url("git://example.org/foo.bar/baz.git"); - assert!(key.contains(".org")); - assert!(key.ends_with(".git")); - } - - #[test] - fn sanitize_url_redacts_password_in_credentials() { - let key = GitUtil::sanitize_url("https://alice:s3cret@example.com/repo.git"); - // Password is replaced with ***, then non-alphanumerics become '-'. - assert!(key.contains("alice")); - assert!(!key.contains("s3cret")); - } - - #[test] - fn sanitize_url_redacts_user_when_looks_like_github_token() { - // 40-hex token in the user position triggers full redaction. - let token = "abcdef0123456789abcdef0123456789abcdef01"; - let key = GitUtil::sanitize_url(&format!("https://{token}:x-oauth-basic@github.com/o/r")); - assert!(!key.contains("abcdef")); - } - - #[test] - fn sanitize_url_redacts_modern_github_pat() { - // ghp_xxx and github_pat_xxx forms. - let key1 = GitUtil::sanitize_url("https://ghp_abc123XYZ:x@github.com/o/r"); - assert!(!key1.contains("ghp_")); - let key2 = GitUtil::sanitize_url("https://github_pat_abc123:x@github.com/o/r"); - assert!(!key2.contains("github_pat_")); - } - - #[test] - fn sanitize_url_strips_access_token_query() { - let key = GitUtil::sanitize_url("https://api.github.com/x?access_token=secrettoken"); - assert!(!key.contains("secrettoken")); - } - - #[test] - fn sanitize_url_token_variants_share_cache_key() { - // Two pulls of the same repo with different access tokens should land - // in the same cache subdirectory. - let a = GitUtil::sanitize_url("https://api.github.com/repo?access_token=tokenA"); - let b = GitUtil::sanitize_url("https://api.github.com/repo?access_token=tokenB"); - assert_eq!(a, b); - } -} diff --git a/crates/mozart-vcs/src/util/hg.rs b/crates/mozart-vcs/src/util/hg.rs deleted file mode 100644 index 7f5abcc..0000000 --- a/crates/mozart-vcs/src/util/hg.rs +++ /dev/null @@ -1,30 +0,0 @@ -use std::path::Path; - -use anyhow::Result; - -use crate::process::{ProcessExecutor, ProcessOutput}; - -/// Mercurial utility for command execution. -pub struct HgUtil { - process: ProcessExecutor, -} - -impl HgUtil { - pub fn new(process: ProcessExecutor) -> Self { - Self { process } - } - - /// Execute a Mercurial command. - pub fn execute(&self, args: &[&str], cwd: Option<&Path>) -> Result { - let mut full_args = vec!["hg"]; - full_args.extend_from_slice(args); - self.process.execute_checked(&full_args, cwd) - } - - /// Execute a Mercurial command, not erroring on non-zero exit. - pub fn execute_unchecked(&self, args: &[&str], cwd: Option<&Path>) -> Result { - let mut full_args = vec!["hg"]; - full_args.extend_from_slice(args); - self.process.execute(&full_args, cwd) - } -} diff --git a/crates/mozart-vcs/src/util/mod.rs b/crates/mozart-vcs/src/util/mod.rs deleted file mode 100644 index b2c35fc..0000000 --- a/crates/mozart-vcs/src/util/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod git; -pub mod hg; -pub mod svn; diff --git a/crates/mozart-vcs/src/util/svn.rs b/crates/mozart-vcs/src/util/svn.rs deleted file mode 100644 index e9a6813..0000000 --- a/crates/mozart-vcs/src/util/svn.rs +++ /dev/null @@ -1,91 +0,0 @@ -use std::path::Path; - -use anyhow::Result; - -use crate::process::{ProcessExecutor, ProcessOutput}; - -/// SVN credentials for authenticated operations. -#[derive(Debug, Clone)] -pub struct SvnCredentials { - pub username: String, - pub password: String, -} - -/// SVN utility for command execution with credential handling. -pub struct SvnUtil { - process: ProcessExecutor, -} - -impl SvnUtil { - pub fn new(process: ProcessExecutor) -> Self { - Self { process } - } - - /// Execute an SVN command with `--non-interactive`. - pub fn execute(&self, args: &[&str], cwd: Option<&Path>) -> Result { - let mut full_args = vec!["svn"]; - full_args.extend_from_slice(args); - full_args.push("--non-interactive"); - self.process.execute_checked(&full_args, cwd) - } - - /// Execute an SVN command with optional credentials, retrying on auth failure. - pub fn execute_with_credentials( - &self, - args: &[&str], - creds: Option<&SvnCredentials>, - cwd: Option<&Path>, - ) -> Result { - let mut full_args = vec!["svn"]; - full_args.extend_from_slice(args); - full_args.push("--non-interactive"); - - let cred_args: Vec; - if let Some(c) = creds { - cred_args = vec![ - "--username".to_string(), - c.username.clone(), - "--password".to_string(), - c.password.clone(), - ]; - for arg in &cred_args { - full_args.push(arg); - } - } - - let full_args_refs: Vec<&str> = full_args.iter().map(|s| &**s).collect(); - - // Retry up to 5 times on auth failure - let max_retries = 5; - let mut last_output = None; - for _ in 0..max_retries { - let output = self.process.execute(&full_args_refs, cwd)?; - if output.status == 0 { - return Ok(output); - } - // Check if it's an auth error (SVN exit code or stderr hint) - if !output.stderr.contains("authorization failed") - && !output.stderr.contains("Could not authenticate") - && !output.stderr.contains("Authentication failed") - { - // Not an auth error, return immediately - last_output = Some(output); - break; - } - last_output = Some(output); - } - - match last_output { - Some(output) if output.status != 0 => { - anyhow::bail!( - "SVN command `{}` failed with exit code {}\nstderr: {}", - full_args_refs.join(" "), - output.status, - output.stderr.trim(), - ); - } - Some(output) => Ok(output), - None => anyhow::bail!("SVN command failed with no output"), - } - } -} diff --git a/crates/mozart-vcs/src/version_guesser.rs b/crates/mozart-vcs/src/version_guesser.rs deleted file mode 100644 index 038e332..0000000 --- a/crates/mozart-vcs/src/version_guesser.rs +++ /dev/null @@ -1,605 +0,0 @@ -//! `VersionGuesser` — derive a package's current version from the working -//! copy, mirroring `Composer\Package\Version\VersionGuesser`. -//! -//! Differences from the PHP version: -//! - Fossil is not supported (Mozart has no Fossil driver). -//! - `Platform::isInputCompletionProcess()` short-circuit is omitted. -//! - `guess_feature_version` runs candidate comparisons sequentially. -//! Composer parallelises via `executeAsync`; ours is simpler at the -//! cost of speed when many candidate branches exist. - -use std::path::Path; -use std::sync::LazyLock; - -use regex::Regex; -use serde_json::Value; - -use mozart_semver::{Version, normalize_branch}; - -use crate::process::ProcessExecutor; - -const DEFAULT_BRANCH_ALIAS: &str = "9999999-dev"; - -/// Mirrors `Composer\Package\Version\VersionParser` (itself a thin wrapper -/// around `Composer\Semver\VersionParser`). In Rust, semver parsing is -/// handled by `mozart_semver` directly, so this type carries no state; -/// it exists to keep `VersionGuesser::new` signature compatible with the -/// PHP constructor. -pub struct VersionParser; - -impl Default for VersionParser { - fn default() -> Self { - Self::new() - } -} - -impl VersionParser { - pub fn new() -> Self { - Self - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct GuessedVersion { - pub version: String, - pub commit: Option, - pub pretty_version: Option, - pub feature_version: Option, - pub feature_pretty_version: Option, -} - -pub struct VersionGuesser { - process: ProcessExecutor, -} - -impl Default for VersionGuesser { - fn default() -> Self { - Self::new(VersionParser::new()) - } -} - -impl VersionGuesser { - /// Mirrors `Composer\Package\Version\VersionGuesser::__construct`. - /// `_version_parser` is accepted for API parity but unused — Rust relies - /// on `mozart_semver` directly. - pub fn new(_version_parser: VersionParser) -> Self { - Self { - process: ProcessExecutor::new(), - } - } - - /// `Composer\Package\Version\VersionGuesser::guessVersion`. - pub fn guess_version(&self, package_config: &Value, path: &Path) -> Option { - if let Some(v) = self.guess_git_version(package_config, path) { - return Some(postprocess(v)); - } - if let Some(v) = self.guess_hg_version(package_config, path) { - return Some(postprocess(v)); - } - if let Some(v) = self.guess_svn_version(package_config, path) { - return Some(postprocess(v)); - } - None - } - - fn guess_git_version(&self, package_config: &Value, path: &Path) -> Option { - let mut commit: Option = None; - let mut version: Option = None; - let mut pretty_version: Option = None; - let mut feature_version: Option = None; - let mut feature_pretty_version: Option = None; - let mut is_detached = false; - - let branch_out = self - .process - .execute( - &["git", "branch", "-a", "--no-color", "--no-abbrev", "-v"], - Some(path), - ) - .ok()?; - if branch_out.status != 0 { - return None; - } - - let mut branches: Vec = Vec::new(); - let mut is_feature_branch = false; - - for line in branch_out.stdout.lines() { - if line.is_empty() { - continue; - } - if let Some(caps) = CURRENT_BRANCH_RE.captures(line) { - let name = caps.get(1).map_or("", |m| m.as_str()); - let hash = caps.get(2).map_or("", |m| m.as_str()); - if name == "(no branch)" - || name.starts_with("(detached ") - || name.starts_with("(HEAD detached at") - { - let v = format!("dev-{hash}"); - version = Some(v.clone()); - pretty_version = Some(v); - is_feature_branch = true; - is_detached = true; - } else { - version = Some(normalize_branch(name)); - pretty_version = Some(format!("dev-{name}")); - is_feature_branch = is_feature_branch_name(package_config, name); - } - commit = Some(hash.to_string()); - } - - if !REMOTE_HEAD_RE.is_match(line) - && let Some(caps) = ANY_BRANCH_RE.captures(line) - && let Some(m) = caps.get(1) - { - branches.push(m.as_str().to_string()); - } - } - - if is_feature_branch { - feature_version = version.clone(); - feature_pretty_version = pretty_version.clone(); - let result = self.guess_feature_version( - package_config, - version.as_deref(), - &branches, - &["git", "rev-list", "%candidate%..%branch%"], - path, - ); - version = result.0; - pretty_version = result.1; - } - - if (version.is_none() || is_detached) - && let Some((tag_v, tag_pretty)) = self.version_from_git_tags(path) - { - version = Some(tag_v); - pretty_version = Some(tag_pretty); - feature_version = None; - feature_pretty_version = None; - } - - if commit.is_none() - && let Ok(out) = self - .process - .execute(&["git", "rev-parse", "HEAD"], Some(path)) - && out.status == 0 - { - let trimmed = out.stdout.trim(); - if !trimmed.is_empty() { - commit = Some(trimmed.to_string()); - } - } - - version.as_ref()?; - Some(GuessedVersion { - version: version.unwrap(), - commit, - pretty_version, - feature_version, - feature_pretty_version, - }) - } - - fn version_from_git_tags(&self, path: &Path) -> Option<(String, String)> { - let out = self - .process - .execute(&["git", "describe", "--exact-match", "--tags"], Some(path)) - .ok()?; - if out.status != 0 { - return None; - } - let pretty = out.stdout.trim().to_string(); - if pretty.is_empty() { - return None; - } - let normalized = Version::parse(&pretty).ok()?; - Some((normalized.to_string(), pretty)) - } - - fn guess_hg_version(&self, package_config: &Value, path: &Path) -> Option { - let out = self.process.execute(&["hg", "branch"], Some(path)).ok()?; - if out.status != 0 { - return None; - } - let branch = out.stdout.trim().to_string(); - if branch.is_empty() { - return None; - } - let version = normalize_branch(&branch); - let is_feature = version.starts_with("dev-"); - - if version == DEFAULT_BRANCH_ALIAS { - return Some(GuessedVersion { - version, - commit: None, - pretty_version: Some(format!("dev-{branch}")), - feature_version: None, - feature_pretty_version: None, - }); - } - - if !is_feature { - return Some(GuessedVersion { - version: version.clone(), - commit: None, - pretty_version: Some(version), - feature_version: None, - feature_pretty_version: None, - }); - } - - // List branches via `hg branches` (first whitespace-separated token per line). - let branches_out = self.process.execute(&["hg", "branches"], Some(path)).ok()?; - let branches: Vec = if branches_out.status == 0 { - branches_out - .stdout - .lines() - .filter_map(|l| l.split_whitespace().next().map(str::to_string)) - .collect() - } else { - Vec::new() - }; - - let (out_version, out_pretty) = self.guess_feature_version( - package_config, - Some(&version), - &branches, - &[ - "hg", - "log", - "-r", - "not ancestors('%candidate%') and ancestors('%branch%')", - "--template", - "\"{node}\\n\"", - ], - path, - ); - - Some(GuessedVersion { - version: out_version.unwrap_or(version.clone()), - commit: Some(String::new()), - pretty_version: out_pretty, - feature_version: Some(version.clone()), - feature_pretty_version: Some(version), - }) - } - - fn guess_svn_version(&self, package_config: &Value, path: &Path) -> Option { - let out = self - .process - .execute(&["svn", "info", "--xml"], Some(path)) - .ok()?; - if out.status != 0 { - return None; - } - - let trunk = package_config - .get("trunk-path") - .and_then(Value::as_str) - .unwrap_or("trunk"); - let branches = package_config - .get("branches-path") - .and_then(Value::as_str) - .unwrap_or("branches"); - let tags = package_config - .get("tags-path") - .and_then(Value::as_str) - .unwrap_or("tags"); - - let pattern = format!( - r".*/({trunk}|({branches}|{tags})/(.*))", - trunk = regex::escape(trunk), - branches = regex::escape(branches), - tags = regex::escape(tags), - ); - let re = Regex::new(&pattern).ok()?; - let caps = re.captures(&out.stdout)?; - - let kind = caps.get(2).map(|m| m.as_str().to_string()); - let inner = caps.get(3).map(|m| m.as_str().to_string()); - - if let (Some(kind), Some(inner)) = (kind, inner) - && (kind == branches || kind == tags) - { - let pretty = format!("dev-{inner}"); - return Some(GuessedVersion { - version: normalize_branch(&inner), - commit: Some(String::new()), - pretty_version: Some(pretty), - feature_version: None, - feature_pretty_version: None, - }); - } - - let trunk_match = caps.get(1)?; - let pretty = trunk_match.as_str().trim().to_string(); - let version = if pretty == "trunk" { - "dev-trunk".to_string() - } else { - Version::parse(&pretty).ok()?.to_string() - }; - Some(GuessedVersion { - version, - commit: Some(String::new()), - pretty_version: Some(pretty), - feature_version: None, - feature_pretty_version: None, - }) - } - - /// Find the nearest non-feature branch by diff size. Sequential port of - /// `guessFeatureVersion`; Composer runs candidates in parallel. - fn guess_feature_version( - &self, - package_config: &Value, - version: Option<&str>, - branches: &[String], - scm_cmdline: &[&str], - path: &Path, - ) -> (Option, Option) { - let version = version.map(str::to_string); - let pretty_version = version.clone(); - - let Some(v) = version.clone() else { - return (version, pretty_version); - }; - - // Skip if the branch has a non-self.version branch-alias OR self.version is referenced. - let has_branch_alias = package_config - .get("extra") - .and_then(|e| e.get("branch-alias")) - .and_then(|b| b.get(&v)) - .is_some(); - let uses_self_version = serde_json::to_string(package_config) - .map(|s| s.contains("\"self.version\"")) - .unwrap_or(false); - if has_branch_alias && !uses_self_version { - return (Some(v), pretty_version); - } - - // Composer also returns early if `self.version` is referenced — see L283. - // The PHP precedence is: skip iff (no branch-alias) OR (json contains self.version). - if uses_self_version { - return (Some(v), pretty_version); - } - - let branch = v.strip_prefix("dev-").unwrap_or(&v).to_string(); - - if !is_feature_branch_name(package_config, &branch) { - return (Some(v), pretty_version); - } - - let mut sorted: Vec = branches.to_vec(); - sorted.sort_by(|a, b| { - let a_remote = a.starts_with("remotes/"); - let b_remote = b.starts_with("remotes/"); - if a_remote != b_remote { - return if a_remote { - std::cmp::Ordering::Greater - } else { - std::cmp::Ordering::Less - }; - } - // strnatcasecmp(b, a) — natural-sort, descending, case-insensitive. - natural_cmp(&b.to_ascii_lowercase(), &a.to_ascii_lowercase()) - }); - - let mut last_index: i64 = -1; - let mut length: usize = usize::MAX; - let mut version = Some(v); - let mut pretty = pretty_version; - - for (index, candidate) in sorted.iter().enumerate() { - let candidate_version = REMOTES_PREFIX_RE.replace(candidate, "").to_string(); - if candidate.as_str() == branch.as_str() - || is_feature_branch_name(package_config, &candidate_version) - { - continue; - } - let cmd: Vec = scm_cmdline - .iter() - .map(|c| { - c.replace("%candidate%", candidate) - .replace("%branch%", &branch) - }) - .collect(); - let cmd_refs: Vec<&str> = cmd.iter().map(String::as_str).collect(); - let Ok(output) = self.process.execute(&cmd_refs, Some(path)) else { - continue; - }; - if output.status != 0 { - continue; - } - let len = output.stdout.len(); - if len < length || (len == length && last_index < index as i64) { - last_index = index as i64; - length = len; - version = Some(normalize_branch(&candidate_version)); - pretty = Some(format!("dev-{candidate_version}")); - if length == 0 { - break; - } - } - } - - (version, pretty) - } -} - -fn postprocess(mut v: GuessedVersion) -> GuessedVersion { - if v.feature_version.is_some() - && v.feature_version == Some(v.version.clone()) - && v.feature_pretty_version == v.pretty_version - { - v.feature_version = None; - v.feature_pretty_version = None; - } - - if v.version.ends_with("-dev") && contains_long_nines(&v.version) { - v.pretty_version = Some(replace_long_nines_with_x(&v.version)); - } - if let Some(ref fv) = v.feature_version - && fv.ends_with("-dev") - && contains_long_nines(fv) - { - v.feature_pretty_version = Some(replace_long_nines_with_x(fv)); - } - v -} - -fn contains_long_nines(s: &str) -> bool { - NINE_SEVEN_RE.is_match(s) -} - -fn replace_long_nines_with_x(s: &str) -> String { - NINE_SEVEN_GROUP_RE.replace_all(s, ".x").to_string() -} - -fn is_feature_branch_name(package_config: &Value, branch_name: &str) -> bool { - let mut non_feature = String::new(); - if let Some(arr) = package_config - .get("non-feature-branches") - .and_then(Value::as_array) - { - let parts: Vec = arr - .iter() - .filter_map(|v| v.as_str().map(str::to_string)) - .collect(); - if !parts.is_empty() { - non_feature = parts.join("|"); - } - } - let pattern = format!( - r"^({non_feature}|master|main|latest|next|current|support|tip|trunk|default|develop|\d+\..+)$" - ); - let Ok(re) = Regex::new(&pattern) else { - return true; - }; - !re.is_match(branch_name) -} - -/// Natural-order, case-insensitive string comparison (mirrors PHP `strnatcasecmp`). -fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering { - let mut ai = a.chars().peekable(); - let mut bi = b.chars().peekable(); - loop { - match (ai.peek().copied(), bi.peek().copied()) { - (None, None) => return std::cmp::Ordering::Equal, - (None, _) => return std::cmp::Ordering::Less, - (_, None) => return std::cmp::Ordering::Greater, - (Some(ac), Some(bc)) => { - if ac.is_ascii_digit() && bc.is_ascii_digit() { - let mut na = String::new(); - let mut nb = String::new(); - while let Some(&c) = ai.peek() { - if !c.is_ascii_digit() { - break; - } - na.push(c); - ai.next(); - } - while let Some(&c) = bi.peek() { - if !c.is_ascii_digit() { - break; - } - nb.push(c); - bi.next(); - } - let na_v: u128 = na.parse().unwrap_or(0); - let nb_v: u128 = nb.parse().unwrap_or(0); - match na_v.cmp(&nb_v) { - std::cmp::Ordering::Equal => continue, - ord => return ord, - } - } else { - match ac.cmp(&bc) { - std::cmp::Ordering::Equal => { - ai.next(); - bi.next(); - } - ord => return ord, - } - } - } - } - } -} - -static CURRENT_BRANCH_RE: LazyLock = LazyLock::new(|| { - Regex::new( - r"^(?:\* ) *(\(no branch\)|\(detached from \S+\)|\(HEAD detached at \S+\)|\S+) *([a-f0-9]+) .*$", - ) - .unwrap() -}); - -static REMOTE_HEAD_RE: LazyLock = LazyLock::new(|| Regex::new(r"^ *.+/HEAD ").unwrap()); - -static ANY_BRANCH_RE: LazyLock = LazyLock::new(|| { - Regex::new(r"^(?:\* )? *((?:remotes/(?:origin|upstream)/)?[^\s/]+) *([a-f0-9]+) .*$").unwrap() -}); - -static REMOTES_PREFIX_RE: LazyLock = - LazyLock::new(|| Regex::new(r"^remotes/[^/]+/").unwrap()); - -static NINE_SEVEN_RE: LazyLock = LazyLock::new(|| Regex::new(r"\.9{7}").unwrap()); - -static NINE_SEVEN_GROUP_RE: LazyLock = LazyLock::new(|| Regex::new(r"(\.9{7})+").unwrap()); - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn test_postprocess_strips_duplicate_feature() { - let v = GuessedVersion { - version: "1.0.0.0".into(), - commit: None, - pretty_version: Some("1.0.0".into()), - feature_version: Some("1.0.0.0".into()), - feature_pretty_version: Some("1.0.0".into()), - }; - let p = postprocess(v); - assert_eq!(p.feature_version, None); - assert_eq!(p.feature_pretty_version, None); - } - - #[test] - fn test_postprocess_nine_seven_to_x() { - let v = GuessedVersion { - version: "1.9999999.9999999.9999999-dev".into(), - commit: None, - pretty_version: Some("dev-1.x".into()), - feature_version: None, - feature_pretty_version: None, - }; - let p = postprocess(v); - assert_eq!(p.pretty_version.as_deref(), Some("1.x-dev")); - } - - #[test] - fn test_is_feature_branch_known_mainlines() { - let cfg = json!({}); - assert!(!is_feature_branch_name(&cfg, "master")); - assert!(!is_feature_branch_name(&cfg, "main")); - assert!(!is_feature_branch_name(&cfg, "develop")); - assert!(!is_feature_branch_name(&cfg, "1.0")); - assert!(is_feature_branch_name(&cfg, "feature/x")); - } - - #[test] - fn test_is_feature_branch_with_non_feature_list() { - let cfg = json!({"non-feature-branches": ["staging", "release-.+"]}); - assert!(!is_feature_branch_name(&cfg, "staging")); - assert!(!is_feature_branch_name(&cfg, "release-2")); - assert!(is_feature_branch_name(&cfg, "wip-x")); - } - - #[test] - fn test_natural_cmp_orders_naturally() { - assert_eq!(natural_cmp("1.10", "1.9"), std::cmp::Ordering::Greater); - assert_eq!(natural_cmp("1.2", "1.10"), std::cmp::Ordering::Less); - assert_eq!(natural_cmp("abc", "abc"), std::cmp::Ordering::Equal); - } -} diff --git a/crates/mozart-vcs/tests/git_driver_test.rs b/crates/mozart-vcs/tests/git_driver_test.rs deleted file mode 100644 index dd72ad6..0000000 --- a/crates/mozart-vcs/tests/git_driver_test.rs +++ /dev/null @@ -1,340 +0,0 @@ -use std::path::Path; -use std::process::Command; - -use tempfile::TempDir; - -use mozart_vcs::downloader::VcsDownloader; -use mozart_vcs::downloader::git::GitDownloader; -use mozart_vcs::driver::{DriverConfig, DriverType, create_driver}; -use mozart_vcs::process::ProcessExecutor; -use mozart_vcs::util::git::GitUtil; - -fn has_git() -> bool { - Command::new("git").arg("--version").output().is_ok() -} - -fn create_test_repo(dir: &Path) { - let run = |args: &[&str]| { - let output = Command::new(args[0]) - .args(&args[1..]) - .current_dir(dir) - .env("GIT_AUTHOR_NAME", "Test") - .env("GIT_AUTHOR_EMAIL", "test@test.com") - .env("GIT_COMMITTER_NAME", "Test") - .env("GIT_COMMITTER_EMAIL", "test@test.com") - .output() - .unwrap(); - assert!( - output.status.success(), - "Command failed: {:?}\nstderr: {}", - args, - String::from_utf8_lossy(&output.stderr) - ); - }; - - run(&["git", "init", "-b", "main"]); - run(&["git", "config", "user.email", "test@test.com"]); - run(&["git", "config", "user.name", "Test"]); - - // Create composer.json - std::fs::write( - dir.join("composer.json"), - r#"{"name": "test/package", "description": "Test package"}"#, - ) - .unwrap(); - - run(&["git", "add", "."]); - run(&["git", "commit", "-m", "Initial commit"]); - - // Create a tag - run(&["git", "tag", "v1.0.0"]); - - // Create another commit on main - std::fs::write(dir.join("README.md"), "# Test").unwrap(); - run(&["git", "add", "."]); - run(&["git", "commit", "-m", "Add readme"]); - - // Create a second tag - run(&["git", "tag", "v1.1.0"]); - - // Create a feature branch - run(&["git", "checkout", "-b", "feature/test"]); - std::fs::write(dir.join("feature.txt"), "feature").unwrap(); - run(&["git", "add", "."]); - run(&["git", "commit", "-m", "Feature commit"]); - run(&["git", "checkout", "main"]); -} - -#[tokio::test] -async fn test_git_driver_local_repo() { - if !has_git() { - eprintln!("Skipping test: git not available"); - return; - } - - let repo_dir = TempDir::new().unwrap(); - let cache_dir = TempDir::new().unwrap(); - create_test_repo(repo_dir.path()); - - let config = DriverConfig { - cache_vcs_dir: cache_dir.path().to_path_buf(), - ..DriverConfig::default() - }; - - let mut driver = create_driver(repo_dir.path().to_str().unwrap(), DriverType::Git, config); - - driver.initialize().await.unwrap(); - assert_eq!(driver.root_identifier(), "main"); - - // Check tags - let tags = driver.tags().await.unwrap().clone(); - assert!( - tags.contains_key("v1.0.0"), - "Missing tag v1.0.0: {:?}", - tags - ); - assert!( - tags.contains_key("v1.1.0"), - "Missing tag v1.1.0: {:?}", - tags - ); - - // Check branches - let branches = driver.branches().await.unwrap().clone(); - assert!( - branches.contains_key("main"), - "Missing branch main: {:?}", - branches - ); - assert!( - branches.contains_key("feature/test"), - "Missing branch feature/test: {:?}", - branches, - ); - - // Read composer.json - let tag_hash = &tags["v1.0.0"]; - let info = driver.composer_information(tag_hash).await.unwrap(); - assert!(info.is_some()); - let info = info.unwrap(); - assert_eq!(info["name"].as_str(), Some("test/package")); - - // Read file content - let content = driver - .file_content("composer.json", tag_hash) - .await - .unwrap(); - assert!(content.is_some()); - assert!(content.unwrap().contains("test/package")); - - // Change date - let date = driver.change_date(tag_hash).await.unwrap(); - assert!(date.is_some()); - - // Source reference - let source = driver.source(tag_hash); - assert_eq!(source.source_type, "git"); - - driver.cleanup().await.unwrap(); -} - -#[test] -fn test_git_downloader() { - if !has_git() { - eprintln!("Skipping test: git not available"); - return; - } - - let repo_dir = TempDir::new().unwrap(); - let cache_dir = TempDir::new().unwrap(); - let install_dir = TempDir::new().unwrap(); - create_test_repo(repo_dir.path()); - - let process = ProcessExecutor::new(); - let git_util = GitUtil::new(process, cache_dir.path().join("git")); - let downloader = GitDownloader::new(git_util); - - let url = repo_dir.path().to_str().unwrap(); - let target = install_dir.path().join("test-package"); - - // Download (sync mirror) - downloader.download(url, "v1.0.0", &target).unwrap(); - - // Install - downloader.install(url, "v1.0.0", &target).unwrap(); - assert!(target.join("composer.json").exists()); - - // Check no local changes - let changes = downloader.get_local_changes(&target).unwrap(); - assert!(changes.is_none(), "Expected no changes, got: {:?}", changes); - - // Untracked files alone must NOT count as local changes (matches - // Composer's `git status --porcelain --untracked-files=no`). - std::fs::write(target.join("untracked.txt"), "untracked").unwrap(); - let changes = downloader.get_local_changes(&target).unwrap(); - assert!( - changes.is_none(), - "Untracked files should be ignored, got: {:?}", - changes - ); - - // Modifying a tracked file is a local change. - std::fs::write(target.join("composer.json"), "{\"name\":\"changed\"}\n").unwrap(); - let changes = downloader.get_local_changes(&target).unwrap(); - assert!(changes.is_some()); - assert!(changes.unwrap().contains("composer.json")); - - // Commit logs - let logs = downloader.commit_logs("v1.0.0", "v1.1.0", &target).unwrap(); - assert!(logs.contains("Add readme")); - - // Remove - downloader.remove(&target).unwrap(); - assert!(!target.exists()); -} - -#[test] -fn test_git_downloader_unpushed_changes() { - if !has_git() { - eprintln!("Skipping test: git not available"); - return; - } - - let repo_dir = TempDir::new().unwrap(); - let cache_dir = TempDir::new().unwrap(); - let install_dir = TempDir::new().unwrap(); - create_test_repo(repo_dir.path()); - - let process = ProcessExecutor::new(); - let git_util = GitUtil::new(process, cache_dir.path().join("git")); - let downloader = GitDownloader::new(git_util); - - let url = repo_dir.path().to_str().unwrap(); - let target = install_dir.path().join("test-package"); - - downloader.download(url, "main", &target).unwrap(); - downloader.install(url, "main", &target).unwrap(); - - // No commits added locally → no unpushed changes. - let unpushed = downloader.unpushed_changes(&target).unwrap(); - assert!( - unpushed.is_none(), - "Expected no unpushed changes, got: {:?}", - unpushed - ); - - // Commit a local change without pushing. - let run = |args: &[&str]| { - let output = Command::new(args[0]) - .args(&args[1..]) - .current_dir(&target) - .env("GIT_AUTHOR_NAME", "Test") - .env("GIT_AUTHOR_EMAIL", "test@test.com") - .env("GIT_COMMITTER_NAME", "Test") - .env("GIT_COMMITTER_EMAIL", "test@test.com") - .output() - .unwrap(); - assert!(output.status.success(), "Command failed: {:?}", args); - }; - std::fs::write(target.join("local-only.txt"), "local-only").unwrap(); - run(&["git", "add", "."]); - run(&["git", "commit", "-m", "Local-only commit"]); - - let unpushed = downloader.unpushed_changes(&target).unwrap(); - assert!(unpushed.is_some(), "Expected unpushed changes"); - let body = unpushed.unwrap(); - assert!( - body.contains("local-only.txt"), - "Expected diff body to mention local-only.txt, got: {body}" - ); -} - -#[test] -fn test_detect_driver() { - use mozart_vcs::driver::{DriverType, detect_driver}; - - let config = DriverConfig::default(); - - assert_eq!( - detect_driver("https://github.com/owner/repo", None, &config), - Some(DriverType::GitHub), - ); - assert_eq!( - detect_driver("git@github.com:owner/repo.git", None, &config), - Some(DriverType::GitHub), - ); - assert_eq!( - detect_driver("https://gitlab.com/owner/repo", None, &config), - Some(DriverType::GitLab), - ); - assert_eq!( - detect_driver("https://bitbucket.org/owner/repo", None, &config), - Some(DriverType::Bitbucket), - ); - assert_eq!( - detect_driver("https://codeberg.org/owner/repo", None, &config), - Some(DriverType::Forgejo), - ); - assert_eq!( - detect_driver("git://example.com/repo.git", None, &config), - Some(DriverType::Git), - ); - assert_eq!( - detect_driver("svn://example.com/repo", None, &config), - Some(DriverType::Svn), - ); - - // Forced type - assert_eq!( - detect_driver("https://example.com/repo", Some("git"), &config), - Some(DriverType::Git), - ); -} - -#[tokio::test] -async fn test_vcs_repository_scan() { - if !has_git() { - eprintln!("Skipping test: git not available"); - return; - } - - let repo_dir = TempDir::new().unwrap(); - let cache_dir = TempDir::new().unwrap(); - create_test_repo(repo_dir.path()); - - let config = DriverConfig { - cache_vcs_dir: cache_dir.path().to_path_buf(), - ..DriverConfig::default() - }; - - let repo = mozart_vcs::repository::VcsRepository::new( - repo_dir.path().to_str().unwrap().to_string(), - None, - config, - ); - - let versions = repo.scan().await.unwrap(); - assert!(!versions.is_empty(), "No versions found"); - - // Should find tag versions - let tag_versions: Vec<_> = versions - .iter() - .filter(|v| !v.version.starts_with("dev-")) - .collect(); - assert!(!tag_versions.is_empty(), "No tag versions found"); - - // Should find branch versions - let dev_versions: Vec<_> = versions - .iter() - .filter(|v| v.version.starts_with("dev-")) - .collect(); - assert!(!dev_versions.is_empty(), "No dev versions found"); - - // Check default branch flag - let default_versions: Vec<_> = versions.iter().filter(|v| v.is_default_branch).collect(); - assert_eq!( - default_versions.len(), - 1, - "Expected exactly one default branch version" - ); -} diff --git a/crates/mozart/Cargo.toml b/crates/mozart/Cargo.toml index 9a0e3eb..c1b10d6 100644 --- a/crates/mozart/Cargo.toml +++ b/crates/mozart/Cargo.toml @@ -4,14 +4,10 @@ version.workspace = true edition.workspace = true [dependencies] -mozart-archiver.workspace = true -mozart-autoload.workspace = true mozart-console-macros.workspace = true mozart-core.workspace = true -mozart-registry.workspace = true mozart-semver.workspace = true mozart-spdx-licenses.workspace = true -mozart-vcs.workspace = true anyhow.workspace = true clap.workspace = true clap_complete.workspace = true @@ -26,9 +22,9 @@ sha1.workspace = true tempfile.workspace = true terminal_size.workspace = true tokio.workspace = true -url.workspace = true tracing-subscriber.workspace = true tracing.workspace = true +url.workspace = true [dev-dependencies] mozart-test-harness.workspace = true diff --git a/crates/mozart/src/commands/archive.rs b/crates/mozart/src/commands/archive.rs index 7e1697f..d83bdb5 100644 --- a/crates/mozart/src/commands/archive.rs +++ b/crates/mozart/src/commands/archive.rs @@ -1,8 +1,8 @@ use crate::composer::Composer; use clap::Args; -use mozart_archiver::{ArchiveManager, ArchivePackage}; use mozart_core::console_writeln; use mozart_core::factory::create_config; +use mozart_core::package::archiver::{ArchiveManager, ArchivePackage}; use std::borrow::Cow; use std::path::{Path, PathBuf}; @@ -74,9 +74,9 @@ async fn archive( working_dir: &Path, no_cache: bool, ) -> anyhow::Result<()> { - let cache_config = mozart_registry::cache::build_cache_config(no_cache); - let repo_cache = mozart_registry::cache::Cache::repo(&cache_config); - let files_cache = mozart_registry::cache::Cache::files(&cache_config); + let cache_config = mozart_core::repository::cache::build_cache_config(no_cache); + let repo_cache = mozart_core::repository::cache::Cache::repo(&cache_config); + let files_cache = mozart_core::repository::cache::Cache::files(&cache_config); let archive_manager = ArchiveManager::new(); @@ -138,10 +138,10 @@ async fn select_package( io: &mozart_core::console::Console, package_name: &str, version: Option<&str>, - repo_cache: &mozart_registry::cache::Cache, + repo_cache: &mozart_core::repository::cache::Cache, ) -> anyhow::Result { use mozart_core::package::Stability; - use mozart_registry::version::find_best_candidate; + use mozart_core::repository::version::find_best_candidate; io.info("Searching for the specified package."); @@ -160,7 +160,8 @@ async fn select_package( let version = version.as_deref(); let packages = - mozart_registry::packagist::fetch_package_versions(package_name, repo_cache).await?; + mozart_core::repository::packagist::fetch_package_versions(package_name, repo_cache) + .await?; if packages.is_empty() { anyhow::bail!("No versions found for package \"{}\"", package_name); } diff --git a/crates/mozart/src/commands/audit.rs b/crates/mozart/src/commands/audit.rs index 30f0716..72d4408 100644 --- a/crates/mozart/src/commands/audit.rs +++ b/crates/mozart/src/commands/audit.rs @@ -4,9 +4,9 @@ use crate::composer::Composer; use clap::Args; use indexmap::IndexMap; use mozart_core::advisory::{AbandonedHandling, AuditConfig, AuditFormat}; -use mozart_registry::advisory::{AuditOptions, Auditor, PackageInfo}; -use mozart_registry::cache::{Cache, build_cache_config}; -use mozart_registry::repository::RepositorySet; +use mozart_core::repository::advisory::{AuditOptions, Auditor, PackageInfo}; +use mozart_core::repository::cache::{Cache, build_cache_config}; +use mozart_core::repository::repository::RepositorySet; #[derive(Args)] pub struct AuditArgs { @@ -127,7 +127,7 @@ fn get_packages(composer: &Composer, args: &AuditArgs) -> anyhow::Result anyhow::Result> { let vendor_dir = working_dir.join("vendor"); - let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?; + let installed = mozart_core::repository::installed::InstalledPackages::read(&vendor_dir)?; let dev_names: indexmap::IndexSet = installed .dev_package_names @@ -166,9 +166,9 @@ fn load_locked_packages(working_dir: &Path, no_dev: bool) -> anyhow::Result = + let mut all_packages: Vec<&mozart_core::repository::lockfile::LockedPackage> = lock.packages.iter().collect(); if !no_dev && let Some(ref pkgs_dev) = lock.packages_dev { @@ -196,7 +196,7 @@ mod tests { use std::collections::BTreeMap; use super::*; - use mozart_registry::lockfile::{LockFile, LockedPackage}; + use mozart_core::repository::lockfile::{LockFile, LockedPackage}; fn make_pkg(name: &str, version: &str, version_normalized: Option<&str>) -> PackageInfo { PackageInfo { @@ -228,8 +228,8 @@ mod tests { let working_dir = dir.path(); let vendor_dir = working_dir.join("vendor"); - let mut installed = mozart_registry::installed::InstalledPackages::new(); - installed.upsert(mozart_registry::installed::InstalledPackageEntry { + let mut installed = mozart_core::repository::installed::InstalledPackages::new(); + installed.upsert(mozart_core::repository::installed::InstalledPackageEntry { name: "monolog/monolog".to_string(), version: "1.5.0".to_string(), version_normalized: Some("1.5.0.0".to_string()), @@ -259,8 +259,8 @@ mod tests { let working_dir = dir.path(); let vendor_dir = working_dir.join("vendor"); - let mut installed = mozart_registry::installed::InstalledPackages::new(); - installed.upsert(mozart_registry::installed::InstalledPackageEntry { + let mut installed = mozart_core::repository::installed::InstalledPackages::new(); + installed.upsert(mozart_core::repository::installed::InstalledPackageEntry { name: "monolog/monolog".to_string(), version: "1.5.0".to_string(), version_normalized: None, @@ -274,7 +274,7 @@ mod tests { support: None, extra_fields: BTreeMap::new(), }); - installed.upsert(mozart_registry::installed::InstalledPackageEntry { + installed.upsert(mozart_core::repository::installed::InstalledPackageEntry { name: "phpunit/phpunit".to_string(), version: "10.0.0".to_string(), version_normalized: None, diff --git a/crates/mozart/src/commands/browse.rs b/crates/mozart/src/commands/browse.rs index a8ccab3..f646577 100644 --- a/crates/mozart/src/commands/browse.rs +++ b/crates/mozart/src/commands/browse.rs @@ -4,9 +4,9 @@ use mozart_core::console::Console; use mozart_core::console_writeln; use mozart_core::console_writeln_error; use mozart_core::exit_code; -use mozart_registry::browse_repos::{BrowseRepos, CompletePackageView}; -use mozart_registry::cache::{Cache, build_cache_config}; -use mozart_registry::installed::InstalledPackages; +use mozart_core::repository::browse_repos::{BrowseRepos, CompletePackageView}; +use mozart_core::repository::cache::{Cache, build_cache_config}; +use mozart_core::repository::installed::InstalledPackages; use std::process::Command; #[derive(Args)] diff --git a/crates/mozart/src/commands/bump.rs b/crates/mozart/src/commands/bump.rs index 4722fe2..ee611d1 100644 --- a/crates/mozart/src/commands/bump.rs +++ b/crates/mozart/src/commands/bump.rs @@ -109,13 +109,13 @@ pub async fn do_bump( // to an empty map (`getLockedRepository` would throw in PHP — Mozart degrades // gracefully because `bump` has nothing to bump in that case anyway). if composer.locker().is_locked() { - let lock = mozart_registry::lockfile::LockFile::read_from_file(lock_path)?; + let lock = mozart_core::repository::lockfile::LockFile::read_from_file(lock_path)?; build_locked_versions_from_lock(&lock) } else { IndexMap::new() } } else if composer.locker().is_locked() { - let lock = mozart_registry::lockfile::LockFile::read_from_file(lock_path)?; + let lock = mozart_core::repository::lockfile::LockFile::read_from_file(lock_path)?; if !lock.is_fresh(&contents) { console_writeln_error!( io, @@ -282,9 +282,10 @@ fn update_file_cleanly( /// successful in-place edit so the lockfile stays "fresh" for the next install. fn update_lock_hash(lock_path: &Path, composer_json_path: &Path) -> anyhow::Result<()> { let new_composer_json_content = std::fs::read_to_string(composer_json_path)?; - let new_hash = - mozart_registry::lockfile::LockFile::compute_content_hash(&new_composer_json_content)?; - let mut lock = mozart_registry::lockfile::LockFile::read_from_file(lock_path)?; + let new_hash = mozart_core::repository::lockfile::LockFile::compute_content_hash( + &new_composer_json_content, + )?; + let mut lock = mozart_core::repository::lockfile::LockFile::read_from_file(lock_path)?; lock.content_hash = new_hash; lock.write_to_file(lock_path)?; Ok(()) @@ -304,7 +305,7 @@ fn is_writable(path: &Path) -> bool { /// Build a map of lowercase package names to (pretty_version, version_normalized) /// from a parsed `composer.lock`. fn build_locked_versions_from_lock( - lock: &mozart_registry::lockfile::LockFile, + lock: &mozart_core::repository::lockfile::LockFile, ) -> IndexMap)> { let mut map: IndexMap)> = IndexMap::new(); let all_packages = lock @@ -352,7 +353,7 @@ fn strip_inline_constraint(arg: &str) -> &str { #[cfg(test)] mod tests { use super::*; - use mozart_registry::lockfile::{LockFile, LockedPackage}; + use mozart_core::repository::lockfile::{LockFile, LockedPackage}; use tempfile::tempdir; fn minimal_lock(packages: Vec, packages_dev: Vec) -> LockFile { diff --git a/crates/mozart/src/commands/check_platform_reqs.rs b/crates/mozart/src/commands/check_platform_reqs.rs index 1a10882..31cdb35 100644 --- a/crates/mozart/src/commands/check_platform_reqs.rs +++ b/crates/mozart/src/commands/check_platform_reqs.rs @@ -89,12 +89,13 @@ pub async fn execute( load_lock(&lock_path, args.no_dev, &mut installed_repo, &mut requires)?; } else { let installed_packages_present = installed_path.exists() - && !mozart_registry::installed::InstalledPackages::read(&vendor_dir)? + && !mozart_core::repository::installed::InstalledPackages::read(&vendor_dir)? .packages .is_empty(); if installed_packages_present { - let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?; + let installed = + mozart_core::repository::installed::InstalledPackages::read(&vendor_dir)?; console_writeln_error!( console, "Checking {}platform requirements for packages in the vendor dir", @@ -252,9 +253,10 @@ fn load_lock( repo: &mut InstalledRepoLite, requires: &mut BTreeMap>, ) -> anyhow::Result<()> { - let lock = mozart_registry::lockfile::LockFile::read_from_file(lock_path)?; + let lock = mozart_core::repository::lockfile::LockFile::read_from_file(lock_path)?; - let mut all: Vec<&mozart_registry::lockfile::LockedPackage> = lock.packages.iter().collect(); + let mut all: Vec<&mozart_core::repository::lockfile::LockedPackage> = + lock.packages.iter().collect(); if !no_dev && let Some(ref pkgs_dev) = lock.packages_dev { all.extend(pkgs_dev.iter()); } @@ -277,7 +279,7 @@ fn load_lock( } fn load_installed( - installed: &mozart_registry::installed::InstalledPackages, + installed: &mozart_core::repository::installed::InstalledPackages, no_dev: bool, repo: &mut InstalledRepoLite, requires: &mut BTreeMap>, diff --git a/crates/mozart/src/commands/clear_cache.rs b/crates/mozart/src/commands/clear_cache.rs index 9ee27ed..6a601da 100644 --- a/crates/mozart/src/commands/clear_cache.rs +++ b/crates/mozart/src/commands/clear_cache.rs @@ -4,7 +4,7 @@ use crate::composer::Composer; use clap::Args; use mozart_core::console_writeln_error; use mozart_core::factory::create_config; -use mozart_registry::cache::Cache; +use mozart_core::repository::cache::Cache; #[derive(Args)] pub struct ClearCacheArgs { diff --git a/crates/mozart/src/commands/create_project.rs b/crates/mozart/src/commands/create_project.rs index ff9776d..2b2fbe1 100644 --- a/crates/mozart/src/commands/create_project.rs +++ b/crates/mozart/src/commands/create_project.rs @@ -3,12 +3,12 @@ use indexmap::IndexMap; use mozart_core::console::Console; use mozart_core::console_format; use mozart_core::package::{self, Stability}; +use mozart_core::repository::downloader; +use mozart_core::repository::lockfile; +use mozart_core::repository::packagist; +use mozart_core::repository::resolver::{self, PlatformConfig, ResolveRequest}; +use mozart_core::repository::version; use mozart_core::validation; -use mozart_registry::downloader; -use mozart_registry::lockfile; -use mozart_registry::packagist; -use mozart_registry::resolver::{self, PlatformConfig, ResolveRequest}; -use mozart_registry::version; use std::path::{Path, PathBuf}; #[derive(Args)] @@ -498,8 +498,8 @@ async fn install_project( .and_then(|v| v.as_bool()) .unwrap_or(false); - let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache); - let repo_cache = mozart_registry::cache::Cache::repo(&cache_config); + let cache_config = mozart_core::repository::cache::build_cache_config(cli.no_cache); + let repo_cache = mozart_core::repository::cache::Cache::repo(&cache_config); let request = ResolveRequest { root_name: raw.name.clone(), @@ -515,7 +515,7 @@ async fn install_project( ignore_platform_reqs: args.ignore_platform_reqs, ignore_platform_req_list: args.ignore_platform_req.clone(), repositories: std::sync::Arc::new( - mozart_registry::repository::RepositorySet::with_packagist(repo_cache.clone()), + mozart_core::repository::repository::RepositorySet::with_packagist(repo_cache.clone()), ), temporary_constraints: IndexMap::new(), raw_repositories: raw.repositories.clone(), @@ -559,7 +559,7 @@ async fn install_project( composer_json: raw.clone(), include_dev: dev_mode, repositories: std::sync::Arc::new( - mozart_registry::repository::RepositorySet::with_packagist(repo_cache.clone()), + mozart_core::repository::repository::RepositorySet::with_packagist(repo_cache.clone()), ), previous_lock: None, lock_pinned_names: indexmap::IndexSet::new(), @@ -611,9 +611,10 @@ async fn install_project( .and_then(|v| v.as_bool()) .unwrap_or(false); - let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache); - let files_cache = mozart_registry::cache::Cache::files(&cache_config); - let mut executor = mozart_registry::installer_executor::FilesystemExecutor::new(files_cache); + let cache_config = mozart_core::repository::cache::build_cache_config(cli.no_cache); + let files_cache = mozart_core::repository::cache::Cache::files(&cache_config); + let mut executor = + mozart_core::repository::installer_executor::FilesystemExecutor::new(files_cache); super::install::install_from_lock( &new_lock, &target_dir, @@ -726,9 +727,9 @@ async fn install_root_package( let (_, minimum_stability) = resolve_stability(stability, package_version.as_deref())?; // --- Find the best candidate matching constraint + stability --- - let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache); - let repo_cache = mozart_registry::cache::Cache::repo(&cache_config); - let files_cache = mozart_registry::cache::Cache::files(&cache_config); + let cache_config = mozart_core::repository::cache::build_cache_config(cli.no_cache); + let repo_cache = mozart_core::repository::cache::Cache::repo(&cache_config); + let files_cache = mozart_core::repository::cache::Cache::files(&cache_config); let versions = packagist::fetch_package_versions(&name, &repo_cache).await?; diff --git a/crates/mozart/src/commands/dependency.rs b/crates/mozart/src/commands/dependency.rs index f4e7430..0bdd3da 100644 --- a/crates/mozart/src/commands/dependency.rs +++ b/crates/mozart/src/commands/dependency.rs @@ -258,7 +258,7 @@ fn load_from_lockfile(lock_path: &Path) -> Result> { if !lock_path.exists() { anyhow::bail!("composer.lock not found — run `mozart install` first or omit --locked"); } - let lock = mozart_registry::lockfile::LockFile::read_from_file(lock_path)?; + let lock = mozart_core::repository::lockfile::LockFile::read_from_file(lock_path)?; let mut packages: Vec = Vec::new(); @@ -291,7 +291,7 @@ fn load_from_lockfile(lock_path: &Path) -> Result> { fn load_from_installed(working_dir: &Path) -> Result> { let vendor_dir = working_dir.join("vendor"); - let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?; + let installed = mozart_core::repository::installed::InstalledPackages::read(&vendor_dir)?; let packages = installed .packages diff --git a/crates/mozart/src/commands/dump_autoload.rs b/crates/mozart/src/commands/dump_autoload.rs index 7557d37..f8222bb 100644 --- a/crates/mozart/src/commands/dump_autoload.rs +++ b/crates/mozart/src/commands/dump_autoload.rs @@ -1,6 +1,6 @@ use crate::composer::Composer; use clap::Args; -use mozart_autoload::AutoloadGeneratorExt; +use mozart_core::autoload::AutoloadGeneratorExt; use mozart_core::composer::AutoloadDumpOptions; use mozart_core::console_writeln; diff --git a/crates/mozart/src/commands/fund.rs b/crates/mozart/src/commands/fund.rs index 90a8418..792edd6 100644 --- a/crates/mozart/src/commands/fund.rs +++ b/crates/mozart/src/commands/fund.rs @@ -4,9 +4,9 @@ use mozart_core::console::{Console, hyperlink}; use mozart_core::console_format; use mozart_core::console_writeln; use mozart_core::exit_code; -use mozart_registry::cache::{Cache, build_cache_config}; -use mozart_registry::installed::InstalledPackages; -use mozart_registry::repository::{PackageQuery, RepositorySet}; +use mozart_core::repository::cache::{Cache, build_cache_config}; +use mozart_core::repository::installed::InstalledPackages; +use mozart_core::repository::repository::{PackageQuery, RepositorySet}; use serde::Serialize; use std::collections::{BTreeMap, BTreeSet}; diff --git a/crates/mozart/src/commands/init.rs b/crates/mozart/src/commands/init.rs index 5d6d501..90a5806 100644 --- a/crates/mozart/src/commands/init.rs +++ b/crates/mozart/src/commands/init.rs @@ -6,8 +6,8 @@ use mozart_core::console_format; use mozart_core::package::{ self, RawAuthor, RawAutoload, RawPackageData, RawRepository, Stability, }; +use mozart_core::repository::{packagist, version}; use mozart_core::validation; -use mozart_registry::{packagist, version}; use std::collections::BTreeMap; use std::io::{BufRead, Write}; use std::path::Path; @@ -66,8 +66,8 @@ pub async fn execute( cli: &super::Cli, console: &console::Console, ) -> anyhow::Result<()> { - let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache); - let repo_cache = mozart_registry::cache::Cache::repo(&cache_config); + let cache_config = mozart_core::repository::cache::build_cache_config(cli.no_cache); + let repo_cache = mozart_core::repository::cache::Cache::repo(&cache_config); let working_dir = cli.working_dir()?; @@ -235,7 +235,7 @@ async fn build_interactive( args: &InitArgs, console: &console::Console, working_dir: &Path, - repo_cache: &mozart_registry::cache::Cache, + repo_cache: &mozart_core::repository::cache::Cache, ) -> anyhow::Result { console.info(""); console.info(&format!( @@ -487,7 +487,7 @@ async fn interactive_search_packages( label: &str, already_required: &BTreeMap, preferred_stability: Stability, - repo_cache: &mozart_registry::cache::Cache, + repo_cache: &mozart_core::repository::cache::Cache, console: &console::Console, ) -> anyhow::Result> { let stdin = std::io::stdin(); diff --git a/crates/mozart/src/commands/install.rs b/crates/mozart/src/commands/install.rs index 64af756..59d400b 100644 --- a/crates/mozart/src/commands/install.rs +++ b/crates/mozart/src/commands/install.rs @@ -2,14 +2,14 @@ use clap::Args; use indexmap::IndexSet; use mozart_core::console; use mozart_core::console_format; -use mozart_registry::installed; -use mozart_registry::installer_executor::{ +use mozart_core::repository::installed; +use mozart_core::repository::installer_executor::{ Action, ExecuteContext, FilesystemExecutor, InstallerExecutor, PackageOperation, compute_operations, compute_stale_installed_aliases, format_full_pretty_version, format_full_pretty_version_for_installed, format_update_pretty_versions, locked_to_installed_entry, previously_installed_alias_versions, }; -use mozart_registry::lockfile; +use mozart_core::repository::lockfile; use std::collections::BTreeMap; use std::path::Path; @@ -815,7 +815,7 @@ pub async fn install_from_lock( let suffix = lock.content_hash.clone(); let _result = - mozart_autoload::autoload::generate(&mozart_autoload::autoload::AutoloadConfig { + mozart_core::autoload::generate(&mozart_core::autoload::AutoloadConfig { project_dir: working_dir.to_path_buf(), vendor_dir: vendor_dir.to_path_buf(), dev_mode, @@ -826,7 +826,7 @@ pub async fn install_from_lock( apcu_prefix: config.apcu_autoloader_prefix.clone(), strict_psr: false, strict_ambiguous: false, - platform_check: mozart_autoload::autoload::PlatformCheckMode::Full, + platform_check: mozart_core::autoload::PlatformCheckMode::Full, ignore_platform_reqs: config.ignore_platform_reqs, })?; } @@ -835,19 +835,21 @@ pub async fn install_from_lock( Ok(()) } -/// CLI entry point. Builds production [`mozart_registry::repository::RepositorySet`] +/// CLI entry point. Builds production [`mozart_core::repository::repository::RepositorySet`] /// (Packagist) and [`FilesystemExecutor`] from `cli`, then dispatches to [`run`]. pub async fn execute( args: &InstallArgs, cli: &super::Cli, console: &mozart_core::console::Console, ) -> anyhow::Result<()> { - let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache); - let repositories = - std::sync::Arc::new(mozart_registry::repository::RepositorySet::with_packagist( - mozart_registry::cache::Cache::repo(&cache_config), - )); - let mut executor = FilesystemExecutor::new(mozart_registry::cache::Cache::files(&cache_config)); + let cache_config = mozart_core::repository::cache::build_cache_config(cli.no_cache); + let repositories = std::sync::Arc::new( + mozart_core::repository::repository::RepositorySet::with_packagist( + mozart_core::repository::cache::Cache::repo(&cache_config), + ), + ); + let mut executor = + FilesystemExecutor::new(mozart_core::repository::cache::Cache::files(&cache_config)); let working_dir = cli.working_dir()?; run( &working_dir, @@ -875,7 +877,7 @@ pub async fn run( path_repo_base_override: Option<&Path>, args: &InstallArgs, console: &mozart_core::console::Console, - repositories: std::sync::Arc, + repositories: std::sync::Arc, executor: &mut dyn InstallerExecutor, ) -> anyhow::Result<()> { // Step 2: Validate arguments — order matches Composer's InstallCommand::execute (80–101): diff --git a/crates/mozart/src/commands/licenses.rs b/crates/mozart/src/commands/licenses.rs index 671ce2a..344a5fa 100644 --- a/crates/mozart/src/commands/licenses.rs +++ b/crates/mozart/src/commands/licenses.rs @@ -144,7 +144,7 @@ fn load_installed_entries( no_dev: bool, ) -> anyhow::Result> { let vendor_dir = working_dir.join("vendor"); - let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?; + let installed = mozart_core::repository::installed::InstalledPackages::read(&vendor_dir)?; let entries: Vec = installed.packages.iter().map(installed_to_entry).collect(); @@ -179,7 +179,7 @@ fn load_locked_entries( "Valid composer.json and composer.lock files are required to run this command with --locked" ); } - let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; + let lock = mozart_core::repository::lockfile::LockFile::read_from_file(&lock_path)?; // Mirrors `Locker::getLockedRepository(!$noDev)`: the prod-only call // returns just `packages`, the dev-included call returns the union. @@ -190,7 +190,9 @@ fn load_locked_entries( Ok(entries) } -fn installed_to_entry(pkg: &mozart_registry::installed::InstalledPackageEntry) -> LicenseEntry { +fn installed_to_entry( + pkg: &mozart_core::repository::installed::InstalledPackageEntry, +) -> LicenseEntry { let licenses = pkg .extra_fields .get("license") @@ -240,7 +242,7 @@ fn installed_to_entry(pkg: &mozart_registry::installed::InstalledPackageEntry) - } } -fn locked_to_entry(pkg: &mozart_registry::lockfile::LockedPackage) -> LicenseEntry { +fn locked_to_entry(pkg: &mozart_core::repository::lockfile::LockedPackage) -> LicenseEntry { let support_source = pkg .support .as_ref() @@ -537,7 +539,7 @@ mod tests { #[test] fn installed_to_entry_extracts_require_and_license() { - use mozart_registry::installed::InstalledPackageEntry; + use mozart_core::repository::installed::InstalledPackageEntry; let mut extra = BTreeMap::new(); extra.insert("license".to_string(), serde_json::json!(["MIT"])); extra.insert( @@ -565,7 +567,7 @@ mod tests { #[test] fn installed_to_entry_pulls_support_source_and_source_url() { - use mozart_registry::installed::InstalledPackageEntry; + use mozart_core::repository::installed::InstalledPackageEntry; let pkg = InstalledPackageEntry { name: "vendor/pkg".to_string(), version: "1.0.0".to_string(), @@ -608,8 +610,8 @@ mod tests { ) .unwrap(); - let mut installed = mozart_registry::installed::InstalledPackages::new(); - installed.upsert(mozart_registry::installed::InstalledPackageEntry { + let mut installed = mozart_core::repository::installed::InstalledPackages::new(); + installed.upsert(mozart_core::repository::installed::InstalledPackageEntry { name: "a/a".to_string(), version: "1.0.0".to_string(), version_normalized: None, @@ -623,7 +625,7 @@ mod tests { support: None, extra_fields: BTreeMap::new(), }); - installed.upsert(mozart_registry::installed::InstalledPackageEntry { + installed.upsert(mozart_core::repository::installed::InstalledPackageEntry { name: "b/b".to_string(), version: "1.0.0".to_string(), version_normalized: None, @@ -653,7 +655,7 @@ mod tests { #[test] fn locked_no_dev_drops_packages_dev() { - use mozart_registry::lockfile::{LockFile, LockedPackage}; + use mozart_core::repository::lockfile::{LockFile, LockedPackage}; let dir = tempfile::tempdir().unwrap(); let working_dir = dir.path(); std::fs::write( diff --git a/crates/mozart/src/commands/reinstall.rs b/crates/mozart/src/commands/reinstall.rs index dc99a91..52dfd52 100644 --- a/crates/mozart/src/commands/reinstall.rs +++ b/crates/mozart/src/commands/reinstall.rs @@ -1,6 +1,6 @@ use crate::composer::Composer; use clap::Args; -use mozart_autoload::AutoloadGeneratorExt; +use mozart_core::autoload::AutoloadGeneratorExt; use mozart_core::composer::{AutoloadDumpOptions, LocalPackage}; use mozart_core::console_format; use mozart_core::validation::package_name_to_regexp; @@ -138,8 +138,8 @@ pub async fn execute( // `mozart-registry::installer_executor` exposes the same shape, we // remove the install dir and re-download in place using each package's // recorded `dist` info. - let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache); - let files_cache = mozart_registry::cache::Cache::files(&cache_config); + let cache_config = mozart_core::repository::cache::build_cache_config(cli.no_cache); + let files_cache = mozart_core::repository::cache::Cache::files(&cache_config); let installation_manager = composer.installation_manager(); for package in &packages_to_reinstall { @@ -166,12 +166,12 @@ pub async fn execute( std::fs::remove_dir_all(&install_path)?; } - let mut progress = mozart_registry::downloader::DownloadProgress::new( + let mut progress = mozart_core::repository::downloader::DownloadProgress::new( !args.no_progress, format!("{} ({})", package.pretty_name(), package.pretty_version()), ); - mozart_registry::downloader::install_package( + mozart_core::repository::downloader::install_package( &dist.url, &dist.kind, dist.shasum.as_deref(), diff --git a/crates/mozart/src/commands/remove.rs b/crates/mozart/src/commands/remove.rs index d4d727f..c2d4d47 100644 --- a/crates/mozart/src/commands/remove.rs +++ b/crates/mozart/src/commands/remove.rs @@ -3,9 +3,9 @@ use indexmap::{IndexMap, IndexSet}; use mozart_core::console_format; use mozart_core::console_writeln; use mozart_core::package; -use mozart_registry::installed; -use mozart_registry::lockfile; -use mozart_registry::resolver::{self, PlatformConfig, ResolveRequest}; +use mozart_core::repository::installed; +use mozart_core::repository::lockfile; +use mozart_core::repository::resolver::{self, PlatformConfig, ResolveRequest}; #[derive(Args)] pub struct RemoveArgs { @@ -102,8 +102,8 @@ pub async fn execute( cli: &super::Cli, console: &mozart_core::console::Console, ) -> anyhow::Result<()> { - let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache); - let repo_cache = mozart_registry::cache::Cache::repo(&cache_config); + let cache_config = mozart_core::repository::cache::build_cache_config(cli.no_cache); + let repo_cache = mozart_core::repository::cache::Cache::repo(&cache_config); if args.packages.is_empty() && !args.unused { anyhow::bail!("Not enough arguments (missing: \"packages\")."); @@ -242,7 +242,7 @@ pub async fn execute( ignore_platform_reqs: args.ignore_platform_reqs, ignore_platform_req_list: args.ignore_platform_req.clone(), repositories: std::sync::Arc::new( - mozart_registry::repository::RepositorySet::with_packagist(repo_cache.clone()), + mozart_core::repository::repository::RepositorySet::with_packagist(repo_cache.clone()), ), temporary_constraints: IndexMap::new(), raw_repositories: composer.repositories.clone(), @@ -344,7 +344,7 @@ pub async fn execute( composer_json: composer.clone(), include_dev: dev_mode, repositories: std::sync::Arc::new( - mozart_registry::repository::RepositorySet::with_packagist(repo_cache.clone()), + mozart_core::repository::repository::RepositorySet::with_packagist(repo_cache.clone()), ), previous_lock: old_lock.clone(), lock_pinned_names: IndexSet::new(), @@ -430,10 +430,10 @@ pub async fn execute( } if !args.no_install && !args.dry_run { - let cache_config = mozart_registry::cache::build_cache_config(no_cache); - let files_cache = mozart_registry::cache::Cache::files(&cache_config); + let cache_config = mozart_core::repository::cache::build_cache_config(no_cache); + let files_cache = mozart_core::repository::cache::Cache::files(&cache_config); let mut executor = - mozart_registry::installer_executor::FilesystemExecutor::new(files_cache); + mozart_core::repository::installer_executor::FilesystemExecutor::new(files_cache); super::install::install_from_lock( &new_lock, &working_dir, @@ -499,7 +499,7 @@ async fn remove_unused( composer: &package::RawPackageData, working_dir: &std::path::Path, args: &RemoveArgs, - repo_cache: &mozart_registry::cache::Cache, + repo_cache: &mozart_core::repository::cache::Cache, no_cache: bool, console: &mozart_core::console::Console, ) -> anyhow::Result<()> { @@ -546,7 +546,7 @@ async fn remove_unused( ignore_platform_reqs: args.ignore_platform_reqs, ignore_platform_req_list: args.ignore_platform_req.clone(), repositories: std::sync::Arc::new( - mozart_registry::repository::RepositorySet::with_packagist(repo_cache.clone()), + mozart_core::repository::repository::RepositorySet::with_packagist(repo_cache.clone()), ), temporary_constraints: IndexMap::new(), raw_repositories: composer.repositories.clone(), @@ -625,7 +625,7 @@ async fn remove_unused( composer_json: composer.clone(), include_dev: dev_mode, repositories: std::sync::Arc::new( - mozart_registry::repository::RepositorySet::with_packagist(repo_cache.clone()), + mozart_core::repository::repository::RepositorySet::with_packagist(repo_cache.clone()), ), previous_lock: Some(old_lock.clone()), lock_pinned_names: IndexSet::new(), @@ -637,10 +637,10 @@ async fn remove_unused( if !args.no_install { let vendor_dir = working_dir.join("vendor"); - let cache_config = mozart_registry::cache::build_cache_config(no_cache); - let files_cache = mozart_registry::cache::Cache::files(&cache_config); + let cache_config = mozart_core::repository::cache::build_cache_config(no_cache); + let files_cache = mozart_core::repository::cache::Cache::files(&cache_config); let mut executor = - mozart_registry::installer_executor::FilesystemExecutor::new(files_cache); + mozart_core::repository::installer_executor::FilesystemExecutor::new(files_cache); super::install::install_from_lock( &new_lock, working_dir, @@ -672,7 +672,7 @@ async fn remove_unused( mod tests { use super::*; use mozart_core::package::RawPackageData; - use mozart_registry::lockfile; + use mozart_core::repository::lockfile; use std::collections::BTreeMap; fn make_locked_package(name: &str, version: &str) -> lockfile::LockedPackage { @@ -920,8 +920,8 @@ mod tests { #[ignore] async fn test_remove_full_e2e() { use indexmap::{IndexMap, IndexSet}; - use mozart_registry::lockfile::{LockFileGenerationRequest, generate_lock_file}; - use mozart_registry::resolver::{ResolveRequest, resolve}; + use mozart_core::repository::lockfile::{LockFileGenerationRequest, generate_lock_file}; + use mozart_core::repository::resolver::{ResolveRequest, resolve}; use tempfile::tempdir; let dir = tempdir().unwrap(); @@ -944,12 +944,12 @@ mod tests { stability_flags: IndexMap::new(), prefer_stable: true, prefer_lowest: false, - platform: mozart_registry::resolver::PlatformConfig::new(), + platform: mozart_core::repository::resolver::PlatformConfig::new(), ignore_platform_reqs: false, ignore_platform_req_list: vec![], repositories: std::sync::Arc::new( - mozart_registry::repository::RepositorySet::with_packagist( - mozart_registry::cache::Cache::new( + mozart_core::repository::repository::RepositorySet::with_packagist( + mozart_core::repository::cache::Cache::new( std::env::temp_dir().join("mozart-test-cache"), false, ), @@ -976,8 +976,8 @@ mod tests { composer_json: composer.clone(), include_dev: false, repositories: std::sync::Arc::new( - mozart_registry::repository::RepositorySet::with_packagist( - mozart_registry::cache::Cache::new( + mozart_core::repository::repository::RepositorySet::with_packagist( + mozart_core::repository::cache::Cache::new( std::env::temp_dir().join("mozart-test-cache"), false, ), @@ -1005,12 +1005,12 @@ mod tests { stability_flags: IndexMap::new(), prefer_stable: true, prefer_lowest: false, - platform: mozart_registry::resolver::PlatformConfig::new(), + platform: mozart_core::repository::resolver::PlatformConfig::new(), ignore_platform_reqs: false, ignore_platform_req_list: vec![], repositories: std::sync::Arc::new( - mozart_registry::repository::RepositorySet::with_packagist( - mozart_registry::cache::Cache::new( + mozart_core::repository::repository::RepositorySet::with_packagist( + mozart_core::repository::cache::Cache::new( std::env::temp_dir().join("mozart-test-cache"), false, ), @@ -1039,8 +1039,8 @@ mod tests { composer_json: composer, include_dev: false, repositories: std::sync::Arc::new( - mozart_registry::repository::RepositorySet::with_packagist( - mozart_registry::cache::Cache::new( + mozart_core::repository::repository::RepositorySet::with_packagist( + mozart_core::repository::cache::Cache::new( std::env::temp_dir().join("mozart-test-cache"), false, ), diff --git a/crates/mozart/src/commands/require.rs b/crates/mozart/src/commands/require.rs index 3ccba96..9ec4195 100644 --- a/crates/mozart/src/commands/require.rs +++ b/crates/mozart/src/commands/require.rs @@ -3,12 +3,12 @@ use indexmap::{IndexMap, IndexSet}; use mozart_core::console_format; use mozart_core::console_writeln; use mozart_core::package::{self, RawPackageData, Stability}; +use mozart_core::repository::lockfile; +use mozart_core::repository::packagist; +use mozart_core::repository::resolver::{self, PlatformConfig, ResolveRequest}; +use mozart_core::repository::version; +use mozart_core::repository::version_selector::VersionSelector; use mozart_core::validation; -use mozart_registry::lockfile; -use mozart_registry::packagist; -use mozart_registry::resolver::{self, PlatformConfig, ResolveRequest}; -use mozart_registry::version; -use mozart_registry::version_selector::VersionSelector; use std::io::{BufRead, IsTerminal, Write}; use std::path::{Path, PathBuf}; @@ -270,8 +270,8 @@ async fn do_update( ) -> anyhow::Result<()> { let working_dir = cli.working_dir()?; let vendor_dir = working_dir.join("vendor"); - let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache); - let repo_cache = mozart_registry::cache::Cache::repo(&cache_config); + let cache_config = mozart_core::repository::cache::build_cache_config(cli.no_cache); + let repo_cache = mozart_core::repository::cache::Cache::repo(&cache_config); let dev_mode = !args.update_no_dev; @@ -322,7 +322,7 @@ async fn do_update( ignore_platform_reqs: args.ignore_platform_reqs, ignore_platform_req_list: args.ignore_platform_req.clone(), repositories: std::sync::Arc::new( - mozart_registry::repository::RepositorySet::with_packagist(repo_cache.clone()), + mozart_core::repository::repository::RepositorySet::with_packagist(repo_cache.clone()), ), temporary_constraints: IndexMap::new(), raw_repositories: raw.repositories.clone(), @@ -444,7 +444,7 @@ async fn do_update( composer_json: raw.clone(), include_dev: dev_mode, repositories: std::sync::Arc::new( - mozart_registry::repository::RepositorySet::with_packagist(repo_cache.clone()), + mozart_core::repository::repository::RepositorySet::with_packagist(repo_cache.clone()), ), previous_lock: old_lock.clone(), lock_pinned_names: IndexSet::new(), @@ -548,11 +548,11 @@ async fn do_update( .and_then(|v| v.as_bool()) .unwrap_or(false); - let files_cache = mozart_registry::cache::Cache::files( - &mozart_registry::cache::build_cache_config(cli.no_cache), + let files_cache = mozart_core::repository::cache::Cache::files( + &mozart_core::repository::cache::build_cache_config(cli.no_cache), ); let mut executor = - mozart_registry::installer_executor::FilesystemExecutor::new(files_cache); + mozart_core::repository::installer_executor::FilesystemExecutor::new(files_cache); super::install::install_from_lock( &new_lock, &working_dir, @@ -590,7 +590,7 @@ async fn interactive_search_packages( already_required: &indexmap::IndexSet, preferred_stability: Stability, fixed: bool, - repo_cache: &mozart_registry::cache::Cache, + repo_cache: &mozart_core::repository::cache::Cache, console: &mozart_core::console::Console, ) -> anyhow::Result> { let stdin = std::io::stdin(); @@ -784,8 +784,8 @@ pub async fn execute( cli: &super::Cli, console: &mozart_core::console::Console, ) -> anyhow::Result<()> { - let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache); - let repo_cache = mozart_registry::cache::Cache::repo(&cache_config); + let cache_config = mozart_core::repository::cache::build_cache_config(cli.no_cache); + let repo_cache = mozart_core::repository::cache::Cache::repo(&cache_config); // --- Deprecated flag warnings --- // Mirrors Composer\Command\RequireCommand::execute() L134-136. @@ -1316,7 +1316,7 @@ mod tests { async fn test_require_full_e2e() { use indexmap::IndexSet; use mozart_core::package::RawPackageData; - use mozart_registry::lockfile::{LockFileGenerationRequest, generate_lock_file}; + use mozart_core::repository::lockfile::{LockFileGenerationRequest, generate_lock_file}; let composer_json_content = r#"{"name": "test/project", "require": {"psr/log": "^3.0"}}"#; let composer_json: RawPackageData = serde_json::from_str(composer_json_content).unwrap(); @@ -1335,8 +1335,8 @@ mod tests { ignore_platform_reqs: false, ignore_platform_req_list: vec![], repositories: std::sync::Arc::new( - mozart_registry::repository::RepositorySet::with_packagist( - mozart_registry::cache::Cache::new( + mozart_core::repository::repository::RepositorySet::with_packagist( + mozart_core::repository::cache::Cache::new( std::env::temp_dir().join("mozart-test-cache"), false, ), @@ -1367,8 +1367,8 @@ mod tests { composer_json, include_dev: false, repositories: std::sync::Arc::new( - mozart_registry::repository::RepositorySet::with_packagist( - mozart_registry::cache::Cache::new( + mozart_core::repository::repository::RepositorySet::with_packagist( + mozart_core::repository::cache::Cache::new( std::env::temp_dir().join("mozart-test-cache"), false, ), @@ -1416,8 +1416,8 @@ mod tests { ignore_platform_reqs: false, ignore_platform_req_list: vec![], repositories: std::sync::Arc::new( - mozart_registry::repository::RepositorySet::with_packagist( - mozart_registry::cache::Cache::new( + mozart_core::repository::repository::RepositorySet::with_packagist( + mozart_core::repository::cache::Cache::new( std::env::temp_dir().join("mozart-test-cache"), false, ), @@ -1445,8 +1445,8 @@ mod tests { composer_json: raw, include_dev: false, repositories: std::sync::Arc::new( - mozart_registry::repository::RepositorySet::with_packagist( - mozart_registry::cache::Cache::new( + mozart_core::repository::repository::RepositorySet::with_packagist( + mozart_core::repository::cache::Cache::new( std::env::temp_dir().join("mozart-test-cache"), false, ), diff --git a/crates/mozart/src/commands/search.rs b/crates/mozart/src/commands/search.rs index 14d50dd..a5ab04a 100644 --- a/crates/mozart/src/commands/search.rs +++ b/crates/mozart/src/commands/search.rs @@ -2,8 +2,8 @@ use clap::Args; use mozart_core::console::{Console, hyperlink}; use mozart_core::console_format; use mozart_core::console_writeln; -use mozart_registry::packagist::SearchResult; -use mozart_registry::repository::{RepositorySet, SearchMode}; +use mozart_core::repository::packagist::SearchResult; +use mozart_core::repository::repository::{RepositorySet, SearchMode}; use serde::Serialize; /// JSON output structure matching Composer's search result schema. @@ -111,8 +111,8 @@ pub async fn execute(args: &SearchArgs, cli: &super::Cli, console: &Console) -> // 5. Build the repository set. Configured remote repositories from // `composer.json` are not yet wired up; this is a known divergence // from Composer's full `CompositeRepository`. - let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache); - let repo_cache = mozart_registry::cache::Cache::repo(&cache_config); + let cache_config = mozart_core::repository::cache::build_cache_config(cli.no_cache); + let repo_cache = mozart_core::repository::cache::Cache::repo(&cache_config); let repos = RepositorySet::with_packagist(repo_cache); // 6. Dispatch. @@ -199,7 +199,7 @@ mod tests { #[test] fn test_parse_search_response() { - use mozart_registry::packagist::SearchResponse; + use mozart_core::repository::packagist::SearchResponse; let json = r#"{ "results": [ @@ -245,7 +245,7 @@ mod tests { #[test] fn test_parse_search_response_with_abandoned() { - use mozart_registry::packagist::SearchResponse; + use mozart_core::repository::packagist::SearchResponse; let json = r#"{ "results": [ @@ -288,7 +288,7 @@ mod tests { #[test] fn test_parse_search_response_with_next() { - use mozart_registry::packagist::SearchResponse; + use mozart_core::repository::packagist::SearchResponse; let json = r#"{ "results": [], diff --git a/crates/mozart/src/commands/show.rs b/crates/mozart/src/commands/show.rs index f0b82b3..8876694 100644 --- a/crates/mozart/src/commands/show.rs +++ b/crates/mozart/src/commands/show.rs @@ -110,8 +110,8 @@ pub async fn execute( cli: &super::Cli, console: &mozart_core::console::Console, ) -> anyhow::Result<()> { - let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache); - let repo_cache = mozart_registry::cache::Cache::repo(&cache_config); + let cache_config = mozart_core::repository::cache::build_cache_config(cli.no_cache); + let repo_cache = mozart_core::repository::cache::Cache::repo(&cache_config); // A9: --installed deprecation warning (mirrors Composer 143-145) if args.installed && !args.self_info { @@ -296,12 +296,13 @@ async fn fetch_latest_for_package( name: &str, current_normalized: &str, args: &ShowArgs, - repo_cache: &mozart_registry::cache::Cache, + repo_cache: &mozart_core::repository::cache::Cache, ) -> anyhow::Result { use mozart_core::package::Stability; - use mozart_registry::version::find_best_candidate; + use mozart_core::repository::version::find_best_candidate; - let versions = mozart_registry::packagist::fetch_package_versions(name, repo_cache).await?; + let versions = + mozart_core::repository::packagist::fetch_package_versions(name, repo_cache).await?; let current_major = extract_major(current_normalized); let current_minor = extract_minor(current_normalized); @@ -313,7 +314,7 @@ async fn fetch_latest_for_package( anyhow::bail!("Cannot determine major update for dev version of {name}"); } - let filtered: Vec = versions + let filtered: Vec = versions .iter() .filter(|v| { let v_norm = &v.version_normalized; @@ -355,7 +356,7 @@ fn abandoned_info(val: &serde_json::Value) -> Option { } fn classify_update_category(current_normalized: &str, latest_normalized: &str) -> ListUpdateKind { - use mozart_registry::version::compare_normalized_versions; + use mozart_core::repository::version::compare_normalized_versions; use std::cmp::Ordering; if compare_normalized_versions(latest_normalized, current_normalized) != Ordering::Greater { @@ -400,10 +401,10 @@ fn extract_minor(version_normalized: &str) -> u64 { // ============================================================================ async fn collect_installed_entries( - packages: &[&mozart_registry::installed::InstalledPackageEntry], + packages: &[&mozart_core::repository::installed::InstalledPackageEntry], args: &ShowArgs, direct_names: &IndexSet, - repo_cache: &mozart_registry::cache::Cache, + repo_cache: &mozart_core::repository::cache::Cache, ) -> Vec { let show_latest = args.latest || args.outdated; let mut entries = Vec::new(); @@ -435,7 +436,7 @@ async fn collect_installed_entries( if args.outdated { if let Some(ref li) = latest_info { - use mozart_registry::version::compare_normalized_versions; + use mozart_core::repository::version::compare_normalized_versions; use std::cmp::Ordering; if compare_normalized_versions(&li.version_normalized, &version_normalized) != Ordering::Greater @@ -462,10 +463,10 @@ async fn collect_installed_entries( } async fn collect_locked_entries( - packages: &[&mozart_registry::lockfile::LockedPackage], + packages: &[&mozart_core::repository::lockfile::LockedPackage], args: &ShowArgs, direct_names: &IndexSet, - repo_cache: &mozart_registry::cache::Cache, + repo_cache: &mozart_core::repository::cache::Cache, ) -> Vec { let show_latest = args.latest || args.outdated; let mut entries = Vec::new(); @@ -497,7 +498,7 @@ async fn collect_locked_entries( if args.outdated { if let Some(ref li) = latest_info { - use mozart_registry::version::compare_normalized_versions; + use mozart_core::repository::version::compare_normalized_versions; use std::cmp::Ordering; if compare_normalized_versions(&li.version_normalized, &version_normalized) != Ordering::Greater @@ -803,7 +804,7 @@ fn render_list_json( /// Build a `PackageDetail` from an installed package entry. fn installed_to_detail( - pkg: &mozart_registry::installed::InstalledPackageEntry, + pkg: &mozart_core::repository::installed::InstalledPackageEntry, vendor_dir: &Path, ) -> PackageDetail { let install_path = vendor_dir.join(&pkg.name); @@ -871,7 +872,7 @@ fn installed_to_detail( } /// Build a `PackageDetail` from a locked package entry. -fn locked_to_detail(pkg: &mozart_registry::lockfile::LockedPackage) -> PackageDetail { +fn locked_to_detail(pkg: &mozart_core::repository::lockfile::LockedPackage) -> PackageDetail { let mut names = vec![pkg.name.clone()]; names.extend(pkg.provide.keys().cloned()); names.extend(pkg.replace.keys().cloned()); @@ -927,7 +928,7 @@ fn locked_to_detail(pkg: &mozart_registry::lockfile::LockedPackage) -> PackageDe async fn print_package_detail( detail: &PackageDetail, args: &ShowArgs, - repo_cache: &mozart_registry::cache::Cache, + repo_cache: &mozart_core::repository::cache::Cache, console: &mozart_core::console::Console, ) -> anyhow::Result<()> { if args.format == "json" { @@ -1157,7 +1158,7 @@ fn print_links_section( async fn print_package_detail_json( detail: &PackageDetail, args: &ShowArgs, - repo_cache: &mozart_registry::cache::Cache, + repo_cache: &mozart_core::repository::cache::Cache, console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let mut obj = serde_json::json!({ @@ -1225,11 +1226,11 @@ async fn print_package_detail_json( async fn execute_installed( args: &ShowArgs, working_dir: &Path, - repo_cache: &mozart_registry::cache::Cache, + repo_cache: &mozart_core::repository::cache::Cache, console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let vendor_dir = working_dir.join("vendor"); - let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?; + let installed = mozart_core::repository::installed::InstalledPackages::read(&vendor_dir)?; if installed.packages.is_empty() { let composer_json_path = working_dir.join("composer.json"); @@ -1343,11 +1344,11 @@ async fn execute_installed( } fn filter_installed_packages<'a>( - installed: &'a mozart_registry::installed::InstalledPackages, + installed: &'a mozart_core::repository::installed::InstalledPackages, args: &ShowArgs, direct_names: &IndexSet, -) -> Vec<&'a mozart_registry::installed::InstalledPackageEntry> { - let mut packages: Vec<&mozart_registry::installed::InstalledPackageEntry> = +) -> Vec<&'a mozart_core::repository::installed::InstalledPackageEntry> { + let mut packages: Vec<&mozart_core::repository::installed::InstalledPackageEntry> = installed.packages.iter().collect(); // --no-dev: exclude dev packages @@ -1376,7 +1377,7 @@ fn filter_installed_packages<'a>( async fn execute_locked( args: &ShowArgs, working_dir: &Path, - repo_cache: &mozart_registry::cache::Cache, + repo_cache: &mozart_core::repository::cache::Cache, console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let lock_path = working_dir.join("composer.lock"); @@ -1386,9 +1387,9 @@ async fn execute_locked( ); } - let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; + let lock = mozart_core::repository::lockfile::LockFile::read_from_file(&lock_path)?; - let mut packages: Vec<&mozart_registry::lockfile::LockedPackage> = + let mut packages: Vec<&mozart_core::repository::lockfile::LockedPackage> = lock.packages.iter().collect(); if let Some(ref pkgs_dev) = lock.packages_dev @@ -1573,10 +1574,10 @@ fn show_tree( let root = mozart_core::package::read_from_file(&composer_json_path)?; - let pkg_map: IndexMap; + let pkg_map: IndexMap; let lock_storage; if lock_path.exists() { - lock_storage = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; + lock_storage = mozart_core::repository::lockfile::LockFile::read_from_file(&lock_path)?; pkg_map = lock_storage .packages .iter() @@ -1635,7 +1636,7 @@ fn show_tree( fn print_tree_node( pkg_name: &str, constraint: &str, - pkg_map: &IndexMap, + pkg_map: &IndexMap, prefix: &str, child_prefix: &str, visited: &mut IndexSet, @@ -1736,7 +1737,7 @@ fn show_platform( let lock_path = working_dir.join("composer.lock"); if lock_path.exists() { - let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; + let lock = mozart_core::repository::lockfile::LockFile::read_from_file(&lock_path)?; if let Some(obj) = lock.platform.as_object() { for (name, version_val) in obj { @@ -1839,7 +1840,7 @@ fn show_platform( async fn show_available( args: &ShowArgs, working_dir: &Path, - repo_cache: &mozart_registry::cache::Cache, + repo_cache: &mozart_core::repository::cache::Cache, console: &mozart_core::console::Console, ) -> anyhow::Result<()> { if let Some(ref pkg_name) = args.package { @@ -1847,21 +1848,21 @@ async fn show_available( } let vendor_dir = working_dir.join("vendor"); - let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir); + let installed = mozart_core::repository::installed::InstalledPackages::read(&vendor_dir); let installed = match installed { Ok(i) if !i.packages.is_empty() => i, _ => { let lock_path = working_dir.join("composer.lock"); if lock_path.exists() { - let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; + let lock = mozart_core::repository::lockfile::LockFile::read_from_file(&lock_path)?; console_writeln!( console, "Available versions for locked packages (from Packagist):", ); console_writeln!(console, ""); - let mut all_packages: Vec<&mozart_registry::lockfile::LockedPackage> = + let mut all_packages: Vec<&mozart_core::repository::lockfile::LockedPackage> = lock.packages.iter().collect(); if !args.no_dev && let Some(ref dev_pkgs) = lock.packages_dev @@ -1898,7 +1899,9 @@ async fn show_available( if is_platform_package(&pkg.name) { continue; } - match mozart_registry::packagist::fetch_package_versions(&pkg.name, repo_cache).await { + match mozart_core::repository::packagist::fetch_package_versions(&pkg.name, repo_cache) + .await + { Ok(versions) => { let version_strings: Vec = versions.iter().map(|v| v.version.clone()).collect(); @@ -1934,11 +1937,12 @@ async fn show_available( async fn show_available_versions( pkg_name: &str, - repo_cache: &mozart_registry::cache::Cache, + repo_cache: &mozart_core::repository::cache::Cache, args: &ShowArgs, console: &mozart_core::console::Console, ) -> anyhow::Result<()> { - let versions = mozart_registry::packagist::fetch_package_versions(pkg_name, repo_cache).await?; + let versions = + mozart_core::repository::packagist::fetch_package_versions(pkg_name, repo_cache).await?; if versions.is_empty() { console_writeln!(console, "No versions found for {pkg_name}"); return Ok(()); @@ -1967,10 +1971,10 @@ async fn show_available_versions( async fn show_available_versions_inline( pkg_name: &str, - repo_cache: &mozart_registry::cache::Cache, + repo_cache: &mozart_core::repository::cache::Cache, console: &mozart_core::console::Console, ) { - match mozart_registry::packagist::fetch_package_versions(pkg_name, repo_cache).await { + match mozart_core::repository::packagist::fetch_package_versions(pkg_name, repo_cache).await { Ok(versions) => { if versions.is_empty() { console_writeln!( @@ -2020,7 +2024,9 @@ fn format_version_highlight(version: &str) -> String { format!("* {}", format_version(version)) } -fn get_installed_description(pkg: &mozart_registry::installed::InstalledPackageEntry) -> String { +fn get_installed_description( + pkg: &mozart_core::repository::installed::InstalledPackageEntry, +) -> String { pkg.extra_fields .get("description") .and_then(|v| v.as_str()) @@ -2029,7 +2035,7 @@ fn get_installed_description(pkg: &mozart_registry::installed::InstalledPackageE } fn get_installed_keywords_vec( - pkg: &mozart_registry::installed::InstalledPackageEntry, + pkg: &mozart_core::repository::installed::InstalledPackageEntry, ) -> Vec { pkg.extra_fields .get("keywords") @@ -2042,7 +2048,9 @@ fn get_installed_keywords_vec( .unwrap_or_default() } -fn get_installed_licenses(pkg: &mozart_registry::installed::InstalledPackageEntry) -> Vec { +fn get_installed_licenses( + pkg: &mozart_core::repository::installed::InstalledPackageEntry, +) -> Vec { pkg.extra_fields .get("license") .and_then(|v| v.as_array()) @@ -2055,7 +2063,7 @@ fn get_installed_licenses(pkg: &mozart_registry::installed::InstalledPackageEntr } fn get_installed_homepage( - pkg: &mozart_registry::installed::InstalledPackageEntry, + pkg: &mozart_core::repository::installed::InstalledPackageEntry, ) -> Option { pkg.extra_fields .get("homepage") @@ -2064,7 +2072,7 @@ fn get_installed_homepage( } fn get_installed_release_date( - pkg: &mozart_registry::installed::InstalledPackageEntry, + pkg: &mozart_core::repository::installed::InstalledPackageEntry, ) -> Option { pkg.extra_fields .get("time") @@ -2075,7 +2083,7 @@ fn get_installed_release_date( /// Extract a map of `{name: constraint}` from an installed package's /// extra_fields for the given key (e.g. "require", "conflict", "provide"). fn get_installed_link_map( - pkg: &mozart_registry::installed::InstalledPackageEntry, + pkg: &mozart_core::repository::installed::InstalledPackageEntry, key: &str, ) -> BTreeMap { pkg.extra_fields @@ -2091,7 +2099,7 @@ fn get_installed_link_map( /// Extract a map of `{package: reason}` from an installed package's suggest field. fn get_installed_suggest_map( - pkg: &mozart_registry::installed::InstalledPackageEntry, + pkg: &mozart_core::repository::installed::InstalledPackageEntry, ) -> BTreeMap { pkg.extra_fields .get("suggest") @@ -2274,7 +2282,7 @@ mod tests { "description".to_string(), serde_json::Value::String("A logging library".to_string()), ); - let pkg = mozart_registry::installed::InstalledPackageEntry { + let pkg = mozart_core::repository::installed::InstalledPackageEntry { name: "monolog/monolog".to_string(), version: "3.0.0".to_string(), version_normalized: None, @@ -2294,7 +2302,7 @@ mod tests { #[test] fn test_get_installed_description_absent() { use std::collections::BTreeMap; - let pkg = mozart_registry::installed::InstalledPackageEntry { + let pkg = mozart_core::repository::installed::InstalledPackageEntry { name: "psr/log".to_string(), version: "3.0.0".to_string(), version_normalized: None, @@ -2319,7 +2327,7 @@ mod tests { "keywords".to_string(), serde_json::json!(["log", "psr3", "logging"]), ); - let pkg = mozart_registry::installed::InstalledPackageEntry { + let pkg = mozart_core::repository::installed::InstalledPackageEntry { name: "psr/log".to_string(), version: "3.0.0".to_string(), version_normalized: None, diff --git a/crates/mozart/src/commands/status.rs b/crates/mozart/src/commands/status.rs index d15ffb3..f0445bf 100644 --- a/crates/mozart/src/commands/status.rs +++ b/crates/mozart/src/commands/status.rs @@ -5,7 +5,7 @@ use mozart_core::console::Console; use mozart_core::console_writeln; use mozart_core::console_writeln_error; use mozart_core::exit_code; -use mozart_vcs::version_guesser::{VersionGuesser, VersionParser}; +use mozart_core::vcs::version_guesser::{VersionGuesser, VersionParser}; #[derive(Args)] pub struct StatusArgs {} diff --git a/crates/mozart/src/commands/suggests.rs b/crates/mozart/src/commands/suggests.rs index 690b9d3..1643e94 100644 --- a/crates/mozart/src/commands/suggests.rs +++ b/crates/mozart/src/commands/suggests.rs @@ -62,7 +62,7 @@ pub async fn execute( // Iterate every package that contributes suggestions: locked/installed, // then root. Mirrors `$installedRepo->getPackages() + $composer->getPackage()`. if has_lock { - let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; + let lock = mozart_core::repository::lockfile::LockFile::read_from_file(&lock_path)?; for pkg in lock.packages.iter() { if filter.is_empty() || filter.contains(&pkg.name) { reporter.add_suggestions_from_package(pkg); @@ -79,7 +79,7 @@ pub async fn execute( } } else { let vendor_dir = working_dir.join("vendor"); - let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?; + let installed = mozart_core::repository::installed::InstalledPackages::read(&vendor_dir)?; if installed.packages.is_empty() { let installed_json = vendor_dir.join("composer/installed.json"); @@ -148,9 +148,9 @@ fn build_installed_repo( if has_lock { let lock_path = working_dir.join("composer.lock"); - let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; + let lock = mozart_core::repository::lockfile::LockFile::read_from_file(&lock_path)?; - let mut all_packages: Vec<&mozart_registry::lockfile::LockedPackage> = + let mut all_packages: Vec<&mozart_core::repository::lockfile::LockedPackage> = lock.packages.iter().collect(); if !no_dev && let Some(ref pkgs_dev) = lock.packages_dev { all_packages.extend(pkgs_dev.iter()); @@ -179,7 +179,7 @@ fn build_installed_repo( } } else { let vendor_dir = working_dir.join("vendor"); - let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?; + let installed = mozart_core::repository::installed::InstalledPackages::read(&vendor_dir)?; let dev_names: IndexSet = installed .dev_package_names @@ -238,8 +238,8 @@ mod tests { fn make_locked_package( name: &str, suggest: Option>, - ) -> mozart_registry::lockfile::LockedPackage { - mozart_registry::lockfile::LockedPackage { + ) -> mozart_core::repository::lockfile::LockedPackage { + mozart_core::repository::lockfile::LockedPackage { name: name.to_string(), version: "1.0.0".to_string(), version_normalized: None, @@ -269,7 +269,7 @@ mod tests { fn make_installed_entry( name: &str, suggest: Option>, - ) -> mozart_registry::installed::InstalledPackageEntry { + ) -> mozart_core::repository::installed::InstalledPackageEntry { let mut extra_fields: BTreeMap = BTreeMap::new(); if let Some(s) = suggest { let map: serde_json::Map = s @@ -278,7 +278,7 @@ mod tests { .collect(); extra_fields.insert("suggest".to_string(), serde_json::Value::Object(map)); } - mozart_registry::installed::InstalledPackageEntry { + mozart_core::repository::installed::InstalledPackageEntry { name: name.to_string(), version: "1.0.0".to_string(), version_normalized: None, @@ -295,11 +295,11 @@ mod tests { } fn minimal_lock( - packages: Vec, - packages_dev: Option>, - ) -> mozart_registry::lockfile::LockFile { - mozart_registry::lockfile::LockFile { - readme: mozart_registry::lockfile::LockFile::default_readme(), + packages: Vec, + packages_dev: Option>, + ) -> mozart_core::repository::lockfile::LockFile { + mozart_core::repository::lockfile::LockFile { + readme: mozart_core::repository::lockfile::LockFile::default_readme(), content_hash: "abc123".to_string(), packages, packages_dev, diff --git a/crates/mozart/src/commands/update.rs b/crates/mozart/src/commands/update.rs index 11672fd..5498983 100644 --- a/crates/mozart/src/commands/update.rs +++ b/crates/mozart/src/commands/update.rs @@ -4,8 +4,8 @@ use indexmap::{IndexMap, IndexSet}; use mozart_core::console_format; use mozart_core::package; use mozart_core::platform::is_platform_package; -use mozart_registry::lockfile; -use mozart_registry::resolver::{ +use mozart_core::repository::lockfile; +use mozart_core::repository::resolver::{ self, LockedPackageInfo, PlatformConfig, ResolveRequest, ResolvedPackage, }; @@ -559,13 +559,13 @@ pub fn collect_repo_requires( repositories: &[mozart_core::package::RawRepository], ) -> IndexMap> { let mut out: IndexMap> = IndexMap::new(); - for ipkg in mozart_registry::inline_package::collect_inline_packages(repositories) { + for ipkg in mozart_core::repository::inline_package::collect_inline_packages(repositories) { let entry = out.entry(ipkg.name.to_lowercase()).or_default(); for req in ipkg.version.require.keys() { entry.insert(req.to_lowercase()); } } - for cpkg in mozart_registry::composer_repo::collect_composer_packages(repositories) { + for cpkg in mozart_core::repository::composer_repo::collect_composer_packages(repositories) { let entry = out.entry(cpkg.name.to_lowercase()).or_default(); for req in cpkg.version.require.keys() { entry.insert(req.to_lowercase()); @@ -923,13 +923,14 @@ pub async fn execute( cli: &super::Cli, console: &mozart_core::console::Console, ) -> anyhow::Result<()> { - let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache); - let repositories = - std::sync::Arc::new(mozart_registry::repository::RepositorySet::with_packagist( - mozart_registry::cache::Cache::repo(&cache_config), - )); - let mut executor = mozart_registry::installer_executor::FilesystemExecutor::new( - mozart_registry::cache::Cache::files(&cache_config), + let cache_config = mozart_core::repository::cache::build_cache_config(cli.no_cache); + let repositories = std::sync::Arc::new( + mozart_core::repository::repository::RepositorySet::with_packagist( + mozart_core::repository::cache::Cache::repo(&cache_config), + ), + ); + let mut executor = mozart_core::repository::installer_executor::FilesystemExecutor::new( + mozart_core::repository::cache::Cache::files(&cache_config), ); let working_dir = cli.working_dir()?; run( @@ -962,8 +963,8 @@ pub async fn run( path_repo_base_override: Option<&std::path::Path>, args: &UpdateArgs, console: &mozart_core::console::Console, - repositories: std::sync::Arc, - executor: &mut dyn mozart_registry::installer_executor::InstallerExecutor, + repositories: std::sync::Arc, + executor: &mut dyn mozart_core::repository::installer_executor::InstallerExecutor, ) -> anyhow::Result<()> { // Step 2: Handle deprecated flags if args.dev { @@ -1005,7 +1006,7 @@ pub async fn run( let path_repo_base = path_repo_base_override.unwrap_or(working_dir); let composer_json_expanded = { let mut clone = composer_json.clone(); - clone.repositories = mozart_registry::path_repository::expand_path_repositories( + clone.repositories = mozart_core::repository::path_repository::expand_path_repositories( &clone.repositories, path_repo_base, ); @@ -2379,8 +2380,8 @@ mod tests { #[ignore] async fn test_update_full_e2e() { use mozart_core::package::RawPackageData; - use mozart_registry::lockfile::{LockFileGenerationRequest, generate_lock_file}; - use mozart_registry::resolver::{ResolveRequest, resolve}; + use mozart_core::repository::lockfile::{LockFileGenerationRequest, generate_lock_file}; + use mozart_core::repository::resolver::{ResolveRequest, resolve}; let composer_json_content = r#"{"name": "test/project", "require": {"monolog/monolog": "^3.0"}}"#; @@ -2400,8 +2401,8 @@ mod tests { ignore_platform_reqs: false, ignore_platform_req_list: vec![], repositories: std::sync::Arc::new( - mozart_registry::repository::RepositorySet::with_packagist( - mozart_registry::cache::Cache::new( + mozart_core::repository::repository::RepositorySet::with_packagist( + mozart_core::repository::cache::Cache::new( std::env::temp_dir().join("mozart-test-cache"), false, ), @@ -2430,8 +2431,8 @@ mod tests { composer_json, include_dev: false, repositories: std::sync::Arc::new( - mozart_registry::repository::RepositorySet::with_packagist( - mozart_registry::cache::Cache::new( + mozart_core::repository::repository::RepositorySet::with_packagist( + mozart_core::repository::cache::Cache::new( std::env::temp_dir().join("mozart-test-cache"), false, ), diff --git a/crates/mozart/src/commands/validate.rs b/crates/mozart/src/commands/validate.rs index 873b371..853eb2b 100644 --- a/crates/mozart/src/commands/validate.rs +++ b/crates/mozart/src/commands/validate.rs @@ -337,7 +337,7 @@ fn check_lock_freshness( return; } - match mozart_registry::lockfile::LockFile::read_from_file(&lock_path) { + match mozart_core::repository::lockfile::LockFile::read_from_file(&lock_path) { Ok(lock) => { if !lock.is_fresh(composer_json_content) { lock_errors.push( @@ -578,7 +578,7 @@ mod tests { #[test] fn test_check_lock_freshness_fresh_lock() { - use mozart_registry::lockfile::LockFile; + use mozart_core::repository::lockfile::LockFile; use tempfile::tempdir; let dir = tempdir().unwrap(); @@ -614,7 +614,7 @@ mod tests { #[test] fn test_check_lock_freshness_stale_lock() { - use mozart_registry::lockfile::LockFile; + use mozart_core::repository::lockfile::LockFile; use tempfile::tempdir; let dir = tempdir().unwrap(); diff --git a/crates/mozart/src/composer.rs b/crates/mozart/src/composer.rs index 108a5d3..337b053 100644 --- a/crates/mozart/src/composer.rs +++ b/crates/mozart/src/composer.rs @@ -17,7 +17,7 @@ use crate::factory::create_composer; use mozart_core::composer::{AutoloadGenerator, InstallationManager, Locker, RepositoryManager}; use mozart_core::config::Config; use mozart_core::package::RawPackageData; -use mozart_registry::download_manager::DownloadManager; +use mozart_core::repository::download_manager::DownloadManager; /// Project-level Composer state. Mirrors `Composer\PartialComposer` / /// `Composer\Composer` in PHP, exposing the subset of getters command diff --git a/crates/mozart/src/factory.rs b/crates/mozart/src/factory.rs index 67fc9a3..ca46671 100644 --- a/crates/mozart/src/factory.rs +++ b/crates/mozart/src/factory.rs @@ -17,7 +17,7 @@ use mozart_core::composer::{ use mozart_core::config::resolve_references; use mozart_core::factory::create_config; use mozart_core::package::read_from_file; -use mozart_registry::download_manager::DownloadManager; +use mozart_core::repository::download_manager::DownloadManager; /// Rust port of `Factory::createComposer()`. /// diff --git a/crates/mozart/tests/installer.rs b/crates/mozart/tests/installer.rs index 78772fb..970fce4 100644 --- a/crates/mozart/tests/installer.rs +++ b/crates/mozart/tests/installer.rs @@ -15,8 +15,8 @@ use clap::Parser; use mozart::commands::{Cli, Commands, install, update}; use mozart_core::console::Console; use mozart_core::exit_code::MozartError; -use mozart_registry::installer_executor::TraceRecorderExecutor; -use mozart_registry::repository::RepositorySet; +use mozart_core::repository::installer_executor::TraceRecorderExecutor; +use mozart_core::repository::repository::RepositorySet; use mozart_test_harness::{ParsedTest, parse_test_file}; use tempfile::TempDir; -- cgit v1.3.1