use chrono::{DateTime, NaiveDate, Utc};
use regex::Regex;
use serde::{Deserialize, Serialize};
use sha1::{Digest, Sha1};
use std::collections::HashMap;
use std::fmt;
use std::sync::{Mutex, OnceLock};
static REGEX_CACHE: OnceLock<Mutex<HashMap<String, Option<Regex>>>> = OnceLock::new();
const ROLLOUT_HASH_SALT: &str = "";
const VARIANT_HASH_SALT: &str = "variant";
fn get_cached_regex(pattern: &str) -> Option<Regex> {
let cache = REGEX_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
let mut cache_guard = match cache.lock() {
Ok(guard) => guard,
Err(_) => {
tracing::warn!(
pattern,
"Regex cache mutex poisoned, treating as cache miss"
);
return None;
}
};
if let Some(cached) = cache_guard.get(pattern) {
return cached.clone();
}
let compiled = Regex::new(pattern).ok();
cache_guard.insert(pattern.to_string(), compiled.clone());
compiled
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum FlagValue {
Boolean(bool),
String(String),
}
#[derive(Debug)]
pub struct InconclusiveMatchError {
pub message: String,
}
impl InconclusiveMatchError {
pub fn new(message: &str) -> Self {
Self {
message: message.to_string(),
}
}
}
impl fmt::Display for InconclusiveMatchError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for InconclusiveMatchError {}
impl Default for FlagValue {
fn default() -> Self {
FlagValue::Boolean(false)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeatureFlag {
pub key: String,
pub active: bool,
#[serde(default)]
pub filters: FeatureFlagFilters,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FeatureFlagFilters {
#[serde(default)]
pub groups: Vec<FeatureFlagCondition>,
#[serde(default)]
pub multivariate: Option<MultivariateFilter>,
#[serde(default)]
pub payloads: HashMap<String, serde_json::Value>,
#[serde(default)]
pub aggregation_group_type_index: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeatureFlagCondition {
#[serde(default)]
pub properties: Vec<Property>,
pub rollout_percentage: Option<f64>,
pub variant: Option<String>,
#[serde(default)]
pub aggregation_group_type_index: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Property {
pub key: String,
pub value: serde_json::Value,
#[serde(default = "default_operator")]
pub operator: String,
#[serde(rename = "type")]
pub property_type: Option<String>,
}
fn default_operator() -> String {
"exact".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CohortDefinition {
pub id: String,
#[serde(default)]
pub properties: serde_json::Value,
}
impl CohortDefinition {
pub fn new(id: String, properties: Vec<Property>) -> Self {
Self {
id,
properties: serde_json::to_value(properties).unwrap_or_default(),
}
}
pub fn parse_properties(&self) -> Vec<Property> {
if let Some(arr) = self.properties.as_array() {
return arr
.iter()
.filter_map(|v| serde_json::from_value::<Property>(v.clone()).ok())
.collect();
}
if let Some(obj) = self.properties.as_object() {
if let Some(values) = obj.get("values") {
if let Some(values_arr) = values.as_array() {
return values_arr
.iter()
.filter_map(|v| {
if v.get("type").and_then(|t| t.as_str()) == Some("property") {
serde_json::from_value::<Property>(v.clone()).ok()
} else if let Some(inner_values) = v.get("values") {
inner_values.as_array().and_then(|arr| {
arr.iter()
.filter_map(|inner| {
serde_json::from_value::<Property>(inner.clone()).ok()
})
.next()
})
} else {
None
}
})
.collect();
}
}
}
Vec::new()
}
}
pub struct EvaluationContext<'a> {
pub cohorts: &'a HashMap<String, CohortDefinition>,
pub flags: &'a HashMap<String, FeatureFlag>,
pub distinct_id: &'a str,
pub groups: &'a HashMap<String, String>,
pub group_properties: &'a HashMap<String, HashMap<String, serde_json::Value>>,
pub group_type_mapping: &'a HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MultivariateFilter {
pub variants: Vec<MultivariateVariant>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MultivariateVariant {
pub key: String,
pub rollout_percentage: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum FeatureFlagsResponse {
V2 {
flags: HashMap<String, FlagDetail>,
#[serde(rename = "errorsWhileComputingFlags")]
#[serde(default)]
errors_while_computing_flags: bool,
#[serde(rename = "quotaLimited")]
#[serde(default)]
quota_limited: bool,
#[serde(rename = "requestId")]
#[serde(default)]
request_id: Option<String>,
},
Legacy {
#[serde(rename = "featureFlags")]
feature_flags: HashMap<String, FlagValue>,
#[serde(rename = "featureFlagPayloads")]
#[serde(default)]
feature_flag_payloads: HashMap<String, serde_json::Value>,
#[serde(default)]
errors: Option<Vec<String>>,
},
}
impl FeatureFlagsResponse {
pub fn normalize(
self,
) -> (
HashMap<String, FlagValue>,
HashMap<String, serde_json::Value>,
) {
match self {
FeatureFlagsResponse::V2 { flags, .. } => {
let mut feature_flags = HashMap::new();
let mut payloads = HashMap::new();
for (key, detail) in flags {
if detail.enabled {
if let Some(variant) = detail.variant {
feature_flags.insert(key.clone(), FlagValue::String(variant));
} else {
feature_flags.insert(key.clone(), FlagValue::Boolean(true));
}
} else {
feature_flags.insert(key.clone(), FlagValue::Boolean(false));
}
if let Some(metadata) = detail.metadata {
if let Some(payload) = metadata.payload {
payloads.insert(key, payload);
}
}
}
(feature_flags, payloads)
}
FeatureFlagsResponse::Legacy {
feature_flags,
feature_flag_payloads,
..
} => (feature_flags, feature_flag_payloads),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlagDetail {
pub key: String,
pub enabled: bool,
pub variant: Option<String>,
#[serde(default)]
pub reason: Option<FlagReason>,
#[serde(default)]
pub metadata: Option<FlagMetadata>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlagReason {
pub code: String,
#[serde(default)]
pub condition_index: Option<usize>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlagMetadata {
pub id: u64,
pub version: u32,
pub description: Option<String>,
pub payload: Option<serde_json::Value>,
}
const LONG_SCALE: f64 = 0xFFFFFFFFFFFFFFFu64 as f64;
pub fn hash_key(key: &str, distinct_id: &str, salt: &str) -> f64 {
let hash_key = format!("{key}.{distinct_id}{salt}");
let mut hasher = Sha1::new();
hasher.update(hash_key.as_bytes());
let result = hasher.finalize();
let hex_str = format!("{result:x}");
let hash_val = u64::from_str_radix(&hex_str[..15], 16).unwrap_or(0);
hash_val as f64 / LONG_SCALE
}
pub fn get_matching_variant(flag: &FeatureFlag, distinct_id: &str) -> Option<String> {
let hash_value = hash_key(&flag.key, distinct_id, VARIANT_HASH_SALT);
let variants = flag.filters.multivariate.as_ref()?.variants.as_slice();
let mut value_min = 0.0;
for variant in variants {
let value_max = value_min + variant.rollout_percentage / 100.0;
if hash_value >= value_min && hash_value < value_max {
return Some(variant.key.clone());
}
value_min = value_max;
}
None
}
enum ConditionTarget<'a> {
Use {
bucketing: String,
properties: &'a HashMap<String, serde_json::Value>,
},
Skip,
Inconclusive,
}
fn resolve_condition_target<'a>(
condition: &FeatureFlagCondition,
flag_aggregation: Option<i32>,
distinct_id: &str,
person_properties: &'a HashMap<String, serde_json::Value>,
groups: &HashMap<String, String>,
group_properties: &'a HashMap<String, HashMap<String, serde_json::Value>>,
group_type_mapping: &HashMap<String, String>,
) -> ConditionTarget<'a> {
let effective_aggregation = condition.aggregation_group_type_index.or(flag_aggregation);
match effective_aggregation {
None => ConditionTarget::Use {
bucketing: distinct_id.to_string(),
properties: person_properties,
},
Some(idx) => {
let key = idx.to_string();
let Some(group_type) = group_type_mapping.get(&key) else {
return ConditionTarget::Skip;
};
let Some(group_key) = groups.get(group_type) else {
return ConditionTarget::Skip;
};
let Some(props) = group_properties.get(group_type) else {
return ConditionTarget::Inconclusive;
};
ConditionTarget::Use {
bucketing: group_key.clone(),
properties: props,
}
}
}
}
#[must_use = "feature flag evaluation result should be used"]
#[allow(clippy::too_many_arguments)]
pub fn match_feature_flag(
flag: &FeatureFlag,
distinct_id: &str,
person_properties: &HashMap<String, serde_json::Value>,
groups: &HashMap<String, String>,
group_properties: &HashMap<String, HashMap<String, serde_json::Value>>,
group_type_mapping: &HashMap<String, String>,
) -> Result<FlagValue, InconclusiveMatchError> {
if !flag.active {
return Ok(FlagValue::Boolean(false));
}
let conditions = &flag.filters.groups;
let flag_aggregation = flag.filters.aggregation_group_type_index;
let mut sorted_conditions = conditions.clone();
sorted_conditions.sort_by_key(|c| if c.variant.is_some() { 0 } else { 1 });
let mut is_inconclusive = false;
for condition in sorted_conditions {
let (effective_bucketing, effective_properties) = match resolve_condition_target(
&condition,
flag_aggregation,
distinct_id,
person_properties,
groups,
group_properties,
group_type_mapping,
) {
ConditionTarget::Use {
bucketing,
properties,
} => (bucketing, properties),
ConditionTarget::Skip => continue,
ConditionTarget::Inconclusive => {
is_inconclusive = true;
continue;
}
};
match is_condition_match(flag, &effective_bucketing, &condition, effective_properties) {
Ok(true) => {
if let Some(variant_override) = &condition.variant {
if let Some(ref multivariate) = flag.filters.multivariate {
let valid_variants: Vec<String> = multivariate
.variants
.iter()
.map(|v| v.key.clone())
.collect();
if valid_variants.contains(variant_override) {
return Ok(FlagValue::String(variant_override.clone()));
}
}
}
if let Some(variant) = get_matching_variant(flag, &effective_bucketing) {
return Ok(FlagValue::String(variant));
}
return Ok(FlagValue::Boolean(true));
}
Ok(false) => continue,
Err(_) => {
is_inconclusive = true;
}
}
}
if is_inconclusive {
return Err(InconclusiveMatchError::new(
"Can't determine if feature flag is enabled or not with given properties",
));
}
Ok(FlagValue::Boolean(false))
}
fn is_condition_match(
flag: &FeatureFlag,
bucketing_id: &str,
condition: &FeatureFlagCondition,
properties: &HashMap<String, serde_json::Value>,
) -> Result<bool, InconclusiveMatchError> {
for prop in &condition.properties {
if !match_property(prop, properties)? {
return Ok(false);
}
}
if let Some(rollout_percentage) = condition.rollout_percentage {
let hash_value = hash_key(&flag.key, bucketing_id, ROLLOUT_HASH_SALT);
if hash_value > (rollout_percentage / 100.0) {
return Ok(false);
}
}
Ok(true)
}
#[must_use = "feature flag evaluation result should be used"]
pub fn match_feature_flag_with_context(
flag: &FeatureFlag,
person_properties: &HashMap<String, serde_json::Value>,
ctx: &EvaluationContext,
) -> Result<FlagValue, InconclusiveMatchError> {
if !flag.active {
return Ok(FlagValue::Boolean(false));
}
let conditions = &flag.filters.groups;
let flag_aggregation = flag.filters.aggregation_group_type_index;
let mut sorted_conditions = conditions.clone();
sorted_conditions.sort_by_key(|c| if c.variant.is_some() { 0 } else { 1 });
let mut is_inconclusive = false;
for condition in sorted_conditions {
let (effective_bucketing, effective_properties) = match resolve_condition_target(
&condition,
flag_aggregation,
ctx.distinct_id,
person_properties,
ctx.groups,
ctx.group_properties,
ctx.group_type_mapping,
) {
ConditionTarget::Use {
bucketing,
properties,
} => (bucketing, properties),
ConditionTarget::Skip => continue,
ConditionTarget::Inconclusive => {
is_inconclusive = true;
continue;
}
};
match is_condition_match_with_context(
flag,
&effective_bucketing,
&condition,
effective_properties,
ctx,
) {
Ok(true) => {
if let Some(variant_override) = &condition.variant {
if let Some(ref multivariate) = flag.filters.multivariate {
let valid_variants: Vec<String> = multivariate
.variants
.iter()
.map(|v| v.key.clone())
.collect();
if valid_variants.contains(variant_override) {
return Ok(FlagValue::String(variant_override.clone()));
}
}
}
if let Some(variant) = get_matching_variant(flag, &effective_bucketing) {
return Ok(FlagValue::String(variant));
}
return Ok(FlagValue::Boolean(true));
}
Ok(false) => continue,
Err(_) => {
is_inconclusive = true;
}
}
}
if is_inconclusive {
return Err(InconclusiveMatchError::new(
"Can't determine if feature flag is enabled or not with given properties",
));
}
Ok(FlagValue::Boolean(false))
}
fn is_condition_match_with_context(
flag: &FeatureFlag,
bucketing_id: &str,
condition: &FeatureFlagCondition,
properties: &HashMap<String, serde_json::Value>,
ctx: &EvaluationContext,
) -> Result<bool, InconclusiveMatchError> {
for prop in &condition.properties {
if !match_property_with_context(prop, properties, ctx)? {
return Ok(false);
}
}
if let Some(rollout_percentage) = condition.rollout_percentage {
let hash_value = hash_key(&flag.key, bucketing_id, ROLLOUT_HASH_SALT);
if hash_value > (rollout_percentage / 100.0) {
return Ok(false);
}
}
Ok(true)
}
pub fn match_property_with_context(
property: &Property,
properties: &HashMap<String, serde_json::Value>,
ctx: &EvaluationContext,
) -> Result<bool, InconclusiveMatchError> {
if property.property_type.as_deref() == Some("cohort") {
return match_cohort_property(property, properties, ctx);
}
if property.key.starts_with("$feature/") {
return match_flag_dependency_property(property, ctx);
}
match_property(property, properties)
}
fn match_cohort_property(
property: &Property,
properties: &HashMap<String, serde_json::Value>,
ctx: &EvaluationContext,
) -> Result<bool, InconclusiveMatchError> {
let cohort_id = property
.value
.as_str()
.ok_or_else(|| InconclusiveMatchError::new("Cohort ID must be a string"))?;
let cohort = ctx.cohorts.get(cohort_id).ok_or_else(|| {
InconclusiveMatchError::new(&format!("Cohort '{}' not found in local cache", cohort_id))
})?;
let cohort_properties = cohort.parse_properties();
let mut is_in_cohort = true;
for cohort_prop in &cohort_properties {
match match_property(cohort_prop, properties) {
Ok(true) => continue,
Ok(false) => {
is_in_cohort = false;
break;
}
Err(e) => {
return Err(InconclusiveMatchError::new(&format!(
"Cannot evaluate cohort '{}' property '{}': {}",
cohort_id, cohort_prop.key, e.message
)));
}
}
}
Ok(match property.operator.as_str() {
"in" => is_in_cohort,
"not_in" => !is_in_cohort,
op => {
return Err(InconclusiveMatchError::new(&format!(
"Unknown cohort operator: {}",
op
)));
}
})
}
fn match_flag_dependency_property(
property: &Property,
ctx: &EvaluationContext,
) -> Result<bool, InconclusiveMatchError> {
let flag_key = property
.key
.strip_prefix("$feature/")
.ok_or_else(|| InconclusiveMatchError::new("Invalid flag dependency format"))?;
let flag = ctx.flags.get(flag_key).ok_or_else(|| {
InconclusiveMatchError::new(&format!("Flag '{}' not found in local cache", flag_key))
})?;
let empty_props = HashMap::new();
let flag_value = match_feature_flag(
flag,
ctx.distinct_id,
&empty_props,
ctx.groups,
ctx.group_properties,
ctx.group_type_mapping,
)?;
let expected = &property.value;
let matches = match (&flag_value, expected) {
(FlagValue::Boolean(b), serde_json::Value::Bool(expected_b)) => b == expected_b,
(FlagValue::String(s), serde_json::Value::String(expected_s)) => {
s.eq_ignore_ascii_case(expected_s)
}
(FlagValue::Boolean(true), serde_json::Value::String(s)) => {
s.is_empty() || s == "true"
}
(FlagValue::Boolean(false), serde_json::Value::String(s)) => s.is_empty() || s == "false",
(FlagValue::String(s), serde_json::Value::Bool(true)) => {
!s.is_empty()
}
(FlagValue::String(_), serde_json::Value::Bool(false)) => false,
_ => false,
};
Ok(match property.operator.as_str() {
"exact" => matches,
"is_not" => !matches,
op => {
return Err(InconclusiveMatchError::new(&format!(
"Unknown flag dependency operator: {}",
op
)));
}
})
}
fn parse_relative_date(value: &str) -> Option<DateTime<Utc>> {
let value = value.trim();
if value.len() < 3 || !value.starts_with('-') {
return None;
}
let (num_str, unit) = value[1..].split_at(value.len() - 2);
let num: i64 = num_str.parse().ok()?;
let duration = match unit {
"h" => chrono::Duration::hours(num),
"d" => chrono::Duration::days(num),
"w" => chrono::Duration::weeks(num),
"m" => chrono::Duration::days(num * 30), "y" => chrono::Duration::days(num * 365), _ => return None,
};
Some(Utc::now() - duration)
}
fn parse_date_value(value: &serde_json::Value) -> Option<DateTime<Utc>> {
let date_str = value.as_str()?;
if date_str.starts_with('-') && date_str.len() > 1 {
if let Some(dt) = parse_relative_date(date_str) {
return Some(dt);
}
}
if let Ok(dt) = DateTime::parse_from_rfc3339(date_str) {
return Some(dt.with_timezone(&Utc));
}
if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
return Some(
date.and_hms_opt(0, 0, 0)
.expect("midnight is always valid")
.and_utc(),
);
}
None
}
type SemverTuple = (u64, u64, u64);
fn parse_semver(value: &str) -> Option<SemverTuple> {
let value = value.trim();
if value.is_empty() {
return None;
}
let value = value
.strip_prefix('v')
.or_else(|| value.strip_prefix('V'))
.unwrap_or(value);
if value.is_empty() {
return None;
}
let value = value.split(['-', '+']).next().unwrap_or(value);
if value.is_empty() {
return None;
}
if value.starts_with('.') {
return None;
}
let parts: Vec<&str> = value.split('.').collect();
if parts.is_empty() {
return None;
}
let major: u64 = parts.first().and_then(|s| s.parse().ok())?;
let minor: u64 = parts.get(1).map(|s| s.parse().ok()).unwrap_or(Some(0))?;
let patch: u64 = parts.get(2).map(|s| s.parse().ok()).unwrap_or(Some(0))?;
Some((major, minor, patch))
}
fn parse_semver_wildcard(pattern: &str) -> Option<(SemverTuple, SemverTuple)> {
let pattern = pattern.trim();
if pattern.is_empty() {
return None;
}
let pattern = pattern
.strip_prefix('v')
.or_else(|| pattern.strip_prefix('V'))
.unwrap_or(pattern);
if pattern.is_empty() {
return None;
}
let parts: Vec<&str> = pattern.split('.').collect();
match parts.as_slice() {
[major_str, "*"] => {
let major: u64 = major_str.parse().ok()?;
Some(((major, 0, 0), (major + 1, 0, 0)))
}
[major_str, minor_str, "*"] => {
let major: u64 = major_str.parse().ok()?;
let minor: u64 = minor_str.parse().ok()?;
Some(((major, minor, 0), (major, minor + 1, 0)))
}
_ => None,
}
}
fn compute_tilde_bounds(version: SemverTuple) -> (SemverTuple, SemverTuple) {
let (major, minor, patch) = version;
((major, minor, patch), (major, minor + 1, 0))
}
fn compute_caret_bounds(version: SemverTuple) -> (SemverTuple, SemverTuple) {
let (major, minor, patch) = version;
if major > 0 {
((major, minor, patch), (major + 1, 0, 0))
} else if minor > 0 {
((0, minor, patch), (0, minor + 1, 0))
} else {
((0, 0, patch), (0, 0, patch + 1))
}
}
fn match_property(
property: &Property,
properties: &HashMap<String, serde_json::Value>,
) -> Result<bool, InconclusiveMatchError> {
let value = match properties.get(&property.key) {
Some(v) => v,
None => {
if property.operator == "is_not_set" {
return Ok(true);
}
if property.operator == "is_set" {
return Ok(false);
}
return Err(InconclusiveMatchError::new(&format!(
"Property '{}' not found in provided properties",
property.key
)));
}
};
Ok(match property.operator.as_str() {
"exact" => {
if property.value.is_array() {
if let Some(arr) = property.value.as_array() {
for val in arr {
if compare_values(val, value) {
return Ok(true);
}
}
return Ok(false);
}
}
compare_values(&property.value, value)
}
"is_not" => {
if property.value.is_array() {
if let Some(arr) = property.value.as_array() {
for val in arr {
if compare_values(val, value) {
return Ok(false);
}
}
return Ok(true);
}
}
!compare_values(&property.value, value)
}
"is_set" => true, "is_not_set" => false, "icontains" => {
let prop_str = value_to_string(value);
let search_str = value_to_string(&property.value);
prop_str.to_lowercase().contains(&search_str.to_lowercase())
}
"not_icontains" => {
let prop_str = value_to_string(value);
let search_str = value_to_string(&property.value);
!prop_str.to_lowercase().contains(&search_str.to_lowercase())
}
"regex" => {
let prop_str = value_to_string(value);
let regex_str = value_to_string(&property.value);
get_cached_regex(®ex_str)
.map(|re| re.is_match(&prop_str))
.unwrap_or(false)
}
"not_regex" => {
let prop_str = value_to_string(value);
let regex_str = value_to_string(&property.value);
get_cached_regex(®ex_str)
.map(|re| !re.is_match(&prop_str))
.unwrap_or(true)
}
"gt" | "gte" | "lt" | "lte" => compare_numeric(&property.operator, &property.value, value),
"is_date_before" | "is_date_after" => {
let target_date = parse_date_value(&property.value).ok_or_else(|| {
InconclusiveMatchError::new(&format!(
"Unable to parse target date value: {:?}",
property.value
))
})?;
let prop_date = parse_date_value(value).ok_or_else(|| {
InconclusiveMatchError::new(&format!(
"Unable to parse property date value for '{}': {:?}",
property.key, value
))
})?;
if property.operator == "is_date_before" {
prop_date < target_date
} else {
prop_date > target_date
}
}
"semver_eq" | "semver_neq" | "semver_gt" | "semver_gte" | "semver_lt" | "semver_lte" => {
let prop_str = value_to_string(value);
let target_str = value_to_string(&property.value);
let prop_version = parse_semver(&prop_str).ok_or_else(|| {
InconclusiveMatchError::new(&format!(
"Unable to parse property semver value for '{}': {:?}",
property.key, value
))
})?;
let target_version = parse_semver(&target_str).ok_or_else(|| {
InconclusiveMatchError::new(&format!(
"Unable to parse target semver value: {:?}",
property.value
))
})?;
match property.operator.as_str() {
"semver_eq" => prop_version == target_version,
"semver_neq" => prop_version != target_version,
"semver_gt" => prop_version > target_version,
"semver_gte" => prop_version >= target_version,
"semver_lt" => prop_version < target_version,
"semver_lte" => prop_version <= target_version,
_ => unreachable!(),
}
}
"semver_tilde" => {
let prop_str = value_to_string(value);
let target_str = value_to_string(&property.value);
let prop_version = parse_semver(&prop_str).ok_or_else(|| {
InconclusiveMatchError::new(&format!(
"Unable to parse property semver value for '{}': {:?}",
property.key, value
))
})?;
let target_version = parse_semver(&target_str).ok_or_else(|| {
InconclusiveMatchError::new(&format!(
"Unable to parse target semver value: {:?}",
property.value
))
})?;
let (lower, upper) = compute_tilde_bounds(target_version);
prop_version >= lower && prop_version < upper
}
"semver_caret" => {
let prop_str = value_to_string(value);
let target_str = value_to_string(&property.value);
let prop_version = parse_semver(&prop_str).ok_or_else(|| {
InconclusiveMatchError::new(&format!(
"Unable to parse property semver value for '{}': {:?}",
property.key, value
))
})?;
let target_version = parse_semver(&target_str).ok_or_else(|| {
InconclusiveMatchError::new(&format!(
"Unable to parse target semver value: {:?}",
property.value
))
})?;
let (lower, upper) = compute_caret_bounds(target_version);
prop_version >= lower && prop_version < upper
}
"semver_wildcard" => {
let prop_str = value_to_string(value);
let target_str = value_to_string(&property.value);
let prop_version = parse_semver(&prop_str).ok_or_else(|| {
InconclusiveMatchError::new(&format!(
"Unable to parse property semver value for '{}': {:?}",
property.key, value
))
})?;
let (lower, upper) = parse_semver_wildcard(&target_str).ok_or_else(|| {
InconclusiveMatchError::new(&format!(
"Unable to parse target semver wildcard pattern: {:?}",
property.value
))
})?;
prop_version >= lower && prop_version < upper
}
unknown => {
return Err(InconclusiveMatchError::new(&format!(
"Unknown operator: {}",
unknown
)));
}
})
}
fn compare_values(a: &serde_json::Value, b: &serde_json::Value) -> bool {
if let (Some(a_str), Some(b_str)) = (a.as_str(), b.as_str()) {
return a_str.eq_ignore_ascii_case(b_str);
}
a == b
}
fn value_to_string(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
_ => value.to_string(),
}
}
fn compare_numeric(
operator: &str,
property_value: &serde_json::Value,
value: &serde_json::Value,
) -> bool {
let prop_num = match property_value {
serde_json::Value::Number(n) => n.as_f64(),
serde_json::Value::String(s) => s.parse::<f64>().ok(),
_ => None,
};
let val_num = match value {
serde_json::Value::Number(n) => n.as_f64(),
serde_json::Value::String(s) => s.parse::<f64>().ok(),
_ => None,
};
if let (Some(prop), Some(val)) = (prop_num, val_num) {
match operator {
"gt" => val > prop,
"gte" => val >= prop,
"lt" => val < prop,
"lte" => val <= prop,
_ => false,
}
} else {
let prop_str = value_to_string(property_value);
let val_str = value_to_string(value);
match operator {
"gt" => val_str > prop_str,
"gte" => val_str >= prop_str,
"lt" => val_str < prop_str,
"lte" => val_str <= prop_str,
_ => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const TEST_SALT: &str = "test-salt";
#[test]
fn test_hash_key() {
let hash = hash_key("test-flag", "user-123", TEST_SALT);
assert!((0.0..=1.0).contains(&hash));
let hash2 = hash_key("test-flag", "user-123", TEST_SALT);
assert_eq!(hash, hash2);
let hash3 = hash_key("test-flag", "user-456", TEST_SALT);
assert_ne!(hash, hash3);
}
#[test]
fn test_simple_flag_match() {
let flag = FeatureFlag {
key: "test-flag".to_string(),
active: true,
filters: FeatureFlagFilters {
groups: vec![FeatureFlagCondition {
properties: vec![],
rollout_percentage: Some(100.0),
variant: None,
aggregation_group_type_index: None,
}],
multivariate: None,
payloads: HashMap::new(),
aggregation_group_type_index: None,
},
};
let properties = HashMap::new();
let result = match_feature_flag(
&flag,
"user-123",
&properties,
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
)
.unwrap();
assert_eq!(result, FlagValue::Boolean(true));
}
#[test]
fn test_property_matching() {
let prop = Property {
key: "country".to_string(),
value: json!("US"),
operator: "exact".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("country".to_string(), json!("US"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("country".to_string(), json!("UK"));
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_multivariate_variants() {
let flag = FeatureFlag {
key: "test-flag".to_string(),
active: true,
filters: FeatureFlagFilters {
groups: vec![FeatureFlagCondition {
properties: vec![],
rollout_percentage: Some(100.0),
variant: None,
aggregation_group_type_index: None,
}],
multivariate: Some(MultivariateFilter {
variants: vec![
MultivariateVariant {
key: "control".to_string(),
rollout_percentage: 50.0,
},
MultivariateVariant {
key: "test".to_string(),
rollout_percentage: 50.0,
},
],
}),
payloads: HashMap::new(),
aggregation_group_type_index: None,
},
};
let properties = HashMap::new();
let result = match_feature_flag(
&flag,
"user-123",
&properties,
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
)
.unwrap();
match result {
FlagValue::String(variant) => {
assert!(variant == "control" || variant == "test");
}
_ => panic!("Expected string variant"),
}
}
#[test]
fn test_inactive_flag() {
let flag = FeatureFlag {
key: "inactive-flag".to_string(),
active: false,
filters: FeatureFlagFilters {
groups: vec![FeatureFlagCondition {
properties: vec![],
rollout_percentage: Some(100.0),
variant: None,
aggregation_group_type_index: None,
}],
multivariate: None,
payloads: HashMap::new(),
aggregation_group_type_index: None,
},
};
let properties = HashMap::new();
let result = match_feature_flag(
&flag,
"user-123",
&properties,
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
)
.unwrap();
assert_eq!(result, FlagValue::Boolean(false));
}
#[test]
fn test_rollout_percentage() {
let flag = FeatureFlag {
key: "rollout-flag".to_string(),
active: true,
filters: FeatureFlagFilters {
groups: vec![FeatureFlagCondition {
properties: vec![],
rollout_percentage: Some(30.0), variant: None,
aggregation_group_type_index: None,
}],
multivariate: None,
payloads: HashMap::new(),
aggregation_group_type_index: None,
},
};
let properties = HashMap::new();
let mut enabled_count = 0;
for i in 0..1000 {
let result = match_feature_flag(
&flag,
&format!("user-{}", i),
&properties,
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
)
.unwrap();
if result == FlagValue::Boolean(true) {
enabled_count += 1;
}
}
assert!(enabled_count > 250 && enabled_count < 350);
}
#[test]
fn test_regex_operator() {
let prop = Property {
key: "email".to_string(),
value: json!(".*@company\\.com$"),
operator: "regex".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("email".to_string(), json!("user@company.com"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("email".to_string(), json!("user@example.com"));
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_icontains_operator() {
let prop = Property {
key: "name".to_string(),
value: json!("ADMIN"),
operator: "icontains".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("name".to_string(), json!("admin_user"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("name".to_string(), json!("regular_user"));
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_numeric_operators() {
let prop_gt = Property {
key: "age".to_string(),
value: json!(18),
operator: "gt".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("age".to_string(), json!(25));
assert!(match_property(&prop_gt, &properties).unwrap());
properties.insert("age".to_string(), json!(15));
assert!(!match_property(&prop_gt, &properties).unwrap());
let prop_lte = Property {
key: "score".to_string(),
value: json!(100),
operator: "lte".to_string(),
property_type: None,
};
properties.insert("score".to_string(), json!(100));
assert!(match_property(&prop_lte, &properties).unwrap());
properties.insert("score".to_string(), json!(101));
assert!(!match_property(&prop_lte, &properties).unwrap());
}
#[test]
fn test_is_set_operator() {
let prop = Property {
key: "email".to_string(),
value: json!(true),
operator: "is_set".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("email".to_string(), json!("test@example.com"));
assert!(match_property(&prop, &properties).unwrap());
properties.remove("email");
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_is_not_set_operator() {
let prop = Property {
key: "phone".to_string(),
value: json!(true),
operator: "is_not_set".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
assert!(match_property(&prop, &properties).unwrap());
properties.insert("phone".to_string(), json!("+1234567890"));
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_empty_groups() {
let flag = FeatureFlag {
key: "empty-groups".to_string(),
active: true,
filters: FeatureFlagFilters {
groups: vec![],
multivariate: None,
payloads: HashMap::new(),
aggregation_group_type_index: None,
},
};
let properties = HashMap::new();
let result = match_feature_flag(
&flag,
"user-123",
&properties,
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
)
.unwrap();
assert_eq!(result, FlagValue::Boolean(false));
}
#[test]
fn test_hash_scale_constant() {
assert_eq!(LONG_SCALE, 0xFFFFFFFFFFFFFFFu64 as f64);
assert_ne!(LONG_SCALE, 0xFFFFFFFFFFFFFFFFu64 as f64);
}
#[test]
fn test_unknown_operator_returns_inconclusive_error() {
let prop = Property {
key: "status".to_string(),
value: json!("active"),
operator: "unknown_operator".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("status".to_string(), json!("active"));
let result = match_property(&prop, &properties);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.message.contains("unknown_operator"));
}
#[test]
fn test_is_date_before_with_relative_date() {
let prop = Property {
key: "signup_date".to_string(),
value: json!("-7d"), operator: "is_date_before".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
let ten_days_ago = chrono::Utc::now() - chrono::Duration::days(10);
properties.insert(
"signup_date".to_string(),
json!(ten_days_ago.format("%Y-%m-%d").to_string()),
);
assert!(match_property(&prop, &properties).unwrap());
let three_days_ago = chrono::Utc::now() - chrono::Duration::days(3);
properties.insert(
"signup_date".to_string(),
json!(three_days_ago.format("%Y-%m-%d").to_string()),
);
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_is_date_after_with_relative_date() {
let prop = Property {
key: "last_seen".to_string(),
value: json!("-30d"), operator: "is_date_after".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
let ten_days_ago = chrono::Utc::now() - chrono::Duration::days(10);
properties.insert(
"last_seen".to_string(),
json!(ten_days_ago.format("%Y-%m-%d").to_string()),
);
assert!(match_property(&prop, &properties).unwrap());
let sixty_days_ago = chrono::Utc::now() - chrono::Duration::days(60);
properties.insert(
"last_seen".to_string(),
json!(sixty_days_ago.format("%Y-%m-%d").to_string()),
);
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_is_date_before_with_iso_date() {
let prop = Property {
key: "expiry_date".to_string(),
value: json!("2024-06-15"),
operator: "is_date_before".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("expiry_date".to_string(), json!("2024-06-10"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("expiry_date".to_string(), json!("2024-06-20"));
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_is_date_after_with_iso_date() {
let prop = Property {
key: "start_date".to_string(),
value: json!("2024-01-01"),
operator: "is_date_after".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("start_date".to_string(), json!("2024-03-15"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("start_date".to_string(), json!("2023-12-01"));
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_is_date_with_relative_hours() {
let prop = Property {
key: "last_active".to_string(),
value: json!("-24h"), operator: "is_date_after".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
let twelve_hours_ago = chrono::Utc::now() - chrono::Duration::hours(12);
properties.insert(
"last_active".to_string(),
json!(twelve_hours_ago.to_rfc3339()),
);
assert!(match_property(&prop, &properties).unwrap());
let forty_eight_hours_ago = chrono::Utc::now() - chrono::Duration::hours(48);
properties.insert(
"last_active".to_string(),
json!(forty_eight_hours_ago.to_rfc3339()),
);
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_is_date_with_relative_weeks() {
let prop = Property {
key: "joined".to_string(),
value: json!("-2w"), operator: "is_date_before".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
let three_weeks_ago = chrono::Utc::now() - chrono::Duration::weeks(3);
properties.insert(
"joined".to_string(),
json!(three_weeks_ago.format("%Y-%m-%d").to_string()),
);
assert!(match_property(&prop, &properties).unwrap());
let one_week_ago = chrono::Utc::now() - chrono::Duration::weeks(1);
properties.insert(
"joined".to_string(),
json!(one_week_ago.format("%Y-%m-%d").to_string()),
);
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_is_date_with_relative_months() {
let prop = Property {
key: "subscription_date".to_string(),
value: json!("-3m"), operator: "is_date_after".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
let one_month_ago = chrono::Utc::now() - chrono::Duration::days(30);
properties.insert(
"subscription_date".to_string(),
json!(one_month_ago.format("%Y-%m-%d").to_string()),
);
assert!(match_property(&prop, &properties).unwrap());
let six_months_ago = chrono::Utc::now() - chrono::Duration::days(180);
properties.insert(
"subscription_date".to_string(),
json!(six_months_ago.format("%Y-%m-%d").to_string()),
);
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_is_date_with_relative_years() {
let prop = Property {
key: "created_at".to_string(),
value: json!("-1y"), operator: "is_date_before".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
let two_years_ago = chrono::Utc::now() - chrono::Duration::days(730);
properties.insert(
"created_at".to_string(),
json!(two_years_ago.format("%Y-%m-%d").to_string()),
);
assert!(match_property(&prop, &properties).unwrap());
let six_months_ago = chrono::Utc::now() - chrono::Duration::days(180);
properties.insert(
"created_at".to_string(),
json!(six_months_ago.format("%Y-%m-%d").to_string()),
);
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_is_date_with_invalid_date_format() {
let prop = Property {
key: "date".to_string(),
value: json!("-7d"),
operator: "is_date_before".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("date".to_string(), json!("not-a-date"));
let result = match_property(&prop, &properties);
assert!(result.is_err());
}
#[test]
fn test_is_date_with_iso_datetime() {
let prop = Property {
key: "event_time".to_string(),
value: json!("2024-06-15T10:30:00Z"),
operator: "is_date_before".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("event_time".to_string(), json!("2024-06-15T08:00:00Z"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("event_time".to_string(), json!("2024-06-15T12:00:00Z"));
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_cohort_membership_in() {
let mut cohorts = HashMap::new();
cohorts.insert(
"cohort_1".to_string(),
CohortDefinition::new(
"cohort_1".to_string(),
vec![Property {
key: "country".to_string(),
value: json!("US"),
operator: "exact".to_string(),
property_type: None,
}],
),
);
let prop = Property {
key: "$cohort".to_string(),
value: json!("cohort_1"),
operator: "in".to_string(),
property_type: Some("cohort".to_string()),
};
let mut properties = HashMap::new();
properties.insert("country".to_string(), json!("US"));
let ctx = EvaluationContext {
cohorts: &cohorts,
flags: &HashMap::new(),
distinct_id: "user-123",
groups: &HashMap::new(),
group_properties: &HashMap::new(),
group_type_mapping: &HashMap::new(),
};
assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
properties.insert("country".to_string(), json!("UK"));
assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
}
#[test]
fn test_cohort_membership_not_in() {
let mut cohorts = HashMap::new();
cohorts.insert(
"cohort_blocked".to_string(),
CohortDefinition::new(
"cohort_blocked".to_string(),
vec![Property {
key: "status".to_string(),
value: json!("blocked"),
operator: "exact".to_string(),
property_type: None,
}],
),
);
let prop = Property {
key: "$cohort".to_string(),
value: json!("cohort_blocked"),
operator: "not_in".to_string(),
property_type: Some("cohort".to_string()),
};
let mut properties = HashMap::new();
properties.insert("status".to_string(), json!("active"));
let ctx = EvaluationContext {
cohorts: &cohorts,
flags: &HashMap::new(),
distinct_id: "user-123",
groups: &HashMap::new(),
group_properties: &HashMap::new(),
group_type_mapping: &HashMap::new(),
};
assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
properties.insert("status".to_string(), json!("blocked"));
assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
}
#[test]
fn test_cohort_not_found_returns_inconclusive() {
let cohorts = HashMap::new();
let prop = Property {
key: "$cohort".to_string(),
value: json!("nonexistent_cohort"),
operator: "in".to_string(),
property_type: Some("cohort".to_string()),
};
let properties = HashMap::new();
let ctx = EvaluationContext {
cohorts: &cohorts,
flags: &HashMap::new(),
distinct_id: "user-123",
groups: &HashMap::new(),
group_properties: &HashMap::new(),
group_type_mapping: &HashMap::new(),
};
let result = match_property_with_context(&prop, &properties, &ctx);
assert!(result.is_err());
assert!(result.unwrap_err().message.contains("Cohort"));
}
#[test]
fn test_flag_dependency_enabled() {
let mut flags = HashMap::new();
flags.insert(
"prerequisite-flag".to_string(),
FeatureFlag {
key: "prerequisite-flag".to_string(),
active: true,
filters: FeatureFlagFilters {
groups: vec![FeatureFlagCondition {
properties: vec![],
rollout_percentage: Some(100.0),
variant: None,
aggregation_group_type_index: None,
}],
multivariate: None,
payloads: HashMap::new(),
aggregation_group_type_index: None,
},
},
);
let prop = Property {
key: "$feature/prerequisite-flag".to_string(),
value: json!(true),
operator: "exact".to_string(),
property_type: None,
};
let properties = HashMap::new();
let ctx = EvaluationContext {
cohorts: &HashMap::new(),
flags: &flags,
distinct_id: "user-123",
groups: &HashMap::new(),
group_properties: &HashMap::new(),
group_type_mapping: &HashMap::new(),
};
assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
}
#[test]
fn test_flag_dependency_disabled() {
let mut flags = HashMap::new();
flags.insert(
"disabled-flag".to_string(),
FeatureFlag {
key: "disabled-flag".to_string(),
active: false, filters: FeatureFlagFilters {
groups: vec![],
multivariate: None,
payloads: HashMap::new(),
aggregation_group_type_index: None,
},
},
);
let prop = Property {
key: "$feature/disabled-flag".to_string(),
value: json!(true),
operator: "exact".to_string(),
property_type: None,
};
let properties = HashMap::new();
let ctx = EvaluationContext {
cohorts: &HashMap::new(),
flags: &flags,
distinct_id: "user-123",
groups: &HashMap::new(),
group_properties: &HashMap::new(),
group_type_mapping: &HashMap::new(),
};
assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
}
#[test]
fn test_flag_dependency_variant_match() {
let mut flags = HashMap::new();
flags.insert(
"ab-test-flag".to_string(),
FeatureFlag {
key: "ab-test-flag".to_string(),
active: true,
filters: FeatureFlagFilters {
groups: vec![FeatureFlagCondition {
properties: vec![],
rollout_percentage: Some(100.0),
variant: None,
aggregation_group_type_index: None,
}],
multivariate: Some(MultivariateFilter {
variants: vec![
MultivariateVariant {
key: "control".to_string(),
rollout_percentage: 50.0,
},
MultivariateVariant {
key: "test".to_string(),
rollout_percentage: 50.0,
},
],
}),
payloads: HashMap::new(),
aggregation_group_type_index: None,
},
},
);
let prop = Property {
key: "$feature/ab-test-flag".to_string(),
value: json!("control"),
operator: "exact".to_string(),
property_type: None,
};
let properties = HashMap::new();
let ctx = EvaluationContext {
cohorts: &HashMap::new(),
flags: &flags,
distinct_id: "user-gets-control", groups: &HashMap::new(),
group_properties: &HashMap::new(),
group_type_mapping: &HashMap::new(),
};
let result = match_property_with_context(&prop, &properties, &ctx);
assert!(result.is_ok());
}
#[test]
fn test_flag_dependency_not_found_returns_inconclusive() {
let flags = HashMap::new();
let prop = Property {
key: "$feature/nonexistent-flag".to_string(),
value: json!(true),
operator: "exact".to_string(),
property_type: None,
};
let properties = HashMap::new();
let ctx = EvaluationContext {
cohorts: &HashMap::new(),
flags: &flags,
distinct_id: "user-123",
groups: &HashMap::new(),
group_properties: &HashMap::new(),
group_type_mapping: &HashMap::new(),
};
let result = match_property_with_context(&prop, &properties, &ctx);
assert!(result.is_err());
assert!(result.unwrap_err().message.contains("Flag"));
}
#[test]
fn test_parse_relative_date_edge_cases() {
let prop = Property {
key: "date".to_string(),
value: json!("placeholder"),
operator: "is_date_before".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("date".to_string(), json!("2024-01-01"));
let empty_prop = Property {
value: json!(""),
..prop.clone()
};
assert!(match_property(&empty_prop, &properties).is_err());
let dash_prop = Property {
value: json!("-"),
..prop.clone()
};
assert!(match_property(&dash_prop, &properties).is_err());
let no_unit_prop = Property {
value: json!("-7"),
..prop.clone()
};
assert!(match_property(&no_unit_prop, &properties).is_err());
let no_number_prop = Property {
value: json!("-d"),
..prop.clone()
};
assert!(match_property(&no_number_prop, &properties).is_err());
let invalid_unit_prop = Property {
value: json!("-7x"),
..prop.clone()
};
assert!(match_property(&invalid_unit_prop, &properties).is_err());
}
#[test]
fn test_parse_relative_date_large_values() {
let prop = Property {
key: "created_at".to_string(),
value: json!("-1000d"), operator: "is_date_before".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
let five_years_ago = chrono::Utc::now() - chrono::Duration::days(1825);
properties.insert(
"created_at".to_string(),
json!(five_years_ago.format("%Y-%m-%d").to_string()),
);
assert!(match_property(&prop, &properties).unwrap());
}
#[test]
fn test_regex_with_invalid_pattern_returns_false() {
let prop = Property {
key: "email".to_string(),
value: json!("(unclosed"),
operator: "regex".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("email".to_string(), json!("test@example.com"));
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_not_regex_with_invalid_pattern_returns_true() {
let prop = Property {
key: "email".to_string(),
value: json!("(unclosed"),
operator: "not_regex".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("email".to_string(), json!("test@example.com"));
assert!(match_property(&prop, &properties).unwrap());
}
#[test]
fn test_regex_with_various_invalid_patterns() {
let invalid_patterns = vec![
"(unclosed", "[unclosed", "*invalid", "(?P<bad", r"\", ];
for pattern in invalid_patterns {
let prop = Property {
key: "value".to_string(),
value: json!(pattern),
operator: "regex".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("value".to_string(), json!("test"));
assert!(
!match_property(&prop, &properties).unwrap(),
"Invalid pattern '{}' should return false for regex",
pattern
);
let not_regex_prop = Property {
operator: "not_regex".to_string(),
..prop
};
assert!(
match_property(¬_regex_prop, &properties).unwrap(),
"Invalid pattern '{}' should return true for not_regex",
pattern
);
}
}
#[test]
fn test_parse_semver_basic() {
assert_eq!(parse_semver("1.2.3"), Some((1, 2, 3)));
assert_eq!(parse_semver("0.0.0"), Some((0, 0, 0)));
assert_eq!(parse_semver("10.20.30"), Some((10, 20, 30)));
}
#[test]
fn test_parse_semver_v_prefix() {
assert_eq!(parse_semver("v1.2.3"), Some((1, 2, 3)));
assert_eq!(parse_semver("V1.2.3"), Some((1, 2, 3)));
}
#[test]
fn test_parse_semver_whitespace() {
assert_eq!(parse_semver(" 1.2.3 "), Some((1, 2, 3)));
assert_eq!(parse_semver(" v1.2.3 "), Some((1, 2, 3)));
}
#[test]
fn test_parse_semver_prerelease_stripped() {
assert_eq!(parse_semver("1.2.3-alpha"), Some((1, 2, 3)));
assert_eq!(parse_semver("1.2.3-beta.1"), Some((1, 2, 3)));
assert_eq!(parse_semver("1.2.3-rc.1+build.123"), Some((1, 2, 3)));
assert_eq!(parse_semver("1.2.3+build.456"), Some((1, 2, 3)));
}
#[test]
fn test_parse_semver_partial_versions() {
assert_eq!(parse_semver("1.2"), Some((1, 2, 0)));
assert_eq!(parse_semver("1"), Some((1, 0, 0)));
assert_eq!(parse_semver("v1.2"), Some((1, 2, 0)));
}
#[test]
fn test_parse_semver_extra_components_ignored() {
assert_eq!(parse_semver("1.2.3.4"), Some((1, 2, 3)));
assert_eq!(parse_semver("1.2.3.4.5.6"), Some((1, 2, 3)));
}
#[test]
fn test_parse_semver_leading_zeros() {
assert_eq!(parse_semver("01.02.03"), Some((1, 2, 3)));
assert_eq!(parse_semver("001.002.003"), Some((1, 2, 3)));
}
#[test]
fn test_parse_semver_invalid() {
assert_eq!(parse_semver(""), None);
assert_eq!(parse_semver(" "), None);
assert_eq!(parse_semver("v"), None);
assert_eq!(parse_semver(".1.2.3"), None);
assert_eq!(parse_semver("abc"), None);
assert_eq!(parse_semver("1.abc.3"), None);
assert_eq!(parse_semver("1.2.abc"), None);
assert_eq!(parse_semver("not-a-version"), None);
}
#[test]
fn test_semver_eq_basic() {
let prop = Property {
key: "version".to_string(),
value: json!("1.2.3"),
operator: "semver_eq".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("version".to_string(), json!("1.2.3"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.2.4"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.3.3"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("2.2.3"));
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_semver_eq_with_v_prefix() {
let prop = Property {
key: "version".to_string(),
value: json!("1.2.3"),
operator: "semver_eq".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("version".to_string(), json!("v1.2.3"));
assert!(match_property(&prop, &properties).unwrap());
let prop_with_v = Property {
value: json!("v1.2.3"),
..prop.clone()
};
properties.insert("version".to_string(), json!("1.2.3"));
assert!(match_property(&prop_with_v, &properties).unwrap());
}
#[test]
fn test_semver_eq_prerelease_stripped() {
let prop = Property {
key: "version".to_string(),
value: json!("1.2.3"),
operator: "semver_eq".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("version".to_string(), json!("1.2.3-alpha"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.2.3-beta.1"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.2.3+build.456"));
assert!(match_property(&prop, &properties).unwrap());
}
#[test]
fn test_semver_eq_partial_versions() {
let prop = Property {
key: "version".to_string(),
value: json!("1.2.0"),
operator: "semver_eq".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("version".to_string(), json!("1.2"));
assert!(match_property(&prop, &properties).unwrap());
let partial_prop = Property {
value: json!("1.2"),
..prop.clone()
};
properties.insert("version".to_string(), json!("1.2.0"));
assert!(match_property(&partial_prop, &properties).unwrap());
}
#[test]
fn test_semver_neq() {
let prop = Property {
key: "version".to_string(),
value: json!("1.2.3"),
operator: "semver_neq".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("version".to_string(), json!("1.2.3"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.2.4"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("2.0.0"));
assert!(match_property(&prop, &properties).unwrap());
}
#[test]
fn test_semver_gt() {
let prop = Property {
key: "version".to_string(),
value: json!("1.2.3"),
operator: "semver_gt".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("version".to_string(), json!("1.2.4"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.3.0"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("2.0.0"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.2.3"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.2.2"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.1.9"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("0.9.9"));
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_semver_gte() {
let prop = Property {
key: "version".to_string(),
value: json!("1.2.3"),
operator: "semver_gte".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("version".to_string(), json!("1.2.4"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("2.0.0"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.2.3"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.2.2"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("0.9.9"));
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_semver_lt() {
let prop = Property {
key: "version".to_string(),
value: json!("1.2.3"),
operator: "semver_lt".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("version".to_string(), json!("1.2.2"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.1.9"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("0.9.9"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.2.3"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.2.4"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("2.0.0"));
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_semver_lte() {
let prop = Property {
key: "version".to_string(),
value: json!("1.2.3"),
operator: "semver_lte".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("version".to_string(), json!("1.2.2"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("0.9.9"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.2.3"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.2.4"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("2.0.0"));
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_semver_tilde_basic() {
let prop = Property {
key: "version".to_string(),
value: json!("1.2.3"),
operator: "semver_tilde".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("version".to_string(), json!("1.2.3"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.2.4"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.2.99"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.3.0"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.3.1"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("2.0.0"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.2.2"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.1.9"));
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_semver_tilde_zero_versions() {
let prop = Property {
key: "version".to_string(),
value: json!("0.2.3"),
operator: "semver_tilde".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("version".to_string(), json!("0.2.3"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("0.2.9"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("0.3.0"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("0.2.2"));
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_semver_caret_major_nonzero() {
let prop = Property {
key: "version".to_string(),
value: json!("1.2.3"),
operator: "semver_caret".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("version".to_string(), json!("1.2.3"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.2.4"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.3.0"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.99.99"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("2.0.0"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("2.0.1"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.2.2"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("0.9.9"));
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_semver_caret_major_zero_minor_nonzero() {
let prop = Property {
key: "version".to_string(),
value: json!("0.2.3"),
operator: "semver_caret".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("version".to_string(), json!("0.2.3"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("0.2.4"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("0.2.99"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("0.3.0"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("0.3.1"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.0.0"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("0.2.2"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("0.1.9"));
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_semver_caret_major_zero_minor_zero() {
let prop = Property {
key: "version".to_string(),
value: json!("0.0.3"),
operator: "semver_caret".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("version".to_string(), json!("0.0.3"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("0.0.4"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("0.0.5"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("0.1.0"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("0.0.2"));
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_semver_wildcard_major() {
let prop = Property {
key: "version".to_string(),
value: json!("1.*"),
operator: "semver_wildcard".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("version".to_string(), json!("1.0.0"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.2.3"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.99.99"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("2.0.0"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("2.0.1"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("0.9.9"));
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_semver_wildcard_minor() {
let prop = Property {
key: "version".to_string(),
value: json!("1.2.*"),
operator: "semver_wildcard".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("version".to_string(), json!("1.2.0"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.2.3"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.2.99"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.3.0"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.3.1"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("2.0.0"));
assert!(!match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.1.9"));
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_semver_wildcard_zero() {
let prop = Property {
key: "version".to_string(),
value: json!("0.*"),
operator: "semver_wildcard".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("version".to_string(), json!("0.0.0"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("0.99.99"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.0.0"));
assert!(!match_property(&prop, &properties).unwrap());
}
#[test]
fn test_semver_invalid_property_value() {
let prop = Property {
key: "version".to_string(),
value: json!("1.2.3"),
operator: "semver_eq".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("version".to_string(), json!("not-a-version"));
assert!(match_property(&prop, &properties).is_err());
properties.insert("version".to_string(), json!(""));
assert!(match_property(&prop, &properties).is_err());
properties.insert("version".to_string(), json!(".1.2.3"));
assert!(match_property(&prop, &properties).is_err());
properties.insert("version".to_string(), json!("abc.def.ghi"));
assert!(match_property(&prop, &properties).is_err());
}
#[test]
fn test_semver_invalid_target_value() {
let mut properties = HashMap::new();
properties.insert("version".to_string(), json!("1.2.3"));
let prop = Property {
key: "version".to_string(),
value: json!("not-valid"),
operator: "semver_eq".to_string(),
property_type: None,
};
assert!(match_property(&prop, &properties).is_err());
let prop = Property {
key: "version".to_string(),
value: json!(""),
operator: "semver_gt".to_string(),
property_type: None,
};
assert!(match_property(&prop, &properties).is_err());
}
#[test]
fn test_semver_invalid_wildcard_pattern() {
let mut properties = HashMap::new();
properties.insert("version".to_string(), json!("1.2.3"));
let invalid_patterns = vec![
"*", "*.2.3", "1.*.3", "1.2.3.*", "abc.*", ];
for pattern in invalid_patterns {
let prop = Property {
key: "version".to_string(),
value: json!(pattern),
operator: "semver_wildcard".to_string(),
property_type: None,
};
assert!(
match_property(&prop, &properties).is_err(),
"Pattern '{}' should be invalid",
pattern
);
}
}
#[test]
fn test_semver_missing_property() {
let prop = Property {
key: "version".to_string(),
value: json!("1.2.3"),
operator: "semver_eq".to_string(),
property_type: None,
};
let properties = HashMap::new(); assert!(match_property(&prop, &properties).is_err());
}
#[test]
fn test_semver_null_property_value() {
let prop = Property {
key: "version".to_string(),
value: json!("1.2.3"),
operator: "semver_eq".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("version".to_string(), json!(null));
assert!(match_property(&prop, &properties).is_err());
}
#[test]
fn test_semver_numeric_property_value() {
let prop = Property {
key: "version".to_string(),
value: json!("1.0.0"),
operator: "semver_eq".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("version".to_string(), json!(1));
assert!(match_property(&prop, &properties).unwrap());
}
#[test]
fn test_semver_four_part_versions() {
let prop = Property {
key: "version".to_string(),
value: json!("1.2.3.4"),
operator: "semver_eq".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("version".to_string(), json!("1.2.3"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.2.3.4"));
assert!(match_property(&prop, &properties).unwrap());
properties.insert("version".to_string(), json!("1.2.3.999"));
assert!(match_property(&prop, &properties).unwrap());
}
#[test]
fn test_semver_large_version_numbers() {
let prop = Property {
key: "version".to_string(),
value: json!("1000.2000.3000"),
operator: "semver_eq".to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("version".to_string(), json!("1000.2000.3000"));
assert!(match_property(&prop, &properties).unwrap());
}
#[test]
fn test_semver_comparison_ordering() {
let cases = vec![
("0.0.1", "0.0.2", "semver_lt", true),
("0.1.0", "0.0.99", "semver_gt", true),
("1.0.0", "0.99.99", "semver_gt", true),
("1.0.0", "1.0.0", "semver_eq", true),
("2.0.0", "10.0.0", "semver_lt", true), ("9.0.0", "10.0.0", "semver_lt", true), ("1.9.0", "1.10.0", "semver_lt", true), ("1.2.9", "1.2.10", "semver_lt", true), ];
for (prop_val, target_val, op, expected) in cases {
let prop = Property {
key: "version".to_string(),
value: json!(target_val),
operator: op.to_string(),
property_type: None,
};
let mut properties = HashMap::new();
properties.insert("version".to_string(), json!(prop_val));
assert_eq!(
match_property(&prop, &properties).unwrap(),
expected,
"{} {} {} should be {}",
prop_val,
op,
target_val,
expected
);
}
}
}