extern crate pest;
use std::collections::HashSet;
use std::io::Cursor;
use std::num::ParseFloatError;
use crate::sendable_closures::{SendableContextResolver, SendableFragment};
use crate::EnrichedContext as Context;
use chrono::{DateTime, Utc};
use murmur3::murmur3_32;
use pest::error::Error;
use pest::iterators::{Pair, Pairs};
use pest::pratt_parser::{Assoc, Op, PrattParser};
use pest::Parser;
use rand::Rng;
use semver::Version;
#[derive(Parser)]
#[grammar = "strategy_grammar.pest"]
struct Strategy;
lazy_static! {
static ref PRATT_PARSER: PrattParser<Rule> = {
use Assoc::*;
use Rule::*;
PrattParser::new()
.op(Op::infix(and, Left))
.op(Op::infix(or, Left))
};
}
pub fn normalized_hash(group: &str, identifier: &str, modulus: u32) -> std::io::Result<u32> {
let mut reader = Cursor::new(format!("{}:{}", &group, &identifier));
murmur3_32(&mut reader, 0).map(|hash_result| hash_result % modulus)
}
pub type RuleFragment = Box<dyn SendableFragment + Send + Sync + 'static>;
pub type ContextResolver = Box<dyn SendableContextResolver + Send + Sync + 'static>;
trait Invertible {
fn invert(&self, inverted: bool) -> bool;
}
impl Invertible for bool {
fn invert(&self, inverted: bool) -> bool {
if inverted {
!self
} else {
*self
}
}
}
#[derive(Debug, Clone)]
enum OrdinalComparator {
Lte,
Lt,
Gte,
Gt,
Eq,
}
#[derive(Debug, Clone)]
enum ContentComparator {
In,
NotIn,
}
#[derive(Debug, Clone)]
enum StringComparator {
StartsWith,
EndsWith,
Contains,
}
struct StringComparatorType {
ignore_case: bool,
comparator_type: StringComparator,
}
fn context_value(mut node: Pairs<Rule>) -> ContextResolver {
let child = node.next().unwrap();
match child.as_rule() {
Rule::user_id => Box::new(|context: &Context| context.user_id.clone()),
Rule::app_name => Box::new(|context: &Context| context.app_name.clone()),
Rule::environment => Box::new(|context: &Context| context.environment.clone()),
Rule::session_id => Box::new(|context: &Context| context.session_id.clone()),
Rule::remote_address => Box::new(|context: &Context| context.remote_address.clone()),
Rule::current_time => Box::new(|context: &Context| context.current_time.clone()),
Rule::random => {
Box::new(|_: &Context| Some(rand::thread_rng().gen_range(0..99).to_string()))
}
Rule::property => context_property(child.into_inner()),
_ => unreachable!(),
}
}
fn stickiness_param(mut node: Pairs<Rule>) -> ContextResolver {
context_value(node.next().unwrap().into_inner())
}
fn context_property(mut node: Pairs<Rule>) -> ContextResolver {
let mut chars = node.next().unwrap().as_str().chars();
chars.next();
chars.next_back();
let context_name = chars.as_str().to_string();
Box::new(move |context: &Context| -> Option<String> {
match &context.properties {
Some(props) => props.get(&context_name).cloned(),
None => None,
}
})
}
fn to_ordinal_comparator(node: Pair<Rule>) -> OrdinalComparator {
match node.as_str() {
"<" => OrdinalComparator::Lt,
"<=" => OrdinalComparator::Lte,
"==" => OrdinalComparator::Eq,
">" => OrdinalComparator::Gt,
">=" => OrdinalComparator::Gte,
_ => unreachable!(),
}
}
fn to_content_comparator(node: Pair<Rule>) -> ContentComparator {
match node.as_str() {
"in" => ContentComparator::In,
"not_in" => ContentComparator::NotIn,
_ => unreachable!(),
}
}
fn to_string_comparator(node: Pair<Rule>) -> StringComparatorType {
match node.as_str() {
"starts_with_any" => StringComparatorType {
ignore_case: false,
comparator_type: StringComparator::StartsWith,
},
"ends_with_any" => StringComparatorType {
ignore_case: false,
comparator_type: StringComparator::EndsWith,
},
"contains_any" => StringComparatorType {
ignore_case: false,
comparator_type: StringComparator::Contains,
},
"starts_with_any_ignore_case" => StringComparatorType {
ignore_case: true,
comparator_type: StringComparator::StartsWith,
},
"ends_with_any_ignore_case" => StringComparatorType {
ignore_case: true,
comparator_type: StringComparator::EndsWith,
},
"contains_any_ignore_case" => StringComparatorType {
ignore_case: true,
comparator_type: StringComparator::Contains,
},
_ => unreachable!(),
}
}
fn numeric(node: Pair<Rule>) -> f64 {
node.as_str().parse::<f64>().unwrap()
}
fn date(node: Pair<Rule>) -> DateTime<Utc> {
node.as_str().parse::<DateTime<Utc>>().unwrap()
}
fn semver(node: Pair<Rule>) -> Version {
Version::parse(node.as_str()).unwrap()
}
fn percentage(node: Pair<Rule>) -> u8 {
let mut chars = node.as_str().chars();
chars.next_back();
chars.as_str().parse::<u8>().unwrap()
}
fn group_id_param(mut node: Pairs<Rule>) -> String {
string(node.next().unwrap())
}
fn string(node: Pair<Rule>) -> String {
let mut chars = node.as_str().chars();
chars.next();
chars.next_back();
chars.as_str().into()
}
fn numeric_constraint(inverted: bool, mut node: Pairs<Rule>) -> RuleFragment {
let context_getter = context_value(node.next().unwrap().into_inner());
let ordinal_operation = to_ordinal_comparator(node.next().unwrap());
let number = numeric(node.next().unwrap());
Box::new(move |context: &Context| {
let context_value = context_getter(context);
match context_value {
Some(context_value) => {
let context_value: f64 = context_value.parse().unwrap();
match ordinal_operation {
OrdinalComparator::Lte => context_value <= number,
OrdinalComparator::Lt => context_value < number,
OrdinalComparator::Gte => context_value >= number,
OrdinalComparator::Gt => context_value > number,
OrdinalComparator::Eq => (context_value - number).abs() < f64::EPSILON,
}
.invert(inverted)
}
None => false,
}
})
}
fn date_constraint(inverted: bool, mut node: Pairs<Rule>) -> RuleFragment {
let context_getter = context_value(node.next().unwrap().into_inner());
let ordinal_operation = to_ordinal_comparator(node.next().unwrap());
let date = date(node.next().unwrap());
Box::new(move |context: &Context| {
let context_value = context_getter(context);
match context_value {
Some(context_value) => {
let context_value: DateTime<Utc> = context_value.parse().unwrap();
match ordinal_operation {
OrdinalComparator::Lte => context_value <= date,
OrdinalComparator::Lt => context_value < date,
OrdinalComparator::Gte => context_value >= date,
OrdinalComparator::Gt => context_value > date,
OrdinalComparator::Eq => context_value == date,
}
.invert(inverted)
}
None => false,
}
})
}
fn semver_constraint(inverted: bool, mut node: Pairs<Rule>) -> RuleFragment {
let context_getter = context_value(node.next().unwrap().into_inner());
let ordinal_operation = to_ordinal_comparator(node.next().unwrap());
let semver = semver(node.next().unwrap());
Box::new(move |context: &Context| {
let context_value = context_getter(context);
match context_value {
Some(context_value) => {
let context_value = context_value.parse::<Version>().unwrap();
match ordinal_operation {
OrdinalComparator::Lte => context_value <= semver,
OrdinalComparator::Lt => context_value < semver,
OrdinalComparator::Gte => context_value >= semver,
OrdinalComparator::Gt => context_value > semver,
OrdinalComparator::Eq => context_value == semver,
}
.invert(inverted)
}
None => false,
}
})
}
fn rollout_constraint(mut node: Pairs<Rule>) -> RuleFragment {
let percent_rollout = percentage(node.next().unwrap());
let second = node.next();
let third = node.next();
let (stickiness_getter, group_id) = match (second, third) {
(Some(second), Some(third)) => {
let sticky = stickiness_param(second.into_inner());
let group_id = group_id_param(third.into_inner());
(Some(sticky), Some(group_id))
}
(Some(second), None) => match second.as_rule() {
Rule::stickiness_param => (Some(stickiness_param(second.into_inner())), None),
Rule::group_id_param => (None, Some(group_id_param(second.into_inner()))),
_ => unreachable!(),
},
_ => (None, None),
};
Box::new(move |context: &Context| {
let stickiness = match &stickiness_getter {
Some(stickiness_getter) => {
let custom_stickiness = stickiness_getter(context);
if custom_stickiness.is_none() {
return false;
}
custom_stickiness
}
None => context
.user_id
.clone()
.or_else(|| context.session_id.clone()),
};
let group_id = match &group_id {
Some(group_id) => group_id.clone(),
None => context.toggle_name.clone(),
};
let hash = if let Some(stickiness) = stickiness {
normalized_hash(&group_id, &stickiness, 100)
} else {
Ok(rand::thread_rng().gen_range(0..99) as u32)
};
if let Ok(hash) = hash {
hash <= percent_rollout.into()
} else {
false
}
})
}
fn list_constraint(inverted: bool, mut node: Pairs<Rule>) -> RuleFragment {
let context_getter = context_value(node.next().unwrap().into_inner());
let comparator = to_content_comparator(node.next().unwrap());
let list = node.next().unwrap();
match list.as_rule() {
Rule::empty_list => Box::new(move |_context: &Context| match comparator {
ContentComparator::In => false.invert(inverted),
ContentComparator::NotIn => true.invert(inverted),
}),
Rule::numeric_list => {
let values = harvest_list(list.into_inner());
Box::new(move |context: &Context| {
let context_value = context_getter(context);
match context_value {
Some(context_value) => {
let context_value: f64 = context_value.parse().unwrap();
match comparator {
ContentComparator::In => {
values.contains(&context_value).invert(inverted)
}
ContentComparator::NotIn => {
!values.contains(&context_value).invert(inverted)
}
}
}
None => false,
}
})
}
Rule::string_list => {
let values = harvest_set(list.into_inner());
Box::new(move |context: &Context| {
let context_value = context_getter(context);
match comparator {
ContentComparator::In => match context_value {
Some(context_value) => values.contains(&context_value).invert(inverted),
None => false,
},
ContentComparator::NotIn => match context_value {
Some(context_value) => !values.contains(&context_value).invert(inverted),
None => true,
},
}
})
}
_ => unreachable!(),
}
}
fn harvest_set(node: Pairs<Rule>) -> HashSet<String> {
node.into_iter().map(string).collect::<HashSet<String>>()
}
fn harvest_string_list(node: Pairs<Rule>) -> Vec<String> {
node.into_iter().map(string).collect::<Vec<String>>()
}
fn harvest_list(node: Pairs<Rule>) -> Vec<f64> {
let nodes: Result<Vec<f64>, ParseFloatError> =
node.into_iter().map(|x| x.as_str().parse()).collect();
nodes.unwrap()
}
fn default_strategy_constraint(inverted: bool, node: Pairs<Rule>) -> RuleFragment {
let enabled: bool = node.as_str().chars().as_str().parse().unwrap();
Box::new(move |_: &Context| enabled.invert(inverted))
}
fn string_fragment_constraint(inverted: bool, mut node: Pairs<Rule>) -> RuleFragment {
let context_getter = context_value(node.next().unwrap().into_inner());
let comparator_details = to_string_comparator(node.next().unwrap());
let comparator = comparator_details.comparator_type;
let ignore_case = comparator_details.ignore_case;
let mut list = harvest_string_list(node.next().unwrap().into_inner());
if ignore_case {
list = list.into_iter().map(|item| item.to_lowercase()).collect();
};
Box::new(move |context: &Context| {
let mut value = context_getter(context);
if ignore_case {
value = value.map(|value| value.to_lowercase())
}
if let Some(value) = value {
match comparator {
StringComparator::Contains => list.iter().any(|item| value.contains(item)),
StringComparator::StartsWith => list.iter().any(|item| value.starts_with(item)),
StringComparator::EndsWith => list.iter().any(|item| value.ends_with(item)),
}
.invert(inverted)
} else {
false
}
})
}
fn constraint(mut node: Pairs<Rule>) -> RuleFragment {
let first = node.next();
let second = node.next();
let (inverted, child) = match (first, second) {
(Some(_), Some(second)) => (true, second),
(Some(first), None) => (false, first),
_ => unreachable!(),
};
match child.as_rule() {
Rule::date_constraint => date_constraint(inverted, child.into_inner()),
Rule::numeric_constraint => numeric_constraint(inverted, child.into_inner()),
Rule::semver_constraint => semver_constraint(inverted, child.into_inner()),
Rule::rollout_constraint => rollout_constraint(child.into_inner()), Rule::default_strategy_constraint => {
default_strategy_constraint(inverted, child.into_inner())
}
Rule::string_fragment_constraint => {
string_fragment_constraint(inverted, child.into_inner())
}
Rule::list_constraint => list_constraint(inverted, child.into_inner()),
_ => unreachable!(),
}
}
fn eval(expression: Pairs<Rule>) -> RuleFragment {
PRATT_PARSER
.map_primary(|primary| match primary.as_rule() {
Rule::constraint => constraint(primary.into_inner()),
Rule::expr => eval(primary.into_inner()),
_ => unreachable!(),
})
.map_infix(|lhs, op, rhs| match op.as_rule() {
Rule::and => {
Box::new(move |context: &Context| -> bool { lhs(context) && rhs(context) })
}
Rule::or => Box::new(move |context: &Context| -> bool { lhs(context) || rhs(context) }),
_ => unreachable!(),
})
.parse(expression)
}
#[allow(clippy::result_large_err)] pub fn compile_rule(rule: &str) -> Result<RuleFragment, Error<Rule>> {
let parse_result = Strategy::parse(Rule::strategy, rule);
parse_result.map(|mut x| eval(x.next().unwrap().into_inner()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use test_case::test_case;
fn context_from_user_id(user_id: &str) -> Context {
Context {
user_id: Some(user_id.into()),
current_time: None,
properties: Some(HashMap::new()),
session_id: None,
environment: None,
app_name: None,
remote_address: None,
toggle_name: "".into(),
}
}
impl Default for Context {
fn default() -> Self {
Self {
user_id: Default::default(),
session_id: Default::default(),
environment: Default::default(),
app_name: Default::default(),
current_time: Default::default(),
remote_address: Default::default(),
properties: Default::default(),
toggle_name: Default::default(),
}
}
}
#[test_case("1", "user_id == 1 and (user_id > 1 and user_id < 1)", false)]
#[test_case("9", "user_id == 9 or (user_id > 5 and user_id < 5)", true)]
#[test_case("4", "user_id < 5 or user_id > 5", true)]
#[test_case("2", "user_id < 1 and user_id > 1", false)]
#[test_case("5", "user_id < 6 and user_id > 2", true)]
#[test_case("0", "(true and (true and (true)))", true)]
#[test_case("0", "(true and (true and (false)))", false)]
fn can_chain_operators(user_id: &str, rule: &str, expected: bool) {
run_test(user_id, rule, expected);
}
#[test_case("6", "user_id < 5", false)]
#[test_case("6.0", "user_id < 5", false)]
#[test_case("5", "user_id < 5", false)]
#[test_case("4", "user_id < 5", true)]
#[test_case("4.0", "user_id < 5", true)]
#[test_case("-4.0", "user_id < -5", false)]
#[test_case("-4.0", "user_id < -3", true)]
#[test_case("-4.0", "user_id < -4", false)]
#[test_case("6", "user_id < 5.0", false)]
#[test_case("6.0", "user_id < 5.0", false)]
#[test_case("5", "user_id < 5.0", false)]
#[test_case("4", "user_id < 5.0", true)]
#[test_case("4.0", "user_id < 5.0", true)]
#[test_case("-4.0", "user_id < -5.0", false)]
#[test_case("-4.0", "user_id < -3.0", true)]
#[test_case("-4.0", "user_id < -4.0", false)]
fn test_numeric_lt(user_id: &str, rule: &str, expected: bool) {
run_test(user_id, rule, expected);
}
#[test_case("3", "user_id <= 4", true)]
#[test_case("3", "user_id <= 3", true)]
#[test_case("3", "user_id <= 2", false)]
#[test_case("-4.0", "user_id <= -5.2", false)]
fn test_numeric_lte(user_id: &str, rule: &str, expected: bool) {
run_test(user_id, rule, expected);
}
#[test_case("5", "user_id > 4", true)]
#[test_case("4", "user_id > 4", false)]
#[test_case("3", "user_id > 4", false)]
fn test_numeric_gt(user_id: &str, rule: &str, expected: bool) {
run_test(user_id, rule, expected);
}
#[test_case("3", "user_id >= 4", false)]
#[test_case("3", "user_id >= 3", true)]
#[test_case("3", "user_id >= 2", true)]
#[test_case("-4.0", "user_id >= -5.2", true)]
fn test_numeric_gte(user_id: &str, rule: &str, expected: bool) {
run_test(user_id, rule, expected);
}
#[test_case("3.0", "user_id == 3.0", true)]
#[test_case("3", "user_id == 3.0", true)]
#[test_case("3.0", "user_id == 3", true)]
#[test_case("-3", "user_id == -3", true)]
#[test_case("4", "user_id == 4", true)]
#[test_case("4", "user_id == 3", false)]
fn test_numeric_eq(user_id: &str, rule: &str, expected: bool) {
run_test(user_id, rule, expected);
}
#[test_case("30.0.0", "user_id == 30.0.0", true)]
#[test_case("3.0.0", "user_id > 3.0.0", false)]
#[test_case("3.0.0-beta", "user_id == 3.0.0-beta", true)]
#[test_case("3.0.0-beta.2", "user_id > 3.0.0-beta.1", true)]
#[test_case("3.0.0-beta", "user_id > 3.0.0-alpha", true)]
#[test_case("3.0.0-beta", "user_id < 3.0.0-alpha", false)]
#[test_case("3.0.0", "user_id > 3.0.0-alpha", true)]
#[test_case("3.0.0", "user_id >= 3.0.0", true)]
#[test_case("3.0.0-beta.stuff", "user_id == 3.0.0-beta.stuff", true)]
#[test_case("3.0.0-beta.stuff+build1", "user_id == 3.0.0-beta.stuff+build1", true)]
fn test_semver_gt(user_id: &str, rule: &str, expected: bool) {
run_test(user_id, rule, expected);
}
fn run_test(user_id: &str, rule: &str, expected: bool) {
let rule = compile_rule(rule).expect("");
let context = context_from_user_id(user_id);
assert_eq!(rule(&context), expected);
}
#[test]
fn test_random_parses() {
let rule = "random() < 100";
let rule = compile_rule(rule).expect("");
let context = Context::default();
assert_eq!(rule(&context), true);
}
#[test_case("true", true)]
#[test_case("false", false)]
#[test_case("true or false", true)]
#[test_case("true and true", true)]
#[test_case("false or false", false)]
#[test_case("false or true", true)]
#[test_case("false and true", false)]
fn run_boolean_constraint(rule: &str, expected: bool) {
let rule = compile_rule(rule).expect("");
let context = context_from_user_id("6");
assert_eq!(rule(&context), expected);
}
#[test_case("100%", true)]
#[test_case("99%", true)]
fn run_rollout_test(rule: &str, expected: bool) {
let rule = compile_rule(rule).expect("");
let context = context_from_user_id("6");
assert_eq!(rule(&context), expected);
}
#[test_case("context[\"penguins\"] == 7", true)]
#[test_case("context[\"squirrels\"] == -2", true)]
#[test_case("context[\"squirrels\"] == 7", false)]
fn gets_context_property(rule: &str, expected: bool) {
let mut context_property = HashMap::new();
context_property.insert("penguins".into(), "7".into());
context_property.insert("squirrels".into(), "-2".into());
let context = Context {
current_time: None,
user_id: Some("6".into()),
properties: Some(context_property),
session_id: None,
environment: None,
app_name: None,
remote_address: None,
toggle_name: "".into(),
};
let rule = compile_rule(rule).expect("");
assert_eq!(rule(&context), expected);
}
#[test_case(
"55% sticky on user_id with group_id of \"Feature.flexibleRollout.userId.55\"",
true
)]
fn run_rollout_test_with_group_id(rule: &str, expected: bool) {
let rule = compile_rule(rule).expect("");
let context = context_from_user_id("25");
assert_eq!(rule(&context), expected);
}
#[test_case("55% with group_id of \"Feature.flexibleRollout.userId.55\"", true)]
fn run_rollout_test_with_group_id_and_no_sticky(rule: &str, expected: bool) {
let rule = compile_rule(rule).expect("");
let context = context_from_user_id("25");
assert_eq!(rule(&context), expected);
}
#[test_case("100% sticky on user_id", true)]
fn run_rollout_test_with_stickiness(rule: &str, expected: bool) {
let rule = compile_rule(rule).expect("");
let context = context_from_user_id("6");
assert_eq!(rule(&context), expected);
}
#[test_case("user_id in [1, 3, 6]", true)]
#[test_case("user_id in [1, 3, 5]", false)]
#[test_case("user_id not_in [1, 3, 5]", true)]
#[test_case("user_id in [\"dfsfsd\"]", false)]
#[test_case("user_id not_in [\"dfsfsd\"]", true)]
fn run_numeric_list_test(rule: &str, expected: bool) {
let rule = compile_rule(rule).expect("");
let context = context_from_user_id("6");
assert_eq!(rule(&context), expected);
}
#[test]
fn not_in_is_always_true_when_no_context_value() {
let rule = "app_name not_in [\"\"]";
let rule = compile_rule(rule).expect("");
let context = Context::default();
assert_eq!(rule(&context), true);
}
#[test_case("app_name not_in [\"Ær dette en helt ålreit string før eller siden fo Østhus\"]" ; "Norwegian characters")]
#[test_case("app_name not_in [\"Ελληνικά\"]" ; "Greek characters")]
#[test_case("app_name not_in [\"おはようございます\"]" ; "Japanese characters")]
#[test_case("app_name not_in [\"😃💁\"]"; "Teenager characters" )]
#[test_case("app_name not_in [\".&-/\"]"; "Limited punctuation characters" )]
fn arbitrary_unicode_is_handled(input: &str) {
let rule_text = input;
let rule = compile_rule(rule_text).unwrap();
assert_eq!(rule(&Context::default()), true);
}
#[test]
fn empty_lists_are_tolerated() {
let rule_text = "app_name not_in []";
let rule = compile_rule(rule_text).unwrap();
assert_eq!(rule(&Context::default()), true);
}
#[test_case("user_id starts_with_any [\"some\"]", true)]
#[test_case("user_id ends_with_any [\".com\"]", true)]
#[test_case("user_id contains_any [\"email\"]", true)]
#[test_case("user_id contains_any [\"EMAIL\"]", false)]
#[test_case("user_id contains_any_ignore_case [\"EMAIL\"]", true)]
#[test_case("user_id ends_with_any_ignore_case [\".COM\"]", true)]
#[test_case("user_id starts_with_any_ignore_case [\"SOME\"]", true)]
fn run_string_operators_tests(rule: &str, expected: bool) {
let rule = compile_rule(rule).expect("");
let context = context_from_user_id("some-email.com");
assert_eq!(rule(&context), expected);
}
#[test_case("context[\"cutoff\"] == 2022-01-25T13:00:00.000Z", true)]
#[test_case("context[\"cutoff\"] == 2022-01-25T12:00:00.000Z", false)]
#[test_case("context[\"cutoff\"] > 2022-01-25T12:00:00.000Z", true)]
#[test_case("context[\"cutoff\"] < 2022-01-25T14:00:00.000Z", true)]
#[test_case("context[\"cutoff\"] < 2022-01-25T11:00:00.000Z", false)]
#[test_case("context[\"cutoff\"] >= 2022-01-25T11:00:00.000Z", true)]
fn run_date_operators_tests(rule: &str, expected: bool) {
let rule = compile_rule(rule).expect("");
let mut context = Context::default();
let mut props = HashMap::new();
props.insert("cutoff".into(), "2022-01-25T13:00:00.000Z".into());
context.properties = Some(props);
assert_eq!(rule(&context), expected);
}
#[test_case("!user_id > 8", false)]
fn run_invert_rest(rule: &str, expected: bool) {
let rule = compile_rule(rule).expect("");
let context = context_from_user_id("9");
assert_eq!(rule(&context), expected);
}
#[test]
fn gradual_rollout_user_id_disabled_with_no_user() {
let rule_text = "100% sticky on context[\"customField\"] with group_id of \"Feature.flexible.rollout.custom.stickiness_100\"";
let rule = compile_rule(rule_text).unwrap();
assert_eq!(rule(&Context::default()), false);
}
#[test]
fn date_constraint_respects_timezones() {
let mut context = Context::default();
context.app_name = Some("2022-01-22T11:30:00.000Z".into());
let rule_text =
"app_name > 2022-01-22T13:00:00.000+02:00 and app_name < 2022-01-22T14:00:00.000+02:00";
let rule = compile_rule(rule_text).unwrap();
assert_eq!(rule(&context), true);
}
#[test]
fn inversion_works_on_string_any_rules() {
let mut context = Context::default();
context.app_name = Some("email".into());
let rule_text = "!app_name contains_any [\"@another.com\"]";
let rule = compile_rule(rule_text).unwrap();
assert_eq!(rule(&context), true);
}
}