Skip to content

Add Unmarshal support for structs using established syntax #16

@andreimerlescu

Description

@andreimerlescu

Unmarshal Functionality

This is the unmarshal.go file that can be placed into the branch for testing. Thank you Claude for generating this.

// In types.go or figtree.go — add to the Fruit interface
type Fruit interface {
    // ... existing methods ...
    Unmarshal(target interface{}) error
}

The Unmarshal(target interface()) error method

// 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
}

All 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)

Tag Token Equivalent AssureFunc Example Tag
notEmpty AssureStringNotEmpty assure:"notEmpty"
length=N AssureStringLength(N) assure:"length=8"
notLength=N AssureStringNotLength(N) assure:"notLength=0"
contains=X AssureStringContains(X) assure:"contains=admin"
notContains=X AssureStringNotContains(X) assure:"notContains=drop"
substring=X AssureStringSubstring(X) assure:"substring=https"
hasPrefix=X AssureStringHasPrefix(X) assure:"hasPrefix=https://"
hasSuffix=X AssureStringHasSuffix(X) assure:"hasSuffix=.com"
noPrefix=X AssureStringNoPrefix(X) assure:"noPrefix=http://"
noSuffix=X AssureStringNoSuffix(X) assure:"noSuffix=.exe"
noPrefixes=X,Y AssureStringNoPrefixes([]string) assure:"noPrefixes=http://,ftp://"
noSuffixes=X,Y AssureStringNoSuffixes([]string) assure:"noSuffixes=.exe,.bat"

Pipe-chained example:

type ServerConfig struct {
    Endpoint string `fig:"endpoint" assure:"notEmpty|hasPrefix=https://|hasSuffix=.com|notContains=localhost"`
}

Bool Validators (tBool)

Tag Token Equivalent AssureFunc Example Tag
true AssureBoolTrue assure:"true"
false AssureBoolFalse assure:"false"

Example:

type FeatureFlags struct {
    ProductionMode bool `fig:"production" assure:"true"`
    DebugMode      bool `fig:"debug"      assure:"false"`
}

Int Validators (tInt)

Tag Token Equivalent AssureFunc Example Tag
positive AssurePositiveInt assure:"positive"
negative AssureNegativeInt assure:"negative"
gt=N AssureIntGreaterThan(N) assure:"gt=0"
lt=N AssureIntLessThan(N) assure:"lt=65535"
inRange=N,M AssureIntInRange(N,M) assure:"inRange=1024,49151"

Example:

type NetworkConfig struct {
    Port    int `fig:"port"    assure:"gt=1023|lt=65536"`
    Workers int `fig:"workers" assure:"positive|inRange=1,128"`
}

Int64 Validators (tInt64)

Tag Token Equivalent AssureFunc Example Tag
positive AssurePositiveInt64 assure:"positive"
gt=N AssureInt64GreaterThan(N) assure:"gt=0"
lt=N AssureInt64LessThan(N) assure:"lt=1000000"
inRange=N,M AssureInt64InRange(N,M) assure:"inRange=1,9999999"

Note: positive and negative are shared token names with tInt — the parser resolves which AssureFunc to call based on the struct field’s reflected type, so there’s no collision.

Example:

type RetryConfig struct {
    MaxAttempts int64 `fig:"maxRetries" assure:"positive|lt=100"`
    BackoffMs   int64 `fig:"backoffMs"  assure:"gt=0|inRange=100,30000"`
}

Float64 Validators (tFloat64)

Tag Token Equivalent AssureFunc Example Tag
positive AssureFloat64Positive assure:"positive"
notNaN AssureFloat64NotNaN assure:"notNaN"
gt=N AssureFloat64GreaterThan(N) assure:"gt=0.0"
lt=N AssureFloat64LessThan(N) assure:"lt=1.0"
inRange=N,M AssureFloat64InRange(N,M) assure:"inRange=0.0,1.0"

Example:

type ThresholdConfig struct {
    Confidence float64 `fig:"confidence" assure:"notNaN|positive|inRange=0.0,1.0"`
    SampleRate float64 `fig:"sampleRate"  assure:"gt=0.0|lt=100.0"`
}

Duration Validators (tDuration / tUnitDuration)

Tag Token Equivalent AssureFunc Example Tag
positive AssureDurationPositive assure:"positive"
gt=Xs AssureDurationGreaterThan(d) assure:"gt=5s"
lt=Xs AssureDurationLessThan(d) assure:"lt=1h"
min=Xs AssureDurationMin(d) assure:"min=10s"
max=Xs AssureDurationMax(d) assure:"max=12h"

The duration token values use Go’s native time.ParseDuration format (5s, 2m, 1h30m, etc.) so no new syntax is needed.

Example:

type TimeoutConfig struct {
    RequestTimeout time.Duration `fig:"timeout"  assure:"positive|min=5s|max=2m"`
    PollInterval   time.Duration `fig:"interval" assure:"gt=30s|lt=1h"`
}

List Validators (tList)

Tag Token Equivalent AssureFunc Example Tag
notEmpty AssureListNotEmpty assure:"notEmpty"
minLength=N AssureListMinLength(N) assure:"minLength=1"
length=N AssureListLength(N) assure:"length=3"
notLength=N AssureListNotLength(N) assure:"notLength=0"
contains=X AssureListContains(X) assure:"contains=primary"
notContains=X AssureListNotContains(X) assure:"notContains=localhost"
containsKey=X AssureListContainsKey(X) assure:"containsKey=admin"

Example:

type ClusterConfig struct {
    Servers []string `fig:"servers" assure:"notEmpty|minLength=2|notContains=localhost"`
}

Map Validators (tMap)

Tag Token Equivalent AssureFunc Example Tag
notEmpty AssureMapNotEmpty assure:"notEmpty"
hasKey=X AssureMapHasKey(X) assure:"hasKey=env"
hasKeys=X,Y AssureMapHasKeys([]string) assure:"hasKeys=env,version"
length=N AssureMapLength(N) assure:"length=2"
notLength=N AssureMapNotLength(N) assure:"notLength=0"
valueMatches=K:V AssureMapValueMatches(K,V) assure:"valueMatches=env:prod"

Example:

type MetaConfig struct {
    Labels map[string]string `fig:"labels" assure:"notEmpty|hasKeys=env,version|valueMatches=env:prod"`
}

Token Parser Design

The internal parser for the assure tag would work like this:

func parseAssureTag(tag string, fieldType reflect.Type) ([]ValidatorFunc, error) {
    tokens := strings.Split(tag, "|")
    var validators []ValidatorFunc
    for _, token := range tokens {
        fn, err := resolveToken(token, fieldType)
        if err != nil {
            return nil, fmt.Errorf("unknown assure token %q for type %s", token, fieldType)
        }
        validators = append(validators, fn)
    }
    return validators, nil
}

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:

const (
    AssureNotEmpty    = "notEmpty"
    AssureLength      = "length"
    AssureGt          = "gt"
    AssureLt          = "lt"
    AssureInRange     = "inRange"
    AssureHasPrefix   = "hasPrefix"
    // ...
)

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

package main

import (
    "log"
    "time"
    "github.com/andreimerlescu/figtree/v2"
)

// All 36 validators exercised across a realistic config struct

type DatabaseConfig struct {
    Host     string `fig:"db-host" assure:"notEmpty|hasPrefix=postgres://|noSuffix=.test"`
    Name     string `fig:"db-name" assure:"notEmpty|length=8|notContains=drop"`
    Schema   string `fig:"db-schema" assure:"hasPrefix=sc_|hasSuffix=_v1|notEmpty"`
    Password string `fig:"db-password" assure:"notEmpty|noPrefix=password|noPrefixes=pass,secret,admin"`
    Encoding string `fig:"db-encoding" assure:"hasSuffix=8|noSuffixes=ascii,latin1"`
    Tag      string `fig:"db-tag" assure:"substring=prod"`
}

type WorkerConfig struct {
    Count      int           `fig:"workers"    assure:"positive|inRange=1,128"`
    Port       int           `fig:"port"       assure:"gt=1023|lt=65536"`
    Offset     int           `fig:"offset"     assure:"negative"`
    MaxRetries int64         `fig:"maxRetries" assure:"positive|gt=0|lt=100"`
    BatchSize  int64         `fig:"batchSize"  assure:"inRange=1,10000"`
    Timeout    time.Duration `fig:"timeout"    assure:"positive|min=5s|max=2m"`
    Interval   time.Duration `fig:"interval"   assure:"gt=30s|lt=1h"`
}

type ThresholdConfig struct {
    Confidence float64 `fig:"confidence" assure:"positive|notNaN|inRange=0.0,1.0"`
    SampleRate float64 `fig:"sampleRate" assure:"gt=0.0|lt=100.0"`
}

type FeatureConfig struct {
    ProductionMode bool `fig:"production" assure:"true"`
    DebugMode      bool `fig:"debug"      assure:"false"`
}

type InfraConfig struct {
    Servers []string          `fig:"servers"  assure:"notEmpty|minLength=2|notContains=localhost|containsKey=primary"`
    Labels  map[string]string `fig:"labels"   assure:"notEmpty|hasKeys=env,version|hasKey=region|valueMatches=env:prod"`
}

type AppConfig struct {
    Database   DatabaseConfig
    Workers    WorkerConfig
    Thresholds ThresholdConfig
    Features   FeatureConfig
    Infra      InfraConfig
}

func main() {
    figs := figtree.Grow()

    // Register all figs
    figs.NewString("db-host", "postgres://localhost/mydb", "Database host")
    figs.NewString("db-name", "mydbname", "Database name")
    figs.NewString("db-schema", "sc_public_v1", "Database schema")
    figs.NewString("db-password", "s3cur3pass!", "Database password")
    figs.NewString("db-encoding", "utf8", "Database encoding")
    figs.NewString("db-tag", "prod-replica", "Database tag")
    figs.NewInt("workers", 8, "Worker count")
    figs.NewInt("port", 5432, "Port")
    figs.NewInt("offset", -1, "Offset")
    figs.NewInt64("maxRetries", 5, "Max retries")
    figs.NewInt64("batchSize", 500, "Batch size")
    figs.NewDuration("timeout", 30*time.Second, "Request timeout")
    figs.NewDuration("interval", 2*time.Minute, "Poll interval")
    figs.NewFloat64("confidence", 0.95, "Confidence threshold")
    figs.NewFloat64("sampleRate", 10.0, "Sample rate")
    figs.NewBool("production", true, "Production mode")
    figs.NewBool("debug", false, "Debug mode")
    figs.NewList("servers", []string{"primary", "replica-1"}, "Server list")
    figs.NewMap("labels", map[string]string{
        "env":     "prod",
        "version": "2.0",
        "region":  "us-east-1",
    }, "Infra labels")

    if err := figs.Parse(); err != nil {
        log.Fatal(err)
    }

    var cfg AppConfig
    if err := figs.Unmarshal(&cfg); err != nil {
        log.Fatalf("config invalid: %v", err)
    }

    log.Printf("Database host: %s", cfg.Database.Host)
    log.Printf("Workers: %d", cfg.Workers.Count)
    log.Printf("Confidence: %.2f", cfg.Thresholds.Confidence)
}

Metadata

Metadata

Labels

enhancementNew feature or request

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions