use std::{collections::BTreeMap, ops::RangeBounds, str::FromStr}; use js_int::{Int, UInt}; use regex::bytes::Regex; #[cfg(feature = "unstable-msc3931")] use ruma_macros::StringEnum; use serde::{Deserialize, Serialize}; use serde_json::value::Value as JsonValue; use wildmatch::WildMatch; use crate::{power_levels::NotificationPowerLevels, OwnedRoomId, OwnedUserId, UserId}; #[cfg(feature = "unstable-msc3931")] use crate::{PrivOwnedStr, RoomVersionId}; mod flattened_json; mod push_condition_serde; mod room_member_count_is; pub use self::{ flattened_json::{FlattenedJson, FlattenedJsonValue, ScalarJsonValue}, room_member_count_is::{ComparisonOperator, RoomMemberCountIs}, }; /// Features supported by room versions. #[cfg(feature = "unstable-msc3931")] #[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))] #[derive(Clone, PartialEq, Eq, StringEnum)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] pub enum RoomVersionFeature { /// m.extensible_events /// /// The room supports [extensible events]. /// /// [extensible events]: https://github.com/matrix-org/matrix-spec-proposals/pull/1767 #[cfg(feature = "unstable-msc3932")] #[ruma_enum(rename = "org.matrix.msc3932.extensible_events")] ExtensibleEvents, #[doc(hidden)] _Custom(PrivOwnedStr), } #[cfg(feature = "unstable-msc3931")] impl RoomVersionFeature { /// Get the default features for the given room version. pub fn list_for_room_version(version: &RoomVersionId) -> Vec { match version { RoomVersionId::V1 | RoomVersionId::V2 | RoomVersionId::V3 | RoomVersionId::V4 | RoomVersionId::V5 | RoomVersionId::V6 | RoomVersionId::V7 | RoomVersionId::V8 | RoomVersionId::V9 | RoomVersionId::V10 | RoomVersionId::V11 | RoomVersionId::_Custom(_) => vec![], } } } /// A condition that must apply for an associated push rule's action to be taken. #[derive(Clone, Debug)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] pub enum PushCondition { /// A glob pattern match on a field of the event. EventMatch { /// The [dot-separated path] of the property of the event to match. /// /// [dot-separated path]: https://spec.matrix.org/latest/appendices/#dot-separated-property-paths key: String, /// The glob-style pattern to match against. /// /// Patterns with no special glob characters should be treated as having asterisks /// prepended and appended when testing the condition. pattern: String, }, /// Matches unencrypted messages where `content.body` contains the owner's display name in that /// room. ContainsDisplayName, /// Matches the current number of members in the room. RoomMemberCount { /// The condition on the current number of members in the room. is: RoomMemberCountIs, }, /// Takes into account the current power levels in the room, ensuring the sender of the event /// has high enough power to trigger the notification. SenderNotificationPermission { /// The field in the power level event the user needs a minimum power level for. /// /// Fields must be specified under the `notifications` property in the power level event's /// `content`. key: String, }, /// Apply the rule only to rooms that support a given feature. #[cfg(feature = "unstable-msc3931")] RoomVersionSupports { /// The feature the room must support for the push rule to apply. feature: RoomVersionFeature, }, /// Exact value match on a property of the event. EventPropertyIs { /// The [dot-separated path] of the property of the event to match. /// /// [dot-separated path]: https://spec.matrix.org/latest/appendices/#dot-separated-property-paths key: String, /// The value to match against. value: ScalarJsonValue, }, /// Exact value match on a value in an array property of the event. EventPropertyContains { /// The [dot-separated path] of the property of the event to match. /// /// [dot-separated path]: https://spec.matrix.org/latest/appendices/#dot-separated-property-paths key: String, /// The value to match against. value: ScalarJsonValue, }, #[doc(hidden)] _Custom(_CustomPushCondition), } pub(super) fn check_event_match( event: &FlattenedJson, key: &str, pattern: &str, context: &PushConditionRoomCtx, ) -> bool { let value = match key { "room_id" => context.room_id.as_str(), _ => match event.get_str(key) { Some(v) => v, None => return false, }, }; value.matches_pattern(pattern, key == "content.body") } impl PushCondition { /// Check if this condition applies to the event. /// /// # Arguments /// /// * `event` - The flattened JSON representation of a room message event. /// * `context` - The context of the room at the time of the event. If the power levels context /// is missing from it, conditions that depend on it will never apply. pub fn applies(&self, event: &FlattenedJson, context: &PushConditionRoomCtx) -> bool { if event.get_str("sender").is_some_and(|sender| sender == context.user_id) { return false; } match self { Self::EventMatch { key, pattern } => check_event_match(event, key, pattern, context), Self::ContainsDisplayName => { let value = match event.get_str("content.body") { Some(v) => v, None => return false, }; value.matches_pattern(&context.user_display_name, true) } Self::RoomMemberCount { is } => is.contains(&context.member_count), Self::SenderNotificationPermission { key } => { let Some(power_levels) = &context.power_levels else { return false; }; let sender_id = match event.get_str("sender") { Some(v) => match <&UserId>::try_from(v) { Ok(u) => u, Err(_) => return false, }, None => return false, }; let sender_level = power_levels.users.get(sender_id).unwrap_or(&power_levels.users_default); match power_levels.notifications.get(key) { Some(l) => sender_level >= l, None => false, } } #[cfg(feature = "unstable-msc3931")] Self::RoomVersionSupports { feature } => match feature { RoomVersionFeature::ExtensibleEvents => { context.supported_features.contains(&RoomVersionFeature::ExtensibleEvents) } RoomVersionFeature::_Custom(_) => false, }, Self::EventPropertyIs { key, value } => event.get(key).is_some_and(|v| v == value), Self::EventPropertyContains { key, value } => event .get(key) .and_then(FlattenedJsonValue::as_array) .is_some_and(|a| a.contains(value)), Self::_Custom(_) => false, } } } /// An unknown push condition. #[doc(hidden)] #[derive(Clone, Debug, Deserialize, Serialize)] #[allow(clippy::exhaustive_structs)] pub struct _CustomPushCondition { /// The kind of the condition. kind: String, /// The additional fields that the condition contains. #[serde(flatten)] data: BTreeMap, } /// The context of the room associated to an event to be able to test all push conditions. #[derive(Clone, Debug)] #[allow(clippy::exhaustive_structs)] pub struct PushConditionRoomCtx { /// The ID of the room. pub room_id: OwnedRoomId, /// The number of members in the room. pub member_count: UInt, /// The user's matrix ID. pub user_id: OwnedUserId, /// The display name of the current user in the room. pub user_display_name: String, /// The room power levels context for the room. /// /// If this is missing, push rules that require this will never match. pub power_levels: Option, /// The list of features this room's version or the room itself supports. #[cfg(feature = "unstable-msc3931")] pub supported_features: Vec, } /// The room power levels context to be able to test the corresponding push conditions. #[derive(Clone, Debug)] #[allow(clippy::exhaustive_structs)] pub struct PushConditionPowerLevelsCtx { /// The power levels of the users of the room. pub users: BTreeMap, /// The default power level of the users of the room. pub users_default: Int, /// The notification power levels of the room. pub notifications: NotificationPowerLevels, } /// Additional functions for character matching. trait CharExt { /// Whether or not this char can be part of a word. fn is_word_char(&self) -> bool; } impl CharExt for char { fn is_word_char(&self) -> bool { self.is_ascii_alphanumeric() || *self == '_' } } /// Additional functions for string matching. trait StrExt { /// Get the length of the char at `index`. The byte index must correspond to /// the start of a char boundary. fn char_len(&self, index: usize) -> usize; /// Get the char at `index`. The byte index must correspond to the start of /// a char boundary. fn char_at(&self, index: usize) -> char; /// Get the index of the char that is before the char at `index`. The byte index /// must correspond to a char boundary. /// /// Returns `None` if there's no previous char. Otherwise, returns the char. fn find_prev_char(&self, index: usize) -> Option; /// Matches this string against `pattern`. /// /// The pattern can be a glob with wildcards `*` and `?`. /// /// The match is case insensitive. /// /// If `match_words` is `true`, checks that the pattern is separated from other words. fn matches_pattern(&self, pattern: &str, match_words: bool) -> bool; /// Matches this string against `pattern`, with word boundaries. /// /// The pattern can be a glob with wildcards `*` and `?`. /// /// A word boundary is defined as the start or end of the value, or any character not in the /// sets `[A-Z]`, `[a-z]`, `[0-9]` or `_`. /// /// The match is case sensitive. fn matches_word(&self, pattern: &str) -> bool; /// Translate the wildcards in `self` to a regex syntax. /// /// `self` must only contain wildcards. fn wildcards_to_regex(&self) -> String; } impl StrExt for str { fn char_len(&self, index: usize) -> usize { let mut len = 1; while !self.is_char_boundary(index + len) { len += 1; } len } fn char_at(&self, index: usize) -> char { let end = index + self.char_len(index); let char_str = &self[index..end]; char::from_str(char_str) .unwrap_or_else(|_| panic!("Could not convert str '{char_str}' to char")) } fn find_prev_char(&self, index: usize) -> Option { if index == 0 { return None; } let mut pos = index - 1; while !self.is_char_boundary(pos) { pos -= 1; } Some(self.char_at(pos)) } fn matches_pattern(&self, pattern: &str, match_words: bool) -> bool { let value = &self.to_lowercase(); let pattern = &pattern.to_lowercase(); if match_words { value.matches_word(pattern) } else { WildMatch::new(pattern).matches(value) } } fn matches_word(&self, pattern: &str) -> bool { if self == pattern { return true; } if pattern.is_empty() { return false; } let has_wildcards = pattern.contains(|c| matches!(c, '?' | '*')); if has_wildcards { let mut chunks: Vec = vec![]; let mut prev_wildcard = false; let mut chunk_start = 0; for (i, c) in pattern.char_indices() { if matches!(c, '?' | '*') && !prev_wildcard { if i != 0 { chunks.push(regex::escape(&pattern[chunk_start..i])); chunk_start = i; } prev_wildcard = true; } else if prev_wildcard { let chunk = &pattern[chunk_start..i]; chunks.push(chunk.wildcards_to_regex()); chunk_start = i; prev_wildcard = false; } } let len = pattern.len(); if !prev_wildcard { chunks.push(regex::escape(&pattern[chunk_start..len])); } else if prev_wildcard { let chunk = &pattern[chunk_start..len]; chunks.push(chunk.wildcards_to_regex()); } // The word characters in ASCII compatible mode (with the `-u` flag) match the // definition in the spec: any character not in the set `[A-Za-z0-9_]`. let regex = format!(r"(?-u:^|\W|\b){}(?-u:\b|\W|$)", chunks.concat()); let re = Regex::new(®ex).expect("regex construction should succeed"); re.is_match(self.as_bytes()) } else { match self.find(pattern) { Some(start) => { let end = start + pattern.len(); // Look if the match has word boundaries. let word_boundary_start = !self.char_at(start).is_word_char() || !self.find_prev_char(start).is_some_and(|c| c.is_word_char()); if word_boundary_start { let word_boundary_end = end == self.len() || !self.find_prev_char(end).unwrap().is_word_char() || !self.char_at(end).is_word_char(); if word_boundary_end { return true; } } // Find next word. let non_word_str = &self[start..]; let non_word = match non_word_str.find(|c: char| !c.is_word_char()) { Some(pos) => pos, None => return false, }; let word_str = &non_word_str[non_word..]; let word = match word_str.find(|c: char| c.is_word_char()) { Some(pos) => pos, None => return false, }; word_str[word..].matches_word(pattern) } None => false, } } } fn wildcards_to_regex(&self) -> String { // Simplify pattern to avoid performance issues: // - The glob `?**?**?` is equivalent to the glob `???*` // - The glob `???*` is equivalent to the regex `.{3,}` let question_marks = self.matches('?').count(); if self.contains('*') { format!(".{{{question_marks},}}") } else { format!(".{{{question_marks}}}") } } } #[cfg(test)] mod tests { use std::collections::BTreeMap; use assert_matches2::assert_matches; use js_int::{int, uint}; use serde_json::{ from_value as from_json_value, json, to_value as to_json_value, Value as JsonValue, }; use super::{ FlattenedJson, PushCondition, PushConditionPowerLevelsCtx, PushConditionRoomCtx, RoomMemberCountIs, StrExt, }; use crate::{ owned_room_id, owned_user_id, power_levels::NotificationPowerLevels, serde::Raw, OwnedUserId, }; #[test] fn serialize_event_match_condition() { let json_data = json!({ "key": "content.msgtype", "kind": "event_match", "pattern": "m.notice" }); assert_eq!( to_json_value(PushCondition::EventMatch { key: "content.msgtype".into(), pattern: "m.notice".into(), }) .unwrap(), json_data ); } #[test] fn serialize_contains_display_name_condition() { assert_eq!( to_json_value(PushCondition::ContainsDisplayName).unwrap(), json!({ "kind": "contains_display_name" }) ); } #[test] fn serialize_room_member_count_condition() { let json_data = json!({ "is": "2", "kind": "room_member_count" }); assert_eq!( to_json_value(PushCondition::RoomMemberCount { is: RoomMemberCountIs::from(uint!(2)) }) .unwrap(), json_data ); } #[test] fn serialize_sender_notification_permission_condition() { let json_data = json!({ "key": "room", "kind": "sender_notification_permission" }); assert_eq!( json_data, to_json_value(PushCondition::SenderNotificationPermission { key: "room".into() }) .unwrap() ); } #[test] fn deserialize_event_match_condition() { let json_data = json!({ "key": "content.msgtype", "kind": "event_match", "pattern": "m.notice" }); assert_matches!( from_json_value::(json_data).unwrap(), PushCondition::EventMatch { key, pattern } ); assert_eq!(key, "content.msgtype"); assert_eq!(pattern, "m.notice"); } #[test] fn deserialize_contains_display_name_condition() { assert_matches!( from_json_value::(json!({ "kind": "contains_display_name" })).unwrap(), PushCondition::ContainsDisplayName ); } #[test] fn deserialize_room_member_count_condition() { let json_data = json!({ "is": "2", "kind": "room_member_count" }); assert_matches!( from_json_value::(json_data).unwrap(), PushCondition::RoomMemberCount { is } ); assert_eq!(is, RoomMemberCountIs::from(uint!(2))); } #[test] fn deserialize_sender_notification_permission_condition() { let json_data = json!({ "key": "room", "kind": "sender_notification_permission" }); assert_matches!( from_json_value::(json_data).unwrap(), PushCondition::SenderNotificationPermission { key } ); assert_eq!(key, "room"); } #[test] fn words_match() { assert!("foo bar".matches_word("foo")); assert!(!"Foo bar".matches_word("foo")); assert!(!"foobar".matches_word("foo")); assert!("foobar foo".matches_word("foo")); assert!(!"foobar foobar".matches_word("foo")); assert!(!"foobar bar".matches_word("bar bar")); assert!("foobar bar bar".matches_word("bar bar")); assert!(!"foobar bar barfoo".matches_word("bar bar")); assert!("conduit ⚡️".matches_word("conduit ⚡️")); assert!("conduit ⚡️".matches_word("conduit")); assert!("conduit ⚡️".matches_word("⚡️")); assert!("conduit⚡️".matches_word("conduit")); assert!("conduit⚡️".matches_word("⚡️")); assert!("⚡️conduit".matches_word("conduit")); assert!("⚡️conduit".matches_word("⚡️")); assert!("Ruma Dev👩‍💻".matches_word("Dev")); assert!("Ruma Dev👩‍💻".matches_word("👩‍💻")); assert!("Ruma Dev👩‍💻".matches_word("Dev👩‍💻")); // Regex syntax is escaped assert!(!"matrix".matches_word(r"\w*")); assert!(r"\w".matches_word(r"\w*")); assert!(!"matrix".matches_word("[a-z]*")); assert!("[a-z] and [0-9]".matches_word("[a-z]*")); assert!(!"m".matches_word("[[:alpha:]]?")); assert!("[[:alpha:]]!".matches_word("[[:alpha:]]?")); // From the spec: assert!("An example event.".matches_word("ex*ple")); assert!("exple".matches_word("ex*ple")); assert!("An exciting triple-whammy".matches_word("ex*ple")); } #[test] fn patterns_match() { // Word matching without glob assert!("foo bar".matches_pattern("foo", true)); assert!("Foo bar".matches_pattern("foo", true)); assert!(!"foobar".matches_pattern("foo", true)); assert!("".matches_pattern("", true)); assert!(!"foo".matches_pattern("", true)); assert!("foo bar".matches_pattern("foo bar", true)); assert!(" foo bar ".matches_pattern("foo bar", true)); assert!("baz foo bar baz".matches_pattern("foo bar", true)); assert!("foo baré".matches_pattern("foo bar", true)); assert!(!"bar foo".matches_pattern("foo bar", true)); assert!("foo bar".matches_pattern("foo ", true)); assert!("foo ".matches_pattern("foo ", true)); assert!("foo ".matches_pattern("foo ", true)); assert!(" foo ".matches_pattern("foo ", true)); // Word matching with glob assert!("foo bar".matches_pattern("foo*", true)); assert!("foo bar".matches_pattern("foo b?r", true)); assert!(" foo bar ".matches_pattern("foo b?r", true)); assert!("baz foo bar baz".matches_pattern("foo b?r", true)); assert!("foo baré".matches_pattern("foo b?r", true)); assert!(!"bar foo".matches_pattern("foo b?r", true)); assert!("foo bar".matches_pattern("f*o ", true)); assert!("foo ".matches_pattern("f*o ", true)); assert!("foo ".matches_pattern("f*o ", true)); assert!(" foo ".matches_pattern("f*o ", true)); // Glob matching assert!(!"foo bar".matches_pattern("foo", false)); assert!("foo".matches_pattern("foo", false)); assert!("foo".matches_pattern("foo*", false)); assert!("foobar".matches_pattern("foo*", false)); assert!("foo bar".matches_pattern("foo*", false)); assert!(!"foo".matches_pattern("foo?", false)); assert!("fooo".matches_pattern("foo?", false)); assert!("FOO".matches_pattern("foo", false)); assert!("".matches_pattern("", false)); assert!("".matches_pattern("*", false)); assert!(!"foo".matches_pattern("", false)); // From the spec: assert!("Lunch plans".matches_pattern("lunc?*", false)); assert!("LUNCH".matches_pattern("lunc?*", false)); assert!(!" lunch".matches_pattern("lunc?*", false)); assert!(!"lunc".matches_pattern("lunc?*", false)); } fn sender() -> OwnedUserId { owned_user_id!("@worthy_whale:server.name") } fn push_context() -> PushConditionRoomCtx { let mut users = BTreeMap::new(); users.insert(sender(), int!(25)); let power_levels = PushConditionPowerLevelsCtx { users, users_default: int!(50), notifications: NotificationPowerLevels { room: int!(50) }, }; PushConditionRoomCtx { room_id: owned_room_id!("!room:server.name"), member_count: uint!(3), user_id: owned_user_id!("@gorilla:server.name"), user_display_name: "Groovy Gorilla".into(), power_levels: Some(power_levels), #[cfg(feature = "unstable-msc3931")] supported_features: Default::default(), } } fn first_flattened_event() -> FlattenedJson { let raw = serde_json::from_str::>( r#"{ "sender": "@worthy_whale:server.name", "content": { "msgtype": "m.text", "body": "@room Give a warm welcome to Groovy Gorilla" } }"#, ) .unwrap(); FlattenedJson::from_raw(&raw) } fn second_flattened_event() -> FlattenedJson { let raw = serde_json::from_str::>( r#"{ "sender": "@party_bot:server.name", "content": { "msgtype": "m.notice", "body": "Everybody come to party!" } }"#, ) .unwrap(); FlattenedJson::from_raw(&raw) } #[test] fn event_match_applies() { let context = push_context(); let first_event = first_flattened_event(); let second_event = second_flattened_event(); let correct_room = PushCondition::EventMatch { key: "room_id".into(), pattern: "!room:server.name".into(), }; let incorrect_room = PushCondition::EventMatch { key: "room_id".into(), pattern: "!incorrect:server.name".into(), }; assert!(correct_room.applies(&first_event, &context)); assert!(!incorrect_room.applies(&first_event, &context)); let keyword = PushCondition::EventMatch { key: "content.body".into(), pattern: "come".into() }; assert!(!keyword.applies(&first_event, &context)); assert!(keyword.applies(&second_event, &context)); let msgtype = PushCondition::EventMatch { key: "content.msgtype".into(), pattern: "m.notice".into() }; assert!(!msgtype.applies(&first_event, &context)); assert!(msgtype.applies(&second_event, &context)); } #[test] fn room_member_count_is_applies() { let context = push_context(); let event = first_flattened_event(); let member_count_eq = PushCondition::RoomMemberCount { is: RoomMemberCountIs::from(uint!(3)) }; let member_count_gt = PushCondition::RoomMemberCount { is: RoomMemberCountIs::from(uint!(2)..) }; let member_count_lt = PushCondition::RoomMemberCount { is: RoomMemberCountIs::from(..uint!(3)) }; assert!(member_count_eq.applies(&event, &context)); assert!(member_count_gt.applies(&event, &context)); assert!(!member_count_lt.applies(&event, &context)); } #[test] fn contains_display_name_applies() { let context = push_context(); let first_event = first_flattened_event(); let second_event = second_flattened_event(); let contains_display_name = PushCondition::ContainsDisplayName; assert!(contains_display_name.applies(&first_event, &context)); assert!(!contains_display_name.applies(&second_event, &context)); } #[test] fn sender_notification_permission_applies() { let context = push_context(); let first_event = first_flattened_event(); let second_event = second_flattened_event(); let sender_notification_permission = PushCondition::SenderNotificationPermission { key: "room".into() }; assert!(!sender_notification_permission.applies(&first_event, &context)); assert!(sender_notification_permission.applies(&second_event, &context)); } #[cfg(feature = "unstable-msc3932")] #[test] fn room_version_supports_applies() { let context_not_matching = push_context(); let context_matching = PushConditionRoomCtx { room_id: owned_room_id!("!room:server.name"), member_count: uint!(3), user_id: owned_user_id!("@gorilla:server.name"), user_display_name: "Groovy Gorilla".into(), power_levels: context_not_matching.power_levels.clone(), supported_features: vec![super::RoomVersionFeature::ExtensibleEvents], }; let simple_event_raw = serde_json::from_str::>( r#"{ "sender": "@worthy_whale:server.name", "content": { "msgtype": "org.matrix.msc3932.extensible_events", "body": "@room Give a warm welcome to Groovy Gorilla" } }"#, ) .unwrap(); let simple_event = FlattenedJson::from_raw(&simple_event_raw); let room_version_condition = PushCondition::RoomVersionSupports { feature: super::RoomVersionFeature::ExtensibleEvents, }; assert!(room_version_condition.applies(&simple_event, &context_matching)); assert!(!room_version_condition.applies(&simple_event, &context_not_matching)); } #[test] fn event_property_is_applies() { use crate::push::condition::ScalarJsonValue; let context = push_context(); let event_raw = serde_json::from_str::>( r#"{ "sender": "@worthy_whale:server.name", "content": { "msgtype": "m.text", "body": "Boom!", "org.fake.boolean": false, "org.fake.number": 13, "org.fake.null": null } }"#, ) .unwrap(); let event = FlattenedJson::from_raw(&event_raw); let string_match = PushCondition::EventPropertyIs { key: "content.body".to_owned(), value: "Boom!".into(), }; assert!(string_match.applies(&event, &context)); let string_no_match = PushCondition::EventPropertyIs { key: "content.body".to_owned(), value: "Boom".into() }; assert!(!string_no_match.applies(&event, &context)); let wrong_type = PushCondition::EventPropertyIs { key: "content.body".to_owned(), value: false.into() }; assert!(!wrong_type.applies(&event, &context)); let bool_match = PushCondition::EventPropertyIs { key: r"content.org\.fake\.boolean".to_owned(), value: false.into(), }; assert!(bool_match.applies(&event, &context)); let bool_no_match = PushCondition::EventPropertyIs { key: r"content.org\.fake\.boolean".to_owned(), value: true.into(), }; assert!(!bool_no_match.applies(&event, &context)); let int_match = PushCondition::EventPropertyIs { key: r"content.org\.fake\.number".to_owned(), value: int!(13).into(), }; assert!(int_match.applies(&event, &context)); let int_no_match = PushCondition::EventPropertyIs { key: r"content.org\.fake\.number".to_owned(), value: int!(130).into(), }; assert!(!int_no_match.applies(&event, &context)); let null_match = PushCondition::EventPropertyIs { key: r"content.org\.fake\.null".to_owned(), value: ScalarJsonValue::Null, }; assert!(null_match.applies(&event, &context)); } #[test] fn event_property_contains_applies() { use crate::push::condition::ScalarJsonValue; let context = push_context(); let event_raw = serde_json::from_str::>( r#"{ "sender": "@worthy_whale:server.name", "content": { "org.fake.array": ["Boom!", false, 13, null] } }"#, ) .unwrap(); let event = FlattenedJson::from_raw(&event_raw); let wrong_key = PushCondition::EventPropertyContains { key: "send".to_owned(), value: false.into() }; assert!(!wrong_key.applies(&event, &context)); let string_match = PushCondition::EventPropertyContains { key: r"content.org\.fake\.array".to_owned(), value: "Boom!".into(), }; assert!(string_match.applies(&event, &context)); let string_no_match = PushCondition::EventPropertyContains { key: r"content.org\.fake\.array".to_owned(), value: "Boom".into(), }; assert!(!string_no_match.applies(&event, &context)); let bool_match = PushCondition::EventPropertyContains { key: r"content.org\.fake\.array".to_owned(), value: false.into(), }; assert!(bool_match.applies(&event, &context)); let bool_no_match = PushCondition::EventPropertyContains { key: r"content.org\.fake\.array".to_owned(), value: true.into(), }; assert!(!bool_no_match.applies(&event, &context)); let int_match = PushCondition::EventPropertyContains { key: r"content.org\.fake\.array".to_owned(), value: int!(13).into(), }; assert!(int_match.applies(&event, &context)); let int_no_match = PushCondition::EventPropertyContains { key: r"content.org\.fake\.array".to_owned(), value: int!(130).into(), }; assert!(!int_no_match.applies(&event, &context)); let null_match = PushCondition::EventPropertyContains { key: r"content.org\.fake\.array".to_owned(), value: ScalarJsonValue::Null, }; assert!(null_match.applies(&event, &context)); } }