348 lines
8.8 KiB
Go
348 lines
8.8 KiB
Go
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: <kind> >= 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 (>= <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 "<nil>"
|
|
}
|
|
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
|
|
}
|