package rules import ( "fmt" "regexp" "strconv" "strings" "time" "git.pengzhan.dev/haopengzhan/rsspipe/internal/models" ) type Evaluator struct{} func NewEvaluator() *Evaluator { return &Evaluator{} } // Evaluate checks whether a FeedEntry satisfies a Rule's conditions. func (e *Evaluator) Evaluate(rule models.Rule, entry models.FeedEntry) bool { if len(rule.Conditions) == 0 { return true } matchMode := strings.ToLower(rule.MatchMode) if matchMode == "" || matchMode == "all" { for _, cond := range rule.Conditions { if !e.evaluateCondition(cond, entry) { return false } } return true } // matchMode == "any" for _, cond := range rule.Conditions { if e.evaluateCondition(cond, entry) { return true } } return false } func (e *Evaluator) evaluateCondition(cond models.RuleCondition, entry models.FeedEntry) bool { actualValue := e.resolveField(cond.Field, entry) return e.compare(actualValue, cond.Operator, cond.Value) } func (e *Evaluator) resolveField(field string, entry models.FeedEntry) interface{} { switch strings.ToLower(field) { case "feed_id": return entry.FeedID case "feed_title": return entry.FeedTitle case "title": return entry.Title case "author": return entry.Author case "url": return entry.URL case "content", "raw_content", "rss_content": if entry.Content != "" { return entry.Content } return entry.RawContent case "is_read": return entry.IsRead case "is_starred": return entry.IsStarred case "published_at": return entry.PublishedAt case "created_at": return entry.CreatedAt } // Variant field resolution: "variants.keywords", "variants.readability_full_text", "variants.tags" if strings.HasPrefix(field, "variants.") { variantKey := strings.TrimPrefix(field, "variants.") if entry.Variants != nil { if v, ok := entry.Variants[variantKey]; ok { return v.TextValue } } } return nil } func (e *Evaluator) compare(actual interface{}, op string, expected interface{}) bool { opLower := strings.ToLower(strings.TrimSpace(op)) // Handle exists / not_exists operators if opLower == "exists" { if actual == nil { return false } if s, ok := actual.(string); ok && s == "" { return false } return true } if opLower == "not_exists" { if actual == nil { return true } if s, ok := actual.(string); ok && s == "" { return true } return false } if actual == nil { return opLower == "neq" || opLower == "!=" || opLower == "not_in" || opLower == "not_contains" } // Handle Time/Timestamp comparisons if actualTime, ok := actual.(time.Time); ok { return e.compareTime(actualTime, opLower, expected) } actualStr := fmt.Sprintf("%v", actual) expectedStr := fmt.Sprintf("%v", expected) switch opLower { case "eq", "==": return strings.EqualFold(actualStr, expectedStr) case "neq", "!=": return !strings.EqualFold(actualStr, expectedStr) case "contains": return strings.Contains(strings.ToLower(actualStr), strings.ToLower(expectedStr)) case "not_contains": return !strings.Contains(strings.ToLower(actualStr), strings.ToLower(expectedStr)) case "starts_with": return strings.HasPrefix(strings.ToLower(actualStr), strings.ToLower(expectedStr)) case "ends_with": return strings.HasSuffix(strings.ToLower(actualStr), strings.ToLower(expectedStr)) case "contains_any": tokens := parseStringList(expected) for _, token := range tokens { if strings.Contains(strings.ToLower(actualStr), strings.ToLower(token)) { return true } } return false case "in": tokens := parseStringList(expected) for _, item := range tokens { if strings.EqualFold(actualStr, item) { return true } } return false case "not_in": tokens := parseStringList(expected) for _, item := range tokens { if strings.EqualFold(actualStr, item) { return false } } return true case "matches", "regex": re, err := regexp.Compile(expectedStr) if err != nil { return false } return re.MatchString(actualStr) default: return false } } func (e *Evaluator) compareTime(t time.Time, op string, expected interface{}) bool { now := time.Now() switch op { case "within_last_hours": hours, err := toFloat64(expected) if err != nil { return false } cutoff := now.Add(-time.Duration(hours * float64(time.Hour))) return t.After(cutoff) case "older_than_days": days, err := toFloat64(expected) if err != nil { return false } cutoff := now.Add(-time.Duration(days * 24 * float64(time.Hour))) return t.Before(cutoff) case ">=", "after": targetTime, err := parseTime(expected) if err != nil { return false } return t.After(targetTime) || t.Equal(targetTime) case "<=", "before": targetTime, err := parseTime(expected) if err != nil { return false } return t.Before(targetTime) || t.Equal(targetTime) } return false } func parseStringList(val interface{}) []string { var result []string switch v := val.(type) { case []interface{}: for _, item := range v { result = append(result, strings.TrimSpace(fmt.Sprintf("%v", item))) } case []string: for _, item := range v { result = append(result, strings.TrimSpace(item)) } case string: parts := strings.Split(v, ",") for _, part := range parts { trimmed := strings.TrimSpace(part) if trimmed != "" { result = append(result, trimmed) } } } return result } func toFloat64(val interface{}) (float64, error) { switch v := val.(type) { case float64: return v, nil case int: return float64(v), nil case int64: return float64(v), nil case string: return strconv.ParseFloat(strings.TrimSpace(v), 64) } return 0, fmt.Errorf("cannot convert %v to float64", val) } func parseTime(val interface{}) (time.Time, error) { if t, ok := val.(time.Time); ok { return t, nil } s := fmt.Sprintf("%v", val) return time.Parse(time.RFC3339, s) } // ValidateRuleSafety checks for potential cyclic/endless loops at rule creation time. func ValidateRuleSafety(rule models.Rule) error { trigger := strings.ToLower(rule.Trigger) // If trigger is on_variant_updated / variant_change, check for recursive set_variant on same key if trigger == "on_variant_updated" || trigger == "variant_change" { for _, action := range rule.Actions { if action.Type == "set_variant" { targetKey, _ := action.Params["variant_key"].(string) if targetKey == "" { targetKey, _ = action.Params["key"].(string) } // Check if trigger targets this exact key without safe conditions for _, cond := range rule.Conditions { if cond.Field == "variants."+targetKey && (cond.Operator == "exists" || cond.Operator == "eq") { return fmt.Errorf("cyclic rule detected: rule triggers on variant '%s' and unconditionally writes to '%s'", targetKey, targetKey) } } } } } return nil }