From da971f4df5c9d6d77ecb529e013b1499926a310b Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 7 Sep 2026 13:59:59 +0100 Subject: [PATCH 1/4] fix incorrect and vague doc comments Audit ahead of the README/doc.go work (issue #3): - number.go: Min, MinFloat32, MinFloat64 all said "less than or equal to a given maximum" -- copy-pasted from the Max family. They check the minimum. Fixed. - chars_test.go: ExampleExceptChars called Chars, not ExceptChars, so it never exercised the function it documents. - chars.go: "does not contain disallowed characters" was circular. - in.go: NotIn's parameter was named "allow"; renamed to "disallow". - length.go: note that length is bytes (len), not runes -- the "%d characters" messages imply otherwise. - url.go: spell out that ParseRequestURI wants an absolute URL or path. - uuid.go: note lowercase-hex only, version/variant not checked. - error.go: explain Err (the match-anything sentinel), how Error() formats with Data, and what With does. all.go: point at Collect. Comments only; no behaviour change. go vet + go test ./... pass. Co-Authored-By: Claude Sonnet 5 --- all.go | 5 +++-- chars.go | 2 +- chars_test.go | 2 +- error.go | 16 ++++++++++------ in.go | 8 ++++---- length.go | 6 ++++-- number.go | 18 +++++++++--------- url.go | 4 +++- uuid.go | 5 +++-- 9 files changed, 38 insertions(+), 28 deletions(-) diff --git a/all.go b/all.go index ef1e4fa..8394264 100644 --- a/all.go +++ b/all.go @@ -1,7 +1,8 @@ package validate -// All validates a value using a sequence of validation functions. -// If any validation function returns an error, the sequence stops and the error is returned. +// All validates a value against a sequence of validation functions, +// stopping and returning the first error. See Collect to run every +// function and report all failures at once. func All[T any](fs ...func(T) error) func(T) error { return func(value T) error { for _, f := range fs { diff --git a/chars.go b/chars.go index f43bf99..ec7fb6a 100644 --- a/chars.go +++ b/chars.go @@ -20,7 +20,7 @@ func Chars(allow string) func(string) error { } } -// ExceptChars validates whether a string does not contain disallowed characters. +// ExceptChars validates that a string contains none of the given characters. func ExceptChars(disallow string) func(string) error { return func(value string) error { for _, r := range disallow { diff --git a/chars_test.go b/chars_test.go index 704e10f..10a3956 100644 --- a/chars_test.go +++ b/chars_test.go @@ -13,7 +13,7 @@ func ExampleChars() { } func ExampleExceptChars() { - testExceptChars := Chars("0123456789abcdef") + testExceptChars := ExceptChars("0123456789abcdef") fmt.Println(testExceptChars("invalid input")) // Output: contains disallowed characters } diff --git a/error.go b/error.go index 45265f5..2f4c11d 100644 --- a/error.go +++ b/error.go @@ -2,10 +2,9 @@ package validate import "fmt" -// Validation error. -var ( - Err = Error{} -) +// Err is the zero Error. It carries no message, so errors.Is(x, Err) is +// true for any error produced by this package. +var Err = Error{} // Error represents a validation error. type Error struct { @@ -13,8 +12,9 @@ type Error struct { Data []any } -// Error retrieves the message of a validation Error. -// If it has Data, the message will be formatted. +// Error returns the error message. If Data is non-empty, Message is used +// as an fmt.Sprintf format string and Data as its arguments (this is how +// the sentinels with %d/%q verbs are filled in, e.g. via With). func (e Error) Error() string { if len(e.Data) > 0 { return fmt.Sprintf(e.Message, e.Data...) @@ -34,6 +34,10 @@ func (e Error) Is(target error) bool { return false } +// With returns a copy of the Error with value appended to Data, so it +// lands in the message when Message contains a formatting verb: +// +// ErrMustBeLonger.With(4) // "must contain at least 4 characters" func (e Error) With(value any) Error { if e.Data == nil { e.Data = []any{} diff --git a/in.go b/in.go index 87fcd2a..7327402 100644 --- a/in.go +++ b/in.go @@ -4,7 +4,7 @@ var ( ErrValueNotAllowed = NewError("not allowed") ) -// In validates whether a value is found in a slice of allowed values. +// In validates that a value equals one of the allowed values. func In[T comparable](allow ...T) func(T) error { return func(value T) error { for _, cmp := range allow { @@ -16,10 +16,10 @@ func In[T comparable](allow ...T) func(T) error { } } -// NotIn validates whether a value is not found in a slice of disallowed values. -func NotIn[T comparable](allow ...T) func(T) error { +// NotIn validates that a value equals none of the disallowed values. +func NotIn[T comparable](disallow ...T) func(T) error { return func(value T) error { - for _, cmp := range allow { + for _, cmp := range disallow { if cmp == value { return ErrValueNotAllowed } diff --git a/length.go b/length.go index db0013a..02d77f6 100644 --- a/length.go +++ b/length.go @@ -5,7 +5,8 @@ var ( ErrMustBeShorter = NewError("must contain no more than %d characters") ) -// MaxLength validates the length of a string as being less than or equal to a given maximum. +// MaxLength validates that a string is no longer than a given maximum. +// Length is measured in bytes (len), not runes. func MaxLength(l int) func(string) error { return func(value string) error { if len(value) > l { @@ -15,7 +16,8 @@ func MaxLength(l int) func(string) error { } } -// MinLength validates the length of a string as being greater than or equal to a given minimum. +// MinLength validates that a string is at least a given minimum length. +// Length is measured in bytes (len), not runes. func MinLength(l int) func(string) error { return func(value string) error { if len(value) < l { diff --git a/number.go b/number.go index 9db317b..11cea8a 100644 --- a/number.go +++ b/number.go @@ -8,7 +8,7 @@ var ( ) // Max validates whether an integer is less than or equal to a given maximum. -// If exclusive is true, an equal value will also produce an error. +// If exclusive is true, an equal value also produces an error. func Max(n int, exclusive bool) func(int) error { return func(value int) error { if exclusive { @@ -24,7 +24,7 @@ func Max(n int, exclusive bool) func(int) error { } // MaxFloat32 validates whether a float32 is less than or equal to a given maximum. -// If exclusive is true, an equal value will also produce an error. +// If exclusive is true, an equal value also produces an error. func MaxFloat32(n float32, exclusive bool) func(float32) error { return func(value float32) error { if exclusive { @@ -40,7 +40,7 @@ func MaxFloat32(n float32, exclusive bool) func(float32) error { } // MaxFloat64 validates whether a float64 is less than or equal to a given maximum. -// If exclusive is true, an equal value will also produce an error. +// If exclusive is true, an equal value also produces an error. func MaxFloat64(n float64, exclusive bool) func(float64) error { return func(value float64) error { if exclusive { @@ -55,8 +55,8 @@ func MaxFloat64(n float64, exclusive bool) func(float64) error { } } -// Min validates whether an integer is less than or equal to a given maximum. -// If exclusive is true, an equal value will also produce an error. +// Min validates whether an integer is greater than or equal to a given minimum. +// If exclusive is true, an equal value also produces an error. func Min(n int, exclusive bool) func(int) error { return func(value int) error { if exclusive { @@ -71,8 +71,8 @@ func Min(n int, exclusive bool) func(int) error { } } -// MinFloat32 validates whether a float32 is less than or equal to a given maximum. -// If exclusive is true, an equal value will also produce an error. +// MinFloat32 validates whether a float32 is greater than or equal to a given minimum. +// If exclusive is true, an equal value also produces an error. func MinFloat32(n float32, exclusive bool) func(float32) error { return func(value float32) error { if exclusive { @@ -87,8 +87,8 @@ func MinFloat32(n float32, exclusive bool) func(float32) error { } } -// MinFloat64 validates whether a float64 is less than or equal to a given maximum. -// If exclusive is true, an equal value will also produce an error. +// MinFloat64 validates whether a float64 is greater than or equal to a given minimum. +// If exclusive is true, an equal value also produces an error. func MinFloat64(n float64, exclusive bool) func(float64) error { return func(value float64) error { if exclusive { diff --git a/url.go b/url.go index e0c30f4..fe9f15f 100644 --- a/url.go +++ b/url.go @@ -6,7 +6,9 @@ var ( ErrInvalidURL Error = NewError("invalid URL") ) -// URL validates a URL. +// URL validates that a string is an absolute URL (with a scheme) or an +// absolute path, per net/url.ParseRequestURI. "example.com" with no +// scheme is rejected. func URL(value string) error { if _, err := url.ParseRequestURI(value); err != nil { return ErrInvalidURL diff --git a/uuid.go b/uuid.go index 89ce9ea..341b337 100644 --- a/uuid.go +++ b/uuid.go @@ -10,8 +10,9 @@ var ( var uuidRegexp = regexp.MustCompile("^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$") -// UUID validates a UUID string. -// The UUID must be formatted with separators. +// UUID validates a UUID string in the canonical 8-4-4-4-12 hyphenated +// form. Only lowercase hexadecimal is accepted; the version and variant +// bits are not checked. func UUID(value string) error { if !uuidRegexp.MatchString(value) { return ErrInvalidUUID -- 2.54.0 From 4483105a7818664913ecef048197c7b3f59a93ed Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 7 Sep 2026 14:01:03 +0100 Subject: [PATCH 2/4] docs: positioning README, package doc, usage examples Closes #3. - doc.go: package overview -- what a validator is, All vs Collect, errors are sentinels, how this compares to validator/v10 (tags+reflection), ozzo-validation (Rule interface) and govalidator (IsX helpers), and when to pick something else. - README.md: rewritten from three lines to a positioning intro, a "why this and not X" table, a building-blocks table, and a "not a fit if" note. - example_test.go: runnable Example (compose built-ins + a closure), Example_collectAll (errors.Is over a joined error), Example_errorsIs (sentinel matching, Err as match-anything). Co-Authored-By: Claude Sonnet 5 --- README.md | 82 +++++++++++++++++++++++++++++++++++++++++++++++-- doc.go | 41 +++++++++++++++++++++++++ example_test.go | 66 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 doc.go create mode 100644 example_test.go diff --git a/README.md b/README.md index f4070e8..b3cd0c3 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,85 @@ # Go Validate -A suite of straightforward validation functions. You put something in, you get back `nil` or an error. +Small, composable value validators for Go. A validator is just a +`func(T) error` — it returns `nil` or an error. You compose them with +`All` or `Collect` and drop in plain closures wherever you need something +custom. No struct tags, no reflection, no dependencies outside the +standard library. + +```go +username := validate.All( + validate.MinLength(3), + validate.MaxLength(16), + validate.Chars("abcdefghijklmnopqrstuvwxyz0123456789_"), + func(s string) error { + if strings.HasPrefix(s, "_") { + return errors.New("must not start with an underscore") + } + return nil + }, +) + +if err := username(input); err != nil { + // errors.Is(err, validate.ErrDisallowedChars) still works +} +``` + +## Why this and not one of the established packages + +| Package | Style | +|---|---| +| [`go-playground/validator`](https://github.com/go-playground/validator) | struct tags (`validate:"required,email"`) driven by reflection | +| [`go-ozzo/ozzo-validation`](https://github.com/go-ozzo/ozzo-validation) | rules composed in code, through a `Rule` interface over `interface{}` | +| [`asaskevich/govalidator`](https://github.com/asaskevich/govalidator) | a bag of `IsEmail` / `IsURL` string helpers | + +This package is the code-composition style (closest to ozzo-validation) +with two constraints held on purpose: + +- **Validators are ordinary generic functions.** `In`, `Equal` and the + rest are type-checked by the compiler — pass the wrong type and it does + not build. The tag/reflection libraries can't do this; they predate + generics. +- **Errors are plain sentinels.** `errors.Is(err, ErrInvalidEmail)` + composes with the standard `errors` package. There is no bespoke + `ValidationErrors` type to learn. `Err` matches any error from the + package. + +Everything is value-level: there is no struct walker. You wire fields +together yourself (a few lines) and decide how to present the result. + +## Fail fast or collect everything + +```go +// stops at the first failure +validate.All(rules...) + +// runs every rule, joins the failures with errors.Join; +// errors.Is still matches each one +validate.Collect(rules...) +``` + +## Building blocks + +| Group | Functions | +|---|---| +| Compose | `All`, `Collect` | +| Presence / equality | `Required`, `Equal`, `In`, `NotIn` | +| String length & content | `MinLength`, `MaxLength`, `Chars`, `ExceptChars`, `Prefix`, `Suffix`, `Contains`, `Match` | +| Formats | `Email`, `URL`, `UUID` | +| Numbers | `Min`, `Max`, `MinFloat32`, `MaxFloat32`, `MinFloat64`, `MaxFloat64` | +| Slices | `MinSize`, `MaxSize` | +| Errors | `Error`, `NewError`, `Err` | + +Each returns (or is) a `func(T) error`, so anything you write with the +same shape composes with them. + +## Not a fit if… + +You want **struct-tag validation**, **translated / i18n messages**, or a +**large catalogue** of built-in checks (credit cards, ISO codes, CIDRs, …). +Use [`go-playground/validator`](https://github.com/go-playground/validator) +for that. This package deliberately stays small. ## License -See [LICENSE.md](./LICENSE.md) +MIT. See [LICENSE.md](./LICENSE.md). diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..3834495 --- /dev/null +++ b/doc.go @@ -0,0 +1,41 @@ +// Package validate provides small, composable value validators. +// +// A validator is just a func(T) error: it returns nil when the value is +// acceptable, or an error describing the problem. Nothing has to implement +// an interface, so a plain closure works anywhere a validator is expected. +// Combine validators with [All] (stop at the first failure) or [Collect] +// (run them all and join every failure). +// +// username := validate.All( +// validate.MinLength(3), +// validate.MaxLength(16), +// validate.Chars("abcdefghijklmnopqrstuvwxyz0123456789_"), +// ) +// if err := username(input); err != nil { +// // errors.Is(err, validate.ErrDisallowedChars) still works here +// } +// +// Errors are plain sentinel values (see [Error]); match them with +// errors.Is. [Err] matches any error produced by this package. +// +// # Comparison with other packages +// +// - go-playground/validator drives validation from struct tags and +// reflection. +// - go-ozzo/ozzo-validation composes rules in code, but through a Rule +// interface over interface{}. +// - asaskevich/govalidator is a bag of IsX string helpers. +// +// This package is the code-composition style with two constraints kept +// deliberately: validators are ordinary generic functions, checked by the +// compiler with no reflection ([In], [Equal] and the rest will not accept +// the wrong type), and there are no dependencies outside the standard +// library. +// +// # When to use something else +// +// Reach for go-playground/validator if you want struct-tag validation, +// translated messages, or a large catalogue of built-in checks. This +// package stays small and leaves struct/field wiring and error +// presentation to the caller. +package validate diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..acee601 --- /dev/null +++ b/example_test.go @@ -0,0 +1,66 @@ +package validate_test + +import ( + "errors" + "fmt" + "strings" + + "code.aneur.in/go/validate" +) + +// A validator is any func(T) error. Compose the built-ins with your own +// closures using All (stop at the first failure) or Collect (report all). +func Example() { + username := validate.All( + validate.MinLength(3), + validate.MaxLength(16), + validate.Chars("abcdefghijklmnopqrstuvwxyz0123456789_"), + func(s string) error { + if strings.HasPrefix(s, "_") { + return errors.New("must not start with an underscore") + } + return nil + }, + ) + + fmt.Println(username("ok_name")) + fmt.Println(username("_nope")) + fmt.Println(username("Nope")) + // Output: + // + // must not start with an underscore + // contains disallowed characters +} + +// Collect runs every validator and joins the failures; errors.Is still +// matches each one. +func Example_collectAll() { + password := validate.Collect( + validate.MinLength(8), + validate.Chars("abcdefghijklmnopqrstuvwxyz"), + ) + + err := password("Ab1") + fmt.Println(err) + fmt.Println(errors.Is(err, validate.ErrMustBeLonger)) + fmt.Println(errors.Is(err, validate.ErrDisallowedChars)) + // Output: + // must contain at least 8 characters + // contains disallowed characters + // true + // true +} + +// Errors are plain sentinels, so errors.Is works with no library-specific +// machinery. Err matches any error from this package. +func Example_errorsIs() { + err := validate.Email("not-an-email") + + fmt.Println(err) + fmt.Println(errors.Is(err, validate.ErrInvalidEmail)) + fmt.Println(errors.Is(err, validate.Err)) + // Output: + // invalid email address + // true + // true +} -- 2.54.0 From 4e2e05c1b9db6c9d59c865520d2add4afa6f0fa6 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Mon, 7 Sep 2026 16:27:22 +0100 Subject: [PATCH 3/4] length: count runes, not bytes MinLength/MaxLength measured len(value), so a string of 8 accented characters (9+ bytes) failed MaxLength(8) despite the error message promising "characters". Count with utf8.RuneCountInString so the check matches the wording and the common-sense intent. Still not grapheme-cluster aware (combining marks, emoji ZWJ sequences count as several runes), which is out of scope for a stdlib-only helper. Co-Authored-By: Claude Sonnet 5 --- length.go | 10 ++++++---- length_test.go | 18 ++++++++++++++++-- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/length.go b/length.go index 02d77f6..22987cd 100644 --- a/length.go +++ b/length.go @@ -1,15 +1,17 @@ package validate +import "unicode/utf8" + var ( ErrMustBeLonger = NewError("must contain at least %d characters") ErrMustBeShorter = NewError("must contain no more than %d characters") ) // MaxLength validates that a string is no longer than a given maximum. -// Length is measured in bytes (len), not runes. +// Length is counted in runes, so multi-byte characters count as one. func MaxLength(l int) func(string) error { return func(value string) error { - if len(value) > l { + if utf8.RuneCountInString(value) > l { return ErrMustBeShorter.With(l) } return nil @@ -17,10 +19,10 @@ func MaxLength(l int) func(string) error { } // MinLength validates that a string is at least a given minimum length. -// Length is measured in bytes (len), not runes. +// Length is counted in runes, so multi-byte characters count as one. func MinLength(l int) func(string) error { return func(value string) error { - if len(value) < l { + if utf8.RuneCountInString(value) < l { return ErrMustBeLonger.With(l) } return nil diff --git a/length_test.go b/length_test.go index defd24e..b471c38 100644 --- a/length_test.go +++ b/length_test.go @@ -20,7 +20,14 @@ func ExampleMinLength() { func TestMaxLength(t *testing.T) { testCases := map[int]map[string]error{ - 8: {"abcd": nil, "abcdefgh": nil, "abcd efg": nil, "abcdefghi": ErrMustBeShorter.With(8)}, + 8: { + "abcd": nil, + "abcdefgh": nil, + "abcd efg": nil, + "abcdéfgh": nil, // 8 runes, 9 bytes + "abcdefghi": ErrMustBeShorter.With(8), + "abcdéfghi": ErrMustBeShorter.With(8), // 9 runes, 10 bytes + }, } for setup, values := range testCases { @@ -41,7 +48,14 @@ func TestMaxLength(t *testing.T) { func TestMinLength(t *testing.T) { testCases := map[int]map[string]error{ - 8: {"abcd": ErrMustBeLonger.With(8), "abcdefgh": nil, "abcd efg": nil, "abcdefghi": nil}, + 8: { + "abcd": ErrMustBeLonger.With(8), + "abcdéfg": ErrMustBeLonger.With(8), // 7 runes, 8 bytes + "abcdefgh": nil, + "abcdéfgh": nil, // 8 runes, 9 bytes + "abcd efg": nil, + "abcdefghi": nil, + }, } for setup, values := range testCases { -- 2.54.0 From 164dcbf367e9adf1dc053e681dec92f30feae986 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Mon, 7 Sep 2026 16:48:29 +0100 Subject: [PATCH 4/4] length: add MinLengthBytes / MaxLengthBytes MinLength/MaxLength now count runes, leaving no way to bound a string by its byte size -- still wanted for fixed-width columns and wire-format fields. Add the byte-counting pair alongside them, with their own "%d bytes" sentinels (ErrMustHaveMoreBytes / ErrMustHaveFewerBytes). Co-Authored-By: Claude Sonnet 5 --- README.md | 2 +- length.go | 31 ++++++++++++++++++++++-- length_test.go | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b3cd0c3..4f9fd0e 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ validate.Collect(rules...) |---|---| | Compose | `All`, `Collect` | | Presence / equality | `Required`, `Equal`, `In`, `NotIn` | -| String length & content | `MinLength`, `MaxLength`, `Chars`, `ExceptChars`, `Prefix`, `Suffix`, `Contains`, `Match` | +| String length & content | `MinLength`, `MaxLength` (runes), `MinLengthBytes`, `MaxLengthBytes`, `Chars`, `ExceptChars`, `Prefix`, `Suffix`, `Contains`, `Match` | | Formats | `Email`, `URL`, `UUID` | | Numbers | `Min`, `Max`, `MinFloat32`, `MaxFloat32`, `MinFloat64`, `MaxFloat64` | | Slices | `MinSize`, `MaxSize` | diff --git a/length.go b/length.go index 22987cd..a0cf8f7 100644 --- a/length.go +++ b/length.go @@ -3,8 +3,10 @@ package validate import "unicode/utf8" var ( - ErrMustBeLonger = NewError("must contain at least %d characters") - ErrMustBeShorter = NewError("must contain no more than %d characters") + ErrMustBeLonger = NewError("must contain at least %d characters") + ErrMustBeShorter = NewError("must contain no more than %d characters") + ErrMustHaveMoreBytes = NewError("must have at least %d bytes") + ErrMustHaveFewerBytes = NewError("must have no more than %d bytes") ) // MaxLength validates that a string is no longer than a given maximum. @@ -28,3 +30,28 @@ func MinLength(l int) func(string) error { return nil } } + +// MaxLengthBytes validates that a string is no longer than a given maximum +// number of bytes (len). Prefer [MaxLength] for a limit on visible +// characters; use this when the budget is genuinely a byte count, such as +// a fixed-width column or a wire-format field. +func MaxLengthBytes(l int) func(string) error { + return func(value string) error { + if len(value) > l { + return ErrMustHaveFewerBytes.With(l) + } + return nil + } +} + +// MinLengthBytes validates that a string is at least a given minimum +// number of bytes (len). See [MaxLengthBytes] on when to prefer this over +// [MinLength]. +func MinLengthBytes(l int) func(string) error { + return func(value string) error { + if len(value) < l { + return ErrMustHaveMoreBytes.With(l) + } + return nil + } +} diff --git a/length_test.go b/length_test.go index b471c38..f1b496d 100644 --- a/length_test.go +++ b/length_test.go @@ -73,3 +73,69 @@ func TestMinLength(t *testing.T) { } } } + +func ExampleMaxLengthBytes() { + testMaxLengthBytes := MaxLengthBytes(8) + fmt.Println(testMaxLengthBytes("cafés round the world")) + // Output: must have no more than 8 bytes +} + +func ExampleMinLengthBytes() { + testMinLengthBytes := MinLengthBytes(8) + fmt.Println(testMinLengthBytes("2short")) + // Output: must have at least 8 bytes +} + +func TestMaxLengthBytes(t *testing.T) { + testCases := map[int]map[string]error{ + 8: { + "abcd": nil, + "abcdefgh": nil, + "abcdéfg": nil, // 7 runes, 8 bytes + "abcdéfgh": ErrMustHaveFewerBytes.With(8), // 8 runes, 9 bytes + "abcdefghi": ErrMustHaveFewerBytes.With(8), + }, + } + + for setup, values := range testCases { + testMaxLengthBytes := MaxLengthBytes(setup) + + for input, want := range values { + t.Run(fmt.Sprintf("%d/%s", setup, input), func(t *testing.T) { + got := testMaxLengthBytes(input) + + if !errors.Is(got, want) { + t.Error("got", got) + t.Error("want", want) + } + }) + } + } +} + +func TestMinLengthBytes(t *testing.T) { + testCases := map[int]map[string]error{ + 8: { + "abcd": ErrMustHaveMoreBytes.With(8), + "abcdefg": ErrMustHaveMoreBytes.With(8), + "abcdéf": ErrMustHaveMoreBytes.With(8), // 6 runes, 7 bytes + "abcdéfg": nil, // 7 runes, 8 bytes + "abcdefgh": nil, + }, + } + + for setup, values := range testCases { + testMinLengthBytes := MinLengthBytes(setup) + + for input, want := range values { + t.Run(fmt.Sprintf("%d/%s", setup, input), func(t *testing.T) { + got := testMinLengthBytes(input) + + if !errors.Is(got, want) { + t.Error("got", got) + t.Error("want", want) + } + }) + } + } +} -- 2.54.0