diff --git a/.golangci.yaml b/.golangci.yaml index 22c9ef1..640c493 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -58,7 +58,6 @@ linters: - ginkgolinter - gocheckcompilerdirectives - gochecksumtype - - goconst - gocritic - gocyclo - godot diff --git a/README.md b/README.md index 9a58d5c..55b6b26 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,18 @@ # Semver +

+ + + + +

+ +--- + +Semantic Versioning v2 version and range parser library for Go. + +--- + The `semver` package implements logic to work with [Sementic Versioning 2.0.0](http://semver.org/) in Go. It provides: - Parser for semantic versions diff --git a/constraintparser.go b/constraintparser.go index 476e9d3..4eb6d6a 100644 --- a/constraintparser.go +++ b/constraintparser.go @@ -11,6 +11,7 @@ package semver import ( "fmt" + "sort" "pkg.package-operator.run/semver/internal" "pkg.package-operator.run/semver/internal/ranges" @@ -244,6 +245,9 @@ func (p *parserState) close(pos internal.Position) error { p.or = append(p.or, p.and) } + // Compact OR'd ranges if possible + p.or = compactLogicalOR(p.or) + switch len(p.or) { case 0: case 1: @@ -285,7 +289,7 @@ parse: return nil, fmt.Errorf("%s: AND empty range constraint", pos) } if err := p.closeRange(pos); err != nil { - return nil, fmt.Errorf("%s: AND %w", pos, err) + return nil, err } case ranges.OR: @@ -293,7 +297,7 @@ parse: return nil, fmt.Errorf("%s: OR empty range constraint", pos) } if err := p.closeRange(pos); err != nil { - return nil, fmt.Errorf("%s: OR %w", pos, err) + return nil, err } // Shift current AND constraint into OR if len(p.and) == 1 { @@ -352,12 +356,114 @@ parse: return p.c, nil } +// compactLogicalOR combines adjacent or overlapping ranges in OR operations. +// For example, "1-2 || 2-3" → "1-3" (union of ranges). +func compactLogicalOR(constraints or) or { + if len(constraints) < 2 { + return constraints + } + + // Extract only Range constraints + var ranges []Range + var other []Constraint + for _, c := range constraints { + if r, ok := c.(*Range); ok { + ranges = append(ranges, *r) + } else { + other = append(other, c) + } + } + + if len(ranges) < 2 { + return constraints + } + + // Sort ranges by min version + sort.Sort(AscendingMin(ranges)) + + // Merge overlapping or adjacent ranges + merged := []Range{ranges[0]} + for i := 1; i < len(ranges); i++ { + last := &merged[len(merged)-1] + current := ranges[i] + + // Check if current range overlaps or is adjacent to the last merged range + // Adjacent means last.Max >= current.Min (allowing for touching at boundary) + if !last.Max.LessThan(current.Min) { + // Merge: extend last range's max if current goes further + if current.Max.GreaterThan(last.Max) { + last.Max = current.Max + } + } else { + // No overlap, add as new range + merged = append(merged, current) + } + } + + // Rebuild constraint list + result := make(or, 0, len(merged)+len(other)) + for i := range merged { + result = append(result, &merged[i]) + } + result = append(result, other...) + + return result +} + +// simplifyIntersectingRanges checks if multiple ranges in AND only overlap at a single version +// and simplifies them to a single range with that version. +// For example, "1-2 && 2-3" → "2-2" (which represents =2.0.0). +func simplifyIntersectingRanges(ranges []Range) []Range { + if len(ranges) < 2 { + return ranges + } + + // Calculate the intersection of all ranges + // Intersection min = max of all mins + // Intersection max = min of all maxs + intersectionMin := ranges[0].Min + intersectionMax := ranges[0].Max + + for i := 1; i < len(ranges); i++ { + // Update min to the highest min + if ranges[i].Min.GreaterThan(intersectionMin) { + intersectionMin = ranges[i].Min + } + // Update max to the lowest max + if ranges[i].Max.LessThan(intersectionMax) { + intersectionMax = ranges[i].Max + } + } + + // If intersection is a single version, replace all ranges with that single version + if intersectionMin.Same(intersectionMax) { + return []Range{{Min: intersectionMin, Max: intersectionMax}} + } + + // Otherwise, return ranges as-is + return ranges +} + +// compactAndValidateLogicalAND validates ranges make sense and don't overlap. +// It combines separate lower and upper bounds (e.g., ">=X && <=Y" → "X - Y") +// but does NOT combine full ranges, as AND represents intersection, not union. func compactAndValidateLogicalAND(pos internal.Position, and and) (and, error) { + // Validate even single constraints to catch impossible ranges + if len(and) < 1 { + return and, nil + } + + // Validate that constraints are not over-constrained before compaction + if err := validateNotOverConstrained(pos, and); err != nil { + return nil, err + } + if len(and) < 2 { return and, nil } - var fullyDefinedConstraints []Constraint + var newRanges []Range + var otherConstraints []Constraint // find min version and max version var ( @@ -370,7 +476,7 @@ func compactAndValidateLogicalAND(pos internal.Position, and and) (and, error) { case ok && isMinUnconstraint(*r): if maxVersion != nil { return nil, fmt.Errorf( - "%s: <=%s overlaps with <=%s in logical AND", + "%s: <=%s is redundant with <=%s in logical AND", pos, r.Max.String(), maxVersion, ) } @@ -379,38 +485,160 @@ func compactAndValidateLogicalAND(pos internal.Position, and and) (and, error) { case ok && isMaxUnconstraint(*r): if minVersion != nil { return nil, fmt.Errorf( - "%s: >=%s overlaps with >=%s in logical AND", + "%s: >=%s is redundant with >=%s in logical AND", pos, r.Min.String(), minVersion, ) } minVersion = &r.Min case ok: + // Don't combine full ranges in AND - they represent intersections, not unions. + // Only combine when we have separate lower/upper bounds (e.g., >=X && <=Y). if minVersion != nil && maxVersion != nil { - existingRange := Range{Min: *minVersion, Max: *maxVersion} - if !existingRange.Contains(r) { - return nil, fmt.Errorf( - "%s: non overlapping ranges %q and %q in logical AND", - pos, r.String(), existingRange.String(), - ) - } - fullyDefinedConstraints = append(fullyDefinedConstraints, c) + // We already have a combined range, so this is a separate constraint + newRanges = append(newRanges, *r) } else { minVersion = &r.Min maxVersion = &r.Max } default: - fullyDefinedConstraints = append(fullyDefinedConstraints, c) + otherConstraints = append(otherConstraints, c) } } if minVersion != nil && maxVersion != nil { - fullyDefinedConstraints = append(fullyDefinedConstraints, &Range{ + newRanges = append(newRanges, Range{ Min: *minVersion, Max: *maxVersion, }) } - return fullyDefinedConstraints, nil + + sort.Sort(AscendingMin(newRanges)) + + // Simplify ranges that only overlap at a single version + simplifiedRanges := simplifyIntersectingRanges(newRanges) + + out := make([]Constraint, 0, len(simplifiedRanges)+len(otherConstraints)) + for _, r := range simplifiedRanges { + out = append(out, &r) + } + out = append(out, otherConstraints...) + + if len(and) != len(out) { + // Recompact after constraint changes + return compactAndValidateLogicalAND(pos, out) + } + + // Validate that the constraint is not over-constrained (impossible to satisfy) + if err := validateNotOverConstrained(pos, out); err != nil { + return nil, err + } + + return out, nil +} + +// validateNotOverConstrained checks if constraints in AND are impossible to satisfy. +func validateNotOverConstrained(pos internal.Position, constraints and) error { + var ranges []Range + var notConstraints []not + + // Collect ranges and NOT constraints + for _, c := range constraints { + switch v := c.(type) { + case *Range: + // Check for impossible individual ranges (min > max) + if v.Min.GreaterThan(v.Max) { + return fmt.Errorf( + "%s: over-constrained, no version can satisfy %s (min > max)", + pos, v.String(), + ) + } + ranges = append(ranges, *v) + case not: + notConstraints = append(notConstraints, v) + case and: + // Nested AND - validate recursively + if err := validateNotOverConstrained(pos, v); err != nil { + return err + } + case or: + // OR in AND - each branch should be valid on its own + // We don't validate OR branches as over-constrained since + // at least one branch might be satisfiable + } + } + + // Check for non-overlapping ranges in AND + if len(ranges) > 1 { + // Check if all ranges have a common overlap + for i := range len(ranges) - 1 { + for j := i + 1; j < len(ranges); j++ { + if !rangesOverlap(ranges[i], ranges[j]) { + return fmt.Errorf( + "%s: over-constrained, ranges do not overlap: %s AND %s", + pos, ranges[i].String(), ranges[j].String(), + ) + } + } + } + } + + // Check if we have both >= and <= constraints that don't overlap + // This catches cases like ">=2.0.0 && <1.0.0" + var minBound *Version // from >= constraint + var maxBound *Version // from <= or < constraint + + for _, r := range ranges { + // Check if this is a lower bound (>= or >) + if isMaxUnconstraint(r) { + if minBound == nil || r.Min.GreaterThan(*minBound) { + minBound = &r.Min + } + } + // Check if this is an upper bound (<= or <) + if isMinUnconstraint(r) { + if maxBound == nil || r.Max.LessThan(*maxBound) { + maxBound = &r.Max + } + } + } + + // If we have both bounds, check if they're compatible + if minBound != nil && maxBound != nil { + if minBound.GreaterThan(*maxBound) { + return fmt.Errorf( + "%s: over-constrained, lower bound %s is greater than upper bound %s", + pos, minBound.String(), maxBound.String(), + ) + } + } + + // Check if NOT constraints exclude all versions in ranges + if len(ranges) > 0 && len(notConstraints) > 0 { + // For single-version ranges with NOT, check if they exclude that exact version + for _, r := range ranges { + if r.Min.Same(r.Max) { + // This is an equality constraint (e.g., =1.0.0) + for _, n := range notConstraints { + if n.Min.Same(r.Min) && n.Max.Same(r.Max) { + return fmt.Errorf( + "%s: over-constrained, %s AND %s excludes all versions", + pos, r.String(), n.String(), + ) + } + } + } + } + } + + return nil +} + +// rangesOverlap checks if two ranges have any overlapping versions. +func rangesOverlap(a, b Range) bool { + // Ranges overlap if: + // - a.Min <= b.Max AND b.Min <= a.Max + return !a.Min.GreaterThan(b.Max) && !b.Min.GreaterThan(a.Max) } func isMinUnconstraint(r Range) bool { diff --git a/constraintparser_test.go b/constraintparser_test.go index e0bb24a..a7b7b4b 100644 --- a/constraintparser_test.go +++ b/constraintparser_test.go @@ -366,7 +366,7 @@ func TestConstraintParser_success(t *testing.T) { }, }, { - name: "caret unstable minor", + name: "caret unstable major", input: `^0`, expected: &Range{ Min: Version{Major: 0, Minor: 0, Patch: 0}, @@ -374,27 +374,81 @@ func TestConstraintParser_success(t *testing.T) { }, }, { - name: "caret unstable minor", + name: "greater-equal less compaction", input: `>=3.4,<3.5`, expected: &Range{ Min: Version{Major: 3, Minor: 4, Patch: 0}, Max: Version{Major: 3, Minor: 4, Patch: maxUint64}, }, }, + { + name: "direct adjacent or ranges", + input: `1 - 2 || 2 - 3`, + expected: &Range{ + Min: Version{Major: 1, Minor: 0, Patch: 0}, + Max: Version{Major: 3, Minor: 0, Patch: 0}, + }, + }, + { + name: "direct adjacent and ranges", + input: `1 - 2 && 2 - 3`, + expected: &Range{ + Min: Version{Major: 2, Minor: 0, Patch: 0}, + Max: Version{Major: 2, Minor: 0, Patch: 0}, + }, + }, + { + name: "reverse adjacent or ranges", + input: `2 - 3 || 1 - 2`, + expected: &Range{ + Min: Version{Major: 1, Minor: 0, Patch: 0}, + Max: Version{Major: 3, Minor: 0, Patch: 0}, + }, + }, { name: "simple range and exclude", input: `4.12.x - 4.14.x && != 4.13.5`, expected: and{ + &Range{ + Min: Version{Major: 4, Minor: 12, Patch: 0}, + Max: Version{Major: 4, Minor: 14, Patch: maxUint64}, + }, not{ Range{ Min: Version{Major: 4, Minor: 13, Patch: 5}, Max: Version{Major: 4, Minor: 13, Patch: 5}, }, }, + }, + }, + { + name: "three ranges with single overlap", + input: `1 - 5 && 3 - 7 && 5 - 9`, + expected: &Range{ + Min: Version{Major: 5, Minor: 0, Patch: 0}, + Max: Version{Major: 5, Minor: 0, Patch: 0}, + }, + }, + { + name: "ranges with broader overlap", + input: `1 - 5 && 3 - 7`, + expected: and{ &Range{ - Min: Version{Major: 4, Minor: 12, Patch: 0}, - Max: Version{Major: 4, Minor: 14, Patch: maxUint64}, + Min: Version{Major: 1, Minor: 0, Patch: 0}, + Max: Version{Major: 5, Minor: 0, Patch: 0}, }, + &Range{ + Min: Version{Major: 3, Minor: 0, Patch: 0}, + Max: Version{Major: 7, Minor: 0, Patch: 0}, + }, + }, + }, + { + name: "exact version match via bounds", + input: `>=2.5.0 && <=2.5.0`, + expected: &Range{ + Min: Version{Major: 2, Minor: 5, Patch: 0}, + Max: Version{Major: 2, Minor: 5, Patch: 0}, }, }, } @@ -470,24 +524,20 @@ func TestConstraintParser_error(t *testing.T) { expectedErr: "col 4: unexpected character U+006E 'n'", }, { - input: `>=1.3 && <2 && <1`, - expectedErr: "col 17: <=0.x.x overlaps with <=1.x.x in logical AND", - }, - { - input: `>=1.3 && <2 && >1.1`, - expectedErr: "col 19: >=1.2.0 overlaps with >=1.3.0 in logical AND", + input: `>=1.3 && <2 && <1`, // Over-constrained: >=1.3 and <1 don't overlap + expectedErr: "col 17: over-constrained, ranges do not overlap: 1.3.0 - 1.x.x AND 0.0.0 - 0.x.x", }, { - input: `2 - 3 && 1 - 2`, - expectedErr: `col 14: non overlapping ranges "1.0.0 - 2.0.0" and "2.0.0 - 3.0.0" in logical AND`, + input: `>=1.3 && <2 && >1.1`, // >=1.3 is redundant, because >1.1 includes >=1.3 + expectedErr: "col 19: >=1.2.0 is redundant with >=1.3.0 in logical AND", }, { input: `2 - 3 1 - 2`, expectedErr: `col 9: double hyphen in range constraint`, }, { - input: `2 - 3, 1 - 2`, - expectedErr: `col 12: non overlapping ranges "1.0.0 - 2.0.0" and "2.0.0 - 3.0.0" in logical AND`, + input: `2 - 3 && 5 - 6 && 1 - 2`, // Non-overlapping ranges after compaction + expectedErr: `col 16: over-constrained, ranges do not overlap: 2.0.0 - 3.0.0 AND 5.0.0 - 6.0.0`, }, } for _, test := range tests { diff --git a/constraints.go b/constraints.go index 1f49b3e..71c9543 100644 --- a/constraints.go +++ b/constraints.go @@ -38,15 +38,57 @@ func (and and) Check(v Version) bool { return true } -func (and and) Contains(or Constraint) bool { - for _, r := range and { - if !r.Contains(or) { +//nolint:revive // Receiver name differs from type to avoid shadowing +func (a and) Contains(other Constraint) bool { + // Unwrap originalInputConstraint if needed + otherUnwrapped := other + if oic, ok := other.(*originalInputConstraint); ok { + otherUnwrapped = oic.Constraint + } + + // Special case: when checking if (A && B) contains (C && D) + otherAnd, ok := otherUnwrapped.(and) + if !ok { + // For non-AND constraints, check if all of our constraints contain it + for _, r := range a { + if !r.Contains(other) { + return false + } + } + return true + } + + // For each constraint in other AND, check if it's covered by our AND + return a.containsAnd(otherAnd) +} + +//nolint:revive // Receiver name differs from type to avoid shadowing +func (a and) containsAnd(other and) bool { + for _, otherConstraint := range other { + if !a.coversConstraint(otherConstraint) { return false } } return true } +//nolint:revive // Receiver name differs from type to avoid shadowing +func (a and) coversConstraint(c Constraint) bool { + // Check if at least one constraint in our AND contains it + for _, ourConstraint := range a { + if ourConstraint.Contains(c) { + return true + } + } + // Check if it's the same constraint (e.g., both have !=2) + for _, ourConstraint := range a { + if ourConstraint.String() == c.String() { + return true + } + } + return false +} + func (and and) String() string { parts := make([]string, len(and)) for i := range and { @@ -69,8 +111,37 @@ func (or or) Check(v Version) bool { return false } -func (or or) Contains(other Constraint) bool { - for _, r := range or { +//nolint:revive // Receiver name differs from type to avoid shadowing +func (o or) Contains(other Constraint) bool { + // Unwrap originalInputConstraint if needed + otherUnwrapped := other + if oic, ok := other.(*originalInputConstraint); ok { + otherUnwrapped = oic.Constraint + } + + // Special case: when checking if (A || B) contains (C || D), + // we need to verify that each branch of other is contained by at least one branch of our OR. + // This is because (A || B) represents the union of A and B, and it contains (C || D) + // if and only if every version in (C || D) is in (A || B). + if otherOr, ok := otherUnwrapped.(or); ok { + // For each branch in other OR, check if at least one branch in our OR contains it + for _, otherBranch := range otherOr { + contained := false + for _, branch := range o { + if branch.Contains(otherBranch) { + contained = true + break + } + } + if !contained { + return false + } + } + return true + } + + // For non-OR constraints, check if any of our branches contains it + for _, r := range o { if r.Contains(other) { return true } @@ -95,10 +166,33 @@ func (not not) Check(v Version) bool { return !not.Range.Check(v) } -func (not not) Contains(other Constraint) bool { - return !other.Contains(¬.Range) +//nolint:revive // Receiver name differs from type to avoid shadowing +func (n not) Contains(other Constraint) bool { + switch v := other.(type) { + case *originalInputConstraint: + return n.Contains(v.Constraint) + case not: + return rangeContainsRange(v.Range, n.Range) + case *Range: + return !rangesOverlap(n.Range, *v) + case and: + for _, c := range v { + if !n.Contains(c) { + return false + } + } + return true + case or: + for _, c := range v { + if !n.Contains(c) { + return false + } + } + return true + } + return false } func (not not) String() string { - return "!=" + not.Range.String() + return "!" + not.Range.String() } diff --git a/constraints_test.go b/constraints_test.go index 51521d9..aedd24d 100644 --- a/constraints_test.go +++ b/constraints_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestAnd(t *testing.T) { @@ -27,6 +28,58 @@ func TestAnd(t *testing.T) { assert.False(t, and.Check(Version{})) assert.False(t, and.Contains(&Range{})) }) + + t.Run("compaction structure", func(t *testing.T) { + t.Parallel() + c, err := NewConstraint("1 - 2 && 2 - 3 && 1.1 - 10") + require.NoError(t, err) + oic := c.(*originalInputConstraint) + r, isSingleRange := oic.Constraint.(*Range) + if isSingleRange { + assert.True(t, r.Min.Same(Version{Major: 2}) && r.Max.Same(Version{Major: 2}), + "intersection should be {2.0.0}, got %s", r.String()) + } + }) + + t.Run("compaction Check", func(t *testing.T) { + t.Parallel() + tests := []struct { + name string + constraint string + version string + expected bool + }{ + {"adjacent AND matches at boundary", "1 - 2 && 2 - 3", "2.0.0", true}, + {"adjacent AND rejects left-only version", "1 - 2 && 2 - 3", "1.5.0", false}, + {"adjacent AND rejects right-only version", "1 - 2 && 2 - 3", "2.5.0", false}, + {"adjacent AND rejects left boundary", "1 - 2 && 2 - 3", "1.0.0", false}, + {"adjacent AND rejects right boundary", "1 - 2 && 2 - 3", "3.0.0", false}, + {"adjacent AND rejects below both", "1 - 2 && 2 - 3", "0.5.0", false}, + {"adjacent AND rejects above both", "1 - 2 && 2 - 3", "3.5.0", false}, + {"overlapping AND matches intersection start", "1.0.0 - 3.0.0 && 2.0.0 - 4.0.0", "2.0.0", true}, + {"overlapping AND matches intersection mid", "1.0.0 - 3.0.0 && 2.0.0 - 4.0.0", "2.5.0", true}, + {"overlapping AND matches intersection end", "1.0.0 - 3.0.0 && 2.0.0 - 4.0.0", "3.0.0", true}, + {"overlapping AND rejects left-only", "1.0.0 - 3.0.0 && 2.0.0 - 4.0.0", "1.5.0", false}, + {"overlapping AND rejects right-only", "1.0.0 - 3.0.0 && 2.0.0 - 4.0.0", "3.5.0", false}, + {"identical AND matches inside", "1.0.0 - 2.0.0 && 1.0.0 - 2.0.0", "1.5.0", true}, + {"identical AND rejects outside", "1.0.0 - 2.0.0 && 1.0.0 - 2.0.0", "2.5.0", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c, err := NewConstraint(tt.constraint) + require.NoError(t, err) + assert.Equal(t, tt.expected, c.Check(MustNewVersion(tt.version))) + }) + } + }) + + t.Run("compaction Contains", func(t *testing.T) { + t.Parallel() + assert.False(t, + MustNewConstraint("2 - 3 && 1 - 2").Contains(MustNewConstraint("1 - 3")), + "adjacent AND does not contain full union range") + }) } func TestAnd_String(t *testing.T) { @@ -65,6 +118,36 @@ func TestOr(t *testing.T) { assert.False(t, or.Check(Version{})) assert.False(t, or.Contains(&Range{})) }) + + t.Run("overlapping branches Contains", func(t *testing.T) { + t.Parallel() + tests := []struct { + name string + rA string + rB string + expected bool + }{ + { + name: "overlapping OR branches cover full range", + rA: "1 - 3 || 2 - 5", + rB: "1 - 5", + expected: true, + }, + { + name: "adjacent OR branches cover full range", + rA: "1 - 2 || 2 - 3", + rB: "1 - 3", + expected: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, + MustNewConstraint(tt.rA).Contains(MustNewConstraint(tt.rB))) + }) + } + }) } func TestOr_String(t *testing.T) { @@ -109,6 +192,48 @@ func TestNot(t *testing.T) { Max: MustNewVersion("4.0.0"), })) }) + + t.Run("Contains", func(t *testing.T) { + t.Parallel() + tests := []struct { + name string + rA string + rB string + expected bool + }{ + { + name: "wider exclusion does not contain narrower exclusion", + rA: "1 - 5 && !=2", + rB: "1 - 5 && !=2.0.0", + expected: false, + }, + { + name: "narrower exclusion contains wider exclusion", + rA: "1 - 5 && !=2.0.0", + rB: "1 - 5 && !=2", + expected: true, + }, + { + name: "narrow not contains wide not", + rA: "!=2.0.0", + rB: "!=2", + expected: true, + }, + { + name: "wide not does not contain narrow not", + rA: "!=2", + rB: "!=2.0.0", + expected: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, + MustNewConstraint(tt.rA).Contains(MustNewConstraint(tt.rB))) + }) + } + }) } func TestNot_String(t *testing.T) { diff --git a/go.mod b/go.mod index 45fdb0f..c921f06 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module pkg.package-operator.run/semver -go 1.25.3 +go 1.26 require github.com/stretchr/testify v1.11.1 diff --git a/overconstrained_test.go b/overconstrained_test.go new file mode 100644 index 0000000..8294099 --- /dev/null +++ b/overconstrained_test.go @@ -0,0 +1,202 @@ +package semver + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOverConstrainedDetection(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + constraint string + shouldError bool + errorMsg string + }{ + // Valid constraints (should NOT error) + { + name: "valid range", + constraint: "1.0.0 - 2.0.0", + shouldError: false, + }, + { + name: "valid AND with overlap", + constraint: ">=1.0.0 && <=2.0.0", + shouldError: false, + }, + { + name: "valid adjacent ranges", + constraint: "1.0.0 - 2.0.0 && 2.0.0 - 3.0.0", + shouldError: false, + }, + { + name: "valid overlapping ranges", + constraint: "1.0.0 - 2.5.0 && 2.0.0 - 3.0.0", + shouldError: false, + }, + { + name: "valid NOT with range", + constraint: "1.0.0 - 2.0.0 && !=1.5.0", + shouldError: false, + }, + + // Over-constrained: min > max (detected as non-overlapping ranges) + { + name: "impossible min > max", + constraint: ">=2.0.0 && <1.0.0", + shouldError: true, + errorMsg: "over-constrained, ranges do not overlap", + }, + { + name: "impossible min > max with exact versions", + constraint: ">=5.0.0 && <=3.0.0", + shouldError: true, + errorMsg: "over-constrained, ranges do not overlap", + }, + + // Over-constrained: non-overlapping ranges + { + name: "non-overlapping ranges gap between", + constraint: "1.0.0 - 2.0.0 && 3.0.0 - 4.0.0", + shouldError: true, + errorMsg: "over-constrained, ranges do not overlap", + }, + { + name: "non-overlapping ranges adjacent but not touching", + constraint: "1.0.0 - 1.9.9 && 2.0.0 - 3.0.0", + shouldError: true, + errorMsg: "over-constrained, ranges do not overlap", + }, + { + name: "non-overlapping with multiple ranges", + constraint: "1.0.0 - 1.5.0 && 2.0.0 - 2.5.0 && 3.0.0 - 3.5.0", + shouldError: true, + errorMsg: "over-constrained, ranges do not overlap", + }, + + // Over-constrained: NOT excludes exact version + { + name: "equal and not equal same version", + constraint: "=1.0.0 && !=1.0.0", + shouldError: true, + errorMsg: "over-constrained, =1.0.0 AND !=1.0.0 excludes all versions", + }, + { + name: "equal and not equal different order", + constraint: "!=2.5.0 && =2.5.0", + shouldError: true, + errorMsg: "over-constrained, =2.5.0 AND !=2.5.0 excludes all versions", + }, + + // Edge cases + { + name: "touching ranges at boundary (valid)", + constraint: "1.0.0 - 2.0.0 && 2.0.0 - 3.0.0", + shouldError: false, // 2.0.0 is in both ranges + }, + { + name: "same range twice (valid but redundant)", + constraint: "1.0.0 - 2.0.0 && 1.0.0 - 2.0.0", + shouldError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := NewConstraint(tt.constraint) + + if tt.shouldError { + require.Error(t, err, "expected error for constraint: %s", tt.constraint) + assert.Contains(t, err.Error(), tt.errorMsg, + "error message should contain '%s' for constraint: %s", tt.errorMsg, tt.constraint) + } else { + require.NoError(t, err, "constraint should be valid: %s", tt.constraint) + } + }) + } +} + +func TestOverConstrainedWithOperators(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + constraint string + shouldError bool + errorMsg string + }{ + // Valid combinations - these may trigger redundancy warnings but are not over-constrained + { + name: "tilde with wider range gets redundancy check", + constraint: "~1.2.3 && >=1.2.0", + shouldError: true, // Redundancy check, not over-constrained + errorMsg: "redundant", + }, + { + name: "caret with wider range gets redundancy check", + constraint: "^1.2.0 && >=1.0.0", + shouldError: true, // Redundancy check, not over-constrained + errorMsg: "redundant", + }, + + // Invalid combinations - truly over-constrained + { + name: "tilde incompatible with lower range", + constraint: "~2.0.0 && <1.0.0", + shouldError: true, + errorMsg: "over-constrained", + }, + { + name: "caret incompatible with higher range", + constraint: "^1.0.0 && >=5.0.0", + shouldError: true, + errorMsg: "over-constrained", + }, + { + name: "greater than less than impossible", + constraint: ">5.0.0 && <3.0.0", + shouldError: true, + errorMsg: "over-constrained", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := NewConstraint(tt.constraint) + + if tt.shouldError { + require.Error(t, err, "expected error for constraint: %s", tt.constraint) + assert.Contains(t, err.Error(), tt.errorMsg, + "error should mention '%s' for: %s", tt.errorMsg, tt.constraint) + } else { + require.NoError(t, err, "constraint should be valid: %s", tt.constraint) + } + }) + } +} + +// TestOverConstrainedErrorPosition verifies that error messages include position info. +func TestOverConstrainedErrorPosition(t *testing.T) { + t.Parallel() + + _, err := NewConstraint(">=2.0.0 && <1.0.0") + require.Error(t, err) + + // Error should include column position + assert.Contains(t, err.Error(), "col", "error should include position information") + assert.Contains(t, err.Error(), "over-constrained", "error should mention over-constrained") +} + +func TestOverConstrainedErrorFormat(t *testing.T) { + t.Parallel() + + _, err := NewConstraint("2 - 3 && 5 - 6 && 1 - 2") + require.Error(t, err) + assert.NotRegexp(t, `col \d+: AND col \d+:`, err.Error(), + "error should not have doubled position prefix") +} diff --git a/range.go b/range.go index e70d2bb..65dcd50 100644 --- a/range.go +++ b/range.go @@ -14,7 +14,7 @@ type Range struct { func (r *Range) String() string { if r.Min.Same(r.Max) { - return r.Min.String() + return "=" + r.Min.String() } return fmt.Sprintf("%s - %s", r.Min.String(), r.Max.String()) } @@ -56,12 +56,14 @@ func rangeContains(r Range, other Constraint) bool { return true case or: + // An OR constraint (A || B) represents the union of versions matching A or B. + // For a range to contain this union, it must contain ALL branches. for _, ac := range v { - if rangeContains(r, ac) { - return true + if !rangeContains(r, ac) { + return false } } - return false + return true } return false } diff --git a/range_test.go b/range_test.go index d11c8da..17fbc1f 100644 --- a/range_test.go +++ b/range_test.go @@ -48,6 +48,7 @@ func TestRange_Test(t *testing.T) { } } +//nolint:maintidx // Table-driven test with many edge cases func TestRange_Contains(t *testing.T) { t.Parallel() tests := []struct { @@ -182,6 +183,136 @@ func TestRange_Contains(t *testing.T) { rB: MustNewConstraint("!=4.14.3"), expected: true, }, + { + name: "2 - 3 && 1 - 2 does NOT contain 1 - 3 (AND is intersection, not union)", + rA: MustNewConstraint("2 - 3 && 1 - 2"), + rB: MustNewConstraint("1 - 3"), + expected: false, + }, + { + name: "4.12.x - 4.14.x && != 4.13.5 does contain 4.13.0 - 4.13.4 || 4.13.6 - 4.13.8", + rA: MustNewConstraint("4.12.x - 4.14.x && != 4.13.5"), + rB: MustNewConstraint("4.13.0 - 4.13.4 || 4.13.6 - 4.13.8"), + expected: true, + }, + + // Edge cases: Range.Contains(OR) - must contain ALL branches + { + name: "1-3 does NOT contain 1-1.5 || 2.5-3.5 (second branch extends beyond)", + rA: MustNewConstraint("1-3"), + rB: MustNewConstraint("1-1.5 || 2.5-3.5"), + expected: false, // 3.5 > 3 + }, + { + name: "1-4 does contain 1-1.5 || 2.5-3.5 (both branches within bounds)", + rA: MustNewConstraint("1-4"), + rB: MustNewConstraint("1-1.5 || 2.5-3.5"), + expected: true, + }, + { + name: "1-3 does NOT contain 0.5-1.5 || 2-3 (first branch extends below)", + rA: MustNewConstraint("1-3"), + rB: MustNewConstraint("0.5-1.5 || 2-3"), + expected: false, // 0.5 < 1 + }, + { + name: ">=2 does NOT contain >=1.5 || >=3 (first branch too wide)", + rA: MustNewConstraint(">=2"), + rB: MustNewConstraint(">=1.5 || >=3"), + expected: false, // >=1.5 includes versions < 2 + }, + { + name: "<=3 does NOT contain <=2 || <=4 (second branch too wide)", + rA: MustNewConstraint("<=3"), + rB: MustNewConstraint("<=2 || <=4"), + expected: false, // <=4 extends beyond <=3 + }, + { + name: "=2.0.0 does NOT contain =2.0.0 || =3.0.0 (includes extra version)", + rA: MustNewConstraint("=2.0.0"), + rB: MustNewConstraint("=2.0.0 || =3.0.0"), + expected: false, + }, + + // Edge cases: OR.Contains(OR) + { + name: "1-4 || 5-6 does contain 1.5-3.5 || 5.2-5.8 (each branch covered)", + rA: MustNewConstraint("1-4 || 5-6"), + rB: MustNewConstraint("1.5-3.5 || 5.2-5.8"), + expected: true, + }, + { + name: "1-2 || 3-4 does NOT contain 1.5-2 || 2.5-3.5 (gap not covered)", + rA: MustNewConstraint("1-2 || 3-4"), + rB: MustNewConstraint("1.5-2 || 2.5-3.5"), + expected: false, // 2.5 falls in gap between 2 and 3 + }, + { + name: "1-3 || 5-7 || 9-11 does contain 1-2 || 6-7 || 10-11", + rA: MustNewConstraint("1-3 || 5-7 || 9-11"), + rB: MustNewConstraint("1-2 || 6-7 || 10-11"), + expected: true, + }, + + // Edge cases: AND with multiple != + { + name: "1-5 && !=2 && !=3 does contain 1.5-4.5 && !=2 && !=3", + rA: MustNewConstraint("1-5 && !=2 && !=3"), + rB: MustNewConstraint("1.5-4.5 && !=2 && !=3"), + expected: true, + }, + { + name: "1-5 && !=2 does NOT contain 1-5 && !=3 (different exclusions)", + rA: MustNewConstraint("1-5 && !=2"), + rB: MustNewConstraint("1-5 && !=3"), + expected: false, + }, + { + name: "1-10 && !=3 && !=5 does contain 2-4 && !=3 && !=5", + rA: MustNewConstraint("1-10 && !=3 && !=5"), + rB: MustNewConstraint("2-4 && !=3 && !=5"), + expected: true, + }, + + // Edge cases: Mixed AND/OR with != + { + name: "1-5 && !=3 does NOT contain 1-2 || 3-4 (OR includes excluded version)", + rA: MustNewConstraint("1-5 && !=3"), + rB: MustNewConstraint("1-2 || 3-4"), + expected: false, // rB includes 3.0.0 + }, + { + name: "1-5 && !=3 does contain 1-2 || 4-5 (OR avoids excluded version)", + rA: MustNewConstraint("1-5 && !=3"), + rB: MustNewConstraint("1-2 || 4-5"), + expected: true, + }, + { + name: "1-2 || 3-4 does NOT contain 1.5-3.5 && !=2 (AND crosses gap)", + rA: MustNewConstraint("1-2 || 3-4"), + rB: MustNewConstraint("1.5-3.5 && !=2"), + expected: false, + }, + + // Edge cases: Tilde and Caret with OR + { + name: "~1 does contain ~1.2 || ~1.5", + rA: MustNewConstraint("~1"), + rB: MustNewConstraint("~1.2 || ~1.5"), + expected: true, + }, + { + name: "~1.2 || ~1.5 does NOT contain ~1 (gaps between ranges)", + rA: MustNewConstraint("~1.2 || ~1.5"), + rB: MustNewConstraint("~1"), + expected: false, + }, + { + name: "^1.0.0 does contain ^1.2.0 || ^1.5.0", + rA: MustNewConstraint("^1.0.0"), + rB: MustNewConstraint("^1.2.0 || ^1.5.0"), + expected: true, + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/sort_range.go b/sort_range.go new file mode 100644 index 0000000..fca2ff2 --- /dev/null +++ b/sort_range.go @@ -0,0 +1,99 @@ +package semver + +import "sort" + +// AscendingMin sorts ranges Ascending by minimal version via the sort standard lib package. +// resulting order: 1.0.0 - 4.0.0, 1.1.0 - 2.0.0, 2.0.0 - 5.0.0. +type AscendingMin []Range + +var _ sort.Interface = AscendingMin{} + +// Returns the number of items of the slice. +// Implements sort.Interface. +func (l AscendingMin) Len() int { + return len(l) +} + +// Returns true if item[i] should sort before item[j] (descending order). +// Implements sort.Interface. +func (l AscendingMin) Less(i, j int) bool { + return l[i].Min.LessThan(l[j].Min) +} + +// Swaps the position of two items in the list. +// Implements sort.Interface. +func (l AscendingMin) Swap(i, j int) { + l[i], l[j] = l[j], l[i] +} + +// AscendingMax sorts ranges Ascending by max version via the sort standard lib package. +// resulting order: 1.0.0 - 2.0.0, 0.4.0 - 3.0.0, 0.1.0 - 4.0.0. +type AscendingMax []Range + +var _ sort.Interface = AscendingMax{} + +// Returns the number of items of the slice. +// Implements sort.Interface. +func (l AscendingMax) Len() int { + return len(l) +} + +// Returns true if item[i] should sort before item[j] (descending order). +// Implements sort.Interface. +func (l AscendingMax) Less(i, j int) bool { + return l[i].Max.LessThan(l[j].Max) +} + +// Swaps the position of two items in the list. +// Implements sort.Interface. +func (l AscendingMax) Swap(i, j int) { + l[i], l[j] = l[j], l[i] +} + +// DescendingMin sorts ranges Descending by min version via the sort standard lib package. +// resulting order: 2.0.0, 1.1.0, 1.0.0. +type DescendingMin []Range + +var _ sort.Interface = DescendingMin{} + +// Returns the number of items of the slice. +// Implements sort.Interface. +func (l DescendingMin) Len() int { + return len(l) +} + +// Returns true if item[i] should sort before item[j] (descending order). +// Implements sort.Interface. +func (l DescendingMin) Less(i, j int) bool { + return l[i].Min.GreaterThan(l[j].Min) +} + +// Swaps the position of two items in the list. +// Implements sort.Interface. +func (l DescendingMin) Swap(i, j int) { + l[i], l[j] = l[j], l[i] +} + +// DescendingMax sorts ranges Descending by max version via the sort standard lib package. +// resulting order: 2.0.0, 1.1.0, 1.0.0. +type DescendingMax []Range + +var _ sort.Interface = DescendingMax{} + +// Returns the number of items of the slice. +// Implements sort.Interface. +func (l DescendingMax) Len() int { + return len(l) +} + +// Returns true if item[i] should sort before item[j] (descending order). +// Implements sort.Interface. +func (l DescendingMax) Less(i, j int) bool { + return l[i].Max.GreaterThan(l[j].Max) +} + +// Swaps the position of two items in the list. +// Implements sort.Interface. +func (l DescendingMax) Swap(i, j int) { + l[i], l[j] = l[j], l[i] +} diff --git a/sort_range_test.go b/sort_range_test.go new file mode 100644 index 0000000..f469da5 --- /dev/null +++ b/sort_range_test.go @@ -0,0 +1,491 @@ +package semver + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAscendingMin(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input []Range + expected []Range + }{ + { + name: "sort by min version ascending", + input: []Range{ + {Min: MustNewVersion("2.0.0"), Max: MustNewVersion("5.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("4.0.0")}, + {Min: MustNewVersion("1.1.0"), Max: MustNewVersion("2.0.0")}, + }, + expected: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("4.0.0")}, + {Min: MustNewVersion("1.1.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("2.0.0"), Max: MustNewVersion("5.0.0")}, + }, + }, + { + name: "already sorted", + input: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("2.0.0"), Max: MustNewVersion("3.0.0")}, + {Min: MustNewVersion("3.0.0"), Max: MustNewVersion("4.0.0")}, + }, + expected: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("2.0.0"), Max: MustNewVersion("3.0.0")}, + {Min: MustNewVersion("3.0.0"), Max: MustNewVersion("4.0.0")}, + }, + }, + { + name: "reverse sorted", + input: []Range{ + {Min: MustNewVersion("3.0.0"), Max: MustNewVersion("4.0.0")}, + {Min: MustNewVersion("2.0.0"), Max: MustNewVersion("3.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + }, + expected: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("2.0.0"), Max: MustNewVersion("3.0.0")}, + {Min: MustNewVersion("3.0.0"), Max: MustNewVersion("4.0.0")}, + }, + }, + { + name: "same min versions with different max", + input: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("5.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("3.0.0")}, + }, + expected: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("5.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("3.0.0")}, + }, + }, + { + name: "minor version differences", + input: []Range{ + {Min: MustNewVersion("1.5.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("1.2.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + }, + expected: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("1.2.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("1.5.0"), Max: MustNewVersion("2.0.0")}, + }, + }, + { + name: "patch version differences", + input: []Range{ + {Min: MustNewVersion("1.0.5"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("1.0.1"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("1.0.3"), Max: MustNewVersion("2.0.0")}, + }, + expected: []Range{ + {Min: MustNewVersion("1.0.1"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("1.0.3"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("1.0.5"), Max: MustNewVersion("2.0.0")}, + }, + }, + { + name: "empty slice", + input: []Range{}, + expected: []Range{}, + }, + { + name: "single element", + input: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + }, + expected: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + input := make([]Range, len(tt.input)) + copy(input, tt.input) + sort.Sort(AscendingMin(input)) + assert.Equal(t, tt.expected, input) + }) + } +} + +func TestAscendingMax(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input []Range + expected []Range + }{ + { + name: "sort by max version ascending", + input: []Range{ + {Min: MustNewVersion("0.1.0"), Max: MustNewVersion("4.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("0.4.0"), Max: MustNewVersion("3.0.0")}, + }, + expected: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("0.4.0"), Max: MustNewVersion("3.0.0")}, + {Min: MustNewVersion("0.1.0"), Max: MustNewVersion("4.0.0")}, + }, + }, + { + name: "already sorted", + input: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("3.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("4.0.0")}, + }, + expected: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("3.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("4.0.0")}, + }, + }, + { + name: "reverse sorted", + input: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("4.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("3.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + }, + expected: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("3.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("4.0.0")}, + }, + }, + { + name: "same max versions with different min", + input: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("5.0.0")}, + {Min: MustNewVersion("2.0.0"), Max: MustNewVersion("5.0.0")}, + {Min: MustNewVersion("3.0.0"), Max: MustNewVersion("5.0.0")}, + }, + expected: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("5.0.0")}, + {Min: MustNewVersion("2.0.0"), Max: MustNewVersion("5.0.0")}, + {Min: MustNewVersion("3.0.0"), Max: MustNewVersion("5.0.0")}, + }, + }, + { + name: "minor version differences", + input: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.5.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.1.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + }, + expected: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.1.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.5.0")}, + }, + }, + { + name: "patch version differences", + input: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.7")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.3")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.5")}, + }, + expected: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.3")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.5")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.7")}, + }, + }, + { + name: "empty slice", + input: []Range{}, + expected: []Range{}, + }, + { + name: "single element", + input: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + }, + expected: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + input := make([]Range, len(tt.input)) + copy(input, tt.input) + sort.Sort(AscendingMax(input)) + assert.Equal(t, tt.expected, input) + }) + } +} + +func TestDescendingMin(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input []Range + expected []Range + }{ + { + name: "sort by min version descending", + input: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("4.0.0")}, + {Min: MustNewVersion("2.0.0"), Max: MustNewVersion("5.0.0")}, + {Min: MustNewVersion("1.1.0"), Max: MustNewVersion("2.0.0")}, + }, + expected: []Range{ + {Min: MustNewVersion("2.0.0"), Max: MustNewVersion("5.0.0")}, + {Min: MustNewVersion("1.1.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("4.0.0")}, + }, + }, + { + name: "already sorted", + input: []Range{ + {Min: MustNewVersion("3.0.0"), Max: MustNewVersion("4.0.0")}, + {Min: MustNewVersion("2.0.0"), Max: MustNewVersion("3.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + }, + expected: []Range{ + {Min: MustNewVersion("3.0.0"), Max: MustNewVersion("4.0.0")}, + {Min: MustNewVersion("2.0.0"), Max: MustNewVersion("3.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + }, + }, + { + name: "reverse sorted", + input: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("2.0.0"), Max: MustNewVersion("3.0.0")}, + {Min: MustNewVersion("3.0.0"), Max: MustNewVersion("4.0.0")}, + }, + expected: []Range{ + {Min: MustNewVersion("3.0.0"), Max: MustNewVersion("4.0.0")}, + {Min: MustNewVersion("2.0.0"), Max: MustNewVersion("3.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + }, + }, + { + name: "same min versions with different max", + input: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("5.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("3.0.0")}, + }, + expected: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("5.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("3.0.0")}, + }, + }, + { + name: "minor version differences", + input: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("1.2.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("1.5.0"), Max: MustNewVersion("2.0.0")}, + }, + expected: []Range{ + {Min: MustNewVersion("1.5.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("1.2.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + }, + }, + { + name: "patch version differences", + input: []Range{ + {Min: MustNewVersion("1.0.1"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("1.0.5"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("1.0.3"), Max: MustNewVersion("2.0.0")}, + }, + expected: []Range{ + {Min: MustNewVersion("1.0.5"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("1.0.3"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("1.0.1"), Max: MustNewVersion("2.0.0")}, + }, + }, + { + name: "empty slice", + input: []Range{}, + expected: []Range{}, + }, + { + name: "single element", + input: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + }, + expected: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + input := make([]Range, len(tt.input)) + copy(input, tt.input) + sort.Sort(DescendingMin(input)) + assert.Equal(t, tt.expected, input) + }) + } +} + +func TestDescendingMax(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input []Range + expected []Range + }{ + { + name: "sort by max version descending", + input: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("0.1.0"), Max: MustNewVersion("4.0.0")}, + {Min: MustNewVersion("0.4.0"), Max: MustNewVersion("3.0.0")}, + }, + expected: []Range{ + {Min: MustNewVersion("0.1.0"), Max: MustNewVersion("4.0.0")}, + {Min: MustNewVersion("0.4.0"), Max: MustNewVersion("3.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + }, + }, + { + name: "already sorted", + input: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("4.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("3.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + }, + expected: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("4.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("3.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + }, + }, + { + name: "reverse sorted", + input: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("3.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("4.0.0")}, + }, + expected: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("4.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("3.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + }, + }, + { + name: "same max versions with different min", + input: []Range{ + {Min: MustNewVersion("3.0.0"), Max: MustNewVersion("5.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("5.0.0")}, + {Min: MustNewVersion("2.0.0"), Max: MustNewVersion("5.0.0")}, + }, + expected: []Range{ + {Min: MustNewVersion("3.0.0"), Max: MustNewVersion("5.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("5.0.0")}, + {Min: MustNewVersion("2.0.0"), Max: MustNewVersion("5.0.0")}, + }, + }, + { + name: "minor version differences", + input: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.1.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.5.0")}, + }, + expected: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.5.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.1.0")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + }, + }, + { + name: "patch version differences", + input: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.3")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.7")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.5")}, + }, + expected: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.7")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.5")}, + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.3")}, + }, + }, + { + name: "empty slice", + input: []Range{}, + expected: []Range{}, + }, + { + name: "single element", + input: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + }, + expected: []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + input := make([]Range, len(tt.input)) + copy(input, tt.input) + sort.Sort(DescendingMax(input)) + assert.Equal(t, tt.expected, input) + }) + } +} + +func TestSortInterface_Len(t *testing.T) { + t.Parallel() + + ranges := []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("2.0.0"), Max: MustNewVersion("3.0.0")}, + {Min: MustNewVersion("3.0.0"), Max: MustNewVersion("4.0.0")}, + } + + assert.Equal(t, 3, AscendingMin(ranges).Len()) + assert.Equal(t, 3, AscendingMax(ranges).Len()) + assert.Equal(t, 3, DescendingMin(ranges).Len()) + assert.Equal(t, 3, DescendingMax(ranges).Len()) + + assert.Equal(t, 0, AscendingMin([]Range{}).Len()) +} + +func TestSortInterface_Swap(t *testing.T) { + t.Parallel() + + ranges := []Range{ + {Min: MustNewVersion("1.0.0"), Max: MustNewVersion("2.0.0")}, + {Min: MustNewVersion("2.0.0"), Max: MustNewVersion("3.0.0")}, + {Min: MustNewVersion("3.0.0"), Max: MustNewVersion("4.0.0")}, + } + + AscendingMin(ranges).Swap(0, 2) + assert.Equal(t, MustNewVersion("3.0.0"), ranges[0].Min) + assert.Equal(t, MustNewVersion("1.0.0"), ranges[2].Min) +}