// unmarshal.go
package figtree
import (
"fmt"
"reflect"
"strconv"
"strings"
"time"
)
// ============================================================================
// TAG CONSTANTS — freeze these, they are public API
// ============================================================================
const (
tagFig = "fig"
tagAssure = "assure"
// Shared across multiple mutageneses (resolved by field type)
tokenNotEmpty = "notEmpty"
tokenPositive = "positive"
tokenNegative = "negative"
tokenLength = "length"
tokenNotLength = "notLength"
tokenInRange = "inRange"
tokenGt = "gt"
tokenLt = "lt"
tokenMin = "min"
tokenMax = "max"
tokenContains = "contains"
tokenNotContains = "notContains"
tokenMinLength = "minLength"
// String-only
tokenSubstring = "substring"
tokenHasPrefix = "hasPrefix"
tokenHasSuffix = "hasSuffix"
tokenNoPrefix = "noPrefix"
tokenNoSuffix = "noSuffix"
tokenNoPrefixes = "noPrefixes"
tokenNoSuffixes = "noSuffixes"
// Bool-only
tokenTrue = "true"
tokenFalse = "false"
// Float64-only
tokenNotNaN = "notNaN"
// Duration-only (gt/lt/min/max reused, resolved by type)
// List-only
tokenContainsKey = "containsKey"
// Map-only
tokenHasKey = "hasKey"
tokenHasKeys = "hasKeys"
tokenValueMatches = "valueMatches"
)
// ============================================================================
// UNMARSHAL ERRORS
// ============================================================================
// UnmarshalError describes a failure during Unmarshal, including which
// struct field and which assure token caused the problem.
type UnmarshalError struct {
Field string // struct field name
FigKey string // fig tag value
Token string // assure token that failed, empty if not a validation error
Cause error
}
func (e *UnmarshalError) Error() string {
if e.Token != "" {
return fmt.Sprintf(
"figtree: unmarshal validation failed on field %q (fig:%q) token %q: %v",
e.Field, e.FigKey, e.Token, e.Cause,
)
}
return fmt.Sprintf(
"figtree: unmarshal failed on field %q (fig:%q): %v",
e.Field, e.FigKey, e.Cause,
)
}
func (e *UnmarshalError) Unwrap() error { return e.Cause }
// ============================================================================
// UNMARSHAL — primary entry point
// ============================================================================
// Unmarshal populates a struct from the figtree using `fig` and `assure`
// struct tags. The target must be a non-nil pointer to a struct.
//
// Example:
//
// type Config struct {
// Host string `fig:"host" assure:"notEmpty|hasPrefix=https://"`
// Port int `fig:"port" assure:"gt=1023|lt=65536"`
// Timeout time.Duration `fig:"timeout" assure:"min=5s|max=2m"`
// }
// var cfg Config
// err := figs.Unmarshal(&cfg)
func (t *tree) Unmarshal(target interface{}) error {
if target == nil {
return fmt.Errorf("figtree: Unmarshal target must not be nil")
}
rv := reflect.ValueOf(target)
if rv.Kind() != reflect.Ptr || rv.IsNil() {
return fmt.Errorf("figtree: Unmarshal target must be a non-nil pointer to a struct")
}
rv = rv.Elem()
if rv.Kind() != reflect.Struct {
return fmt.Errorf("figtree: Unmarshal target must point to a struct, got %s", rv.Kind())
}
return t.unmarshalStruct(rv)
}
// unmarshalStruct walks every field of a struct via reflection.
func (t *tree) unmarshalStruct(rv reflect.Value) error {
rt := rv.Type()
for i := 0; i < rt.NumField(); i++ {
field := rt.Field(i)
fieldVal := rv.Field(i)
// Skip unexported fields
if !fieldVal.CanSet() {
continue
}
// Recurse into embedded or nested structs that have no fig tag
figKey := strings.TrimSpace(field.Tag.Get(tagFig))
if figKey == "" {
if field.Type.Kind() == reflect.Struct {
if err := t.unmarshalStruct(fieldVal); err != nil {
return err
}
}
continue
}
assureTag := strings.TrimSpace(field.Tag.Get(tagAssure))
if err := t.unmarshalField(field, fieldVal, figKey, assureTag); err != nil {
return err
}
}
return nil
}
// unmarshalField resolves the fig value, sets the field, then validates.
func (t *tree) unmarshalField(
field reflect.StructField,
fieldVal reflect.Value,
figKey string,
assureTag string,
) error {
fieldType := field.Type
// Resolve the raw value from the tree by field type
if err := t.setFieldFromTree(field, fieldVal, figKey, fieldType); err != nil {
return &UnmarshalError{
Field: field.Name,
FigKey: figKey,
Cause: err,
}
}
// Run assure validators if tag is present
if assureTag == "" {
return nil
}
tokens := strings.Split(assureTag, "|")
for _, raw := range tokens {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
validator, err := resolveToken(raw, fieldType, fieldVal)
if err != nil {
return &UnmarshalError{
Field: field.Name,
FigKey: figKey,
Token: raw,
Cause: err,
}
}
if err := validator(fieldVal.Interface()); err != nil {
return &UnmarshalError{
Field: field.Name,
FigKey: figKey,
Token: raw,
Cause: err,
}
}
}
return nil
}
// ============================================================================
// SET FIELD FROM TREE
// ============================================================================
var durationType = reflect.TypeOf(time.Duration(0))
var listFlagType = reflect.TypeOf((*ListFlag)(nil))
var mapFlagType = reflect.TypeOf((*MapFlag)(nil))
// setFieldFromTree reads the registered fig value and sets it onto the
// reflected struct field. Supported field types mirror the mutagenesis table.
func (t *tree) setFieldFromTree(
field reflect.StructField,
fieldVal reflect.Value,
figKey string,
fieldType reflect.Type,
) error {
switch {
// ---- string ----
case fieldType.Kind() == reflect.String:
p := t.String(figKey)
if p == nil {
return nil
}
fieldVal.SetString(*p)
// ---- bool ----
case fieldType.Kind() == reflect.Bool:
p := t.Bool(figKey)
if p == nil {
return nil
}
fieldVal.SetBool(*p)
// ---- int ----
case fieldType.Kind() == reflect.Int &&
fieldType != durationType:
p := t.Int(figKey)
if p == nil {
return nil
}
fieldVal.SetInt(int64(*p))
// ---- int64 ----
case fieldType.Kind() == reflect.Int64 &&
fieldType != durationType:
p := t.Int64(figKey)
if p == nil {
return nil
}
fieldVal.SetInt(*p)
// ---- float64 ----
case fieldType.Kind() == reflect.Float64:
p := t.Float64(figKey)
if p == nil {
return nil
}
fieldVal.SetFloat(*p)
// ---- time.Duration ----
case fieldType == durationType:
p := t.Duration(figKey)
if p == nil {
return nil
}
fieldVal.Set(reflect.ValueOf(*p))
// ---- []string / *[]string / *ListFlag ----
case fieldType == reflect.TypeOf([]string{}):
p := t.List(figKey)
if p == nil {
return nil
}
fieldVal.Set(reflect.ValueOf(*p))
case fieldType == reflect.TypeOf(map[string]string{}):
p := t.Map(figKey)
if p == nil {
return nil
}
fieldVal.Set(reflect.ValueOf(*p))
default:
return fmt.Errorf("unsupported field type %s for fig key %q", fieldType, figKey)
}
return nil
}
// ============================================================================
// TOKEN RESOLVER
// ============================================================================
// resolveToken parses a single pipe-segment from the assure tag and returns
// a ValidatorFunc bound to any arguments extracted from the token.
// The fieldType is used to disambiguate shared token names (e.g. "positive",
// "notEmpty", "length") that apply to multiple mutageneses.
func resolveToken(token string, fieldType reflect.Type, fieldVal reflect.Value) (ValidatorFunc, error) {
// Split on first '=' to separate name from argument(s)
name, arg, hasArg := strings.Cut(token, "=")
name = strings.ToLower(strings.TrimSpace(name))
kind := fieldType.Kind()
isDuration := fieldType == durationType
isList := fieldType == reflect.TypeOf([]string{})
isMap := fieldType == reflect.TypeOf(map[string]string{})
switch name {
// =========================================================
// SHARED: notEmpty
// =========================================================
case tokenNotEmpty:
switch {
case kind == reflect.String:
return wrapAssure(token, AssureStringNotEmpty), nil
case isList:
return wrapAssure(token, AssureListNotEmpty), nil
case isMap:
return wrapAssure(token, AssureMapNotEmpty), nil
default:
return nil, fmt.Errorf("token %q not applicable to type %s", token, fieldType)
}
// =========================================================
// SHARED: positive
// =========================================================
case tokenPositive:
switch {
case kind == reflect.Int:
return wrapAssure(token, AssurePositiveInt), nil
case kind == reflect.Int64 && !isDuration:
return wrapAssure(token, AssurePositiveInt64), nil
case kind == reflect.Float64:
return wrapAssure(token, AssureFloat64Positive), nil
case isDuration:
return wrapAssure(token, AssureDurationPositive), nil
default:
return nil, fmt.Errorf("token %q not applicable to type %s", token, fieldType)
}
// =========================================================
// SHARED: negative
// =========================================================
case tokenNegative:
switch {
case kind == reflect.Int:
return wrapAssure(token, AssureNegativeInt), nil
default:
return nil, fmt.Errorf("token %q only applies to tInt, got %s", token, fieldType)
}
// =========================================================
// SHARED: length=N
// =========================================================
case tokenLength:
n, err := requireInt(token, arg, hasArg)
if err != nil {
return nil, err
}
switch {
case kind == reflect.String:
return wrapAssure(token, AssureStringLength(n)), nil
case isList:
return wrapAssure(token, AssureListLength(n)), nil
case isMap:
return wrapAssure(token, AssureMapLength(n)), nil
default:
return nil, fmt.Errorf("token %q not applicable to type %s", token, fieldType)
}
// =========================================================
// SHARED: notLength=N
// =========================================================
case tokenNotLength:
n, err := requireInt(token, arg, hasArg)
if err != nil {
return nil, err
}
switch {
case kind == reflect.String:
return wrapAssure(token, AssureStringNotLength(n)), nil
case isList:
return wrapAssure(token, AssureListNotLength(n)), nil
case isMap:
return wrapAssure(token, AssureMapNotLength(n)), nil
default:
return nil, fmt.Errorf("token %q not applicable to type %s", token, fieldType)
}
// =========================================================
// SHARED: gt=N (Int, Int64, Float64, Duration)
// =========================================================
case tokenGt:
switch {
case kind == reflect.Int:
n, err := requireInt(token, arg, hasArg)
if err != nil {
return nil, err
}
return wrapAssure(token, AssureIntGreaterThan(n)), nil
case kind == reflect.Int64 && !isDuration:
n, err := requireInt64(token, arg, hasArg)
if err != nil {
return nil, err
}
return wrapAssure(token, AssureInt64GreaterThan(n)), nil
case kind == reflect.Float64:
f, err := requireFloat64(token, arg, hasArg)
if err != nil {
return nil, err
}
return wrapAssure(token, AssureFloat64GreaterThan(f)), nil
case isDuration:
d, err := requireDuration(token, arg, hasArg)
if err != nil {
return nil, err
}
return wrapAssure(token, AssureDurationGreaterThan(d)), nil
default:
return nil, fmt.Errorf("token %q not applicable to type %s", token, fieldType)
}
// =========================================================
// SHARED: lt=N (Int, Int64, Float64, Duration)
// =========================================================
case tokenLt:
switch {
case kind == reflect.Int:
n, err := requireInt(token, arg, hasArg)
if err != nil {
return nil, err
}
return wrapAssure(token, AssureIntLessThan(n)), nil
case kind == reflect.Int64 && !isDuration:
n, err := requireInt64(token, arg, hasArg)
if err != nil {
return nil, err
}
return wrapAssure(token, AssureInt64LessThan(n)), nil
case kind == reflect.Float64:
f, err := requireFloat64(token, arg, hasArg)
if err != nil {
return nil, err
}
return wrapAssure(token, AssureFloat64LessThan(f)), nil
case isDuration:
d, err := requireDuration(token, arg, hasArg)
if err != nil {
return nil, err
}
return wrapAssure(token, AssureDurationLessThan(d)), nil
default:
return nil, fmt.Errorf("token %q not applicable to type %s", token, fieldType)
}
// =========================================================
// SHARED: inRange=N,M (Int, Int64, Float64)
// =========================================================
case tokenInRange:
switch {
case kind == reflect.Int:
lo, hi, err := requireIntRange(token, arg, hasArg)
if err != nil {
return nil, err
}
return wrapAssure(token, AssureIntInRange(lo, hi)), nil
case kind == reflect.Int64 && !isDuration:
lo, hi, err := requireInt64Range(token, arg, hasArg)
if err != nil {
return nil, err
}
return wrapAssure(token, AssureInt64InRange(lo, hi)), nil
case kind == reflect.Float64:
lo, hi, err := requireFloat64Range(token, arg, hasArg)
if err != nil {
return nil, err
}
return wrapAssure(token, AssureFloat64InRange(lo, hi)), nil
default:
return nil, fmt.Errorf("token %q not applicable to type %s", token, fieldType)
}
// =========================================================
// SHARED: min=X (Duration only — Int uses gt/inRange)
// =========================================================
case tokenMin:
switch {
case isDuration:
d, err := requireDuration(token, arg, hasArg)
if err != nil {
return nil, err
}
return wrapAssure(token, AssureDurationMin(d)), nil
default:
return nil, fmt.Errorf("token %q only applies to Duration, got %s", token, fieldType)
}
// =========================================================
// SHARED: max=X (Duration only)
// =========================================================
case tokenMax:
switch {
case isDuration:
d, err := requireDuration(token, arg, hasArg)
if err != nil {
return nil, err
}
return wrapAssure(token, AssureDurationMax(d)), nil
default:
return nil, fmt.Errorf("token %q only applies to Duration, got %s", token, fieldType)
}
// =========================================================
// SHARED: contains=X (String, List)
// =========================================================
case tokenContains:
if !hasArg {
return nil, fmt.Errorf("token %q requires a value argument", token)
}
switch {
case kind == reflect.String:
return wrapAssure(token, AssureStringContains(arg)), nil
case isList:
return wrapAssure(token, AssureListContains(arg)), nil
default:
return nil, fmt.Errorf("token %q not applicable to type %s", token, fieldType)
}
// =========================================================
// SHARED: notContains=X (String, List)
// =========================================================
case tokenNotContains:
if !hasArg {
return nil, fmt.Errorf("token %q requires a value argument", token)
}
switch {
case kind == reflect.String:
return wrapAssure(token, AssureStringNotContains(arg)), nil
case isList:
return wrapAssure(token, AssureListNotContains(arg)), nil
default:
return nil, fmt.Errorf("token %q not applicable to type %s", token, fieldType)
}
// =========================================================
// SHARED: minLength=N (List only — String uses length)
// =========================================================
case tokenMinLength:
n, err := requireInt(token, arg, hasArg)
if err != nil {
return nil, err
}
switch {
case isList:
return wrapAssure(token, AssureListMinLength(n)), nil
default:
return nil, fmt.Errorf("token %q only applies to tList, got %s", token, fieldType)
}
// =========================================================
// STRING ONLY
// =========================================================
case tokenSubstring:
if !hasArg {
return nil, fmt.Errorf("token %q requires a value argument", token)
}
if kind != reflect.String {
return nil, fmt.Errorf("token %q only applies to tString, got %s", token, fieldType)
}
return wrapAssure(token, AssureStringSubstring(arg)), nil
case tokenHasPrefix:
if !hasArg {
return nil, fmt.Errorf("token %q requires a value argument", token)
}
if kind != reflect.String {
return nil, fmt.Errorf("token %q only applies to tString, got %s", token, fieldType)
}
return wrapAssure(token, AssureStringHasPrefix(arg)), nil
case tokenHasSuffix:
if !hasArg {
return nil, fmt.Errorf("token %q requires a value argument", token)
}
if kind != reflect.String {
return nil, fmt.Errorf("token %q only applies to tString, got %s", token, fieldType)
}
return wrapAssure(token, AssureStringHasSuffix(arg)), nil
case tokenNoPrefix:
if !hasArg {
return nil, fmt.Errorf("token %q requires a value argument", token)
}
if kind != reflect.String {
return nil, fmt.Errorf("token %q only applies to tString, got %s", token, fieldType)
}
return wrapAssure(token, AssureStringNoPrefix(arg)), nil
case tokenNoSuffix:
if !hasArg {
return nil, fmt.Errorf("token %q requires a value argument", token)
}
if kind != reflect.String {
return nil, fmt.Errorf("token %q only applies to tString, got %s", token, fieldType)
}
return wrapAssure(token, AssureStringNoSuffix(arg)), nil
case tokenNoPrefixes:
if !hasArg {
return nil, fmt.Errorf("token %q requires a comma-separated value argument", token)
}
if kind != reflect.String {
return nil, fmt.Errorf("token %q only applies to tString, got %s", token, fieldType)
}
parts := splitComma(arg)
return wrapAssure(token, AssureStringNoPrefixes(parts)), nil
case tokenNoSuffixes:
if !hasArg {
return nil, fmt.Errorf("token %q requires a comma-separated value argument", token)
}
if kind != reflect.String {
return nil, fmt.Errorf("token %q only applies to tString, got %s", token, fieldType)
}
parts := splitComma(arg)
return wrapAssure(token, AssureStringNoSuffixes(parts)), nil
// =========================================================
// BOOL ONLY
// =========================================================
case tokenTrue:
if kind != reflect.Bool {
return nil, fmt.Errorf("token %q only applies to tBool, got %s", token, fieldType)
}
return wrapAssure(token, AssureBoolTrue), nil
case tokenFalse:
if kind != reflect.Bool {
return nil, fmt.Errorf("token %q only applies to tBool, got %s", token, fieldType)
}
return wrapAssure(token, AssureBoolFalse), nil
// =========================================================
// FLOAT64 ONLY
// =========================================================
case tokenNotNaN:
if kind != reflect.Float64 {
return nil, fmt.Errorf("token %q only applies to tFloat64, got %s", token, fieldType)
}
return wrapAssure(token, AssureFloat64NotNaN), nil
// =========================================================
// LIST ONLY
// =========================================================
case tokenContainsKey:
if !hasArg {
return nil, fmt.Errorf("token %q requires a value argument", token)
}
if !isList {
return nil, fmt.Errorf("token %q only applies to tList, got %s", token, fieldType)
}
return wrapAssure(token, AssureListContainsKey(arg)), nil
// =========================================================
// MAP ONLY
// =========================================================
case tokenHasKey:
if !hasArg {
return nil, fmt.Errorf("token %q requires a value argument", token)
}
if !isMap {
return nil, fmt.Errorf("token %q only applies to tMap, got %s", token, fieldType)
}
return wrapAssure(token, AssureMapHasKey(arg)), nil
case tokenHasKeys:
if !hasArg {
return nil, fmt.Errorf("token %q requires a comma-separated value argument", token)
}
if !isMap {
return nil, fmt.Errorf("token %q only applies to tMap, got %s", token, fieldType)
}
parts := splitComma(arg)
return wrapAssure(token, AssureMapHasKeys(parts)), nil
case tokenValueMatches:
if !hasArg {
return nil, fmt.Errorf("token %q requires a K:V argument", token)
}
if !isMap {
return nil, fmt.Errorf("token %q only applies to tMap, got %s", token, fieldType)
}
k, v, ok := strings.Cut(arg, ":")
if !ok {
return nil, fmt.Errorf("token %q argument must be K:V format, got %q", token, arg)
}
return wrapAssure(token, AssureMapValueMatches(k, v)), nil
default:
return nil, fmt.Errorf("figtree: unknown assure token %q for type %s", token, fieldType)
}
}
// ============================================================================
// ARGUMENT PARSERS — helpers for typed argument extraction
// ============================================================================
func requireInt(token, arg string, hasArg bool) (int, error) {
if !hasArg || arg == "" {
return 0, fmt.Errorf("token %q requires an integer argument", token)
}
n, err := strconv.Atoi(strings.TrimSpace(arg))
if err != nil {
return 0, fmt.Errorf("token %q argument %q is not a valid integer: %w", token, arg, err)
}
return n, nil
}
func requireInt64(token, arg string, hasArg bool) (int64, error) {
if !hasArg || arg == "" {
return 0, fmt.Errorf("token %q requires an int64 argument", token)
}
n, err := strconv.ParseInt(strings.TrimSpace(arg), 10, 64)
if err != nil {
return 0, fmt.Errorf("token %q argument %q is not a valid int64: %w", token, arg, err)
}
return n, nil
}
func requireFloat64(token, arg string, hasArg bool) (float64, error) {
if !hasArg || arg == "" {
return 0, fmt.Errorf("token %q requires a float64 argument", token)
}
f, err := strconv.ParseFloat(strings.TrimSpace(arg), 64)
if err != nil {
return 0, fmt.Errorf("token %q argument %q is not a valid float64: %w", token, arg, err)
}
return f, nil
}
func requireDuration(token, arg string, hasArg bool) (time.Duration, error) {
if !hasArg || arg == "" {
return 0, fmt.Errorf("token %q requires a duration argument (e.g. 5s, 2m, 1h)", token)
}
d, err := time.ParseDuration(strings.TrimSpace(arg))
if err != nil {
return 0, fmt.Errorf("token %q argument %q is not a valid duration: %w", token, arg, err)
}
return d, nil
}
func requireIntRange(token, arg string, hasArg bool) (int, int, error) {
if !hasArg || arg == "" {
return 0, 0, fmt.Errorf("token %q requires a N,M range argument", token)
}
lo, hi, err := splitRange(arg)
if err != nil {
return 0, 0, fmt.Errorf("token %q: %w", token, err)
}
loN, err := strconv.Atoi(lo)
if err != nil {
return 0, 0, fmt.Errorf("token %q lower bound %q is not a valid integer: %w", token, lo, err)
}
hiN, err := strconv.Atoi(hi)
if err != nil {
return 0, 0, fmt.Errorf("token %q upper bound %q is not a valid integer: %w", token, hi, err)
}
return loN, hiN, nil
}
func requireInt64Range(token, arg string, hasArg bool) (int64, int64, error) {
lo, hi, err := splitRange(arg)
if err != nil {
return 0, 0, fmt.Errorf("token %q: %w", token, err)
}
loN, err := strconv.ParseInt(lo, 10, 64)
if err != nil {
return 0, 0, fmt.Errorf("token %q lower bound %q is not a valid int64: %w", token, lo, err)
}
hiN, err := strconv.ParseInt(hi, 10, 64)
if err != nil {
return 0, 0, fmt.Errorf("token %q upper bound %q is not a valid int64: %w", token, hi, err)
}
return loN, hiN, nil
}
func requireFloat64Range(token, arg string, hasArg bool) (float64, float64, error) {
lo, hi, err := splitRange(arg)
if err != nil {
return 0, 0, fmt.Errorf("token %q: %w", token, err)
}
loF, err := strconv.ParseFloat(lo, 64)
if err != nil {
return 0, 0, fmt.Errorf("token %q lower bound %q is not a valid float64: %w", token, lo, err)
}
hiF, err := strconv.ParseFloat(hi, 64)
if err != nil {
return 0, 0, fmt.Errorf("token %q upper bound %q is not a valid float64: %w", token, hi, err)
}
return loF, hiF, nil
}
// splitRange splits "N,M" into ("N", "M") with whitespace trimmed.
func splitRange(arg string) (string, string, error) {
parts := strings.SplitN(arg, ",", 2)
if len(parts) != 2 {
return "", "", fmt.Errorf("range argument %q must be in N,M format", arg)
}
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), nil
}
// splitComma splits "A,B,C" into ["A","B","C"] with whitespace trimmed.
func splitComma(arg string) []string {
parts := strings.Split(arg, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
// ============================================================================
// WRAP ASSURE
// ============================================================================
// wrapAssure adapts any figtree AssureFunc into a ValidatorFunc that receives
// the already-reflected interface{} value from the struct field. This is the
// bridge between the tag-parsed validators and the existing assure system.
func wrapAssure(token string, fn ValidatorFunc) (ValidatorFunc, error) {
if fn == nil {
return nil, fmt.Errorf("figtree: nil ValidatorFunc for token %q", token)
}
return fn, nil
}
Here is the full mapping of every existing AssureFunc into the proposed assure:"" tag DSL, with the tag token, its equivalent function, and example usage.
resolveToken does a two-pass lookup: first by token prefix (e.g., gt=, hasKey=, inRange=), then by exact match (notEmpty, positive, true). Type is used to disambiguate shared token names like positive and notEmpty which appear across multiple mutageneses.
That way the DSL is defined in one place, documented in one place, and testable as a table in one place. It also makes the fig:"name" and assure:"notEmpty" struct tag story feel as intentional and complete as encoding/json does — which is exactly the standard library bar you want to clear.
Unmarshal Functionality
This is the
unmarshal.gofile that can be placed into the branch for testing. Thank you Claude for generating this.The
Unmarshal(target interface()) errormethodAll 36 Validators as Struct Tags
Here is the full mapping of every existing AssureFunc into the proposed assure:"" tag DSL, with the tag token, its equivalent function, and example usage.
String Validators (tString)
notEmptyAssureStringNotEmptyassure:"notEmpty"length=NAssureStringLength(N)assure:"length=8"notLength=NAssureStringNotLength(N)assure:"notLength=0"contains=XAssureStringContains(X)assure:"contains=admin"notContains=XAssureStringNotContains(X)assure:"notContains=drop"substring=XAssureStringSubstring(X)assure:"substring=https"hasPrefix=XAssureStringHasPrefix(X)assure:"hasPrefix=https://"hasSuffix=XAssureStringHasSuffix(X)assure:"hasSuffix=.com"noPrefix=XAssureStringNoPrefix(X)assure:"noPrefix=http://"noSuffix=XAssureStringNoSuffix(X)assure:"noSuffix=.exe"noPrefixes=X,YAssureStringNoPrefixes([]string)assure:"noPrefixes=http://,ftp://"noSuffixes=X,YAssureStringNoSuffixes([]string)assure:"noSuffixes=.exe,.bat"Pipe-chained example:
Bool Validators (tBool)
trueAssureBoolTrueassure:"true"falseAssureBoolFalseassure:"false"Example:
Int Validators (tInt)
positiveAssurePositiveIntassure:"positive"negativeAssureNegativeIntassure:"negative"gt=NAssureIntGreaterThan(N)assure:"gt=0"lt=NAssureIntLessThan(N)assure:"lt=65535"inRange=N,MAssureIntInRange(N,M)assure:"inRange=1024,49151"Example:
Int64 Validators (tInt64)
positiveAssurePositiveInt64assure:"positive"gt=NAssureInt64GreaterThan(N)assure:"gt=0"lt=NAssureInt64LessThan(N)assure:"lt=1000000"inRange=N,MAssureInt64InRange(N,M)assure:"inRange=1,9999999"Example:
Float64 Validators (tFloat64)
positiveAssureFloat64Positiveassure:"positive"notNaNAssureFloat64NotNaNassure:"notNaN"gt=NAssureFloat64GreaterThan(N)assure:"gt=0.0"lt=NAssureFloat64LessThan(N)assure:"lt=1.0"inRange=N,MAssureFloat64InRange(N,M)assure:"inRange=0.0,1.0"Example:
Duration Validators (tDuration / tUnitDuration)
positiveAssureDurationPositiveassure:"positive"gt=XsAssureDurationGreaterThan(d)assure:"gt=5s"lt=XsAssureDurationLessThan(d)assure:"lt=1h"min=XsAssureDurationMin(d)assure:"min=10s"max=XsAssureDurationMax(d)assure:"max=12h"Example:
List Validators (tList)
notEmptyAssureListNotEmptyassure:"notEmpty"minLength=NAssureListMinLength(N)assure:"minLength=1"length=NAssureListLength(N)assure:"length=3"notLength=NAssureListNotLength(N)assure:"notLength=0"contains=XAssureListContains(X)assure:"contains=primary"notContains=XAssureListNotContains(X)assure:"notContains=localhost"containsKey=XAssureListContainsKey(X)assure:"containsKey=admin"Example:
Map Validators (tMap)
notEmptyAssureMapNotEmptyassure:"notEmpty"hasKey=XAssureMapHasKey(X)assure:"hasKey=env"hasKeys=X,YAssureMapHasKeys([]string)assure:"hasKeys=env,version"length=NAssureMapLength(N)assure:"length=2"notLength=NAssureMapNotLength(N)assure:"notLength=0"valueMatches=K:VAssureMapValueMatches(K,V)assure:"valueMatches=env:prod"Example:
Token Parser Design
The internal parser for the assure tag would work like this:
resolveToken does a two-pass lookup: first by token prefix (e.g., gt=, hasKey=, inRange=), then by exact match (notEmpty, positive, true). Type is used to disambiguate shared token names like positive and notEmpty which appear across multiple mutageneses.
The Critical Constraint You Already Identified
The DSL you freeze now is a public API. Every token name is a promise. Before shipping this, the recommendation is to write the complete token registry as a const block — one const per token string — so that any future rename is a compile-time break rather than a silent behavioral change. Something like:
That way the DSL is defined in one place, documented in one place, and testable as a table in one place. It also makes the fig:"name" and assure:"notEmpty" struct tag story feel as intentional and complete as encoding/json does — which is exactly the standard library bar you want to clear.
Example of All 36 Validators In Use