aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-registry/src/installer_executor
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-10 00:32:08 +0900
committernsfisis <nsfisis@gmail.com>2026-05-10 00:32:08 +0900
commit8cc1ba8a02c0318b65658f1634de378c780392b9 (patch)
treefdd5cb61e488018891a486b25991b87c84220bb8 /crates/mozart-registry/src/installer_executor
parent72b2e877c01e67ba7edd37e34ac2eadb7a1c62c4 (diff)
downloadphp-mozart-8cc1ba8a02c0318b65658f1634de378c780392b9.tar.gz
php-mozart-8cc1ba8a02c0318b65658f1634de378c780392b9.tar.zst
php-mozart-8cc1ba8a02c0318b65658f1634de378c780392b9.zip
refactor(workspace): consolidate crates into mozart-core
Merged mozart-archiver, mozart-autoload, mozart-registry, mozart-sat-resolver, and mozart-vcs into mozart-core to align the source layout with Composer's structure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart-registry/src/installer_executor')
-rw-r--r--crates/mozart-registry/src/installer_executor/filesystem.rs232
-rw-r--r--crates/mozart-registry/src/installer_executor/mod.rs348
-rw-r--r--crates/mozart-registry/src/installer_executor/trace_recorder.rs160
-rw-r--r--crates/mozart-registry/src/installer_executor/transaction.rs411
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"))
-}