aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-03 23:31:31 +0900
committernsfisis <nsfisis@gmail.com>2026-05-03 23:31:31 +0900
commite37b12d6e2d95b4d3924859732513e125fc552e0 (patch)
tree555b049cdca81814b4adc74d542ef39833cf5f5d /crates/mozart
parent26af378d81da76c50593674fa86ed4911aa0e46f (diff)
downloadphp-mozart-e37b12d6e2d95b4d3924859732513e125fc552e0.tar.gz
php-mozart-e37b12d6e2d95b4d3924859732513e125fc552e0.tar.zst
php-mozart-e37b12d6e2d95b4d3924859732513e125fc552e0.zip
feat(registry): support type: path repositories
Adds a `mozart-php-serialize` crate (a byte-compatible port of PHP's `serialize()`) and a `mozart-registry::path_repository` module that expands `type: path` entries into synthetic `type: package` repositories. Each synthesized package carries the same SHA-1 dist reference Composer computes (`sha1(\$json . serialize(\$options))`) so the lockfile and trace lines match Composer byte-for-byte. Two latent bugs surfaced once the path-repo flow exercised real resolutions: - `apply_partial_update` swapped path-repo packages back to their locked version, defeating Composer's "path repos always reload" rule (`PoolBuilder` treats them as canonical, not lock-bound). Mirror the path-repo skip already used when constructing `locked_packages`. - `normalize_root_alias_atom` returned the raw input string for stable numeric atoms (e.g. `1.1.1`), so the alias matcher's `input.version \!= alias.version_normalized` check — comparing against pool inputs that carry the 4-segment normalized form — silently never matched. Run the parsed Version through Display so both sides are in the same shape. `install/update::run` gain a `path_repo_base_override: Option<&Path>` parameter for the in-process test harness: Composer's PHPUnit `InstallerTest::setUp` does `chdir(__DIR__)` so relative path-repo URLs resolve against `composer/tests/Composer/Test/`, but the Rust harness writes `composer.json` into a per-test tempdir and can't chdir safely under parallel tests. Production callers pass `None` and resolve against `working_dir`. Greens 3 ignored installer fixtures: partial_update_loads_root_aliases_for_path_repos alias_in_lock alias_in_lock2
Diffstat (limited to 'crates/mozart')
-rw-r--r--crates/mozart/src/commands/install.rs26
-rw-r--r--crates/mozart/src/commands/update.rs49
-rw-r--r--crates/mozart/tests/installer.rs38
3 files changed, 100 insertions, 13 deletions
diff --git a/crates/mozart/src/commands/install.rs b/crates/mozart/src/commands/install.rs
index ba9bd8a..a5698ff 100644
--- a/crates/mozart/src/commands/install.rs
+++ b/crates/mozart/src/commands/install.rs
@@ -1325,7 +1325,15 @@ pub async fn execute(
));
let mut executor = FilesystemExecutor::new(mozart_registry::cache::Cache::files(&cache_config));
let working_dir = resolve_working_dir(cli);
- run(&working_dir, args, console, repositories, &mut executor).await
+ run(
+ &working_dir,
+ None,
+ args,
+ console,
+ repositories,
+ &mut executor,
+ )
+ .await
}
/// Library entry point — pure logic, no `Cli` access.
@@ -1334,8 +1342,13 @@ pub async fn execute(
/// `'packagist' => false` test config) and a tracing `InstallerExecutor`,
/// then call this function directly to exercise the install flow without
/// spawning the binary.
+///
+/// `path_repo_base_override` is the in-process test escape hatch for relative
+/// `type: path` repo URLs — see [`super::update::run`] for the full rationale.
+/// Production callers pass `None` to anchor against `working_dir`.
pub async fn run(
working_dir: &Path,
+ path_repo_base_override: Option<&Path>,
args: &InstallArgs,
console: &mozart_core::console::Console,
repositories: std::sync::Arc<mozart_registry::repository::RepositorySet>,
@@ -1420,8 +1433,15 @@ pub async fn run(
};
// Forward the caller's repositories + executor so in-process tests
// see their mocks honored across the install→update fallback edge.
- return super::update::run(working_dir, &update_args, console, repositories, executor)
- .await;
+ return super::update::run(
+ working_dir,
+ path_repo_base_override,
+ &update_args,
+ console,
+ repositories,
+ executor,
+ )
+ .await;
}
let lock = lockfile::LockFile::read_from_file(&lock_path)?;
diff --git a/crates/mozart/src/commands/update.rs b/crates/mozart/src/commands/update.rs
index f065c2a..ac5a685 100644
--- a/crates/mozart/src/commands/update.rs
+++ b/crates/mozart/src/commands/update.rs
@@ -395,8 +395,15 @@ pub fn apply_partial_update(
let name_lower = pkg.name.to_lowercase();
// If this package is NOT in the update set and we have an old locked version,
// swap it back to the old version to prevent unintended changes.
+ //
+ // Exception: path-repo packages always reload from disk (Composer's
+ // PoolBuilder treats them as canonical sources, not lock-bound), so
+ // the resolver-picked version must survive the partial-update
+ // swap-back. The earlier locked-set construction already excludes
+ // them from `locked_packages` for the same reason; mirror it here.
if !update_set.contains(&name_lower)
&& let Some(old_pkg) = old_pkg_map.get(&name_lower)
+ && old_pkg.dist.as_ref().map(|d| d.dist_type.as_str()) != Some("path")
{
pkg.version = old_pkg.version.clone();
pkg.version_normalized = locked_version_normalized(old_pkg);
@@ -964,7 +971,15 @@ pub async fn execute(
mozart_registry::cache::Cache::files(&cache_config),
);
let working_dir = super::install::resolve_working_dir(cli);
- run(&working_dir, args, console, repositories, &mut executor).await
+ run(
+ &working_dir,
+ None,
+ args,
+ console,
+ repositories,
+ &mut executor,
+ )
+ .await
}
/// Library entry point — pure logic, no CLI / Cli access.
@@ -973,8 +988,17 @@ pub async fn execute(
/// (Composer's `'packagist' => false` test config) and a tracing
/// `InstallerExecutor`, then call this function directly to exercise the
/// update flow without spawning the binary.
+///
+/// `path_repo_base_override` is for the in-process test harness only:
+/// Composer's PHP test suite `chdir(__DIR__)` so that `type: path` repo URLs
+/// like `Fixtures/.../pkg` resolve against the test directory, but the
+/// Rust harness writes `composer.json` into a per-test tempdir, so we need a
+/// way to anchor relative path-repo URLs somewhere other than `working_dir`.
+/// Production callers pass `None` to use `working_dir`, matching Composer's
+/// "resolve relative to cwd" behaviour.
pub async fn run(
working_dir: &std::path::Path,
+ path_repo_base_override: Option<&std::path::Path>,
args: &UpdateArgs,
console: &mozart_core::console::Console,
repositories: std::sync::Arc<mozart_registry::repository::RepositorySet>,
@@ -1012,6 +1036,21 @@ pub async fn run(
composer_json.validate_root_does_not_self_require()?;
let composer_json_content = std::fs::read_to_string(&composer_json_path)?;
+ // Expand `type: path` repos into synthetic `type: package` entries so the
+ // resolver and lockfile see them as ordinary inline packages. The
+ // original `composer_json.repositories` is preserved for writeback paths
+ // (e.g. `--bump-after-update` rewrites composer.json) — only the cloned
+ // `composer_json_expanded` carries the synthetic entries.
+ let path_repo_base = path_repo_base_override.unwrap_or(working_dir);
+ let composer_json_expanded = {
+ let mut clone = composer_json.clone();
+ clone.repositories = mozart_registry::path_repository::expand_path_repositories(
+ &clone.repositories,
+ path_repo_base,
+ );
+ clone
+ };
+
let lock_path = working_dir.join("composer.lock");
let vendor_dir = working_dir.join("vendor");
@@ -1135,7 +1174,7 @@ pub async fn run(
// (line 524: when a propagated package's `require`
// points at a `skippedLoad` entry, the dep is unlocked
// and re-loaded).
- let repo_requires = collect_repo_requires(&composer_json.repositories);
+ let repo_requires = collect_repo_requires(&composer_json_expanded.repositories);
let updated: IndexSet<String> = expand_packages(
&raw_packages,
Some(&l),
@@ -1334,7 +1373,7 @@ pub async fn run(
ignore_platform_req_list: args.ignore_platform_req.clone(),
repositories: repositories.clone(),
temporary_constraints,
- raw_repositories: composer_json.repositories.clone(),
+ raw_repositories: composer_json_expanded.repositories.clone(),
root_provide: composer_json
.provide
.iter()
@@ -1428,7 +1467,7 @@ pub async fn run(
}
Some(lock) => {
// 1. Expand wildcards
- let repo_requires = collect_repo_requires(&composer_json.repositories);
+ let repo_requires = collect_repo_requires(&composer_json_expanded.repositories);
let mut expanded = expand_packages(
&effective_packages,
Some(lock),
@@ -1509,7 +1548,7 @@ pub async fn run(
let mut new_lock = lockfile::generate_lock_file(&lockfile::LockFileGenerationRequest {
resolved_packages: resolved,
composer_json_content: composer_json_content.clone(),
- composer_json: composer_json.clone(),
+ composer_json: composer_json_expanded.clone(),
include_dev: true,
repositories: repositories.clone(),
previous_lock: old_lock.clone(),
diff --git a/crates/mozart/tests/installer.rs b/crates/mozart/tests/installer.rs
index 197b00f..d22f2a0 100644
--- a/crates/mozart/tests/installer.rs
+++ b/crates/mozart/tests/installer.rs
@@ -25,6 +25,17 @@ fn fixtures_dir() -> PathBuf {
.join("../../composer/tests/Composer/Test/Fixtures/installer")
}
+/// Composer's PHPUnit `InstallerTest::setUp()` runs `chdir(__DIR__)` so that
+/// relative `type: path` repo URLs (`Fixtures/functional/.../pkg`) resolve
+/// against `composer/tests/Composer/Test/`. The Rust harness can't chdir
+/// safely (cargo test runs cases in parallel), so it threads the same
+/// directory through `install/update::run` as the path-repo resolution base
+/// instead. Production callers of `run()` pass `None` and resolve against
+/// `working_dir`, matching Composer's "use cwd" behaviour.
+fn path_repo_base_for_fixtures() -> PathBuf {
+ Path::new(env!("CARGO_MANIFEST_DIR")).join("../../composer/tests/Composer/Test")
+}
+
/// Rewrite `file://foobar` URLs in COMPOSER content to absolute fixture
/// paths. Mirrors `composer/tests/Composer/Test/InstallerTest.php:540-542`:
/// when a fixture's repository entry uses a relative `file://` URL, anchor
@@ -101,12 +112,29 @@ async fn run_fixture_in_process(test: &ParsedTest) -> anyhow::Result<InProcessRu
let repositories = Arc::new(RepositorySet::empty());
let mut executor = TraceRecorderExecutor::new();
+ let path_repo_base = path_repo_base_for_fixtures();
let outcome: anyhow::Result<()> = match &cli.command {
Some(Commands::Install(args)) => {
- install::run(root, args, &console, repositories, &mut executor).await
+ install::run(
+ root,
+ Some(&path_repo_base),
+ args,
+ &console,
+ repositories,
+ &mut executor,
+ )
+ .await
}
Some(Commands::Update(args)) => {
- update::run(root, args, &console, repositories, &mut executor).await
+ update::run(
+ root,
+ Some(&path_repo_base),
+ args,
+ &console,
+ repositories,
+ &mut executor,
+ )
+ .await
}
other => anyhow::bail!("unsupported run command in fixture: {:?}", other.is_some()),
};
@@ -217,8 +245,8 @@ macro_rules! installer_fixture {
installer_fixture!(abandoned_listed);
installer_fixture!(alias_in_complex_constraints, ignore);
-installer_fixture!(alias_in_lock, ignore);
-installer_fixture!(alias_in_lock2, ignore);
+installer_fixture!(alias_in_lock);
+installer_fixture!(alias_in_lock2);
installer_fixture!(alias_on_unloadable_package);
installer_fixture!(alias_solver_problems);
installer_fixture!(alias_solver_problems2);
@@ -292,7 +320,7 @@ installer_fixture!(partial_update_from_lock_with_root_alias, ignore);
installer_fixture!(partial_update_installs_from_lock_even_missing, ignore);
installer_fixture!(partial_update_keeps_older_dep_if_still_required);
installer_fixture!(partial_update_keeps_older_dep_if_still_required_with_provide);
-installer_fixture!(partial_update_loads_root_aliases_for_path_repos, ignore);
+installer_fixture!(partial_update_loads_root_aliases_for_path_repos);
installer_fixture!(partial_update_security_advisory_matching_locked_dep);
installer_fixture!(partial_update_security_advisory_matching_locked_dep_with_dependencies);
installer_fixture!(partial_update_with_dependencies_provide);