diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-23 01:34:55 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-23 01:58:28 +0900 |
| commit | ec3d69446cf07409b9c91de3d2e63856f33b26fd (patch) | |
| tree | 06f6c7eb0633809c8f58ab098adb9899cc57af90 /crates/mozart | |
| parent | 1ab3d928a2d350ce407205d9ee6ea9569cd38424 (diff) | |
| download | php-mozart-ec3d69446cf07409b9c91de3d2e63856f33b26fd.tar.gz php-mozart-ec3d69446cf07409b9c91de3d2e63856f33b26fd.tar.zst php-mozart-ec3d69446cf07409b9c91de3d2e63856f33b26fd.zip | |
fix(show,outdated): align outdated classification and wildcard handling with Composer
- Extract matches_wildcard to mozart-core for reuse across commands
- Support wildcard patterns in --package and --ignore arguments
- Use ^<installed_version> for semver-safe classification instead of root constraint
- Replace std::process::exit(1) with bail_silent for proper cleanup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart')
| -rw-r--r-- | crates/mozart/src/commands/outdated.rs | 111 | ||||
| -rw-r--r-- | crates/mozart/src/commands/show.rs | 52 |
2 files changed, 60 insertions, 103 deletions
diff --git a/crates/mozart/src/commands/outdated.rs b/crates/mozart/src/commands/outdated.rs index 5c7b691..9da8e0e 100644 --- a/crates/mozart/src/commands/outdated.rs +++ b/crates/mozart/src/commands/outdated.rs @@ -1,6 +1,7 @@ use clap::Args; +use mozart_core::matches_wildcard; use std::cmp::Ordering; -use std::collections::{BTreeMap, HashSet}; +use std::collections::HashSet; use std::path::{Path, PathBuf}; #[derive(Args)] @@ -142,33 +143,15 @@ pub async fn execute( HashSet::new() }; - // Build constraint map from root composer.json - let root_constraints: BTreeMap<String, String> = if let Some(ref root) = root_package { - let mut map: BTreeMap<String, String> = root - .require - .iter() - .map(|(k, v)| (k.to_lowercase(), v.clone())) - .collect(); - if !args.no_dev { - map.extend( - root.require_dev - .iter() - .map(|(k, v)| (k.to_lowercase(), v.clone())), - ); - } - map - } else { - BTreeMap::new() - }; - - // Build ignore set - let ignore_set: HashSet<String> = args.ignore.iter().map(|n| n.to_lowercase()).collect(); - // Process each package let mut entries: Vec<OutdatedEntry> = Vec::new(); for pkg in &packages { // Skip ignored packages - if ignore_set.contains(&pkg.name.to_lowercase()) { + if args + .ignore + .iter() + .any(|pattern| matches_wildcard(&pkg.name, pattern)) + { continue; } @@ -178,10 +161,14 @@ pub async fn execute( } // --package filter - if let Some(ref filter) = args.package - && !pkg.name.eq_ignore_ascii_case(filter) - { - continue; + if let Some(ref filter) = args.package { + if filter.contains('*') { + if !matches_wildcard(&pkg.name, filter) { + continue; + } + } else if !pkg.name.eq_ignore_ascii_case(filter) { + continue; + } } // Fetch latest version from Packagist @@ -194,12 +181,7 @@ pub async fn execute( }; // Classify the update - let root_constraint = root_constraints.get(&pkg.name.to_lowercase()).cloned(); - let category = classify_update( - &pkg.version_normalized, - &latest.version_normalized, - root_constraint.as_deref(), - ); + let category = classify_update(&pkg.version_normalized, &latest.version_normalized); // If showing all, include up-to-date; otherwise only show outdated if !args.all && category == UpdateCategory::UpToDate { @@ -242,7 +224,9 @@ pub async fn execute( .iter() .any(|e| e.category != UpdateCategory::UpToDate); if has_outdated { - std::process::exit(1); + return Err(mozart_core::exit_code::bail_silent( + mozart_core::exit_code::GENERAL_ERROR, + )); } } @@ -359,15 +343,13 @@ async fn fetch_latest_version(name: &str) -> anyhow::Result<PackageInfo> { /// Determine the update category for a package. /// +/// Mirrors Composer's logic: constructs `^<installed_version>` and checks if the +/// latest version satisfies it. +/// /// - If latest <= current → UpToDate -/// - If root constraint exists and latest matches it → SemverCompatible (red) -/// - If root constraint exists but latest doesn't match → SemverIncompatible (yellow) -/// - Fallback (no constraint): same major = compatible, different major = incompatible -fn classify_update( - current_normalized: &str, - latest_normalized: &str, - root_constraint: Option<&str>, -) -> UpdateCategory { +/// - If latest satisfies `^<current>` → SemverCompatible (semver-safe update) +/// - Otherwise → SemverIncompatible (update-possible, may require constraint change) +fn classify_update(current_normalized: &str, latest_normalized: &str) -> UpdateCategory { use mozart_registry::version::compare_normalized_versions; // If latest is not newer than current, it's up-to-date @@ -375,9 +357,10 @@ fn classify_update( return UpdateCategory::UpToDate; } - // We have an update available — classify it - if let Some(constraint_str) = root_constraint - && let Ok(constraint) = mozart_semver::VersionConstraint::parse(constraint_str) + // Build ^<current_normalized> constraint and check if latest satisfies it. + // This mirrors Composer's approach of checking semver safety. + let caret_constraint = format!("^{current_normalized}"); + if let Ok(constraint) = mozart_semver::VersionConstraint::parse(&caret_constraint) && let Ok(latest_ver) = mozart_semver::Version::parse(latest_normalized) { if constraint.matches(&latest_ver) { @@ -387,7 +370,7 @@ fn classify_update( } } - // Fallback: no constraint or parse failed — compare major versions + // Fallback: parse failed — compare major versions let current_major = extract_major(current_normalized); let latest_major = extract_major(latest_normalized); if current_major == latest_major { @@ -590,48 +573,48 @@ mod tests { #[test] fn test_classify_up_to_date_equal() { - let cat = classify_update("1.2.3.0", "1.2.3.0", None); + let cat = classify_update("1.2.3.0", "1.2.3.0"); assert_eq!(cat, UpdateCategory::UpToDate); } #[test] fn test_classify_up_to_date_latest_older() { - let cat = classify_update("2.0.0.0", "1.5.0.0", None); + let cat = classify_update("2.0.0.0", "1.5.0.0"); assert_eq!(cat, UpdateCategory::UpToDate); } #[test] - fn test_classify_semver_compatible_with_constraint() { - // Current 1.2.0, latest 1.3.0, constraint ^1.0 — latest matches constraint - let cat = classify_update("1.2.0.0", "1.3.0.0", Some("^1.0")); + fn test_classify_semver_compatible_minor_update() { + // Current 1.2.0, latest 1.3.0 — ^1.2.0 allows 1.3.0 + let cat = classify_update("1.2.0.0", "1.3.0.0"); assert_eq!(cat, UpdateCategory::SemverCompatible); } #[test] - fn test_classify_semver_incompatible_with_constraint() { - // Current 1.2.0, latest 2.0.0, constraint ^1.0 — latest doesn't match - let cat = classify_update("1.2.0.0", "2.0.0.0", Some("^1.0")); + fn test_classify_semver_incompatible_major_bump() { + // Current 1.2.0, latest 2.0.0 — ^1.2.0 does not allow 2.0.0 + let cat = classify_update("1.2.0.0", "2.0.0.0"); assert_eq!(cat, UpdateCategory::SemverIncompatible); } #[test] - fn test_classify_no_constraint_same_major() { - // No constraint, same major → SemverCompatible - let cat = classify_update("1.2.0.0", "1.5.0.0", None); + fn test_classify_semver_compatible_same_major() { + // Same major, minor bump → semver-safe under ^ + let cat = classify_update("1.2.0.0", "1.5.0.0"); assert_eq!(cat, UpdateCategory::SemverCompatible); } #[test] - fn test_classify_no_constraint_different_major() { - // No constraint, different major → SemverIncompatible - let cat = classify_update("1.9.0.0", "2.0.0.0", None); + fn test_classify_semver_incompatible_different_major() { + // Different major → not semver-safe under ^ + let cat = classify_update("1.9.0.0", "2.0.0.0"); assert_eq!(cat, UpdateCategory::SemverIncompatible); } #[test] - fn test_classify_no_constraint_patch_update() { - // No constraint, same major.minor, patch bump → SemverCompatible - let cat = classify_update("1.2.3.0", "1.2.4.0", None); + fn test_classify_semver_compatible_patch_update() { + // Patch bump → SemverCompatible + let cat = classify_update("1.2.3.0", "1.2.4.0"); assert_eq!(cat, UpdateCategory::SemverCompatible); } diff --git a/crates/mozart/src/commands/show.rs b/crates/mozart/src/commands/show.rs index 4712757..346c7b7 100644 --- a/crates/mozart/src/commands/show.rs +++ b/crates/mozart/src/commands/show.rs @@ -1,5 +1,6 @@ use clap::Args; use mozart_core::console_format; +use mozart_core::matches_wildcard; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -334,7 +335,9 @@ async fn show_installed_package_list( if format == "json" { render_installed_json(&entries)?; if args.strict && has_outdated { - std::process::exit(1); + return Err(mozart_core::exit_code::bail_silent( + mozart_core::exit_code::GENERAL_ERROR, + )); } return Ok(()); } @@ -430,7 +433,9 @@ async fn show_installed_package_list( } if args.strict && has_outdated { - std::process::exit(1); + return Err(mozart_core::exit_code::bail_silent( + mozart_core::exit_code::GENERAL_ERROR, + )); } Ok(()) @@ -784,7 +789,9 @@ async fn show_locked_package_list( if format == "json" { render_locked_json(&entries)?; if args.strict && has_outdated { - std::process::exit(1); + return Err(mozart_core::exit_code::bail_silent( + mozart_core::exit_code::GENERAL_ERROR, + )); } return Ok(()); } @@ -880,7 +887,9 @@ async fn show_locked_package_list( } if args.strict && has_outdated { - std::process::exit(1); + return Err(mozart_core::exit_code::bail_silent( + mozart_core::exit_code::GENERAL_ERROR, + )); } Ok(()) @@ -1648,41 +1657,6 @@ fn resolve_path(path: &Path) -> String { } } -/// Match a package name against a wildcard pattern (case-insensitive). -/// `*` matches any sequence of characters. -fn matches_wildcard(name: &str, pattern: &str) -> bool { - let name_lower = name.to_lowercase(); - let pattern_lower = pattern.to_lowercase(); - let parts: Vec<&str> = pattern_lower.split('*').collect(); - - if parts.len() == 1 { - return name_lower == pattern_lower; - } - - let mut pos = 0usize; - for (i, part) in parts.iter().enumerate() { - if part.is_empty() { - continue; - } - match name_lower[pos..].find(*part) { - Some(found) => { - if i == 0 && found != 0 { - return false; // First segment must match at start - } - pos += found + part.len(); - } - None => return false, - } - } - - // If pattern doesn't end with *, name must be fully consumed - if !pattern_lower.ends_with('*') { - return pos == name_lower.len(); - } - - true -} - /// Simple version normalizer fallback when `version_normalized` is absent. fn normalize_version_simple(version: &str) -> String { let v = version.strip_prefix('v').unwrap_or(version); |
