use serde::{Deserialize, Serialize};
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Operator {
And,
Or,
Not,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Filter<T> {
Operator(FilterOperator<T>),
Condition(T),
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FilterOperator<T> {
pub operator: Operator,
pub conditions: Vec<Filter<T>>,
}
impl<T> Filter<T> {
pub fn matches(&self, condition: &impl Fn(&T) -> bool) -> bool {
match self {
Filter::Condition(cond) => condition(cond),
Filter::Operator(op) => {
let mut each = op.conditions.iter().map(|f| f.matches(condition));
match op.operator {
Operator::And => each.all(|m| m),
Operator::Or => each.any(|m| m),
Operator::Not => !each.any(|m| m),
}
}
}
}
}
impl<T> FilterOperator<T> {
pub fn new(operator: Operator, conditions: Vec<Filter<T>>) -> Self {
Self {
operator,
conditions,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct Cond {
#[serde(skip_serializing_if = "Option::is_none")]
pub has_keyword: Option<String>,
}
#[test]
fn filter_operator_or_roundtrip() {
let json =
r#"{"operator":"OR","conditions":[{"has_keyword":"music"},{"has_keyword":"video"}]}"#;
let f: Filter<Cond> = serde_json::from_str(json).expect("must parse");
match &f {
Filter::Operator(op) => {
assert_eq!(op.operator, Operator::Or);
assert_eq!(op.conditions.len(), 2);
}
other => panic!("expected Operator, got {other:?}"),
}
let back = serde_json::to_string(&f).expect("must serialize");
let f2: Filter<Cond> = serde_json::from_str(&back).expect("roundtrip");
assert_eq!(f, f2);
}
#[test]
fn filter_condition_deserialization() {
let json = r#"{"has_keyword":"$seen"}"#;
let f: Filter<Cond> = serde_json::from_str(json).expect("must parse");
match &f {
Filter::Condition(c) => assert_eq!(c.has_keyword.as_deref(), Some("$seen")),
other => panic!("expected Condition, got {other:?}"),
}
}
#[test]
fn operator_serialization() {
assert_eq!(serde_json::to_string(&Operator::And).unwrap(), r#""AND""#);
assert_eq!(serde_json::to_string(&Operator::Or).unwrap(), r#""OR""#);
assert_eq!(serde_json::to_string(&Operator::Not).unwrap(), r#""NOT""#);
}
#[test]
fn filter_with_typoed_operator_silently_decodes_as_match_all_condition() {
let raw = r#"{"opperator":"AND","conditions":[]}"#;
let f: Filter<Cond> = serde_json::from_str(raw).expect(
"untagged enum must accept the typo as Filter::Condition — \
this is the silent-fallthrough hazard",
);
match f {
Filter::Condition(c) => assert!(
c.has_keyword.is_none(),
"fallthrough Condition must be the all-None default \
'match-all' shape; got: {c:?}"
),
Filter::Operator(_) => panic!(
"expected silent fallthrough to Condition, got Operator \
— serde untagged behavior changed?"
),
}
let raw_capitalised = r#"{"Operator":"AND","conditions":[]}"#;
let f2: Filter<Cond> =
serde_json::from_str(raw_capitalised).expect("capitalised typo must also fall through");
match f2 {
Filter::Condition(c) => assert!(c.has_keyword.is_none()),
Filter::Operator(_) => panic!("expected silent fallthrough to Condition, got Operator"),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct StrictCond {
#[serde(skip_serializing_if = "Option::is_none")]
has_keyword: Option<String>,
}
#[test]
fn filter_with_strict_condition_rejects_unknown_keys() {
let accepted = [
r#"{"hasKeyword":"$seen"}"#,
"{}",
r#"{"operator":"AND","conditions":[{"hasKeyword":"a"}]}"#,
r#"{"operator":"OR","conditions":[]}"#,
r#"{"operator":"AND","conditions":[{"operator":"NOT","conditions":[{"hasKeyword":"b"}]}]}"#,
];
for raw in accepted {
serde_json::from_str::<Filter<StrictCond>>(raw)
.unwrap_or_else(|e| panic!("{raw} must decode: {e}"));
}
let rejected = [
r#"{"has_keyword":"$seen"}"#,
r#"{"hasKeyword":"$seen","bogus":1}"#,
r#"{"opperator":"AND","conditions":[]}"#,
r#"{"operator":"AND","conditions":[],"extra":true}"#,
r#"{"operator":"XOR","conditions":[]}"#,
r#"{"operator":"AND","conditions":[{"operator":"OR","conditions":[{"bogus":1}]}]}"#,
];
for raw in rejected {
assert!(
serde_json::from_str::<Filter<StrictCond>>(raw).is_err(),
"{raw} must be rejected"
);
}
}
#[test]
fn nested_filter_roundtrip() {
let filter = Filter::Operator(FilterOperator {
operator: Operator::And,
conditions: vec![
Filter::Operator(FilterOperator {
operator: Operator::Or,
conditions: vec![
Filter::Condition(Cond {
has_keyword: Some("a".to_owned()),
}),
Filter::Condition(Cond {
has_keyword: Some("b".to_owned()),
}),
],
}),
Filter::Condition(Cond {
has_keyword: Some("c".to_owned()),
}),
],
});
let json = serde_json::to_string(&filter).expect("serialize");
let back: Filter<Cond> = serde_json::from_str(&json).expect("deserialize");
assert_eq!(filter, back);
}
#[test]
fn matches_follows_rfc8620_operator_semantics() {
let seen = |c: &Cond| c.has_keyword.as_deref() == Some("$seen");
let tree = |json: &str| -> Filter<Cond> { serde_json::from_str(json).expect("must parse") };
let hit = r#"{"has_keyword":"$seen"}"#;
let miss = r#"{"has_keyword":"$flagged"}"#;
let op =
|o: &str, a: &str, b: &str| format!(r#"{{"operator":"{o}","conditions":[{a},{b}]}}"#);
assert!(tree(hit).matches(&seen));
assert!(!tree(miss).matches(&seen));
assert!(tree(&op("AND", hit, hit)).matches(&seen));
assert!(!tree(&op("AND", hit, miss)).matches(&seen));
assert!(tree(&op("OR", miss, hit)).matches(&seen));
assert!(!tree(&op("OR", miss, miss)).matches(&seen));
assert!(tree(&op("NOT", miss, miss)).matches(&seen));
assert!(!tree(&op("NOT", miss, hit)).matches(&seen));
let nested = op("NOT", &op("OR", miss, &op("AND", hit, hit)), miss);
assert!(!tree(&nested).matches(&seen));
}
}