diff --git a/server/handlers/semver.go b/server/handlers/semver.go new file mode 100644 index 0000000..b4d5f6b --- /dev/null +++ b/server/handlers/semver.go @@ -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 +} diff --git a/server/handlers/semver_test.go b/server/handlers/semver_test.go new file mode 100644 index 0000000..4ea9952 --- /dev/null +++ b/server/handlers/semver_test.go @@ -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) + } + }) + } +}