diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-08 20:06:29 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-08 20:06:29 +0900 |
| commit | b286af9ffe78d50b63bf5fda7fc796ab20f2552f (patch) | |
| tree | 3b3cb80e790aaa6b457cfeca1e7efab7126e016a /crates | |
| parent | 5cb8fc4e306970764e84bb850da2c56f844c3b12 (diff) | |
| download | php-mozart-b286af9ffe78d50b63bf5fda7fc796ab20f2552f.tar.gz php-mozart-b286af9ffe78d50b63bf5fda7fc796ab20f2552f.tar.zst php-mozart-b286af9ffe78d50b63bf5fda7fc796ab20f2552f.zip | |
fix(reinstall): align with Composer's ReinstallCommand pipeline
Switch to Composer::require() for the entrypoint, drop the Mozart-only
--dry-run / --no-dev flags, mirror selection inline using a port of
BasePackage::packageNameToRegexp, read autoloader options from
composer.config(), and route the autoload dump through
composer.autoload_generator(). Empty-result and unmatched-pattern
warnings now emit on stderr with <warning> markup, matching
$io->writeError. Plugin/script-event dispatch and Transaction-based
operation building remain TODO until the installer_executor lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/mozart-core/src/composer.rs | 26 | ||||
| -rw-r--r-- | crates/mozart-core/src/factory.rs | 28 | ||||
| -rw-r--r-- | crates/mozart-core/src/validation.rs | 52 | ||||
| -rw-r--r-- | crates/mozart/src/commands/reinstall.rs | 665 |
4 files changed, 208 insertions, 563 deletions
diff --git a/crates/mozart-core/src/composer.rs b/crates/mozart-core/src/composer.rs index 66bae92..effcae4 100644 --- a/crates/mozart-core/src/composer.rs +++ b/crates/mozart-core/src/composer.rs @@ -219,11 +219,27 @@ impl LocalPackage { /// commands that walk the local install (currently: `dump-autoload`). pub struct LocalRepository { packages: Vec<LocalPackage>, + /// Mirrors `InstalledRepositoryInterface::getDevMode()`: `Some(true)` when + /// the last install ran with dev requires, `Some(false)` when run with + /// `--no-dev`, and `None` when the flag was absent (the legacy v1 + /// installed.json shape, or an in-memory repository that was never + /// hydrated from disk). Callers default to `true` on `None`, matching + /// `ReinstallCommand::execute`'s `getDevMode() ?? true`. + dev_mode: Option<bool>, } impl LocalRepository { pub fn new(packages: Vec<LocalPackage>) -> Self { - Self { packages } + Self { + packages, + dev_mode: None, + } + } + + /// Build a [`LocalRepository`] with an explicit `dev` flag taken from + /// `vendor/composer/installed.json`'s top-level `dev` field. + pub fn with_dev_mode(packages: Vec<LocalPackage>, dev_mode: Option<bool>) -> Self { + Self { packages, dev_mode } } /// Mirror of `WritableRepositoryInterface::getCanonicalPackages` — @@ -233,6 +249,14 @@ impl LocalRepository { pub fn canonical_packages(&self) -> impl Iterator<Item = &LocalPackage> { self.packages.iter() } + + /// Mirror of `InstalledRepositoryInterface::getDevMode()` — returns + /// `None` when the source `installed.json` did not record a `dev` + /// flag (e.g. legacy v1 array form). Callers should default to + /// `true` on `None`, matching PHP's `?? true` coalesce. + pub fn dev_mode(&self) -> Option<bool> { + self.dev_mode + } } /// Mirror of `Composer\Repository\RepositoryManager`. Today only the diff --git a/crates/mozart-core/src/factory.rs b/crates/mozart-core/src/factory.rs index c9d346b..6602056 100644 --- a/crates/mozart-core/src/factory.rs +++ b/crates/mozart-core/src/factory.rs @@ -208,8 +208,9 @@ pub fn create_composer(project_dir: PathBuf, composer_json: &Path) -> anyhow::Re project_dir.join(&config.vendor_dir) }; - let local_packages = read_local_packages(&vendor_dir)?; - let repository_manager = RepositoryManager::new(LocalRepository::new(local_packages)); + let (local_packages, dev_mode) = read_local_packages(&vendor_dir)?; + let repository_manager = + RepositoryManager::new(LocalRepository::with_dev_mode(local_packages, dev_mode)); let installation_manager = InstallationManager::new(vendor_dir); let autoload_generator = AutoloadGenerator::new(); @@ -252,21 +253,24 @@ pub fn create_composer(project_dir: PathBuf, composer_json: &Path) -> anyhow::Re /// dependency graph; the parsing that's actually load-bearing for the /// install-path computation is just the package name + optional /// `target-dir`. -fn read_local_packages(vendor_dir: &Path) -> anyhow::Result<Vec<LocalPackage>> { +fn read_local_packages(vendor_dir: &Path) -> anyhow::Result<(Vec<LocalPackage>, Option<bool>)> { let path = vendor_dir.join("composer/installed.json"); if !path.exists() { - return Ok(Vec::new()); + return Ok((Vec::new(), None)); } let content = std::fs::read_to_string(&path)?; let value: serde_json::Value = serde_json::from_str(&content)?; - let entries: &[serde_json::Value] = match &value { - serde_json::Value::Object(obj) => match obj.get("packages") { - Some(serde_json::Value::Array(arr)) => arr.as_slice(), - _ => return Ok(Vec::new()), - }, - serde_json::Value::Array(arr) => arr.as_slice(), - _ => return Ok(Vec::new()), + let (entries, dev_mode): (&[serde_json::Value], Option<bool>) = match &value { + serde_json::Value::Object(obj) => { + let entries = match obj.get("packages") { + Some(serde_json::Value::Array(arr)) => arr.as_slice(), + _ => return Ok((Vec::new(), obj.get("dev").and_then(|v| v.as_bool()))), + }; + (entries, obj.get("dev").and_then(|v| v.as_bool())) + } + serde_json::Value::Array(arr) => (arr.as_slice(), None), + _ => return Ok((Vec::new(), None)), }; let mut out = Vec::with_capacity(entries.len()); @@ -310,7 +314,7 @@ fn read_local_packages(vendor_dir: &Path) -> anyhow::Result<Vec<LocalPackage>> { extra, )); } - Ok(out) + Ok((out, dev_mode)) } fn read_package_reference( diff --git a/crates/mozart-core/src/validation.rs b/crates/mozart-core/src/validation.rs index 24f1705..c27eb91 100644 --- a/crates/mozart-core/src/validation.rs +++ b/crates/mozart-core/src/validation.rs @@ -115,6 +115,19 @@ pub fn parse_require_string(s: &str) -> Result<(String, String), String> { )) } +/// Mirror of `Composer\Package\BasePackage::packageNameToRegexp`. Each +/// character of `pattern` is `regex::escape`d, then `\*` is rewritten +/// to `.*`, and the result is anchored and compiled case-insensitively. +/// Used by selection commands (`reinstall`, `remove`, `update`, …) to +/// expand `vendor/*`-style globs against installed/locked package names. +pub fn package_name_to_regexp(pattern: &str) -> Regex { + let escaped = regex::escape(pattern).replace("\\*", ".*"); + // PHP wraps with `{^...$}i`; in Rust we anchor with `\A`/`\z` and + // pass `(?i)` for case-insensitivity. + Regex::new(&format!(r"(?i)\A{escaped}\z")) + .expect("package_name_to_regexp pattern always compiles") +} + #[cfg(test)] mod tests { use super::*; @@ -207,6 +220,45 @@ mod tests { } #[test] + fn test_package_name_to_regexp_exact() { + let re = package_name_to_regexp("monolog/monolog"); + assert!(re.is_match("monolog/monolog")); + assert!(re.is_match("Monolog/Monolog")); + assert!(!re.is_match("psr/log")); + assert!(!re.is_match("monolog/monolog-extra")); + assert!(!re.is_match("xmonolog/monolog")); + } + + #[test] + fn test_package_name_to_regexp_wildcard() { + let re = package_name_to_regexp("psr/*"); + assert!(re.is_match("psr/log")); + assert!(re.is_match("psr/container")); + assert!(re.is_match("psr/log/sub")); + assert!(!re.is_match("monolog/monolog")); + + let re = package_name_to_regexp("*/log"); + assert!(re.is_match("psr/log")); + assert!(re.is_match("monolog/log")); + assert!(!re.is_match("psr/container")); + + let re = package_name_to_regexp("symfony/*/bridge"); + assert!(re.is_match("symfony/http/bridge")); + assert!(!re.is_match("symfony/bridge")); + + let re = package_name_to_regexp("*"); + assert!(re.is_match("anything/at/all")); + } + + #[test] + fn test_package_name_to_regexp_escapes_metacharacters() { + // `.` must be literal, not "any character" + let re = package_name_to_regexp("foo.bar/baz"); + assert!(re.is_match("foo.bar/baz")); + assert!(!re.is_match("fooXbar/baz")); + } + + #[test] fn test_parse_require_string() { let (name, ver) = parse_require_string("foo/bar:^1.0").unwrap(); assert_eq!(name, "foo/bar"); 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()) } |
