aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-01 20:42:56 +0900
committernsfisis <nsfisis@gmail.com>2026-05-01 20:42:56 +0900
commitfe210391f59d5fe19d5c3d56d369ab7918117de1 (patch)
tree4486d5945d241d0e6e8bf79072744cc0cca90cf4
parent8261abde3d9fb9ec1d764b912fa5487e4fcd9339 (diff)
downloadphp-mozart-fe210391f59d5fe19d5c3d56d369ab7918117de1.tar.gz
php-mozart-fe210391f59d5fe19d5c3d56d369ab7918117de1.tar.zst
php-mozart-fe210391f59d5fe19d5c3d56d369ab7918117de1.zip
feat(registry): accept v1 (bare array) installed.json
Composer's FilesystemRepository::initialize branches on isset($data['packages']) — object form is v2, bare array is v1 — and treats dev-package-names/dev as optional. Mirror that in InstalledPackages::read so Mozart consumes shared .test fixtures (which use v1) without harness preprocessing, and so installs over v1-era vendor directories keep working. Drop the v1→v2 wrapper that was added to mozart-test-harness for the same reason. Removes #[ignore] from update_to_empty_from_locked (2/187 green). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
-rw-r--r--crates/mozart-registry/src/installed.rs107
-rw-r--r--crates/mozart-test-harness/src/runner.rs5
-rw-r--r--crates/mozart/tests/installer.rs5
3 files changed, 111 insertions, 6 deletions
diff --git a/crates/mozart-registry/src/installed.rs b/crates/mozart-registry/src/installed.rs
index 7543b0e..6e8e0ac 100644
--- a/crates/mozart-registry/src/installed.rs
+++ b/crates/mozart-registry/src/installed.rs
@@ -70,14 +70,67 @@ impl InstalledPackages {
/// Read installed.json from `vendor/composer/installed.json`.
/// If the file does not exist, returns an empty registry.
+ ///
+ /// Accepts both Composer formats, mirroring `FilesystemRepository::initialize`:
+ /// - **v2** — object with a `packages` array, plus optional `dev-package-names`/`dev`
+ /// (the shape Composer 2.x writes).
+ /// - **v1** — bare array of package entries (older shape; still legal input).
pub fn read(vendor_dir: &Path) -> anyhow::Result<InstalledPackages> {
let path = vendor_dir.join("composer/installed.json");
if !path.exists() {
return Ok(InstalledPackages::new());
}
let content = fs::read_to_string(&path)?;
- let installed: InstalledPackages = serde_json::from_str(&content)?;
- Ok(installed)
+ Self::from_json_str(&content)
+ }
+
+ /// Parse an installed.json document. See [`Self::read`] for the accepted shapes.
+ pub fn from_json_str(content: &str) -> anyhow::Result<InstalledPackages> {
+ use anyhow::{Context, anyhow};
+
+ let value: serde_json::Value =
+ serde_json::from_str(content).context("invalid installed.json")?;
+
+ match value {
+ serde_json::Value::Object(mut obj) => {
+ let packages_value = obj.remove("packages").ok_or_else(|| {
+ anyhow!("Could not parse package list from installed.json (missing `packages`)")
+ })?;
+ let packages: Vec<InstalledPackageEntry> =
+ serde_json::from_value(packages_value)
+ .context("invalid `packages` array in installed.json")?;
+
+ let dev_package_names: Vec<String> = match obj.remove("dev-package-names") {
+ Some(v) => serde_json::from_value(v)
+ .context("invalid `dev-package-names` in installed.json")?,
+ None => Vec::new(),
+ };
+ let dev: bool = match obj.remove("dev") {
+ Some(v) => {
+ serde_json::from_value(v).context("invalid `dev` flag in installed.json")?
+ }
+ None => true,
+ };
+
+ Ok(InstalledPackages {
+ packages,
+ dev_package_names,
+ dev,
+ })
+ }
+ serde_json::Value::Array(_) => {
+ let packages: Vec<InstalledPackageEntry> = serde_json::from_value(value)
+ .context("invalid v1 installed.json package array")?;
+ Ok(InstalledPackages {
+ packages,
+ dev_package_names: Vec::new(),
+ dev: true,
+ })
+ }
+ _ => Err(anyhow!(
+ "Could not parse package list from installed.json (expected object or array)"
+ )),
+ }
}
/// Write installed.json to `vendor/composer/installed.json`.
@@ -206,6 +259,56 @@ mod tests {
}
#[test]
+ fn test_reads_v2_object_form() {
+ let json = r#"{
+ "packages": [
+ {"name": "a/a", "version": "1.0.0"}
+ ],
+ "dev-package-names": ["a/a"],
+ "dev": false
+ }"#;
+ let installed = InstalledPackages::from_json_str(json).unwrap();
+ assert_eq!(installed.packages.len(), 1);
+ assert_eq!(installed.packages[0].name, "a/a");
+ assert_eq!(installed.dev_package_names, vec!["a/a".to_string()]);
+ assert!(!installed.dev);
+ }
+
+ #[test]
+ fn test_reads_v1_array_form() {
+ // Composer 1.x / fixture-style: bare array of packages.
+ // FilesystemRepository::initialize accepts this; so must Mozart.
+ let json = r#"[
+ {"name": "a/a", "version": "1.0.0"},
+ {"name": "b/b", "version": "2.0.0"}
+ ]"#;
+ let installed = InstalledPackages::from_json_str(json).unwrap();
+ assert_eq!(installed.packages.len(), 2);
+ assert_eq!(installed.packages[0].name, "a/a");
+ assert_eq!(installed.packages[1].name, "b/b");
+ assert!(installed.dev_package_names.is_empty());
+ assert!(installed.dev);
+ }
+
+ #[test]
+ fn test_v2_defaults_when_optional_fields_missing() {
+ let json = r#"{"packages": []}"#;
+ let installed = InstalledPackages::from_json_str(json).unwrap();
+ assert!(installed.packages.is_empty());
+ assert!(installed.dev_package_names.is_empty());
+ assert!(installed.dev);
+ }
+
+ #[test]
+ fn test_rejects_non_object_non_array() {
+ let err = InstalledPackages::from_json_str("\"oops\"").unwrap_err();
+ assert!(
+ err.to_string().contains("expected object or array"),
+ "{err}"
+ );
+ }
+
+ #[test]
fn test_is_installed_case_insensitive() {
let mut installed = InstalledPackages::new();
installed.upsert(make_entry("Monolog/Monolog", "3.8.0"));
diff --git a/crates/mozart-test-harness/src/runner.rs b/crates/mozart-test-harness/src/runner.rs
index e041cd7..cefd50f 100644
--- a/crates/mozart-test-harness/src/runner.rs
+++ b/crates/mozart-test-harness/src/runner.rs
@@ -20,6 +20,11 @@ pub struct RunResult {
/// Set up a temp project from the parsed test, invoke `mozart` with the
/// `--RUN--` command, and capture the result.
+///
+/// `--INSTALLED--` is written verbatim to `vendor/composer/installed.json`.
+/// Composer's fixtures use the v1 plain-array shape (`[{...}]`), which
+/// `FilesystemRepository::initialize` accepts alongside the v2 object shape;
+/// Mozart's reader is expected to do the same. Do not pre-wrap here.
pub fn run_test(test: &ParsedTest, mozart_bin: &Path) -> Result<RunResult> {
let working_dir = TempDir::new().context("failed to create tempdir")?;
let root = working_dir.path();
diff --git a/crates/mozart/tests/installer.rs b/crates/mozart/tests/installer.rs
index a0e3f25..0520108 100644
--- a/crates/mozart/tests/installer.rs
+++ b/crates/mozart/tests/installer.rs
@@ -766,10 +766,7 @@ installer_fixture!(
ignore = "mozart binary cannot yet run this fixture"
);
installer_fixture!(update_to_empty_from_blank);
-installer_fixture!(
- update_to_empty_from_locked,
- ignore = "mozart binary cannot yet run this fixture"
-);
+installer_fixture!(update_to_empty_from_locked);
installer_fixture!(
update_with_all_dependencies,
ignore = "mozart binary cannot yet run this fixture"