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 hash_val = result
476 .first_chunk::<8>()
477 .map_or(0, |head| u64::from_be_bytes(*head) >> 4);
478 hash_val as f64 / LONG_SCALE
479}
480
481pub fn get_matching_variant(flag: &FeatureFlag, distinct_id: &str) -> Option<String> {
487 let hash_value = hash_key(&flag.key, distinct_id, VARIANT_HASH_SALT);
488 let variants = flag.filters.multivariate.as_ref()?.variants.as_slice();
489
490 let mut value_min = 0.0;
491 for variant in variants {
492 let value_max = value_min + variant.rollout_percentage / 100.0;
493 if hash_value >= value_min && hash_value < value_max {
494 return Some(variant.key.clone());
495 }
496 value_min = value_max;
497 }
498 None
499}
500
501enum ConditionTarget<'a> {
503 Use {
505 bucketing: String,
506 properties: &'a HashMap<String, serde_json::Value>,
507 },
508 Skip,
510 Inconclusive,
513}
514
515fn resolve_condition_target<'a>(
521 condition: &FeatureFlagCondition,
522 flag_aggregation: Option<i32>,
523 distinct_id: &str,
524 person_properties: &'a HashMap<String, serde_json::Value>,
525 groups: &HashMap<String, String>,
526 group_properties: &'a HashMap<String, HashMap<String, serde_json::Value>>,
527 group_type_mapping: &HashMap<String, String>,
528) -> ConditionTarget<'a> {
529 let effective_aggregation = condition.aggregation_group_type_index.or(flag_aggregation);
532
533 match effective_aggregation {
534 None => ConditionTarget::Use {
535 bucketing: distinct_id.to_string(),
536 properties: person_properties,
537 },
538 Some(idx) => {
539 let key = idx.to_string();
540 let Some(group_type) = group_type_mapping.get(&key) else {
541 return ConditionTarget::Skip;
542 };
543 let Some(group_key) = groups.get(group_type) else {
544 return ConditionTarget::Skip;
545 };
546 let Some(props) = group_properties.get(group_type) else {
547 return ConditionTarget::Inconclusive;
548 };
549 ConditionTarget::Use {
550 bucketing: group_key.clone(),
551 properties: props,
552 }
553 }
554 }
555}
556
557#[must_use = "feature flag evaluation result should be used"]
581#[allow(clippy::too_many_arguments)]
582pub fn match_feature_flag(
583 flag: &FeatureFlag,
584 distinct_id: &str,
585 person_properties: &HashMap<String, serde_json::Value>,
586 groups: &HashMap<String, String>,
587 group_properties: &HashMap<String, HashMap<String, serde_json::Value>>,
588 group_type_mapping: &HashMap<String, String>,
589) -> Result<FlagValue, InconclusiveMatchError> {
590 if !flag.active {
591 return Ok(FlagValue::Boolean(false));
592 }
593
594 let conditions = &flag.filters.groups;
595 let flag_aggregation = flag.filters.aggregation_group_type_index;
596
597 let mut sorted_conditions = conditions.clone();
599 sorted_conditions.sort_by_key(|c| if c.variant.is_some() { 0 } else { 1 });
600
601 let mut is_inconclusive = false;
602
603 for condition in sorted_conditions {
604 let (effective_bucketing, effective_properties) = match resolve_condition_target(
605 &condition,
606 flag_aggregation,
607 distinct_id,
608 person_properties,
609 groups,
610 group_properties,
611 group_type_mapping,
612 ) {
613 ConditionTarget::Use {
614 bucketing,
615 properties,
616 } => (bucketing, properties),
617 ConditionTarget::Skip => continue,
618 ConditionTarget::Inconclusive => {
619 is_inconclusive = true;
620 continue;
621 }
622 };
623
624 match is_condition_match(flag, &effective_bucketing, &condition, effective_properties) {
625 Ok(ConditionMatch::Match) => {
626 if let Some(variant_override) = &condition.variant {
627 if let Some(ref multivariate) = flag.filters.multivariate {
629 let valid_variants: Vec<String> = multivariate
630 .variants
631 .iter()
632 .map(|v| v.key.clone())
633 .collect();
634
635 if valid_variants.contains(variant_override) {
636 return Ok(FlagValue::String(variant_override.clone()));
637 }
638 }
639 }
640
641 if let Some(variant) = get_matching_variant(flag, &effective_bucketing) {
643 return Ok(FlagValue::String(variant));
644 }
645 return Ok(FlagValue::Boolean(true));
646 }
647 Ok(ConditionMatch::OutOfRolloutBound) => {
648 if flag.filters.early_exit && !is_inconclusive {
655 return Ok(FlagValue::Boolean(false));
656 }
657 }
658 Ok(ConditionMatch::NoMatch) => continue,
659 Err(_) => {
660 is_inconclusive = true;
661 }
662 }
663 }
664
665 if is_inconclusive {
666 return Err(InconclusiveMatchError::new(
667 "Can't determine if feature flag is enabled or not with given properties",
668 ));
669 }
670
671 Ok(FlagValue::Boolean(false))
672}
673
674#[derive(Debug, Clone, Copy, PartialEq, Eq)]
679enum ConditionMatch {
680 Match,
683 NoMatch,
685 OutOfRolloutBound,
688}
689
690fn is_condition_match(
691 flag: &FeatureFlag,
692 bucketing_id: &str,
693 condition: &FeatureFlagCondition,
694 properties: &HashMap<String, serde_json::Value>,
695) -> Result<ConditionMatch, InconclusiveMatchError> {
696 for prop in &condition.properties {
698 if !match_property(prop, properties)? {
699 return Ok(ConditionMatch::NoMatch);
700 }
701 }
702
703 if let Some(rollout_percentage) = condition.rollout_percentage {
705 let hash_value = hash_key(&flag.key, bucketing_id, ROLLOUT_HASH_SALT);
706 if hash_value > (rollout_percentage / 100.0) {
707 return Ok(ConditionMatch::OutOfRolloutBound);
708 }
709 }
710
711 Ok(ConditionMatch::Match)
712}
713
714#[must_use = "feature flag evaluation result should be used"]
726pub fn match_feature_flag_with_context(
727 flag: &FeatureFlag,
728 person_properties: &HashMap<String, serde_json::Value>,
729 ctx: &EvaluationContext,
730) -> Result<FlagValue, InconclusiveMatchError> {
731 if !flag.active {
732 return Ok(FlagValue::Boolean(false));
733 }
734
735 let conditions = &flag.filters.groups;
736 let flag_aggregation = flag.filters.aggregation_group_type_index;
737
738 let mut sorted_conditions = conditions.clone();
740 sorted_conditions.sort_by_key(|c| if c.variant.is_some() { 0 } else { 1 });
741
742 let mut is_inconclusive = false;
743
744 for condition in sorted_conditions {
745 let (effective_bucketing, effective_properties) = match resolve_condition_target(
746 &condition,
747 flag_aggregation,
748 ctx.distinct_id,
749 person_properties,
750 ctx.groups,
751 ctx.group_properties,
752 ctx.group_type_mapping,
753 ) {
754 ConditionTarget::Use {
755 bucketing,
756 properties,
757 } => (bucketing, properties),
758 ConditionTarget::Skip => continue,
759 ConditionTarget::Inconclusive => {
760 is_inconclusive = true;
761 continue;
762 }
763 };
764
765 match is_condition_match_with_context(
766 flag,
767 &effective_bucketing,
768 &condition,
769 effective_properties,
770 ctx,
771 ) {
772 Ok(ConditionMatch::Match) => {
773 if let Some(variant_override) = &condition.variant {
774 if let Some(ref multivariate) = flag.filters.multivariate {
776 let valid_variants: Vec<String> = multivariate
777 .variants
778 .iter()
779 .map(|v| v.key.clone())
780 .collect();
781
782 if valid_variants.contains(variant_override) {
783 return Ok(FlagValue::String(variant_override.clone()));
784 }
785 }
786 }
787
788 if let Some(variant) = get_matching_variant(flag, &effective_bucketing) {
790 return Ok(FlagValue::String(variant));
791 }
792 return Ok(FlagValue::Boolean(true));
793 }
794 Ok(ConditionMatch::OutOfRolloutBound) => {
795 if flag.filters.early_exit && !is_inconclusive {
802 return Ok(FlagValue::Boolean(false));
803 }
804 }
805 Ok(ConditionMatch::NoMatch) => continue,
806 Err(_) => {
807 is_inconclusive = true;
808 }
809 }
810 }
811
812 if is_inconclusive {
813 return Err(InconclusiveMatchError::new(
814 "Can't determine if feature flag is enabled or not with given properties",
815 ));
816 }
817
818 Ok(FlagValue::Boolean(false))
819}
820
821fn is_condition_match_with_context(
822 flag: &FeatureFlag,
823 bucketing_id: &str,
824 condition: &FeatureFlagCondition,
825 properties: &HashMap<String, serde_json::Value>,
826 ctx: &EvaluationContext,
827) -> Result<ConditionMatch, InconclusiveMatchError> {
828 for prop in &condition.properties {
830 if !match_property_with_context(prop, properties, ctx)? {
831 return Ok(ConditionMatch::NoMatch);
832 }
833 }
834
835 if let Some(rollout_percentage) = condition.rollout_percentage {
837 let hash_value = hash_key(&flag.key, bucketing_id, ROLLOUT_HASH_SALT);
838 if hash_value > (rollout_percentage / 100.0) {
839 return Ok(ConditionMatch::OutOfRolloutBound);
840 }
841 }
842
843 Ok(ConditionMatch::Match)
844}
845
846pub fn match_property_with_context(
857 property: &Property,
858 properties: &HashMap<String, serde_json::Value>,
859 ctx: &EvaluationContext,
860) -> Result<bool, InconclusiveMatchError> {
861 if property.property_type.as_deref() == Some("cohort") {
863 return match_cohort_property(property, properties, ctx);
864 }
865
866 if property.key.starts_with("$feature/") {
868 return match_flag_dependency_property(property, ctx);
869 }
870
871 match_property(property, properties)
873}
874
875fn match_cohort_property(
877 property: &Property,
878 properties: &HashMap<String, serde_json::Value>,
879 ctx: &EvaluationContext,
880) -> Result<bool, InconclusiveMatchError> {
881 let cohort_id = cohort_id_to_string(&property.value)
882 .ok_or_else(|| InconclusiveMatchError::new("Cohort ID must be a string or number"))?;
883
884 let mut active_cohorts = HashSet::new();
885 let is_in_cohort = match_cohort_by_id(&cohort_id, properties, ctx, &mut active_cohorts, 0)
886 .map_err(CohortMatchError::into_inconclusive)?;
887
888 Ok(match property.operator.as_str() {
890 "exact" | "in" => is_in_cohort,
891 "not_in" => !is_in_cohort,
892 op => {
893 return Err(InconclusiveMatchError::new(&format!(
894 "Unknown cohort operator: {}",
895 op
896 )));
897 }
898 })
899}
900
901fn cohort_id_to_string(value: &serde_json::Value) -> Option<String> {
905 match value {
906 serde_json::Value::String(s) => Some(s.clone()),
907 serde_json::Value::Number(n) => Some(n.to_string()),
908 _ => None,
909 }
910}
911
912#[derive(Debug)]
913enum CohortMatchError {
914 Inconclusive(InconclusiveMatchError),
915 InvalidDefinition(InconclusiveMatchError),
916 MissingCohort(InconclusiveMatchError),
917}
918
919impl CohortMatchError {
920 fn into_inconclusive(self) -> InconclusiveMatchError {
921 match self {
922 Self::Inconclusive(error)
923 | Self::InvalidDefinition(error)
924 | Self::MissingCohort(error) => error,
925 }
926 }
927
928 fn requires_server_evaluation(&self) -> bool {
929 !matches!(self, Self::Inconclusive(_))
930 }
931}
932
933const MAX_COHORT_RESOLUTION_DEPTH: usize = 100;
941
942fn match_cohort_by_id(
950 cohort_id: &str,
951 properties: &HashMap<String, serde_json::Value>,
952 ctx: &EvaluationContext,
953 active_cohorts: &mut HashSet<String>,
954 resolution_depth: usize,
955) -> Result<bool, CohortMatchError> {
956 let cohort = ctx.cohorts.get(cohort_id).ok_or_else(|| {
957 CohortMatchError::MissingCohort(InconclusiveMatchError::new(&format!(
958 "Cohort '{}' not found in local cache",
959 cohort_id
960 )))
961 })?;
962
963 if resolution_depth >= MAX_COHORT_RESOLUTION_DEPTH {
964 return Err(CohortMatchError::InvalidDefinition(
965 InconclusiveMatchError::new(&format!(
966 "Cohort '{}' is nested deeper than the limit of {}",
967 cohort_id, MAX_COHORT_RESOLUTION_DEPTH
968 )),
969 ));
970 }
971
972 if !active_cohorts.insert(cohort_id.to_string()) {
973 return Err(CohortMatchError::InvalidDefinition(
974 InconclusiveMatchError::new(&format!(
975 "Cohort '{}' is part of a reference cycle",
976 cohort_id
977 )),
978 ));
979 }
980
981 let result = match_property_group(
982 &cohort.properties,
983 properties,
984 ctx,
985 active_cohorts,
986 resolution_depth,
987 );
988 active_cohorts.remove(cohort_id);
989 result
990}
991
992fn match_property_group(
1003 group: &serde_json::Value,
1004 properties: &HashMap<String, serde_json::Value>,
1005 ctx: &EvaluationContext,
1006 active_cohorts: &mut HashSet<String>,
1007 resolution_depth: usize,
1008) -> Result<bool, CohortMatchError> {
1009 if resolution_depth >= MAX_COHORT_RESOLUTION_DEPTH {
1010 return Err(CohortMatchError::InvalidDefinition(
1011 InconclusiveMatchError::new(&format!(
1012 "Cohort property groups are nested deeper than the limit of {}",
1013 MAX_COHORT_RESOLUTION_DEPTH
1014 )),
1015 ));
1016 }
1017
1018 if let Some(arr) = group.as_array() {
1019 return match_property_group_values(
1020 "AND",
1021 arr,
1022 properties,
1023 ctx,
1024 active_cohorts,
1025 resolution_depth,
1026 );
1027 }
1028
1029 let Some(obj) = group.as_object() else {
1030 return Err(CohortMatchError::InvalidDefinition(
1031 InconclusiveMatchError::new("Cohort property group must be an object or array"),
1032 ));
1033 };
1034
1035 if obj.is_empty() {
1037 return Ok(true);
1038 }
1039
1040 let group_type = obj.get("type").and_then(|t| t.as_str()).unwrap_or("AND");
1041
1042 let Some(values) = obj.get("values").and_then(|v| v.as_array()) else {
1043 return Err(CohortMatchError::InvalidDefinition(
1044 InconclusiveMatchError::new("Cohort property group values must be an array"),
1045 ));
1046 };
1047
1048 match_property_group_values(
1049 group_type,
1050 values,
1051 properties,
1052 ctx,
1053 active_cohorts,
1054 resolution_depth,
1055 )
1056}
1057
1058fn match_property_group_values(
1066 group_type: &str,
1067 values: &[serde_json::Value],
1068 properties: &HashMap<String, serde_json::Value>,
1069 ctx: &EvaluationContext,
1070 active_cohorts: &mut HashSet<String>,
1071 resolution_depth: usize,
1072) -> Result<bool, CohortMatchError> {
1073 if values.is_empty() {
1074 return Ok(true);
1075 }
1076
1077 let is_and = !group_type.eq_ignore_ascii_case("OR");
1078 let mut decisive_result = None;
1079 let mut inconclusive: Option<CohortMatchError> = None;
1080
1081 for value in values {
1082 let result = if value.get("values").is_some() {
1083 match_property_group(value, properties, ctx, active_cohorts, resolution_depth + 1)
1085 } else if value.get("type").and_then(|t| t.as_str()) == Some("cohort") {
1086 match_nested_cohort(value, properties, ctx, active_cohorts, resolution_depth + 1)
1088 } else {
1089 match serde_json::from_value::<CohortProperty>(value.clone()) {
1091 Ok(prop) => match_property_with_context(&prop.property, properties, ctx)
1092 .map(|matches| matches != prop.negation)
1093 .map_err(CohortMatchError::Inconclusive),
1094 Err(e) => Err(CohortMatchError::InvalidDefinition(
1095 InconclusiveMatchError::new(&format!("Unable to parse cohort property: {}", e)),
1096 )),
1097 }
1098 };
1099
1100 match result {
1101 Ok(true) if !is_and => decisive_result = Some(true),
1102 Ok(false) if is_and => decisive_result = Some(false),
1103 Ok(_) => {}
1104 Err(error) if error.requires_server_evaluation() => return Err(error),
1105 Err(error) => inconclusive = Some(error),
1106 }
1107 }
1108
1109 if let Some(result) = decisive_result {
1110 return Ok(result);
1111 }
1112
1113 if let Some(error) = inconclusive {
1114 return Err(error);
1115 }
1116
1117 Ok(is_and)
1119}
1120
1121fn match_nested_cohort(
1124 value: &serde_json::Value,
1125 properties: &HashMap<String, serde_json::Value>,
1126 ctx: &EvaluationContext,
1127 active_cohorts: &mut HashSet<String>,
1128 resolution_depth: usize,
1129) -> Result<bool, CohortMatchError> {
1130 let cohort_id = value
1131 .get("value")
1132 .and_then(cohort_id_to_string)
1133 .ok_or_else(|| {
1134 CohortMatchError::InvalidDefinition(InconclusiveMatchError::new(
1135 "Nested cohort ID must be a string or number",
1136 ))
1137 })?;
1138
1139 let negation = value
1140 .get("negation")
1141 .and_then(|n| n.as_bool())
1142 .unwrap_or(false);
1143
1144 let is_member = match_cohort_by_id(
1145 &cohort_id,
1146 properties,
1147 ctx,
1148 active_cohorts,
1149 resolution_depth,
1150 )?;
1151 Ok(is_member != negation)
1152}
1153
1154fn match_flag_dependency_property(
1156 property: &Property,
1157 ctx: &EvaluationContext,
1158) -> Result<bool, InconclusiveMatchError> {
1159 let flag_key = property
1161 .key
1162 .strip_prefix("$feature/")
1163 .ok_or_else(|| InconclusiveMatchError::new("Invalid flag dependency format"))?;
1164
1165 let flag = ctx.flags.get(flag_key).ok_or_else(|| {
1166 InconclusiveMatchError::new(&format!("Flag '{}' not found in local cache", flag_key))
1167 })?;
1168
1169 let empty_props = HashMap::new();
1172 let flag_value = match_feature_flag(
1173 flag,
1174 ctx.distinct_id,
1175 &empty_props,
1176 ctx.groups,
1177 ctx.group_properties,
1178 ctx.group_type_mapping,
1179 )?;
1180
1181 let expected = &property.value;
1183
1184 let matches = match (&flag_value, expected) {
1185 (FlagValue::Boolean(b), serde_json::Value::Bool(expected_b)) => b == expected_b,
1186 (FlagValue::String(s), serde_json::Value::String(expected_s)) => {
1187 s.eq_ignore_ascii_case(expected_s)
1188 }
1189 (FlagValue::Boolean(true), serde_json::Value::String(s)) => {
1190 s.is_empty() || s == "true"
1193 }
1194 (FlagValue::Boolean(false), serde_json::Value::String(s)) => s.is_empty() || s == "false",
1195 (FlagValue::String(s), serde_json::Value::Bool(true)) => {
1196 !s.is_empty()
1198 }
1199 (FlagValue::String(_), serde_json::Value::Bool(false)) => false,
1200 _ => false,
1201 };
1202
1203 Ok(match property.operator.as_str() {
1205 "exact" => matches,
1206 "is_not" => !matches,
1207 op => {
1208 return Err(InconclusiveMatchError::new(&format!(
1209 "Unknown flag dependency operator: {}",
1210 op
1211 )));
1212 }
1213 })
1214}
1215
1216fn parse_relative_date(value: &str) -> Option<DateTime<Utc>> {
1219 let value = value.trim();
1220 if value.len() < 3 || !value.starts_with('-') {
1222 return None;
1223 }
1224
1225 let (num_str, unit) = value[1..].split_at(value.len() - 2);
1226 let num: i64 = num_str.parse().ok()?;
1227
1228 let duration = match unit {
1229 "h" => chrono::Duration::hours(num),
1230 "d" => chrono::Duration::days(num),
1231 "w" => chrono::Duration::weeks(num),
1232 "m" => chrono::Duration::days(num * 30), "y" => chrono::Duration::days(num * 365), _ => return None,
1235 };
1236
1237 Some(Utc::now() - duration)
1238}
1239
1240fn parse_date_value(value: &serde_json::Value) -> Option<DateTime<Utc>> {
1242 let date_str = value.as_str()?;
1243
1244 if date_str.starts_with('-') && date_str.len() > 1 {
1246 if let Some(dt) = parse_relative_date(date_str) {
1247 return Some(dt);
1248 }
1249 }
1250
1251 if let Ok(dt) = DateTime::parse_from_rfc3339(date_str) {
1253 return Some(dt.with_timezone(&Utc));
1254 }
1255
1256 if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
1258 return Some(
1259 date.and_hms_opt(0, 0, 0)
1260 .expect("midnight is always valid")
1261 .and_utc(),
1262 );
1263 }
1264
1265 None
1266}
1267
1268type SemverTuple = (u64, u64, u64);
1270
1271fn parse_semver(value: &str) -> Option<SemverTuple> {
1283 let value = value.trim();
1284 if value.is_empty() {
1285 return None;
1286 }
1287
1288 let value = value
1290 .strip_prefix('v')
1291 .or_else(|| value.strip_prefix('V'))
1292 .unwrap_or(value);
1293 if value.is_empty() {
1294 return None;
1295 }
1296
1297 let value = value.split(['-', '+']).next().unwrap_or(value);
1299 if value.is_empty() {
1300 return None;
1301 }
1302
1303 if value.starts_with('.') {
1305 return None;
1306 }
1307
1308 let parts: Vec<&str> = value.split('.').collect();
1310 if parts.is_empty() {
1311 return None;
1312 }
1313
1314 let major = parse_semver_numeric(parts.first()?)?;
1315 let minor = parts.get(1).map_or(Some(0), |s| parse_semver_numeric(s))?;
1316 let patch = parts.get(2).map_or(Some(0), |s| parse_semver_numeric(s))?;
1317
1318 Some((major, minor, patch))
1319}
1320
1321fn parse_semver_numeric(part: &str) -> Option<u64> {
1326 if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
1327 return None;
1328 }
1329 if part.len() > 1 && part.starts_with('0') {
1330 return None;
1331 }
1332 part.parse().ok()
1333}
1334
1335fn parse_semver_wildcard(pattern: &str) -> Option<(SemverTuple, SemverTuple)> {
1338 let pattern = pattern.trim();
1339 if pattern.is_empty() {
1340 return None;
1341 }
1342
1343 let pattern = pattern
1345 .strip_prefix('v')
1346 .or_else(|| pattern.strip_prefix('V'))
1347 .unwrap_or(pattern);
1348 if pattern.is_empty() {
1349 return None;
1350 }
1351
1352 let parts: Vec<&str> = pattern.split('.').collect();
1353
1354 match parts.as_slice() {
1355 [major_str, "*"] => {
1357 let major = parse_semver_numeric(major_str)?;
1358 Some(((major, 0, 0), (major + 1, 0, 0)))
1359 }
1360 [major_str, minor_str, "*"] => {
1362 let major = parse_semver_numeric(major_str)?;
1363 let minor = parse_semver_numeric(minor_str)?;
1364 Some(((major, minor, 0), (major, minor + 1, 0)))
1365 }
1366 _ => None,
1367 }
1368}
1369
1370fn compute_tilde_bounds(version: SemverTuple) -> (SemverTuple, SemverTuple) {
1372 let (major, minor, patch) = version;
1373 ((major, minor, patch), (major, minor + 1, 0))
1374}
1375
1376fn compute_caret_bounds(version: SemverTuple) -> (SemverTuple, SemverTuple) {
1381 let (major, minor, patch) = version;
1382 if major > 0 {
1383 ((major, minor, patch), (major + 1, 0, 0))
1384 } else if minor > 0 {
1385 ((0, minor, patch), (0, minor + 1, 0))
1386 } else {
1387 ((0, 0, patch), (0, 0, patch + 1))
1388 }
1389}
1390
1391fn parse_target_semver(
1392 target_value: &serde_json::Value,
1393) -> Result<SemverTuple, InconclusiveMatchError> {
1394 let target_str = value_to_string(target_value);
1395 parse_semver(&target_str).ok_or_else(|| {
1396 InconclusiveMatchError::new(&format!(
1397 "Unable to parse target semver value: {:?}",
1398 target_value
1399 ))
1400 })
1401}
1402
1403fn match_property(
1404 property: &Property,
1405 properties: &HashMap<String, serde_json::Value>,
1406) -> Result<bool, InconclusiveMatchError> {
1407 let value = match properties.get(&property.key) {
1408 Some(v) => v,
1409 None => {
1410 return Err(InconclusiveMatchError::new(&format!(
1411 "Property '{}' not found in provided properties",
1412 property.key
1413 )));
1414 }
1415 };
1416
1417 let parse_property_semver = || {
1418 let prop_str = value_to_string(value);
1419 parse_semver(&prop_str).ok_or_else(|| {
1420 InconclusiveMatchError::new(&format!(
1421 "Unable to parse property semver value for '{}': {:?}",
1422 property.key, value
1423 ))
1424 })
1425 };
1426 let parse_semver_operands = || {
1427 Ok((
1428 parse_property_semver()?,
1429 parse_target_semver(&property.value)?,
1430 ))
1431 };
1432
1433 Ok(match property.operator.as_str() {
1434 "exact" => compute_exact_match(&property.value, value),
1435 "is_not" => !compute_exact_match(&property.value, value),
1436 "is_set" => true, "is_not_set" => false, "icontains" => {
1439 let prop_str = value_to_string(value);
1440 let search_str = value_to_string(&property.value);
1441 prop_str
1442 .to_ascii_lowercase()
1443 .contains(&search_str.to_ascii_lowercase())
1444 }
1445 "not_icontains" => {
1446 let prop_str = value_to_string(value);
1447 let search_str = value_to_string(&property.value);
1448 !prop_str
1449 .to_ascii_lowercase()
1450 .contains(&search_str.to_ascii_lowercase())
1451 }
1452 "starts_with" => {
1453 let prop_str = value_to_string(value);
1454 let search_str = value_to_string(&property.value);
1455 prop_str
1456 .to_ascii_lowercase()
1457 .starts_with(&search_str.to_ascii_lowercase())
1458 }
1459 "not_starts_with" => {
1460 let prop_str = value_to_string(value);
1461 let search_str = value_to_string(&property.value);
1462 !prop_str
1463 .to_ascii_lowercase()
1464 .starts_with(&search_str.to_ascii_lowercase())
1465 }
1466 "ends_with" => {
1467 let prop_str = value_to_string(value);
1468 let search_str = value_to_string(&property.value);
1469 prop_str
1470 .to_ascii_lowercase()
1471 .ends_with(&search_str.to_ascii_lowercase())
1472 }
1473 "not_ends_with" => {
1474 let prop_str = value_to_string(value);
1475 let search_str = value_to_string(&property.value);
1476 !prop_str
1477 .to_ascii_lowercase()
1478 .ends_with(&search_str.to_ascii_lowercase())
1479 }
1480 "regex" => {
1481 let prop_str = value_to_string(value);
1482 let regex_str = value_to_string(&property.value);
1483 get_cached_regex(®ex_str)
1484 .map(|re| re.is_match(&prop_str))
1485 .unwrap_or(false)
1486 }
1487 "not_regex" => {
1488 let prop_str = value_to_string(value);
1489 let regex_str = value_to_string(&property.value);
1490 get_cached_regex(®ex_str)
1491 .map(|re| !re.is_match(&prop_str))
1492 .unwrap_or(true)
1493 }
1494 "gt" | "gte" | "lt" | "lte" => compare_numeric(&property.operator, &property.value, value),
1495 "is_date_before" | "is_date_after" => {
1496 let target_date = parse_date_value(&property.value).ok_or_else(|| {
1497 InconclusiveMatchError::new(&format!(
1498 "Unable to parse target date value: {:?}",
1499 property.value
1500 ))
1501 })?;
1502
1503 let prop_date = parse_date_value(value).ok_or_else(|| {
1504 InconclusiveMatchError::new(&format!(
1505 "Unable to parse property date value for '{}': {:?}",
1506 property.key, value
1507 ))
1508 })?;
1509
1510 if property.operator == "is_date_before" {
1511 prop_date < target_date
1512 } else {
1513 prop_date > target_date
1514 }
1515 }
1516 "semver_eq" | "semver_neq" | "semver_gt" | "semver_gte" | "semver_lt" | "semver_lte" => {
1518 let (prop_version, target_version) = parse_semver_operands()?;
1519
1520 match property.operator.as_str() {
1521 "semver_eq" => prop_version == target_version,
1522 "semver_neq" => prop_version != target_version,
1523 "semver_gt" => prop_version > target_version,
1524 "semver_gte" => prop_version >= target_version,
1525 "semver_lt" => prop_version < target_version,
1526 "semver_lte" => prop_version <= target_version,
1527 _ => unreachable!(),
1528 }
1529 }
1530 "semver_tilde" => {
1531 let (prop_version, target_version) = parse_semver_operands()?;
1532 let (lower, upper) = compute_tilde_bounds(target_version);
1533 prop_version >= lower && prop_version < upper
1534 }
1535 "semver_caret" => {
1536 let (prop_version, target_version) = parse_semver_operands()?;
1537 let (lower, upper) = compute_caret_bounds(target_version);
1538 prop_version >= lower && prop_version < upper
1539 }
1540 "semver_wildcard" => {
1541 let prop_version = parse_property_semver()?;
1542 let target_str = value_to_string(&property.value);
1543
1544 let (lower, upper) = parse_semver_wildcard(&target_str).ok_or_else(|| {
1545 InconclusiveMatchError::new(&format!(
1546 "Unable to parse target semver wildcard pattern: {:?}",
1547 property.value
1548 ))
1549 })?;
1550
1551 prop_version >= lower && prop_version < upper
1552 }
1553 unknown => {
1554 return Err(InconclusiveMatchError::new(&format!(
1555 "Unknown operator: {}",
1556 unknown
1557 )));
1558 }
1559 })
1560}
1561
1562fn compute_exact_match(value: &serde_json::Value, override_value: &serde_json::Value) -> bool {
1563 if is_truthy_or_falsy_property_value(value) {
1564 return is_truthy_property_value(value) == is_truthy_property_value(override_value);
1565 }
1566
1567 if let Some(values) = value.as_array() {
1568 return values
1569 .iter()
1570 .any(|candidate| compare_values(candidate, override_value));
1571 }
1572
1573 compare_values(value, override_value)
1574}
1575
1576fn is_truthy_or_falsy_property_value(value: &serde_json::Value) -> bool {
1577 match value {
1578 serde_json::Value::Bool(_) => true,
1579 serde_json::Value::String(value) => {
1580 value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("false")
1581 }
1582 serde_json::Value::Array(values) => values.iter().all(is_truthy_or_falsy_property_value),
1583 _ => false,
1584 }
1585}
1586
1587fn is_truthy_property_value(value: &serde_json::Value) -> bool {
1588 match value {
1589 serde_json::Value::Bool(value) => *value,
1590 serde_json::Value::String(value) => value.eq_ignore_ascii_case("true"),
1591 serde_json::Value::Array(values) => values.iter().all(is_truthy_property_value),
1592 _ => false,
1593 }
1594}
1595
1596fn compare_values(a: &serde_json::Value, b: &serde_json::Value) -> bool {
1597 value_to_string(a).to_lowercase() == value_to_string(b).to_lowercase()
1598}
1599
1600fn value_to_string(value: &serde_json::Value) -> String {
1601 match value {
1602 serde_json::Value::String(s) => s.clone(),
1603 serde_json::Value::Number(n) => n.to_string(),
1604 serde_json::Value::Bool(b) => b.to_string(),
1605 _ => value.to_string(),
1606 }
1607}
1608
1609fn compare_numeric(
1610 operator: &str,
1611 property_value: &serde_json::Value,
1612 value: &serde_json::Value,
1613) -> bool {
1614 let prop_num = match property_value {
1615 serde_json::Value::Number(n) => n.as_f64(),
1616 serde_json::Value::String(s) => s.parse::<f64>().ok(),
1617 _ => None,
1618 };
1619
1620 let val_num = match value {
1621 serde_json::Value::Number(n) => n.as_f64(),
1622 serde_json::Value::String(s) => s.parse::<f64>().ok(),
1623 _ => None,
1624 };
1625
1626 if let (Some(prop), Some(val)) = (prop_num, val_num) {
1627 match operator {
1628 "gt" => val > prop,
1629 "gte" => val >= prop,
1630 "lt" => val < prop,
1631 "lte" => val <= prop,
1632 _ => false,
1633 }
1634 } else {
1635 let prop_str = value_to_string(property_value);
1637 let val_str = value_to_string(value);
1638 match operator {
1639 "gt" => val_str > prop_str,
1640 "gte" => val_str >= prop_str,
1641 "lt" => val_str < prop_str,
1642 "lte" => val_str <= prop_str,
1643 _ => false,
1644 }
1645 }
1646}
1647
1648#[cfg(test)]
1649mod tests {
1650 use super::*;
1651 use serde_json::json;
1652
1653 const TEST_SALT: &str = "test-salt";
1655
1656 #[test]
1657 fn test_hash_key() {
1658 let hash = hash_key("test-flag", "user-123", TEST_SALT);
1659 assert!((0.0..=1.0).contains(&hash));
1660
1661 let hash2 = hash_key("test-flag", "user-123", TEST_SALT);
1663 assert_eq!(hash, hash2);
1664
1665 let hash3 = hash_key("test-flag", "user-456", TEST_SALT);
1667 assert_ne!(hash, hash3);
1668 }
1669
1670 #[test]
1674 fn test_hash_key_matches_known_vectors() {
1675 for (key, distinct_id, salt, expected) in [
1676 ("test-flag", "user-123", TEST_SALT, 0.982_062_667_408_254_5),
1677 ("test-flag", "user-456", TEST_SALT, 0.695_145_973_300_181_1),
1678 (
1679 "beta-feature",
1680 "distinct_id",
1681 ROLLOUT_HASH_SALT,
1682 0.875_596_347_947_407_8,
1683 ),
1684 (
1685 "beta-feature",
1686 "distinct_id",
1687 VARIANT_HASH_SALT,
1688 0.228_302_715_824_090_7,
1689 ),
1690 (
1691 "multivariate-flag",
1692 "user_1",
1693 ROLLOUT_HASH_SALT,
1694 0.223_607_742_058_685_7,
1695 ),
1696 ] {
1697 assert_eq!(
1698 hash_key(key, distinct_id, salt),
1699 expected,
1700 "hash_key({key:?}, {distinct_id:?}, {salt:?}) drifted from the other SDKs"
1701 );
1702 }
1703 }
1704
1705 #[test]
1706 fn test_simple_flag_match() {
1707 let flag = FeatureFlag {
1708 key: "test-flag".to_string(),
1709 active: true,
1710 has_experiment: None,
1711 filters: FeatureFlagFilters {
1712 groups: vec![FeatureFlagCondition {
1713 properties: vec![],
1714 rollout_percentage: Some(100.0),
1715 variant: None,
1716 aggregation_group_type_index: None,
1717 }],
1718 multivariate: None,
1719 payloads: HashMap::new(),
1720 aggregation_group_type_index: None,
1721 early_exit: false,
1722 },
1723 };
1724
1725 let properties = HashMap::new();
1726 let result = match_feature_flag(
1727 &flag,
1728 "user-123",
1729 &properties,
1730 &HashMap::new(),
1731 &HashMap::new(),
1732 &HashMap::new(),
1733 )
1734 .unwrap();
1735 assert_eq!(result, FlagValue::Boolean(true));
1736 }
1737
1738 #[test]
1739 fn test_property_matching() {
1740 let prop = Property {
1741 key: "country".to_string(),
1742 value: json!("US"),
1743 operator: "exact".to_string(),
1744 property_type: None,
1745 };
1746
1747 let mut properties = HashMap::new();
1748 properties.insert("country".to_string(), json!("US"));
1749
1750 assert!(match_property(&prop, &properties).unwrap());
1751
1752 properties.insert("country".to_string(), json!("UK"));
1753 assert!(!match_property(&prop, &properties).unwrap());
1754 }
1755
1756 #[test]
1757 fn test_property_case_folding_matches_flags_service() {
1758 let matches = |operator: &str, expected, actual| {
1759 let property = Property {
1760 key: "key".to_string(),
1761 value: expected,
1762 operator: operator.to_string(),
1763 property_type: None,
1764 };
1765 match_property(&property, &HashMap::from([("key".to_string(), actual)])).unwrap()
1766 };
1767
1768 assert_eq!(value_to_string(&json!(323.0)), "323.0");
1770
1771 let cases = [
1772 ("exact", json!("PRO"), json!("pro"), true),
1774 ("exact", json!("Ä"), json!("ä"), true),
1775 ("exact", json!("ß"), json!("ss"), false),
1776 ("exact", json!("Σ"), json!("ς"), false),
1777 ("exact", json!("ΟΣ"), json!("ος"), true),
1778 ("exact", json!("ΟΣ"), json!("οσ"), false),
1779 ("exact", json!("İ"), json!("i\u{0307}"), true),
1780 ("exact", json!("İ"), json!("i"), false),
1781 ("exact", json!(323), json!("323"), true),
1782 ("exact", json!(["FREE", "PRÖ"]), json!("prö"), true),
1783 ("is_not", json!(["FREE", "PRÖ"]), json!("prö"), false),
1784 ("is_not", json!(["FREE", "PRÖ"]), json!("team"), true),
1785 ("exact", json!("323.0"), json!(323.0), true),
1786 ("exact", json!("323"), json!(323.0), false),
1787 ("icontains", json!("ADMIN"), json!("admin-user"), true),
1789 ("icontains", json!("Ä"), json!("äbc"), false),
1790 ("not_icontains", json!("Ä"), json!("äbc"), true),
1791 ("starts_with", json!("Ä"), json!("äbc"), false),
1792 ("not_starts_with", json!("Ä"), json!("äbc"), true),
1793 ("ends_with", json!("Ä"), json!("bcä"), false),
1794 ("not_ends_with", json!("Ä"), json!("bcä"), true),
1795 ];
1796
1797 for (operator, expected, actual, should_match) in cases {
1798 let result = matches(operator, expected.clone(), actual.clone());
1799 assert_eq!(
1800 result, should_match,
1801 "operator {operator} comparing {actual} against {expected}"
1802 );
1803 }
1804 }
1805
1806 #[test]
1807 fn test_exact_boolean_coercion_matches_flags_service() {
1808 let matches = |operator: &str, expected, actual| {
1809 let property = Property {
1810 key: "key".to_string(),
1811 value: expected,
1812 operator: operator.to_string(),
1813 property_type: None,
1814 };
1815 match_property(&property, &HashMap::from([("key".to_string(), actual)])).unwrap()
1816 };
1817
1818 let cases = [
1819 (json!(false), json!("banana"), true),
1821 (json!("false"), json!(0), true),
1822 (json!(["false"]), json!(null), true),
1823 (json!(["true", "false"]), json!("true"), false),
1824 (json!(["true", "false"]), json!("pro"), true),
1825 (json!([]), json!(true), true),
1827 (json!([]), json!("true"), true),
1828 (json!([]), json!([]), true),
1829 (json!([]), json!([true]), true),
1830 (json!([]), json!(false), false),
1831 (json!([]), json!("banana"), false),
1832 (json!(["FREE", "PRO"]), json!("pro"), true),
1834 (json!(["FREE", "PRO"]), json!("team"), false),
1835 ];
1836
1837 for (expected, actual, exact_match) in cases {
1838 assert_eq!(
1839 matches("exact", expected.clone(), actual.clone()),
1840 exact_match,
1841 "exact comparing {actual} against {expected}"
1842 );
1843 assert_eq!(
1844 matches("is_not", expected.clone(), actual.clone()),
1845 !exact_match,
1846 "is_not comparing {actual} against {expected}"
1847 );
1848 }
1849 }
1850
1851 #[test]
1852 fn test_null_property_operator_defaults_to_exact() {
1853 let prop: Property = serde_json::from_value(json!({
1854 "key": "country",
1855 "value": "US",
1856 "operator": null,
1857 "type": "person"
1858 }))
1859 .unwrap();
1860
1861 assert_eq!(prop.operator, "exact");
1862 }
1863
1864 #[test]
1865 fn test_multivariate_variants() {
1866 let flag = FeatureFlag {
1867 key: "test-flag".to_string(),
1868 active: true,
1869 has_experiment: None,
1870 filters: FeatureFlagFilters {
1871 groups: vec![FeatureFlagCondition {
1872 properties: vec![],
1873 rollout_percentage: Some(100.0),
1874 variant: None,
1875 aggregation_group_type_index: None,
1876 }],
1877 multivariate: Some(MultivariateFilter {
1878 variants: vec![
1879 MultivariateVariant {
1880 key: "control".to_string(),
1881 rollout_percentage: 50.0,
1882 },
1883 MultivariateVariant {
1884 key: "test".to_string(),
1885 rollout_percentage: 50.0,
1886 },
1887 ],
1888 }),
1889 payloads: HashMap::new(),
1890 aggregation_group_type_index: None,
1891 early_exit: false,
1892 },
1893 };
1894
1895 let properties = HashMap::new();
1896 let result = match_feature_flag(
1897 &flag,
1898 "user-123",
1899 &properties,
1900 &HashMap::new(),
1901 &HashMap::new(),
1902 &HashMap::new(),
1903 )
1904 .unwrap();
1905
1906 match result {
1907 FlagValue::String(variant) => {
1908 assert!(variant == "control" || variant == "test");
1909 }
1910 _ => panic!("Expected string variant"),
1911 }
1912 }
1913
1914 #[test]
1915 fn test_inactive_flag() {
1916 let flag = FeatureFlag {
1917 key: "inactive-flag".to_string(),
1918 active: false,
1919 has_experiment: None,
1920 filters: FeatureFlagFilters {
1921 groups: vec![FeatureFlagCondition {
1922 properties: vec![],
1923 rollout_percentage: Some(100.0),
1924 variant: None,
1925 aggregation_group_type_index: None,
1926 }],
1927 multivariate: None,
1928 payloads: HashMap::new(),
1929 aggregation_group_type_index: None,
1930 early_exit: false,
1931 },
1932 };
1933
1934 let properties = HashMap::new();
1935 let result = match_feature_flag(
1936 &flag,
1937 "user-123",
1938 &properties,
1939 &HashMap::new(),
1940 &HashMap::new(),
1941 &HashMap::new(),
1942 )
1943 .unwrap();
1944 assert_eq!(result, FlagValue::Boolean(false));
1945 }
1946
1947 #[test]
1948 fn test_rollout_percentage() {
1949 let flag = FeatureFlag {
1950 key: "rollout-flag".to_string(),
1951 active: true,
1952 has_experiment: None,
1953 filters: FeatureFlagFilters {
1954 groups: vec![FeatureFlagCondition {
1955 properties: vec![],
1956 rollout_percentage: Some(30.0), variant: None,
1958 aggregation_group_type_index: None,
1959 }],
1960 multivariate: None,
1961 payloads: HashMap::new(),
1962 aggregation_group_type_index: None,
1963 early_exit: false,
1964 },
1965 };
1966
1967 let properties = HashMap::new();
1968
1969 let mut enabled_count = 0;
1971 for i in 0..1000 {
1972 let result = match_feature_flag(
1973 &flag,
1974 &format!("user-{}", i),
1975 &properties,
1976 &HashMap::new(),
1977 &HashMap::new(),
1978 &HashMap::new(),
1979 )
1980 .unwrap();
1981 if result == FlagValue::Boolean(true) {
1982 enabled_count += 1;
1983 }
1984 }
1985
1986 assert!(enabled_count > 250 && enabled_count < 350);
1988 }
1989
1990 #[test]
1991 fn test_regex_operator() {
1992 let prop = Property {
1993 key: "email".to_string(),
1994 value: json!(".*@company\\.com$"),
1995 operator: "regex".to_string(),
1996 property_type: None,
1997 };
1998
1999 let mut properties = HashMap::new();
2000 properties.insert("email".to_string(), json!("user@company.com"));
2001 assert!(match_property(&prop, &properties).unwrap());
2002
2003 properties.insert("email".to_string(), json!("user@example.com"));
2004 assert!(!match_property(&prop, &properties).unwrap());
2005 }
2006
2007 #[test]
2008 fn test_icontains_operator() {
2009 let prop = Property {
2010 key: "name".to_string(),
2011 value: json!("ADMIN"),
2012 operator: "icontains".to_string(),
2013 property_type: None,
2014 };
2015
2016 let mut properties = HashMap::new();
2017 properties.insert("name".to_string(), json!("admin_user"));
2018 assert!(match_property(&prop, &properties).unwrap());
2019
2020 properties.insert("name".to_string(), json!("regular_user"));
2021 assert!(!match_property(&prop, &properties).unwrap());
2022 }
2023
2024 #[test]
2025 fn test_starts_with_operator() {
2026 let prop = Property {
2027 key: "name".to_string(),
2028 value: json!("Val"),
2029 operator: "starts_with".to_string(),
2030 property_type: None,
2031 };
2032
2033 let mut properties = HashMap::new();
2035 properties.insert("name".to_string(), json!("value"));
2036 assert!(match_property(&prop, &properties).unwrap());
2037
2038 properties.insert("name".to_string(), json!("VALUE"));
2039 assert!(match_property(&prop, &properties).unwrap());
2040
2041 properties.insert("name".to_string(), json!("prevalue"));
2043 assert!(!match_property(&prop, &properties).unwrap());
2044
2045 properties.insert("name".to_string(), json!("Alakazam"));
2046 assert!(!match_property(&prop, &properties).unwrap());
2047
2048 let numeric_prop = Property {
2050 key: "name".to_string(),
2051 value: json!("3"),
2052 operator: "starts_with".to_string(),
2053 property_type: None,
2054 };
2055
2056 properties.insert("name".to_string(), json!(323));
2057 assert!(match_property(&numeric_prop, &properties).unwrap());
2058
2059 properties.insert("name".to_string(), json!(123));
2060 assert!(!match_property(&numeric_prop, &properties).unwrap());
2061
2062 let negated_prop = Property {
2063 key: "name".to_string(),
2064 value: json!("Val"),
2065 operator: "not_starts_with".to_string(),
2066 property_type: None,
2067 };
2068
2069 properties.insert("name".to_string(), json!("value"));
2070 assert!(!match_property(&negated_prop, &properties).unwrap());
2071
2072 properties.insert("name".to_string(), json!("prevalue"));
2073 assert!(match_property(&negated_prop, &properties).unwrap());
2074
2075 assert!(match_property(&prop, &HashMap::new()).is_err());
2077 }
2078
2079 #[test]
2080 fn test_ends_with_operator() {
2081 let prop = Property {
2082 key: "name".to_string(),
2083 value: json!("lUe"),
2084 operator: "ends_with".to_string(),
2085 property_type: None,
2086 };
2087
2088 let mut properties = HashMap::new();
2090 properties.insert("name".to_string(), json!("value"));
2091 assert!(match_property(&prop, &properties).unwrap());
2092
2093 properties.insert("name".to_string(), json!("VALUE"));
2094 assert!(match_property(&prop, &properties).unwrap());
2095
2096 properties.insert("name".to_string(), json!("value2"));
2098 assert!(!match_property(&prop, &properties).unwrap());
2099
2100 properties.insert("name".to_string(), json!("Alakazam"));
2101 assert!(!match_property(&prop, &properties).unwrap());
2102
2103 let numeric_prop = Property {
2105 key: "name".to_string(),
2106 value: json!("3"),
2107 operator: "ends_with".to_string(),
2108 property_type: None,
2109 };
2110
2111 properties.insert("name".to_string(), json!(323));
2112 assert!(match_property(&numeric_prop, &properties).unwrap());
2113
2114 properties.insert("name".to_string(), json!(321));
2115 assert!(!match_property(&numeric_prop, &properties).unwrap());
2116
2117 let negated_prop = Property {
2118 key: "name".to_string(),
2119 value: json!("lUe"),
2120 operator: "not_ends_with".to_string(),
2121 property_type: None,
2122 };
2123
2124 properties.insert("name".to_string(), json!("value"));
2125 assert!(!match_property(&negated_prop, &properties).unwrap());
2126
2127 properties.insert("name".to_string(), json!("value2"));
2128 assert!(match_property(&negated_prop, &properties).unwrap());
2129
2130 assert!(match_property(&prop, &HashMap::new()).is_err());
2132 }
2133
2134 #[test]
2135 fn test_numeric_operators() {
2136 let prop_gt = Property {
2138 key: "age".to_string(),
2139 value: json!(18),
2140 operator: "gt".to_string(),
2141 property_type: None,
2142 };
2143
2144 let mut properties = HashMap::new();
2145 properties.insert("age".to_string(), json!(25));
2146 assert!(match_property(&prop_gt, &properties).unwrap());
2147
2148 properties.insert("age".to_string(), json!(15));
2149 assert!(!match_property(&prop_gt, &properties).unwrap());
2150
2151 let prop_lte = Property {
2153 key: "score".to_string(),
2154 value: json!(100),
2155 operator: "lte".to_string(),
2156 property_type: None,
2157 };
2158
2159 properties.insert("score".to_string(), json!(100));
2160 assert!(match_property(&prop_lte, &properties).unwrap());
2161
2162 properties.insert("score".to_string(), json!(101));
2163 assert!(!match_property(&prop_lte, &properties).unwrap());
2164 }
2165
2166 #[test]
2167 fn test_is_set_operator() {
2168 let prop = Property {
2169 key: "email".to_string(),
2170 value: json!(true),
2171 operator: "is_set".to_string(),
2172 property_type: None,
2173 };
2174
2175 let mut properties = HashMap::new();
2176 for value in [
2177 json!(null),
2178 json!(false),
2179 json!(0),
2180 json!(""),
2181 json!([]),
2182 json!({}),
2183 ] {
2184 properties.insert("email".to_string(), value);
2185 assert!(match_property(&prop, &properties).unwrap());
2186 }
2187
2188 properties.remove("email");
2189 assert!(matches!(
2190 match_property(&prop, &properties),
2191 Err(InconclusiveMatchError { .. })
2192 ));
2193 }
2194
2195 #[test]
2196 fn test_is_not_set_operator() {
2197 let prop = Property {
2198 key: "phone".to_string(),
2199 value: json!(true),
2200 operator: "is_not_set".to_string(),
2201 property_type: None,
2202 };
2203
2204 let mut properties = HashMap::new();
2205 for value in [
2206 json!(null),
2207 json!(false),
2208 json!(0),
2209 json!(""),
2210 json!([]),
2211 json!({}),
2212 ] {
2213 properties.insert("phone".to_string(), value);
2214 assert!(!match_property(&prop, &properties).unwrap());
2215 }
2216
2217 properties.remove("phone");
2218 assert!(matches!(
2219 match_property(&prop, &properties),
2220 Err(InconclusiveMatchError { .. })
2221 ));
2222 }
2223
2224 #[test]
2225 fn test_empty_groups() {
2226 let flag = FeatureFlag {
2227 key: "empty-groups".to_string(),
2228 active: true,
2229 has_experiment: None,
2230 filters: FeatureFlagFilters {
2231 groups: vec![],
2232 multivariate: None,
2233 payloads: HashMap::new(),
2234 aggregation_group_type_index: None,
2235 early_exit: false,
2236 },
2237 };
2238
2239 let properties = HashMap::new();
2240 let result = match_feature_flag(
2241 &flag,
2242 "user-123",
2243 &properties,
2244 &HashMap::new(),
2245 &HashMap::new(),
2246 &HashMap::new(),
2247 )
2248 .unwrap();
2249 assert_eq!(result, FlagValue::Boolean(false));
2250 }
2251
2252 #[test]
2253 fn test_hash_scale_constant() {
2254 assert_eq!(LONG_SCALE, 0xFFFFFFFFFFFFFFFu64 as f64);
2256 assert_ne!(LONG_SCALE, 0xFFFFFFFFFFFFFFFFu64 as f64);
2257 }
2258
2259 #[test]
2262 fn test_unknown_operator_returns_inconclusive_error() {
2263 let prop = Property {
2264 key: "status".to_string(),
2265 value: json!("active"),
2266 operator: "unknown_operator".to_string(),
2267 property_type: None,
2268 };
2269
2270 let mut properties = HashMap::new();
2271 properties.insert("status".to_string(), json!("active"));
2272
2273 let result = match_property(&prop, &properties);
2274 assert!(result.is_err());
2275 let err = result.unwrap_err();
2276 assert!(err.message.contains("unknown_operator"));
2277 }
2278
2279 #[test]
2280 fn test_is_date_before_with_relative_date() {
2281 let prop = Property {
2282 key: "signup_date".to_string(),
2283 value: json!("-7d"), operator: "is_date_before".to_string(),
2285 property_type: None,
2286 };
2287
2288 let mut properties = HashMap::new();
2289 let ten_days_ago = chrono::Utc::now() - chrono::Duration::days(10);
2291 properties.insert(
2292 "signup_date".to_string(),
2293 json!(ten_days_ago.format("%Y-%m-%d").to_string()),
2294 );
2295 assert!(match_property(&prop, &properties).unwrap());
2296
2297 let three_days_ago = chrono::Utc::now() - chrono::Duration::days(3);
2299 properties.insert(
2300 "signup_date".to_string(),
2301 json!(three_days_ago.format("%Y-%m-%d").to_string()),
2302 );
2303 assert!(!match_property(&prop, &properties).unwrap());
2304 }
2305
2306 #[test]
2307 fn test_is_date_after_with_relative_date() {
2308 let prop = Property {
2309 key: "last_seen".to_string(),
2310 value: json!("-30d"), operator: "is_date_after".to_string(),
2312 property_type: None,
2313 };
2314
2315 let mut properties = HashMap::new();
2316 let ten_days_ago = chrono::Utc::now() - chrono::Duration::days(10);
2318 properties.insert(
2319 "last_seen".to_string(),
2320 json!(ten_days_ago.format("%Y-%m-%d").to_string()),
2321 );
2322 assert!(match_property(&prop, &properties).unwrap());
2323
2324 let sixty_days_ago = chrono::Utc::now() - chrono::Duration::days(60);
2326 properties.insert(
2327 "last_seen".to_string(),
2328 json!(sixty_days_ago.format("%Y-%m-%d").to_string()),
2329 );
2330 assert!(!match_property(&prop, &properties).unwrap());
2331 }
2332
2333 #[test]
2334 fn test_is_date_before_with_iso_date() {
2335 let prop = Property {
2336 key: "expiry_date".to_string(),
2337 value: json!("2024-06-15"),
2338 operator: "is_date_before".to_string(),
2339 property_type: None,
2340 };
2341
2342 let mut properties = HashMap::new();
2343 properties.insert("expiry_date".to_string(), json!("2024-06-10"));
2344 assert!(match_property(&prop, &properties).unwrap());
2345
2346 properties.insert("expiry_date".to_string(), json!("2024-06-20"));
2347 assert!(!match_property(&prop, &properties).unwrap());
2348 }
2349
2350 #[test]
2351 fn test_is_date_after_with_iso_date() {
2352 let prop = Property {
2353 key: "start_date".to_string(),
2354 value: json!("2024-01-01"),
2355 operator: "is_date_after".to_string(),
2356 property_type: None,
2357 };
2358
2359 let mut properties = HashMap::new();
2360 properties.insert("start_date".to_string(), json!("2024-03-15"));
2361 assert!(match_property(&prop, &properties).unwrap());
2362
2363 properties.insert("start_date".to_string(), json!("2023-12-01"));
2364 assert!(!match_property(&prop, &properties).unwrap());
2365 }
2366
2367 #[test]
2368 fn test_is_date_with_relative_hours() {
2369 let prop = Property {
2370 key: "last_active".to_string(),
2371 value: json!("-24h"), operator: "is_date_after".to_string(),
2373 property_type: None,
2374 };
2375
2376 let mut properties = HashMap::new();
2377 let twelve_hours_ago = chrono::Utc::now() - chrono::Duration::hours(12);
2379 properties.insert(
2380 "last_active".to_string(),
2381 json!(twelve_hours_ago.to_rfc3339()),
2382 );
2383 assert!(match_property(&prop, &properties).unwrap());
2384
2385 let forty_eight_hours_ago = chrono::Utc::now() - chrono::Duration::hours(48);
2387 properties.insert(
2388 "last_active".to_string(),
2389 json!(forty_eight_hours_ago.to_rfc3339()),
2390 );
2391 assert!(!match_property(&prop, &properties).unwrap());
2392 }
2393
2394 #[test]
2395 fn test_is_date_with_relative_weeks() {
2396 let prop = Property {
2397 key: "joined".to_string(),
2398 value: json!("-2w"), operator: "is_date_before".to_string(),
2400 property_type: None,
2401 };
2402
2403 let mut properties = HashMap::new();
2404 let three_weeks_ago = chrono::Utc::now() - chrono::Duration::weeks(3);
2406 properties.insert(
2407 "joined".to_string(),
2408 json!(three_weeks_ago.format("%Y-%m-%d").to_string()),
2409 );
2410 assert!(match_property(&prop, &properties).unwrap());
2411
2412 let one_week_ago = chrono::Utc::now() - chrono::Duration::weeks(1);
2414 properties.insert(
2415 "joined".to_string(),
2416 json!(one_week_ago.format("%Y-%m-%d").to_string()),
2417 );
2418 assert!(!match_property(&prop, &properties).unwrap());
2419 }
2420
2421 #[test]
2422 fn test_is_date_with_relative_months() {
2423 let prop = Property {
2424 key: "subscription_date".to_string(),
2425 value: json!("-3m"), operator: "is_date_after".to_string(),
2427 property_type: None,
2428 };
2429
2430 let mut properties = HashMap::new();
2431 let one_month_ago = chrono::Utc::now() - chrono::Duration::days(30);
2433 properties.insert(
2434 "subscription_date".to_string(),
2435 json!(one_month_ago.format("%Y-%m-%d").to_string()),
2436 );
2437 assert!(match_property(&prop, &properties).unwrap());
2438
2439 let six_months_ago = chrono::Utc::now() - chrono::Duration::days(180);
2441 properties.insert(
2442 "subscription_date".to_string(),
2443 json!(six_months_ago.format("%Y-%m-%d").to_string()),
2444 );
2445 assert!(!match_property(&prop, &properties).unwrap());
2446 }
2447
2448 #[test]
2449 fn test_is_date_with_relative_years() {
2450 let prop = Property {
2451 key: "created_at".to_string(),
2452 value: json!("-1y"), operator: "is_date_before".to_string(),
2454 property_type: None,
2455 };
2456
2457 let mut properties = HashMap::new();
2458 let two_years_ago = chrono::Utc::now() - chrono::Duration::days(730);
2460 properties.insert(
2461 "created_at".to_string(),
2462 json!(two_years_ago.format("%Y-%m-%d").to_string()),
2463 );
2464 assert!(match_property(&prop, &properties).unwrap());
2465
2466 let six_months_ago = chrono::Utc::now() - chrono::Duration::days(180);
2468 properties.insert(
2469 "created_at".to_string(),
2470 json!(six_months_ago.format("%Y-%m-%d").to_string()),
2471 );
2472 assert!(!match_property(&prop, &properties).unwrap());
2473 }
2474
2475 #[test]
2476 fn test_is_date_with_invalid_date_format() {
2477 let prop = Property {
2478 key: "date".to_string(),
2479 value: json!("-7d"),
2480 operator: "is_date_before".to_string(),
2481 property_type: None,
2482 };
2483
2484 let mut properties = HashMap::new();
2485 properties.insert("date".to_string(), json!("not-a-date"));
2486
2487 let result = match_property(&prop, &properties);
2489 assert!(result.is_err());
2490 }
2491
2492 #[test]
2493 fn test_is_date_with_iso_datetime() {
2494 let prop = Property {
2495 key: "event_time".to_string(),
2496 value: json!("2024-06-15T10:30:00Z"),
2497 operator: "is_date_before".to_string(),
2498 property_type: None,
2499 };
2500
2501 let mut properties = HashMap::new();
2502 properties.insert("event_time".to_string(), json!("2024-06-15T08:00:00Z"));
2503 assert!(match_property(&prop, &properties).unwrap());
2504
2505 properties.insert("event_time".to_string(), json!("2024-06-15T12:00:00Z"));
2506 assert!(!match_property(&prop, &properties).unwrap());
2507 }
2508
2509 #[test]
2512 fn test_cohort_membership_in() {
2513 let mut cohorts = HashMap::new();
2515 cohorts.insert(
2516 "cohort_1".to_string(),
2517 CohortDefinition::new(
2518 "cohort_1".to_string(),
2519 vec![Property {
2520 key: "country".to_string(),
2521 value: json!("US"),
2522 operator: "exact".to_string(),
2523 property_type: None,
2524 }],
2525 ),
2526 );
2527
2528 let prop = Property {
2530 key: "$cohort".to_string(),
2531 value: json!("cohort_1"),
2532 operator: "in".to_string(),
2533 property_type: Some("cohort".to_string()),
2534 };
2535
2536 let mut properties = HashMap::new();
2538 properties.insert("country".to_string(), json!("US"));
2539
2540 let ctx = EvaluationContext {
2541 cohorts: &cohorts,
2542 flags: &HashMap::new(),
2543 distinct_id: "user-123",
2544 groups: &HashMap::new(),
2545 group_properties: &HashMap::new(),
2546 group_type_mapping: &HashMap::new(),
2547 };
2548 assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
2549
2550 properties.insert("country".to_string(), json!("UK"));
2552 assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
2553 }
2554
2555 #[test]
2556 fn test_cohort_membership_not_in() {
2557 let mut cohorts = HashMap::new();
2558 cohorts.insert(
2559 "cohort_blocked".to_string(),
2560 CohortDefinition::new(
2561 "cohort_blocked".to_string(),
2562 vec![Property {
2563 key: "status".to_string(),
2564 value: json!("blocked"),
2565 operator: "exact".to_string(),
2566 property_type: None,
2567 }],
2568 ),
2569 );
2570
2571 let prop = Property {
2572 key: "$cohort".to_string(),
2573 value: json!("cohort_blocked"),
2574 operator: "not_in".to_string(),
2575 property_type: Some("cohort".to_string()),
2576 };
2577
2578 let mut properties = HashMap::new();
2579 properties.insert("status".to_string(), json!("active"));
2580
2581 let ctx = EvaluationContext {
2582 cohorts: &cohorts,
2583 flags: &HashMap::new(),
2584 distinct_id: "user-123",
2585 groups: &HashMap::new(),
2586 group_properties: &HashMap::new(),
2587 group_type_mapping: &HashMap::new(),
2588 };
2589 assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
2591
2592 properties.insert("status".to_string(), json!("blocked"));
2594 assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
2595 }
2596
2597 #[test]
2598 fn test_cohort_not_found_returns_inconclusive() {
2599 let cohorts = HashMap::new(); let prop = Property {
2602 key: "$cohort".to_string(),
2603 value: json!("nonexistent_cohort"),
2604 operator: "in".to_string(),
2605 property_type: Some("cohort".to_string()),
2606 };
2607
2608 let properties = HashMap::new();
2609 let ctx = EvaluationContext {
2610 cohorts: &cohorts,
2611 flags: &HashMap::new(),
2612 distinct_id: "user-123",
2613 groups: &HashMap::new(),
2614 group_properties: &HashMap::new(),
2615 group_type_mapping: &HashMap::new(),
2616 };
2617
2618 let result = match_property_with_context(&prop, &properties, &ctx);
2619 assert!(result.is_err());
2620 assert!(result.unwrap_err().message.contains("Cohort"));
2621 }
2622
2623 fn cohort_ctx(cohorts: &HashMap<String, CohortDefinition>) -> EvaluationContext<'_> {
2626 EvaluationContext {
2627 cohorts,
2628 flags: EMPTY_FLAGS.get_or_init(HashMap::new),
2629 distinct_id: "user-123",
2630 groups: EMPTY_GROUPS.get_or_init(HashMap::new),
2631 group_properties: EMPTY_GROUP_PROPS.get_or_init(HashMap::new),
2632 group_type_mapping: EMPTY_GROUP_MAPPING.get_or_init(HashMap::new),
2633 }
2634 }
2635
2636 static EMPTY_FLAGS: OnceLock<HashMap<String, FeatureFlag>> = OnceLock::new();
2637 static EMPTY_GROUPS: OnceLock<HashMap<String, String>> = OnceLock::new();
2638 static EMPTY_GROUP_PROPS: OnceLock<HashMap<String, HashMap<String, serde_json::Value>>> =
2639 OnceLock::new();
2640 static EMPTY_GROUP_MAPPING: OnceLock<HashMap<String, String>> = OnceLock::new();
2641
2642 #[test]
2645 fn test_cohort_or_group() {
2646 let mut cohorts = HashMap::new();
2647 cohorts.insert(
2648 "cohort_or".to_string(),
2649 CohortDefinition {
2650 id: "cohort_or".to_string(),
2651 properties: json!({
2652 "type": "OR",
2653 "values": [
2654 {"key": "country", "value": "US", "operator": "exact", "type": "person"},
2655 {"key": "country", "value": "CA", "operator": "exact", "type": "person"},
2656 ],
2657 }),
2658 },
2659 );
2660
2661 let prop = Property {
2662 key: "$cohort".to_string(),
2663 value: json!("cohort_or"),
2664 operator: "in".to_string(),
2665 property_type: Some("cohort".to_string()),
2666 };
2667
2668 let ctx = cohort_ctx(&cohorts);
2669
2670 let mut properties = HashMap::new();
2672 properties.insert("country".to_string(), json!("US"));
2673 assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
2674
2675 properties.insert("country".to_string(), json!("CA"));
2676 assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
2677
2678 properties.insert("country".to_string(), json!("UK"));
2680 assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
2681 }
2682
2683 #[test]
2686 fn test_cohort_nested_and_of_or() {
2687 let mut cohorts = HashMap::new();
2688 cohorts.insert(
2689 "cohort_nested".to_string(),
2690 CohortDefinition {
2691 id: "cohort_nested".to_string(),
2692 properties: json!({
2693 "type": "AND",
2694 "values": [
2695 {"key": "plan", "value": "paid", "operator": "exact", "type": "person"},
2696 {
2697 "type": "OR",
2698 "values": [
2699 {"key": "country", "value": "US", "operator": "exact", "type": "person"},
2700 {"key": "country", "value": "CA", "operator": "exact", "type": "person"},
2701 ],
2702 },
2703 ],
2704 }),
2705 },
2706 );
2707
2708 let prop = Property {
2709 key: "$cohort".to_string(),
2710 value: json!("cohort_nested"),
2711 operator: "in".to_string(),
2712 property_type: Some("cohort".to_string()),
2713 };
2714
2715 let ctx = cohort_ctx(&cohorts);
2716
2717 let mut properties = HashMap::new();
2719 properties.insert("plan".to_string(), json!("paid"));
2720 properties.insert("country".to_string(), json!("CA"));
2721 assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
2722
2723 properties.insert("country".to_string(), json!("UK"));
2726 assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
2727
2728 properties.insert("plan".to_string(), json!("free"));
2730 properties.insert("country".to_string(), json!("US"));
2731 assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
2732 }
2733
2734 #[test]
2737 fn test_cohort_nested_cohort_reference() {
2738 let mut cohorts = HashMap::new();
2739 cohorts.insert(
2740 "child".to_string(),
2741 CohortDefinition {
2742 id: "child".to_string(),
2743 properties: json!({
2744 "type": "AND",
2745 "values": [
2746 {"key": "country", "value": "US", "operator": "exact", "type": "person"},
2747 ],
2748 }),
2749 },
2750 );
2751 cohorts.insert(
2752 "parent".to_string(),
2753 CohortDefinition {
2754 id: "parent".to_string(),
2755 properties: json!({
2756 "type": "AND",
2757 "values": [
2758 {"type": "cohort", "value": "child", "negation": false},
2759 ],
2760 }),
2761 },
2762 );
2763
2764 let prop = Property {
2765 key: "$cohort".to_string(),
2766 value: json!("parent"),
2767 operator: "in".to_string(),
2768 property_type: Some("cohort".to_string()),
2769 };
2770
2771 let ctx = cohort_ctx(&cohorts);
2772
2773 let mut properties = HashMap::new();
2774 properties.insert("country".to_string(), json!("US"));
2775 assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
2776
2777 properties.insert("country".to_string(), json!("UK"));
2778 assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
2779 }
2780
2781 #[test]
2785 fn test_cyclic_cohort_reference_is_inconclusive() {
2786 let mut cohorts = HashMap::new();
2787 cohorts.insert(
2788 "a".to_string(),
2789 CohortDefinition {
2790 id: "a".to_string(),
2791 properties: json!({
2792 "type": "AND",
2793 "values": [{"type": "cohort", "value": "b", "negation": false}],
2794 }),
2795 },
2796 );
2797 cohorts.insert(
2798 "b".to_string(),
2799 CohortDefinition {
2800 id: "b".to_string(),
2801 properties: json!({
2802 "type": "AND",
2803 "values": [{"type": "cohort", "value": "a", "negation": false}],
2804 }),
2805 },
2806 );
2807
2808 let prop = Property {
2809 key: "$cohort".to_string(),
2810 value: json!("a"),
2811 operator: "in".to_string(),
2812 property_type: Some("cohort".to_string()),
2813 };
2814 let ctx = cohort_ctx(&cohorts);
2815
2816 let error = match_property_with_context(&prop, &HashMap::new(), &ctx)
2817 .expect_err("a cohort cycle must not resolve locally");
2818 assert!(
2819 error.to_string().contains("cycle"),
2820 "error should name the cycle, got: {}",
2821 error
2822 );
2823 }
2824
2825 #[test]
2830 fn test_deep_acyclic_cohort_chain_is_inconclusive() {
2831 let chain_len = MAX_COHORT_RESOLUTION_DEPTH + 50;
2832 let mut cohorts = HashMap::new();
2833 for link in 0..chain_len {
2834 let properties = if link == chain_len - 1 {
2835 json!({
2836 "type": "AND",
2837 "values": [
2838 {"key": "country", "value": "US", "operator": "exact", "type": "person"},
2839 ],
2840 })
2841 } else {
2842 json!({
2843 "type": "AND",
2844 "values": [{"type": "cohort", "value": (link + 1).to_string()}],
2845 })
2846 };
2847 cohorts.insert(
2848 link.to_string(),
2849 CohortDefinition {
2850 id: link.to_string(),
2851 properties,
2852 },
2853 );
2854 }
2855
2856 let prop = Property {
2857 key: "$cohort".to_string(),
2858 value: json!("0"),
2859 operator: "in".to_string(),
2860 property_type: Some("cohort".to_string()),
2861 };
2862 let ctx = cohort_ctx(&cohorts);
2863 let mut properties = HashMap::new();
2864 properties.insert("country".to_string(), json!("US"));
2865
2866 assert!(
2867 match_property_with_context(&prop, &properties, &ctx).is_err(),
2868 "a cohort chain deeper than the limit must not resolve locally"
2869 );
2870 }
2871
2872 #[test]
2874 fn test_cohort_chain_within_the_depth_limit_still_resolves() {
2875 let chain_len = 10;
2876 let mut cohorts = HashMap::new();
2877 for link in 0..chain_len {
2878 let properties = if link == chain_len - 1 {
2879 json!({
2880 "type": "AND",
2881 "values": [
2882 {"key": "country", "value": "US", "operator": "exact", "type": "person"},
2883 ],
2884 })
2885 } else {
2886 json!({
2887 "type": "AND",
2888 "values": [{"type": "cohort", "value": (link + 1).to_string()}],
2889 })
2890 };
2891 cohorts.insert(
2892 link.to_string(),
2893 CohortDefinition {
2894 id: link.to_string(),
2895 properties,
2896 },
2897 );
2898 }
2899
2900 let prop = Property {
2901 key: "$cohort".to_string(),
2902 value: json!("0"),
2903 operator: "in".to_string(),
2904 property_type: Some("cohort".to_string()),
2905 };
2906 let ctx = cohort_ctx(&cohorts);
2907 let mut properties = HashMap::new();
2908 properties.insert("country".to_string(), json!("US"));
2909
2910 assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
2911 }
2912
2913 #[test]
2914 fn test_self_referencing_cohort_is_inconclusive() {
2915 let mut cohorts = HashMap::new();
2916 cohorts.insert(
2917 "loop".to_string(),
2918 CohortDefinition {
2919 id: "loop".to_string(),
2920 properties: json!({
2921 "type": "AND",
2922 "values": [{"type": "cohort", "value": "loop", "negation": false}],
2923 }),
2924 },
2925 );
2926
2927 let prop = Property {
2928 key: "$cohort".to_string(),
2929 value: json!("loop"),
2930 operator: "in".to_string(),
2931 property_type: Some("cohort".to_string()),
2932 };
2933 let ctx = cohort_ctx(&cohorts);
2934
2935 assert!(match_property_with_context(&prop, &HashMap::new(), &ctx).is_err());
2936 }
2937
2938 #[test]
2942 fn test_repeated_cohort_reference_is_not_a_cycle() {
2943 let mut cohorts = HashMap::new();
2944 cohorts.insert(
2945 "shared".to_string(),
2946 CohortDefinition {
2947 id: "shared".to_string(),
2948 properties: json!({
2949 "type": "AND",
2950 "values": [
2951 {"key": "country", "value": "US", "operator": "exact", "type": "person"},
2952 ],
2953 }),
2954 },
2955 );
2956 for branch in ["left", "right"] {
2957 cohorts.insert(
2958 branch.to_string(),
2959 CohortDefinition {
2960 id: branch.to_string(),
2961 properties: json!({
2962 "type": "AND",
2963 "values": [{"type": "cohort", "value": "shared", "negation": false}],
2964 }),
2965 },
2966 );
2967 }
2968 cohorts.insert(
2969 "parent".to_string(),
2970 CohortDefinition {
2971 id: "parent".to_string(),
2972 properties: json!({
2973 "type": "AND",
2974 "values": [
2975 {"type": "cohort", "value": "left", "negation": false},
2976 {"type": "cohort", "value": "right", "negation": false},
2977 ],
2978 }),
2979 },
2980 );
2981
2982 let prop = Property {
2983 key: "$cohort".to_string(),
2984 value: json!("parent"),
2985 operator: "in".to_string(),
2986 property_type: Some("cohort".to_string()),
2987 };
2988 let ctx = cohort_ctx(&cohorts);
2989
2990 let mut properties = HashMap::new();
2991 properties.insert("country".to_string(), json!("US"));
2992 assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
2993
2994 properties.insert("country".to_string(), json!("UK"));
2995 assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
2996 }
2997
2998 #[test]
2999 fn test_cohort_leaf_negation() {
3000 let mut cohorts = HashMap::new();
3001 cohorts.insert(
3002 "negated_leaf".to_string(),
3003 CohortDefinition {
3004 id: "negated_leaf".to_string(),
3005 properties: json!({
3006 "type": "AND",
3007 "values": [
3008 {
3009 "key": "country",
3010 "value": "US",
3011 "operator": "exact",
3012 "type": "person",
3013 "negation": true
3014 },
3015 ],
3016 }),
3017 },
3018 );
3019
3020 let prop = Property {
3021 key: "$cohort".to_string(),
3022 value: json!("negated_leaf"),
3023 operator: "in".to_string(),
3024 property_type: Some("cohort".to_string()),
3025 };
3026 let ctx = cohort_ctx(&cohorts);
3027 let mut properties = HashMap::new();
3028
3029 properties.insert("country".to_string(), json!("US"));
3030 assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
3031
3032 properties.insert("country".to_string(), json!("UK"));
3033 assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
3034 }
3035
3036 #[test]
3037 fn test_missing_nested_cohort_is_not_suppressed() {
3038 let missing_cohort = json!({"type": "cohort", "value": "missing"});
3039 let country_leaf = json!({
3040 "key": "country",
3041 "value": "US",
3042 "operator": "exact",
3043 "type": "person"
3044 });
3045 let mut cohorts = HashMap::new();
3046 cohorts.insert(
3047 "or_parent".to_string(),
3048 CohortDefinition {
3049 id: "or_parent".to_string(),
3050 properties: json!({
3051 "type": "OR",
3052 "values": [country_leaf.clone(), missing_cohort.clone()],
3053 }),
3054 },
3055 );
3056 cohorts.insert(
3057 "and_parent".to_string(),
3058 CohortDefinition {
3059 id: "and_parent".to_string(),
3060 properties: json!({
3061 "type": "AND",
3062 "values": [country_leaf, missing_cohort],
3063 }),
3064 },
3065 );
3066
3067 let ctx = cohort_ctx(&cohorts);
3068 for (cohort_id, country) in [("or_parent", "US"), ("and_parent", "UK")] {
3069 let prop = Property {
3070 key: "$cohort".to_string(),
3071 value: json!(cohort_id),
3072 operator: "in".to_string(),
3073 property_type: Some("cohort".to_string()),
3074 };
3075 let properties = HashMap::from([("country".to_string(), json!(country))]);
3076
3077 assert!(match_property_with_context(&prop, &properties, &ctx).is_err());
3078 }
3079 }
3080
3081 #[test]
3082 fn test_malformed_cohort_groups_are_inconclusive() {
3083 let mut cohorts = HashMap::new();
3084 cohorts.insert(
3085 "scalar".to_string(),
3086 CohortDefinition {
3087 id: "scalar".to_string(),
3088 properties: json!("invalid"),
3089 },
3090 );
3091 cohorts.insert(
3092 "object_values".to_string(),
3093 CohortDefinition {
3094 id: "object_values".to_string(),
3095 properties: json!({"type": "AND", "values": {}}),
3096 },
3097 );
3098 cohorts.insert(
3099 "missing_values".to_string(),
3100 CohortDefinition {
3101 id: "missing_values".to_string(),
3102 properties: json!({"type": "AND"}),
3103 },
3104 );
3105 cohorts.insert(
3106 "empty_object".to_string(),
3107 CohortDefinition {
3108 id: "empty_object".to_string(),
3109 properties: json!({}),
3110 },
3111 );
3112 cohorts.insert(
3113 "empty_values".to_string(),
3114 CohortDefinition {
3115 id: "empty_values".to_string(),
3116 properties: json!({"type": "AND", "values": []}),
3117 },
3118 );
3119
3120 let ctx = cohort_ctx(&cohorts);
3121 let properties = HashMap::new();
3122 for cohort_id in ["scalar", "object_values", "missing_values"] {
3123 let prop = Property {
3124 key: "$cohort".to_string(),
3125 value: json!(cohort_id),
3126 operator: "in".to_string(),
3127 property_type: Some("cohort".to_string()),
3128 };
3129
3130 assert!(match_property_with_context(&prop, &properties, &ctx).is_err());
3131 }
3132
3133 for cohort_id in ["empty_object", "empty_values"] {
3134 let empty = Property {
3135 key: "$cohort".to_string(),
3136 value: json!(cohort_id),
3137 operator: "in".to_string(),
3138 property_type: Some("cohort".to_string()),
3139 };
3140 assert!(match_property_with_context(&empty, &properties, &ctx).unwrap());
3141 }
3142 }
3143
3144 #[test]
3147 fn test_flag_dependency_enabled() {
3148 let mut flags = HashMap::new();
3149 flags.insert(
3150 "prerequisite-flag".to_string(),
3151 FeatureFlag {
3152 key: "prerequisite-flag".to_string(),
3153 active: true,
3154 has_experiment: None,
3155 filters: FeatureFlagFilters {
3156 groups: vec![FeatureFlagCondition {
3157 properties: vec![],
3158 rollout_percentage: Some(100.0),
3159 variant: None,
3160 aggregation_group_type_index: None,
3161 }],
3162 multivariate: None,
3163 payloads: HashMap::new(),
3164 aggregation_group_type_index: None,
3165 early_exit: false,
3166 },
3167 },
3168 );
3169
3170 let prop = Property {
3172 key: "$feature/prerequisite-flag".to_string(),
3173 value: json!(true),
3174 operator: "exact".to_string(),
3175 property_type: None,
3176 };
3177
3178 let properties = HashMap::new();
3179 let ctx = EvaluationContext {
3180 cohorts: &HashMap::new(),
3181 flags: &flags,
3182 distinct_id: "user-123",
3183 groups: &HashMap::new(),
3184 group_properties: &HashMap::new(),
3185 group_type_mapping: &HashMap::new(),
3186 };
3187
3188 assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
3190 }
3191
3192 #[test]
3193 fn test_flag_dependency_disabled() {
3194 let mut flags = HashMap::new();
3195 flags.insert(
3196 "disabled-flag".to_string(),
3197 FeatureFlag {
3198 key: "disabled-flag".to_string(),
3199 active: false, has_experiment: None,
3201 filters: FeatureFlagFilters {
3202 groups: vec![],
3203 multivariate: None,
3204 payloads: HashMap::new(),
3205 aggregation_group_type_index: None,
3206 early_exit: false,
3207 },
3208 },
3209 );
3210
3211 let prop = Property {
3213 key: "$feature/disabled-flag".to_string(),
3214 value: json!(true),
3215 operator: "exact".to_string(),
3216 property_type: None,
3217 };
3218
3219 let properties = HashMap::new();
3220 let ctx = EvaluationContext {
3221 cohorts: &HashMap::new(),
3222 flags: &flags,
3223 distinct_id: "user-123",
3224 groups: &HashMap::new(),
3225 group_properties: &HashMap::new(),
3226 group_type_mapping: &HashMap::new(),
3227 };
3228
3229 assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
3231 }
3232
3233 #[test]
3234 fn test_flag_dependency_variant_match() {
3235 let mut flags = HashMap::new();
3236 flags.insert(
3237 "ab-test-flag".to_string(),
3238 FeatureFlag {
3239 key: "ab-test-flag".to_string(),
3240 active: true,
3241 has_experiment: None,
3242 filters: FeatureFlagFilters {
3243 groups: vec![FeatureFlagCondition {
3244 properties: vec![],
3245 rollout_percentage: Some(100.0),
3246 variant: None,
3247 aggregation_group_type_index: None,
3248 }],
3249 multivariate: Some(MultivariateFilter {
3250 variants: vec![
3251 MultivariateVariant {
3252 key: "control".to_string(),
3253 rollout_percentage: 50.0,
3254 },
3255 MultivariateVariant {
3256 key: "test".to_string(),
3257 rollout_percentage: 50.0,
3258 },
3259 ],
3260 }),
3261 payloads: HashMap::new(),
3262 aggregation_group_type_index: None,
3263 early_exit: false,
3264 },
3265 },
3266 );
3267
3268 let prop = Property {
3270 key: "$feature/ab-test-flag".to_string(),
3271 value: json!("control"),
3272 operator: "exact".to_string(),
3273 property_type: None,
3274 };
3275
3276 let properties = HashMap::new();
3277 let ctx = EvaluationContext {
3278 cohorts: &HashMap::new(),
3279 flags: &flags,
3280 distinct_id: "user-gets-control", groups: &HashMap::new(),
3282 group_properties: &HashMap::new(),
3283 group_type_mapping: &HashMap::new(),
3284 };
3285
3286 let result = match_property_with_context(&prop, &properties, &ctx);
3288 assert!(result.is_ok());
3289 }
3290
3291 #[test]
3292 fn test_flag_dependency_not_found_returns_inconclusive() {
3293 let flags = HashMap::new(); let prop = Property {
3296 key: "$feature/nonexistent-flag".to_string(),
3297 value: json!(true),
3298 operator: "exact".to_string(),
3299 property_type: None,
3300 };
3301
3302 let properties = HashMap::new();
3303 let ctx = EvaluationContext {
3304 cohorts: &HashMap::new(),
3305 flags: &flags,
3306 distinct_id: "user-123",
3307 groups: &HashMap::new(),
3308 group_properties: &HashMap::new(),
3309 group_type_mapping: &HashMap::new(),
3310 };
3311
3312 let result = match_property_with_context(&prop, &properties, &ctx);
3313 assert!(result.is_err());
3314 assert!(result.unwrap_err().message.contains("Flag"));
3315 }
3316
3317 #[test]
3320 fn test_parse_relative_date_edge_cases() {
3321 let prop = Property {
3323 key: "date".to_string(),
3324 value: json!("placeholder"),
3325 operator: "is_date_before".to_string(),
3326 property_type: None,
3327 };
3328
3329 let mut properties = HashMap::new();
3330 properties.insert("date".to_string(), json!("2024-01-01"));
3331
3332 let empty_prop = Property {
3334 value: json!(""),
3335 ..prop.clone()
3336 };
3337 assert!(match_property(&empty_prop, &properties).is_err());
3338
3339 let dash_prop = Property {
3341 value: json!("-"),
3342 ..prop.clone()
3343 };
3344 assert!(match_property(&dash_prop, &properties).is_err());
3345
3346 let no_unit_prop = Property {
3348 value: json!("-7"),
3349 ..prop.clone()
3350 };
3351 assert!(match_property(&no_unit_prop, &properties).is_err());
3352
3353 let no_number_prop = Property {
3355 value: json!("-d"),
3356 ..prop.clone()
3357 };
3358 assert!(match_property(&no_number_prop, &properties).is_err());
3359
3360 let invalid_unit_prop = Property {
3362 value: json!("-7x"),
3363 ..prop.clone()
3364 };
3365 assert!(match_property(&invalid_unit_prop, &properties).is_err());
3366 }
3367
3368 #[test]
3369 fn test_parse_relative_date_large_values() {
3370 let prop = Property {
3372 key: "created_at".to_string(),
3373 value: json!("-1000d"), operator: "is_date_before".to_string(),
3375 property_type: None,
3376 };
3377
3378 let mut properties = HashMap::new();
3379 let five_years_ago = chrono::Utc::now() - chrono::Duration::days(1825);
3381 properties.insert(
3382 "created_at".to_string(),
3383 json!(five_years_ago.format("%Y-%m-%d").to_string()),
3384 );
3385 assert!(match_property(&prop, &properties).unwrap());
3386 }
3387
3388 #[test]
3391 fn test_regex_with_invalid_pattern_returns_false() {
3392 let prop = Property {
3394 key: "email".to_string(),
3395 value: json!("(unclosed"),
3396 operator: "regex".to_string(),
3397 property_type: None,
3398 };
3399
3400 let mut properties = HashMap::new();
3401 properties.insert("email".to_string(), json!("test@example.com"));
3402
3403 assert!(!match_property(&prop, &properties).unwrap());
3405 }
3406
3407 #[test]
3408 fn test_not_regex_with_invalid_pattern_returns_true() {
3409 let prop = Property {
3411 key: "email".to_string(),
3412 value: json!("(unclosed"),
3413 operator: "not_regex".to_string(),
3414 property_type: None,
3415 };
3416
3417 let mut properties = HashMap::new();
3418 properties.insert("email".to_string(), json!("test@example.com"));
3419
3420 assert!(match_property(&prop, &properties).unwrap());
3422 }
3423
3424 #[test]
3425 fn test_regex_with_various_invalid_patterns() {
3426 let invalid_patterns = vec![
3427 "(unclosed", "[unclosed", "*invalid", "(?P<bad", r"\", ];
3433
3434 for pattern in invalid_patterns {
3435 let prop = Property {
3436 key: "value".to_string(),
3437 value: json!(pattern),
3438 operator: "regex".to_string(),
3439 property_type: None,
3440 };
3441
3442 let mut properties = HashMap::new();
3443 properties.insert("value".to_string(), json!("test"));
3444
3445 assert!(
3447 !match_property(&prop, &properties).unwrap(),
3448 "Invalid pattern '{}' should return false for regex",
3449 pattern
3450 );
3451
3452 let not_regex_prop = Property {
3454 operator: "not_regex".to_string(),
3455 ..prop
3456 };
3457 assert!(
3458 match_property(¬_regex_prop, &properties).unwrap(),
3459 "Invalid pattern '{}' should return true for not_regex",
3460 pattern
3461 );
3462 }
3463 }
3464
3465 #[test]
3468 fn test_parse_semver_basic() {
3469 assert_eq!(parse_semver("1.2.3"), Some((1, 2, 3)));
3470 assert_eq!(parse_semver("0.0.0"), Some((0, 0, 0)));
3471 assert_eq!(parse_semver("10.20.30"), Some((10, 20, 30)));
3472 }
3473
3474 #[test]
3475 fn test_parse_semver_v_prefix() {
3476 assert_eq!(parse_semver("v1.2.3"), Some((1, 2, 3)));
3477 assert_eq!(parse_semver("V1.2.3"), Some((1, 2, 3)));
3478 }
3479
3480 #[test]
3481 fn test_parse_semver_whitespace() {
3482 assert_eq!(parse_semver(" 1.2.3 "), Some((1, 2, 3)));
3483 assert_eq!(parse_semver(" v1.2.3 "), Some((1, 2, 3)));
3484 }
3485
3486 #[test]
3487 fn test_parse_semver_prerelease_stripped() {
3488 assert_eq!(parse_semver("1.2.3-alpha"), Some((1, 2, 3)));
3489 assert_eq!(parse_semver("1.2.3-beta.1"), Some((1, 2, 3)));
3490 assert_eq!(parse_semver("1.2.3-rc.1+build.123"), Some((1, 2, 3)));
3491 assert_eq!(parse_semver("1.2.3+build.456"), Some((1, 2, 3)));
3492 }
3493
3494 #[test]
3495 fn test_parse_semver_partial_versions() {
3496 assert_eq!(parse_semver("1.2"), Some((1, 2, 0)));
3497 assert_eq!(parse_semver("1"), Some((1, 0, 0)));
3498 assert_eq!(parse_semver("v1.2"), Some((1, 2, 0)));
3499 }
3500
3501 #[test]
3502 fn test_parse_semver_extra_components_ignored() {
3503 assert_eq!(parse_semver("1.2.3.4"), Some((1, 2, 3)));
3504 assert_eq!(parse_semver("1.2.3.4.5.6"), Some((1, 2, 3)));
3505 }
3506
3507 #[test]
3508 fn test_parse_semver_leading_zeros_rejected() {
3509 assert_eq!(parse_semver("01.02.03"), None);
3511 assert_eq!(parse_semver("001.002.003"), None);
3512 assert_eq!(parse_semver("1.07.3"), None);
3513 assert_eq!(parse_semver("1.2.03"), None);
3514 assert_eq!(parse_semver("v01.2.3"), None);
3515
3516 assert_eq!(parse_semver("0.1.0"), Some((0, 1, 0)));
3518 assert_eq!(parse_semver("1.0.0"), Some((1, 0, 0)));
3519 assert_eq!(parse_semver("0.0.0"), Some((0, 0, 0)));
3520 }
3521
3522 #[test]
3523 fn test_parse_semver_invalid() {
3524 assert_eq!(parse_semver(""), None);
3525 assert_eq!(parse_semver(" "), None);
3526 assert_eq!(parse_semver("v"), None);
3527 assert_eq!(parse_semver(".1.2.3"), None);
3528 assert_eq!(parse_semver("abc"), None);
3529 assert_eq!(parse_semver("1.abc.3"), None);
3530 assert_eq!(parse_semver("1.2.abc"), None);
3531 assert_eq!(parse_semver("not-a-version"), None);
3532 }
3533
3534 #[test]
3537 fn test_semver_eq_basic() {
3538 let prop = Property {
3539 key: "version".to_string(),
3540 value: json!("1.2.3"),
3541 operator: "semver_eq".to_string(),
3542 property_type: None,
3543 };
3544
3545 let mut properties = HashMap::new();
3546
3547 properties.insert("version".to_string(), json!("1.2.3"));
3548 assert!(match_property(&prop, &properties).unwrap());
3549
3550 properties.insert("version".to_string(), json!("1.2.4"));
3551 assert!(!match_property(&prop, &properties).unwrap());
3552
3553 properties.insert("version".to_string(), json!("1.3.3"));
3554 assert!(!match_property(&prop, &properties).unwrap());
3555
3556 properties.insert("version".to_string(), json!("2.2.3"));
3557 assert!(!match_property(&prop, &properties).unwrap());
3558 }
3559
3560 #[test]
3561 fn test_semver_eq_with_v_prefix() {
3562 let prop = Property {
3563 key: "version".to_string(),
3564 value: json!("1.2.3"),
3565 operator: "semver_eq".to_string(),
3566 property_type: None,
3567 };
3568
3569 let mut properties = HashMap::new();
3570
3571 properties.insert("version".to_string(), json!("v1.2.3"));
3573 assert!(match_property(&prop, &properties).unwrap());
3574
3575 let prop_with_v = Property {
3577 value: json!("v1.2.3"),
3578 ..prop.clone()
3579 };
3580 properties.insert("version".to_string(), json!("1.2.3"));
3581 assert!(match_property(&prop_with_v, &properties).unwrap());
3582 }
3583
3584 #[test]
3585 fn test_semver_eq_prerelease_stripped() {
3586 let prop = Property {
3587 key: "version".to_string(),
3588 value: json!("1.2.3"),
3589 operator: "semver_eq".to_string(),
3590 property_type: None,
3591 };
3592
3593 let mut properties = HashMap::new();
3594
3595 properties.insert("version".to_string(), json!("1.2.3-alpha"));
3596 assert!(match_property(&prop, &properties).unwrap());
3597
3598 properties.insert("version".to_string(), json!("1.2.3-beta.1"));
3599 assert!(match_property(&prop, &properties).unwrap());
3600
3601 properties.insert("version".to_string(), json!("1.2.3+build.456"));
3602 assert!(match_property(&prop, &properties).unwrap());
3603 }
3604
3605 #[test]
3606 fn test_semver_eq_partial_versions() {
3607 let prop = Property {
3608 key: "version".to_string(),
3609 value: json!("1.2.0"),
3610 operator: "semver_eq".to_string(),
3611 property_type: None,
3612 };
3613
3614 let mut properties = HashMap::new();
3615
3616 properties.insert("version".to_string(), json!("1.2"));
3618 assert!(match_property(&prop, &properties).unwrap());
3619
3620 let partial_prop = Property {
3622 value: json!("1.2"),
3623 ..prop.clone()
3624 };
3625 properties.insert("version".to_string(), json!("1.2.0"));
3626 assert!(match_property(&partial_prop, &properties).unwrap());
3627 }
3628
3629 #[test]
3630 fn test_semver_neq() {
3631 let prop = Property {
3632 key: "version".to_string(),
3633 value: json!("1.2.3"),
3634 operator: "semver_neq".to_string(),
3635 property_type: None,
3636 };
3637
3638 let mut properties = HashMap::new();
3639
3640 properties.insert("version".to_string(), json!("1.2.3"));
3641 assert!(!match_property(&prop, &properties).unwrap());
3642
3643 properties.insert("version".to_string(), json!("1.2.4"));
3644 assert!(match_property(&prop, &properties).unwrap());
3645
3646 properties.insert("version".to_string(), json!("2.0.0"));
3647 assert!(match_property(&prop, &properties).unwrap());
3648 }
3649
3650 #[test]
3653 fn test_semver_gt() {
3654 let prop = Property {
3655 key: "version".to_string(),
3656 value: json!("1.2.3"),
3657 operator: "semver_gt".to_string(),
3658 property_type: None,
3659 };
3660
3661 let mut properties = HashMap::new();
3662
3663 properties.insert("version".to_string(), json!("1.2.4"));
3665 assert!(match_property(&prop, &properties).unwrap());
3666
3667 properties.insert("version".to_string(), json!("1.3.0"));
3668 assert!(match_property(&prop, &properties).unwrap());
3669
3670 properties.insert("version".to_string(), json!("2.0.0"));
3671 assert!(match_property(&prop, &properties).unwrap());
3672
3673 properties.insert("version".to_string(), json!("1.2.3"));
3675 assert!(!match_property(&prop, &properties).unwrap());
3676
3677 properties.insert("version".to_string(), json!("1.2.2"));
3679 assert!(!match_property(&prop, &properties).unwrap());
3680
3681 properties.insert("version".to_string(), json!("1.1.9"));
3682 assert!(!match_property(&prop, &properties).unwrap());
3683
3684 properties.insert("version".to_string(), json!("0.9.9"));
3685 assert!(!match_property(&prop, &properties).unwrap());
3686 }
3687
3688 #[test]
3689 fn test_semver_gte() {
3690 let prop = Property {
3691 key: "version".to_string(),
3692 value: json!("1.2.3"),
3693 operator: "semver_gte".to_string(),
3694 property_type: None,
3695 };
3696
3697 let mut properties = HashMap::new();
3698
3699 properties.insert("version".to_string(), json!("1.2.4"));
3701 assert!(match_property(&prop, &properties).unwrap());
3702
3703 properties.insert("version".to_string(), json!("2.0.0"));
3704 assert!(match_property(&prop, &properties).unwrap());
3705
3706 properties.insert("version".to_string(), json!("1.2.3"));
3708 assert!(match_property(&prop, &properties).unwrap());
3709
3710 properties.insert("version".to_string(), json!("1.2.2"));
3712 assert!(!match_property(&prop, &properties).unwrap());
3713
3714 properties.insert("version".to_string(), json!("0.9.9"));
3715 assert!(!match_property(&prop, &properties).unwrap());
3716 }
3717
3718 #[test]
3719 fn test_semver_lt() {
3720 let prop = Property {
3721 key: "version".to_string(),
3722 value: json!("1.2.3"),
3723 operator: "semver_lt".to_string(),
3724 property_type: None,
3725 };
3726
3727 let mut properties = HashMap::new();
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!("1.1.9"));
3734 assert!(match_property(&prop, &properties).unwrap());
3735
3736 properties.insert("version".to_string(), json!("0.9.9"));
3737 assert!(match_property(&prop, &properties).unwrap());
3738
3739 properties.insert("version".to_string(), json!("1.2.3"));
3741 assert!(!match_property(&prop, &properties).unwrap());
3742
3743 properties.insert("version".to_string(), json!("1.2.4"));
3745 assert!(!match_property(&prop, &properties).unwrap());
3746
3747 properties.insert("version".to_string(), json!("2.0.0"));
3748 assert!(!match_property(&prop, &properties).unwrap());
3749 }
3750
3751 #[test]
3752 fn test_semver_lte() {
3753 let prop = Property {
3754 key: "version".to_string(),
3755 value: json!("1.2.3"),
3756 operator: "semver_lte".to_string(),
3757 property_type: None,
3758 };
3759
3760 let mut properties = HashMap::new();
3761
3762 properties.insert("version".to_string(), json!("1.2.2"));
3764 assert!(match_property(&prop, &properties).unwrap());
3765
3766 properties.insert("version".to_string(), json!("0.9.9"));
3767 assert!(match_property(&prop, &properties).unwrap());
3768
3769 properties.insert("version".to_string(), json!("1.2.3"));
3771 assert!(match_property(&prop, &properties).unwrap());
3772
3773 properties.insert("version".to_string(), json!("1.2.4"));
3775 assert!(!match_property(&prop, &properties).unwrap());
3776
3777 properties.insert("version".to_string(), json!("2.0.0"));
3778 assert!(!match_property(&prop, &properties).unwrap());
3779 }
3780
3781 #[test]
3784 fn test_semver_tilde_basic() {
3785 let prop = Property {
3787 key: "version".to_string(),
3788 value: json!("1.2.3"),
3789 operator: "semver_tilde".to_string(),
3790 property_type: None,
3791 };
3792
3793 let mut properties = HashMap::new();
3794
3795 properties.insert("version".to_string(), json!("1.2.3"));
3797 assert!(match_property(&prop, &properties).unwrap());
3798
3799 properties.insert("version".to_string(), json!("1.2.4"));
3801 assert!(match_property(&prop, &properties).unwrap());
3802
3803 properties.insert("version".to_string(), json!("1.2.99"));
3804 assert!(match_property(&prop, &properties).unwrap());
3805
3806 properties.insert("version".to_string(), json!("1.3.0"));
3808 assert!(!match_property(&prop, &properties).unwrap());
3809
3810 properties.insert("version".to_string(), json!("1.3.1"));
3812 assert!(!match_property(&prop, &properties).unwrap());
3813
3814 properties.insert("version".to_string(), json!("2.0.0"));
3815 assert!(!match_property(&prop, &properties).unwrap());
3816
3817 properties.insert("version".to_string(), json!("1.2.2"));
3819 assert!(!match_property(&prop, &properties).unwrap());
3820
3821 properties.insert("version".to_string(), json!("1.1.9"));
3822 assert!(!match_property(&prop, &properties).unwrap());
3823 }
3824
3825 #[test]
3826 fn test_semver_tilde_zero_versions() {
3827 let prop = Property {
3829 key: "version".to_string(),
3830 value: json!("0.2.3"),
3831 operator: "semver_tilde".to_string(),
3832 property_type: None,
3833 };
3834
3835 let mut properties = HashMap::new();
3836
3837 properties.insert("version".to_string(), json!("0.2.3"));
3838 assert!(match_property(&prop, &properties).unwrap());
3839
3840 properties.insert("version".to_string(), json!("0.2.9"));
3841 assert!(match_property(&prop, &properties).unwrap());
3842
3843 properties.insert("version".to_string(), json!("0.3.0"));
3844 assert!(!match_property(&prop, &properties).unwrap());
3845
3846 properties.insert("version".to_string(), json!("0.2.2"));
3847 assert!(!match_property(&prop, &properties).unwrap());
3848 }
3849
3850 #[test]
3853 fn test_semver_caret_major_nonzero() {
3854 let prop = Property {
3856 key: "version".to_string(),
3857 value: json!("1.2.3"),
3858 operator: "semver_caret".to_string(),
3859 property_type: None,
3860 };
3861
3862 let mut properties = HashMap::new();
3863
3864 properties.insert("version".to_string(), json!("1.2.3"));
3866 assert!(match_property(&prop, &properties).unwrap());
3867
3868 properties.insert("version".to_string(), json!("1.2.4"));
3870 assert!(match_property(&prop, &properties).unwrap());
3871
3872 properties.insert("version".to_string(), json!("1.3.0"));
3873 assert!(match_property(&prop, &properties).unwrap());
3874
3875 properties.insert("version".to_string(), json!("1.99.99"));
3876 assert!(match_property(&prop, &properties).unwrap());
3877
3878 properties.insert("version".to_string(), json!("2.0.0"));
3880 assert!(!match_property(&prop, &properties).unwrap());
3881
3882 properties.insert("version".to_string(), json!("2.0.1"));
3884 assert!(!match_property(&prop, &properties).unwrap());
3885
3886 properties.insert("version".to_string(), json!("1.2.2"));
3888 assert!(!match_property(&prop, &properties).unwrap());
3889
3890 properties.insert("version".to_string(), json!("0.9.9"));
3891 assert!(!match_property(&prop, &properties).unwrap());
3892 }
3893
3894 #[test]
3895 fn test_semver_caret_major_zero_minor_nonzero() {
3896 let prop = Property {
3898 key: "version".to_string(),
3899 value: json!("0.2.3"),
3900 operator: "semver_caret".to_string(),
3901 property_type: None,
3902 };
3903
3904 let mut properties = HashMap::new();
3905
3906 properties.insert("version".to_string(), json!("0.2.3"));
3908 assert!(match_property(&prop, &properties).unwrap());
3909
3910 properties.insert("version".to_string(), json!("0.2.4"));
3912 assert!(match_property(&prop, &properties).unwrap());
3913
3914 properties.insert("version".to_string(), json!("0.2.99"));
3915 assert!(match_property(&prop, &properties).unwrap());
3916
3917 properties.insert("version".to_string(), json!("0.3.0"));
3919 assert!(!match_property(&prop, &properties).unwrap());
3920
3921 properties.insert("version".to_string(), json!("0.3.1"));
3923 assert!(!match_property(&prop, &properties).unwrap());
3924
3925 properties.insert("version".to_string(), json!("1.0.0"));
3926 assert!(!match_property(&prop, &properties).unwrap());
3927
3928 properties.insert("version".to_string(), json!("0.2.2"));
3930 assert!(!match_property(&prop, &properties).unwrap());
3931
3932 properties.insert("version".to_string(), json!("0.1.9"));
3933 assert!(!match_property(&prop, &properties).unwrap());
3934 }
3935
3936 #[test]
3937 fn test_semver_caret_major_zero_minor_zero() {
3938 let prop = Property {
3940 key: "version".to_string(),
3941 value: json!("0.0.3"),
3942 operator: "semver_caret".to_string(),
3943 property_type: None,
3944 };
3945
3946 let mut properties = HashMap::new();
3947
3948 properties.insert("version".to_string(), json!("0.0.3"));
3950 assert!(match_property(&prop, &properties).unwrap());
3951
3952 properties.insert("version".to_string(), json!("0.0.4"));
3954 assert!(!match_property(&prop, &properties).unwrap());
3955
3956 properties.insert("version".to_string(), json!("0.0.5"));
3958 assert!(!match_property(&prop, &properties).unwrap());
3959
3960 properties.insert("version".to_string(), json!("0.1.0"));
3961 assert!(!match_property(&prop, &properties).unwrap());
3962
3963 properties.insert("version".to_string(), json!("0.0.2"));
3965 assert!(!match_property(&prop, &properties).unwrap());
3966 }
3967
3968 #[test]
3971 fn test_semver_wildcard_major() {
3972 let prop = Property {
3974 key: "version".to_string(),
3975 value: json!("1.*"),
3976 operator: "semver_wildcard".to_string(),
3977 property_type: None,
3978 };
3979
3980 let mut properties = HashMap::new();
3981
3982 properties.insert("version".to_string(), json!("1.0.0"));
3984 assert!(match_property(&prop, &properties).unwrap());
3985
3986 properties.insert("version".to_string(), json!("1.2.3"));
3988 assert!(match_property(&prop, &properties).unwrap());
3989
3990 properties.insert("version".to_string(), json!("1.99.99"));
3991 assert!(match_property(&prop, &properties).unwrap());
3992
3993 properties.insert("version".to_string(), json!("2.0.0"));
3995 assert!(!match_property(&prop, &properties).unwrap());
3996
3997 properties.insert("version".to_string(), json!("2.0.1"));
3999 assert!(!match_property(&prop, &properties).unwrap());
4000
4001 properties.insert("version".to_string(), json!("0.9.9"));
4003 assert!(!match_property(&prop, &properties).unwrap());
4004 }
4005
4006 #[test]
4007 fn test_semver_wildcard_minor() {
4008 let prop = Property {
4010 key: "version".to_string(),
4011 value: json!("1.2.*"),
4012 operator: "semver_wildcard".to_string(),
4013 property_type: None,
4014 };
4015
4016 let mut properties = HashMap::new();
4017
4018 properties.insert("version".to_string(), json!("1.2.0"));
4020 assert!(match_property(&prop, &properties).unwrap());
4021
4022 properties.insert("version".to_string(), json!("1.2.3"));
4024 assert!(match_property(&prop, &properties).unwrap());
4025
4026 properties.insert("version".to_string(), json!("1.2.99"));
4027 assert!(match_property(&prop, &properties).unwrap());
4028
4029 properties.insert("version".to_string(), json!("1.3.0"));
4031 assert!(!match_property(&prop, &properties).unwrap());
4032
4033 properties.insert("version".to_string(), json!("1.3.1"));
4035 assert!(!match_property(&prop, &properties).unwrap());
4036
4037 properties.insert("version".to_string(), json!("2.0.0"));
4038 assert!(!match_property(&prop, &properties).unwrap());
4039
4040 properties.insert("version".to_string(), json!("1.1.9"));
4042 assert!(!match_property(&prop, &properties).unwrap());
4043 }
4044
4045 #[test]
4046 fn test_semver_wildcard_zero() {
4047 let prop = Property {
4049 key: "version".to_string(),
4050 value: json!("0.*"),
4051 operator: "semver_wildcard".to_string(),
4052 property_type: None,
4053 };
4054
4055 let mut properties = HashMap::new();
4056
4057 properties.insert("version".to_string(), json!("0.0.0"));
4058 assert!(match_property(&prop, &properties).unwrap());
4059
4060 properties.insert("version".to_string(), json!("0.99.99"));
4061 assert!(match_property(&prop, &properties).unwrap());
4062
4063 properties.insert("version".to_string(), json!("1.0.0"));
4064 assert!(!match_property(&prop, &properties).unwrap());
4065 }
4066
4067 #[test]
4070 fn test_semver_invalid_property_value() {
4071 let prop = Property {
4072 key: "version".to_string(),
4073 value: json!("1.2.3"),
4074 operator: "semver_eq".to_string(),
4075 property_type: None,
4076 };
4077
4078 let mut properties = HashMap::new();
4079
4080 properties.insert("version".to_string(), json!("not-a-version"));
4082 assert!(match_property(&prop, &properties).is_err());
4083
4084 properties.insert("version".to_string(), json!(""));
4085 assert!(match_property(&prop, &properties).is_err());
4086
4087 properties.insert("version".to_string(), json!(".1.2.3"));
4088 assert!(match_property(&prop, &properties).is_err());
4089
4090 properties.insert("version".to_string(), json!("abc.def.ghi"));
4091 assert!(match_property(&prop, &properties).is_err());
4092 }
4093
4094 #[test]
4095 fn test_semver_invalid_target_value() {
4096 let mut properties = HashMap::new();
4097 properties.insert("version".to_string(), json!("1.2.3"));
4098
4099 let prop = Property {
4101 key: "version".to_string(),
4102 value: json!("not-valid"),
4103 operator: "semver_eq".to_string(),
4104 property_type: None,
4105 };
4106 assert!(match_property(&prop, &properties).is_err());
4107
4108 let prop = Property {
4109 key: "version".to_string(),
4110 value: json!(""),
4111 operator: "semver_gt".to_string(),
4112 property_type: None,
4113 };
4114 assert!(match_property(&prop, &properties).is_err());
4115 }
4116
4117 #[test]
4118 fn test_semver_invalid_wildcard_pattern() {
4119 let mut properties = HashMap::new();
4120 properties.insert("version".to_string(), json!("1.2.3"));
4121
4122 let invalid_patterns = vec![
4124 "*", "*.2.3", "1.*.3", "1.2.3.*", "abc.*", ];
4130
4131 for pattern in invalid_patterns {
4132 let prop = Property {
4133 key: "version".to_string(),
4134 value: json!(pattern),
4135 operator: "semver_wildcard".to_string(),
4136 property_type: None,
4137 };
4138 assert!(
4139 match_property(&prop, &properties).is_err(),
4140 "Pattern '{}' should be invalid",
4141 pattern
4142 );
4143 }
4144 }
4145
4146 #[test]
4147 fn test_semver_missing_property() {
4148 let prop = Property {
4149 key: "version".to_string(),
4150 value: json!("1.2.3"),
4151 operator: "semver_eq".to_string(),
4152 property_type: None,
4153 };
4154
4155 let properties = HashMap::new(); assert!(match_property(&prop, &properties).is_err());
4157 }
4158
4159 #[test]
4160 fn test_semver_null_property_value() {
4161 let prop = Property {
4162 key: "version".to_string(),
4163 value: json!("1.2.3"),
4164 operator: "semver_eq".to_string(),
4165 property_type: None,
4166 };
4167
4168 let mut properties = HashMap::new();
4169 properties.insert("version".to_string(), json!(null));
4170
4171 assert!(match_property(&prop, &properties).is_err());
4173 }
4174
4175 #[test]
4176 fn test_semver_numeric_property_value() {
4177 let prop = Property {
4179 key: "version".to_string(),
4180 value: json!("1.0.0"),
4181 operator: "semver_eq".to_string(),
4182 property_type: None,
4183 };
4184
4185 let mut properties = HashMap::new();
4186 properties.insert("version".to_string(), json!(1));
4188 assert!(match_property(&prop, &properties).unwrap());
4189 }
4190
4191 #[test]
4194 fn test_semver_four_part_versions() {
4195 let prop = Property {
4196 key: "version".to_string(),
4197 value: json!("1.2.3.4"),
4198 operator: "semver_eq".to_string(),
4199 property_type: None,
4200 };
4201
4202 let mut properties = HashMap::new();
4203
4204 properties.insert("version".to_string(), json!("1.2.3"));
4206 assert!(match_property(&prop, &properties).unwrap());
4207
4208 properties.insert("version".to_string(), json!("1.2.3.4"));
4209 assert!(match_property(&prop, &properties).unwrap());
4210
4211 properties.insert("version".to_string(), json!("1.2.3.999"));
4212 assert!(match_property(&prop, &properties).unwrap());
4213 }
4214
4215 #[test]
4216 fn test_semver_large_version_numbers() {
4217 let prop = Property {
4218 key: "version".to_string(),
4219 value: json!("1000.2000.3000"),
4220 operator: "semver_eq".to_string(),
4221 property_type: None,
4222 };
4223
4224 let mut properties = HashMap::new();
4225 properties.insert("version".to_string(), json!("1000.2000.3000"));
4226 assert!(match_property(&prop, &properties).unwrap());
4227 }
4228
4229 #[test]
4230 fn test_semver_comparison_ordering() {
4231 let cases = vec![
4233 ("0.0.1", "0.0.2", "semver_lt", true),
4234 ("0.1.0", "0.0.99", "semver_gt", true),
4235 ("1.0.0", "0.99.99", "semver_gt", true),
4236 ("1.0.0", "1.0.0", "semver_eq", true),
4237 ("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), ];
4242
4243 for (prop_val, target_val, op, expected) in cases {
4244 let prop = Property {
4245 key: "version".to_string(),
4246 value: json!(target_val),
4247 operator: op.to_string(),
4248 property_type: None,
4249 };
4250
4251 let mut properties = HashMap::new();
4252 properties.insert("version".to_string(), json!(prop_val));
4253
4254 assert_eq!(
4255 match_property(&prop, &properties).unwrap(),
4256 expected,
4257 "{} {} {} should be {}",
4258 prop_val,
4259 op,
4260 target_val,
4261 expected
4262 );
4263 }
4264 }
4265
4266 #[test]
4267 fn test_match_property_semver_rejects_leading_zeros() {
4268 let bad_versions = ["1.07.3", "01.02.03", "1.2.03", "v01.2.3", "001.0.0"];
4273
4274 for bad in bad_versions {
4276 let prop = Property {
4277 key: "version".to_string(),
4278 value: json!("1.2.3"),
4279 operator: "semver_eq".to_string(),
4280 property_type: None,
4281 };
4282 let mut properties = HashMap::new();
4283 properties.insert("version".to_string(), json!(bad));
4284 assert!(
4285 match_property(&prop, &properties).is_err(),
4286 "override '{}' should be rejected",
4287 bad
4288 );
4289 }
4290
4291 for good in ["0.1.0", "1.0.0", "0.0.0"] {
4293 let prop = Property {
4294 key: "version".to_string(),
4295 value: json!(good),
4296 operator: "semver_eq".to_string(),
4297 property_type: None,
4298 };
4299 let mut properties = HashMap::new();
4300 properties.insert("version".to_string(), json!(good));
4301 assert!(
4302 match_property(&prop, &properties).unwrap(),
4303 "'{}' should parse and match itself",
4304 good
4305 );
4306 }
4307
4308 let mut properties = HashMap::new();
4310 properties.insert("version".to_string(), json!("1.2.3"));
4311
4312 for op in ["semver_gt", "semver_caret", "semver_tilde"] {
4313 for bad in bad_versions {
4314 let prop = Property {
4315 key: "version".to_string(),
4316 value: json!(bad),
4317 operator: op.to_string(),
4318 property_type: None,
4319 };
4320 assert!(
4321 match_property(&prop, &properties).is_err(),
4322 "target '{}' for {} should be rejected",
4323 bad,
4324 op
4325 );
4326 }
4327 }
4328
4329 for bad_pattern in ["01.*", "1.07.*", "v01.2.*"] {
4331 let prop = Property {
4332 key: "version".to_string(),
4333 value: json!(bad_pattern),
4334 operator: "semver_wildcard".to_string(),
4335 property_type: None,
4336 };
4337 assert!(
4338 match_property(&prop, &properties).is_err(),
4339 "wildcard target '{}' should be rejected",
4340 bad_pattern
4341 );
4342 }
4343 }
4344
4345 fn early_exit_flag(early_exit: bool) -> FeatureFlag {
4352 FeatureFlag {
4353 key: "early-exit-flag".to_string(),
4354 active: true,
4355 has_experiment: None,
4356 filters: FeatureFlagFilters {
4357 groups: vec![
4358 FeatureFlagCondition {
4361 properties: vec![],
4362 rollout_percentage: Some(0.0),
4363 variant: None,
4364 aggregation_group_type_index: None,
4365 },
4366 FeatureFlagCondition {
4368 properties: vec![],
4369 rollout_percentage: Some(100.0),
4370 variant: None,
4371 aggregation_group_type_index: None,
4372 },
4373 ],
4374 multivariate: None,
4375 payloads: HashMap::new(),
4376 aggregation_group_type_index: None,
4377 early_exit,
4378 },
4379 }
4380 }
4381
4382 macro_rules! test_early_exit {
4383 ($name:ident, $early_exit:expr, $expected:expr) => {
4384 #[test]
4385 fn $name() {
4386 let flag = early_exit_flag($early_exit);
4387 let result = match_feature_flag(
4388 &flag,
4389 "user-123",
4390 &HashMap::new(),
4391 &HashMap::new(),
4392 &HashMap::new(),
4393 &HashMap::new(),
4394 )
4395 .unwrap();
4396 assert_eq!(result, $expected);
4397 }
4398 };
4399 }
4400
4401 test_early_exit!(
4402 test_early_exit_enabled_returns_false_without_evaluating_later_group,
4403 true,
4404 FlagValue::Boolean(false)
4405 );
4406 test_early_exit!(
4407 test_early_exit_unset_falls_through_to_matching_group,
4408 false,
4409 FlagValue::Boolean(true)
4410 );
4411
4412 #[test]
4413 fn test_early_exit_default_is_false_from_json() {
4414 let flag: FeatureFlag = serde_json::from_value(json!({
4417 "key": "early-exit-flag",
4418 "active": true,
4419 "filters": {
4420 "groups": [
4421 { "properties": [], "rollout_percentage": 0.0, "variant": null },
4422 { "properties": [], "rollout_percentage": 100.0, "variant": null }
4423 ]
4424 }
4425 }))
4426 .unwrap();
4427 assert!(!flag.filters.early_exit);
4428 let result = match_feature_flag(
4429 &flag,
4430 "user-123",
4431 &HashMap::new(),
4432 &HashMap::new(),
4433 &HashMap::new(),
4434 &HashMap::new(),
4435 )
4436 .unwrap();
4437 assert_eq!(result, FlagValue::Boolean(true));
4438 }
4439
4440 #[test]
4441 fn test_early_exit_explicit_false_falls_through() {
4442 let flag: FeatureFlag = serde_json::from_value(json!({
4443 "key": "early-exit-flag",
4444 "active": true,
4445 "filters": {
4446 "early_exit": false,
4447 "groups": [
4448 { "properties": [], "rollout_percentage": 0.0, "variant": null },
4449 { "properties": [], "rollout_percentage": 100.0, "variant": null }
4450 ]
4451 }
4452 }))
4453 .unwrap();
4454 assert!(!flag.filters.early_exit);
4455 let result = match_feature_flag(
4456 &flag,
4457 "user-123",
4458 &HashMap::new(),
4459 &HashMap::new(),
4460 &HashMap::new(),
4461 &HashMap::new(),
4462 )
4463 .unwrap();
4464 assert_eq!(result, FlagValue::Boolean(true));
4465 }
4466
4467 #[test]
4468 fn test_early_exit_property_mismatch_does_not_short_circuit() {
4469 let flag = FeatureFlag {
4473 key: "early-exit-flag".to_string(),
4474 active: true,
4475 has_experiment: None,
4476 filters: FeatureFlagFilters {
4477 groups: vec![
4478 FeatureFlagCondition {
4479 properties: vec![Property {
4480 key: "country".to_string(),
4481 value: json!("US"),
4482 operator: "exact".to_string(),
4483 property_type: None,
4484 }],
4485 rollout_percentage: Some(100.0),
4486 variant: None,
4487 aggregation_group_type_index: None,
4488 },
4489 FeatureFlagCondition {
4490 properties: vec![],
4491 rollout_percentage: Some(100.0),
4492 variant: None,
4493 aggregation_group_type_index: None,
4494 },
4495 ],
4496 multivariate: None,
4497 payloads: HashMap::new(),
4498 aggregation_group_type_index: None,
4499 early_exit: true,
4500 },
4501 };
4502
4503 let mut properties = HashMap::new();
4504 properties.insert("country".to_string(), json!("UK")); let result = match_feature_flag(
4507 &flag,
4508 "user-123",
4509 &properties,
4510 &HashMap::new(),
4511 &HashMap::new(),
4512 &HashMap::new(),
4513 )
4514 .unwrap();
4515 assert_eq!(result, FlagValue::Boolean(true));
4517 }
4518
4519 macro_rules! test_early_exit_with_context {
4520 ($name:ident, $early_exit:expr, $expected:expr) => {
4521 #[test]
4522 fn $name() {
4523 let flag = early_exit_flag($early_exit);
4524 let ctx = EvaluationContext {
4525 cohorts: &HashMap::new(),
4526 flags: &HashMap::new(),
4527 distinct_id: "user-123",
4528 groups: &HashMap::new(),
4529 group_properties: &HashMap::new(),
4530 group_type_mapping: &HashMap::new(),
4531 };
4532 let result = match_feature_flag_with_context(&flag, &HashMap::new(), &ctx).unwrap();
4533 assert_eq!(result, $expected);
4534 }
4535 };
4536 }
4537
4538 test_early_exit_with_context!(
4539 test_early_exit_enabled_short_circuits_with_context,
4540 true,
4541 FlagValue::Boolean(false)
4542 );
4543 test_early_exit_with_context!(
4544 test_early_exit_unset_falls_through_with_context,
4545 false,
4546 FlagValue::Boolean(true)
4547 );
4548
4549 #[test]
4550 fn test_early_exit_does_not_short_circuit_when_prior_group_inconclusive() {
4551 let flag = FeatureFlag {
4559 key: "early-exit-flag".to_string(),
4560 active: true,
4561 has_experiment: None,
4562 filters: FeatureFlagFilters {
4563 groups: vec![
4564 FeatureFlagCondition {
4565 properties: vec![],
4566 rollout_percentage: Some(100.0),
4567 variant: None,
4568 aggregation_group_type_index: Some(0),
4569 },
4570 FeatureFlagCondition {
4571 properties: vec![],
4572 rollout_percentage: Some(0.0),
4573 variant: None,
4574 aggregation_group_type_index: None,
4575 },
4576 ],
4577 multivariate: None,
4578 payloads: HashMap::new(),
4579 aggregation_group_type_index: None,
4580 early_exit: true,
4581 },
4582 };
4583
4584 let mut group_type_mapping = HashMap::new();
4585 group_type_mapping.insert("0".to_string(), "company".to_string());
4586
4587 let mut groups = HashMap::new();
4588 groups.insert("company".to_string(), "acme".to_string());
4589
4590 let result = match_feature_flag(
4592 &flag,
4593 "user-123",
4594 &HashMap::new(),
4595 &groups,
4596 &HashMap::new(), &group_type_mapping,
4598 );
4599 assert!(
4600 result.is_err(),
4601 "expected InconclusiveMatchError, got {:?}",
4602 result
4603 );
4604 }
4605}