diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-21 10:46:26 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-21 10:46:26 +0900 |
| commit | a03ad0152ec28cdd1cc05f96a9823807dbb2b818 (patch) | |
| tree | 83b4e4aadb98bf53c89237b58845ba1f41fbef68 /crates/mozart | |
| parent | 0b5d333083f1317391338d3aa67b1290e93922cc (diff) | |
| download | php-mozart-a03ad0152ec28cdd1cc05f96a9823807dbb2b818.tar.gz php-mozart-a03ad0152ec28cdd1cc05f96a9823807dbb2b818.tar.zst php-mozart-a03ad0152ec28cdd1cc05f96a9823807dbb2b818.zip | |
feat(core): add version constraint, lockfile, installed registry, and downloader modules
Phase 1 infrastructure for the install command:
- constraint: Composer-compatible version parsing and constraint matching
(caret, tilde, wildcard, hyphen range, OR/AND combinators)
- lockfile: composer.lock read/write with content-hash computation
- installed: vendor/composer/installed.json registry (Composer 2.x format)
- downloader: dist archive download with SHA-1 verification and
zip/tar.gz extraction with top-level directory stripping
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart')
| -rw-r--r-- | crates/mozart/Cargo.toml | 5 | ||||
| -rw-r--r-- | crates/mozart/src/constraint.rs | 856 | ||||
| -rw-r--r-- | crates/mozart/src/downloader.rs | 380 | ||||
| -rw-r--r-- | crates/mozart/src/installed.rs | 229 | ||||
| -rw-r--r-- | crates/mozart/src/lib.rs | 4 | ||||
| -rw-r--r-- | crates/mozart/src/lockfile.rs | 344 |
6 files changed, 1818 insertions, 0 deletions
diff --git a/crates/mozart/Cargo.toml b/crates/mozart/Cargo.toml index f4d160b..9432c4a 100644 --- a/crates/mozart/Cargo.toml +++ b/crates/mozart/Cargo.toml @@ -8,11 +8,16 @@ anyhow = "1.0.101" clap = { version = "4.5.57", features = ["derive"] } colored = "3.1.1" dialoguer = "0.12.0" +flate2 = "1" +md5 = "0.7" regex = "1.12.3" reqwest = { version = "0.13.2", features = ["blocking", "json"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" +sha1 = "0.10" +tar = "0.4" tokio = { version = "1.49.0", features = ["full"] } +zip = { version = "2", default-features = false, features = ["deflate"] } [dev-dependencies] tempfile = "3.25.0" diff --git a/crates/mozart/src/constraint.rs b/crates/mozart/src/constraint.rs new file mode 100644 index 0000000..ff9b14e --- /dev/null +++ b/crates/mozart/src/constraint.rs @@ -0,0 +1,856 @@ +use std::cmp::Ordering; + +/// A parsed Composer version (always 4 numeric segments + optional stability suffix). +/// Composer normalizes all versions to `major.minor.patch.build[-stability[N]]`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Version { + pub major: u64, + pub minor: u64, + pub patch: u64, + pub build: u64, + /// None = stable, Some("alpha1"), Some("beta2"), Some("RC1"), Some("dev") + pub pre_release: Option<String>, + /// true for "dev-master", "dev-feature/foo", etc. + pub is_dev_branch: bool, + /// The original branch name for dev branches (e.g. "master", "feature/foo") + pub dev_branch_name: Option<String>, +} + +/// Stability rank for ordering (lower = more stable). +fn stability_rank(pre: &str) -> u8 { + let lower = pre.to_lowercase(); + if lower.starts_with("dev") { + 50 + } else if lower.starts_with("alpha") || lower.starts_with("a") { + 40 + } else if lower.starts_with("beta") || lower.starts_with("b") { + 30 + } else if lower.starts_with("rc") { + 20 + } else if lower.starts_with("patch") || lower.starts_with("pl") || lower == "p" { + 5 + } else { + 0 + } +} + +/// Extract numeric suffix from a pre-release string like "alpha1" → 1, "beta" → 0 +fn pre_release_number(pre: &str) -> u64 { + let digits: String = pre.chars().skip_while(|c| c.is_alphabetic()).collect(); + digits.parse().unwrap_or(0) +} + +impl PartialOrd for Version { + fn partial_cmp(&self, other: &Self) -> Option<Ordering> { + Some(self.cmp(other)) + } +} + +impl Ord for Version { + fn cmp(&self, other: &Self) -> Ordering { + // Dev branches are always lowest + match (self.is_dev_branch, other.is_dev_branch) { + (true, true) => { + // Compare branch names + return self.dev_branch_name.cmp(&other.dev_branch_name); + } + (true, false) => return Ordering::Less, + (false, true) => return Ordering::Greater, + (false, false) => {} + } + + // Compare numeric segments + let num_cmp = (self.major, self.minor, self.patch, self.build).cmp(&( + other.major, + other.minor, + other.patch, + other.build, + )); + if num_cmp != Ordering::Equal { + return num_cmp; + } + + // Compare pre-release: None (stable) > any pre-release + match (&self.pre_release, &other.pre_release) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Greater, + (Some(_), None) => Ordering::Less, + (Some(a), Some(b)) => { + let rank_a = stability_rank(a); + let rank_b = stability_rank(b); + match rank_a.cmp(&rank_b) { + Ordering::Equal => { + // Same stability: compare numeric suffix + pre_release_number(a).cmp(&pre_release_number(b)) + } + // Lower rank = more stable = greater version + Ordering::Less => Ordering::Greater, + Ordering::Greater => Ordering::Less, + } + } + } + } +} + +impl Version { + /// Parse a version string into a `Version` struct using Composer normalization rules. + pub fn parse(input: &str) -> Result<Version, String> { + let s = input.trim(); + + // Strip inline alias: "1.0.x-dev as 1.0.0" → "1.0.x-dev" + let s = if let Some(pos) = s.find(" as ") { + &s[..pos] + } else { + s + }; + + // Strip stability flag: "@dev", "@alpha", "@beta", "@RC", "@stable" + let s = if let Some(pos) = s.rfind('@') { + let after = &s[pos + 1..]; + let known = ["dev", "alpha", "beta", "rc", "stable"]; + if known.iter().any(|k| after.eq_ignore_ascii_case(k)) { + &s[..pos] + } else { + s + } + } else { + s + }; + + // Handle dev-* prefix branches + if s.to_lowercase().starts_with("dev-") { + let branch = &s[4..]; + return Ok(Version { + major: 0, + minor: 0, + patch: 0, + build: 0, + pre_release: Some("dev".to_string()), + is_dev_branch: true, + dev_branch_name: Some(branch.to_string()), + }); + } + + // Handle *-dev suffix (e.g., "2.1.x-dev" or "2.x-dev") + let s_lower = s.to_lowercase(); + if s_lower.ends_with("-dev") || s_lower.ends_with(".x-dev") { + let base = if s_lower.ends_with("-dev") { + &s[..s.len() - 4] + } else { + s + }; + // Replace any trailing .x with nothing, parse numeric parts + let base = base.trim_end_matches(".x").trim_end_matches("-dev"); + let parts: Vec<&str> = base.split('.').collect(); + let major = parts.first().and_then(|p| p.parse().ok()).unwrap_or(0); + let minor = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); + return Ok(Version { + major, + minor, + patch: 9999999, + build: 9999999, + pre_release: Some("dev".to_string()), + is_dev_branch: true, + dev_branch_name: None, + }); + } + + // Strip leading v/V + let s = s + .strip_prefix('v') + .or_else(|| s.strip_prefix('V')) + .unwrap_or(s); + + // Strip build metadata after + + let s = s.split('+').next().unwrap_or(s); + + // Parse the version using regex-like approach + parse_classical_version(s) + } + + /// Create a "dev boundary" version for constraint matching (major.minor.patch.build with dev pre-release). + pub fn dev_boundary(major: u64, minor: u64, patch: u64, build: u64) -> Version { + Version { + major, + minor, + patch, + build, + pre_release: Some("dev".to_string()), + is_dev_branch: false, + dev_branch_name: None, + } + } +} + +fn parse_classical_version(s: &str) -> Result<Version, String> { + // Split on '-' to separate version from pre-release + let (version_part, pre_part) = if let Some(pos) = s.find('-') { + (&s[..pos], Some(&s[pos + 1..])) + } else { + (s, None) + }; + + let segments: Vec<&str> = version_part.split('.').collect(); + if segments.is_empty() || segments[0].is_empty() { + return Err(format!("Invalid version: {s}")); + } + + let major: u64 = segments[0] + .parse() + .map_err(|_| format!("Invalid major version segment: {}", segments[0]))?; + let minor: u64 = segments.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); + let patch: u64 = segments + .get(2) + .and_then(|p| { + // strip trailing .x + let p = p.trim_end_matches('x').trim_end_matches('.'); + if p.is_empty() { + Some(0) + } else { + p.parse().ok() + } + }) + .unwrap_or(0); + let build: u64 = segments.get(3).and_then(|p| p.parse().ok()).unwrap_or(0); + + let pre_release = pre_part.map(normalize_pre_release); + + Ok(Version { + major, + minor, + patch, + build, + pre_release, + is_dev_branch: false, + dev_branch_name: None, + }) +} + +fn normalize_pre_release(s: &str) -> String { + // Normalize aliases: b→beta, a→alpha, rc→RC, p/pl/patch→patch + let lower = s.to_lowercase(); + // Strip leading non-alpha characters (dots, underscores, dashes used as separators) + let normalized = lower + .trim_start_matches(|c: char| !c.is_alphabetic()) + .to_string(); + + // Extract the alphabetic prefix (stability name) + let alpha: String = normalized.chars().take_while(|c| c.is_alphabetic()).collect(); + // Extract only digits from the rest (strip separators like dots) + let num: String = normalized + .chars() + .skip_while(|c| c.is_alphabetic()) + .filter(|c| c.is_ascii_digit()) + .collect(); + + if alpha.starts_with("beta") || alpha == "b" { + format!("beta{num}") + } else if alpha.starts_with("alpha") || alpha == "a" { + format!("alpha{num}") + } else if alpha == "rc" { + format!("RC{num}") + } else if alpha == "patch" || alpha == "pl" || alpha == "p" { + format!("patch{num}") + } else if alpha == "dev" { + "dev".to_string() + } else { + s.to_string() + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Constraint types +// ───────────────────────────────────────────────────────────────────────────── + +/// A single atomic constraint. +#[derive(Debug, Clone)] +pub enum Constraint { + /// Exact version match + Exact(Version), + /// Greater than: `> 1.2.3` + GreaterThan(Version), + /// Greater than or equal: `>= 1.2.3` + GreaterThanOrEqual(Version), + /// Less than: `< 1.2.3` + LessThan(Version), + /// Less than or equal: `<= 1.2.3` + LessThanOrEqual(Version), + /// Not equal: `!= 1.2.3` + NotEqual(Version), + /// Matches any version + Any, +} + +impl Constraint { + pub fn matches(&self, v: &Version) -> bool { + match self { + Constraint::Exact(target) => v == target, + Constraint::GreaterThan(target) => v > target, + Constraint::GreaterThanOrEqual(target) => v >= target, + Constraint::LessThan(target) => v < target, + Constraint::LessThanOrEqual(target) => v <= target, + Constraint::NotEqual(target) => v != target, + Constraint::Any => true, + } + } +} + +/// A compound constraint with AND/OR combinators. +#[derive(Debug, Clone)] +pub enum VersionConstraint { + /// Single atomic constraint + Single(Constraint), + /// All must match (AND — space/comma separated) + And(Vec<VersionConstraint>), + /// At least one must match (OR — `||` separated) + Or(Vec<VersionConstraint>), +} + +impl VersionConstraint { + pub fn matches(&self, version: &Version) -> bool { + match self { + VersionConstraint::Single(c) => c.matches(version), + VersionConstraint::And(cs) => cs.iter().all(|c| c.matches(version)), + VersionConstraint::Or(cs) => cs.iter().any(|c| c.matches(version)), + } + } + + /// Parse a constraint string like `^1.2`, `>=1.0 <2.0`, `^1.0 || ^2.0`. + pub fn parse(input: &str) -> Result<VersionConstraint, String> { + let input = input.trim(); + + // Split on || (OR) + let or_parts: Vec<&str> = split_or(input); + + if or_parts.len() > 1 { + let constraints: Result<Vec<_>, _> = + or_parts.iter().map(|p| parse_and_group(p.trim())).collect(); + let mut cs = constraints?; + // Flatten single-element groups + if cs.len() == 1 { + return Ok(cs.remove(0)); + } + return Ok(VersionConstraint::Or(cs)); + } + + parse_and_group(input) + } +} + +/// Split on `||` (pipe-OR), but not inside version strings. +fn split_or(s: &str) -> Vec<&str> { + let mut parts = Vec::new(); + let mut start = 0; + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if i + 1 < bytes.len() && bytes[i] == b'|' && bytes[i + 1] == b'|' { + parts.push(s[start..i].trim()); + i += 2; + start = i; + } else { + i += 1; + } + } + parts.push(s[start..].trim()); + parts +} + +/// Parse an AND group (space or comma separated constraints). +fn parse_and_group(s: &str) -> Result<VersionConstraint, String> { + // Detect hyphen range first: "1.0 - 2.0" where both sides start with a digit + if let Some(idx) = s.find(" - ") { + let before = s[..idx].trim(); + let after = s[idx + 3..].trim(); + let before_is_version = before + .chars() + .next() + .is_some_and(|c| c.is_ascii_digit() || c == 'v' || c == 'V'); + let after_is_version = after + .chars() + .next() + .is_some_and(|c| c.is_ascii_digit() || c == 'v' || c == 'V'); + if before_is_version && after_is_version { + return parse_hyphen_range(s); + } + } + + let parts = split_and(s); + + if parts.is_empty() { + return Err("Empty constraint".to_string()); + } + + let constraints: Result<Vec<_>, _> = parts.iter().map(|p| parse_single(p.trim())).collect(); + let mut cs = constraints?; + + if cs.len() == 1 { + return Ok(cs.remove(0)); + } + + // Flatten nested And + let flat: Vec<VersionConstraint> = cs + .into_iter() + .flat_map(|c| match c { + VersionConstraint::And(inner) => inner, + other => vec![other], + }) + .collect(); + + Ok(VersionConstraint::And(flat)) +} + +/// Split on spaces or commas (AND separator), respecting that version strings +/// can contain `-` (pre-release). +fn split_and(s: &str) -> Vec<String> { + // A constraint "part" is separated by space or comma when not part of + // operator prefixes like `>=`, `<=`, `!=`, or version like `1.2.3-beta`. + // Strategy: tokenize by whitespace/comma, then re-join multi-token ranges. + let tokens: Vec<&str> = s.split([' ', ',']).filter(|t| !t.is_empty()).collect(); + + let mut parts: Vec<String> = Vec::new(); + let mut current = String::new(); + + for token in tokens { + if current.is_empty() { + current = token.to_string(); + } else { + // If the token starts with an operator or a digit/^ ~/>, it's a new constraint + let starts_new = token.starts_with(|c: char| { + matches!(c, '>' | '<' | '!' | '=' | '^' | '~' | '*') || c.is_ascii_digit() + }); + if starts_new { + parts.push(current.trim().to_string()); + current = token.to_string(); + } else { + // Continuation (e.g. part of a version string with spaces) + current.push(' '); + current.push_str(token); + } + } + } + if !current.is_empty() { + parts.push(current.trim().to_string()); + } + + parts +} + +/// Parse a single constraint part. +fn parse_single(s: &str) -> Result<VersionConstraint, String> { + if s == "*" || s.is_empty() { + return Ok(VersionConstraint::Single(Constraint::Any)); + } + + // Caret: ^1.2.3 + if let Some(rest) = s.strip_prefix('^') { + return parse_caret(rest); + } + + // Tilde: ~1.2.3 + if let Some(rest) = s.strip_prefix('~') { + return parse_tilde(rest); + } + + // Hyphen range: "1.0 - 2.0" — handled at and-group level, but check here too + if s.contains(" - ") { + return parse_hyphen_range(s); + } + + // Comparison operators + if let Some(rest) = s.strip_prefix(">=") { + let v = Version::parse(rest.trim())?; + return Ok(VersionConstraint::Single(Constraint::GreaterThanOrEqual(v))); + } + if let Some(rest) = s.strip_prefix("<=") { + let v = Version::parse(rest.trim())?; + return Ok(VersionConstraint::Single(Constraint::LessThanOrEqual(v))); + } + if let Some(rest) = s.strip_prefix("!=") { + let v = Version::parse(rest.trim())?; + return Ok(VersionConstraint::Single(Constraint::NotEqual(v))); + } + if let Some(rest) = s.strip_prefix('>') { + let v = Version::parse(rest.trim())?; + return Ok(VersionConstraint::Single(Constraint::GreaterThan(v))); + } + if let Some(rest) = s.strip_prefix('<') { + let v = Version::parse(rest.trim())?; + return Ok(VersionConstraint::Single(Constraint::LessThan(v))); + } + if let Some(rest) = s.strip_prefix('=') { + let v = Version::parse(rest.trim())?; + return Ok(VersionConstraint::Single(Constraint::Exact(v))); + } + + // Wildcard: 1.2.* or 1.* + if s.ends_with(".*") || s.ends_with(".*.*") || s == "*" { + return parse_wildcard(s); + } + + // Exact version + let v = Version::parse(s)?; + Ok(VersionConstraint::Single(Constraint::Exact(v))) +} + +/// Parse `^major.minor.patch` caret constraint. +/// First non-zero segment is the "locked" boundary. +fn parse_caret(s: &str) -> Result<VersionConstraint, String> { + let parts: Vec<&str> = s.split('.').collect(); + let major: u64 = parts.first().and_then(|p| p.parse().ok()).unwrap_or(0); + let minor: u64 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); + let patch: u64 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0); + let build: u64 = parts.get(3).and_then(|p| p.parse().ok()).unwrap_or(0); + + let lower = Version::dev_boundary(major, minor, patch, build); + + // Determine upper bound based on first non-zero segment + let upper = if major > 0 { + Version::dev_boundary(major + 1, 0, 0, 0) + } else if minor > 0 { + Version::dev_boundary(0, minor + 1, 0, 0) + } else if patch > 0 { + Version::dev_boundary(0, 0, patch + 1, 0) + } else { + Version::dev_boundary(0, 0, 1, 0) + }; + + Ok(VersionConstraint::And(vec![ + VersionConstraint::Single(Constraint::GreaterThanOrEqual(lower)), + VersionConstraint::Single(Constraint::LessThan(upper)), + ])) +} + +/// Parse `~major.minor.patch` tilde constraint. +fn parse_tilde(s: &str) -> Result<VersionConstraint, String> { + let parts: Vec<&str> = s.split('.').collect(); + let major: u64 = parts.first().and_then(|p| p.parse().ok()).unwrap_or(0); + let minor: u64 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); + let patch: u64 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0); + let build: u64 = parts.get(3).and_then(|p| p.parse().ok()).unwrap_or(0); + + let lower = Version::dev_boundary(major, minor, patch, build); + + // ~major.minor.patch → >=major.minor.patch <major.(minor+1).0 + // ~major.minor → >=major.minor.0 <(major+1).0.0 + // ~major → >=major.0.0 <(major+1).0.0 + let upper = if parts.len() >= 3 { + Version::dev_boundary(major, minor + 1, 0, 0) + } else { + Version::dev_boundary(major + 1, 0, 0, 0) + }; + + Ok(VersionConstraint::And(vec![ + VersionConstraint::Single(Constraint::GreaterThanOrEqual(lower)), + VersionConstraint::Single(Constraint::LessThan(upper)), + ])) +} + +/// Parse `1.2.*` wildcard constraint. +fn parse_wildcard(s: &str) -> Result<VersionConstraint, String> { + if s == "*" { + return Ok(VersionConstraint::Single(Constraint::Any)); + } + + // Strip trailing .* + let base = s.trim_end_matches(".*"); + if base.is_empty() { + return Ok(VersionConstraint::Single(Constraint::Any)); + } + + let parts: Vec<&str> = base.split('.').collect(); + let major: u64 = parts.first().and_then(|p| p.parse().ok()).unwrap_or(0); + let minor: u64 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); + + let (lower, upper) = if parts.len() == 1 { + ( + Version::dev_boundary(major, 0, 0, 0), + Version::dev_boundary(major + 1, 0, 0, 0), + ) + } else { + ( + Version::dev_boundary(major, minor, 0, 0), + Version::dev_boundary(major, minor + 1, 0, 0), + ) + }; + + Ok(VersionConstraint::And(vec![ + VersionConstraint::Single(Constraint::GreaterThanOrEqual(lower)), + VersionConstraint::Single(Constraint::LessThan(upper)), + ])) +} + +/// Parse `1.0 - 2.0` hyphen range. +fn parse_hyphen_range(s: &str) -> Result<VersionConstraint, String> { + let parts: Vec<&str> = s.splitn(2, " - ").collect(); + if parts.len() != 2 { + return Err(format!("Invalid hyphen range: {s}")); + } + + let lower_v = Version::parse(parts[0].trim())?; + let upper_v = Version::parse(parts[1].trim())?; + + Ok(VersionConstraint::And(vec![ + VersionConstraint::Single(Constraint::GreaterThanOrEqual(lower_v)), + VersionConstraint::Single(Constraint::LessThanOrEqual(upper_v)), + ])) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ──────────── Version parsing ──────────── + + #[test] + fn test_parse_simple() { + let v = Version::parse("1.2.3").unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 2); + assert_eq!(v.patch, 3); + assert_eq!(v.build, 0); + assert_eq!(v.pre_release, None); + assert!(!v.is_dev_branch); + } + + #[test] + fn test_parse_with_v_prefix() { + let v = Version::parse("v1.2").unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 2); + assert_eq!(v.patch, 0); + assert_eq!(v.build, 0); + assert_eq!(v.pre_release, None); + } + + #[test] + fn test_parse_four_segments() { + let v = Version::parse("1.2.3.4").unwrap(); + assert_eq!((v.major, v.minor, v.patch, v.build), (1, 2, 3, 4)); + } + + #[test] + fn test_parse_beta() { + let v = Version::parse("1.0.0-beta.1").unwrap(); + assert_eq!(v.major, 1); + // "beta.1" normalizes to "beta1" (dot is stripped) + assert_eq!(v.pre_release, Some("beta1".to_string())); + } + + #[test] + fn test_parse_beta1() { + let v = Version::parse("1.0.0-beta1").unwrap(); + assert_eq!(v.pre_release, Some("beta1".to_string())); + } + + #[test] + fn test_parse_rc() { + let v = Version::parse("1.0.0-RC1").unwrap(); + assert_eq!(v.pre_release, Some("RC1".to_string())); + } + + #[test] + fn test_parse_alpha() { + let v = Version::parse("2.0.0-alpha3").unwrap(); + assert_eq!(v.pre_release, Some("alpha3".to_string())); + } + + #[test] + fn test_parse_dev_master() { + let v = Version::parse("dev-master").unwrap(); + assert!(v.is_dev_branch); + assert_eq!(v.dev_branch_name, Some("master".to_string())); + assert_eq!(v.pre_release, Some("dev".to_string())); + } + + #[test] + fn test_parse_dev_feature() { + let v = Version::parse("dev-feature/foo").unwrap(); + assert!(v.is_dev_branch); + assert_eq!(v.dev_branch_name, Some("feature/foo".to_string())); + } + + #[test] + fn test_parse_x_dev() { + let v = Version::parse("2.1.x-dev").unwrap(); + assert!(v.is_dev_branch); + assert_eq!(v.major, 2); + assert_eq!(v.minor, 1); + assert_eq!(v.patch, 9999999); + assert_eq!(v.build, 9999999); + } + + #[test] + fn test_parse_strip_at_stability() { + let v = Version::parse("1.2.3@stable").unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 2); + assert_eq!(v.patch, 3); + assert_eq!(v.pre_release, None); + } + + #[test] + fn test_parse_inline_alias() { + let v = Version::parse("1.0.x-dev as 1.0.0").unwrap(); + // Takes left side: 1.0.x-dev + assert!(v.is_dev_branch); + } + + // ──────────── Version ordering ──────────── + + #[test] + fn test_ordering_major() { + let a = Version::parse("2.0.0").unwrap(); + let b = Version::parse("1.0.0").unwrap(); + assert!(a > b); + } + + #[test] + fn test_ordering_minor() { + let a = Version::parse("1.2.0").unwrap(); + let b = Version::parse("1.1.0").unwrap(); + assert!(a > b); + } + + #[test] + fn test_ordering_stable_gt_rc() { + let stable = Version::parse("1.0.0").unwrap(); + let rc = Version::parse("1.0.0-RC1").unwrap(); + assert!(stable > rc); + } + + #[test] + fn test_ordering_rc_gt_beta() { + let rc = Version::parse("1.0.0-RC1").unwrap(); + let beta = Version::parse("1.0.0-beta1").unwrap(); + assert!(rc > beta); + } + + #[test] + fn test_ordering_beta_gt_alpha() { + let beta = Version::parse("1.0.0-beta1").unwrap(); + let alpha = Version::parse("1.0.0-alpha1").unwrap(); + assert!(beta > alpha); + } + + #[test] + fn test_ordering_alpha_gt_dev_branch() { + let alpha = Version::parse("1.0.0-alpha1").unwrap(); + let dev = Version::parse("dev-master").unwrap(); + assert!(alpha > dev); + } + + #[test] + fn test_ordering_pre_release_numbers() { + let beta2 = Version::parse("1.0.0-beta2").unwrap(); + let beta1 = Version::parse("1.0.0-beta1").unwrap(); + assert!(beta2 > beta1); + } + + // ──────────── Constraint parsing ──────────── + + #[test] + fn test_parse_any() { + let c = VersionConstraint::parse("*").unwrap(); + let v = Version::parse("1.2.3").unwrap(); + assert!(c.matches(&v)); + } + + #[test] + fn test_parse_exact() { + let c = VersionConstraint::parse("1.2.3").unwrap(); + let v = Version::parse("1.2.3").unwrap(); + assert!(c.matches(&v)); + let v2 = Version::parse("1.2.4").unwrap(); + assert!(!c.matches(&v2)); + } + + #[test] + fn test_parse_gte() { + let c = VersionConstraint::parse(">=1.0.0").unwrap(); + assert!(c.matches(&Version::parse("1.0.0").unwrap())); + assert!(c.matches(&Version::parse("2.0.0").unwrap())); + assert!(!c.matches(&Version::parse("0.9.0").unwrap())); + } + + #[test] + fn test_parse_caret_major() { + let c = VersionConstraint::parse("^1.2").unwrap(); + assert!(c.matches(&Version::parse("1.2.0").unwrap())); + assert!(c.matches(&Version::parse("1.3.0").unwrap())); + assert!(c.matches(&Version::parse("1.9.9").unwrap())); + assert!(!c.matches(&Version::parse("2.0.0").unwrap())); + assert!(!c.matches(&Version::parse("1.1.0").unwrap())); + } + + #[test] + fn test_parse_caret_zero_minor() { + // ^0.2.3 → >=0.2.3 <0.3.0 + let c = VersionConstraint::parse("^0.2.3").unwrap(); + assert!(c.matches(&Version::parse("0.2.3").unwrap())); + assert!(c.matches(&Version::parse("0.2.9").unwrap())); + assert!(!c.matches(&Version::parse("0.3.0").unwrap())); + assert!(!c.matches(&Version::parse("1.0.0").unwrap())); + } + + #[test] + fn test_parse_tilde_three_parts() { + // ~1.2.3 → >=1.2.3 <1.3.0 + let c = VersionConstraint::parse("~1.2.3").unwrap(); + assert!(c.matches(&Version::parse("1.2.3").unwrap())); + assert!(c.matches(&Version::parse("1.2.9").unwrap())); + assert!(!c.matches(&Version::parse("1.3.0").unwrap())); + } + + #[test] + fn test_parse_tilde_two_parts() { + // ~1.2 → >=1.2.0 <2.0.0 + let c = VersionConstraint::parse("~1.2").unwrap(); + assert!(c.matches(&Version::parse("1.2.0").unwrap())); + assert!(c.matches(&Version::parse("1.9.0").unwrap())); + assert!(!c.matches(&Version::parse("2.0.0").unwrap())); + } + + #[test] + fn test_parse_wildcard() { + let c = VersionConstraint::parse("1.2.*").unwrap(); + assert!(c.matches(&Version::parse("1.2.0").unwrap())); + assert!(c.matches(&Version::parse("1.2.9").unwrap())); + assert!(!c.matches(&Version::parse("1.3.0").unwrap())); + } + + #[test] + fn test_parse_and() { + let c = VersionConstraint::parse(">=1.0 <2.0").unwrap(); + assert!(c.matches(&Version::parse("1.0.0").unwrap())); + assert!(c.matches(&Version::parse("1.9.9").unwrap())); + assert!(!c.matches(&Version::parse("2.0.0").unwrap())); + assert!(!c.matches(&Version::parse("0.9.9").unwrap())); + } + + #[test] + fn test_parse_or() { + let c = VersionConstraint::parse("^1.0 || ^2.0").unwrap(); + assert!(c.matches(&Version::parse("1.5.0").unwrap())); + assert!(c.matches(&Version::parse("2.3.0").unwrap())); + assert!(!c.matches(&Version::parse("3.0.0").unwrap())); + } + + #[test] + fn test_parse_not_equal() { + let c = VersionConstraint::parse("!=1.5.0").unwrap(); + assert!(c.matches(&Version::parse("1.4.0").unwrap())); + assert!(!c.matches(&Version::parse("1.5.0").unwrap())); + assert!(c.matches(&Version::parse("1.6.0").unwrap())); + } + + #[test] + fn test_parse_hyphen_range() { + let c = VersionConstraint::parse("1.0 - 2.0").unwrap(); + assert!(c.matches(&Version::parse("1.0.0").unwrap())); + assert!(c.matches(&Version::parse("1.5.0").unwrap())); + assert!(c.matches(&Version::parse("2.0.0").unwrap())); + assert!(!c.matches(&Version::parse("0.9.0").unwrap())); + assert!(!c.matches(&Version::parse("2.1.0").unwrap())); + } +} diff --git a/crates/mozart/src/downloader.rs b/crates/mozart/src/downloader.rs new file mode 100644 index 0000000..b477ab4 --- /dev/null +++ b/crates/mozart/src/downloader.rs @@ -0,0 +1,380 @@ +use sha1::{Digest, Sha1}; +use std::collections::HashSet; +use std::fs; +use std::io::{Cursor, Read}; +use std::path::Path; + +/// Download a dist archive from a URL. +/// Returns the raw bytes of the downloaded archive. +/// If `expected_shasum` is provided and non-empty, verifies SHA-1 of the downloaded bytes. +pub fn download_dist(url: &str, expected_shasum: Option<&str>) -> anyhow::Result<Vec<u8>> { + let response = reqwest::blocking::get(url)?; + + if !response.status().is_success() { + anyhow::bail!( + "Failed to download dist archive from {} (HTTP {})", + url, + response.status() + ); + } + + let bytes = response.bytes()?.to_vec(); + + // Verify SHA-1 checksum if provided + if let Some(shasum) = expected_shasum + && !shasum.is_empty() + { + let mut hasher = Sha1::new(); + hasher.update(&bytes); + let result = hasher.finalize(); + let computed = format!("{result:x}"); + + if computed != shasum { + anyhow::bail!( + "SHA-1 checksum mismatch for {url}: expected {shasum}, got {computed}" + ); + } + } + + Ok(bytes) +} + +/// Find the common top-level directory prefix shared by all entries. +/// Returns `Some(prefix)` if all entries share a single top-level directory. +fn find_top_level_dir(entries: &[String]) -> Option<String> { + if entries.is_empty() { + return None; + } + + let mut prefixes: HashSet<String> = HashSet::new(); + for entry in entries { + if let Some(slash_pos) = entry.find('/') { + prefixes.insert(entry[..slash_pos + 1].to_string()); + } else { + // Entry at root level — no common prefix to strip + return None; + } + } + + if prefixes.len() == 1 { + prefixes.into_iter().next() + } else { + None + } +} + +/// Extract a zip archive to the target directory. +/// Strips a common top-level directory if all entries share one (Packagist pattern). +pub fn extract_zip(data: &[u8], target_dir: &Path) -> anyhow::Result<()> { + let cursor = Cursor::new(data); + let mut archive = zip::ZipArchive::new(cursor)?; + + // Collect all entry names to detect common prefix + let entry_names: Vec<String> = (0..archive.len()) + .map(|i| archive.by_index(i).map(|e| e.name().to_string())) + .collect::<Result<_, _>>()?; + + let prefix = find_top_level_dir(&entry_names); + + for i in 0..archive.len() { + let mut entry = archive.by_index(i)?; + let raw_name = entry.name().to_string(); + + // Strip common prefix + let relative = if let Some(ref pfx) = prefix { + if raw_name.starts_with(pfx.as_str()) { + &raw_name[pfx.len()..] + } else { + &raw_name + } + } else { + &raw_name + }; + + // Skip the directory entry itself (empty name after stripping) + if relative.is_empty() { + continue; + } + + let target_path = target_dir.join(relative); + + if raw_name.ends_with('/') { + // Directory entry + fs::create_dir_all(&target_path)?; + } else { + // File entry + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent)?; + } + + let mut buf = Vec::new(); + entry.read_to_end(&mut buf)?; + fs::write(&target_path, &buf)?; + + // Set permissions on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Some(mode) = entry.unix_mode() { + fs::set_permissions(&target_path, fs::Permissions::from_mode(mode))?; + } + } + } + } + + Ok(()) +} + +/// Extract a tar.gz archive to the target directory. +/// Strips a common top-level directory if all entries share one (Packagist pattern). +pub fn extract_tar_gz(data: &[u8], target_dir: &Path) -> anyhow::Result<()> { + let cursor = Cursor::new(data); + let decoder = flate2::read::GzDecoder::new(cursor); + let mut archive = tar::Archive::new(decoder); + + // We need to process in two passes: first collect names, then extract. + // Use a buffered approach: collect entries into memory. + let cursor2 = Cursor::new(data); + let decoder2 = flate2::read::GzDecoder::new(cursor2); + let mut archive2 = tar::Archive::new(decoder2); + + let entry_names: Vec<String> = archive2 + .entries()? + .filter_map(|e| e.ok()) + .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string())) + .collect(); + + let prefix = find_top_level_dir(&entry_names); + + for entry in archive.entries()? { + let mut entry = entry?; + let raw_path = entry.path()?.to_string_lossy().to_string(); + + // Strip common prefix + let relative = if let Some(ref pfx) = prefix { + if raw_path.starts_with(pfx.as_str()) { + raw_path[pfx.len()..].to_string() + } else { + raw_path.clone() + } + } else { + raw_path.clone() + }; + + // Skip empty (top-level dir itself) + if relative.is_empty() { + continue; + } + + let target_path = target_dir.join(&relative); + + let entry_type = entry.header().entry_type(); + if entry_type.is_dir() { + fs::create_dir_all(&target_path)?; + } else if entry_type.is_file() { + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent)?; + } + let mut buf = Vec::new(); + entry.read_to_end(&mut buf)?; + fs::write(&target_path, &buf)?; + + // Set permissions on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(mode) = entry.header().mode() { + fs::set_permissions(&target_path, fs::Permissions::from_mode(mode))?; + } + } + } + // Symlinks and other types are skipped for now + } + + Ok(()) +} + +/// Download and install a package to the vendor directory. +/// +/// - `dist_url`: the download URL (from `LockedPackage.dist.url`) +/// - `dist_type`: `"zip"` or `"tar"` (from `LockedPackage.dist.dist_type`) +/// - `dist_shasum`: optional SHA-1 checksum +/// - `vendor_dir`: path to `vendor/` directory +/// - `package_name`: e.g. `"monolog/monolog"` +pub fn install_package( + dist_url: &str, + dist_type: &str, + dist_shasum: Option<&str>, + vendor_dir: &Path, + package_name: &str, +) -> anyhow::Result<()> { + let target = vendor_dir.join(package_name); + + // Remove existing installation for a clean reinstall + if target.exists() { + fs::remove_dir_all(&target)?; + } + fs::create_dir_all(&target)?; + + let bytes = download_dist(dist_url, dist_shasum)?; + + match dist_type { + "zip" => extract_zip(&bytes, &target)?, + "tar" | "tar.gz" | "tgz" => extract_tar_gz(&bytes, &target)?, + other => anyhow::bail!("Unsupported dist type: {other}"), + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write as IoWrite; + use tempfile::tempdir; + + /// Build a minimal zip archive in memory. + fn make_zip(files: &[(&str, &[u8])]) -> Vec<u8> { + let buf = Vec::new(); + let cursor = Cursor::new(buf); + let mut writer = zip::ZipWriter::new(cursor); + let options = zip::write::FileOptions::<()>::default() + .compression_method(zip::CompressionMethod::Stored); + + for (name, content) in files { + writer.start_file(*name, options).unwrap(); + writer.write_all(content).unwrap(); + } + + writer.finish().unwrap().into_inner() + } + + /// Build a minimal tar.gz archive in memory. + fn make_tar_gz(files: &[(&str, &[u8])]) -> Vec<u8> { + let buf = Vec::new(); + let enc = flate2::write::GzEncoder::new(buf, flate2::Compression::default()); + let mut builder = tar::Builder::new(enc); + + for (name, content) in files { + let mut header = tar::Header::new_gnu(); + header.set_size(content.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder + .append_data(&mut header, name, Cursor::new(content)) + .unwrap(); + } + + builder.into_inner().unwrap().finish().unwrap() + } + + #[test] + fn test_extract_zip_flat() { + let zip_data = make_zip(&[("file1.txt", b"hello"), ("subdir/file2.txt", b"world")]); + + let dir = tempdir().unwrap(); + extract_zip(&zip_data, dir.path()).unwrap(); + + assert_eq!( + fs::read_to_string(dir.path().join("file1.txt")).unwrap(), + "hello" + ); + assert_eq!( + fs::read_to_string(dir.path().join("subdir/file2.txt")).unwrap(), + "world" + ); + } + + #[test] + fn test_extract_zip_with_top_level_dir() { + // Packagist pattern: all files under vendor-package-abc123/ + let zip_data = make_zip(&[ + ("vendor-pkg-abc/", &[]), + ("vendor-pkg-abc/file1.txt", b"hello"), + ("vendor-pkg-abc/src/Foo.php", b"<?php"), + ]); + + let dir = tempdir().unwrap(); + extract_zip(&zip_data, dir.path()).unwrap(); + + // Top-level dir should be stripped + assert!(dir.path().join("file1.txt").exists()); + assert!(dir.path().join("src/Foo.php").exists()); + assert_eq!( + fs::read_to_string(dir.path().join("file1.txt")).unwrap(), + "hello" + ); + } + + #[test] + fn test_extract_tar_gz_flat() { + let tar_data = make_tar_gz(&[("file1.txt", b"hello"), ("subdir/file2.txt", b"world")]); + + let dir = tempdir().unwrap(); + extract_tar_gz(&tar_data, dir.path()).unwrap(); + + assert_eq!( + fs::read_to_string(dir.path().join("file1.txt")).unwrap(), + "hello" + ); + assert_eq!( + fs::read_to_string(dir.path().join("subdir/file2.txt")).unwrap(), + "world" + ); + } + + #[test] + fn test_extract_tar_gz_with_top_level_dir() { + let tar_data = make_tar_gz(&[ + ("vendor-pkg-abc/file1.txt", b"hello"), + ("vendor-pkg-abc/src/Foo.php", b"<?php"), + ]); + + let dir = tempdir().unwrap(); + extract_tar_gz(&tar_data, dir.path()).unwrap(); + + assert!(dir.path().join("file1.txt").exists()); + assert!(dir.path().join("src/Foo.php").exists()); + } + + #[test] + fn test_sha1_verification() { + use sha1::{Digest, Sha1}; + + let data = b"test content"; + let mut hasher = Sha1::new(); + hasher.update(data); + let expected = format!("{:x}", hasher.finalize()); + + // We can't test download_dist without a server, but we can verify the + // SHA-1 logic: same data should produce same hash + let mut hasher2 = Sha1::new(); + hasher2.update(data); + let computed = format!("{:x}", hasher2.finalize()); + + assert_eq!(expected, computed); + assert!(!expected.is_empty()); + } + + #[test] + fn test_find_top_level_dir_common() { + let entries = vec![ + "pkg-1.0/".to_string(), + "pkg-1.0/README.md".to_string(), + "pkg-1.0/src/Foo.php".to_string(), + ]; + assert_eq!(find_top_level_dir(&entries), Some("pkg-1.0/".to_string())); + } + + #[test] + fn test_find_top_level_dir_none_when_mixed() { + let entries = vec!["pkg-1.0/file.txt".to_string(), "other/file.txt".to_string()]; + assert_eq!(find_top_level_dir(&entries), None); + } + + #[test] + fn test_find_top_level_dir_none_when_root_file() { + let entries = vec!["file.txt".to_string(), "pkg/other.txt".to_string()]; + assert_eq!(find_top_level_dir(&entries), None); + } +} diff --git a/crates/mozart/src/installed.rs b/crates/mozart/src/installed.rs new file mode 100644 index 0000000..8ed4721 --- /dev/null +++ b/crates/mozart/src/installed.rs @@ -0,0 +1,229 @@ +use crate::package::to_json_pretty; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +fn default_true() -> bool { + true +} + +/// Represents `vendor/composer/installed.json`. +/// This is the Composer 2.x format. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstalledPackages { + pub packages: Vec<InstalledPackageEntry>, + + #[serde(rename = "dev-package-names", default)] + pub dev_package_names: Vec<String>, + + #[serde(default = "default_true")] + pub dev: bool, +} + +/// An entry in installed.json's packages array. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstalledPackageEntry { + 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<serde_json::Value>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub dist: Option<serde_json::Value>, + + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub package_type: Option<String>, + + #[serde(rename = "install-path", skip_serializing_if = "Option::is_none")] + pub install_path: Option<String>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub autoload: Option<serde_json::Value>, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub aliases: Vec<String>, + + #[serde(flatten)] + pub extra_fields: BTreeMap<String, serde_json::Value>, +} + +impl Default for InstalledPackages { + fn default() -> Self { + Self::new() + } +} + +impl InstalledPackages { + /// Create an empty registry. + pub fn new() -> InstalledPackages { + InstalledPackages { + packages: Vec::new(), + dev_package_names: Vec::new(), + dev: true, + } + } + + /// Read installed.json from `vendor/composer/installed.json`. + /// If the file does not exist, returns an empty registry. + pub fn read(vendor_dir: &Path) -> anyhow::Result<InstalledPackages> { + let path = vendor_dir.join("composer/installed.json"); + if !path.exists() { + return Ok(InstalledPackages::new()); + } + let content = fs::read_to_string(&path)?; + let installed: InstalledPackages = serde_json::from_str(&content)?; + Ok(installed) + } + + /// Write installed.json to `vendor/composer/installed.json`. + /// Creates the `vendor/composer/` directory if it doesn't exist. + pub fn write(&self, vendor_dir: &Path) -> anyhow::Result<()> { + let composer_dir = vendor_dir.join("composer"); + fs::create_dir_all(&composer_dir)?; + let path = composer_dir.join("installed.json"); + let json = to_json_pretty(self)?; + fs::write(path, json)?; + Ok(()) + } + + /// Check if a package at a specific version is installed. + pub fn is_installed(&self, name: &str, version: &str) -> bool { + self.packages + .iter() + .any(|p| p.name.eq_ignore_ascii_case(name) && p.version == version) + } + + /// Add or update a package entry (replace if same name exists). + pub fn upsert(&mut self, entry: InstalledPackageEntry) { + if let Some(pos) = self + .packages + .iter() + .position(|p| p.name.eq_ignore_ascii_case(&entry.name)) + { + self.packages[pos] = entry; + } else { + self.packages.push(entry); + } + } + + /// Remove a package by name. + pub fn remove(&mut self, name: &str) { + self.packages.retain(|p| !p.name.eq_ignore_ascii_case(name)); + self.dev_package_names + .retain(|n| !n.eq_ignore_ascii_case(name)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn make_entry(name: &str, version: &str) -> InstalledPackageEntry { + InstalledPackageEntry { + name: name.to_string(), + version: version.to_string(), + version_normalized: None, + source: None, + dist: None, + package_type: None, + install_path: None, + autoload: None, + aliases: vec![], + extra_fields: BTreeMap::new(), + } + } + + #[test] + fn test_new_is_empty() { + let installed = InstalledPackages::new(); + assert!(installed.packages.is_empty()); + assert!(installed.dev_package_names.is_empty()); + assert!(installed.dev); + } + + #[test] + fn test_write_read_empty() { + let dir = tempdir().unwrap(); + let vendor = dir.path().join("vendor"); + + let installed = InstalledPackages::new(); + installed.write(&vendor).unwrap(); + + let loaded = InstalledPackages::read(&vendor).unwrap(); + assert!(loaded.packages.is_empty()); + assert!(loaded.dev); + } + + #[test] + fn test_read_nonexistent_returns_empty() { + let dir = tempdir().unwrap(); + let vendor = dir.path().join("vendor"); + // Don't create the directory + let installed = InstalledPackages::read(&vendor).unwrap(); + assert!(installed.packages.is_empty()); + } + + #[test] + fn test_upsert_and_is_installed() { + let mut installed = InstalledPackages::new(); + installed.upsert(make_entry("monolog/monolog", "3.8.0")); + + assert!(installed.is_installed("monolog/monolog", "3.8.0")); + assert!(!installed.is_installed("monolog/monolog", "3.7.0")); + assert!(!installed.is_installed("other/pkg", "1.0.0")); + } + + #[test] + fn test_upsert_replaces_existing() { + let mut installed = InstalledPackages::new(); + installed.upsert(make_entry("monolog/monolog", "3.7.0")); + installed.upsert(make_entry("monolog/monolog", "3.8.0")); + + assert_eq!(installed.packages.len(), 1); + assert_eq!(installed.packages[0].version, "3.8.0"); + } + + #[test] + fn test_remove() { + let mut installed = InstalledPackages::new(); + installed.upsert(make_entry("monolog/monolog", "3.8.0")); + installed.upsert(make_entry("psr/log", "3.0.0")); + installed + .dev_package_names + .push("monolog/monolog".to_string()); + + installed.remove("monolog/monolog"); + + assert_eq!(installed.packages.len(), 1); + assert_eq!(installed.packages[0].name, "psr/log"); + assert!(installed.dev_package_names.is_empty()); + } + + #[test] + fn test_is_installed_case_insensitive() { + let mut installed = InstalledPackages::new(); + installed.upsert(make_entry("Monolog/Monolog", "3.8.0")); + assert!(installed.is_installed("monolog/monolog", "3.8.0")); + } + + #[test] + fn test_roundtrip_with_package() { + let dir = tempdir().unwrap(); + let vendor = dir.path().join("vendor"); + + let mut installed = InstalledPackages::new(); + installed.upsert(make_entry("monolog/monolog", "3.8.0")); + installed.write(&vendor).unwrap(); + + let loaded = InstalledPackages::read(&vendor).unwrap(); + assert_eq!(loaded.packages.len(), 1); + assert_eq!(loaded.packages[0].name, "monolog/monolog"); + assert_eq!(loaded.packages[0].version, "3.8.0"); + } +} diff --git a/crates/mozart/src/lib.rs b/crates/mozart/src/lib.rs index 5e5487a..d2b3533 100644 --- a/crates/mozart/src/lib.rs +++ b/crates/mozart/src/lib.rs @@ -1,5 +1,9 @@ pub mod commands; pub mod console; +pub mod constraint; +pub mod downloader; +pub mod installed; +pub mod lockfile; pub mod package; pub mod packagist; pub mod validation; diff --git a/crates/mozart/src/lockfile.rs b/crates/mozart/src/lockfile.rs new file mode 100644 index 0000000..523520e --- /dev/null +++ b/crates/mozart/src/lockfile.rs @@ -0,0 +1,344 @@ +use crate::package::to_json_pretty; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +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(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)) + } +} + +#[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(), + 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")); + } +} |
