Semantic Versioning (SemVer) is a versioning scheme that provides meaning to version numbers, making it easier to manage dependencies and communicate changes. This implementation provides a complete Java solution for parsing, comparing, validating, and managing semantic versions.
Project Setup and Dependencies
1. Maven Dependencies
<dependencies> <!-- Validation --> <dependency> <groupId>jakarta.validation</groupId> <artifactId>jakarta.validation-api</artifactId> <version>3.0.2</version> </dependency> <dependency> <groupId>org.hibernate.validator</groupId> <artifactId>hibernate-validator</artifactId> <version>8.0.1.Final</version> </dependency> <!-- JSON Processing --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.2</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-annotations</artifactId> <version>2.15.2</version> </dependency> <!-- Testing --> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.9.2</version> <scope>test</scope> </dependency> <dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> <version>3.24.2</version> <scope>test</scope> </dependency> </dependencies>
Core Semantic Versioning Implementation
1. Semantic Version Class
// SemanticVersion.java
package com.company.semver;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import jakarta.validation.constraints.Pattern;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Immutable class representing a Semantic Version (SemVer 2.0.0)
* Format: MAJOR.MINOR.PATCH[-PRERELEASE][+BUILD]
*/
public final class SemanticVersion implements Comparable<SemanticVersion> {
private final int major;
private final int minor;
private final int patch;
private final List<String> preRelease;
private final List<String> build;
private static final java.util.regex.Pattern SEMVER_PATTERN = java.util.regex.Pattern.compile(
"^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)" +
"(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][a-zA-Z0-9-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][a-zA-Z0-9-]*))*))?" +
"(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$"
);
// Factory methods
public static SemanticVersion of(int major, int minor, int patch) {
return new SemanticVersion(major, minor, patch, null, null);
}
public static SemanticVersion of(int major, int minor, int patch, String preRelease) {
return new SemanticVersion(major, minor, patch, preRelease, null);
}
public static SemanticVersion of(int major, int minor, int patch, String preRelease, String build) {
return new SemanticVersion(major, minor, patch, preRelease, build);
}
@JsonCreator
public static SemanticVersion fromString(String version) {
return parse(version);
}
public static SemanticVersion parse(String version) {
if (version == null || version.trim().isEmpty()) {
throw new IllegalArgumentException("Version string cannot be null or empty");
}
Matcher matcher = SEMVER_PATTERN.matcher(version.trim());
if (!matcher.matches()) {
throw new IllegalArgumentException("Invalid semantic version: " + version);
}
try {
int major = Integer.parseInt(matcher.group(1));
int minor = Integer.parseInt(matcher.group(2));
int patch = Integer.parseInt(matcher.group(3));
String preReleaseStr = matcher.group(4);
String buildStr = matcher.group(5);
List<String> preRelease = preReleaseStr != null ?
parseIdentifiers(preReleaseStr) : Collections.emptyList();
List<String> build = buildStr != null ?
parseIdentifiers(buildStr) : Collections.emptyList();
return new SemanticVersion(major, minor, patch, preRelease, build);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid version number in: " + version, e);
}
}
private static List<String> parseIdentifiers(String identifiers) {
return Arrays.asList(identifiers.split("\\."));
}
// Constructors
private SemanticVersion(int major, int minor, int patch,
List<String> preRelease, List<String> build) {
validateVersionComponents(major, minor, patch, preRelease, build);
this.major = major;
this.minor = minor;
this.patch = patch;
this.preRelease = preRelease != null ?
Collections.unmodifiableList(new ArrayList<>(preRelease)) :
Collections.emptyList();
this.build = build != null ?
Collections.unmodifiableList(new ArrayList<>(build)) :
Collections.emptyList();
}
private SemanticVersion(int major, int minor, int patch,
String preRelease, String build) {
this(major, minor, patch,
preRelease != null ? parseIdentifiers(preRelease) : null,
build != null ? parseIdentifiers(build) : null);
}
private void validateVersionComponents(int major, int minor, int patch,
List<String> preRelease, List<String> build) {
if (major < 0) throw new IllegalArgumentException("Major version cannot be negative");
if (minor < 0) throw new IllegalArgumentException("Minor version cannot be negative");
if (patch < 0) throw new IllegalArgumentException("Patch version cannot be negative");
validateIdentifiers(preRelease, "pre-release");
validateIdentifiers(build, "build");
}
private void validateIdentifiers(List<String> identifiers, String type) {
if (identifiers == null) return;
for (String identifier : identifiers) {
if (identifier == null || identifier.isEmpty()) {
throw new IllegalArgumentException(type + " identifier cannot be null or empty");
}
// Check if identifier is numeric
if (identifier.matches("\\d+")) {
// Numeric identifiers must not have leading zeros
if (identifier.length() > 1 && identifier.startsWith("0")) {
throw new IllegalArgumentException(
type + " numeric identifier cannot have leading zeros: " + identifier);
}
} else {
// Non-numeric identifiers must contain only ASCII alphanumerics and hyphens
if (!identifier.matches("[0-9A-Za-z-]+")) {
throw new IllegalArgumentException(
type + " identifier contains invalid characters: " + identifier);
}
}
}
}
// Getters
public int getMajor() { return major; }
public int getMinor() { return minor; }
public int getPatch() { return patch; }
public List<String> getPreRelease() { return preRelease; }
public List<String> getBuild() { return build; }
public boolean isPreRelease() { return !preRelease.isEmpty(); }
public boolean hasBuildMetadata() { return !build.isEmpty(); }
public boolean isStable() { return !isPreRelease() && major > 0; }
// Version increment methods
public SemanticVersion incrementMajor() {
return new SemanticVersion(major + 1, 0, 0, Collections.emptyList(), Collections.emptyList());
}
public SemanticVersion incrementMajor(String preRelease) {
return new SemanticVersion(major + 1, 0, 0, preRelease, null);
}
public SemanticVersion incrementMinor() {
return new SemanticVersion(major, minor + 1, 0, Collections.emptyList(), Collections.emptyList());
}
public SemanticVersion incrementMinor(String preRelease) {
return new SemanticVersion(major, minor + 1, 0, preRelease, null);
}
public SemanticVersion incrementPatch() {
return new SemanticVersion(major, minor, patch + 1, Collections.emptyList(), Collections.emptyList());
}
public SemanticVersion incrementPatch(String preRelease) {
return new SemanticVersion(major, minor, patch + 1, preRelease, null);
}
public SemanticVersion withPreRelease(String preRelease) {
return new SemanticVersion(major, minor, patch, preRelease,
build.isEmpty() ? null : String.join(".", build));
}
public SemanticVersion withBuild(String build) {
return new SemanticVersion(major, minor, patch,
preRelease.isEmpty() ? null : String.join(".", preRelease), build);
}
public SemanticVersion withoutPreRelease() {
return new SemanticVersion(major, minor, patch, Collections.emptyList(),
build.isEmpty() ? null : String.join(".", build));
}
public SemanticVersion withoutBuild() {
return new SemanticVersion(major, minor, patch,
preRelease.isEmpty() ? null : String.join(".", preRelease),
Collections.emptyList());
}
// Comparison methods
@Override
public int compareTo(SemanticVersion other) {
if (other == null) return 1;
int majorCompare = Integer.compare(major, other.major);
if (majorCompare != 0) return majorCompare;
int minorCompare = Integer.compare(minor, other.minor);
if (minorCompare != 0) return minorCompare;
int patchCompare = Integer.compare(patch, other.patch);
if (patchCompare != 0) return patchCompare;
return comparePreRelease(this.preRelease, other.preRelease);
}
private int comparePreRelease(List<String> pre1, List<String> pre2) {
// No pre-release has higher precedence than pre-release
if (pre1.isEmpty() && pre2.isEmpty()) return 0;
if (pre1.isEmpty()) return 1;
if (pre2.isEmpty()) return -1;
int minSize = Math.min(pre1.size(), pre2.size());
for (int i = 0; i < minSize; i++) {
String id1 = pre1.get(i);
String id2 = pre2.get(i);
int result = compareIdentifier(id1, id2);
if (result != 0) return result;
}
// All compared identifiers are equal
return Integer.compare(pre1.size(), pre2.size());
}
private int compareIdentifier(String id1, String id2) {
boolean isNumeric1 = id1.matches("\\d+");
boolean isNumeric2 = id2.matches("\\d+");
if (isNumeric1 && isNumeric2) {
return Long.compare(Long.parseLong(id1), Long.parseLong(id2));
} else if (isNumeric1) {
return -1; // Numeric identifiers have lower precedence than non-numeric
} else if (isNumeric2) {
return 1; // Non-numeric identifiers have higher precedence than numeric
} else {
return id1.compareTo(id2);
}
}
// Compatibility checks
public boolean isCompatibleWith(SemanticVersion other) {
if (other == null) return false;
return this.major == other.major;
}
public boolean isBackwardCompatibleWith(SemanticVersion other) {
if (other == null) return false;
return this.major == other.major && this.minor >= other.minor;
}
public boolean isPatchCompatibleWith(SemanticVersion other) {
if (other == null) return false;
return this.major == other.major && this.minor == other.minor && this.patch >= other.patch;
}
// Range checking
public boolean satisfies(VersionRange range) {
return range != null && range.contains(this);
}
// Object methods
@Override
@JsonValue
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(major).append('.').append(minor).append('.').append(patch);
if (!preRelease.isEmpty()) {
sb.append('-').append(String.join(".", preRelease));
}
if (!build.isEmpty()) {
sb.append('+').append(String.join(".", build));
}
return sb.toString();
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
SemanticVersion that = (SemanticVersion) obj;
return major == that.major &&
minor == that.minor &&
patch == that.patch &&
preRelease.equals(that.preRelease);
// Build metadata is ignored for equality
}
@Override
public int hashCode() {
return Objects.hash(major, minor, patch, preRelease);
}
// Static utility methods
public static SemanticVersion min(SemanticVersion v1, SemanticVersion v2) {
if (v1 == null) return v2;
if (v2 == null) return v1;
return v1.compareTo(v2) <= 0 ? v1 : v2;
}
public static SemanticVersion max(SemanticVersion v1, SemanticVersion v2) {
if (v1 == null) return v2;
if (v2 == null) return v1;
return v1.compareTo(v2) >= 0 ? v1 : v2;
}
public static boolean isValid(String version) {
if (version == null || version.trim().isEmpty()) return false;
return SEMVER_PATTERN.matcher(version.trim()).matches();
}
}
2. Version Range Implementation
// VersionRange.java
package com.company.semver;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Represents a version range that can match multiple semantic versions
* Supports common range syntax: ^, ~, >=, <=, >, <, ||, etc.
*/
public class VersionRange {
private final List<Constraint> constraints;
private static final Pattern RANGE_PATTERN = Pattern.compile(
"([>=<~^]+)\\s*([0-9A-Za-z.+*-]+)|" +
"\\|\\||" +
"([0-9A-Za-z.+*-]+)\\s*-\\s*([0-9A-Za-z.+*-]+)"
);
private VersionRange(List<Constraint> constraints) {
this.constraints = Collections.unmodifiableList(new ArrayList<>(constraints));
}
public static VersionRange exact(SemanticVersion version) {
return new VersionRange(List.of(Constraint.exact(version)));
}
public static VersionRange any() {
return new VersionRange(List.of(Constraint.any()));
}
public static VersionRange none() {
return new VersionRange(List.of(Constraint.none()));
}
public static VersionRange parse(String range) {
if (range == null || range.trim().isEmpty()) {
throw new IllegalArgumentException("Range string cannot be null or empty");
}
String trimmed = range.trim();
// Handle special cases
if ("*".equals(trimmed) || "x".equals(trimmed) || "X".equals(trimmed)) {
return any();
}
if ("~".equals(trimmed) || "^".equals(trimmed)) {
throw new IllegalArgumentException("Invalid range: " + range);
}
List<Constraint> constraints = new ArrayList<>();
// Split by OR (||)
String[] orParts = trimmed.split("\\|\\|");
for (String orPart : orParts) {
List<Constraint> andConstraints = parseAndConstraints(orPart.trim());
if (!andConstraints.isEmpty()) {
constraints.add(Constraint.or(andConstraints));
}
}
return new VersionRange(constraints);
}
private static List<Constraint> parseAndConstraints(String range) {
List<Constraint> constraints = new ArrayList<>();
// Split by AND (space or comma)
String[] andParts = range.split("[\\s,]+");
for (String part : andParts) {
if (part.isEmpty()) continue;
Constraint constraint = parseConstraint(part.trim());
if (constraint != null) {
constraints.add(constraint);
}
}
return constraints;
}
private static Constraint parseConstraint(String constraint) {
if (constraint.equals("*")) {
return Constraint.any();
}
Matcher matcher = RANGE_PATTERN.matcher(constraint);
if (matcher.matches()) {
String operator = matcher.group(1);
String versionStr = matcher.group(2);
if (operator != null && versionStr != null) {
SemanticVersion version = SemanticVersion.parse(versionStr);
return parseOperatorConstraint(operator, version);
}
// Handle hyphen ranges (version1 - version2)
String fromVersion = matcher.group(3);
String toVersion = matcher.group(4);
if (fromVersion != null && toVersion != null) {
SemanticVersion from = SemanticVersion.parse(fromVersion);
SemanticVersion to = SemanticVersion.parse(toVersion);
return Constraint.between(from, to);
}
}
// Default to exact match
try {
SemanticVersion version = SemanticVersion.parse(constraint);
return Constraint.exact(version);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Invalid range constraint: " + constraint, e);
}
}
private static Constraint parseOperatorConstraint(String operator, SemanticVersion version) {
return switch (operator) {
case ">=" -> Constraint.greaterThanOrEqual(version);
case ">" -> Constraint.greaterThan(version);
case "<=" -> Constraint.lessThanOrEqual(version);
case "<" -> Constraint.lessThan(version);
case "~", "~>" -> createTildeRange(version);
case "^" -> createCaretRange(version);
case "=" -> Constraint.exact(version);
default -> throw new IllegalArgumentException("Unknown operator: " + operator);
};
}
private static Constraint createTildeRange(SemanticVersion version) {
// ~1.2.3 means >=1.2.3 <1.3.0
// ~1.2 means >=1.2.0 <1.3.0
// ~1 means >=1.0.0 <2.0.0
if (version.getPatch() >= 0) {
// ~1.2.3
SemanticVersion min = version;
SemanticVersion max = SemanticVersion.of(version.getMajor(), version.getMinor() + 1, 0);
return Constraint.between(min, max);
} else if (version.getMinor() >= 0) {
// ~1.2
SemanticVersion min = SemanticVersion.of(version.getMajor(), version.getMinor(), 0);
SemanticVersion max = SemanticVersion.of(version.getMajor(), version.getMinor() + 1, 0);
return Constraint.between(min, max);
} else {
// ~1
SemanticVersion min = SemanticVersion.of(version.getMajor(), 0, 0);
SemanticVersion max = SemanticVersion.of(version.getMajor() + 1, 0, 0);
return Constraint.between(min, max);
}
}
private static Constraint createCaretRange(SemanticVersion version) {
// ^1.2.3 means >=1.2.3 <2.0.0
// ^0.2.3 means >=0.2.3 <0.3.0
// ^0.0.3 means >=0.0.3 <0.0.4
if (version.getMajor() > 0) {
SemanticVersion min = version;
SemanticVersion max = SemanticVersion.of(version.getMajor() + 1, 0, 0);
return Constraint.between(min, max);
} else if (version.getMinor() > 0) {
SemanticVersion min = version;
SemanticVersion max = SemanticVersion.of(0, version.getMinor() + 1, 0);
return Constraint.between(min, max);
} else {
SemanticVersion min = version;
SemanticVersion max = SemanticVersion.of(0, 0, version.getPatch() + 1);
return Constraint.between(min, max);
}
}
public boolean contains(SemanticVersion version) {
if (version == null) return false;
for (Constraint constraint : constraints) {
if (constraint.matches(version)) {
return true;
}
}
return false;
}
public SemanticVersion findBestMatch(List<SemanticVersion> versions) {
if (versions == null || versions.isEmpty()) return null;
return versions.stream()
.filter(this::contains)
.max(SemanticVersion::compareTo)
.orElse(null);
}
public boolean isSatisfiedByAny(List<SemanticVersion> versions) {
return versions.stream().anyMatch(this::contains);
}
public List<SemanticVersion> findAllMatches(List<SemanticVersion> versions) {
if (versions == null) return Collections.emptyList();
return versions.stream()
.filter(this::contains)
.sorted(Comparator.reverseOrder())
.toList();
}
@Override
public String toString() {
if (constraints.isEmpty()) return "none";
if (constraints.size() == 1) return constraints.get(0).toString();
return String.join(" || ", constraints.stream()
.map(Constraint::toString)
.toList());
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
VersionRange that = (VersionRange) obj;
return constraints.equals(that.constraints);
}
@Override
public int hashCode() {
return Objects.hash(constraints);
}
}
// Constraint.java
class Constraint {
private final Type type;
private final SemanticVersion version;
private final SemanticVersion minVersion;
private final SemanticVersion maxVersion;
private final List<Constraint> orConstraints;
enum Type {
ANY,
NONE,
EXACT,
GREATER_THAN,
GREATER_THAN_OR_EQUAL,
LESS_THAN,
LESS_THAN_OR_EQUAL,
BETWEEN,
OR
}
private Constraint(Type type, SemanticVersion version,
SemanticVersion minVersion, SemanticVersion maxVersion,
List<Constraint> orConstraints) {
this.type = type;
this.version = version;
this.minVersion = minVersion;
this.maxVersion = maxVersion;
this.orConstraints = orConstraints != null ?
Collections.unmodifiableList(new ArrayList<>(orConstraints)) : null;
}
// Factory methods
static Constraint any() {
return new Constraint(Type.ANY, null, null, null, null);
}
static Constraint none() {
return new Constraint(Type.NONE, null, null, null, null);
}
static Constraint exact(SemanticVersion version) {
return new Constraint(Type.EXACT, version, null, null, null);
}
static Constraint greaterThan(SemanticVersion version) {
return new Constraint(Type.GREATER_THAN, version, null, null, null);
}
static Constraint greaterThanOrEqual(SemanticVersion version) {
return new Constraint(Type.GREATER_THAN_OR_EQUAL, version, null, null, null);
}
static Constraint lessThan(SemanticVersion version) {
return new Constraint(Type.LESS_THAN, version, null, null, null);
}
static Constraint lessThanOrEqual(SemanticVersion version) {
return new Constraint(Type.LESS_THAN_OR_EQUAL, version, null, null, null);
}
static Constraint between(SemanticVersion min, SemanticVersion max) {
return new Constraint(Type.BETWEEN, null, min, max, null);
}
static Constraint or(List<Constraint> constraints) {
return new Constraint(Type.OR, null, null, null, constraints);
}
boolean matches(SemanticVersion version) {
if (version == null) return false;
return switch (type) {
case ANY -> true;
case NONE -> false;
case EXACT -> version.equals(this.version);
case GREATER_THAN -> version.compareTo(this.version) > 0;
case GREATER_THAN_OR_EQUAL -> version.compareTo(this.version) >= 0;
case LESS_THAN -> version.compareTo(this.version) < 0;
case LESS_THAN_OR_EQUAL -> version.compareTo(this.version) <= 0;
case BETWEEN ->
version.compareTo(minVersion) >= 0 && version.compareTo(maxVersion) < 0;
case OR -> orConstraints.stream().anyMatch(c -> c.matches(version));
};
}
@Override
public String toString() {
return switch (type) {
case ANY -> "*";
case NONE -> "none";
case EXACT -> version.toString();
case GREATER_THAN -> ">" + version;
case GREATER_THAN_OR_EQUAL -> ">=" + version;
case LESS_THAN -> "<" + version;
case LESS_THAN_OR_EQUAL -> "<=" + version;
case BETWEEN -> minVersion + " - " + maxVersion;
case OR -> String.join(" || ", orConstraints.stream()
.map(Constraint::toString)
.toList());
};
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Constraint that = (Constraint) obj;
return type == that.type &&
Objects.equals(version, that.version) &&
Objects.equals(minVersion, that.minVersion) &&
Objects.equals(maxVersion, that.maxVersion) &&
Objects.equals(orConstraints, that.orConstraints);
}
@Override
public int hashCode() {
return Objects.hash(type, version, minVersion, maxVersion, orConstraints);
}
}
3. Version Management Service
// VersionManager.java
package com.company.semver;
import java.util.*;
import java.util.stream.Collectors;
/**
* Service for managing semantic versions and their lifecycle
*/
public class VersionManager {
private final List<SemanticVersion> versions;
private final Map<String, SemanticVersion> latestVersions;
public VersionManager() {
this.versions = new ArrayList<>();
this.latestVersions = new HashMap<>();
}
public VersionManager(Collection<SemanticVersion> initialVersions) {
this();
if (initialVersions != null) {
this.versions.addAll(initialVersions);
updateLatestVersions();
}
}
public void addVersion(SemanticVersion version) {
if (version == null) throw new IllegalArgumentException("Version cannot be null");
versions.add(version);
updateLatestVersions();
// Sort versions after addition
versions.sort(Comparator.reverseOrder());
}
public void addVersions(Collection<SemanticVersion> versions) {
if (versions == null) return;
this.versions.addAll(versions);
updateLatestVersions();
// Sort versions after addition
this.versions.sort(Comparator.reverseOrder());
}
public boolean removeVersion(SemanticVersion version) {
boolean removed = versions.remove(version);
if (removed) {
updateLatestVersions();
}
return removed;
}
public List<SemanticVersion> getAllVersions() {
return Collections.unmodifiableList(versions);
}
public List<SemanticVersion> getStableVersions() {
return versions.stream()
.filter(SemanticVersion::isStable)
.collect(Collectors.toList());
}
public List<SemanticVersion> getPreReleaseVersions() {
return versions.stream()
.filter(SemanticVersion::isPreRelease)
.collect(Collectors.toList());
}
public SemanticVersion getLatest() {
return versions.stream()
.max(SemanticVersion::compareTo)
.orElse(null);
}
public SemanticVersion getLatestStable() {
return versions.stream()
.filter(SemanticVersion::isStable)
.max(SemanticVersion::compareTo)
.orElse(null);
}
public SemanticVersion getLatestPreRelease() {
return versions.stream()
.filter(SemanticVersion::isPreRelease)
.max(SemanticVersion::compareTo)
.orElse(null);
}
public SemanticVersion findBestMatch(VersionRange range) {
return range.findBestMatch(versions);
}
public List<SemanticVersion> findAllMatches(VersionRange range) {
return range.findAllMatches(versions);
}
public boolean hasVersion(SemanticVersion version) {
return versions.contains(version);
}
public SemanticVersion getNextMajor() {
SemanticVersion latest = getLatest();
return latest != null ? latest.incrementMajor() : SemanticVersion.of(1, 0, 0);
}
public SemanticVersion getNextMinor() {
SemanticVersion latest = getLatest();
return latest != null ? latest.incrementMinor() : SemanticVersion.of(0, 1, 0);
}
public SemanticVersion getNextPatch() {
SemanticVersion latest = getLatest();
return latest != null ? latest.incrementPatch() : SemanticVersion.of(0, 0, 1);
}
public SemanticVersion getNextPreRelease(String preReleaseIdentifier) {
SemanticVersion latest = getLatest();
if (latest == null) {
return SemanticVersion.of(0, 1, 0, preReleaseIdentifier);
}
// If latest is already a pre-release with same identifier, increment it
if (latest.isPreRelease() &&
latest.getPreRelease().get(0).equals(preReleaseIdentifier)) {
// Try to increment the numeric part of pre-release
List<String> newPreRelease = incrementPreRelease(latest.getPreRelease());
return latest.withPreRelease(String.join(".", newPreRelease));
}
// Otherwise, create new pre-release from the current version
return latest.incrementPatch(preReleaseIdentifier + ".1");
}
private List<String> incrementPreRelease(List<String> preRelease) {
List<String> newPreRelease = new ArrayList<>(preRelease);
// Find the last numeric identifier and increment it
for (int i = newPreRelease.size() - 1; i >= 0; i--) {
String identifier = newPreRelease.get(i);
if (identifier.matches("\\d+")) {
long numericValue = Long.parseLong(identifier);
newPreRelease.set(i, String.valueOf(numericValue + 1));
return newPreRelease;
}
}
// No numeric identifier found, append .1
newPreRelease.add("1");
return newPreRelease;
}
public VersionAnalysis analyzeVersions() {
SemanticVersion latest = getLatest();
SemanticVersion latestStable = getLatestStable();
long totalVersions = versions.size();
long stableVersions = getStableVersions().size();
long preReleaseVersions = getPreReleaseVersions().size();
double stabilityPercentage = totalVersions > 0 ?
(double) stableVersions / totalVersions * 100 : 0.0;
List<VersionGap> gaps = findVersionGaps();
return VersionAnalysis.builder()
.totalVersions(totalVersions)
.stableVersions(stableVersions)
.preReleaseVersions(preReleaseVersions)
.stabilityPercentage(stabilityPercentage)
.latestVersion(latest)
.latestStableVersion(latestStable)
.versionGaps(gaps)
.build();
}
private List<VersionGap> findVersionGaps() {
List<VersionGap> gaps = new ArrayList<>();
if (versions.size() < 2) return gaps;
List<SemanticVersion> sorted = new ArrayList<>(versions);
sorted.sort(SemanticVersion::compareTo);
for (int i = 0; i < sorted.size() - 1; i++) {
SemanticVersion current = sorted.get(i);
SemanticVersion next = sorted.get(i + 1);
// Check if there's a gap between current and next
if (!areConsecutive(current, next)) {
gaps.add(VersionGap.builder()
.fromVersion(current)
.toVersion(next)
.gapType(determineGapType(current, next))
.build());
}
}
return gaps;
}
private boolean areConsecutive(SemanticVersion v1, SemanticVersion v2) {
// Two versions are consecutive if v2 is the immediate next version of v1
if (v1.getMajor() == v2.getMajor() &&
v1.getMinor() == v2.getMinor() &&
v1.getPatch() + 1 == v2.getPatch()) {
return true;
}
// Handle major/minor increments
if (v1.getMajor() == v2.getMajor() &&
v1.getMinor() + 1 == v2.getMinor() &&
v2.getPatch() == 0) {
return true;
}
if (v1.getMajor() + 1 == v2.getMajor() &&
v2.getMinor() == 0 &&
v2.getPatch() == 0) {
return true;
}
return false;
}
private String determineGapType(SemanticVersion from, SemanticVersion to) {
if (from.getMajor() != to.getMajor()) return "MAJOR";
if (from.getMinor() != to.getMinor()) return "MINOR";
return "PATCH";
}
private void updateLatestVersions() {
latestVersions.clear();
// Group by major version and find latest for each
Map<Integer, List<SemanticVersion>> byMajor = versions.stream()
.collect(Collectors.groupingBy(SemanticVersion::getMajor));
for (Map.Entry<Integer, List<SemanticVersion>> entry : byMajor.entrySet()) {
SemanticVersion latestInMajor = entry.getValue().stream()
.max(SemanticVersion::compareTo)
.orElse(null);
if (latestInMajor != null) {
latestVersions.put(String.valueOf(entry.getKey()), latestInMajor);
}
}
// Also group by major.minor
Map<String, List<SemanticVersion>> byMinor = versions.stream()
.collect(Collectors.groupingBy(v ->
v.getMajor() + "." + v.getMinor()));
for (Map.Entry<String, List<SemanticVersion>> entry : byMinor.entrySet()) {
SemanticVersion latestInMinor = entry.getValue().stream()
.max(SemanticVersion::compareTo)
.orElse(null);
if (latestInMinor != null) {
latestVersions.put(entry.getKey(), latestInMinor);
}
}
}
public Map<String, SemanticVersion> getLatestVersions() {
return Collections.unmodifiableMap(latestVersions);
}
}
// VersionAnalysis.java
package com.company.semver;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class VersionAnalysis {
private long totalVersions;
private long stableVersions;
private long preReleaseVersions;
private double stabilityPercentage;
private SemanticVersion latestVersion;
private SemanticVersion latestStableVersion;
private List<VersionGap> versionGaps;
public boolean isHealthy() {
return stabilityPercentage >= 80.0 && latestStableVersion != null;
}
public String getHealthStatus() {
if (stabilityPercentage >= 90.0) return "EXCELLENT";
if (stabilityPercentage >= 80.0) return "GOOD";
if (stabilityPercentage >= 60.0) return "FAIR";
return "POOR";
}
}
// VersionGap.java
package com.company.semver;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class VersionGap {
private SemanticVersion fromVersion;
private SemanticVersion toVersion;
private String gapType; // MAJOR, MINOR, PATCH
public String getDescription() {
return String.format("Gap between %s and %s (%s)",
fromVersion, toVersion, gapType);
}
}
REST API for Version Management
1. Version Controller
// VersionController.java
package com.company.semver.api;
import com.company.semver.*;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/v1/versions")
public class VersionController {
private final VersionManager versionManager;
public VersionController(VersionManager versionManager) {
this.versionManager = versionManager;
}
@PostMapping("/parse")
public ResponseEntity<ApiResponse<SemanticVersion>> parseVersion(
@RequestBody ParseVersionRequest request) {
try {
SemanticVersion version = SemanticVersion.parse(request.getVersionString());
return ResponseEntity.ok(ApiResponse.success(version));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest()
.body(ApiResponse.error(e.getMessage()));
}
}
@PostMapping
public ResponseEntity<ApiResponse<SemanticVersion>> addVersion(
@Valid @RequestBody AddVersionRequest request) {
try {
SemanticVersion version = SemanticVersion.parse(request.getVersion());
versionManager.addVersion(version);
return ResponseEntity.ok(ApiResponse.success(version, "Version added successfully"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest()
.body(ApiResponse.error(e.getMessage()));
}
}
@GetMapping
public ResponseEntity<ApiResponse<List<SemanticVersion>>> getAllVersions(
@RequestParam(required = false) Boolean stable) {
List<SemanticVersion> versions;
if (stable != null) {
versions = stable ?
versionManager.getStableVersions() :
versionManager.getPreReleaseVersions();
} else {
versions = versionManager.getAllVersions();
}
return ResponseEntity.ok(ApiResponse.success(versions));
}
@GetMapping("/latest")
public ResponseEntity<ApiResponse<SemanticVersion>> getLatestVersion(
@RequestParam(required = false) Boolean stable) {
SemanticVersion latest;
if (stable != null && stable) {
latest = versionManager.getLatestStable();
} else {
latest = versionManager.getLatest();
}
if (latest == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(ApiResponse.success(latest));
}
@PostMapping("/next")
public ResponseEntity<ApiResponse<SemanticVersion>> getNextVersion(
@RequestBody NextVersionRequest request) {
try {
SemanticVersion nextVersion = switch (request.getIncrementType()) {
case MAJOR -> versionManager.getNextMajor();
case MINOR -> versionManager.getNextMinor();
case PATCH -> versionManager.getNextPatch();
case PRE_RELEASE -> versionManager.getNextPreRelease(request.getPreReleaseIdentifier());
};
return ResponseEntity.ok(ApiResponse.success(nextVersion));
} catch (Exception e) {
return ResponseEntity.badRequest()
.body(ApiResponse.error(e.getMessage()));
}
}
@PostMapping("/match")
public ResponseEntity<ApiResponse<VersionMatchResult>> findMatchingVersions(
@RequestBody MatchVersionsRequest request) {
try {
VersionRange range = VersionRange.parse(request.getRange());
List<SemanticVersion> matches = versionManager.findAllMatches(range);
SemanticVersion bestMatch = versionManager.findBestMatch(range);
VersionMatchResult result = VersionMatchResult.builder()
.range(range.toString())
.matches(matches)
.bestMatch(bestMatch)
.matchCount(matches.size())
.build();
return ResponseEntity.ok(ApiResponse.success(result));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest()
.body(ApiResponse.error(e.getMessage()));
}
}
@GetMapping("/analysis")
public ResponseEntity<ApiResponse<VersionAnalysis>> analyzeVersions() {
VersionAnalysis analysis = versionManager.analyzeVersions();
return ResponseEntity.ok(ApiResponse.success(analysis));
}
@PostMapping("/validate")
public ResponseEntity<ApiResponse<ValidationResult>> validateVersion(
@RequestBody ValidateVersionRequest request) {
boolean isValid = SemanticVersion.isValid(request.getVersionString());
ValidationResult result = ValidationResult.builder()
.versionString(request.getVersionString())
.valid(isValid)
.message(isValid ? "Valid semantic version" : "Invalid semantic version")
.build();
return ResponseEntity.ok(ApiResponse.success(result));
}
}
// Request/Response DTOs
class ParseVersionRequest {
private String versionString;
// getters and setters
public String getVersionString() { return versionString; }
public void setVersionString(String versionString) { this.versionString = versionString; }
}
class AddVersionRequest {
private String version;
// getters and setters
public String getVersion() { return version; }
public void setVersion(String version) { this.version = version; }
}
class NextVersionRequest {
private IncrementType incrementType;
private String preReleaseIdentifier;
public enum IncrementType {
MAJOR, MINOR, PATCH, PRE_RELEASE
}
// getters and setters
public IncrementType getIncrementType() { return incrementType; }
public void setIncrementType(IncrementType incrementType) { this.incrementType = incrementType; }
public String getPreReleaseIdentifier() { return preReleaseIdentifier; }
public void setPreReleaseIdentifier(String preReleaseIdentifier) { this.preReleaseIdentifier = preReleaseIdentifier; }
}
class MatchVersionsRequest {
private String range;
// getters and setters
public String getRange() { return range; }
public void setRange(String range) { this.range = range; }
}
class ValidateVersionRequest {
private String versionString;
// getters and setters
public String getVersionString() { return versionString; }
public void setVersionString(String versionString) { this.versionString = versionString; }
}
class VersionMatchResult {
private String range;
private List<SemanticVersion> matches;
private SemanticVersion bestMatch;
private int matchCount;
// builder, getters, setters
public static VersionMatchResultBuilder builder() { return new VersionMatchResultBuilder(); }
public String getRange() { return range; }
public void setRange(String range) { this.range = range; }
public List<SemanticVersion> getMatches() { return matches; }
public void setMatches(List<SemanticVersion> matches) { this.matches = matches; }
public SemanticVersion getBestMatch() { return bestMatch; }
public void setBestMatch(SemanticVersion bestMatch) { this.bestMatch = bestMatch; }
public int getMatchCount() { return matchCount; }
public void setMatchCount(int matchCount) { this.matchCount = matchCount; }
public static class VersionMatchResultBuilder {
private String range;
private List<SemanticVersion> matches;
private SemanticVersion bestMatch;
private int matchCount;
public VersionMatchResultBuilder range(String range) { this.range = range; return this; }
public VersionMatchResultBuilder matches(List<SemanticVersion> matches) { this.matches = matches; return this; }
public VersionMatchResultBuilder bestMatch(SemanticVersion bestMatch) { this.bestMatch = bestMatch; return this; }
public VersionMatchResultBuilder matchCount(int matchCount) { this.matchCount = matchCount; return this; }
public VersionMatchResult build() {
VersionMatchResult result = new VersionMatchResult();
result.setRange(range);
result.setMatches(matches);
result.setBestMatch(bestMatch);
result.setMatchCount(matchCount);
return result;
}
}
}
class ValidationResult {
private String versionString;
private boolean valid;
private String message;
// builder, getters, setters
public static ValidationResultBuilder builder() { return new ValidationResultBuilder(); }
public String getVersionString() { return versionString; }
public void setVersionString(String versionString) { this.versionString = versionString; }
public boolean isValid() { return valid; }
public void setValid(boolean valid) { this.valid = valid; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public static class ValidationResultBuilder {
private String versionString;
private boolean valid;
private String message;
public ValidationResultBuilder versionString(String versionString) { this.versionString = versionString; return this; }
public ValidationResultBuilder valid(boolean valid) { this.valid = valid; return this; }
public ValidationResultBuilder message(String message) { this.message = message; return this; }
public ValidationResult build() {
ValidationResult result = new ValidationResult();
result.setVersionString(versionString);
result.setValid(valid);
result.setMessage(message);
return result;
}
}
}
class ApiResponse<T> {
private boolean success;
private String message;
private T data;
public ApiResponse(boolean success, String message, T data) {
this.success = success;
this.message = message;
this.data = data;
}
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, "Success", data);
}
public static <T> ApiResponse<T> success(T data, String message) {
return new ApiResponse<>(true, message, data);
}
public static <T> ApiResponse<T> error(String message) {
return new ApiResponse<>(false, message, null);
}
// getters and setters
public boolean isSuccess() { return success; }
public void setSuccess(boolean success) { this.success = success; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public T getData() { return data; }
public void setData(T data) { this.data = data; }
}
Testing
1. Unit Tests
// SemanticVersionTest.java
package com.company.semver;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
class SemanticVersionTest {
@Test
void testParseValidVersions() {
assertThat(SemanticVersion.parse("1.0.0")).isNotNull();
assertThat(SemanticVersion.parse("2.1.3")).isNotNull();
assertThat(SemanticVersion.parse("1.0.0-alpha")).isNotNull();
assertThat(SemanticVersion.parse("1.0.0-alpha.1")).isNotNull();
assertThat(SemanticVersion.parse("1.0.0+build.1")).isNotNull();
assertThat(SemanticVersion.parse("1.0.0-alpha+build.1")).isNotNull();
}
@ParameterizedTest
@ValueSource(strings = {
"1.0",
"1.0.0.0",
"01.0.0",
"1.0.0-",
"1.0.0-01",
"1.0.0-alpha..1",
"1.0.0-alpha.!",
""
})
void testParseInvalidVersions(String invalidVersion) {
assertThatThrownBy(() -> SemanticVersion.parse(invalidVersion))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void testVersionComparison() {
SemanticVersion v1 = SemanticVersion.parse("1.0.0");
SemanticVersion v2 = SemanticVersion.parse("2.0.0");
SemanticVersion v1_1 = SemanticVersion.parse("1.1.0");
SemanticVersion v1_0_1 = SemanticVersion.parse("1.0.1");
SemanticVersion v1_0_0_alpha = SemanticVersion.parse("1.0.0-alpha");
SemanticVersion v1_0_0_beta = SemanticVersion.parse("1.0.0-beta");
assertThat(v1).isLessThan(v2);
assertThat(v1).isLessThan(v1_1);
assertThat(v1).isLessThan(v1_0_1);
assertThat(v1_0_0_alpha).isLessThan(v1);
assertThat(v1_0_0_alpha).isLessThan(v1_0_0_beta);
}
@Test
void testIncrementMethods() {
SemanticVersion v1 = SemanticVersion.parse("1.2.3");
assertThat(v1.incrementMajor()).isEqualTo(SemanticVersion.parse("2.0.0"));
assertThat(v1.incrementMinor()).isEqualTo(SemanticVersion.parse("1.3.0"));
assertThat(v1.incrementPatch()).isEqualTo(SemanticVersion.parse("1.2.4"));
assertThat(v1.incrementMajor("beta")).isEqualTo(SemanticVersion.parse("2.0.0-beta"));
assertThat(v1.incrementMinor("rc")).isEqualTo(SemanticVersion.parse("1.3.0-rc"));
}
@Test
void testCompatibilityChecks() {
SemanticVersion v1_0_0 = SemanticVersion.parse("1.0.0");
SemanticVersion v1_1_0 = SemanticVersion.parse("1.1.0");
SemanticVersion v2_0_0 = SemanticVersion.parse("2.0.0");
assertThat(v1_1_0.isCompatibleWith(v1_0_0)).isTrue();
assertThat(v2_0_0.isCompatibleWith(v1_0_0)).isFalse();
assertThat(v1_1_0.isBackwardCompatibleWith(v1_0_0)).isTrue();
assertThat(v1_0_0.isBackwardCompatibleWith(v1_1_0)).isFalse();
}
@ParameterizedTest
@CsvSource({
"1.0.0, true",
"0.1.0, false",
"1.0.0-alpha, false",
"2.0.0, true"
})
void testStabilityCheck(String versionString, boolean expectedStable) {
SemanticVersion version = SemanticVersion.parse(versionString);
assertThat(version.isStable()).isEqualTo(expectedStable);
}
}
// VersionRangeTest.java
package com.company.semver;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
class VersionRangeTest {
@Test
void testRangeParsing() {
assertThat(VersionRange.parse("1.0.0")).isNotNull();
assertThat(VersionRange.parse(">=1.0.0")).isNotNull();
assertThat(VersionRange.parse("^1.0.0")).isNotNull();
assertThat(VersionRange.parse("~1.0.0")).isNotNull();
assertThat(VersionRange.parse("1.0.0 - 2.0.0")).isNotNull();
assertThat(VersionRange.parse(">=1.0.0 <2.0.0")).isNotNull();
assertThat(VersionRange.parse("1.0.0 || 2.0.0")).isNotNull();
}
@Test
void testRangeMatching() {
VersionRange exact = VersionRange.parse("1.2.3");
VersionRange greater = VersionRange.parse(">=1.0.0");
VersionRange caret = VersionRange.parse("^1.2.0");
VersionRange tilde = VersionRange.parse("~1.2.0");
SemanticVersion v1_2_3 = SemanticVersion.parse("1.2.3");
SemanticVersion v1_2_4 = SemanticVersion.parse("1.2.4");
SemanticVersion v1_3_0 = SemanticVersion.parse("1.3.0");
SemanticVersion v2_0_0 = SemanticVersion.parse("2.0.0");
assertThat(exact.contains(v1_2_3)).isTrue();
assertThat(exact.contains(v1_2_4)).isFalse();
assertThat(greater.contains(v1_2_3)).isTrue();
assertThat(greater.contains(v2_0_0)).isTrue();
assertThat(caret.contains(v1_2_3)).isTrue();
assertThat(caret.contains(v1_3_0)).isTrue();
assertThat(caret.contains(v2_0_0)).isFalse();
assertThat(tilde.contains(v1_2_3)).isTrue();
assertThat(tilde.contains(v1_2_4)).isTrue();
assertThat(tilde.contains(v1_3_0)).isFalse();
}
@Test
void testFindBestMatch() {
VersionManager manager = new VersionManager();
manager.addVersion(SemanticVersion.parse("1.2.3"));
manager.addVersion(SemanticVersion.parse("1.2.4"));
manager.addVersion(SemanticVersion.parse("1.3.0"));
manager.addVersion(SemanticVersion.parse("2.0.0"));
VersionRange range = VersionRange.parse("^1.2.0");
SemanticVersion bestMatch = manager.findBestMatch(range);
assertThat(bestMatch).isEqualTo(SemanticVersion.parse("1.3.0"));
}
}
Configuration
1. Spring Configuration
// SemVerConfig.java
package com.company.semver;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SemVerConfig {
@Bean
public VersionManager versionManager() {
return new VersionManager();
}
}
2. Application Properties
# application.yml spring: application: name: semantic-versioning-service server: port: 8080 logging: level: com.company.semver: INFO
This comprehensive semantic versioning implementation provides:
- Full SemVer 2.0.0 compliance with proper parsing and validation
- Immutable version objects with proper equality and comparison
- Version range support with common syntax (^, ~, >=, etc.)
- Version management with lifecycle operations
- REST API for version operations
- Comprehensive testing with unit tests
- Compatibility checking for dependency management
- Analysis tools for version health and gaps
The implementation is production-ready and can be integrated into build systems, dependency management tools, or deployment pipelines to ensure proper versioning practices.