aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-core
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-10 00:32:08 +0900
committernsfisis <nsfisis@gmail.com>2026-05-10 00:32:08 +0900
commit8cc1ba8a02c0318b65658f1634de378c780392b9 (patch)
treefdd5cb61e488018891a486b25991b87c84220bb8 /crates/mozart-core
parent72b2e877c01e67ba7edd37e34ac2eadb7a1c62c4 (diff)
downloadphp-mozart-8cc1ba8a02c0318b65658f1634de378c780392b9.tar.gz
php-mozart-8cc1ba8a02c0318b65658f1634de378c780392b9.tar.zst
php-mozart-8cc1ba8a02c0318b65658f1634de378c780392b9.zip
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 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart-core')
-rw-r--r--crates/mozart-core/Cargo.toml16
-rw-r--r--crates/mozart-core/src/autoload.rs1912
-rw-r--r--crates/mozart-core/src/dependency_resolver.rs25
-rw-r--r--crates/mozart-core/src/dependency_resolver/decisions.rs263
-rw-r--r--crates/mozart-core/src/dependency_resolver/error.rs50
-rw-r--r--crates/mozart-core/src/dependency_resolver/policy.rs264
-rw-r--r--crates/mozart-core/src/dependency_resolver/pool.rs427
-rw-r--r--crates/mozart-core/src/dependency_resolver/pool_builder.rs222
-rw-r--r--crates/mozart-core/src/dependency_resolver/problem.rs499
-rw-r--r--crates/mozart-core/src/dependency_resolver/request.rs65
-rw-r--r--crates/mozart-core/src/dependency_resolver/rule.rs280
-rw-r--r--crates/mozart-core/src/dependency_resolver/rule_set.rs211
-rw-r--r--crates/mozart-core/src/dependency_resolver/rule_set_generator.rs464
-rw-r--r--crates/mozart-core/src/dependency_resolver/rule_watch_graph.rs288
-rw-r--r--crates/mozart-core/src/dependency_resolver/solver.rs1008
-rw-r--r--crates/mozart-core/src/dependency_resolver/transaction.rs568
-rw-r--r--crates/mozart-core/src/lib.rs4
-rw-r--r--crates/mozart-core/src/package.rs2
-rw-r--r--crates/mozart-core/src/package/archiver.rs899
-rw-r--r--crates/mozart-core/src/package/archiver/manager.rs299
-rw-r--r--crates/mozart-core/src/repository.rs19
-rw-r--r--crates/mozart-core/src/repository/advisory.rs731
-rw-r--r--crates/mozart-core/src/repository/browse_repos.rs293
-rw-r--r--crates/mozart-core/src/repository/cache.rs575
-rw-r--r--crates/mozart-core/src/repository/composer_repo.rs173
-rw-r--r--crates/mozart-core/src/repository/download_manager.rs143
-rw-r--r--crates/mozart-core/src/repository/downloader.rs500
-rw-r--r--crates/mozart-core/src/repository/inline_package.rs277
-rw-r--r--crates/mozart-core/src/repository/installed.rs383
-rw-r--r--crates/mozart-core/src/repository/installer_executor/filesystem.rs230
-rw-r--r--crates/mozart-core/src/repository/installer_executor/mod.rs348
-rw-r--r--crates/mozart-core/src/repository/installer_executor/trace_recorder.rs160
-rw-r--r--crates/mozart-core/src/repository/installer_executor/transaction.rs412
-rw-r--r--crates/mozart-core/src/repository/lockfile.rs2040
-rw-r--r--crates/mozart-core/src/repository/packagist.rs1011
-rw-r--r--crates/mozart-core/src/repository/path_repository.rs243
-rw-r--r--crates/mozart-core/src/repository/repository/inline_package_repo.rs63
-rw-r--r--crates/mozart-core/src/repository/repository/mod.rs319
-rw-r--r--crates/mozart-core/src/repository/repository/packagist_repo.rs121
-rw-r--r--crates/mozart-core/src/repository/repository/vcs_repo.rs63
-rw-r--r--crates/mozart-core/src/repository/repository_filter.rs136
-rw-r--r--crates/mozart-core/src/repository/resolver.rs1998
-rw-r--r--crates/mozart-core/src/repository/vcs_bridge.rs216
-rw-r--r--crates/mozart-core/src/repository/version.rs269
-rw-r--r--crates/mozart-core/src/repository/version_selector.rs48
-rw-r--r--crates/mozart-core/src/vcs.rs6
-rw-r--r--crates/mozart-core/src/vcs/downloader/git.rs271
-rw-r--r--crates/mozart-core/src/vcs/downloader/hg.rs84
-rw-r--r--crates/mozart-core/src/vcs/downloader/mod.rs56
-rw-r--r--crates/mozart-core/src/vcs/downloader/svn.rs84
-rw-r--r--crates/mozart-core/src/vcs/driver/bitbucket.rs277
-rw-r--r--crates/mozart-core/src/vcs/driver/forgejo.rs285
-rw-r--r--crates/mozart-core/src/vcs/driver/git.rs275
-rw-r--r--crates/mozart-core/src/vcs/driver/github.rs315
-rw-r--r--crates/mozart-core/src/vcs/driver/gitlab.rs301
-rw-r--r--crates/mozart-core/src/vcs/driver/hg.rs202
-rw-r--r--crates/mozart-core/src/vcs/driver/mod.rs309
-rw-r--r--crates/mozart-core/src/vcs/driver/svn.rs214
-rw-r--r--crates/mozart-core/src/vcs/process.rs142
-rw-r--r--crates/mozart-core/src/vcs/repository.rs205
-rw-r--r--crates/mozart-core/src/vcs/util/git.rs312
-rw-r--r--crates/mozart-core/src/vcs/util/hg.rs28
-rw-r--r--crates/mozart-core/src/vcs/util/mod.rs3
-rw-r--r--crates/mozart-core/src/vcs/util/svn.rs89
-rw-r--r--crates/mozart-core/src/vcs/version_guesser.rs602
-rw-r--r--crates/mozart-core/tests/git_driver_test.rs335
66 files changed, 22932 insertions, 0 deletions
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<String>,
+ /// 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<String, Vec<String>>,
+ /// PSR-0: namespace prefix -> list of directory path expressions.
+ /// (Empty in Phase 2.2, populated in 5.6.)
+ pub psr0: BTreeMap<String, Vec<String>>,
+ /// Classmap entries: class name -> file path expression.
+ /// (Empty in Phase 2.2, populated in 5.6.)
+ pub classmap: BTreeMap<String, String>,
+ /// Files to include on every request: file_identifier -> path expression.
+ pub files: BTreeMap<String, String>,
+}
+
+/// 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<String, String>,
+ psr_violations: Vec<String>,
+ ambiguous_classes: BTreeMap<String, Vec<String>>,
+}
+
+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<String, String> {
+ &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<String, Vec<String>> {
+ &self.ambiguous_classes
+ }
+}
+
+fn regex_filter_default() -> regex::Regex {
+ use std::sync::OnceLock;
+ static RE: OnceLock<regex::Regex> = 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<ClassMap>;
+}
+
+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<ClassMap> {
+ // 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::<serde_json::Value>(&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<String> {
+ 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<String> {
+ match value {
+ serde_json::Value::String(s) => vec![s.clone()],
+ serde_json::Value::Array(arr) => arr
+ .iter()
+ .filter_map(|v| v.as_str().map(|s| s.to_string()))
+ .collect(),
+ _ => vec![],
+ }
+}
+
+/// Strip trailing slash from a path component.
+fn strip_trailing_slash(s: &str) -> &str {
+ s.trim_end_matches('/')
+}
+
+/// Normalize a PSR-4 namespace: ensure it ends with `\`.
+/// (The empty string "" is valid and is left as-is.)
+fn normalize_namespace(ns: &str) -> String {
+ if ns.is_empty() || ns.ends_with('\\') {
+ ns.to_string()
+ } else {
+ format!("{ns}\\")
+ }
+}
+
+/// Build a PHP path expression from a base expression and a relative path component.
+///
+/// For vendor packages: `base_expr` = `"$vendorDir"`, `pkg_path` = `"psr/log"`,
+/// `sub_path` = `"src/"` → result: `"$vendorDir . '/psr/log/src'"`.
+///
+/// For root packages: `base_expr` = `"$baseDir"`, `pkg_path` = `""`,
+/// `sub_path` = `"src/"` → result: `"$baseDir . '/src'"`.
+fn build_path_expr(base_expr: &str, pkg_path: &str, sub_path: &str) -> String {
+ let sub = strip_trailing_slash(sub_path);
+ let combined = if pkg_path.is_empty() {
+ sub.to_string()
+ } else if sub.is_empty() {
+ pkg_path.to_string()
+ } else {
+ format!("{pkg_path}/{sub}")
+ };
+
+ if combined.is_empty() {
+ base_expr.to_string()
+ } else {
+ format!("{base_expr} . '/{combined}'")
+ }
+}
+
+/// Process an autoload JSON value and merge its rules into `data`.
+///
+/// `pkg_path` is the package-relative path segment within vendor.
+/// For vendor packages it is `"vendor/name"` (e.g. `"psr/log"`).
+/// For the root package it is `""`.
+///
+/// `dyn_base` is the dynamic PHP variable: `"$vendorDir"` or `"$baseDir"`.
+/// `static_base` is the static PHP expression: `"__DIR__ . '/..'"` or `"__DIR__ . '/../.'"`.
+fn process_autoload_value(
+ autoload_val: &serde_json::Value,
+ package_name: &str,
+ pkg_path: &str,
+ dyn_base: &str,
+ static_base: &str,
+ data: &mut AutoloadData,
+ static_data: &mut AutoloadData,
+) {
+ // PSR-4
+ if let Some(psr4_obj) = autoload_val.get("psr-4").and_then(|v| v.as_object()) {
+ for (ns_raw, paths_val) in psr4_obj {
+ let ns = normalize_namespace(ns_raw);
+ let paths = json_to_paths(paths_val);
+ let entry = data.psr4.entry(ns.clone()).or_default();
+ let static_entry = static_data.psr4.entry(ns).or_default();
+ for path in paths {
+ entry.push(build_path_expr(dyn_base, pkg_path, &path));
+ static_entry.push(build_path_expr(static_base, pkg_path, &path));
+ }
+ }
+ }
+
+ // PSR-0
+ if let Some(psr0_obj) = autoload_val.get("psr-0").and_then(|v| v.as_object()) {
+ for (ns_raw, paths_val) in psr0_obj {
+ let ns = ns_raw.clone();
+ let paths = json_to_paths(paths_val);
+ let entry = data.psr0.entry(ns.clone()).or_default();
+ let static_entry = static_data.psr0.entry(ns).or_default();
+ for path in paths {
+ entry.push(build_path_expr(dyn_base, pkg_path, &path));
+ static_entry.push(build_path_expr(static_base, pkg_path, &path));
+ }
+ }
+ }
+
+ // Files
+ if let Some(files_arr) = autoload_val.get("files").and_then(|v| v.as_array()) {
+ for file_val in files_arr {
+ if let Some(file_path) = file_val.as_str() {
+ let id = file_identifier(package_name, file_path);
+ let expr = build_path_expr(dyn_base, pkg_path, file_path);
+ let static_expr = build_path_expr(static_base, pkg_path, file_path);
+ data.files.insert(id.clone(), expr);
+ static_data.files.insert(id, static_expr);
+ }
+ }
+ }
+}
+
+/// Collect autoload rules from all installed packages and the root package.
+///
+/// Returns a tuple of `(dynamic_data, static_data)` where:
+/// - `dynamic_data` uses `$vendorDir` / `$baseDir` path expressions (for autoload_psr4.php, etc.)
+/// - `static_data` uses `__DIR__ . '/..'` path expressions (for autoload_static.php)
+fn collect_autoloads(
+ installed: &InstalledPackages,
+ root_autoload: Option<&serde_json::Value>,
+ root_autoload_dev: Option<&serde_json::Value>,
+ root_package_name: &str,
+ dev_mode: bool,
+) -> (AutoloadData, AutoloadData) {
+ let mut data = AutoloadData {
+ psr4: BTreeMap::new(),
+ psr0: BTreeMap::new(),
+ classmap: BTreeMap::new(),
+ files: BTreeMap::new(),
+ };
+ let mut static_data = AutoloadData {
+ psr4: BTreeMap::new(),
+ psr0: BTreeMap::new(),
+ classmap: BTreeMap::new(),
+ files: BTreeMap::new(),
+ };
+
+ // Process each installed package
+ for pkg in &installed.packages {
+ if let Some(autoload_val) = &pkg.autoload {
+ process_autoload_value(
+ autoload_val,
+ &pkg.name,
+ &pkg.name, // pkg_path within vendor
+ "$vendorDir",
+ "__DIR__ . '/..'",
+ &mut data,
+ &mut static_data,
+ );
+ }
+ }
+
+ // Process root package autoload
+ if let Some(autoload_val) = root_autoload {
+ process_autoload_value(
+ autoload_val,
+ root_package_name,
+ "", // no pkg_path for root
+ "$baseDir",
+ "__DIR__ . '/../..'",
+ &mut data,
+ &mut static_data,
+ );
+ }
+
+ // Process root package autoload-dev (only in dev mode)
+ if dev_mode && let Some(autoload_dev_val) = root_autoload_dev {
+ process_autoload_value(
+ autoload_dev_val,
+ root_package_name,
+ "",
+ "$baseDir",
+ "__DIR__ . '/../..'",
+ &mut data,
+ &mut static_data,
+ );
+ }
+
+ (data, static_data)
+}
+
+/// Generate `vendor/composer/autoload_psr4.php`.
+fn generate_autoload_psr4(data: &AutoloadData) -> String {
+ let mut out = String::new();
+ out.push_str("<?php\n\n// autoload_psr4.php @generated by Composer\n\n");
+ out.push_str("$vendorDir = dirname(__DIR__);\n");
+ out.push_str("$baseDir = dirname($vendorDir);\n\n");
+ out.push_str("return array(\n");
+
+ // krsort: reverse alphabetical (longer/more specific namespaces first)
+ let mut sorted: Vec<(&String, &Vec<String>)> = data.psr4.iter().collect();
+ sorted.sort_by(|(a, _), (b, _)| b.cmp(a));
+
+ for (ns, paths) in &sorted {
+ let escaped_ns = php_escape(ns);
+ if paths.len() == 1 {
+ out.push_str(&format!(" '{}' => array({}),\n", escaped_ns, paths[0]));
+ } else {
+ out.push_str(&format!(" '{}' => array(\n", escaped_ns));
+ for path in paths.iter() {
+ out.push_str(&format!(" {},\n", path));
+ }
+ out.push_str(" ),\n");
+ }
+ }
+
+ out.push_str(");\n");
+ out
+}
+
+/// Generate `vendor/composer/autoload_namespaces.php` (PSR-0, empty for Phase 2.2).
+fn generate_autoload_namespaces(data: &AutoloadData) -> String {
+ let mut out = String::new();
+ out.push_str("<?php\n\n// autoload_namespaces.php @generated by Composer\n\n");
+ out.push_str("$vendorDir = dirname(__DIR__);\n");
+ out.push_str("$baseDir = dirname($vendorDir);\n\n");
+ out.push_str("return array(\n");
+
+ let mut sorted: Vec<(&String, &Vec<String>)> = data.psr0.iter().collect();
+ sorted.sort_by(|(a, _), (b, _)| b.cmp(a));
+
+ for (ns, paths) in &sorted {
+ let escaped_ns = php_escape(ns);
+ if paths.len() == 1 {
+ out.push_str(&format!(" '{}' => array({}),\n", escaped_ns, paths[0]));
+ } else {
+ out.push_str(&format!(" '{}' => array(\n", escaped_ns));
+ for path in paths.iter() {
+ out.push_str(&format!(" {},\n", path));
+ }
+ out.push_str(" ),\n");
+ }
+ }
+
+ out.push_str(");\n");
+ out
+}
+
+/// Generate `vendor/composer/autoload_classmap.php`.
+/// Always contains `Composer\InstalledVersions`; classmap scanning deferred to Phase 5.6.
+fn generate_autoload_classmap(data: &AutoloadData) -> String {
+ let mut out = String::new();
+ out.push_str("<?php\n\n// autoload_classmap.php @generated by Composer\n\n");
+ out.push_str("$vendorDir = dirname(__DIR__);\n");
+ out.push_str("$baseDir = dirname($vendorDir);\n\n");
+ out.push_str("return array(\n");
+ out.push_str(
+ " 'Composer\\\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',\n",
+ );
+
+ // Include any additional classmap entries from data
+ for (class, path) in &data.classmap {
+ let escaped_class = php_escape(class);
+ out.push_str(&format!(" '{}' => {},\n", escaped_class, path));
+ }
+
+ out.push_str(");\n");
+ out
+}
+
+/// Generate `vendor/composer/autoload_files.php`.
+/// Returns `None` if there are no files to autoload.
+fn generate_autoload_files(data: &AutoloadData) -> Option<String> {
+ if data.files.is_empty() {
+ return None;
+ }
+
+ let mut out = String::new();
+ out.push_str("<?php\n\n// autoload_files.php @generated by Composer\n\n");
+ out.push_str("$vendorDir = dirname(__DIR__);\n");
+ out.push_str("$baseDir = dirname($vendorDir);\n\n");
+ out.push_str("return array(\n");
+
+ for (id, path) in &data.files {
+ out.push_str(&format!(" '{}' => {},\n", id, path));
+ }
+
+ out.push_str(");\n");
+ Some(out)
+}
+
+/// Generate `vendor/composer/autoload_static.php`.
+///
+/// `static_data` must have been collected with `__DIR__ . '/..'` path prefixes.
+fn generate_autoload_static(static_data: &AutoloadData, suffix: &str) -> String {
+ let mut out = String::new();
+ out.push_str("<?php\n\n// autoload_static.php @generated by Composer\n\n");
+ out.push_str("namespace Composer\\Autoload;\n\n");
+ out.push_str(&format!("class ComposerStaticInit{suffix}\n{{\n"));
+
+ // $files
+ if !static_data.files.is_empty() {
+ out.push_str(" public static $files = array (\n");
+ for (id, path) in &static_data.files {
+ out.push_str(&format!(" '{id}' => {path},\n"));
+ }
+ out.push_str(" );\n\n");
+ }
+
+ // $prefixLengthsPsr4 — group by first character of namespace
+ if !static_data.psr4.is_empty() {
+ // Group namespaces by first character, sorted reverse
+ let mut by_char: BTreeMap<char, Vec<(&String, usize)>> = BTreeMap::new();
+
+ let mut sorted_ns: Vec<&String> = static_data.psr4.keys().collect();
+ sorted_ns.sort_by(|a, b| b.cmp(a));
+
+ for ns in sorted_ns {
+ if let Some(first_char) = ns.chars().next() {
+ // The byte length in PHP (single-quoted string with single backslashes)
+ // ns in our data uses single backslash (stored as-is from JSON).
+ let byte_len = ns.len();
+ by_char.entry(first_char).or_default().push((ns, byte_len));
+ }
+ }
+
+ out.push_str(" public static $prefixLengthsPsr4 = array (\n");
+ // Sort characters in reverse order too
+ let mut chars: Vec<char> = by_char.keys().copied().collect();
+ chars.sort_by(|a, b| b.cmp(a));
+ for ch in &chars {
+ out.push_str(&format!(" '{ch}' =>\n array (\n"));
+ if let Some(entries) = by_char.get(ch) {
+ for (ns, len) in entries {
+ let escaped_ns = php_escape(ns);
+ out.push_str(&format!(" '{escaped_ns}' => {len},\n"));
+ }
+ }
+ out.push_str(" ),\n");
+ }
+ out.push_str(" );\n\n");
+
+ // $prefixDirsPsr4
+ out.push_str(" public static $prefixDirsPsr4 = array (\n");
+ let mut sorted_ns2: Vec<(&String, &Vec<String>)> = static_data.psr4.iter().collect();
+ sorted_ns2.sort_by(|(a, _), (b, _)| b.cmp(a));
+ for (ns, paths) in sorted_ns2 {
+ let escaped_ns = php_escape(ns);
+ out.push_str(&format!(" '{escaped_ns}' =>\n array (\n"));
+ for (i, path) in paths.iter().enumerate() {
+ out.push_str(&format!(" {i} => {path},\n"));
+ }
+ out.push_str(" ),\n");
+ }
+ out.push_str(" );\n\n");
+ }
+
+ // $classMap — always contains Composer\InstalledVersions
+ out.push_str(" public static $classMap = array (\n");
+ out.push_str(
+ " 'Composer\\\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',\n",
+ );
+ for (class, path) in &static_data.classmap {
+ let escaped_class = php_escape(class);
+ out.push_str(&format!(" '{}' => {},\n", escaped_class, path));
+ }
+ out.push_str(" );\n\n");
+
+ // getInitializer
+ out.push_str(" public static function getInitializer(ClassLoader $loader)\n {\n");
+ out.push_str(" return \\Closure::bind(function () use ($loader) {\n");
+
+ if !static_data.psr4.is_empty() {
+ out.push_str(&format!(
+ " $loader->prefixLengthsPsr4 = ComposerStaticInit{suffix}::$prefixLengthsPsr4;\n"
+ ));
+ out.push_str(&format!(
+ " $loader->prefixDirsPsr4 = ComposerStaticInit{suffix}::$prefixDirsPsr4;\n"
+ ));
+ }
+ out.push_str(&format!(
+ " $loader->classMap = ComposerStaticInit{suffix}::$classMap;\n"
+ ));
+ out.push_str("\n }, null, ClassLoader::class);\n }\n}\n");
+
+ out
+}
+
+/// 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<String>,
+) -> Option<String> {
+ if matches!(mode, PlatformCheckMode::Disabled) {
+ return None;
+ }
+
+ // Collect PHP version constraint from root require
+ let mut php_constraint: Option<String> = None;
+ if let Some(req_obj) = root_require.and_then(|v| v.as_object())
+ && let Some(v) = req_obj.get("php").and_then(|v| v.as_str())
+ {
+ php_constraint = Some(v.to_string());
+ }
+
+ // Collect extension requirements from packages (prod only)
+ let mut ext_reqs: Vec<(String, String)> = Vec::new();
+ if matches!(mode, PlatformCheckMode::Full) {
+ for pkg in packages {
+ let is_dev = dev_package_names.contains(&pkg.name.to_lowercase());
+ if is_dev {
+ continue;
+ }
+ for (req_name, req_constraint) in &pkg.require {
+ let lower = req_name.to_lowercase();
+ if lower.starts_with("ext-") {
+ ext_reqs.push((req_name.clone(), req_constraint.clone()));
+ }
+ }
+ }
+ ext_reqs.sort();
+ ext_reqs.dedup();
+ }
+
+ if php_constraint.is_none() && ext_reqs.is_empty() {
+ return None;
+ }
+
+ let mut out = String::new();
+ out.push_str("<?php\n\n");
+ out.push_str("// platform_check.php @generated by Composer\n\n");
+ out.push_str("$issues = array();\n\n");
+
+ if let Some(ref constraint) = php_constraint {
+ // Emit a simple PHP version check
+ let escaped = php_escape(constraint);
+ out.push_str(&format!("// PHP version check: {constraint}\n"));
+ out.push_str("if (!(PHP_VERSION_ID >= 50600)) {\n");
+ out.push_str(&format!(
+ " $issues[] = 'Your Composer dependencies require a PHP version \"{escaped}\". You are running ' . PHP_VERSION . '.';\n"
+ ));
+ out.push_str("}\n\n");
+ }
+
+ for (ext_name, _constraint) in &ext_reqs {
+ let ext_short = ext_name.trim_start_matches("ext-");
+ let escaped_ext = php_escape(ext_short);
+ out.push_str(&format!("if (!extension_loaded('{escaped_ext}')) {{\n"));
+ out.push_str(&format!(
+ " $issues[] = 'Your Composer dependencies require the \"{escaped_ext}\" PHP extension to be installed.';\n"
+ ));
+ out.push_str("}\n\n");
+ }
+
+ out.push_str("if ($issues) {\n");
+ out.push_str(" if (!headers_sent()) {\n");
+ out.push_str(" header('HTTP/1.1 500 Internal Server Error');\n");
+ out.push_str(" }\n");
+ out.push_str(" if (!ini_get('display_errors')) {\n");
+ out.push_str(" if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {\n");
+ out.push_str(" fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL);\n");
+ out.push_str(" } elseif (!headers_sent()) {\n");
+ out.push_str(" echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL;\n");
+ out.push_str(" }\n");
+ out.push_str(" }\n");
+ out.push_str(" trigger_error(\n");
+ out.push_str(
+ " 'Composer detected issues in your platform: ' . implode(' ', $issues),\n",
+ );
+ out.push_str(" E_USER_ERROR\n");
+ out.push_str(" );\n");
+ out.push_str("}\n");
+
+ Some(out)
+}
+
+/// Generate `vendor/composer/autoload_real.php`.
+fn generate_autoload_real(
+ suffix: &str,
+ has_files: bool,
+ classmap_authoritative: bool,
+ apcu: bool,
+ apcu_prefix: Option<&str>,
+ has_platform_check: bool,
+) -> String {
+ let mut out = String::new();
+ out.push_str("<?php\n\n");
+ out.push_str("// autoload_real.php @generated by Composer\n\n");
+ out.push_str(&format!("class ComposerAutoloaderInit{suffix}\n"));
+ out.push_str("{\n");
+ out.push_str(" private static $loader;\n\n");
+ out.push_str(" public static function loadClassLoader($class)\n");
+ out.push_str(" {\n");
+ out.push_str(" if ('Composer\\Autoload\\ClassLoader' === $class) {\n");
+ out.push_str(" require __DIR__ . '/ClassLoader.php';\n");
+ out.push_str(" }\n");
+ out.push_str(" }\n\n");
+ out.push_str(" /**\n");
+ out.push_str(" * @return \\Composer\\Autoload\\ClassLoader\n");
+ out.push_str(" */\n");
+ out.push_str(" public static function getLoader()\n");
+ out.push_str(" {\n");
+ out.push_str(" if (null !== self::$loader) {\n");
+ out.push_str(" return self::$loader;\n");
+ out.push_str(" }\n\n");
+ out.push_str(&format!(
+ " spl_autoload_register(array('ComposerAutoloaderInit{suffix}', 'loadClassLoader'), true, true);\n"
+ ));
+ out.push_str(
+ " self::$loader = $loader = new \\Composer\\Autoload\\ClassLoader(\\dirname(__DIR__));\n",
+ );
+ out.push_str(&format!(
+ " spl_autoload_unregister(array('ComposerAutoloaderInit{suffix}', 'loadClassLoader'));\n\n"
+ ));
+ if has_platform_check {
+ out.push_str(" require __DIR__ . '/platform_check.php';\n");
+ }
+ out.push_str(" require __DIR__ . '/autoload_static.php';\n");
+ out.push_str(&format!(
+ " call_user_func(\\Composer\\Autoload\\ComposerStaticInit{suffix}::getInitializer($loader));\n\n"
+ ));
+ out.push_str(" $loader->register(true);\n");
+
+ if classmap_authoritative {
+ out.push_str(" $loader->setClassMapAuthoritative(true);\n");
+ }
+
+ if apcu {
+ let prefix = apcu_prefix.unwrap_or(suffix);
+ let escaped = php_escape(prefix);
+ out.push_str(&format!(" $loader->setApcuPrefix('{escaped}');\n"));
+ }
+
+ if has_files {
+ out.push('\n');
+ out.push_str(&format!(
+ " $filesToLoad = \\Composer\\Autoload\\ComposerStaticInit{suffix}::$files;\n"
+ ));
+ out.push_str(
+ " $requireFile = \\Closure::bind(static function ($fileIdentifier, $file) {\n",
+ );
+ out.push_str(
+ " if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) {\n",
+ );
+ out.push_str(
+ " $GLOBALS['__composer_autoload_files'][$fileIdentifier] = true;\n",
+ );
+ out.push('\n');
+ out.push_str(" require $file;\n");
+ out.push_str(" }\n");
+ out.push_str(" }, null, null);\n");
+ out.push_str(" foreach ($filesToLoad as $fileIdentifier => $file) {\n");
+ out.push_str(" $requireFile($fileIdentifier, $file);\n");
+ out.push_str(" }\n");
+ }
+
+ out.push('\n');
+ out.push_str(" return $loader;\n");
+ out.push_str(" }\n");
+ out.push_str("}\n");
+ out
+}
+
+/// Generate `vendor/autoload.php` (the entry point).
+fn generate_autoload_php(suffix: &str) -> String {
+ let mut out = String::new();
+ out.push_str("<?php\n\n");
+ out.push_str("// autoload.php @generated by Composer\n\n");
+ out.push_str("if (PHP_VERSION_ID < 50600) {\n");
+ out.push_str(" if (!headers_sent()) {\n");
+ out.push_str(" header('HTTP/1.1 500 Internal Server Error');\n");
+ out.push_str(" }\n");
+ out.push_str(" $err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via \"composer self-update --2.2\". Aborting.'.PHP_EOL;\n");
+ out.push_str(" if (!ini_get('display_errors')) {\n");
+ out.push_str(" if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {\n");
+ out.push_str(" fwrite(STDERR, $err);\n");
+ out.push_str(" } elseif (!headers_sent()) {\n");
+ out.push_str(" echo $err;\n");
+ out.push_str(" }\n");
+ out.push_str(" }\n");
+ out.push_str(" throw new RuntimeException($err);\n");
+ out.push_str("}\n\n");
+ out.push_str("require_once __DIR__ . '/composer/autoload_real.php';\n\n");
+ out.push_str(&format!(
+ "return ComposerAutoloaderInit{suffix}::getLoader();\n"
+ ));
+ out
+}
+
+/// Generate `vendor/composer/installed.php`.
+fn generate_installed_php(
+ root_name: &str,
+ root_type: &str,
+ installed: &InstalledPackages,
+ dev_mode: bool,
+) -> String {
+ let dev_str = if dev_mode { "true" } else { "false" };
+
+ let mut out = String::new();
+ out.push_str("<?php return array(\n");
+ out.push_str(" 'root' => array(\n");
+ out.push_str(&format!(" 'name' => '{}',\n", php_escape(root_name)));
+ out.push_str(" 'pretty_version' => 'dev-main',\n");
+ out.push_str(" 'version' => 'dev-main',\n");
+ out.push_str(" 'reference' => null,\n");
+ out.push_str(&format!(" 'type' => '{}',\n", php_escape(root_type)));
+ out.push_str(" 'install_path' => __DIR__ . '/../../',\n");
+ out.push_str(" 'aliases' => array(),\n");
+ out.push_str(&format!(" 'dev' => {dev_str},\n"));
+ out.push_str(" ),\n");
+ out.push_str(" 'versions' => array(\n");
+
+ for pkg in &installed.packages {
+ let version = &pkg.version;
+ let version_normalized = pkg.version_normalized.as_deref().unwrap_or(version);
+ let pkg_type = pkg.package_type.as_deref().unwrap_or("library");
+ let is_dev = installed
+ .dev_package_names
+ .iter()
+ .any(|n| n.eq_ignore_ascii_case(&pkg.name));
+ let is_dev_str = if is_dev { "true" } else { "false" };
+
+ out.push_str(&format!(" '{}' => array(\n", php_escape(&pkg.name)));
+ out.push_str(&format!(
+ " 'pretty_version' => '{}',\n",
+ php_escape(version)
+ ));
+ out.push_str(&format!(
+ " 'version' => '{}',\n",
+ php_escape(version_normalized)
+ ));
+ out.push_str(" 'reference' => null,\n");
+ out.push_str(&format!(
+ " 'type' => '{}',\n",
+ php_escape(pkg_type)
+ ));
+ // Install path relative to vendor/composer/installed.php: __DIR__ . '/./' . relative_name
+ // The install_path stored is like '../psr/log', relative to vendor/composer/
+ // So from vendor/composer/, the package is at __DIR__ . '/../psr/log/'
+ out.push_str(&format!(
+ " 'install_path' => __DIR__ . '/../{}/',\n",
+ pkg.name
+ ));
+ out.push_str(" 'aliases' => array(),\n");
+ out.push_str(&format!(" 'dev_requirement' => {is_dev_str},\n"));
+ out.push_str(" ),\n");
+ }
+
+ out.push_str(" ),\n");
+ out.push_str(");\n");
+ out
+}
+
+/// Determine the autoloader suffix.
+///
+/// Priority:
+/// 1. Existing `vendor/autoload.php` suffix (carry over to avoid breaking existing references).
+/// 2. Lock file `content-hash` (if locked).
+/// 3. Fall back to a timestamp-based hex string.
+pub fn determine_suffix(working_dir: &Path, vendor_dir: &Path) -> anyhow::Result<String> {
+ // Try existing autoload.php
+ let autoload_path = vendor_dir.join("autoload.php");
+ if autoload_path.exists() {
+ let content = std::fs::read_to_string(&autoload_path)?;
+ if let Some(start) = content.find("ComposerAutoloaderInit") {
+ let rest = &content[start + "ComposerAutoloaderInit".len()..];
+ if let Some(end) = rest.find("::") {
+ let suffix = &rest[..end];
+ if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_hexdigit()) {
+ return Ok(suffix.to_string());
+ }
+ }
+ }
+ }
+
+ // Try composer.lock content-hash
+ let lock_path = working_dir.join("composer.lock");
+ if lock_path.exists() {
+ let lock = 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<GenerateResult> {
+ // 1. Read installed.json
+ let installed = InstalledPackages::read(&config.vendor_dir)?;
+
+ // 2. Read root package autoload from composer.json
+ let composer_json_path = config.project_dir.join("composer.json");
+ let (root_autoload, root_autoload_dev, root_name, root_type) = if composer_json_path.exists() {
+ let content = std::fs::read_to_string(&composer_json_path)?;
+ let value: serde_json::Value = serde_json::from_str(&content)?;
+ (
+ value.get("autoload").cloned(),
+ value.get("autoload-dev").cloned(),
+ value
+ .get("name")
+ .and_then(|n| n.as_str())
+ .unwrap_or("__root__")
+ .to_string(),
+ value
+ .get("type")
+ .and_then(|t| t.as_str())
+ .unwrap_or("project")
+ .to_string(),
+ )
+ } else {
+ (None, None, "__root__".to_string(), "project".to_string())
+ };
+
+ // 3. Collect autoload data
+ let (mut data, mut static_data) = collect_autoloads(
+ &installed,
+ root_autoload.as_ref(),
+ root_autoload_dev.as_ref(),
+ &root_name,
+ config.dev_mode,
+ );
+
+ // 3a. Read classmap dirs declared in composer.json
+ let excluded: Vec<String> = root_autoload
+ .as_ref()
+ .and_then(|v| v.get("exclude-from-classmap"))
+ .and_then(|v| v.as_array())
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|v| v.as_str().map(|s| s.to_string()))
+ .collect()
+ })
+ .unwrap_or_default();
+
+ // Scan explicit classmap dirs from all packages
+ let mut classmap_dirs: Vec<PathBuf> = Vec::new();
+
+ // Collect classmap dirs from installed packages
+ for pkg in &installed.packages {
+ if let Some(autoload_val) = &pkg.autoload
+ && let Some(cm_arr) = autoload_val.get("classmap").and_then(|v| v.as_array())
+ {
+ for cm_val in cm_arr {
+ if let Some(cm_path) = cm_val.as_str() {
+ let abs = config.vendor_dir.join(&pkg.name).join(cm_path);
+ classmap_dirs.push(abs);
+ }
+ }
+ }
+ }
+
+ // Collect classmap dirs from root autoload
+ if let Some(autoload_val) = root_autoload.as_ref()
+ && let Some(cm_arr) = autoload_val.get("classmap").and_then(|v| v.as_array())
+ {
+ for cm_val in cm_arr {
+ if let Some(cm_path) = cm_val.as_str() {
+ let abs = config.project_dir.join(cm_path);
+ classmap_dirs.push(abs);
+ }
+ }
+ }
+
+ // Scan classmap dirs
+ 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<String> = Vec::new();
+
+ if do_optimize {
+ let (opt_dyn, opt_static, violations) = scan_psr_for_classmap(
+ &data.psr4,
+ &data.psr0,
+ &config.vendor_dir,
+ &config.project_dir,
+ &excluded,
+ );
+ psr_violations = violations;
+ for (class, path_expr) in opt_dyn {
+ 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<String> = installed
+ .dev_package_names
+ .iter()
+ .map(|n| n.to_lowercase())
+ .collect();
+
+ // Re-read composer.json for root require (not from autoload, but from root "require" key)
+ let root_require_val: Option<serde_json::Value> = if composer_json_path.exists() {
+ let content = std::fs::read_to_string(&composer_json_path)?;
+ let value: serde_json::Value = serde_json::from_str(&content)?;
+ value.get("require").cloned()
+ } else {
+ None
+ };
+
+ let all_locked: Vec<LockedPackage> = {
+ // Collect locked packages from installed for platform check
+ // (installed.packages are LockedPackage-compatible via InstalledPackageEntry)
+ // We'll build minimal LockedPackage-like data from installed entries
+ installed
+ .packages
+ .iter()
+ .map(|p| 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("<?php"));
+ assert!(output.contains("autoload_psr4.php @generated by Composer"));
+ assert!(output.contains("$vendorDir = dirname(__DIR__);"));
+ assert!(output.contains("$baseDir = dirname($vendorDir);"));
+ assert!(output.contains("'Psr\\\\Log\\\\'"));
+ assert!(output.contains("$vendorDir . '/psr/log/src'"));
+ assert!(output.starts_with("<?php\n"));
+ }
+
+ #[test]
+ fn test_generate_autoload_psr4_empty() {
+ let data = AutoloadData {
+ psr4: BTreeMap::new(),
+ psr0: BTreeMap::new(),
+ classmap: BTreeMap::new(),
+ files: BTreeMap::new(),
+ };
+ let output = generate_autoload_psr4(&data);
+ assert!(output.contains("return array(\n);"));
+ }
+
+ #[test]
+ fn test_generate_autoload_psr4_sorted_reverse() {
+ let mut installed = InstalledPackages::new();
+ installed.upsert(make_installed_pkg_with_autoload(
+ "aaa/pkg",
+ "1.0.0",
+ serde_json::json!({"psr-4": {"Aaa\\": "src/"}}),
+ ));
+ installed.upsert(make_installed_pkg_with_autoload(
+ "zzz/pkg",
+ "1.0.0",
+ serde_json::json!({"psr-4": {"Zzz\\": "src/"}}),
+ ));
+
+ let (data, _) = collect_autoloads(&installed, None, None, "__root__", false);
+ let output = generate_autoload_psr4(&data);
+
+ // Zzz should appear before Aaa (reverse sort)
+ let zzz_pos = output.find("Zzz").unwrap();
+ let aaa_pos = output.find("Aaa").unwrap();
+ assert!(
+ zzz_pos < aaa_pos,
+ "Zzz should appear before Aaa (reverse sort)"
+ );
+ }
+
+ // -------------------------------------------------------------------------
+ // generate_autoload_static tests
+ // -------------------------------------------------------------------------
+
+ #[test]
+ fn test_generate_autoload_static_output() {
+ let mut installed = InstalledPackages::new();
+ installed.upsert(make_installed_pkg_with_autoload(
+ "psr/log",
+ "3.0.2",
+ serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}),
+ ));
+
+ let (_, static_data) = collect_autoloads(&installed, None, None, "__root__", false);
+ let output = generate_autoload_static(&static_data, "abc123");
+
+ assert!(output.contains("class ComposerStaticInitabc123"));
+ assert!(output.contains("$prefixLengthsPsr4"));
+ assert!(output.contains("$prefixDirsPsr4"));
+ assert!(output.contains("$classMap"));
+ assert!(output.contains("Composer\\\\InstalledVersions"));
+ assert!(output.contains("getInitializer"));
+ assert!(output.contains("__DIR__ . '/..' . '/psr/log/src'"));
+ }
+
+ #[test]
+ fn test_generate_autoload_static_prefix_lengths() {
+ let mut installed = InstalledPackages::new();
+ // "Psr\Log\" = 8 bytes (with single backslashes)
+ installed.upsert(make_installed_pkg_with_autoload(
+ "psr/log",
+ "3.0.2",
+ serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}),
+ ));
+
+ let (_, static_data) = collect_autoloads(&installed, None, None, "__root__", false);
+ let output = generate_autoload_static(&static_data, "test");
+
+ // The namespace "Psr\Log\" is 8 bytes
+ assert!(output.contains("'Psr\\\\Log\\\\' => 8"));
+ }
+
+ // -------------------------------------------------------------------------
+ // generate_autoload_real tests
+ // -------------------------------------------------------------------------
+
+ #[test]
+ fn test_generate_autoload_real_with_files() {
+ let output = generate_autoload_real("abc123", true, false, false, None, false);
+ assert!(output.contains("class ComposerAutoloaderInitabc123"));
+ assert!(output.contains("ComposerStaticInitabc123::$files"));
+ assert!(output.contains("$requireFile"));
+ assert!(output.contains("__composer_autoload_files"));
+ }
+
+ #[test]
+ fn test_generate_autoload_real_without_files() {
+ let output = generate_autoload_real("abc123", false, false, false, None, false);
+ assert!(output.contains("class ComposerAutoloaderInitabc123"));
+ assert!(!output.contains("$filesToLoad"));
+ assert!(!output.contains("__composer_autoload_files"));
+ }
+
+ #[test]
+ fn test_generate_autoload_real_apcu() {
+ let output = generate_autoload_real("abc123", false, false, true, None, false);
+ assert!(output.contains("setApcuPrefix('abc123')"));
+ }
+
+ #[test]
+ fn test_generate_autoload_real_apcu_custom_prefix() {
+ let output = generate_autoload_real("abc123", false, false, true, Some("myprefix"), false);
+ assert!(output.contains("setApcuPrefix('myprefix')"));
+ }
+
+ #[test]
+ fn test_generate_autoload_real_platform_check() {
+ let output = generate_autoload_real("abc123", false, false, false, None, true);
+ assert!(output.contains("require __DIR__ . '/platform_check.php'"));
+ }
+
+ #[test]
+ fn test_generate_autoload_real_no_platform_check() {
+ let output = generate_autoload_real("abc123", false, false, false, None, false);
+ assert!(!output.contains("platform_check.php"));
+ }
+
+ // -------------------------------------------------------------------------
+ // generate_installed_php tests
+ // -------------------------------------------------------------------------
+
+ #[test]
+ fn test_generate_installed_php() {
+ let mut installed = InstalledPackages::new();
+ let mut pkg = make_installed_pkg("psr/log", "3.0.2");
+ pkg.version_normalized = Some("3.0.2.0".to_string());
+ installed.upsert(pkg);
+
+ let output = generate_installed_php("myproject/app", "project", &installed, true);
+
+ assert!(output.contains("'name' => 'myproject/app'"));
+ assert!(output.contains("'type' => 'project'"));
+ assert!(output.contains("'dev' => true"));
+ assert!(output.contains("'psr/log'"));
+ assert!(output.contains("'pretty_version' => '3.0.2'"));
+ assert!(output.contains("'version' => '3.0.2.0'"));
+ assert!(output.contains("__DIR__ . '/../psr/log/'"));
+ assert!(output.contains("'dev_requirement' => false"));
+ }
+
+ #[test]
+ fn test_generate_installed_php_dev_package() {
+ let mut installed = InstalledPackages::new();
+ installed.upsert(make_installed_pkg("phpunit/phpunit", "11.0.0"));
+ installed
+ .dev_package_names
+ .push("phpunit/phpunit".to_string());
+
+ let output = generate_installed_php("test/project", "project", &installed, true);
+
+ assert!(output.contains("'dev_requirement' => true"));
+ }
+
+ // -------------------------------------------------------------------------
+ // generate() integration test
+ // -------------------------------------------------------------------------
+
+ #[test]
+ fn test_generate_full_roundtrip() {
+ let dir = tempdir().unwrap();
+ let project_dir = dir.path().to_path_buf();
+ let vendor_dir = project_dir.join("vendor");
+
+ // Write a minimal composer.json
+ std::fs::write(
+ project_dir.join("composer.json"),
+ r#"{"name": "test/project", "type": "project", "autoload": {"psr-4": {"App\\": "src/"}}}"#,
+ )
+ .unwrap();
+
+ // Write a minimal installed.json
+ let mut installed = InstalledPackages::new();
+ installed.upsert(make_installed_pkg_with_autoload(
+ "psr/log",
+ "3.0.2",
+ serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}),
+ ));
+ installed.write(&vendor_dir).unwrap();
+
+ let config = AutoloadConfig {
+ project_dir: project_dir.clone(),
+ vendor_dir: vendor_dir.clone(),
+ dev_mode: false,
+ suffix: "abc123def456".to_string(),
+ classmap_authoritative: false,
+ optimize: false,
+ apcu: false,
+ apcu_prefix: None,
+ strict_psr: false,
+ 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<PackageId, i32>,
+ /// Queue of decisions in order.
+ decision_queue: Vec<Decision>,
+}
+
+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<RuleId, SolverBugError> {
+ 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<Item = &Decision> {
+ 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<String>),
+}
+
+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<SolverBugError> 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<IndexMap<String, String>>,
+}
+
+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<String, String>,
+ ) -> 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<Literal> {
+ if literals.is_empty() {
+ return vec![];
+ }
+
+ // Group literals by package name
+ let mut groups: IndexMap<&str, Vec<Literal>> = 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<Literal> = 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<Literal> {
+ 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<Literal> = 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<PoolLink>,
+ /// Packages this replaces.
+ pub replaces: Vec<PoolLink>,
+ /// Packages this provides.
+ pub provides: Vec<PoolLink>,
+ /// Packages this conflicts with.
+ pub conflicts: Vec<PoolLink>,
+ /// 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<PackageId>,
+}
+
+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<PoolLink>,
+ pub replaces: Vec<PoolLink>,
+ pub provides: Vec<PoolLink>,
+ pub conflicts: Vec<PoolLink>,
+ 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<String>,
+}
+
+/// 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<PoolPackage>,
+ /// Index: package name → list of package IDs providing that name.
+ package_by_name: IndexMap<String, Vec<PackageId>>,
+ /// Cache for what_provides results.
+ provider_cache: IndexMap<(String, String), Vec<PackageId>>,
+ /// Packages that are fixed/locked but unacceptable (e.g. failed stability).
+ unacceptable_fixed_packages: Vec<PackageId>,
+}
+
+impl Pool {
+ /// Create a new pool from a list of package inputs.
+ pub fn new(inputs: Vec<PoolPackageInput>, unacceptable_fixed_ids: Vec<PackageId>) -> Self {
+ let mut packages: Vec<PoolPackage> = Vec::with_capacity(inputs.len());
+ let mut package_by_name: IndexMap<String, Vec<PackageId>> = 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<PackageId> {
+ 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<PackageId> {
+ 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<PoolPackageInput>,
+ /// Names already added (to avoid duplicates).
+ added: IndexSet<String>,
+ /// Queue of package names that need to be explored.
+ pending_names: VecDeque<String>,
+ /// Package names that have already been explored (returned by next_pending).
+ explored_names: IndexSet<String>,
+ /// Specific platform packages to ignore (from `--ignore-platform-req=name`).
+ ignore_platform_reqs: IndexSet<String>,
+ /// 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<String>) {
+ 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<String> {
+ 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<PoolLink> {
+ 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<Vec<RuleId>>,
+}
+
+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<RuleId> {
+ 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<RuleId> = 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<String> = 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::<Vec<_>>()
+ .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::<Vec<_>>()
+ .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<Literal> = 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<String> = 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<String> = 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<String> = 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<Literal> = 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<String>,
+}
+
+/// 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<String, Option<String>>,
+ /// Fixed packages (must be installed, cannot be modified).
+ pub fixed_packages: Vec<PackageId>,
+ /// Locked packages (installed but can be removed if nothing requires them).
+ pub locked_packages: Vec<PackageId>,
+}
+
+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<Literal>,
+ /// 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<Literal>, 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<Literal>,
+ 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<String> = self.literals.iter().map(|l| l.to_string()).collect();
+ format!("c:{}", parts.join(","))
+ } else {
+ let parts: Vec<String> = 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<usize>,
+ /// Rules grouped by type.
+ package_rules: Vec<Rule>,
+ request_rules: Vec<Rule>,
+ learned_rules: Vec<Rule>,
+ /// Total rule count.
+ next_rule_id: usize,
+ /// Deduplication index.
+ rules_by_hash: IndexMap<String, Vec<usize>>,
+ /// 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<Item = (RuleId, &Rule)> {
+ (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<Self::Item> {
+ 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<PackageId>,
+ /// Package names → list of package IDs with that name (non-alias).
+ added_packages_by_name: IndexMap<String, Vec<PackageId>>,
+ /// Specific platform packages to ignore (from `--ignore-platform-req=name`).
+ ignore_platform_reqs: IndexSet<String>,
+ /// 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<String>) {
+ 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<String, Option<String>>,
+ fixed_packages: &[PackageId],
+ root_provides: &IndexMap<String, String>,
+ root_replaces: &IndexMap<String, String>,
+ ) -> (RuleSet, Vec<(String, Option<String>)>) {
+ let mut missing_root_requires: Vec<(String, Option<String>)> = 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<Literal> = 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<PackageId> = VecDeque::new();
+ work_queue.push_back(pkg_id);
+
+ while let Some(current_id) = work_queue.pop_front() {
+ if self.added_map.contains(&current_id) {
+ continue;
+ }
+ self.added_map.insert(current_id);
+
+ let pkg = self.pool.package_by_id(current_id);
+ let conflict_names: Vec<String> =
+ 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<Literal> = 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<PackageId> = 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<PackageId>)> = 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<Literal> = 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<String, String>,
+) -> 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<Literal, Vec<usize>>,
+ /// All watch nodes.
+ nodes: Vec<WatchNode>,
+}
+
+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<Option<RuleId>, 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<PackageId>,
+}
+
+/// 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<PackageId>,
+ /// Current propagation index in decision queue.
+ propagate_index: usize,
+ /// Branch points: (alternative literals, decision level).
+ branches: Vec<(Vec<Literal>, i32)>,
+ /// Problems found during solving.
+ problems: Vec<Problem>,
+ /// Learned rule pool: for each learned rule, the chain of rules that led to it.
+ learned_pool: Vec<Vec<RuleId>>,
+ /// Map from rule ID → learned pool index.
+ learned_why: IndexMap<RuleId, usize>,
+}
+
+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<PackageId>,
+ ) -> 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<SolverResult, SolverError> {
+ // 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<String> = 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<RuleId> = 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<Option<RuleId>, 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<i32, SolverError> {
+ 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<Literal>,
+ rule_id: RuleId,
+ ) -> Result<i32, SolverError> {
+ 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<PackageId> = IndexSet::new();
+ let mut learned_literal: Option<Literal> = None;
+ let mut other_learned_literals: Vec<Literal> = 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<RuleId>,
+ ) {
+ 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<PackageId> = 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<RuleId> = 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<Literal> = 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<Literal> = 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<Literal> = 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<Literal> = None;
+ let mut last_level: Option<i32> = 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<PackageId>,
+ max_id: u32,
+ ) -> Result<SolverResult, SolverError> {
+ 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<PackageId>,
+ /// Result package IDs from the solver.
+ result_ids: Vec<PackageId>,
+ /// Computed operations.
+ operations: Vec<Operation>,
+}
+
+impl<'a> Transaction<'a> {
+ /// Create a new transaction from present and result package sets.
+ pub fn new(pool: &'a Pool, present_ids: Vec<PackageId>, result_ids: Vec<PackageId>) -> 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<PackageId>,
+ 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<PackageId> = 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<PackageId> {
+ // 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<PackageId>> = 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<PackageId> = IndexSet::new();
+ let mut order: Vec<PackageId> = 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<PackageId>>,
+ ) -> Vec<PackageId> {
+ let mut required: IndexSet<PackageId> = 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<PackageId> = 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<PackageId>,
+ /// Non-dev result package IDs.
+ non_dev_ids: Vec<PackageId>,
+ /// Dev result package IDs.
+ dev_ids: Vec<PackageId>,
+}
+
+impl<'a> LockTransaction<'a> {
+ /// Create a lock transaction from solver decisions.
+ pub fn new(
+ pool: &'a Pool,
+ present_ids: Vec<PackageId>,
+ unlockable_ids: IndexSet<PackageId>,
+ 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<String> = 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<char> = glob.chars().collect();
+ let mut i = 0;
+ while i < chars.len() {
+ match chars[i] {
+ '*' if i + 1 < chars.len() && chars[i + 1] == '*' => {
+ result.push_str(".*");
+ i += 2;
+ }
+ '*' => {
+ result.push_str("[^/]*");
+ i += 1;
+ }
+ '?' => {
+ result.push_str("[^/]");
+ i += 1;
+ }
+ '[' => {
+ // Pass character classes through as-is until the closing `]`
+ result.push('[');
+ i += 1;
+ while i < chars.len() && chars[i] != ']' {
+ result.push(chars[i]);
+ i += 1;
+ }
+ if i < chars.len() {
+ result.push(']');
+ i += 1;
+ }
+ }
+ c => {
+ // Regex-escape special characters
+ if r"\.+^$|{}()?".contains(c) {
+ result.push('\\');
+ }
+ result.push(c);
+ i += 1;
+ }
+ }
+ }
+ result
+}
+
+/// Convert a single gitignore-style rule into an `ExcludePattern`.
+///
+/// Returns `None` if the rule is empty or a comment.
+pub fn parse_gitignore_pattern(rule: &str) -> Option<ExcludePattern> {
+ let rule = rule.trim();
+ if rule.is_empty() || rule.starts_with('#') {
+ return None;
+ }
+
+ // Leading `!` negates the pattern
+ let (negate, rule) = if let Some(rest) = rule.strip_prefix('!') {
+ (true, rest)
+ } else {
+ (false, rule)
+ };
+
+ // Strip trailing `/` before globbing
+ let rule = rule.trim_end_matches('/');
+ if rule.is_empty() {
+ return None;
+ }
+
+ // Determine anchor prefix:
+ // - leading `/` → anchored at root: `^/<glob_regex>`
+ // - no `/` inside pattern → matches anywhere: `/<glob_regex>`
+ // - `/` somewhere in middle → anchored at root: `^/<glob_regex>`
+ let (prefix, glob) = if let Some(without_leading_slash) = rule.strip_prefix('/') {
+ // Root-anchored
+ ("^/", without_leading_slash)
+ } else if rule.contains('/') {
+ // Slash in middle: treat as root-anchored
+ ("^/", rule)
+ } else {
+ // No slash: matches anywhere
+ ("/", rule)
+ };
+
+ let glob_regex = glob_to_regex(glob);
+ // The final regex: `<prefix><glob_regex>(/|$)`
+ // This matches the path component exactly (followed by a `/` or end-of-string).
+ let pattern = format!("{prefix}{glob_regex}(/|$)");
+ let regex = Regex::new(&pattern).ok()?;
+
+ Some(ExcludePattern { regex, negate })
+}
+
+/// Apply a chain of exclude patterns to a relative path (as a `/`-prefixed string).
+///
+/// Patterns are applied in order; later patterns override earlier ones.
+/// Returns `true` if the file is excluded by the final matching pattern
+/// (or by `initially_excluded` if no pattern matches).
+fn apply_filters(
+ path_with_slash: &str,
+ patterns: &[ExcludePattern],
+ initially_excluded: bool,
+) -> bool {
+ let mut excluded = initially_excluded;
+ for pat in patterns {
+ if pat.regex.is_match(path_with_slash) {
+ // A negate pattern re-includes; a normal pattern excludes
+ excluded = !pat.negate;
+ }
+ }
+ excluded
+}
+
+/// Parse `.gitattributes` from the source directory.
+///
+/// Returns exclude patterns for lines containing `export-ignore` or
+/// `-export-ignore`.
+pub fn parse_gitattributes(source_dir: &Path) -> Vec<ExcludePattern> {
+ let path = source_dir.join(".gitattributes");
+ let content = match fs::read_to_string(&path) {
+ Ok(c) => c,
+ Err(_) => return vec![],
+ };
+
+ let mut patterns = Vec::new();
+ for line in content.lines() {
+ let line = line.trim();
+ if line.is_empty() || line.starts_with('#') {
+ continue;
+ }
+ let parts: Vec<&str> = line.split_whitespace().collect();
+ if parts.len() < 2 {
+ continue;
+ }
+ let file_pattern = parts[0];
+ // Check each attribute token for export-ignore / -export-ignore
+ for attr in &parts[1..] {
+ if *attr == "export-ignore" {
+ if let Some(p) = parse_gitignore_pattern(file_pattern) {
+ patterns.push(p);
+ }
+ } else if *attr == "-export-ignore" {
+ // Negation: re-include files that would otherwise be excluded
+ let negated = format!("!{}", file_pattern);
+ if let Some(p) = parse_gitignore_pattern(&negated) {
+ patterns.push(p);
+ }
+ }
+ }
+ }
+ patterns
+}
+
+/// Convert `composer.json` `archive.exclude` rules into exclude patterns.
+pub fn parse_composer_excludes(excludes: &[String]) -> Vec<ExcludePattern> {
+ excludes
+ .iter()
+ .filter_map(|rule| parse_gitignore_pattern(rule))
+ .collect()
+}
+
+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<Vec<PathBuf>> {
+ let source_dir = source_dir
+ .canonicalize()
+ .unwrap_or_else(|_| source_dir.to_path_buf());
+ let mut files = Vec::new();
+ collect_recursive(&source_dir, &source_dir, exclude_patterns, &mut files)?;
+ files.sort();
+ Ok(files)
+}
+
+fn collect_recursive(
+ source_dir: &Path,
+ current_dir: &Path,
+ exclude_patterns: &[ExcludePattern],
+ out: &mut Vec<PathBuf>,
+) -> anyhow::Result<()> {
+ let entries = fs::read_dir(current_dir)
+ .with_context(|| format!("Failed to read directory: {}", current_dir.display()))?;
+
+ let mut items: Vec<_> = entries.filter_map(|e| e.ok()).collect();
+ // Sort for determinism
+ items.sort_by_key(|e| e.file_name());
+
+ for entry in items {
+ let path = entry.path();
+ let file_name = entry.file_name();
+ let name_str = file_name.to_string_lossy();
+
+ // Skip VCS directories
+ if VCS_DIRS.contains(&name_str.as_ref()) {
+ continue;
+ }
+
+ // Compute the relative path (forward-slash, prefixed with `/` for filter matching)
+ let relative = path
+ .strip_prefix(source_dir)
+ .unwrap_or(&path)
+ .to_string_lossy()
+ .replace('\\', "/");
+ let path_with_slash = format!("/{}", relative);
+
+ // Check if this entry is excluded
+ if apply_filters(&path_with_slash, exclude_patterns, false) {
+ continue;
+ }
+
+ let metadata = match entry.metadata() {
+ Ok(m) => m,
+ Err(_) => continue,
+ };
+
+ if metadata.is_symlink() {
+ // Resolve the symlink; skip if it points outside source_dir
+ if let Ok(resolved) = fs::canonicalize(&path) {
+ if !resolved.starts_with(source_dir) {
+ continue;
+ }
+ out.push(PathBuf::from(&relative));
+ }
+ // If canonicalize fails, skip the symlink
+ } else if metadata.is_dir() {
+ // Collect children recursively
+ let mut children = Vec::new();
+ collect_recursive(source_dir, &path, exclude_patterns, &mut children)?;
+ if children.is_empty() {
+ // Include empty directory
+ out.push(PathBuf::from(&relative));
+ } else {
+ out.extend(children);
+ }
+ } else {
+ out.push(PathBuf::from(&relative));
+ }
+ }
+
+ Ok(())
+}
+
+/// Supported archive formats.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ArchiveFormat {
+ Zip,
+ Tar,
+ TarGz,
+ TarBz2,
+}
+
+impl ArchiveFormat {
+ /// Parse a format string (case-insensitive). Returns `None` for unsupported formats.
+ pub fn parse(s: &str) -> Option<Self> {
+ match s.to_lowercase().as_str() {
+ "zip" => Some(Self::Zip),
+ "tar" => Some(Self::Tar),
+ "tar.gz" | "tgz" => Some(Self::TarGz),
+ "tar.bz2" => Some(Self::TarBz2),
+ _ => None,
+ }
+ }
+
+ /// File extension for this format.
+ pub fn extension(&self) -> &str {
+ match self {
+ Self::Zip => "zip",
+ Self::Tar => "tar",
+ Self::TarGz => "tar.gz",
+ Self::TarBz2 => "tar.bz2",
+ }
+ }
+}
+
+/// 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<String> = vec![base];
+
+ // Determine if dist_reference is a 40-char hex (SHA-1 commit hash)
+ let is_sha_dist_ref = dist_reference
+ .map(|r| r.len() == 40 && r.chars().all(|c| c.is_ascii_hexdigit()))
+ .unwrap_or(false);
+
+ if is_sha_dist_ref {
+ // Append dist_reference and dist_type
+ if let Some(dr) = dist_reference {
+ parts.push(dr.to_string());
+ }
+ if let Some(dt) = dist_type {
+ parts.push(dt.to_string());
+ }
+ } else {
+ // Append version (if any), then dist_reference (if any)
+ if let Some(v) = version {
+ parts.push(v.to_string());
+ }
+ if let Some(dr) = dist_reference {
+ parts.push(dr.to_string());
+ }
+ }
+
+ // Append first 6 chars of SHA-1 of source_reference (if any)
+ if let Some(sr) = source_reference {
+ let mut hasher = Sha1::new();
+ hasher.update(sr.as_bytes());
+ let hash = format!("{:x}", hasher.finalize());
+ parts.push(hash[..6.min(hash.len())].to_string());
+ }
+
+ // Replace `/` with `-` in each part, then join
+ parts
+ .iter()
+ .map(|p| p.replace('/', "-"))
+ .collect::<Vec<_>>()
+ .join("-")
+}
+
+/// The set of archive extensions we support.
+const ARCHIVE_EXTENSIONS: &[&str] = &["zip", "tar", "tar.gz", "tar.bz2"];
+
+/// Generate patterns to exclude previous archives of this package from the archive.
+///
+/// If `has_extra_parts` is true (version/ref was appended), the pattern is
+/// `<base>-*.<ext>`. Otherwise it's `<base>.<ext>`.
+pub fn self_exclusion_patterns(base_name: &str, has_extra_parts: bool) -> Vec<String> {
+ ARCHIVE_EXTENSIONS
+ .iter()
+ .map(|ext| {
+ if has_extra_parts {
+ format!("/{}-*.{}", base_name, ext)
+ } else {
+ format!("/{}.{}", base_name, ext)
+ }
+ })
+ .collect()
+}
+
+#[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"<?php").unwrap();
+ fs::write(dir.path().join("b.php"), b"<?php").unwrap();
+ fs::create_dir(dir.path().join("src")).unwrap();
+ fs::write(dir.path().join("src").join("c.php"), b"<?php").unwrap();
+
+ let files = collect_archivable_files(dir.path(), &[]).unwrap();
+ let strs: Vec<String> = files
+ .iter()
+ .map(|p| p.to_string_lossy().to_string())
+ .collect();
+ assert!(strs.contains(&"a.php".to_string()));
+ assert!(strs.contains(&"b.php".to_string()));
+ assert!(strs.contains(&"src/c.php".to_string()));
+ }
+
+ #[test]
+ fn test_collect_files_excludes() {
+ let dir = tempdir().unwrap();
+ fs::write(dir.path().join("main.php"), b"<?php").unwrap();
+ fs::create_dir(dir.path().join("tests")).unwrap();
+ fs::write(dir.path().join("tests").join("test.php"), b"<?php").unwrap();
+
+ let patterns = vec![parse_gitignore_pattern("tests/").unwrap()];
+ let files = collect_archivable_files(dir.path(), &patterns).unwrap();
+ let strs: Vec<String> = files
+ .iter()
+ .map(|p| p.to_string_lossy().to_string())
+ .collect();
+ assert!(strs.contains(&"main.php".to_string()));
+ assert!(!strs.iter().any(|s| s.starts_with("tests")));
+ }
+
+ #[test]
+ fn test_collect_files_skips_vcs() {
+ let dir = tempdir().unwrap();
+ fs::write(dir.path().join("main.php"), b"<?php").unwrap();
+ fs::create_dir(dir.path().join(".git")).unwrap();
+ fs::write(
+ dir.path().join(".git").join("HEAD"),
+ b"ref: refs/heads/main",
+ )
+ .unwrap();
+
+ let files = collect_archivable_files(dir.path(), &[]).unwrap();
+ let strs: Vec<String> = files
+ .iter()
+ .map(|p| p.to_string_lossy().to_string())
+ .collect();
+ assert!(strs.contains(&"main.php".to_string()));
+ assert!(!strs.iter().any(|s| s.starts_with(".git")));
+ }
+
+ #[test]
+ fn test_collect_files_empty_dir() {
+ let dir = tempdir().unwrap();
+ fs::write(dir.path().join("main.php"), b"<?php").unwrap();
+ fs::create_dir(dir.path().join("empty_dir")).unwrap();
+
+ let files = collect_archivable_files(dir.path(), &[]).unwrap();
+ let strs: Vec<String> = files
+ .iter()
+ .map(|p| p.to_string_lossy().to_string())
+ .collect();
+ assert!(strs.contains(&"main.php".to_string()));
+ assert!(strs.contains(&"empty_dir".to_string()));
+ }
+
+ fn make_source_tree(dir: &Path) {
+ fs::write(dir.join("main.php"), b"<?php echo 'hello';").unwrap();
+ fs::create_dir(dir.join("src")).unwrap();
+ fs::write(dir.join("src").join("Foo.php"), b"<?php class Foo {}").unwrap();
+ }
+
+ #[test]
+ fn test_create_zip_archive() {
+ let src = tempdir().unwrap();
+ make_source_tree(src.path());
+ let out = tempdir().unwrap();
+ let target = out.path().join("test.zip");
+
+ let files = collect_archivable_files(src.path(), &[]).unwrap();
+ create_archive(src.path(), &files, &target, &ArchiveFormat::Zip).unwrap();
+ assert!(target.exists());
+
+ // Verify contents
+ let zip_data = fs::read(&target).unwrap();
+ let cursor = std::io::Cursor::new(zip_data);
+ let mut archive = zip::ZipArchive::new(cursor).unwrap();
+ let names: Vec<String> = (0..archive.len())
+ .map(|i| archive.by_index(i).unwrap().name().to_string())
+ .collect();
+ assert!(names.contains(&"main.php".to_string()));
+ assert!(names.contains(&"src/Foo.php".to_string()));
+ }
+
+ #[test]
+ fn test_create_tar_archive() {
+ let src = tempdir().unwrap();
+ make_source_tree(src.path());
+ let out = tempdir().unwrap();
+ let target = out.path().join("test.tar");
+
+ let files = collect_archivable_files(src.path(), &[]).unwrap();
+ create_archive(src.path(), &files, &target, &ArchiveFormat::Tar).unwrap();
+ assert!(target.exists());
+
+ // Verify contents
+ let tar_data = fs::read(&target).unwrap();
+ let cursor = std::io::Cursor::new(tar_data);
+ let mut archive = tar::Archive::new(cursor);
+ let names: Vec<String> = archive
+ .entries()
+ .unwrap()
+ .filter_map(|e| e.ok())
+ .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string()))
+ .collect();
+ assert!(names.contains(&"main.php".to_string()));
+ assert!(names.contains(&"src/Foo.php".to_string()));
+ }
+
+ #[test]
+ fn test_create_tar_gz_archive() {
+ let src = tempdir().unwrap();
+ make_source_tree(src.path());
+ let out = tempdir().unwrap();
+ let target = out.path().join("test.tar.gz");
+
+ let files = collect_archivable_files(src.path(), &[]).unwrap();
+ create_archive(src.path(), &files, &target, &ArchiveFormat::TarGz).unwrap();
+ assert!(target.exists());
+
+ let gz_data = fs::read(&target).unwrap();
+ let cursor = std::io::Cursor::new(gz_data);
+ let decoder = flate2::read::GzDecoder::new(cursor);
+ let mut archive = tar::Archive::new(decoder);
+ let names: Vec<String> = archive
+ .entries()
+ .unwrap()
+ .filter_map(|e| e.ok())
+ .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string()))
+ .collect();
+ assert!(names.contains(&"main.php".to_string()));
+ }
+
+ #[test]
+ fn test_create_tar_bz2_archive() {
+ let src = tempdir().unwrap();
+ make_source_tree(src.path());
+ let out = tempdir().unwrap();
+ let target = out.path().join("test.tar.bz2");
+
+ let files = collect_archivable_files(src.path(), &[]).unwrap();
+ create_archive(src.path(), &files, &target, &ArchiveFormat::TarBz2).unwrap();
+ assert!(target.exists());
+
+ let bz_data = fs::read(&target).unwrap();
+ let cursor = std::io::Cursor::new(bz_data);
+ let decoder = bzip2::read::BzDecoder::new(cursor);
+ let mut archive = tar::Archive::new(decoder);
+ let names: Vec<String> = archive
+ .entries()
+ .unwrap()
+ .filter_map(|e| e.ok())
+ .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string()))
+ .collect();
+ assert!(names.contains(&"main.php".to_string()));
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn test_zip_preserves_permissions() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let src = tempdir().unwrap();
+ let script = src.path().join("run.sh");
+ fs::write(&script, b"#!/bin/sh\necho hello").unwrap();
+ fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
+
+ let out = tempdir().unwrap();
+ let target = out.path().join("test.zip");
+ let files = collect_archivable_files(src.path(), &[]).unwrap();
+ create_archive(src.path(), &files, &target, &ArchiveFormat::Zip).unwrap();
+
+ let zip_data = fs::read(&target).unwrap();
+ let cursor = std::io::Cursor::new(zip_data);
+ let mut archive = zip::ZipArchive::new(cursor).unwrap();
+ let entry = archive.by_name("run.sh").unwrap();
+ let mode = entry.unix_mode().unwrap_or(0);
+ // Lower 9 bits should be 0o755
+ assert_eq!(mode & 0o777, 0o755);
+ }
+
+ #[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<String>,
+ source_dir: PathBuf,
+ },
+ Remote {
+ name: String,
+ version: String,
+ dist_url: String,
+ dist_type: String,
+ dist_shasum: Option<String>,
+ dist_reference: Option<String>,
+ source_reference: Option<String>,
+ },
+}
+
+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<String>,
+ archive_excludes: Vec<String>,
+ _temp_dir: Option<PathBuf>,
+}
+
+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<String>, Vec<String>)> {
+ 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<PathBuf> {
+ 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<AcquiredSource> {
+ 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<String>,
+ /// Raw abandoned field from JSON: `true` = abandoned no replacement, `String` = replacement name.
+ pub abandoned_raw: Option<serde_json::Value>,
+}
+
+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<String>,
+}
+
+/// Result of `Auditor::process_advisories`.
+#[derive(Debug, Default)]
+pub struct ProcessedAdvisories {
+ pub advisories: BTreeMap<String, Vec<MatchedAdvisory>>,
+ pub ignored_advisories: BTreeMap<String, Vec<IgnoredAdvisory>>,
+}
+
+/// An abandoned package found during audit.
+#[derive(Debug, Clone)]
+pub struct AbandonedPackage {
+ pub name: String,
+ pub version: String,
+ pub replacement: Option<String>,
+}
+
+/// Options passed to `Auditor::audit()`.
+pub struct AuditOptions<'a> {
+ pub format: AuditFormat,
+ pub warning_only: bool,
+ pub ignore_list: &'a IndexMap<String, Option<String>>,
+ pub abandoned: AbandonedHandling,
+ pub ignored_severities: &'a IndexMap<String, Option<String>>,
+ pub ignore_unreachable: bool,
+ pub ignore_abandoned: &'a IndexMap<String, Option<String>>,
+}
+
+/// 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<u8> {
+ 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, "<info>{msg}</info>");
+ 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, "<warning>{msg}</warning>");
+ } else {
+ console_writeln_error!(console, "<error>{msg}</error>");
+ }
+ 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,
+ "<info>No security vulnerability advisories found.</info>",
+ );
+ }
+
+ if !unreachable_repos.is_empty() {
+ console_writeln_error!(
+ console,
+ "<warning>The following repositories were unreachable:</warning>",
+ );
+ 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<String, Vec<MatchedAdvisory>>,
+ ignore_list: &IndexMap<String, Option<String>>,
+ ignored_severities: &IndexMap<String, Option<String>>,
+ ) -> ProcessedAdvisories {
+ if ignore_list.is_empty() && ignored_severities.is_empty() {
+ return ProcessedAdvisories {
+ advisories: all_advisories,
+ ignored_advisories: BTreeMap::new(),
+ };
+ }
+
+ let mut advisories: BTreeMap<String, Vec<MatchedAdvisory>> = BTreeMap::new();
+ let mut ignored: BTreeMap<String, Vec<IgnoredAdvisory>> = 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<String> = 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<String, Option<String>>,
+ ) -> Vec<AbandonedPackage> {
+ 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<String, Vec<MatchedAdvisory>>,
+ _ignore_list: &IndexMap<String, Option<String>>,
+ ) -> 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<String, Vec<IgnoredAdvisory>>) -> (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<String, Vec<MatchedAdvisory>>) -> (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<String, Vec<MatchedAdvisory>>,
+ 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<String, Vec<IgnoredAdvisory>>,
+ 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<String, Vec<MatchedAdvisory>>,
+ ) {
+ 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<String, Vec<IgnoredAdvisory>>,
+ ) {
+ 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!(
+ "+-{:-<lw$}-+-{:-<vw$}-+",
+ "",
+ "",
+ lw = label_width,
+ vw = value_width
+ );
+
+ console_writeln_error!(console, "{}", separator);
+ for (label, value) in &rows {
+ console_writeln_error!(
+ console,
+ "| {:<lw$} | {:<vw$} |",
+ label,
+ value,
+ lw = label_width,
+ vw = value_width,
+ );
+ }
+ console_writeln_error!(console, "{}", &separator);
+ console_writeln_error!(console, "");
+ }
+
+ fn output_advisories_plain(
+ &self,
+ console: &Console,
+ advisories: &BTreeMap<String, Vec<MatchedAdvisory>>,
+ ) {
+ 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<String, Vec<IgnoredAdvisory>>,
+ ) {
+ 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,
+ "<error>Found {count} abandoned package{plurality}:</error>",
+ );
+
+ 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,
+ "| {:<nw$} | {:<vw$} | {:<rw$} |",
+ "Abandoned Package",
+ "Version",
+ "Suggested Replacement",
+ nw = name_width,
+ vw = ver_width,
+ rw = repl_width,
+ );
+ console_writeln_error!(
+ console,
+ "+-{:-<nw$}-+-{:-<vw$}-+-{:-<rw$}-+",
+ "",
+ "",
+ "",
+ nw = name_width,
+ vw = ver_width,
+ rw = repl_width,
+ );
+ for pkg in packages {
+ let replacement = pkg
+ .replacement
+ .as_deref()
+ .unwrap_or("No replacement suggested");
+ console_writeln_error!(
+ console,
+ "| {:<nw$} | {:<vw$} | {:<rw$} |",
+ pkg.name,
+ pkg.version,
+ replacement,
+ nw = name_width,
+ vw = ver_width,
+ rw = repl_width,
+ );
+ }
+ console_writeln_error!(console, "");
+ }
+
+ fn render_json(
+ &self,
+ advisories: &BTreeMap<String, Vec<MatchedAdvisory>>,
+ ignored_advisories: &BTreeMap<String, Vec<IgnoredAdvisory>>,
+ unreachable_repos: &[String],
+ abandoned_packages: &[AbandonedPackage],
+ console: &Console,
+ ) {
+ let mut advisories_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
+ for (pkg_name, matched_list) in advisories {
+ let arr: Vec<serde_json::Value> = 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<String, serde_json::Value> =
+ serde_json::Map::new();
+ for (pkg_name, ignored_list) in ignored_advisories {
+ let arr: Vec<serde_json::Value> = 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<serde_json::Value> = 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<String, serde_json::Value> = 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<String>,
+ /// `$package->getSourceUrl()`.
+ pub source_url: Option<String>,
+ /// `$package->getHomepage()`.
+ pub homepage: Option<String>,
+}
+
+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<RawPackageData>),
+ /// 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<Vec<CompletePackageView>> {
+ 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<BrowseRepo>,
+}
+
+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<RawPackageData>,
+ installed: Option<InstalledPackages>,
+ packagist_cache: Cache,
+ ) -> Self {
+ let mut repos: Vec<BrowseRepo> = 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<String> {
+ if !self.enabled {
+ return None;
+ }
+ fs::read_to_string(self.path_for(key)).ok()
+ }
+
+ /// Write a string entry atomically (write to temp file, then rename).
+ pub fn write(&self, key: &str, contents: &str) -> anyhow::Result<()> {
+ if !self.enabled || 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<Vec<u8>> {
+ if !self.enabled {
+ return None;
+ }
+ fs::read(self.path_for(key)).ok()
+ }
+
+ /// Write a binary entry atomically (write to temp file, then rename).
+ pub fn write_bytes(&self, key: &str, data: &[u8]) -> anyhow::Result<()> {
+ if !self.enabled || 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<u64> {
+ if !self.enabled {
+ return None;
+ }
+ let path = self.path_for(key);
+ let metadata = fs::metadata(&path).ok()?;
+ let mtime = metadata.modified().ok()?;
+ let now = SystemTime::now();
+ now.duration_since(mtime).ok().map(|d| d.as_secs())
+ }
+}
+
+/// Recursively collect all files under `dir` as `(path, mtime_secs, size_bytes)`.
+fn collect_files(dir: &Path, out: &mut Vec<(PathBuf, u64, u64)>) -> anyhow::Result<()> {
+ if !dir.exists() {
+ return Ok(());
+ }
+ for entry in fs::read_dir(dir)? {
+ let entry = entry?;
+ let path = entry.path();
+ let metadata = entry.metadata()?;
+ if metadata.is_dir() {
+ collect_files(&path, out)?;
+ } else if metadata.is_file() {
+ let mtime = metadata
+ .modified()
+ .ok()
+ .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
+ .map(|d| d.as_secs())
+ .unwrap_or(0);
+ let size = metadata.len();
+ out.push((path, mtime, size));
+ }
+ }
+ Ok(())
+}
+
+/// 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<ComposerRepoPackage> {
+ let mut out = Vec::new();
+ let mut claimed: IndexSet<String> = 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::<serde_json::Value>(&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<String> = 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::<PackagistVersion>(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<PathBuf> {
+ 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.
+ /// `<vendor>/.cache/git`).
+ pub fn new(git_cache_dir: PathBuf) -> Self {
+ Self { git_cache_dir }
+ }
+
+ pub fn get_downloader_for_package(
+ &self,
+ package: &LocalPackage,
+ ) -> Option<Box<dyn VcsDownloader>> {
+ 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<InstallationSource>,
+ 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<String>) -> Self {
+ Self {
+ show,
+ total: 0,
+ downloaded: 0,
+ label: label.into(),
+ }
+ }
+
+ /// Set the total expected bytes from a `Content-Length` header.
+ pub fn set_total(&mut self, total: u64) {
+ self.total = total;
+ }
+
+ /// Advance the downloaded byte count and redraw the line.
+ pub fn inc(&mut self, n: u64) {
+ if !self.show {
+ return;
+ }
+ self.downloaded += n;
+ let stderr = std::io::stderr();
+ let mut out = stderr.lock();
+ if let Some(pct) = (self.downloaded * 100).checked_div(self.total) {
+ let _ = write!(
+ out,
+ "\r Downloading {} ({}/{} bytes, {}%)",
+ self.label, self.downloaded, self.total, pct
+ );
+ } else {
+ let _ = write!(
+ out,
+ "\r Downloading {} ({} bytes)",
+ self.label, self.downloaded
+ );
+ }
+ let _ = out.flush();
+ }
+
+ /// Clear the progress line from the terminal.
+ pub fn finish(&self) {
+ if !self.show {
+ return;
+ }
+ let stderr = std::io::stderr();
+ let mut out = stderr.lock();
+ // Clear the line with spaces then return to start
+ let _ = write!(out, "\r{}\r", " ".repeat(80));
+ let _ = out.flush();
+ }
+}
+
+/// Download a dist archive from a URL.
+/// Returns the raw bytes of the downloaded archive.
+/// If `expected_shasum` is provided and non-empty, verifies SHA-1 of the downloaded bytes.
+/// If `progress` is provided, increments it as bytes are received and sets the total from
+/// the `Content-Length` response header.
+/// 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<Vec<u8>> {
+ // 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<String> {
+ if entries.is_empty() {
+ return None;
+ }
+
+ let mut prefixes: IndexSet<String> = 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<String> = (0..archive.len())
+ .map(|i| archive.by_index(i).map(|e| e.name().to_string()))
+ .collect::<Result<_, _>>()?;
+
+ let prefix = find_top_level_dir(&entry_names);
+
+ for i in 0..archive.len() {
+ let mut entry = archive.by_index(i)?;
+ let raw_name = entry.name().to_string();
+
+ // Strip common prefix
+ let relative = if let Some(ref pfx) = prefix {
+ if raw_name.starts_with(pfx.as_str()) {
+ &raw_name[pfx.len()..]
+ } else {
+ &raw_name
+ }
+ } else {
+ &raw_name
+ };
+
+ // Skip the directory entry itself (empty name after stripping)
+ if relative.is_empty() {
+ continue;
+ }
+
+ let target_path = target_dir.join(relative);
+
+ if raw_name.ends_with('/') {
+ // Directory entry
+ fs::create_dir_all(&target_path)?;
+ } else {
+ // File entry
+ if let Some(parent) = target_path.parent() {
+ fs::create_dir_all(parent)?;
+ }
+
+ let mut buf = Vec::new();
+ entry.read_to_end(&mut buf)?;
+ fs::write(&target_path, &buf)?;
+
+ // Set permissions on Unix
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ if let Some(mode) = entry.unix_mode() {
+ fs::set_permissions(&target_path, fs::Permissions::from_mode(mode))?;
+ }
+ }
+ }
+ }
+
+ Ok(())
+}
+
+/// Extract a tar.gz archive to the target directory.
+/// Strips a common top-level directory if all entries share one (Packagist pattern).
+pub fn extract_tar_gz(data: &[u8], target_dir: &Path) -> anyhow::Result<()> {
+ let cursor = Cursor::new(data);
+ let decoder = flate2::read::GzDecoder::new(cursor);
+ let mut archive = tar::Archive::new(decoder);
+
+ // We need to process in two passes: first collect names, then extract.
+ // Use a buffered approach: collect entries into memory.
+ let cursor2 = Cursor::new(data);
+ let decoder2 = flate2::read::GzDecoder::new(cursor2);
+ let mut archive2 = tar::Archive::new(decoder2);
+
+ let entry_names: Vec<String> = archive2
+ .entries()?
+ .filter_map(|e| e.ok())
+ .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string()))
+ .collect();
+
+ let prefix = find_top_level_dir(&entry_names);
+
+ for entry in archive.entries()? {
+ let mut entry = entry?;
+ let raw_path = entry.path()?.to_string_lossy().to_string();
+
+ // Strip common prefix
+ let relative = if let Some(ref pfx) = prefix {
+ if raw_path.starts_with(pfx.as_str()) {
+ raw_path[pfx.len()..].to_string()
+ } else {
+ raw_path.clone()
+ }
+ } else {
+ raw_path.clone()
+ };
+
+ // Skip empty (top-level dir itself)
+ if relative.is_empty() {
+ continue;
+ }
+
+ let target_path = target_dir.join(&relative);
+
+ let entry_type = entry.header().entry_type();
+ if entry_type.is_dir() {
+ fs::create_dir_all(&target_path)?;
+ } else if entry_type.is_file() {
+ if let Some(parent) = target_path.parent() {
+ fs::create_dir_all(parent)?;
+ }
+ let mut buf = Vec::new();
+ entry.read_to_end(&mut buf)?;
+ fs::write(&target_path, &buf)?;
+
+ // Set permissions on Unix
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ if let Ok(mode) = entry.header().mode() {
+ fs::set_permissions(&target_path, fs::Permissions::from_mode(mode))?;
+ }
+ }
+ }
+ // Symlinks and other types are skipped for now
+ }
+
+ Ok(())
+}
+
+/// Download and install a package to the vendor directory.
+///
+/// - `dist_url`: the download URL (from `LockedPackage.dist.url`)
+/// - `dist_type`: `"zip"` or `"tar"` (from `LockedPackage.dist.dist_type`)
+/// - `dist_shasum`: optional SHA-1 checksum
+/// - `vendor_dir`: path to `vendor/` directory
+/// - `package_name`: e.g. `"monolog/monolog"`
+/// - `progress`: optional mutable progress tracker to update during download
+/// - `files_cache`: 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<u8> {
+ let buf = Vec::new();
+ let cursor = Cursor::new(buf);
+ let mut writer = zip::ZipWriter::new(cursor);
+ let options = zip::write::FileOptions::<()>::default()
+ .compression_method(zip::CompressionMethod::Stored);
+
+ for (name, content) in files {
+ writer.start_file(*name, options).unwrap();
+ writer.write_all(content).unwrap();
+ }
+
+ writer.finish().unwrap().into_inner()
+ }
+
+ /// Build a minimal tar.gz archive in memory.
+ fn make_tar_gz(files: &[(&str, &[u8])]) -> Vec<u8> {
+ let buf = Vec::new();
+ let enc = flate2::write::GzEncoder::new(buf, flate2::Compression::default());
+ let mut builder = tar::Builder::new(enc);
+
+ for (name, content) in files {
+ let mut header = tar::Header::new_gnu();
+ header.set_size(content.len() as u64);
+ header.set_mode(0o644);
+ header.set_cksum();
+ builder
+ .append_data(&mut header, name, Cursor::new(content))
+ .unwrap();
+ }
+
+ builder.into_inner().unwrap().finish().unwrap()
+ }
+
+ #[test]
+ fn test_extract_zip_flat() {
+ let zip_data = make_zip(&[("file1.txt", b"hello"), ("subdir/file2.txt", b"world")]);
+
+ let dir = tempdir().unwrap();
+ extract_zip(&zip_data, dir.path()).unwrap();
+
+ assert_eq!(
+ fs::read_to_string(dir.path().join("file1.txt")).unwrap(),
+ "hello"
+ );
+ assert_eq!(
+ fs::read_to_string(dir.path().join("subdir/file2.txt")).unwrap(),
+ "world"
+ );
+ }
+
+ #[test]
+ fn test_extract_zip_with_top_level_dir() {
+ // Packagist pattern: all files under vendor-package-abc123/
+ let zip_data = make_zip(&[
+ ("vendor-pkg-abc/", &[]),
+ ("vendor-pkg-abc/file1.txt", b"hello"),
+ ("vendor-pkg-abc/src/Foo.php", b"<?php"),
+ ]);
+
+ let dir = tempdir().unwrap();
+ extract_zip(&zip_data, dir.path()).unwrap();
+
+ // Top-level dir should be stripped
+ assert!(dir.path().join("file1.txt").exists());
+ assert!(dir.path().join("src/Foo.php").exists());
+ assert_eq!(
+ fs::read_to_string(dir.path().join("file1.txt")).unwrap(),
+ "hello"
+ );
+ }
+
+ #[test]
+ fn test_extract_tar_gz_flat() {
+ let tar_data = make_tar_gz(&[("file1.txt", b"hello"), ("subdir/file2.txt", b"world")]);
+
+ let dir = tempdir().unwrap();
+ extract_tar_gz(&tar_data, dir.path()).unwrap();
+
+ assert_eq!(
+ fs::read_to_string(dir.path().join("file1.txt")).unwrap(),
+ "hello"
+ );
+ assert_eq!(
+ fs::read_to_string(dir.path().join("subdir/file2.txt")).unwrap(),
+ "world"
+ );
+ }
+
+ #[test]
+ fn test_extract_tar_gz_with_top_level_dir() {
+ let tar_data = make_tar_gz(&[
+ ("vendor-pkg-abc/file1.txt", b"hello"),
+ ("vendor-pkg-abc/src/Foo.php", b"<?php"),
+ ]);
+
+ let dir = tempdir().unwrap();
+ extract_tar_gz(&tar_data, dir.path()).unwrap();
+
+ assert!(dir.path().join("file1.txt").exists());
+ assert!(dir.path().join("src/Foo.php").exists());
+ }
+
+ #[test]
+ fn test_sha1_verification() {
+ use sha1::{Digest, Sha1};
+
+ let data = b"test content";
+ let mut hasher = Sha1::new();
+ hasher.update(data);
+ let expected = format!("{:x}", hasher.finalize());
+
+ // We can't test download_dist without a server, but we can verify the
+ // SHA-1 logic: same data should produce same hash
+ let mut hasher2 = Sha1::new();
+ hasher2.update(data);
+ let computed = format!("{:x}", hasher2.finalize());
+
+ assert_eq!(expected, computed);
+ assert!(!expected.is_empty());
+ }
+
+ #[test]
+ fn test_find_top_level_dir_common() {
+ let entries = vec![
+ "pkg-1.0/".to_string(),
+ "pkg-1.0/README.md".to_string(),
+ "pkg-1.0/src/Foo.php".to_string(),
+ ];
+ assert_eq!(find_top_level_dir(&entries), Some("pkg-1.0/".to_string()));
+ }
+
+ #[test]
+ fn test_find_top_level_dir_none_when_mixed() {
+ let entries = vec!["pkg-1.0/file.txt".to_string(), "other/file.txt".to_string()];
+ assert_eq!(find_top_level_dir(&entries), None);
+ }
+
+ #[test]
+ fn test_find_top_level_dir_none_when_root_file() {
+ let entries = vec!["file.txt".to_string(), "pkg/other.txt".to_string()];
+ assert_eq!(find_top_level_dir(&entries), None);
+ }
+}
diff --git a/crates/mozart-core/src/repository/inline_package.rs b/crates/mozart-core/src/repository/inline_package.rs
new file mode 100644
index 0000000..fd33d19
--- /dev/null
+++ b/crates/mozart-core/src/repository/inline_package.rs
@@ -0,0 +1,277 @@
+//! Support for inline `type: package` repositories.
+//!
+//! `composer.json` may embed full package metadata under
+//! `repositories[].package`, mirroring `Composer\Repository\PackageRepository`.
+//! These packages need no network fetch — they go straight into the resolver
+//! pool and into the generated lockfile entry verbatim.
+
+use super::packagist::PackagistVersion;
+use super::repository_filter::RepositoryFilter;
+use crate::package::RawRepository;
+use indexmap::IndexSet;
+
+/// One package extracted from a `type: package` repository.
+pub struct InlinePackage {
+ pub name: String,
+ pub version: PackagistVersion,
+}
+
+/// Collect every package definition from `type: package` repositories.
+///
+/// Each repository's `package` field may be a single object or an array of
+/// objects. Entries that fail to parse (missing `name`/`version`, etc.) are
+/// silently skipped so the rest of the repositories list still applies —
+/// matching Composer's lenient PackageRepository constructor.
+///
+/// Repositories are processed in declaration order. Once any repository
+/// authoritatively answers for a package name, lower-priority `type: package`
+/// repositories that list the same name are skipped — mirroring Composer's
+/// first-repo-wins priority via `RepositorySet::findPackages`.
+pub fn collect_inline_packages(repositories: &[RawRepository]) -> Vec<InlinePackage> {
+ let mut packages = Vec::new();
+ let mut claimed: IndexSet<String> = 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<InlinePackage> = 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<String> = 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<String, Vec<SecurityAdvisory>> {
+ let mut out: indexmap::IndexMap<String, Vec<SecurityAdvisory>> = 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<InlinePackage> {
+ 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<InstalledPackageEntry>,
+
+ #[serde(rename = "dev-package-names", default)]
+ pub dev_package_names: Vec<String>,
+
+ #[serde(default = "default_true")]
+ pub dev: bool,
+}
+
+/// An entry in installed.json's packages array.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct InstalledPackageEntry {
+ pub name: String,
+ pub version: String,
+
+ #[serde(rename = "version_normalized", skip_serializing_if = "Option::is_none")]
+ pub version_normalized: Option<String>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub source: Option<serde_json::Value>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub dist: Option<serde_json::Value>,
+
+ #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
+ pub package_type: Option<String>,
+
+ #[serde(rename = "install-path", skip_serializing_if = "Option::is_none")]
+ pub install_path: Option<String>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub autoload: Option<serde_json::Value>,
+
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub aliases: Vec<String>,
+
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub homepage: Option<String>,
+
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub support: Option<serde_json::Value>,
+
+ #[serde(flatten)]
+ pub extra_fields: BTreeMap<String, serde_json::Value>,
+}
+
+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<InstalledPackages> {
+ 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<InstalledPackages> {
+ 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<InstalledPackageEntry> =
+ serde_json::from_value(packages_value)
+ .context("invalid `packages` array in installed.json")?;
+
+ let dev_package_names: Vec<String> = 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<InstalledPackageEntry> = 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<String>` 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 <name> (<version>)`
+//! - Update (upgrade direction): `Upgrading <name> (<oldVersion> => <newVersion>)`
+//! - Update (downgrade direction): `Downgrading <name> (<oldVersion> => <newVersion>)`
+//! - Uninstall: `Removing <name> (<version>)`
+
+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<String>,
+}
+
+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<String> {
+ 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<String>) {
+ 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<String> = locked.iter().map(|p| p.name.to_lowercase()).collect();
+ let removals: Vec<String> = 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<String, Vec<&'a LockedPackage>> = 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<String> = 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<String> = IndexSet::new();
+ let mut processed: IndexSet<String> = 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<StaleInstalledAlias> {
+ 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<String> {
+ 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<String>,
+
+ /// 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<LockedPackage>,
+
+ #[serde(rename = "packages-dev")]
+ pub packages_dev: Option<Vec<LockedPackage>>,
+
+ #[serde(default)]
+ pub aliases: Vec<LockAlias>,
+
+ #[serde(rename = "minimum-stability", default = "default_stability")]
+ pub minimum_stability: String,
+
+ #[serde(rename = "stability-flags", default = "default_empty_object")]
+ pub stability_flags: serde_json::Value,
+
+ #[serde(rename = "prefer-stable", default)]
+ pub prefer_stable: bool,
+
+ #[serde(rename = "prefer-lowest", default)]
+ pub prefer_lowest: bool,
+
+ #[serde(default = "default_empty_object")]
+ pub platform: serde_json::Value,
+
+ #[serde(rename = "platform-dev", default = "default_empty_object")]
+ pub platform_dev: serde_json::Value,
+
+ #[serde(rename = "plugin-api-version", skip_serializing_if = "Option::is_none")]
+ pub plugin_api_version: Option<String>,
+}
+
+/// A locked package entry in composer.lock.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct LockedPackage {
+ pub name: String,
+ pub version: String,
+
+ #[serde(rename = "version_normalized", skip_serializing_if = "Option::is_none")]
+ pub version_normalized: Option<String>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub source: Option<LockedSource>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub dist: Option<LockedDist>,
+
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+ pub require: BTreeMap<String, String>,
+
+ #[serde(
+ rename = "require-dev",
+ default,
+ skip_serializing_if = "BTreeMap::is_empty"
+ )]
+ pub require_dev: BTreeMap<String, String>,
+
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+ pub conflict: BTreeMap<String, String>,
+
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+ pub provide: BTreeMap<String, String>,
+
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+ pub replace: BTreeMap<String, String>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub suggest: Option<BTreeMap<String, String>>,
+
+ #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
+ pub package_type: Option<String>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub autoload: Option<serde_json::Value>,
+
+ #[serde(rename = "autoload-dev", skip_serializing_if = "Option::is_none")]
+ pub autoload_dev: Option<serde_json::Value>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub license: Option<Vec<String>>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub description: Option<String>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub homepage: Option<String>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub keywords: Option<Vec<String>>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub authors: Option<Vec<serde_json::Value>>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub support: Option<serde_json::Value>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub funding: Option<Vec<serde_json::Value>>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub time: Option<String>,
+
+ /// Catch-all for extra fields we don't explicitly model
+ #[serde(flatten)]
+ pub extra_fields: BTreeMap<String, serde_json::Value>,
+}
+
+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<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct LockedDist {
+ #[serde(rename = "type")]
+ pub dist_type: String,
+ pub url: String,
+ pub reference: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub shasum: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct LockAlias {
+ pub package: String,
+ pub version: String,
+ pub alias: String,
+ pub alias_normalized: String,
+}
+
+impl LockFile {
+ /// Create default readme entries.
+ pub fn default_readme() -> Vec<String> {
+ vec![
+ "This file locks the dependencies of your project to a known state".to_string(),
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies".to_string(),
+ "This file is @generated automatically".to_string(),
+ ]
+ }
+
+ /// Read a composer.lock file from disk.
+ pub fn read_from_file(path: &Path) -> anyhow::Result<LockFile> {
+ let content = fs::read_to_string(path)?;
+ let lock: LockFile = serde_json::from_str(&content)?;
+ Ok(lock)
+ }
+
+ /// Write a composer.lock file to disk with deterministic formatting.
+ pub fn write_to_file(&self, path: &Path) -> anyhow::Result<()> {
+ let json = to_json_pretty(self)?;
+ fs::write(path, json)?;
+ Ok(())
+ }
+
+ /// Check if the lock file is fresh (content-hash matches composer.json).
+ pub fn is_fresh(&self, composer_json_content: &str) -> bool {
+ match Self::compute_content_hash(composer_json_content) {
+ Ok(hash) => hash == self.content_hash,
+ Err(_) => false,
+ }
+ }
+
+ /// Compute the content hash from composer.json content.
+ /// Matches Composer's `Locker::getContentHash()`.
+ pub fn compute_content_hash(composer_json_content: &str) -> anyhow::Result<String> {
+ let value: serde_json::Value = serde_json::from_str(composer_json_content)?;
+ let obj = value
+ .as_object()
+ .ok_or_else(|| anyhow::anyhow!("composer.json must be a JSON object"))?;
+
+ // Keys that affect the content hash (Composer's relevantKeys)
+ let relevant_keys = [
+ "name",
+ "version",
+ "require",
+ "require-dev",
+ "conflict",
+ "replace",
+ "provide",
+ "minimum-stability",
+ "prefer-stable",
+ "repositories",
+ "extra",
+ ];
+
+ // Collect relevant keys into a BTreeMap (auto-sorted by key)
+ let mut filtered: BTreeMap<&str, &serde_json::Value> = BTreeMap::new();
+ for key in &relevant_keys {
+ if let Some(v) = obj.get(*key) {
+ filtered.insert(key, v);
+ }
+ }
+
+ // Also include config.platform if present
+ if let Some(config) = obj.get("config")
+ && let Some(platform) = config.get("platform")
+ {
+ filtered.insert("config.platform", platform);
+ }
+
+ // Encode to compact JSON
+ let compact = serde_json::to_string(&filtered)?;
+
+ // Compute MD5
+ let digest = md5::compute(compact.as_bytes());
+ Ok(format!("{:x}", digest))
+ }
+
+ /// 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<String> {
+ let mut messages = Vec::new();
+ let mut any_missing = false;
+
+ let base_pool: Vec<LockedSearchEntry> = self
+ .packages
+ .iter()
+ .map(|p| LockedSearchEntry::build(p, &self.aliases))
+ .collect();
+ let mut dev_pool: Vec<LockedSearchEntry> = 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<String>,
+}
+
+impl<'a> LockedSearchEntry<'a> {
+ fn build(package: &'a LockedPackage, root_aliases: &[LockAlias]) -> Self {
+ let mut alias_versions: Vec<String> = 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<LockAlias> {
+ 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<String, String>,
+ description: &str,
+ pool: &[LockedSearchEntry],
+ messages: &mut Vec<String>,
+ 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<ResolvedPackage>,
+ /// Raw composer.json content string (for content-hash computation).
+ pub composer_json_content: String,
+ /// Parsed composer.json data (for platform, minimum-stability, etc.).
+ pub composer_json: RawPackageData,
+ /// Whether require-dev was included in resolution.
+ pub include_dev: bool,
+ /// 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<RepositorySet>,
+ /// 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<LockFile>,
+ /// 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<String>,
+}
+
+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<PackagistVersion> {
+ 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<PackagistVersion> {
+ 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<PackagistVersion> {
+ 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<String, String>,
+ require_dev: &BTreeMap<String, String>,
+) -> BTreeMap<String, String> {
+ 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<String> {
+ // 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<String, serde_json::Value> = BTreeMap::new();
+
+ if let Some(extra) = &pv.extra {
+ extra_fields.insert("extra".to_string(), extra.clone());
+ }
+ if let Some(notification_url) = &pv.notification_url {
+ extra_fields.insert(
+ "notification-url".to_string(),
+ serde_json::Value::String(notification_url.clone()),
+ );
+ }
+ // 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<String, String>,
+ _require_dev: &BTreeMap<String, String>,
+ requires_by_name: &IndexMap<String, Vec<String>>,
+ providers_by_name: &IndexMap<String, Vec<String>>,
+) -> IndexSet<String> {
+ // BFS from non-dev root dependencies through each package's `require` map.
+ // All reachable packages are production packages.
+ let mut production: IndexSet<String> = IndexSet::new();
+ let mut queue: VecDeque<String> = VecDeque::new();
+
+ let visit = |name: &str, production: &mut IndexSet<String>, queue: &mut VecDeque<String>| {
+ 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<String, String>) -> serde_json::Value {
+ let map: serde_json::Map<String, serde_json::Value> = requirements
+ .iter()
+ .filter(|(k, _)| is_platform_name(k))
+ .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
+ .collect();
+ serde_json::Value::Object(map)
+}
+
+/// Generate a complete `LockFile` from resolution results.
+///
+/// This function:
+/// 1. Fetches full metadata from Packagist for each resolved package
+/// 2. Separates packages into production vs dev-only
+/// 3. Computes the content-hash
+/// 4. Assembles the complete `LockFile` struct
+pub async fn generate_lock_file(request: &LockFileGenerationRequest) -> anyhow::Result<LockFile> {
+ // 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<String, String>,
+ require_dev: BTreeMap<String, String>,
+ conflict: BTreeMap<String, String>,
+ provide: BTreeMap<String, String>,
+ replace: BTreeMap<String, String>,
+ suggest: Option<BTreeMap<String, String>>,
+ }
+ let mut preserved_rel: IndexMap<String, PreservedRelationships> = 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<String, PackagistVersion> = 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<ResolvedPackage> = 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<String, Vec<String>> = 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<String, Vec<String>> = IndexMap::new();
+ for (name, pv) in &package_metadata {
+ let name_lower = name.to_lowercase();
+ let (require_keys, provide_keys, replace_keys): (Vec<String>, Vec<String>, Vec<String>) =
+ 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<LockedPackage> = Vec::new();
+ let mut packages_dev: Vec<LockedPackage> = 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<LockAlias> = 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<String, String>,
+ ) -> PackagistVersion {
+ PackagistVersion {
+ version: version.to_string(),
+ version_normalized: version_normalized.to_string(),
+ require,
+ replace: BTreeMap::new(),
+ provide: BTreeMap::new(),
+ conflict: BTreeMap::new(),
+ dist: Some(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<String, PackagistVersion> = 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<String, Vec<String>> = metadata
+ .iter()
+ .map(|(name, pv)| (name.to_lowercase(), pv.require.keys().cloned().collect()))
+ .collect();
+ let providers_by_name: IndexMap<String, Vec<String>> = 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<String, PackagistVersion> = 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<String, Vec<String>> = metadata
+ .iter()
+ .map(|(name, pv)| (name.to_lowercase(), pv.require.keys().cloned().collect()))
+ .collect();
+ let providers_by_name: IndexMap<String, Vec<String>> = 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<LockedPackage>, dev: Vec<LockedPackage>) -> 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<Option<T>, 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<T, D::Error>
+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<String>,
+ pub shasum: Option<String>,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct PackagistSource {
+ #[serde(rename = "type")]
+ pub source_type: String,
+ pub url: String,
+ pub reference: Option<String>,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct PackagistVersion {
+ pub version: String,
+ pub version_normalized: String,
+ #[serde(default, deserialize_with = "deserialize_unset_as_default")]
+ pub require: BTreeMap<String, String>,
+ #[serde(default, deserialize_with = "deserialize_unset_as_default")]
+ pub replace: BTreeMap<String, String>,
+ #[serde(default, deserialize_with = "deserialize_unset_as_default")]
+ pub provide: BTreeMap<String, String>,
+ #[serde(default, deserialize_with = "deserialize_unset_as_default")]
+ pub conflict: BTreeMap<String, String>,
+ #[serde(default, deserialize_with = "deserialize_unset_as_none")]
+ pub dist: Option<PackagistDist>,
+ #[serde(default, deserialize_with = "deserialize_unset_as_none")]
+ pub source: Option<PackagistSource>,
+
+ #[serde(
+ rename = "require-dev",
+ default,
+ deserialize_with = "deserialize_unset_as_default"
+ )]
+ pub require_dev: BTreeMap<String, String>,
+
+ #[serde(default, deserialize_with = "deserialize_unset_as_none")]
+ pub suggest: Option<BTreeMap<String, String>>,
+
+ #[serde(
+ rename = "type",
+ default,
+ deserialize_with = "deserialize_unset_as_none"
+ )]
+ pub package_type: Option<String>,
+
+ #[serde(default, deserialize_with = "deserialize_unset_as_none")]
+ pub autoload: Option<serde_json::Value>,
+
+ #[serde(
+ rename = "autoload-dev",
+ default,
+ deserialize_with = "deserialize_unset_as_none"
+ )]
+ pub autoload_dev: Option<serde_json::Value>,
+
+ #[serde(default, deserialize_with = "deserialize_unset_as_none")]
+ pub license: Option<Vec<String>>,
+
+ #[serde(default, deserialize_with = "deserialize_unset_as_none")]
+ pub description: Option<String>,
+
+ #[serde(default, deserialize_with = "deserialize_unset_as_none")]
+ pub homepage: Option<String>,
+
+ #[serde(default, deserialize_with = "deserialize_unset_as_none")]
+ pub keywords: Option<Vec<String>>,
+
+ #[serde(default, deserialize_with = "deserialize_unset_as_none")]
+ pub authors: Option<Vec<serde_json::Value>>,
+
+ #[serde(default, deserialize_with = "deserialize_unset_as_none")]
+ pub support: Option<serde_json::Value>,
+
+ #[serde(default, deserialize_with = "deserialize_unset_as_none")]
+ pub funding: Option<Vec<serde_json::Value>>,
+
+ #[serde(default, deserialize_with = "deserialize_unset_as_none")]
+ pub time: Option<String>,
+
+ #[serde(default, deserialize_with = "deserialize_unset_as_none")]
+ pub extra: Option<serde_json::Value>,
+
+ #[serde(
+ rename = "notification-url",
+ default,
+ deserialize_with = "deserialize_unset_as_none"
+ )]
+ pub notification_url: Option<String>,
+
+ /// `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: "<replacement-package>"`. 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<serde_json::Value>,
+}
+
+impl PackagistVersion {
+ /// Extract the `extra.branch-alias` map from this version's metadata.
+ ///
+ /// Composer packages can declare branch aliases in `extra.branch-alias`:
+ /// ```json
+ /// {
+ /// "extra": {
+ /// "branch-alias": {
+ /// "dev-master": "2.x-dev"
+ /// }
+ /// }
+ /// }
+ /// ```
+ ///
+ /// Returns a map from branch name (e.g. `"dev-master"`) to alias target
+ /// (e.g. `"2.x-dev"`). Returns an empty map when no aliases are declared.
+ pub fn branch_aliases(&self) -> BTreeMap<String, String> {
+ let Some(extra) = &self.extra else {
+ return BTreeMap::new();
+ };
+
+ let Some(branch_alias) = extra.get("branch-alias") else {
+ return BTreeMap::new();
+ };
+
+ let Some(map) = branch_alias.as_object() else {
+ return BTreeMap::new();
+ };
+
+ map.iter()
+ .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
+ .collect()
+ }
+}
+
+/// Parse a Packagist p2 API JSON response.
+///
+/// The response format is:
+/// ```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<Vec<PackagistVersion>> {
+ 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<serde_json::Value> = 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<Vec<PackagistVersion>> {
+ // Build cache key: replace `/` with `~` per cache key convention
+ let cache_key = format!("provider-{}.json", package_name.replace('/', "~"));
+
+ // Check cache first
+ if let Some(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<String>,
+ 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<serde_json::Value>,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct SearchResponse {
+ pub results: Vec<SearchResult>,
+ pub total: u64,
+ pub next: Option<String>,
+}
+
+/// Maximum number of pages to fetch from the Packagist search API.
+const SEARCH_MAX_PAGES: usize = 20;
+
+/// Percent-encode a string for use in a URL query parameter value.
+fn url_encode(s: &str) -> String {
+ let mut encoded = String::with_capacity(s.len());
+ for byte in s.bytes() {
+ match byte {
+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
+ encoded.push(byte as char);
+ }
+ b' ' => encoded.push_str("%20"),
+ other => {
+ encoded.push_str(&format!("%{other:02X}"));
+ }
+ }
+ }
+ encoded
+}
+
+/// Search Packagist for packages matching `query`.
+///
+/// Fetches up to `SEARCH_MAX_PAGES` pages of results and returns the full list.
+/// An optional `package_type` filter can narrow results (e.g. `"library"`).
+#[tracing::instrument(fields(type_filter = package_type))]
+pub async fn search_packages(
+ query: &str,
+ package_type: Option<&str>,
+) -> anyhow::Result<(Vec<SearchResult>, u64)> {
+ let client = crate::http::client_builder().build()?;
+
+ let mut all_results: Vec<SearchResult> = Vec::new();
+ let mut page = 1usize;
+ let mut next_url: Option<String> = None;
+ let mut total: u64 = 0;
+
+ loop {
+ let response: SearchResponse = if let Some(ref url) = next_url {
+ 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<String>,
+}
+
+/// 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<Vec<String>> {
+ 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<Vec<String>> {
+ let names = fetch_package_names(None, repo_cache).await?;
+ let mut seen: indexmap::IndexSet<String> = 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<String>,
+
+ pub cve: Option<String>,
+
+ /// Composer version constraint string, e.g. ">=1.0,<1.5.1|>=2.0,<2.3"
+ #[serde(rename = "affectedVersions")]
+ pub affected_versions: String,
+
+ pub source: String,
+
+ #[serde(rename = "reportedAt")]
+ pub reported_at: String,
+
+ #[serde(rename = "composerRepository")]
+ pub composer_repository: Option<String>,
+
+ pub severity: Option<String>,
+
+ #[serde(default)]
+ pub sources: Vec<AdvisorySource>,
+}
+
+/// A source entry within a security advisory.
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct AdvisorySource {
+ pub name: String,
+ #[serde(rename = "remoteId")]
+ pub remote_id: String,
+}
+
+/// Response from POST `https://packagist.org/api/security-advisories/`.
+#[derive(Debug, Deserialize)]
+pub struct SecurityAdvisoriesResponse {
+ pub advisories: BTreeMap<String, Vec<SecurityAdvisory>>,
+}
+
+/// Fetch security advisories for the given package names from the Packagist API.
+///
+/// Sends a POST request to `https://packagist.org/api/security-advisories/`
+/// with form-encoded package names. Returns advisories grouped by package name.
+///
+/// If the package list is very large (500+), requests are batched in chunks of
+/// 500 names per request and the results are merged.
+#[tracing::instrument(skip(package_names), fields(package_count = package_names.len()))]
+pub async fn fetch_security_advisories(
+ package_names: &[&str],
+) -> anyhow::Result<BTreeMap<String, Vec<SecurityAdvisory>>> {
+ let client = crate::http::client_builder().build()?;
+
+ let mut all_advisories: BTreeMap<String, Vec<SecurityAdvisory>> = BTreeMap::new();
+
+ for chunk in package_names.chunks(500) {
+ // Build an application/x-www-form-urlencoded body manually.
+ // Each package is encoded as `packages[]=<name>` and joined with `&`.
+ let body: String = chunk
+ .iter()
+ .map(|name| format!("packages[]={}", url_encode(name)))
+ .collect::<Vec<_>>()
+ .join("&");
+
+ 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<RawRepository> {
+ 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<RawRepository> {
+ 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<InlinePackage>,
+}
+
+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<LoadResult> {
+ 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<NamedPackagistVersion>,
+ pub names_found: Vec<String>,
+}
+
+/// 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:<url>"`).
+ 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<LoadResult>;
+
+ /// 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<Vec<SearchResult>> {
+ 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<Box<dyn Repository>>,
+}
+
+impl RepositorySet {
+ pub fn new(repos: Vec<Box<dyn Repository>>) -> 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<Item = &dyn Repository> {
+ 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<Vec<NamedPackagistVersion>> {
+ use indexmap::IndexSet;
+
+ let mut packages: Vec<NamedPackagistVersion> = Vec::new();
+ let mut answered: IndexSet<String> = IndexSet::new();
+
+ for repo in &self.repos {
+ let pending: Vec<PackageQuery<'_>> = 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<Vec<SearchResult>> {
+ 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<String, Vec<MatchedAdvisory>>, Vec<String>)> {
+ 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<String, Vec<super::packagist::SecurityAdvisory>>,
+ packages: &[PackageInfo],
+) -> BTreeMap<String, Vec<MatchedAdvisory>> {
+ let mut result: BTreeMap<String, Vec<MatchedAdvisory>> = 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<MatchedAdvisory> = 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<LoadResult> {
+ 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<Vec<SearchResult>> {
+ 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<regex::Regex> {
+ 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<LoadResult> {
+ 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<Regex>,
+ exclude: Option<Regex>,
+ /// `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<Regex> {
+ if patterns.is_empty() {
+ return None;
+ }
+ let parts: Vec<String> = 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<Vec<String>>,
+ exclude: Option<Vec<String>>,
+ canonical: Option<bool>,
+ ) -> 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<Stability>) {
+ 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<Stability> {
+ 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 `<modifier><digits?>` 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<Version> {
+ 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<Version> {
+ 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<String> {
+ 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<String> = 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<String> = 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<String> {
+ 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
+/// `<left> as <right>` 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<Regex> = LazyLock::new(|| {
+ Regex::new(
+ r"(?P<sep>^|\| *|, *)(?P<left>[^,\s#|]+)(?:#[^ ]+)? +as +(?P<right>[^,\s|]+)(?P<after>$| *\|| *,)",
+ )
+ .expect("alias clause regex compiles")
+});
+
+/// Strip every `<X> as <Y>` 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<String, String>,
+}
+
+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<String, Version> {
+ 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<String, Stability>,
+) -> 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: "<replacement>"` 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<String, Stability>,
+) -> Vec<PoolPackageInput> {
+ let mut results = Vec::new();
+
+ let make_input = |version_str: &str,
+ version_normalized: &str,
+ is_alias_of: Option<String>|
+ -> 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::<Vec<_>>(),
+ ),
+ replaces: make_pool_links(
+ package_name,
+ version_normalized,
+ &pv.replace
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect::<Vec<_>>(),
+ ),
+ provides: make_pool_links(
+ package_name,
+ version_normalized,
+ &pv.provide
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect::<Vec<_>>(),
+ ),
+ conflicts: make_pool_links(
+ package_name,
+ version_normalized,
+ &pv.conflict
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect::<Vec<_>>(),
+ ),
+ 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<String>,
+ /// 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<String, Stability>,
+ /// Whether prefer-stable is enabled.
+ pub prefer_stable: bool,
+ /// Whether prefer-lowest is enabled.
+ pub prefer_lowest: bool,
+ /// Platform package configuration.
+ pub platform: PlatformConfig,
+ /// Ignore all platform requirements.
+ pub ignore_platform_reqs: bool,
+ /// Specific platform requirements to ignore.
+ pub ignore_platform_req_list: Vec<String>,
+ /// 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<RepositorySet>,
+ /// Temporary version constraint overrides (from --with flag).
+ /// Maps package name (lowercase) to constraint string.
+ pub temporary_constraints: IndexMap<String, String>,
+ /// 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<RawRepository>,
+ /// 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<String, String>,
+ /// 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<String, String>,
+ /// 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<String, String>,
+ /// 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<String>,
+ /// 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<LockedPackageInfo>,
+ /// When true, drop abandoned packages (`abandoned: true|<replacement>`)
+ /// 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<String>,
+ /// `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<String, String>,
+ /// 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<String>,
+}
+
+/// 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<Vec<ResolvedPackage>, ResolveError> {
+ // 1. Build root requirements
+ let mut root_requires: IndexMap<String, Option<String>> = 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<String, Stability> = 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<RootAlias> = Vec::new();
+
+ let minimum_stability = request.minimum_stability;
+ let mut insert_root_require = |name: &str, constraint: &str| {
+ // Strip every `<X> as <Y>` 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 <Y>` 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<String> = 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<String, u32> = 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<String, String>| -> Vec<PoolLink> {
+ 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<String, String>| -> Vec<PoolLink> {
+ 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 <v> (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<String> = 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::<Vec<_>>()
+ .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<String, Vec<String>> = 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<String> = 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<String, Vec<&super::inline_package::InlinePackage>> =
+ 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<String, VersionConstraint> = 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<String> = 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<String> = 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<PackageQuery<'_>> = 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<PoolPackageInput> = 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<PoolLink> {
+ 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<u32> = 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<String> = 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::<Vec<_>>()
+ .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<u32> = 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<VcsPackageVersion> {
+ 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<String, Stability>,
+) -> Vec<PoolPackageInput> {
+ 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::<Vec<_>>(),
+ ),
+ replaces: make_pool_links(
+ &vpkg.name,
+ &vpkg.version_normalized,
+ &replace
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect::<Vec<_>>(),
+ ),
+ provides: make_pool_links(
+ &vpkg.name,
+ &vpkg.version_normalized,
+ &provide
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect::<Vec<_>>(),
+ ),
+ conflicts: make_pool_links(
+ &vpkg.name,
+ &vpkg.version_normalized,
+ &conflict
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect::<Vec<_>>(),
+ ),
+ 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<String, String> {
+ 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<String, Stability>,
+) -> 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<u64>, Option<String>) {
+ // Split off any pre-release suffix
+ let (base, suffix) = if let Some(pos) = v.find('-') {
+ (&v[..pos], Some(v[pos + 1..].to_string()))
+ } else {
+ (v, None)
+ };
+ let parts: Vec<u64> = base.split('.').filter_map(|p| p.parse().ok()).collect();
+ (parts, suffix)
+ };
+
+ let (a_parts, a_suffix) = parse(a);
+ let (b_parts, b_suffix) = parse(b);
+
+ // Compare numeric parts
+ let max_len = a_parts.len().max(b_parts.len());
+ for i in 0..max_len {
+ let a_val = a_parts.get(i).copied().unwrap_or(0);
+ let b_val = b_parts.get(i).copied().unwrap_or(0);
+ match a_val.cmp(&b_val) {
+ Ordering::Equal => continue,
+ other => return other,
+ }
+ }
+
+ // If numeric parts are equal, compare stability
+ // A stable version (no suffix) is greater than a pre-release
+ match (&a_suffix, &b_suffix) {
+ (None, None) => Ordering::Equal,
+ (None, Some(_)) => Ordering::Greater, // stable > pre-release
+ (Some(_), None) => Ordering::Less, // pre-release < stable
+ (Some(a_s), Some(b_s)) => {
+ let stab_a = stability_of(&format!("0.0.0.0-{a_s}"));
+ let stab_b = stability_of(&format!("0.0.0.0-{b_s}"));
+ // Lower stability value = more stable = greater version
+ match stab_a.cmp(&stab_b) {
+ Ordering::Equal => a_s.cmp(b_s),
+ // Stability enum: Stable(0) < RC(5) < Beta(10) < Alpha(15) < Dev(20)
+ // But more stable = higher version, so we reverse
+ Ordering::Less => Ordering::Greater,
+ Ordering::Greater => Ordering::Less,
+ }
+ }
+ }
+}
+
+/// Find the best version candidate given a preferred minimum stability.
+///
+/// Returns the highest version whose stability is at least as stable as
+/// the preferred stability (i.e., stability value <= preferred value).
+pub fn find_best_candidate(
+ versions: &[PackagistVersion],
+ preferred_stability: Stability,
+) -> Option<&PackagistVersion> {
+ versions
+ .iter()
+ .filter(|v| stability_of(&v.version_normalized) <= preferred_stability)
+ .max_by(|a, b| compare_normalized_versions(&a.version_normalized, &b.version_normalized))
+}
+
+/// Generate a recommended version constraint string from a concrete version.
+///
+/// Examples:
+/// - `"1.2.1"` (stable) → `"^1.2"`
+/// - `"0.3.5"` (stable) → `"^0.3"`
+/// - `"2.0.0-beta.1"` (beta) → `"^2.0@beta"`
+/// - `"dev-master"` (dev) → `"dev-master"`
+pub fn find_recommended_require_version(
+ version: &str,
+ version_normalized: &str,
+ stability: Stability,
+) -> String {
+ // dev branches are returned as-is
+ if stability == Stability::Dev {
+ return version.to_string();
+ }
+
+ // Extract major.minor from the normalized version (e.g. "1.2.3.0" → "1.2")
+ let base = if let Some(pos) = version_normalized.find('-') {
+ &version_normalized[..pos]
+ } else {
+ version_normalized
+ };
+
+ let parts: Vec<&str> = base.split('.').collect();
+ let major = parts.first().copied().unwrap_or("0");
+ let minor = parts.get(1).copied().unwrap_or("0");
+
+ let constraint = format!("^{major}.{minor}");
+
+ match stability {
+ Stability::Stable => constraint,
+ Stability::RC => format!("{constraint}@RC"),
+ Stability::Beta => format!("{constraint}@beta"),
+ Stability::Alpha => format!("{constraint}@alpha"),
+ Stability::Dev => 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<Option<PackagistVersion>> {
+ 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 `<hex> HEAD` lines in `git show-ref --head -d` output.
+static HEAD_REF_RE: LazyLock<Regex> =
+ 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<Option<String>> {
+ 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<Option<String>> {
+ 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<Option<String>> {
+ 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<String> = None;
+ let mut branch_not_found_error = false;
+
+ for i in 0..=1 {
+ let mut remote_branches: Vec<String> = 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<String> {
+ 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<Option<String>> {
+ 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<String> {
+ 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<String> {
+ 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<Option<String>> {
+ 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<String> {
+ 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<Option<String>>;
+
+ /// 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<Option<String>> {
+ 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<Option<String>> {
+ Ok(None)
+ }
+
+ /// Get commit log between two references.
+ fn commit_logs(&self, from: &str, to: &str, target: &Path) -> Result<String>;
+
+ /// 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<Regex> = 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<Option<String>> {
+ 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<String> {
+ 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<String>,
+ tags: Option<BTreeMap<String, String>>,
+ branches: Option<BTreeMap<String, String>>,
+ info_cache: IndexMap<String, Option<serde_json::Value>>,
+ git_driver: Option<Box<GitDriver>>,
+ 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<serde_json::Value> {
+ 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<Vec<serde_json::Value>> {
+ 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<String, String>> {
+ 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<String, String>> {
+ 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<Option<serde_json::Value>> {
+ 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<Option<String>> {
+ 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<Option<String>> {
+ 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<Option<DistReference>> {
+ 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<String>,
+ tags: Option<BTreeMap<String, String>>,
+ branches: Option<BTreeMap<String, String>>,
+ info_cache: IndexMap<String, Option<serde_json::Value>>,
+ git_driver: Option<Box<GitDriver>>,
+ 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<serde_json::Value> {
+ 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<Vec<serde_json::Value>> {
+ 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<serde_json::Value> = 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<String, String>> {
+ 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<String, String>> {
+ 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<Option<serde_json::Value>> {
+ 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<Option<String>> {
+ 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<Option<String>> {
+ 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<Option<DistReference>> {
+ 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<PathBuf>,
+ root_identifier: Option<String>,
+ tags: Option<BTreeMap<String, String>>,
+ branches: Option<BTreeMap<String, String>>,
+ info_cache: IndexMap<String, Option<serde_json::Value>>,
+ 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<String, String> {
+ 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<String, String> {
+ 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<String, String>> {
+ 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<String, String>> {
+ 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<Option<serde_json::Value>> {
+ 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<Option<String>> {
+ 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<Option<String>> {
+ 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<Option<DistReference>> {
+ // 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<String>,
+ tags: Option<BTreeMap<String, String>>,
+ branches: Option<BTreeMap<String, String>>,
+ repo_data: Option<serde_json::Value>,
+ info_cache: IndexMap<String, Option<serde_json::Value>>,
+ git_driver: Option<Box<GitDriver>>,
+ 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<serde_json::Value> {
+ 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<Vec<serde_json::Value>> {
+ 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<serde_json::Value> = 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<String, String>> {
+ 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<String, String>> {
+ 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<Option<serde_json::Value>> {
+ 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<Option<String>> {
+ 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<Option<String>> {
+ 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<Option<DistReference>> {
+ 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<String> {
+ use base64::Engine;
+ let cleaned: Vec<u8> = 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<String>,
+ root_identifier: Option<String>,
+ tags: Option<BTreeMap<String, String>>,
+ branches: Option<BTreeMap<String, String>>,
+ info_cache: IndexMap<String, Option<serde_json::Value>>,
+ git_driver: Option<Box<GitDriver>>,
+ 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<serde_json::Value> {
+ 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<Vec<serde_json::Value>> {
+ 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<serde_json::Value> = 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<String, String>> {
+ 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<String, String>> {
+ 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<Option<serde_json::Value>> {
+ 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<Option<String>> {
+ 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<Option<String>> {
+ 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<Option<DistReference>> {
+ 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<PathBuf>,
+ root_identifier: Option<String>,
+ tags: Option<BTreeMap<String, String>>,
+ branches: Option<BTreeMap<String, String>>,
+ info_cache: IndexMap<String, Option<serde_json::Value>>,
+ 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<String, String>> {
+ 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<String, String>> {
+ 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<Option<serde_json::Value>> {
+ 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<Option<String>> {
+ 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<Option<String>> {
+ 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<Option<DistReference>> {
+ 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<String>,
+}
+
+/// 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<String>,
+ /// GitLab OAuth token.
+ pub gitlab_token: Option<String>,
+ /// Bitbucket OAuth consumer key/secret.
+ pub bitbucket_oauth: Option<(String, String)>,
+ /// Forgejo token.
+ pub forgejo_token: Option<String>,
+ /// Custom GitLab domains (for self-hosted).
+ pub gitlab_domains: Vec<String>,
+ /// Custom Forgejo domains (for self-hosted).
+ pub forgejo_domains: Vec<String>,
+}
+
+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<String, String>>;
+
+ /// All tags as `name -> commit_hash`.
+ async fn tags(&mut self) -> Result<&BTreeMap<String, String>>;
+
+ /// Get composer.json content parsed as JSON for a given identifier.
+ async fn composer_information(&mut self, identifier: &str)
+ -> Result<Option<serde_json::Value>>;
+
+ /// Get raw file content at a given path and identifier.
+ async fn file_content(&self, file: &str, identifier: &str) -> Result<Option<String>>;
+
+ /// Get the change date for a given identifier (ISO 8601).
+ async fn change_date(&self, identifier: &str) -> Result<Option<String>>;
+
+ /// Get the dist reference for a given identifier.
+ async fn dist(&self, identifier: &str) -> Result<Option<DistReference>>;
+
+ /// 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<String, String>> {
+ dispatch_async!(self, branches)
+ }
+
+ pub async fn tags(&mut self) -> Result<&BTreeMap<String, String>> {
+ dispatch_async!(self, tags)
+ }
+
+ pub async fn composer_information(
+ &mut self,
+ identifier: &str,
+ ) -> Result<Option<serde_json::Value>> {
+ dispatch_async!(self, composer_information, identifier)
+ }
+
+ pub async fn file_content(&self, file: &str, identifier: &str) -> Result<Option<String>> {
+ dispatch_async!(self, file_content, file, identifier)
+ }
+
+ pub async fn change_date(&self, identifier: &str) -> Result<Option<String>> {
+ dispatch_async!(self, change_date, identifier)
+ }
+
+ pub async fn dist(&self, identifier: &str) -> Result<Option<DistReference>> {
+ 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<DriverType> {
+ 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<String>,
+ tags: Option<BTreeMap<String, String>>,
+ branches: Option<BTreeMap<String, String>>,
+ info_cache: IndexMap<String, Option<serde_json::Value>>,
+ 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<serde_json::Value> {
+ 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<Vec<String>> {
+ 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<String, String>> {
+ 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<String, String>> {
+ 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<Option<serde_json::Value>> {
+ 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<Option<String>> {
+ // 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<Option<String>> {
+ 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<Option<DistReference>> {
+ // 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<String> {
+ 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<String> {
+ let pattern = format!(r"<{tag}>([^<]*)</{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<Duration>,
+ env_overrides: IndexMap<String, Option<String>>,
+}
+
+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<String>, value: impl Into<String>) {
+ 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<String>) {
+ 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<ProcessOutput> {
+ 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<ProcessOutput> {
+ 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<DistReference>,
+ /// Whether this is the default branch version.
+ pub is_default_branch: bool,
+ /// Release date (ISO 8601).
+ pub time: Option<String>,
+}
+
+/// Repository that scans a VCS URL for package versions.
+///
+/// Corresponds to Composer's `Repository\VcsRepository`.
+pub struct VcsRepository {
+ url: String,
+ driver_type: Option<DriverType>,
+ 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<Vec<VcsPackageVersion>> {
+ 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<String> {
+ // 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<Regex> = 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<Regex> =
+ LazyLock::new(|| Regex::new(r"([&?]access_token=)[^&]+").unwrap());
+
+/// `<scheme>://user:password@` credential block.
+static CREDENTIALS_RE: LazyLock<Regex> = LazyLock::new(|| {
+ Regex::new(r"(?i)(?P<prefix>[a-z0-9]+://)?(?P<user>[^:/\s@]+):(?P<password>[^@\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<PathBuf> {
+ 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<bool> {
+ 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<Option<String>> {
+ 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<ProcessOutput> {
+ 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<String> {
+ 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<String> {
+ 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. `<scheme>://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: &regex::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<ProcessOutput> {
+ 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<ProcessOutput> {
+ 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<ProcessOutput> {
+ 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<ProcessOutput> {
+ let mut full_args = vec!["svn"];
+ full_args.extend_from_slice(args);
+ full_args.push("--non-interactive");
+
+ let cred_args: Vec<String>;
+ 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<String>,
+ pub pretty_version: Option<String>,
+ pub feature_version: Option<String>,
+ pub feature_pretty_version: Option<String>,
+}
+
+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<GuessedVersion> {
+ 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<GuessedVersion> {
+ let mut commit: Option<String> = None;
+ let mut version: Option<String> = None;
+ let mut pretty_version: Option<String> = None;
+ let mut feature_version: Option<String> = None;
+ let mut feature_pretty_version: Option<String> = 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<String> = 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<GuessedVersion> {
+ 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<String> = 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<GuessedVersion> {
+ 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"<url>.*/({trunk}|({branches}|{tags})/(.*))</url>",
+ 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<String>, Option<String>) {
+ 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<String> = 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<String> = 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<String> = 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<Regex> = LazyLock::new(|| {
+ Regex::new(
+ r"^(?:\* ) *(\(no branch\)|\(detached from \S+\)|\(HEAD detached at \S+\)|\S+) *([a-f0-9]+) .*$",
+ )
+ .unwrap()
+});
+
+static REMOTE_HEAD_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^ *.+/HEAD ").unwrap());
+
+static ANY_BRANCH_RE: LazyLock<Regex> = LazyLock::new(|| {
+ Regex::new(r"^(?:\* )? *((?:remotes/(?:origin|upstream)/)?[^\s/]+) *([a-f0-9]+) .*$").unwrap()
+});
+
+static REMOTES_PREFIX_RE: LazyLock<Regex> =
+ LazyLock::new(|| Regex::new(r"^remotes/[^/]+/").unwrap());
+
+static NINE_SEVEN_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\.9{7}").unwrap());
+
+static NINE_SEVEN_GROUP_RE: LazyLock<Regex> = 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"
+ );
+}