google-site-verification: google61fe8ba583a51912.html
Semantic Versioning in Java: Comprehensive Implementation and Management

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.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper