diff options
Diffstat (limited to 'crates/mozart/src/commands/reinstall.rs')
| -rw-r--r-- | crates/mozart/src/commands/reinstall.rs | 665 |
1 files changed, 115 insertions, 550 deletions
diff --git a/crates/mozart/src/commands/reinstall.rs b/crates/mozart/src/commands/reinstall.rs index 45f44f5..206d2bf 100644 --- a/crates/mozart/src/commands/reinstall.rs +++ b/crates/mozart/src/commands/reinstall.rs @@ -1,11 +1,14 @@ use clap::Args; +use mozart_autoload::AutoloadGeneratorExt; +use mozart_core::composer::{ + AutoloadDumpOptions, Composer, LocalPackage, PlatformRequirementFilter, +}; use mozart_core::console_format; -use mozart_core::console_writeln; -use mozart_core::package; +use mozart_core::validation::package_name_to_regexp; #[derive(Args)] pub struct ReinstallArgs { - /// Package(s) to reinstall + /// Package(s) to reinstall, can include a wildcard (*) to match any substring pub packages: Vec<String>, /// Forces installation from package sources when possible @@ -20,14 +23,6 @@ pub struct ReinstallArgs { #[arg(long)] pub prefer_install: Option<String>, - /// Only output what would be changed, do not modify files - #[arg(long)] - pub dry_run: bool, - - /// Disables installation of require-dev packages - #[arg(long)] - pub no_dev: bool, - /// Skips autoloader generation #[arg(long)] pub no_autoloader: bool, @@ -71,73 +66,43 @@ pub async fn execute( console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let working_dir = cli.working_dir()?; + let composer = Composer::require(&working_dir)?; + let local_repo = composer.repository_manager().local_repository(); - let vendor_dir = working_dir.join("vendor"); - - // Step 2: Read installed.json - let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?; - - // Step 3: Read composer.lock - let lock_path = working_dir.join("composer.lock"); - if !lock_path.exists() { - anyhow::bail!( - "No composer.lock found in {}. Run `mozart install` first.", - working_dir.display() - ); - } - let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; - - // Step 4: Validate — error if both --type and package names are provided; - // error if neither is provided. - let has_packages = !args.packages.is_empty(); - let has_type = !args.r#type.is_empty(); + // Selection: mirrors `ReinstallCommand::execute` lines 79-110. + let mut packages_to_reinstall: Vec<&LocalPackage> = Vec::new(); + let mut package_names_to_reinstall: Vec<String> = Vec::new(); - if has_packages && has_type { - anyhow::bail!( - "You cannot use --type together with explicit package names. \ - Use one or the other." - ); - } - if !has_packages && !has_type { - anyhow::bail!( - "You must specify at least one package name or use --type to filter by package type." - ); - } - - // Step 5: Determine packages to reinstall. - // Build the full set of installed packages (prod + dev unless --no-dev). - let dev_package_names: indexmap::IndexSet<String> = installed - .dev_package_names - .iter() - .map(|n| n.to_lowercase()) - .collect(); - - let candidates: Vec<&mozart_registry::installed::InstalledPackageEntry> = installed - .packages - .iter() - .filter(|pkg| { - // Apply --no-dev filter - if args.no_dev && dev_package_names.contains(&pkg.name.to_lowercase()) { - return false; + if !args.r#type.is_empty() { + if !args.packages.is_empty() { + anyhow::bail!("You cannot specify package names and filter by type at the same time."); + } + let lower_types: Vec<String> = args.r#type.iter().map(|t| t.to_lowercase()).collect(); + for package in local_repo.canonical_packages() { + // Composer compares against `getType()` — packages without a + // `type` are normalised to `library` by the package loader. + let pt = package.package_type().unwrap_or("library").to_lowercase(); + if lower_types.contains(&pt) { + packages_to_reinstall.push(package); + package_names_to_reinstall.push(package.pretty_name().to_string()); } - true - }) - .collect(); - - let selected: Vec<&mozart_registry::installed::InstalledPackageEntry> = if has_type { - filter_by_type(&candidates, &args.r#type) + } } else { - filter_by_names(&candidates, &args.packages) - }; - - // Emit per-pattern warnings for patterns that matched no installed packages. - if has_packages { + if args.packages.is_empty() { + anyhow::bail!("You must pass one or more package names to be reinstalled."); + } for pattern in &args.packages { - let matched = candidates - .iter() - .any(|pkg| glob_matches(&pattern.to_lowercase(), &pkg.name.to_lowercase())); + let pattern_regexp = package_name_to_regexp(pattern); + let mut matched = false; + for package in local_repo.canonical_packages() { + if pattern_regexp.is_match(package.pretty_name()) { + matched = true; + packages_to_reinstall.push(package); + package_names_to_reinstall.push(package.pretty_name().to_string()); + } + } if !matched { - console.info(&console_format!( + console.error(&console_format!( "<warning>Pattern \"{}\" does not match any currently installed packages.</warning>", pattern )); @@ -145,65 +110,46 @@ pub async fn execute( } } - if selected.is_empty() { - console.info("Found no packages to reinstall, aborting."); + if packages_to_reinstall.is_empty() { + console.error(&console_format!( + "<warning>Found no packages to reinstall, aborting.</warning>" + )); return Err(mozart_core::exit_code::bail_silent( mozart_core::exit_code::GENERAL_ERROR, )); } - // Step 6: For each selected package, find its locked metadata. - // Build a lookup map: lowercase name -> LockedPackage - let all_locked: Vec<&mozart_registry::lockfile::LockedPackage> = lock - .packages - .iter() - .chain(lock.packages_dev.as_deref().unwrap_or(&[])) - .collect(); + // TODO(plugins): build `uninstall_operations` + `Transaction(present, result)` + // and reverse-sort uninstalls by install order, mirroring PHP L118-143. + // TODO(plugins): dispatch CommandEvent for `reinstall`. + // TODO(plugins): apply prefer-source/prefer-dist via DownloadManager; + // today the flags are accepted but not propagated. - // Step 7: Dry-run mode — just print what would be done. - if args.dry_run { - for pkg in &selected { - let locked = find_locked_package(&all_locked, &pkg.name); - if let Some(lp) = locked { - console_writeln!( - console, - &format!(" - Would reinstall {} ({})", lp.name, lp.version), - ); - } else { - console_writeln!( - console, - &format!(" - Would reinstall {} (not found in lock file)", pkg.name), - ); - } - } - return Ok(()); + let dev_mode = local_repo.dev_mode().unwrap_or(true); + + // SAFETY: single-threaded at this point; no concurrent env access. + unsafe { + std::env::set_var("COMPOSER_DEV_MODE", if dev_mode { "1" } else { "0" }); } - // Step 8: For each package, remove vendor dir and re-download. + // TODO(plugins): dispatchScript(PRE_INSTALL_CMD, dev_mode). + + // Reinstall loop. Composer delegates this to + // `InstallationManager::execute(localRepo, ops, devMode)` twice; until + // `mozart-registry::installer_executor` exposes the same shape, we + // remove the install dir and re-download in place using each package's + // recorded `dist` info. let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache); let files_cache = mozart_registry::cache::Cache::files(&cache_config); + let installation_manager = composer.installation_manager(); - let mut reinstalled_count = 0usize; - - for pkg in &selected { - let locked = find_locked_package(&all_locked, &pkg.name); - let locked = match locked { - Some(lp) => lp, - None => { - console.info(&format!( - " Warning: {} is not in the lock file; skipping.", - pkg.name - )); - continue; - } - }; - - let dist = match &locked.dist { + for package in &packages_to_reinstall { + let dist = match package.dist() { Some(d) => d, None => { console.info(&format!( " Warning: {} has no dist information; skipping.", - locked.name + package.pretty_name() )); continue; } @@ -211,467 +157,86 @@ pub async fn execute( console.info(&format!( " - Reinstalling {} ({})", - locked.name, locked.version + package.pretty_name(), + package.pretty_version() )); - // Remove vendor directory for this package - let pkg_dir = vendor_dir.join(&locked.name); - if pkg_dir.exists() { - std::fs::remove_dir_all(&pkg_dir)?; + if let Some(install_path) = installation_manager.get_install_path(package) + && install_path.exists() + { + std::fs::remove_dir_all(&install_path)?; } - // Re-download and install let mut progress = mozart_registry::downloader::DownloadProgress::new( !args.no_progress, - format!("{} ({})", locked.name, locked.version), + format!("{} ({})", package.pretty_name(), package.pretty_version()), ); mozart_registry::downloader::install_package( &dist.url, - &dist.dist_type, + &dist.kind, dist.shasum.as_deref(), - &vendor_dir, - &locked.name, + installation_manager.vendor_dir(), + package.pretty_name(), Some(&mut progress), &files_cache, ) .await?; progress.finish(); - reinstalled_count += 1; - } - - if reinstalled_count == 0 { - console_writeln!(console, "Nothing was reinstalled.",); - return Ok(()); } - // Step 9: Regenerate autoloader unless --no-autoloader. if !args.no_autoloader { - console.info("Generating autoload files"); - - let dev_mode = !args.no_dev && installed.dev; - - // SAFETY: single-threaded at this point; no concurrent env access - unsafe { - std::env::set_var("COMPOSER_DEV_MODE", if dev_mode { "1" } else { "0" }); - } - - let suffix = lock.content_hash.clone(); - - // Read composer.json config section for autoloader defaults. - let composer_path = working_dir.join("composer.json"); - let raw = package::read_from_file(&composer_path)?; - let project_config = raw.extra_fields.get("config"); - let config_optimize = project_config - .and_then(|c| c.get("optimize-autoloader")) - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let config_classmap_auth = project_config - .and_then(|c| c.get("classmap-authoritative")) - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let config_apcu = project_config - .and_then(|c| c.get("apcu-autoloader")) - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - let optimize = args.optimize_autoloader || config_optimize; - let classmap_auth = args.classmap_authoritative || config_classmap_auth; - let apcu = args.apcu_autoloader || args.apcu_autoloader_prefix.is_some() || config_apcu; + let optimize = args.optimize_autoloader || composer.config().optimize_autoloader; + let class_map_authoritative = + args.classmap_authoritative || composer.config().classmap_authoritative; + let apcu_prefix = args.apcu_autoloader_prefix.clone(); + let apcu = + apcu_prefix.is_some() || args.apcu_autoloader || composer.config().apcu_autoloader; - let _result = - mozart_autoload::autoload::generate(&mozart_autoload::autoload::AutoloadConfig { - project_dir: working_dir.to_path_buf(), - vendor_dir: vendor_dir.to_path_buf(), - dev_mode, - suffix, - classmap_authoritative: classmap_auth, - optimize, - apcu, - apcu_prefix: args.apcu_autoloader_prefix.clone(), - strict_psr: false, - strict_ambiguous: false, - platform_check: mozart_autoload::autoload::PlatformCheckMode::Full, - ignore_platform_reqs: args.ignore_platform_reqs, - })?; - - console.info("Generated autoload files"); - } - - Ok(()) -} - -/// Filter candidates by package type (case-insensitive). -fn filter_by_type<'a>( - candidates: &[&'a mozart_registry::installed::InstalledPackageEntry], - types: &[String], -) -> Vec<&'a mozart_registry::installed::InstalledPackageEntry> { - let lower_types: Vec<String> = types.iter().map(|t| t.to_lowercase()).collect(); - candidates - .iter() - .filter(|pkg| { - if let Some(ref pt) = pkg.package_type { - lower_types.contains(&pt.to_lowercase()) - } else { - // Packages without a type are treated as "library" - lower_types.contains(&"library".to_string()) - } - }) - .copied() - .collect() -} - -/// Filter candidates by package name patterns (glob/wildcard, case-insensitive). -/// -/// Patterns support `*` as a wildcard matching any sequence of characters -/// (including `/`). -fn filter_by_names<'a>( - candidates: &[&'a mozart_registry::installed::InstalledPackageEntry], - patterns: &[String], -) -> Vec<&'a mozart_registry::installed::InstalledPackageEntry> { - candidates - .iter() - .filter(|pkg| { - let name_lower = pkg.name.to_lowercase(); - patterns - .iter() - .any(|pat| glob_matches(&pat.to_lowercase(), &name_lower)) - }) - .copied() - .collect() -} - -/// Simple glob matching where `*` matches any sequence of characters. -/// -/// The match is always performed on already-lowercased strings. -fn glob_matches(pattern: &str, value: &str) -> bool { - // If there is no wildcard, fall back to exact equality. - if !pattern.contains('*') { - return pattern == value; - } - - // Split on `*` and match greedily left-to-right. - let parts: Vec<&str> = pattern.split('*').collect(); - let mut remaining = value; + let options = AutoloadDumpOptions { + dev_mode: Some(dev_mode), + class_map_authoritative, + apcu, + apcu_prefix, + run_scripts: false, + dry_run: false, + platform_requirement_filter: get_platform_requirement_filter(args)?, + }; - for (i, part) in parts.iter().enumerate() { - if part.is_empty() { - continue; - } - if i == 0 { - // First segment must match at the start - if !remaining.starts_with(part) { - return false; - } - remaining = &remaining[part.len()..]; - } else { - // Subsequent segments must be found somewhere in `remaining` - match remaining.find(part) { - Some(pos) => { - remaining = &remaining[pos + part.len()..]; - } - None => return false, - } - } + let _class_map = composer.autoload_generator().dump( + &options, + composer.config(), + local_repo, + composer.package(), + installation_manager, + "composer", + optimize, + None, + composer.locker(), + false, + )?; } - // If the pattern ends with `*`, anything can trail; otherwise `remaining` must be empty. - if pattern.ends_with('*') { - true - } else { - remaining.is_empty() - } -} + // TODO(plugins): dispatchScript(POST_INSTALL_CMD, dev_mode). -/// Find a locked package by name (case-insensitive). -fn find_locked_package<'a>( - locked: &[&'a mozart_registry::lockfile::LockedPackage], - name: &str, -) -> Option<&'a mozart_registry::lockfile::LockedPackage> { - let name_lower = name.to_lowercase(); - locked - .iter() - .find(|lp| lp.name.to_lowercase() == name_lower) - .copied() + Ok(()) } -#[cfg(test)] -mod tests { - use super::*; - use std::collections::BTreeMap; - - fn make_installed_entry( - name: &str, - pkg_type: Option<&str>, - ) -> mozart_registry::installed::InstalledPackageEntry { - mozart_registry::installed::InstalledPackageEntry { - name: name.to_string(), - version: "1.0.0".to_string(), - version_normalized: None, - source: None, - dist: None, - package_type: pkg_type.map(|t| t.to_string()), - install_path: None, - autoload: None, - aliases: vec![], - homepage: None, - support: None, - extra_fields: BTreeMap::new(), - } - } - - fn make_locked_package(name: &str, version: &str) -> mozart_registry::lockfile::LockedPackage { - mozart_registry::lockfile::LockedPackage { - name: name.to_string(), - version: version.to_string(), - version_normalized: None, - source: None, - dist: None, - require: BTreeMap::new(), - require_dev: BTreeMap::new(), - conflict: BTreeMap::new(), - provide: BTreeMap::new(), - replace: 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(), - } - } - - #[test] - fn test_glob_exact_match() { - assert!(glob_matches("monolog/monolog", "monolog/monolog")); - assert!(!glob_matches("monolog/monolog", "psr/log")); - } - - #[test] - fn test_glob_wildcard_suffix() { - assert!(glob_matches("monolog/*", "monolog/monolog")); - assert!(glob_matches("psr/*", "psr/log")); - assert!(!glob_matches("psr/*", "monolog/monolog")); - } - - #[test] - fn test_glob_wildcard_prefix() { - assert!(glob_matches("*/log", "monolog/log")); - assert!(glob_matches("*/log", "psr/log")); - assert!(!glob_matches("*/log", "psr/container")); - } - - #[test] - fn test_glob_wildcard_middle() { - assert!(glob_matches("symfony/*/bridge", "symfony/http/bridge")); - assert!(!glob_matches("symfony/*/bridge", "monolog/monolog")); - } - - #[test] - fn test_glob_star_only() { - assert!(glob_matches("*", "anything/at/all")); - assert!(glob_matches("*", "psr/log")); - } - - #[test] - fn test_glob_case_insensitive_by_caller() { - // glob_matches operates on pre-lowercased strings; - // confirm exact match fails if caller did not lowercase - assert!(!glob_matches("Monolog/Monolog", "monolog/monolog")); - // But lowercased match works - assert!(glob_matches("monolog/monolog", "monolog/monolog")); - } - - #[test] - fn test_find_locked_package_found() { - let pkgs = [ - make_locked_package("psr/log", "3.0.0"), - make_locked_package("monolog/monolog", "3.8.0"), - ]; - let refs: Vec<&mozart_registry::lockfile::LockedPackage> = pkgs.iter().collect(); - - let result = find_locked_package(&refs, "psr/log"); - assert!(result.is_some()); - assert_eq!(result.unwrap().name, "psr/log"); - } - - #[test] - fn test_find_locked_package_case_insensitive() { - let pkgs = [make_locked_package("Monolog/Monolog", "3.8.0")]; - let refs: Vec<&mozart_registry::lockfile::LockedPackage> = pkgs.iter().collect(); - - let result = find_locked_package(&refs, "monolog/monolog"); - assert!(result.is_some()); - } - - #[test] - fn test_find_locked_package_not_found() { - let pkgs = [make_locked_package("psr/log", "3.0.0")]; - let refs: Vec<&mozart_registry::lockfile::LockedPackage> = pkgs.iter().collect(); - - let result = find_locked_package(&refs, "monolog/monolog"); - assert!(result.is_none()); - } - - #[test] - fn test_filter_by_type_library() { - let e1 = make_installed_entry("psr/log", Some("library")); - let e2 = make_installed_entry("symfony/console", Some("library")); - let e3 = make_installed_entry("my/plugin", Some("composer-plugin")); - let candidates = vec![&e1, &e2, &e3]; - - let result = filter_by_type(&candidates, &["library".to_string()]); - assert_eq!(result.len(), 2); - assert!(result.iter().any(|p| p.name == "psr/log")); - assert!(result.iter().any(|p| p.name == "symfony/console")); - } - - #[test] - fn test_filter_by_type_case_insensitive() { - let e1 = make_installed_entry("my/plugin", Some("Composer-Plugin")); - let candidates = vec![&e1]; - - let result = filter_by_type(&candidates, &["composer-plugin".to_string()]); - assert_eq!(result.len(), 1); - } - - #[test] - fn test_filter_by_type_no_type_field_treated_as_library() { - let e1 = make_installed_entry("psr/log", None); // no type - let candidates = vec![&e1]; - - let result = filter_by_type(&candidates, &["library".to_string()]); - assert_eq!(result.len(), 1); - assert_eq!(result[0].name, "psr/log"); - } - - #[test] - fn test_filter_by_type_multiple_types() { - let e1 = make_installed_entry("psr/log", Some("library")); - let e2 = make_installed_entry("my/plugin", Some("composer-plugin")); - let e3 = make_installed_entry("my/project", Some("project")); - let candidates = vec![&e1, &e2, &e3]; - - let result = filter_by_type( - &candidates, - &["library".to_string(), "composer-plugin".to_string()], - ); - assert_eq!(result.len(), 2); - } - - #[test] - fn test_filter_by_names_exact() { - let e1 = make_installed_entry("psr/log", Some("library")); - let e2 = make_installed_entry("monolog/monolog", Some("library")); - let candidates = vec![&e1, &e2]; - - let result = filter_by_names(&candidates, &["psr/log".to_string()]); - assert_eq!(result.len(), 1); - assert_eq!(result[0].name, "psr/log"); - } - - #[test] - fn test_filter_by_names_wildcard() { - let e1 = make_installed_entry("psr/log", Some("library")); - let e2 = make_installed_entry("psr/container", Some("library")); - let e3 = make_installed_entry("monolog/monolog", Some("library")); - let candidates = vec![&e1, &e2, &e3]; - - let result = filter_by_names(&candidates, &["psr/*".to_string()]); - assert_eq!(result.len(), 2); - assert!(result.iter().any(|p| p.name == "psr/log")); - assert!(result.iter().any(|p| p.name == "psr/container")); - } - - #[test] - fn test_filter_by_names_case_insensitive() { - let e1 = make_installed_entry("Monolog/Monolog", Some("library")); - let candidates = vec![&e1]; - - let result = filter_by_names(&candidates, &["monolog/monolog".to_string()]); - assert_eq!(result.len(), 1); - } - - #[test] - fn test_filter_by_names_no_match() { - let e1 = make_installed_entry("psr/log", Some("library")); - let candidates = vec![&e1]; - - let result = filter_by_names(&candidates, &["nonexistent/package".to_string()]); - assert!(result.is_empty()); - } - - /// Verify that the validation logic (both --type and names) is reflected in arg combinations. - /// We can't call execute() without a full environment, but we can test the logic directly. - #[test] - fn test_mutual_exclusion_both_type_and_names() { - let has_packages = true; - let has_type = true; - assert!( - has_packages && has_type, - "Both packages and type provided — should be rejected" - ); - } - - #[test] - fn test_mutual_exclusion_neither_type_nor_names() { - let has_packages = false; - let has_type = false; - assert!( - !has_packages && !has_type, - "Neither packages nor type provided — should be rejected" - ); - } - - #[test] - fn test_dev_filtering_excludes_dev_packages() { - let e1 = make_installed_entry("psr/log", Some("library")); - let e2 = make_installed_entry("phpunit/phpunit", Some("library")); - - let mut installed = mozart_registry::installed::InstalledPackages::new(); - installed.packages.push(e1.clone()); - installed.packages.push(e2.clone()); - installed.dev_package_names = vec!["phpunit/phpunit".to_string()]; - - let dev_package_names: indexmap::IndexSet<String> = installed - .dev_package_names - .iter() - .map(|n| n.to_lowercase()) - .collect(); - - // Simulate --no-dev filtering - let candidates: Vec<&mozart_registry::installed::InstalledPackageEntry> = installed - .packages - .iter() - .filter(|pkg| !dev_package_names.contains(&pkg.name.to_lowercase())) - .collect(); - - assert_eq!(candidates.len(), 1); - assert_eq!(candidates[0].name, "psr/log"); +/// Mirror of `BaseCommand::getPlatformRequirementFilter` for the +/// `reinstall` command. Priority: +/// 1. `--ignore-platform-reqs` → ignore every platform requirement +/// 2. `--ignore-platform-req <name>...` (non-empty) → ignore the listed +/// names (with `*` glob support) +/// 3. neither → ignore nothing +fn get_platform_requirement_filter( + args: &ReinstallArgs, +) -> anyhow::Result<PlatformRequirementFilter> { + if args.ignore_platform_reqs { + return Ok(PlatformRequirementFilter::ignore_all()); } - - #[test] - fn test_dev_filtering_includes_all_without_no_dev() { - let e1 = make_installed_entry("psr/log", Some("library")); - let e2 = make_installed_entry("phpunit/phpunit", Some("library")); - - let mut installed = mozart_registry::installed::InstalledPackages::new(); - installed.packages.push(e1.clone()); - installed.packages.push(e2.clone()); - installed.dev_package_names = vec!["phpunit/phpunit".to_string()]; - - // no_dev = false: include all - let candidates: Vec<&mozart_registry::installed::InstalledPackageEntry> = - installed.packages.iter().collect(); - - assert_eq!(candidates.len(), 2); + if !args.ignore_platform_req.is_empty() { + return PlatformRequirementFilter::from_list(&args.ignore_platform_req); } + Ok(PlatformRequirementFilter::ignore_nothing()) } |
