aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart/src
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-21 17:46:03 +0900
committernsfisis <nsfisis@gmail.com>2026-02-21 17:46:03 +0900
commit597a0711ae09fb47ee1889ccaaa6a38055494478 (patch)
tree75b8c91827c13b410eeef17e5bacbe751e6870bd /crates/mozart/src
parent42b024a6a7a093f598e7e3448b5b3e29332bb064 (diff)
downloadphp-mozart-597a0711ae09fb47ee1889ccaaa6a38055494478.tar.gz
php-mozart-597a0711ae09fb47ee1889ccaaa6a38055494478.tar.zst
php-mozart-597a0711ae09fb47ee1889ccaaa6a38055494478.zip
feat(bump): implement bump command to raise version constraints to installed versions
Adds the version_bumper module for constraint manipulation (caret, tilde, wildcard, GTE, OR constraints, stability flags, dev versions) and wires it into the bump command with --dev-only, --no-dev-only, --dry-run flags, package filtering, lock file freshness checks, and content-hash updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart/src')
-rw-r--r--crates/mozart/src/commands/bump.rs597
-rw-r--r--crates/mozart/src/lib.rs1
-rw-r--r--crates/mozart/src/version_bumper.rs667
3 files changed, 1263 insertions, 2 deletions
diff --git a/crates/mozart/src/commands/bump.rs b/crates/mozart/src/commands/bump.rs
index fbeba0e..b27eec6 100644
--- a/crates/mozart/src/commands/bump.rs
+++ b/crates/mozart/src/commands/bump.rs
@@ -1,4 +1,6 @@
use clap::Args;
+use std::collections::HashMap;
+use std::path::PathBuf;
#[derive(Args)]
pub struct BumpArgs {
@@ -18,6 +20,597 @@ pub struct BumpArgs {
pub dry_run: bool,
}
-pub fn execute(_args: &BumpArgs, _cli: &super::Cli) -> anyhow::Result<()> {
- todo!()
+// ─── Main entry point ─────────────────────────────────────────────────────────
+
+pub fn execute(args: &BumpArgs, cli: &super::Cli) -> anyhow::Result<()> {
+ let working_dir = match &cli.working_dir {
+ Some(dir) => PathBuf::from(dir),
+ None => std::env::current_dir()?,
+ };
+
+ let composer_json_path = working_dir.join("composer.json");
+ let lock_path = working_dir.join("composer.lock");
+
+ // Ensure composer.json exists
+ if !composer_json_path.exists() {
+ anyhow::bail!("No composer.json found in {}", working_dir.display());
+ }
+
+ // Read composer.json content (raw string for hash computation)
+ let composer_json_content = std::fs::read_to_string(&composer_json_path)?;
+
+ // Parse composer.json
+ let mut root: crate::package::RawPackageData = serde_json::from_str(&composer_json_content)?;
+
+ // Warn if package is not a project (libraries shouldn't bump)
+ if let Some(ref pkg_type) = root.package_type
+ && pkg_type != "project"
+ {
+ eprintln!(
+ "{}",
+ crate::console::warning(&format!(
+ "Warning: Bumping constraints for a non-project package (type=\"{pkg_type}\"). \
+ Libraries should not pin their dependencies."
+ ))
+ );
+ }
+
+ // Check lock file existence
+ if !lock_path.exists() {
+ anyhow::bail!("No composer.lock found. Run `mozart install` first.");
+ }
+
+ // Read and parse lock file
+ let lock = crate::lockfile::LockFile::read_from_file(&lock_path)?;
+
+ // Check lock file freshness
+ if !lock.is_fresh(&composer_json_content) {
+ eprintln!(
+ "{}",
+ crate::console::error(
+ "composer.lock is not up to date with composer.json. \
+ Run `mozart install` or `mozart update` to refresh it."
+ )
+ );
+ std::process::exit(2);
+ }
+
+ // Build map: package name (lowercase) → (pretty_version, version_normalized)
+ let locked_versions = build_locked_versions_map(&lock);
+
+ // Determine which sections to process
+ let bump_require = !args.dev_only;
+ let bump_require_dev = !args.no_dev_only;
+
+ // Package filter (if specified)
+ let package_filter: Option<Vec<String>> = if args.packages.is_empty() {
+ None
+ } else {
+ Some(args.packages.iter().map(|p| p.to_lowercase()).collect())
+ };
+
+ // Collect changes
+ let mut require_changes: Vec<(String, String, String)> = Vec::new(); // (name, old, new)
+ let mut require_dev_changes: Vec<(String, String, String)> = Vec::new();
+
+ // Process require
+ if bump_require {
+ for (pkg_name, constraint) in &root.require {
+ if is_platform_package(pkg_name) {
+ continue;
+ }
+ if let Some(ref filter) = package_filter
+ && !filter.contains(&pkg_name.to_lowercase())
+ {
+ continue;
+ }
+ if let Some((pretty_version, version_normalized)) =
+ locked_versions.get(&pkg_name.to_lowercase())
+ && let Some(new_constraint) = crate::version_bumper::bump_requirement(
+ constraint,
+ pretty_version,
+ version_normalized.as_deref(),
+ )
+ {
+ require_changes.push((pkg_name.clone(), constraint.clone(), new_constraint));
+ }
+ }
+ }
+
+ // Process require-dev
+ if bump_require_dev {
+ for (pkg_name, constraint) in &root.require_dev {
+ if is_platform_package(pkg_name) {
+ continue;
+ }
+ if let Some(ref filter) = package_filter
+ && !filter.contains(&pkg_name.to_lowercase())
+ {
+ continue;
+ }
+ if let Some((pretty_version, version_normalized)) =
+ locked_versions.get(&pkg_name.to_lowercase())
+ && let Some(new_constraint) = crate::version_bumper::bump_requirement(
+ constraint,
+ pretty_version,
+ version_normalized.as_deref(),
+ )
+ {
+ require_dev_changes.push((pkg_name.clone(), constraint.clone(), new_constraint));
+ }
+ }
+ }
+
+ let total_changes = require_changes.len() + require_dev_changes.len();
+
+ if total_changes == 0 {
+ println!("Nothing to bump.");
+ return Ok(());
+ }
+
+ // Print what would change
+ for (name, old, new) in require_changes.iter().chain(require_dev_changes.iter()) {
+ if args.dry_run {
+ println!(
+ "{}: {} → {}",
+ crate::console::info(name),
+ old,
+ crate::console::comment(new)
+ );
+ } else {
+ println!(
+ "Bumping {} from {} to {}",
+ crate::console::info(name),
+ old,
+ crate::console::comment(new)
+ );
+ }
+ }
+
+ if args.dry_run {
+ println!("\n{} constraint(s) would be bumped.", total_changes);
+ return Ok(());
+ }
+
+ // Apply changes to root package
+ for (name, _old, new) in &require_changes {
+ root.require.insert(name.clone(), new.clone());
+ }
+ for (name, _old, new) in &require_dev_changes {
+ root.require_dev.insert(name.clone(), new.clone());
+ }
+
+ // Write updated composer.json
+ crate::package::write_to_file(&root, &composer_json_path)?;
+
+ // Update the lock file content-hash to match the new composer.json
+ let new_composer_json_content = std::fs::read_to_string(&composer_json_path)?;
+ let new_hash = crate::lockfile::LockFile::compute_content_hash(&new_composer_json_content)?;
+ let mut updated_lock = lock;
+ updated_lock.content_hash = new_hash;
+ updated_lock.write_to_file(&lock_path)?;
+
+ println!(
+ "\n{}",
+ crate::console::info(&format!(
+ "{} constraint(s) bumped successfully.",
+ total_changes
+ ))
+ );
+
+ Ok(())
+}
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+/// Build a map of lowercase package names to (pretty_version, version_normalized) from composer.lock.
+fn build_locked_versions_map(
+ lock: &crate::lockfile::LockFile,
+) -> HashMap<String, (String, Option<String>)> {
+ let mut map: HashMap<String, (String, Option<String>)> = HashMap::new();
+
+ let all_packages = lock
+ .packages
+ .iter()
+ .chain(lock.packages_dev.as_deref().unwrap_or(&[]));
+
+ for pkg in all_packages {
+ map.insert(
+ pkg.name.to_lowercase(),
+ (pkg.version.clone(), pkg.version_normalized.clone()),
+ );
+ }
+
+ map
+}
+
+/// Returns true if the package name is a platform requirement (php, ext-*, lib-*, etc.).
+fn is_platform_package(name: &str) -> bool {
+ let lower = name.to_lowercase();
+ lower == "php"
+ || lower.starts_with("ext-")
+ || lower.starts_with("lib-")
+ || lower == "php-64bit"
+ || lower == "php-ipv6"
+ || lower == "php-zts"
+ || lower == "php-debug"
+}
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::lockfile::{LockFile, LockedPackage};
+ use std::collections::BTreeMap;
+ use tempfile::tempdir;
+
+ fn minimal_lock(packages: Vec<LockedPackage>, packages_dev: Vec<LockedPackage>) -> LockFile {
+ LockFile {
+ readme: LockFile::default_readme(),
+ content_hash: "placeholder".to_string(),
+ packages,
+ packages_dev: Some(packages_dev),
+ aliases: vec![],
+ minimum_stability: "stable".to_string(),
+ stability_flags: serde_json::json!({}),
+ prefer_stable: false,
+ prefer_lowest: false,
+ platform: serde_json::json!({}),
+ platform_dev: serde_json::json!({}),
+ plugin_api_version: Some("2.6.0".to_string()),
+ }
+ }
+
+ fn make_locked_package(name: &str, version: &str) -> LockedPackage {
+ LockedPackage {
+ name: name.to_string(),
+ version: version.to_string(),
+ version_normalized: Some(format!("{version}.0")),
+ source: None,
+ dist: None,
+ require: BTreeMap::new(),
+ require_dev: BTreeMap::new(),
+ conflict: BTreeMap::new(),
+ suggest: None,
+ package_type: None,
+ autoload: None,
+ autoload_dev: None,
+ license: None,
+ description: None,
+ homepage: None,
+ keywords: None,
+ authors: None,
+ support: None,
+ funding: None,
+ time: None,
+ extra_fields: BTreeMap::new(),
+ }
+ }
+
+ fn write_composer_json(dir: &std::path::Path, content: &str) {
+ std::fs::write(dir.join("composer.json"), content).unwrap();
+ }
+
+ fn write_lock_with_hash(dir: &std::path::Path, mut lock: LockFile, composer_json: &str) {
+ let hash = LockFile::compute_content_hash(composer_json).unwrap();
+ lock.content_hash = hash;
+ lock.write_to_file(&dir.join("composer.lock")).unwrap();
+ }
+
+ fn make_cli(working_dir: &std::path::Path) -> super::super::Cli {
+ super::super::Cli {
+ command: super::super::Commands::Bump(BumpArgs {
+ packages: vec![],
+ dev_only: false,
+ no_dev_only: false,
+ dry_run: false,
+ }),
+ verbose: 0,
+ profile: false,
+ no_plugins: false,
+ no_scripts: false,
+ working_dir: Some(working_dir.to_str().unwrap().to_string()),
+ no_cache: false,
+ no_interaction: false,
+ quiet: false,
+ ansi: false,
+ no_ansi: false,
+ }
+ }
+
+ // ── Basic bump ─────────────────────────────────────────────────────────
+
+ #[test]
+ fn test_basic_bump_modifies_composer_json() {
+ let dir = tempdir().unwrap();
+ let composer_json = r#"{
+ "name": "test/project",
+ "type": "project",
+ "require": {
+ "psr/log": "^1.0"
+ }
+}"#;
+ write_composer_json(dir.path(), composer_json);
+
+ let lock = minimal_lock(vec![make_locked_package("psr/log", "1.1.4")], vec![]);
+ write_lock_with_hash(dir.path(), lock, composer_json);
+
+ let args = BumpArgs {
+ packages: vec![],
+ dev_only: false,
+ no_dev_only: false,
+ dry_run: false,
+ };
+ let cli = make_cli(dir.path());
+ execute(&args, &cli).unwrap();
+
+ let updated = std::fs::read_to_string(dir.path().join("composer.json")).unwrap();
+ let parsed: serde_json::Value = serde_json::from_str(&updated).unwrap();
+ assert_eq!(parsed["require"]["psr/log"], "^1.1.4");
+ }
+
+ // ── Dry run ────────────────────────────────────────────────────────────
+
+ #[test]
+ fn test_dry_run_does_not_modify_files() {
+ let dir = tempdir().unwrap();
+ let composer_json = r#"{
+ "name": "test/project",
+ "type": "project",
+ "require": {
+ "psr/log": "^1.0"
+ }
+}"#;
+ write_composer_json(dir.path(), composer_json);
+
+ let lock = minimal_lock(vec![make_locked_package("psr/log", "1.1.4")], vec![]);
+ write_lock_with_hash(dir.path(), lock, composer_json);
+
+ let args = BumpArgs {
+ packages: vec![],
+ dev_only: false,
+ no_dev_only: false,
+ dry_run: true,
+ };
+ let cli = make_cli(dir.path());
+ execute(&args, &cli).unwrap();
+
+ // composer.json should be unchanged
+ let content = std::fs::read_to_string(dir.path().join("composer.json")).unwrap();
+ let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
+ assert_eq!(parsed["require"]["psr/log"], "^1.0");
+ }
+
+ // ── No changes ─────────────────────────────────────────────────────────
+
+ #[test]
+ fn test_no_changes_when_already_bumped() {
+ let dir = tempdir().unwrap();
+ let composer_json = r#"{
+ "name": "test/project",
+ "type": "project",
+ "require": {
+ "psr/log": "^1.1.4"
+ }
+}"#;
+ write_composer_json(dir.path(), composer_json);
+
+ let lock = minimal_lock(vec![make_locked_package("psr/log", "1.1.4")], vec![]);
+ write_lock_with_hash(dir.path(), lock, composer_json);
+
+ let args = BumpArgs {
+ packages: vec![],
+ dev_only: false,
+ no_dev_only: false,
+ dry_run: false,
+ };
+ let cli = make_cli(dir.path());
+ execute(&args, &cli).unwrap();
+
+ // No changes should be made
+ let content = std::fs::read_to_string(dir.path().join("composer.json")).unwrap();
+ let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
+ assert_eq!(parsed["require"]["psr/log"], "^1.1.4");
+ }
+
+ // ── Dev-only flag ──────────────────────────────────────────────────────
+
+ #[test]
+ fn test_dev_only_flag_only_bumps_require_dev() {
+ let dir = tempdir().unwrap();
+ let composer_json = r#"{
+ "name": "test/project",
+ "type": "project",
+ "require": {
+ "psr/log": "^1.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.0"
+ }
+}"#;
+ write_composer_json(dir.path(), composer_json);
+
+ let lock = minimal_lock(
+ vec![make_locked_package("psr/log", "1.1.4")],
+ vec![make_locked_package("phpunit/phpunit", "9.5.0")],
+ );
+ write_lock_with_hash(dir.path(), lock, composer_json);
+
+ let args = BumpArgs {
+ packages: vec![],
+ dev_only: true,
+ no_dev_only: false,
+ dry_run: false,
+ };
+ let cli = make_cli(dir.path());
+ execute(&args, &cli).unwrap();
+
+ let content = std::fs::read_to_string(dir.path().join("composer.json")).unwrap();
+ let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
+ // require should NOT be bumped
+ assert_eq!(parsed["require"]["psr/log"], "^1.0");
+ // require-dev should be bumped
+ assert_eq!(parsed["require-dev"]["phpunit/phpunit"], "^9.5");
+ }
+
+ // ── No-dev-only flag ───────────────────────────────────────────────────
+
+ #[test]
+ fn test_no_dev_only_flag_only_bumps_require() {
+ let dir = tempdir().unwrap();
+ let composer_json = r#"{
+ "name": "test/project",
+ "type": "project",
+ "require": {
+ "psr/log": "^1.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.0"
+ }
+}"#;
+ write_composer_json(dir.path(), composer_json);
+
+ let lock = minimal_lock(
+ vec![make_locked_package("psr/log", "1.1.4")],
+ vec![make_locked_package("phpunit/phpunit", "9.5.0")],
+ );
+ write_lock_with_hash(dir.path(), lock, composer_json);
+
+ let args = BumpArgs {
+ packages: vec![],
+ dev_only: false,
+ no_dev_only: true,
+ dry_run: false,
+ };
+ let cli = make_cli(dir.path());
+ execute(&args, &cli).unwrap();
+
+ let content = std::fs::read_to_string(dir.path().join("composer.json")).unwrap();
+ let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
+ // require should be bumped
+ assert_eq!(parsed["require"]["psr/log"], "^1.1.4");
+ // require-dev should NOT be bumped
+ assert_eq!(parsed["require-dev"]["phpunit/phpunit"], "^9.0");
+ }
+
+ // ── Stale lock file ────────────────────────────────────────────────────
+
+ #[test]
+ fn test_stale_lock_file_produces_exit_code_2() {
+ let dir = tempdir().unwrap();
+ let composer_json = r#"{
+ "name": "test/project",
+ "require": {
+ "psr/log": "^1.0"
+ }
+}"#;
+ write_composer_json(dir.path(), composer_json);
+
+ // Write lock with a wrong hash (stale)
+ let mut lock = minimal_lock(vec![make_locked_package("psr/log", "1.1.4")], vec![]);
+ lock.content_hash = "wrong_hash_here".to_string();
+ lock.write_to_file(&dir.path().join("composer.lock"))
+ .unwrap();
+
+ let _args = BumpArgs {
+ packages: vec![],
+ dev_only: false,
+ no_dev_only: false,
+ dry_run: false,
+ };
+ let _cli = make_cli(dir.path());
+
+ // The execute function calls std::process::exit(2) for stale lock.
+ // We can't test that directly, but we can verify the lock IS stale
+ let lock_loaded = LockFile::read_from_file(&dir.path().join("composer.lock")).unwrap();
+ assert!(!lock_loaded.is_fresh(composer_json));
+ }
+
+ // ── Platform packages skipped ──────────────────────────────────────────
+
+ #[test]
+ fn test_platform_packages_are_skipped() {
+ assert!(is_platform_package("php"));
+ assert!(is_platform_package("ext-json"));
+ assert!(is_platform_package("ext-mbstring"));
+ assert!(is_platform_package("lib-pcre"));
+ assert!(!is_platform_package("psr/log"));
+ assert!(!is_platform_package("monolog/monolog"));
+ }
+
+ // ── Lock file hash updated ─────────────────────────────────────────────
+
+ #[test]
+ fn test_lock_file_hash_updated_after_bump() {
+ let dir = tempdir().unwrap();
+ let composer_json = r#"{
+ "name": "test/project",
+ "type": "project",
+ "require": {
+ "psr/log": "^1.0"
+ }
+}"#;
+ write_composer_json(dir.path(), composer_json);
+
+ let lock = minimal_lock(vec![make_locked_package("psr/log", "1.1.4")], vec![]);
+ write_lock_with_hash(dir.path(), lock, composer_json);
+
+ let args = BumpArgs {
+ packages: vec![],
+ dev_only: false,
+ no_dev_only: false,
+ dry_run: false,
+ };
+ let cli = make_cli(dir.path());
+ execute(&args, &cli).unwrap();
+
+ // The lock file content-hash should now match the updated composer.json
+ let updated_composer = std::fs::read_to_string(dir.path().join("composer.json")).unwrap();
+ let updated_lock = LockFile::read_from_file(&dir.path().join("composer.lock")).unwrap();
+ assert!(
+ updated_lock.is_fresh(&updated_composer),
+ "Lock file hash should be updated to match new composer.json"
+ );
+ }
+
+ // ── Package filter ─────────────────────────────────────────────────────
+
+ #[test]
+ fn test_package_filter_only_bumps_specified_packages() {
+ let dir = tempdir().unwrap();
+ let composer_json = r#"{
+ "name": "test/project",
+ "type": "project",
+ "require": {
+ "psr/log": "^1.0",
+ "psr/http-message": "^1.0"
+ }
+}"#;
+ write_composer_json(dir.path(), composer_json);
+
+ let lock = minimal_lock(
+ vec![
+ make_locked_package("psr/log", "1.1.4"),
+ make_locked_package("psr/http-message", "1.2.0"),
+ ],
+ vec![],
+ );
+ write_lock_with_hash(dir.path(), lock, composer_json);
+
+ let args = BumpArgs {
+ packages: vec!["psr/log".to_string()],
+ dev_only: false,
+ no_dev_only: false,
+ dry_run: false,
+ };
+ let cli = make_cli(dir.path());
+ execute(&args, &cli).unwrap();
+
+ let content = std::fs::read_to_string(dir.path().join("composer.json")).unwrap();
+ let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
+ assert_eq!(parsed["require"]["psr/log"], "^1.1.4");
+ // psr/http-message should NOT be bumped
+ assert_eq!(parsed["require"]["psr/http-message"], "^1.0");
+ }
}
diff --git a/crates/mozart/src/lib.rs b/crates/mozart/src/lib.rs
index 19b44c4..954ef0e 100644
--- a/crates/mozart/src/lib.rs
+++ b/crates/mozart/src/lib.rs
@@ -12,3 +12,4 @@ pub mod php_scanner;
pub mod resolver;
pub mod validation;
pub mod version;
+pub mod version_bumper;
diff --git a/crates/mozart/src/version_bumper.rs b/crates/mozart/src/version_bumper.rs
new file mode 100644
index 0000000..43c21d6
--- /dev/null
+++ b/crates/mozart/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]);
+ }
+}