Skip to main content

net/consumer/
filter.rs

1//! JSON predicate filtering for event consumption.
2//!
3//! The filter engine supports:
4//! - Logical operators: `$and`, `$or`, `$not`
5//! - Dot-path field access: `"foo.bar.baz"`
6//! - Equality matching (values must match exactly)
7//!
8//! Filtering is performed **after retrieval** from the adapter,
9//! not pushed down to the storage layer.
10
11use serde::{Deserialize, Serialize};
12use serde_json::Value as JsonValue;
13
14/// Inner equality condition (path + value).
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16pub struct EqCondition {
17    /// Dot-separated path to the field (e.g., "foo.bar.baz").
18    pub path: String,
19    /// Value to match against.
20    pub value: JsonValue,
21}
22
23/// A filter predicate for matching events.
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25#[serde(untagged)]
26pub enum Filter {
27    /// Logical AND: all filters must match.
28    And {
29        /// List of filters that must all match.
30        #[serde(rename = "$and")]
31        filters: Vec<Filter>,
32    },
33    /// Logical OR: at least one filter must match.
34    Or {
35        /// List of filters where at least one must match.
36        #[serde(rename = "$or")]
37        filters: Vec<Filter>,
38    },
39    /// Logical NOT: the inner filter must not match.
40    Not {
41        /// The filter to negate.
42        #[serde(rename = "$not")]
43        filter: Box<Filter>,
44    },
45    /// Equality match with $eq wrapper: `{ "$eq": { "path": "...", "value": ... } }`
46    EqWrapped {
47        /// The equality condition.
48        #[serde(rename = "$eq")]
49        condition: EqCondition,
50    },
51    /// Equality match (shorthand): `{ "path": "...", "value": ... }`
52    Eq {
53        /// Dot-separated path to the field (e.g., "foo.bar.baz").
54        path: String,
55        /// Value to match against.
56        value: JsonValue,
57    },
58}
59
60/// One step in a compiled dot-path.
61///
62/// Holds the raw field name (used for `JsonValue::Object` lookup) plus
63/// the optional array-index parse cached at compile time (used for
64/// `JsonValue::Array` lookup).
65#[derive(Debug, Clone)]
66pub struct CompiledSegment {
67    field: String,
68    idx: Option<usize>,
69}
70
71impl CompiledSegment {
72    fn from_str(s: &str) -> Self {
73        Self {
74            field: s.to_string(),
75            idx: s.parse().ok(),
76        }
77    }
78}
79
80/// Pre-compiled filter where every dot-path is split + each segment's
81/// integer parse is cached. Produced once via [`Filter::compile`] and
82/// reused across every event in a poll.
83///
84/// Pre-fix perf #15 / #16 in `docs/performance/net-perf-analysis.md`
85/// every event in the filtered-poll retain loop re-split the path on
86/// `'.'` and re-parsed each segment as `usize`. For a 10K-event response
87/// with a 3-segment path, that was 30K path splits + ~30K speculative
88/// integer parses, all producing the same compile-time-known segments.
89#[derive(Debug, Clone)]
90pub enum CompiledFilter {
91    /// Pre-compiled AND.
92    And(Vec<CompiledFilter>),
93    /// Pre-compiled OR.
94    Or(Vec<CompiledFilter>),
95    /// Pre-compiled NOT.
96    Not(Box<CompiledFilter>),
97    /// Pre-compiled equality match with the path already split.
98    Eq {
99        /// Path segments, pre-split and per-segment index-parsed.
100        segments: Vec<CompiledSegment>,
101        /// Value to match against.
102        value: JsonValue,
103    },
104}
105
106impl CompiledFilter {
107    /// Evaluate the compiled filter against an event. Semantically
108    /// identical to [`Filter::matches`].
109    #[inline]
110    pub fn matches(&self, event: &JsonValue) -> bool {
111        match self {
112            Self::And(filters) if filters.len() == 1 => filters[0].matches(event),
113            Self::Or(filters) if filters.len() == 1 => filters[0].matches(event),
114            Self::And(filters) => !filters.is_empty() && filters.iter().all(|f| f.matches(event)),
115            Self::Or(filters) => filters.iter().any(|f| f.matches(event)),
116            Self::Not(f) => !f.matches(event),
117            Self::Eq { segments, value } => json_path_get_compiled(event, segments) == Some(value),
118        }
119    }
120}
121
122/// Walk a pre-compiled segment list. Mirror of [`json_path_get`] but
123/// skips the per-call `split` and `segment.parse::<usize>()`.
124#[inline]
125fn json_path_get_compiled<'a>(
126    value: &'a JsonValue,
127    segments: &[CompiledSegment],
128) -> Option<&'a JsonValue> {
129    if segments.is_empty() {
130        return Some(value);
131    }
132    let mut current = value;
133    for seg in segments {
134        current = match current {
135            JsonValue::Object(map) => map.get(&seg.field)?,
136            JsonValue::Array(arr) => arr.get(seg.idx?)?,
137            _ => return None,
138        };
139    }
140    Some(current)
141}
142
143impl Filter {
144    /// Create an AND filter.
145    pub fn and(filters: Vec<Filter>) -> Self {
146        Self::And { filters }
147    }
148
149    /// Create an OR filter.
150    pub fn or(filters: Vec<Filter>) -> Self {
151        Self::Or { filters }
152    }
153
154    /// Create a NOT filter.
155    #[allow(clippy::should_implement_trait)]
156    pub fn not(filter: Filter) -> Self {
157        Self::Not {
158            filter: Box::new(filter),
159        }
160    }
161
162    /// Create an equality filter.
163    pub fn eq(path: impl Into<String>, value: JsonValue) -> Self {
164        Self::Eq {
165            path: path.into(),
166            value,
167        }
168    }
169
170    /// Check if an event matches this filter.
171    ///
172    /// Empty `And` children are rejected as "matches nothing" rather
173    /// than "matches everything" — `.all()` on an empty iterator
174    /// returns `true`, which would silently turn an externally-
175    /// supplied filter JSON like `{"and": []}` into a universal
176    /// pass-through. Empty `Or` naturally returns `false` via
177    /// `.any()` on an empty iterator and keeps its documented
178    /// "matches nothing" behavior.
179    #[inline]
180    pub fn matches(&self, event: &JsonValue) -> bool {
181        match self {
182            // Single-element fast path: skip the iterator + closure
183            // setup and recurse directly. `And { filters: [f] }` and
184            // `Or { filters: [f] }` are common after deserializing
185            // small filter trees and were otherwise paying iter+all/any
186            // overhead per event.
187            Self::And { filters } if filters.len() == 1 => filters[0].matches(event),
188            Self::Or { filters } if filters.len() == 1 => filters[0].matches(event),
189            Self::And { filters } => {
190                !filters.is_empty() && filters.iter().all(|f| f.matches(event))
191            }
192            Self::Or { filters } => filters.iter().any(|f| f.matches(event)),
193            Self::Not { filter } => !filter.matches(event),
194            Self::EqWrapped { condition } => {
195                json_path_get(event, &condition.path) == Some(&condition.value)
196            }
197            Self::Eq { path, value } => json_path_get(event, path) == Some(value),
198        }
199    }
200
201    /// Parse a filter from JSON.
202    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
203        serde_json::from_str(json)
204    }
205
206    /// Convert the filter to JSON.
207    pub fn to_json(&self) -> Result<String, serde_json::Error> {
208        serde_json::to_string(self)
209    }
210
211    /// Pre-split every path and pre-parse each segment's integer
212    /// form into a [`CompiledFilter`]. Call once per poll before
213    /// the per-event retain loop — see perf #15 / #16.
214    pub fn compile(&self) -> CompiledFilter {
215        match self {
216            Self::And { filters } => {
217                CompiledFilter::And(filters.iter().map(Self::compile).collect())
218            }
219            Self::Or { filters } => CompiledFilter::Or(filters.iter().map(Self::compile).collect()),
220            Self::Not { filter } => CompiledFilter::Not(Box::new(filter.compile())),
221            Self::Eq { path, value } => CompiledFilter::Eq {
222                segments: compile_path(path),
223                value: value.clone(),
224            },
225            Self::EqWrapped { condition } => CompiledFilter::Eq {
226                segments: compile_path(&condition.path),
227                value: condition.value.clone(),
228            },
229        }
230    }
231}
232
233/// Split a dot-path into [`CompiledSegment`]s. An empty path
234/// compiles to an empty segment list — semantically equivalent to
235/// "match the root value" per [`json_path_get`].
236fn compile_path(path: &str) -> Vec<CompiledSegment> {
237    if path.is_empty() {
238        Vec::new()
239    } else {
240        path.split('.').map(CompiledSegment::from_str).collect()
241    }
242}
243
244/// Efficient dot-path accessor for JSON values.
245///
246/// Given a path like `"foo.bar.baz"`, returns `value["foo"]["bar"]["baz"]`.
247///
248/// # Examples
249///
250/// ```
251/// use serde_json::json;
252/// use net::consumer::filter::json_path_get;
253///
254/// let value = json!({"user": {"name": "Alice", "age": 30}});
255/// assert_eq!(json_path_get(&value, "user.name"), Some(&json!("Alice")));
256/// assert_eq!(json_path_get(&value, "user.age"), Some(&json!(30)));
257/// assert_eq!(json_path_get(&value, "user.missing"), None);
258/// ```
259#[inline]
260pub fn json_path_get<'a>(value: &'a JsonValue, path: &str) -> Option<&'a JsonValue> {
261    if path.is_empty() {
262        return Some(value);
263    }
264
265    let mut current = value;
266    for segment in path.split('.') {
267        current = match current {
268            JsonValue::Object(map) => map.get(segment)?,
269            JsonValue::Array(arr) => {
270                // Support numeric indexing for arrays
271                let idx: usize = segment.parse().ok()?;
272                arr.get(idx)?
273            }
274            _ => return None,
275        };
276    }
277    Some(current)
278}
279
280/// Filter builder for fluent API.
281#[derive(Debug, Default)]
282pub struct FilterBuilder {
283    filters: Vec<Filter>,
284}
285
286impl FilterBuilder {
287    /// Create a new filter builder.
288    pub fn new() -> Self {
289        Self::default()
290    }
291
292    /// Add an equality condition.
293    pub fn eq(mut self, path: impl Into<String>, value: JsonValue) -> Self {
294        self.filters.push(Filter::eq(path, value));
295        self
296    }
297
298    /// Build an AND filter from accumulated conditions.
299    #[expect(
300        clippy::unwrap_used,
301        reason = "len == 1 branch guarantees the iterator yields exactly one element"
302    )]
303    pub fn build_and(self) -> Filter {
304        if self.filters.len() == 1 {
305            self.filters.into_iter().next().unwrap()
306        } else {
307            Filter::and(self.filters)
308        }
309    }
310
311    /// Build an OR filter from accumulated conditions.
312    #[expect(
313        clippy::unwrap_used,
314        reason = "len == 1 branch guarantees the iterator yields exactly one element"
315    )]
316    pub fn build_or(self) -> Filter {
317        if self.filters.len() == 1 {
318            self.filters.into_iter().next().unwrap()
319        } else {
320            Filter::or(self.filters)
321        }
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use serde_json::json;
329
330    #[test]
331    fn test_eq_filter() {
332        let filter = Filter::eq("type", json!("token"));
333
334        assert!(filter.matches(&json!({"type": "token", "value": "hello"})));
335        assert!(!filter.matches(&json!({"type": "message", "value": "hello"})));
336        assert!(!filter.matches(&json!({"value": "hello"}))); // Missing field
337    }
338
339    /// `Filter::from_json` is reachable from any FFI / SDK path
340    /// that accepts an externally-supplied filter. A deeply
341    /// nested adversarial input must NOT crash the consumer
342    /// thread via stack overflow. We rely on `serde_json`'s
343    /// recursion limit (default 128) to reject the JSON form;
344    /// this test pins that the limit is in force, so a future
345    /// switch to a non-recursive deserializer doesn't silently
346    /// open a DoS vector. Constructed depth (10_000) is well
347    /// past any plausible user filter and well past serde_json's
348    /// limit.
349    #[test]
350    fn from_json_rejects_adversarially_nested_filter() {
351        let depth = 10_000usize;
352        let mut json = String::with_capacity(depth * 8 + 32);
353        for _ in 0..depth {
354            json.push_str(r#"{"$not":"#);
355        }
356        json.push_str(r#"{"path":"x","value":1}"#);
357        for _ in 0..depth {
358            json.push('}');
359        }
360
361        let parsed = Filter::from_json(&json);
362        assert!(
363            parsed.is_err(),
364            "depth-{depth} filter JSON must be rejected by serde_json's recursion limit"
365        );
366    }
367
368    /// Programmatic construction bypasses `from_json` and can
369    /// nest arbitrarily — but that's a Rust-API-only path, not a
370    /// DoS surface. We verify here that `matches` handles a
371    /// modest depth (256 — the same `recursion_limit` set in
372    /// `lib.rs:55`) without overflow even on a small thread
373    /// stack. A future change that materially deepens recursion
374    /// per frame (e.g. wrapping in `Box::pin`) would surface
375    /// here.
376    #[test]
377    fn matches_handles_modest_depth_on_small_stack() {
378        const DEPTH: usize = 256;
379        // Build (depth-many) `Not` wrappers around an Eq leaf.
380        let mut f = Filter::eq("x", json!(1));
381        for _ in 0..DEPTH {
382            f = Filter::not(f);
383        }
384
385        // 256 KiB is well below typical defaults; if `matches`
386        // were to use materially more than ~1 KiB per frame this
387        // would overflow.
388        let result = std::thread::Builder::new()
389            .stack_size(256 * 1024)
390            .spawn(move || f.matches(&json!({"x": 1})))
391            .expect("spawn small-stack thread")
392            .join()
393            .expect("matches() must not panic at depth 256 on a small stack");
394
395        // Even number of `Not` wraps → unchanged truth value.
396        assert!(result, "depth-256 nested Not over true Eq should be true");
397    }
398
399    #[test]
400    fn test_nested_path() {
401        let filter = Filter::eq("user.profile.name", json!("Alice"));
402
403        assert!(filter.matches(&json!({
404            "user": {
405                "profile": {
406                    "name": "Alice",
407                    "age": 30
408                }
409            }
410        })));
411
412        assert!(!filter.matches(&json!({
413            "user": {
414                "profile": {
415                    "name": "Bob"
416                }
417            }
418        })));
419    }
420
421    #[test]
422    fn test_array_indexing() {
423        let filter = Filter::eq("items.0.name", json!("first"));
424
425        assert!(filter.matches(&json!({
426            "items": [
427                {"name": "first"},
428                {"name": "second"}
429            ]
430        })));
431
432        assert!(!filter.matches(&json!({
433            "items": [
434                {"name": "other"}
435            ]
436        })));
437    }
438
439    #[test]
440    fn test_and_filter() {
441        let filter = Filter::and(vec![
442            Filter::eq("type", json!("token")),
443            Filter::eq("index", json!(0)),
444        ]);
445
446        assert!(filter.matches(&json!({"type": "token", "index": 0})));
447        assert!(!filter.matches(&json!({"type": "token", "index": 1})));
448        assert!(!filter.matches(&json!({"type": "message", "index": 0})));
449    }
450
451    #[test]
452    fn test_or_filter() {
453        let filter = Filter::or(vec![
454            Filter::eq("type", json!("token")),
455            Filter::eq("type", json!("message")),
456        ]);
457
458        assert!(filter.matches(&json!({"type": "token"})));
459        assert!(filter.matches(&json!({"type": "message"})));
460        assert!(!filter.matches(&json!({"type": "error"})));
461    }
462
463    #[test]
464    fn test_not_filter() {
465        let filter = Filter::not(Filter::eq("type", json!("error")));
466
467        assert!(filter.matches(&json!({"type": "token"})));
468        assert!(filter.matches(&json!({"type": "message"})));
469        assert!(!filter.matches(&json!({"type": "error"})));
470    }
471
472    #[test]
473    fn test_complex_filter() {
474        // Match tokens that are either "hello" or "world" but not from user "bot"
475        let filter = Filter::and(vec![
476            Filter::eq("type", json!("token")),
477            Filter::or(vec![
478                Filter::eq("value", json!("hello")),
479                Filter::eq("value", json!("world")),
480            ]),
481            Filter::not(Filter::eq("user", json!("bot"))),
482        ]);
483
484        assert!(filter.matches(&json!({
485            "type": "token",
486            "value": "hello",
487            "user": "alice"
488        })));
489
490        assert!(!filter.matches(&json!({
491            "type": "token",
492            "value": "hello",
493            "user": "bot"  // Excluded by NOT
494        })));
495
496        assert!(!filter.matches(&json!({
497            "type": "token",
498            "value": "other",  // Not hello or world
499            "user": "alice"
500        })));
501    }
502
503    #[test]
504    fn test_filter_builder() {
505        let filter = FilterBuilder::new()
506            .eq("type", json!("token"))
507            .eq("active", json!(true))
508            .build_and();
509
510        assert!(filter.matches(&json!({"type": "token", "active": true})));
511        assert!(!filter.matches(&json!({"type": "token", "active": false})));
512    }
513
514    #[test]
515    fn test_filter_serialization() {
516        let filter = Filter::and(vec![
517            Filter::eq("type", json!("token")),
518            Filter::not(Filter::eq("error", json!(true))),
519        ]);
520
521        let json = filter.to_json().unwrap();
522        let parsed: Filter = Filter::from_json(&json).unwrap();
523
524        // Should behave the same after round-trip
525        let event = json!({"type": "token", "error": false});
526        assert_eq!(filter.matches(&event), parsed.matches(&event));
527    }
528
529    /// Pin perf #15 / #16: `Filter::compile()` produces a
530    /// `CompiledFilter` whose `matches` is semantically identical
531    /// to `Filter::matches` for every shape (And / Or / Not / Eq /
532    /// EqWrapped, single-element + multi-element, nested,
533    /// numeric-index path, empty path).
534    ///
535    /// We pin this exhaustively against the same event corpus so
536    /// a regression that drifts compiled semantics from raw
537    /// semantics — e.g. a future field-name normalization on
538    /// either side that doesn't run on both — gets caught.
539    #[test]
540    fn compiled_filter_matches_raw_filter_semantically() {
541        // Nested + numeric index + EqWrapped + Not + multi-And/Or.
542        let raw: Filter = serde_json::from_str(
543            r#"{"$and": [
544                 {"path": "user.profile.name", "value": "Alice"},
545                 {"$or": [
546                    {"path": "items.0", "value": "first"},
547                    {"$eq": {"path": "items.1", "value": "second"}}
548                 ]},
549                 {"$not": {"path": "user.profile.role", "value": "guest"}}
550              ]}"#,
551        )
552        .unwrap();
553        let compiled = raw.compile();
554
555        let events = [
556            // Full match.
557            serde_json::json!({
558                "user": {"profile": {"name": "Alice", "role": "admin"}},
559                "items": ["first", "second"]
560            }),
561            // Wrong name.
562            serde_json::json!({
563                "user": {"profile": {"name": "Bob", "role": "admin"}},
564                "items": ["first", "second"]
565            }),
566            // Guest role → Not arm rejects.
567            serde_json::json!({
568                "user": {"profile": {"name": "Alice", "role": "guest"}},
569                "items": ["first", "second"]
570            }),
571            // Missing items.
572            serde_json::json!({
573                "user": {"profile": {"name": "Alice", "role": "admin"}}
574            }),
575            // items has 1 element matching index 0; Or arm satisfied.
576            serde_json::json!({
577                "user": {"profile": {"name": "Alice", "role": "admin"}},
578                "items": ["first"]
579            }),
580        ];
581
582        for ev in &events {
583            assert_eq!(
584                compiled.matches(ev),
585                raw.matches(ev),
586                "compiled vs raw diverge on {ev:?}",
587            );
588        }
589    }
590
591    /// Pin: compiling a path with a purely-numeric segment caches
592    /// the integer parse — `CompiledSegment::idx` is `Some(_)`
593    /// when the segment is parseable, `None` otherwise.
594    /// A regression that forgot to pre-parse would surface as a
595    /// `parse()` per event-match call (perf #16).
596    #[test]
597    fn compile_caches_array_index_parse_per_segment() {
598        // Path with mixed field + numeric-index + non-numeric
599        // segments. Inspect the resulting CompiledSegment list to
600        // confirm the parse happened at compile time.
601        let f = Filter::eq("items.42.foo", serde_json::json!(1));
602        let compiled = f.compile();
603        let CompiledFilter::Eq { segments, .. } = compiled else {
604            panic!("expected CompiledFilter::Eq");
605        };
606        assert_eq!(segments.len(), 3);
607        assert_eq!(segments[0].field, "items");
608        assert!(
609            segments[0].idx.is_none(),
610            "'items' must not pre-parse as usize",
611        );
612        assert_eq!(segments[1].field, "42");
613        assert_eq!(
614            segments[1].idx,
615            Some(42),
616            "'42' must pre-parse as Some(42) — cached integer index",
617        );
618        assert_eq!(segments[2].field, "foo");
619        assert!(segments[2].idx.is_none());
620    }
621
622    #[test]
623    fn test_json_path_get() {
624        let value = json!({
625            "a": {
626                "b": {
627                    "c": 42
628                }
629            },
630            "arr": [1, 2, 3],
631            "nested_arr": [{"x": 10}, {"x": 20}]
632        });
633
634        assert_eq!(json_path_get(&value, "a.b.c"), Some(&json!(42)));
635        assert_eq!(json_path_get(&value, "arr.1"), Some(&json!(2)));
636        assert_eq!(json_path_get(&value, "nested_arr.0.x"), Some(&json!(10)));
637        assert_eq!(json_path_get(&value, "missing"), None);
638        assert_eq!(json_path_get(&value, "a.b.missing"), None);
639        assert_eq!(json_path_get(&value, ""), Some(&value));
640    }
641
642    #[test]
643    fn test_json_path_get_primitive() {
644        // Trying to access path on primitive value
645        let value = json!(42);
646        assert_eq!(json_path_get(&value, "foo"), None);
647
648        let value = json!("string");
649        assert_eq!(json_path_get(&value, "bar"), None);
650
651        let value = json!(true);
652        assert_eq!(json_path_get(&value, "baz"), None);
653
654        let value = json!(null);
655        assert_eq!(json_path_get(&value, "qux"), None);
656    }
657
658    #[test]
659    fn test_json_path_get_invalid_array_index() {
660        let value = json!({"arr": [1, 2, 3]});
661        // Non-numeric index on array
662        assert_eq!(json_path_get(&value, "arr.foo"), None);
663        // Out of bounds
664        assert_eq!(json_path_get(&value, "arr.100"), None);
665    }
666
667    #[test]
668    fn test_filter_builder_single() {
669        // Single filter should not wrap in AND/OR
670        let filter = FilterBuilder::new().eq("type", json!("token")).build_and();
671
672        assert!(matches!(filter, Filter::Eq { .. }));
673
674        let filter = FilterBuilder::new().eq("type", json!("token")).build_or();
675
676        assert!(matches!(filter, Filter::Eq { .. }));
677    }
678
679    #[test]
680    fn test_filter_builder_multiple_or() {
681        let filter = FilterBuilder::new()
682            .eq("type", json!("a"))
683            .eq("type", json!("b"))
684            .build_or();
685
686        assert!(filter.matches(&json!({"type": "a"})));
687        assert!(filter.matches(&json!({"type": "b"})));
688        assert!(!filter.matches(&json!({"type": "c"})));
689    }
690
691    #[test]
692    fn test_filter_clone() {
693        let filter = Filter::and(vec![
694            Filter::eq("a", json!(1)),
695            Filter::not(Filter::eq("b", json!(2))),
696        ]);
697
698        let cloned = filter.clone();
699        let event = json!({"a": 1, "b": 3});
700        assert_eq!(filter.matches(&event), cloned.matches(&event));
701    }
702
703    #[test]
704    fn test_filter_debug() {
705        let filter = Filter::eq("type", json!("token"));
706        let debug = format!("{:?}", filter);
707        assert!(debug.contains("Eq"));
708        assert!(debug.contains("type"));
709    }
710
711    #[test]
712    fn test_filter_partial_eq() {
713        let f1 = Filter::eq("type", json!("token"));
714        let f2 = Filter::eq("type", json!("token"));
715        let f3 = Filter::eq("type", json!("other"));
716
717        assert_eq!(f1, f2);
718        assert_ne!(f1, f3);
719    }
720
721    #[test]
722    fn test_empty_and_filter() {
723        // Regression (LOW, BUGS.md): empty `And` used to match
724        // everything via `.all()` on an empty iterator returning
725        // `true`. A filter JSON like `{"and": []}` reaching the
726        // matcher would silently become a universal pass-through.
727        // Now empty `And` matches nothing, consistent with the
728        // conservative "an empty filter isn't a filter" choice.
729        let filter = Filter::and(vec![]);
730        assert!(
731            !filter.matches(&json!({"any": "value"})),
732            "empty And must not match — was silently universal-pass before"
733        );
734    }
735
736    #[test]
737    fn test_empty_or_filter() {
738        let filter = Filter::or(vec![]);
739        // Empty OR should match nothing
740        assert!(!filter.matches(&json!({"any": "value"})));
741    }
742
743    /// Single-element `And` / `Or` must produce the same result as
744    /// the inner filter alone — the fast path in `matches()` recurses
745    /// directly without the iterator+closure setup, but it has to be
746    /// semantically identical to the iter-based path.
747    #[test]
748    fn test_single_element_and_or_match_inner_filter() {
749        let inner = Filter::eq("k", json!("v"));
750        let single_and = Filter::and(vec![inner.clone()]);
751        let single_or = Filter::or(vec![inner.clone()]);
752
753        let yes = json!({"k": "v"});
754        let no = json!({"k": "other"});
755
756        for ev in &[yes, no] {
757            assert_eq!(
758                single_and.matches(ev),
759                inner.matches(ev),
760                "single-element And must match inner: {ev}",
761            );
762            assert_eq!(
763                single_or.matches(ev),
764                inner.matches(ev),
765                "single-element Or must match inner: {ev}",
766            );
767        }
768    }
769
770    /// Fast path must recurse correctly when the single child is
771    /// itself a composite filter (Not, nested And/Or, Eq, etc.) — the
772    /// straight-line `filters[0].matches(event)` call has to dispatch
773    /// the same way the slow path's closure would.
774    #[test]
775    fn test_single_element_fast_path_recurses_into_composite() {
776        let leaf = Filter::eq("k", json!("v"));
777        let yes = json!({"k": "v"});
778        let no = json!({"k": "other"});
779
780        // And{[Not{leaf}]}
781        let nested_not = Filter::and(vec![Filter::not(leaf.clone())]);
782        assert!(!nested_not.matches(&yes));
783        assert!(nested_not.matches(&no));
784
785        // Or{[And{[leaf]}]} — both layers hit the fast path.
786        let nested_double = Filter::or(vec![Filter::and(vec![leaf.clone()])]);
787        assert!(nested_double.matches(&yes));
788        assert!(!nested_double.matches(&no));
789
790        // Or{[And{[leaf, leaf2]}]} — outer hits the fast path, inner
791        // falls through to the iterator path. Verifies the two paths
792        // compose correctly.
793        let leaf2 = Filter::eq("x", json!(1));
794        let mixed = Filter::or(vec![Filter::and(vec![leaf.clone(), leaf2.clone()])]);
795        assert!(mixed.matches(&json!({"k": "v", "x": 1})));
796        assert!(!mixed.matches(&json!({"k": "v", "x": 2})));
797        assert!(!mixed.matches(&json!({"k": "other", "x": 1})));
798    }
799
800    /// Regression: multi-element `And` / `Or` must keep using the
801    /// iterator path (not silently fall into the single-element
802    /// shortcut). Guards against a future refactor of the fast-path
803    /// guard.
804    #[test]
805    fn test_multi_element_and_or_uses_slow_path() {
806        let f1 = Filter::eq("k", json!("v"));
807        let f2 = Filter::eq("x", json!(1));
808
809        let and = Filter::and(vec![f1.clone(), f2.clone()]);
810        assert!(and.matches(&json!({"k": "v", "x": 1})));
811        assert!(!and.matches(&json!({"k": "v", "x": 2})));
812        assert!(!and.matches(&json!({"k": "other", "x": 1})));
813
814        let or = Filter::or(vec![f1.clone(), f2.clone()]);
815        assert!(or.matches(&json!({"k": "v", "x": 99})));
816        assert!(or.matches(&json!({"k": "nope", "x": 1})));
817        assert!(!or.matches(&json!({"k": "nope", "x": 2})));
818    }
819
820    #[test]
821    fn test_filter_builder_default() {
822        let builder = FilterBuilder::default();
823        let debug = format!("{:?}", builder);
824        assert!(debug.contains("FilterBuilder"));
825    }
826
827    #[test]
828    fn test_eq_wrapped_filter_deserialization() {
829        // Test $eq wrapper format: { "$eq": { "path": "type", "value": "token" } }
830        let json_str = r#"{"$eq": {"path": "type", "value": "token"}}"#;
831        let filter: Filter = serde_json::from_str(json_str).unwrap();
832
833        assert!(filter.matches(&json!({"type": "token", "data": "hello"})));
834        assert!(!filter.matches(&json!({"type": "message", "data": "hello"})));
835    }
836
837    #[test]
838    fn test_eq_wrapped_with_nested_path() {
839        // Test $eq with nested path
840        let json_str = r#"{"$eq": {"path": "user.role", "value": "admin"}}"#;
841        let filter: Filter = serde_json::from_str(json_str).unwrap();
842
843        assert!(filter.matches(&json!({"user": {"role": "admin"}})));
844        assert!(!filter.matches(&json!({"user": {"role": "user"}})));
845    }
846
847    #[test]
848    fn test_eq_wrapped_with_numeric_value() {
849        // Test $eq with numeric value
850        let json_str = r#"{"$eq": {"path": "count", "value": 42}}"#;
851        let filter: Filter = serde_json::from_str(json_str).unwrap();
852
853        assert!(filter.matches(&json!({"count": 42})));
854        assert!(!filter.matches(&json!({"count": 41})));
855    }
856
857    #[test]
858    fn test_eq_wrapped_with_boolean_value() {
859        // Test $eq with boolean value
860        let json_str = r#"{"$eq": {"path": "active", "value": true}}"#;
861        let filter: Filter = serde_json::from_str(json_str).unwrap();
862
863        assert!(filter.matches(&json!({"active": true})));
864        assert!(!filter.matches(&json!({"active": false})));
865    }
866
867    #[test]
868    fn test_eq_wrapped_in_and() {
869        // Test $eq wrapped inside $and
870        let json_str = r#"{"$and": [{"$eq": {"path": "type", "value": "token"}}, {"$eq": {"path": "index", "value": 0}}]}"#;
871        let filter: Filter = serde_json::from_str(json_str).unwrap();
872
873        assert!(filter.matches(&json!({"type": "token", "index": 0})));
874        assert!(!filter.matches(&json!({"type": "token", "index": 1})));
875        assert!(!filter.matches(&json!({"type": "message", "index": 0})));
876    }
877
878    #[test]
879    fn test_eq_wrapped_in_or() {
880        // Test $eq wrapped inside $or
881        let json_str = r#"{"$or": [{"$eq": {"path": "type", "value": "token"}}, {"$eq": {"path": "type", "value": "message"}}]}"#;
882        let filter: Filter = serde_json::from_str(json_str).unwrap();
883
884        assert!(filter.matches(&json!({"type": "token"})));
885        assert!(filter.matches(&json!({"type": "message"})));
886        assert!(!filter.matches(&json!({"type": "error"})));
887    }
888
889    #[test]
890    fn test_eq_wrapped_in_not() {
891        // Test $eq wrapped inside $not
892        let json_str = r#"{"$not": {"$eq": {"path": "type", "value": "error"}}}"#;
893        let filter: Filter = serde_json::from_str(json_str).unwrap();
894
895        assert!(filter.matches(&json!({"type": "token"})));
896        assert!(filter.matches(&json!({"type": "message"})));
897        assert!(!filter.matches(&json!({"type": "error"})));
898    }
899
900    #[test]
901    fn test_both_eq_formats_work() {
902        // Test that both shorthand and wrapped formats work
903        let shorthand = r#"{"path": "type", "value": "token"}"#;
904        let wrapped = r#"{"$eq": {"path": "type", "value": "token"}}"#;
905
906        let filter1: Filter = serde_json::from_str(shorthand).unwrap();
907        let filter2: Filter = serde_json::from_str(wrapped).unwrap();
908
909        let event = json!({"type": "token"});
910        assert!(filter1.matches(&event));
911        assert!(filter2.matches(&event));
912
913        let event2 = json!({"type": "other"});
914        assert!(!filter1.matches(&event2));
915        assert!(!filter2.matches(&event2));
916    }
917}