diff options
Diffstat (limited to 'crates/mozart-core/src/repository/lockfile.rs')
| -rw-r--r-- | crates/mozart-core/src/repository/lockfile.rs | 2040 |
1 files changed, 2040 insertions, 0 deletions
diff --git a/crates/mozart-core/src/repository/lockfile.rs b/crates/mozart-core/src/repository/lockfile.rs new file mode 100644 index 0000000..4c41bbb --- /dev/null +++ b/crates/mozart-core/src/repository/lockfile.rs @@ -0,0 +1,2040 @@ +use super::packagist::{PackagistDist, PackagistSource, PackagistVersion}; +use super::repository::RepositorySet; +use super::resolver::ResolvedPackage; +use crate::installer::HasSuggests; +use crate::package::{RawPackageData, to_json_pretty}; +use indexmap::IndexMap; +use indexmap::IndexSet; +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: &crate::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) = super::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 crate::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> { + super::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> { + super::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 = [super::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(super::super::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(super::super::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( + super::super::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 super::super::super::package::Stability; + use super::super::cache::Cache; + use super::super::resolver::PlatformConfig; + use super::super::resolver::{ResolveRequest, resolve}; + 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)], + ) -> crate::package::RawPackageData { + let mut root = crate::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\"")); + } +} |
