aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart/src/lockfile.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/mozart/src/lockfile.rs')
-rw-r--r--crates/mozart/src/lockfile.rs1088
1 files changed, 0 insertions, 1088 deletions
diff --git a/crates/mozart/src/lockfile.rs b/crates/mozart/src/lockfile.rs
deleted file mode 100644
index 3a13778..0000000
--- a/crates/mozart/src/lockfile.rs
+++ /dev/null
@@ -1,1088 +0,0 @@
-use mozart_registry::cache::Cache;
-use mozart_core::package::{RawPackageData, to_json_pretty};
-use mozart_registry::packagist::{self, PackagistDist, PackagistSource, PackagistVersion};
-use mozart_registry::resolver::ResolvedPackage;
-use serde::{Deserialize, Serialize};
-use std::collections::{BTreeMap, HashMap, HashSet, 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")]
- pub readme: Vec<String>,
-
- #[serde(rename = "content-hash")]
- 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(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>,
-}
-
-#[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))
- }
-}
-
-// ─────────────────────────────────────────────────────────────────────────────
-// Lock file generation
-// ─────────────────────────────────────────────────────────────────────────────
-
-/// 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,
- /// Optional repo cache for Packagist API calls made during generation.
- pub repo_cache: Option<Cache>,
-}
-
-/// 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(),
- }
-}
-
-/// 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()),
- );
- }
-
- 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(),
- 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).
-///
-/// `package_metadata` must be pre-fetched full `PackagistVersion` data for each resolved package.
-fn classify_dev_packages(
- resolved: &[ResolvedPackage],
- require: &BTreeMap<String, String>,
- _require_dev: &BTreeMap<String, String>,
- package_metadata: &HashMap<String, PackagistVersion>,
-) -> HashSet<String> {
- // Build set of all resolved package names for quick lookup
- let resolved_names: HashSet<&str> = resolved.iter().map(|p| p.name.as_str()).collect();
-
- // BFS from non-dev root dependencies through each package's `require` map.
- // All reachable packages are production packages.
- let mut production: HashSet<String> = HashSet::new();
- let mut queue: VecDeque<String> = VecDeque::new();
-
- // Seed queue with non-dev root dependencies that are actual packages (not platform)
- for name in require.keys() {
- let name_lower = name.to_lowercase();
- // Skip platform packages (php, ext-*, lib-*, etc.)
- if is_platform_name(&name_lower) {
- continue;
- }
- if resolved_names.contains(name_lower.as_str()) && production.insert(name_lower.clone()) {
- queue.push_back(name_lower);
- }
- }
-
- // BFS: walk transitive `require` deps of each production package
- while let Some(pkg_name) = queue.pop_front() {
- if let Some(pv) = package_metadata.get(&pkg_name) {
- for dep_name in pv.require.keys() {
- let dep_lower = dep_name.to_lowercase();
- if is_platform_name(&dep_lower) {
- continue;
- }
- if resolved_names.contains(dep_lower.as_str())
- && production.insert(dep_lower.clone())
- {
- queue.push_back(dep_lower);
- }
- }
- }
- }
-
- // Any resolved package not in `production` is dev-only
- resolved
- .iter()
- .filter(|p| !production.contains(&p.name))
- .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 fn generate_lock_file(request: &LockFileGenerationRequest) -> anyhow::Result<LockFile> {
- // 1. Fetch full metadata for all resolved packages
- let mut package_metadata: HashMap<String, PackagistVersion> = HashMap::new();
- for pkg in &request.resolved_packages {
- let versions = packagist::fetch_package_versions(&pkg.name, request.repo_cache.as_ref())?;
- // Find the exact version matching pkg.version_normalized
- let matching = versions
- .into_iter()
- .find(|v| v.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);
- }
-
- // 2. Classify dev vs non-dev packages
- let dev_only = classify_dev_packages(
- &request.resolved_packages,
- &request.composer_json.require,
- &request.composer_json.require_dev,
- &package_metadata,
- );
-
- // 3. Build LockedPackage lists
- let mut packages: Vec<LockedPackage> = Vec::new();
- let mut packages_dev: Vec<LockedPackage> = Vec::new();
- for pkg in &request.resolved_packages {
- let pv = &package_metadata[&pkg.name];
- let locked = packagist_version_to_locked_package(&pkg.name, pv);
- 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. Compute content-hash
- let content_hash = LockFile::compute_content_hash(&request.composer_json_content)?;
-
- // 6. Extract platform requirements
- let platform = extract_platform_requirements(&request.composer_json.require);
- let platform_dev = extract_platform_requirements(&request.composer_json.require_dev);
-
- // 7. 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);
-
- // 8. Assemble LockFile
- Ok(LockFile {
- readme: LockFile::default_readme(),
- content_hash,
- packages,
- packages_dev: if request.include_dev {
- Some(packages_dev)
- } else {
- Some(vec![])
- },
- aliases: vec![],
- 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(),
- 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"));
- }
-
- // ──────────── Lock file generation tests ────────────
-
- 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(mozart_registry::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(mozart_registry::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()),
- }
- }
-
- #[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_ref().map(|k| k.as_slice()),
- 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,
- };
-
- 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,
- },
- ResolvedPackage {
- name: "vendor/b".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: "1.0.0.0".to_string(),
- is_dev: false,
- },
- ResolvedPackage {
- name: "vendor/c".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: "1.0.0.0".to_string(),
- is_dev: false,
- },
- ResolvedPackage {
- name: "vendor/d".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: "1.0.0.0".to_string(),
- is_dev: false,
- },
- ];
-
- 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: HashMap<String, PackagistVersion> = HashMap::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 dev_only = classify_dev_packages(&resolved, &require, &require_dev, &metadata);
-
- 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,
- },
- ResolvedPackage {
- name: "vendor/b".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: "1.0.0.0".to_string(),
- is_dev: false,
- },
- ResolvedPackage {
- name: "vendor/c".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: "1.0.0.0".to_string(),
- is_dev: false,
- },
- ];
-
- 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: HashMap<String, PackagistVersion> = HashMap::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 dev_only = classify_dev_packages(&resolved, &require, &require_dev, &metadata);
-
- 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!({}));
- }
-
- #[test]
- 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,
- repo_cache: None,
- };
-
- let lock = generate_lock_file(&request).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 = vec![
- 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(),
- 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(),
- 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");
- }
-
- #[test]
- #[ignore]
- fn test_generate_lock_file_monolog() {
- use mozart_core::package::Stability;
- use mozart_registry::resolver::PlatformConfig;
- use mozart_registry::resolver::{ResolveRequest, resolve};
-
- // Resolve monolog/monolog ^3.0
- let resolve_request = ResolveRequest {
- require: vec![("monolog/monolog".to_string(), "^3.0".to_string())],
- require_dev: vec![],
- include_dev: false,
- minimum_stability: Stability::Stable,
- stability_flags: HashMap::new(),
- prefer_stable: true,
- prefer_lowest: false,
- platform: PlatformConfig::new(),
- ignore_platform_reqs: false,
- ignore_platform_req_list: vec![],
- repo_cache: None,
- };
-
- let resolved = resolve(&resolve_request).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,
- repo_cache: None,
- };
-
- let lock = generate_lock_file(&gen_request).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);
- }
- }
-}