diff options
Diffstat (limited to 'crates/mozart/src/commands/remove.rs')
| -rw-r--r-- | crates/mozart/src/commands/remove.rs | 818 |
1 files changed, 430 insertions, 388 deletions
diff --git a/crates/mozart/src/commands/remove.rs b/crates/mozart/src/commands/remove.rs index 56555f7..2941867 100644 --- a/crates/mozart/src/commands/remove.rs +++ b/crates/mozart/src/commands/remove.rs @@ -3,7 +3,7 @@ use indexmap::{IndexMap, IndexSet}; use mozart_core::console_format; use mozart_core::console_writeln; use mozart_core::package; -use mozart_core::validation; +use mozart_registry::installed; use mozart_registry::lockfile; use mozart_registry::resolver::{self, PlatformConfig, ResolveRequest}; @@ -48,11 +48,11 @@ pub struct RemoveArgs { #[arg(long)] pub update_no_dev: bool, - /// [Deprecated] Use --with-all-dependencies instead + /// [Deprecated] Use --no-update-with-dependencies instead #[arg(short = 'w', long)] pub update_with_dependencies: bool, - /// [Deprecated] Use --with-all-dependencies instead + /// Alias for --with-all-dependencies #[arg(short = 'W', long)] pub update_with_all_dependencies: bool, @@ -105,17 +105,15 @@ pub async fn execute( let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache); let repo_cache = mozart_registry::cache::Cache::repo(&cache_config); - // Step 1: Validate inputs if args.packages.is_empty() && !args.unused { anyhow::bail!("Not enough arguments (missing: \"packages\")."); } - // Step 2: Handle deprecated flags + // Only -w/--update-with-dependencies is deprecated in Composer; -W is an alias, not deprecated if args.update_with_dependencies { - console.info(&console_format!("<warning>The -w / --update-with-dependencies flag is deprecated. Use --with-all-dependencies instead.</warning>")); - } - if args.update_with_all_dependencies { - console.info(&console_format!("<warning>The -W / --update-with-all-dependencies flag is deprecated. Use --with-all-dependencies instead.</warning>")); + console.write_error(&console_format!( + "<warning>You are using the deprecated option \"update-with-dependencies\". This is now default behaviour. The --no-update-with-dependencies option can be used to remove a package without its dependencies.</warning>" + )); } let working_dir = cli.working_dir()?; @@ -128,72 +126,69 @@ pub async fn execute( ); } - let mut raw = package::read_from_file(&composer_path)?; + let mut composer = package::read_from_file(&composer_path)?; + // Backup for revert on pipeline failure (mirrors $composerBackup in Composer) + let composer_backup = std::fs::read(&composer_path)?; - // Step 4: Handle --unused flag - // When --unused is set with no explicit packages, we re-resolve to detect - // packages in the lock file that are no longer reachable from root requirements. if args.unused && args.packages.is_empty() { - return remove_unused(&raw, &working_dir, args, &repo_cache, cli.no_cache, console).await; + return remove_unused( + &composer, + &working_dir, + args, + &repo_cache, + cli.no_cache, + console, + ) + .await; } - // Step 5: Determine which packages to remove and remove them - let mut any_removed = false; + // Per-package removal; tracks actually-removed names for the post-install still-present check + let mut packages_removed: Vec<String> = Vec::new(); for pkg_arg in &args.packages { let name = pkg_arg.trim().to_lowercase(); - - // Validate package name format - if !validation::validate_package_name(&name) { - anyhow::bail!("Invalid package name: \"{name}\""); - } + // No validate_package_name bail: invalid names fall through to the "not required" warning, + // matching Composer's behaviour (it does not validate name format here either). if args.dev { - // Only look in require-dev - if raw.require_dev.contains_key(&name) { + if composer.require_dev.contains_key(&name) { console_writeln!( console, &console_format!("<info>Removing {name} from require-dev</info>"), ); - raw.require_dev.remove(&name); - any_removed = true; + composer.require_dev.remove(&name); + packages_removed.push(name); } else { - console.info(&console_format!("<warning>{name} is not required in require-dev and has not been removed.</warning>")); + console.info(&console_format!( + "<warning>{name} is not required in your composer.json and has not been removed</warning>" + )); } + } else if composer.require.contains_key(&name) { + console_writeln!( + console, + &console_format!("<info>Removing {name} from require</info>"), + ); + composer.require.remove(&name); + packages_removed.push(name); + } else if composer.require_dev.contains_key(&name) { + console_writeln!( + console, + &console_format!("<info>Removing {name} from require-dev</info>"), + ); + composer.require_dev.remove(&name); + packages_removed.push(name); } else { - // Auto-detect: look in require first, then require-dev - if raw.require.contains_key(&name) { - console_writeln!( - console, - &console_format!("<info>Removing {name} from require</info>"), - ); - raw.require.remove(&name); - any_removed = true; - } else if raw.require_dev.contains_key(&name) { - console_writeln!( - console, - &console_format!("<info>Removing {name} from require-dev</info>"), - ); - raw.require_dev.remove(&name); - any_removed = true; - } else { - console.info(&console_format!("<warning>{name} is not required in your composer.json and has not been removed.</warning>")); - } + console.info(&console_format!( + "<warning>{name} is not required in your composer.json and has not been removed</warning>" + )); } } - // Step 6: Write updated composer.json (unless --dry-run) - if args.dry_run { - console_writeln!( - console, - &console_format!("<comment>Dry run: composer.json not modified.</comment>"), - ); - } else if any_removed { - package::write_to_file(&raw, &composer_path)?; + if !args.dry_run && !packages_removed.is_empty() { + package::write_to_file(&composer, &composer_path)?; } console.info("./composer.json has been updated"); - // Step 7: Handle --no-update early return if args.no_update { console_writeln!( console, @@ -204,288 +199,308 @@ pub async fn execute( return Ok(()); } - // If nothing was removed, we can still proceed with resolution (e.g. to clean up orphans). - // But if nothing changed and there's nothing to resolve, exit cleanly. - if !any_removed { - return Ok(()); - } - // --- Full resolution + lock + install pipeline --- let dev_mode = !args.update_no_dev; let lock_path = working_dir.join("composer.lock"); let vendor_dir = working_dir.join("vendor"); + let pkg_names = args.packages.join(" "); + let with_all_deps = args.with_all_dependencies || args.update_with_all_dependencies; + // Flag suffix echoed in "Running composer update" — mirrors Composer's $flags variable + let flags: &str = if with_all_deps { + " --with-all-dependencies" + } else if args.no_update_with_dependencies { + "" + } else { + " --with-dependencies" + }; - // Build require/require_dev lists from the updated raw data - let require: Vec<(String, String)> = raw - .require - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - - let require_dev: Vec<(String, String)> = raw - .require_dev - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - - // Parse minimum-stability from composer.json (defaults to "stable") - let minimum_stability_str = raw.minimum_stability.as_deref().unwrap_or("stable"); - let minimum_stability = package::Stability::parse(minimum_stability_str); - - // Determine prefer-stable from composer.json field - let composer_prefer_stable = raw - .extra_fields - .get("prefer-stable") - .and_then(|v| v.as_bool()) - .unwrap_or(false); + let no_cache = cli.no_cache; - let request = ResolveRequest { - root_name: raw.name.clone(), - root_version: raw.version.clone(), - require, - require_dev, - include_dev: dev_mode, - minimum_stability, - stability_flags: IndexMap::new(), - prefer_stable: composer_prefer_stable, - prefer_lowest: false, - platform: PlatformConfig::new(), - ignore_platform_reqs: args.ignore_platform_reqs, - ignore_platform_req_list: args.ignore_platform_req.clone(), - repositories: std::sync::Arc::new( - mozart_registry::repository::RepositorySet::with_packagist(repo_cache.clone()), - ), - temporary_constraints: IndexMap::new(), - raw_repositories: raw.repositories.clone(), - root_provide: raw - .provide + let pipeline_result: anyhow::Result<()> = async { + let require: Vec<(String, String)> = composer + .require .iter() .map(|(k, v)| (k.clone(), v.clone())) - .collect(), - root_replace: raw - .replace - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(), - root_conflict: raw - .conflict + .collect(); + + let require_dev: Vec<(String, String)> = composer + .require_dev .iter() .map(|(k, v)| (k.clone(), v.clone())) - .collect(), - locked_package_names: indexmap::IndexSet::new(), - locked_packages: Vec::new(), - block_abandoned: false, - root_branch_alias: None, - preferred_versions: indexmap::IndexMap::new(), - block_insecure: false, - }; + .collect(); - // Print header messages - let pkg_names = args.packages.join(" "); - console.info(&console_format!( - "<info>Running composer update {}</info>", - pkg_names - )); - console.info("Loading composer repositories with package information"); - if dev_mode { - console.info("Updating dependencies (including require-dev)"); - } else { - console.info("Updating dependencies"); - } - console.info("Resolving dependencies..."); + let minimum_stability_str = composer.minimum_stability.as_deref().unwrap_or("stable"); + let minimum_stability = package::Stability::parse(minimum_stability_str); - // Run resolver - let mut resolved = resolver::resolve(&request).await.map_err(|e| { - mozart_core::exit_code::bail( - mozart_core::exit_code::DEPENDENCY_RESOLUTION_FAILED, - e.to_string(), - ) - })?; + let composer_prefer_stable = composer + .extra_fields + .get("prefer-stable") + .and_then(|v| v.as_bool()) + .unwrap_or(false); - // Read old lock file (if any) for change reporting and partial update - let old_lock = if lock_path.exists() { - match lockfile::LockFile::read_from_file(&lock_path) { - Ok(l) => Some(l), - Err(e) => { - console.info(&console_format!("<warning>Could not read existing composer.lock: {}. Treating as a fresh install.</warning>", e)); - None - } - } - } else { - None - }; + let request = ResolveRequest { + root_name: composer.name.clone(), + root_version: composer.version.clone(), + require, + require_dev, + include_dev: dev_mode, + minimum_stability, + stability_flags: IndexMap::new(), + prefer_stable: composer_prefer_stable, + prefer_lowest: false, + platform: PlatformConfig::new(), + ignore_platform_reqs: args.ignore_platform_reqs, + ignore_platform_req_list: args.ignore_platform_req.clone(), + repositories: std::sync::Arc::new( + mozart_registry::repository::RepositorySet::with_packagist(repo_cache.clone()), + ), + temporary_constraints: IndexMap::new(), + raw_repositories: composer.repositories.clone(), + root_provide: composer + .provide + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + root_replace: composer + .replace + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + root_conflict: composer + .conflict + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + locked_package_names: IndexSet::new(), + locked_packages: Vec::new(), + block_abandoned: false, + root_branch_alias: None, + preferred_versions: IndexMap::new(), + block_insecure: false, + }; - // Apply partial update logic for `remove`: - // - // Composer's default for `remove` is to also update the direct dependencies of the - // removed packages (i.e. they become candidates for removal if nothing else needs them). - // With --with-all-dependencies the full transitive dependency tree is considered. - // With --no-update-with-dependencies only the removed packages themselves are freed. - // - // We implement this by building an "allow list" of packages that may change: - // - --no-update-with-dependencies: only the removed packages - // - --with-all-dependencies: removed packages + full transitive deps - // - default: removed packages + direct deps (Composer default) - // Then we pin everything NOT in the allow list to its locked version. - let with_all_deps = args.with_all_dependencies || args.update_with_all_dependencies; + console.info(&console_format!( + "<info>Running composer update {pkg_names}{flags}</info>" + )); + console.info("Loading composer repositories with package information"); + if dev_mode { + console.info("Updating dependencies (including require-dev)"); + } else { + console.info("Updating dependencies"); + } + console.info("Resolving dependencies..."); - if let Some(ref lock) = old_lock { - let removed_names: Vec<String> = args - .packages - .iter() - .map(|s| s.trim().to_lowercase()) - .collect(); + let mut resolved = resolver::resolve(&request).await.map_err(|e| { + mozart_core::exit_code::bail( + mozart_core::exit_code::DEPENDENCY_RESOLUTION_FAILED, + e.to_string(), + ) + })?; - let repo_requires = super::update::collect_repo_requires(&raw.repositories); - let allow_list = if args.no_update_with_dependencies { - // Only the removed packages themselves are freed - removed_names - } else if with_all_deps { - super::update::expand_with_all_dependencies(removed_names, lock, &repo_requires) + let old_lock = if lock_path.exists() { + match lockfile::LockFile::read_from_file(&lock_path) { + Ok(l) => Some(l), + Err(e) => { + console.info(&console_format!( + "<warning>Could not read existing composer.lock: {}. Treating as a fresh install.</warning>", + e + )); + None + } + } } else { - // Default: freed packages + their direct dependencies - super::update::expand_with_direct_dependencies( - removed_names, - lock, - &IndexSet::new(), - &repo_requires, - ) + None }; - // For --minimal-changes, additionally pin packages beyond the allow list - if args.minimal_changes { - console.info(&console_format!("<info>Minimal changes mode: preserving locked versions for non-removed packages.</info>")); - } + if let Some(ref lock) = old_lock { + let removed_names: Vec<String> = args + .packages + .iter() + .map(|s| s.trim().to_lowercase()) + .collect(); - resolved = super::update::apply_partial_update(resolved, lock, &allow_list); - } + let repo_requires = super::update::collect_repo_requires(&composer.repositories); + let allow_list = if args.no_update_with_dependencies { + removed_names + } else if with_all_deps { + super::update::expand_with_all_dependencies(removed_names, lock, &repo_requires) + } else { + super::update::expand_with_direct_dependencies( + removed_names, + lock, + &IndexSet::new(), + &repo_requires, + ) + }; - // Get the composer.json content string for content-hash computation. - // For --dry-run, serialize from memory; otherwise re-read the file we just wrote. - let composer_json_content = if args.dry_run { - package::to_json_pretty(&raw)? - } else { - std::fs::read_to_string(&composer_path)? - }; + if args.minimal_changes { + console.info(&console_format!( + "<info>Minimal changes mode: preserving locked versions for non-removed packages.</info>" + )); + } - // Generate new lock file - let new_lock = lockfile::generate_lock_file(&lockfile::LockFileGenerationRequest { - resolved_packages: resolved, - composer_json_content: composer_json_content.clone(), - composer_json: raw.clone(), - include_dev: dev_mode, - repositories: std::sync::Arc::new( - mozart_registry::repository::RepositorySet::with_packagist(repo_cache.clone()), - ), - previous_lock: old_lock.clone(), - lock_pinned_names: indexmap::IndexSet::new(), - }) - .await?; + resolved = super::update::apply_partial_update(resolved, lock, &allow_list); + } - // Compute and print change report - let changes = super::update::compute_update_changes(old_lock.as_ref(), &new_lock, dev_mode); + let composer_json_content = if args.dry_run { + package::to_json_pretty(&composer)? + } else { + std::fs::read_to_string(&composer_path)? + }; - let installs: Vec<_> = changes - .iter() - .filter(|c| matches!(c.kind, super::update::ChangeKind::Install { .. })) - .collect(); - let updates: Vec<_> = changes - .iter() - .filter(|c| matches!(c.kind, super::update::ChangeKind::Update { .. })) - .collect(); - let removals: Vec<_> = changes - .iter() - .filter(|c| matches!(c.kind, super::update::ChangeKind::Remove { .. })) - .collect(); + let new_lock = lockfile::generate_lock_file(&lockfile::LockFileGenerationRequest { + resolved_packages: resolved, + composer_json_content: composer_json_content.clone(), + composer_json: composer.clone(), + include_dev: dev_mode, + repositories: std::sync::Arc::new( + mozart_registry::repository::RepositorySet::with_packagist(repo_cache.clone()), + ), + previous_lock: old_lock.clone(), + lock_pinned_names: IndexSet::new(), + }) + .await?; - console.info(&console_format!( - "<info>Package operations: {} install{}, {} update{}, {} removal{}</info>", - installs.len(), - if installs.len() == 1 { "" } else { "s" }, - updates.len(), - if updates.len() == 1 { "" } else { "s" }, - removals.len(), - if removals.len() == 1 { "" } else { "s" }, - )); + let changes = + super::update::compute_update_changes(old_lock.as_ref(), &new_lock, dev_mode); - // Print individual change lines - for change in &changes { - match &change.kind { - super::update::ChangeKind::Remove { old_version } => { - if args.dry_run { - console.info(&format!( - " - Would remove {} ({})", - change.name, old_version - )); - } else { - console.info(&format!(" - Removing {} ({})", change.name, old_version)); + let installs: Vec<_> = changes + .iter() + .filter(|c| matches!(c.kind, super::update::ChangeKind::Install { .. })) + .collect(); + let updates: Vec<_> = changes + .iter() + .filter(|c| matches!(c.kind, super::update::ChangeKind::Update { .. })) + .collect(); + let removals: Vec<_> = changes + .iter() + .filter(|c| matches!(c.kind, super::update::ChangeKind::Remove { .. })) + .collect(); + + console.info(&console_format!( + "<info>Package operations: {} install{}, {} update{}, {} removal{}</info>", + installs.len(), + if installs.len() == 1 { "" } else { "s" }, + updates.len(), + if updates.len() == 1 { "" } else { "s" }, + removals.len(), + if removals.len() == 1 { "" } else { "s" }, + )); + + for change in &changes { + match &change.kind { + super::update::ChangeKind::Remove { old_version } => { + if args.dry_run { + console.info(&format!( + " - Would remove {} ({})", + change.name, old_version + )); + } else { + console.info(&format!( + " - Removing {} ({})", + change.name, old_version + )); + } } - } - super::update::ChangeKind::Install { new_version } => { - if args.dry_run { - console.info(&format!( - " - Would install {} ({})", - change.name, new_version - )); - } else { - console.info(&format!(" - Installing {} ({})", change.name, new_version)); + super::update::ChangeKind::Install { new_version } => { + if args.dry_run { + console.info(&format!( + " - Would install {} ({})", + change.name, new_version + )); + } else { + console.info(&format!( + " - Installing {} ({})", + change.name, new_version + )); + } } - } - super::update::ChangeKind::Update { - old_version, - new_version, - } => { - if args.dry_run { - console.info(&format!( - " - Would update {} ({} => {})", - change.name, old_version, new_version - )); - } else { - console.info(&format!( - " - Updating {} ({} => {})", - change.name, old_version, new_version - )); + super::update::ChangeKind::Update { + old_version, + new_version, + } => { + if args.dry_run { + console.info(&format!( + " - Would update {} ({} => {})", + change.name, old_version, new_version + )); + } else { + console.info(&format!( + " - Updating {} ({} => {})", + change.name, old_version, new_version + )); + } } + super::update::ChangeKind::Unchanged => {} } - super::update::ChangeKind::Unchanged => {} } + + if !args.dry_run { + console.info("Writing lock file"); + new_lock.write_to_file(&lock_path)?; + } + + if !args.no_install && !args.dry_run { + let cache_config = mozart_registry::cache::build_cache_config(no_cache); + let files_cache = mozart_registry::cache::Cache::files(&cache_config); + let mut executor = + mozart_registry::installer_executor::FilesystemExecutor::new(files_cache); + super::install::install_from_lock( + &new_lock, + &working_dir, + &vendor_dir, + &super::install::InstallConfig { + dev_mode, + dry_run: false, + no_autoloader: false, + no_progress: args.no_progress, + ignore_platform_reqs: args.ignore_platform_reqs, + ignore_platform_req: args.ignore_platform_req.clone(), + optimize_autoloader: args.optimize_autoloader, + classmap_authoritative: args.classmap_authoritative, + apcu_autoloader: args.apcu_autoloader || args.apcu_autoloader_prefix.is_some(), + apcu_autoloader_prefix: args.apcu_autoloader_prefix.clone(), + download_only: false, + prefer_source: false, + }, + console, + &mut executor, + ) + .await?; + } + + Ok(()) } + .await; - // Write lock file (unless --dry-run) - if !args.dry_run { - console.info("Writing lock file"); - new_lock.write_to_file(&lock_path)?; + if let Err(e) = pipeline_result { + if !args.dry_run && !packages_removed.is_empty() { + let _ = std::fs::write(&composer_path, &composer_backup); + console.error("\nRemoval failed, reverting ./composer.json to its original content."); + } + return Err(e); } - // Install packages (unless --no-install or --dry-run) - if !args.no_install && !args.dry_run { - let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache); - let files_cache = mozart_registry::cache::Cache::files(&cache_config); - let mut executor = - mozart_registry::installer_executor::FilesystemExecutor::new(files_cache); - super::install::install_from_lock( - &new_lock, - &working_dir, - &vendor_dir, - &super::install::InstallConfig { - dev_mode, - dry_run: false, // dry_run already handled above - no_autoloader: false, // always generate autoloader - no_progress: args.no_progress, - ignore_platform_reqs: args.ignore_platform_reqs, - ignore_platform_req: args.ignore_platform_req.clone(), - optimize_autoloader: args.optimize_autoloader, - classmap_authoritative: args.classmap_authoritative, - apcu_autoloader: args.apcu_autoloader || args.apcu_autoloader_prefix.is_some(), - apcu_autoloader_prefix: args.apcu_autoloader_prefix.clone(), - download_only: false, - prefer_source: false, - }, - console, - &mut executor, - ) - .await?; + // Post-install still-present check — mirrors Composer's local-repository query at L303-311 + if !args.dry_run && !args.no_install && !packages_removed.is_empty() { + let installed_pkgs = installed::InstalledPackages::read(&vendor_dir)?; + let mut still_present = false; + for name in &packages_removed { + if installed_pkgs + .packages + .iter() + .any(|p| p.name.eq_ignore_ascii_case(name)) + { + console.error(&format!( + "Removal failed, {name} is still present, it may be required by another package. See `mozart why {name}`." + )); + still_present = true; + } + } + if still_present { + return Err(mozart_core::exit_code::bail_silent(2)); + } } Ok(()) @@ -493,7 +508,7 @@ pub async fn execute( /// Remove unused packages by re-resolving and comparing with the current lock file. async fn remove_unused( - raw: &package::RawPackageData, + composer: &package::RawPackageData, working_dir: &std::path::Path, args: &RemoveArgs, repo_cache: &mozart_registry::cache::Cache, @@ -503,36 +518,35 @@ async fn remove_unused( let lock_path = working_dir.join("composer.lock"); if !lock_path.exists() { - console.info("No lock file found. Nothing to prune."); - return Ok(()); + anyhow::bail!("A valid composer.lock file is required to run this command with --unused"); } let old_lock = lockfile::LockFile::read_from_file(&lock_path)?; let dev_mode = !args.update_no_dev; - let require: Vec<(String, String)> = raw + let require: Vec<(String, String)> = composer .require .iter() .map(|(k, v)| (k.clone(), v.clone())) .collect(); - let require_dev: Vec<(String, String)> = raw + let require_dev: Vec<(String, String)> = composer .require_dev .iter() .map(|(k, v)| (k.clone(), v.clone())) .collect(); - let minimum_stability_str = raw.minimum_stability.as_deref().unwrap_or("stable"); + let minimum_stability_str = composer.minimum_stability.as_deref().unwrap_or("stable"); let minimum_stability = package::Stability::parse(minimum_stability_str); - let composer_prefer_stable = raw + let composer_prefer_stable = composer .extra_fields .get("prefer-stable") .and_then(|v| v.as_bool()) .unwrap_or(false); let request = ResolveRequest { - root_name: raw.name.clone(), - root_version: raw.version.clone(), + root_name: composer.name.clone(), + root_version: composer.version.clone(), require, require_dev, include_dev: dev_mode, @@ -547,27 +561,27 @@ async fn remove_unused( mozart_registry::repository::RepositorySet::with_packagist(repo_cache.clone()), ), temporary_constraints: IndexMap::new(), - raw_repositories: raw.repositories.clone(), - root_provide: raw + raw_repositories: composer.repositories.clone(), + root_provide: composer .provide .iter() .map(|(k, v)| (k.clone(), v.clone())) .collect(), - root_replace: raw + root_replace: composer .replace .iter() .map(|(k, v)| (k.clone(), v.clone())) .collect(), - root_conflict: raw + root_conflict: composer .conflict .iter() .map(|(k, v)| (k.clone(), v.clone())) .collect(), - locked_package_names: indexmap::IndexSet::new(), + locked_package_names: IndexSet::new(), locked_packages: Vec::new(), block_abandoned: false, root_branch_alias: None, - preferred_versions: indexmap::IndexMap::new(), + preferred_versions: IndexMap::new(), block_insecure: false, }; @@ -580,11 +594,9 @@ async fn remove_unused( ) })?; - // Build set of resolved package names let resolved_names: indexmap::IndexSet<String> = resolved.iter().map(|p| p.name.to_lowercase()).collect(); - // Find packages in the old lock that are not in the new resolution let mut unused: Vec<String> = Vec::new(); for pkg in &old_lock.packages { if !resolved_names.contains(&pkg.name.to_lowercase()) { @@ -600,7 +612,9 @@ async fn remove_unused( } if unused.is_empty() { - console.info("No unused packages found."); + console.info(&console_format!( + "<info>No unused packages to remove</info>" + )); return Ok(()); } @@ -616,25 +630,23 @@ async fn remove_unused( return Ok(()); } - // Re-generate lock file without unused packages let composer_json_content = std::fs::read_to_string(working_dir.join("composer.json"))?; let new_lock = lockfile::generate_lock_file(&lockfile::LockFileGenerationRequest { resolved_packages: resolved, composer_json_content, - composer_json: raw.clone(), + composer_json: composer.clone(), include_dev: dev_mode, repositories: std::sync::Arc::new( mozart_registry::repository::RepositorySet::with_packagist(repo_cache.clone()), ), previous_lock: Some(old_lock.clone()), - lock_pinned_names: indexmap::IndexSet::new(), + lock_pinned_names: IndexSet::new(), }) .await?; console.info("Writing lock file"); new_lock.write_to_file(&lock_path)?; - // Install if !args.no_install { let vendor_dir = working_dir.join("vendor"); let cache_config = mozart_registry::cache::build_cache_config(no_cache); @@ -727,23 +739,24 @@ mod tests { /// Remove a package from `require`, verify it's gone from `RawPackageData`. #[test] fn test_remove_from_require() { - let mut raw = make_raw_package("test/project"); - raw.require + let mut composer = make_raw_package("test/project"); + composer + .require .insert("psr/log".to_string(), "^3.0".to_string()); - raw.require + composer + .require .insert("monolog/monolog".to_string(), "^3.0".to_string()); - assert!(raw.require.contains_key("psr/log")); + assert!(composer.require.contains_key("psr/log")); - // Simulate the removal logic - raw.require.remove("psr/log"); + composer.require.remove("psr/log"); assert!( - !raw.require.contains_key("psr/log"), + !composer.require.contains_key("psr/log"), "psr/log should be removed from require" ); assert!( - raw.require.contains_key("monolog/monolog"), + composer.require.contains_key("monolog/monolog"), "monolog/monolog should remain in require" ); } @@ -751,23 +764,24 @@ mod tests { /// Remove a package from `require-dev` with `--dev` flag. #[test] fn test_remove_from_require_dev() { - let mut raw = make_raw_package("test/project"); - raw.require_dev + let mut composer = make_raw_package("test/project"); + composer + .require_dev .insert("phpunit/phpunit".to_string(), "^11.0".to_string()); - raw.require_dev + composer + .require_dev .insert("mockery/mockery".to_string(), "^1.0".to_string()); - assert!(raw.require_dev.contains_key("phpunit/phpunit")); + assert!(composer.require_dev.contains_key("phpunit/phpunit")); - // Simulate the --dev removal logic - raw.require_dev.remove("phpunit/phpunit"); + composer.require_dev.remove("phpunit/phpunit"); assert!( - !raw.require_dev.contains_key("phpunit/phpunit"), + !composer.require_dev.contains_key("phpunit/phpunit"), "phpunit/phpunit should be removed from require-dev" ); assert!( - raw.require_dev.contains_key("mockery/mockery"), + composer.require_dev.contains_key("mockery/mockery"), "mockery/mockery should remain in require-dev" ); } @@ -775,37 +789,37 @@ mod tests { /// Removing a package not in either section does not panic and doesn't change anything. #[test] fn test_remove_nonexistent_package_no_panic() { - let mut raw = make_raw_package("test/project"); - raw.require + let mut composer = make_raw_package("test/project"); + composer + .require .insert("psr/log".to_string(), "^3.0".to_string()); - // Package not present — simulate the warning-and-skip behavior let name = "nonexistent/package"; - let found_in_require = raw.require.remove(name).is_some(); - let found_in_require_dev = raw.require_dev.remove(name).is_some(); + let found_in_require = composer.require.remove(name).is_some(); + let found_in_require_dev = composer.require_dev.remove(name).is_some(); assert!(!found_in_require); assert!(!found_in_require_dev); - // composer.json is unchanged - assert_eq!(raw.require.len(), 1); - assert!(raw.require.contains_key("psr/log")); + assert_eq!(composer.require.len(), 1); + assert!(composer.require.contains_key("psr/log")); } /// Without `--dev`, auto-detect finds the package in whichever section contains it. #[test] fn test_remove_auto_detects_section_require() { - let mut raw = make_raw_package("test/project"); - raw.require + let mut composer = make_raw_package("test/project"); + composer + .require .insert("psr/log".to_string(), "^3.0".to_string()); - raw.require_dev + composer + .require_dev .insert("phpunit/phpunit".to_string(), "^11.0".to_string()); - // Auto-detect: psr/log is in require let name = "psr/log"; - let removed_from_require = raw.require.remove(name).is_some(); + let removed_from_require = composer.require.remove(name).is_some(); let removed_from_dev = if !removed_from_require { - raw.require_dev.remove(name).is_some() + composer.require_dev.remove(name).is_some() } else { false }; @@ -815,24 +829,25 @@ mod tests { "should be found and removed from require" ); assert!(!removed_from_dev); - assert!(!raw.require.contains_key("psr/log")); - assert!(raw.require_dev.contains_key("phpunit/phpunit")); + assert!(!composer.require.contains_key("psr/log")); + assert!(composer.require_dev.contains_key("phpunit/phpunit")); } /// Without `--dev`, auto-detect finds the package in require-dev if not in require. #[test] fn test_remove_auto_detects_section_require_dev() { - let mut raw = make_raw_package("test/project"); - raw.require + let mut composer = make_raw_package("test/project"); + composer + .require .insert("psr/log".to_string(), "^3.0".to_string()); - raw.require_dev + composer + .require_dev .insert("phpunit/phpunit".to_string(), "^11.0".to_string()); - // Auto-detect: phpunit/phpunit is in require-dev let name = "phpunit/phpunit"; - let removed_from_require = raw.require.remove(name).is_some(); + let removed_from_require = composer.require.remove(name).is_some(); let removed_from_dev = if !removed_from_require { - raw.require_dev.remove(name).is_some() + composer.require_dev.remove(name).is_some() } else { false }; @@ -842,14 +857,13 @@ mod tests { removed_from_dev, "should be found and removed from require-dev" ); - assert!(!raw.require_dev.contains_key("phpunit/phpunit")); - assert!(raw.require.contains_key("psr/log")); + assert!(!composer.require_dev.contains_key("phpunit/phpunit")); + assert!(composer.require.contains_key("psr/log")); } /// After re-resolve, removed packages appear as `ChangeKind::Remove` in the change report. #[test] fn test_remove_change_report_shows_removals() { - // Old lock has psr/log + monolog; new lock has only psr/log let old_lock = minimal_lock(vec![ make_locked_package("psr/log", "3.0.0"), make_locked_package("monolog/monolog", "3.8.0"), @@ -871,6 +885,49 @@ mod tests { ); } + /// Glob-style package names (e.g. "vendor/*") no longer bail with an "Invalid package name" + /// error — they fall through to the "not required" warning path. This is a regression test + /// for the validate_package_name bail that was removed in PR-A. + #[test] + fn test_glob_package_name_falls_through_to_not_required() { + let mut composer = make_raw_package("test/project"); + composer + .require + .insert("psr/log".to_string(), "^3.0".to_string()); + + // A glob-style name: not a valid exact package name, not in require either. + let name = "vendor/*"; + let found = + composer.require.remove(name).is_some() || composer.require_dev.remove(name).is_some(); + + // Should NOT be found (falls through to "not required" warning), not panicked/bailed. + assert!(!found, "glob name should not match any package"); + // composer.json is unchanged + assert_eq!(composer.require.len(), 1); + } + + /// --unused with no lock file must return an error matching Composer's wording. + #[test] + fn test_unused_no_lock_error_wording() { + use tempfile::tempdir; + + let dir = tempdir().unwrap(); + // No lock file present — the error message is tested via the remove_unused code path. + let lock_path = dir.path().join("composer.lock"); + assert!(!lock_path.exists()); + + // The error message Composer uses (and Mozart must match): + let expected = "A valid composer.lock file is required to run this command with --unused"; + // Simulate the check that remove_unused() performs: + let result: anyhow::Result<()> = if !lock_path.exists() { + Err(anyhow::anyhow!("{}", expected)) + } else { + Ok(()) + }; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains(expected)); + } + #[tokio::test] #[ignore] async fn test_remove_full_e2e() { @@ -884,13 +941,11 @@ mod tests { let lock_path = dir.path().join("composer.lock"); let vendor_dir = dir.path().join("vendor"); - // Start with psr/log in require let content = r#"{"name": "test/project", "require": {"psr/log": "^3.0"}}"#; std::fs::write(&composer_path, content).unwrap(); - let mut raw: RawPackageData = serde_json::from_str(content).unwrap(); + let mut composer: RawPackageData = serde_json::from_str(content).unwrap(); - // Simulate initial install let request = ResolveRequest { root_name: String::new(), root_version: None, @@ -921,7 +976,7 @@ mod tests { locked_packages: Vec::new(), block_abandoned: false, root_branch_alias: None, - preferred_versions: indexmap::IndexMap::new(), + preferred_versions: IndexMap::new(), block_insecure: false, }; let resolved = resolve(&request) @@ -930,7 +985,7 @@ mod tests { let initial_lock = generate_lock_file(&LockFileGenerationRequest { resolved_packages: resolved, composer_json_content: content.to_string(), - composer_json: raw.clone(), + composer_json: composer.clone(), include_dev: false, repositories: std::sync::Arc::new( mozart_registry::repository::RepositorySet::with_packagist( @@ -949,11 +1004,9 @@ mod tests { .write_to_file(&lock_path) .expect("should write initial lock file"); - // Now remove psr/log - raw.require.remove("psr/log"); - package::write_to_file(&raw, &composer_path).unwrap(); + composer.require.remove("psr/log"); + package::write_to_file(&composer, &composer_path).unwrap(); - // Re-resolve with empty require let request2 = ResolveRequest { root_name: String::new(), root_version: None, @@ -984,7 +1037,7 @@ mod tests { locked_packages: Vec::new(), block_abandoned: false, root_branch_alias: None, - preferred_versions: indexmap::IndexMap::new(), + preferred_versions: IndexMap::new(), block_insecure: false, }; let resolved2 = resolve(&request2) @@ -995,7 +1048,7 @@ mod tests { let new_lock = generate_lock_file(&LockFileGenerationRequest { resolved_packages: resolved2, composer_json_content: composer_json_content2, - composer_json: raw, + composer_json: composer, include_dev: false, repositories: std::sync::Arc::new( mozart_registry::repository::RepositorySet::with_packagist( @@ -1011,19 +1064,15 @@ mod tests { .await .expect("post-remove lock file generation should succeed"); - // psr/log should no longer be in the new lock assert!( !new_lock.packages.iter().any(|p| p.name == "psr/log"), "psr/log should be absent from the new lock file" ); - // Write new lock new_lock.write_to_file(&lock_path).unwrap(); assert!(lock_path.exists(), "lock file should exist"); - // Vendor should not contain psr/log after install_from_lock - // (install_from_lock removes packages no longer in lock) - let _ = vendor_dir; // referenced to avoid dead_code warning + let _ = vendor_dir; } #[test] @@ -1037,20 +1086,15 @@ mod tests { let content = r#"{"name": "test/project", "require": {"psr/log": "^3.0"}}"#; std::fs::write(&composer_path, content).unwrap(); - // Simulate what execute() does with --no-update: - // 1. Read and modify composer.json - let mut raw: RawPackageData = serde_json::from_str(content).unwrap(); - raw.require.remove("psr/log"); - package::write_to_file(&raw, &composer_path).unwrap(); + let mut composer: RawPackageData = serde_json::from_str(content).unwrap(); + composer.require.remove("psr/log"); + package::write_to_file(&composer, &composer_path).unwrap(); - // 2. Return early — do NOT write lock file - // Lock file should not exist assert!( !lock_path.exists(), "lock file should not be created with --no-update" ); - // composer.json should be updated let updated_content = std::fs::read_to_string(&composer_path).unwrap(); assert!( !updated_content.contains("psr/log"), @@ -1070,8 +1114,6 @@ mod tests { let original_content = r#"{"name": "test/project", "require": {"psr/log": "^3.0"}}"#; std::fs::write(&composer_path, original_content).unwrap(); - // Simulate --dry-run: composer.json, lock, vendor all left unchanged. - // The execute() function with dry_run=true won't write any files. assert_eq!( std::fs::read_to_string(&composer_path).unwrap(), original_content, |
