diff --git a/version.go b/version.go index 541e656..85cbf50 100644 --- a/version.go +++ b/version.go @@ -13,14 +13,26 @@ type Semver struct { Major int Minor int Patch int + // Pre holds the prerelease identifiers that follow the first hyphen, + // without the leading hyphen. It is empty for a plain release tag. + // "v1.2.3-rc1" yields "rc1"; "v1.2.3" yields "". + Pre string } -// ParseSemver parses a version string like "v1.2.3" or "1.2.3" or "v1.2.3-dirty". -// It strips the "v" prefix and any suffix after a hyphen. +// ParseSemver parses a version string like "v1.2.3" or "1.2.3" or "v1.2.3-rc1". +// It strips the "v" prefix and any "+build" metadata, keeps the numeric +// major.minor.patch triple, and retains the prerelease suffix in Pre. func ParseSemver(s string) (Semver, error) { s = strings.TrimPrefix(s, "v") - // Strip anything after hyphen (e.g. "-dirty", "-rc1") + // Build metadata does not take part in version precedence. + if idx := strings.IndexByte(s, '+'); idx >= 0 { + s = s[:idx] + } + // Everything after the first hyphen is the prerelease suffix + // (e.g. "-dirty", "-rc1", "-beta.2"). + var pre string if idx := strings.IndexByte(s, '-'); idx >= 0 { + pre = s[idx+1:] s = s[:idx] } parts := strings.Split(s, ".") @@ -39,21 +51,108 @@ func ParseSemver(s string) (Semver, error) { if err != nil { return Semver{}, fmt.Errorf("invalid patch: %w", err) } - return Semver{Major: major, Minor: minor, Patch: patch}, nil + return Semver{Major: major, Minor: minor, Patch: patch, Pre: pre}, nil } -// NewerThan returns true if v is strictly newer than other. -func (v Semver) NewerThan(other Semver) bool { - if v.Major != other.Major { - return v.Major > other.Major +// Compare returns -1, 0 or +1 as v sorts before, equal to, or after other. +// Ordering follows semantic-version precedence: the numeric triple is +// compared first, then the prerelease suffix. A version carrying a +// prerelease suffix sorts before the same version without one, so +// "1.2.3-rc1" < "1.2.3". +func (v Semver) Compare(other Semver) int { + if c := compareInt(v.Major, other.Major); c != 0 { + return c } - if v.Minor != other.Minor { - return v.Minor > other.Minor + if c := compareInt(v.Minor, other.Minor); c != 0 { + return c } - return v.Patch > other.Patch + if c := compareInt(v.Patch, other.Patch); c != 0 { + return c + } + return comparePrerelease(v.Pre, other.Pre) +} + +// NewerThan returns true if v is strictly newer than other. +func (v Semver) NewerThan(other Semver) bool { + return v.Compare(other) > 0 } -// String returns the version as "vMAJOR.MINOR.PATCH". +// String returns the version as "vMAJOR.MINOR.PATCH", with the prerelease +// suffix appended when present. func (v Semver) String() string { + if v.Pre != "" { + return fmt.Sprintf("v%d.%d.%d-%s", v.Major, v.Minor, v.Patch, v.Pre) + } return fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor, v.Patch) } + +// comparePrerelease orders two prerelease suffixes. An empty suffix (a plain +// release) sorts after any non-empty one. Otherwise the suffixes are split on +// "." and compared identifier by identifier; when every shared identifier is +// equal, the suffix with more identifiers sorts later. +func comparePrerelease(a, b string) int { + if a == b { + return 0 + } + if a == "" { + return 1 + } + if b == "" { + return -1 + } + as := strings.Split(a, ".") + bs := strings.Split(b, ".") + for i := 0; i < len(as) && i < len(bs); i++ { + if c := comparePrereleaseIdent(as[i], bs[i]); c != 0 { + return c + } + } + return compareInt(len(as), len(bs)) +} + +// comparePrereleaseIdent orders two prerelease identifiers. All-digit +// identifiers are compared numerically and sort before alphanumeric ones; +// any other pair is compared bytewise. +func comparePrereleaseIdent(a, b string) int { + an, aNumeric := numericIdent(a) + bn, bNumeric := numericIdent(b) + switch { + case aNumeric && bNumeric: + return compareInt(an, bn) + case aNumeric: + return -1 + case bNumeric: + return 1 + default: + return strings.Compare(a, b) + } +} + +// numericIdent reports whether s consists solely of digits, returning its +// numeric value when it does. +func numericIdent(s string) (int, bool) { + if s == "" { + return 0, false + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return 0, false + } + } + n, err := strconv.Atoi(s) + if err != nil { + return 0, false + } + return n, true +} + +func compareInt(a, b int) int { + switch { + case a < b: + return -1 + case a > b: + return 1 + default: + return 0 + } +} diff --git a/zz_more_test.go b/zz_more_test.go index bd5a4d3..c2e05e5 100644 --- a/zz_more_test.go +++ b/zz_more_test.go @@ -177,11 +177,13 @@ func TestSemver_NewerThan_AllBranches(t *testing.T) { a, b Semver want bool }{ - {Semver{2, 0, 0}, Semver{1, 9, 9}, true}, - {Semver{1, 2, 0}, Semver{1, 1, 9}, true}, - {Semver{1, 1, 2}, Semver{1, 1, 1}, true}, - {Semver{1, 1, 1}, Semver{1, 1, 1}, false}, - {Semver{1, 0, 0}, Semver{2, 0, 0}, false}, + {Semver{2, 0, 0, ""}, Semver{1, 9, 9, ""}, true}, + {Semver{1, 2, 0, ""}, Semver{1, 1, 9, ""}, true}, + {Semver{1, 1, 2, ""}, Semver{1, 1, 1, ""}, true}, + {Semver{1, 1, 1, ""}, Semver{1, 1, 1, ""}, false}, + {Semver{1, 0, 0, ""}, Semver{2, 0, 0, ""}, false}, + {Semver{1, 1, 1, ""}, Semver{1, 1, 1, "rc1"}, true}, + {Semver{1, 1, 1, "rc1"}, Semver{1, 1, 1, ""}, false}, } for _, tc := range cases { if got := tc.a.NewerThan(tc.b); got != tc.want { @@ -193,7 +195,10 @@ func TestSemver_NewerThan_AllBranches(t *testing.T) { // TestSemver_String covers the stringer. func TestSemver_String(t *testing.T) { t.Parallel() - if got := (Semver{1, 2, 3}).String(); got != "v1.2.3" { + if got := (Semver{1, 2, 3, ""}).String(); got != "v1.2.3" { + t.Errorf("String = %q", got) + } + if got := (Semver{1, 2, 3, "beta.1"}).String(); got != "v1.2.3-beta.1" { t.Errorf("String = %q", got) } } diff --git a/zz_test.go b/zz_test.go index 09a6753..8c350ac 100644 --- a/zz_test.go +++ b/zz_test.go @@ -37,12 +37,15 @@ func TestParseSemver(t *testing.T) { want Semver wantErr bool }{ - {"v1.2.3", Semver{1, 2, 3}, false}, - {"1.2.3", Semver{1, 2, 3}, false}, - {"v0.0.1", Semver{0, 0, 1}, false}, - {"v10.20.30", Semver{10, 20, 30}, false}, - {"v1.2.3-dirty", Semver{1, 2, 3}, false}, - {"v1.6.2-rc1", Semver{1, 6, 2}, false}, + {"v1.2.3", Semver{1, 2, 3, ""}, false}, + {"1.2.3", Semver{1, 2, 3, ""}, false}, + {"v0.0.1", Semver{0, 0, 1, ""}, false}, + {"v10.20.30", Semver{10, 20, 30, ""}, false}, + {"v1.2.3-dirty", Semver{1, 2, 3, "dirty"}, false}, + {"v1.6.2-rc1", Semver{1, 6, 2, "rc1"}, false}, + {"v1.2.3-beta.2", Semver{1, 2, 3, "beta.2"}, false}, + {"v1.2.3+build.7", Semver{1, 2, 3, ""}, false}, + {"v1.2.3-rc.1+build.7", Semver{1, 2, 3, "rc.1"}, false}, {"", Semver{}, true}, {"v1.2", Semver{}, true}, {"v1.2.x", Semver{}, true}, @@ -77,6 +80,21 @@ func TestSemverNewerThan(t *testing.T) { {"v1.2.3", "v1.3.0", false}, {"v1.2.3", "v2.0.0", false}, {"v0.0.1", "v0.0.0", true}, + + // A prerelease sorts below the release it leads up to. + {"v1.2.3", "v1.2.3-rc1", true}, + {"v1.2.3-rc1", "v1.2.3", false}, + {"v1.2.3-rc1", "v1.2.3-rc1", false}, + {"v1.2.3-rc2", "v1.2.3-rc1", true}, + {"v1.2.3-rc1", "v1.2.3-rc2", false}, + {"v1.2.3-beta.2", "v1.2.3-beta.1", true}, + {"v1.2.3-rc.1", "v1.2.3-beta.9", true}, + {"v1.2.3-rc.1", "v1.2.2", true}, + {"v1.2.3-rc.1", "v1.2.4", false}, + {"v1.2.3-rc.1.1", "v1.2.3-rc.1", true}, + {"v1.2.3-1", "v1.2.3-alpha", false}, + {"v1.2.3-alpha", "v1.2.3-1", true}, + {"v1.2.3+build.7", "v1.2.3", false}, } for _, tt := range tests { @@ -93,10 +111,48 @@ func TestSemverNewerThan(t *testing.T) { func TestSemverString(t *testing.T) { t.Parallel() - v := Semver{1, 6, 3} + v := Semver{1, 6, 3, ""} if s := v.String(); s != "v1.6.3" { t.Fatalf("String() = %q, want %q", s, "v1.6.3") } + if s := (Semver{1, 6, 3, "rc1"}).String(); s != "v1.6.3-rc1" { + t.Fatalf("String() = %q, want %q", s, "v1.6.3-rc1") + } +} + +// TestSemverPinEquality checks that an exact pin only matches a tag with the +// same prerelease suffix, which is how checkPinnedVersion decides whether the +// pinned release is already installed. +func TestSemverPinEquality(t *testing.T) { + t.Parallel() + tests := []struct { + pin, installed string + want bool + }{ + {"v1.2.3", "v1.2.3", true}, + {"v1.2.3", "v1.2.3-rc1", false}, + {"v1.2.3-rc1", "v1.2.3", false}, + {"v1.2.3-rc1", "v1.2.3-rc1", true}, + {"v1.2.3-rc1", "v1.2.3-rc2", false}, + {"v1.2.3", "v1.2.3+build.7", true}, + } + + for _, tt := range tests { + name := fmt.Sprintf("%s==%s", tt.pin, tt.installed) + t.Run(name, func(t *testing.T) { + pin, err := ParseSemver(tt.pin) + if err != nil { + t.Fatalf("ParseSemver(%q): %v", tt.pin, err) + } + installed, err := ParseSemver(tt.installed) + if err != nil { + t.Fatalf("ParseSemver(%q): %v", tt.installed, err) + } + if got := installed == pin; got != tt.want { + t.Fatalf("%q == %q = %v, want %v", tt.installed, tt.pin, got, tt.want) + } + }) + } } func TestVerifyChecksum(t *testing.T) {