diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-10 00:32:08 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-10 00:32:08 +0900 |
| commit | 8cc1ba8a02c0318b65658f1634de378c780392b9 (patch) | |
| tree | fdd5cb61e488018891a486b25991b87c84220bb8 /crates/mozart-core/src | |
| parent | 72b2e877c01e67ba7edd37e34ac2eadb7a1c62c4 (diff) | |
| download | php-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/src')
64 files changed, 22581 insertions, 0 deletions
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(¤t_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: ®ex::Captures<'_>| { + let prefix = caps.name("prefix").map(|m| m.as_str()).unwrap_or(""); + let user = &caps["user"]; + if GITHUB_TOKEN_RE.is_match(user) { + format!("{prefix}***:***@") + } else { + format!("{prefix}{user}:***@") + } + }) + .into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_url_replaces_special_chars_with_dash() { + assert_eq!( + GitUtil::sanitize_url("https://github.com/owner/repo.git"), + "https---github.com-owner-repo.git" + ); + } + + #[test] + fn sanitize_url_preserves_dot() { + // Dot must survive — it appears in hostnames and ".git" suffixes. + let key = GitUtil::sanitize_url("git://example.org/foo.bar/baz.git"); + assert!(key.contains(".org")); + assert!(key.ends_with(".git")); + } + + #[test] + fn sanitize_url_redacts_password_in_credentials() { + let key = GitUtil::sanitize_url("https://alice:s3cret@example.com/repo.git"); + // Password is replaced with ***, then non-alphanumerics become '-'. + assert!(key.contains("alice")); + assert!(!key.contains("s3cret")); + } + + #[test] + fn sanitize_url_redacts_user_when_looks_like_github_token() { + // 40-hex token in the user position triggers full redaction. + let token = "abcdef0123456789abcdef0123456789abcdef01"; + let key = GitUtil::sanitize_url(&format!("https://{token}:x-oauth-basic@github.com/o/r")); + assert!(!key.contains("abcdef")); + } + + #[test] + fn sanitize_url_redacts_modern_github_pat() { + // ghp_xxx and github_pat_xxx forms. + let key1 = GitUtil::sanitize_url("https://ghp_abc123XYZ:x@github.com/o/r"); + assert!(!key1.contains("ghp_")); + let key2 = GitUtil::sanitize_url("https://github_pat_abc123:x@github.com/o/r"); + assert!(!key2.contains("github_pat_")); + } + + #[test] + fn sanitize_url_strips_access_token_query() { + let key = GitUtil::sanitize_url("https://api.github.com/x?access_token=secrettoken"); + assert!(!key.contains("secrettoken")); + } + + #[test] + fn sanitize_url_token_variants_share_cache_key() { + // Two pulls of the same repo with different access tokens should land + // in the same cache subdirectory. + let a = GitUtil::sanitize_url("https://api.github.com/repo?access_token=tokenA"); + let b = GitUtil::sanitize_url("https://api.github.com/repo?access_token=tokenB"); + assert_eq!(a, b); + } +} diff --git a/crates/mozart-core/src/vcs/util/hg.rs b/crates/mozart-core/src/vcs/util/hg.rs new file mode 100644 index 0000000..73051b7 --- /dev/null +++ b/crates/mozart-core/src/vcs/util/hg.rs @@ -0,0 +1,28 @@ +use super::super::process::{ProcessExecutor, ProcessOutput}; +use anyhow::Result; +use std::path::Path; + +/// Mercurial utility for command execution. +pub struct HgUtil { + process: ProcessExecutor, +} + +impl HgUtil { + pub fn new(process: ProcessExecutor) -> Self { + Self { process } + } + + /// Execute a Mercurial command. + pub fn execute(&self, args: &[&str], cwd: Option<&Path>) -> Result<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); + } +} |
