Skip to content

Commit b4ab5fc

Browse files
brondumGaardsholtOrKarstoftssagarverma
authored
Support parsing versions with custom prefixes via opt-in option (#79)
Introduce an opt-in mechanism to support parsing version strings with custom prefixes using WithPrefix. This allows callers to normalize prefixed version strings (e.g. controller-v1.2.3) before parsing, while preserving the original input for display and debugging purposes. Comparison behavior remains unchanged and operates on normalized versions. Prefix handling is intentionally left to the caller to keep the library behavior predictable and backward compatible. Documentation and tests have been updated to reflect the new functionality and usage patterns. -- Signed-off-by: Lasse Gaardsholt <lasse.gaardsholt@bestseller.com> Co-authored-by: Lasse Gaardsholt <lasse.gaardsholt@bestseller.com> Co-authored-by: Lasse Gaardsholt <lasse.gaardsholt@gmail.com> Co-authored-by: Oliver Karstoft <orkarstoft@gmail.com> Co-authored-by: Sagar Verma <sagar.verma@ibm.com>
1 parent 25c683b commit b4ab5fc

File tree

3 files changed

+208
-4
lines changed

3 files changed

+208
-4
lines changed

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,32 @@ if v1.LessThan(v2) {
3434
}
3535
```
3636

37+
#### Version Parsing and Comparison with Prefixes
38+
39+
The library also supports parsing versions with a custom prefix.
40+
Using the `WithPrefix` option, you can specify a prefix to strip
41+
before parsing the version.
42+
43+
Use `WithPrefix` when your input strings carry a known release prefix such as
44+
`deployment-`, `controller-`, etc.
45+
46+
After parsing, the prefix is not part of the canonical version value. This
47+
means the regular comparison methods such as `Compare`, `LessThan`, `Equal`,
48+
and `GreaterThan` compare only the stripped version. If you compare versions
49+
from different prefixes with these methods, the prefixes are ignored. If you
50+
need to reject cross-prefix comparisons, inspect the parsed prefixes before
51+
comparing the versions.
52+
53+
```go
54+
v1, _ := version.NewVersion("deployment-v1.2.3-beta+metadata", version.WithPrefix("deployment-"))
55+
v2, _ := version.NewVersion("deployment-v1.2.4", version.WithPrefix("deployment-"))
56+
57+
if v1.LessThan(v2) {
58+
fmt.Printf("%s (%s) is less than %s (%s)\n", v1, v1.Original(), v2, v2.Original())
59+
// Outputs: 1.2.3-beta+metadata (deployment-v1.2.3-beta+metadata) is less than 1.2.4 (deployment-v1.2.4)
60+
}
61+
```
62+
3763
#### Version Constraints
3864

3965
```go

version.go

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,19 +49,60 @@ const (
4949
`?`
5050
)
5151

52+
// Optional options for NewVersion function.
53+
type options struct {
54+
// If set, this prefix will be trimmed from the version string before parsing.
55+
prefix string
56+
}
57+
58+
// Option is a functional option for NewVersion.
59+
type Option func(*options)
60+
61+
// WithPrefix is a functional option that sets a prefix to be removed from the
62+
// version string before parsing.
63+
func WithPrefix(prefix string) Option {
64+
return func(o *options) {
65+
o.prefix = prefix
66+
}
67+
}
68+
5269
// Version represents a single version.
5370
type Version struct {
5471
metadata string
5572
pre string
5673
segments []int64
5774
si int
5875
original string
76+
prefix string
5977
}
6078

61-
// NewVersion parses the given version and returns a new
62-
// Version.
63-
func NewVersion(v string) (*Version, error) {
64-
return newVersion(v, getVersionRegexp())
79+
// NewVersion parses the given version and returns a new Version.
80+
//
81+
// Optional parsing behavior can be enabled with Option values such as
82+
// WithPrefix, which validates and strips an expected prefix before parsing.
83+
func NewVersion(v string, opts ...Option) (*Version, error) {
84+
options := &options{}
85+
for _, opt := range opts {
86+
if opt != nil {
87+
opt(options)
88+
}
89+
}
90+
91+
vToParse := v
92+
if options.prefix != "" {
93+
if !strings.HasPrefix(v, options.prefix) {
94+
return nil, fmt.Errorf("version %q does not have prefix %q", v, options.prefix)
95+
}
96+
vToParse = strings.TrimPrefix(v, options.prefix)
97+
}
98+
99+
ver, err := newVersion(vToParse, getVersionRegexp())
100+
if err != nil {
101+
return nil, err
102+
}
103+
ver.prefix = options.prefix
104+
ver.original = v
105+
return ver, nil
65106
}
66107

67108
// NewSemver parses the given version and returns a new
@@ -424,6 +465,11 @@ func (v *Version) Original() string {
424465
return v.original
425466
}
426467

468+
// Prefix returns the explicit prefix used with WithPrefix, if any.
469+
func (v *Version) Prefix() string {
470+
return v.prefix
471+
}
472+
427473
// UnmarshalText implements encoding.TextUnmarshaler interface.
428474
func (v *Version) UnmarshalText(b []byte) error {
429475
temp, err := NewVersion(string(b))

version_test.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ func TestNewVersion(t *testing.T) {
3939
{"1.7rc2", false},
4040
{"v1.7rc2", false},
4141
{"1.0-", false},
42+
{"controller-v0.40.2", true},
43+
{"azure-cli-v1.4.2", true},
4244
}
4345

4446
for _, tc := range cases {
@@ -51,6 +53,33 @@ func TestNewVersion(t *testing.T) {
5153
}
5254
}
5355

56+
func TestNewVersionWithPrefix(t *testing.T) {
57+
cases := []struct {
58+
version string
59+
prefix string
60+
err bool
61+
}{
62+
{"", "release-", true},
63+
{"rel-1.2.3", "release-", true},
64+
{"release_1.2.3", "release-", true},
65+
{"release_1.2.0-x.Y.0+metadata", "release_", false},
66+
{"release-1.2.0-x.Y.0+metadata-width-hyphen", "release-", false},
67+
{"myrelease-1.2.3-rc1-with-hyphen", "myrelease-", false},
68+
{"prefix-1.2.3.4", "prefix-", false},
69+
{"controller-v0.40.2", "controller-", false},
70+
{"azure-cli-v1.4.2", "azure-cli-", false},
71+
}
72+
73+
for _, tc := range cases {
74+
_, err := NewVersion(tc.version, WithPrefix(tc.prefix))
75+
if tc.err && err == nil {
76+
t.Fatalf("expected error for version: %q", tc.version)
77+
} else if !tc.err && err != nil {
78+
t.Fatalf("error for version %q: %s", tc.version, err)
79+
}
80+
}
81+
}
82+
5483
func TestNewSemver(t *testing.T) {
5584
cases := []struct {
5685
version string
@@ -80,6 +109,8 @@ func TestNewSemver(t *testing.T) {
80109
{"1.7rc2", true},
81110
{"v1.7rc2", true},
82111
{"1.0-", true},
112+
{"controller-v0.40.2", true},
113+
{"azure-cli-v1.4.2", true},
83114
}
84115

85116
for _, tc := range cases {
@@ -171,6 +202,107 @@ func TestVersionCompare(t *testing.T) {
171202
}
172203
}
173204

205+
func TestVersionCompareWithPrefix(t *testing.T) {
206+
cases := []struct {
207+
v1 string
208+
v1Prefix string
209+
v2 string
210+
v2Prefix string
211+
expected int
212+
}{
213+
{"controller-v0.40.2", "controller-", "controller-v0.40.3", "controller-", -1},
214+
{"0.40.4", "", "controller-v0.40.2", "controller-", 1},
215+
{"0.40.4", "", "controller-v0.40.4", "controller-", 0},
216+
{"azure-cli-v1.4.2", "azure-cli-", "azure-cli-v1.4.2", "azure-cli-", 0},
217+
{"azure-cli-v1.4.1", "azure-cli-", "azure-cli-v1.4.2", "azure-cli-", -1},
218+
{"1.4.3", "", "azure-cli-v1.4.2", "azure-cli-", 1},
219+
{"v1.4.3", "", "azure-cli-v1.4.2", "azure-cli-", 1},
220+
{"controller-v1.4.1", "controller-", "azure-cli-v1.4.2", "azure-cli-", -1},
221+
}
222+
223+
for _, tc := range cases {
224+
var v1 *Version
225+
var err error
226+
if tc.v1Prefix != "" {
227+
v1, err = NewVersion(tc.v1, WithPrefix(tc.v1Prefix))
228+
} else {
229+
v1, err = NewVersion(tc.v1)
230+
}
231+
if err != nil {
232+
t.Fatalf("err: %s", err)
233+
}
234+
235+
var v2 *Version
236+
if tc.v2Prefix != "" {
237+
v2, err = NewVersion(tc.v2, WithPrefix(tc.v2Prefix))
238+
} else {
239+
v2, err = NewVersion(tc.v2)
240+
}
241+
if err != nil {
242+
t.Fatalf("err: %s", err)
243+
}
244+
245+
actual := v1.Compare(v2)
246+
expected := tc.expected
247+
if actual != expected {
248+
t.Fatalf(
249+
"%s <=> %s\nexpected: %d\nactual: %d",
250+
tc.v1, tc.v2,
251+
expected, actual)
252+
}
253+
}
254+
}
255+
256+
func TestVersionAccessorsWithPrefix(t *testing.T) {
257+
v, err := NewVersion("controller-v1.2.0-beta.2+build.5", WithPrefix("controller-"))
258+
if err != nil {
259+
t.Fatalf("err: %s", err)
260+
}
261+
262+
if got := v.Prefix(); got != "controller-" {
263+
t.Fatalf("expected prefix %q, got %q", "controller-", got)
264+
}
265+
266+
if got := v.Original(); got != "controller-v1.2.0-beta.2+build.5" {
267+
t.Fatalf("expected original %q, got %q", "controller-v1.2.0-beta.2+build.5", got)
268+
}
269+
270+
if got := v.String(); got != "1.2.0-beta.2+build.5" {
271+
t.Fatalf("expected string %q, got %q", "1.2.0-beta.2+build.5", got)
272+
}
273+
274+
if got := v.Metadata(); got != "build.5" {
275+
t.Fatalf("expected metadata %q, got %q", "build.5", got)
276+
}
277+
278+
if got := v.Prerelease(); got != "beta.2" {
279+
t.Fatalf("expected prerelease %q, got %q", "beta.2", got)
280+
}
281+
282+
expectedSegments := []int{1, 2, 0}
283+
if got := v.Segments(); !reflect.DeepEqual(got, expectedSegments) {
284+
t.Fatalf("expected segments %#v, got %#v", expectedSegments, got)
285+
}
286+
287+
expectedSegments64 := []int64{1, 2, 0}
288+
if got := v.Segments64(); !reflect.DeepEqual(got, expectedSegments64) {
289+
t.Fatalf("expected segments64 %#v, got %#v", expectedSegments64, got)
290+
}
291+
}
292+
293+
func TestVersionSegmentsWithPrefix(t *testing.T) {
294+
v, err := NewVersion("azure-cli-v1.4.2", WithPrefix("azure-cli-"))
295+
if err != nil {
296+
t.Fatalf("err: %s", err)
297+
}
298+
299+
expected := []int{1, 4, 2}
300+
actual := v.Segments()
301+
if !reflect.DeepEqual(actual, expected) {
302+
t.Fatalf("expected: %#v\nactual: %#v", expected, actual)
303+
}
304+
}
305+
174306
func TestVersionCompare_versionAndSemver(t *testing.T) {
175307
cases := []struct {
176308
versionRaw string

0 commit comments

Comments
 (0)