aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-23 01:34:55 +0900
committernsfisis <nsfisis@gmail.com>2026-02-23 01:58:28 +0900
commitec3d69446cf07409b9c91de3d2e63856f33b26fd (patch)
tree06f6c7eb0633809c8f58ab098adb9899cc57af90
parent1ab3d928a2d350ce407205d9ee6ea9569cd38424 (diff)
downloadphp-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>
-rw-r--r--crates/mozart-core/src/lib.rs2
-rw-r--r--crates/mozart-core/src/wildcard.rs86
-rw-r--r--crates/mozart/src/commands/outdated.rs111
-rw-r--r--crates/mozart/src/commands/show.rs52
4 files changed, 148 insertions, 103 deletions
diff --git a/crates/mozart-core/src/lib.rs b/crates/mozart-core/src/lib.rs
index 510257b..5383d9a 100644
--- a/crates/mozart-core/src/lib.rs
+++ b/crates/mozart-core/src/lib.rs
@@ -6,5 +6,7 @@ pub mod platform;
pub mod suggest;
pub mod validation;
pub mod version_bumper;
+pub mod wildcard;
pub use mozart_console_macros::console_format;
+pub use wildcard::matches_wildcard;
diff --git a/crates/mozart-core/src/wildcard.rs b/crates/mozart-core/src/wildcard.rs
new file mode 100644
index 0000000..9193549
--- /dev/null
+++ b/crates/mozart-core/src/wildcard.rs
@@ -0,0 +1,86 @@
+/// Match a package name against a wildcard pattern (case-insensitive).
+/// `*` matches any sequence of characters.
+pub 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
+}
+
+// ─── Tests ──────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_matches_wildcard_exact() {
+ assert!(matches_wildcard("psr/log", "psr/log"));
+ }
+
+ #[test]
+ fn test_matches_wildcard_star_end() {
+ assert!(matches_wildcard("psr/log", "psr/*"));
+ }
+
+ #[test]
+ fn test_matches_wildcard_star_start() {
+ assert!(matches_wildcard("psr/log", "*/log"));
+ }
+
+ #[test]
+ fn test_matches_wildcard_star_middle() {
+ assert!(matches_wildcard("monolog/monolog", "mono*/mono*"));
+ }
+
+ #[test]
+ fn test_matches_wildcard_no_match() {
+ assert!(!matches_wildcard("psr/log", "symfony/*"));
+ }
+
+ #[test]
+ fn test_matches_wildcard_case_insensitive() {
+ assert!(matches_wildcard("PSR/Log", "psr/*"));
+ }
+
+ #[test]
+ fn test_matches_wildcard_star_both_ends() {
+ assert!(matches_wildcard("monolog/monolog", "*log*"));
+ }
+
+ #[test]
+ fn test_matches_wildcard_no_wildcard_mismatch() {
+ assert!(!matches_wildcard("psr/log", "psr/log2"));
+ }
+
+ #[test]
+ fn test_matches_wildcard_trailing_chars_fail() {
+ assert!(!matches_wildcard("psr/log", "psr/l"));
+ }
+}
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);