From 16e856a20307a3ca20524d96ea13348db7f2cffd Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 2 May 2026 17:40:07 +0900 Subject: 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) --- .../src/installer_executor/filesystem.rs | 7 +- .../mozart-registry/src/installer_executor/mod.rs | 14 +- .../src/installer_executor/trace_recorder.rs | 104 +++++++++++++ crates/mozart/src/commands/install.rs | 104 +++++++++++-- crates/mozart/tests/installer_in_process.rs | 162 +++++++++++++++++++++ 5 files changed, 379 insertions(+), 12 deletions(-) create mode 100644 crates/mozart-registry/src/installer_executor/trace_recorder.rs create mode 100644 crates/mozart/tests/installer_in_process.rs (limited to 'crates') 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` 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 ()` +//! - Update (upgrade direction): `Upgrading ( => )` +//! - Update (downgrade direction): `Downgrading ( => )` +//! - Uninstall: `Uninstalling ()` + +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, +} + +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 { + 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) { - 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 = packages.iter().map(|p| p.name.to_lowercase()).collect(); + let mut by_name: BTreeMap = BTreeMap::new(); + for pkg in packages { + by_name.insert(pkg.name.to_lowercase(), *pkg); + } + + let mut visited: HashSet = HashSet::new(); + let mut on_stack: HashSet = HashSet::new(); + let mut ordered: Vec<&'a lockfile::LockedPackage> = Vec::with_capacity(packages.len()); + + fn visit<'b>( + name: &str, + names: &HashSet, + by_name: &BTreeMap, + visited: &mut HashSet, + on_stack: &mut HashSet, + 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 {}", 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, + /// Final `composer.lock` JSON, as written to disk by the runner. + final_lock: Option, + /// Final `vendor/composer/installed.json`, as written to disk. + final_installed: Option, + /// 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 { + 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 = 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::() + .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"); +} -- cgit v1.3.1