aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-registry/src/lockfile.rs
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-10 00:32:08 +0900
committernsfisis <nsfisis@gmail.com>2026-05-10 00:32:08 +0900
commit8cc1ba8a02c0318b65658f1634de378c780392b9 (patch)
treefdd5cb61e488018891a486b25991b87c84220bb8 /crates/mozart-registry/src/lockfile.rs
parent72b2e877c01e67ba7edd37e34ac2eadb7a1c62c4 (diff)
downloadphp-mozart-8cc1ba8a02c0318b65658f1634de378c780392b9.tar.gz
php-mozart-8cc1ba8a02c0318b65658f1634de378c780392b9.tar.zst
php-mozart-8cc1ba8a02c0318b65658f1634de378c780392b9.zip
refactor(workspace): consolidate crates into mozart-core
Merged mozart-archiver, mozart-autoload, mozart-registry, mozart-sat-resolver, and mozart-vcs into mozart-core to align the source layout with Composer's structure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart-registry/src/lockfile.rs')
-rw-r--r--crates/mozart-registry/src/lockfile.rs2037
1 files changed, 0 insertions, 2037 deletions
diff --git a/crates/mozart-registry/src/lockfile.rs b/crates/mozart-registry/src/lockfile.rs
deleted file mode 100644
index fd6b5e3..0000000
--- a/crates/mozart-registry/src/lockfile.rs
+++ /dev/null
@@ -1,2037 +0,0 @@
-use crate::packagist::{PackagistDist, PackagistSource, PackagistVersion};
-use crate::repository::RepositorySet;
-use crate::resolver::ResolvedPackage;
-use indexmap::IndexMap;
-use indexmap::IndexSet;
-use mozart_core::installer::HasSuggests;
-use mozart_core::package::{RawPackageData, to_json_pretty};
-use serde::{Deserialize, Serialize};
-use std::collections::{BTreeMap, VecDeque};
-use std::fs;
-use std::path::Path;
-
-fn default_stability() -> String {
- "stable".to_string()
-}
-
-fn default_empty_object() -> serde_json::Value {
- serde_json::Value::Object(serde_json::Map::new())
-}
-
-/// Represents the content of a composer.lock file.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct LockFile {
- #[serde(rename = "_readme", default = "LockFile::default_readme")]
- pub readme: Vec<String>,
-
- /// Composer lock files written before content-hash existed (or fixtures
- /// covering BC behavior) may omit this field; mirror Composer's BC support
- /// in `Locker::isLocked()` by defaulting to empty.
- #[serde(rename = "content-hash", default)]
- pub content_hash: String,
-
- pub packages: Vec<LockedPackage>,
-
- #[serde(rename = "packages-dev")]
- pub packages_dev: Option<Vec<LockedPackage>>,
-
- #[serde(default)]
- pub aliases: Vec<LockAlias>,
-
- #[serde(rename = "minimum-stability", default = "default_stability")]
- pub minimum_stability: String,
-
- #[serde(rename = "stability-flags", default = "default_empty_object")]
- pub stability_flags: serde_json::Value,
-
- #[serde(rename = "prefer-stable", default)]
- pub prefer_stable: bool,
-
- #[serde(rename = "prefer-lowest", default)]
- pub prefer_lowest: bool,
-
- #[serde(default = "default_empty_object")]
- pub platform: serde_json::Value,
-
- #[serde(rename = "platform-dev", default = "default_empty_object")]
- pub platform_dev: serde_json::Value,
-
- #[serde(rename = "plugin-api-version", skip_serializing_if = "Option::is_none")]
- pub plugin_api_version: Option<String>,
-}
-
-/// A locked package entry in composer.lock.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct LockedPackage {
- pub name: String,
- pub version: String,
-
- #[serde(rename = "version_normalized", skip_serializing_if = "Option::is_none")]
- pub version_normalized: Option<String>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub source: Option<LockedSource>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub dist: Option<LockedDist>,
-
- #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
- pub require: BTreeMap<String, String>,
-
- #[serde(
- rename = "require-dev",
- default,
- skip_serializing_if = "BTreeMap::is_empty"
- )]
- pub require_dev: BTreeMap<String, String>,
-
- #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
- pub conflict: BTreeMap<String, String>,
-
- #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
- pub provide: BTreeMap<String, String>,
-
- #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
- pub replace: BTreeMap<String, String>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub suggest: Option<BTreeMap<String, String>>,
-
- #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
- pub package_type: Option<String>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub autoload: Option<serde_json::Value>,
-
- #[serde(rename = "autoload-dev", skip_serializing_if = "Option::is_none")]
- pub autoload_dev: Option<serde_json::Value>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub license: Option<Vec<String>>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub description: Option<String>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub homepage: Option<String>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub keywords: Option<Vec<String>>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub authors: Option<Vec<serde_json::Value>>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub support: Option<serde_json::Value>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub funding: Option<Vec<serde_json::Value>>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub time: Option<String>,
-
- /// Catch-all for extra fields we don't explicitly model
- #[serde(flatten)]
- pub extra_fields: BTreeMap<String, serde_json::Value>,
-}
-
-impl HasSuggests for LockedPackage {
- fn pretty_name(&self) -> &str {
- &self.name
- }
-
- fn suggests(&self) -> Vec<(String, String)> {
- self.suggest
- .as_ref()
- .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
- .unwrap_or_default()
- }
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct LockedSource {
- #[serde(rename = "type")]
- pub source_type: String,
- pub url: String,
- pub reference: Option<String>,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct LockedDist {
- #[serde(rename = "type")]
- pub dist_type: String,
- pub url: String,
- pub reference: Option<String>,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub shasum: Option<String>,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct LockAlias {
- pub package: String,
- pub version: String,
- pub alias: String,
- pub alias_normalized: String,
-}
-
-impl LockFile {
- /// Create default readme entries.
- pub fn default_readme() -> Vec<String> {
- vec![
- "This file locks the dependencies of your project to a known state".to_string(),
- "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies".to_string(),
- "This file is @generated automatically".to_string(),
- ]
- }
-
- /// Read a composer.lock file from disk.
- pub fn read_from_file(path: &Path) -> anyhow::Result<LockFile> {
- let content = fs::read_to_string(path)?;
- let lock: LockFile = serde_json::from_str(&content)?;
- Ok(lock)
- }
-
- /// Write a composer.lock file to disk with deterministic formatting.
- pub fn write_to_file(&self, path: &Path) -> anyhow::Result<()> {
- let json = to_json_pretty(self)?;
- fs::write(path, json)?;
- Ok(())
- }
-
- /// Check if the lock file is fresh (content-hash matches composer.json).
- pub fn is_fresh(&self, composer_json_content: &str) -> bool {
- match Self::compute_content_hash(composer_json_content) {
- Ok(hash) => hash == self.content_hash,
- Err(_) => false,
- }
- }
-
- /// Compute the content hash from composer.json content.
- /// Matches Composer's `Locker::getContentHash()`.
- pub fn compute_content_hash(composer_json_content: &str) -> anyhow::Result<String> {
- let value: serde_json::Value = serde_json::from_str(composer_json_content)?;
- let obj = value
- .as_object()
- .ok_or_else(|| anyhow::anyhow!("composer.json must be a JSON object"))?;
-
- // Keys that affect the content hash (Composer's relevantKeys)
- let relevant_keys = [
- "name",
- "version",
- "require",
- "require-dev",
- "conflict",
- "replace",
- "provide",
- "minimum-stability",
- "prefer-stable",
- "repositories",
- "extra",
- ];
-
- // Collect relevant keys into a BTreeMap (auto-sorted by key)
- let mut filtered: BTreeMap<&str, &serde_json::Value> = BTreeMap::new();
- for key in &relevant_keys {
- if let Some(v) = obj.get(*key) {
- filtered.insert(key, v);
- }
- }
-
- // Also include config.platform if present
- if let Some(config) = obj.get("config")
- && let Some(platform) = config.get("platform")
- {
- filtered.insert("config.platform", platform);
- }
-
- // Encode to compact JSON
- let compact = serde_json::to_string(&filtered)?;
-
- // Compute MD5
- let digest = md5::compute(compact.as_bytes());
- Ok(format!("{:x}", digest))
- }
-
- /// Check that every root `require` (and `require-dev` when `include_dev`)
- /// is satisfied by the locked packages. Returns the list of bullet-prefixed
- /// error lines (plus the trailing merge-conflict hint) if anything is
- /// missing or mismatched, otherwise an empty vec.
- ///
- /// Mirrors `Composer\Package\Locker::getMissingRequirementInfo()`.
- pub fn get_missing_requirement_info(
- &self,
- root: &mozart_core::package::RawPackageData,
- include_dev: bool,
- ) -> Vec<String> {
- let mut messages = Vec::new();
- let mut any_missing = false;
-
- let base_pool: Vec<LockedSearchEntry> = self
- .packages
- .iter()
- .map(|p| LockedSearchEntry::build(p, &self.aliases))
- .collect();
- let mut dev_pool: Vec<LockedSearchEntry> = base_pool.clone();
- if let Some(dev) = &self.packages_dev {
- dev_pool.extend(
- dev.iter()
- .map(|p| LockedSearchEntry::build(p, &self.aliases)),
- );
- }
-
- check_requirement_set(
- &root.require,
- "Required",
- &base_pool,
- &mut messages,
- &mut any_missing,
- );
- if include_dev {
- check_requirement_set(
- &root.require_dev,
- "Required (in require-dev)",
- &dev_pool,
- &mut messages,
- &mut any_missing,
- );
- }
-
- if any_missing {
- messages.push(
- "This usually happens when composer files are incorrectly merged or the composer.json file is manually edited.".to_string(),
- );
- messages.push(
- "Read more about correctly resolving merge conflicts https://getcomposer.org/doc/articles/resolving-merge-conflicts.md".to_string(),
- );
- messages.push(
- "and prefer using the \"require\" command over editing the composer.json file directly https://getcomposer.org/doc/03-cli.md#require-r".to_string(),
- );
- }
-
- messages
- }
-}
-
-/// A locked package paired with the additional version strings the locked
-/// repository would surface for it (branch-alias targets + matching root
-/// aliases from `lock.aliases`).
-///
-/// Mirrors the AliasPackage entries that `Composer\Package\Locker::getLockedRepository`
-/// adds alongside each locked package, so requirement checks see the same
-/// version surface Composer does.
-#[derive(Clone)]
-struct LockedSearchEntry<'a> {
- package: &'a LockedPackage,
- alias_versions: Vec<String>,
-}
-
-impl<'a> LockedSearchEntry<'a> {
- fn build(package: &'a LockedPackage, root_aliases: &[LockAlias]) -> Self {
- let mut alias_versions: Vec<String> = locked_package_branch_aliases(package)
- .into_iter()
- .map(|a| a.alias_normalized)
- .collect();
- for alias in root_aliases {
- if alias.package.eq_ignore_ascii_case(&package.name)
- && alias.version.eq_ignore_ascii_case(&package.version)
- {
- alias_versions.push(alias.alias_normalized.clone());
- }
- }
- Self {
- package,
- alias_versions,
- }
- }
-}
-
-/// Build the synthetic `LockAlias` entries a `dev-*` locked package contributes
-/// via `extra.branch-alias`. Mirrors `Composer\Package\Loader\ArrayLoader::getBranchAlias`
-/// followed by `VersionParser::normalizeBranch` — the same expansion
-/// `Locker::getLockedRepository` performs when constructing AliasPackages
-/// alongside each locked package.
-pub fn locked_package_branch_aliases(pkg: &LockedPackage) -> Vec<LockAlias> {
- let pkg_version_lower = pkg.version.to_lowercase();
- let is_dev_branch =
- pkg_version_lower.starts_with("dev-") || pkg_version_lower.ends_with("-dev");
- if !is_dev_branch {
- return Vec::new();
- }
- let Some(extra) = pkg.extra_fields.get("extra") else {
- return Vec::new();
- };
- let Some(branch_alias) = extra.get("branch-alias") else {
- return Vec::new();
- };
- let Some(map) = branch_alias.as_object() else {
- return Vec::new();
- };
- let mut out = Vec::new();
- for (source, target) in map.iter() {
- if !source.eq_ignore_ascii_case(&pkg.version) {
- continue;
- }
- let Some(target_str) = target.as_str() else {
- continue;
- };
- if !target_str.to_lowercase().ends_with("-dev") {
- continue;
- }
- let Some(normalized) = crate::resolver::normalize_branch_alias_target(target_str) else {
- continue;
- };
- // Pretty-form trim: Composer's `Preg::replace('{(\.9{7})+}', '.x', ...)`
- // turns the normalized form back into the wildcard form (e.g.
- // `2.1.9999999.9999999-dev` → `2.1.x-dev`). For trace output we want
- // the raw alias target string the package author wrote.
- out.push(LockAlias {
- package: pkg.name.clone(),
- version: pkg.version.clone(),
- alias: target_str.to_string(),
- alias_normalized: normalized,
- });
- }
- out
-}
-
-fn check_requirement_set(
- requires: &BTreeMap<String, String>,
- description: &str,
- pool: &[LockedSearchEntry],
- messages: &mut Vec<String>,
- any_missing: &mut bool,
-) {
- for (name, constraint_str) in requires {
- if mozart_core::platform::is_platform_package(name) {
- continue;
- }
- if constraint_str.trim() == "self.version" {
- continue;
- }
-
- let constraint = mozart_semver::VersionConstraint::parse(constraint_str).ok();
-
- let mut name_only_match: Option<&LockedPackage> = None;
- let mut satisfied = false;
- for entry in pool {
- let pkg = entry.package;
- if pkg.name != *name {
- continue;
- }
- if name_only_match.is_none() {
- name_only_match = Some(pkg);
- }
- let Some(ref c) = constraint else { continue };
- if let Ok(version) = mozart_semver::Version::parse(&pkg.version)
- && c.matches(&version)
- {
- satisfied = true;
- break;
- }
- if entry.alias_versions.iter().any(|alias| {
- mozart_semver::Version::parse(alias)
- .ok()
- .is_some_and(|v| c.matches(&v))
- }) {
- satisfied = true;
- break;
- }
- }
-
- if satisfied {
- continue;
- }
-
- *any_missing = true;
- if let Some(pkg) = name_only_match {
- messages.push(format!(
- "- {description} package \"{name}\" is in the lock file as \"{}\" but that does not satisfy your constraint \"{constraint_str}\".",
- pkg.version
- ));
- } else {
- messages.push(format!(
- "- {description} package \"{name}\" is not present in the lock file."
- ));
- }
- }
-}
-
-/// Input for lock file generation.
-pub struct LockFileGenerationRequest {
- /// Resolved packages from the dependency resolver.
- pub resolved_packages: Vec<ResolvedPackage>,
- /// Raw composer.json content string (for content-hash computation).
- pub composer_json_content: String,
- /// Parsed composer.json data (for platform, minimum-stability, etc.).
- pub composer_json: RawPackageData,
- /// Whether require-dev was included in resolution.
- pub include_dev: bool,
- /// Repository set used to fetch full metadata for resolved packages
- /// that aren't already covered by inline `type: package` repositories.
- pub repositories: std::sync::Arc<RepositorySet>,
- /// Previous `composer.lock` (when running update / require / remove).
- /// For each resolved package whose name+normalized-version matches an
- /// entry in this lock, the entry is copied into the new lock verbatim
- /// rather than being re-fetched from the inline / composer-repo /
- /// Packagist sources. Mirrors Composer's `Locker::setLockData` behaviour
- /// during partial updates: lock entries are stable across updates that
- /// don't touch the package, even if the upstream metadata has drifted.
- pub previous_lock: Option<LockFile>,
- /// Lowercase package names that were held back to their locked version
- /// on a partial update — i.e. they were NOT in the CLI's allow list and
- /// were re-pinned by `apply_partial_update`. For these names the lock
- /// entry's metadata (source/dist references in particular) is canonical:
- /// inline / composer-repo metadata may have drifted to a newer commit
- /// that the partial update is explicitly choosing not to take. Mirrors
- /// Composer's `PoolBuilder`, which keeps non-allow-listed packages at
- /// the locked-repo entry rather than re-loading them from the inline /
- /// VCS sources.
- pub lock_pinned_names: indexmap::IndexSet<String>,
-}
-
-impl LockFileGenerationRequest {
- /// Look up an inline `type: package` definition for `name` (if any).
- /// Returns the matching `PackagistVersion` so callers can short-circuit
- /// the Packagist fetch for resolved packages that came from a `type:
- /// package` repository.
- fn inline_lookup(&self, name: &str, version_normalized: &str) -> Option<PackagistVersion> {
- crate::inline_package::collect_inline_packages(&self.composer_json.repositories)
- .into_iter()
- .find(|ipkg| ipkg.name == name && ipkg.version.version_normalized == version_normalized)
- .map(|ipkg| ipkg.version)
- }
-
- /// Look up a `type: composer` repository entry for `name@version_normalized`.
- /// Used to short-circuit the Packagist fetch when the resolved package came
- /// from a local Composer repo (the test fixtures' file:// case).
- fn composer_repo_lookup(
- &self,
- name: &str,
- version_normalized: &str,
- ) -> Option<PackagistVersion> {
- crate::composer_repo::collect_composer_packages(&self.composer_json.repositories)
- .into_iter()
- .find(|cpkg| cpkg.name == name && cpkg.version.version_normalized == version_normalized)
- .map(|cpkg| cpkg.version)
- }
-
- /// Reuse `previous_lock` as a metadata source when no repository can
- /// answer for `(name, version_normalized)`. Mirrors the slice of
- /// Composer's `PoolBuilder` flow that re-loads locked-only packages
- /// straight off the lock: a partial update keeping a package at its
- /// locked version doesn't need to re-fetch its metadata, and the
- /// repositories may no longer carry that version (e.g. an inline
- /// `type: package` repo only listing the new release).
- fn previous_lock_lookup(
- &self,
- name: &str,
- version_normalized: &str,
- ) -> Option<PackagistVersion> {
- let prev = self.previous_lock.as_ref()?;
- prev.packages
- .iter()
- .chain(prev.packages_dev.iter().flatten())
- .find(|p| {
- p.name.eq_ignore_ascii_case(name)
- && p.version_normalized
- .as_deref()
- .map(|v| v == version_normalized)
- .unwrap_or_else(|| {
- mozart_semver::Version::parse(&p.version)
- .map(|v| v.to_string() == version_normalized)
- .unwrap_or(false)
- })
- })
- .map(locked_package_to_packagist_version)
- }
-}
-
-/// Synthesize a `PackagistVersion` from a `LockedPackage`. Used by
-/// `previous_lock_lookup` so the metadata loop has a complete view even
-/// when the surrounding repositories have moved on from a locked version.
-fn locked_package_to_packagist_version(pkg: &LockedPackage) -> PackagistVersion {
- PackagistVersion {
- version: pkg.version.clone(),
- version_normalized: pkg
- .version_normalized
- .clone()
- .unwrap_or_else(|| pkg.version.clone()),
- require: pkg.require.clone(),
- replace: pkg.replace.clone(),
- provide: pkg.provide.clone(),
- conflict: pkg.conflict.clone(),
- dist: pkg.dist.as_ref().map(|d| PackagistDist {
- dist_type: d.dist_type.clone(),
- url: d.url.clone(),
- reference: d.reference.clone(),
- shasum: d.shasum.clone(),
- }),
- source: pkg.source.as_ref().map(|s| PackagistSource {
- source_type: s.source_type.clone(),
- url: s.url.clone(),
- reference: s.reference.clone(),
- }),
- require_dev: pkg.require_dev.clone(),
- suggest: pkg.suggest.clone(),
- package_type: pkg.package_type.clone(),
- autoload: pkg.autoload.clone(),
- autoload_dev: pkg.autoload_dev.clone(),
- license: pkg.license.clone(),
- description: pkg.description.clone(),
- homepage: pkg.homepage.clone(),
- keywords: pkg.keywords.clone(),
- authors: pkg.authors.clone(),
- support: None,
- funding: None,
- time: pkg.time.clone(),
- extra: pkg.extra_fields.get("extra").cloned(),
- notification_url: pkg
- .extra_fields
- .get("notification-url")
- .and_then(|v| v.as_str())
- .map(String::from),
- default_branch: pkg
- .extra_fields
- .get("default-branch")
- .and_then(|v| v.as_bool())
- .unwrap_or(false),
- abandoned: pkg.extra_fields.get("abandoned").cloned(),
- }
-}
-
-/// Convert a `PackagistSource` to a `LockedSource`.
-fn packagist_source_to_locked(ps: &PackagistSource) -> LockedSource {
- LockedSource {
- source_type: ps.source_type.clone(),
- url: ps.url.clone(),
- reference: ps.reference.clone(),
- }
-}
-
-/// Convert a `PackagistDist` to a `LockedDist`.
-fn packagist_dist_to_locked(pd: &PackagistDist) -> LockedDist {
- LockedDist {
- dist_type: pd.dist_type.clone(),
- url: pd.url.clone(),
- reference: pd.reference.clone(),
- shasum: pd.shasum.clone(),
- }
-}
-
-/// Mirror Composer's `RootPackageLoader::extractReferences`: scan
-/// `require`/`require-dev` for `dev-foo#hex` style constraints, returning a
-/// lowercase package name → reference map. Constraints whose stability isn't
-/// `dev` after stripping the reference are left out (matching the
-/// `'dev' === VersionParser::parseStability(...)` guard in PHP).
-fn extract_root_references(
- require: &BTreeMap<String, String>,
- require_dev: &BTreeMap<String, String>,
-) -> BTreeMap<String, String> {
- let mut out = BTreeMap::new();
- for (name, raw_constraint) in require.iter().chain(require_dev.iter()) {
- if let Some(reference) = parse_inline_reference(raw_constraint) {
- out.insert(name.to_lowercase(), reference);
- }
- }
- out
-}
-
-/// Pull the `#hex` suffix out of a single-atom dev constraint. Returns
-/// `None` for non-`dev-*` / non-`*-dev` constraints, matching Composer's
-/// `'{^[^,\s@]+?#([a-f0-9]+)$}'` + `parseStability == 'dev'` guard.
-fn parse_inline_reference(constraint: &str) -> Option<String> {
- // Strip `... as alias` first, mirroring extractReferences's
- // `'{^([^,\s@]+) as .+$}'` replacement.
- let core = match constraint.split(" as ").next() {
- Some(c) => c.trim(),
- None => constraint.trim(),
- };
- let (head, hash) = core.rsplit_once('#')?;
- if hash.is_empty() || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
- return None;
- }
- if head.contains([' ', '\t', ',', '@']) {
- return None;
- }
- let lower = head.to_lowercase();
- if !(lower.starts_with("dev-") || lower.ends_with("-dev")) {
- return None;
- }
- Some(hash.to_string())
-}
-
-/// Mirror `Composer\Package\Package::setSourceDistReferences`: rewrite both
-/// source and dist references to the supplied value, and rewrite the
-/// reference inside any auto-generated GitHub/GitLab/Bitbucket dist URL when
-/// present. The dist reference is only written if there was already one
-/// (Composer leaves `dist.reference == null` packages alone).
-fn apply_reference_override(pkg: &mut LockedPackage, reference: &str) {
- if let Some(source) = pkg.source.as_mut() {
- source.reference = Some(reference.to_string());
- }
- if let Some(dist) = pkg.dist.as_mut() {
- let url_carries_known_host = matches_dist_url_with_known_host(Some(&dist.url));
- if dist.reference.is_some() || url_carries_known_host {
- dist.reference = Some(reference.to_string());
- }
- if url_carries_known_host {
- dist.url = rewrite_known_dist_url_reference(&dist.url, reference);
- }
- }
-}
-
-/// Match the bitbucket / github / gitlab dist-URL prefixes Composer
-/// rewrites. Mirrors the regex
-/// `{^https?://(?:(?:www\.)?bitbucket\.org|(api\.)?github\.com|(?:www\.)?gitlab\.com)/}i`.
-fn matches_dist_url_with_known_host(url: Option<&str>) -> bool {
- let Some(url) = url else { return false };
- let lower = url.to_lowercase();
- let stripped = lower
- .strip_prefix("http://")
- .or_else(|| lower.strip_prefix("https://"))
- .unwrap_or(&lower);
- let stripped = stripped.strip_prefix("www.").unwrap_or(stripped);
- let stripped = stripped.strip_prefix("api.").unwrap_or(stripped);
- stripped.starts_with("bitbucket.org/")
- || stripped.starts_with("github.com/")
- || stripped.starts_with("gitlab.com/")
-}
-
-/// Substitute any 40-char hex segment surrounded by `/` or `sha=` (the
-/// archive shape produced by GitHub/GitLab/Bitbucket) with the new
-/// reference. Matches Composer's
-/// `'{(?<=/|sha=)[a-f0-9]{40}(?=/|$)}i'` rewrite.
-fn rewrite_known_dist_url_reference(url: &str, reference: &str) -> String {
- let bytes = url.as_bytes();
- let mut out = String::with_capacity(url.len());
- let mut i = 0;
- while i < bytes.len() {
- let start = i;
- let preceded_by_slash = i > 0 && bytes[i - 1] == b'/';
- let preceded_by_sha = i >= 4 && &bytes[i - 4..i] == b"sha=";
- if (preceded_by_slash || preceded_by_sha) && i + 40 <= bytes.len() {
- let candidate = &url[i..i + 40];
- if candidate.chars().all(|c| c.is_ascii_hexdigit()) {
- let after = bytes.get(i + 40).copied();
- if after == Some(b'/') || after.is_none() {
- out.push_str(reference);
- i += 40;
- continue;
- }
- }
- }
- out.push(url[start..].chars().next().unwrap());
- i += url[start..].chars().next().unwrap().len_utf8();
- }
- out
-}
-
-/// Convert a `PackagistVersion` to a `LockedPackage`.
-fn packagist_version_to_locked_package(name: &str, pv: &PackagistVersion) -> LockedPackage {
- let mut extra_fields: BTreeMap<String, serde_json::Value> = BTreeMap::new();
-
- if let Some(extra) = &pv.extra {
- extra_fields.insert("extra".to_string(), extra.clone());
- }
- if let Some(notification_url) = &pv.notification_url {
- extra_fields.insert(
- "notification-url".to_string(),
- serde_json::Value::String(notification_url.clone()),
- );
- }
- // Propagate `abandoned` so the lock (and downstream installed.json
- // round-trip) preserves the package's deprecation state. Mirrors
- // Composer's `ArrayDumper::dump`, which emits the field when truthy
- // (`true` for "abandoned, no replacement", a string for "abandoned,
- // use this instead"). `false`/null collapse to "not abandoned" and
- // are dropped.
- if let Some(abandoned) = &pv.abandoned {
- let keep = match abandoned {
- serde_json::Value::Bool(b) => *b,
- serde_json::Value::String(s) => !s.is_empty(),
- serde_json::Value::Null => false,
- _ => true,
- };
- if keep {
- extra_fields.insert("abandoned".to_string(), abandoned.clone());
- }
- }
- // Propagate `default-branch: true` so the lock surface — and the
- // installed.json round-trip — keeps the marker that drives Composer's
- // synthetic `9999999-dev` alias for default-branch dev packages.
- // Without this, `Locker::getLockedRepository` (which Mozart mirrors via
- // `collect_stale_installed_aliases` / `lock_alias_pretty_pairs`) can't
- // tell that the package's default branch is still aliased and emits a
- // spurious `MarkAliasUninstalled` for the missing `9999999-dev` alias.
- if pv.default_branch {
- extra_fields.insert("default-branch".to_string(), serde_json::Value::Bool(true));
- }
-
- LockedPackage {
- name: name.to_string(),
- version: pv.version.clone(),
- version_normalized: Some(pv.version_normalized.clone()),
- source: pv.source.as_ref().map(packagist_source_to_locked),
- dist: pv.dist.as_ref().map(packagist_dist_to_locked),
- require: pv.require.clone(),
- require_dev: pv.require_dev.clone(),
- conflict: pv.conflict.clone(),
- provide: pv.provide.clone(),
- replace: pv.replace.clone(),
- suggest: pv.suggest.clone(),
- package_type: pv.package_type.clone(),
- autoload: pv.autoload.clone(),
- autoload_dev: pv.autoload_dev.clone(),
- license: pv.license.clone(),
- description: pv.description.clone(),
- homepage: pv.homepage.clone(),
- keywords: pv.keywords.clone(),
- authors: pv.authors.clone(),
- support: pv.support.clone(),
- funding: pv.funding.clone(),
- time: pv.time.clone(),
- extra_fields,
- }
-}
-
-/// Determine which resolved packages are dev-only.
-///
-/// A package is dev-only if it is NOT reachable from the non-dev dependency tree
-/// (i.e., only reachable through require-dev paths).
-///
-/// `requires_by_name` and `providers_by_name` are keyed by lowercase package
-/// names. `providers_by_name` maps a satisfied name (own name + each `provide`
-/// or `replace` target) to the list of resolved package names that satisfy it,
-/// so a non-dev `require` like `provided/pkg` reaches `b/b` when `b/b`
-/// declares `provide: { provided/pkg: 1.0.0 }`.
-fn classify_dev_packages(
- resolved: &[ResolvedPackage],
- require: &BTreeMap<String, String>,
- _require_dev: &BTreeMap<String, String>,
- requires_by_name: &IndexMap<String, Vec<String>>,
- providers_by_name: &IndexMap<String, Vec<String>>,
-) -> IndexSet<String> {
- // BFS from non-dev root dependencies through each package's `require` map.
- // All reachable packages are production packages.
- let mut production: IndexSet<String> = IndexSet::new();
- let mut queue: VecDeque<String> = VecDeque::new();
-
- let visit = |name: &str, production: &mut IndexSet<String>, queue: &mut VecDeque<String>| {
- let name_lower = name.to_lowercase();
- if is_platform_name(&name_lower) {
- return;
- }
- // A required name is satisfied either by a resolved package whose own
- // name matches (the common case, captured here as `providers_by_name`
- // also indexes own names) or by a resolved package that provides /
- // replaces it. Mirrors Composer's `extractDevPackages` second-solve
- // semantics, which walks the same provide/replace edges through a
- // real Solver call.
- if let Some(provs) = providers_by_name.get(&name_lower) {
- for prov in provs {
- let prov_lower = prov.to_lowercase();
- if production.insert(prov_lower.clone()) {
- queue.push_back(prov_lower);
- }
- }
- }
- };
-
- for name in require.keys() {
- visit(name, &mut production, &mut queue);
- }
-
- while let Some(pkg_name) = queue.pop_front() {
- if let Some(deps) = requires_by_name.get(&pkg_name) {
- for dep_name in deps.clone() {
- visit(&dep_name, &mut production, &mut queue);
- }
- }
- }
-
- // Any resolved package not in `production` is dev-only
- resolved
- .iter()
- .filter(|p| !production.contains(&p.name.to_lowercase()))
- .map(|p| p.name.clone())
- .collect()
-}
-
-/// Returns true if the package name is a platform package (php, ext-*, lib-*, etc.).
-fn is_platform_name(name: &str) -> bool {
- name == "php"
- || name.starts_with("ext-")
- || name.starts_with("lib-")
- || name == "php-64bit"
- || name == "php-ipv6"
- || name == "php-zts"
- || name == "php-debug"
-}
-
-/// Extract platform requirements from a requirements map.
-///
-/// Filters the map to include only platform package keys (`php`, `ext-*`, `lib-*`, etc.)
-/// and returns them as a JSON object.
-fn extract_platform_requirements(requirements: &BTreeMap<String, String>) -> serde_json::Value {
- let map: serde_json::Map<String, serde_json::Value> = requirements
- .iter()
- .filter(|(k, _)| is_platform_name(k))
- .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
- .collect();
- serde_json::Value::Object(map)
-}
-
-/// Generate a complete `LockFile` from resolution results.
-///
-/// This function:
-/// 1. Fetches full metadata from Packagist for each resolved package
-/// 2. Separates packages into production vs dev-only
-/// 3. Computes the content-hash
-/// 4. Assembles the complete `LockFile` struct
-pub async fn generate_lock_file(request: &LockFileGenerationRequest) -> anyhow::Result<LockFile> {
- // Split the resolved set into real packages and alias entries up front.
- // Aliases get emitted as a separate `aliases[]` block and never enter the
- // metadata fetch loop — their target package carries the real metadata.
- let (real_resolved, alias_resolved): (Vec<&ResolvedPackage>, Vec<&ResolvedPackage>) = request
- .resolved_packages
- .iter()
- .partition(|p| p.alias_of_normalized.is_none());
-
- // 1. Fetch full metadata for real (non-alias) packages.
- //
- // Inline `type: package` repositories carry full metadata in composer.json
- // — short-circuit those before hitting the network. Everything else goes
- // through `RepositorySet`, which today contains only Packagist; future
- // steps will move VCS / inline through the same set.
- // Previous-lock relationship pass-through: when a resolved package
- // matches an entry in `previous_lock` at the same name +
- // version_normalized, capture the entry's relationship-shaped fields
- // (require / require-dev / conflict / replace / provide / suggest).
- // Composer's transaction calculates operation order using these
- // relationship fields off the locked repository, so a partial update
- // shouldn't refresh them from upstream metadata for packages that
- // didn't move — otherwise topological_sort sees a different graph
- // than Composer would.
- //
- // Source/dist references and version-shaped fields still come from
- // the freshly-fetched metadata, so dev packages whose ref bumped (the
- // resolver picked a new commit at the same version label) still get
- // their ref refreshed.
- struct PreservedRelationships {
- require: BTreeMap<String, String>,
- require_dev: BTreeMap<String, String>,
- conflict: BTreeMap<String, String>,
- provide: BTreeMap<String, String>,
- replace: BTreeMap<String, String>,
- suggest: Option<BTreeMap<String, String>>,
- }
- let mut preserved_rel: IndexMap<String, PreservedRelationships> = IndexMap::new();
- if let Some(prev) = &request.previous_lock {
- for prev_pkg in prev
- .packages
- .iter()
- .chain(prev.packages_dev.iter().flatten())
- {
- let prev_normalized = prev_pkg.version_normalized.clone().unwrap_or_else(|| {
- mozart_semver::Version::parse(&prev_pkg.version)
- .map(|v| v.to_string())
- .unwrap_or_else(|_| prev_pkg.version.clone())
- });
- for pkg in &real_resolved {
- if pkg.name.eq_ignore_ascii_case(&prev_pkg.name)
- && pkg.version_normalized == prev_normalized
- {
- preserved_rel.insert(
- pkg.name.clone(),
- PreservedRelationships {
- require: prev_pkg.require.clone(),
- require_dev: prev_pkg.require_dev.clone(),
- conflict: prev_pkg.conflict.clone(),
- provide: prev_pkg.provide.clone(),
- replace: prev_pkg.replace.clone(),
- suggest: prev_pkg.suggest.clone(),
- },
- );
- }
- }
- }
- }
-
- let mut package_metadata: IndexMap<String, PackagistVersion> = IndexMap::new();
- let repo_set = &request.repositories;
- for pkg in &real_resolved {
- // For packages held back to the locked version on a partial update,
- // the lock entry is the canonical metadata source. Inline / composer-
- // repo / VCS sources may have moved to a newer commit that this
- // partial update is explicitly choosing NOT to take, so consulting
- // them first would silently bump the source/dist reference. Mirrors
- // Composer's `PoolBuilder` behaviour: non-allow-listed packages keep
- // the locked-repo entry rather than re-loading from upstream.
- let pinned = request.lock_pinned_names.contains(&pkg.name.to_lowercase());
- if pinned
- && let Some(prev) = request.previous_lock_lookup(&pkg.name, &pkg.version_normalized)
- {
- package_metadata.insert(pkg.name.clone(), prev);
- continue;
- }
-
- if let Some(inline) = request.inline_lookup(&pkg.name, &pkg.version_normalized) {
- package_metadata.insert(pkg.name.clone(), inline);
- continue;
- }
-
- if let Some(cv) = request.composer_repo_lookup(&pkg.name, &pkg.version_normalized) {
- package_metadata.insert(pkg.name.clone(), cv);
- continue;
- }
-
- if let Some(prev) = request.previous_lock_lookup(&pkg.name, &pkg.version_normalized) {
- package_metadata.insert(pkg.name.clone(), prev);
- continue;
- }
-
- let queries = [crate::repository::PackageQuery {
- name: pkg.name.as_str(),
- constraint: None,
- }];
- let results = repo_set.load_packages(&queries).await?;
- let matching = results
- .into_iter()
- .find(|r| r.version.version_normalized == pkg.version_normalized)
- .ok_or_else(|| {
- anyhow::anyhow!(
- "Could not find version {} for package {} in Packagist response",
- pkg.version_normalized,
- pkg.name
- )
- })?;
- package_metadata.insert(pkg.name.clone(), matching.version);
- }
-
- // 2. Classify dev vs non-dev packages (real packages only).
- let real_owned: Vec<ResolvedPackage> = real_resolved
- .iter()
- .map(|p| ResolvedPackage {
- name: p.name.clone(),
- version: p.version.clone(),
- version_normalized: p.version_normalized.clone(),
- is_dev: p.is_dev,
- alias_of_normalized: None,
- })
- .collect();
- // Build the `name → require keys` view classify_dev_packages walks. Use
- // preserved-from-old-lock requires when available so a partial update
- // sees the same dev-classification graph the previous lock did.
- let mut requires_by_name: IndexMap<String, Vec<String>> = IndexMap::new();
- // Inverse map: `satisfied name → list of resolved packages that satisfy it`.
- // A resolved package satisfies its own name plus each `provide` / `replace`
- // target (Composer's `extractDevPackages` reaches the same edges through
- // its second Solver run; we walk them directly during the dev BFS).
- let mut providers_by_name: IndexMap<String, Vec<String>> = IndexMap::new();
- for (name, pv) in &package_metadata {
- let name_lower = name.to_lowercase();
- let (require_keys, provide_keys, replace_keys): (Vec<String>, Vec<String>, Vec<String>) =
- if let Some(rel) = preserved_rel.get(name) {
- (
- rel.require.keys().cloned().collect(),
- rel.provide.keys().cloned().collect(),
- rel.replace.keys().cloned().collect(),
- )
- } else {
- (
- pv.require.keys().cloned().collect(),
- pv.provide.keys().cloned().collect(),
- pv.replace.keys().cloned().collect(),
- )
- };
- requires_by_name.insert(name_lower.clone(), require_keys);
- providers_by_name
- .entry(name_lower.clone())
- .or_default()
- .push(name_lower.clone());
- for target in provide_keys.iter().chain(replace_keys.iter()) {
- providers_by_name
- .entry(target.to_lowercase())
- .or_default()
- .push(name_lower.clone());
- }
- }
- let dev_only = classify_dev_packages(
- &real_owned,
- &request.composer_json.require,
- &request.composer_json.require_dev,
- &requires_by_name,
- &providers_by_name,
- );
-
- // 3. Build LockedPackage lists.
- //
- // Apply root-level `#hex` reference overrides extracted from
- // `require`/`require-dev`. Mirrors Composer's
- // `RootPackageLoader::extractReferences` + `PoolBuilder::loadPackage`'s
- // `setSourceDistReferences` call: when the user pinned a dev package via
- // `dev-main#abcd123`, the resolved package's source/dist must show that
- // reference in the lock + trace, not whatever the inline metadata said.
- let root_references = extract_root_references(
- &request.composer_json.require,
- &request.composer_json.require_dev,
- );
- let mut packages: Vec<LockedPackage> = Vec::new();
- let mut packages_dev: Vec<LockedPackage> = Vec::new();
- for pkg in &real_resolved {
- let pv = &package_metadata[&pkg.name];
- let mut locked = packagist_version_to_locked_package(&pkg.name, pv);
- // Overlay relationship fields from the previous lock when applicable
- // — the resolver's transaction-time view came from the lock, so the
- // new lock should mirror those relationships even if the upstream
- // metadata has drifted.
- if let Some(rel) = preserved_rel.get(&pkg.name) {
- locked.require = rel.require.clone();
- locked.require_dev = rel.require_dev.clone();
- locked.conflict = rel.conflict.clone();
- locked.provide = rel.provide.clone();
- locked.replace = rel.replace.clone();
- locked.suggest = rel.suggest.clone();
- }
- if let Some(reference) = root_references.get(&pkg.name.to_lowercase()) {
- apply_reference_override(&mut locked, reference);
- }
- if dev_only.contains(&pkg.name) {
- packages_dev.push(locked);
- } else {
- packages.push(locked);
- }
- }
-
- // 4. Sort each list alphabetically by name (Composer does this)
- packages.sort_by(|a, b| a.name.cmp(&b.name));
- packages_dev.sort_by(|a, b| a.name.cmp(&b.name));
-
- // 5. Build the aliases[] block. Each alias entry references the target
- // package (`package` + `version`) and carries the alias's pretty/normalized
- // form (`alias` + `alias_normalized`). Mirrors Composer's
- // `Locker::lockPackages` alias dump.
- let mut alias_blocks: Vec<LockAlias> = Vec::new();
- for alias in &alias_resolved {
- let target_normalized = match &alias.alias_of_normalized {
- Some(t) => t.clone(),
- None => continue,
- };
- let target_pretty = real_resolved
- .iter()
- .find(|p| p.name == alias.name && p.version_normalized == target_normalized)
- .map(|p| p.version.clone())
- .unwrap_or_else(|| target_normalized.clone());
- alias_blocks.push(LockAlias {
- package: alias.name.clone(),
- version: target_pretty,
- alias: alias.version.clone(),
- alias_normalized: alias.version_normalized.clone(),
- });
- }
- alias_blocks.sort_by(|a, b| a.package.cmp(&b.package).then(a.alias.cmp(&b.alias)));
-
- // 6. Compute content-hash
- let content_hash = LockFile::compute_content_hash(&request.composer_json_content)?;
-
- // 7. Extract platform requirements
- let platform = extract_platform_requirements(&request.composer_json.require);
- let platform_dev = extract_platform_requirements(&request.composer_json.require_dev);
-
- // 8. Determine minimum-stability and prefer-stable
- let minimum_stability = request
- .composer_json
- .minimum_stability
- .clone()
- .unwrap_or_else(|| "stable".to_string());
-
- let prefer_stable = request
- .composer_json
- .extra_fields
- .get("prefer-stable")
- .and_then(|v| v.as_bool())
- .unwrap_or(false);
-
- // 9. Assemble LockFile
- Ok(LockFile {
- readme: LockFile::default_readme(),
- content_hash,
- packages,
- packages_dev: if request.include_dev {
- Some(packages_dev)
- } else {
- Some(vec![])
- },
- aliases: alias_blocks,
- minimum_stability,
- stability_flags: serde_json::json!({}),
- prefer_stable,
- prefer_lowest: false,
- platform,
- platform_dev,
- plugin_api_version: Some("2.6.0".to_string()),
- })
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use tempfile::tempdir;
-
- fn minimal_lock() -> LockFile {
- LockFile {
- readme: LockFile::default_readme(),
- content_hash: "abc123".to_string(),
- packages: vec![],
- packages_dev: Some(vec![]),
- aliases: vec![],
- minimum_stability: "stable".to_string(),
- stability_flags: serde_json::json!({}),
- prefer_stable: false,
- prefer_lowest: false,
- platform: serde_json::json!({}),
- platform_dev: serde_json::json!({}),
- plugin_api_version: Some("2.6.0".to_string()),
- }
- }
-
- #[test]
- fn test_roundtrip_minimal() {
- let dir = tempdir().unwrap();
- let path = dir.path().join("composer.lock");
-
- let lock = minimal_lock();
- lock.write_to_file(&path).unwrap();
-
- let loaded = LockFile::read_from_file(&path).unwrap();
- assert_eq!(loaded.content_hash, "abc123");
- assert_eq!(loaded.minimum_stability, "stable");
- assert!(!loaded.prefer_stable);
- assert_eq!(loaded.packages.len(), 0);
- }
-
- #[test]
- fn test_roundtrip_with_package() {
- let dir = tempdir().unwrap();
- let path = dir.path().join("composer.lock");
-
- let mut lock = minimal_lock();
- lock.packages.push(LockedPackage {
- name: "monolog/monolog".to_string(),
- version: "3.8.0".to_string(),
- version_normalized: None,
- source: None,
- dist: Some(LockedDist {
- dist_type: "zip".to_string(),
- url: "https://example.com/monolog.zip".to_string(),
- reference: Some("abc123".to_string()),
- shasum: Some("".to_string()),
- }),
- require: BTreeMap::new(),
- require_dev: BTreeMap::new(),
- conflict: BTreeMap::new(),
- provide: BTreeMap::new(),
- replace: BTreeMap::new(),
- suggest: None,
- package_type: Some("library".to_string()),
- autoload: None,
- autoload_dev: None,
- license: Some(vec!["MIT".to_string()]),
- description: Some("A logging library".to_string()),
- homepage: None,
- keywords: None,
- authors: None,
- support: None,
- funding: None,
- time: None,
- extra_fields: BTreeMap::new(),
- });
-
- lock.write_to_file(&path).unwrap();
- let loaded = LockFile::read_from_file(&path).unwrap();
-
- assert_eq!(loaded.packages.len(), 1);
- assert_eq!(loaded.packages[0].name, "monolog/monolog");
- assert_eq!(loaded.packages[0].version, "3.8.0");
- assert_eq!(
- loaded.packages[0].description.as_deref(),
- Some("A logging library")
- );
- }
-
- #[test]
- fn test_content_hash_deterministic() {
- let composer_json = r#"{"name": "test/project", "require": {"monolog/monolog": "^3.0"}}"#;
- let h1 = LockFile::compute_content_hash(composer_json).unwrap();
- let h2 = LockFile::compute_content_hash(composer_json).unwrap();
- assert_eq!(h1, h2);
- assert!(!h1.is_empty());
- }
-
- #[test]
- fn test_content_hash_changes_on_require_change() {
- let composer1 = r#"{"name": "test/project", "require": {"monolog/monolog": "^3.0"}}"#;
- let composer2 = r#"{"name": "test/project", "require": {"monolog/monolog": "^2.0"}}"#;
- let h1 = LockFile::compute_content_hash(composer1).unwrap();
- let h2 = LockFile::compute_content_hash(composer2).unwrap();
- assert_ne!(h1, h2);
- }
-
- #[test]
- fn test_is_fresh() {
- let composer_json = r#"{"name": "test/project", "require": {"php": ">=8.1"}}"#;
- let hash = LockFile::compute_content_hash(composer_json).unwrap();
-
- let mut lock = minimal_lock();
- lock.content_hash = hash;
-
- assert!(lock.is_fresh(composer_json));
- assert!(!lock.is_fresh(r#"{"name": "test/project", "require": {"php": ">=8.0"}}"#));
- }
-
- #[test]
- fn test_default_readme() {
- let readme = LockFile::default_readme();
- assert_eq!(readme.len(), 3);
- assert!(readme[0].contains("locks the dependencies"));
- }
-
- #[test]
- fn parses_lock_without_content_hash() {
- // Composer fixtures (and historical lock files) may omit content-hash;
- // mirror Composer's BC handling by accepting it and treating the lock
- // as not-fresh against any composer.json.
- let raw = r#"{
- "packages": [],
- "packages-dev": [],
- "aliases": [],
- "minimum-stability": "dev",
- "stability-flags": {},
- "prefer-stable": false,
- "prefer-lowest": false
- }"#;
- let lock: LockFile = serde_json::from_str(raw).unwrap();
- assert_eq!(lock.content_hash, "");
- assert!(!lock.is_fresh(r#"{"require": {}}"#));
- }
-
- fn make_packagist_version(
- version: &str,
- version_normalized: &str,
- require: BTreeMap<String, String>,
- ) -> PackagistVersion {
- PackagistVersion {
- version: version.to_string(),
- version_normalized: version_normalized.to_string(),
- require,
- replace: BTreeMap::new(),
- provide: BTreeMap::new(),
- conflict: BTreeMap::new(),
- dist: Some(crate::packagist::PackagistDist {
- dist_type: "zip".to_string(),
- url: format!("https://example.com/{version}.zip"),
- reference: Some("deadbeef".to_string()),
- shasum: Some("abc123".to_string()),
- }),
- source: Some(crate::packagist::PackagistSource {
- source_type: "git".to_string(),
- url: "https://github.com/example/pkg.git".to_string(),
- reference: Some("deadbeef".to_string()),
- }),
- require_dev: BTreeMap::new(),
- suggest: None,
- package_type: Some("library".to_string()),
- autoload: Some(serde_json::json!({"psr-4": {"Example\\": "src/"}})),
- autoload_dev: None,
- license: Some(vec!["MIT".to_string()]),
- description: Some("An example package".to_string()),
- homepage: Some("https://example.com".to_string()),
- keywords: Some(vec!["example".to_string(), "test".to_string()]),
- authors: Some(vec![
- serde_json::json!({"name": "Alice", "email": "alice@example.com"}),
- ]),
- support: Some(serde_json::json!({"issues": "https://github.com/example/pkg/issues"})),
- funding: Some(vec![
- serde_json::json!({"type": "github", "url": "https://github.com/sponsors/alice"}),
- ]),
- time: Some("2024-01-15T10:00:00+00:00".to_string()),
- extra: Some(serde_json::json!({"branch-alias": {"dev-main": "1.0.x-dev"}})),
- notification_url: Some("https://packagist.org/downloads/".to_string()),
- default_branch: false,
- abandoned: None,
- }
- }
-
- #[test]
- fn test_packagist_version_to_locked_package() {
- let pv = make_packagist_version("1.2.3", "1.2.3.0", BTreeMap::new());
- let locked = packagist_version_to_locked_package("example/pkg", &pv);
-
- assert_eq!(locked.name, "example/pkg");
- assert_eq!(locked.version, "1.2.3");
- assert_eq!(locked.version_normalized.as_deref(), Some("1.2.3.0"));
- assert_eq!(locked.description.as_deref(), Some("An example package"));
- assert_eq!(locked.homepage.as_deref(), Some("https://example.com"));
- assert_eq!(
- locked.license.as_deref(),
- Some(vec!["MIT".to_string()].as_slice())
- );
- assert_eq!(
- locked.keywords.as_deref(),
- Some(["example".to_string(), "test".to_string()].as_slice())
- );
- assert_eq!(locked.package_type.as_deref(), Some("library"));
- assert!(locked.autoload.is_some());
- assert!(locked.authors.is_some());
- assert!(locked.support.is_some());
- assert!(locked.funding.is_some());
- assert_eq!(locked.time.as_deref(), Some("2024-01-15T10:00:00+00:00"));
-
- // Check dist
- let dist = locked.dist.as_ref().unwrap();
- assert_eq!(dist.dist_type, "zip");
- assert_eq!(dist.reference.as_deref(), Some("deadbeef"));
- assert_eq!(dist.shasum.as_deref(), Some("abc123"));
-
- // Check source
- let source = locked.source.as_ref().unwrap();
- assert_eq!(source.source_type, "git");
- assert_eq!(source.reference.as_deref(), Some("deadbeef"));
-
- // Check extra_fields (extra and notification-url)
- assert!(locked.extra_fields.contains_key("extra"));
- assert!(locked.extra_fields.contains_key("notification-url"));
- assert_eq!(
- locked.extra_fields["notification-url"],
- serde_json::Value::String("https://packagist.org/downloads/".to_string())
- );
- }
-
- #[test]
- fn test_packagist_version_to_locked_package_no_optional_fields() {
- let pv = PackagistVersion {
- version: "1.0.0".to_string(),
- version_normalized: "1.0.0.0".to_string(),
- require: BTreeMap::new(),
- replace: BTreeMap::new(),
- provide: BTreeMap::new(),
- conflict: BTreeMap::new(),
- dist: None,
- source: None,
- require_dev: BTreeMap::new(),
- suggest: None,
- package_type: None,
- autoload: None,
- autoload_dev: None,
- license: None,
- description: None,
- homepage: None,
- keywords: None,
- authors: None,
- support: None,
- funding: None,
- time: None,
- extra: None,
- notification_url: None,
- default_branch: false,
- abandoned: None,
- };
-
- let locked = packagist_version_to_locked_package("vendor/pkg", &pv);
- assert_eq!(locked.name, "vendor/pkg");
- assert!(locked.dist.is_none());
- assert!(locked.source.is_none());
- assert!(locked.description.is_none());
- assert!(locked.license.is_none());
- assert!(locked.extra_fields.is_empty());
- }
-
- #[test]
- fn test_classify_dev_packages_simple() {
- // Root: require={A}, require-dev={B}
- // A depends on C; B depends on D
- // Expected dev-only: {B, D}
- let resolved = vec![
- ResolvedPackage {
- name: "vendor/a".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: "1.0.0.0".to_string(),
- is_dev: false,
- alias_of_normalized: None,
- },
- ResolvedPackage {
- name: "vendor/b".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: "1.0.0.0".to_string(),
- is_dev: false,
- alias_of_normalized: None,
- },
- ResolvedPackage {
- name: "vendor/c".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: "1.0.0.0".to_string(),
- is_dev: false,
- alias_of_normalized: None,
- },
- ResolvedPackage {
- name: "vendor/d".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: "1.0.0.0".to_string(),
- is_dev: false,
- alias_of_normalized: None,
- },
- ];
-
- let mut require = BTreeMap::new();
- require.insert("vendor/a".to_string(), "^1.0".to_string());
-
- let mut require_dev = BTreeMap::new();
- require_dev.insert("vendor/b".to_string(), "^1.0".to_string());
-
- let mut metadata: IndexMap<String, PackagistVersion> = IndexMap::new();
-
- // A requires C
- let mut a_require = BTreeMap::new();
- a_require.insert("vendor/c".to_string(), "^1.0".to_string());
- metadata.insert(
- "vendor/a".to_string(),
- make_packagist_version("1.0.0", "1.0.0.0", a_require),
- );
-
- // B requires D
- let mut b_require = BTreeMap::new();
- b_require.insert("vendor/d".to_string(), "^1.0".to_string());
- metadata.insert(
- "vendor/b".to_string(),
- make_packagist_version("1.0.0", "1.0.0.0", b_require),
- );
-
- // C and D have no deps
- metadata.insert(
- "vendor/c".to_string(),
- make_packagist_version("1.0.0", "1.0.0.0", BTreeMap::new()),
- );
- metadata.insert(
- "vendor/d".to_string(),
- make_packagist_version("1.0.0", "1.0.0.0", BTreeMap::new()),
- );
-
- let requires_by_name: IndexMap<String, Vec<String>> = metadata
- .iter()
- .map(|(name, pv)| (name.to_lowercase(), pv.require.keys().cloned().collect()))
- .collect();
- let providers_by_name: IndexMap<String, Vec<String>> = metadata
- .keys()
- .map(|name| {
- let lower = name.to_lowercase();
- (lower.clone(), vec![lower])
- })
- .collect();
- let dev_only = classify_dev_packages(
- &resolved,
- &require,
- &require_dev,
- &requires_by_name,
- &providers_by_name,
- );
-
- assert!(!dev_only.contains("vendor/a"), "A is a production package");
- assert!(dev_only.contains("vendor/b"), "B is dev-only");
- assert!(
- !dev_only.contains("vendor/c"),
- "C is reachable from A (production)"
- );
- assert!(
- dev_only.contains("vendor/d"),
- "D is only reachable from B (dev)"
- );
- }
-
- #[test]
- fn test_classify_dev_packages_shared() {
- // Root: require={A}, require-dev={B}
- // Both A and B depend on C — C is NOT dev-only (reachable from production)
- let resolved = vec![
- ResolvedPackage {
- name: "vendor/a".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: "1.0.0.0".to_string(),
- is_dev: false,
- alias_of_normalized: None,
- },
- ResolvedPackage {
- name: "vendor/b".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: "1.0.0.0".to_string(),
- is_dev: false,
- alias_of_normalized: None,
- },
- ResolvedPackage {
- name: "vendor/c".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: "1.0.0.0".to_string(),
- is_dev: false,
- alias_of_normalized: None,
- },
- ];
-
- let mut require = BTreeMap::new();
- require.insert("vendor/a".to_string(), "^1.0".to_string());
-
- let mut require_dev = BTreeMap::new();
- require_dev.insert("vendor/b".to_string(), "^1.0".to_string());
-
- let mut metadata: IndexMap<String, PackagistVersion> = IndexMap::new();
-
- // A requires C
- let mut a_require = BTreeMap::new();
- a_require.insert("vendor/c".to_string(), "^1.0".to_string());
- metadata.insert(
- "vendor/a".to_string(),
- make_packagist_version("1.0.0", "1.0.0.0", a_require),
- );
-
- // B also requires C
- let mut b_require = BTreeMap::new();
- b_require.insert("vendor/c".to_string(), "^1.0".to_string());
- metadata.insert(
- "vendor/b".to_string(),
- make_packagist_version("1.0.0", "1.0.0.0", b_require),
- );
-
- // C has no deps
- metadata.insert(
- "vendor/c".to_string(),
- make_packagist_version("1.0.0", "1.0.0.0", BTreeMap::new()),
- );
-
- let requires_by_name: IndexMap<String, Vec<String>> = metadata
- .iter()
- .map(|(name, pv)| (name.to_lowercase(), pv.require.keys().cloned().collect()))
- .collect();
- let providers_by_name: IndexMap<String, Vec<String>> = metadata
- .keys()
- .map(|name| {
- let lower = name.to_lowercase();
- (lower.clone(), vec![lower])
- })
- .collect();
- let dev_only = classify_dev_packages(
- &resolved,
- &require,
- &require_dev,
- &requires_by_name,
- &providers_by_name,
- );
-
- assert!(!dev_only.contains("vendor/a"), "A is a production package");
- assert!(dev_only.contains("vendor/b"), "B is dev-only");
- assert!(
- !dev_only.contains("vendor/c"),
- "C is shared but reachable from production (A), so it's not dev-only"
- );
- }
-
- #[test]
- fn test_extract_platform_requirements() {
- let mut requirements = BTreeMap::new();
- requirements.insert("php".to_string(), ">=8.1".to_string());
- requirements.insert("ext-json".to_string(), "*".to_string());
- requirements.insert("ext-mbstring".to_string(), "*".to_string());
- requirements.insert("monolog/monolog".to_string(), "^3.0".to_string());
- requirements.insert("lib-pcre".to_string(), "*".to_string());
-
- let platform = extract_platform_requirements(&requirements);
- let obj = platform.as_object().unwrap();
-
- assert!(obj.contains_key("php"), "php should be in platform");
- assert!(
- obj.contains_key("ext-json"),
- "ext-json should be in platform"
- );
- assert!(
- obj.contains_key("ext-mbstring"),
- "ext-mbstring should be in platform"
- );
- assert!(
- obj.contains_key("lib-pcre"),
- "lib-pcre should be in platform"
- );
- assert!(
- !obj.contains_key("monolog/monolog"),
- "monolog/monolog should NOT be in platform"
- );
- assert_eq!(obj["php"], serde_json::Value::String(">=8.1".to_string()));
- assert_eq!(obj["ext-json"], serde_json::Value::String("*".to_string()));
- }
-
- #[test]
- fn test_extract_platform_requirements_empty() {
- let requirements = BTreeMap::new();
- let platform = extract_platform_requirements(&requirements);
- assert_eq!(platform, serde_json::json!({}));
- }
-
- #[tokio::test]
- async fn test_generate_lock_file_minimal() {
- let composer_json_content =
- r#"{"name": "test/project", "require": {"php": ">=8.1"}}"#.to_string();
- let composer_json: RawPackageData = serde_json::from_str(&composer_json_content).unwrap();
-
- let request = LockFileGenerationRequest {
- resolved_packages: vec![],
- composer_json_content: composer_json_content.clone(),
- composer_json,
- include_dev: true,
- repositories: std::sync::Arc::new(RepositorySet::with_packagist(
- crate::cache::Cache::new(std::env::temp_dir().join("mozart-test-cache"), false),
- )),
- previous_lock: None,
- lock_pinned_names: IndexSet::new(),
- };
-
- let lock = generate_lock_file(&request).await.unwrap();
-
- assert_eq!(lock.packages.len(), 0);
- assert_eq!(lock.packages_dev.as_ref().unwrap().len(), 0);
- assert_eq!(lock.minimum_stability, "stable");
- assert!(!lock.prefer_stable);
- assert!(!lock.prefer_lowest);
- assert_eq!(lock.plugin_api_version.as_deref(), Some("2.6.0"));
-
- // Verify content-hash matches
- let expected_hash = LockFile::compute_content_hash(&composer_json_content).unwrap();
- assert_eq!(lock.content_hash, expected_hash);
-
- // Verify platform requirements extracted
- let platform_obj = lock.platform.as_object().unwrap();
- assert!(platform_obj.contains_key("php"));
- assert_eq!(
- platform_obj["php"],
- serde_json::Value::String(">=8.1".to_string())
- );
- }
-
- #[test]
- fn test_lock_file_packages_sorted() {
- // Verify that packages are sorted alphabetically when assembled in generate_lock_file
- // We test this by constructing two LockedPackages and sorting them the same way
-
- let mut packages = [
- LockedPackage {
- name: "vendor/zebra".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: None,
- source: None,
- dist: None,
- require: BTreeMap::new(),
- require_dev: BTreeMap::new(),
- conflict: BTreeMap::new(),
- provide: BTreeMap::new(),
- replace: BTreeMap::new(),
- suggest: None,
- package_type: None,
- autoload: None,
- autoload_dev: None,
- license: None,
- description: None,
- homepage: None,
- keywords: None,
- authors: None,
- support: None,
- funding: None,
- time: None,
- extra_fields: BTreeMap::new(),
- },
- LockedPackage {
- name: "vendor/alpha".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: None,
- source: None,
- dist: None,
- require: BTreeMap::new(),
- require_dev: BTreeMap::new(),
- conflict: BTreeMap::new(),
- provide: BTreeMap::new(),
- replace: BTreeMap::new(),
- suggest: None,
- package_type: None,
- autoload: None,
- autoload_dev: None,
- license: None,
- description: None,
- homepage: None,
- keywords: None,
- authors: None,
- support: None,
- funding: None,
- time: None,
- extra_fields: BTreeMap::new(),
- },
- ];
-
- packages.sort_by(|a, b| a.name.cmp(&b.name));
-
- assert_eq!(packages[0].name, "vendor/alpha");
- assert_eq!(packages[1].name, "vendor/zebra");
- }
-
- #[tokio::test]
- #[ignore]
- async fn test_generate_lock_file_monolog() {
- use crate::cache::Cache;
- use crate::resolver::PlatformConfig;
- use crate::resolver::{ResolveRequest, resolve};
- use mozart_core::package::Stability;
- use std::sync::Arc;
-
- // Resolve monolog/monolog ^3.0
- let resolve_request = ResolveRequest {
- root_name: String::new(),
- root_version: None,
- require: vec![("monolog/monolog".to_string(), "^3.0".to_string())],
- require_dev: vec![],
- include_dev: false,
- minimum_stability: Stability::Stable,
- stability_flags: IndexMap::new(),
- prefer_stable: true,
- prefer_lowest: false,
- platform: PlatformConfig::new(),
- ignore_platform_reqs: false,
- ignore_platform_req_list: vec![],
- repositories: Arc::new(RepositorySet::with_packagist(Cache::new(
- std::env::temp_dir().join("mozart-test-cache"),
- false,
- ))),
- temporary_constraints: IndexMap::new(),
- raw_repositories: vec![],
- root_provide: IndexMap::new(),
- root_replace: IndexMap::new(),
- root_conflict: IndexMap::new(),
- locked_package_names: IndexSet::new(),
- locked_packages: Vec::new(),
- block_abandoned: false,
- root_branch_alias: None,
- preferred_versions: IndexMap::new(),
- block_insecure: false,
- };
-
- let resolved = resolve(&resolve_request)
- .await
- .expect("Resolution should succeed");
- assert!(!resolved.is_empty());
-
- let composer_json_content =
- r#"{"name": "test/project", "require": {"monolog/monolog": "^3.0"}}"#.to_string();
- let composer_json: RawPackageData = serde_json::from_str(&composer_json_content).unwrap();
-
- let gen_request = LockFileGenerationRequest {
- resolved_packages: resolved,
- composer_json_content: composer_json_content.clone(),
- composer_json,
- include_dev: false,
- repositories: Arc::new(RepositorySet::with_packagist(Cache::new(
- std::env::temp_dir().join("mozart-test-cache"),
- false,
- ))),
- previous_lock: None,
- lock_pinned_names: IndexSet::new(),
- };
-
- let lock = generate_lock_file(&gen_request)
- .await
- .expect("Lock file generation should succeed");
-
- // Verify monolog is in packages
- assert!(
- lock.packages.iter().any(|p| p.name == "monolog/monolog"),
- "monolog/monolog should be in packages"
- );
-
- // Verify packages are sorted alphabetically
- let names: Vec<&str> = lock.packages.iter().map(|p| p.name.as_str()).collect();
- let mut sorted_names = names.clone();
- sorted_names.sort();
- assert_eq!(
- names, sorted_names,
- "Packages should be sorted alphabetically"
- );
-
- // Verify content-hash matches
- let expected_hash = LockFile::compute_content_hash(&composer_json_content).unwrap();
- assert_eq!(lock.content_hash, expected_hash);
-
- // Verify monolog has full metadata
- let monolog = lock
- .packages
- .iter()
- .find(|p| p.name == "monolog/monolog")
- .unwrap();
- assert!(monolog.dist.is_some(), "monolog should have dist info");
- assert!(
- monolog.description.is_some(),
- "monolog should have description"
- );
- assert!(monolog.autoload.is_some(), "monolog should have autoload");
-
- println!("Generated lock file with {} packages:", lock.packages.len());
- for pkg in &lock.packages {
- println!(" {} {}", pkg.name, pkg.version);
- }
- }
-
- fn make_locked(name: &str, version: &str) -> LockedPackage {
- LockedPackage {
- name: name.to_string(),
- version: version.to_string(),
- version_normalized: None,
- source: None,
- dist: None,
- require: BTreeMap::new(),
- require_dev: BTreeMap::new(),
- conflict: BTreeMap::new(),
- provide: BTreeMap::new(),
- replace: BTreeMap::new(),
- suggest: None,
- package_type: Some("library".to_string()),
- autoload: None,
- autoload_dev: None,
- license: None,
- description: None,
- homepage: None,
- keywords: None,
- authors: None,
- support: None,
- funding: None,
- time: None,
- extra_fields: BTreeMap::new(),
- }
- }
-
- fn lock_with(packages: Vec<LockedPackage>, dev: Vec<LockedPackage>) -> LockFile {
- LockFile {
- readme: LockFile::default_readme(),
- content_hash: "x".to_string(),
- packages,
- packages_dev: Some(dev),
- aliases: vec![],
- minimum_stability: "stable".to_string(),
- stability_flags: serde_json::json!({}),
- prefer_stable: false,
- prefer_lowest: false,
- platform: serde_json::json!({}),
- platform_dev: serde_json::json!({}),
- plugin_api_version: Some("2.6.0".to_string()),
- }
- }
-
- fn root_with_require(
- require: &[(&str, &str)],
- require_dev: &[(&str, &str)],
- ) -> mozart_core::package::RawPackageData {
- let mut root = mozart_core::package::RawPackageData::new("__root__".to_string());
- for (k, v) in require {
- root.require.insert((*k).to_string(), (*v).to_string());
- }
- for (k, v) in require_dev {
- root.require_dev.insert((*k).to_string(), (*v).to_string());
- }
- root
- }
-
- #[test]
- fn missing_requirement_info_empty_when_satisfied() {
- let lock = lock_with(vec![make_locked("a/a", "1.0.0")], vec![]);
- let root = root_with_require(&[("a/a", "^1.0")], &[]);
- assert!(lock.get_missing_requirement_info(&root, true).is_empty());
- }
-
- #[test]
- fn missing_requirement_info_reports_missing_package() {
- let lock = lock_with(vec![], vec![]);
- let root = root_with_require(&[("a/a", "^1.0")], &[]);
- let info = lock.get_missing_requirement_info(&root, true);
- assert_eq!(
- info[0],
- "- Required package \"a/a\" is not present in the lock file."
- );
- assert!(info.iter().any(|m| m.contains("merge conflicts")));
- }
-
- #[test]
- fn missing_requirement_info_reports_unsatisfied_constraint() {
- let lock = lock_with(vec![make_locked("some/dep", "dev-foo")], vec![]);
- let root = root_with_require(&[("some/dep", "dev-main")], &[]);
- let info = lock.get_missing_requirement_info(&root, true);
- assert_eq!(
- info[0],
- "- Required package \"some/dep\" is in the lock file as \"dev-foo\" but that does not satisfy your constraint \"dev-main\"."
- );
- }
-
- #[test]
- fn missing_requirement_info_skips_platform_packages() {
- let lock = lock_with(vec![], vec![]);
- let root = root_with_require(&[("php", "^8.0"), ("ext-json", "*")], &[]);
- assert!(lock.get_missing_requirement_info(&root, true).is_empty());
- }
-
- #[test]
- fn missing_requirement_info_skips_self_version() {
- let lock = lock_with(vec![], vec![]);
- let root = root_with_require(&[("a/a", "self.version")], &[]);
- assert!(lock.get_missing_requirement_info(&root, true).is_empty());
- }
-
- #[test]
- fn missing_requirement_info_dev_pool_includes_packages_dev() {
- // require-dev "a/a" should be satisfied by an entry in packages-dev.
- let lock = lock_with(vec![], vec![make_locked("a/a", "1.0.0")]);
- let root = root_with_require(&[], &[("a/a", "^1.0")]);
- assert!(lock.get_missing_requirement_info(&root, true).is_empty());
- }
-
- #[test]
- fn missing_requirement_info_skips_dev_when_include_dev_false() {
- // require-dev errors must NOT appear when include_dev is false (no_dev).
- let lock = lock_with(vec![], vec![]);
- let root = root_with_require(&[], &[("a/a", "^1.0")]);
- assert!(lock.get_missing_requirement_info(&root, false).is_empty());
- }
-
- #[test]
- fn missing_requirement_info_require_pool_excludes_packages_dev() {
- // A regular require should NOT be satisfied by an entry that lives only
- // in packages-dev.
- let lock = lock_with(vec![], vec![make_locked("a/a", "1.0.0")]);
- let root = root_with_require(&[("a/a", "^1.0")], &[]);
- let info = lock.get_missing_requirement_info(&root, true);
- assert_eq!(
- info[0],
- "- Required package \"a/a\" is not present in the lock file."
- );
- }
-
- #[test]
- fn missing_requirement_info_reports_multiple_problems() {
- let lock = lock_with(vec![make_locked("some/dep", "dev-foo")], vec![]);
- let root = root_with_require(&[("some/dep", "dev-main"), ("some/dep2", "dev-main")], &[]);
- let info = lock.get_missing_requirement_info(&root, true);
- assert!(
- info.iter()
- .any(|m| m.contains("some/dep") && m.contains("dev-foo") && m.contains("dev-main"))
- );
- assert!(
- info.iter()
- .any(|m| m == "- Required package \"some/dep2\" is not present in the lock file.")
- );
- }
-
- #[test]
- fn missing_requirement_info_uses_dev_description_label() {
- let lock = lock_with(vec![], vec![]);
- let root = root_with_require(&[], &[("a/a", "^1.0")]);
- let info = lock.get_missing_requirement_info(&root, true);
- assert!(info[0].contains("Required (in require-dev) package \"a/a\""));
- }
-}