1 /*
2 * Licensed to the Apache Software Foundation (ASF) under one
3 * or more contributor license agreements. See the NOTICE file
4 * distributed with this work for additional information
5 * regarding copyright ownership. The ASF licenses this file
6 * to you under the Apache License, Version 2.0 (the
7 * "License"); you may not use this file except in compliance
8 * with the License. You may obtain a copy of the License at
9 *
10 * http://www.apache.org/licenses/LICENSE-2.0
11 *
12 * Unless required by applicable law or agreed to in writing,
13 * software distributed under the License is distributed on an
14 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15 * KIND, either express or implied. See the License for the
16 * specific language governing permissions and limitations
17 * under the License.
18 */
19 package org.apache.maven.shared.release.policy.semver;
20
21 import java.util.Objects;
22 import java.util.regex.Matcher;
23 import java.util.regex.Pattern;
24
25 /**
26 * A simple Semantic Versioning 2.0.0 implementation.
27 * <p>
28 * This class provides basic parsing and manipulation of semantic version strings
29 * following the format: MAJOR.MINOR.PATCH[-PRERELEASE][+METADATA]
30 * </p>
31 *
32 * @see <a href="https://semver.org/">Semantic Versioning 2.0.0</a>
33 */
34 class SemVer {
35
36 /**
37 * Regex pattern for parsing semantic versions from semver.org.
38 * Groups: 1=major, 2=minor, 3=patch, 4=prerelease, 5=metadata
39 *
40 * @see <a href="https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string">SemVer Regex</a>
41 */
42 private static final Pattern SEMVER_PATTERN = Pattern.compile("^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)"
43 + "(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)"
44 + "(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$");
45
46 private final int major;
47 private final int minor;
48 private final int patch;
49 private final String preRelease;
50 private final String metadata;
51
52 /**
53 * Version element types that can be incremented.
54 */
55 public enum Element {
56 MAJOR,
57 MINOR,
58 PATCH
59 }
60
61 /**
62 * Constructs a new SemVer instance.
63 *
64 * @param major the major version number
65 * @param minor the minor version number
66 * @param patch the patch version number
67 * @param preRelease the pre-release identifier (can be null)
68 * @param metadata the build metadata (can be null)
69 */
70 protected SemVer(int major, int minor, int patch, String preRelease, String metadata) {
71 if (major < 0 || minor < 0 || patch < 0) {
72 throw new IllegalArgumentException("Version numbers must be non-negative");
73 }
74 this.major = major;
75 this.minor = minor;
76 this.patch = patch;
77 this.preRelease = preRelease;
78 this.metadata = metadata;
79 }
80
81 /**
82 * Parses a version string into a SemVer object.
83 *
84 * @param version the version string to parse
85 * @return the parsed SemVer object
86 * @throws IllegalArgumentException if the version string is invalid
87 */
88 static SemVer parse(String version) {
89 if (version == null || version.trim().isEmpty()) {
90 throw new IllegalArgumentException("Version string cannot be null or empty");
91 }
92
93 Matcher matcher = SEMVER_PATTERN.matcher(version.trim());
94 if (!matcher.matches()) {
95 throw new IllegalArgumentException("Invalid semantic version format: " + version);
96 }
97
98 int major = Integer.parseInt(matcher.group(1));
99 int minor = Integer.parseInt(matcher.group(2));
100 int patch = Integer.parseInt(matcher.group(3));
101 String preRelease = matcher.group(4);
102 String metadata = matcher.group(5);
103
104 return new SemVer(major, minor, patch, preRelease, metadata);
105 }
106
107 /**
108 * Returns a new SemVer with the release version (removes -SNAPSHOT and any pre-release/metadata).
109 * <p>
110 * Examples:
111 * <ul>
112 * <li>1.2.3-SNAPSHOT → 1.2.3</li>
113 * <li>1.2.3-beta+build → 1.2.3</li>
114 * <li>1.2.3 → 1.2.3</li>
115 * </ul>
116 *
117 * @return a new SemVer representing the release version
118 */
119 SemVer toReleaseVersion() {
120 return new SemVer(major, minor, patch, null, null);
121 }
122
123 /**
124 * Returns a new SemVer with -SNAPSHOT appended as pre-release identifier.
125 * If the version already has a pre-release identifier, it will be replaced with SNAPSHOT.
126 * Metadata is removed.
127 * <p>
128 * Examples:
129 * <ul>
130 * <li>1.2.3 → 1.2.3-SNAPSHOT</li>
131 * <li>1.2.3-beta → 1.2.3-SNAPSHOT</li>
132 * <li>1.2.3+build → 1.2.3-SNAPSHOT</li>
133 * <li>1.2.3-SNAPSHOT → 1.2.3-SNAPSHOT (no change)</li>
134 * </ul>
135 *
136 * @return a new SemVer with SNAPSHOT pre-release identifier, or this instance if already a SNAPSHOT version
137 */
138 SemVer toSnapshotVersion() {
139 if ("SNAPSHOT".equals(preRelease) && metadata == null) {
140 return this;
141 }
142 return new SemVer(major, minor, patch, "SNAPSHOT", null);
143 }
144
145 /**
146 * Returns a new SemVer with the specified element incremented.
147 * If the version has pre-release or metadata, returns the release version without incrementing.
148 * Otherwise, increments the specified element and all lower elements are reset to 0.
149 * Pre-release and metadata are always cleared in the result.
150 *
151 * @param element the element to increment (MAJOR, MINOR, or PATCH)
152 * @return a new SemVer with the specified element incremented (or release version if pre-release/metadata present)
153 */
154 SemVer next(Element element) {
155 Objects.requireNonNull(element, "Element cannot be null");
156
157 // If version has pre-release or metadata, just return release version without incrementing
158 if (hasPreRelease() || hasMetadata()) {
159 return toReleaseVersion();
160 }
161
162 switch (element) {
163 case MAJOR:
164 return new SemVer(major + 1, 0, 0, null, null);
165 case MINOR:
166 return new SemVer(major, minor + 1, 0, null, null);
167 case PATCH:
168 return new SemVer(major, minor, patch + 1, null, null);
169 default:
170 throw new IllegalArgumentException("Unknown element: " + element);
171 }
172 }
173
174 /**
175 * Checks if this version has a pre-release identifier.
176 *
177 * @return true if pre-release identifier is present, false otherwise
178 */
179 private boolean hasPreRelease() {
180 return preRelease != null && !preRelease.isEmpty();
181 }
182
183 /**
184 * Checks if this version has build metadata.
185 *
186 * @return true if build metadata is present, false otherwise
187 */
188 private boolean hasMetadata() {
189 return metadata != null && !metadata.isEmpty();
190 }
191
192 /**
193 * Returns the major version number.
194 *
195 * @return the major version
196 */
197 int getMajor() {
198 return major;
199 }
200
201 /**
202 * Returns the minor version number.
203 *
204 * @return the minor version
205 */
206 int getMinor() {
207 return minor;
208 }
209
210 /**
211 * Returns the patch version number.
212 *
213 * @return the patch version
214 */
215 int getPatch() {
216 return patch;
217 }
218
219 /**
220 * Returns the pre-release identifier.
221 *
222 * @return the pre-release identifier, or null if not present
223 */
224 String getPreRelease() {
225 return preRelease;
226 }
227
228 /**
229 * Returns the build metadata.
230 *
231 * @return the build metadata, or null if not present
232 */
233 String getMetadata() {
234 return metadata;
235 }
236
237 @Override
238 public String toString() {
239 StringBuilder sb = new StringBuilder();
240 sb.append(major).append('.').append(minor).append('.').append(patch);
241
242 if (preRelease != null && !preRelease.isEmpty()) {
243 sb.append('-').append(preRelease);
244 }
245
246 if (metadata != null && !metadata.isEmpty()) {
247 sb.append('+').append(metadata);
248 }
249
250 return sb.toString();
251 }
252 }