Skip to content

Commit ea5ebc7

Browse files
committed
Add support for version comparison with scheme-specific rules
- Introduced CompareWithScheme function to handle version comparisons based on different schemes (NuGet, Maven). - Implemented detailed comparison logic for NuGet versions, including handling of prerelease versions. - Added Maven version comparison with special qualifier ordering and handling of sublist components. - Enhanced Range struct to store original constraints for accurate VERS output. - Updated parser functions to maintain raw constraints and improve version normalization. - Added tests for new version comparison functionalities.
1 parent 4aab804 commit ea5ebc7

8 files changed

Lines changed: 995 additions & 30 deletions

File tree

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[submodule "testdata/vers-spec"]
2+
path = testdata/vers-spec
3+
url = https://github.com/package-url/vers-spec.git

conformance_test.go

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
package vers
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
)
9+
10+
type versTestFile struct {
11+
Tests []versTestCase `json:"tests"`
12+
}
13+
14+
type versTestCase struct {
15+
Description string `json:"description"`
16+
TestGroup string `json:"test_group"`
17+
TestType string `json:"test_type"`
18+
Input json.RawMessage `json:"input"`
19+
ExpectedOutput json.RawMessage `json:"expected_output"`
20+
}
21+
22+
type fromNativeInput struct {
23+
NativeRange string `json:"native_range"`
24+
Scheme string `json:"scheme"`
25+
}
26+
27+
type containmentInput struct {
28+
Vers string `json:"vers"`
29+
Version string `json:"version"`
30+
}
31+
32+
type versionCmpInput struct {
33+
InputScheme string `json:"input_scheme"`
34+
Versions []string `json:"versions"`
35+
}
36+
37+
func loadTestFile(t *testing.T, filename string) *versTestFile {
38+
t.Helper()
39+
path := filepath.Join("testdata", "vers-spec", "tests", filename)
40+
data, err := os.ReadFile(path)
41+
if err != nil {
42+
t.Fatalf("failed to read test file %s: %v", filename, err)
43+
}
44+
var tf versTestFile
45+
if err := json.Unmarshal(data, &tf); err != nil {
46+
t.Fatalf("failed to parse test file %s: %v", filename, err)
47+
}
48+
return &tf
49+
}
50+
51+
func TestConformance_FromNative(t *testing.T) {
52+
files := []string{
53+
"gem_range_from_native_test.json",
54+
"npm_range_from_native_test.json",
55+
"pypi_range_from_native_test.json",
56+
"nuget_range_from_native_test.json",
57+
}
58+
59+
for _, file := range files {
60+
t.Run(file, func(t *testing.T) {
61+
tf := loadTestFile(t, file)
62+
for _, tc := range tf.Tests {
63+
if tc.TestType != "from_native" {
64+
continue
65+
}
66+
67+
var input fromNativeInput
68+
if err := json.Unmarshal(tc.Input, &input); err != nil {
69+
t.Errorf("failed to parse input: %v", err)
70+
continue
71+
}
72+
73+
var expected string
74+
if err := json.Unmarshal(tc.ExpectedOutput, &expected); err != nil {
75+
t.Errorf("failed to parse expected output: %v", err)
76+
continue
77+
}
78+
79+
t.Run(input.NativeRange, func(t *testing.T) {
80+
r, err := ParseNative(input.NativeRange, input.Scheme)
81+
if err != nil {
82+
t.Errorf("ParseNative(%q, %q) error: %v", input.NativeRange, input.Scheme, err)
83+
return
84+
}
85+
86+
got := ToVersString(r, input.Scheme)
87+
if got != expected {
88+
t.Errorf("ParseNative(%q, %q) = %q, want %q", input.NativeRange, input.Scheme, got, expected)
89+
}
90+
})
91+
}
92+
})
93+
}
94+
}
95+
96+
func TestConformance_Containment(t *testing.T) {
97+
files := []string{
98+
"npm_range_containment_test.json",
99+
"pypi_range_containment_test.json",
100+
}
101+
102+
for _, file := range files {
103+
t.Run(file, func(t *testing.T) {
104+
tf := loadTestFile(t, file)
105+
for _, tc := range tf.Tests {
106+
if tc.TestType != "containment" {
107+
continue
108+
}
109+
110+
var input containmentInput
111+
if err := json.Unmarshal(tc.Input, &input); err != nil {
112+
t.Errorf("failed to parse input: %v", err)
113+
continue
114+
}
115+
116+
var expected bool
117+
if err := json.Unmarshal(tc.ExpectedOutput, &expected); err != nil {
118+
t.Errorf("failed to parse expected output: %v", err)
119+
continue
120+
}
121+
122+
t.Run(input.Vers+"_"+input.Version, func(t *testing.T) {
123+
r, err := Parse(input.Vers)
124+
if err != nil {
125+
t.Errorf("Parse(%q) error: %v", input.Vers, err)
126+
return
127+
}
128+
129+
got := r.Contains(input.Version)
130+
if got != expected {
131+
t.Errorf("Parse(%q).Contains(%q) = %v, want %v", input.Vers, input.Version, got, expected)
132+
}
133+
})
134+
}
135+
})
136+
}
137+
}
138+
139+
func TestConformance_VersionComparison(t *testing.T) {
140+
files := []string{
141+
"nuget_version_cmp_test.json",
142+
"maven_version_cmp_test.json",
143+
}
144+
145+
for _, file := range files {
146+
t.Run(file, func(t *testing.T) {
147+
tf := loadTestFile(t, file)
148+
for _, tc := range tf.Tests {
149+
var input versionCmpInput
150+
if err := json.Unmarshal(tc.Input, &input); err != nil {
151+
t.Errorf("failed to parse input: %v", err)
152+
continue
153+
}
154+
155+
if len(input.Versions) != 2 {
156+
t.Errorf("expected 2 versions, got %d", len(input.Versions))
157+
continue
158+
}
159+
160+
v1, v2 := input.Versions[0], input.Versions[1]
161+
162+
switch tc.TestType {
163+
case "equality":
164+
var expected bool
165+
if err := json.Unmarshal(tc.ExpectedOutput, &expected); err != nil {
166+
t.Errorf("failed to parse expected output: %v", err)
167+
continue
168+
}
169+
170+
t.Run("eq_"+v1+"_"+v2, func(t *testing.T) {
171+
cmp := CompareWithScheme(v1, v2, input.InputScheme)
172+
got := cmp == 0
173+
if got != expected {
174+
t.Errorf("CompareWithScheme(%q, %q, %q) == 0 is %v, want %v (cmp=%d)", v1, v2, input.InputScheme, got, expected, cmp)
175+
}
176+
})
177+
178+
case "comparison":
179+
var expected []string
180+
if err := json.Unmarshal(tc.ExpectedOutput, &expected); err != nil {
181+
t.Errorf("failed to parse expected output: %v", err)
182+
continue
183+
}
184+
185+
if len(expected) != 2 {
186+
t.Errorf("expected 2 versions in output, got %d", len(expected))
187+
continue
188+
}
189+
190+
t.Run("cmp_"+v1+"_"+v2, func(t *testing.T) {
191+
cmp := CompareWithScheme(v1, v2, input.InputScheme)
192+
// expected[0] should be less than expected[1]
193+
// Use comparison to determine which version v1 matches (handles case normalization)
194+
v1MatchesFirst := CompareWithScheme(v1, expected[0], input.InputScheme) == 0
195+
if expected[0] == expected[1] || CompareWithScheme(expected[0], expected[1], input.InputScheme) == 0 {
196+
if cmp != 0 {
197+
t.Errorf("CompareWithScheme(%q, %q, %q) = %d, want 0 (equal versions)", v1, v2, input.InputScheme, cmp)
198+
}
199+
} else if v1MatchesFirst {
200+
// v1 matches the smaller version, so cmp(v1, v2) should be < 0
201+
if cmp >= 0 {
202+
t.Errorf("CompareWithScheme(%q, %q, %q) = %d, want < 0 (expected order: %v)", v1, v2, input.InputScheme, cmp, expected)
203+
}
204+
} else {
205+
// v1 matches the larger version, so cmp(v1, v2) should be > 0
206+
if cmp <= 0 {
207+
t.Errorf("CompareWithScheme(%q, %q, %q) = %d, want > 0 (expected order: %v)", v1, v2, input.InputScheme, cmp, expected)
208+
}
209+
}
210+
})
211+
}
212+
}
213+
})
214+
}
215+
}

constraint.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,20 @@ func ParseConstraint(s string) (*Constraint, error) {
3131
if version == "" {
3232
return nil, fmt.Errorf("invalid constraint format: %s", s)
3333
}
34+
version = stripVPrefix(version)
3435
return &Constraint{Operator: operator, Version: version}, nil
3536
}
3637

3738
// No operator found, treat as exact match
38-
return &Constraint{Operator: "=", Version: s}, nil
39+
return &Constraint{Operator: "=", Version: stripVPrefix(s)}, nil
40+
}
41+
42+
// stripVPrefix removes a leading 'v' or 'V' from version strings.
43+
func stripVPrefix(version string) string {
44+
if len(version) > 1 && (version[0] == 'v' || version[0] == 'V') {
45+
return version[1:]
46+
}
47+
return version
3948
}
4049

4150
// ToInterval converts this constraint to an interval.

0 commit comments

Comments
 (0)