aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-02 17:40:07 +0900
committernsfisis <nsfisis@gmail.com>2026-05-02 17:40:07 +0900
commit16e856a20307a3ca20524d96ea13348db7f2cffd (patch)
tree5a054afdd608bc39856fe0ff3a9cac2fda980cf7 /crates
parent99a33b951502d3e80eb70f53551413b9dc0f4d6c (diff)
downloadphp-mozart-16e856a20307a3ca20524d96ea13348db7f2cffd.tar.gz
php-mozart-16e856a20307a3ca20524d96ea13348db7f2cffd.tar.zst
php-mozart-16e856a20307a3ca20524d96ea13348db7f2cffd.zip
feat(installer): add trace recorder and topo install order
Adds TraceRecorderExecutor (Composer's InstallationManagerMock analog), which records every install/update/uninstall as a string matching Composer's *Operation::__toString output (after strip_tags) - the load-bearing assertion target for in-process fixture tests. Two changes were needed to make the recorder useful: - InstallerExecutor::uninstall_package gains a version parameter, and install_from_lock now looks up both the uninstall and the Update-from-version from installed.json. Previously the Update path passed the new version as a placeholder; the recorder needs the real old version to emit `Upgrading pkg (old => new)`. - compute_operations now topologically sorts the lock contents (deps before dependents) before computing actions, mirroring Composer's Transaction::calculateOperations. Without this, packages would install in alphabetical order and the trace would diverge from Composer's expectation. Also adds crates/mozart/tests/installer_in_process.rs with the in-process harness scaffold: parses the same .test fixtures, builds a tempdir, calls commands::install::run / update::run with an empty RepositorySet (no Packagist) and a TraceRecorderExecutor, then asserts exit code + EXPECT trace. One fixture wired up: suggest_replaced - the original CI failure that motivated this whole DI refactor. It now passes on the in-process path because the empty RepositorySet makes b/b unreachable just like Composer's `'packagist' => false` test config, and the resolver finds c/c (which replaces b/b) via the inline package repo's eager preload. Step F will migrate every fixture currently in installer.rs to the new harness; remaining divergences (alias handling, output ordering, replace trace shape, etc.) will surface as individual follow-ups. All 136 existing spawn-based fixtures + 114 mozart-registry tests + 541 mozart lib tests still green; clippy clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates')
-rw-r--r--crates/mozart-registry/src/installer_executor/filesystem.rs7
-rw-r--r--crates/mozart-registry/src/installer_executor/mod.rs14
-rw-r--r--crates/mozart-registry/src/installer_executor/trace_recorder.rs104
-rw-r--r--crates/mozart/src/commands/install.rs104
-rw-r--r--crates/mozart/tests/installer_in_process.rs162
5 files changed, 379 insertions, 12 deletions
diff --git a/crates/mozart-registry/src/installer_executor/filesystem.rs b/crates/mozart-registry/src/installer_executor/filesystem.rs
index 82acc42..e36006c 100644
--- a/crates/mozart-registry/src/installer_executor/filesystem.rs
+++ b/crates/mozart-registry/src/installer_executor/filesystem.rs
@@ -81,7 +81,12 @@ impl InstallerExecutor for FilesystemExecutor {
Ok(())
}
- fn uninstall_package(&mut self, name: &str, ctx: &ExecuteContext) -> anyhow::Result<()> {
+ 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)?;
diff --git a/crates/mozart-registry/src/installer_executor/mod.rs b/crates/mozart-registry/src/installer_executor/mod.rs
index fde4c49..1fab19f 100644
--- a/crates/mozart-registry/src/installer_executor/mod.rs
+++ b/crates/mozart-registry/src/installer_executor/mod.rs
@@ -18,8 +18,10 @@ use std::path::PathBuf;
use crate::lockfile::LockedPackage;
pub mod filesystem;
+pub mod trace_recorder;
pub use filesystem::FilesystemExecutor;
+pub use trace_recorder::TraceRecorderExecutor;
/// One install or update operation handed to [`InstallerExecutor::install_package`].
#[derive(Debug, Clone, Copy)]
@@ -73,7 +75,17 @@ pub trait InstallerExecutor: Send + Sync {
) -> anyhow::Result<()>;
/// Perform side effects for one uninstall.
- fn uninstall_package(&mut self, name: &str, ctx: &ExecuteContext) -> anyhow::Result<()>;
+ ///
+ /// `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
diff --git a/crates/mozart-registry/src/installer_executor/trace_recorder.rs b/crates/mozart-registry/src/installer_executor/trace_recorder.rs
new file mode 100644
index 0000000..bb20eb1
--- /dev/null
+++ b/crates/mozart-registry/src/installer_executor/trace_recorder.rs
@@ -0,0 +1,104 @@
+//! 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: `Uninstalling <name> (<version>)`
+
+use mozart_semver::Version;
+
+use super::{ExecuteContext, InstallerExecutor, PackageOperation};
+
+/// 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, package.version
+ ));
+ }
+ PackageOperation::Update {
+ from_version,
+ package,
+ } => {
+ let action = if is_upgrade(from_version, &package.version) {
+ "Upgrading"
+ } else {
+ "Downgrading"
+ };
+ self.trace.push(format!(
+ "{} {} ({} => {})",
+ action, package.name, from_version, package.version
+ ));
+ }
+ }
+ Ok(())
+ }
+
+ fn uninstall_package(
+ &mut self,
+ name: &str,
+ version: &str,
+ _ctx: &ExecuteContext,
+ ) -> anyhow::Result<()> {
+ self.trace
+ .push(format!("Uninstalling {} ({})", name, version));
+ Ok(())
+ }
+}
+
+/// Mirrors `Composer\Package\Version\VersionParser::isUpgrade` — returns
+/// true when `to` is a strictly higher version than `from`. Both unparseable
+/// or both equal means treat as upgrade (Composer's behavior on edge cases).
+fn is_upgrade(from: &str, to: &str) -> bool {
+ match (Version::parse(from), Version::parse(to)) {
+ (Ok(a), Ok(b)) => b >= a,
+ _ => true,
+ }
+}
diff --git a/crates/mozart/src/commands/install.rs b/crates/mozart/src/commands/install.rs
index c8b0431..b89793b 100644
--- a/crates/mozart/src/commands/install.rs
+++ b/crates/mozart/src/commands/install.rs
@@ -167,15 +167,23 @@ pub fn resolve_working_dir(cli: &super::Cli) -> PathBuf {
/// Compute install operations by comparing locked packages against installed packages.
///
/// Returns a tuple of (ops, removals) where:
-/// - ops: list of (package, action) for each locked package
+/// - ops: list of (package, action) ordered topologically — every package's
+/// lock-internal `require` deps appear before it, so installs run in
+/// dependency-first order to match Composer's `Transaction::calculateOperations`.
/// - removals: list of package names that are installed but not locked
pub fn compute_operations<'a>(
locked: &[&'a lockfile::LockedPackage],
installed: &installed::InstalledPackages,
) -> (Vec<(&'a lockfile::LockedPackage, Action)>, Vec<String>) {
- let mut ops: Vec<(&'a lockfile::LockedPackage, Action)> = Vec::new();
+ // Topo-sort `locked` so each package's deps (within the lock set) come
+ // before it. Composer's solver yields operations in this order via the
+ // Transaction; Mozart writes the lock alphabetically, so the install
+ // loop must re-order before emitting trace lines or invoking the
+ // executor.
+ let ordered = topological_sort(locked);
- for pkg in locked {
+ let mut ops: Vec<(&'a lockfile::LockedPackage, Action)> = Vec::new();
+ for pkg in ordered {
if installed.is_installed(&pkg.name, &pkg.version) {
ops.push((pkg, Action::Skip));
} else if installed
@@ -202,6 +210,72 @@ pub fn compute_operations<'a>(
(ops, removals)
}
+/// Order a slice of locked packages so every package's `require` deps that
+/// are present in the same slice come before it. Cycles fall back to the
+/// input order (Composer rejects cycles earlier in the resolver, so Mozart
+/// shouldn't see them here in practice). Mirrors the topological sort
+/// inside `Composer\DependencyResolver\Transaction::calculateOperations`.
+fn topological_sort<'a>(
+ packages: &[&'a lockfile::LockedPackage],
+) -> Vec<&'a lockfile::LockedPackage> {
+ use std::collections::BTreeMap;
+
+ let names: HashSet<String> = packages.iter().map(|p| p.name.to_lowercase()).collect();
+ let mut by_name: BTreeMap<String, &'a lockfile::LockedPackage> = BTreeMap::new();
+ for pkg in packages {
+ by_name.insert(pkg.name.to_lowercase(), *pkg);
+ }
+
+ let mut visited: HashSet<String> = HashSet::new();
+ let mut on_stack: HashSet<String> = HashSet::new();
+ let mut ordered: Vec<&'a lockfile::LockedPackage> = Vec::with_capacity(packages.len());
+
+ fn visit<'b>(
+ name: &str,
+ names: &HashSet<String>,
+ by_name: &BTreeMap<String, &'b lockfile::LockedPackage>,
+ visited: &mut HashSet<String>,
+ on_stack: &mut HashSet<String>,
+ ordered: &mut Vec<&'b lockfile::LockedPackage>,
+ ) {
+ if visited.contains(name) || on_stack.contains(name) {
+ return;
+ }
+ let Some(pkg) = by_name.get(name) else {
+ return;
+ };
+ on_stack.insert(name.to_string());
+ for dep in pkg.require.keys() {
+ let dep_lower = dep.to_lowercase();
+ if names.contains(&dep_lower) {
+ visit(&dep_lower, names, by_name, visited, on_stack, ordered);
+ }
+ }
+ on_stack.remove(name);
+ visited.insert(name.to_string());
+ ordered.push(*pkg);
+ }
+
+ // Seed iteration in the input order so two packages with no relation
+ // come out in the order Mozart's lock writer produced them
+ // (alphabetical), matching Composer's deterministic output.
+ for pkg in packages {
+ let lower = pkg.name.to_lowercase();
+ if !visited.contains(&lower) {
+ visit(
+ &lower,
+ &names,
+ &by_name,
+ &mut visited,
+ &mut on_stack,
+ &mut ordered,
+ );
+ }
+ }
+
+ ordered
+}
+
/// Convert a LockedPackage to an InstalledPackageEntry.
///
/// `LockedPackage::extra_fields` is forwarded verbatim so flags like
@@ -524,13 +598,17 @@ pub async fn install_from_lock(
pkg.name,
pkg.version
));
- // The previous-version string is unknown to install_from_lock
- // (it only sees the post-update lock). Pass the new version
- // as a placeholder; this path is unused by the recorder, and
- // Composer's `Upgrading` trace string is generated upstream
- // by the resolver, not by InstallationManager itself.
+ // Pull the previously-installed version from installed.json
+ // so the trace recorder can format
+ // `Upgrading pkg (oldVersion => newVersion)`.
+ let from_version = installed
+ .packages
+ .iter()
+ .find(|p| p.name.eq_ignore_ascii_case(&pkg.name))
+ .map(|p| p.version.as_str())
+ .unwrap_or("");
PackageOperation::Update {
- from_version: &pkg.version,
+ from_version,
package: pkg,
}
}
@@ -541,7 +619,13 @@ pub async fn install_from_lock(
// Handle removals
for name in &removals {
console.info(&console_format!(" - Removing <info>{}</info>", name));
- executor.uninstall_package(name, &exec_ctx)?;
+ let from_version = installed
+ .packages
+ .iter()
+ .find(|p| p.name.eq_ignore_ascii_case(name))
+ .map(|p| p.version.as_str())
+ .unwrap_or("");
+ executor.uninstall_package(name, from_version, &exec_ctx)?;
}
// Step 7: Clean up empty vendor namespace directories
diff --git a/crates/mozart/tests/installer_in_process.rs b/crates/mozart/tests/installer_in_process.rs
new file mode 100644
index 0000000..f3e8ce2
--- /dev/null
+++ b/crates/mozart/tests/installer_in_process.rs
@@ -0,0 +1,162 @@
+//! In-process installer fixture runner.
+//!
+//! Mirrors Composer's PHPUnit-driven `InstallerTest`: parses the same
+//! `.test` fixture files, sets up a tempdir with `composer.json` /
+//! `composer.lock` / `vendor/composer/installed.json`, then invokes
+//! `mozart::commands::{install,update}::run` directly with an empty
+//! `RepositorySet` (Composer's `'packagist' => false` test config) and a
+//! `TraceRecorderExecutor` (Composer's `InstallationManagerMock`).
+//!
+//! Step F will move every fixture in `installer.rs` over to this harness;
+//! for now this file just demonstrates the path on a single fixture
+//! (`suggest_replaced` — the original CI failure that motivated the whole
+//! DI refactor).
+
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+use clap::Parser;
+use mozart::commands::{Cli, Commands, install, update};
+use mozart_core::console::Console;
+use mozart_core::exit_code::MozartError;
+use mozart_registry::installer_executor::TraceRecorderExecutor;
+use mozart_registry::repository::RepositorySet;
+use mozart_test_harness::{ParsedTest, parse_test_file};
+use tempfile::TempDir;
+
+fn fixtures_dir() -> PathBuf {
+ Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../../composer/tests/Composer/Test/Fixtures/installer")
+}
+
+/// Outcome of a single in-process fixture run.
+struct InProcessRunResult {
+ /// Kept alive so the caller can inspect on-disk artifacts; dropped
+ /// (and removed) when this struct goes out of scope.
+ _working_dir: TempDir,
+ /// Composer-shape operation trace from `TraceRecorderExecutor`.
+ /// Compare against the fixture's `--EXPECT--` section.
+ trace: Vec<String>,
+ /// Final `composer.lock` JSON, as written to disk by the runner.
+ final_lock: Option<String>,
+ /// Final `vendor/composer/installed.json`, as written to disk.
+ final_installed: Option<String>,
+ /// Mapped exit code: 0 for success, otherwise the carried
+ /// [`MozartError::exit_code`] (or 1 for unclassified errors).
+ exit_code: i32,
+}
+
+async fn run_fixture_in_process(test: &ParsedTest) -> anyhow::Result<InProcessRunResult> {
+ let working_dir = TempDir::new()?;
+ let root = working_dir.path();
+
+ std::fs::write(root.join("composer.json"), &test.composer)?;
+ if let Some(lock) = &test.lock {
+ std::fs::write(root.join("composer.lock"), lock)?;
+ }
+ if let Some(installed) = &test.installed {
+ let vendor_composer = root.join("vendor").join("composer");
+ std::fs::create_dir_all(&vendor_composer)?;
+ std::fs::write(vendor_composer.join("installed.json"), installed)?;
+ }
+
+ // Parse the `--RUN--` line through clap so we get the same arg semantics
+ // the real CLI does — including default flags, validators, etc.
+ let argv: Vec<String> = std::iter::once("mozart".to_string())
+ .chain(test.run.split_whitespace().map(String::from))
+ .collect();
+ let cli = Cli::try_parse_from(&argv)?;
+
+ // Quiet console: tests assert on `trace` / lock / installed, not on
+ // captured stdout/stderr (Console doesn't yet support buffered sinks).
+ let console = Console::new(0, true, false, true, true);
+ let repositories = Arc::new(RepositorySet::empty());
+ let mut executor = TraceRecorderExecutor::new();
+
+ let outcome: anyhow::Result<()> = match &cli.command {
+ Some(Commands::Install(args)) => {
+ install::run(root, args, &console, repositories, &mut executor).await
+ }
+ Some(Commands::Update(args)) => {
+ update::run(root, args, &console, repositories, &mut executor).await
+ }
+ other => anyhow::bail!(
+ "unsupported run command in fixture: {:?}",
+ other.is_some()
+ ),
+ };
+
+ let exit_code = match &outcome {
+ Ok(()) => 0,
+ Err(e) => e
+ .downcast_ref::<MozartError>()
+ .map(|m| m.exit_code)
+ .unwrap_or(1),
+ };
+
+ let final_lock = std::fs::read_to_string(root.join("composer.lock")).ok();
+ let final_installed =
+ std::fs::read_to_string(root.join("vendor").join("composer").join("installed.json")).ok();
+
+ Ok(InProcessRunResult {
+ _working_dir: working_dir,
+ trace: executor.into_trace(),
+ final_lock,
+ final_installed,
+ exit_code,
+ })
+}
+
+fn run_fixture(ident: &str) {
+ let filename = format!("{}.test", ident.replace('_', "-"));
+ let path = fixtures_dir().join(&filename);
+ let parsed = parse_test_file(&path)
+ .unwrap_or_else(|e| panic!("failed to parse {}: {:#}", path.display(), e));
+
+ let runtime = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .expect("failed to build tokio runtime");
+ let result = runtime
+ .block_on(run_fixture_in_process(&parsed))
+ .unwrap_or_else(|e| panic!("failed to run {}: {:#}", path.display(), e));
+
+ let expected_exit = parsed.expect_exit_code.unwrap_or(0);
+ assert_eq!(
+ result.exit_code,
+ expected_exit,
+ "exit code mismatch for {}\n--- trace ---\n{}",
+ path.display(),
+ result.trace.join("\n"),
+ );
+
+ // EXPECT (the trace) is the load-bearing assertion in Composer's
+ // PHPUnit harness — every line of the operation log must match
+ // byte-for-byte against `(string) $operation` after `strip_tags`.
+ let expected_trace = parsed.expect.trim();
+ let actual_trace = result.trace.join("\n");
+ assert_eq!(
+ actual_trace.trim(),
+ expected_trace,
+ "EXPECT trace mismatch for {}\n--- expected ---\n{}\n--- actual ---\n{}\n--- final lock ---\n{}\n--- final installed ---\n{}",
+ path.display(),
+ expected_trace,
+ actual_trace,
+ result.final_lock.as_deref().unwrap_or("(absent)"),
+ result.final_installed.as_deref().unwrap_or("(absent)"),
+ );
+}
+
+// ────────────────────────────────────────────────────────────────────────────
+// In-process fixtures
+//
+// Step F will migrate every fixture from `installer.rs` to this harness.
+// For now this file holds just the proof-of-concept: `suggest_replaced`,
+// the original CI failure (the spawn runner can't reach Packagist for
+// `b/b`, even though `c/c` replaces it).
+// ────────────────────────────────────────────────────────────────────────────
+
+#[test]
+fn suggest_replaced_in_process() {
+ run_fixture("suggest_replaced");
+}