Skip to content

Commit 8fe658b

Browse files
fix(automations): negate regex match for NotContains/NotEqual operators (#1441)
Co-authored-by: soup <s0up4200@pm.me>
1 parent 62c50c0 commit 8fe658b

3 files changed

Lines changed: 152 additions & 8 deletions

File tree

documentation/docs/features/automations.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,21 @@ The State field matches these status buckets:
132132

133133
### Regex Support
134134

135-
Full RE2 (Go regex) syntax supported. Patterns are case-insensitive by default. The UI validates patterns and shows helpful error messages for invalid regex.
135+
Full RE2 (Go regex) syntax supported. Patterns are case-insensitive by default.
136+
137+
Regex can be used either by selecting **matches regex** or by enabling the **Regex** toggle for a condition:
138+
139+
- When regex is enabled, the condition checks whether the regex matches the field value.
140+
- `not equals` and `not contains` invert the regex result (true only if the regex does **not** match).
141+
- Operators like `equals`, `contains`, `starts with`, and `ends with` are treated as regex match when regex is enabled.
142+
- Regex is not implicitly anchored: use `^` and `$` if you want an exact/full-string match (example: `^BHD$`).
143+
144+
Field notes:
145+
146+
- **Tracker**: checked against multiple candidates (raw URL, extracted domain, and optional customization display name). Negative regex passes only if **none** of the candidates match.
147+
- **Tags**: without regex, string operators are applied per-tag. With regex enabled, the regex is matched against the full raw tags string.
148+
149+
The UI validates patterns and shows helpful error messages for invalid regex.
136150

137151
## Tracker Matching
138152

internal/services/automations/evaluator.go

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,11 @@ func compareString(value string, cond *RuleCondition) bool {
573573
if cond.Compiled == nil {
574574
return false
575575
}
576-
return cond.Compiled.MatchString(value)
576+
matched := cond.Compiled.MatchString(value)
577+
if cond.Operator == OperatorNotContains || cond.Operator == OperatorNotEqual {
578+
return !matched
579+
}
580+
return matched
577581
}
578582

579583
switch cond.Operator {
@@ -621,15 +625,23 @@ func compareTracker(trackerURL string, cond *RuleCondition, ctx *EvalContext) bo
621625
return compareString("", cond)
622626
}
623627

624-
// Keep string-field semantics consistent: when regex is enabled, operator is ignored and we
625-
// just test the regex against the value.
626628
if cond.Regex || cond.Operator == OperatorMatches {
629+
if cond.Compiled == nil {
630+
return false
631+
}
632+
anyMatch := false
627633
for _, c := range candidates {
628-
if compareString(c, cond) {
629-
return true
634+
if cond.Compiled.MatchString(c) {
635+
anyMatch = true
636+
break
630637
}
631638
}
632-
return false
639+
if cond.Operator == OperatorNotContains || cond.Operator == OperatorNotEqual {
640+
// Negative operators apply to the combined candidate set: fail if any candidate matches.
641+
return !anyMatch
642+
}
643+
// Regex-enabled string operators: succeed if any candidate matches.
644+
return anyMatch
633645
}
634646

635647
// Important: negative operators must apply to the combined candidate set.
@@ -720,7 +732,11 @@ func compareTags(tagsRaw string, cond *RuleCondition) bool {
720732
if cond.Compiled == nil {
721733
return false
722734
}
723-
return cond.Compiled.MatchString(tagsRaw)
735+
matched := cond.Compiled.MatchString(tagsRaw)
736+
if cond.Operator == OperatorNotContains || cond.Operator == OperatorNotEqual {
737+
return !matched
738+
}
739+
return matched
724740
}
725741

726742
tags := splitTags(tagsRaw)

internal/services/automations/evaluator_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,72 @@ func TestEvaluateCondition_StringFields(t *testing.T) {
217217
torrent: qbt.Torrent{Name: "Test.Torrent.2024"},
218218
expected: true,
219219
},
220+
{
221+
name: "not_contains regex - false when regex matches",
222+
cond: &RuleCondition{
223+
Field: FieldName,
224+
Operator: OperatorNotContains,
225+
Value: "^Test.*2024$",
226+
Regex: true,
227+
},
228+
torrent: qbt.Torrent{Name: "Test.Torrent.2024"},
229+
expected: false,
230+
},
231+
{
232+
name: "not_contains regex - true when regex does not match",
233+
cond: &RuleCondition{
234+
Field: FieldName,
235+
Operator: OperatorNotContains,
236+
Value: "^Movie.*2024$",
237+
Regex: true,
238+
},
239+
torrent: qbt.Torrent{Name: "Test.Torrent.2024"},
240+
expected: true,
241+
},
242+
{
243+
name: "not_equal regex - false when regex matches",
244+
cond: &RuleCondition{
245+
Field: FieldName,
246+
Operator: OperatorNotEqual,
247+
Value: ".*Torrent.*",
248+
Regex: true,
249+
},
250+
torrent: qbt.Torrent{Name: "Test.Torrent.2024"},
251+
expected: false,
252+
},
253+
{
254+
name: "not_equal regex - true when regex does not match",
255+
cond: &RuleCondition{
256+
Field: FieldName,
257+
Operator: OperatorNotEqual,
258+
Value: "^Movie",
259+
Regex: true,
260+
},
261+
torrent: qbt.Torrent{Name: "Test.Torrent.2024"},
262+
expected: true,
263+
},
264+
{
265+
name: "contains regex - true when regex matches",
266+
cond: &RuleCondition{
267+
Field: FieldName,
268+
Operator: OperatorContains,
269+
Value: "Torrent",
270+
Regex: true,
271+
},
272+
torrent: qbt.Torrent{Name: "Test.Torrent.2024"},
273+
expected: true,
274+
},
275+
{
276+
name: "contains regex - false when regex does not match",
277+
cond: &RuleCondition{
278+
Field: FieldName,
279+
Operator: OperatorContains,
280+
Value: "^Movie",
281+
Regex: true,
282+
},
283+
torrent: qbt.Torrent{Name: "Test.Torrent.2024"},
284+
expected: false,
285+
},
220286
}
221287

222288
for _, tt := range tests {
@@ -280,6 +346,54 @@ func TestEvaluateCondition_TrackerField_DisplayNameAndNegation(t *testing.T) {
280346
t.Fatalf("expected false, got %v", got)
281347
}
282348
})
349+
350+
t.Run("not_equal regex - false when any candidate matches (display name)", func(t *testing.T) {
351+
cond := &RuleCondition{
352+
Field: FieldTracker,
353+
Operator: OperatorNotEqual,
354+
Value: "^BHD$",
355+
Regex: true,
356+
}
357+
if got := EvaluateConditionWithContext(cond, torrent, ctx, 0); got != false {
358+
t.Fatalf("expected false, got %v", got)
359+
}
360+
})
361+
362+
t.Run("not_contains regex - false when any candidate matches (display name)", func(t *testing.T) {
363+
cond := &RuleCondition{
364+
Field: FieldTracker,
365+
Operator: OperatorNotContains,
366+
Value: "BHD",
367+
Regex: true,
368+
}
369+
if got := EvaluateConditionWithContext(cond, torrent, ctx, 0); got != false {
370+
t.Fatalf("expected false, got %v", got)
371+
}
372+
})
373+
374+
t.Run("not_equal regex - true when no candidate matches", func(t *testing.T) {
375+
cond := &RuleCondition{
376+
Field: FieldTracker,
377+
Operator: OperatorNotEqual,
378+
Value: "^XYZ$",
379+
Regex: true,
380+
}
381+
if got := EvaluateConditionWithContext(cond, torrent, ctx, 0); got != true {
382+
t.Fatalf("expected true, got %v", got)
383+
}
384+
})
385+
386+
t.Run("not_contains regex - true when no candidate matches", func(t *testing.T) {
387+
cond := &RuleCondition{
388+
Field: FieldTracker,
389+
Operator: OperatorNotContains,
390+
Value: "XYZ",
391+
Regex: true,
392+
}
393+
if got := EvaluateConditionWithContext(cond, torrent, ctx, 0); got != true {
394+
t.Fatalf("expected true, got %v", got)
395+
}
396+
})
283397
}
284398

285399
func TestEvaluateCondition_NumericFields(t *testing.T) {

0 commit comments

Comments
 (0)