diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-08 21:45:02 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-08 21:45:02 +0900 |
| commit | 9c2396134465613d3c650e881219572aecc777a5 (patch) | |
| tree | 30ef4992d3f346a332771e80236a88f816501036 | |
| parent | 6d853db4e74f07abe480ab9532c914ba94623dc0 (diff) | |
| download | php-mozart-9c2396134465613d3c650e881219572aecc777a5.tar.gz php-mozart-9c2396134465613d3c650e881219572aecc777a5.tar.zst php-mozart-9c2396134465613d3c650e881219572aecc777a5.zip | |
fix(bump): align with Composer's BumpCommand pipeline
Splits execute() into execute → do_bump → update_file_cleanly mirroring
Composer's structure, switches state loading to Composer::require, adds
the no-lock fallback to vendor/composer/installed.json, routes warnings
and errors through stderr, and matches Composer's wording verbatim.
JsonManipulator is deferred — update_file_cleanly always falls back to
a full structured rewrite for now, documented in known-incompatibilities.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| -rw-r--r-- | crates/mozart/src/commands/bump.rs | 648 | ||||
| -rw-r--r-- | docs/known-incompatibilities.md | 9 |
2 files changed, 342 insertions, 315 deletions
diff --git a/crates/mozart/src/commands/bump.rs b/crates/mozart/src/commands/bump.rs index 22b251e..a4c71b8 100644 --- a/crates/mozart/src/commands/bump.rs +++ b/crates/mozart/src/commands/bump.rs @@ -1,9 +1,13 @@ use clap::Args; use indexmap::IndexMap; +use mozart_core::composer::{Composer, LocalRepository}; +use mozart_core::console::Console; use mozart_core::console_format; -use mozart_core::console_writeln; +use mozart_core::{console_writeln, console_writeln_error}; +use std::collections::BTreeMap; +use std::path::Path; -/// Exit code for stale lock file (matches Composer's BumpCommand::ERROR_LOCK_OUTDATED) +/// Exit code for stale lock file (matches Composer's BumpCommand::ERROR_LOCK_OUTDATED). const ERROR_LOCK_OUTDATED: i32 = 2; #[derive(Args)] @@ -24,215 +28,335 @@ pub struct BumpArgs { pub dry_run: bool, } -pub async fn execute( - args: &BumpArgs, - cli: &super::Cli, - console: &mozart_core::console::Console, -) -> anyhow::Result<()> { +pub async fn execute(args: &BumpArgs, cli: &super::Cli, console: &Console) -> anyhow::Result<()> { let working_dir = cli.working_dir()?; + let composer = Composer::require(&working_dir)?; - let composer_json_path = working_dir.join("composer.json"); - let lock_path = working_dir.join("composer.lock"); + let exit = do_bump( + console, + &composer, + args.dev_only, + args.no_dev_only, + args.dry_run, + &args.packages, + "--dev-only", + ) + .await?; - // Ensure composer.json exists - if !composer_json_path.exists() { - anyhow::bail!("No composer.json found in {}", working_dir.display()); + if exit != 0 { + return Err(mozart_core::exit_code::bail_silent(exit)); } + Ok(()) +} - // Read composer.json content (raw string for hash computation) - let composer_json_content = std::fs::read_to_string(&composer_json_path)?; +/// Mirrors `Composer\Command\BumpCommand::doBump`. Returns the exit code +/// (0 / `ERROR_GENERIC` / `ERROR_LOCK_OUTDATED`). +/// +/// `dev_only_flag_hint` is the option name shown in the `Alternatively you can use {hint}` +/// warning when the package has no `type` set. `bump` itself passes `--dev-only`; +/// `update --bump` will pass its own combined option name once that command is ported. +pub async fn do_bump( + io: &Console, + composer: &Composer, + dev_only: bool, + no_dev_only: bool, + dry_run: bool, + packages_filter: &[String], + dev_only_flag_hint: &str, +) -> anyhow::Result<i32> { + let composer_json_path = composer.project_dir().join("composer.json"); - // Parse composer.json - let mut root: mozart_core::package::RawPackageData = - serde_json::from_str(&composer_json_content)?; + if !is_readable(&composer_json_path) { + console_writeln_error!( + io, + &console_format!( + "<error>{} is not readable.</error>", + composer_json_path.display() + ), + ); + return Ok(mozart_core::exit_code::GENERAL_ERROR); + } - // Warn if package is not a project (libraries shouldn't bump) - match root.package_type.as_deref() { - Some("project") => {} - Some(pkg_type) => { - console.info(&console_format!("<warning>Warning: Bumping constraints for a non-project package (type=\"{pkg_type}\"). Libraries should not pin their dependencies.</warning>")); - } - None if !args.dev_only => { - console.info(&console_format!("<warning>Warning: Bumping constraints for a non-project package. No type was set so it defaults to \"library\". Libraries should not pin their dependencies. Consider using --dev-only or setting the type to \"project\".</warning>")); + let contents = match std::fs::read_to_string(&composer_json_path) { + Ok(c) => c, + Err(_) => { + console_writeln_error!( + io, + &console_format!( + "<error>{} is not readable.</error>", + composer_json_path.display() + ), + ); + return Ok(mozart_core::exit_code::GENERAL_ERROR); } - None => {} - } + }; - // Check lock file existence - if !lock_path.exists() { - anyhow::bail!("No composer.lock found. Run `mozart install` first."); + if !is_writable(&composer_json_path) { + console_writeln_error!( + io, + &console_format!( + "<error>{} is not writable.</error>", + composer_json_path.display() + ), + ); + return Ok(mozart_core::exit_code::GENERAL_ERROR); } - // Read and parse lock file - let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; + // Mirrors Composer's `$hasLockfileDisabled = !$config->has('lock') || $config->get('lock')`. + // The PHP variable is named "hasLockfileDisabled" but its value is *true* when the + // lock is enabled (default) — i.e. the name is upstream-confusing. Mozart's + // `Config::lock` is a `bool` (defaults to `true`), so the equivalent is just the field. + let lock_enabled = composer.config().lock; + let lock_path = composer.locker().lock_file_path(); - // Check lock file freshness - if !lock.is_fresh(&composer_json_content) { - return Err(mozart_core::exit_code::bail( - ERROR_LOCK_OUTDATED, - "composer.lock is not up to date with composer.json. \ - Run `mozart install` or `mozart update` to refresh it.", - )); - } + let locked_versions: IndexMap<String, (String, Option<String>)> = if !lock_enabled { + // Composer always reaches for the locker here, even though `lock` is disabled. + // Mirror that: if a lockfile exists on disk we use it; otherwise we fall back + // to an empty map (`getLockedRepository` would throw in PHP — Mozart degrades + // gracefully because `bump` has nothing to bump in that case anyway). + if composer.locker().is_locked() { + let lock = mozart_registry::lockfile::LockFile::read_from_file(lock_path)?; + build_locked_versions_from_lock(&lock) + } else { + IndexMap::new() + } + } else if composer.locker().is_locked() { + let lock = mozart_registry::lockfile::LockFile::read_from_file(lock_path)?; + if !lock.is_fresh(&contents) { + console_writeln_error!( + io, + &console_format!( + "<error>The lock file is not up to date with the latest changes in composer.json. Run the appropriate `update` to fix that before you use the `bump` command.</error>" + ), + ); + return Ok(ERROR_LOCK_OUTDATED); + } + build_locked_versions_from_lock(&lock) + } else { + build_locked_versions_from_local(composer.repository_manager().local_repository()) + }; - // Build map: package name (lowercase) → (pretty_version, version_normalized) - let locked_versions = build_locked_versions_map(&lock); + let package_type = composer.package().package_type.as_deref(); + if package_type != Some("project") && !dev_only { + console_writeln_error!( + io, + &console_format!( + "<warning>Warning: Bumping dependency constraints is not recommended for libraries as it will narrow down your dependencies and may cause problems for your users.</warning>" + ), + ); + if package_type.is_none() { + console_writeln_error!( + io, + &console_format!( + "<warning>If your package is not a library, you can explicitly specify the \"type\" by using \"composer config type project\".</warning>" + ), + ); + console_writeln_error!( + io, + &console_format!( + "<warning>Alternatively you can use {dev_only_flag_hint} to only bump dependencies within \"require-dev\".</warning>" + ), + ); + } + } - // Determine which sections to process - let bump_require = !args.dev_only; - let bump_require_dev = !args.no_dev_only; + let mut tasks: Vec<(&'static str, &BTreeMap<String, String>)> = Vec::new(); + if !dev_only { + tasks.push(("require", &composer.package().require)); + } + if !no_dev_only { + tasks.push(("require-dev", &composer.package().require_dev)); + } - // Package filter (if specified) - let package_filter: Option<Vec<String>> = if args.packages.is_empty() { + let stripped_filter: Option<Vec<String>> = if packages_filter.is_empty() { None } else { - Some( - args.packages - .iter() - .map(|p| strip_inline_constraint(p).to_lowercase()) - .collect(), - ) + let mut filtered: Vec<String> = packages_filter + .iter() + .map(|p| strip_inline_constraint(p).to_lowercase()) + .collect(); + filtered.sort(); + filtered.dedup(); + Some(filtered) }; - // 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(); + let mut updates: BTreeMap<&'static str, BTreeMap<String, String>> = BTreeMap::new(); - // Process require - if bump_require { - for (pkg_name, constraint) in &root.require { - if is_platform_package(pkg_name) { + for (key, reqs) in &tasks { + for (pkg_name, constraint) in reqs.iter() { + if mozart_core::platform::is_platform_package(pkg_name) { continue; } - if let Some(ref filter) = package_filter - && !matches_filter(filter, pkg_name) + if let Some(ref filter) = stripped_filter + && !filter + .iter() + .any(|pat| mozart_core::matches_wildcard(pkg_name, pat)) { continue; } - if let Some((pretty_version, version_normalized)) = + let Some((pretty_version, version_normalized)) = locked_versions.get(&pkg_name.to_lowercase()) - && let Some(new_constraint) = mozart_core::version_bumper::bump_requirement( - constraint, - pretty_version, - version_normalized.as_deref(), - ) - { - require_changes.push((pkg_name.clone(), constraint.clone(), new_constraint)); + else { + continue; + }; + let Some(new_constraint) = mozart_core::version_bumper::bump_requirement( + constraint, + pretty_version, + version_normalized.as_deref(), + ) else { + continue; + }; + if &new_constraint == constraint { + continue; } + updates + .entry(*key) + .or_default() + .insert(pkg_name.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 - && !matches_filter(filter, pkg_name) - { - continue; - } - if let Some((pretty_version, version_normalized)) = - locked_versions.get(&pkg_name.to_lowercase()) - && let Some(new_constraint) = mozart_core::version_bumper::bump_requirement( - constraint, - pretty_version, - version_normalized.as_deref(), - ) - { - require_dev_changes.push((pkg_name.clone(), constraint.clone(), new_constraint)); + if !dry_run && !update_file_cleanly(&composer_json_path, &updates)? { + let mut composer_definition: mozart_core::package::RawPackageData = + serde_json::from_str(&std::fs::read_to_string(&composer_json_path)?)?; + for (key, packages) in &updates { + for (package, version) in packages { + match *key { + "require" => { + composer_definition + .require + .insert(package.clone(), version.clone()); + } + "require-dev" => { + composer_definition + .require_dev + .insert(package.clone(), version.clone()); + } + _ => unreachable!(), + } } } + mozart_core::package::write_to_file(&composer_definition, &composer_json_path)?; } - let total_changes = require_changes.len() + require_dev_changes.len(); - - if total_changes == 0 { + let change_count: usize = updates.values().map(|m| m.len()).sum(); + if change_count > 0 { + if dry_run { + console_writeln!( + io, + &console_format!( + "<info>{} would be updated with:</info>", + composer_json_path.display() + ), + ); + for (require_type, packages) in &updates { + for (package, version) in packages { + console_writeln!( + io, + &console_format!("<info> - {require_type}.{package}: {version}</info>"), + ); + } + } + } else { + console_writeln!( + io, + &console_format!( + "<info>{} has been updated ({change_count} changes).</info>", + composer_json_path.display() + ), + ); + } + } else { console_writeln!( - console, + io, &console_format!( "<info>No requirements to update in {}.</info>", composer_json_path.display() ), ); - return Ok(()); } - if args.dry_run { - console_writeln!( - console, - &console_format!( - "<info>{} would be updated with:</info>", - composer_json_path.display() - ), - ); - for (name, _old, new) in &require_changes { - console_writeln!( - console, - &console_format!("<info> - require.{name}: {new}</info>"), - ); - } - for (name, _old, new) in &require_dev_changes { - console_writeln!( - console, - &console_format!("<info> - require-dev.{name}: {new}</info>"), - ); - } - // Return exit code 1 when dry-run detects changes (useful for CI to detect un-bumped constraints) - return Err(mozart_core::exit_code::bail_silent( - mozart_core::exit_code::GENERAL_ERROR, - )); + if !dry_run && composer.locker().is_locked() && composer.config().lock && change_count > 0 { + update_lock_hash(lock_path, &composer_json_path)?; } - // 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()); + if dry_run && change_count > 0 { + return Ok(mozart_core::exit_code::GENERAL_ERROR); } - // Write updated composer.json - mozart_core::package::write_to_file(&root, &composer_json_path)?; + Ok(0) +} + +/// Mirrors `BumpCommand::updateFileCleanly`. Returns `Ok(true)` on a clean, +/// formatting-preserving write; `Ok(false)` when the caller must fall back +/// to a full structured rewrite of `composer.json`. +/// +/// Mozart does not yet have a `JsonManipulator` port, so this always returns +/// `Ok(false)` and the caller falls back. See `docs/known-incompatibilities.md`. +fn update_file_cleanly( + _path: &Path, + _updates: &BTreeMap<&'static str, BTreeMap<String, String>>, +) -> anyhow::Result<bool> { + Ok(false) +} - // 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)?; +/// Recompute the lock file's `content-hash` to match `composer_json_path`. +/// Mirrors `Locker::updateHash`, which `BumpCommand::doBump` calls after a +/// successful in-place edit so the lockfile stays "fresh" for the next install. +fn update_lock_hash(lock_path: &Path, composer_json_path: &Path) -> anyhow::Result<()> { + let new_composer_json_content = std::fs::read_to_string(composer_json_path)?; let new_hash = mozart_registry::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)?; + let mut lock = mozart_registry::lockfile::LockFile::read_from_file(lock_path)?; + lock.content_hash = new_hash; + lock.write_to_file(lock_path)?; + Ok(()) +} - console_writeln!( - console, - &console_format!( - "<info>{} has been updated ({total_changes} changes).</info>", - composer_json_path.display() - ), - ); +fn is_readable(path: &Path) -> bool { + std::fs::File::open(path).is_ok() +} - Ok(()) +fn is_writable(path: &Path) -> bool { + match std::fs::metadata(path) { + Ok(m) => !m.permissions().readonly(), + Err(_) => false, + } } -/// Build a map of lowercase package names to (pretty_version, version_normalized) from composer.lock. -fn build_locked_versions_map( +/// Build a map of lowercase package names to (pretty_version, version_normalized) +/// from a parsed `composer.lock`. +fn build_locked_versions_from_lock( lock: &mozart_registry::lockfile::LockFile, ) -> IndexMap<String, (String, Option<String>)> { let mut map: IndexMap<String, (String, Option<String>)> = IndexMap::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 +} +/// Build a map of lowercase package names to (pretty_version, None) from +/// the local repository (`vendor/composer/installed.json`). Used as the +/// fallback when no `composer.lock` is present, mirroring Composer's +/// `getRepositoryManager()->getLocalRepository()` branch. +fn build_locked_versions_from_local( + repo: &LocalRepository, +) -> IndexMap<String, (String, Option<String>)> { + let mut map: IndexMap<String, (String, Option<String>)> = IndexMap::new(); + for pkg in repo.canonical_packages() { + map.insert( + pkg.pretty_name().to_lowercase(), + (pkg.pretty_version().to_string(), None), + ); + } map } @@ -241,79 +365,17 @@ fn build_locked_versions_map( /// Composer allows arguments like `vendor/pkg:^2.0`, `vendor/pkg=2.0`, or /// `vendor/pkg ^2.0`. This function strips everything from the first `:`, /// `=`, or ` ` character onward, returning just the package name portion. +/// Mirrors `Preg::replace('{[:= ].+}', '', $constraint)`. fn strip_inline_constraint(arg: &str) -> &str { arg.find([':', '=', ' ']) .map(|pos| &arg[..pos]) .unwrap_or(arg) } -/// Returns true if `name` matches any of the glob patterns in `filter`. -/// -/// Patterns may contain `*` wildcards (e.g. `psr/*`, `symfony/*`). -/// Matching is case-insensitive. Exact patterns are also supported. -fn matches_filter(filter: &[String], name: &str) -> bool { - let name_lower = name.to_lowercase(); - filter.iter().any(|pat| glob_matches(pat, &name_lower)) -} - -/// Match a single package name against a glob pattern. -/// -/// Only `*` wildcards are supported (matches any sequence of characters within -/// a path segment). Matching is case-insensitive. -/// - `psr/*` matches `psr/log`, `psr/container` -/// - `symfony/*` matches `symfony/console`, `symfony/http-kernel` -fn glob_matches(pattern: &str, name: &str) -> bool { - // Fast path: no wildcard - if !pattern.contains('*') { - return pattern == name; - } - let pat_parts: Vec<&str> = pattern.splitn(2, '/').collect(); - let name_parts: Vec<&str> = name.splitn(2, '/').collect(); - if pat_parts.len() != name_parts.len() { - return false; - } - pat_parts - .iter() - .zip(name_parts.iter()) - .all(|(pp, np)| glob_segment_matches(pp, np)) -} - -/// Match a single path segment against a pattern segment (no `/` involved). -/// `*` matches any sequence of characters (including empty). Both inputs are -/// already lowercased before being passed here. -fn glob_segment_matches(pattern: &str, text: &str) -> bool { - glob_segment_matches_inner(pattern.as_bytes(), text.as_bytes()) -} - -fn glob_segment_matches_inner(pattern: &[u8], text: &[u8]) -> bool { - match (pattern.first(), text.first()) { - (None, None) => true, - (Some(&b'*'), _) => { - glob_segment_matches_inner(&pattern[1..], text) - || (!text.is_empty() && glob_segment_matches_inner(pattern, &text[1..])) - } - (Some(p), Some(t)) if p == t => glob_segment_matches_inner(&pattern[1..], &text[1..]), - _ => false, - } -} - -/// 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" -} - #[cfg(test)] mod tests { use super::*; use mozart_registry::lockfile::{LockFile, LockedPackage}; - use std::collections::BTreeMap; use tempfile::tempdir; fn minimal_lock(packages: Vec<LockedPackage>, packages_dev: Vec<LockedPackage>) -> LockFile { @@ -393,6 +455,14 @@ mod tests { } } + fn quiet_console() -> Console { + Console { + interactive: false, + verbosity: mozart_core::console::Verbosity::Normal, + decorated: false, + } + } + #[tokio::test] async fn test_basic_bump_modifies_composer_json() { let dir = tempdir().unwrap(); @@ -415,11 +485,7 @@ mod tests { dry_run: false, }; let cli = make_cli(dir.path()); - let console = mozart_core::console::Console { - interactive: false, - verbosity: mozart_core::console::Verbosity::Normal, - decorated: false, - }; + let console = quiet_console(); execute(&args, &cli, &console).await.unwrap(); let updated = std::fs::read_to_string(dir.path().join("composer.json")).unwrap(); @@ -449,11 +515,7 @@ mod tests { dry_run: true, }; let cli = make_cli(dir.path()); - let console = mozart_core::console::Console { - interactive: false, - verbosity: mozart_core::console::Verbosity::Normal, - decorated: false, - }; + let console = quiet_console(); let result = execute(&args, &cli, &console).await; // dry-run with changes returns exit code 1 (for CI usage) @@ -491,11 +553,7 @@ mod tests { dry_run: false, }; let cli = make_cli(dir.path()); - let console = mozart_core::console::Console { - interactive: false, - verbosity: mozart_core::console::Verbosity::Normal, - decorated: false, - }; + let console = quiet_console(); execute(&args, &cli, &console).await.unwrap(); // No changes should be made @@ -532,11 +590,7 @@ mod tests { dry_run: false, }; let cli = make_cli(dir.path()); - let console = mozart_core::console::Console { - interactive: false, - verbosity: mozart_core::console::Verbosity::Normal, - decorated: false, - }; + let console = quiet_console(); execute(&args, &cli, &console).await.unwrap(); let content = std::fs::read_to_string(dir.path().join("composer.json")).unwrap(); @@ -575,11 +629,7 @@ mod tests { dry_run: false, }; let cli = make_cli(dir.path()); - let console = mozart_core::console::Console { - interactive: false, - verbosity: mozart_core::console::Verbosity::Normal, - decorated: false, - }; + let console = quiet_console(); execute(&args, &cli, &console).await.unwrap(); let content = std::fs::read_to_string(dir.path().join("composer.json")).unwrap(); @@ -614,11 +664,7 @@ mod tests { dry_run: false, }; let cli = make_cli(dir.path()); - let console = mozart_core::console::Console { - interactive: false, - verbosity: mozart_core::console::Verbosity::Normal, - decorated: false, - }; + let console = quiet_console(); let result = execute(&args, &cli, &console).await; // stale lock file should return exit code 2 (ERROR_LOCK_OUTDATED) @@ -629,16 +675,6 @@ mod tests { assert_eq!(mozart_err.exit_code, ERROR_LOCK_OUTDATED); } - #[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")); - } - #[tokio::test] async fn test_lock_file_hash_updated_after_bump() { let dir = tempdir().unwrap(); @@ -661,11 +697,7 @@ mod tests { dry_run: false, }; let cli = make_cli(dir.path()); - let console = mozart_core::console::Console { - interactive: false, - verbosity: mozart_core::console::Verbosity::Normal, - decorated: false, - }; + let console = quiet_console(); execute(&args, &cli, &console).await.unwrap(); // The lock file content-hash should now match the updated composer.json @@ -677,6 +709,52 @@ mod tests { ); } + #[tokio::test] + async fn test_no_lock_falls_back_to_local_repository() { + 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); + + // No composer.lock — instead populate vendor/composer/installed.json. + let installed_dir = dir.path().join("vendor/composer"); + std::fs::create_dir_all(&installed_dir).unwrap(); + let installed = serde_json::json!({ + "packages": [ + { + "name": "psr/log", + "version": "1.1.4", + "version_normalized": "1.1.4.0", + } + ], + "dev": false, + }); + std::fs::write( + installed_dir.join("installed.json"), + serde_json::to_string_pretty(&installed).unwrap(), + ) + .unwrap(); + + let args = BumpArgs { + packages: vec![], + dev_only: false, + no_dev_only: false, + dry_run: false, + }; + let cli = make_cli(dir.path()); + let console = quiet_console(); + execute(&args, &cli, &console).await.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"); + } + #[test] fn test_strip_inline_constraint_colon() { assert_eq!(strip_inline_constraint("vendor/pkg:^2.0"), "vendor/pkg"); @@ -698,54 +776,6 @@ mod tests { assert_eq!(strip_inline_constraint("psr/log"), "psr/log"); } - #[test] - fn test_glob_matches_exact() { - assert!(glob_matches("psr/log", "psr/log")); - assert!(!glob_matches("psr/log", "psr/container")); - } - - #[test] - fn test_glob_matches_wildcard_vendor() { - assert!(glob_matches("psr/*", "psr/log")); - assert!(glob_matches("psr/*", "psr/container")); - assert!(!glob_matches("psr/*", "symfony/console")); - } - - #[test] - fn test_glob_matches_wildcard_suffix() { - assert!(glob_matches("monolog/mono*", "monolog/monolog")); - assert!(!glob_matches("monolog/mono*", "monolog/other")); - } - - #[test] - fn test_glob_matches_case_insensitive() { - // pattern is lowercased before being stored; name is also lowercased - assert!(glob_matches("psr/log", "psr/log")); - } - - #[test] - fn test_matches_filter_exact() { - let filter = vec!["psr/log".to_string()]; - assert!(matches_filter(&filter, "psr/log")); - assert!(!matches_filter(&filter, "psr/container")); - } - - #[test] - fn test_matches_filter_glob() { - let filter = vec!["psr/*".to_string()]; - assert!(matches_filter(&filter, "psr/log")); - assert!(matches_filter(&filter, "psr/container")); - assert!(!matches_filter(&filter, "monolog/monolog")); - } - - #[test] - fn test_matches_filter_multiple_patterns() { - let filter = vec!["psr/*".to_string(), "monolog/monolog".to_string()]; - assert!(matches_filter(&filter, "psr/log")); - assert!(matches_filter(&filter, "monolog/monolog")); - assert!(!matches_filter(&filter, "symfony/console")); - } - #[tokio::test] async fn test_package_filter_only_bumps_specified_packages() { let dir = tempdir().unwrap(); @@ -775,11 +805,7 @@ mod tests { dry_run: false, }; let cli = make_cli(dir.path()); - let console = mozart_core::console::Console { - interactive: false, - verbosity: mozart_core::console::Verbosity::Normal, - decorated: false, - }; + let console = quiet_console(); execute(&args, &cli, &console).await.unwrap(); let content = std::fs::read_to_string(dir.path().join("composer.json")).unwrap(); @@ -821,11 +847,7 @@ mod tests { dry_run: false, }; let cli = make_cli(dir.path()); - let console = mozart_core::console::Console { - interactive: false, - verbosity: mozart_core::console::Verbosity::Normal, - decorated: false, - }; + let console = quiet_console(); execute(&args, &cli, &console).await.unwrap(); let content = std::fs::read_to_string(dir.path().join("composer.json")).unwrap(); @@ -867,11 +889,7 @@ mod tests { dry_run: false, }; let cli = make_cli(dir.path()); - let console = mozart_core::console::Console { - interactive: false, - verbosity: mozart_core::console::Verbosity::Normal, - decorated: false, - }; + let console = quiet_console(); execute(&args, &cli, &console).await.unwrap(); let content = std::fs::read_to_string(dir.path().join("composer.json")).unwrap(); diff --git a/docs/known-incompatibilities.md b/docs/known-incompatibilities.md index 5a46e0a..45f0c17 100644 --- a/docs/known-incompatibilities.md +++ b/docs/known-incompatibilities.md @@ -15,3 +15,12 @@ In typical environments this does not matter; both Composer and Mozart trust the - PHP-only ini overrides: `openssl.cafile` / `openssl.capath` configured via `php.ini` affect Composer but have no effect on Mozart. If you rely on a private CA, set `config.cafile` and `config.capath` in `composer.json` (or the global `$COMPOSER_HOME/config.json`). It works in Mozart too. + + +## `bump` rewrites composer.json without preserving comments / key order + +Composer's `bump` command edits `composer.json` through `JsonManipulator`, which keeps unrelated whitespace, comments, and key ordering intact — only the bumped constraint values change in place. + +Mozart does not yet have a `JsonManipulator` port. `bump` therefore falls back to a full structured rewrite of `composer.json` (the same path Composer uses when `JsonManipulator` cannot handle the input). User-authored comments and idiosyncratic key orderings are lost on every run; the resulting file is functionally equivalent but reformatted. + +This will be addressed by porting `JsonManipulator`, which is also needed by `require`, `remove`, `config`, `init`, and `create-project`. |
