diff options
| -rw-r--r-- | crates/mozart/src/commands/bump.rs | 597 | ||||
| -rw-r--r-- | crates/mozart/src/lib.rs | 1 | ||||
| -rw-r--r-- | crates/mozart/src/version_bumper.rs | 667 |
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]); + } +} |
