1use chrono::{DateTime, NaiveDate, Utc};
2use regex::Regex;
3use serde::{Deserialize, Serialize};
4use sha1::{Digest, Sha1};
5use std::collections::{HashMap, HashSet};
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 {
72 Self {
73 message: message.to_string(),
74 }
75 }
76}
77
78impl fmt::Display for InconclusiveMatchError {
79 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80 write!(f, "{}", self.message)
81 }
82}
83
84impl std::error::Error for InconclusiveMatchError {}
85
86impl Default for FlagValue {
87 fn default() -> Self {
88 FlagValue::Boolean(false)
89 }
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct FeatureFlag {
98 pub key: String,
100 pub active: bool,
102 #[serde(default)]
104 pub filters: FeatureFlagFilters,
105 #[serde(default)]
110 pub has_experiment: Option<bool>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, Default)]
115pub struct FeatureFlagFilters {
116 #[serde(default)]
118 pub groups: Vec<FeatureFlagCondition>,
119 #[serde(default)]
121 pub multivariate: Option<MultivariateFilter>,
122 #[serde(default)]
124 pub payloads: HashMap<String, serde_json::Value>,
125 #[serde(default)]
128 pub aggregation_group_type_index: Option<i32>,
129 #[serde(default)]
135 pub early_exit: bool,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct FeatureFlagCondition {
144 #[serde(default)]
146 pub properties: Vec<Property>,
147 pub rollout_percentage: Option<f64>,
149 pub variant: Option<String>,
151 #[serde(default)]
155 pub aggregation_group_type_index: Option<i32>,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct Property {
163 pub key: String,
165 pub value: serde_json::Value,
167 #[serde(
174 default = "default_operator",
175 deserialize_with = "deserialize_operator"
176 )]
177 pub operator: String,
178 #[serde(rename = "type")]
181 pub property_type: Option<String>,
182}
183
184fn default_operator() -> String {
185 "exact".to_string()
186}
187
188fn deserialize_operator<'de, D>(deserializer: D) -> Result<String, D::Error>
189where
190 D: serde::Deserializer<'de>,
191{
192 Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_else(default_operator))
193}
194
195#[derive(Deserialize)]
196struct CohortProperty {
197 #[serde(flatten)]
198 property: Property,
199 #[serde(default)]
200 negation: bool,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct CohortDefinition {
206 pub id: String,
208 #[serde(default)]
212 pub properties: serde_json::Value,
213}
214
215impl CohortDefinition {
216 pub fn new(id: String, properties: Vec<Property>) -> Self {
223 Self {
224 id,
225 properties: serde_json::to_value(properties).unwrap_or_default(),
226 }
227 }
228
229 pub fn parse_properties(&self) -> Vec<Property> {
234 if let Some(arr) = self.properties.as_array() {
236 return arr
237 .iter()
238 .filter_map(|v| serde_json::from_value::<Property>(v.clone()).ok())
239 .collect();
240 }
241
242 if let Some(obj) = self.properties.as_object() {
244 if let Some(values) = obj.get("values") {
245 if let Some(values_arr) = values.as_array() {
246 return values_arr
247 .iter()
248 .filter_map(|v| {
249 if v.get("type").and_then(|t| t.as_str()) == Some("property") {
251 serde_json::from_value::<Property>(v.clone()).ok()
252 } else if let Some(inner_values) = v.get("values") {
253 inner_values.as_array().and_then(|arr| {
255 arr.iter()
256 .filter_map(|inner| {
257 serde_json::from_value::<Property>(inner.clone()).ok()
258 })
259 .next()
260 })
261 } else {
262 None
263 }
264 })
265 .collect();
266 }
267 }
268 }
269
270 Vec::new()
271 }
272}
273
274pub struct EvaluationContext<'a> {
282 pub cohorts: &'a HashMap<String, CohortDefinition>,
284 pub flags: &'a HashMap<String, FeatureFlag>,
287 pub distinct_id: &'a str,
289 pub groups: &'a HashMap<String, String>,
291 pub group_properties: &'a HashMap<String, HashMap<String, serde_json::Value>>,
294 pub group_type_mapping: &'a HashMap<String, String>,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize, Default)]
300pub struct MultivariateFilter {
301 pub variants: Vec<MultivariateVariant>,
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct MultivariateVariant {
308 pub key: String,
310 pub rollout_percentage: f64,
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
319#[serde(untagged)]
320pub enum FeatureFlagsResponse {
321 V2 {
323 flags: HashMap<String, FlagDetail>,
325 #[serde(rename = "errorsWhileComputingFlags")]
327 #[serde(default)]
328 errors_while_computing_flags: bool,
329 #[serde(rename = "quotaLimited")]
332 #[serde(default)]
333 quota_limited: bool,
334 #[serde(rename = "requestId")]
338 #[serde(default)]
339 request_id: Option<String>,
340 #[serde(rename = "minimalFlagCalledEvents")]
344 #[serde(default)]
345 minimal_flag_called_events: bool,
346 },
347 Legacy {
349 #[serde(rename = "featureFlags")]
351 feature_flags: HashMap<String, FlagValue>,
352 #[serde(rename = "featureFlagPayloads")]
354 #[serde(default)]
355 feature_flag_payloads: HashMap<String, serde_json::Value>,
356 #[serde(default)]
358 errors: Option<Vec<String>>,
359 },
360}
361
362impl FeatureFlagsResponse {
363 pub fn normalize(
370 self,
371 ) -> (
372 HashMap<String, FlagValue>,
373 HashMap<String, serde_json::Value>,
374 ) {
375 match self {
376 FeatureFlagsResponse::V2 { flags, .. } => {
377 let mut feature_flags = HashMap::new();
378 let mut payloads = HashMap::new();
379
380 for (key, detail) in flags {
381 if detail.enabled {
382 if let Some(variant) = detail.variant {
383 feature_flags.insert(key.clone(), FlagValue::String(variant));
384 } else {
385 feature_flags.insert(key.clone(), FlagValue::Boolean(true));
386 }
387 } else {
388 feature_flags.insert(key.clone(), FlagValue::Boolean(false));
389 }
390
391 if let Some(metadata) = detail.metadata {
392 if let Some(payload) = metadata.payload {
393 payloads.insert(key, payload);
394 }
395 }
396 }
397
398 (feature_flags, payloads)
399 }
400 FeatureFlagsResponse::Legacy {
401 feature_flags,
402 feature_flag_payloads,
403 ..
404 } => (feature_flags, feature_flag_payloads),
405 }
406 }
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize)]
414pub struct FlagDetail {
415 pub key: String,
417 pub enabled: bool,
419 pub variant: Option<String>,
421 #[serde(default)]
423 pub reason: Option<FlagReason>,
424 #[serde(default)]
426 pub metadata: Option<FlagMetadata>,
427}
428
429#[derive(Debug, Clone, Serialize, Deserialize)]
431pub struct FlagReason {
432 pub code: String,
434 #[serde(default)]
436 pub condition_index: Option<usize>,
437 #[serde(default)]
439 pub description: Option<String>,
440}
441
442#[derive(Debug, Clone, Serialize, Deserialize)]
444pub struct FlagMetadata {
445 pub id: u64,
447 pub version: u32,
449 pub description: Option<String>,
451 pub payload: Option<serde_json::Value>,
453 #[serde(default)]
458 pub has_experiment: Option<bool>,
459}
460
461const LONG_SCALE: f64 = 0xFFFFFFFFFFFFFFFu64 as f64; pub fn hash_key(key: &str, distinct_id: &str, salt: &str) -> f64 {
469 let hash_key = format!("{key}.{distinct_id}{salt}");
470 let mut hasher = Sha1::new();
471 hasher.update(hash_key.as_bytes());
472 let result = hasher.finalize();
473 let hex_str = format!("{result:x}");
474 let hash_val = u64::from_str_radix(&hex_str[..15], 16).unwrap_or(0);
475 hash_val as f64 / LONG_SCALE
476}
477
478pub fn get_matching_variant(flag: &FeatureFlag, distinct_id: &str) -> Option<String> {
484 let hash_value = hash_key(&flag.key, distinct_id, VARIANT_HASH_SALT);
485 let variants = flag.filters.multivariate.as_ref()?.variants.as_slice();
486
487 let mut value_min = 0.0;
488 for variant in variants {
489 let value_max = value_min + variant.rollout_percentage / 100.0;
490 if hash_value >= value_min && hash_value < value_max {
491 return Some(variant.key.clone());
492 }
493 value_min = value_max;
494 }
495 None
496}
497
498enum ConditionTarget<'a> {
500 Use {
502 bucketing: String,
503 properties: &'a HashMap<String, serde_json::Value>,
504 },
505 Skip,
507 Inconclusive,
510}
511
512fn resolve_condition_target<'a>(
518 condition: &FeatureFlagCondition,
519 flag_aggregation: Option<i32>,
520 distinct_id: &str,
521 person_properties: &'a HashMap<String, serde_json::Value>,
522 groups: &HashMap<String, String>,
523 group_properties: &'a HashMap<String, HashMap<String, serde_json::Value>>,
524 group_type_mapping: &HashMap<String, String>,
525) -> ConditionTarget<'a> {
526 let effective_aggregation = condition.aggregation_group_type_index.or(flag_aggregation);
529
530 match effective_aggregation {
531 None => ConditionTarget::Use {
532 bucketing: distinct_id.to_string(),
533 properties: person_properties,
534 },
535 Some(idx) => {
536 let key = idx.to_string();
537 let Some(group_type) = group_type_mapping.get(&key) else {
538 return ConditionTarget::Skip;
539 };
540 let Some(group_key) = groups.get(group_type) else {
541 return ConditionTarget::Skip;
542 };
543 let Some(props) = group_properties.get(group_type) else {
544 return ConditionTarget::Inconclusive;
545 };
546 ConditionTarget::Use {
547 bucketing: group_key.clone(),
548 properties: props,
549 }
550 }
551 }
552}
553
554#[must_use = "feature flag evaluation result should be used"]
578#[allow(clippy::too_many_arguments)]
579pub fn match_feature_flag(
580 flag: &FeatureFlag,
581 distinct_id: &str,
582 person_properties: &HashMap<String, serde_json::Value>,
583 groups: &HashMap<String, String>,
584 group_properties: &HashMap<String, HashMap<String, serde_json::Value>>,
585 group_type_mapping: &HashMap<String, String>,
586) -> Result<FlagValue, InconclusiveMatchError> {
587 if !flag.active {
588 return Ok(FlagValue::Boolean(false));
589 }
590
591 let conditions = &flag.filters.groups;
592 let flag_aggregation = flag.filters.aggregation_group_type_index;
593
594 let mut sorted_conditions = conditions.clone();
596 sorted_conditions.sort_by_key(|c| if c.variant.is_some() { 0 } else { 1 });
597
598 let mut is_inconclusive = false;
599
600 for condition in sorted_conditions {
601 let (effective_bucketing, effective_properties) = match resolve_condition_target(
602 &condition,
603 flag_aggregation,
604 distinct_id,
605 person_properties,
606 groups,
607 group_properties,
608 group_type_mapping,
609 ) {
610 ConditionTarget::Use {
611 bucketing,
612 properties,
613 } => (bucketing, properties),
614 ConditionTarget::Skip => continue,
615 ConditionTarget::Inconclusive => {
616 is_inconclusive = true;
617 continue;
618 }
619 };
620
621 match is_condition_match(flag, &effective_bucketing, &condition, effective_properties) {
622 Ok(ConditionMatch::Match) => {
623 if let Some(variant_override) = &condition.variant {
624 if let Some(ref multivariate) = flag.filters.multivariate {
626 let valid_variants: Vec<String> = multivariate
627 .variants
628 .iter()
629 .map(|v| v.key.clone())
630 .collect();
631
632 if valid_variants.contains(variant_override) {
633 return Ok(FlagValue::String(variant_override.clone()));
634 }
635 }
636 }
637
638 if let Some(variant) = get_matching_variant(flag, &effective_bucketing) {
640 return Ok(FlagValue::String(variant));
641 }
642 return Ok(FlagValue::Boolean(true));
643 }
644 Ok(ConditionMatch::OutOfRolloutBound) => {
645 if flag.filters.early_exit && !is_inconclusive {
652 return Ok(FlagValue::Boolean(false));
653 }
654 }
655 Ok(ConditionMatch::NoMatch) => continue,
656 Err(_) => {
657 is_inconclusive = true;
658 }
659 }
660 }
661
662 if is_inconclusive {
663 return Err(InconclusiveMatchError::new(
664 "Can't determine if feature flag is enabled or not with given properties",
665 ));
666 }
667
668 Ok(FlagValue::Boolean(false))
669}
670
671#[derive(Debug, Clone, Copy, PartialEq, Eq)]
676enum ConditionMatch {
677 Match,
680 NoMatch,
682 OutOfRolloutBound,
685}
686
687fn is_condition_match(
688 flag: &FeatureFlag,
689 bucketing_id: &str,
690 condition: &FeatureFlagCondition,
691 properties: &HashMap<String, serde_json::Value>,
692) -> Result<ConditionMatch, InconclusiveMatchError> {
693 for prop in &condition.properties {
695 if !match_property(prop, properties)? {
696 return Ok(ConditionMatch::NoMatch);
697 }
698 }
699
700 if let Some(rollout_percentage) = condition.rollout_percentage {
702 let hash_value = hash_key(&flag.key, bucketing_id, ROLLOUT_HASH_SALT);
703 if hash_value > (rollout_percentage / 100.0) {
704 return Ok(ConditionMatch::OutOfRolloutBound);
705 }
706 }
707
708 Ok(ConditionMatch::Match)
709}
710
711#[must_use = "feature flag evaluation result should be used"]
723pub fn match_feature_flag_with_context(
724 flag: &FeatureFlag,
725 person_properties: &HashMap<String, serde_json::Value>,
726 ctx: &EvaluationContext,
727) -> Result<FlagValue, InconclusiveMatchError> {
728 if !flag.active {
729 return Ok(FlagValue::Boolean(false));
730 }
731
732 let conditions = &flag.filters.groups;
733 let flag_aggregation = flag.filters.aggregation_group_type_index;
734
735 let mut sorted_conditions = conditions.clone();
737 sorted_conditions.sort_by_key(|c| if c.variant.is_some() { 0 } else { 1 });
738
739 let mut is_inconclusive = false;
740
741 for condition in sorted_conditions {
742 let (effective_bucketing, effective_properties) = match resolve_condition_target(
743 &condition,
744 flag_aggregation,
745 ctx.distinct_id,
746 person_properties,
747 ctx.groups,
748 ctx.group_properties,
749 ctx.group_type_mapping,
750 ) {
751 ConditionTarget::Use {
752 bucketing,
753 properties,
754 } => (bucketing, properties),
755 ConditionTarget::Skip => continue,
756 ConditionTarget::Inconclusive => {
757 is_inconclusive = true;
758 continue;
759 }
760 };
761
762 match is_condition_match_with_context(
763 flag,
764 &effective_bucketing,
765 &condition,
766 effective_properties,
767 ctx,
768 ) {
769 Ok(ConditionMatch::Match) => {
770 if let Some(variant_override) = &condition.variant {
771 if let Some(ref multivariate) = flag.filters.multivariate {
773 let valid_variants: Vec<String> = multivariate
774 .variants
775 .iter()
776 .map(|v| v.key.clone())
777 .collect();
778
779 if valid_variants.contains(variant_override) {
780 return Ok(FlagValue::String(variant_override.clone()));
781 }
782 }
783 }
784
785 if let Some(variant) = get_matching_variant(flag, &effective_bucketing) {
787 return Ok(FlagValue::String(variant));
788 }
789 return Ok(FlagValue::Boolean(true));
790 }
791 Ok(ConditionMatch::OutOfRolloutBound) => {
792 if flag.filters.early_exit && !is_inconclusive {
799 return Ok(FlagValue::Boolean(false));
800 }
801 }
802 Ok(ConditionMatch::NoMatch) => continue,
803 Err(_) => {
804 is_inconclusive = true;
805 }
806 }
807 }
808
809 if is_inconclusive {
810 return Err(InconclusiveMatchError::new(
811 "Can't determine if feature flag is enabled or not with given properties",
812 ));
813 }
814
815 Ok(FlagValue::Boolean(false))
816}
817
818fn is_condition_match_with_context(
819 flag: &FeatureFlag,
820 bucketing_id: &str,
821 condition: &FeatureFlagCondition,
822 properties: &HashMap<String, serde_json::Value>,
823 ctx: &EvaluationContext,
824) -> Result<ConditionMatch, InconclusiveMatchError> {
825 for prop in &condition.properties {
827 if !match_property_with_context(prop, properties, ctx)? {
828 return Ok(ConditionMatch::NoMatch);
829 }
830 }
831
832 if let Some(rollout_percentage) = condition.rollout_percentage {
834 let hash_value = hash_key(&flag.key, bucketing_id, ROLLOUT_HASH_SALT);
835 if hash_value > (rollout_percentage / 100.0) {
836 return Ok(ConditionMatch::OutOfRolloutBound);
837 }
838 }
839
840 Ok(ConditionMatch::Match)
841}
842
843pub fn match_property_with_context(
854 property: &Property,
855 properties: &HashMap<String, serde_json::Value>,
856 ctx: &EvaluationContext,
857) -> Result<bool, InconclusiveMatchError> {
858 if property.property_type.as_deref() == Some("cohort") {
860 return match_cohort_property(property, properties, ctx);
861 }
862
863 if property.key.starts_with("$feature/") {
865 return match_flag_dependency_property(property, ctx);
866 }
867
868 match_property(property, properties)
870}
871
872fn match_cohort_property(
874 property: &Property,
875 properties: &HashMap<String, serde_json::Value>,
876 ctx: &EvaluationContext,
877) -> Result<bool, InconclusiveMatchError> {
878 let cohort_id = cohort_id_to_string(&property.value)
879 .ok_or_else(|| InconclusiveMatchError::new("Cohort ID must be a string or number"))?;
880
881 let mut active_cohorts = HashSet::new();
882 let is_in_cohort = match_cohort_by_id(&cohort_id, properties, ctx, &mut active_cohorts, 0)
883 .map_err(CohortMatchError::into_inconclusive)?;
884
885 Ok(match property.operator.as_str() {
887 "exact" | "in" => is_in_cohort,
888 "not_in" => !is_in_cohort,
889 op => {
890 return Err(InconclusiveMatchError::new(&format!(
891 "Unknown cohort operator: {}",
892 op
893 )));
894 }
895 })
896}
897
898fn cohort_id_to_string(value: &serde_json::Value) -> Option<String> {
902 match value {
903 serde_json::Value::String(s) => Some(s.clone()),
904 serde_json::Value::Number(n) => Some(n.to_string()),
905 _ => None,
906 }
907}
908
909#[derive(Debug)]
910enum CohortMatchError {
911 Inconclusive(InconclusiveMatchError),
912 InvalidDefinition(InconclusiveMatchError),
913 MissingCohort(InconclusiveMatchError),
914}
915
916impl CohortMatchError {
917 fn into_inconclusive(self) -> InconclusiveMatchError {
918 match self {
919 Self::Inconclusive(error)
920 | Self::InvalidDefinition(error)
921 | Self::MissingCohort(error) => error,
922 }
923 }
924
925 fn requires_server_evaluation(&self) -> bool {
926 !matches!(self, Self::Inconclusive(_))
927 }
928}
929
930const MAX_COHORT_RESOLUTION_DEPTH: usize = 100;
938
939fn match_cohort_by_id(
947 cohort_id: &str,
948 properties: &HashMap<String, serde_json::Value>,
949 ctx: &EvaluationContext,
950 active_cohorts: &mut HashSet<String>,
951 resolution_depth: usize,
952) -> Result<bool, CohortMatchError> {
953 let cohort = ctx.cohorts.get(cohort_id).ok_or_else(|| {
954 CohortMatchError::MissingCohort(InconclusiveMatchError::new(&format!(
955 "Cohort '{}' not found in local cache",
956 cohort_id
957 )))
958 })?;
959
960 if resolution_depth >= MAX_COHORT_RESOLUTION_DEPTH {
961 return Err(CohortMatchError::InvalidDefinition(
962 InconclusiveMatchError::new(&format!(
963 "Cohort '{}' is nested deeper than the limit of {}",
964 cohort_id, MAX_COHORT_RESOLUTION_DEPTH
965 )),
966 ));
967 }
968
969 if !active_cohorts.insert(cohort_id.to_string()) {
970 return Err(CohortMatchError::InvalidDefinition(
971 InconclusiveMatchError::new(&format!(
972 "Cohort '{}' is part of a reference cycle",
973 cohort_id
974 )),
975 ));
976 }
977
978 let result = match_property_group(
979 &cohort.properties,
980 properties,
981 ctx,
982 active_cohorts,
983 resolution_depth,
984 );
985 active_cohorts.remove(cohort_id);
986 result
987}
988
989fn match_property_group(
1000 group: &serde_json::Value,
1001 properties: &HashMap<String, serde_json::Value>,
1002 ctx: &EvaluationContext,
1003 active_cohorts: &mut HashSet<String>,
1004 resolution_depth: usize,
1005) -> Result<bool, CohortMatchError> {
1006 if resolution_depth >= MAX_COHORT_RESOLUTION_DEPTH {
1007 return Err(CohortMatchError::InvalidDefinition(
1008 InconclusiveMatchError::new(&format!(
1009 "Cohort property groups are nested deeper than the limit of {}",
1010 MAX_COHORT_RESOLUTION_DEPTH
1011 )),
1012 ));
1013 }
1014
1015 if let Some(arr) = group.as_array() {
1016 return match_property_group_values(
1017 "AND",
1018 arr,
1019 properties,
1020 ctx,
1021 active_cohorts,
1022 resolution_depth,
1023 );
1024 }
1025
1026 let Some(obj) = group.as_object() else {
1027 return Err(CohortMatchError::InvalidDefinition(
1028 InconclusiveMatchError::new("Cohort property group must be an object or array"),
1029 ));
1030 };
1031
1032 if obj.is_empty() {
1034 return Ok(true);
1035 }
1036
1037 let group_type = obj.get("type").and_then(|t| t.as_str()).unwrap_or("AND");
1038
1039 let Some(values) = obj.get("values").and_then(|v| v.as_array()) else {
1040 return Err(CohortMatchError::InvalidDefinition(
1041 InconclusiveMatchError::new("Cohort property group values must be an array"),
1042 ));
1043 };
1044
1045 match_property_group_values(
1046 group_type,
1047 values,
1048 properties,
1049 ctx,
1050 active_cohorts,
1051 resolution_depth,
1052 )
1053}
1054
1055fn match_property_group_values(
1063 group_type: &str,
1064 values: &[serde_json::Value],
1065 properties: &HashMap<String, serde_json::Value>,
1066 ctx: &EvaluationContext,
1067 active_cohorts: &mut HashSet<String>,
1068 resolution_depth: usize,
1069) -> Result<bool, CohortMatchError> {
1070 if values.is_empty() {
1071 return Ok(true);
1072 }
1073
1074 let is_and = !group_type.eq_ignore_ascii_case("OR");
1075 let mut decisive_result = None;
1076 let mut inconclusive: Option<CohortMatchError> = None;
1077
1078 for value in values {
1079 let result = if value.get("values").is_some() {
1080 match_property_group(value, properties, ctx, active_cohorts, resolution_depth + 1)
1082 } else if value.get("type").and_then(|t| t.as_str()) == Some("cohort") {
1083 match_nested_cohort(value, properties, ctx, active_cohorts, resolution_depth + 1)
1085 } else {
1086 match serde_json::from_value::<CohortProperty>(value.clone()) {
1088 Ok(prop) => match_property_with_context(&prop.property, properties, ctx)
1089 .map(|matches| matches != prop.negation)
1090 .map_err(CohortMatchError::Inconclusive),
1091 Err(e) => Err(CohortMatchError::InvalidDefinition(
1092 InconclusiveMatchError::new(&format!("Unable to parse cohort property: {}", e)),
1093 )),
1094 }
1095 };
1096
1097 match result {
1098 Ok(true) if !is_and => decisive_result = Some(true),
1099 Ok(false) if is_and => decisive_result = Some(false),
1100 Ok(_) => {}
1101 Err(error) if error.requires_server_evaluation() => return Err(error),
1102 Err(error) => inconclusive = Some(error),
1103 }
1104 }
1105
1106 if let Some(result) = decisive_result {
1107 return Ok(result);
1108 }
1109
1110 if let Some(error) = inconclusive {
1111 return Err(error);
1112 }
1113
1114 Ok(is_and)
1116}
1117
1118fn match_nested_cohort(
1121 value: &serde_json::Value,
1122 properties: &HashMap<String, serde_json::Value>,
1123 ctx: &EvaluationContext,
1124 active_cohorts: &mut HashSet<String>,
1125 resolution_depth: usize,
1126) -> Result<bool, CohortMatchError> {
1127 let cohort_id = value
1128 .get("value")
1129 .and_then(cohort_id_to_string)
1130 .ok_or_else(|| {
1131 CohortMatchError::InvalidDefinition(InconclusiveMatchError::new(
1132 "Nested cohort ID must be a string or number",
1133 ))
1134 })?;
1135
1136 let negation = value
1137 .get("negation")
1138 .and_then(|n| n.as_bool())
1139 .unwrap_or(false);
1140
1141 let is_member = match_cohort_by_id(
1142 &cohort_id,
1143 properties,
1144 ctx,
1145 active_cohorts,
1146 resolution_depth,
1147 )?;
1148 Ok(is_member != negation)
1149}
1150
1151fn match_flag_dependency_property(
1153 property: &Property,
1154 ctx: &EvaluationContext,
1155) -> Result<bool, InconclusiveMatchError> {
1156 let flag_key = property
1158 .key
1159 .strip_prefix("$feature/")
1160 .ok_or_else(|| InconclusiveMatchError::new("Invalid flag dependency format"))?;
1161
1162 let flag = ctx.flags.get(flag_key).ok_or_else(|| {
1163 InconclusiveMatchError::new(&format!("Flag '{}' not found in local cache", flag_key))
1164 })?;
1165
1166 let empty_props = HashMap::new();
1169 let flag_value = match_feature_flag(
1170 flag,
1171 ctx.distinct_id,
1172 &empty_props,
1173 ctx.groups,
1174 ctx.group_properties,
1175 ctx.group_type_mapping,
1176 )?;
1177
1178 let expected = &property.value;
1180
1181 let matches = match (&flag_value, expected) {
1182 (FlagValue::Boolean(b), serde_json::Value::Bool(expected_b)) => b == expected_b,
1183 (FlagValue::String(s), serde_json::Value::String(expected_s)) => {
1184 s.eq_ignore_ascii_case(expected_s)
1185 }
1186 (FlagValue::Boolean(true), serde_json::Value::String(s)) => {
1187 s.is_empty() || s == "true"
1190 }
1191 (FlagValue::Boolean(false), serde_json::Value::String(s)) => s.is_empty() || s == "false",
1192 (FlagValue::String(s), serde_json::Value::Bool(true)) => {
1193 !s.is_empty()
1195 }
1196 (FlagValue::String(_), serde_json::Value::Bool(false)) => false,
1197 _ => false,
1198 };
1199
1200 Ok(match property.operator.as_str() {
1202 "exact" => matches,
1203 "is_not" => !matches,
1204 op => {
1205 return Err(InconclusiveMatchError::new(&format!(
1206 "Unknown flag dependency operator: {}",
1207 op
1208 )));
1209 }
1210 })
1211}
1212
1213fn parse_relative_date(value: &str) -> Option<DateTime<Utc>> {
1216 let value = value.trim();
1217 if value.len() < 3 || !value.starts_with('-') {
1219 return None;
1220 }
1221
1222 let (num_str, unit) = value[1..].split_at(value.len() - 2);
1223 let num: i64 = num_str.parse().ok()?;
1224
1225 let duration = match unit {
1226 "h" => chrono::Duration::hours(num),
1227 "d" => chrono::Duration::days(num),
1228 "w" => chrono::Duration::weeks(num),
1229 "m" => chrono::Duration::days(num * 30), "y" => chrono::Duration::days(num * 365), _ => return None,
1232 };
1233
1234 Some(Utc::now() - duration)
1235}
1236
1237fn parse_date_value(value: &serde_json::Value) -> Option<DateTime<Utc>> {
1239 let date_str = value.as_str()?;
1240
1241 if date_str.starts_with('-') && date_str.len() > 1 {
1243 if let Some(dt) = parse_relative_date(date_str) {
1244 return Some(dt);
1245 }
1246 }
1247
1248 if let Ok(dt) = DateTime::parse_from_rfc3339(date_str) {
1250 return Some(dt.with_timezone(&Utc));
1251 }
1252
1253 if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
1255 return Some(
1256 date.and_hms_opt(0, 0, 0)
1257 .expect("midnight is always valid")
1258 .and_utc(),
1259 );
1260 }
1261
1262 None
1263}
1264
1265type SemverTuple = (u64, u64, u64);
1267
1268fn parse_semver(value: &str) -> Option<SemverTuple> {
1280 let value = value.trim();
1281 if value.is_empty() {
1282 return None;
1283 }
1284
1285 let value = value
1287 .strip_prefix('v')
1288 .or_else(|| value.strip_prefix('V'))
1289 .unwrap_or(value);
1290 if value.is_empty() {
1291 return None;
1292 }
1293
1294 let value = value.split(['-', '+']).next().unwrap_or(value);
1296 if value.is_empty() {
1297 return None;
1298 }
1299
1300 if value.starts_with('.') {
1302 return None;
1303 }
1304
1305 let parts: Vec<&str> = value.split('.').collect();
1307 if parts.is_empty() {
1308 return None;
1309 }
1310
1311 let major = parse_semver_numeric(parts.first()?)?;
1312 let minor = parts.get(1).map_or(Some(0), |s| parse_semver_numeric(s))?;
1313 let patch = parts.get(2).map_or(Some(0), |s| parse_semver_numeric(s))?;
1314
1315 Some((major, minor, patch))
1316}
1317
1318fn parse_semver_numeric(part: &str) -> Option<u64> {
1323 if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
1324 return None;
1325 }
1326 if part.len() > 1 && part.starts_with('0') {
1327 return None;
1328 }
1329 part.parse().ok()
1330}
1331
1332fn parse_semver_wildcard(pattern: &str) -> Option<(SemverTuple, SemverTuple)> {
1335 let pattern = pattern.trim();
1336 if pattern.is_empty() {
1337 return None;
1338 }
1339
1340 let pattern = pattern
1342 .strip_prefix('v')
1343 .or_else(|| pattern.strip_prefix('V'))
1344 .unwrap_or(pattern);
1345 if pattern.is_empty() {
1346 return None;
1347 }
1348
1349 let parts: Vec<&str> = pattern.split('.').collect();
1350
1351 match parts.as_slice() {
1352 [major_str, "*"] => {
1354 let major = parse_semver_numeric(major_str)?;
1355 Some(((major, 0, 0), (major + 1, 0, 0)))
1356 }
1357 [major_str, minor_str, "*"] => {
1359 let major = parse_semver_numeric(major_str)?;
1360 let minor = parse_semver_numeric(minor_str)?;
1361 Some(((major, minor, 0), (major, minor + 1, 0)))
1362 }
1363 _ => None,
1364 }
1365}
1366
1367fn compute_tilde_bounds(version: SemverTuple) -> (SemverTuple, SemverTuple) {
1369 let (major, minor, patch) = version;
1370 ((major, minor, patch), (major, minor + 1, 0))
1371}
1372
1373fn compute_caret_bounds(version: SemverTuple) -> (SemverTuple, SemverTuple) {
1378 let (major, minor, patch) = version;
1379 if major > 0 {
1380 ((major, minor, patch), (major + 1, 0, 0))
1381 } else if minor > 0 {
1382 ((0, minor, patch), (0, minor + 1, 0))
1383 } else {
1384 ((0, 0, patch), (0, 0, patch + 1))
1385 }
1386}
1387
1388fn parse_target_semver(
1389 target_value: &serde_json::Value,
1390) -> Result<SemverTuple, InconclusiveMatchError> {
1391 let target_str = value_to_string(target_value);
1392 parse_semver(&target_str).ok_or_else(|| {
1393 InconclusiveMatchError::new(&format!(
1394 "Unable to parse target semver value: {:?}",
1395 target_value
1396 ))
1397 })
1398}
1399
1400fn match_property(
1401 property: &Property,
1402 properties: &HashMap<String, serde_json::Value>,
1403) -> Result<bool, InconclusiveMatchError> {
1404 let value = match properties.get(&property.key) {
1405 Some(v) => v,
1406 None => {
1407 if property.operator == "is_not_set" {
1409 return Ok(true);
1410 }
1411 if property.operator == "is_set" {
1413 return Ok(false);
1414 }
1415 return Err(InconclusiveMatchError::new(&format!(
1417 "Property '{}' not found in provided properties",
1418 property.key
1419 )));
1420 }
1421 };
1422
1423 let parse_property_semver = || {
1424 let prop_str = value_to_string(value);
1425 parse_semver(&prop_str).ok_or_else(|| {
1426 InconclusiveMatchError::new(&format!(
1427 "Unable to parse property semver value for '{}': {:?}",
1428 property.key, value
1429 ))
1430 })
1431 };
1432 let parse_semver_operands = || {
1433 Ok((
1434 parse_property_semver()?,
1435 parse_target_semver(&property.value)?,
1436 ))
1437 };
1438
1439 Ok(match property.operator.as_str() {
1440 "exact" => {
1441 if property.value.is_array() {
1442 if let Some(arr) = property.value.as_array() {
1443 for val in arr {
1444 if compare_values(val, value) {
1445 return Ok(true);
1446 }
1447 }
1448 return Ok(false);
1449 }
1450 }
1451 compare_values(&property.value, value)
1452 }
1453 "is_not" => {
1454 if property.value.is_array() {
1455 if let Some(arr) = property.value.as_array() {
1456 for val in arr {
1457 if compare_values(val, value) {
1458 return Ok(false);
1459 }
1460 }
1461 return Ok(true);
1462 }
1463 }
1464 !compare_values(&property.value, value)
1465 }
1466 "is_set" => true, "is_not_set" => false, "icontains" => {
1469 let prop_str = value_to_string(value);
1470 let search_str = value_to_string(&property.value);
1471 prop_str.to_lowercase().contains(&search_str.to_lowercase())
1472 }
1473 "not_icontains" => {
1474 let prop_str = value_to_string(value);
1475 let search_str = value_to_string(&property.value);
1476 !prop_str.to_lowercase().contains(&search_str.to_lowercase())
1477 }
1478 "starts_with" => {
1479 let prop_str = value_to_string(value);
1480 let search_str = value_to_string(&property.value);
1481 prop_str
1482 .to_lowercase()
1483 .starts_with(&search_str.to_lowercase())
1484 }
1485 "not_starts_with" => {
1486 let prop_str = value_to_string(value);
1487 let search_str = value_to_string(&property.value);
1488 !prop_str
1489 .to_lowercase()
1490 .starts_with(&search_str.to_lowercase())
1491 }
1492 "ends_with" => {
1493 let prop_str = value_to_string(value);
1494 let search_str = value_to_string(&property.value);
1495 prop_str
1496 .to_lowercase()
1497 .ends_with(&search_str.to_lowercase())
1498 }
1499 "not_ends_with" => {
1500 let prop_str = value_to_string(value);
1501 let search_str = value_to_string(&property.value);
1502 !prop_str
1503 .to_lowercase()
1504 .ends_with(&search_str.to_lowercase())
1505 }
1506 "regex" => {
1507 let prop_str = value_to_string(value);
1508 let regex_str = value_to_string(&property.value);
1509 get_cached_regex(®ex_str)
1510 .map(|re| re.is_match(&prop_str))
1511 .unwrap_or(false)
1512 }
1513 "not_regex" => {
1514 let prop_str = value_to_string(value);
1515 let regex_str = value_to_string(&property.value);
1516 get_cached_regex(®ex_str)
1517 .map(|re| !re.is_match(&prop_str))
1518 .unwrap_or(true)
1519 }
1520 "gt" | "gte" | "lt" | "lte" => compare_numeric(&property.operator, &property.value, value),
1521 "is_date_before" | "is_date_after" => {
1522 let target_date = parse_date_value(&property.value).ok_or_else(|| {
1523 InconclusiveMatchError::new(&format!(
1524 "Unable to parse target date value: {:?}",
1525 property.value
1526 ))
1527 })?;
1528
1529 let prop_date = parse_date_value(value).ok_or_else(|| {
1530 InconclusiveMatchError::new(&format!(
1531 "Unable to parse property date value for '{}': {:?}",
1532 property.key, value
1533 ))
1534 })?;
1535
1536 if property.operator == "is_date_before" {
1537 prop_date < target_date
1538 } else {
1539 prop_date > target_date
1540 }
1541 }
1542 "semver_eq" | "semver_neq" | "semver_gt" | "semver_gte" | "semver_lt" | "semver_lte" => {
1544 let (prop_version, target_version) = parse_semver_operands()?;
1545
1546 match property.operator.as_str() {
1547 "semver_eq" => prop_version == target_version,
1548 "semver_neq" => prop_version != target_version,
1549 "semver_gt" => prop_version > target_version,
1550 "semver_gte" => prop_version >= target_version,
1551 "semver_lt" => prop_version < target_version,
1552 "semver_lte" => prop_version <= target_version,
1553 _ => unreachable!(),
1554 }
1555 }
1556 "semver_tilde" => {
1557 let (prop_version, target_version) = parse_semver_operands()?;
1558 let (lower, upper) = compute_tilde_bounds(target_version);
1559 prop_version >= lower && prop_version < upper
1560 }
1561 "semver_caret" => {
1562 let (prop_version, target_version) = parse_semver_operands()?;
1563 let (lower, upper) = compute_caret_bounds(target_version);
1564 prop_version >= lower && prop_version < upper
1565 }
1566 "semver_wildcard" => {
1567 let prop_version = parse_property_semver()?;
1568 let target_str = value_to_string(&property.value);
1569
1570 let (lower, upper) = parse_semver_wildcard(&target_str).ok_or_else(|| {
1571 InconclusiveMatchError::new(&format!(
1572 "Unable to parse target semver wildcard pattern: {:?}",
1573 property.value
1574 ))
1575 })?;
1576
1577 prop_version >= lower && prop_version < upper
1578 }
1579 unknown => {
1580 return Err(InconclusiveMatchError::new(&format!(
1581 "Unknown operator: {}",
1582 unknown
1583 )));
1584 }
1585 })
1586}
1587
1588fn compare_values(a: &serde_json::Value, b: &serde_json::Value) -> bool {
1589 if let (Some(a_str), Some(b_str)) = (a.as_str(), b.as_str()) {
1591 return a_str.eq_ignore_ascii_case(b_str);
1592 }
1593
1594 a == b
1596}
1597
1598fn value_to_string(value: &serde_json::Value) -> String {
1599 match value {
1600 serde_json::Value::String(s) => s.clone(),
1601 serde_json::Value::Number(n) => n.to_string(),
1602 serde_json::Value::Bool(b) => b.to_string(),
1603 _ => value.to_string(),
1604 }
1605}
1606
1607fn compare_numeric(
1608 operator: &str,
1609 property_value: &serde_json::Value,
1610 value: &serde_json::Value,
1611) -> bool {
1612 let prop_num = match property_value {
1613 serde_json::Value::Number(n) => n.as_f64(),
1614 serde_json::Value::String(s) => s.parse::<f64>().ok(),
1615 _ => None,
1616 };
1617
1618 let val_num = match value {
1619 serde_json::Value::Number(n) => n.as_f64(),
1620 serde_json::Value::String(s) => s.parse::<f64>().ok(),
1621 _ => None,
1622 };
1623
1624 if let (Some(prop), Some(val)) = (prop_num, val_num) {
1625 match operator {
1626 "gt" => val > prop,
1627 "gte" => val >= prop,
1628 "lt" => val < prop,
1629 "lte" => val <= prop,
1630 _ => false,
1631 }
1632 } else {
1633 let prop_str = value_to_string(property_value);
1635 let val_str = value_to_string(value);
1636 match operator {
1637 "gt" => val_str > prop_str,
1638 "gte" => val_str >= prop_str,
1639 "lt" => val_str < prop_str,
1640 "lte" => val_str <= prop_str,
1641 _ => false,
1642 }
1643 }
1644}
1645
1646#[cfg(test)]
1647mod tests {
1648 use super::*;
1649 use serde_json::json;
1650
1651 const TEST_SALT: &str = "test-salt";
1653
1654 #[test]
1655 fn test_hash_key() {
1656 let hash = hash_key("test-flag", "user-123", TEST_SALT);
1657 assert!((0.0..=1.0).contains(&hash));
1658
1659 let hash2 = hash_key("test-flag", "user-123", TEST_SALT);
1661 assert_eq!(hash, hash2);
1662
1663 let hash3 = hash_key("test-flag", "user-456", TEST_SALT);
1665 assert_ne!(hash, hash3);
1666 }
1667
1668 #[test]
1669 fn test_simple_flag_match() {
1670 let flag = FeatureFlag {
1671 key: "test-flag".to_string(),
1672 active: true,
1673 has_experiment: None,
1674 filters: FeatureFlagFilters {
1675 groups: vec![FeatureFlagCondition {
1676 properties: vec![],
1677 rollout_percentage: Some(100.0),
1678 variant: None,
1679 aggregation_group_type_index: None,
1680 }],
1681 multivariate: None,
1682 payloads: HashMap::new(),
1683 aggregation_group_type_index: None,
1684 early_exit: false,
1685 },
1686 };
1687
1688 let properties = HashMap::new();
1689 let result = match_feature_flag(
1690 &flag,
1691 "user-123",
1692 &properties,
1693 &HashMap::new(),
1694 &HashMap::new(),
1695 &HashMap::new(),
1696 )
1697 .unwrap();
1698 assert_eq!(result, FlagValue::Boolean(true));
1699 }
1700
1701 #[test]
1702 fn test_property_matching() {
1703 let prop = Property {
1704 key: "country".to_string(),
1705 value: json!("US"),
1706 operator: "exact".to_string(),
1707 property_type: None,
1708 };
1709
1710 let mut properties = HashMap::new();
1711 properties.insert("country".to_string(), json!("US"));
1712
1713 assert!(match_property(&prop, &properties).unwrap());
1714
1715 properties.insert("country".to_string(), json!("UK"));
1716 assert!(!match_property(&prop, &properties).unwrap());
1717 }
1718
1719 #[test]
1720 fn test_null_property_operator_defaults_to_exact() {
1721 let prop: Property = serde_json::from_value(json!({
1722 "key": "country",
1723 "value": "US",
1724 "operator": null,
1725 "type": "person"
1726 }))
1727 .unwrap();
1728
1729 assert_eq!(prop.operator, "exact");
1730 }
1731
1732 #[test]
1733 fn test_multivariate_variants() {
1734 let flag = FeatureFlag {
1735 key: "test-flag".to_string(),
1736 active: true,
1737 has_experiment: None,
1738 filters: FeatureFlagFilters {
1739 groups: vec![FeatureFlagCondition {
1740 properties: vec![],
1741 rollout_percentage: Some(100.0),
1742 variant: None,
1743 aggregation_group_type_index: None,
1744 }],
1745 multivariate: Some(MultivariateFilter {
1746 variants: vec![
1747 MultivariateVariant {
1748 key: "control".to_string(),
1749 rollout_percentage: 50.0,
1750 },
1751 MultivariateVariant {
1752 key: "test".to_string(),
1753 rollout_percentage: 50.0,
1754 },
1755 ],
1756 }),
1757 payloads: HashMap::new(),
1758 aggregation_group_type_index: None,
1759 early_exit: false,
1760 },
1761 };
1762
1763 let properties = HashMap::new();
1764 let result = match_feature_flag(
1765 &flag,
1766 "user-123",
1767 &properties,
1768 &HashMap::new(),
1769 &HashMap::new(),
1770 &HashMap::new(),
1771 )
1772 .unwrap();
1773
1774 match result {
1775 FlagValue::String(variant) => {
1776 assert!(variant == "control" || variant == "test");
1777 }
1778 _ => panic!("Expected string variant"),
1779 }
1780 }
1781
1782 #[test]
1783 fn test_inactive_flag() {
1784 let flag = FeatureFlag {
1785 key: "inactive-flag".to_string(),
1786 active: false,
1787 has_experiment: None,
1788 filters: FeatureFlagFilters {
1789 groups: vec![FeatureFlagCondition {
1790 properties: vec![],
1791 rollout_percentage: Some(100.0),
1792 variant: None,
1793 aggregation_group_type_index: None,
1794 }],
1795 multivariate: None,
1796 payloads: HashMap::new(),
1797 aggregation_group_type_index: None,
1798 early_exit: false,
1799 },
1800 };
1801
1802 let properties = HashMap::new();
1803 let result = match_feature_flag(
1804 &flag,
1805 "user-123",
1806 &properties,
1807 &HashMap::new(),
1808 &HashMap::new(),
1809 &HashMap::new(),
1810 )
1811 .unwrap();
1812 assert_eq!(result, FlagValue::Boolean(false));
1813 }
1814
1815 #[test]
1816 fn test_rollout_percentage() {
1817 let flag = FeatureFlag {
1818 key: "rollout-flag".to_string(),
1819 active: true,
1820 has_experiment: None,
1821 filters: FeatureFlagFilters {
1822 groups: vec![FeatureFlagCondition {
1823 properties: vec![],
1824 rollout_percentage: Some(30.0), variant: None,
1826 aggregation_group_type_index: None,
1827 }],
1828 multivariate: None,
1829 payloads: HashMap::new(),
1830 aggregation_group_type_index: None,
1831 early_exit: false,
1832 },
1833 };
1834
1835 let properties = HashMap::new();
1836
1837 let mut enabled_count = 0;
1839 for i in 0..1000 {
1840 let result = match_feature_flag(
1841 &flag,
1842 &format!("user-{}", i),
1843 &properties,
1844 &HashMap::new(),
1845 &HashMap::new(),
1846 &HashMap::new(),
1847 )
1848 .unwrap();
1849 if result == FlagValue::Boolean(true) {
1850 enabled_count += 1;
1851 }
1852 }
1853
1854 assert!(enabled_count > 250 && enabled_count < 350);
1856 }
1857
1858 #[test]
1859 fn test_regex_operator() {
1860 let prop = Property {
1861 key: "email".to_string(),
1862 value: json!(".*@company\\.com$"),
1863 operator: "regex".to_string(),
1864 property_type: None,
1865 };
1866
1867 let mut properties = HashMap::new();
1868 properties.insert("email".to_string(), json!("user@company.com"));
1869 assert!(match_property(&prop, &properties).unwrap());
1870
1871 properties.insert("email".to_string(), json!("user@example.com"));
1872 assert!(!match_property(&prop, &properties).unwrap());
1873 }
1874
1875 #[test]
1876 fn test_icontains_operator() {
1877 let prop = Property {
1878 key: "name".to_string(),
1879 value: json!("ADMIN"),
1880 operator: "icontains".to_string(),
1881 property_type: None,
1882 };
1883
1884 let mut properties = HashMap::new();
1885 properties.insert("name".to_string(), json!("admin_user"));
1886 assert!(match_property(&prop, &properties).unwrap());
1887
1888 properties.insert("name".to_string(), json!("regular_user"));
1889 assert!(!match_property(&prop, &properties).unwrap());
1890 }
1891
1892 #[test]
1893 fn test_starts_with_operator() {
1894 let prop = Property {
1895 key: "name".to_string(),
1896 value: json!("Val"),
1897 operator: "starts_with".to_string(),
1898 property_type: None,
1899 };
1900
1901 let mut properties = HashMap::new();
1903 properties.insert("name".to_string(), json!("value"));
1904 assert!(match_property(&prop, &properties).unwrap());
1905
1906 properties.insert("name".to_string(), json!("VALUE"));
1907 assert!(match_property(&prop, &properties).unwrap());
1908
1909 properties.insert("name".to_string(), json!("prevalue"));
1911 assert!(!match_property(&prop, &properties).unwrap());
1912
1913 properties.insert("name".to_string(), json!("Alakazam"));
1914 assert!(!match_property(&prop, &properties).unwrap());
1915
1916 let numeric_prop = Property {
1918 key: "name".to_string(),
1919 value: json!("3"),
1920 operator: "starts_with".to_string(),
1921 property_type: None,
1922 };
1923
1924 properties.insert("name".to_string(), json!(323));
1925 assert!(match_property(&numeric_prop, &properties).unwrap());
1926
1927 properties.insert("name".to_string(), json!(123));
1928 assert!(!match_property(&numeric_prop, &properties).unwrap());
1929
1930 let negated_prop = Property {
1931 key: "name".to_string(),
1932 value: json!("Val"),
1933 operator: "not_starts_with".to_string(),
1934 property_type: None,
1935 };
1936
1937 properties.insert("name".to_string(), json!("value"));
1938 assert!(!match_property(&negated_prop, &properties).unwrap());
1939
1940 properties.insert("name".to_string(), json!("prevalue"));
1941 assert!(match_property(&negated_prop, &properties).unwrap());
1942
1943 assert!(match_property(&prop, &HashMap::new()).is_err());
1945 }
1946
1947 #[test]
1948 fn test_ends_with_operator() {
1949 let prop = Property {
1950 key: "name".to_string(),
1951 value: json!("lUe"),
1952 operator: "ends_with".to_string(),
1953 property_type: None,
1954 };
1955
1956 let mut properties = HashMap::new();
1958 properties.insert("name".to_string(), json!("value"));
1959 assert!(match_property(&prop, &properties).unwrap());
1960
1961 properties.insert("name".to_string(), json!("VALUE"));
1962 assert!(match_property(&prop, &properties).unwrap());
1963
1964 properties.insert("name".to_string(), json!("value2"));
1966 assert!(!match_property(&prop, &properties).unwrap());
1967
1968 properties.insert("name".to_string(), json!("Alakazam"));
1969 assert!(!match_property(&prop, &properties).unwrap());
1970
1971 let numeric_prop = Property {
1973 key: "name".to_string(),
1974 value: json!("3"),
1975 operator: "ends_with".to_string(),
1976 property_type: None,
1977 };
1978
1979 properties.insert("name".to_string(), json!(323));
1980 assert!(match_property(&numeric_prop, &properties).unwrap());
1981
1982 properties.insert("name".to_string(), json!(321));
1983 assert!(!match_property(&numeric_prop, &properties).unwrap());
1984
1985 let negated_prop = Property {
1986 key: "name".to_string(),
1987 value: json!("lUe"),
1988 operator: "not_ends_with".to_string(),
1989 property_type: None,
1990 };
1991
1992 properties.insert("name".to_string(), json!("value"));
1993 assert!(!match_property(&negated_prop, &properties).unwrap());
1994
1995 properties.insert("name".to_string(), json!("value2"));
1996 assert!(match_property(&negated_prop, &properties).unwrap());
1997
1998 assert!(match_property(&prop, &HashMap::new()).is_err());
2000 }
2001
2002 #[test]
2003 fn test_numeric_operators() {
2004 let prop_gt = Property {
2006 key: "age".to_string(),
2007 value: json!(18),
2008 operator: "gt".to_string(),
2009 property_type: None,
2010 };
2011
2012 let mut properties = HashMap::new();
2013 properties.insert("age".to_string(), json!(25));
2014 assert!(match_property(&prop_gt, &properties).unwrap());
2015
2016 properties.insert("age".to_string(), json!(15));
2017 assert!(!match_property(&prop_gt, &properties).unwrap());
2018
2019 let prop_lte = Property {
2021 key: "score".to_string(),
2022 value: json!(100),
2023 operator: "lte".to_string(),
2024 property_type: None,
2025 };
2026
2027 properties.insert("score".to_string(), json!(100));
2028 assert!(match_property(&prop_lte, &properties).unwrap());
2029
2030 properties.insert("score".to_string(), json!(101));
2031 assert!(!match_property(&prop_lte, &properties).unwrap());
2032 }
2033
2034 #[test]
2035 fn test_is_set_operator() {
2036 let prop = Property {
2037 key: "email".to_string(),
2038 value: json!(true),
2039 operator: "is_set".to_string(),
2040 property_type: None,
2041 };
2042
2043 let mut properties = HashMap::new();
2044 properties.insert("email".to_string(), json!("test@example.com"));
2045 assert!(match_property(&prop, &properties).unwrap());
2046
2047 properties.remove("email");
2048 assert!(!match_property(&prop, &properties).unwrap());
2049 }
2050
2051 #[test]
2052 fn test_is_not_set_operator() {
2053 let prop = Property {
2054 key: "phone".to_string(),
2055 value: json!(true),
2056 operator: "is_not_set".to_string(),
2057 property_type: None,
2058 };
2059
2060 let mut properties = HashMap::new();
2061 assert!(match_property(&prop, &properties).unwrap());
2062
2063 properties.insert("phone".to_string(), json!("+1234567890"));
2064 assert!(!match_property(&prop, &properties).unwrap());
2065 }
2066
2067 #[test]
2068 fn test_empty_groups() {
2069 let flag = FeatureFlag {
2070 key: "empty-groups".to_string(),
2071 active: true,
2072 has_experiment: None,
2073 filters: FeatureFlagFilters {
2074 groups: vec![],
2075 multivariate: None,
2076 payloads: HashMap::new(),
2077 aggregation_group_type_index: None,
2078 early_exit: false,
2079 },
2080 };
2081
2082 let properties = HashMap::new();
2083 let result = match_feature_flag(
2084 &flag,
2085 "user-123",
2086 &properties,
2087 &HashMap::new(),
2088 &HashMap::new(),
2089 &HashMap::new(),
2090 )
2091 .unwrap();
2092 assert_eq!(result, FlagValue::Boolean(false));
2093 }
2094
2095 #[test]
2096 fn test_hash_scale_constant() {
2097 assert_eq!(LONG_SCALE, 0xFFFFFFFFFFFFFFFu64 as f64);
2099 assert_ne!(LONG_SCALE, 0xFFFFFFFFFFFFFFFFu64 as f64);
2100 }
2101
2102 #[test]
2105 fn test_unknown_operator_returns_inconclusive_error() {
2106 let prop = Property {
2107 key: "status".to_string(),
2108 value: json!("active"),
2109 operator: "unknown_operator".to_string(),
2110 property_type: None,
2111 };
2112
2113 let mut properties = HashMap::new();
2114 properties.insert("status".to_string(), json!("active"));
2115
2116 let result = match_property(&prop, &properties);
2117 assert!(result.is_err());
2118 let err = result.unwrap_err();
2119 assert!(err.message.contains("unknown_operator"));
2120 }
2121
2122 #[test]
2123 fn test_is_date_before_with_relative_date() {
2124 let prop = Property {
2125 key: "signup_date".to_string(),
2126 value: json!("-7d"), operator: "is_date_before".to_string(),
2128 property_type: None,
2129 };
2130
2131 let mut properties = HashMap::new();
2132 let ten_days_ago = chrono::Utc::now() - chrono::Duration::days(10);
2134 properties.insert(
2135 "signup_date".to_string(),
2136 json!(ten_days_ago.format("%Y-%m-%d").to_string()),
2137 );
2138 assert!(match_property(&prop, &properties).unwrap());
2139
2140 let three_days_ago = chrono::Utc::now() - chrono::Duration::days(3);
2142 properties.insert(
2143 "signup_date".to_string(),
2144 json!(three_days_ago.format("%Y-%m-%d").to_string()),
2145 );
2146 assert!(!match_property(&prop, &properties).unwrap());
2147 }
2148
2149 #[test]
2150 fn test_is_date_after_with_relative_date() {
2151 let prop = Property {
2152 key: "last_seen".to_string(),
2153 value: json!("-30d"), operator: "is_date_after".to_string(),
2155 property_type: None,
2156 };
2157
2158 let mut properties = HashMap::new();
2159 let ten_days_ago = chrono::Utc::now() - chrono::Duration::days(10);
2161 properties.insert(
2162 "last_seen".to_string(),
2163 json!(ten_days_ago.format("%Y-%m-%d").to_string()),
2164 );
2165 assert!(match_property(&prop, &properties).unwrap());
2166
2167 let sixty_days_ago = chrono::Utc::now() - chrono::Duration::days(60);
2169 properties.insert(
2170 "last_seen".to_string(),
2171 json!(sixty_days_ago.format("%Y-%m-%d").to_string()),
2172 );
2173 assert!(!match_property(&prop, &properties).unwrap());
2174 }
2175
2176 #[test]
2177 fn test_is_date_before_with_iso_date() {
2178 let prop = Property {
2179 key: "expiry_date".to_string(),
2180 value: json!("2024-06-15"),
2181 operator: "is_date_before".to_string(),
2182 property_type: None,
2183 };
2184
2185 let mut properties = HashMap::new();
2186 properties.insert("expiry_date".to_string(), json!("2024-06-10"));
2187 assert!(match_property(&prop, &properties).unwrap());
2188
2189 properties.insert("expiry_date".to_string(), json!("2024-06-20"));
2190 assert!(!match_property(&prop, &properties).unwrap());
2191 }
2192
2193 #[test]
2194 fn test_is_date_after_with_iso_date() {
2195 let prop = Property {
2196 key: "start_date".to_string(),
2197 value: json!("2024-01-01"),
2198 operator: "is_date_after".to_string(),
2199 property_type: None,
2200 };
2201
2202 let mut properties = HashMap::new();
2203 properties.insert("start_date".to_string(), json!("2024-03-15"));
2204 assert!(match_property(&prop, &properties).unwrap());
2205
2206 properties.insert("start_date".to_string(), json!("2023-12-01"));
2207 assert!(!match_property(&prop, &properties).unwrap());
2208 }
2209
2210 #[test]
2211 fn test_is_date_with_relative_hours() {
2212 let prop = Property {
2213 key: "last_active".to_string(),
2214 value: json!("-24h"), operator: "is_date_after".to_string(),
2216 property_type: None,
2217 };
2218
2219 let mut properties = HashMap::new();
2220 let twelve_hours_ago = chrono::Utc::now() - chrono::Duration::hours(12);
2222 properties.insert(
2223 "last_active".to_string(),
2224 json!(twelve_hours_ago.to_rfc3339()),
2225 );
2226 assert!(match_property(&prop, &properties).unwrap());
2227
2228 let forty_eight_hours_ago = chrono::Utc::now() - chrono::Duration::hours(48);
2230 properties.insert(
2231 "last_active".to_string(),
2232 json!(forty_eight_hours_ago.to_rfc3339()),
2233 );
2234 assert!(!match_property(&prop, &properties).unwrap());
2235 }
2236
2237 #[test]
2238 fn test_is_date_with_relative_weeks() {
2239 let prop = Property {
2240 key: "joined".to_string(),
2241 value: json!("-2w"), operator: "is_date_before".to_string(),
2243 property_type: None,
2244 };
2245
2246 let mut properties = HashMap::new();
2247 let three_weeks_ago = chrono::Utc::now() - chrono::Duration::weeks(3);
2249 properties.insert(
2250 "joined".to_string(),
2251 json!(three_weeks_ago.format("%Y-%m-%d").to_string()),
2252 );
2253 assert!(match_property(&prop, &properties).unwrap());
2254
2255 let one_week_ago = chrono::Utc::now() - chrono::Duration::weeks(1);
2257 properties.insert(
2258 "joined".to_string(),
2259 json!(one_week_ago.format("%Y-%m-%d").to_string()),
2260 );
2261 assert!(!match_property(&prop, &properties).unwrap());
2262 }
2263
2264 #[test]
2265 fn test_is_date_with_relative_months() {
2266 let prop = Property {
2267 key: "subscription_date".to_string(),
2268 value: json!("-3m"), operator: "is_date_after".to_string(),
2270 property_type: None,
2271 };
2272
2273 let mut properties = HashMap::new();
2274 let one_month_ago = chrono::Utc::now() - chrono::Duration::days(30);
2276 properties.insert(
2277 "subscription_date".to_string(),
2278 json!(one_month_ago.format("%Y-%m-%d").to_string()),
2279 );
2280 assert!(match_property(&prop, &properties).unwrap());
2281
2282 let six_months_ago = chrono::Utc::now() - chrono::Duration::days(180);
2284 properties.insert(
2285 "subscription_date".to_string(),
2286 json!(six_months_ago.format("%Y-%m-%d").to_string()),
2287 );
2288 assert!(!match_property(&prop, &properties).unwrap());
2289 }
2290
2291 #[test]
2292 fn test_is_date_with_relative_years() {
2293 let prop = Property {
2294 key: "created_at".to_string(),
2295 value: json!("-1y"), operator: "is_date_before".to_string(),
2297 property_type: None,
2298 };
2299
2300 let mut properties = HashMap::new();
2301 let two_years_ago = chrono::Utc::now() - chrono::Duration::days(730);
2303 properties.insert(
2304 "created_at".to_string(),
2305 json!(two_years_ago.format("%Y-%m-%d").to_string()),
2306 );
2307 assert!(match_property(&prop, &properties).unwrap());
2308
2309 let six_months_ago = chrono::Utc::now() - chrono::Duration::days(180);
2311 properties.insert(
2312 "created_at".to_string(),
2313 json!(six_months_ago.format("%Y-%m-%d").to_string()),
2314 );
2315 assert!(!match_property(&prop, &properties).unwrap());
2316 }
2317
2318 #[test]
2319 fn test_is_date_with_invalid_date_format() {
2320 let prop = Property {
2321 key: "date".to_string(),
2322 value: json!("-7d"),
2323 operator: "is_date_before".to_string(),
2324 property_type: None,
2325 };
2326
2327 let mut properties = HashMap::new();
2328 properties.insert("date".to_string(), json!("not-a-date"));
2329
2330 let result = match_property(&prop, &properties);
2332 assert!(result.is_err());
2333 }
2334
2335 #[test]
2336 fn test_is_date_with_iso_datetime() {
2337 let prop = Property {
2338 key: "event_time".to_string(),
2339 value: json!("2024-06-15T10:30:00Z"),
2340 operator: "is_date_before".to_string(),
2341 property_type: None,
2342 };
2343
2344 let mut properties = HashMap::new();
2345 properties.insert("event_time".to_string(), json!("2024-06-15T08:00:00Z"));
2346 assert!(match_property(&prop, &properties).unwrap());
2347
2348 properties.insert("event_time".to_string(), json!("2024-06-15T12:00:00Z"));
2349 assert!(!match_property(&prop, &properties).unwrap());
2350 }
2351
2352 #[test]
2355 fn test_cohort_membership_in() {
2356 let mut cohorts = HashMap::new();
2358 cohorts.insert(
2359 "cohort_1".to_string(),
2360 CohortDefinition::new(
2361 "cohort_1".to_string(),
2362 vec![Property {
2363 key: "country".to_string(),
2364 value: json!("US"),
2365 operator: "exact".to_string(),
2366 property_type: None,
2367 }],
2368 ),
2369 );
2370
2371 let prop = Property {
2373 key: "$cohort".to_string(),
2374 value: json!("cohort_1"),
2375 operator: "in".to_string(),
2376 property_type: Some("cohort".to_string()),
2377 };
2378
2379 let mut properties = HashMap::new();
2381 properties.insert("country".to_string(), json!("US"));
2382
2383 let ctx = EvaluationContext {
2384 cohorts: &cohorts,
2385 flags: &HashMap::new(),
2386 distinct_id: "user-123",
2387 groups: &HashMap::new(),
2388 group_properties: &HashMap::new(),
2389 group_type_mapping: &HashMap::new(),
2390 };
2391 assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
2392
2393 properties.insert("country".to_string(), json!("UK"));
2395 assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
2396 }
2397
2398 #[test]
2399 fn test_cohort_membership_not_in() {
2400 let mut cohorts = HashMap::new();
2401 cohorts.insert(
2402 "cohort_blocked".to_string(),
2403 CohortDefinition::new(
2404 "cohort_blocked".to_string(),
2405 vec![Property {
2406 key: "status".to_string(),
2407 value: json!("blocked"),
2408 operator: "exact".to_string(),
2409 property_type: None,
2410 }],
2411 ),
2412 );
2413
2414 let prop = Property {
2415 key: "$cohort".to_string(),
2416 value: json!("cohort_blocked"),
2417 operator: "not_in".to_string(),
2418 property_type: Some("cohort".to_string()),
2419 };
2420
2421 let mut properties = HashMap::new();
2422 properties.insert("status".to_string(), json!("active"));
2423
2424 let ctx = EvaluationContext {
2425 cohorts: &cohorts,
2426 flags: &HashMap::new(),
2427 distinct_id: "user-123",
2428 groups: &HashMap::new(),
2429 group_properties: &HashMap::new(),
2430 group_type_mapping: &HashMap::new(),
2431 };
2432 assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
2434
2435 properties.insert("status".to_string(), json!("blocked"));
2437 assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
2438 }
2439
2440 #[test]
2441 fn test_cohort_not_found_returns_inconclusive() {
2442 let cohorts = HashMap::new(); let prop = Property {
2445 key: "$cohort".to_string(),
2446 value: json!("nonexistent_cohort"),
2447 operator: "in".to_string(),
2448 property_type: Some("cohort".to_string()),
2449 };
2450
2451 let properties = HashMap::new();
2452 let ctx = EvaluationContext {
2453 cohorts: &cohorts,
2454 flags: &HashMap::new(),
2455 distinct_id: "user-123",
2456 groups: &HashMap::new(),
2457 group_properties: &HashMap::new(),
2458 group_type_mapping: &HashMap::new(),
2459 };
2460
2461 let result = match_property_with_context(&prop, &properties, &ctx);
2462 assert!(result.is_err());
2463 assert!(result.unwrap_err().message.contains("Cohort"));
2464 }
2465
2466 fn cohort_ctx(cohorts: &HashMap<String, CohortDefinition>) -> EvaluationContext<'_> {
2469 EvaluationContext {
2470 cohorts,
2471 flags: EMPTY_FLAGS.get_or_init(HashMap::new),
2472 distinct_id: "user-123",
2473 groups: EMPTY_GROUPS.get_or_init(HashMap::new),
2474 group_properties: EMPTY_GROUP_PROPS.get_or_init(HashMap::new),
2475 group_type_mapping: EMPTY_GROUP_MAPPING.get_or_init(HashMap::new),
2476 }
2477 }
2478
2479 static EMPTY_FLAGS: OnceLock<HashMap<String, FeatureFlag>> = OnceLock::new();
2480 static EMPTY_GROUPS: OnceLock<HashMap<String, String>> = OnceLock::new();
2481 static EMPTY_GROUP_PROPS: OnceLock<HashMap<String, HashMap<String, serde_json::Value>>> =
2482 OnceLock::new();
2483 static EMPTY_GROUP_MAPPING: OnceLock<HashMap<String, String>> = OnceLock::new();
2484
2485 #[test]
2488 fn test_cohort_or_group() {
2489 let mut cohorts = HashMap::new();
2490 cohorts.insert(
2491 "cohort_or".to_string(),
2492 CohortDefinition {
2493 id: "cohort_or".to_string(),
2494 properties: json!({
2495 "type": "OR",
2496 "values": [
2497 {"key": "country", "value": "US", "operator": "exact", "type": "person"},
2498 {"key": "country", "value": "CA", "operator": "exact", "type": "person"},
2499 ],
2500 }),
2501 },
2502 );
2503
2504 let prop = Property {
2505 key: "$cohort".to_string(),
2506 value: json!("cohort_or"),
2507 operator: "in".to_string(),
2508 property_type: Some("cohort".to_string()),
2509 };
2510
2511 let ctx = cohort_ctx(&cohorts);
2512
2513 let mut properties = HashMap::new();
2515 properties.insert("country".to_string(), json!("US"));
2516 assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
2517
2518 properties.insert("country".to_string(), json!("CA"));
2519 assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
2520
2521 properties.insert("country".to_string(), json!("UK"));
2523 assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
2524 }
2525
2526 #[test]
2529 fn test_cohort_nested_and_of_or() {
2530 let mut cohorts = HashMap::new();
2531 cohorts.insert(
2532 "cohort_nested".to_string(),
2533 CohortDefinition {
2534 id: "cohort_nested".to_string(),
2535 properties: json!({
2536 "type": "AND",
2537 "values": [
2538 {"key": "plan", "value": "paid", "operator": "exact", "type": "person"},
2539 {
2540 "type": "OR",
2541 "values": [
2542 {"key": "country", "value": "US", "operator": "exact", "type": "person"},
2543 {"key": "country", "value": "CA", "operator": "exact", "type": "person"},
2544 ],
2545 },
2546 ],
2547 }),
2548 },
2549 );
2550
2551 let prop = Property {
2552 key: "$cohort".to_string(),
2553 value: json!("cohort_nested"),
2554 operator: "in".to_string(),
2555 property_type: Some("cohort".to_string()),
2556 };
2557
2558 let ctx = cohort_ctx(&cohorts);
2559
2560 let mut properties = HashMap::new();
2562 properties.insert("plan".to_string(), json!("paid"));
2563 properties.insert("country".to_string(), json!("CA"));
2564 assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
2565
2566 properties.insert("country".to_string(), json!("UK"));
2569 assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
2570
2571 properties.insert("plan".to_string(), json!("free"));
2573 properties.insert("country".to_string(), json!("US"));
2574 assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
2575 }
2576
2577 #[test]
2580 fn test_cohort_nested_cohort_reference() {
2581 let mut cohorts = HashMap::new();
2582 cohorts.insert(
2583 "child".to_string(),
2584 CohortDefinition {
2585 id: "child".to_string(),
2586 properties: json!({
2587 "type": "AND",
2588 "values": [
2589 {"key": "country", "value": "US", "operator": "exact", "type": "person"},
2590 ],
2591 }),
2592 },
2593 );
2594 cohorts.insert(
2595 "parent".to_string(),
2596 CohortDefinition {
2597 id: "parent".to_string(),
2598 properties: json!({
2599 "type": "AND",
2600 "values": [
2601 {"type": "cohort", "value": "child", "negation": false},
2602 ],
2603 }),
2604 },
2605 );
2606
2607 let prop = Property {
2608 key: "$cohort".to_string(),
2609 value: json!("parent"),
2610 operator: "in".to_string(),
2611 property_type: Some("cohort".to_string()),
2612 };
2613
2614 let ctx = cohort_ctx(&cohorts);
2615
2616 let mut properties = HashMap::new();
2617 properties.insert("country".to_string(), json!("US"));
2618 assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
2619
2620 properties.insert("country".to_string(), json!("UK"));
2621 assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
2622 }
2623
2624 #[test]
2628 fn test_cyclic_cohort_reference_is_inconclusive() {
2629 let mut cohorts = HashMap::new();
2630 cohorts.insert(
2631 "a".to_string(),
2632 CohortDefinition {
2633 id: "a".to_string(),
2634 properties: json!({
2635 "type": "AND",
2636 "values": [{"type": "cohort", "value": "b", "negation": false}],
2637 }),
2638 },
2639 );
2640 cohorts.insert(
2641 "b".to_string(),
2642 CohortDefinition {
2643 id: "b".to_string(),
2644 properties: json!({
2645 "type": "AND",
2646 "values": [{"type": "cohort", "value": "a", "negation": false}],
2647 }),
2648 },
2649 );
2650
2651 let prop = Property {
2652 key: "$cohort".to_string(),
2653 value: json!("a"),
2654 operator: "in".to_string(),
2655 property_type: Some("cohort".to_string()),
2656 };
2657 let ctx = cohort_ctx(&cohorts);
2658
2659 let error = match_property_with_context(&prop, &HashMap::new(), &ctx)
2660 .expect_err("a cohort cycle must not resolve locally");
2661 assert!(
2662 error.to_string().contains("cycle"),
2663 "error should name the cycle, got: {}",
2664 error
2665 );
2666 }
2667
2668 #[test]
2673 fn test_deep_acyclic_cohort_chain_is_inconclusive() {
2674 let chain_len = MAX_COHORT_RESOLUTION_DEPTH + 50;
2675 let mut cohorts = HashMap::new();
2676 for link in 0..chain_len {
2677 let properties = if link == chain_len - 1 {
2678 json!({
2679 "type": "AND",
2680 "values": [
2681 {"key": "country", "value": "US", "operator": "exact", "type": "person"},
2682 ],
2683 })
2684 } else {
2685 json!({
2686 "type": "AND",
2687 "values": [{"type": "cohort", "value": (link + 1).to_string()}],
2688 })
2689 };
2690 cohorts.insert(
2691 link.to_string(),
2692 CohortDefinition {
2693 id: link.to_string(),
2694 properties,
2695 },
2696 );
2697 }
2698
2699 let prop = Property {
2700 key: "$cohort".to_string(),
2701 value: json!("0"),
2702 operator: "in".to_string(),
2703 property_type: Some("cohort".to_string()),
2704 };
2705 let ctx = cohort_ctx(&cohorts);
2706 let mut properties = HashMap::new();
2707 properties.insert("country".to_string(), json!("US"));
2708
2709 assert!(
2710 match_property_with_context(&prop, &properties, &ctx).is_err(),
2711 "a cohort chain deeper than the limit must not resolve locally"
2712 );
2713 }
2714
2715 #[test]
2717 fn test_cohort_chain_within_the_depth_limit_still_resolves() {
2718 let chain_len = 10;
2719 let mut cohorts = HashMap::new();
2720 for link in 0..chain_len {
2721 let properties = if link == chain_len - 1 {
2722 json!({
2723 "type": "AND",
2724 "values": [
2725 {"key": "country", "value": "US", "operator": "exact", "type": "person"},
2726 ],
2727 })
2728 } else {
2729 json!({
2730 "type": "AND",
2731 "values": [{"type": "cohort", "value": (link + 1).to_string()}],
2732 })
2733 };
2734 cohorts.insert(
2735 link.to_string(),
2736 CohortDefinition {
2737 id: link.to_string(),
2738 properties,
2739 },
2740 );
2741 }
2742
2743 let prop = Property {
2744 key: "$cohort".to_string(),
2745 value: json!("0"),
2746 operator: "in".to_string(),
2747 property_type: Some("cohort".to_string()),
2748 };
2749 let ctx = cohort_ctx(&cohorts);
2750 let mut properties = HashMap::new();
2751 properties.insert("country".to_string(), json!("US"));
2752
2753 assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
2754 }
2755
2756 #[test]
2757 fn test_self_referencing_cohort_is_inconclusive() {
2758 let mut cohorts = HashMap::new();
2759 cohorts.insert(
2760 "loop".to_string(),
2761 CohortDefinition {
2762 id: "loop".to_string(),
2763 properties: json!({
2764 "type": "AND",
2765 "values": [{"type": "cohort", "value": "loop", "negation": false}],
2766 }),
2767 },
2768 );
2769
2770 let prop = Property {
2771 key: "$cohort".to_string(),
2772 value: json!("loop"),
2773 operator: "in".to_string(),
2774 property_type: Some("cohort".to_string()),
2775 };
2776 let ctx = cohort_ctx(&cohorts);
2777
2778 assert!(match_property_with_context(&prop, &HashMap::new(), &ctx).is_err());
2779 }
2780
2781 #[test]
2785 fn test_repeated_cohort_reference_is_not_a_cycle() {
2786 let mut cohorts = HashMap::new();
2787 cohorts.insert(
2788 "shared".to_string(),
2789 CohortDefinition {
2790 id: "shared".to_string(),
2791 properties: json!({
2792 "type": "AND",
2793 "values": [
2794 {"key": "country", "value": "US", "operator": "exact", "type": "person"},
2795 ],
2796 }),
2797 },
2798 );
2799 for branch in ["left", "right"] {
2800 cohorts.insert(
2801 branch.to_string(),
2802 CohortDefinition {
2803 id: branch.to_string(),
2804 properties: json!({
2805 "type": "AND",
2806 "values": [{"type": "cohort", "value": "shared", "negation": false}],
2807 }),
2808 },
2809 );
2810 }
2811 cohorts.insert(
2812 "parent".to_string(),
2813 CohortDefinition {
2814 id: "parent".to_string(),
2815 properties: json!({
2816 "type": "AND",
2817 "values": [
2818 {"type": "cohort", "value": "left", "negation": false},
2819 {"type": "cohort", "value": "right", "negation": false},
2820 ],
2821 }),
2822 },
2823 );
2824
2825 let prop = Property {
2826 key: "$cohort".to_string(),
2827 value: json!("parent"),
2828 operator: "in".to_string(),
2829 property_type: Some("cohort".to_string()),
2830 };
2831 let ctx = cohort_ctx(&cohorts);
2832
2833 let mut properties = HashMap::new();
2834 properties.insert("country".to_string(), json!("US"));
2835 assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
2836
2837 properties.insert("country".to_string(), json!("UK"));
2838 assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
2839 }
2840
2841 #[test]
2842 fn test_cohort_leaf_negation() {
2843 let mut cohorts = HashMap::new();
2844 cohorts.insert(
2845 "negated_leaf".to_string(),
2846 CohortDefinition {
2847 id: "negated_leaf".to_string(),
2848 properties: json!({
2849 "type": "AND",
2850 "values": [
2851 {
2852 "key": "country",
2853 "value": "US",
2854 "operator": "exact",
2855 "type": "person",
2856 "negation": true
2857 },
2858 ],
2859 }),
2860 },
2861 );
2862
2863 let prop = Property {
2864 key: "$cohort".to_string(),
2865 value: json!("negated_leaf"),
2866 operator: "in".to_string(),
2867 property_type: Some("cohort".to_string()),
2868 };
2869 let ctx = cohort_ctx(&cohorts);
2870 let mut properties = HashMap::new();
2871
2872 properties.insert("country".to_string(), json!("US"));
2873 assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
2874
2875 properties.insert("country".to_string(), json!("UK"));
2876 assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
2877 }
2878
2879 #[test]
2880 fn test_missing_nested_cohort_is_not_suppressed() {
2881 let missing_cohort = json!({"type": "cohort", "value": "missing"});
2882 let country_leaf = json!({
2883 "key": "country",
2884 "value": "US",
2885 "operator": "exact",
2886 "type": "person"
2887 });
2888 let mut cohorts = HashMap::new();
2889 cohorts.insert(
2890 "or_parent".to_string(),
2891 CohortDefinition {
2892 id: "or_parent".to_string(),
2893 properties: json!({
2894 "type": "OR",
2895 "values": [country_leaf.clone(), missing_cohort.clone()],
2896 }),
2897 },
2898 );
2899 cohorts.insert(
2900 "and_parent".to_string(),
2901 CohortDefinition {
2902 id: "and_parent".to_string(),
2903 properties: json!({
2904 "type": "AND",
2905 "values": [country_leaf, missing_cohort],
2906 }),
2907 },
2908 );
2909
2910 let ctx = cohort_ctx(&cohorts);
2911 for (cohort_id, country) in [("or_parent", "US"), ("and_parent", "UK")] {
2912 let prop = Property {
2913 key: "$cohort".to_string(),
2914 value: json!(cohort_id),
2915 operator: "in".to_string(),
2916 property_type: Some("cohort".to_string()),
2917 };
2918 let properties = HashMap::from([("country".to_string(), json!(country))]);
2919
2920 assert!(match_property_with_context(&prop, &properties, &ctx).is_err());
2921 }
2922 }
2923
2924 #[test]
2925 fn test_malformed_cohort_groups_are_inconclusive() {
2926 let mut cohorts = HashMap::new();
2927 cohorts.insert(
2928 "scalar".to_string(),
2929 CohortDefinition {
2930 id: "scalar".to_string(),
2931 properties: json!("invalid"),
2932 },
2933 );
2934 cohorts.insert(
2935 "object_values".to_string(),
2936 CohortDefinition {
2937 id: "object_values".to_string(),
2938 properties: json!({"type": "AND", "values": {}}),
2939 },
2940 );
2941 cohorts.insert(
2942 "missing_values".to_string(),
2943 CohortDefinition {
2944 id: "missing_values".to_string(),
2945 properties: json!({"type": "AND"}),
2946 },
2947 );
2948 cohorts.insert(
2949 "empty_object".to_string(),
2950 CohortDefinition {
2951 id: "empty_object".to_string(),
2952 properties: json!({}),
2953 },
2954 );
2955 cohorts.insert(
2956 "empty_values".to_string(),
2957 CohortDefinition {
2958 id: "empty_values".to_string(),
2959 properties: json!({"type": "AND", "values": []}),
2960 },
2961 );
2962
2963 let ctx = cohort_ctx(&cohorts);
2964 let properties = HashMap::new();
2965 for cohort_id in ["scalar", "object_values", "missing_values"] {
2966 let prop = Property {
2967 key: "$cohort".to_string(),
2968 value: json!(cohort_id),
2969 operator: "in".to_string(),
2970 property_type: Some("cohort".to_string()),
2971 };
2972
2973 assert!(match_property_with_context(&prop, &properties, &ctx).is_err());
2974 }
2975
2976 for cohort_id in ["empty_object", "empty_values"] {
2977 let empty = Property {
2978 key: "$cohort".to_string(),
2979 value: json!(cohort_id),
2980 operator: "in".to_string(),
2981 property_type: Some("cohort".to_string()),
2982 };
2983 assert!(match_property_with_context(&empty, &properties, &ctx).unwrap());
2984 }
2985 }
2986
2987 #[test]
2990 fn test_flag_dependency_enabled() {
2991 let mut flags = HashMap::new();
2992 flags.insert(
2993 "prerequisite-flag".to_string(),
2994 FeatureFlag {
2995 key: "prerequisite-flag".to_string(),
2996 active: true,
2997 has_experiment: None,
2998 filters: FeatureFlagFilters {
2999 groups: vec![FeatureFlagCondition {
3000 properties: vec![],
3001 rollout_percentage: Some(100.0),
3002 variant: None,
3003 aggregation_group_type_index: None,
3004 }],
3005 multivariate: None,
3006 payloads: HashMap::new(),
3007 aggregation_group_type_index: None,
3008 early_exit: false,
3009 },
3010 },
3011 );
3012
3013 let prop = Property {
3015 key: "$feature/prerequisite-flag".to_string(),
3016 value: json!(true),
3017 operator: "exact".to_string(),
3018 property_type: None,
3019 };
3020
3021 let properties = HashMap::new();
3022 let ctx = EvaluationContext {
3023 cohorts: &HashMap::new(),
3024 flags: &flags,
3025 distinct_id: "user-123",
3026 groups: &HashMap::new(),
3027 group_properties: &HashMap::new(),
3028 group_type_mapping: &HashMap::new(),
3029 };
3030
3031 assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
3033 }
3034
3035 #[test]
3036 fn test_flag_dependency_disabled() {
3037 let mut flags = HashMap::new();
3038 flags.insert(
3039 "disabled-flag".to_string(),
3040 FeatureFlag {
3041 key: "disabled-flag".to_string(),
3042 active: false, has_experiment: None,
3044 filters: FeatureFlagFilters {
3045 groups: vec![],
3046 multivariate: None,
3047 payloads: HashMap::new(),
3048 aggregation_group_type_index: None,
3049 early_exit: false,
3050 },
3051 },
3052 );
3053
3054 let prop = Property {
3056 key: "$feature/disabled-flag".to_string(),
3057 value: json!(true),
3058 operator: "exact".to_string(),
3059 property_type: None,
3060 };
3061
3062 let properties = HashMap::new();
3063 let ctx = EvaluationContext {
3064 cohorts: &HashMap::new(),
3065 flags: &flags,
3066 distinct_id: "user-123",
3067 groups: &HashMap::new(),
3068 group_properties: &HashMap::new(),
3069 group_type_mapping: &HashMap::new(),
3070 };
3071
3072 assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
3074 }
3075
3076 #[test]
3077 fn test_flag_dependency_variant_match() {
3078 let mut flags = HashMap::new();
3079 flags.insert(
3080 "ab-test-flag".to_string(),
3081 FeatureFlag {
3082 key: "ab-test-flag".to_string(),
3083 active: true,
3084 has_experiment: None,
3085 filters: FeatureFlagFilters {
3086 groups: vec![FeatureFlagCondition {
3087 properties: vec![],
3088 rollout_percentage: Some(100.0),
3089 variant: None,
3090 aggregation_group_type_index: None,
3091 }],
3092 multivariate: Some(MultivariateFilter {
3093 variants: vec![
3094 MultivariateVariant {
3095 key: "control".to_string(),
3096 rollout_percentage: 50.0,
3097 },
3098 MultivariateVariant {
3099 key: "test".to_string(),
3100 rollout_percentage: 50.0,
3101 },
3102 ],
3103 }),
3104 payloads: HashMap::new(),
3105 aggregation_group_type_index: None,
3106 early_exit: false,
3107 },
3108 },
3109 );
3110
3111 let prop = Property {
3113 key: "$feature/ab-test-flag".to_string(),
3114 value: json!("control"),
3115 operator: "exact".to_string(),
3116 property_type: None,
3117 };
3118
3119 let properties = HashMap::new();
3120 let ctx = EvaluationContext {
3121 cohorts: &HashMap::new(),
3122 flags: &flags,
3123 distinct_id: "user-gets-control", groups: &HashMap::new(),
3125 group_properties: &HashMap::new(),
3126 group_type_mapping: &HashMap::new(),
3127 };
3128
3129 let result = match_property_with_context(&prop, &properties, &ctx);
3131 assert!(result.is_ok());
3132 }
3133
3134 #[test]
3135 fn test_flag_dependency_not_found_returns_inconclusive() {
3136 let flags = HashMap::new(); let prop = Property {
3139 key: "$feature/nonexistent-flag".to_string(),
3140 value: json!(true),
3141 operator: "exact".to_string(),
3142 property_type: None,
3143 };
3144
3145 let properties = HashMap::new();
3146 let ctx = EvaluationContext {
3147 cohorts: &HashMap::new(),
3148 flags: &flags,
3149 distinct_id: "user-123",
3150 groups: &HashMap::new(),
3151 group_properties: &HashMap::new(),
3152 group_type_mapping: &HashMap::new(),
3153 };
3154
3155 let result = match_property_with_context(&prop, &properties, &ctx);
3156 assert!(result.is_err());
3157 assert!(result.unwrap_err().message.contains("Flag"));
3158 }
3159
3160 #[test]
3163 fn test_parse_relative_date_edge_cases() {
3164 let prop = Property {
3166 key: "date".to_string(),
3167 value: json!("placeholder"),
3168 operator: "is_date_before".to_string(),
3169 property_type: None,
3170 };
3171
3172 let mut properties = HashMap::new();
3173 properties.insert("date".to_string(), json!("2024-01-01"));
3174
3175 let empty_prop = Property {
3177 value: json!(""),
3178 ..prop.clone()
3179 };
3180 assert!(match_property(&empty_prop, &properties).is_err());
3181
3182 let dash_prop = Property {
3184 value: json!("-"),
3185 ..prop.clone()
3186 };
3187 assert!(match_property(&dash_prop, &properties).is_err());
3188
3189 let no_unit_prop = Property {
3191 value: json!("-7"),
3192 ..prop.clone()
3193 };
3194 assert!(match_property(&no_unit_prop, &properties).is_err());
3195
3196 let no_number_prop = Property {
3198 value: json!("-d"),
3199 ..prop.clone()
3200 };
3201 assert!(match_property(&no_number_prop, &properties).is_err());
3202
3203 let invalid_unit_prop = Property {
3205 value: json!("-7x"),
3206 ..prop.clone()
3207 };
3208 assert!(match_property(&invalid_unit_prop, &properties).is_err());
3209 }
3210
3211 #[test]
3212 fn test_parse_relative_date_large_values() {
3213 let prop = Property {
3215 key: "created_at".to_string(),
3216 value: json!("-1000d"), operator: "is_date_before".to_string(),
3218 property_type: None,
3219 };
3220
3221 let mut properties = HashMap::new();
3222 let five_years_ago = chrono::Utc::now() - chrono::Duration::days(1825);
3224 properties.insert(
3225 "created_at".to_string(),
3226 json!(five_years_ago.format("%Y-%m-%d").to_string()),
3227 );
3228 assert!(match_property(&prop, &properties).unwrap());
3229 }
3230
3231 #[test]
3234 fn test_regex_with_invalid_pattern_returns_false() {
3235 let prop = Property {
3237 key: "email".to_string(),
3238 value: json!("(unclosed"),
3239 operator: "regex".to_string(),
3240 property_type: None,
3241 };
3242
3243 let mut properties = HashMap::new();
3244 properties.insert("email".to_string(), json!("test@example.com"));
3245
3246 assert!(!match_property(&prop, &properties).unwrap());
3248 }
3249
3250 #[test]
3251 fn test_not_regex_with_invalid_pattern_returns_true() {
3252 let prop = Property {
3254 key: "email".to_string(),
3255 value: json!("(unclosed"),
3256 operator: "not_regex".to_string(),
3257 property_type: None,
3258 };
3259
3260 let mut properties = HashMap::new();
3261 properties.insert("email".to_string(), json!("test@example.com"));
3262
3263 assert!(match_property(&prop, &properties).unwrap());
3265 }
3266
3267 #[test]
3268 fn test_regex_with_various_invalid_patterns() {
3269 let invalid_patterns = vec![
3270 "(unclosed", "[unclosed", "*invalid", "(?P<bad", r"\", ];
3276
3277 for pattern in invalid_patterns {
3278 let prop = Property {
3279 key: "value".to_string(),
3280 value: json!(pattern),
3281 operator: "regex".to_string(),
3282 property_type: None,
3283 };
3284
3285 let mut properties = HashMap::new();
3286 properties.insert("value".to_string(), json!("test"));
3287
3288 assert!(
3290 !match_property(&prop, &properties).unwrap(),
3291 "Invalid pattern '{}' should return false for regex",
3292 pattern
3293 );
3294
3295 let not_regex_prop = Property {
3297 operator: "not_regex".to_string(),
3298 ..prop
3299 };
3300 assert!(
3301 match_property(¬_regex_prop, &properties).unwrap(),
3302 "Invalid pattern '{}' should return true for not_regex",
3303 pattern
3304 );
3305 }
3306 }
3307
3308 #[test]
3311 fn test_parse_semver_basic() {
3312 assert_eq!(parse_semver("1.2.3"), Some((1, 2, 3)));
3313 assert_eq!(parse_semver("0.0.0"), Some((0, 0, 0)));
3314 assert_eq!(parse_semver("10.20.30"), Some((10, 20, 30)));
3315 }
3316
3317 #[test]
3318 fn test_parse_semver_v_prefix() {
3319 assert_eq!(parse_semver("v1.2.3"), Some((1, 2, 3)));
3320 assert_eq!(parse_semver("V1.2.3"), Some((1, 2, 3)));
3321 }
3322
3323 #[test]
3324 fn test_parse_semver_whitespace() {
3325 assert_eq!(parse_semver(" 1.2.3 "), Some((1, 2, 3)));
3326 assert_eq!(parse_semver(" v1.2.3 "), Some((1, 2, 3)));
3327 }
3328
3329 #[test]
3330 fn test_parse_semver_prerelease_stripped() {
3331 assert_eq!(parse_semver("1.2.3-alpha"), Some((1, 2, 3)));
3332 assert_eq!(parse_semver("1.2.3-beta.1"), Some((1, 2, 3)));
3333 assert_eq!(parse_semver("1.2.3-rc.1+build.123"), Some((1, 2, 3)));
3334 assert_eq!(parse_semver("1.2.3+build.456"), Some((1, 2, 3)));
3335 }
3336
3337 #[test]
3338 fn test_parse_semver_partial_versions() {
3339 assert_eq!(parse_semver("1.2"), Some((1, 2, 0)));
3340 assert_eq!(parse_semver("1"), Some((1, 0, 0)));
3341 assert_eq!(parse_semver("v1.2"), Some((1, 2, 0)));
3342 }
3343
3344 #[test]
3345 fn test_parse_semver_extra_components_ignored() {
3346 assert_eq!(parse_semver("1.2.3.4"), Some((1, 2, 3)));
3347 assert_eq!(parse_semver("1.2.3.4.5.6"), Some((1, 2, 3)));
3348 }
3349
3350 #[test]
3351 fn test_parse_semver_leading_zeros_rejected() {
3352 assert_eq!(parse_semver("01.02.03"), None);
3354 assert_eq!(parse_semver("001.002.003"), None);
3355 assert_eq!(parse_semver("1.07.3"), None);
3356 assert_eq!(parse_semver("1.2.03"), None);
3357 assert_eq!(parse_semver("v01.2.3"), None);
3358
3359 assert_eq!(parse_semver("0.1.0"), Some((0, 1, 0)));
3361 assert_eq!(parse_semver("1.0.0"), Some((1, 0, 0)));
3362 assert_eq!(parse_semver("0.0.0"), Some((0, 0, 0)));
3363 }
3364
3365 #[test]
3366 fn test_parse_semver_invalid() {
3367 assert_eq!(parse_semver(""), None);
3368 assert_eq!(parse_semver(" "), None);
3369 assert_eq!(parse_semver("v"), None);
3370 assert_eq!(parse_semver(".1.2.3"), None);
3371 assert_eq!(parse_semver("abc"), None);
3372 assert_eq!(parse_semver("1.abc.3"), None);
3373 assert_eq!(parse_semver("1.2.abc"), None);
3374 assert_eq!(parse_semver("not-a-version"), None);
3375 }
3376
3377 #[test]
3380 fn test_semver_eq_basic() {
3381 let prop = Property {
3382 key: "version".to_string(),
3383 value: json!("1.2.3"),
3384 operator: "semver_eq".to_string(),
3385 property_type: None,
3386 };
3387
3388 let mut properties = HashMap::new();
3389
3390 properties.insert("version".to_string(), json!("1.2.3"));
3391 assert!(match_property(&prop, &properties).unwrap());
3392
3393 properties.insert("version".to_string(), json!("1.2.4"));
3394 assert!(!match_property(&prop, &properties).unwrap());
3395
3396 properties.insert("version".to_string(), json!("1.3.3"));
3397 assert!(!match_property(&prop, &properties).unwrap());
3398
3399 properties.insert("version".to_string(), json!("2.2.3"));
3400 assert!(!match_property(&prop, &properties).unwrap());
3401 }
3402
3403 #[test]
3404 fn test_semver_eq_with_v_prefix() {
3405 let prop = Property {
3406 key: "version".to_string(),
3407 value: json!("1.2.3"),
3408 operator: "semver_eq".to_string(),
3409 property_type: None,
3410 };
3411
3412 let mut properties = HashMap::new();
3413
3414 properties.insert("version".to_string(), json!("v1.2.3"));
3416 assert!(match_property(&prop, &properties).unwrap());
3417
3418 let prop_with_v = Property {
3420 value: json!("v1.2.3"),
3421 ..prop.clone()
3422 };
3423 properties.insert("version".to_string(), json!("1.2.3"));
3424 assert!(match_property(&prop_with_v, &properties).unwrap());
3425 }
3426
3427 #[test]
3428 fn test_semver_eq_prerelease_stripped() {
3429 let prop = Property {
3430 key: "version".to_string(),
3431 value: json!("1.2.3"),
3432 operator: "semver_eq".to_string(),
3433 property_type: None,
3434 };
3435
3436 let mut properties = HashMap::new();
3437
3438 properties.insert("version".to_string(), json!("1.2.3-alpha"));
3439 assert!(match_property(&prop, &properties).unwrap());
3440
3441 properties.insert("version".to_string(), json!("1.2.3-beta.1"));
3442 assert!(match_property(&prop, &properties).unwrap());
3443
3444 properties.insert("version".to_string(), json!("1.2.3+build.456"));
3445 assert!(match_property(&prop, &properties).unwrap());
3446 }
3447
3448 #[test]
3449 fn test_semver_eq_partial_versions() {
3450 let prop = Property {
3451 key: "version".to_string(),
3452 value: json!("1.2.0"),
3453 operator: "semver_eq".to_string(),
3454 property_type: None,
3455 };
3456
3457 let mut properties = HashMap::new();
3458
3459 properties.insert("version".to_string(), json!("1.2"));
3461 assert!(match_property(&prop, &properties).unwrap());
3462
3463 let partial_prop = Property {
3465 value: json!("1.2"),
3466 ..prop.clone()
3467 };
3468 properties.insert("version".to_string(), json!("1.2.0"));
3469 assert!(match_property(&partial_prop, &properties).unwrap());
3470 }
3471
3472 #[test]
3473 fn test_semver_neq() {
3474 let prop = Property {
3475 key: "version".to_string(),
3476 value: json!("1.2.3"),
3477 operator: "semver_neq".to_string(),
3478 property_type: None,
3479 };
3480
3481 let mut properties = HashMap::new();
3482
3483 properties.insert("version".to_string(), json!("1.2.3"));
3484 assert!(!match_property(&prop, &properties).unwrap());
3485
3486 properties.insert("version".to_string(), json!("1.2.4"));
3487 assert!(match_property(&prop, &properties).unwrap());
3488
3489 properties.insert("version".to_string(), json!("2.0.0"));
3490 assert!(match_property(&prop, &properties).unwrap());
3491 }
3492
3493 #[test]
3496 fn test_semver_gt() {
3497 let prop = Property {
3498 key: "version".to_string(),
3499 value: json!("1.2.3"),
3500 operator: "semver_gt".to_string(),
3501 property_type: None,
3502 };
3503
3504 let mut properties = HashMap::new();
3505
3506 properties.insert("version".to_string(), json!("1.2.4"));
3508 assert!(match_property(&prop, &properties).unwrap());
3509
3510 properties.insert("version".to_string(), json!("1.3.0"));
3511 assert!(match_property(&prop, &properties).unwrap());
3512
3513 properties.insert("version".to_string(), json!("2.0.0"));
3514 assert!(match_property(&prop, &properties).unwrap());
3515
3516 properties.insert("version".to_string(), json!("1.2.3"));
3518 assert!(!match_property(&prop, &properties).unwrap());
3519
3520 properties.insert("version".to_string(), json!("1.2.2"));
3522 assert!(!match_property(&prop, &properties).unwrap());
3523
3524 properties.insert("version".to_string(), json!("1.1.9"));
3525 assert!(!match_property(&prop, &properties).unwrap());
3526
3527 properties.insert("version".to_string(), json!("0.9.9"));
3528 assert!(!match_property(&prop, &properties).unwrap());
3529 }
3530
3531 #[test]
3532 fn test_semver_gte() {
3533 let prop = Property {
3534 key: "version".to_string(),
3535 value: json!("1.2.3"),
3536 operator: "semver_gte".to_string(),
3537 property_type: None,
3538 };
3539
3540 let mut properties = HashMap::new();
3541
3542 properties.insert("version".to_string(), json!("1.2.4"));
3544 assert!(match_property(&prop, &properties).unwrap());
3545
3546 properties.insert("version".to_string(), json!("2.0.0"));
3547 assert!(match_property(&prop, &properties).unwrap());
3548
3549 properties.insert("version".to_string(), json!("1.2.3"));
3551 assert!(match_property(&prop, &properties).unwrap());
3552
3553 properties.insert("version".to_string(), json!("1.2.2"));
3555 assert!(!match_property(&prop, &properties).unwrap());
3556
3557 properties.insert("version".to_string(), json!("0.9.9"));
3558 assert!(!match_property(&prop, &properties).unwrap());
3559 }
3560
3561 #[test]
3562 fn test_semver_lt() {
3563 let prop = Property {
3564 key: "version".to_string(),
3565 value: json!("1.2.3"),
3566 operator: "semver_lt".to_string(),
3567 property_type: None,
3568 };
3569
3570 let mut properties = HashMap::new();
3571
3572 properties.insert("version".to_string(), json!("1.2.2"));
3574 assert!(match_property(&prop, &properties).unwrap());
3575
3576 properties.insert("version".to_string(), json!("1.1.9"));
3577 assert!(match_property(&prop, &properties).unwrap());
3578
3579 properties.insert("version".to_string(), json!("0.9.9"));
3580 assert!(match_property(&prop, &properties).unwrap());
3581
3582 properties.insert("version".to_string(), json!("1.2.3"));
3584 assert!(!match_property(&prop, &properties).unwrap());
3585
3586 properties.insert("version".to_string(), json!("1.2.4"));
3588 assert!(!match_property(&prop, &properties).unwrap());
3589
3590 properties.insert("version".to_string(), json!("2.0.0"));
3591 assert!(!match_property(&prop, &properties).unwrap());
3592 }
3593
3594 #[test]
3595 fn test_semver_lte() {
3596 let prop = Property {
3597 key: "version".to_string(),
3598 value: json!("1.2.3"),
3599 operator: "semver_lte".to_string(),
3600 property_type: None,
3601 };
3602
3603 let mut properties = HashMap::new();
3604
3605 properties.insert("version".to_string(), json!("1.2.2"));
3607 assert!(match_property(&prop, &properties).unwrap());
3608
3609 properties.insert("version".to_string(), json!("0.9.9"));
3610 assert!(match_property(&prop, &properties).unwrap());
3611
3612 properties.insert("version".to_string(), json!("1.2.3"));
3614 assert!(match_property(&prop, &properties).unwrap());
3615
3616 properties.insert("version".to_string(), json!("1.2.4"));
3618 assert!(!match_property(&prop, &properties).unwrap());
3619
3620 properties.insert("version".to_string(), json!("2.0.0"));
3621 assert!(!match_property(&prop, &properties).unwrap());
3622 }
3623
3624 #[test]
3627 fn test_semver_tilde_basic() {
3628 let prop = Property {
3630 key: "version".to_string(),
3631 value: json!("1.2.3"),
3632 operator: "semver_tilde".to_string(),
3633 property_type: None,
3634 };
3635
3636 let mut properties = HashMap::new();
3637
3638 properties.insert("version".to_string(), json!("1.2.3"));
3640 assert!(match_property(&prop, &properties).unwrap());
3641
3642 properties.insert("version".to_string(), json!("1.2.4"));
3644 assert!(match_property(&prop, &properties).unwrap());
3645
3646 properties.insert("version".to_string(), json!("1.2.99"));
3647 assert!(match_property(&prop, &properties).unwrap());
3648
3649 properties.insert("version".to_string(), json!("1.3.0"));
3651 assert!(!match_property(&prop, &properties).unwrap());
3652
3653 properties.insert("version".to_string(), json!("1.3.1"));
3655 assert!(!match_property(&prop, &properties).unwrap());
3656
3657 properties.insert("version".to_string(), json!("2.0.0"));
3658 assert!(!match_property(&prop, &properties).unwrap());
3659
3660 properties.insert("version".to_string(), json!("1.2.2"));
3662 assert!(!match_property(&prop, &properties).unwrap());
3663
3664 properties.insert("version".to_string(), json!("1.1.9"));
3665 assert!(!match_property(&prop, &properties).unwrap());
3666 }
3667
3668 #[test]
3669 fn test_semver_tilde_zero_versions() {
3670 let prop = Property {
3672 key: "version".to_string(),
3673 value: json!("0.2.3"),
3674 operator: "semver_tilde".to_string(),
3675 property_type: None,
3676 };
3677
3678 let mut properties = HashMap::new();
3679
3680 properties.insert("version".to_string(), json!("0.2.3"));
3681 assert!(match_property(&prop, &properties).unwrap());
3682
3683 properties.insert("version".to_string(), json!("0.2.9"));
3684 assert!(match_property(&prop, &properties).unwrap());
3685
3686 properties.insert("version".to_string(), json!("0.3.0"));
3687 assert!(!match_property(&prop, &properties).unwrap());
3688
3689 properties.insert("version".to_string(), json!("0.2.2"));
3690 assert!(!match_property(&prop, &properties).unwrap());
3691 }
3692
3693 #[test]
3696 fn test_semver_caret_major_nonzero() {
3697 let prop = Property {
3699 key: "version".to_string(),
3700 value: json!("1.2.3"),
3701 operator: "semver_caret".to_string(),
3702 property_type: None,
3703 };
3704
3705 let mut properties = HashMap::new();
3706
3707 properties.insert("version".to_string(), json!("1.2.3"));
3709 assert!(match_property(&prop, &properties).unwrap());
3710
3711 properties.insert("version".to_string(), json!("1.2.4"));
3713 assert!(match_property(&prop, &properties).unwrap());
3714
3715 properties.insert("version".to_string(), json!("1.3.0"));
3716 assert!(match_property(&prop, &properties).unwrap());
3717
3718 properties.insert("version".to_string(), json!("1.99.99"));
3719 assert!(match_property(&prop, &properties).unwrap());
3720
3721 properties.insert("version".to_string(), json!("2.0.0"));
3723 assert!(!match_property(&prop, &properties).unwrap());
3724
3725 properties.insert("version".to_string(), json!("2.0.1"));
3727 assert!(!match_property(&prop, &properties).unwrap());
3728
3729 properties.insert("version".to_string(), json!("1.2.2"));
3731 assert!(!match_property(&prop, &properties).unwrap());
3732
3733 properties.insert("version".to_string(), json!("0.9.9"));
3734 assert!(!match_property(&prop, &properties).unwrap());
3735 }
3736
3737 #[test]
3738 fn test_semver_caret_major_zero_minor_nonzero() {
3739 let prop = Property {
3741 key: "version".to_string(),
3742 value: json!("0.2.3"),
3743 operator: "semver_caret".to_string(),
3744 property_type: None,
3745 };
3746
3747 let mut properties = HashMap::new();
3748
3749 properties.insert("version".to_string(), json!("0.2.3"));
3751 assert!(match_property(&prop, &properties).unwrap());
3752
3753 properties.insert("version".to_string(), json!("0.2.4"));
3755 assert!(match_property(&prop, &properties).unwrap());
3756
3757 properties.insert("version".to_string(), json!("0.2.99"));
3758 assert!(match_property(&prop, &properties).unwrap());
3759
3760 properties.insert("version".to_string(), json!("0.3.0"));
3762 assert!(!match_property(&prop, &properties).unwrap());
3763
3764 properties.insert("version".to_string(), json!("0.3.1"));
3766 assert!(!match_property(&prop, &properties).unwrap());
3767
3768 properties.insert("version".to_string(), json!("1.0.0"));
3769 assert!(!match_property(&prop, &properties).unwrap());
3770
3771 properties.insert("version".to_string(), json!("0.2.2"));
3773 assert!(!match_property(&prop, &properties).unwrap());
3774
3775 properties.insert("version".to_string(), json!("0.1.9"));
3776 assert!(!match_property(&prop, &properties).unwrap());
3777 }
3778
3779 #[test]
3780 fn test_semver_caret_major_zero_minor_zero() {
3781 let prop = Property {
3783 key: "version".to_string(),
3784 value: json!("0.0.3"),
3785 operator: "semver_caret".to_string(),
3786 property_type: None,
3787 };
3788
3789 let mut properties = HashMap::new();
3790
3791 properties.insert("version".to_string(), json!("0.0.3"));
3793 assert!(match_property(&prop, &properties).unwrap());
3794
3795 properties.insert("version".to_string(), json!("0.0.4"));
3797 assert!(!match_property(&prop, &properties).unwrap());
3798
3799 properties.insert("version".to_string(), json!("0.0.5"));
3801 assert!(!match_property(&prop, &properties).unwrap());
3802
3803 properties.insert("version".to_string(), json!("0.1.0"));
3804 assert!(!match_property(&prop, &properties).unwrap());
3805
3806 properties.insert("version".to_string(), json!("0.0.2"));
3808 assert!(!match_property(&prop, &properties).unwrap());
3809 }
3810
3811 #[test]
3814 fn test_semver_wildcard_major() {
3815 let prop = Property {
3817 key: "version".to_string(),
3818 value: json!("1.*"),
3819 operator: "semver_wildcard".to_string(),
3820 property_type: None,
3821 };
3822
3823 let mut properties = HashMap::new();
3824
3825 properties.insert("version".to_string(), json!("1.0.0"));
3827 assert!(match_property(&prop, &properties).unwrap());
3828
3829 properties.insert("version".to_string(), json!("1.2.3"));
3831 assert!(match_property(&prop, &properties).unwrap());
3832
3833 properties.insert("version".to_string(), json!("1.99.99"));
3834 assert!(match_property(&prop, &properties).unwrap());
3835
3836 properties.insert("version".to_string(), json!("2.0.0"));
3838 assert!(!match_property(&prop, &properties).unwrap());
3839
3840 properties.insert("version".to_string(), json!("2.0.1"));
3842 assert!(!match_property(&prop, &properties).unwrap());
3843
3844 properties.insert("version".to_string(), json!("0.9.9"));
3846 assert!(!match_property(&prop, &properties).unwrap());
3847 }
3848
3849 #[test]
3850 fn test_semver_wildcard_minor() {
3851 let prop = Property {
3853 key: "version".to_string(),
3854 value: json!("1.2.*"),
3855 operator: "semver_wildcard".to_string(),
3856 property_type: None,
3857 };
3858
3859 let mut properties = HashMap::new();
3860
3861 properties.insert("version".to_string(), json!("1.2.0"));
3863 assert!(match_property(&prop, &properties).unwrap());
3864
3865 properties.insert("version".to_string(), json!("1.2.3"));
3867 assert!(match_property(&prop, &properties).unwrap());
3868
3869 properties.insert("version".to_string(), json!("1.2.99"));
3870 assert!(match_property(&prop, &properties).unwrap());
3871
3872 properties.insert("version".to_string(), json!("1.3.0"));
3874 assert!(!match_property(&prop, &properties).unwrap());
3875
3876 properties.insert("version".to_string(), json!("1.3.1"));
3878 assert!(!match_property(&prop, &properties).unwrap());
3879
3880 properties.insert("version".to_string(), json!("2.0.0"));
3881 assert!(!match_property(&prop, &properties).unwrap());
3882
3883 properties.insert("version".to_string(), json!("1.1.9"));
3885 assert!(!match_property(&prop, &properties).unwrap());
3886 }
3887
3888 #[test]
3889 fn test_semver_wildcard_zero() {
3890 let prop = Property {
3892 key: "version".to_string(),
3893 value: json!("0.*"),
3894 operator: "semver_wildcard".to_string(),
3895 property_type: None,
3896 };
3897
3898 let mut properties = HashMap::new();
3899
3900 properties.insert("version".to_string(), json!("0.0.0"));
3901 assert!(match_property(&prop, &properties).unwrap());
3902
3903 properties.insert("version".to_string(), json!("0.99.99"));
3904 assert!(match_property(&prop, &properties).unwrap());
3905
3906 properties.insert("version".to_string(), json!("1.0.0"));
3907 assert!(!match_property(&prop, &properties).unwrap());
3908 }
3909
3910 #[test]
3913 fn test_semver_invalid_property_value() {
3914 let prop = Property {
3915 key: "version".to_string(),
3916 value: json!("1.2.3"),
3917 operator: "semver_eq".to_string(),
3918 property_type: None,
3919 };
3920
3921 let mut properties = HashMap::new();
3922
3923 properties.insert("version".to_string(), json!("not-a-version"));
3925 assert!(match_property(&prop, &properties).is_err());
3926
3927 properties.insert("version".to_string(), json!(""));
3928 assert!(match_property(&prop, &properties).is_err());
3929
3930 properties.insert("version".to_string(), json!(".1.2.3"));
3931 assert!(match_property(&prop, &properties).is_err());
3932
3933 properties.insert("version".to_string(), json!("abc.def.ghi"));
3934 assert!(match_property(&prop, &properties).is_err());
3935 }
3936
3937 #[test]
3938 fn test_semver_invalid_target_value() {
3939 let mut properties = HashMap::new();
3940 properties.insert("version".to_string(), json!("1.2.3"));
3941
3942 let prop = Property {
3944 key: "version".to_string(),
3945 value: json!("not-valid"),
3946 operator: "semver_eq".to_string(),
3947 property_type: None,
3948 };
3949 assert!(match_property(&prop, &properties).is_err());
3950
3951 let prop = Property {
3952 key: "version".to_string(),
3953 value: json!(""),
3954 operator: "semver_gt".to_string(),
3955 property_type: None,
3956 };
3957 assert!(match_property(&prop, &properties).is_err());
3958 }
3959
3960 #[test]
3961 fn test_semver_invalid_wildcard_pattern() {
3962 let mut properties = HashMap::new();
3963 properties.insert("version".to_string(), json!("1.2.3"));
3964
3965 let invalid_patterns = vec![
3967 "*", "*.2.3", "1.*.3", "1.2.3.*", "abc.*", ];
3973
3974 for pattern in invalid_patterns {
3975 let prop = Property {
3976 key: "version".to_string(),
3977 value: json!(pattern),
3978 operator: "semver_wildcard".to_string(),
3979 property_type: None,
3980 };
3981 assert!(
3982 match_property(&prop, &properties).is_err(),
3983 "Pattern '{}' should be invalid",
3984 pattern
3985 );
3986 }
3987 }
3988
3989 #[test]
3990 fn test_semver_missing_property() {
3991 let prop = Property {
3992 key: "version".to_string(),
3993 value: json!("1.2.3"),
3994 operator: "semver_eq".to_string(),
3995 property_type: None,
3996 };
3997
3998 let properties = HashMap::new(); assert!(match_property(&prop, &properties).is_err());
4000 }
4001
4002 #[test]
4003 fn test_semver_null_property_value() {
4004 let prop = Property {
4005 key: "version".to_string(),
4006 value: json!("1.2.3"),
4007 operator: "semver_eq".to_string(),
4008 property_type: None,
4009 };
4010
4011 let mut properties = HashMap::new();
4012 properties.insert("version".to_string(), json!(null));
4013
4014 assert!(match_property(&prop, &properties).is_err());
4016 }
4017
4018 #[test]
4019 fn test_semver_numeric_property_value() {
4020 let prop = Property {
4022 key: "version".to_string(),
4023 value: json!("1.0.0"),
4024 operator: "semver_eq".to_string(),
4025 property_type: None,
4026 };
4027
4028 let mut properties = HashMap::new();
4029 properties.insert("version".to_string(), json!(1));
4031 assert!(match_property(&prop, &properties).unwrap());
4032 }
4033
4034 #[test]
4037 fn test_semver_four_part_versions() {
4038 let prop = Property {
4039 key: "version".to_string(),
4040 value: json!("1.2.3.4"),
4041 operator: "semver_eq".to_string(),
4042 property_type: None,
4043 };
4044
4045 let mut properties = HashMap::new();
4046
4047 properties.insert("version".to_string(), json!("1.2.3"));
4049 assert!(match_property(&prop, &properties).unwrap());
4050
4051 properties.insert("version".to_string(), json!("1.2.3.4"));
4052 assert!(match_property(&prop, &properties).unwrap());
4053
4054 properties.insert("version".to_string(), json!("1.2.3.999"));
4055 assert!(match_property(&prop, &properties).unwrap());
4056 }
4057
4058 #[test]
4059 fn test_semver_large_version_numbers() {
4060 let prop = Property {
4061 key: "version".to_string(),
4062 value: json!("1000.2000.3000"),
4063 operator: "semver_eq".to_string(),
4064 property_type: None,
4065 };
4066
4067 let mut properties = HashMap::new();
4068 properties.insert("version".to_string(), json!("1000.2000.3000"));
4069 assert!(match_property(&prop, &properties).unwrap());
4070 }
4071
4072 #[test]
4073 fn test_semver_comparison_ordering() {
4074 let cases = vec![
4076 ("0.0.1", "0.0.2", "semver_lt", true),
4077 ("0.1.0", "0.0.99", "semver_gt", true),
4078 ("1.0.0", "0.99.99", "semver_gt", true),
4079 ("1.0.0", "1.0.0", "semver_eq", true),
4080 ("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), ];
4085
4086 for (prop_val, target_val, op, expected) in cases {
4087 let prop = Property {
4088 key: "version".to_string(),
4089 value: json!(target_val),
4090 operator: op.to_string(),
4091 property_type: None,
4092 };
4093
4094 let mut properties = HashMap::new();
4095 properties.insert("version".to_string(), json!(prop_val));
4096
4097 assert_eq!(
4098 match_property(&prop, &properties).unwrap(),
4099 expected,
4100 "{} {} {} should be {}",
4101 prop_val,
4102 op,
4103 target_val,
4104 expected
4105 );
4106 }
4107 }
4108
4109 #[test]
4110 fn test_match_property_semver_rejects_leading_zeros() {
4111 let bad_versions = ["1.07.3", "01.02.03", "1.2.03", "v01.2.3", "001.0.0"];
4116
4117 for bad in bad_versions {
4119 let prop = Property {
4120 key: "version".to_string(),
4121 value: json!("1.2.3"),
4122 operator: "semver_eq".to_string(),
4123 property_type: None,
4124 };
4125 let mut properties = HashMap::new();
4126 properties.insert("version".to_string(), json!(bad));
4127 assert!(
4128 match_property(&prop, &properties).is_err(),
4129 "override '{}' should be rejected",
4130 bad
4131 );
4132 }
4133
4134 for good in ["0.1.0", "1.0.0", "0.0.0"] {
4136 let prop = Property {
4137 key: "version".to_string(),
4138 value: json!(good),
4139 operator: "semver_eq".to_string(),
4140 property_type: None,
4141 };
4142 let mut properties = HashMap::new();
4143 properties.insert("version".to_string(), json!(good));
4144 assert!(
4145 match_property(&prop, &properties).unwrap(),
4146 "'{}' should parse and match itself",
4147 good
4148 );
4149 }
4150
4151 let mut properties = HashMap::new();
4153 properties.insert("version".to_string(), json!("1.2.3"));
4154
4155 for op in ["semver_gt", "semver_caret", "semver_tilde"] {
4156 for bad in bad_versions {
4157 let prop = Property {
4158 key: "version".to_string(),
4159 value: json!(bad),
4160 operator: op.to_string(),
4161 property_type: None,
4162 };
4163 assert!(
4164 match_property(&prop, &properties).is_err(),
4165 "target '{}' for {} should be rejected",
4166 bad,
4167 op
4168 );
4169 }
4170 }
4171
4172 for bad_pattern in ["01.*", "1.07.*", "v01.2.*"] {
4174 let prop = Property {
4175 key: "version".to_string(),
4176 value: json!(bad_pattern),
4177 operator: "semver_wildcard".to_string(),
4178 property_type: None,
4179 };
4180 assert!(
4181 match_property(&prop, &properties).is_err(),
4182 "wildcard target '{}' should be rejected",
4183 bad_pattern
4184 );
4185 }
4186 }
4187
4188 fn early_exit_flag(early_exit: bool) -> FeatureFlag {
4195 FeatureFlag {
4196 key: "early-exit-flag".to_string(),
4197 active: true,
4198 has_experiment: None,
4199 filters: FeatureFlagFilters {
4200 groups: vec![
4201 FeatureFlagCondition {
4204 properties: vec![],
4205 rollout_percentage: Some(0.0),
4206 variant: None,
4207 aggregation_group_type_index: None,
4208 },
4209 FeatureFlagCondition {
4211 properties: vec![],
4212 rollout_percentage: Some(100.0),
4213 variant: None,
4214 aggregation_group_type_index: None,
4215 },
4216 ],
4217 multivariate: None,
4218 payloads: HashMap::new(),
4219 aggregation_group_type_index: None,
4220 early_exit,
4221 },
4222 }
4223 }
4224
4225 macro_rules! test_early_exit {
4226 ($name:ident, $early_exit:expr, $expected:expr) => {
4227 #[test]
4228 fn $name() {
4229 let flag = early_exit_flag($early_exit);
4230 let result = match_feature_flag(
4231 &flag,
4232 "user-123",
4233 &HashMap::new(),
4234 &HashMap::new(),
4235 &HashMap::new(),
4236 &HashMap::new(),
4237 )
4238 .unwrap();
4239 assert_eq!(result, $expected);
4240 }
4241 };
4242 }
4243
4244 test_early_exit!(
4245 test_early_exit_enabled_returns_false_without_evaluating_later_group,
4246 true,
4247 FlagValue::Boolean(false)
4248 );
4249 test_early_exit!(
4250 test_early_exit_unset_falls_through_to_matching_group,
4251 false,
4252 FlagValue::Boolean(true)
4253 );
4254
4255 #[test]
4256 fn test_early_exit_default_is_false_from_json() {
4257 let flag: FeatureFlag = serde_json::from_value(json!({
4260 "key": "early-exit-flag",
4261 "active": true,
4262 "filters": {
4263 "groups": [
4264 { "properties": [], "rollout_percentage": 0.0, "variant": null },
4265 { "properties": [], "rollout_percentage": 100.0, "variant": null }
4266 ]
4267 }
4268 }))
4269 .unwrap();
4270 assert!(!flag.filters.early_exit);
4271 let result = match_feature_flag(
4272 &flag,
4273 "user-123",
4274 &HashMap::new(),
4275 &HashMap::new(),
4276 &HashMap::new(),
4277 &HashMap::new(),
4278 )
4279 .unwrap();
4280 assert_eq!(result, FlagValue::Boolean(true));
4281 }
4282
4283 #[test]
4284 fn test_early_exit_explicit_false_falls_through() {
4285 let flag: FeatureFlag = serde_json::from_value(json!({
4286 "key": "early-exit-flag",
4287 "active": true,
4288 "filters": {
4289 "early_exit": false,
4290 "groups": [
4291 { "properties": [], "rollout_percentage": 0.0, "variant": null },
4292 { "properties": [], "rollout_percentage": 100.0, "variant": null }
4293 ]
4294 }
4295 }))
4296 .unwrap();
4297 assert!(!flag.filters.early_exit);
4298 let result = match_feature_flag(
4299 &flag,
4300 "user-123",
4301 &HashMap::new(),
4302 &HashMap::new(),
4303 &HashMap::new(),
4304 &HashMap::new(),
4305 )
4306 .unwrap();
4307 assert_eq!(result, FlagValue::Boolean(true));
4308 }
4309
4310 #[test]
4311 fn test_early_exit_property_mismatch_does_not_short_circuit() {
4312 let flag = FeatureFlag {
4316 key: "early-exit-flag".to_string(),
4317 active: true,
4318 has_experiment: None,
4319 filters: FeatureFlagFilters {
4320 groups: vec![
4321 FeatureFlagCondition {
4322 properties: vec![Property {
4323 key: "country".to_string(),
4324 value: json!("US"),
4325 operator: "exact".to_string(),
4326 property_type: None,
4327 }],
4328 rollout_percentage: Some(100.0),
4329 variant: None,
4330 aggregation_group_type_index: None,
4331 },
4332 FeatureFlagCondition {
4333 properties: vec![],
4334 rollout_percentage: Some(100.0),
4335 variant: None,
4336 aggregation_group_type_index: None,
4337 },
4338 ],
4339 multivariate: None,
4340 payloads: HashMap::new(),
4341 aggregation_group_type_index: None,
4342 early_exit: true,
4343 },
4344 };
4345
4346 let mut properties = HashMap::new();
4347 properties.insert("country".to_string(), json!("UK")); let result = match_feature_flag(
4350 &flag,
4351 "user-123",
4352 &properties,
4353 &HashMap::new(),
4354 &HashMap::new(),
4355 &HashMap::new(),
4356 )
4357 .unwrap();
4358 assert_eq!(result, FlagValue::Boolean(true));
4360 }
4361
4362 macro_rules! test_early_exit_with_context {
4363 ($name:ident, $early_exit:expr, $expected:expr) => {
4364 #[test]
4365 fn $name() {
4366 let flag = early_exit_flag($early_exit);
4367 let ctx = EvaluationContext {
4368 cohorts: &HashMap::new(),
4369 flags: &HashMap::new(),
4370 distinct_id: "user-123",
4371 groups: &HashMap::new(),
4372 group_properties: &HashMap::new(),
4373 group_type_mapping: &HashMap::new(),
4374 };
4375 let result = match_feature_flag_with_context(&flag, &HashMap::new(), &ctx).unwrap();
4376 assert_eq!(result, $expected);
4377 }
4378 };
4379 }
4380
4381 test_early_exit_with_context!(
4382 test_early_exit_enabled_short_circuits_with_context,
4383 true,
4384 FlagValue::Boolean(false)
4385 );
4386 test_early_exit_with_context!(
4387 test_early_exit_unset_falls_through_with_context,
4388 false,
4389 FlagValue::Boolean(true)
4390 );
4391
4392 #[test]
4393 fn test_early_exit_does_not_short_circuit_when_prior_group_inconclusive() {
4394 let flag = FeatureFlag {
4402 key: "early-exit-flag".to_string(),
4403 active: true,
4404 has_experiment: None,
4405 filters: FeatureFlagFilters {
4406 groups: vec![
4407 FeatureFlagCondition {
4408 properties: vec![],
4409 rollout_percentage: Some(100.0),
4410 variant: None,
4411 aggregation_group_type_index: Some(0),
4412 },
4413 FeatureFlagCondition {
4414 properties: vec![],
4415 rollout_percentage: Some(0.0),
4416 variant: None,
4417 aggregation_group_type_index: None,
4418 },
4419 ],
4420 multivariate: None,
4421 payloads: HashMap::new(),
4422 aggregation_group_type_index: None,
4423 early_exit: true,
4424 },
4425 };
4426
4427 let mut group_type_mapping = HashMap::new();
4428 group_type_mapping.insert("0".to_string(), "company".to_string());
4429
4430 let mut groups = HashMap::new();
4431 groups.insert("company".to_string(), "acme".to_string());
4432
4433 let result = match_feature_flag(
4435 &flag,
4436 "user-123",
4437 &HashMap::new(),
4438 &groups,
4439 &HashMap::new(), &group_type_mapping,
4441 );
4442 assert!(
4443 result.is_err(),
4444 "expected InconclusiveMatchError, got {:?}",
4445 result
4446 );
4447 }
4448}