a73x

internal/version/order.go

Ref:   Size: 4.7 KiB   History

package version

import (
	"strconv"
	"strings"
)

// Less reports whether version a orders strictly before b. Versions are
// eitri's own tags — releases ("v0.0.3"), pre-releases of them ("v0.0.4-pre.1")
// — and the git-describe builds derived from either ("v0.0.3-5-gabc1234",
// "v0.0.4-pre.1-3-gabc1234", N commits past that tag). The whole chain orders
// the way it reads:
//
//	v0.0.4-pre.1 < v0.0.4-pre.1-3-gabc1234 < v0.0.4-pre.2 < v0.0.4 < v0.0.4-2-gabc1234
//
// So a hand-deployed "v0.0.2-2-g68f804d" orders before the "v0.0.3" release and
// takes the upgrade; a build ahead of the latest release never orders before it
// and is never offered a downgrade; and an agent on a pre-release takes the next
// pre-release and then the release itself, which is what lets a staging plane
// rehearse the upgrade path on the same tags a release cycle produces.
//
// Anything unparsable — "dev", a "-dirty" working tree, a malformed tag — never
// orders before anything, so an unstamped build never sees an upgrade, and an
// unstamped agent is never held below a version floor.
//
// Ordering lives here, beside the stamp it orders, because both planes need it:
// the control plane offers upgrades against it, and the agent measures itself
// against a snapshot's floor. Neither plane may import the other (arch R1), and
// this package is the shared leaf that already carries Version.
func Less(a, b string) bool {
	pa, oka := parse(a)
	pb, okb := parse(b)
	if !oka || !okb {
		return false
	}
	return before(pa, pb)
}

// Ordered reports whether v has a shape this ordering understands. It is the
// predicate behind Less's "unparsable orders before nothing" rule, exported so
// that a constant meant to BE a version — a feature floor, a release tag — can
// be asserted well-formed at the point it is written down rather than
// discovered wrong by a fleet that silently refuses or admits.
func Ordered(v string) bool {
	_, ok := parse(v)
	return ok
}

// before compares two ordering tuples component by component.
func before(a, b [6]int) bool {
	for i := range a {
		if a[i] != b[i] {
			return a[i] < b[i]
		}
	}
	return false
}

// parse reads a version into its ordering tuple. Four shapes are accepted:
//
//	vX.Y.Z                 a release
//	vX.Y.Z-pre.N           the Nth pre-release leading up to it
//	vX.Y.Z-C-g<hex>        C commits past the release (git describe)
//	vX.Y.Z-pre.N-C-g<hex>  C commits past the pre-release
//
// The tuple is (X, Y, Z, final, pre, count). `final` is 1 for a release and 0
// for a pre-release of it, which is the whole trick: it sinks every
// vX.Y.Z-pre.N below vX.Y.Z without disturbing how anything else sorts, and
// leaves builds derived from either in their own place. Both counters must be
// exactly a non-negative decimal, and a describe suffix must carry a
// "g"-prefixed non-empty hex abbrev — a "-dirty" marker or any other trailing
// text makes the version unparsable.
func parse(v string) ([6]int, bool) {
	var out [6]int
	core, suffix, hasSuffix := strings.Cut(strings.TrimPrefix(v, "v"), "-")

	out[3] = 1 // a release outranks every pre-release of itself
	if hasSuffix {
		if pre, isPre := strings.CutPrefix(suffix, "pre."); isPre {
			out[3] = 0
			num, rest, hasRest := strings.Cut(pre, "-")
			n, ok := decimal(num)
			if !ok {
				return out, false
			}
			out[4] = n
			suffix, hasSuffix = rest, hasRest
		}
	}
	if hasSuffix {
		count, abbrev, found := strings.Cut(suffix, "-")
		if !found || !isGitAbbrev(abbrev) {
			return out, false
		}
		n, ok := decimal(count)
		if !ok {
			return out, false
		}
		out[5] = n
	}

	parts := strings.SplitN(core, ".", 3)
	if len(parts) != 3 {
		return out, false
	}
	for i, p := range parts {
		n, ok := decimal(p)
		if !ok {
			return out, false
		}
		out[i] = n
	}
	return out, true
}

// decimal parses one non-negative decimal component, rejecting the sign Atoi
// would otherwise accept and any value too large to hold.
func decimal(s string) (int, bool) {
	if !isDecimal(s) {
		return 0, false
	}
	n, err := strconv.Atoi(s)
	if err != nil {
		return 0, false
	}
	return n, true
}

// isDecimal reports whether s is a non-empty run of decimal digits — the sign
// Atoi would otherwise accept is not part of a version component.
func isDecimal(s string) bool {
	if s == "" {
		return false
	}
	for _, r := range s {
		if r < '0' || r > '9' {
			return false
		}
	}
	return true
}

// isGitAbbrev reports whether s is a "g"-prefixed non-empty hex object abbrev,
// the shape git describe appends after the commit count.
func isGitAbbrev(s string) bool {
	hex, ok := strings.CutPrefix(s, "g")
	if !ok || hex == "" {
		return false
	}
	for _, r := range hex {
		switch {
		case r >= '0' && r <= '9', r >= 'a' && r <= 'f', r >= 'A' && r <= 'F':
		default:
			return false
		}
	}
	return true
}