diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-21 13:02:10 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-21 13:02:10 +0900 |
| commit | 79cf48cbf8ba2e3e8c73779039ca6705ae69f9b1 (patch) | |
| tree | 21b71c7358a9207b82a6f2c7969f8db83841be59 /crates/mozart/src/commands/remove.rs | |
| parent | 2cfcce2452cac7b8b75710a37e8aa864cc206d73 (diff) | |
| download | php-mozart-79cf48cbf8ba2e3e8c73779039ca6705ae69f9b1.tar.gz php-mozart-79cf48cbf8ba2e3e8c73779039ca6705ae69f9b1.tar.zst php-mozart-79cf48cbf8ba2e3e8c73779039ca6705ae69f9b1.zip | |
feat(remove): implement remove command with full resolve/lock/install pipeline
Replace the todo\!() stub with a complete implementation that handles
package removal from require/require-dev with auto-detection, supports
--dev, --dry-run, --no-update, --no-install flags, and runs the full
dependency resolution pipeline after modification.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart/src/commands/remove.rs')
| -rw-r--r-- | crates/mozart/src/commands/remove.rs | 693 |
1 files changed, 691 insertions, 2 deletions
diff --git a/crates/mozart/src/commands/remove.rs b/crates/mozart/src/commands/remove.rs index 39832e8..3c238ad 100644 --- a/crates/mozart/src/commands/remove.rs +++ b/crates/mozart/src/commands/remove.rs @@ -1,4 +1,10 @@ +use crate::console; +use crate::lockfile; +use crate::package; +use crate::resolver::{self, PlatformConfig, ResolveRequest}; +use crate::validation; use clap::Args; +use std::collections::HashMap; #[derive(Args)] pub struct RemoveArgs { @@ -90,6 +96,689 @@ pub struct RemoveArgs { pub apcu_autoloader_prefix: Option<String>, } -pub fn execute(_args: &RemoveArgs, _cli: &super::Cli) -> anyhow::Result<()> { - todo!() +pub fn execute(args: &RemoveArgs, cli: &super::Cli) -> anyhow::Result<()> { + // Step 1: Validate inputs + if args.packages.is_empty() && !args.unused { + anyhow::bail!("Not enough arguments (missing: \"packages\")."); + } + + // Step 2: Handle deprecated flags + if args.update_with_dependencies { + eprintln!( + "{}", + console::warning( + "The -w / --update-with-dependencies flag is deprecated. Use --with-all-dependencies instead." + ) + ); + } + if args.update_with_all_dependencies { + eprintln!( + "{}", + console::warning( + "The -W / --update-with-all-dependencies flag is deprecated. Use --with-all-dependencies instead." + ) + ); + } + + // Warn about flags that are accepted but not fully implemented + if args.minimal_changes { + eprintln!( + "{}", + console::warning("--minimal-changes is not yet implemented and will be ignored.") + ); + } + if args.no_update_with_dependencies { + eprintln!( + "{}", + console::warning( + "--no-update-with-dependencies is not yet implemented and will be ignored." + ) + ); + } + + // Step 3: Resolve working directory and read composer.json + let working_dir = super::install::resolve_working_dir(cli); + let composer_path = working_dir.join("composer.json"); + + if !composer_path.exists() { + anyhow::bail!( + "composer.json not found in {}. Run `mozart init` to create one.", + working_dir.display() + ); + } + + let mut raw = package::read_from_file(&composer_path)?; + + // Step 4: Handle --unused flag (deferred implementation) + if args.unused { + eprintln!( + "{}", + console::warning( + "--unused is not yet fully implemented. The resolver will naturally prune unreachable packages." + ) + ); + // Fall through: if no explicit packages were named, nothing to remove. + if args.packages.is_empty() { + return Ok(()); + } + } + + // Step 5: Determine which packages to remove and remove them + let mut any_removed = false; + + 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}\""); + } + + if args.dev { + // Only look in require-dev + if raw.require_dev.contains_key(&name) { + println!( + "{}", + console::info(&format!("Removing {name} from require-dev")) + ); + raw.require_dev.remove(&name); + any_removed = true; + } else { + eprintln!( + "{}", + console::warning(&format!( + "{name} is not required in require-dev and has not been removed." + )) + ); + } + } else { + // Auto-detect: look in require first, then require-dev + if raw.require.contains_key(&name) { + println!( + "{}", + console::info(&format!("Removing {name} from require")) + ); + raw.require.remove(&name); + any_removed = true; + } else if raw.require_dev.contains_key(&name) { + println!( + "{}", + console::info(&format!("Removing {name} from require-dev")) + ); + raw.require_dev.remove(&name); + any_removed = true; + } else { + eprintln!( + "{}", + console::warning(&format!( + "{name} is not required in your composer.json and has not been removed." + )) + ); + } + } + } + + // Step 6: Write updated composer.json (unless --dry-run) + if args.dry_run { + println!( + "{}", + console::comment("Dry run: composer.json not modified.") + ); + } else if any_removed { + package::write_to_file(&raw, &composer_path)?; + } + + // Step 7: Handle --no-update early return + if args.no_update { + println!( + "{}", + console::comment("Not updating dependencies, only modifying composer.json.") + ); + 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"); + + // 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 request = ResolveRequest { + require, + require_dev, + include_dev: dev_mode, + minimum_stability, + stability_flags: HashMap::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(), + }; + + // Print header messages + eprintln!("Loading composer repositories with package information"); + if dev_mode { + eprintln!("Updating dependencies (including require-dev)"); + } else { + eprintln!("Updating dependencies"); + } + eprintln!("Resolving dependencies..."); + + // Run resolver + let resolved = match resolver::resolve(&request) { + Ok(packages) => packages, + Err(e) => { + eprintln!("{}", console::error(&e.to_string())); + std::process::exit(1); + } + }; + + // Read old lock file (if any) for change reporting + let old_lock = if lock_path.exists() { + match lockfile::LockFile::read_from_file(&lock_path) { + Ok(l) => Some(l), + Err(e) => { + eprintln!( + "{}", + console::warning(&format!( + "Could not read existing composer.lock: {}. Treating as a fresh install.", + e + )) + ); + None + } + } + } else { + None + }; + + // 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)? + }; + + // 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, + })?; + + // Compute and print change report + let changes = super::update::compute_update_changes(old_lock.as_ref(), &new_lock, dev_mode); + + 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(); + + eprintln!( + "{}", + console::info(&format!( + "Package operations: {} install{}, {} update{}, {} removal{}", + installs.len(), + if installs.len() == 1 { "" } else { "s" }, + updates.len(), + if updates.len() == 1 { "" } else { "s" }, + removals.len(), + if removals.len() == 1 { "" } else { "s" }, + )) + ); + + // Print individual change lines + for change in &changes { + match &change.kind { + super::update::ChangeKind::Remove { old_version } => { + if args.dry_run { + eprintln!(" - Would remove {} ({})", change.name, old_version); + } else { + eprintln!(" - Removing {} ({})", change.name, old_version); + } + } + super::update::ChangeKind::Install { new_version } => { + if args.dry_run { + eprintln!(" - Would install {} ({})", change.name, new_version); + } else { + eprintln!(" - Installing {} ({})", change.name, new_version); + } + } + super::update::ChangeKind::Update { + old_version, + new_version, + } => { + if args.dry_run { + eprintln!( + " - Would update {} ({} => {})", + change.name, old_version, new_version + ); + } else { + eprintln!( + " - Updating {} ({} => {})", + change.name, old_version, new_version + ); + } + } + super::update::ChangeKind::Unchanged => {} + } + } + + // Write lock file (unless --dry-run) + if !args.dry_run { + eprintln!("Writing lock file"); + new_lock.write_to_file(&lock_path)?; + } + + // Install packages (unless --no-install or --dry-run) + if !args.no_install && !args.dry_run { + super::install::install_from_lock( + &new_lock, + &working_dir, + &vendor_dir, + dev_mode, + false, // dry_run already handled above + false, // no_autoloader: always generate autoloader + )?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lockfile; + use crate::package::RawPackageData; + use std::collections::BTreeMap; + + // ──────────── Helper constructors ──────────── + + fn make_locked_package(name: &str, version: &str) -> lockfile::LockedPackage { + lockfile::LockedPackage { + name: name.to_string(), + version: version.to_string(), + version_normalized: Some(format!("{}.0", version)), + source: None, + dist: None, + require: BTreeMap::new(), + require_dev: BTreeMap::new(), + suggest: None, + package_type: Some("library".to_string()), + 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 minimal_lock(packages: Vec<lockfile::LockedPackage>) -> lockfile::LockFile { + lockfile::LockFile { + readme: lockfile::LockFile::default_readme(), + content_hash: "abc123".to_string(), + packages, + packages_dev: Some(vec![]), + 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_raw_package(name: &str) -> RawPackageData { + RawPackageData::new(name.to_string()) + } + + // ──────────── Unit 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 + .insert("psr/log".to_string(), "^3.0".to_string()); + raw.require + .insert("monolog/monolog".to_string(), "^3.0".to_string()); + + assert!(raw.require.contains_key("psr/log")); + + // Simulate the removal logic + raw.require.remove("psr/log"); + + assert!( + !raw.require.contains_key("psr/log"), + "psr/log should be removed from require" + ); + assert!( + raw.require.contains_key("monolog/monolog"), + "monolog/monolog should remain in require" + ); + } + + /// 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 + .insert("phpunit/phpunit".to_string(), "^11.0".to_string()); + raw.require_dev + .insert("mockery/mockery".to_string(), "^1.0".to_string()); + + assert!(raw.require_dev.contains_key("phpunit/phpunit")); + + // Simulate the --dev removal logic + raw.require_dev.remove("phpunit/phpunit"); + + assert!( + !raw.require_dev.contains_key("phpunit/phpunit"), + "phpunit/phpunit should be removed from require-dev" + ); + assert!( + raw.require_dev.contains_key("mockery/mockery"), + "mockery/mockery should remain in require-dev" + ); + } + + /// 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 + .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(); + + 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")); + } + + /// 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 + .insert("psr/log".to_string(), "^3.0".to_string()); + raw.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_dev = if !removed_from_require { + raw.require_dev.remove(name).is_some() + } else { + false + }; + + assert!( + removed_from_require, + "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")); + } + + /// 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 + .insert("psr/log".to_string(), "^3.0".to_string()); + raw.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_dev = if !removed_from_require { + raw.require_dev.remove(name).is_some() + } else { + false + }; + + assert!(!removed_from_require); + assert!( + 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")); + } + + /// 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"), + ]); + let new_lock = minimal_lock(vec![make_locked_package("psr/log", "3.0.0")]); + + let changes = + super::super::update::compute_update_changes(Some(&old_lock), &new_lock, false); + + assert_eq!(changes.len(), 1, "exactly one change expected"); + assert_eq!(changes[0].name, "monolog/monolog"); + assert!( + matches!( + &changes[0].kind, + super::super::update::ChangeKind::Remove { old_version } + if old_version == "3.8.0" + ), + "monolog/monolog should appear as a Remove change" + ); + } + + // ──────────── Integration tests (network, #[ignore]) ──────────── + + #[test] + #[ignore] + fn test_remove_full_e2e() { + use crate::lockfile::{LockFileGenerationRequest, generate_lock_file}; + use crate::resolver::{ResolveRequest, resolve}; + use std::collections::HashMap; + use tempfile::tempdir; + + let dir = tempdir().unwrap(); + let composer_path = dir.path().join("composer.json"); + 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(); + + // Simulate initial install + let request = ResolveRequest { + require: vec![("psr/log".to_string(), "^3.0".to_string())], + require_dev: vec![], + include_dev: false, + minimum_stability: crate::package::Stability::Stable, + stability_flags: HashMap::new(), + prefer_stable: true, + prefer_lowest: false, + platform: crate::resolver::PlatformConfig::new(), + ignore_platform_reqs: false, + ignore_platform_req_list: vec![], + }; + let resolved = resolve(&request).expect("initial resolution should succeed"); + let initial_lock = generate_lock_file(&LockFileGenerationRequest { + resolved_packages: resolved, + composer_json_content: content.to_string(), + composer_json: raw.clone(), + include_dev: false, + }) + .expect("initial lock file generation should succeed"); + initial_lock + .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(); + + // Re-resolve with empty require + let request2 = ResolveRequest { + require: vec![], + require_dev: vec![], + include_dev: false, + minimum_stability: crate::package::Stability::Stable, + stability_flags: HashMap::new(), + prefer_stable: true, + prefer_lowest: false, + platform: crate::resolver::PlatformConfig::new(), + ignore_platform_reqs: false, + ignore_platform_req_list: vec![], + }; + let resolved2 = resolve(&request2).expect("post-remove resolution should succeed"); + + let composer_json_content2 = std::fs::read_to_string(&composer_path).unwrap(); + let new_lock = generate_lock_file(&LockFileGenerationRequest { + resolved_packages: resolved2, + composer_json_content: composer_json_content2, + composer_json: raw, + include_dev: false, + }) + .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 + } + + #[test] + #[ignore] + fn test_remove_no_update_only_modifies_json() { + use tempfile::tempdir; + + let dir = tempdir().unwrap(); + let composer_path = dir.path().join("composer.json"); + let lock_path = dir.path().join("composer.lock"); + + 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(); + + // 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"), + "psr/log should be removed from composer.json" + ); + } + + #[test] + #[ignore] + fn test_remove_dry_run_modifies_nothing() { + use tempfile::tempdir; + + let dir = tempdir().unwrap(); + let composer_path = dir.path().join("composer.json"); + let lock_path = dir.path().join("composer.lock"); + let vendor_dir = dir.path().join("vendor"); + + 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, + "composer.json should be unmodified after dry run" + ); + assert!( + !lock_path.exists(), + "lock file should not be created by dry run" + ); + assert!( + !vendor_dir.exists(), + "vendor dir should not be created by dry run" + ); + } } |
