diff --git a/CHANGELOG.md b/CHANGELOG.md index d5ce1c0..3fc091a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add ISO week validation with `it.IsWeek()`, `validate.Week`, and `is.Week`, inclusive min/max bounds, calendar-aware week 53 checks, and English/Russian messages. No new dependencies; document empty-value and literal early-year behavior. + - Add `it.HasMinWordCount`, `it.HasMaxWordCount`, and `it.HasWordCountBetween` with Unicode-aware counting, customizable errors/messages, and English/Russian plural forms. No new dependencies; document differences from ICU locale-aware segmentation. - Add country, language, and locale validation with `it.IsCountry()`, `it.IsLanguage()`, `it.IsLocale()`, and matching `is`/`validate` helpers. Use existing `golang.org/x/text/language` data without new dependencies; include English/Russian messages and documentation of accepted formats, special codes, and application-support limitations. diff --git a/README.md b/README.md index 9c8b357..305d3ca 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,41 @@ them; message parameters are `{{ count }}`, `{{ limit }}`, and `{{ value }}` (the quoted input). Conditional validation, groups, and English/Russian plural forms are supported. +### ISO weeks + +Use `it.IsWeek()` for strings such as `2020-W53`. Optional `WithMin` and `WithMax` +set inclusive bounds, including ranges across ISO years: + +```go +err := validator.Validate(context.Background(), + validation.String(week, it.IsWeek().WithMin("2020-W01").WithMax("2021-W01")), +) +``` + +The format is exactly `YYYY-Www`: four ASCII year digits, uppercase `W`, and a +zero-padded week from `01` to `53`. Week 53 is accepted only in ISO years that +have it. Years `0000`–`9999` are interpreted literally in the proleptic Gregorian +calendar, with no two-digit-year remapping. No whitespace is trimmed. + +The behavior is based on [Symfony Week](https://symfony.com/doc/current/reference/constraints/Week.html). +Unlike Symfony, empty strings are skipped, following this library's convention; +nil pointers are also skipped. Use `it.IsNotBlank()` when a value is required. +Literal early years follow Go's calendar, without PHP `mktime`'s remapping of +some years below 100. No local timezone or new dependencies are involved. + +Standalone helpers are `validate.Week` and `is.Week`, with optional +`validate.WithMinWeek` and `validate.WithMaxWeek`. Empty bounds remove restrictions; +malformed, nonexistent, or reversed bounds produce `validate.ErrInvalidWeekBounds` +(or a constraint configuration error through `it`), even for empty values. + +The four violation errors are `validation.ErrInvalidWeekFormat`, +`validation.ErrInvalidWeekNumber`, `validation.ErrWeekTooEarly`, and +`validation.ErrWeekTooLate`. Customize them with `WithFormatError`, +`WithWeekNumberError`, `WithMinError`, and `WithMaxError`; corresponding `Message` +methods accept `{{ value }}`, `{{ min }}`, `{{ max }}`, and custom parameters. +The constraint supports `When`, `WhenGroups`, `This`, `Each`, and English/Russian +translations. Disabled constraints and unmatched groups skip validation entirely. + ## Documentation | Topic | Description | diff --git a/errors.go b/errors.go index cd81ea6..c14410a 100644 --- a/errors.go +++ b/errors.go @@ -42,6 +42,10 @@ var ( ErrInvalidJSON = NewError("invalid JSON", message.InvalidJSON) ErrInvalidLUHN = NewError("invalid LUHN", message.InvalidLUHN) ErrInvalidMAC = NewError("invalid MAC address", message.InvalidMAC) + ErrInvalidWeekFormat = NewError("invalid week format", message.InvalidWeekFormat) + ErrInvalidWeekNumber = NewError("invalid week number", message.InvalidWeekNumber) + ErrWeekTooEarly = NewError("week too early", message.WeekTooEarly) + ErrWeekTooLate = NewError("week too late", message.WeekTooLate) ErrInvalidTime = NewError("invalid time", message.InvalidTime) ErrInvalidTimezone = NewError("invalid timezone", message.InvalidTimezone) ErrInvalidULID = NewError("invalid ULID", message.InvalidULID) diff --git a/is/week.go b/is/week.go new file mode 100644 index 0000000..3621532 --- /dev/null +++ b/is/week.go @@ -0,0 +1,9 @@ +package is + +import "github.com/muonsoft/validation/validate" + +// Week reports whether value is a valid ISO week within optional inclusive bounds. +// See [validate.Week] for format, year range, options, and empty-value behavior. +func Week(value string, options ...func(*validate.WeekOptions)) bool { + return validate.Week(value, options...) == nil +} diff --git a/is/week_test.go b/is/week_test.go new file mode 100644 index 0000000..f06d173 --- /dev/null +++ b/is/week_test.go @@ -0,0 +1,28 @@ +package is_test + +import ( + "fmt" + "testing" + + "github.com/muonsoft/validation/is" + "github.com/muonsoft/validation/validate" + "github.com/stretchr/testify/require" +) + +func TestWeek(t *testing.T) { + require.True(t, is.Week("")) + require.True(t, is.Week("2020-W53")) + require.False(t, is.Week("2021-W53")) + require.False(t, is.Week("2020-W1")) + require.False(t, is.Week("2020-W53", validate.WithMinWeek("2021-W01"))) + require.False(t, is.Week("2021-W01", validate.WithMaxWeek("2020-W53"))) + require.False(t, is.Week("", validate.WithMinWeek("invalid"))) +} + +func ExampleWeek() { + fmt.Println(is.Week("2020-W53")) + fmt.Println(is.Week("2021-W53")) + // Output: + // true + // false +} diff --git a/it/example_test.go b/it/example_test.go index bf63e53..190051f 100644 --- a/it/example_test.go +++ b/it/example_test.go @@ -1181,3 +1181,17 @@ func ExampleHasWordCountBetween_invalid() { // Output: // violation: "This value should contain at least 2 words." } + +func ExampleIsWeek_valid() { + err := validator.Validate(context.Background(), validation.String("2020-W53", it.IsWeek().WithMin("2020-W01").WithMax("2021-W01"))) + fmt.Println(err) + // Output: + // +} + +func ExampleIsWeek_invalid() { + err := validator.Validate(context.Background(), validation.String("2021-W53", it.IsWeek())) + fmt.Println(err) + // Output: + // violation: "Week 2021-W53 does not exist in its ISO year." +} diff --git a/it/week.go b/it/week.go new file mode 100644 index 0000000..879c71e --- /dev/null +++ b/it/week.go @@ -0,0 +1,168 @@ +package it + +import ( + "context" + "errors" + + "github.com/muonsoft/validation" + "github.com/muonsoft/validation/validate" +) + +// WeekConstraint validates ISO weeks with optional inclusive bounds. +// See [validate.Week] for the format and literal year range 0000–9999. +// Nil and empty values are ignored. Use [IsNotBlank] to require a value. +type WeekConstraint struct { + isIgnored bool + groups []string + min, max string + formatErr error + formatMessage string + formatParameters validation.TemplateParameterList + numberErr error + numberMessage string + numberParameters validation.TemplateParameterList + minErr error + minMessage string + minParameters validation.TemplateParameterList + maxErr error + maxMessage string + maxParameters validation.TemplateParameterList +} + +// IsWeek creates a constraint for ISO weeks in YYYY-Www format. +// WithMin and WithMax restrict the range; malformed or reversed bounds +// produce a constraint configuration error during validation. +func IsWeek() WeekConstraint { + return WeekConstraint{ + formatErr: validation.ErrInvalidWeekFormat, + formatMessage: validation.ErrInvalidWeekFormat.Message(), + numberErr: validation.ErrInvalidWeekNumber, + numberMessage: validation.ErrInvalidWeekNumber.Message(), + minErr: validation.ErrWeekTooEarly, + minMessage: validation.ErrWeekTooEarly.Message(), + maxErr: validation.ErrWeekTooLate, + maxMessage: validation.ErrWeekTooLate.Message(), + } +} + +// WithMin sets the inclusive minimum ISO week; empty removes the bound. +func (c WeekConstraint) WithMin(value string) WeekConstraint { + c.min = value + return c +} + +// WithMax sets the inclusive maximum ISO week; empty removes the bound. +func (c WeekConstraint) WithMax(value string) WeekConstraint { + c.max = value + return c +} + +// WithFormatError overrides the underlying error for the format check. +func (c WeekConstraint) WithFormatError(err error) WeekConstraint { + c.formatErr = err + return c +} + +// WithFormatMessage overrides the message for the format check. +// Available parameters are {{ value }}, {{ min }}, and {{ max }}. +func (c WeekConstraint) WithFormatMessage(template string, parameters ...validation.TemplateParameter) WeekConstraint { + c.formatMessage = template + c.formatParameters = parameters + return c +} + +// WithWeekNumberError overrides the underlying error for the number check. +func (c WeekConstraint) WithWeekNumberError(err error) WeekConstraint { + c.numberErr = err + return c +} + +// WithWeekNumberMessage overrides the message for the number check. +// Available parameters are {{ value }}, {{ min }}, and {{ max }}. +func (c WeekConstraint) WithWeekNumberMessage(template string, parameters ...validation.TemplateParameter) WeekConstraint { + c.numberMessage = template + c.numberParameters = parameters + return c +} + +// WithMinError overrides the underlying error for the min check. +func (c WeekConstraint) WithMinError(err error) WeekConstraint { + c.minErr = err + return c +} + +// WithMinMessage overrides the message for the min check. +// Available parameters are {{ value }}, {{ min }}, and {{ max }}. +func (c WeekConstraint) WithMinMessage(template string, parameters ...validation.TemplateParameter) WeekConstraint { + c.minMessage = template + c.minParameters = parameters + return c +} + +// WithMaxError overrides the underlying error for the max check. +func (c WeekConstraint) WithMaxError(err error) WeekConstraint { + c.maxErr = err + return c +} + +// WithMaxMessage overrides the message for the max check. +// Available parameters are {{ value }}, {{ min }}, and {{ max }}. +func (c WeekConstraint) WithMaxMessage(template string, parameters ...validation.TemplateParameter) WeekConstraint { + c.maxMessage = template + c.maxParameters = parameters + return c +} + +// When disables validation when condition is false. +func (c WeekConstraint) When(condition bool) WeekConstraint { + c.isIgnored = !condition + return c +} + +// WhenGroups limits validation to the specified groups. +func (c WeekConstraint) WhenGroups(groups ...string) WeekConstraint { + c.groups = groups + return c +} + +// ValidateString validates an optional ISO week string. +func (c WeekConstraint) ValidateString(ctx context.Context, validator *validation.Validator, value *string) error { + if c.isIgnored || validator.IsIgnoredForGroups(c.groups...) { + return nil + } + input := "" + if value != nil { + input = *value + } + err := validate.Week(input, validate.WithMinWeek(c.min), validate.WithMaxWeek(c.max)) + if err == nil { + return nil + } + if errors.Is(err, validate.ErrInvalidWeekBounds) { + return validator.CreateConstraintError("WeekConstraint", "bounds must be valid ISO weeks and minimum must not exceed maximum") + } + template, parameters, cause := c.violation(err) + return validator.BuildViolation(ctx, cause, template).WithParameters(parameters.Prepend( + validation.TemplateParameter{Key: "{{ value }}", Value: input}, + validation.TemplateParameter{Key: "{{ min }}", Value: c.min}, + validation.TemplateParameter{Key: "{{ max }}", Value: c.max}, + )...).Create() +} + +func (c WeekConstraint) violation(err error) (string, validation.TemplateParameterList, error) { + switch { + case errors.Is(err, validate.ErrInvalidWeekNumber): + return c.numberMessage, c.numberParameters, c.numberErr + case errors.Is(err, validate.ErrWeekTooEarly): + return c.minMessage, c.minParameters, c.minErr + case errors.Is(err, validate.ErrWeekTooLate): + return c.maxMessage, c.maxParameters, c.maxErr + default: + return c.formatMessage, c.formatParameters, c.formatErr + } +} + +// Validate implements [validation.Constraint][string] for [validation.This] and [validation.Each]. +func (c WeekConstraint) Validate(ctx context.Context, validator *validation.Validator, value string) error { + return c.ValidateString(ctx, validator, &value) +} diff --git a/it/week_test.go b/it/week_test.go new file mode 100644 index 0000000..46e58fb --- /dev/null +++ b/it/week_test.go @@ -0,0 +1,59 @@ +package it_test + +import ( + "context" + "testing" + + "github.com/muonsoft/language" + "github.com/muonsoft/validation" + "github.com/muonsoft/validation/it" + "github.com/muonsoft/validation/message/translations/russian" + "github.com/stretchr/testify/require" +) + +func TestWeekTranslations(t *testing.T) { + v, err := validation.NewValidator(validation.Translations(russian.Messages)) + require.NoError(t, err) + tests := []struct { + value string + constraint it.WeekConstraint + want string + }{ + {"bad", it.IsWeek(), "Значение не является допустимой ISO-неделей в формате YYYY-Www."}, + {"2021-W53", it.IsWeek(), "Неделя 2021-W53 не существует в указанном ISO-году."}, + {"2020-W53", it.IsWeek().WithMin("2021-W01"), "Значение должно быть не раньше недели 2021-W01."}, + {"2021-W01", it.IsWeek().WithMax("2020-W53"), "Значение должно быть не позже недели 2020-W53."}, + } + for _, tt := range tests { + t.Run(tt.value+tt.want, func(t *testing.T) { + require.EqualError(t, tt.constraint.Validate(context.Background(), v.WithLanguage(language.Russian), tt.value), `violation: "`+tt.want+`"`) + }) + } +} + +func TestWeekGroupsAndCopies(t *testing.T) { + v, err := validation.NewValidator() + require.NoError(t, err) + base := it.IsWeek().WithMin("2020-W53").WhenGroups("schedule") + derived := base.WithMin("2021-W01") + require.NoError(t, derived.Validate(context.Background(), v, "2020-W53")) + require.NoError(t, base.Validate(context.Background(), v.WithGroups("schedule"), "2020-W53")) + require.ErrorIs(t, derived.Validate(context.Background(), v.WithGroups("schedule"), "2020-W53"), validation.ErrWeekTooEarly) + require.NoError(t, v.Validate(context.Background(), validation.This("2020-W53", it.IsWeek()))) + require.ErrorIs(t, v.Validate(context.Background(), validation.Each([]string{"2020-W53", "2021-W53"}, it.IsWeek())), validation.ErrInvalidWeekNumber) + require.NoError(t, it.IsWeek().WithMin("2021-W01").WithMin("").Validate(context.Background(), v, "2020-W53")) + require.NoError(t, it.IsWeek().WithMax("2020-W53").WithMax("").Validate(context.Background(), v, "2021-W01")) +} + +func TestWeekMessageParameters(t *testing.T) { + v, err := validation.NewValidator() + require.NoError(t, err) + template := "{{ value }}: {{ min }}..{{ max }} {{ custom }}" + parameter := validation.TemplateParameter{Key: "{{ custom }}", Value: "custom"} + c := it.IsWeek().WithMin("2020-W01").WithMax("2020-W53"). + WithFormatMessage(template, parameter).WithWeekNumberMessage(template, parameter). + WithMinMessage(template, parameter).WithMaxMessage(template, parameter) + for _, value := range []string{"bad", "2021-W53", "2019-W52", "2021-W01"} { + require.EqualError(t, c.Validate(context.Background(), v, value), `violation: "`+value+`: 2020-W01..2020-W53 custom"`) + } +} diff --git a/message/messages.go b/message/messages.go index d80e13f..cbd9f2b 100644 --- a/message/messages.go +++ b/message/messages.go @@ -60,6 +60,10 @@ const ( InvalidJSON = "This value should be valid JSON." InvalidLUHN = "Invalid card number." InvalidMAC = "This value is not a valid MAC address." + InvalidWeekFormat = "This value is not a valid ISO week in YYYY-Www format." + InvalidWeekNumber = "Week {{ value }} does not exist in its ISO year." + WeekTooEarly = "This value should be on or after week {{ min }}." + WeekTooLate = "This value should be on or before week {{ max }}." InvalidTime = "This value is not a valid time." InvalidTimezone = "This value is not a valid timezone." InvalidULID = "This is not a valid ULID." diff --git a/message/translations/english/messages.go b/message/translations/english/messages.go index 917efb0..489ab62 100644 --- a/message/translations/english/messages.go +++ b/message/translations/english/messages.go @@ -73,6 +73,10 @@ var Messages = map[language.Tag]map[string]catalog.Message{ message.InvalidJSON: catalog.String(message.InvalidJSON), message.InvalidLUHN: catalog.String(message.InvalidLUHN), message.InvalidMAC: catalog.String(message.InvalidMAC), + message.InvalidWeekFormat: catalog.String(message.InvalidWeekFormat), + message.InvalidWeekNumber: catalog.String(message.InvalidWeekNumber), + message.WeekTooEarly: catalog.String(message.WeekTooEarly), + message.WeekTooLate: catalog.String(message.WeekTooLate), message.InvalidTime: catalog.String(message.InvalidTime), message.InvalidTimezone: catalog.String(message.InvalidTimezone), message.InvalidULID: catalog.String(message.InvalidULID), diff --git a/message/translations/russian/messages.go b/message/translations/russian/messages.go index 3ebe868..7136363 100644 --- a/message/translations/russian/messages.go +++ b/message/translations/russian/messages.go @@ -76,6 +76,10 @@ var Messages = map[language.Tag]map[string]catalog.Message{ message.InvalidJSON: catalog.String("Значение должно быть корректным JSON."), message.InvalidLUHN: catalog.String("Недействительный номер карты."), message.InvalidMAC: catalog.String("Значение не является допустимым MAC-адресом."), + message.InvalidWeekFormat: catalog.String("Значение не является допустимой ISO-неделей в формате YYYY-Www."), + message.InvalidWeekNumber: catalog.String("Неделя {{ value }} не существует в указанном ISO-году."), + message.WeekTooEarly: catalog.String("Значение должно быть не раньше недели {{ min }}."), + message.WeekTooLate: catalog.String("Значение должно быть не позже недели {{ max }}."), message.InvalidTime: catalog.String("Значение времени недопустимо."), message.InvalidTimezone: catalog.String("Значение не является допустимым часовым поясом."), message.InvalidULID: catalog.String("Значение не соответствует формату ULID."), diff --git a/test/constraints_test.go b/test/constraints_test.go index ff570e7..baab9fd 100644 --- a/test/constraints_test.go +++ b/test/constraints_test.go @@ -50,6 +50,7 @@ type ConstraintValidationTestCase struct { } var validateTestCases = mergeTestCases( + weekConstraintTestCases, internationalConstraintTestCases, utf8ConstraintTestCases, barcodeConstraintsTestCases, diff --git a/test/constraints_week_cases_test.go b/test/constraints_week_cases_test.go new file mode 100644 index 0000000..6d6fe26 --- /dev/null +++ b/test/constraints_week_cases_test.go @@ -0,0 +1,120 @@ +package test + +import ( + "github.com/muonsoft/validation" + "github.com/muonsoft/validation/it" + "github.com/muonsoft/validation/message" +) + +var weekConstraintTestCases = []ConstraintValidationTestCase{ + { + name: "Week nil", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsWeek(), + assert: assertNoError, + }, + { + name: "Week empty", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue(""), + constraint: it.IsWeek(), + assert: assertNoError, + }, + { + name: "Week valid", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("2020-W53"), + constraint: it.IsWeek(), + assert: assertNoError, + }, + { + name: "Week format", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("2020-W00"), + constraint: it.IsWeek(), + assert: assertHasOneViolation(validation.ErrInvalidWeekFormat, message.InvalidWeekFormat), + }, + { + name: "Week number", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("2021-W53"), + constraint: it.IsWeek(), + assert: assertHasOneViolation(validation.ErrInvalidWeekNumber, "Week 2021-W53 does not exist in its ISO year."), + }, + { + name: "Week min", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("2020-W53"), + constraint: it.IsWeek().WithMin("2021-W01"), + assert: assertHasOneViolation(validation.ErrWeekTooEarly, "This value should be on or after week 2021-W01."), + }, + { + name: "Week max", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("2021-W01"), + constraint: it.IsWeek().WithMax("2020-W53"), + assert: assertHasOneViolation(validation.ErrWeekTooLate, "This value should be on or before week 2020-W53."), + }, + { + name: "Week bounds", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("2020-W53"), + constraint: it.IsWeek().WithMin("2020-W53").WithMax("2020-W53"), + assert: assertNoError, + }, + { + name: "Week disabled", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("invalid"), + constraint: it.IsWeek().When(false), + assert: assertNoError, + }, + { + name: "Week enabled", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("invalid"), + constraint: it.IsWeek().When(true), + assert: assertHasOneViolation(validation.ErrInvalidWeekFormat, message.InvalidWeekFormat), + }, + { + name: "Week groups", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("invalid"), + constraint: it.IsWeek().WhenGroups(testGroup), + assert: assertNoError, + }, + { + name: "Week invalid bounds", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsWeek().WithMin("invalid"), + assert: assertError("validate by WeekConstraint: bounds must be valid ISO weeks and minimum must not exceed maximum"), + }, + { + name: "Week custom Format", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("invalid"), + constraint: it.IsWeek().WithFormatError(ErrCustom).WithFormatMessage(customMessage, validation.TemplateParameter{Key: "{{ custom }}", Value: "parameter"}), + assert: assertHasOneViolation(ErrCustom, renderedCustomMessage), + }, + { + name: "Week custom WeekNumber", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("2021-W53"), + constraint: it.IsWeek().WithWeekNumberError(ErrCustom).WithWeekNumberMessage(customMessage, validation.TemplateParameter{Key: "{{ custom }}", Value: "parameter"}), + assert: assertHasOneViolation(ErrCustom, renderedCustomMessage), + }, + { + name: "Week custom Min", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("2020-W53"), + constraint: it.IsWeek().WithMin("2021-W01").WithMinError(ErrCustom).WithMinMessage(customMessage, validation.TemplateParameter{Key: "{{ custom }}", Value: "parameter"}), + assert: assertHasOneViolation(ErrCustom, renderedCustomMessage), + }, + { + name: "Week custom Max", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("2021-W01"), + constraint: it.IsWeek().WithMax("2020-W53").WithMaxError(ErrCustom).WithMaxMessage(customMessage, validation.TemplateParameter{Key: "{{ custom }}", Value: "parameter"}), + assert: assertHasOneViolation(ErrCustom, renderedCustomMessage), + }, +} diff --git a/validate/week.go b/validate/week.go new file mode 100644 index 0000000..aff0b1e --- /dev/null +++ b/validate/week.go @@ -0,0 +1,88 @@ +package validate + +import ( + "errors" + "regexp" + "strconv" + "time" +) + +var ( + // ErrInvalidWeekFormat indicates a value outside the YYYY-Www format (weeks 01–53). + ErrInvalidWeekFormat = errors.New("invalid week format") + // ErrInvalidWeekNumber indicates week 53 in an ISO year with only 52 weeks. + ErrInvalidWeekNumber = errors.New("invalid week number") + // ErrWeekTooEarly indicates a week before the inclusive minimum. + ErrWeekTooEarly = errors.New("week too early") + // ErrWeekTooLate indicates a week after the inclusive maximum. + ErrWeekTooLate = errors.New("week too late") + // ErrInvalidWeekBounds indicates malformed, nonexistent, or reversed bounds. + ErrInvalidWeekBounds = errors.New("invalid week bounds") +) + +// WeekOptions configures inclusive ISO week bounds for [Week]. +type WeekOptions struct{ min, max string } + +// WithMinWeek sets the inclusive minimum in YYYY-Www format. Empty removes the bound. +func WithMinWeek(value string) func(*WeekOptions) { + return func(o *WeekOptions) { o.min = value } +} + +// WithMaxWeek sets the inclusive maximum in YYYY-Www format. Empty removes the bound. +func WithMaxWeek(value string) func(*WeekOptions) { + return func(o *WeekOptions) { o.max = value } +} + +var weekPattern = regexp.MustCompile(`^[0-9]{4}-W(0[1-9]|[1-4][0-9]|5[0-3])$`) + +// Week validates an ISO week in YYYY-Www format, using literal years 0000–9999 +// in the proleptic Gregorian calendar. Week 53 must exist in the specified year. +// Empty values are valid; use [NotBlank] to require a value. +// Invalid options return [ErrInvalidWeekBounds], even for empty input. +// Other failures return [ErrInvalidWeekFormat], [ErrInvalidWeekNumber], +// [ErrWeekTooEarly], or [ErrWeekTooLate]. +func Week(value string, options ...func(*WeekOptions)) error { + var o WeekOptions + for _, option := range options { + option(&o) + } + if !o.valid() { + return ErrInvalidWeekBounds + } + if value == "" { + return nil + } + if err := checkWeek(value); err != nil { + return err + } + if o.min != "" && value < o.min { + return ErrWeekTooEarly + } + if o.max != "" && value > o.max { + return ErrWeekTooLate + } + return nil +} + +func (o WeekOptions) valid() bool { + if o.min != "" && checkWeek(o.min) != nil { + return false + } + if o.max != "" && checkWeek(o.max) != nil { + return false + } + return o.min == "" || o.max == "" || o.min <= o.max +} + +func checkWeek(value string) error { + if !weekPattern.MatchString(value) { + return ErrInvalidWeekFormat + } + year, _ := strconv.Atoi(value[:4]) + week, _ := strconv.Atoi(value[6:]) + _, lastWeek := time.Date(year, time.December, 28, 0, 0, 0, 0, time.UTC).ISOWeek() + if week > lastWeek { + return ErrInvalidWeekNumber + } + return nil +} diff --git a/validate/week_test.go b/validate/week_test.go new file mode 100644 index 0000000..c194a1a --- /dev/null +++ b/validate/week_test.go @@ -0,0 +1,100 @@ +package validate_test + +import ( + "fmt" + "testing" + "time" + + "github.com/muonsoft/validation/validate" + "github.com/stretchr/testify/require" +) + +func TestWeek(t *testing.T) { + tests := []struct { + value string + want error + }{ + {"", nil}, + {"2020-W53", nil}, + {"2015-W53", nil}, + {"2026-W53", nil}, + {"2021-W01", nil}, + {"2021-W52", nil}, + {"0000-W01", nil}, + {"0001-W01", nil}, + {"9999-W52", nil}, + {"2021-W53", validate.ErrInvalidWeekNumber}, + {"2022-W53", validate.ErrInvalidWeekNumber}, + {"2020-W00", validate.ErrInvalidWeekFormat}, + {"2020-W54", validate.ErrInvalidWeekFormat}, + {"2020-W1", validate.ErrInvalidWeekFormat}, + {"2020-w01", validate.ErrInvalidWeekFormat}, + {"20-W01", validate.ErrInvalidWeekFormat}, + {"10000-W01", validate.ErrInvalidWeekFormat}, + {"-001-W01", validate.ErrInvalidWeekFormat}, + {"2020-W01", validate.ErrInvalidWeekFormat}, + {"2020-W01", validate.ErrInvalidWeekFormat}, + {"2020-W01\n", validate.ErrInvalidWeekFormat}, + {" 2020-W01", validate.ErrInvalidWeekFormat}, + {"2020-W01 ", validate.ErrInvalidWeekFormat}, + {"2020-W01-1", validate.ErrInvalidWeekFormat}, + {"2020-01-01", validate.ErrInvalidWeekFormat}, + {" ", validate.ErrInvalidWeekFormat}, + {"\xff", validate.ErrInvalidWeekFormat}, + } + for _, tt := range tests { + t.Run(tt.value, func(t *testing.T) { require.ErrorIs(t, validate.Week(tt.value), tt.want) }) + } +} + +func TestWeekBounds(t *testing.T) { + tests := []struct { + name, value, min, max string + want error + }{ + {"min equal", "2020-W53", "2020-W53", "", nil}, + {"max equal", "2021-W01", "", "2021-W01", nil}, + {"cross year below", "2020-W53", "2021-W01", "", validate.ErrWeekTooEarly}, + {"cross year above", "2021-W01", "", "2020-W53", validate.ErrWeekTooLate}, + {"same year below", "2020-W01", "2020-W02", "", validate.ErrWeekTooEarly}, + {"same year above", "2020-W03", "", "2020-W02", validate.ErrWeekTooLate}, + {"exact", "2020-W53", "2020-W53", "2020-W53", nil}, + {"empty value", "", "2020-W01", "2021-W01", nil}, + {"invalid min", "", "2020-W00", "", validate.ErrInvalidWeekBounds}, + {"nonexistent min", "2020-W01", "2021-W53", "", validate.ErrInvalidWeekBounds}, + {"invalid max", "2020-W01", "", "oops", validate.ErrInvalidWeekBounds}, + {"nonexistent max", "2020-W01", "", "2021-W53", validate.ErrInvalidWeekBounds}, + {"reversed", "", "2021-W01", "2020-W53", validate.ErrInvalidWeekBounds}, + {"format before bounds", "oops", "2020-W01", "2021-W01", validate.ErrInvalidWeekFormat}, + {"number before bounds", "2021-W53", "2020-W01", "2021-W01", validate.ErrInvalidWeekNumber}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.ErrorIs(t, validate.Week(tt.value, validate.WithMinWeek(tt.min), validate.WithMaxWeek(tt.max)), tt.want) + }) + } + require.NoError(t, validate.Week("2020-W01", validate.WithMinWeek("2021-W01"), validate.WithMinWeek(""))) + require.NoError(t, validate.Week("2021-W01", validate.WithMaxWeek("2020-W01"), validate.WithMaxWeek(""))) +} + +// Check every literal year against the ISO rule: a year has 53 weeks when +// January 1 is Thursday, or Wednesday in a leap year. +func TestWeek53AllYears(t *testing.T) { + for year := 0; year <= 9999; year++ { + weekday := time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC).Weekday() + leap := year%4 == 0 && (year%100 != 0 || year%400 == 0) + want := validate.ErrInvalidWeekNumber + if weekday == time.Thursday || weekday == time.Wednesday && leap { + want = nil + } + require.ErrorIs(t, validate.Week(fmt.Sprintf("%04d-W53", year)), want, "year %d", year) + } +} + +func ExampleWeek() { + fmt.Println(validate.Week("2020-W53")) + fmt.Println(validate.Week("2021-W53")) + // Output: + // + // invalid week number +}