Add semver parsing and comparison for package version validation

ParseSemver handles major.minor.patch with optional prerelease suffix.
Compare follows semver spec: prerelease < release at same version.
13 test cases covering parse, compare, and string formatting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-30 18:30:30 +00:00
parent 0a6879b6ae
commit 5678cef42d
2 changed files with 189 additions and 0 deletions

97
server/handlers/semver.go Normal file
View File

@@ -0,0 +1,97 @@
package handlers
import (
"fmt"
"strconv"
"strings"
)
// Semver represents a parsed semantic version (major.minor.patch[-prerelease]).
type Semver struct {
Major int
Minor int
Patch int
Prerelease string
}
// ParseSemver parses a version string like "1.2.3" or "v1.2.3-beta.1".
// Leading "v" is stripped. Prerelease suffix after "-" is preserved.
func ParseSemver(s string) (Semver, error) {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "v")
if s == "" {
return Semver{}, fmt.Errorf("empty version string")
}
// Split off prerelease suffix
var pre string
if idx := strings.IndexByte(s, '-'); idx >= 0 {
pre = s[idx+1:]
s = s[:idx]
}
parts := strings.Split(s, ".")
if len(parts) != 3 {
return Semver{}, fmt.Errorf("version %q must have exactly 3 parts (major.minor.patch)", s)
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return Semver{}, fmt.Errorf("invalid major version %q: %w", parts[0], err)
}
minor, err := strconv.Atoi(parts[1])
if err != nil {
return Semver{}, fmt.Errorf("invalid minor version %q: %w", parts[1], err)
}
patch, err := strconv.Atoi(parts[2])
if err != nil {
return Semver{}, fmt.Errorf("invalid patch version %q: %w", parts[2], err)
}
return Semver{Major: major, Minor: minor, Patch: patch, Prerelease: pre}, nil
}
// Compare returns -1 if a < b, 0 if a == b, +1 if a > b.
// Prerelease versions sort lower than the same release version (per semver spec).
func (a Semver) Compare(b Semver) int {
if a.Major != b.Major {
return cmpInt(a.Major, b.Major)
}
if a.Minor != b.Minor {
return cmpInt(a.Minor, b.Minor)
}
if a.Patch != b.Patch {
return cmpInt(a.Patch, b.Patch)
}
// Same numeric version — prerelease < release
if a.Prerelease == b.Prerelease {
return 0
}
if a.Prerelease == "" {
return 1 // a is release, b has prerelease → a > b
}
if b.Prerelease == "" {
return -1 // a has prerelease, b is release → a < b
}
// Both have prerelease — compare lexicographically
if a.Prerelease < b.Prerelease {
return -1
}
return 1
}
// String returns the version in "major.minor.patch[-prerelease]" format.
func (v Semver) String() string {
s := fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
if v.Prerelease != "" {
s += "-" + v.Prerelease
}
return s
}
func cmpInt(a, b int) int {
if a < b {
return -1
}
return 1
}

View File

@@ -0,0 +1,92 @@
package handlers
import "testing"
func TestParseSemver(t *testing.T) {
tests := []struct {
input string
want Semver
wantErr bool
}{
{"1.2.3", Semver{1, 2, 3, ""}, false},
{"v1.2.3", Semver{1, 2, 3, ""}, false},
{"0.0.0", Semver{0, 0, 0, ""}, false},
{"1.2.3-beta.1", Semver{1, 2, 3, "beta.1"}, false},
{"v0.5.4-rc1", Semver{0, 5, 4, "rc1"}, false},
{"10.20.30", Semver{10, 20, 30, ""}, false},
{"", Semver{}, true},
{"not-a-version", Semver{}, true},
{"1.2", Semver{}, true},
{"1.2.3.4", Semver{}, true},
{"a.b.c", Semver{}, true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := ParseSemver(tt.input)
if tt.wantErr {
if err == nil {
t.Fatalf("ParseSemver(%q) expected error, got %v", tt.input, got)
}
return
}
if err != nil {
t.Fatalf("ParseSemver(%q) unexpected error: %v", tt.input, err)
}
if got != tt.want {
t.Errorf("ParseSemver(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestSemverCompare(t *testing.T) {
tests := []struct {
a, b string
want int
}{
{"1.0.0", "2.0.0", -1},
{"2.0.0", "1.0.0", 1},
{"1.1.0", "1.0.0", 1},
{"1.0.0", "1.1.0", -1},
{"1.0.1", "1.0.0", 1},
{"1.0.0", "1.0.0", 0},
{"1.0.0-alpha", "1.0.0", -1},
{"1.0.0", "1.0.0-alpha", 1},
{"1.0.0-alpha", "1.0.0-beta", -1},
{"1.0.0-beta", "1.0.0-alpha", 1},
{"1.0.0-alpha", "1.0.0-alpha", 0},
{"0.5.3", "0.5.4", -1},
{"0.5.4", "0.5.3", 1},
}
for _, tt := range tests {
t.Run(tt.a+"_vs_"+tt.b, func(t *testing.T) {
a, _ := ParseSemver(tt.a)
b, _ := ParseSemver(tt.b)
got := a.Compare(b)
if got != tt.want {
t.Errorf("(%s).Compare(%s) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
func TestSemverString(t *testing.T) {
tests := []struct {
v Semver
want string
}{
{Semver{1, 2, 3, ""}, "1.2.3"},
{Semver{0, 0, 0, ""}, "0.0.0"},
{Semver{1, 0, 0, "beta.1"}, "1.0.0-beta.1"},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
if got := tt.v.String(); got != tt.want {
t.Errorf("Semver.String() = %q, want %q", got, tt.want)
}
})
}
}