You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by cs...@apache.org on 2026/04/17 15:56:26 UTC

(maven-resolver) branch master updated: GH-1835: Expose qualifiers for generic version scheme (#1837)

This is an automated email from the ASF dual-hosted git repository.

cstamas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven-resolver.git


The following commit(s) were added to refs/heads/master by this push:
     new 283b54202 GH-1835: Expose qualifiers for generic version scheme (#1837)
283b54202 is described below

commit 283b542024f6a45d46c89cf0bcc5ec3495c7b8c6
Author: Tamas Cservenak <ta...@cservenak.net>
AuthorDate: Fri Apr 17 17:56:20 2026 +0200

    GH-1835: Expose qualifiers for generic version scheme (#1837)
    
    This allows nice version selection. Also, provide a version filter that
    leaves out preview (sans snapshot) versions.
    
    Cleaned up existing filters, and added new context predicate one.
    
    Finally, made generic classes public, they were package protected.
    
    Fixes #1835
---
 ...er.java => ArtifactPredicateVersionFilter.java} |  10 +-
 ...> ContextPredicateDelegatingVersionFilter.java} |  43 +++----
 .../version/GenericQualifiersVersionFilter.java    |  69 ++++++++++++
 .../util/graph/version/PredicateVersionFilter.java |  51 +--------
 .../util/graph/version/ReleaseVersionFilter.java   |   8 +-
 .../util/graph/version/SnapshotVersionFilter.java  |   6 +-
 .../version/VersionPredicateVersionFilter.java     |   2 +-
 .../aether/util/version/GenericQualifiers.java     | 123 +++++++++++++++++++++
 .../aether/util/version/GenericVersion.java        |  47 ++------
 .../util/version/GenericVersionConstraint.java     |   2 +-
 .../aether/util/version/GenericVersionRange.java   |   2 +-
 .../aether/util/version/GenericVersionScheme.java  |   4 +
 .../aether/util/version/VersionSchemeSupport.java  |   2 +-
 .../eclipse/aether/util/version/package-info.java  |   1 +
 .../AbstractVersionFilterTest.java                 |   7 +-
 .../ArtifactPredicateVersionFilterTest.java}       |  21 ++--
 .../ChainedVersionFilterTest.java                  |   5 +-
 .../ContextualSnapshotVersionFilterTest.java       |   4 +-
 .../GenericQualifiersVersionFilterTest.java        |  90 +++++++++++++++
 .../HighestVersionFilterTest.java                  |   3 +-
 .../LowestVersionFilterTest.java                   |   3 +-
 .../PredicateVersionFilterTest.java                |   3 +-
 .../ReleaseVersionFilterTest.java                  |   3 +-
 .../SnapshotVersionFilterTest.java                 |   3 +-
 .../aether/util/version/GenericQualifiersTest.java |  87 +++++++++++++++
 25 files changed, 452 insertions(+), 147 deletions(-)

diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/PredicateVersionFilter.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/ArtifactPredicateVersionFilter.java
similarity index 87%
copy from maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/PredicateVersionFilter.java
copy to maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/ArtifactPredicateVersionFilter.java
index 2f66d6c7c..1544beadd 100644
--- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/PredicateVersionFilter.java
+++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/ArtifactPredicateVersionFilter.java
@@ -30,17 +30,17 @@ import org.eclipse.aether.version.Version;
 import static java.util.Objects.requireNonNull;
 
 /**
- * A version filter that excludes any version that is blacklisted.
+ * A version filter that excludes any version that is not-matched by artifact predicate.
  *
- * @since 2.0.0
+ * @since 2.0.17
  */
-public class PredicateVersionFilter implements VersionFilter {
+public class ArtifactPredicateVersionFilter implements VersionFilter {
     private final Predicate<Artifact> artifactPredicate;
 
     /**
      * Creates a new instance of this version filter.
      */
-    public PredicateVersionFilter(Predicate<Artifact> artifactPredicate) {
+    public ArtifactPredicateVersionFilter(Predicate<Artifact> artifactPredicate) {
         this.artifactPredicate = requireNonNull(artifactPredicate);
     }
 
@@ -69,7 +69,7 @@ public class PredicateVersionFilter implements VersionFilter {
         if (o == null || getClass() != o.getClass()) {
             return false;
         }
-        PredicateVersionFilter that = (PredicateVersionFilter) o;
+        ArtifactPredicateVersionFilter that = (ArtifactPredicateVersionFilter) o;
         return Objects.equals(artifactPredicate, that.artifactPredicate);
     }
 
diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/PredicateVersionFilter.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/ContextPredicateDelegatingVersionFilter.java
similarity index 54%
copy from maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/PredicateVersionFilter.java
copy to maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/ContextPredicateDelegatingVersionFilter.java
index 2f66d6c7c..7ebb7a4af 100644
--- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/PredicateVersionFilter.java
+++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/ContextPredicateDelegatingVersionFilter.java
@@ -18,47 +18,48 @@
  */
 package org.eclipse.aether.util.graph.version;
 
-import java.util.Iterator;
 import java.util.Objects;
 import java.util.function.Predicate;
 
-import org.eclipse.aether.artifact.Artifact;
+import org.eclipse.aether.RepositoryException;
 import org.eclipse.aether.collection.DependencyCollectionContext;
 import org.eclipse.aether.collection.VersionFilter;
-import org.eclipse.aether.version.Version;
 
 import static java.util.Objects.requireNonNull;
 
 /**
- * A version filter that excludes any version that is blacklisted.
+ * A version filter that applies delegate version filter if context predicate applies.
  *
- * @since 2.0.0
+ * @since 2.0.17
  */
-public class PredicateVersionFilter implements VersionFilter {
-    private final Predicate<Artifact> artifactPredicate;
+public class ContextPredicateDelegatingVersionFilter implements VersionFilter {
+    private final Predicate<VersionFilterContext> contextPredicate;
+    private final VersionFilter delegate;
 
     /**
      * Creates a new instance of this version filter.
      */
-    public PredicateVersionFilter(Predicate<Artifact> artifactPredicate) {
-        this.artifactPredicate = requireNonNull(artifactPredicate);
+    public ContextPredicateDelegatingVersionFilter(
+            Predicate<VersionFilterContext> contextPredicate, VersionFilter delegate) {
+        this.contextPredicate = requireNonNull(contextPredicate);
+        this.delegate = requireNonNull(delegate);
     }
 
     @Override
-    public void filterVersions(VersionFilterContext context) {
-        Artifact dependencyArtifact = context.getDependency().getArtifact();
-        Iterator<Version> it = context.iterator();
-        while (it.hasNext()) {
-            Version version = it.next();
-            if (!artifactPredicate.test(dependencyArtifact.setVersion(version.toString()))) {
-                it.remove();
-            }
+    public void filterVersions(VersionFilterContext context) throws RepositoryException {
+        if (contextPredicate.test(context)) {
+            delegate.filterVersions(context);
         }
     }
 
     @Override
     public VersionFilter deriveChildFilter(DependencyCollectionContext context) {
-        return this;
+        VersionFilter derived = delegate.deriveChildFilter(context);
+        if (derived == delegate) {
+            return this;
+        } else {
+            return new ContextPredicateDelegatingVersionFilter(contextPredicate, derived);
+        }
     }
 
     @Override
@@ -69,12 +70,12 @@ public class PredicateVersionFilter implements VersionFilter {
         if (o == null || getClass() != o.getClass()) {
             return false;
         }
-        PredicateVersionFilter that = (PredicateVersionFilter) o;
-        return Objects.equals(artifactPredicate, that.artifactPredicate);
+        ContextPredicateDelegatingVersionFilter that = (ContextPredicateDelegatingVersionFilter) o;
+        return Objects.equals(contextPredicate, that.contextPredicate) && Objects.equals(delegate, that.delegate);
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(artifactPredicate);
+        return Objects.hash(contextPredicate, delegate);
     }
 }
diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/GenericQualifiersVersionFilter.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/GenericQualifiersVersionFilter.java
new file mode 100644
index 000000000..3dee8efca
--- /dev/null
+++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/GenericQualifiersVersionFilter.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.eclipse.aether.util.graph.version;
+
+import java.util.function.Predicate;
+
+import org.eclipse.aether.util.version.GenericQualifiers;
+
+/**
+ * A version filter that (unconditionally) blocks based on qualifiers, as defined by {@link GenericQualifiers}.
+ * Note: while SNAPSHOT string is also a qualifier, this filter does not deal with them, see {@link SnapshotVersionFilter},
+ * {@link ContextualSnapshotVersionFilter} and {@link ContextualAncestorSnapshotVersionFilter}.
+ *
+ * @since 2.0.17
+ * @see GenericQualifiers
+ * @see SnapshotVersionFilter
+ * @see ContextualSnapshotVersionFilter
+ * @see ContextualAncestorSnapshotVersionFilter
+ */
+public class GenericQualifiersVersionFilter extends VersionPredicateVersionFilter {
+    /**
+     * Filters any version that contains "preview" qualifiers (alpha, beta, milestone).
+     */
+    public static GenericQualifiersVersionFilter previewVersionFilter() {
+        return new GenericQualifiersVersionFilter(i -> i > GenericQualifiers.QUALIFIER_MILESTONE);
+    }
+
+    /**
+     * Filters any version that contains "pre-release" qualifiers (alpha, beta, milestone, rc/cr).
+     */
+    public static GenericQualifiersVersionFilter preReleaseVersionFilter() {
+        return new GenericQualifiersVersionFilter(i -> i > GenericQualifiers.QUALIFIER_RC);
+    }
+
+    /**
+     * Filters any version that contains any qualifiers.
+     */
+    public static GenericQualifiersVersionFilter anyQualifierVersionFilter() {
+        return new GenericQualifiersVersionFilter(i -> false);
+    }
+
+    /**
+     * Constructor that is able to select which qualifier to accept. Passed in predicate is invoked for version with
+     * detected qualifiers only, while versions without qualifiers are accepted.
+     *
+     * @see GenericQualifiers
+     */
+    public GenericQualifiersVersionFilter(Predicate<Integer> qualifierPredicate) {
+        super(v -> GenericQualifiers.qualifier(v.toString())
+                .map(qualifierPredicate::test)
+                .orElse(true));
+    }
+}
diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/PredicateVersionFilter.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/PredicateVersionFilter.java
index 2f66d6c7c..506ad7fae 100644
--- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/PredicateVersionFilter.java
+++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/PredicateVersionFilter.java
@@ -18,63 +18,22 @@
  */
 package org.eclipse.aether.util.graph.version;
 
-import java.util.Iterator;
-import java.util.Objects;
 import java.util.function.Predicate;
 
 import org.eclipse.aether.artifact.Artifact;
-import org.eclipse.aether.collection.DependencyCollectionContext;
-import org.eclipse.aether.collection.VersionFilter;
-import org.eclipse.aether.version.Version;
-
-import static java.util.Objects.requireNonNull;
 
 /**
- * A version filter that excludes any version that is blacklisted.
+ * A version filter that excludes any version that is blacklisted by predicate.
  *
  * @since 2.0.0
+ * @deprecated Use {@link ArtifactPredicateVersionFilter} instead.
  */
-public class PredicateVersionFilter implements VersionFilter {
-    private final Predicate<Artifact> artifactPredicate;
-
+@Deprecated
+public class PredicateVersionFilter extends ArtifactPredicateVersionFilter {
     /**
      * Creates a new instance of this version filter.
      */
     public PredicateVersionFilter(Predicate<Artifact> artifactPredicate) {
-        this.artifactPredicate = requireNonNull(artifactPredicate);
-    }
-
-    @Override
-    public void filterVersions(VersionFilterContext context) {
-        Artifact dependencyArtifact = context.getDependency().getArtifact();
-        Iterator<Version> it = context.iterator();
-        while (it.hasNext()) {
-            Version version = it.next();
-            if (!artifactPredicate.test(dependencyArtifact.setVersion(version.toString()))) {
-                it.remove();
-            }
-        }
-    }
-
-    @Override
-    public VersionFilter deriveChildFilter(DependencyCollectionContext context) {
-        return this;
-    }
-
-    @Override
-    public boolean equals(Object o) {
-        if (this == o) {
-            return true;
-        }
-        if (o == null || getClass() != o.getClass()) {
-            return false;
-        }
-        PredicateVersionFilter that = (PredicateVersionFilter) o;
-        return Objects.equals(artifactPredicate, that.artifactPredicate);
-    }
-
-    @Override
-    public int hashCode() {
-        return Objects.hash(artifactPredicate);
+        super(artifactPredicate);
     }
 }
diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/ReleaseVersionFilter.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/ReleaseVersionFilter.java
index 08a250122..2ba57c9b2 100644
--- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/ReleaseVersionFilter.java
+++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/ReleaseVersionFilter.java
@@ -18,15 +18,17 @@
  */
 package org.eclipse.aether.util.graph.version;
 
+import org.eclipse.aether.artifact.Artifact;
+
 /**
- * A version filter that (unconditionally) blocks non "*-SNAPSHOT" versions.
+ * A version filter that (unconditionally) blocks non-snapshot versions.
  */
-public class ReleaseVersionFilter extends VersionPredicateVersionFilter {
+public class ReleaseVersionFilter extends ArtifactPredicateVersionFilter {
 
     /**
      * Creates a new instance of this version filter.
      */
     public ReleaseVersionFilter() {
-        super(v -> v.toString().endsWith("SNAPSHOT"));
+        super(Artifact::isSnapshot);
     }
 }
diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/SnapshotVersionFilter.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/SnapshotVersionFilter.java
index f933551b0..ecb91be70 100644
--- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/SnapshotVersionFilter.java
+++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/SnapshotVersionFilter.java
@@ -19,15 +19,15 @@
 package org.eclipse.aether.util.graph.version;
 
 /**
- * A version filter that (unconditionally) blocks "*-SNAPSHOT" versions. For practical purposes,
+ * A version filter that (unconditionally) blocks snapshot versions. For practical purposes,
  * {@link ContextualSnapshotVersionFilter} is usually more desirable.
  */
-public class SnapshotVersionFilter extends VersionPredicateVersionFilter {
+public class SnapshotVersionFilter extends ArtifactPredicateVersionFilter {
 
     /**
      * Creates a new instance of this version filter.
      */
     public SnapshotVersionFilter() {
-        super(v -> !v.toString().endsWith("SNAPSHOT"));
+        super(a -> !a.isSnapshot());
     }
 }
diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/VersionPredicateVersionFilter.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/VersionPredicateVersionFilter.java
index 65a4d9fc9..7241006be 100644
--- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/VersionPredicateVersionFilter.java
+++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/version/VersionPredicateVersionFilter.java
@@ -29,7 +29,7 @@ import org.eclipse.aether.version.Version;
 import static java.util.Objects.requireNonNull;
 
 /**
- * A version filter that excludes any version that is blacklisted.
+ * A version filter that excludes any version that is not-matched by version predicate.
  *
  * @since 2.0.11
  */
diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/GenericQualifiers.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/GenericQualifiers.java
new file mode 100644
index 000000000..ff3f532cd
--- /dev/null
+++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/GenericQualifiers.java
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.eclipse.aether.util.version;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * The recognized generic version qualifiers. Qualifiers may apply a "shift" to versions (ie when sorting), if present.
+ *
+ * @since 2.0.17
+ */
+public final class GenericQualifiers {
+    private GenericQualifiers() {}
+
+    public static final String LABEL_ALPHA = "alpha";
+
+    public static final String LABEL_BETA = "beta";
+
+    public static final String LABEL_MILESTONE = "milestone";
+
+    public static final Integer QUALIFIER_ALPHA = -5;
+
+    public static final Integer QUALIFIER_BETA = -4;
+
+    public static final Integer QUALIFIER_MILESTONE = -3;
+
+    public static final Integer QUALIFIER_RC = -2;
+
+    public static final Integer QUALIFIER_SNAPSHOT = -1;
+
+    public static final Integer QUALIFIER_ZERO = 0;
+
+    public static final Integer QUALIFIER_SP = 1;
+
+    private static final Map<String, Integer> QUALIFIERS;
+
+    static {
+        Map<String, Integer> qualifiers = new LinkedHashMap<>();
+        qualifiers.put(LABEL_ALPHA, QUALIFIER_ALPHA);
+        qualifiers.put(LABEL_BETA, QUALIFIER_BETA);
+        qualifiers.put(LABEL_MILESTONE, QUALIFIER_MILESTONE);
+        qualifiers.put("rc", QUALIFIER_RC);
+        qualifiers.put("cr", QUALIFIER_RC);
+        qualifiers.put("snapshot", QUALIFIER_SNAPSHOT);
+        qualifiers.put("ga", QUALIFIER_ZERO);
+        qualifiers.put("final", QUALIFIER_ZERO);
+        qualifiers.put("release", QUALIFIER_ZERO);
+        qualifiers.put("sp", QUALIFIER_SP);
+        QUALIFIERS = Collections.unmodifiableMap(qualifiers);
+    }
+
+    /**
+     * Returns qualifier (an {@link Integer} for given token, if detected. This method is used in {@link GenericVersion}
+     * that tokenizes version, and uses string token in call. The input must have {@link String#toLowerCase(Locale)}
+     * applied.
+     */
+    static Optional<Integer> tokenQualifier(String token) {
+        return Optional.ofNullable(QUALIFIERS.get(token));
+    }
+
+    /**
+     * Returns qualifier (an {@link Integer} for given string, if detected. Qualifier, if present, defines "shift",
+     * if negative, it is a "preview version" (e.g. alpha, beta, milestone, release candidate or snapshot), if zero,
+     * it is a "final version" (e.g. ga, final, release), if positive, it is a "service pack" version (e.g. sp).
+     * If no qualifier is detected, an empty optional is returned.
+     *
+     * @param token the string to analyze for qualifier, must not be {@code null}
+     */
+    public static Optional<Integer> qualifier(String token) {
+        // most trivial preview version is "a1"
+        if (token.length() > 1) {
+            String v = token.toLowerCase(Locale.ENGLISH);
+            // simple case: full qualifier label is present (assuming once)
+            for (Map.Entry<String, Integer> entry : QUALIFIERS.entrySet()) {
+                String label = entry.getKey();
+                int pos = v.indexOf(label);
+                if (pos > -1
+                        && (pos == 0 || !Character.isLetter(v.charAt(pos - 1)))
+                        && (pos >= v.length() - label.length()
+                                || !Character.isLetter(v.charAt(pos + label.length())))) {
+                    // it must be surrounded by "transition" (non-char; to avoid "rc" detection in "1.0-arc")
+                    return Optional.of(entry.getValue());
+                }
+            }
+            // complex case: contains 'a', 'b' or 'm' followed immediately by number
+            for (char ch : new char[] {'a', 'b', 'm'}) {
+                int idx = v.lastIndexOf(ch);
+                if (idx > -1 && v.length() > idx + 1) {
+                    if (Character.isDigit(v.charAt(idx + 1))) {
+                        if (ch == 'a') {
+                            return Optional.of(QUALIFIER_ALPHA);
+                        } else if (ch == 'b') {
+                            return Optional.of(QUALIFIER_BETA);
+                        } else {
+                            return Optional.of(QUALIFIER_MILESTONE);
+                        }
+                    }
+                }
+            }
+        }
+        return Optional.empty();
+    }
+}
diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/GenericVersion.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/GenericVersion.java
index 759b01b2a..9b841efd3 100644
--- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/GenericVersion.java
+++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/GenericVersion.java
@@ -23,8 +23,6 @@ import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Locale;
-import java.util.Map;
-import java.util.TreeMap;
 
 import org.eclipse.aether.version.Version;
 
@@ -34,7 +32,7 @@ import static java.util.Objects.requireNonNull;
  * A generic version, that is a version that accepts any input string and tries to apply common sense sorting. See
  * {@link GenericVersionScheme} for details.
  */
-final class GenericVersion implements Version {
+public final class GenericVersion implements Version {
 
     private final String version;
 
@@ -175,30 +173,6 @@ final class GenericVersion implements Version {
     }
 
     static final class Tokenizer {
-
-        private static final Integer QUALIFIER_ALPHA = -5;
-
-        private static final Integer QUALIFIER_BETA = -4;
-
-        private static final Integer QUALIFIER_MILESTONE = -3;
-
-        private static final Map<String, Integer> QUALIFIERS;
-
-        static {
-            QUALIFIERS = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
-            QUALIFIERS.put("alpha", QUALIFIER_ALPHA);
-            QUALIFIERS.put("beta", QUALIFIER_BETA);
-            QUALIFIERS.put("milestone", QUALIFIER_MILESTONE);
-            QUALIFIERS.put("cr", -2);
-            QUALIFIERS.put("rc", -2);
-            QUALIFIERS.put("snapshot", -1);
-            QUALIFIERS.put("ga", 0);
-            QUALIFIERS.put("final", 0);
-            QUALIFIERS.put("release", 0);
-            QUALIFIERS.put("", 0);
-            QUALIFIERS.put("sp", 1);
-        }
-
         private final String version;
 
         private final int versionLength;
@@ -296,22 +270,23 @@ final class GenericVersion implements Version {
                     switch (token.charAt(0)) {
                         case 'a':
                         case 'A':
-                            return new Item(Item.KIND_QUALIFIER, QUALIFIER_ALPHA);
+                            token = GenericQualifiers.LABEL_ALPHA;
+                            break;
                         case 'b':
                         case 'B':
-                            return new Item(Item.KIND_QUALIFIER, QUALIFIER_BETA);
+                            token = GenericQualifiers.LABEL_BETA;
+                            break;
                         case 'm':
                         case 'M':
-                            return new Item(Item.KIND_QUALIFIER, QUALIFIER_MILESTONE);
+                            token = GenericQualifiers.LABEL_MILESTONE;
+                            break;
                         default:
                     }
                 }
-                Integer qualifier = QUALIFIERS.get(token);
-                if (qualifier != null) {
-                    return new Item(Item.KIND_QUALIFIER, qualifier);
-                } else {
-                    return new Item(Item.KIND_STRING, token.toLowerCase(Locale.ENGLISH));
-                }
+                token = token.toLowerCase(Locale.ENGLISH);
+                return GenericQualifiers.tokenQualifier(token)
+                        .map(integer -> new Item(Item.KIND_QUALIFIER, integer))
+                        .orElseGet(() -> new Item(Item.KIND_STRING, token));
             }
         }
     }
diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/GenericVersionConstraint.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/GenericVersionConstraint.java
index ecaef4aff..7ac0f65a7 100644
--- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/GenericVersionConstraint.java
+++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/GenericVersionConstraint.java
@@ -31,7 +31,7 @@ import static java.util.Objects.requireNonNull;
  * <p>
  * Despite its name, this class is generic in a sense it works with any {@link Version}.
  */
-final class GenericVersionConstraint implements VersionConstraint {
+public final class GenericVersionConstraint implements VersionConstraint {
 
     private final VersionRange range;
 
diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/GenericVersionRange.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/GenericVersionRange.java
index 93c4d20b5..38be79876 100644
--- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/GenericVersionRange.java
+++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/GenericVersionRange.java
@@ -32,7 +32,7 @@ import static java.util.Objects.requireNonNull;
  * <p>
  * Despite its name, this class is generic in a sense it works with any {@link Version}
  */
-final class GenericVersionRange implements VersionRange {
+public final class GenericVersionRange implements VersionRange {
     private final VersionScheme versionScheme;
 
     private final Bound lowerBound;
diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/GenericVersionScheme.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/GenericVersionScheme.java
index 32ba67f4b..3dd7c5ca7 100644
--- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/GenericVersionScheme.java
+++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/GenericVersionScheme.java
@@ -18,6 +18,7 @@
  */
 package org.eclipse.aether.util.version;
 
+import java.nio.charset.StandardCharsets;
 import java.util.Collections;
 import java.util.Map;
 import java.util.WeakHashMap;
@@ -165,6 +166,9 @@ public class GenericVersionScheme extends VersionSchemeSupport {
         GenericVersion prev = null;
         int i = 1;
         for (String version : args) {
+            if (!StandardCharsets.US_ASCII.newEncoder().canEncode(version)) {
+                System.out.println("WW Use of non-ASCII characters for version strings is not recommended.");
+            }
             try {
                 GenericVersion c = scheme.parseVersion(version);
 
diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/VersionSchemeSupport.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/VersionSchemeSupport.java
index 65721218a..c64ad5bad 100644
--- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/VersionSchemeSupport.java
+++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/VersionSchemeSupport.java
@@ -34,7 +34,7 @@ import static java.util.Objects.requireNonNull;
  *
  * @since 2.0.0
  */
-abstract class VersionSchemeSupport implements VersionScheme {
+public abstract class VersionSchemeSupport implements VersionScheme {
     @Override
     public GenericVersionRange parseVersionRange(final String range) throws InvalidVersionSpecificationException {
         return new GenericVersionRange(this, range);
diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/package-info.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/package-info.java
index ad3f40473..191f1bfe5 100644
--- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/package-info.java
+++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/package-info.java
@@ -63,6 +63,7 @@
  * <ul>
  *     <li>Using "min" or "max" special segments as a non-trailing segment. This yields in "undefined" behaviour and should be avoided.</li>
  *     <li>Having a non-number as the first segment of a version. Versions are expected (but not enforced) to start with numbers.</li>
+ *     <li>Using special, non-ASCII characters in version. The recommendation is to keep version strings simple.</li>
  * </ul>
  */
 package org.eclipse.aether.util.version;
diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/versions/AbstractVersionFilterTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/AbstractVersionFilterTest.java
similarity index 92%
rename from maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/versions/AbstractVersionFilterTest.java
rename to maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/AbstractVersionFilterTest.java
index 45152a248..7795dd567 100644
--- a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/versions/AbstractVersionFilterTest.java
+++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/AbstractVersionFilterTest.java
@@ -16,8 +16,9 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.eclipse.aether.util.graph.versions;
+package org.eclipse.aether.util.graph.version;
 
+import java.util.ArrayList;
 import java.util.Iterator;
 
 import org.eclipse.aether.DefaultRepositorySystemSession;
@@ -73,7 +74,9 @@ public abstract class AbstractVersionFilterTest {
     }
 
     protected void assertVersions(VersionFilter.VersionFilterContext context, String... versions) {
-        assertEquals(versions.length, context.getCount());
+        ArrayList<Version> filtered = new ArrayList<>();
+        context.iterator().forEachRemaining(filtered::add);
+        assertEquals(versions.length, context.getCount(), filtered.toString());
         Iterator<Version> it = context.iterator();
         for (String version : versions) {
             assertTrue(it.hasNext());
diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/versions/PredicateVersionFilterTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/ArtifactPredicateVersionFilterTest.java
similarity index 65%
copy from maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/versions/PredicateVersionFilterTest.java
copy to maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/ArtifactPredicateVersionFilterTest.java
index a4cc08478..fa102e2ec 100644
--- a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/versions/PredicateVersionFilterTest.java
+++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/ArtifactPredicateVersionFilterTest.java
@@ -16,23 +16,24 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.eclipse.aether.util.graph.versions;
+package org.eclipse.aether.util.graph.version;
 
 import java.util.function.Predicate;
 
 import org.eclipse.aether.artifact.Artifact;
 import org.eclipse.aether.collection.VersionFilter.VersionFilterContext;
-import org.eclipse.aether.util.graph.version.PredicateVersionFilter;
 import org.junit.jupiter.api.Test;
 
-import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
 
-public class PredicateVersionFilterTest extends AbstractVersionFilterTest {
+public class ArtifactPredicateVersionFilterTest extends AbstractVersionFilterTest {
 
     @Test
     void testFilterVersions() {
         Predicate<Artifact> oddVersions = a -> Integer.parseInt(a.getVersion()) % 2 != 0;
-        PredicateVersionFilter filter = new PredicateVersionFilter(oddVersions);
+        ArtifactPredicateVersionFilter filter = new ArtifactPredicateVersionFilter(oddVersions);
         VersionFilterContext ctx = newContext("g:a:[1,9]", "1", "2", "3", "4", "5", "6", "7", "8", "9");
         filter.filterVersions(ctx);
         assertVersions(ctx, "1", "3", "5", "7", "9");
@@ -40,7 +41,7 @@ public class PredicateVersionFilterTest extends AbstractVersionFilterTest {
 
     @Test
     void testDeriveChildFilter() {
-        PredicateVersionFilter filter = new PredicateVersionFilter(a -> true);
+        ArtifactPredicateVersionFilter filter = new ArtifactPredicateVersionFilter(a -> true);
         assertSame(filter, derive(filter, "g:a:1"));
     }
 
@@ -48,12 +49,12 @@ public class PredicateVersionFilterTest extends AbstractVersionFilterTest {
     void testEquals() {
         Predicate<Artifact> falsePredicate = a -> false;
         Predicate<Artifact> truePredicate = a -> true;
-        PredicateVersionFilter filter1 = new PredicateVersionFilter(falsePredicate);
-        PredicateVersionFilter filter2 = new PredicateVersionFilter(falsePredicate);
-        PredicateVersionFilter filter3 = new PredicateVersionFilter(truePredicate);
+        ArtifactPredicateVersionFilter filter1 = new ArtifactPredicateVersionFilter(falsePredicate);
+        ArtifactPredicateVersionFilter filter2 = new ArtifactPredicateVersionFilter(falsePredicate);
+        ArtifactPredicateVersionFilter filter3 = new ArtifactPredicateVersionFilter(truePredicate);
         assertNotEquals(null, filter1);
         assertEquals(filter1, filter2);
         assertNotEquals(filter2, filter3);
-        assertEquals(filter1, new PredicateVersionFilter(falsePredicate));
+        assertEquals(filter1, new ArtifactPredicateVersionFilter(falsePredicate));
     }
 }
diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/versions/ChainedVersionFilterTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/ChainedVersionFilterTest.java
similarity index 91%
rename from maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/versions/ChainedVersionFilterTest.java
rename to maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/ChainedVersionFilterTest.java
index 7afc74433..546b31fa3 100644
--- a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/versions/ChainedVersionFilterTest.java
+++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/ChainedVersionFilterTest.java
@@ -16,14 +16,11 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.eclipse.aether.util.graph.versions;
+package org.eclipse.aether.util.graph.version;
 
 import org.eclipse.aether.collection.DependencyCollectionContext;
 import org.eclipse.aether.collection.VersionFilter;
 import org.eclipse.aether.collection.VersionFilter.VersionFilterContext;
-import org.eclipse.aether.util.graph.version.ChainedVersionFilter;
-import org.eclipse.aether.util.graph.version.HighestVersionFilter;
-import org.eclipse.aether.util.graph.version.SnapshotVersionFilter;
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.*;
diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/versions/ContextualSnapshotVersionFilterTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/ContextualSnapshotVersionFilterTest.java
similarity index 92%
rename from maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/versions/ContextualSnapshotVersionFilterTest.java
rename to maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/ContextualSnapshotVersionFilterTest.java
index 44e66498c..30a7e37e2 100644
--- a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/versions/ContextualSnapshotVersionFilterTest.java
+++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/ContextualSnapshotVersionFilterTest.java
@@ -16,12 +16,10 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.eclipse.aether.util.graph.versions;
+package org.eclipse.aether.util.graph.version;
 
 import org.eclipse.aether.collection.VersionFilter;
 import org.eclipse.aether.collection.VersionFilter.VersionFilterContext;
-import org.eclipse.aether.util.graph.version.ContextualSnapshotVersionFilter;
-import org.eclipse.aether.util.graph.version.SnapshotVersionFilter;
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.*;
diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/GenericQualifiersVersionFilterTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/GenericQualifiersVersionFilterTest.java
new file mode 100644
index 000000000..4eda2392f
--- /dev/null
+++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/GenericQualifiersVersionFilterTest.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.eclipse.aether.util.graph.version;
+
+import org.eclipse.aether.collection.VersionFilter.VersionFilterContext;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+public class GenericQualifiersVersionFilterTest extends AbstractVersionFilterTest {
+
+    @Test
+    void testPreviewVersionFilter() {
+        GenericQualifiersVersionFilter filter = GenericQualifiersVersionFilter.previewVersionFilter();
+        VersionFilterContext ctx = newContext(
+                "g:a:[1,9]",
+                "1-alpha-1",
+                "1-M1",
+                "1",
+                "2-SNAPSHOT",
+                "3.0-GA",
+                "3.1",
+                "4.0-SNAPSHOT",
+                "5.0.0.rc",
+                "5.0.0",
+                "5.0.0-sp");
+        filter.filterVersions(ctx);
+        assertVersions(ctx, "1", "2-SNAPSHOT", "3.0-GA", "3.1", "4.0-SNAPSHOT", "5.0.0.rc", "5.0.0", "5.0.0-sp");
+    }
+
+    @Test
+    void testPreReleaseVersionFilter() {
+        GenericQualifiersVersionFilter filter = GenericQualifiersVersionFilter.preReleaseVersionFilter();
+        VersionFilterContext ctx = newContext(
+                "g:a:[1,9]",
+                "1-alpha-1",
+                "1-M1",
+                "1",
+                "2-SNAPSHOT",
+                "3.0-GA",
+                "3.1",
+                "4.0-SNAPSHOT",
+                "5.0.0.rc",
+                "5.0.0",
+                "5.0.0-sp");
+        filter.filterVersions(ctx);
+        assertVersions(ctx, "1", "2-SNAPSHOT", "3.0-GA", "3.1", "4.0-SNAPSHOT", "5.0.0", "5.0.0-sp");
+    }
+
+    @Test
+    void testAnyQualifierVersionFilter() {
+        GenericQualifiersVersionFilter filter = GenericQualifiersVersionFilter.anyQualifierVersionFilter();
+        VersionFilterContext ctx = newContext(
+                "g:a:[1,9]",
+                "1-alpha-1",
+                "1-M1",
+                "1",
+                "2-SNAPSHOT",
+                "3.0-GA",
+                "3.1",
+                "4.0-SNAPSHOT",
+                "5.0.0.rc",
+                "5.0.0",
+                "5.0.0-sp");
+        filter.filterVersions(ctx);
+        assertVersions(ctx, "1", "3.1", "5.0.0");
+    }
+
+    @Test
+    void testDeriveChildFilter() {
+        GenericQualifiersVersionFilter filter = GenericQualifiersVersionFilter.previewVersionFilter();
+        assertSame(filter, derive(filter, "g:a:1"));
+    }
+}
diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/versions/HighestVersionFilterTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/HighestVersionFilterTest.java
similarity index 94%
rename from maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/versions/HighestVersionFilterTest.java
rename to maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/HighestVersionFilterTest.java
index 31ff8834b..23ae00161 100644
--- a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/versions/HighestVersionFilterTest.java
+++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/HighestVersionFilterTest.java
@@ -16,10 +16,9 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.eclipse.aether.util.graph.versions;
+package org.eclipse.aether.util.graph.version;
 
 import org.eclipse.aether.collection.VersionFilter.VersionFilterContext;
-import org.eclipse.aether.util.graph.version.HighestVersionFilter;
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.*;
diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/versions/LowestVersionFilterTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/LowestVersionFilterTest.java
similarity index 94%
rename from maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/versions/LowestVersionFilterTest.java
rename to maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/LowestVersionFilterTest.java
index 80508231f..dbe2dd30b 100644
--- a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/versions/LowestVersionFilterTest.java
+++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/LowestVersionFilterTest.java
@@ -16,10 +16,9 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.eclipse.aether.util.graph.versions;
+package org.eclipse.aether.util.graph.version;
 
 import org.eclipse.aether.collection.VersionFilter.VersionFilterContext;
-import org.eclipse.aether.util.graph.version.LowestVersionFilter;
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.*;
diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/versions/PredicateVersionFilterTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/PredicateVersionFilterTest.java
similarity index 95%
rename from maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/versions/PredicateVersionFilterTest.java
rename to maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/PredicateVersionFilterTest.java
index a4cc08478..c9fbbfae4 100644
--- a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/versions/PredicateVersionFilterTest.java
+++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/PredicateVersionFilterTest.java
@@ -16,13 +16,12 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.eclipse.aether.util.graph.versions;
+package org.eclipse.aether.util.graph.version;
 
 import java.util.function.Predicate;
 
 import org.eclipse.aether.artifact.Artifact;
 import org.eclipse.aether.collection.VersionFilter.VersionFilterContext;
-import org.eclipse.aether.util.graph.version.PredicateVersionFilter;
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.*;
diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/versions/ReleaseVersionFilterTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/ReleaseVersionFilterTest.java
similarity index 94%
rename from maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/versions/ReleaseVersionFilterTest.java
rename to maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/ReleaseVersionFilterTest.java
index bf27548eb..746126cd3 100644
--- a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/versions/ReleaseVersionFilterTest.java
+++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/ReleaseVersionFilterTest.java
@@ -16,10 +16,9 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.eclipse.aether.util.graph.versions;
+package org.eclipse.aether.util.graph.version;
 
 import org.eclipse.aether.collection.VersionFilter.VersionFilterContext;
-import org.eclipse.aether.util.graph.version.ReleaseVersionFilter;
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/versions/SnapshotVersionFilterTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/SnapshotVersionFilterTest.java
similarity index 94%
rename from maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/versions/SnapshotVersionFilterTest.java
rename to maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/SnapshotVersionFilterTest.java
index d866ce03d..ccd5fd266 100644
--- a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/versions/SnapshotVersionFilterTest.java
+++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/version/SnapshotVersionFilterTest.java
@@ -16,10 +16,9 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.eclipse.aether.util.graph.versions;
+package org.eclipse.aether.util.graph.version;
 
 import org.eclipse.aether.collection.VersionFilter.VersionFilterContext;
-import org.eclipse.aether.util.graph.version.SnapshotVersionFilter;
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.*;
diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/version/GenericQualifiersTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/version/GenericQualifiersTest.java
new file mode 100644
index 000000000..cd8623fbf
--- /dev/null
+++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/version/GenericQualifiersTest.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.eclipse.aether.util.version;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+public class GenericQualifiersTest {
+    private static final int NO_QUALIFIER = Integer.MIN_VALUE;
+
+    @Test
+    void fullCases() {
+        assertEquals(
+                GenericQualifiers.QUALIFIER_ALPHA,
+                GenericQualifiers.qualifier("1-alpha-1").orElse(NO_QUALIFIER));
+        assertEquals(
+                GenericQualifiers.QUALIFIER_ALPHA,
+                GenericQualifiers.qualifier("alpha-1").orElse(NO_QUALIFIER));
+        assertEquals(
+                GenericQualifiers.QUALIFIER_ALPHA,
+                GenericQualifiers.qualifier("1-alpha").orElse(NO_QUALIFIER));
+        assertEquals(
+                GenericQualifiers.QUALIFIER_BETA,
+                GenericQualifiers.qualifier("1-beta").orElse(NO_QUALIFIER));
+        assertEquals(
+                GenericQualifiers.QUALIFIER_MILESTONE,
+                GenericQualifiers.qualifier("milestone").orElse(NO_QUALIFIER));
+        assertEquals(
+                GenericQualifiers.QUALIFIER_MILESTONE,
+                GenericQualifiers.qualifier("1-milestone").orElse(NO_QUALIFIER));
+        assertEquals(
+                GenericQualifiers.QUALIFIER_ZERO,
+                GenericQualifiers.qualifier("1-ga-1").orElse(NO_QUALIFIER));
+        assertEquals(
+                GenericQualifiers.QUALIFIER_ZERO,
+                GenericQualifiers.qualifier("1-final").orElse(NO_QUALIFIER));
+        assertEquals(
+                GenericQualifiers.QUALIFIER_SP,
+                GenericQualifiers.qualifier("1-sp").orElse(NO_QUALIFIER));
+    }
+
+    @Test
+    void shortCases() {
+        // special: alpha, beta and milestone are detected also when in form of "a1" (a, b, or m immediately followed by
+        // number)
+        assertEquals(
+                GenericQualifiers.QUALIFIER_ALPHA,
+                GenericQualifiers.qualifier("1-a1").orElse(NO_QUALIFIER));
+        assertEquals(
+                GenericQualifiers.QUALIFIER_ALPHA,
+                GenericQualifiers.qualifier("a1").orElse(NO_QUALIFIER));
+        assertEquals(
+                GenericQualifiers.QUALIFIER_BETA,
+                GenericQualifiers.qualifier("1-b1").orElse(NO_QUALIFIER));
+        assertEquals(
+                GenericQualifiers.QUALIFIER_MILESTONE,
+                GenericQualifiers.qualifier("1-m1").orElse(NO_QUALIFIER));
+        assertEquals(
+                GenericQualifiers.QUALIFIER_MILESTONE,
+                GenericQualifiers.qualifier("m1").orElse(NO_QUALIFIER));
+    }
+
+    @Test
+    void edgeCases() {
+        assertFalse(GenericQualifiers.qualifier("1.0-arced").isPresent()); // rc
+        assertFalse(GenericQualifiers.qualifier("1.0-legacy").isPresent()); // ga
+        assertFalse(GenericQualifiers.qualifier("1.0-sacred").isPresent()); // cr
+    }
+}