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-registry/src/installer_executor | |
| 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-registry/src/installer_executor')
4 files changed, 0 insertions, 1151 deletions
diff --git a/crates/mozart-registry/src/installer_executor/filesystem.rs b/crates/mozart-registry/src/installer_executor/filesystem.rs deleted file mode 100644 index cb1a2cc..0000000 --- a/crates/mozart-registry/src/installer_executor/filesystem.rs +++ /dev/null @@ -1,232 +0,0 @@ -//! Production [`InstallerExecutor`] that touches the real filesystem. -//! -//! This is the verb behind `mozart install` / `mozart update` — it pulls -//! dist archives via [`crate::downloader`], clones VCS sources via -//! [`mozart_vcs`], and removes vendor directories. Test code substitutes a -//! recording-only executor instead (added in a later step). - -use std::path::Path; - -use crate::cache::Cache; -use crate::downloader; - -use super::{ExecuteContext, InstallerExecutor, PackageOperation}; - -pub struct FilesystemExecutor { - files_cache: Cache, -} - -impl FilesystemExecutor { - pub fn new(files_cache: Cache) -> Self { - Self { files_cache } - } -} - -#[async_trait::async_trait] -impl InstallerExecutor for FilesystemExecutor { - async fn install_package( - &mut self, - op: PackageOperation<'_>, - ctx: &ExecuteContext, - ) -> anyhow::Result<()> { - // Marking an alias as installed/uninstalled has no filesystem side - // effects — the target package's files are already in vendor/. - // Mirrors Composer's `MarkAlias{,Un}installedOperation` which the - // installation manager only uses to update the in-memory installed - // repository. - let Some(pkg) = op.package() else { - return Ok(()); - }; - - // Try source install if --prefer-source and source info is available. - if ctx.prefer_source - && let Some(source) = &pkg.source - { - return install_from_source( - &source.source_type, - &source.url, - source.reference.as_deref().unwrap_or("HEAD"), - &ctx.vendor_dir, - &pkg.name, - ); - } - - // A package with neither dist nor source has no install action. - // This covers Composer's `type: metapackage` (modeled explicitly as - // "no installer") and inline `type: package` definitions used in - // test fixtures that intentionally omit download metadata. Mozart - // records the operation and the installed.json entry but performs - // no filesystem work, mirroring Composer's MetapackageInstaller. - if pkg.dist.is_none() && pkg.source.is_none() { - return Ok(()); - } - - let dist = pkg.dist.as_ref().ok_or_else(|| { - anyhow::anyhow!( - "Package {} has no dist information. Use --prefer-source to install from VCS.", - pkg.name, - ) - })?; - - let mut progress = downloader::DownloadProgress::new( - !ctx.no_progress, - format!("{} ({})", pkg.name, pkg.version), - ); - - downloader::install_package( - &dist.url, - &dist.dist_type, - dist.shasum.as_deref(), - &ctx.vendor_dir, - &pkg.name, - Some(&mut progress), - &self.files_cache, - ) - .await?; - - progress.finish(); - Ok(()) - } - - fn uninstall_package( - &mut self, - name: &str, - _version: &str, - ctx: &ExecuteContext, - ) -> anyhow::Result<()> { - let pkg_dir = ctx.vendor_dir.join(name); - if pkg_dir.exists() { - std::fs::remove_dir_all(&pkg_dir)?; - } - Ok(()) - } - - fn cleanup_after_uninstalls(&mut self, ctx: &ExecuteContext) -> anyhow::Result<()> { - cleanup_empty_vendor_dirs(&ctx.vendor_dir) - } -} - -/// Remove empty vendor namespace directories left behind after package -/// removals. Skips the `composer/` and `bin/` directories. Mirrors the -/// post-uninstall cleanup Composer does in `LibraryInstaller::removeCode`. -fn cleanup_empty_vendor_dirs(vendor_dir: &Path) -> anyhow::Result<()> { - if let Ok(entries) = std::fs::read_dir(vendor_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - let name = entry.file_name().to_string_lossy().to_string(); - if name == "composer" || name == "bin" { - continue; - } - if std::fs::read_dir(&path)?.next().is_none() { - std::fs::remove_dir(&path)?; - } - } - } - } - Ok(()) -} - -/// Install a package from VCS source (git/svn/hg). Lifted from the previous -/// `commands/install.rs::install_from_source`. Mirrors the per-driver -/// dispatch in `Composer\Downloader\VcsDownloader::install`. -fn install_from_source( - source_type: &str, - url: &str, - reference: &str, - vendor_dir: &Path, - package_name: &str, -) -> anyhow::Result<()> { - let target = vendor_dir.join(package_name); - if target.exists() { - std::fs::remove_dir_all(&target)?; - } - - match source_type { - "git" => { - let process = mozart_vcs::process::ProcessExecutor::new(); - let git_util = - mozart_vcs::util::git::GitUtil::new(process, vendor_dir.join(".cache").join("git")); - let downloader = mozart_vcs::downloader::git::GitDownloader::new(git_util); - use mozart_vcs::downloader::VcsDownloader; - downloader.download(url, reference, &target)?; - downloader.install(url, reference, &target)?; - } - "svn" => { - let process = mozart_vcs::process::ProcessExecutor::new(); - let svn_util = mozart_vcs::util::svn::SvnUtil::new(process); - let downloader = mozart_vcs::downloader::svn::SvnDownloader::new(svn_util); - use mozart_vcs::downloader::VcsDownloader; - downloader.install(url, reference, &target)?; - } - "hg" => { - let process = mozart_vcs::process::ProcessExecutor::new(); - let hg_util = mozart_vcs::util::hg::HgUtil::new(process); - let downloader = mozart_vcs::downloader::hg::HgDownloader::new(hg_util); - use mozart_vcs::downloader::VcsDownloader; - downloader.install(url, reference, &target)?; - } - _ => { - anyhow::bail!("Unsupported source type for VCS install: {}", source_type); - } - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - fn make_executor() -> FilesystemExecutor { - FilesystemExecutor::new(Cache::new(std::env::temp_dir().join("__no_cache"), false)) - } - - #[test] - fn cleanup_after_uninstalls_removes_empty_namespace_dirs() { - let dir = tempdir().unwrap(); - let vendor_dir = dir.path().join("vendor"); - std::fs::create_dir_all(&vendor_dir).unwrap(); - - let empty_ns = vendor_dir.join("old-vendor"); - std::fs::create_dir_all(&empty_ns).unwrap(); - - let nonempty_ns = vendor_dir.join("psr"); - std::fs::create_dir_all(nonempty_ns.join("log")).unwrap(); - - std::fs::create_dir_all(vendor_dir.join("composer")).unwrap(); - - let mut exec = make_executor(); - exec.cleanup_after_uninstalls(&ExecuteContext { - vendor_dir: vendor_dir.clone(), - no_progress: true, - prefer_source: false, - }) - .unwrap(); - - assert!(!empty_ns.exists()); - assert!(vendor_dir.join("psr").exists()); - assert!(vendor_dir.join("composer").exists()); - } - - #[test] - fn cleanup_after_uninstalls_preserves_bin_dir() { - let dir = tempdir().unwrap(); - let vendor_dir = dir.path().join("vendor"); - std::fs::create_dir_all(&vendor_dir).unwrap(); - - let bin_dir = vendor_dir.join("bin"); - std::fs::create_dir_all(&bin_dir).unwrap(); - - let mut exec = make_executor(); - exec.cleanup_after_uninstalls(&ExecuteContext { - vendor_dir: vendor_dir.clone(), - no_progress: true, - prefer_source: false, - }) - .unwrap(); - - assert!(bin_dir.exists()); - } -} diff --git a/crates/mozart-registry/src/installer_executor/mod.rs b/crates/mozart-registry/src/installer_executor/mod.rs deleted file mode 100644 index 4ddad66..0000000 --- a/crates/mozart-registry/src/installer_executor/mod.rs +++ /dev/null @@ -1,348 +0,0 @@ -//! Installation execution abstraction. -//! -//! Mirrors `Composer\Installer\InstallationManager`: the per-operation -//! side-effect surface (download, extract, remove from vendor/) lives behind -//! a trait so test code can substitute a recording-only implementation -//! (Composer's `InstallationManagerMock`) without going anywhere near the -//! filesystem or the network. -//! -//! The orchestration loop (computing operations from lock vs installed, -//! emitting console messages, writing `installed.json`, generating the -//! autoloader) stays in the caller. The executor is purely the verb — -//! "install this package" / "uninstall this package" — so test traces match -//! Composer's `(string) $operation` byte-for-byte without the executor -//! having to also reproduce console formatting. - -use std::path::PathBuf; - -use crate::installed::InstalledPackageEntry; -use crate::lockfile::{LockAlias, LockedPackage}; - -pub mod filesystem; -pub mod trace_recorder; -pub mod transaction; - -pub use filesystem::FilesystemExecutor; -pub use trace_recorder::TraceRecorderExecutor; -pub use transaction::{ - Action, StaleInstalledAlias, compute_operations, compute_stale_installed_aliases, - locked_to_installed_entry, previously_installed_alias_versions, -}; - -/// One install or update operation handed to [`InstallerExecutor::install_package`]. -#[derive(Debug, Clone, Copy)] -pub enum PackageOperation<'a> { - /// First-time install. The whole package directory is created from - /// `package.dist`/`package.source`. - Install { package: &'a LockedPackage }, - /// Replace an existing install with a new version. `from_version` is the - /// pretty version that was installed before (no reference suffix — - /// drives the upgrade-vs-downgrade direction). `from_full_pretty` / - /// `to_full_pretty` are the formatted display strings used verbatim in - /// the trace output; the caller renders them via - /// [`format_update_pretty_versions`] so the SOURCE_REF / DIST_REF mode - /// switch from Composer's `UpdateOperation::format` lands on both sides. - Update { - from_version: &'a str, - from_full_pretty: &'a str, - to_full_pretty: &'a str, - package: &'a LockedPackage, - }, - /// Mark an alias of a real package as installed. No filesystem effects — - /// only the trace recorder needs this. Mirrors Composer's - /// `MarkAliasInstalledOperation`. - MarkAliasInstalled { - /// The alias entry from `composer.lock`'s `aliases[]` block. Carries - /// pretty + normalized alias version and the target's pretty version. - alias: &'a LockAlias, - /// The target package the alias points at — used to source the - /// reference suffix for the trace line. - target: &'a LockedPackage, - }, - /// Mark a previously-installed alias as uninstalled. No filesystem - /// effects — only the trace recorder cares. Mirrors Composer's - /// `MarkAliasUninstalledOperation`. Composer derives the AliasPackage - /// from the previous installed.json entries (via `extra.branch-alias`), - /// then emits this when the alias is no longer in the result. Caller - /// pre-renders the display strings so this variant doesn't need to know - /// how to spelunk the entry. - MarkAliasUninstalled { - /// Package name (e.g. `a/a`) used as both the alias's name and the - /// target's name on the trace line. - name: &'a str, - /// Alias's full-pretty form (alias pretty version plus reference - /// suffix), e.g. `1.0.x-dev master`. - alias_full: &'a str, - /// Target's full-pretty form, e.g. `dev-master master`. - target_full: &'a str, - }, -} - -impl<'a> PackageOperation<'a> { - pub fn package(&self) -> Option<&'a LockedPackage> { - match self { - PackageOperation::Install { package } | PackageOperation::Update { package, .. } => { - Some(package) - } - PackageOperation::MarkAliasInstalled { .. } - | PackageOperation::MarkAliasUninstalled { .. } => None, - } - } -} - -/// Mirror Composer's `BasePackage::getFullPrettyVersion()` for a `LockedPackage`. -/// -/// For dev-stability versions backed by a git/hg source, append the reference -/// (truncated to 7 chars when it looks like a 40-char sha1). Otherwise return -/// the pretty version unchanged. -pub fn format_full_pretty_version(pkg: &LockedPackage) -> String { - format_full_pretty_with_pretty(&pkg.version, pkg) -} - -/// Same as [`format_full_pretty_version`] but lets the caller supply an -/// alternate pretty version (used by `MarkAliasInstalled` so the alias's -/// `3.2.x-dev` text is rendered with the *target's* reference). -pub fn format_full_pretty_with_pretty(pretty_version: &str, pkg: &LockedPackage) -> String { - let source_ref = pkg.source.as_ref().and_then(|s| s.reference.as_deref()); - let dist_ref = pkg.dist.as_ref().and_then(|d| d.reference.as_deref()); - let source_type = pkg.source.as_ref().map(|s| s.source_type.as_str()); - format_full_pretty_with_refs( - pretty_version, - &pkg.version, - source_ref, - dist_ref, - source_type, - ) -} - -/// Render an alias's full pretty version: the alias's own pretty form for -/// the visible text, the alias's *normalized* version for the dev-stability -/// gate, and the target package's source/dist references for the suffix. -/// Mirrors `AliasPackage::getFullPrettyVersion`, where the alias decides on -/// its own whether to append a reference based on its own stability — so a -/// stable alias like `1.0.0` skips the suffix even when the target is a dev -/// branch. -pub fn format_full_pretty_alias( - alias_pretty: &str, - alias_version: &str, - target: &LockedPackage, -) -> String { - let source_ref = target.source.as_ref().and_then(|s| s.reference.as_deref()); - let dist_ref = target.dist.as_ref().and_then(|d| d.reference.as_deref()); - let source_type = target.source.as_ref().map(|s| s.source_type.as_str()); - format_full_pretty_with_refs( - alias_pretty, - alias_version, - source_ref, - dist_ref, - source_type, - ) -} - -/// Same as [`format_full_pretty_version_for_installed`] but lets the caller -/// supply an alternate pretty version. Used when emitting -/// `MarkAliasUninstalled`: the alias's `1.0.x-dev` text needs to be rendered -/// with the *target installed entry's* reference suffix. -pub fn format_full_pretty_with_pretty_for_installed( - pretty_version: &str, - entry: &InstalledPackageEntry, -) -> String { - let source_ref = entry - .source - .as_ref() - .and_then(|v| v.get("reference")) - .and_then(|v| v.as_str()); - let dist_ref = entry - .dist - .as_ref() - .and_then(|v| v.get("reference")) - .and_then(|v| v.as_str()); - let source_type = entry - .source - .as_ref() - .and_then(|v| v.get("type")) - .and_then(|v| v.as_str()); - format_full_pretty_with_refs( - pretty_version, - &entry.version, - source_ref, - dist_ref, - source_type, - ) -} - -/// Mirror Composer's `BasePackage::getFullPrettyVersion()` for an -/// `InstalledPackageEntry`. Same display rules as -/// [`format_full_pretty_version`] but pulls source/dist info out of the -/// installed.json `source`/`dist` JSON values. -pub fn format_full_pretty_version_for_installed(entry: &InstalledPackageEntry) -> String { - format_full_pretty_with_pretty_for_installed(&entry.version, entry) -} - -/// Render the from/to display strings for an update trace line, mirroring -/// Composer's `UpdateOperation::format`. Defaults to `DISPLAY_SOURCE_REF_IF_DEV`, -/// then if both sides render identically: -/// -/// - source references differ → re-render in `DISPLAY_SOURCE_REF` mode, -/// - else dist references differ → re-render in `DISPLAY_DIST_REF` mode. -/// -/// Without the switch, two same-version-different-reference packages would -/// produce a useless `pkg (X => X)` trace line. -pub fn format_update_pretty_versions( - from_entry: &InstalledPackageEntry, - to_pkg: &LockedPackage, -) -> (String, String) { - let from_default = format_full_pretty_version_for_installed(from_entry); - let to_default = format_full_pretty_version(to_pkg); - if from_default != to_default { - return (from_default, to_default); - } - - let from_source_ref = from_entry - .source - .as_ref() - .and_then(|v| v.get("reference")) - .and_then(|v| v.as_str()); - let from_source_type = from_entry - .source - .as_ref() - .and_then(|v| v.get("type")) - .and_then(|v| v.as_str()); - let to_source_ref = to_pkg.source.as_ref().and_then(|s| s.reference.as_deref()); - let to_source_type = to_pkg.source.as_ref().map(|s| s.source_type.as_str()); - - if from_source_ref != to_source_ref { - return ( - format_with_explicit_reference(&from_entry.version, from_source_ref, from_source_type), - format_with_explicit_reference(&to_pkg.version, to_source_ref, to_source_type), - ); - } - - let from_dist_ref = from_entry - .dist - .as_ref() - .and_then(|v| v.get("reference")) - .and_then(|v| v.as_str()); - let to_dist_ref = to_pkg.dist.as_ref().and_then(|d| d.reference.as_deref()); - - if from_dist_ref != to_dist_ref { - return ( - format_with_explicit_reference(&from_entry.version, from_dist_ref, from_source_type), - format_with_explicit_reference(&to_pkg.version, to_dist_ref, to_source_type), - ); - } - - (from_default, to_default) -} - -/// Render `pretty_version` with an explicitly chosen reference, mirroring -/// Composer's `BasePackage::getFullPrettyVersion` with `DISPLAY_SOURCE_REF` -/// or `DISPLAY_DIST_REF`: skip the dev-stability gate, just truncate sha1 -/// references and concatenate. A `None` reference falls back to the bare -/// pretty version. -fn format_with_explicit_reference( - pretty_version: &str, - reference: Option<&str>, - source_type: Option<&str>, -) -> String { - let Some(reference) = reference else { - return pretty_version.to_string(); - }; - if matches!(source_type, Some("svn")) { - return format!("{} {}", pretty_version, reference); - } - if reference.len() == 40 { - return format!("{} {}", pretty_version, &reference[..7]); - } - format!("{} {}", pretty_version, reference) -} - -/// Core of `BasePackage::getFullPrettyVersion()` factored over raw -/// fields so both [`LockedPackage`] and [`InstalledPackageEntry`] can share -/// the rendering logic. `version` drives the dev-stability check; the result -/// is `pretty_version` plus a reference suffix when the package is a dev -/// branch backed by git/hg (with sha1 references truncated to 7 chars). -fn format_full_pretty_with_refs( - pretty_version: &str, - version: &str, - source_ref: Option<&str>, - dist_ref: Option<&str>, - source_type: Option<&str>, -) -> String { - let is_dev = mozart_semver::Version::parse(version) - .map(|v| matches!(v.pre_release.as_deref(), Some("dev")) || v.is_dev_branch) - .unwrap_or(false); - if !is_dev { - return pretty_version.to_string(); - } - // Composer falls back to dist reference only when no source type is set - // (or the package isn't git/hg — in which case the dev display is skipped - // entirely above). - let reference = source_ref.or(match source_type { - Some("git") | Some("hg") => None, - _ => dist_ref, - }); - let Some(reference) = reference else { - return pretty_version.to_string(); - }; - if matches!(source_type, Some("git") | Some("hg")) && reference.len() == 40 { - format!("{} {}", pretty_version, &reference[..7]) - } else if matches!(source_type, Some("svn")) { - // svn references are revision numbers, never truncated - format!("{} {}", pretty_version, reference) - } else if reference.len() == 40 { - // dist-ref fallback (no git/hg source) — Composer truncates here too - format!("{} {}", pretty_version, &reference[..7]) - } else { - format!("{} {}", pretty_version, reference) - } -} - -/// Per-call configuration shared across executor methods. Owned by the -/// caller (typically `install_from_lock`) so the executor sees a consistent -/// view across an entire install/update run. -#[derive(Debug, Clone)] -pub struct ExecuteContext { - pub vendor_dir: PathBuf, - /// Suppress download progress bars. - pub no_progress: bool, - /// Prefer cloning from VCS source over downloading dist archives. - pub prefer_source: bool, -} - -/// Side-effect surface for install/update/uninstall operations. -/// -/// Implementations are stateful — `&mut self` lets a recorder accumulate -/// trace lines and lets the filesystem implementation hold long-lived -/// handles (caches, progress bars). All methods return `anyhow::Result` so -/// callers can short-circuit on the first failure, mirroring Composer's -/// fail-fast `InstallationManager::execute`. -#[async_trait::async_trait] -pub trait InstallerExecutor: Send + Sync { - /// Perform side effects for one install or update operation. - async fn install_package( - &mut self, - op: PackageOperation<'_>, - ctx: &ExecuteContext, - ) -> anyhow::Result<()>; - - /// Perform side effects for one uninstall. - /// - /// `version` is the previously-installed version (from installed.json), - /// passed so the trace recorder can format Composer's - /// `Uninstalling pkg/name (version)` line. The filesystem implementation - /// ignores it — `name` alone is enough to locate the vendor directory. - fn uninstall_package( - &mut self, - name: &str, - version: &str, - ctx: &ExecuteContext, - ) -> anyhow::Result<()>; - - /// Hook called once after every uninstall has run. Default no-op. - /// Composer cleans up empty namespace directories here; the recorder - /// has no work to do. - fn cleanup_after_uninstalls(&mut self, _ctx: &ExecuteContext) -> anyhow::Result<()> { - Ok(()) - } -} diff --git a/crates/mozart-registry/src/installer_executor/trace_recorder.rs b/crates/mozart-registry/src/installer_executor/trace_recorder.rs deleted file mode 100644 index b60a869..0000000 --- a/crates/mozart-registry/src/installer_executor/trace_recorder.rs +++ /dev/null @@ -1,160 +0,0 @@ -//! Recording-only [`InstallerExecutor`] for in-process tests. -//! -//! Mirrors `Composer\Test\Mock\InstallationManagerMock` — every call appends -//! a string to a `Vec<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-registry/src/installer_executor/transaction.rs b/crates/mozart-registry/src/installer_executor/transaction.rs deleted file mode 100644 index 95f9718..0000000 --- a/crates/mozart-registry/src/installer_executor/transaction.rs +++ /dev/null @@ -1,411 +0,0 @@ -//! Transaction computation — lock-vs-installed diff and alias reconciliation. -//! -//! Mirrors `Composer\DependencyResolver\Transaction::calculateOperations` and -//! `Composer\Installer\InstalledFilesystemRepository` (the `ArrayDumper` -//! path). Kept separate so both `install` and `update` commands can share the -//! same operation-computation machinery without going through the `install` -//! command module. - -use crate::installed::{InstalledPackageEntry, InstalledPackages}; -use crate::lockfile::{LockFile, LockedPackage}; -use indexmap::IndexSet; -use std::path::Path; - -/// The action to take for a package during install. -#[derive(Debug, PartialEq, Eq)] -pub enum Action { - Install, - Update, - Skip, -} - -/// Compute install operations by comparing locked packages against installed packages. -/// -/// Returns `(ops, removals)` where: -/// - `ops`: list of `(package, action)` ordered topologically — every package's -/// lock-internal `require` deps appear before it, matching Composer's -/// `Transaction::calculateOperations`. -/// - `removals`: list of package names that are installed but not locked. -pub fn compute_operations<'a>( - locked: &[&'a LockedPackage], - installed: &InstalledPackages, -) -> (Vec<(&'a LockedPackage, Action)>, Vec<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) = crate::resolver::normalize_branch_alias_target(target_str) - { - out.push(normalized); - emitted_explicit_alias = true; - } - } - } - - if !emitted_explicit_alias - && entry - .extra_fields - .get("default-branch") - .and_then(|v| v.as_bool()) - .unwrap_or(false) - { - out.push("9999999.9999999.9999999.9999999-dev".to_string()); - } - } - out -} - -/// Convert a `LockedPackage` to an `InstalledPackageEntry`. -/// -/// Mirrors Composer's `InstalledFilesystemRepository::write()` via -/// `ArrayDumper` — `extra_fields` is forwarded verbatim so flags like -/// `abandoned` and `default-branch` survive the lock → installed.json round -/// trip. -pub fn locked_to_installed_entry(pkg: &LockedPackage, _vendor_dir: &Path) -> InstalledPackageEntry { - let install_path = format!("../{}", pkg.name); - InstalledPackageEntry { - name: pkg.name.clone(), - version: pkg.version.clone(), - version_normalized: pkg.version_normalized.clone(), - source: pkg - .source - .as_ref() - .map(|s| serde_json::to_value(s).unwrap_or_default()), - dist: pkg - .dist - .as_ref() - .map(|d| serde_json::to_value(d).unwrap_or_default()), - package_type: pkg.package_type.clone(), - install_path: Some(install_path), - autoload: pkg.autoload.clone(), - aliases: vec![], - homepage: pkg.homepage.clone(), - support: pkg.support.clone(), - extra_fields: pkg.extra_fields.clone(), - } -} - -fn installed_refs_match_locked(entry: &InstalledPackageEntry, locked: &LockedPackage) -> bool { - let installed_source_ref = entry - .source - .as_ref() - .and_then(|v| v.get("reference")) - .and_then(|v| v.as_str()); - let installed_dist_ref = entry - .dist - .as_ref() - .and_then(|v| v.get("reference")) - .and_then(|v| v.as_str()); - let locked_source_ref = locked.source.as_ref().and_then(|s| s.reference.as_deref()); - let locked_dist_ref = locked.dist.as_ref().and_then(|d| d.reference.as_deref()); - installed_source_ref == locked_source_ref && installed_dist_ref == locked_dist_ref -} - -fn abandoned_state(v: Option<&serde_json::Value>) -> (bool, Option<&str>) { - match v { - Some(serde_json::Value::Bool(b)) => (*b, None), - Some(serde_json::Value::String(s)) => (true, Some(s.as_str())), - _ => (false, None), - } -} - -fn installed_abandoned_matches_locked( - entry: &InstalledPackageEntry, - locked: &LockedPackage, -) -> bool { - abandoned_state(entry.extra_fields.get("abandoned")) - == abandoned_state(locked.extra_fields.get("abandoned")) -} |
