diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-22 00:37:54 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-22 00:37:54 +0900 |
| commit | 0a8e5935e6305819bb02d8c69e2f046ff397913a (patch) | |
| tree | e5a288e679477b1603d7989e986ca22bbe590aa4 /crates/mozart-core/src/version_bumper.rs | |
| parent | b5af594fec7da72b15c9a202c641af0494db6355 (diff) | |
| download | php-mozart-0a8e5935e6305819bb02d8c69e2f046ff397913a.tar.gz php-mozart-0a8e5935e6305819bb02d8c69e2f046ff397913a.tar.zst php-mozart-0a8e5935e6305819bb02d8c69e2f046ff397913a.zip | |
refactor(workspace): split monolithic crate into 6 workspace crates
Extract modules from the single `mozart` crate into 5 focused library
crates to improve compilation parallelism and architectural clarity:
- mozart-constraint: version constraint parser (independent)
- mozart-core: base types, console, validation, platform utilities
- mozart-archiver: archive creation (tar, zip, bzip2)
- mozart-registry: Packagist API, cache, resolver, downloader, lockfile
- mozart-autoload: autoloader generation and PHP scanner
Refactor Console::from_cli and build_cache_config to accept primitive
args instead of &Cli to break circular dependencies. Introduce
[workspace.dependencies] for centralized version management. Remove 9
unused direct dependencies from the CLI crate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart-core/src/version_bumper.rs')
| -rw-r--r-- | crates/mozart-core/src/version_bumper.rs | 667 |
1 files changed, 667 insertions, 0 deletions
diff --git a/crates/mozart-core/src/version_bumper.rs b/crates/mozart-core/src/version_bumper.rs new file mode 100644 index 0000000..43c21d6 --- /dev/null +++ b/crates/mozart-core/src/version_bumper.rs @@ -0,0 +1,667 @@ +/// Version constraint bumper. +/// +/// Given a constraint string (from composer.json) and the installed version +/// (from composer.lock), computes a new constraint string that raises the +/// lower bound to match the installed version. +/// +/// Returns `None` if no change is needed, or `Some(new_constraint)` if the +/// constraint should be updated. +pub fn bump_requirement( + constraint_str: &str, + pretty_version: &str, + version_normalized: Option<&str>, +) -> Option<String> { + let constraint = constraint_str.trim(); + + // Strip and preserve stability flag (@dev, @beta, etc.) + let (constraint_body, stability_flag) = strip_stability_flag(constraint); + + // Dev constraints (dev-master, dev-main, etc.) are left unchanged + if constraint_body.trim().starts_with("dev-") { + return None; + } + + // Skip dev installed versions that have no alias + // An alias looks like "dev-master as 1.0.0" — the version string in the lock + // would be "dev-master" without " as ". + if pretty_version.starts_with("dev-") && !pretty_version.contains(" as ") { + return None; + } + if let Some(norm) = version_normalized + && norm.starts_with("dev-") + && !pretty_version.contains(" as ") + { + return None; + } + + // Resolve the actual version string to use for bumping. + // If the pretty_version contains an inline alias (e.g. "dev-master as 1.0.0"), + // take the alias target. Otherwise use pretty_version directly. + let installed_version = resolve_installed_version(pretty_version, version_normalized); + + // Handle OR constraints (^1.0 || ^2.0) + if constraint_body.contains("||") { + return bump_or_constraint(constraint_body, &installed_version, stability_flag); + } + + // Single constraint + bump_single(constraint_body.trim(), &installed_version, stability_flag) +} + +// ─── OR constraint handling ─────────────────────────────────────────────────── + +fn bump_or_constraint( + constraint_body: &str, + installed_version: &str, + stability_flag: Option<&str>, +) -> Option<String> { + let parts: Vec<&str> = constraint_body.split("||").map(str::trim).collect(); + + // Determine which major the installed version belongs to + let installed_major = parse_major(installed_version); + + let mut changed = false; + let mut new_parts: Vec<String> = Vec::new(); + + for part in &parts { + let part_trimmed = part.trim(); + // Determine the major range this disjunct covers + let part_major = constraint_major(part_trimmed); + + // Only bump the disjunct whose major matches the installed version's major + if part_major == installed_major { + if let Some(bumped) = bump_single(part_trimmed, installed_version, None) { + new_parts.push(bumped); + changed = true; + } else { + new_parts.push(part_trimmed.to_string()); + } + } else { + new_parts.push(part_trimmed.to_string()); + } + } + + if !changed { + return None; + } + + let joined = new_parts.join(" || "); + let result = append_stability_flag(&joined, stability_flag); + Some(result) +} + +// ─── Single constraint handling ─────────────────────────────────────────────── + +fn bump_single( + constraint: &str, + installed_version: &str, + stability_flag: Option<&str>, +) -> Option<String> { + // AND constraints (space-separated multiple operators like ">=1.0 <2.0" or + // comma-separated like ">=1.0,<2.0") are not supported for bumping — leave unchanged. + // We detect them by checking for a space or comma after the version spec begins. + // Quick check: if the constraint contains a space (ignoring leading operators), + // it's likely a multi-part AND constraint. + let after_op = constraint + .trim_start_matches('^') + .trim_start_matches('~') + .trim_start_matches(">=") + .trim_start_matches("<=") + .trim_start_matches("!=") + .trim_start_matches('>') + .trim_start_matches('<') + .trim_start_matches('='); + if after_op.contains(' ') || after_op.contains(',') { + return None; + } + + // Caret: ^X.Y.Z + if let Some(rest) = constraint.strip_prefix('^') { + return bump_caret(rest.trim(), installed_version, stability_flag); + } + + // Tilde: ~X.Y.Z + if let Some(rest) = constraint.strip_prefix('~') { + return bump_tilde(rest.trim(), installed_version, stability_flag); + } + + // Wildcard: * or X.* + if constraint == "*" || constraint.ends_with(".*") { + return bump_wildcard(constraint, installed_version, stability_flag); + } + + // Greater-or-equal: >=X.Y + if let Some(rest) = constraint.strip_prefix(">=") { + return bump_gte(rest.trim(), installed_version, stability_flag); + } + + // Other operators (exact, <, <=, >, !=, range) — leave unchanged + None +} + +// ─── Caret bump ─────────────────────────────────────────────────────────────── + +/// `^X.Y.Z` → bump to installed version if it is greater. +/// +/// The caret prefix is preserved; segments from installed version replace +/// those in the constraint (trimming trailing zeros appropriately). +fn bump_caret(rest: &str, installed_version: &str, stability_flag: Option<&str>) -> Option<String> { + let constraint_segments = parse_version_segments(rest); + let installed_segments = parse_version_segments(installed_version); + + // The constraint length determines how many segments to compare/output + let n_constraint = constraint_segments.len().max(1); + + // Compare: if installed <= current lower bound, no change needed + // We compare as many segments as the installed version has + let current_lower: Vec<u64> = constraint_segments + .iter() + .copied() + .chain(std::iter::repeat(0)) + .take(4) + .collect(); + let installed: Vec<u64> = installed_segments + .iter() + .copied() + .chain(std::iter::repeat(0)) + .take(4) + .collect(); + + if installed <= current_lower { + return None; + } + + // Build new constraint segments: use installed version, but only up to + // the number of non-trivial segments needed. + // We output at least as many segments as the original constraint had, + // but trim trailing zeros. + let mut new_segs: Vec<u64> = installed_segments + .iter() + .copied() + .chain(std::iter::repeat(0)) + .take(n_constraint.max(installed_segments.len())) + .collect(); + + // Trim trailing zeros (but keep at least n_constraint segments, minimum 1) + while new_segs.len() > n_constraint && new_segs.last() == Some(&0) { + new_segs.pop(); + } + // Also trim trailing zeros beyond 1 segment + while new_segs.len() > 1 && new_segs.last() == Some(&0) { + new_segs.pop(); + } + + let version_str = new_segs + .iter() + .map(|n| n.to_string()) + .collect::<Vec<_>>() + .join("."); + + let new_constraint = format!("^{version_str}"); + let result = append_stability_flag(&new_constraint, stability_flag); + Some(result) +} + +// ─── Tilde bump ─────────────────────────────────────────────────────────────── + +/// `~X.Y.Z` (3 segments) → bump patch: `~X.Y.new_patch` +/// `~X.Y` (2 segments) → convert to caret: `^X.Y.new_patch` +fn bump_tilde(rest: &str, installed_version: &str, stability_flag: Option<&str>) -> Option<String> { + let constraint_segments = parse_version_segments(rest); + let installed_segments = parse_version_segments(installed_version); + + let current_lower: Vec<u64> = constraint_segments + .iter() + .copied() + .chain(std::iter::repeat(0)) + .take(4) + .collect(); + let installed: Vec<u64> = installed_segments + .iter() + .copied() + .chain(std::iter::repeat(0)) + .take(4) + .collect(); + + if installed <= current_lower { + return None; + } + + let major = installed_segments.first().copied().unwrap_or(0); + let minor = installed_segments.get(1).copied().unwrap_or(0); + let patch = installed_segments.get(2).copied().unwrap_or(0); + + let new_constraint = if constraint_segments.len() >= 3 { + // ~X.Y.Z → keep tilde, bump patch + if patch == 0 { + format!("~{major}.{minor}.0") + } else { + format!("~{major}.{minor}.{patch}") + } + } else { + // ~X.Y → convert to caret + if patch == 0 { + format!("^{major}.{minor}") + } else { + format!("^{major}.{minor}.{patch}") + } + }; + + let result = append_stability_flag(&new_constraint, stability_flag); + Some(result) +} + +// ─── Wildcard bump ──────────────────────────────────────────────────────────── + +/// `*` → `>=installed` +/// `X.*` → `>=installed` (trimming trailing zeros) +fn bump_wildcard( + constraint: &str, + installed_version: &str, + stability_flag: Option<&str>, +) -> Option<String> { + let installed_segments = parse_version_segments(installed_version); + + // Trim trailing zeros + let mut segs = installed_segments.clone(); + while segs.len() > 1 && segs.last() == Some(&0) { + segs.pop(); + } + + let version_str = segs + .iter() + .map(|n| n.to_string()) + .collect::<Vec<_>>() + .join("."); + + // For plain wildcard "*", always produce >=installed + if constraint == "*" { + let new_constraint = format!(">={version_str}"); + return Some(append_stability_flag(&new_constraint, stability_flag)); + } + + // For "X.*", if installed is at that major, produce >=installed + let base = constraint.trim_end_matches(".*"); + let base_segs = parse_version_segments(base); + let current_lower: Vec<u64> = base_segs + .iter() + .copied() + .chain(std::iter::repeat(0)) + .take(4) + .collect(); + let installed: Vec<u64> = installed_segments + .iter() + .copied() + .chain(std::iter::repeat(0)) + .take(4) + .collect(); + + if installed <= current_lower { + return None; + } + + let new_constraint = format!(">={version_str}"); + Some(append_stability_flag(&new_constraint, stability_flag)) +} + +// ─── GTE bump ───────────────────────────────────────────────────────────────── + +/// `>=X.Y` → raise to installed version (trimming trailing zeros) +fn bump_gte(rest: &str, installed_version: &str, stability_flag: Option<&str>) -> Option<String> { + let constraint_segments = parse_version_segments(rest); + let installed_segments = parse_version_segments(installed_version); + + let current_lower: Vec<u64> = constraint_segments + .iter() + .copied() + .chain(std::iter::repeat(0)) + .take(4) + .collect(); + let installed: Vec<u64> = installed_segments + .iter() + .copied() + .chain(std::iter::repeat(0)) + .take(4) + .collect(); + + if installed <= current_lower { + return None; + } + + // Trim trailing zeros from installed version + let mut segs = installed_segments.clone(); + while segs.len() > 1 && segs.last() == Some(&0) { + segs.pop(); + } + + let version_str = segs + .iter() + .map(|n| n.to_string()) + .collect::<Vec<_>>() + .join("."); + + let new_constraint = format!(">={version_str}"); + let result = append_stability_flag(&new_constraint, stability_flag); + Some(result) +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/// Strip a trailing `@stability` flag from a constraint string. +/// Returns (body, flag) where flag is the `@...` suffix (without the `@`). +fn strip_stability_flag(constraint: &str) -> (&str, Option<&str>) { + let known = ["@dev", "@alpha", "@beta", "@RC", "@rc", "@stable"]; + for flag in &known { + if let Some(body) = constraint.strip_suffix(flag) { + let flag_str = &constraint[body.len()..]; + return (body.trim_end(), Some(flag_str)); + } + } + (constraint, None) +} + +/// Append an optional stability flag to a constraint string. +fn append_stability_flag(constraint: &str, flag: Option<&str>) -> String { + match flag { + Some(f) => format!("{constraint}{f}"), + None => constraint.to_string(), + } +} + +/// Parse a version string into numeric segments. +/// Handles "1.2.3", "1.2", "1", etc. +/// Stops at any non-numeric/non-dot character. +fn parse_version_segments(version: &str) -> Vec<u64> { + // Strip inline alias: "dev-master as 1.0.0" → "1.0.0" + let version = if let Some(pos) = version.find(" as ") { + &version[pos + 4..] + } else { + version + }; + + // Strip leading v/V + let version = version + .strip_prefix('v') + .or_else(|| version.strip_prefix('V')) + .unwrap_or(version); + + // Take up to any pre-release suffix (first '-' or '+') + let version = version.split(['-', '+']).next().unwrap_or(version); + + version + .split('.') + .filter_map(|s| s.parse::<u64>().ok()) + .collect() +} + +/// Parse the major version number from a version string. +fn parse_major(version: &str) -> Option<u64> { + parse_version_segments(version).into_iter().next() +} + +/// Determine the major version that a single disjunct constraint covers. +/// For `^1.2`, returns `Some(1)`. For `^0.3`, returns `Some(0)`. +fn constraint_major(constraint: &str) -> Option<u64> { + if let Some(rest) = constraint.strip_prefix('^') { + return parse_version_segments(rest).into_iter().next(); + } + if let Some(rest) = constraint.strip_prefix('~') { + return parse_version_segments(rest).into_iter().next(); + } + if let Some(rest) = constraint.strip_prefix(">=") { + return parse_version_segments(rest).into_iter().next(); + } + // Try as plain version + parse_version_segments(constraint).into_iter().next() +} + +/// Resolve the installed version string to use for comparison. +/// Handles inline aliases (e.g., "dev-main as 2.1.0" → "2.1.0"). +fn resolve_installed_version<'a>( + pretty_version: &'a str, + _version_normalized: Option<&'a str>, +) -> String { + // If pretty_version contains an inline alias, use the alias target + if let Some(pos) = pretty_version.find(" as ") { + return pretty_version[pos + 4..].trim().to_string(); + } + + // If version_normalized is available and not a dev branch, prefer it + // for more precise comparison, but use pretty_version for output + // Actually we use pretty_version for building constraint strings + // since normalized versions have extra .0 suffixes + + // Use pretty_version as-is (strip leading 'v' for normalization) + pretty_version + .strip_prefix('v') + .unwrap_or(pretty_version) + .to_string() +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ── Caret bumps ─────────────────────────────────────────────────────────── + + #[test] + fn test_caret_bump_basic() { + // ^1.0 + 1.2.1 → ^1.2.1 + let result = bump_requirement("^1.0", "1.2.1", Some("1.2.1.0")); + assert_eq!(result, Some("^1.2.1".to_string())); + } + + #[test] + fn test_caret_no_change_at_lower_bound() { + // ^1.2 + 1.2.0 → None (already at lower bound) + let result = bump_requirement("^1.2", "1.2.0", Some("1.2.0.0")); + assert_eq!(result, None); + } + + #[test] + fn test_caret_no_change_exact_match() { + // ^1.2.1 + 1.2.1 → None + let result = bump_requirement("^1.2.1", "1.2.1", Some("1.2.1.0")); + assert_eq!(result, None); + } + + #[test] + fn test_caret_bump_zero_major() { + // ^0.3 + 0.3.5 → ^0.3.5 + let result = bump_requirement("^0.3", "0.3.5", Some("0.3.5.0")); + assert_eq!(result, Some("^0.3.5".to_string())); + } + + #[test] + fn test_caret_bump_three_segments() { + // ^1.0.0 + 1.2.1 → ^1.2.1 + let result = bump_requirement("^1.0.0", "1.2.1", Some("1.2.1.0")); + assert_eq!(result, Some("^1.2.1".to_string())); + } + + #[test] + fn test_caret_bump_minor_only() { + // ^1.2 + 1.5.0 → ^1.5 (trailing zero trimmed) + let result = bump_requirement("^1.2", "1.5.0", Some("1.5.0.0")); + assert_eq!(result, Some("^1.5".to_string())); + } + + // ── Tilde bumps ─────────────────────────────────────────────────────────── + + #[test] + fn test_tilde_three_segment_bump() { + // ~2.0.0 + 2.0.3 → ~2.0.3 + let result = bump_requirement("~2.0.0", "2.0.3", Some("2.0.3.0")); + assert_eq!(result, Some("~2.0.3".to_string())); + } + + #[test] + fn test_tilde_two_segment_becomes_caret() { + // ~2.0 + 2.0.3 → ^2.0.3 + let result = bump_requirement("~2.0", "2.0.3", Some("2.0.3.0")); + assert_eq!(result, Some("^2.0.3".to_string())); + } + + #[test] + fn test_tilde_no_change() { + // ~2.0.3 + 2.0.3 → None + let result = bump_requirement("~2.0.3", "2.0.3", Some("2.0.3.0")); + assert_eq!(result, None); + } + + #[test] + fn test_tilde_two_segment_no_patch() { + // ~2.3 + 2.5.0 → ^2.5 (patch is 0, trimmed) + let result = bump_requirement("~2.3", "2.5.0", Some("2.5.0.0")); + assert_eq!(result, Some("^2.5".to_string())); + } + + // ── Wildcard bumps ──────────────────────────────────────────────────────── + + #[test] + fn test_wildcard_star() { + // * + 1.2.3 → >=1.2.3 + let result = bump_requirement("*", "1.2.3", Some("1.2.3.0")); + assert_eq!(result, Some(">=1.2.3".to_string())); + } + + #[test] + fn test_wildcard_major_star() { + // 2.* + 2.5.0 → >=2.5 + let result = bump_requirement("2.*", "2.5.0", Some("2.5.0.0")); + assert_eq!(result, Some(">=2.5".to_string())); + } + + #[test] + fn test_wildcard_no_change() { + // 2.* + 2.0.0 → None (installed is at lower bound) + let result = bump_requirement("2.*", "2.0.0", Some("2.0.0.0")); + assert_eq!(result, None); + } + + // ── GTE bumps ───────────────────────────────────────────────────────────── + + #[test] + fn test_gte_bump() { + // >=1.2 + 1.5.0 → >=1.5 + let result = bump_requirement(">=1.2", "1.5.0", Some("1.5.0.0")); + assert_eq!(result, Some(">=1.5".to_string())); + } + + #[test] + fn test_gte_no_change() { + // >=1.5 + 1.5.0 → None + let result = bump_requirement(">=1.5", "1.5.0", Some("1.5.0.0")); + assert_eq!(result, None); + } + + #[test] + fn test_gte_with_patch() { + // >=1.2.0 + 1.5.3 → >=1.5.3 + let result = bump_requirement(">=1.2.0", "1.5.3", Some("1.5.3.0")); + assert_eq!(result, Some(">=1.5.3".to_string())); + } + + // ── OR constraints ──────────────────────────────────────────────────────── + + #[test] + fn test_or_constraint_bumps_matching_major() { + // ^1.2 || ^2.3 + 1.3.0 → ^1.3 || ^2.3 + let result = bump_requirement("^1.2 || ^2.3", "1.3.0", Some("1.3.0.0")); + assert_eq!(result, Some("^1.3 || ^2.3".to_string())); + } + + #[test] + fn test_or_constraint_bumps_second_major() { + // ^1.2 || ^2.3 + 2.5.0 → ^1.2 || ^2.5 + let result = bump_requirement("^1.2 || ^2.3", "2.5.0", Some("2.5.0.0")); + assert_eq!(result, Some("^1.2 || ^2.5".to_string())); + } + + #[test] + fn test_or_constraint_no_change() { + // ^1.2 || ^2.3 + 1.2.0 → None + let result = bump_requirement("^1.2 || ^2.3", "1.2.0", Some("1.2.0.0")); + assert_eq!(result, None); + } + + // ── Dev constraints ─────────────────────────────────────────────────────── + + #[test] + fn test_dev_constraint_unchanged() { + // dev-master → None + let result = bump_requirement("dev-master", "dev-master", None); + assert_eq!(result, None); + } + + #[test] + fn test_dev_installed_no_alias_unchanged() { + // Installed is dev-main without alias → None + let result = bump_requirement("^1.0", "dev-main", None); + assert_eq!(result, None); + } + + #[test] + fn test_dev_installed_with_alias() { + // Installed is "dev-main as 1.2.0" → bump based on alias + let result = bump_requirement("^1.0", "dev-main as 1.2.0", None); + assert_eq!(result, Some("^1.2".to_string())); + } + + // ── Stability flags ─────────────────────────────────────────────────────── + + #[test] + fn test_stability_flag_preserved() { + // ^1.0@dev + 1.2.0 → ^1.2@dev + let result = bump_requirement("^1.0@dev", "1.2.0", Some("1.2.0.0")); + assert_eq!(result, Some("^1.2@dev".to_string())); + } + + #[test] + fn test_stability_flag_beta_preserved() { + // ^1.0@beta + 1.2.1 → ^1.2.1@beta + let result = bump_requirement("^1.0@beta", "1.2.1", Some("1.2.1.0")); + assert_eq!(result, Some("^1.2.1@beta".to_string())); + } + + // ── Edge cases ──────────────────────────────────────────────────────────── + + #[test] + fn test_exact_constraint_no_bump() { + // 1.2.3 → None (exact version, not bumped) + let result = bump_requirement("1.2.3", "1.3.0", Some("1.3.0.0")); + assert_eq!(result, None); + } + + #[test] + fn test_complex_range_no_bump() { + // >=1.0 <2.0 → None (complex range, not bumped) + let result = bump_requirement(">=1.0 <2.0", "1.5.0", Some("1.5.0.0")); + assert_eq!(result, None); + } + + #[test] + fn test_parse_version_segments_basic() { + assert_eq!(parse_version_segments("1.2.3"), vec![1, 2, 3]); + assert_eq!(parse_version_segments("1.2"), vec![1, 2]); + assert_eq!(parse_version_segments("1"), vec![1]); + } + + #[test] + fn test_parse_version_segments_with_prerelease() { + assert_eq!(parse_version_segments("1.2.3-beta1"), vec![1, 2, 3]); + } + + #[test] + fn test_parse_version_segments_with_v_prefix() { + assert_eq!(parse_version_segments("v1.2.3"), vec![1, 2, 3]); + } + + #[test] + fn test_parse_version_segments_alias() { + // "dev-master as 1.0.0" → segments of "1.0.0" + assert_eq!(parse_version_segments("dev-master as 1.0.0"), vec![1, 0, 0]); + } +} |
