Recognize local app states (T-202)
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var ErrInvalidSemVer = errors.New("invalid semantic version")
|
||||
|
||||
// SemVersion is a parsed Semantic Version 2.0.0 value.
|
||||
type SemVersion struct {
|
||||
original string
|
||||
major string
|
||||
minor string
|
||||
patch string
|
||||
prerelease []string
|
||||
build []string
|
||||
}
|
||||
|
||||
// ParseSemVer parses a complete Semantic Version 2.0.0 string.
|
||||
func ParseSemVer(value string) (SemVersion, error) {
|
||||
if value == "" {
|
||||
return SemVersion{}, fmt.Errorf("%w: empty value", ErrInvalidSemVer)
|
||||
}
|
||||
|
||||
coreAndPrerelease := value
|
||||
var build []string
|
||||
if separator := strings.IndexByte(value, '+'); separator >= 0 {
|
||||
if strings.IndexByte(value[separator+1:], '+') >= 0 {
|
||||
return SemVersion{}, fmt.Errorf("%w: multiple build separators", ErrInvalidSemVer)
|
||||
}
|
||||
coreAndPrerelease = value[:separator]
|
||||
var err error
|
||||
build, err = parseIdentifiers(value[separator+1:], false)
|
||||
if err != nil {
|
||||
return SemVersion{}, err
|
||||
}
|
||||
}
|
||||
|
||||
core := coreAndPrerelease
|
||||
var prerelease []string
|
||||
if separator := strings.IndexByte(coreAndPrerelease, '-'); separator >= 0 {
|
||||
core = coreAndPrerelease[:separator]
|
||||
var err error
|
||||
prerelease, err = parseIdentifiers(coreAndPrerelease[separator+1:], true)
|
||||
if err != nil {
|
||||
return SemVersion{}, err
|
||||
}
|
||||
}
|
||||
|
||||
parts := strings.Split(core, ".")
|
||||
if len(parts) != 3 {
|
||||
return SemVersion{}, fmt.Errorf("%w: core must contain major.minor.patch", ErrInvalidSemVer)
|
||||
}
|
||||
for _, part := range parts {
|
||||
if !validNumericIdentifier(part, false) {
|
||||
return SemVersion{}, fmt.Errorf("%w: invalid core identifier %q", ErrInvalidSemVer, part)
|
||||
}
|
||||
}
|
||||
|
||||
return SemVersion{
|
||||
original: value,
|
||||
major: parts[0],
|
||||
minor: parts[1],
|
||||
patch: parts[2],
|
||||
prerelease: prerelease,
|
||||
build: build,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// String returns the original normalized-by-validation input.
|
||||
func (version SemVersion) String() string {
|
||||
return version.original
|
||||
}
|
||||
|
||||
// Compare applies SemVer precedence. Build metadata does not affect ordering.
|
||||
func (version SemVersion) Compare(other SemVersion) int {
|
||||
if comparison := compareNumericText(version.major, other.major); comparison != 0 {
|
||||
return comparison
|
||||
}
|
||||
if comparison := compareNumericText(version.minor, other.minor); comparison != 0 {
|
||||
return comparison
|
||||
}
|
||||
if comparison := compareNumericText(version.patch, other.patch); comparison != 0 {
|
||||
return comparison
|
||||
}
|
||||
|
||||
if len(version.prerelease) == 0 && len(other.prerelease) == 0 {
|
||||
return 0
|
||||
}
|
||||
if len(version.prerelease) == 0 {
|
||||
return 1
|
||||
}
|
||||
if len(other.prerelease) == 0 {
|
||||
return -1
|
||||
}
|
||||
|
||||
count := len(version.prerelease)
|
||||
if len(other.prerelease) < count {
|
||||
count = len(other.prerelease)
|
||||
}
|
||||
for index := 0; index < count; index++ {
|
||||
left := version.prerelease[index]
|
||||
right := other.prerelease[index]
|
||||
leftNumeric := allDigits(left)
|
||||
rightNumeric := allDigits(right)
|
||||
switch {
|
||||
case leftNumeric && rightNumeric:
|
||||
if comparison := compareNumericText(left, right); comparison != 0 {
|
||||
return comparison
|
||||
}
|
||||
case leftNumeric:
|
||||
return -1
|
||||
case rightNumeric:
|
||||
return 1
|
||||
case left < right:
|
||||
return -1
|
||||
case left > right:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(version.prerelease) < len(other.prerelease):
|
||||
return -1
|
||||
case len(version.prerelease) > len(other.prerelease):
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// CompareSemVer parses and compares two version strings.
|
||||
func CompareSemVer(left, right string) (int, error) {
|
||||
leftVersion, err := ParseSemVer(left)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rightVersion, err := ParseSemVer(right)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return leftVersion.Compare(rightVersion), nil
|
||||
}
|
||||
|
||||
func parseIdentifiers(value string, rejectNumericLeadingZero bool) ([]string, error) {
|
||||
identifiers := strings.Split(value, ".")
|
||||
if value == "" || len(identifiers) == 0 {
|
||||
return nil, fmt.Errorf("%w: empty identifier list", ErrInvalidSemVer)
|
||||
}
|
||||
for _, identifier := range identifiers {
|
||||
if identifier == "" {
|
||||
return nil, fmt.Errorf("%w: empty identifier", ErrInvalidSemVer)
|
||||
}
|
||||
for _, character := range identifier {
|
||||
if (character < '0' || character > '9') &&
|
||||
(character < 'A' || character > 'Z') &&
|
||||
(character < 'a' || character > 'z') &&
|
||||
character != '-' {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: invalid identifier %q",
|
||||
ErrInvalidSemVer,
|
||||
identifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
if rejectNumericLeadingZero &&
|
||||
allDigits(identifier) &&
|
||||
!validNumericIdentifier(identifier, false) {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: numeric prerelease identifier %q has a leading zero",
|
||||
ErrInvalidSemVer,
|
||||
identifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
return identifiers, nil
|
||||
}
|
||||
|
||||
func validNumericIdentifier(value string, allowLeadingZero bool) bool {
|
||||
if value == "" || !allDigits(value) {
|
||||
return false
|
||||
}
|
||||
return allowLeadingZero || len(value) == 1 || value[0] != '0'
|
||||
}
|
||||
|
||||
func allDigits(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character < '0' || character > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func compareNumericText(left, right string) int {
|
||||
switch {
|
||||
case len(left) < len(right):
|
||||
return -1
|
||||
case len(left) > len(right):
|
||||
return 1
|
||||
case left < right:
|
||||
return -1
|
||||
case left > right:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSemVerPrecedence(t *testing.T) {
|
||||
ordered := []string{
|
||||
"1.0.0-alpha",
|
||||
"1.0.0-alpha.1",
|
||||
"1.0.0-alpha.beta",
|
||||
"1.0.0-beta",
|
||||
"1.0.0-beta.2",
|
||||
"1.0.0-beta.11",
|
||||
"1.0.0-rc.1",
|
||||
"1.0.0",
|
||||
"1.0.1",
|
||||
"1.1.0",
|
||||
"2.0.0",
|
||||
}
|
||||
for index := 0; index < len(ordered)-1; index++ {
|
||||
comparison, err := CompareSemVer(ordered[index], ordered[index+1])
|
||||
if err != nil {
|
||||
t.Fatalf("CompareSemVer() error = %v", err)
|
||||
}
|
||||
if comparison >= 0 {
|
||||
t.Fatalf("%q should precede %q", ordered[index], ordered[index+1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemVerIgnoresBuildMetadata(t *testing.T) {
|
||||
comparison, err := CompareSemVer("1.2.3+build.1", "1.2.3+build.99")
|
||||
if err != nil {
|
||||
t.Fatalf("CompareSemVer() error = %v", err)
|
||||
}
|
||||
if comparison != 0 {
|
||||
t.Fatalf("comparison = %d, want 0", comparison)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemVerSupportsLargeNumericIdentifiers(t *testing.T) {
|
||||
comparison, err := CompareSemVer(
|
||||
"999999999999999999999999999.0.0",
|
||||
"1000000000000000000000000000.0.0",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("CompareSemVer() error = %v", err)
|
||||
}
|
||||
if comparison >= 0 {
|
||||
t.Fatalf("comparison = %d, want negative", comparison)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemVerRejectsInvalidValues(t *testing.T) {
|
||||
for _, value := range []string{
|
||||
"",
|
||||
"1",
|
||||
"1.2",
|
||||
"01.2.3",
|
||||
"1.02.3",
|
||||
"1.2.03",
|
||||
"1.2.3-",
|
||||
"1.2.3-alpha..1",
|
||||
"1.2.3-01",
|
||||
"1.2.3+",
|
||||
"1.2.3+build..1",
|
||||
"v1.2.3",
|
||||
"1.2.3 alpha",
|
||||
} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
_, err := ParseSemVer(value)
|
||||
if !errors.Is(err, ErrInvalidSemVer) {
|
||||
t.Fatalf("ParseSemVer(%q) error = %v, want %v", value, err, ErrInvalidSemVer)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var ErrInvalidStatusFacts = errors.New("invalid app status facts")
|
||||
|
||||
// AppStatusFacts are IO-free observations used to derive one visible state.
|
||||
type AppStatusFacts struct {
|
||||
Operation AppStatus
|
||||
Running bool
|
||||
RecoveryPending bool
|
||||
Incompatible bool
|
||||
InstalledVersion string
|
||||
CatalogVersion string
|
||||
}
|
||||
|
||||
// ResolveAppStatus derives the single user-visible state from local and
|
||||
// background-operation facts.
|
||||
func ResolveAppStatus(facts AppStatusFacts) (AppStatus, error) {
|
||||
if facts.RecoveryPending {
|
||||
return StatusRollbackPending, nil
|
||||
}
|
||||
if facts.Operation != "" {
|
||||
if !validOperationStatus(facts.Operation) {
|
||||
return "", fmt.Errorf(
|
||||
"%w: operation %q",
|
||||
ErrInvalidStatusFacts,
|
||||
facts.Operation,
|
||||
)
|
||||
}
|
||||
return facts.Operation, nil
|
||||
}
|
||||
if facts.Running {
|
||||
return StatusRunning, nil
|
||||
}
|
||||
if facts.Incompatible {
|
||||
return StatusIncompatible, nil
|
||||
}
|
||||
if facts.InstalledVersion == "" {
|
||||
return StatusNotInstalled, nil
|
||||
}
|
||||
if _, err := ParseSemVer(facts.InstalledVersion); err != nil {
|
||||
return "", fmt.Errorf("%w: installed version: %v", ErrInvalidStatusFacts, err)
|
||||
}
|
||||
if facts.CatalogVersion == "" {
|
||||
return StatusInstalled, nil
|
||||
}
|
||||
comparison, err := CompareSemVer(facts.InstalledVersion, facts.CatalogVersion)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: catalog version: %v", ErrInvalidStatusFacts, err)
|
||||
}
|
||||
if comparison < 0 {
|
||||
return StatusUpdateAvailable, nil
|
||||
}
|
||||
return StatusInstalled, nil
|
||||
}
|
||||
|
||||
func validOperationStatus(status AppStatus) bool {
|
||||
switch status {
|
||||
case StatusQueued,
|
||||
StatusDownloading,
|
||||
StatusVerifying,
|
||||
StatusExtracting,
|
||||
StatusInstalling,
|
||||
StatusFailed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveAppStatusCoversAllStates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
facts AppStatusFacts
|
||||
want AppStatus
|
||||
}{
|
||||
{name: "not installed", want: StatusNotInstalled},
|
||||
{name: "queued", facts: AppStatusFacts{Operation: StatusQueued}, want: StatusQueued},
|
||||
{name: "downloading", facts: AppStatusFacts{Operation: StatusDownloading}, want: StatusDownloading},
|
||||
{name: "verifying", facts: AppStatusFacts{Operation: StatusVerifying}, want: StatusVerifying},
|
||||
{name: "extracting", facts: AppStatusFacts{Operation: StatusExtracting}, want: StatusExtracting},
|
||||
{name: "installing", facts: AppStatusFacts{Operation: StatusInstalling}, want: StatusInstalling},
|
||||
{
|
||||
name: "installed",
|
||||
facts: AppStatusFacts{
|
||||
InstalledVersion: "1.2.0",
|
||||
CatalogVersion: "1.2.0",
|
||||
},
|
||||
want: StatusInstalled,
|
||||
},
|
||||
{
|
||||
name: "update available",
|
||||
facts: AppStatusFacts{
|
||||
InstalledVersion: "1.2.0",
|
||||
CatalogVersion: "1.3.0",
|
||||
},
|
||||
want: StatusUpdateAvailable,
|
||||
},
|
||||
{name: "running", facts: AppStatusFacts{Running: true}, want: StatusRunning},
|
||||
{name: "failed", facts: AppStatusFacts{Operation: StatusFailed}, want: StatusFailed},
|
||||
{
|
||||
name: "rollback pending",
|
||||
facts: AppStatusFacts{RecoveryPending: true},
|
||||
want: StatusRollbackPending,
|
||||
},
|
||||
{
|
||||
name: "incompatible",
|
||||
facts: AppStatusFacts{Incompatible: true},
|
||||
want: StatusIncompatible,
|
||||
},
|
||||
}
|
||||
|
||||
seen := make(map[AppStatus]bool)
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
status, err := ResolveAppStatus(test.facts)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAppStatus() error = %v", err)
|
||||
}
|
||||
if status != test.want {
|
||||
t.Fatalf("status = %q, want %q", status, test.want)
|
||||
}
|
||||
seen[status] = true
|
||||
})
|
||||
}
|
||||
if len(seen) != 12 {
|
||||
t.Fatalf("covered %d states, want 12", len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAppStatusPriorityAndValidation(t *testing.T) {
|
||||
status, err := ResolveAppStatus(AppStatusFacts{
|
||||
Operation: StatusDownloading,
|
||||
Running: true,
|
||||
RecoveryPending: true,
|
||||
Incompatible: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAppStatus() error = %v", err)
|
||||
}
|
||||
if status != StatusRollbackPending {
|
||||
t.Fatalf("status = %q, want %q", status, StatusRollbackPending)
|
||||
}
|
||||
|
||||
_, err = ResolveAppStatus(AppStatusFacts{Operation: StatusInstalled})
|
||||
if !errors.Is(err, ErrInvalidStatusFacts) {
|
||||
t.Fatalf("operation error = %v, want %v", err, ErrInvalidStatusFacts)
|
||||
}
|
||||
_, err = ResolveAppStatus(AppStatusFacts{InstalledVersion: "not-semver"})
|
||||
if !errors.Is(err, ErrInvalidStatusFacts) {
|
||||
t.Fatalf("version error = %v, want %v", err, ErrInvalidStatusFacts)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user