diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-21 17:46:03 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-21 17:46:03 +0900 |
| commit | 597a0711ae09fb47ee1889ccaaa6a38055494478 (patch) | |
| tree | 75b8c91827c13b410eeef17e5bacbe751e6870bd /crates/mozart/src/commands/bump.rs | |
| parent | 42b024a6a7a093f598e7e3448b5b3e29332bb064 (diff) | |
| download | php-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/commands/bump.rs')
| -rw-r--r-- | crates/mozart/src/commands/bump.rs | 597 |
1 files changed, 595 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"); + } } |
