From 4bf051545b5e18be94b3a382b3910fc0668f2063 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Sat, 16 Dec 2023 09:24:07 -0800 Subject: [PATCH] feat: Implement MinVersion() for Constraints. MinVersion() returns return the lowest version that can possibly match the given constraint. For examples: * `MinVersion("1.0.1") = "1.0.1"` * `MinVersion("=1.0.1") = "1.0.1"` * `MinVersion("~1.0.1") = "1.0.1"` * `MinVersion(">1.0.1") = "1.0.2"` * `MinVersion(">=1.0.1") = "1.0.1"` * `MinVersion("<2.0.0 >1.0.1") = "1.0.2"` etc. The implementation is based on node-semver: https://github.com/npm/node-semver/blob/main/ranges/min-version.js One minor difference is how prerelease versions are treated given `>`: * In node-semver, `MinVersion(">1.0.0-beta') = "1.0.0-beta.0"` * In the proposed impl, `MinVersion(">1.0.0-beta') = "1.0.0"` This behavior made more sense to me given how Version("1.0.0-beta").IncPatch() behaves. --- constraints.go | 42 +++++++++++++++++++++ constraints_test.go | 92 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/constraints.go b/constraints.go index 8461c7e..a99e0eb 100644 --- a/constraints.go +++ b/constraints.go @@ -113,6 +113,48 @@ func (cs Constraints) Validate(v *Version) (bool, []error) { return false, e } +// MinVersion return the lowest version that can possibly match the given constraints. +func (cs Constraints) MinVersion() (*Version, error) { + minVer, _ := NewVersion("0.0.0") + if cs.Check(minVer) { + return minVer, nil + } + + minVer, _ = NewVersion("0.0.0-0") + if cs.Check(minVer) { + return minVer, nil + } + + minVer = nil + for _, constraintSet := range cs.constraints { + var minCandidate *Version + for _, c := range constraintSet { + switch c.origfunc { + case "", "=": + minCandidate = c.con + case ">": + newV := c.con.IncPatch() + minCandidate = &newV + case ">=", "=>", "^", "~", "~>": + if minCandidate == nil || c.con.GreaterThan(minCandidate) { + minCandidate = c.con + } + case "<", "<=", "!=", "=<": + // (ignored for minimum version calculation) + default: + return nil, fmt.Errorf("unexpected operator: %s", c.origfunc) + } + } + if minCandidate != nil && (minVer == nil || minCandidate.LessThan(minVer)) { + minVer = minCandidate + } + } + if minVer == nil || !cs.Check(minVer) { + return nil, fmt.Errorf("no valid version found that satisfies all constraints") + } + return minVer, nil +} + func (cs Constraints) String() string { buf := make([]string, len(cs.constraints)) var tmp bytes.Buffer diff --git a/constraints_test.go b/constraints_test.go index c720797..bd6d86f 100644 --- a/constraints_test.go +++ b/constraints_test.go @@ -809,3 +809,95 @@ func FuzzNewConstraint(f *testing.F) { _, _ = NewConstraint(a) }) } + +func TestConstraintMinVersion(t *testing.T) { + tests := []struct { + constraint string + minVersion string + }{ + {"*", "0.0.0"}, + {"* || >=2", "0.0.0"}, + {">=2 || *", "0.0.0"}, + {">2 || *", "0.0.0"}, + {"1.0.0", "1.0.0"}, + {"1.0", "1.0.0"}, + {"1.0.x", "1.0.0"}, + {"1.0.*", "1.0.0"}, + {"1", "1.0.0"}, + {"1.x.x", "1.0.0"}, + {"1.x.x", "1.0.0"}, + {"1.*.x", "1.0.0"}, + {"1.x.*", "1.0.0"}, + {"1.x", "1.0.0"}, + {"1.*", "1.0.0"}, + {"=1.0.0", "1.0.0"}, + {"~1.1.1", "1.1.1"}, + {"~1.1.1-beta", "1.1.1-beta"}, + {"~1.1.1 || >=2", "1.1.1"}, + {"^1.1.1", "1.1.1"}, + {"~>1.1.1", "1.1.1"}, + {"^1.1.1-beta", "1.1.1-beta"}, + {"^1.1.1 || >=2", "1.1.1"}, + {"^2.16.2 ^2.16", "2.16.2"}, + {"1.1.1 - 1.8.0", "1.1.1"}, + {"1.1 - 1.8.0", "1.1.0"}, + {"<2", "0.0.0"}, + {"<0.0.0-beta", "0.0.0-0"}, + {"<0.0.1-beta", "0.0.0"}, + {"<2 || >4", "0.0.0"}, + {">4 || <2", "0.0.0"}, + {"<=2 || >=4", "0.0.0"}, + {">=4 || <=2", "0.0.0"}, + {"=>4 || <=2", "0.0.0"}, + {"=>4 || =<2", "0.0.0"}, + {">=1.1.1 <2 || >=2.2.2 <2", "1.1.1"}, + {">=2.2.2 <2 || >=1.1.1 <2", "1.1.1"}, + {">1.0.0", "1.0.1"}, + {">2 || >1.0.0", "1.0.1"}, + {">1.2.3-0", "1.2.3"}, + } + + for _, tc := range tests { + t.Run(tc.constraint, func(t *testing.T) { + c, err := NewConstraint(tc.constraint) + if err != nil { + t.Fatalf("err: %s", err) + } + + want, err := NewVersion(tc.minVersion) + if err != nil { + t.Fatalf("err: %s", err) + } + + got, err := c.MinVersion() + if err != nil { + t.Fatalf("err: %s", err) + } + if !want.Equal(got) { + t.Errorf("Unexpected min version for constraint %v: want %v, got %v", tc.constraint, tc.minVersion, got.String()) + } + }) + } +} + +func TestConstraintMinVersionError(t *testing.T) { + tests := []struct { + constraint string + }{ + {">=2 <1"}, + } + + for _, tc := range tests { + t.Run(tc.constraint, func(t *testing.T) { + c, err := NewConstraint(tc.constraint) + if err != nil { + t.Fatalf("err: %s", err) + } + + got, err := c.MinVersion() + if err == nil { + t.Fatalf("MinVersion(%s) unexpectedly returned a valid min version of %s", tc.constraint, got.String()) + } + }) + } +}