1use chrono::{DateTime, NaiveDate, Utc};
2use regex::Regex;
3use serde::{Deserialize, Serialize};
4use sha1::{Digest, Sha1};
5use std::collections::HashMap;
6use std::fmt;
7use std::sync::{Mutex, OnceLock};
8
9static REGEX_CACHE: OnceLock<Mutex<HashMap<String, Option<Regex>>>> = OnceLock::new();
11
12const ROLLOUT_HASH_SALT: &str = "";
16
17const VARIANT_HASH_SALT: &str = "variant";
20
21fn get_cached_regex(pattern: &str) -> Option<Regex> {
22 let cache = REGEX_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
23 let mut cache_guard = match cache.lock() {
24 Ok(guard) => guard,
25 Err(_) => {
26 tracing::warn!(
27 pattern,
28 "Regex cache mutex poisoned, treating as cache miss"
29 );
30 return None;
31 }
32 };
33
34 if let Some(cached) = cache_guard.get(pattern) {
35 return cached.clone();
36 }
37
38 let compiled = Regex::new(pattern).ok();
39 cache_guard.insert(pattern.to_string(), compiled.clone());
40 compiled
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48#[serde(untagged)]
49pub enum FlagValue {
50 Boolean(bool),
52 String(String),
54}
55
56#[derive(Debug)]
64pub struct InconclusiveMatchError {
65 pub message: String,
67}
68
69impl InconclusiveMatchError {
70 pub fn new(message: &str) -> Self {
71 Self {
72 message: message.to_string(),
73 }
74 }
75}
76
77impl fmt::Display for InconclusiveMatchError {
78 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79 write!(f, "{}", self.message)
80 }
81}
82
83impl std::error::Error for InconclusiveMatchError {}
84
85impl Default for FlagValue {
86 fn default() -> Self {
87 FlagValue::Boolean(false)
88 }
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct FeatureFlag {
97 pub key: String,
99 pub active: bool,
101 #[serde(default)]
103 pub filters: FeatureFlagFilters,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, Default)]
108pub struct FeatureFlagFilters {
109 #[serde(default)]
111 pub groups: Vec<FeatureFlagCondition>,
112 #[serde(default)]
114 pub multivariate: Option<MultivariateFilter>,
115 #[serde(default)]
117 pub payloads: HashMap<String, serde_json::Value>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct FeatureFlagCondition {
126 #[serde(default)]
128 pub properties: Vec<Property>,
129 pub rollout_percentage: Option<f64>,
131 pub variant: Option<String>,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct Property {
140 pub key: String,
142 pub value: serde_json::Value,
144 #[serde(default = "default_operator")]
148 pub operator: String,
149 #[serde(rename = "type")]
151 pub property_type: Option<String>,
152}
153
154fn default_operator() -> String {
155 "exact".to_string()
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct CohortDefinition {
161 pub id: String,
162 #[serde(default)]
166 pub properties: serde_json::Value,
167}
168
169impl CohortDefinition {
170 pub fn new(id: String, properties: Vec<Property>) -> Self {
172 Self {
173 id,
174 properties: serde_json::to_value(properties).unwrap_or_default(),
175 }
176 }
177
178 pub fn parse_properties(&self) -> Vec<Property> {
182 if let Some(arr) = self.properties.as_array() {
184 return arr
185 .iter()
186 .filter_map(|v| serde_json::from_value::<Property>(v.clone()).ok())
187 .collect();
188 }
189
190 if let Some(obj) = self.properties.as_object() {
192 if let Some(values) = obj.get("values") {
193 if let Some(values_arr) = values.as_array() {
194 return values_arr
195 .iter()
196 .filter_map(|v| {
197 if v.get("type").and_then(|t| t.as_str()) == Some("property") {
199 serde_json::from_value::<Property>(v.clone()).ok()
200 } else if let Some(inner_values) = v.get("values") {
201 inner_values.as_array().and_then(|arr| {
203 arr.iter()
204 .filter_map(|inner| {
205 serde_json::from_value::<Property>(inner.clone()).ok()
206 })
207 .next()
208 })
209 } else {
210 None
211 }
212 })
213 .collect();
214 }
215 }
216 }
217
218 Vec::new()
219 }
220}
221
222pub struct EvaluationContext<'a> {
224 pub cohorts: &'a HashMap<String, CohortDefinition>,
225 pub flags: &'a HashMap<String, FeatureFlag>,
226 pub distinct_id: &'a str,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize, Default)]
231pub struct MultivariateFilter {
232 pub variants: Vec<MultivariateVariant>,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct MultivariateVariant {
239 pub key: String,
241 pub rollout_percentage: f64,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
250#[serde(untagged)]
251pub enum FeatureFlagsResponse {
252 V2 {
254 flags: HashMap<String, FlagDetail>,
256 #[serde(rename = "errorsWhileComputingFlags")]
258 #[serde(default)]
259 errors_while_computing_flags: bool,
260 },
261 Legacy {
263 #[serde(rename = "featureFlags")]
265 feature_flags: HashMap<String, FlagValue>,
266 #[serde(rename = "featureFlagPayloads")]
268 #[serde(default)]
269 feature_flag_payloads: HashMap<String, serde_json::Value>,
270 #[serde(default)]
272 errors: Option<Vec<String>>,
273 },
274}
275
276impl FeatureFlagsResponse {
277 pub fn normalize(
279 self,
280 ) -> (
281 HashMap<String, FlagValue>,
282 HashMap<String, serde_json::Value>,
283 ) {
284 match self {
285 FeatureFlagsResponse::V2 { flags, .. } => {
286 let mut feature_flags = HashMap::new();
287 let mut payloads = HashMap::new();
288
289 for (key, detail) in flags {
290 if detail.enabled {
291 if let Some(variant) = detail.variant {
292 feature_flags.insert(key.clone(), FlagValue::String(variant));
293 } else {
294 feature_flags.insert(key.clone(), FlagValue::Boolean(true));
295 }
296 } else {
297 feature_flags.insert(key.clone(), FlagValue::Boolean(false));
298 }
299
300 if let Some(metadata) = detail.metadata {
301 if let Some(payload) = metadata.payload {
302 payloads.insert(key, payload);
303 }
304 }
305 }
306
307 (feature_flags, payloads)
308 }
309 FeatureFlagsResponse::Legacy {
310 feature_flags,
311 feature_flag_payloads,
312 ..
313 } => (feature_flags, feature_flag_payloads),
314 }
315 }
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize)]
323pub struct FlagDetail {
324 pub key: String,
326 pub enabled: bool,
328 pub variant: Option<String>,
330 #[serde(default)]
332 pub reason: Option<FlagReason>,
333 #[serde(default)]
335 pub metadata: Option<FlagMetadata>,
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct FlagReason {
341 pub code: String,
343 #[serde(default)]
345 pub condition_index: Option<usize>,
346 #[serde(default)]
348 pub description: Option<String>,
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct FlagMetadata {
354 pub id: u64,
356 pub version: u32,
358 pub description: Option<String>,
360 pub payload: Option<serde_json::Value>,
362}
363
364const LONG_SCALE: f64 = 0xFFFFFFFFFFFFFFFu64 as f64; pub fn hash_key(key: &str, distinct_id: &str, salt: &str) -> f64 {
372 let hash_key = format!("{key}.{distinct_id}{salt}");
373 let mut hasher = Sha1::new();
374 hasher.update(hash_key.as_bytes());
375 let result = hasher.finalize();
376 let hex_str = format!("{result:x}");
377 let hash_val = u64::from_str_radix(&hex_str[..15], 16).unwrap_or(0);
378 hash_val as f64 / LONG_SCALE
379}
380
381pub fn get_matching_variant(flag: &FeatureFlag, distinct_id: &str) -> Option<String> {
387 let hash_value = hash_key(&flag.key, distinct_id, VARIANT_HASH_SALT);
388 let variants = flag.filters.multivariate.as_ref()?.variants.as_slice();
389
390 let mut value_min = 0.0;
391 for variant in variants {
392 let value_max = value_min + variant.rollout_percentage / 100.0;
393 if hash_value >= value_min && hash_value < value_max {
394 return Some(variant.key.clone());
395 }
396 value_min = value_max;
397 }
398 None
399}
400
401#[must_use = "feature flag evaluation result should be used"]
402pub fn match_feature_flag(
403 flag: &FeatureFlag,
404 distinct_id: &str,
405 properties: &HashMap<String, serde_json::Value>,
406) -> Result<FlagValue, InconclusiveMatchError> {
407 if !flag.active {
408 return Ok(FlagValue::Boolean(false));
409 }
410
411 let conditions = &flag.filters.groups;
412
413 let mut sorted_conditions = conditions.clone();
415 sorted_conditions.sort_by_key(|c| if c.variant.is_some() { 0 } else { 1 });
416
417 let mut is_inconclusive = false;
418
419 for condition in sorted_conditions {
420 match is_condition_match(flag, distinct_id, &condition, properties) {
421 Ok(true) => {
422 if let Some(variant_override) = &condition.variant {
423 if let Some(ref multivariate) = flag.filters.multivariate {
425 let valid_variants: Vec<String> = multivariate
426 .variants
427 .iter()
428 .map(|v| v.key.clone())
429 .collect();
430
431 if valid_variants.contains(variant_override) {
432 return Ok(FlagValue::String(variant_override.clone()));
433 }
434 }
435 }
436
437 if let Some(variant) = get_matching_variant(flag, distinct_id) {
439 return Ok(FlagValue::String(variant));
440 }
441 return Ok(FlagValue::Boolean(true));
442 }
443 Ok(false) => continue,
444 Err(_) => {
445 is_inconclusive = true;
446 }
447 }
448 }
449
450 if is_inconclusive {
451 return Err(InconclusiveMatchError::new(
452 "Can't determine if feature flag is enabled or not with given properties",
453 ));
454 }
455
456 Ok(FlagValue::Boolean(false))
457}
458
459fn is_condition_match(
460 flag: &FeatureFlag,
461 distinct_id: &str,
462 condition: &FeatureFlagCondition,
463 properties: &HashMap<String, serde_json::Value>,
464) -> Result<bool, InconclusiveMatchError> {
465 for prop in &condition.properties {
467 if !match_property(prop, properties)? {
468 return Ok(false);
469 }
470 }
471
472 if let Some(rollout_percentage) = condition.rollout_percentage {
474 let hash_value = hash_key(&flag.key, distinct_id, ROLLOUT_HASH_SALT);
475 if hash_value > (rollout_percentage / 100.0) {
476 return Ok(false);
477 }
478 }
479
480 Ok(true)
481}
482
483#[must_use = "feature flag evaluation result should be used"]
486pub fn match_feature_flag_with_context(
487 flag: &FeatureFlag,
488 distinct_id: &str,
489 properties: &HashMap<String, serde_json::Value>,
490 ctx: &EvaluationContext,
491) -> Result<FlagValue, InconclusiveMatchError> {
492 if !flag.active {
493 return Ok(FlagValue::Boolean(false));
494 }
495
496 let conditions = &flag.filters.groups;
497
498 let mut sorted_conditions = conditions.clone();
500 sorted_conditions.sort_by_key(|c| if c.variant.is_some() { 0 } else { 1 });
501
502 let mut is_inconclusive = false;
503
504 for condition in sorted_conditions {
505 match is_condition_match_with_context(flag, distinct_id, &condition, properties, ctx) {
506 Ok(true) => {
507 if let Some(variant_override) = &condition.variant {
508 if let Some(ref multivariate) = flag.filters.multivariate {
510 let valid_variants: Vec<String> = multivariate
511 .variants
512 .iter()
513 .map(|v| v.key.clone())
514 .collect();
515
516 if valid_variants.contains(variant_override) {
517 return Ok(FlagValue::String(variant_override.clone()));
518 }
519 }
520 }
521
522 if let Some(variant) = get_matching_variant(flag, distinct_id) {
524 return Ok(FlagValue::String(variant));
525 }
526 return Ok(FlagValue::Boolean(true));
527 }
528 Ok(false) => continue,
529 Err(_) => {
530 is_inconclusive = true;
531 }
532 }
533 }
534
535 if is_inconclusive {
536 return Err(InconclusiveMatchError::new(
537 "Can't determine if feature flag is enabled or not with given properties",
538 ));
539 }
540
541 Ok(FlagValue::Boolean(false))
542}
543
544fn is_condition_match_with_context(
545 flag: &FeatureFlag,
546 distinct_id: &str,
547 condition: &FeatureFlagCondition,
548 properties: &HashMap<String, serde_json::Value>,
549 ctx: &EvaluationContext,
550) -> Result<bool, InconclusiveMatchError> {
551 for prop in &condition.properties {
553 if !match_property_with_context(prop, properties, ctx)? {
554 return Ok(false);
555 }
556 }
557
558 if let Some(rollout_percentage) = condition.rollout_percentage {
560 let hash_value = hash_key(&flag.key, distinct_id, ROLLOUT_HASH_SALT);
561 if hash_value > (rollout_percentage / 100.0) {
562 return Ok(false);
563 }
564 }
565
566 Ok(true)
567}
568
569pub fn match_property_with_context(
571 property: &Property,
572 properties: &HashMap<String, serde_json::Value>,
573 ctx: &EvaluationContext,
574) -> Result<bool, InconclusiveMatchError> {
575 if property.property_type.as_deref() == Some("cohort") {
577 return match_cohort_property(property, properties, ctx);
578 }
579
580 if property.key.starts_with("$feature/") {
582 return match_flag_dependency_property(property, ctx);
583 }
584
585 match_property(property, properties)
587}
588
589fn match_cohort_property(
591 property: &Property,
592 properties: &HashMap<String, serde_json::Value>,
593 ctx: &EvaluationContext,
594) -> Result<bool, InconclusiveMatchError> {
595 let cohort_id = property
596 .value
597 .as_str()
598 .ok_or_else(|| InconclusiveMatchError::new("Cohort ID must be a string"))?;
599
600 let cohort = ctx.cohorts.get(cohort_id).ok_or_else(|| {
601 InconclusiveMatchError::new(&format!("Cohort '{}' not found in local cache", cohort_id))
602 })?;
603
604 let cohort_properties = cohort.parse_properties();
606 let mut is_in_cohort = true;
607 for cohort_prop in &cohort_properties {
608 match match_property(cohort_prop, properties) {
609 Ok(true) => continue,
610 Ok(false) => {
611 is_in_cohort = false;
612 break;
613 }
614 Err(e) => {
615 return Err(InconclusiveMatchError::new(&format!(
617 "Cannot evaluate cohort '{}' property '{}': {}",
618 cohort_id, cohort_prop.key, e.message
619 )));
620 }
621 }
622 }
623
624 Ok(match property.operator.as_str() {
626 "in" => is_in_cohort,
627 "not_in" => !is_in_cohort,
628 op => {
629 return Err(InconclusiveMatchError::new(&format!(
630 "Unknown cohort operator: {}",
631 op
632 )));
633 }
634 })
635}
636
637fn match_flag_dependency_property(
639 property: &Property,
640 ctx: &EvaluationContext,
641) -> Result<bool, InconclusiveMatchError> {
642 let flag_key = property
644 .key
645 .strip_prefix("$feature/")
646 .ok_or_else(|| InconclusiveMatchError::new("Invalid flag dependency format"))?;
647
648 let flag = ctx.flags.get(flag_key).ok_or_else(|| {
649 InconclusiveMatchError::new(&format!("Flag '{}' not found in local cache", flag_key))
650 })?;
651
652 let empty_props = HashMap::new();
654 let flag_value = match_feature_flag(flag, ctx.distinct_id, &empty_props)?;
655
656 let expected = &property.value;
658
659 let matches = match (&flag_value, expected) {
660 (FlagValue::Boolean(b), serde_json::Value::Bool(expected_b)) => b == expected_b,
661 (FlagValue::String(s), serde_json::Value::String(expected_s)) => {
662 s.eq_ignore_ascii_case(expected_s)
663 }
664 (FlagValue::Boolean(true), serde_json::Value::String(s)) => {
665 s.is_empty() || s == "true"
668 }
669 (FlagValue::Boolean(false), serde_json::Value::String(s)) => s.is_empty() || s == "false",
670 (FlagValue::String(s), serde_json::Value::Bool(true)) => {
671 !s.is_empty()
673 }
674 (FlagValue::String(_), serde_json::Value::Bool(false)) => false,
675 _ => false,
676 };
677
678 Ok(match property.operator.as_str() {
680 "exact" => matches,
681 "is_not" => !matches,
682 op => {
683 return Err(InconclusiveMatchError::new(&format!(
684 "Unknown flag dependency operator: {}",
685 op
686 )));
687 }
688 })
689}
690
691fn parse_relative_date(value: &str) -> Option<DateTime<Utc>> {
694 let value = value.trim();
695 if value.len() < 3 || !value.starts_with('-') {
697 return None;
698 }
699
700 let (num_str, unit) = value[1..].split_at(value.len() - 2);
701 let num: i64 = num_str.parse().ok()?;
702
703 let duration = match unit {
704 "h" => chrono::Duration::hours(num),
705 "d" => chrono::Duration::days(num),
706 "w" => chrono::Duration::weeks(num),
707 "m" => chrono::Duration::days(num * 30), "y" => chrono::Duration::days(num * 365), _ => return None,
710 };
711
712 Some(Utc::now() - duration)
713}
714
715fn parse_date_value(value: &serde_json::Value) -> Option<DateTime<Utc>> {
717 let date_str = value.as_str()?;
718
719 if date_str.starts_with('-') && date_str.len() > 1 {
721 if let Some(dt) = parse_relative_date(date_str) {
722 return Some(dt);
723 }
724 }
725
726 if let Ok(dt) = DateTime::parse_from_rfc3339(date_str) {
728 return Some(dt.with_timezone(&Utc));
729 }
730
731 if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
733 return Some(
734 date.and_hms_opt(0, 0, 0)
735 .expect("midnight is always valid")
736 .and_utc(),
737 );
738 }
739
740 None
741}
742
743fn match_property(
744 property: &Property,
745 properties: &HashMap<String, serde_json::Value>,
746) -> Result<bool, InconclusiveMatchError> {
747 let value = match properties.get(&property.key) {
748 Some(v) => v,
749 None => {
750 if property.operator == "is_not_set" {
752 return Ok(true);
753 }
754 if property.operator == "is_set" {
756 return Ok(false);
757 }
758 return Err(InconclusiveMatchError::new(&format!(
760 "Property '{}' not found in provided properties",
761 property.key
762 )));
763 }
764 };
765
766 Ok(match property.operator.as_str() {
767 "exact" => {
768 if property.value.is_array() {
769 if let Some(arr) = property.value.as_array() {
770 for val in arr {
771 if compare_values(val, value) {
772 return Ok(true);
773 }
774 }
775 return Ok(false);
776 }
777 }
778 compare_values(&property.value, value)
779 }
780 "is_not" => {
781 if property.value.is_array() {
782 if let Some(arr) = property.value.as_array() {
783 for val in arr {
784 if compare_values(val, value) {
785 return Ok(false);
786 }
787 }
788 return Ok(true);
789 }
790 }
791 !compare_values(&property.value, value)
792 }
793 "is_set" => true, "is_not_set" => false, "icontains" => {
796 let prop_str = value_to_string(value);
797 let search_str = value_to_string(&property.value);
798 prop_str.to_lowercase().contains(&search_str.to_lowercase())
799 }
800 "not_icontains" => {
801 let prop_str = value_to_string(value);
802 let search_str = value_to_string(&property.value);
803 !prop_str.to_lowercase().contains(&search_str.to_lowercase())
804 }
805 "regex" => {
806 let prop_str = value_to_string(value);
807 let regex_str = value_to_string(&property.value);
808 get_cached_regex(®ex_str)
809 .map(|re| re.is_match(&prop_str))
810 .unwrap_or(false)
811 }
812 "not_regex" => {
813 let prop_str = value_to_string(value);
814 let regex_str = value_to_string(&property.value);
815 get_cached_regex(®ex_str)
816 .map(|re| !re.is_match(&prop_str))
817 .unwrap_or(true)
818 }
819 "gt" | "gte" | "lt" | "lte" => compare_numeric(&property.operator, &property.value, value),
820 "is_date_before" | "is_date_after" => {
821 let target_date = parse_date_value(&property.value).ok_or_else(|| {
822 InconclusiveMatchError::new(&format!(
823 "Unable to parse target date value: {:?}",
824 property.value
825 ))
826 })?;
827
828 let prop_date = parse_date_value(value).ok_or_else(|| {
829 InconclusiveMatchError::new(&format!(
830 "Unable to parse property date value for '{}': {:?}",
831 property.key, value
832 ))
833 })?;
834
835 if property.operator == "is_date_before" {
836 prop_date < target_date
837 } else {
838 prop_date > target_date
839 }
840 }
841 unknown => {
842 return Err(InconclusiveMatchError::new(&format!(
843 "Unknown operator: {}",
844 unknown
845 )));
846 }
847 })
848}
849
850fn compare_values(a: &serde_json::Value, b: &serde_json::Value) -> bool {
851 if let (Some(a_str), Some(b_str)) = (a.as_str(), b.as_str()) {
853 return a_str.eq_ignore_ascii_case(b_str);
854 }
855
856 a == b
858}
859
860fn value_to_string(value: &serde_json::Value) -> String {
861 match value {
862 serde_json::Value::String(s) => s.clone(),
863 serde_json::Value::Number(n) => n.to_string(),
864 serde_json::Value::Bool(b) => b.to_string(),
865 _ => value.to_string(),
866 }
867}
868
869fn compare_numeric(
870 operator: &str,
871 property_value: &serde_json::Value,
872 value: &serde_json::Value,
873) -> bool {
874 let prop_num = match property_value {
875 serde_json::Value::Number(n) => n.as_f64(),
876 serde_json::Value::String(s) => s.parse::<f64>().ok(),
877 _ => None,
878 };
879
880 let val_num = match value {
881 serde_json::Value::Number(n) => n.as_f64(),
882 serde_json::Value::String(s) => s.parse::<f64>().ok(),
883 _ => None,
884 };
885
886 if let (Some(prop), Some(val)) = (prop_num, val_num) {
887 match operator {
888 "gt" => val > prop,
889 "gte" => val >= prop,
890 "lt" => val < prop,
891 "lte" => val <= prop,
892 _ => false,
893 }
894 } else {
895 let prop_str = value_to_string(property_value);
897 let val_str = value_to_string(value);
898 match operator {
899 "gt" => val_str > prop_str,
900 "gte" => val_str >= prop_str,
901 "lt" => val_str < prop_str,
902 "lte" => val_str <= prop_str,
903 _ => false,
904 }
905 }
906}
907
908#[cfg(test)]
909mod tests {
910 use super::*;
911 use serde_json::json;
912
913 const TEST_SALT: &str = "test-salt";
915
916 #[test]
917 fn test_hash_key() {
918 let hash = hash_key("test-flag", "user-123", TEST_SALT);
919 assert!((0.0..=1.0).contains(&hash));
920
921 let hash2 = hash_key("test-flag", "user-123", TEST_SALT);
923 assert_eq!(hash, hash2);
924
925 let hash3 = hash_key("test-flag", "user-456", TEST_SALT);
927 assert_ne!(hash, hash3);
928 }
929
930 #[test]
931 fn test_simple_flag_match() {
932 let flag = FeatureFlag {
933 key: "test-flag".to_string(),
934 active: true,
935 filters: FeatureFlagFilters {
936 groups: vec![FeatureFlagCondition {
937 properties: vec![],
938 rollout_percentage: Some(100.0),
939 variant: None,
940 }],
941 multivariate: None,
942 payloads: HashMap::new(),
943 },
944 };
945
946 let properties = HashMap::new();
947 let result = match_feature_flag(&flag, "user-123", &properties).unwrap();
948 assert_eq!(result, FlagValue::Boolean(true));
949 }
950
951 #[test]
952 fn test_property_matching() {
953 let prop = Property {
954 key: "country".to_string(),
955 value: json!("US"),
956 operator: "exact".to_string(),
957 property_type: None,
958 };
959
960 let mut properties = HashMap::new();
961 properties.insert("country".to_string(), json!("US"));
962
963 assert!(match_property(&prop, &properties).unwrap());
964
965 properties.insert("country".to_string(), json!("UK"));
966 assert!(!match_property(&prop, &properties).unwrap());
967 }
968
969 #[test]
970 fn test_multivariate_variants() {
971 let flag = FeatureFlag {
972 key: "test-flag".to_string(),
973 active: true,
974 filters: FeatureFlagFilters {
975 groups: vec![FeatureFlagCondition {
976 properties: vec![],
977 rollout_percentage: Some(100.0),
978 variant: None,
979 }],
980 multivariate: Some(MultivariateFilter {
981 variants: vec![
982 MultivariateVariant {
983 key: "control".to_string(),
984 rollout_percentage: 50.0,
985 },
986 MultivariateVariant {
987 key: "test".to_string(),
988 rollout_percentage: 50.0,
989 },
990 ],
991 }),
992 payloads: HashMap::new(),
993 },
994 };
995
996 let properties = HashMap::new();
997 let result = match_feature_flag(&flag, "user-123", &properties).unwrap();
998
999 match result {
1000 FlagValue::String(variant) => {
1001 assert!(variant == "control" || variant == "test");
1002 }
1003 _ => panic!("Expected string variant"),
1004 }
1005 }
1006
1007 #[test]
1008 fn test_inactive_flag() {
1009 let flag = FeatureFlag {
1010 key: "inactive-flag".to_string(),
1011 active: false,
1012 filters: FeatureFlagFilters {
1013 groups: vec![FeatureFlagCondition {
1014 properties: vec![],
1015 rollout_percentage: Some(100.0),
1016 variant: None,
1017 }],
1018 multivariate: None,
1019 payloads: HashMap::new(),
1020 },
1021 };
1022
1023 let properties = HashMap::new();
1024 let result = match_feature_flag(&flag, "user-123", &properties).unwrap();
1025 assert_eq!(result, FlagValue::Boolean(false));
1026 }
1027
1028 #[test]
1029 fn test_rollout_percentage() {
1030 let flag = FeatureFlag {
1031 key: "rollout-flag".to_string(),
1032 active: true,
1033 filters: FeatureFlagFilters {
1034 groups: vec![FeatureFlagCondition {
1035 properties: vec![],
1036 rollout_percentage: Some(30.0), variant: None,
1038 }],
1039 multivariate: None,
1040 payloads: HashMap::new(),
1041 },
1042 };
1043
1044 let properties = HashMap::new();
1045
1046 let mut enabled_count = 0;
1048 for i in 0..1000 {
1049 let result = match_feature_flag(&flag, &format!("user-{}", i), &properties).unwrap();
1050 if result == FlagValue::Boolean(true) {
1051 enabled_count += 1;
1052 }
1053 }
1054
1055 assert!(enabled_count > 250 && enabled_count < 350);
1057 }
1058
1059 #[test]
1060 fn test_regex_operator() {
1061 let prop = Property {
1062 key: "email".to_string(),
1063 value: json!(".*@company\\.com$"),
1064 operator: "regex".to_string(),
1065 property_type: None,
1066 };
1067
1068 let mut properties = HashMap::new();
1069 properties.insert("email".to_string(), json!("user@company.com"));
1070 assert!(match_property(&prop, &properties).unwrap());
1071
1072 properties.insert("email".to_string(), json!("user@example.com"));
1073 assert!(!match_property(&prop, &properties).unwrap());
1074 }
1075
1076 #[test]
1077 fn test_icontains_operator() {
1078 let prop = Property {
1079 key: "name".to_string(),
1080 value: json!("ADMIN"),
1081 operator: "icontains".to_string(),
1082 property_type: None,
1083 };
1084
1085 let mut properties = HashMap::new();
1086 properties.insert("name".to_string(), json!("admin_user"));
1087 assert!(match_property(&prop, &properties).unwrap());
1088
1089 properties.insert("name".to_string(), json!("regular_user"));
1090 assert!(!match_property(&prop, &properties).unwrap());
1091 }
1092
1093 #[test]
1094 fn test_numeric_operators() {
1095 let prop_gt = Property {
1097 key: "age".to_string(),
1098 value: json!(18),
1099 operator: "gt".to_string(),
1100 property_type: None,
1101 };
1102
1103 let mut properties = HashMap::new();
1104 properties.insert("age".to_string(), json!(25));
1105 assert!(match_property(&prop_gt, &properties).unwrap());
1106
1107 properties.insert("age".to_string(), json!(15));
1108 assert!(!match_property(&prop_gt, &properties).unwrap());
1109
1110 let prop_lte = Property {
1112 key: "score".to_string(),
1113 value: json!(100),
1114 operator: "lte".to_string(),
1115 property_type: None,
1116 };
1117
1118 properties.insert("score".to_string(), json!(100));
1119 assert!(match_property(&prop_lte, &properties).unwrap());
1120
1121 properties.insert("score".to_string(), json!(101));
1122 assert!(!match_property(&prop_lte, &properties).unwrap());
1123 }
1124
1125 #[test]
1126 fn test_is_set_operator() {
1127 let prop = Property {
1128 key: "email".to_string(),
1129 value: json!(true),
1130 operator: "is_set".to_string(),
1131 property_type: None,
1132 };
1133
1134 let mut properties = HashMap::new();
1135 properties.insert("email".to_string(), json!("test@example.com"));
1136 assert!(match_property(&prop, &properties).unwrap());
1137
1138 properties.remove("email");
1139 assert!(!match_property(&prop, &properties).unwrap());
1140 }
1141
1142 #[test]
1143 fn test_is_not_set_operator() {
1144 let prop = Property {
1145 key: "phone".to_string(),
1146 value: json!(true),
1147 operator: "is_not_set".to_string(),
1148 property_type: None,
1149 };
1150
1151 let mut properties = HashMap::new();
1152 assert!(match_property(&prop, &properties).unwrap());
1153
1154 properties.insert("phone".to_string(), json!("+1234567890"));
1155 assert!(!match_property(&prop, &properties).unwrap());
1156 }
1157
1158 #[test]
1159 fn test_empty_groups() {
1160 let flag = FeatureFlag {
1161 key: "empty-groups".to_string(),
1162 active: true,
1163 filters: FeatureFlagFilters {
1164 groups: vec![],
1165 multivariate: None,
1166 payloads: HashMap::new(),
1167 },
1168 };
1169
1170 let properties = HashMap::new();
1171 let result = match_feature_flag(&flag, "user-123", &properties).unwrap();
1172 assert_eq!(result, FlagValue::Boolean(false));
1173 }
1174
1175 #[test]
1176 fn test_hash_scale_constant() {
1177 assert_eq!(LONG_SCALE, 0xFFFFFFFFFFFFFFFu64 as f64);
1179 assert_ne!(LONG_SCALE, 0xFFFFFFFFFFFFFFFFu64 as f64);
1180 }
1181
1182 #[test]
1185 fn test_unknown_operator_returns_inconclusive_error() {
1186 let prop = Property {
1187 key: "status".to_string(),
1188 value: json!("active"),
1189 operator: "unknown_operator".to_string(),
1190 property_type: None,
1191 };
1192
1193 let mut properties = HashMap::new();
1194 properties.insert("status".to_string(), json!("active"));
1195
1196 let result = match_property(&prop, &properties);
1197 assert!(result.is_err());
1198 let err = result.unwrap_err();
1199 assert!(err.message.contains("unknown_operator"));
1200 }
1201
1202 #[test]
1203 fn test_is_date_before_with_relative_date() {
1204 let prop = Property {
1205 key: "signup_date".to_string(),
1206 value: json!("-7d"), operator: "is_date_before".to_string(),
1208 property_type: None,
1209 };
1210
1211 let mut properties = HashMap::new();
1212 let ten_days_ago = chrono::Utc::now() - chrono::Duration::days(10);
1214 properties.insert(
1215 "signup_date".to_string(),
1216 json!(ten_days_ago.format("%Y-%m-%d").to_string()),
1217 );
1218 assert!(match_property(&prop, &properties).unwrap());
1219
1220 let three_days_ago = chrono::Utc::now() - chrono::Duration::days(3);
1222 properties.insert(
1223 "signup_date".to_string(),
1224 json!(three_days_ago.format("%Y-%m-%d").to_string()),
1225 );
1226 assert!(!match_property(&prop, &properties).unwrap());
1227 }
1228
1229 #[test]
1230 fn test_is_date_after_with_relative_date() {
1231 let prop = Property {
1232 key: "last_seen".to_string(),
1233 value: json!("-30d"), operator: "is_date_after".to_string(),
1235 property_type: None,
1236 };
1237
1238 let mut properties = HashMap::new();
1239 let ten_days_ago = chrono::Utc::now() - chrono::Duration::days(10);
1241 properties.insert(
1242 "last_seen".to_string(),
1243 json!(ten_days_ago.format("%Y-%m-%d").to_string()),
1244 );
1245 assert!(match_property(&prop, &properties).unwrap());
1246
1247 let sixty_days_ago = chrono::Utc::now() - chrono::Duration::days(60);
1249 properties.insert(
1250 "last_seen".to_string(),
1251 json!(sixty_days_ago.format("%Y-%m-%d").to_string()),
1252 );
1253 assert!(!match_property(&prop, &properties).unwrap());
1254 }
1255
1256 #[test]
1257 fn test_is_date_before_with_iso_date() {
1258 let prop = Property {
1259 key: "expiry_date".to_string(),
1260 value: json!("2024-06-15"),
1261 operator: "is_date_before".to_string(),
1262 property_type: None,
1263 };
1264
1265 let mut properties = HashMap::new();
1266 properties.insert("expiry_date".to_string(), json!("2024-06-10"));
1267 assert!(match_property(&prop, &properties).unwrap());
1268
1269 properties.insert("expiry_date".to_string(), json!("2024-06-20"));
1270 assert!(!match_property(&prop, &properties).unwrap());
1271 }
1272
1273 #[test]
1274 fn test_is_date_after_with_iso_date() {
1275 let prop = Property {
1276 key: "start_date".to_string(),
1277 value: json!("2024-01-01"),
1278 operator: "is_date_after".to_string(),
1279 property_type: None,
1280 };
1281
1282 let mut properties = HashMap::new();
1283 properties.insert("start_date".to_string(), json!("2024-03-15"));
1284 assert!(match_property(&prop, &properties).unwrap());
1285
1286 properties.insert("start_date".to_string(), json!("2023-12-01"));
1287 assert!(!match_property(&prop, &properties).unwrap());
1288 }
1289
1290 #[test]
1291 fn test_is_date_with_relative_hours() {
1292 let prop = Property {
1293 key: "last_active".to_string(),
1294 value: json!("-24h"), operator: "is_date_after".to_string(),
1296 property_type: None,
1297 };
1298
1299 let mut properties = HashMap::new();
1300 let twelve_hours_ago = chrono::Utc::now() - chrono::Duration::hours(12);
1302 properties.insert(
1303 "last_active".to_string(),
1304 json!(twelve_hours_ago.to_rfc3339()),
1305 );
1306 assert!(match_property(&prop, &properties).unwrap());
1307
1308 let forty_eight_hours_ago = chrono::Utc::now() - chrono::Duration::hours(48);
1310 properties.insert(
1311 "last_active".to_string(),
1312 json!(forty_eight_hours_ago.to_rfc3339()),
1313 );
1314 assert!(!match_property(&prop, &properties).unwrap());
1315 }
1316
1317 #[test]
1318 fn test_is_date_with_relative_weeks() {
1319 let prop = Property {
1320 key: "joined".to_string(),
1321 value: json!("-2w"), operator: "is_date_before".to_string(),
1323 property_type: None,
1324 };
1325
1326 let mut properties = HashMap::new();
1327 let three_weeks_ago = chrono::Utc::now() - chrono::Duration::weeks(3);
1329 properties.insert(
1330 "joined".to_string(),
1331 json!(three_weeks_ago.format("%Y-%m-%d").to_string()),
1332 );
1333 assert!(match_property(&prop, &properties).unwrap());
1334
1335 let one_week_ago = chrono::Utc::now() - chrono::Duration::weeks(1);
1337 properties.insert(
1338 "joined".to_string(),
1339 json!(one_week_ago.format("%Y-%m-%d").to_string()),
1340 );
1341 assert!(!match_property(&prop, &properties).unwrap());
1342 }
1343
1344 #[test]
1345 fn test_is_date_with_relative_months() {
1346 let prop = Property {
1347 key: "subscription_date".to_string(),
1348 value: json!("-3m"), operator: "is_date_after".to_string(),
1350 property_type: None,
1351 };
1352
1353 let mut properties = HashMap::new();
1354 let one_month_ago = chrono::Utc::now() - chrono::Duration::days(30);
1356 properties.insert(
1357 "subscription_date".to_string(),
1358 json!(one_month_ago.format("%Y-%m-%d").to_string()),
1359 );
1360 assert!(match_property(&prop, &properties).unwrap());
1361
1362 let six_months_ago = chrono::Utc::now() - chrono::Duration::days(180);
1364 properties.insert(
1365 "subscription_date".to_string(),
1366 json!(six_months_ago.format("%Y-%m-%d").to_string()),
1367 );
1368 assert!(!match_property(&prop, &properties).unwrap());
1369 }
1370
1371 #[test]
1372 fn test_is_date_with_relative_years() {
1373 let prop = Property {
1374 key: "created_at".to_string(),
1375 value: json!("-1y"), operator: "is_date_before".to_string(),
1377 property_type: None,
1378 };
1379
1380 let mut properties = HashMap::new();
1381 let two_years_ago = chrono::Utc::now() - chrono::Duration::days(730);
1383 properties.insert(
1384 "created_at".to_string(),
1385 json!(two_years_ago.format("%Y-%m-%d").to_string()),
1386 );
1387 assert!(match_property(&prop, &properties).unwrap());
1388
1389 let six_months_ago = chrono::Utc::now() - chrono::Duration::days(180);
1391 properties.insert(
1392 "created_at".to_string(),
1393 json!(six_months_ago.format("%Y-%m-%d").to_string()),
1394 );
1395 assert!(!match_property(&prop, &properties).unwrap());
1396 }
1397
1398 #[test]
1399 fn test_is_date_with_invalid_date_format() {
1400 let prop = Property {
1401 key: "date".to_string(),
1402 value: json!("-7d"),
1403 operator: "is_date_before".to_string(),
1404 property_type: None,
1405 };
1406
1407 let mut properties = HashMap::new();
1408 properties.insert("date".to_string(), json!("not-a-date"));
1409
1410 let result = match_property(&prop, &properties);
1412 assert!(result.is_err());
1413 }
1414
1415 #[test]
1416 fn test_is_date_with_iso_datetime() {
1417 let prop = Property {
1418 key: "event_time".to_string(),
1419 value: json!("2024-06-15T10:30:00Z"),
1420 operator: "is_date_before".to_string(),
1421 property_type: None,
1422 };
1423
1424 let mut properties = HashMap::new();
1425 properties.insert("event_time".to_string(), json!("2024-06-15T08:00:00Z"));
1426 assert!(match_property(&prop, &properties).unwrap());
1427
1428 properties.insert("event_time".to_string(), json!("2024-06-15T12:00:00Z"));
1429 assert!(!match_property(&prop, &properties).unwrap());
1430 }
1431
1432 #[test]
1435 fn test_cohort_membership_in() {
1436 let mut cohorts = HashMap::new();
1438 cohorts.insert(
1439 "cohort_1".to_string(),
1440 CohortDefinition::new(
1441 "cohort_1".to_string(),
1442 vec![Property {
1443 key: "country".to_string(),
1444 value: json!("US"),
1445 operator: "exact".to_string(),
1446 property_type: None,
1447 }],
1448 ),
1449 );
1450
1451 let prop = Property {
1453 key: "$cohort".to_string(),
1454 value: json!("cohort_1"),
1455 operator: "in".to_string(),
1456 property_type: Some("cohort".to_string()),
1457 };
1458
1459 let mut properties = HashMap::new();
1461 properties.insert("country".to_string(), json!("US"));
1462
1463 let ctx = EvaluationContext {
1464 cohorts: &cohorts,
1465 flags: &HashMap::new(),
1466 distinct_id: "user-123",
1467 };
1468 assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
1469
1470 properties.insert("country".to_string(), json!("UK"));
1472 assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
1473 }
1474
1475 #[test]
1476 fn test_cohort_membership_not_in() {
1477 let mut cohorts = HashMap::new();
1478 cohorts.insert(
1479 "cohort_blocked".to_string(),
1480 CohortDefinition::new(
1481 "cohort_blocked".to_string(),
1482 vec![Property {
1483 key: "status".to_string(),
1484 value: json!("blocked"),
1485 operator: "exact".to_string(),
1486 property_type: None,
1487 }],
1488 ),
1489 );
1490
1491 let prop = Property {
1492 key: "$cohort".to_string(),
1493 value: json!("cohort_blocked"),
1494 operator: "not_in".to_string(),
1495 property_type: Some("cohort".to_string()),
1496 };
1497
1498 let mut properties = HashMap::new();
1499 properties.insert("status".to_string(), json!("active"));
1500
1501 let ctx = EvaluationContext {
1502 cohorts: &cohorts,
1503 flags: &HashMap::new(),
1504 distinct_id: "user-123",
1505 };
1506 assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
1508
1509 properties.insert("status".to_string(), json!("blocked"));
1511 assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
1512 }
1513
1514 #[test]
1515 fn test_cohort_not_found_returns_inconclusive() {
1516 let cohorts = HashMap::new(); let prop = Property {
1519 key: "$cohort".to_string(),
1520 value: json!("nonexistent_cohort"),
1521 operator: "in".to_string(),
1522 property_type: Some("cohort".to_string()),
1523 };
1524
1525 let properties = HashMap::new();
1526 let ctx = EvaluationContext {
1527 cohorts: &cohorts,
1528 flags: &HashMap::new(),
1529 distinct_id: "user-123",
1530 };
1531
1532 let result = match_property_with_context(&prop, &properties, &ctx);
1533 assert!(result.is_err());
1534 assert!(result.unwrap_err().message.contains("Cohort"));
1535 }
1536
1537 #[test]
1540 fn test_flag_dependency_enabled() {
1541 let mut flags = HashMap::new();
1542 flags.insert(
1543 "prerequisite-flag".to_string(),
1544 FeatureFlag {
1545 key: "prerequisite-flag".to_string(),
1546 active: true,
1547 filters: FeatureFlagFilters {
1548 groups: vec![FeatureFlagCondition {
1549 properties: vec![],
1550 rollout_percentage: Some(100.0),
1551 variant: None,
1552 }],
1553 multivariate: None,
1554 payloads: HashMap::new(),
1555 },
1556 },
1557 );
1558
1559 let prop = Property {
1561 key: "$feature/prerequisite-flag".to_string(),
1562 value: json!(true),
1563 operator: "exact".to_string(),
1564 property_type: None,
1565 };
1566
1567 let properties = HashMap::new();
1568 let ctx = EvaluationContext {
1569 cohorts: &HashMap::new(),
1570 flags: &flags,
1571 distinct_id: "user-123",
1572 };
1573
1574 assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
1576 }
1577
1578 #[test]
1579 fn test_flag_dependency_disabled() {
1580 let mut flags = HashMap::new();
1581 flags.insert(
1582 "disabled-flag".to_string(),
1583 FeatureFlag {
1584 key: "disabled-flag".to_string(),
1585 active: false, filters: FeatureFlagFilters {
1587 groups: vec![],
1588 multivariate: None,
1589 payloads: HashMap::new(),
1590 },
1591 },
1592 );
1593
1594 let prop = Property {
1596 key: "$feature/disabled-flag".to_string(),
1597 value: json!(true),
1598 operator: "exact".to_string(),
1599 property_type: None,
1600 };
1601
1602 let properties = HashMap::new();
1603 let ctx = EvaluationContext {
1604 cohorts: &HashMap::new(),
1605 flags: &flags,
1606 distinct_id: "user-123",
1607 };
1608
1609 assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
1611 }
1612
1613 #[test]
1614 fn test_flag_dependency_variant_match() {
1615 let mut flags = HashMap::new();
1616 flags.insert(
1617 "ab-test-flag".to_string(),
1618 FeatureFlag {
1619 key: "ab-test-flag".to_string(),
1620 active: true,
1621 filters: FeatureFlagFilters {
1622 groups: vec![FeatureFlagCondition {
1623 properties: vec![],
1624 rollout_percentage: Some(100.0),
1625 variant: None,
1626 }],
1627 multivariate: Some(MultivariateFilter {
1628 variants: vec![
1629 MultivariateVariant {
1630 key: "control".to_string(),
1631 rollout_percentage: 50.0,
1632 },
1633 MultivariateVariant {
1634 key: "test".to_string(),
1635 rollout_percentage: 50.0,
1636 },
1637 ],
1638 }),
1639 payloads: HashMap::new(),
1640 },
1641 },
1642 );
1643
1644 let prop = Property {
1646 key: "$feature/ab-test-flag".to_string(),
1647 value: json!("control"),
1648 operator: "exact".to_string(),
1649 property_type: None,
1650 };
1651
1652 let properties = HashMap::new();
1653 let ctx = EvaluationContext {
1654 cohorts: &HashMap::new(),
1655 flags: &flags,
1656 distinct_id: "user-gets-control", };
1658
1659 let result = match_property_with_context(&prop, &properties, &ctx);
1661 assert!(result.is_ok());
1662 }
1663
1664 #[test]
1665 fn test_flag_dependency_not_found_returns_inconclusive() {
1666 let flags = HashMap::new(); let prop = Property {
1669 key: "$feature/nonexistent-flag".to_string(),
1670 value: json!(true),
1671 operator: "exact".to_string(),
1672 property_type: None,
1673 };
1674
1675 let properties = HashMap::new();
1676 let ctx = EvaluationContext {
1677 cohorts: &HashMap::new(),
1678 flags: &flags,
1679 distinct_id: "user-123",
1680 };
1681
1682 let result = match_property_with_context(&prop, &properties, &ctx);
1683 assert!(result.is_err());
1684 assert!(result.unwrap_err().message.contains("Flag"));
1685 }
1686
1687 #[test]
1690 fn test_parse_relative_date_edge_cases() {
1691 let prop = Property {
1693 key: "date".to_string(),
1694 value: json!("placeholder"),
1695 operator: "is_date_before".to_string(),
1696 property_type: None,
1697 };
1698
1699 let mut properties = HashMap::new();
1700 properties.insert("date".to_string(), json!("2024-01-01"));
1701
1702 let empty_prop = Property {
1704 value: json!(""),
1705 ..prop.clone()
1706 };
1707 assert!(match_property(&empty_prop, &properties).is_err());
1708
1709 let dash_prop = Property {
1711 value: json!("-"),
1712 ..prop.clone()
1713 };
1714 assert!(match_property(&dash_prop, &properties).is_err());
1715
1716 let no_unit_prop = Property {
1718 value: json!("-7"),
1719 ..prop.clone()
1720 };
1721 assert!(match_property(&no_unit_prop, &properties).is_err());
1722
1723 let no_number_prop = Property {
1725 value: json!("-d"),
1726 ..prop.clone()
1727 };
1728 assert!(match_property(&no_number_prop, &properties).is_err());
1729
1730 let invalid_unit_prop = Property {
1732 value: json!("-7x"),
1733 ..prop.clone()
1734 };
1735 assert!(match_property(&invalid_unit_prop, &properties).is_err());
1736 }
1737
1738 #[test]
1739 fn test_parse_relative_date_large_values() {
1740 let prop = Property {
1742 key: "created_at".to_string(),
1743 value: json!("-1000d"), operator: "is_date_before".to_string(),
1745 property_type: None,
1746 };
1747
1748 let mut properties = HashMap::new();
1749 let five_years_ago = chrono::Utc::now() - chrono::Duration::days(1825);
1751 properties.insert(
1752 "created_at".to_string(),
1753 json!(five_years_ago.format("%Y-%m-%d").to_string()),
1754 );
1755 assert!(match_property(&prop, &properties).unwrap());
1756 }
1757
1758 #[test]
1761 fn test_regex_with_invalid_pattern_returns_false() {
1762 let prop = Property {
1764 key: "email".to_string(),
1765 value: json!("(unclosed"),
1766 operator: "regex".to_string(),
1767 property_type: None,
1768 };
1769
1770 let mut properties = HashMap::new();
1771 properties.insert("email".to_string(), json!("test@example.com"));
1772
1773 assert!(!match_property(&prop, &properties).unwrap());
1775 }
1776
1777 #[test]
1778 fn test_not_regex_with_invalid_pattern_returns_true() {
1779 let prop = Property {
1781 key: "email".to_string(),
1782 value: json!("(unclosed"),
1783 operator: "not_regex".to_string(),
1784 property_type: None,
1785 };
1786
1787 let mut properties = HashMap::new();
1788 properties.insert("email".to_string(), json!("test@example.com"));
1789
1790 assert!(match_property(&prop, &properties).unwrap());
1792 }
1793
1794 #[test]
1795 fn test_regex_with_various_invalid_patterns() {
1796 let invalid_patterns = vec![
1797 "(unclosed", "[unclosed", "*invalid", "(?P<bad", r"\", ];
1803
1804 for pattern in invalid_patterns {
1805 let prop = Property {
1806 key: "value".to_string(),
1807 value: json!(pattern),
1808 operator: "regex".to_string(),
1809 property_type: None,
1810 };
1811
1812 let mut properties = HashMap::new();
1813 properties.insert("value".to_string(), json!("test"));
1814
1815 assert!(
1817 !match_property(&prop, &properties).unwrap(),
1818 "Invalid pattern '{}' should return false for regex",
1819 pattern
1820 );
1821
1822 let not_regex_prop = Property {
1824 operator: "not_regex".to_string(),
1825 ..prop
1826 };
1827 assert!(
1828 match_property(¬_regex_prop, &properties).unwrap(),
1829 "Invalid pattern '{}' should return true for not_regex",
1830 pattern
1831 );
1832 }
1833 }
1834}