Files
felhom-controller/controller/internal/util/version.go
T
2026-10-15 14:34:33 +02:00

151 lines
4.2 KiB
Go

package util
// Semantic-version parsing + comparison — THE one comparator in this repo (house rule: never a
// second one). Moved verbatim from internal/selfupdate/version.go (v0.115.0) so that BOTH
// selfupdate (floor/update decisions) and agentapi (the per-feature MinAgent capability
// comparison) share it without an import cycle (selfupdate imports agentapi). selfupdate keeps
// aliases, so its call sites and tests are unchanged.
import (
"fmt"
"strconv"
"strings"
)
// Version represents a semantic version (Major.Minor.Patch).
type Version struct {
Major int
Minor int
Patch int
Raw string
// Pre (v0.314.0, R-1053) is a pre-release suffix — „rc.4" in 0.313.0-rc.4 — set ONLY by ParseVersionPre.
// ParseVersion refuses a suffix, so every caller that lists or offers tags still never sees a test build.
Pre string
}
// ParseVersion parses "X.Y.Z" or "vX.Y.Z". Returns error for "dev", "latest", or invalid formats.
func ParseVersion(s string) (Version, error) {
s = strings.TrimPrefix(s, "v")
if s == "dev" || s == "latest" || s == "" {
return Version{}, fmt.Errorf("invalid version: %q", s)
}
parts := strings.SplitN(s, ".", 3)
if len(parts) != 3 {
return Version{}, fmt.Errorf("invalid version format: %q (expected X.Y.Z)", s)
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return Version{}, fmt.Errorf("invalid major version: %w", err)
}
minor, err := strconv.Atoi(parts[1])
if err != nil {
return Version{}, fmt.Errorf("invalid minor version: %w", err)
}
patch, err := strconv.Atoi(parts[2])
if err != nil {
return Version{}, fmt.Errorf("invalid patch version: %w", err)
}
return Version{Major: major, Minor: minor, Patch: patch, Raw: s}, nil
}
// ParseVersionPre is ParseVersion that also accepts a pre-release suffix (semver §9): „0.313.0-rc.4" is 0.313.0 with
// Pre „rc.4", which Compare orders BELOW 0.313.0. A build suffix („+abc") is dropped. Used where the question is „is
// this box below the floor?" (R-1053: a box on a test build was never moved by the floor) — never to pick a tag to offer.
func ParseVersionPre(s string) (Version, error) {
s = strings.TrimPrefix(s, "v")
if i := strings.IndexByte(s, '+'); i >= 0 {
s = s[:i]
}
base, pre, hasPre := strings.Cut(s, "-")
v, err := ParseVersion(base)
if err != nil {
return Version{}, err
}
if hasPre {
if pre == "" {
return Version{}, fmt.Errorf("invalid version: %q (empty pre-release)", s)
}
for _, id := range strings.Split(pre, ".") {
if id == "" || strings.Trim(id, "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-") != "" {
return Version{}, fmt.Errorf("invalid pre-release in %q", s)
}
}
v.Pre = pre
}
v.Raw = s
return v, nil
}
// comparePre orders pre-release suffixes per semver §11: none > any; identifiers left to right, numeric ones
// numerically and below alphanumeric ones; a shorter list below a longer one with the same prefix.
func comparePre(a, b string) int {
switch {
case a == b:
return 0
case a == "":
return 1
case b == "":
return -1
}
as, bs := strings.Split(a, "."), strings.Split(b, ".")
for i := 0; i < len(as) && i < len(bs); i++ {
an, aerr := strconv.Atoi(as[i])
bn, berr := strconv.Atoi(bs[i])
switch {
case aerr == nil && berr == nil:
if an != bn {
if an < bn {
return -1
}
return 1
}
case aerr == nil:
return -1
case berr == nil:
return 1
default:
if c := strings.Compare(as[i], bs[i]); c != 0 {
return c
}
}
}
switch {
case len(as) < len(bs):
return -1
case len(as) > len(bs):
return 1
}
return 0
}
// Compare returns -1 if a < b, 0 if a == b, 1 if a > b.
func (a Version) Compare(b Version) int {
if a.Major != b.Major {
if a.Major < b.Major {
return -1
}
return 1
}
if a.Minor != b.Minor {
if a.Minor < b.Minor {
return -1
}
return 1
}
if a.Patch != b.Patch {
if a.Patch < b.Patch {
return -1
}
return 1
}
return comparePre(a.Pre, b.Pre)
}
// String returns the version as "X.Y.Z" (or "X.Y.Z-pre" for a ParseVersionPre result).
func (v Version) String() string {
if v.Pre != "" {
return fmt.Sprintf("%d.%d.%d-%s", v.Major, v.Minor, v.Patch, v.Pre)
}
return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
}