From d30fa811a722b383f25cf938fa384841756f36ec Mon Sep 17 00:00:00 2001
From: "Axel H." <noirbizarre@gmail.com>
Date: Wed, 17 Apr 2024 22:45:38 +0200
Subject: [PATCH] feat(schemes): adds support for SemVer 2.0 (dot in
 pre-releases) (fix #1025)

---
 commitizen/version_schemes.py        |  61 +++++++-
 docs/bump.md                         |  20 +--
 docs/config.md                       |   3 +-
 pyproject.toml                       |   1 +
 tests/test_version_scheme_semver2.py | 211 +++++++++++++++++++++++++++
 5 files changed, 281 insertions(+), 15 deletions(-)
 create mode 100644 tests/test_version_scheme_semver2.py

diff --git a/commitizen/version_schemes.py b/commitizen/version_schemes.py
index ec04fde1e7..596aa94b2e 100644
--- a/commitizen/version_schemes.py
+++ b/commitizen/version_schemes.py
@@ -310,7 +310,7 @@ class SemVer(BaseVersion):
     """
     Semantic Versioning (SemVer) scheme
 
-    See: https://semver.org/
+    See: https://semver.org/spec/v1.0.0.html
     """
 
     def __str__(self) -> str:
@@ -324,9 +324,8 @@ def __str__(self) -> str:
         parts.append(".".join(str(x) for x in self.release))
 
         # Pre-release
-        if self.pre:
-            pre = "".join(str(x) for x in self.pre)
-            parts.append(f"-{pre}")
+        if self.prerelease:
+            parts.append(f"-{self.prerelease}")
 
         # Post-release
         if self.post is not None:
@@ -343,6 +342,60 @@ def __str__(self) -> str:
         return "".join(parts)
 
 
+class SemVer2(SemVer):
+    """
+    Semantic Versioning 2.0 (SemVer2) schema
+
+    See: https://semver.org/spec/v2.0.0.html
+    """
+
+    _STD_PRELEASES = {
+        "a": "alpha",
+        "b": "beta",
+    }
+
+    @property
+    def prerelease(self) -> str | None:
+        if self.is_prerelease and self.pre:
+            prerelease_type = self._STD_PRELEASES.get(self.pre[0], self.pre[0])
+            return f"{prerelease_type}.{self.pre[1]}"
+        return None
+
+    def __str__(self) -> str:
+        parts = []
+
+        # Epoch
+        if self.epoch != 0:
+            parts.append(f"{self.epoch}!")
+
+        # Release segment
+        parts.append(".".join(str(x) for x in self.release))
+
+        # Pre-release identifiers
+        # See: https://semver.org/spec/v2.0.0.html#spec-item-9
+        prerelease_parts = []
+        if self.prerelease:
+            prerelease_parts.append(f"{self.prerelease}")
+
+        # Post-release
+        if self.post is not None:
+            prerelease_parts.append(f"post.{self.post}")
+
+        # Development release
+        if self.dev is not None:
+            prerelease_parts.append(f"dev.{self.dev}")
+
+        if prerelease_parts:
+            parts.append("-")
+            parts.append(".".join(prerelease_parts))
+
+        # Local version segment
+        if self.local:
+            parts.append(f"+{self.local}")
+
+        return "".join(parts)
+
+
 DEFAULT_SCHEME: VersionScheme = Pep440
 
 SCHEMES_ENTRYPOINT = "commitizen.scheme"
diff --git a/docs/bump.md b/docs/bump.md
index 05843eeab5..6dad38219e 100644
--- a/docs/bump.md
+++ b/docs/bump.md
@@ -55,7 +55,7 @@ $ cz bump --help
 usage: cz bump [-h] [--dry-run] [--files-only] [--local-version] [--changelog] [--no-verify] [--yes] [--tag-format TAG_FORMAT]
                [--bump-message BUMP_MESSAGE] [--prerelease {alpha,beta,rc}] [--devrelease DEVRELEASE] [--increment {MAJOR,MINOR,PATCH}]
                [--check-consistency] [--annotated-tag] [--gpg-sign] [--changelog-to-stdout] [--git-output-to-stderr] [--retry] [--major-version-zero]
-               [--prerelease-offset PRERELEASE_OFFSET] [--version-scheme {semver,pep440}] [--version-type {semver,pep440}] [--build-metadata BUILD_METADATA]
+               [--prerelease-offset PRERELEASE_OFFSET] [--version-scheme {pep440,semver,semver2}] [--version-type {pep440,semver,semver2}] [--build-metadata BUILD_METADATA]
                [MANUAL_VERSION]
 
 positional arguments:
@@ -97,9 +97,9 @@ options:
   --major-version-zero  keep major version at zero, even for breaking changes
   --prerelease-offset PRERELEASE_OFFSET
                         start pre-releases with this offset
-  --version-scheme {semver,pep440}
+  --version-scheme {pep440,semver,semver2}
                         choose version scheme
-  --version-type {semver,pep440}
+  --version-type {pep440,semver,semver2}
                         Deprecated, use --version-scheme
   --build-metadata {BUILD_METADATA}
                         additional metadata in the version string
@@ -619,14 +619,14 @@ prerelease_offset = 1
 
 Choose version scheme
 
-| schemes        | pep440         | semver          |
-| -------------- | -------------- | --------------- |
-| non-prerelease | `0.1.0`        | `0.1.0`         |
-| prerelease     | `0.3.1a0`      | `0.3.1-a0`      |
-| devrelease     | `0.1.1.dev1`   | `0.1.1-dev1`    |
-| dev and pre    | `1.0.0a3.dev1` | `1.0.0-a3-dev1` |
+| schemes        | pep440         | semver          | semver2               |
+| -------------- | -------------- | --------------- | --------------------- |
+| non-prerelease | `0.1.0`        | `0.1.0`         | `0.1.0`               |
+| prerelease     | `0.3.1a0`      | `0.3.1-a0`      | `0.3.1-alpha.0`       |
+| devrelease     | `0.1.1.dev1`   | `0.1.1-dev1`    | `0.1.1-dev.1`         |
+| dev and pre    | `1.0.0a3.dev1` | `1.0.0-a3-dev1` | `1.0.0-alpha.3.dev.1` |
 
-Options: `semver`, `pep440`
+Options: `pep440`, `semver`, `semver2`
 
 Defaults to: `pep440`
 
diff --git a/docs/config.md b/docs/config.md
index d6ab12abcd..7ef8644fa2 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -40,7 +40,8 @@ Type: `str`
 
 Default: `pep440`
 
-Select a version scheme from the following options [`pep440`, `semver`]. Useful for non-python projects. [Read more][version-scheme]
+Select a version scheme from the following options [`pep440`, `semver`, `semver2`].
+Useful for non-python projects. [Read more][version-scheme]
 
 ### `tag_format`
 
diff --git a/pyproject.toml b/pyproject.toml
index 66a73a310d..96f630e869 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -103,6 +103,7 @@ scm = "commitizen.providers:ScmProvider"
 [tool.poetry.plugins."commitizen.scheme"]
 pep440 = "commitizen.version_schemes:Pep440"
 semver = "commitizen.version_schemes:SemVer"
+semver2 = "commitizen.version_schemes:SemVer2"
 
 [tool.coverage]
     [tool.coverage.report]
diff --git a/tests/test_version_scheme_semver2.py b/tests/test_version_scheme_semver2.py
new file mode 100644
index 0000000000..d18a058a7c
--- /dev/null
+++ b/tests/test_version_scheme_semver2.py
@@ -0,0 +1,211 @@
+import itertools
+import random
+
+import pytest
+
+from commitizen.version_schemes import SemVer2, VersionProtocol
+
+simple_flow = [
+    (("0.1.0", "PATCH", None, 0, None), "0.1.1"),
+    (("0.1.0", "PATCH", None, 0, 1), "0.1.1-dev.1"),
+    (("0.1.1", "MINOR", None, 0, None), "0.2.0"),
+    (("0.2.0", "MINOR", None, 0, None), "0.3.0"),
+    (("0.2.0", "MINOR", None, 0, 1), "0.3.0-dev.1"),
+    (("0.3.0", "PATCH", None, 0, None), "0.3.1"),
+    (("0.3.0", "PATCH", "alpha", 0, None), "0.3.1-alpha.0"),
+    (("0.3.1-alpha.0", None, "alpha", 0, None), "0.3.1-alpha.1"),
+    (("0.3.0", "PATCH", "alpha", 1, None), "0.3.1-alpha.1"),
+    (("0.3.1-alpha.0", None, "alpha", 1, None), "0.3.1-alpha.1"),
+    (("0.3.1-alpha.0", None, None, 0, None), "0.3.1"),
+    (("0.3.1", "PATCH", None, 0, None), "0.3.2"),
+    (("0.4.2", "MAJOR", "alpha", 0, None), "1.0.0-alpha.0"),
+    (("1.0.0-alpha.0", None, "alpha", 0, None), "1.0.0-alpha.1"),
+    (("1.0.0-alpha.1", None, "alpha", 0, None), "1.0.0-alpha.2"),
+    (("1.0.0-alpha.1", None, "alpha", 0, 1), "1.0.0-alpha.2.dev.1"),
+    (("1.0.0-alpha.2.dev.0", None, "alpha", 0, 1), "1.0.0-alpha.3.dev.1"),
+    (("1.0.0-alpha.2.dev.0", None, "alpha", 0, 0), "1.0.0-alpha.3.dev.0"),
+    (("1.0.0-alpha.1", None, "beta", 0, None), "1.0.0-beta.0"),
+    (("1.0.0-beta.0", None, "beta", 0, None), "1.0.0-beta.1"),
+    (("1.0.0-beta.1", None, "rc", 0, None), "1.0.0-rc.0"),
+    (("1.0.0-rc.0", None, "rc", 0, None), "1.0.0-rc.1"),
+    (("1.0.0-rc.0", None, "rc", 0, 1), "1.0.0-rc.1.dev.1"),
+    (("1.0.0-rc.0", "PATCH", None, 0, None), "1.0.0"),
+    (("1.0.0-alpha.3.dev.0", None, "beta", 0, None), "1.0.0-beta.0"),
+    (("1.0.0", "PATCH", None, 0, None), "1.0.1"),
+    (("1.0.1", "PATCH", None, 0, None), "1.0.2"),
+    (("1.0.2", "MINOR", None, 0, None), "1.1.0"),
+    (("1.1.0", "MINOR", None, 0, None), "1.2.0"),
+    (("1.2.0", "PATCH", None, 0, None), "1.2.1"),
+    (("1.2.1", "MAJOR", None, 0, None), "2.0.0"),
+]
+
+local_versions = [
+    (("4.5.0+0.1.0", "PATCH", None, 0, None), "4.5.0+0.1.1"),
+    (("4.5.0+0.1.1", "MINOR", None, 0, None), "4.5.0+0.2.0"),
+    (("4.5.0+0.2.0", "MAJOR", None, 0, None), "4.5.0+1.0.0"),
+]
+
+# never bump backwards on pre-releases
+linear_prerelease_cases = [
+    (("0.1.1-beta.1", None, "alpha", 0, None), "0.1.1-beta.2"),
+    (("0.1.1-rc.0", None, "alpha", 0, None), "0.1.1-rc.1"),
+    (("0.1.1-rc.0", None, "beta", 0, None), "0.1.1-rc.1"),
+]
+
+weird_cases = [
+    (("1.1", "PATCH", None, 0, None), "1.1.1"),
+    (("1", "MINOR", None, 0, None), "1.1.0"),
+    (("1", "MAJOR", None, 0, None), "2.0.0"),
+    (("1-alpha.0", None, "alpha", 0, None), "1.0.0-alpha.1"),
+    (("1-alpha.0", None, "alpha", 1, None), "1.0.0-alpha.1"),
+    (("1", None, "beta", 0, None), "1.0.0-beta.0"),
+    (("1", None, "beta", 1, None), "1.0.0-beta.1"),
+    (("1-beta", None, "beta", 0, None), "1.0.0-beta.1"),
+    (("1.0.0-alpha.1", None, "alpha", 0, None), "1.0.0-alpha.2"),
+    (("1", None, "rc", 0, None), "1.0.0-rc.0"),
+    (("1.0.0-rc.1+e20d7b57f3eb", "PATCH", None, 0, None), "1.0.0"),
+]
+
+# test driven development
+tdd_cases = [
+    (("0.1.1", "PATCH", None, 0, None), "0.1.2"),
+    (("0.1.1", "MINOR", None, 0, None), "0.2.0"),
+    (("2.1.1", "MAJOR", None, 0, None), "3.0.0"),
+    (("0.9.0", "PATCH", "alpha", 0, None), "0.9.1-alpha.0"),
+    (("0.9.0", "MINOR", "alpha", 0, None), "0.10.0-alpha.0"),
+    (("0.9.0", "MAJOR", "alpha", 0, None), "1.0.0-alpha.0"),
+    (("0.9.0", "MAJOR", "alpha", 1, None), "1.0.0-alpha.1"),
+    (("1.0.0-alpha.2", None, "beta", 0, None), "1.0.0-beta.0"),
+    (("1.0.0-alpha.2", None, "beta", 1, None), "1.0.0-beta.1"),
+    (("1.0.0-beta.1", None, "rc", 0, None), "1.0.0-rc.0"),
+    (("1.0.0-rc.1", None, "rc", 0, None), "1.0.0-rc.2"),
+    (("1.0.0-alpha.0", None, "rc", 0, None), "1.0.0-rc.0"),
+    (("1.0.0-alpha.1", None, "alpha", 0, None), "1.0.0-alpha.2"),
+]
+
+
+@pytest.mark.parametrize(
+    "test_input, expected",
+    itertools.chain(tdd_cases, weird_cases, simple_flow, linear_prerelease_cases),
+)
+def test_bump_semver_version(test_input, expected):
+    current_version = test_input[0]
+    increment = test_input[1]
+    prerelease = test_input[2]
+    prerelease_offset = test_input[3]
+    devrelease = test_input[4]
+    assert (
+        str(
+            SemVer2(current_version).bump(
+                increment=increment,
+                prerelease=prerelease,
+                prerelease_offset=prerelease_offset,
+                devrelease=devrelease,
+            )
+        )
+        == expected
+    )
+
+
+@pytest.mark.parametrize("test_input,expected", local_versions)
+def test_bump_semver_version_local(test_input, expected):
+    current_version = test_input[0]
+    increment = test_input[1]
+    prerelease = test_input[2]
+    prerelease_offset = test_input[3]
+    devrelease = test_input[4]
+    is_local_version = True
+    assert (
+        str(
+            SemVer2(current_version).bump(
+                increment=increment,
+                prerelease=prerelease,
+                prerelease_offset=prerelease_offset,
+                devrelease=devrelease,
+                is_local_version=is_local_version,
+            )
+        )
+        == expected
+    )
+
+
+def test_semver_scheme_property():
+    version = SemVer2("0.0.1")
+    assert version.scheme is SemVer2
+
+
+def test_semver_implement_version_protocol():
+    assert isinstance(SemVer2("0.0.1"), VersionProtocol)
+
+
+def test_semver_sortable():
+    test_input = [x[0][0] for x in simple_flow]
+    test_input.extend([x[1] for x in simple_flow])
+    # randomize
+    random_input = [SemVer2(x) for x in random.sample(test_input, len(test_input))]
+    assert len(random_input) == len(test_input)
+    sorted_result = [str(x) for x in sorted(random_input)]
+    assert sorted_result == [
+        "0.1.0",
+        "0.1.0",
+        "0.1.1-dev.1",
+        "0.1.1",
+        "0.1.1",
+        "0.2.0",
+        "0.2.0",
+        "0.2.0",
+        "0.3.0-dev.1",
+        "0.3.0",
+        "0.3.0",
+        "0.3.0",
+        "0.3.0",
+        "0.3.1-alpha.0",
+        "0.3.1-alpha.0",
+        "0.3.1-alpha.0",
+        "0.3.1-alpha.0",
+        "0.3.1-alpha.1",
+        "0.3.1-alpha.1",
+        "0.3.1-alpha.1",
+        "0.3.1",
+        "0.3.1",
+        "0.3.1",
+        "0.3.2",
+        "0.4.2",
+        "1.0.0-alpha.0",
+        "1.0.0-alpha.0",
+        "1.0.0-alpha.1",
+        "1.0.0-alpha.1",
+        "1.0.0-alpha.1",
+        "1.0.0-alpha.1",
+        "1.0.0-alpha.2.dev.0",
+        "1.0.0-alpha.2.dev.0",
+        "1.0.0-alpha.2.dev.1",
+        "1.0.0-alpha.2",
+        "1.0.0-alpha.3.dev.0",
+        "1.0.0-alpha.3.dev.0",
+        "1.0.0-alpha.3.dev.1",
+        "1.0.0-beta.0",
+        "1.0.0-beta.0",
+        "1.0.0-beta.0",
+        "1.0.0-beta.1",
+        "1.0.0-beta.1",
+        "1.0.0-rc.0",
+        "1.0.0-rc.0",
+        "1.0.0-rc.0",
+        "1.0.0-rc.0",
+        "1.0.0-rc.1.dev.1",
+        "1.0.0-rc.1",
+        "1.0.0",
+        "1.0.0",
+        "1.0.1",
+        "1.0.1",
+        "1.0.2",
+        "1.0.2",
+        "1.1.0",
+        "1.1.0",
+        "1.2.0",
+        "1.2.0",
+        "1.2.1",
+        "1.2.1",
+        "2.0.0",
+    ]