aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart/src/commands/remove.rs
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-21 13:02:10 +0900
committernsfisis <nsfisis@gmail.com>2026-02-21 13:02:10 +0900
commit79cf48cbf8ba2e3e8c73779039ca6705ae69f9b1 (patch)
tree21b71c7358a9207b82a6f2c7969f8db83841be59 /crates/mozart/src/commands/remove.rs
parent2cfcce2452cac7b8b75710a37e8aa864cc206d73 (diff)
downloadphp-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.rs693
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"
+ );
+ }
}