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) }