From 164dcbf367e9adf1dc053e681dec92f30feae986 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Mon, 7 Sep 2026 16:48:29 +0100 Subject: [PATCH] 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) + } + }) + } + } +}