| 1 | // Semver targets and counter iterations (release versioning spec, |
| 2 | // 2026-08-08). A TARGET is a bare MAJOR.MINOR.PATCH ("0.5.0"); an ITERATION |
| 3 | // is a shipped build under a target ("0.5.0-3"). Both are ordinary version |
| 4 | // strings to everything else in this package — ValidVersion accepts them, |
| 5 | // pointers and manifests carry them unchanged. This file only teaches the |
| 6 | // package to read the structure back OUT of them. |
| 7 | package release |
| 8 | |
| 9 | import ( |
| 10 | "strconv" |
| 11 | "strings" |
| 12 | ) |
| 13 | |
| 14 | // SemVer is a parsed MAJOR.MINOR.PATCH. No prerelease/build fields: the |
| 15 | // iteration counter is this package's only prerelease shape, and |
| 16 | // SplitIteration owns it. |
| 17 | type SemVer struct{ Major, Minor, Patch int } |
| 18 | |
| 19 | // ParseSemVer parses a strict bare semver: three dot-separated decimal |
| 20 | // numbers, no leading zeros, nothing else. Refusals are (zero, false) — |
| 21 | // version strings outside this shape are legal everywhere, they just are |
| 22 | // not targets. |
| 23 | func ParseSemVer(s string) (SemVer, bool) { |
| 24 | parts := strings.Split(s, ".") |
| 25 | if len(parts) != 3 { |
| 26 | return SemVer{}, false |
| 27 | } |
| 28 | var n [3]int |
| 29 | for i, p := range parts { |
| 30 | v, ok := parseCounter(p, true) |
| 31 | if !ok { |
| 32 | return SemVer{}, false |
| 33 | } |
| 34 | n[i] = v |
| 35 | } |
| 36 | return SemVer{n[0], n[1], n[2]}, true |
| 37 | } |
| 38 | |
| 39 | // parseCounter parses a plain decimal with no sign and no leading zeros. |
| 40 | // zeroOK distinguishes semver components (0 is fine) from iteration |
| 41 | // counters (they start at 1). |
| 42 | func parseCounter(s string, zeroOK bool) (int, bool) { |
| 43 | if s == "" || len(s) > 9 { |
| 44 | return 0, false |
| 45 | } |
| 46 | for _, c := range s { |
| 47 | if c < '0' || c > '9' { |
| 48 | return 0, false |
| 49 | } |
| 50 | } |
| 51 | if len(s) > 1 && s[0] == '0' { |
| 52 | return 0, false |
| 53 | } |
| 54 | v, err := strconv.Atoi(s) |
| 55 | if err != nil || (!zeroOK && v == 0) { |
| 56 | return 0, false |
| 57 | } |
| 58 | return v, true |
| 59 | } |
| 60 | |
| 61 | func (v SemVer) String() string { |
| 62 | return strconv.Itoa(v.Major) + "." + strconv.Itoa(v.Minor) + "." + strconv.Itoa(v.Patch) |
| 63 | } |
| 64 | |
| 65 | // Compare is < 0 / 0 / > 0, semver precedence (major, then minor, then patch). |
| 66 | func (v SemVer) Compare(o SemVer) int { |
| 67 | switch { |
| 68 | case v.Major != o.Major: |
| 69 | return v.Major - o.Major |
| 70 | case v.Minor != o.Minor: |
| 71 | return v.Minor - o.Minor |
| 72 | default: |
| 73 | return v.Patch - o.Patch |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | // NextPatch is where the target auto-advances after a tag is cut. |
| 78 | func (v SemVer) NextPatch() SemVer { return SemVer{v.Major, v.Minor, v.Patch + 1} } |
| 79 | |
| 80 | // SplitIteration reads "<target>-<n>" back out of an iteration version: |
| 81 | // "0.5.0-3" -> (0.5.0, 3, true). Counters start at 1 and carry no leading |
| 82 | // zeros, so every iteration version has exactly one spelling. Anything |
| 83 | // else — a sha, a bare target, "dev" — is (zero, 0, false), which callers |
| 84 | // read as "not an iteration; no tag business here". |
| 85 | func SplitIteration(version string) (SemVer, int, bool) { |
| 86 | base, cnt, found := strings.Cut(version, "-") |
| 87 | if !found || strings.Contains(cnt, "-") { |
| 88 | return SemVer{}, 0, false |
| 89 | } |
| 90 | t, ok := ParseSemVer(base) |
| 91 | if !ok { |
| 92 | return SemVer{}, 0, false |
| 93 | } |
| 94 | n, ok := parseCounter(cnt, false) |
| 95 | if !ok { |
| 96 | return SemVer{}, 0, false |
| 97 | } |
| 98 | return t, n, true |
| 99 | } |
| 100 | |
| 101 | // IterationVersion is SplitIteration's inverse: the version string minted |
| 102 | // for iteration n under a target. |
| 103 | func IterationVersion(t SemVer, n int) string { |
| 104 | return t.String() + "-" + strconv.Itoa(n) |
| 105 | } |
| 106 | |