commit b4367c8019d1727cf9ba3a210920a46beb5281b8 Author: decentral1se Date: Sat Aug 7 20:26:56 2021 +0200 Init diff --git a/README.md b/README.md new file mode 100644 index 0000000..ba93108 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# tagcmp + +Comparison operations for image tags. Because registries aren't doing this for +us 🙄 This library is helpful if you're aiming to use only "stable" and +"semver-like" tags want to be able to do things like compare them, find which +tags are more recent, sort them and other types of comparisons. + +A best-effort implementation which follows the wisdom of [Renovate]. + +> Docker doesn't really have versioning, instead it supports "tags" and these +> are usually used by Docker image authors as a form of versioning ... It's +> pretty "wild west" for tagging and not always compliant with SemVer. + +The [Renovate implementation], which allows image tags to be automatically +upgraded, is the only show in town, apparently. This library follows that +implementation quite closely. + +[renovate]: https://github.com/renovatebot/renovate/blob/main/lib/versioning/docker/readme.md +[renovate implementation]: https://github.com/renovatebot/renovate/tree/main/lib/datasource/docker + +## Public API + +> TODO + +## Types of versions supported + +```golang +// semver +"5", +"2.6", +"4.3.5", + +// semver with 'v' +"v1", +"v2.3", +"v1.0.2", + +// semver with suffix +"6-alpine", +"6.2-alpine", +"6.2.1-alpine", + +// semver with sufixx and 'v' +"v6-alpine", +"v6.2-alpine", +"v6.2.1-alpine", +"v6.2.1-alpine", + +// semver with multiple suffix values +"v6.2.1-alpine-foo", +``` + +## Types of versions not supported + +> Please note, we could support some of these versions if people really need +> them to be supported. Some tags are using a unique format which we could +> support by implementing a very specific parser for (e.g. `ParseMinioTag`, +> `ParseZncTag`). For now, this library tries to provide a `Parse` function +> which handles more general cases. Please open an issue, change sets are +> welcome. + +```golang +// empty +"", + +// patametrized +"${MAILU_VERSION:-master}", +"${PHP_VERSION}-fpm-alpine3.13", + +// commit hash like +"0a1b2c3d4e5f6a7b8c9d0a1b2c3d4e5f6a7b8c9d", + +// numeric +"20191109", +"e02267d", + +// not semver +"3.0.6.0", +"r1295", +"version-r1070", + +// prerelease +"3.7.0b1", +"3.8.0b1-alpine", + +// multiple versions +"5.36-backdrop-php7.4", +"v1.0.5_3.4.0", +"v1.0.5_3.4.0_openid-sso", + +// tz based +"RELEASE.2021-04-22T15-44-28Z", + +// only text +"alpine", +"latest", +"master", + +// multiple - delimters +"apache-debian-1.8-prod", +"version-znc-1.8.2", +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c7fc748 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module coopcloud.tech/tagcmp + +go 1.16 diff --git a/tagcmp.go b/tagcmp.go new file mode 100644 index 0000000..6b88e1a --- /dev/null +++ b/tagcmp.go @@ -0,0 +1,308 @@ +// Package tagcmp provides image tag comparison operations. +package tagcmp + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +type Tag struct { + Major string `json:",omitempty"` // major semver part + Minor string `json:",omitempty"` // minor semver part + MissingMinor bool // whether or not the minor semver part was left out + Patch string `json:",omitempty"` // patch semver part + MissingPatch bool // whether or not he patch semver part was left out + Suffix string // tag suffix (e.g. "-alpine") + UsesV bool // whether or not the tag uses the "v" prefix +} + +// ByTag sorts tags +type ByTag []Tag + +func (t ByTag) Len() int { return len(t) } +func (t ByTag) Swap(i, j int) { t[i], t[j] = t[j], t[i] } +func (t ByTag) Less(i, j int) bool { + return t[i].IsLessThan(t[j]) +} + +// IsGreaterThan tests if a tag is greater than another. There are some +// tag-isms to take into account here, shorter is bigger (i.e. 2.1 > 2.1.1 == +// true, 2 > 2.1 == true). +func (t Tag) IsGreaterThan(tag Tag) bool { + // shorter is bigger, i.e. 2.1 > 2.1.1 + if t.MissingPatch && !tag.MissingPatch || t.MissingMinor && !tag.MissingMinor { + return true + } + if tag.MissingPatch && !t.MissingPatch || tag.MissingMinor && !t.MissingMinor { + return false + } + + // ignore errors since Parse already handled + mj1, _ := strconv.Atoi(t.Major) + mj2, _ := strconv.Atoi(tag.Major) + if mj1 > mj2 { + return true + } + if mj2 > mj1 { + return false + } + + mn1, _ := strconv.Atoi(t.Minor) + mn2, _ := strconv.Atoi(tag.Minor) + if mn1 > mn2 { + return true + } + if mn2 > mn1 { + return false + } + + p1, _ := strconv.Atoi(t.Patch) + p2, _ := strconv.Atoi(tag.Patch) + if p1 > p2 { + return true + } + if p2 > p1 { + return false + } + + return false +} + +// IsLessThan tests if a tag is less than another. There are some tag-isms to +// take into account here, shorter is bigger (i.e. 2.1 < 2.1.1 == false, 2 < +// 2.1 == false). +func (t Tag) IsLessThan(tag Tag) bool { + if t.IsGreaterThan(tag) { + return false + } + + return true +} + +// Equals tests Tag equality +func (t Tag) Equals(tag Tag) bool { + if t.MissingPatch && !tag.MissingPatch || t.MissingMinor && !tag.MissingMinor { + return false + } + + if tag.MissingPatch && !t.MissingPatch || tag.MissingMinor && !t.MissingMinor { + return false + } + + // ignore errors since Parse already handled + mj1, _ := strconv.Atoi(t.Major) + mj2, _ := strconv.Atoi(tag.Major) + if mj1 != mj2 { + return false + } + + mn1, _ := strconv.Atoi(t.Minor) + mn2, _ := strconv.Atoi(tag.Minor) + if mn1 != mn2 { + return false + } + + p1, _ := strconv.Atoi(t.Patch) + p2, _ := strconv.Atoi(tag.Patch) + if p1 != p2 { + return false + } + + return true +} + +// String formats a Tag correctly in string representation +func (t Tag) String() string { + var repr string + + if t.UsesV { + repr += "v" + } + + repr += t.Major + + if !t.MissingMinor { + repr += fmt.Sprintf(".%s", t.Minor) + } + + if !t.MissingPatch { + repr += fmt.Sprintf(".%s", t.Patch) + } + + if t.Suffix != "" { + repr += fmt.Sprintf("-%s", t.Suffix) + } + + return repr +} + +// IsCompatible determines if two tags can be compared together +func (t Tag) IsCompatible(tag Tag) bool { + if t.UsesV && !tag.UsesV || tag.UsesV && !t.UsesV { + return false + } + + if t.Suffix != "" && tag.Suffix == "" { + return false + } + + if t.Suffix != "" && tag.Suffix != "" { + if t.Suffix != tag.Suffix { + return false + } + } + + if t.MissingMinor && !tag.MissingMinor || tag.MissingMinor && !t.MissingMinor { + return false + } + + if t.MissingPatch && !tag.MissingPatch || tag.MissingPatch && !t.MissingPatch { + return false + } + + return true +} + +// CommitHashPattern matches commit-like hash tags +var CommitHashPattern = "^[a-f0-9]{7,40}$" + +// DotPattern matches tags which contain multiple versions +var DotPattern = "([0-9]+)\\.([0-9]+)" + +// EmptyPattern matches when tags are missing +var EmptyPattern = "^$" + +// ParametrizedPattern matches when tags are parametrized +var ParametrizedPattern = "\\${.+}" + +// StringPattern matches when tags are only made up of alphabetic characters +var StringPattern = "^[a-zA-Z]+$" + +// patternMatches determines if a tag matches unsupported patterns +func patternMatches(tag string) error { + unsupported := []string{ + CommitHashPattern, + EmptyPattern, + ParametrizedPattern, + StringPattern, + } + + for _, pattern := range unsupported { + if match, _ := regexp.Match(pattern, []byte(tag)); match { + return fmt.Errorf("'%s' is not supported (%s)", tag, pattern) + } + } + + return nil +} + +// patternCounts determines if tags match unsupported patterns by counting occurences of matches +func patternCounts(tag string) error { + v := regexp.MustCompile(DotPattern) + if m := v.FindAllStringIndex(tag, -1); len(m) > 1 { + return fmt.Errorf("'%s' is not supported (%s)", tag, DotPattern) + } + + return nil +} + +// parseVersionPart converts a semver version part to an integer +func parseVersionPart(part string) (int, error) { + p, err := strconv.Atoi(part) + if err != nil { + return 0, err + } + return p, nil +} + +// Parse converts an image tag into a structured data format. It aims to to +// support the general case of tags which are "semver-like" and/or stable and +// parseable by heuristics. Image tags follow no formal specification and +// therefore this is a best-effort implementation. Examples of tags this +// function can parse are: "5", "5.2", "v4", "v5.3.6", "4-alpine", +// "v3.2.1-debian". +func Parse(tag string) (Tag, error) { + if err := patternMatches(tag); err != nil { + return Tag{}, err + } + + if err := patternCounts(tag); err != nil { + return Tag{}, err + } + + usesV := false + if string(tag[0]) == "v" { + tag = strings.TrimPrefix(tag, "v") + usesV = true + } + + var suffix string + splits := strings.SplitN(tag, "-", 2) + if len(splits) > 1 { + tag = splits[0] + suffix = splits[1] + } + + var major, minor, patch string + var missingMinor, missingPatch bool + parts := strings.Split(tag, ".") + switch { + case len(parts) == 1: + if _, err := parseVersionPart(parts[0]); err != nil { + return Tag{}, fmt.Errorf("couldn't parse major part of '%s': '%s'", tag, parts[0]) + } + major = parts[0] + missingMinor = true + missingPatch = true + case len(parts) == 2: + if _, err := parseVersionPart(parts[0]); err != nil { + return Tag{}, fmt.Errorf("couldn't parse major part of '%s': '%s'", tag, parts[0]) + } + major = parts[0] + + if _, err := parseVersionPart(parts[1]); err != nil { + return Tag{}, fmt.Errorf("couldn't parse minor part of '%s': '%s'", tag, parts[1]) + } + minor = parts[1] + missingPatch = true + case len(parts) == 3: + if _, err := parseVersionPart(parts[0]); err != nil { + return Tag{}, fmt.Errorf("couldn't parse major part of '%s': '%s'", tag, parts[0]) + } + major = parts[0] + + if _, err := parseVersionPart(parts[1]); err != nil { + return Tag{}, fmt.Errorf("couldn't parse minor part of '%s': '%s'", tag, parts[1]) + } + minor = parts[1] + + if _, err := parseVersionPart(parts[2]); err != nil { + return Tag{}, fmt.Errorf("couldn't parse patch part of '%s': '%s'", tag, parts[2]) + } + patch = parts[2] + default: + return Tag{}, fmt.Errorf("couldn't parse semver of '%s", tag) + } + + parsedTag := Tag{ + Major: major, + Minor: minor, + MissingMinor: missingMinor, + Patch: patch, + MissingPatch: missingPatch, + UsesV: usesV, + Suffix: suffix, + } + + return parsedTag, nil +} + +// IsParseable determines if a tag is supported by this library +func IsParseable(tag string) bool { + if _, err := Parse(tag); err != nil { + return false + } + return true +} diff --git a/tagcmp_test.go b/tagcmp_test.go new file mode 100644 index 0000000..9fb5fcd --- /dev/null +++ b/tagcmp_test.go @@ -0,0 +1,702 @@ +package tagcmp_test + +import ( + "sort" + "testing" + + "coopcloud.tech/tagcmp" +) + +var giteaTags = []string{ + "latest", + "1", + "1-linux-amd64", + "1-linux-amd64-rootless", + "1-linux-arm64", + "1-linux-arm64-rootless", + "1-rootless", + "1.0", + "1.0.0", + "1.0.1", + "1.0.2", + "1.1", + "1.1.0", + "1.1.1", + "1.1.2", + "1.1.3", + "1.1.4", + "1.10", + "1.10-linux-amd64", + "1.10-linux-arm64", + "1.10.0", + "1.10.0-linux-amd64", + "1.10.0-linux-arm64", + "1.10.0-rc1", + "1.10.0-rc1-linux-amd64", + "1.10.0-rc1-linux-arm64", + "1.10.0-rc2", + "1.10.0-rc2-linux-amd64", + "1.10.0-rc2-linux-arm64", + "1.10.1", + "1.10.1-linux-amd64", + "1.10.1-linux-arm64", + "1.10.2", + "1.10.2-linux-amd64", + "1.10.2-linux-arm64", + "1.10.3", + "1.10.3-linux-amd64", + "1.10.3-linux-arm64", + "1.10.4", + "1.10.4-linux-amd64", + "1.10.4-linux-arm64", + "1.10.5", + "1.10.5-linux-amd64", + "1.10.5-linux-arm64", + "1.10.6", + "1.10.6-linux-amd64", + "1.10.6-linux-arm64", + "1.11", + "1.11-linux-amd64", + "1.11-linux-arm64", + "1.11.0", + "1.11.0-linux-amd64", + "1.11.0-linux-arm64", + "1.11.0-rc1", + "1.11.0-rc1-linux-amd64", + "1.11.0-rc1-linux-arm64", + "1.11.0-rc2", + "1.11.0-rc2-linux-amd64", + "1.11.0-rc2-linux-arm64", + "1.11.1", + "1.11.1-linux-amd64", + "1.11.1-linux-arm64", + "1.11.2", + "1.11.2-linux-amd64", + "1.11.2-linux-arm64", + "1.11.3", + "1.11.3-linux-amd64", + "1.11.3-linux-arm64", + "1.11.4", + "1.11.4-linux-amd64", + "1.11.4-linux-arm64", + "1.11.5", + "1.11.5-linux-amd64", + "1.11.5-linux-arm64", + "1.11.6", + "1.11.6-linux-amd64", + "1.11.6-linux-arm64", + "1.11.7", + "1.11.7-linux-amd64", + "1.11.7-linux-arm64", + "1.11.8", + "1.11.8-linux-amd64", + "1.11.8-linux-arm64", + "1.12", + "1.12-linux-amd64", + "1.12-linux-arm64", + "1.12.0", + "1.12.0-linux-amd64", + "1.12.0-linux-arm64", + "1.12.0-rc1", + "1.12.0-rc1-linux-amd64", + "1.12.0-rc1-linux-arm64", + "1.12.0-rc2", + "1.12.0-rc2-linux-amd64", + "1.12.0-rc2-linux-arm64", + "1.12.1", + "1.12.1-linux-amd64", + "1.12.1-linux-arm64", + "1.12.2", + "1.12.2-linux-amd64", + "1.12.2-linux-arm64", + "1.12.3", + "1.12.3-linux-amd64", + "1.12.3-linux-arm64", + "1.12.4", + "1.12.4-linux-amd64", + "1.12.4-linux-arm64", + "1.12.5", + "1.12.5-linux-amd64", + "1.12.5-linux-arm64", + "1.12.6", + "1.12.6-linux-amd64", + "1.12.6-linux-arm64", + "1.13", + "1.13-linux-amd64", + "1.13-linux-arm64", + "1.13.0", + "1.13.0-linux-amd64", + "1.13.0-linux-arm64", + "1.13.0-rc1", + "1.13.0-rc1-linux-amd64", + "1.13.0-rc1-linux-arm64", + "1.13.0-rc2", + "1.13.0-rc2-linux-amd64", + "1.13.0-rc2-linux-arm64", + "1.13.1", + "1.13.1-linux-amd64", + "1.13.1-linux-arm64", + "1.13.2", + "1.13.2-linux-amd64", + "1.13.2-linux-arm64", + "1.13.3", + "1.13.3-linux-amd64", + "1.13.3-linux-arm64", + "1.13.4", + "1.13.4-linux-amd64", + "1.13.4-linux-arm64", + "1.13.5", + "1.13.5-linux-amd64", + "1.13.5-linux-arm64", + "1.13.6", + "1.13.6-linux-amd64", + "1.13.6-linux-arm64", + "1.13.7", + "1.13.7-linux-amd64", + "1.13.7-linux-arm64", + "1.14", + "1.14-linux-amd64", + "1.14-linux-amd64-rootless", + "1.14-linux-arm64", + "1.14-linux-arm64-rootless", + "1.14-rootless", + "1.14.0", + "1.14.0-linux-amd64", + "1.14.0-linux-amd64-rootless", + "1.14.0-linux-arm64", + "1.14.0-linux-arm64-rootless", + "1.14.0-rc1", + "1.14.0-rc1-linux-amd64", + "1.14.0-rc1-linux-amd64-rootless", + "1.14.0-rc1-linux-arm64", + "1.14.0-rc1-linux-arm64-rootless", + "1.14.0-rc1-rootless", + "1.14.0-rc2", + "1.14.0-rc2-linux-amd64", + "1.14.0-rc2-linux-amd64-rootless", + "1.14.0-rc2-linux-arm64", + "1.14.0-rc2-linux-arm64-rootless", + "1.14.0-rc2-rootless", + "1.14.0-rootless", + "1.14.1", + "1.14.1-linux-amd64", + "1.14.1-linux-amd64-rootless", + "1.14.1-linux-arm64", + "1.14.1-linux-arm64-rootless", + "1.14.1-rootless", + "1.14.2", + "1.14.2-linux-amd64", + "1.14.2-linux-amd64-rootless", + "1.14.2-linux-arm64", + "1.14.2-linux-arm64-rootless", + "1.14.2-rootless", + "1.14.3", + "1.14.3-linux-amd64", + "1.14.3-linux-amd64-rootless", + "1.14.3-linux-arm64", + "1.14.3-linux-arm64-rootless", + "1.14.3-rootless", + "1.14.4", + "1.14.4-linux-amd64", + "1.14.4-linux-amd64-rootless", + "1.14.4-linux-arm64", + "1.14.4-linux-arm64-rootless", + "1.14.4-rootless", + "1.14.5", + "1.14.5-linux-amd64", + "1.14.5-linux-amd64-rootless", + "1.14.5-linux-arm64", + "1.14.5-linux-arm64-rootless", + "1.14.5-rootless", + "1.14.6", + "1.14.6-linux-amd64", + "1.14.6-linux-amd64-rootless", + "1.14.6-linux-arm64", + "1.14.6-linux-arm64-rootless", + "1.14.6-rootless", + "1.15.0-rc1", + "1.15.0-rc1-linux-amd64", + "1.15.0-rc1-linux-amd64-rootless", + "1.15.0-rc1-linux-arm64", + "1.15.0-rc1-linux-arm64-rootless", + "1.15.0-rc1-rootless", + "1.15.0-rc2", + "1.15.0-rc2-linux-amd64", + "1.15.0-rc2-linux-amd64-rootless", + "1.15.0-rc2-linux-arm64", + "1.15.0-rc2-linux-arm64-rootless", + "1.15.0-rc2-rootless", + "1.15.0-rc3", + "1.15.0-rc3-linux-amd64", + "1.15.0-rc3-linux-amd64-rootless", + "1.15.0-rc3-linux-arm64", + "1.15.0-rc3-linux-arm64-rootless", + "1.15.0-rc3-rootless", + "1.2", + "1.2.0", + "1.2.0-rc1", + "1.2.0-rc2", + "1.2.0-rc3", + "1.2.0-rc4", + "1.2.1", + "1.2.2", + "1.2.3", + "1.3", + "1.3.0", + "1.3.0-rc1", + "1.3.0-rc2", + "1.3.1", + "1.3.2", + "1.3.3", + "1.4", + "1.4.0", + "1.4.0-rc1", + "1.4.0-rc2", + "1.4.0-rc3", + "1.4.1", + "1.4.2", + "1.4.3", + "1.5", + "1.5.0", + "1.5.0-rc1", + "1.5.0-rc2", + "1.5.1", + "1.5.2", + "1.5.3", + "1.6", + "1.6.0", + "1.6.0-rc1", + "1.6.0-rc2", + "1.6.1", + "1.6.2", + "1.6.3", + "1.6.4", + "1.7", + "1.7.0", + "1.7.0-rc1", + "1.7.0-rc2", + "1.7.0-rc3", + "1.7.1", + "1.7.2", + "1.7.3", + "1.7.4", + "1.7.5", + "1.7.6", + "1.8", + "1.8.0", + "1.8.0-rc1", + "1.8.0-rc2", + "1.8.0-rc3", + "1.8.1", + "1.8.2", + "1.8.3", + "1.9", + "1.9-linux-amd64", + "1.9-linux-arm64", + "1.9.0", + "1.9.1", + "1.9.2", + "1.9.2-linux-amd64", + "1.9.2-linux-arm64", + "1.9.3", + "1.9.3-linux-amd64", + "1.9.3-linux-arm64", + "1.9.4", + "1.9.4-linux-amd64", + "1.9.4-linux-arm64", + "1.9.5", + "1.9.5-linux-amd64", + "1.9.5-linux-arm64", + "1.9.6", + "1.9.6-linux-amd64", + "1.9.6-linux-arm64", + "dev", + "dev-linux-amd64", + "dev-linux-amd64-rootless", + "dev-linux-arm64", + "dev-linux-arm64-rootless", + "dev-rootless", + "latest-rootless", + "linux-amd64", + "linux-amd64-rootless", + "linux-arm64", + "linux-arm64-rootless", +} + +var supported = []string{ + // semver + "5", + "2.6", + "4.3.5", + + // semver with 'v' + "v1", + "v2.3", + "v1.0.2", + + // semver with suffix + "6-alpine", + "6.2-alpine", + "6.2.1-alpine", + + // semver with sufix and 'v' + "v6-alpine", + "v6.2-alpine", + "v6.2.1-alpine", + "v6.2.1-alpine", + + // semver with multiple suffix values + "v6.2.1-alpine-foo", +} + +var unsupported = []string{ + // empty + "", + + // patametrized + "${MAILU_VERSION:-master}", + "${PHP_VERSION}-fpm-alpine3.13", + + // commit hash like + "0a1b2c3d4e5f6a7b8c9d0a1b2c3d4e5f6a7b8c9d", + + // numeric + "20191109", + "e02267d", + + // not semver + "3.0.6.0", + "r1295", + "version-r1070", + + // prerelease + "3.7.0b1", + "3.8.0b1-alpine", + + // multiple versions + "5.36-backdrop-php7.4", + "v1.0.5_3.4.0", + "v1.0.5_3.4.0_openid-sso", + + // tz based + "RELEASE.2021-04-22T15-44-28Z", + + // only text + "alpine", + "latest", + "master", + + // multiple - delimters + "apache-debian-1.8-prod", + "version-znc-1.8.2", +} + +func TestParseUnsupported(t *testing.T) { + for _, tag := range unsupported { + if _, err := tagcmp.Parse(tag); err == nil { + t.Errorf("'%s' was parsed but it is unsupported", tag) + } + } +} + +func TestParseSupported(t *testing.T) { + for _, tag := range supported { + if _, err := tagcmp.Parse(tag); err != nil { + t.Errorf("'%s' was not parsed but it is supported", tag) + } + } +} + +func TestParseRetainZeroes(t *testing.T) { + tag, err := tagcmp.Parse("18.04") + if err != nil { + t.Errorf("'18.04' should have parsed but didn't: %s", err) + } + if tag.Minor != "04" { + t.Errorf("parsing '18.04' didn't retain zeroes: %s", tag.Minor) + } +} + +func TestDetectsV(t *testing.T) { + tag1, err := tagcmp.Parse("v2") + if !tag1.UsesV { + t.Error("'v2' uses 'v' but wasn't detected as such") + } + if err != nil { + t.Errorf("'v2' should have parsed but didn't: %s", err) + } + + tag2, err := tagcmp.Parse("2") + if tag2.UsesV { + t.Error("'v2' doesn't use 'v' but wasn't detected as such") + } + if err != nil { + t.Errorf("'2' should have parsed but didn't: %s", err) + } +} + +func TestMissingParts(t *testing.T) { + tag1, err := tagcmp.Parse("v2") + if !tag1.MissingMinor || !tag1.MissingPatch { + t.Error("'v2' parsed without realising there are missing parts") + } + if err != nil { + t.Errorf("'v2' should have parsed but didn't: %s", err) + } + + tag2, err := tagcmp.Parse("v2.3") + if !tag2.MissingPatch { + t.Error("'v2.3' parsed without realising there are missing parts") + } + if err != nil { + t.Errorf("'v2.3' should have parsed but didn't: %s", err) + } +} + +func TestMajorPart(t *testing.T) { + majors := map[string]string{ + "1.2.3": "1", + "18.04": "18", + "10.1": "10", + "3": "3", + } + + for m := range majors { + if p, _ := tagcmp.Parse(m); p.Major != majors[m] { + t.Errorf("'%s' didn't parse major part correctly: %s", m, p.Major) + } + } +} + +func TestMinorPart(t *testing.T) { + minors := map[string]string{ + "1.2.3": "2", + "18.04": "04", + "10.1": "1", + "3": "", + } + + for m := range minors { + if p, _ := tagcmp.Parse(m); p.Minor != minors[m] { + t.Errorf("'%s' didn't parse minor part correctly: %s", m, p.Minor) + } + } +} + +func TestPatchPart(t *testing.T) { + patches := map[string]string{ + "1.2.3": "3", + "18.04": "", + "10.1": "", + "3": "", + } + + for m := range patches { + if p, _ := tagcmp.Parse(m); p.Patch != patches[m] { + t.Errorf("'%s' didn't parse patch part correctly: %s", m, p.Patch) + } + } +} + +func TestIsGreaterThan(t *testing.T) { + pairs := []struct { + t1 string + t2 string + expected bool + }{ + {"1.2.3", "1.2", false}, + {"18.04", "18.1", true}, + {"10.1", "10.1.2", true}, + {"3", "2", true}, + {"1.2.3", "1.2.3", false}, + } + for _, p := range pairs { + p1, err := tagcmp.Parse(p.t1) + if err != nil { + t.Errorf("'%s' should have parsed", p.t1) + } + p2, err := tagcmp.Parse(p.t2) + if err != nil { + t.Errorf("'%s' should have parsed", p.t2) + } + res := p1.IsGreaterThan(p2) + if res != p.expected { + t.Errorf("(%s).IsGreatherThan(%s) gave %t but expected %t", p.t1, p.t2, res, p.expected) + } + } +} +func TestIsLessThan(t *testing.T) { + pairs := []struct { + t1 string + t2 string + expected bool + }{ + {"1.2.3", "2.0", true}, + {"18.04", "18.1", false}, + {"10.1", "10.0.4", false}, + {"3", "4.0", false}, + {"1.2", "1.3.4", false}, + {"0.0.1", "0.2.1", true}, + {"0.0.1", "2.0.0", true}, + {"1.3.9", "1.4.8", true}, + {"v0.2.1", "v16.0.1", true}, + } + for _, p := range pairs { + p1, err := tagcmp.Parse(p.t1) + if err != nil { + t.Errorf("'%s' should have parsed", p.t1) + } + p2, err := tagcmp.Parse(p.t2) + if err != nil { + t.Errorf("'%s' should have parsed", p.t2) + } + res := p1.IsLessThan(p2) + if res != p.expected { + t.Errorf("(%s).IsLessThan(%s) gave %t but expected %t", p.t1, p.t2, res, p.expected) + } + } +} + +func TestEquals(t *testing.T) { + pairs := []struct { + t1 string + t2 string + expected bool + }{ + {"1.2.3", "1.2.3", true}, + {"18.04", "18.4", true}, + {"10.0", "10.0.4", false}, + {"3", "4.0", false}, + {"1.2", "1.2.3", false}, + } + for _, p := range pairs { + p1, err := tagcmp.Parse(p.t1) + if err != nil { + t.Errorf("'%s' should have parsed", p.t1) + } + p2, err := tagcmp.Parse(p.t2) + if err != nil { + t.Errorf("'%s' should have parsed", p.t2) + } + res := p1.Equals(p2) + if res != p.expected { + t.Errorf("(%s).Equals(%s) gave %t but expected %t", p.t1, p.t2, res, p.expected) + } + } +} + +func TestIsCompatible(t *testing.T) { + pairs := []struct { + t1 string + t2 string + expected bool + }{ + {"v1", "v2", true}, + {"v1", "v2.4", false}, + {"1", "2", true}, + {"2.3.4", "v2.3.4", false}, + {"1.2.3", "1.2.6", true}, + {"1.2.3", "1.2.0", true}, + {"5-alpine", "6-alpine", true}, + {"5-alpine", "6.5-alpine", false}, + } + for _, p := range pairs { + p1, err := tagcmp.Parse(p.t1) + if err != nil { + t.Errorf("'%s' should have parsed", p.t1) + } + p2, err := tagcmp.Parse(p.t2) + if err != nil { + t.Errorf("'%s' should have parsed", p.t2) + } + res := p1.IsCompatible(p2) + if res != p.expected { + t.Errorf("(%s).IsCompatible(%s) gave %t but expected %t", p.t1, p.t2, res, p.expected) + } + } +} + +func TestSort(t *testing.T) { + rawTags := []string{ + "v1.4.8", + "v1.3.9", + "v2.0.0", + "v16.0.1", + "v0.0.1", + "v0.2.1", + "v5.9.1", + } + + var tags []tagcmp.Tag + for _, rawTag := range rawTags { + tag, err := tagcmp.Parse(rawTag) + if err != nil { + t.Errorf("'%s' should have parsed but didn't: %s", tag, err) + } + tags = append(tags, tag) + } + + sort.Sort(tagcmp.ByTag(tags)) + + expected := []string{ + "v0.0.1", + "v0.2.1", + "v1.3.9", + "v1.4.8", + "v2.0.0", + "v5.9.1", + "v16.0.1", + } + + for idx, tag := range tags { + if tag.String() != expected[idx] { + t.Errorf("'%s' sorted out of order, saw '%s', expected '%s'", tag, tags, expected) + } + } +} + +func TestString(t *testing.T) { + for _, tag := range supported { + p, err := tagcmp.Parse(tag) + if err != nil { + t.Errorf("'%s' was not parsed but it is supported", tag) + } + if p.String() != tag { + t.Errorf("String() of '%s' didn't render properly: %s", tag, p.String()) + } + } +} + +func TestGiteaFilterCompatible(t *testing.T) { + expected := []string{ + "1.14.0-rootless", + "1.14.1-rootless", + "1.14.2-rootless", + "1.14.3-rootless", + "1.14.4-rootless", + "1.14.5-rootless", + "1.14.6-rootless", + } + + tag, err := tagcmp.Parse("1.14.0-rootless") + if err != nil { + t.Errorf("'1.14.0-rootless' should have parsed but didn't: %s", err) + } + + var filtered []tagcmp.Tag + for _, giteaTag := range giteaTags { + // not interested in unsupported tags right now + p, _ := tagcmp.Parse(giteaTag) + if tag.IsCompatible(p) { + filtered = append(filtered, p) + } + } + + sort.Sort(tagcmp.ByTag(filtered)) + + for idx, tag := range filtered { + if tag.String() != expected[idx] { + t.Errorf("'%s' out of order or incompatible, saw '%s', expected '%s'", tag, filtered, expected) + } + } +}