aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/mozart-php-serialize/Cargo.toml4
-rw-r--r--crates/mozart-php-serialize/src/lib.rs172
-rw-r--r--crates/mozart-registry/Cargo.toml1
-rw-r--r--crates/mozart-registry/src/lib.rs1
-rw-r--r--crates/mozart-registry/src/path_repository.rs243
-rw-r--r--crates/mozart-registry/src/resolver.rs7
-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
9 files changed, 527 insertions, 14 deletions
diff --git a/crates/mozart-php-serialize/Cargo.toml b/crates/mozart-php-serialize/Cargo.toml
new file mode 100644
index 0000000..e66a5b3
--- /dev/null
+++ b/crates/mozart-php-serialize/Cargo.toml
@@ -0,0 +1,4 @@
+[package]
+name = "mozart-php-serialize"
+version.workspace = true
+edition.workspace = true
diff --git a/crates/mozart-php-serialize/src/lib.rs b/crates/mozart-php-serialize/src/lib.rs
new file mode 100644
index 0000000..6405ea8
--- /dev/null
+++ b/crates/mozart-php-serialize/src/lib.rs
@@ -0,0 +1,172 @@
+//! Byte-compatible port of PHP's `serialize()` function.
+//!
+//! Mirrors `php_var_serialize` in PHP's source: each value is rendered to a
+//! tagged form like `b:1;`, `i:42;`, `s:3:"foo";`, `a:N:{...}` so the output
+//! can be SHA-1'd and compared against PHP-side hashes (e.g. Composer's
+//! `PathRepository` reference, which is `sha1($json . serialize($options))`).
+//!
+//! Only the value forms Mozart needs today are implemented. Floats, objects,
+//! and references are deliberately omitted — extend the [`Value`] enum and
+//! [`serialize`] writer when a new shape is required, and add a focused test
+//! for it (the file_get_contents → hash flow downstream is unforgiving).
+//!
+//! Lengths are byte counts, not character counts. Array keys are written in
+//! insertion order (PHP arrays preserve insertion order). Integer-coercible
+//! string keys (e.g. `"1"`) are NOT auto-converted to integers — PHP itself
+//! does that during array construction, not at serialization time, so callers
+//! that care must construct [`Value::Int`] keys directly.
+
+use std::fmt::Write;
+
+/// One PHP value, suitable for `serialize()`.
+///
+/// Add variants here as the need arises (e.g. `Float(f64)` → `d:<repr>;`).
+/// Keep the variants minimal — every variant we add is a new compatibility
+/// surface that has to match PHP byte-for-byte.
+#[derive(Debug, Clone)]
+pub enum Value {
+ Null,
+ Bool(bool),
+ Int(i64),
+ /// UTF-8 string. Length prefix is the byte length, matching PHP.
+ String(String),
+ /// Associative or indexed array. Order is preserved verbatim — the writer
+ /// does not normalize integer-coercible keys or sort entries.
+ Array(Vec<(Value, Value)>),
+}
+
+/// Render `value` as PHP's `serialize()` would.
+///
+/// Returns a `String` (not bytes) because every byte we emit is in
+/// printable-ASCII or comes from a UTF-8 [`Value::String`] payload, so the
+/// result is always valid UTF-8.
+pub fn serialize(value: &Value) -> String {
+ let mut out = String::new();
+ write_value(&mut out, value);
+ out
+}
+
+fn write_value(out: &mut String, value: &Value) {
+ match value {
+ Value::Null => out.push_str("N;"),
+ Value::Bool(b) => {
+ out.push_str("b:");
+ out.push(if *b { '1' } else { '0' });
+ out.push(';');
+ }
+ Value::Int(n) => {
+ write!(out, "i:{};", n).expect("writing to String never fails");
+ }
+ Value::String(s) => {
+ write!(out, "s:{}:\"{}\";", s.len(), s).expect("writing to String never fails");
+ }
+ Value::Array(entries) => {
+ write!(out, "a:{}:{{", entries.len()).expect("writing to String never fails");
+ for (k, v) in entries {
+ write_value(out, k);
+ write_value(out, v);
+ }
+ out.push('}');
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ // Each `expected` string was produced by running the equivalent PHP
+ // `serialize()` call (`php -r 'echo serialize(...);'`), so the assertions
+ // pin Mozart's output to actual PHP behaviour rather than the spec we
+ // think we're following.
+
+ #[test]
+ fn null() {
+ assert_eq!(serialize(&Value::Null), "N;");
+ }
+
+ #[test]
+ fn bool_true() {
+ assert_eq!(serialize(&Value::Bool(true)), "b:1;");
+ }
+
+ #[test]
+ fn bool_false() {
+ assert_eq!(serialize(&Value::Bool(false)), "b:0;");
+ }
+
+ #[test]
+ fn int_positive() {
+ assert_eq!(serialize(&Value::Int(42)), "i:42;");
+ }
+
+ #[test]
+ fn int_zero() {
+ assert_eq!(serialize(&Value::Int(0)), "i:0;");
+ }
+
+ #[test]
+ fn int_negative() {
+ assert_eq!(serialize(&Value::Int(-7)), "i:-7;");
+ }
+
+ #[test]
+ fn string_ascii() {
+ assert_eq!(serialize(&Value::String("hi".into())), "s:2:\"hi\";");
+ }
+
+ #[test]
+ fn string_empty() {
+ assert_eq!(serialize(&Value::String(String::new())), "s:0:\"\";");
+ }
+
+ #[test]
+ fn string_length_is_bytes_not_chars() {
+ // 「日本」 is 6 bytes in UTF-8 (3 per kanji), 2 chars. PHP measures
+ // by byte; mirror that.
+ assert_eq!(serialize(&Value::String("日本".into())), "s:6:\"日本\";");
+ }
+
+ #[test]
+ fn array_empty() {
+ assert_eq!(serialize(&Value::Array(vec![])), "a:0:{}");
+ }
+
+ #[test]
+ fn array_assoc_single() {
+ let v = Value::Array(vec![(Value::String("relative".into()), Value::Bool(true))]);
+ assert_eq!(serialize(&v), "a:1:{s:8:\"relative\";b:1;}");
+ }
+
+ #[test]
+ fn array_assoc_multi_preserves_order() {
+ let v = Value::Array(vec![
+ (Value::String("a".into()), Value::Int(1)),
+ (Value::String("b".into()), Value::Int(2)),
+ ]);
+ assert_eq!(serialize(&v), "a:2:{s:1:\"a\";i:1;s:1:\"b\";i:2;}");
+ }
+
+ #[test]
+ fn array_indexed() {
+ // PHP `serialize([10, 20])` uses integer keys 0, 1.
+ let v = Value::Array(vec![
+ (Value::Int(0), Value::Int(10)),
+ (Value::Int(1), Value::Int(20)),
+ ]);
+ assert_eq!(serialize(&v), "a:2:{i:0;i:10;i:1;i:20;}");
+ }
+
+ #[test]
+ fn array_nested() {
+ // PHP: serialize(['outer' => ['inner' => true]])
+ let v = Value::Array(vec![(
+ Value::String("outer".into()),
+ Value::Array(vec![(Value::String("inner".into()), Value::Bool(true))]),
+ )]);
+ assert_eq!(
+ serialize(&v),
+ "a:1:{s:5:\"outer\";a:1:{s:5:\"inner\";b:1;}}"
+ );
+ }
+}
diff --git a/crates/mozart-registry/Cargo.toml b/crates/mozart-registry/Cargo.toml
index 578a08c..10eaf3b 100644
--- a/crates/mozart-registry/Cargo.toml
+++ b/crates/mozart-registry/Cargo.toml
@@ -6,6 +6,7 @@ edition.workspace = true
[dependencies]
mozart-core.workspace = true
mozart-metadata-minifier.workspace = true
+mozart-php-serialize.workspace = true
mozart-sat-resolver.workspace = true
mozart-semver.workspace = true
mozart-vcs.workspace = true
diff --git a/crates/mozart-registry/src/lib.rs b/crates/mozart-registry/src/lib.rs
index 654252c..73b5b76 100644
--- a/crates/mozart-registry/src/lib.rs
+++ b/crates/mozart-registry/src/lib.rs
@@ -6,6 +6,7 @@ pub mod installed;
pub mod installer_executor;
pub mod lockfile;
pub mod packagist;
+pub mod path_repository;
pub mod repository;
pub mod repository_filter;
pub mod resolver;
diff --git a/crates/mozart-registry/src/path_repository.rs b/crates/mozart-registry/src/path_repository.rs
new file mode 100644
index 0000000..bf71315
--- /dev/null
+++ b/crates/mozart-registry/src/path_repository.rs
@@ -0,0 +1,243 @@
+//! Support for `type: path` repositories.
+//!
+//! Mirrors `Composer\Repository\PathRepository`: a path repo points at a
+//! local directory containing a `composer.json`, and the resolver loads the
+//! package from that file directly. Mozart does not yet support glob URLs or
+//! the `versions` / `reference: none` options — only the bare
+//! `{ type: path, url: ... }` form the installer fixtures exercise.
+//!
+//! Resolution model: a path repo is expanded into a synthetic
+//! `type: package` [`RawRepository`] whose payload is the loaded composer.json
+//! plus a `dist` block. After this expansion the rest of the registry treats
+//! the package the same as any inline `type: package` entry — that is the
+//! whole point of doing the work here rather than threading a new repo type
+//! through the resolver / lockfile.
+//!
+//! `dist.reference` matches Composer's `hash('sha1', $json . serialize($options))`
+//! where `$options` carries the auto-detected `relative` flag (true when the
+//! original URL was not absolute). The same SHA-1 ends up in the lockfile, so
+//! consumers comparing references against Composer-produced lockfiles see
+//! byte-identical values.
+
+use std::path::{Path, PathBuf};
+
+use mozart_core::package::RawRepository;
+use mozart_php_serialize::{Value as PhpValue, serialize as php_serialize};
+use sha1::{Digest, Sha1};
+
+/// Translate path repos in `repositories` into synthetic `type: package`
+/// entries. Non-path entries are returned unchanged in original order.
+///
+/// `base_dir` is the directory used to resolve relative `url` values
+/// (Composer's PHP code resolves these against the process cwd; in production
+/// that equals the project root, in tests it equals the fixtures anchor).
+///
+/// Failures (missing directory, unreadable composer.json, missing
+/// `name`/`version`) drop the offending entry silently — the rest of the
+/// repository list still applies. This mirrors Composer's lenient
+/// PathRepository, which logs a warning and moves on rather than aborting the
+/// whole resolve.
+pub fn expand_path_repositories(
+ repositories: &[RawRepository],
+ base_dir: &Path,
+) -> Vec<RawRepository> {
+ let mut out = Vec::with_capacity(repositories.len());
+ for repo in repositories {
+ if repo.repo_type != "path" {
+ out.push(repo.clone());
+ continue;
+ }
+ let Some(url) = repo.url.as_deref() else {
+ continue;
+ };
+ let Some(synthetic) = load_path_package(url, base_dir) else {
+ continue;
+ };
+ out.push(synthetic);
+ }
+ out
+}
+
+/// Read one path repo's `composer.json` and synthesize the inline-package
+/// form. Returns `None` for any I/O or parse failure (Composer behaves the
+/// same — `PathRepository::initialize` skips entries whose `composer.json`
+/// is missing).
+fn load_path_package(url: &str, base_dir: &Path) -> Option<RawRepository> {
+ let resolved = resolve_path(url, base_dir);
+ let composer_json_path = resolved.join("composer.json");
+ let json = std::fs::read_to_string(&composer_json_path).ok()?;
+ let mut package: serde_json::Value = serde_json::from_str(&json).ok()?;
+ let obj = package.as_object_mut()?;
+
+ // `version` is mandatory in the inline-package representation: without it
+ // the resolver would skip the package. Composer's PathRepository falls
+ // back to `dev-main` when no version is declared and no VCS is present;
+ // mirror that so a path repo whose composer.json omits `version` still
+ // produces a usable entry.
+ if !obj.contains_key("version") {
+ obj.insert(
+ "version".to_string(),
+ serde_json::Value::String("dev-main".to_string()),
+ );
+ }
+
+ let is_relative = !Path::new(url).is_absolute();
+ let reference = compute_path_reference(json.as_bytes(), is_relative);
+
+ obj.insert(
+ "dist".to_string(),
+ serde_json::json!({
+ "type": "path",
+ "url": url,
+ "reference": reference,
+ }),
+ );
+ // Composer copies `symlink`/`relative` from `options` into
+ // `transport-options`. We have no `options` to forward today but emit an
+ // empty object so consumers reading the package see the same shape.
+ obj.entry("transport-options")
+ .or_insert_with(|| serde_json::json!({}));
+
+ Some(RawRepository {
+ repo_type: "package".to_string(),
+ url: None,
+ package: Some(serde_json::Value::Array(vec![package])),
+ only: None,
+ exclude: None,
+ canonical: None,
+ security_advisories: None,
+ })
+}
+
+fn resolve_path(url: &str, base_dir: &Path) -> PathBuf {
+ let p = Path::new(url);
+ if p.is_absolute() {
+ p.to_path_buf()
+ } else {
+ base_dir.join(p)
+ }
+}
+
+/// Compose the SHA-1 reference Composer uses for path repos:
+/// `sha1($json . serialize(['relative' => $isRelative]))`. The `relative`
+/// flag is the only option Composer's auto-detection populates when the user
+/// supplied no `options` block.
+fn compute_path_reference(json_bytes: &[u8], is_relative: bool) -> String {
+ let options = PhpValue::Array(vec![(
+ PhpValue::String("relative".to_string()),
+ PhpValue::Bool(is_relative),
+ )]);
+ let serialized = php_serialize(&options);
+ let mut hasher = Sha1::new();
+ hasher.update(json_bytes);
+ hasher.update(serialized.as_bytes());
+ let bytes = hasher.finalize();
+ let mut hex = String::with_capacity(bytes.len() * 2);
+ for b in bytes {
+ use std::fmt::Write;
+ let _ = write!(&mut hex, "{:02x}", b);
+ }
+ hex
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn computes_known_reference_for_plugin_a_fixture() {
+ // Fixture used by partial-update-loads-root-aliases-for-path-repos.test.
+ // Expected reference (`b133081...`) is what PHP's
+ // `hash('sha1', file_get_contents($composerJson) . serialize(['relative' => true]))`
+ // produces for this file — pin it here so reference computation
+ // changes can't drift silently from Composer.
+ let composer_json_path = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../../composer/tests/Composer/Test/Fixtures/functional/installed-versions/plugin-a/composer.json");
+ let bytes = std::fs::read(&composer_json_path).expect("fixture composer.json must exist");
+ let reference = compute_path_reference(&bytes, true);
+ assert!(
+ reference.starts_with("b133081"),
+ "unexpected reference: {reference}"
+ );
+ }
+
+ #[test]
+ fn relative_url_resolves_against_base_dir_and_emits_synthetic_package_repo() {
+ let temp = tempfile::tempdir().unwrap();
+ std::fs::create_dir_all(temp.path().join("pkg-dir")).unwrap();
+ std::fs::write(
+ temp.path().join("pkg-dir").join("composer.json"),
+ r#"{"name": "vendor/pkg", "version": "1.2.3"}"#,
+ )
+ .unwrap();
+
+ let input = vec![RawRepository {
+ repo_type: "path".to_string(),
+ url: Some("pkg-dir".to_string()),
+ package: None,
+ only: None,
+ exclude: None,
+ canonical: None,
+ security_advisories: None,
+ }];
+ let expanded = expand_path_repositories(&input, temp.path());
+ assert_eq!(expanded.len(), 1);
+ assert_eq!(expanded[0].repo_type, "package");
+
+ let pkgs = expanded[0]
+ .package
+ .as_ref()
+ .expect("expanded entry must carry a package payload")
+ .as_array()
+ .expect("payload should be an array");
+ assert_eq!(pkgs.len(), 1);
+ let pkg = &pkgs[0];
+ assert_eq!(pkg["name"], "vendor/pkg");
+ assert_eq!(pkg["version"], "1.2.3");
+ assert_eq!(pkg["dist"]["type"], "path");
+ assert_eq!(pkg["dist"]["url"], "pkg-dir");
+ assert!(
+ pkg["dist"]["reference"]
+ .as_str()
+ .map(|s| s.len() == 40)
+ .unwrap_or(false),
+ "reference should be a 40-char SHA-1"
+ );
+ }
+
+ #[test]
+ fn missing_composer_json_drops_the_entry() {
+ let temp = tempfile::tempdir().unwrap();
+ let input = vec![RawRepository {
+ repo_type: "path".to_string(),
+ url: Some("does-not-exist".to_string()),
+ package: None,
+ only: None,
+ exclude: None,
+ canonical: None,
+ security_advisories: None,
+ }];
+ let expanded = expand_path_repositories(&input, temp.path());
+ assert!(expanded.is_empty());
+ }
+
+ #[test]
+ fn non_path_repos_pass_through_unchanged() {
+ let input = vec![RawRepository {
+ repo_type: "vcs".to_string(),
+ url: Some("https://example.com/repo.git".to_string()),
+ package: None,
+ only: None,
+ exclude: None,
+ canonical: None,
+ security_advisories: None,
+ }];
+ let expanded = expand_path_repositories(&input, Path::new("/tmp"));
+ assert_eq!(expanded.len(), 1);
+ assert_eq!(expanded[0].repo_type, "vcs");
+ assert_eq!(
+ expanded[0].url.as_deref(),
+ Some("https://example.com/repo.git")
+ );
+ }
+}
diff --git a/crates/mozart-registry/src/resolver.rs b/crates/mozart-registry/src/resolver.rs
index b323764..d9fe900 100644
--- a/crates/mozart-registry/src/resolver.rs
+++ b/crates/mozart-registry/src/resolver.rs
@@ -265,7 +265,12 @@ fn normalize_root_alias_atom(atom: &str) -> Option<String> {
if let Some(rest) = lower.strip_prefix("dev-") {
return Some(format!("dev-{rest}"));
}
- parse_normalized(trimmed).map(|_| trimmed.to_string())
+ // Stable numeric atoms (e.g. `1.1.1`) need to come back in the
+ // four-segment form `Version::Display` produces, so the alias
+ // matcher's `input.version != alias.version_normalized` check lines
+ // up with pool inputs (which carry the 4-segment normalized form).
+ // Returning the raw input here would silently never match.
+ parse_normalized(trimmed).map(|v| v.to_string())
}
/// A root-level alias declared via the `require: "X as Y"` shorthand on the
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);