diff --git a/common/src/main/kotlin/utils/version/SemanticVersion.kt b/common/src/main/kotlin/utils/version/SemanticVersion.kt new file mode 100644 index 0000000..1b7fb50 --- /dev/null +++ b/common/src/main/kotlin/utils/version/SemanticVersion.kt @@ -0,0 +1,133 @@ +package utils.version + +/** + * A simple semantic version parser + * + * See also: [Semantic Versioning](https://semver.org/) + * */ +data class SemanticVersion( + val major: Int, + val minor: Int, + val patch: Int, + val preRelease: String? = null, + val buildMetadata: String? = null, +) : Comparable { + init { + require(major >= 0) { "Major version must be a non-negative integer." } + require(minor >= 0) { "Major version must be a non-negative integer." } + require(patch >= 0) { "Major version must be a non-negative integer." } + if (preRelease != null) require(preRelease.matches(preReleasePattern)) { "Pre-release version is not valid" } + if (buildMetadata != null) require(buildMetadata.matches(buildMetadataPattern)) { "Build metadata is not valid" } + } + + override fun compareTo(other: SemanticVersion): Int = + when { + // If major versions are different, the one with higher major version is greater + major != other.major -> major.compareTo(other.major) + // If major versions are the same, compare minor versions + minor != other.minor -> minor.compareTo(other.minor) + // If major and minor versions are the same, compare patch versions + patch != other.patch -> patch.compareTo(other.patch) + // If major, minor, and patch versions are the same for both versions, and they don't have pre-release tags, they are equal + preRelease == null && other.preRelease == null -> 0 + // If only this version has no pre-release tag, it is considered greater (i.e., a stable version is greater than a pre-release version) + preRelease == null -> 1 + // If only the other version has no pre-release tag, this version is considered less + other.preRelease == null -> -1 + // If both versions have pre-release tags, compare them + else -> + comparePreRelease( + versionA = preRelease, + versionB = other.preRelease, + ) + // Build metadata does not affect the precedence of the version: https://semver.org/#spec-item-11 + } + + override fun toString(): String = + buildString { + append("$major.$minor.$patch") + preRelease?.let { append("-$it") } + buildMetadata?.let { append("+$it") } + } + + companion object { + private val preReleasePattern: Regex = + Regex("""(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*""") + + private val buildMetadataPattern: Regex = Regex("""[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*""") + + fun parse(version: String): Result = + try { + val (versionPart, buildMetadata) = version.split('+', limit = 2) + listOf(null) + requireNotNull(versionPart) + + val (versionPartWithoutPreReleaseSuffix, preRelease) = versionPart.split('-', limit = 2) + listOf(null) + + requireNotNull(versionPartWithoutPreReleaseSuffix) + val (major, minor, patch) = versionPartWithoutPreReleaseSuffix.split('.', limit = 3) + + Result.success( + SemanticVersion( + major = major.toInt(), + minor = minor.toInt(), + patch = patch.toInt(), + preRelease = preRelease, + buildMetadata = buildMetadata, + ), + ) + } catch (e: Exception) { + Result.failure(e) + } + + /** + * Compare two pre-release versions (e.g., "alpha.1" and "beta.2") + * @return Will be used in [Comparable.compareTo] + * */ + private fun comparePreRelease( + versionA: String, + versionB: String, + ): Int { + val aParts = versionA.split('.') + val bParts = versionB.split('.') + + // Find the minimum length of the two parts lists to avoid out-of-bounds errors + val lowestPartsSize = minOf(aParts.size, bParts.size) + + for (i in 0 until lowestPartsSize) { + val partsComparison = + comparePreReleasePart( + partA = aParts[i], + partB = bParts[i], + ) + if (partsComparison != 0) return partsComparison + } + + // If all compared parts are equal, compare the lengths of the pre-release tags + // The shorter pre-release tag is considered lesser (e.g., "alpha" < "alpha.1") + return aParts.size - bParts.size + } + + /** + * Compare individual parts of pre-release versions (e.g., "alpha" vs "beta" or "1" vs "2") + * */ + private fun comparePreReleasePart( + partA: String, + partB: String, + ): Int { + val aAsNumber: Int? = partA.toIntOrNull() + val bAsNumber: Int? = partB.toIntOrNull() + + return when { + // If both parts are numeric, compare them as integers + aAsNumber != null && bAsNumber != null -> aAsNumber - bAsNumber + // If the only first part is numeric, it's considered greater, + // it's considered greater as numeric parts are more specific + aAsNumber != null -> 1 + // If only the second part is numeric, the first part is considered lesser + bAsNumber != null -> -1 + // If neither part is numeric, compare them lexicographically (alphabetically) + else -> partA.compareTo(partB) + } + } + } +} diff --git a/common/src/test/kotlin/utils/version/SemanticVersionTest.kt b/common/src/test/kotlin/utils/version/SemanticVersionTest.kt new file mode 100644 index 0000000..e8464ff --- /dev/null +++ b/common/src/test/kotlin/utils/version/SemanticVersionTest.kt @@ -0,0 +1,247 @@ +package utils.version + +import org.junit.jupiter.api.assertThrows +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class SemanticVersionTest { + @Test + fun `test parse result`() { + val major = 22 + val minor = 50 + val patch = 11 + val preRelease = "alpha" + val buildMetadata = "151e629ee3c41f3bf996d2c74364bed01afe47a8" + + val version = + SemanticVersion( + major = major, + minor = minor, + patch = patch, + preRelease = preRelease, + buildMetadata = buildMetadata, + ) + assertEquals(version.major, major) + assertEquals(version.minor, minor) + assertEquals(version.patch, patch) + assertEquals(version.preRelease, preRelease) + assertEquals(version.buildMetadata, buildMetadata) + + val version2 = + SemanticVersion + .parse("$major.$minor.$patch-$preRelease+$buildMetadata") + .getOrThrow() + assertEquals(version2.major, major) + assertEquals(version2.minor, minor) + assertEquals(version2.patch, patch) + assertEquals(version2.preRelease, preRelease) + assertEquals(version2.buildMetadata, buildMetadata) + } + + @Test + fun `validate input`() { + // Major, minor and patch + assertThrows { + SemanticVersion(major = -1, minor = 1, patch = 1) + } + assertThrows { + SemanticVersion(major = 1, minor = -1, patch = 1) + } + assertThrows { + SemanticVersion(major = 1, minor = 1, patch = -1) + } + assertThrows { + SemanticVersion(major = -1, minor = -1, patch = -1) + } + + // Pre-Release + assertThrows { + SemanticVersion(major = 1, minor = 1, patch = 1, preRelease = "") + } + assertThrows { + SemanticVersion.parse("1.1.1-").getOrThrow() + } + + // Build Metadata + assertThrows { + SemanticVersion(major = 1, minor = 1, patch = 1, buildMetadata = "") + } + } + + @Test + fun `test equal versions`() { + assertEquals( + SemanticVersion.parse("1.0.0").getOrThrow(), + SemanticVersion.parse("1.0.0").getOrThrow(), + ) + } + + @Test + fun `test not equal versions`() { + assertNotEquals( + SemanticVersion.parse("9.0.0").getOrThrow(), + SemanticVersion.parse("1.0.0").getOrThrow(), + ) + } + + @Test + fun `test major version comparison`() { + assertEquals( + SemanticVersion.parse("1.0.0").getOrThrow().major, + SemanticVersion.parse("1.0.0").getOrThrow().major, + ) + assertNotEquals( + SemanticVersion.parse("2.0.0").getOrThrow().major, + SemanticVersion.parse("3.0.0").getOrThrow().major, + ) + + assertTrue( + SemanticVersion.parse("2.0.0").getOrThrow() + > SemanticVersion.parse("1.10.0").getOrThrow(), + ) + + assertFalse( + SemanticVersion.parse("1.0.0").getOrThrow() + > SemanticVersion.parse("2.10.0").getOrThrow(), + ) + } + + @Test + fun `test minor version comparison`() { + assertEquals( + SemanticVersion.parse("1.3.0").getOrThrow().minor, + SemanticVersion.parse("1.3.0").getOrThrow().minor, + ) + assertNotEquals( + SemanticVersion.parse("1.3.2").getOrThrow().minor, + SemanticVersion.parse("1.5.1").getOrThrow().minor, + ) + + assertTrue( + SemanticVersion.parse("1.5.0").getOrThrow() + > SemanticVersion.parse("1.4.20").getOrThrow(), + ) + assertFalse( + SemanticVersion.parse("1.4.0").getOrThrow() + > SemanticVersion.parse("1.5.20").getOrThrow(), + ) + } + + @Test + fun `test patch version comparison`() { + assertEquals( + SemanticVersion.parse("1.0.1").getOrThrow().patch, + SemanticVersion.parse("1.0.1").getOrThrow().patch, + ) + assertNotEquals( + SemanticVersion.parse("1.0.2").getOrThrow().patch, + SemanticVersion.parse("1.0.1").getOrThrow().patch, + ) + + assertTrue( + SemanticVersion.parse("1.0.20").getOrThrow() + > SemanticVersion.parse("1.0.19").getOrThrow(), + ) + + assertFalse( + SemanticVersion.parse("1.0.19").getOrThrow() + > SemanticVersion.parse("1.0.20").getOrThrow(), + ) + } + + @Test + fun `test pre release comparison`() { + assertTrue( + SemanticVersion.parse("1.0.0-beta").getOrThrow() + > + SemanticVersion.parse("1.0.0-alpha").getOrThrow(), + ) + + assertFalse( + SemanticVersion.parse("1.0.0-beta").getOrThrow() + > + SemanticVersion.parse("1.0.0-beta").getOrThrow(), + ) + + assertEquals( + SemanticVersion.parse("1.0.0-beta").getOrThrow(), + SemanticVersion.parse("1.0.0-beta").getOrThrow(), + ) + + assertFalse( + SemanticVersion.parse("1.0.0-alpha").getOrThrow() + > + SemanticVersion.parse("1.0.0-beta").getOrThrow(), + ) + } + + @Test + fun `test numeric pre release comparison`() { + assertTrue( + SemanticVersion.parse("1.0.0-alpha.2").getOrThrow() + > + SemanticVersion.parse("1.0.0-alpha.1").getOrThrow(), + ) + + assertFalse( + SemanticVersion.parse("1.0.0-alpha.1").getOrThrow() + > + SemanticVersion.parse("1.0.0-alpha.2").getOrThrow(), + ) + } + + @Test + fun `test mixed pre release comparison`() { + assertTrue( + SemanticVersion.parse("1.0.0-alpha.4").getOrThrow() + > SemanticVersion.parse("1.0.0-alpha").getOrThrow(), + ) + assertFalse( + SemanticVersion.parse("1.0.0-alpha").getOrThrow() + > SemanticVersion.parse("1.0.0-alpha.4").getOrThrow(), + ) + } + + @Test + fun `test numeric with non numeric pre release comparison`() { + assertTrue( + SemanticVersion.parse("1.0.0-1").getOrThrow() > + SemanticVersion.parse("1.0.0-alpha").getOrThrow(), + ) + assertFalse( + SemanticVersion.parse("1.0.0-alpha").getOrThrow() > + SemanticVersion.parse("1.0.0-1").getOrThrow(), + ) + } + + @Test + fun `test null pre release with non null pre release`() { + assertTrue( + SemanticVersion.parse("1.0.0").getOrThrow() + > SemanticVersion.parse("1.0.0-alpha").getOrThrow(), + ) + assertFalse( + SemanticVersion.parse("1.0.0-alpha").getOrThrow() + > SemanticVersion.parse("1.0.0").getOrThrow(), + ) + } + + // Build metadata does not affect the precedence of the version: https://semver.org/#spec-item-11 + + @Test + fun `test version with build metadata`() { + val version1 = SemanticVersion.parse("1.0.0+001").getOrThrow() + val version2 = SemanticVersion.parse("1.0.0+002").getOrThrow() + assertEquals(0, version1.compareTo(version2)) + } + + @Test + fun `test pre release version with build metadata`() { + val version1 = SemanticVersion.parse("1.0.0-alpha+001").getOrThrow() + val version2 = SemanticVersion.parse("1.0.0-alpha+002").getOrThrow() + assertEquals(0, version1.compareTo(version2)) + } +} diff --git a/sync-script/src/main/kotlin/services/updater/JarAutoUpdater.kt b/sync-script/src/main/kotlin/services/updater/JarAutoUpdater.kt index 59b4f50..b0032bd 100644 --- a/sync-script/src/main/kotlin/services/updater/JarAutoUpdater.kt +++ b/sync-script/src/main/kotlin/services/updater/JarAutoUpdater.kt @@ -18,6 +18,7 @@ import utils.getRunningJarFilePath import utils.moveToOrTerminate import utils.os.OperatingSystem import utils.terminateWithOrWithoutError +import utils.version.SemanticVersion import java.nio.file.Path import kotlin.io.path.absolutePathString import kotlin.io.path.exists @@ -35,10 +36,12 @@ object JarAutoUpdater { reasonOfDelete = "the script is downloading the new update", ) } + val latestJarFileDownloadUrl = ProjectInfoConstants.LATEST_SYNC_SCRIPT_JAR_FILE_URL + println("\uD83D\uDD3D Downloading the new JAR file from: $latestJarFileDownloadUrl") FileDownloader( downloadUrl = ProjectInfoConstants.LATEST_SYNC_SCRIPT_JAR_FILE_URL, targetFilePath = newJarFile, - progressListener = { _, _, _ -> }, + progressListener = null, ).downloadFile() Result.success(newJarFile) } catch (e: Exception) { @@ -73,27 +76,58 @@ object JarAutoUpdater { Result.failure(e) } - suspend fun updateIfAvailable() { - val currentRunningJarFilePath = - getRunningJarFilePath() - .getOrElse { - println("⚠\uFE0F Auto update feature is only supported when running using JAR.") - return - } - val latestProjectVersion = + private suspend fun shouldUpdate(): Boolean { + val latestProjectVersionString = getLatestProjectVersion().getOrElse { println("❌ We couldn't get the latest project version: ${it.message}") - return + return false } - if (latestProjectVersion == null) { + if (latestProjectVersionString == null) { println( "⚠\uFE0F It seems that the project version is missing, it could have been moved somewhere else. " + "Consider updating manually.", ) - return + return false } - if (latestProjectVersion == BuildConfig.PROJECT_VERSION) { - println("✨ You're using the latest version of the project.") + + val currentVersionString = BuildConfig.PROJECT_VERSION + + val latestProjectSemanticVersion: SemanticVersion = + SemanticVersion.parse(latestProjectVersionString).getOrElse { + println("❌ Failed to parse the latest project version to SemanticVersion: ${it.message}") + return false + } + val currentSemanticVersion: SemanticVersion = + SemanticVersion.parse(currentVersionString).getOrElse { + println("❌ Failed to parse the current application version to SemanticVersion: ${it.message}") + return false + } + + return when { + currentSemanticVersion == latestProjectSemanticVersion -> { + println("✨ You're using the latest version of the project.") + false + } + + currentSemanticVersion > latestProjectSemanticVersion -> { + println("✨ You're using a version that's newer than the latest.") + false + } + + else -> true + } + } + + suspend fun updateIfAvailable() { + val currentRunningJarFilePath = + getRunningJarFilePath() + .getOrElse { + println("⚠\uFE0F Auto update feature is only supported when running using JAR.") + return + } + + val shouldUpdate = shouldUpdate() + if (!shouldUpdate) { return } val newJarFile = diff --git a/sync-script/src/main/kotlin/utils/FileDownloader.kt b/sync-script/src/main/kotlin/utils/FileDownloader.kt index 1b64c38..3356227 100644 --- a/sync-script/src/main/kotlin/utils/FileDownloader.kt +++ b/sync-script/src/main/kotlin/utils/FileDownloader.kt @@ -26,11 +26,13 @@ class FileDownloader( private val downloadUrl: String, private val targetFilePath: Path, val progressListener: ( - downloadedBytes: Long, - // in percentage, from 0 to 100 - downloadedProgress: Float, - bytesToDownload: Long, - ) -> Unit, + ( + downloadedBytes: Long, + // in percentage, from 0 to 100 + downloadedProgress: Float, + bytesToDownload: Long, + ) -> Unit + )?, ) { suspend fun downloadFile() { if (targetFilePath.exists()) { @@ -84,7 +86,7 @@ class FileDownloader( if (readBytes == -1L) break downloadedBytes += readBytes val progress = downloadedBytes.toFloat() / bytesToDownload.coerceAtLeast(1L) * 100 - progressListener(downloadedBytes, progress, bytesToDownload) + progressListener?.invoke(downloadedBytes, progress, bytesToDownload) sink.flush() } }