aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-21 10:46:26 +0900
committernsfisis <nsfisis@gmail.com>2026-02-21 10:46:26 +0900
commita03ad0152ec28cdd1cc05f96a9823807dbb2b818 (patch)
tree83b4e4aadb98bf53c89237b58845ba1f41fbef68 /crates/mozart
parent0b5d333083f1317391338d3aa67b1290e93922cc (diff)
downloadphp-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.toml5
-rw-r--r--crates/mozart/src/constraint.rs856
-rw-r--r--crates/mozart/src/downloader.rs380
-rw-r--r--crates/mozart/src/installed.rs229
-rw-r--r--crates/mozart/src/lib.rs4
-rw-r--r--crates/mozart/src/lockfile.rs344
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"));
+ }
+}