package voucher import ( "errors" "fmt" "strconv" "strings" "unicode" ) /* Package voucher - rule parser A lightweight parser for voucher rule expressions. Supported rule kinds (case-insensitive keywords): sku=SKU1|SKU2|SKU3 - At least one of the listed SKUs must be present in the cart. category=CatA|CatB|CatC - At least one of the listed categories must be present. min_total>=12345 - Cart total (Inc VAT) must be at least this value (int64). min_item_price>=5000 - At least one individual item (Inc VAT single unit price) must be at least this value (int64). Rule list grammar (simplified): rules := rule (sep rule)* rule := (sku|category) '=' valueList | (min_total|min_item_price) comparator number valueList := value ('|' value)* comparator := '>=' (only comparator currently supported for numeric rules) sep := ';' | ',' | newline Whitespace is ignored around tokens. Example: sku=ABC123|XYZ999; category=Shoes|Bags min_total>=10000 min_item_price>=2500, category=Accessories Parsing returns a RuleSet which can later be evaluated against a generic context. The evaluation context uses simple Item abstractions to avoid tight coupling with the cart implementation (which currently lives under cmd/cart and cannot be imported due to being in package main). This is intentionally conservative and extensible: * Adding new rule kinds: extend RuleKind constants, add parse + evaluate logic. * Supporting new operators: extend numeric rule parsing & evaluation. */ var ( // ErrEmptyExpression is returned when the input string has only whitespace. ErrEmptyExpression = errors.New("voucher: empty rule expression") // ErrInvalidRule indicates a syntactic or semantic issue with a single rule fragment. ErrInvalidRule = errors.New("voucher: invalid rule") ) // RuleKind enumerates supported rule kinds. type RuleKind string const ( RuleSku RuleKind = "sku" RuleCategory RuleKind = "category" RuleMinTotal RuleKind = "min_total" RuleMinItemPrice RuleKind = "min_item_price" ) // ruleCondition represents a single, parsed rule. type ruleCondition struct { Kind RuleKind StringVals []string // For sku / category multi-value list MinValue *int64 // For numeric threshold rules // Operator reserved for future (e.g., >, >=, ==). Currently always ">=" for numeric kinds. Operator string } // RuleSet groups multiple rule conditions (logical AND). // All conditions must pass for Applies() to return true. type RuleSet struct { Conditions []ruleCondition Source string // original, trimmed source string } // Item is a minimal abstraction for evaluation (decoupled from cart domain structs). type Item struct { Sku string Category string UnitPrice int64 // Inc VAT (single unit) } // EvalContext bundles cart-like data necessary for evaluation. type EvalContext struct { Items []Item CartTotalInc int64 } // Applies returns true if all rule conditions pass for the context. func (rs *RuleSet) Applies(ctx EvalContext) bool { for _, c := range rs.Conditions { switch c.Kind { case RuleSku: if !anyItem(ctx.Items, func(it Item) bool { return containsFold(c.StringVals, it.Sku) }) { return false } case RuleCategory: if !anyItem(ctx.Items, func(it Item) bool { return containsFold(c.StringVals, it.Category) }) { return false } case RuleMinTotal: if c.MinValue == nil || ctx.CartTotalInc < *c.MinValue { return false } case RuleMinItemPrice: if c.MinValue == nil { return false } if !anyItem(ctx.Items, func(it Item) bool { return it.UnitPrice >= *c.MinValue }) { return false } default: // Unknown kinds fail closed to avoid granting unintended discounts. return false } } return true } // anyItem returns true if predicate matches any item. func anyItem(items []Item, pred func(Item) bool) bool { for _, it := range items { if pred(it) { return true } } return false } // ParseRules parses a rule expression into a RuleSet. func ParseRules(input string) (*RuleSet, error) { trimmed := strings.TrimSpace(input) if trimmed == "" { return nil, ErrEmptyExpression } fragments := splitRuleFragments(trimmed) if len(fragments) == 0 { return nil, ErrInvalidRule } var conditions []ruleCondition for _, frag := range fragments { if frag == "" { continue } c, err := parseFragment(frag) if err != nil { return nil, fmt.Errorf("%w: %s (%v)", ErrInvalidRule, frag, err) } conditions = append(conditions, c) } if len(conditions) == 0 { return nil, ErrInvalidRule } return &RuleSet{ Conditions: conditions, Source: trimmed, }, nil } // splitRuleFragments splits on ; , or newline, while respecting basic structure. func splitRuleFragments(s string) []string { // Normalize line endings s = strings.ReplaceAll(s, "\r\n", "\n") // We allow separators: newline, semicolon, comma. seps := func(r rune) bool { return r == ';' || r == '\n' || r == ',' } raw := strings.FieldsFunc(s, seps) out := make([]string, 0, len(raw)) for _, f := range raw { t := strings.TrimSpace(f) if t != "" { out = append(out, t) } } return out } // parseFragment parses an individual rule fragment. func parseFragment(frag string) (ruleCondition, error) { lower := strings.ToLower(frag) // Numeric rules have form: >= number if strings.HasPrefix(lower, string(RuleMinTotal)) || strings.HasPrefix(lower, string(RuleMinItemPrice)) { return parseNumericRule(frag) } // Key=Value list rules (sku / category). if i := strings.Index(frag, "="); i > 0 { key := strings.TrimSpace(frag[:i]) valPart := strings.TrimSpace(frag[i+1:]) if key == "" || valPart == "" { return ruleCondition{}, errors.New("empty key/value") } kind := RuleKind(strings.ToLower(key)) switch kind { case RuleSku, RuleCategory: values := splitAndClean(valPart, "|") if len(values) == 0 { return ruleCondition{}, errors.New("empty value list") } return ruleCondition{ Kind: kind, StringVals: values, }, nil default: return ruleCondition{}, fmt.Errorf("unsupported key '%s'", key) } } return ruleCondition{}, fmt.Errorf("unrecognized fragment '%s'", frag) } func parseNumericRule(frag string) (ruleCondition, error) { // Support only '>=' for now. var kind RuleKind var rest string fragTrim := strings.TrimSpace(frag) switch { case strings.HasPrefix(strings.ToLower(fragTrim), string(RuleMinTotal)): kind = RuleMinTotal rest = strings.TrimSpace(fragTrim[len(RuleMinTotal):]) case strings.HasPrefix(strings.ToLower(fragTrim), string(RuleMinItemPrice)): kind = RuleMinItemPrice rest = strings.TrimSpace(fragTrim[len(RuleMinItemPrice):]) default: return ruleCondition{}, fmt.Errorf("unknown numeric rule '%s'", frag) } // Expect operator and number (>= ) rest = stripLeadingSpace(rest) if !strings.HasPrefix(rest, ">=") { return ruleCondition{}, fmt.Errorf("expected '>=' in '%s'", frag) } numStr := strings.TrimSpace(rest[2:]) if numStr == "" { return ruleCondition{}, fmt.Errorf("missing numeric value in '%s'", frag) } value, err := strconv.ParseInt(numStr, 10, 64) if err != nil { return ruleCondition{}, fmt.Errorf("invalid number '%s': %v", numStr, err) } if value < 0 { return ruleCondition{}, fmt.Errorf("negative threshold %d", value) } return ruleCondition{ Kind: kind, MinValue: &value, Operator: ">=", }, nil } func stripLeadingSpace(s string) string { for len(s) > 0 && unicode.IsSpace(rune(s[0])) { s = s[1:] } return s } func splitAndClean(s string, sep string) []string { raw := strings.Split(s, sep) out := make([]string, 0, len(raw)) for _, r := range raw { t := strings.TrimSpace(r) if t != "" { out = append(out, t) } } return out } func containsFold(list []string, candidate string) bool { for _, v := range list { if strings.EqualFold(v, candidate) { return true } } return false } // Describe returns a human-friendly summary of the parsed rule set. func (rs *RuleSet) Describe() string { if rs == nil { return "" } var parts []string for _, c := range rs.Conditions { switch c.Kind { case RuleSku, RuleCategory: parts = append(parts, fmt.Sprintf("%s in (%s)", c.Kind, strings.Join(c.StringVals, "|"))) case RuleMinTotal, RuleMinItemPrice: if c.MinValue != nil { parts = append(parts, fmt.Sprintf("%s %s %d", c.Kind, c.OperatorOr(">="), *c.MinValue)) } default: parts = append(parts, fmt.Sprintf("unknown(%s)", c.Kind)) } } return strings.Join(parts, " AND ") } func (c ruleCondition) OperatorOr(def string) string { if c.Operator == "" { return def } return c.Operator } // --- Convenience helpers for incremental adoption --- // MustParseRules panics on parse error (useful in tests or static initialization). func MustParseRules(expr string) *RuleSet { rs, err := ParseRules(expr) if err != nil { panic(err) } return rs }