Skip to main content

posthog_rs/
feature_flags.rs

1use chrono::{DateTime, NaiveDate, Utc};
2use regex::Regex;
3use serde::{Deserialize, Serialize};
4use sha1::{Digest, Sha1};
5use std::collections::HashMap;
6use std::fmt;
7use std::sync::{Mutex, OnceLock};
8
9/// Global cache for compiled regexes to avoid recompilation on every flag evaluation
10static REGEX_CACHE: OnceLock<Mutex<HashMap<String, Option<Regex>>>> = OnceLock::new();
11
12/// Salt used for rollout percentage hashing. Intentionally empty to match PostHog's
13/// consistent hashing algorithm across all SDKs. This ensures the same user gets
14/// the same rollout decision regardless of which SDK evaluates the flag.
15const ROLLOUT_HASH_SALT: &str = "";
16
17/// Salt used for multivariate variant selection. Uses "variant" to ensure consistent
18/// variant assignment across all PostHog SDKs for the same user/flag combination.
19const 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/// The value of a feature flag evaluation.
44///
45/// Feature flags can return either a boolean (enabled/disabled) or a string
46/// (for multivariate flags where users are assigned to different variants).
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48#[serde(untagged)]
49pub enum FlagValue {
50    /// Flag is either enabled (true) or disabled (false)
51    Boolean(bool),
52    /// Flag returns a specific variant key (e.g., "control", "test", "variant-a")
53    String(String),
54}
55
56/// Error returned when a feature flag cannot be evaluated locally.
57///
58/// This typically occurs when:
59/// - Required person/group properties are missing
60/// - A cohort referenced by the flag is not in the local cache
61/// - A dependent flag is not available locally
62/// - An unknown operator is encountered
63#[derive(Debug)]
64pub struct InconclusiveMatchError {
65    /// Human-readable description of why evaluation was inconclusive
66    pub message: String,
67}
68
69impl InconclusiveMatchError {
70    pub fn new(message: &str) -> Self {
71        Self {
72            message: message.to_string(),
73        }
74    }
75}
76
77impl fmt::Display for InconclusiveMatchError {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        write!(f, "{}", self.message)
80    }
81}
82
83impl std::error::Error for InconclusiveMatchError {}
84
85impl Default for FlagValue {
86    fn default() -> Self {
87        FlagValue::Boolean(false)
88    }
89}
90
91/// A feature flag definition from PostHog.
92///
93/// Contains all the information needed to evaluate whether a flag should be
94/// enabled for a given user, including targeting rules and rollout percentages.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct FeatureFlag {
97    /// Unique identifier for the flag (e.g., "new-checkout-flow")
98    pub key: String,
99    /// Whether the flag is currently active. Inactive flags always return false.
100    pub active: bool,
101    /// Targeting rules and rollout configuration
102    #[serde(default)]
103    pub filters: FeatureFlagFilters,
104}
105
106/// Targeting rules and configuration for a feature flag.
107#[derive(Debug, Clone, Serialize, Deserialize, Default)]
108pub struct FeatureFlagFilters {
109    /// List of condition groups (evaluated with OR logic between groups)
110    #[serde(default)]
111    pub groups: Vec<FeatureFlagCondition>,
112    /// Multivariate configuration for A/B tests with multiple variants
113    #[serde(default)]
114    pub multivariate: Option<MultivariateFilter>,
115    /// JSON payloads associated with flag variants
116    #[serde(default)]
117    pub payloads: HashMap<String, serde_json::Value>,
118}
119
120/// A single condition group within a feature flag's targeting rules.
121///
122/// All properties within a condition must match (AND logic), and the user
123/// must fall within the rollout percentage to be included.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct FeatureFlagCondition {
126    /// Property filters that must all match (AND logic)
127    #[serde(default)]
128    pub properties: Vec<Property>,
129    /// Percentage of matching users who should see this flag (0-100)
130    pub rollout_percentage: Option<f64>,
131    /// Specific variant to serve for this condition (for variant overrides)
132    pub variant: Option<String>,
133}
134
135/// A property filter used in feature flag targeting.
136///
137/// Supports various operators for matching user properties against expected values.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct Property {
140    /// The property key to match (e.g., "email", "country", "$feature/other-flag")
141    pub key: String,
142    /// The value to compare against
143    pub value: serde_json::Value,
144    /// Comparison operator: "exact", "is_not", "icontains", "not_icontains",
145    /// "regex", "not_regex", "gt", "gte", "lt", "lte", "is_set", "is_not_set",
146    /// "is_date_before", "is_date_after"
147    #[serde(default = "default_operator")]
148    pub operator: String,
149    /// Property type, e.g., "cohort" for cohort membership checks
150    #[serde(rename = "type")]
151    pub property_type: Option<String>,
152}
153
154fn default_operator() -> String {
155    "exact".to_string()
156}
157
158/// Definition of a cohort for local evaluation
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct CohortDefinition {
161    pub id: String,
162    /// Properties can be either:
163    /// - A JSON object with "type" and "values" for complex property groups
164    /// - Or a direct Vec<Property> for simple cases
165    #[serde(default)]
166    pub properties: serde_json::Value,
167}
168
169impl CohortDefinition {
170    /// Create a new cohort definition with simple property list
171    pub fn new(id: String, properties: Vec<Property>) -> Self {
172        Self {
173            id,
174            properties: serde_json::to_value(properties).unwrap_or_default(),
175        }
176    }
177
178    /// Parse the properties from the JSON structure
179    /// PostHog cohort properties come in format:
180    /// {"type": "AND", "values": [{"type": "property", "key": "...", "value": "...", "operator": "..."}]}
181    pub fn parse_properties(&self) -> Vec<Property> {
182        // If it's an array, treat it as direct property list
183        if let Some(arr) = self.properties.as_array() {
184            return arr
185                .iter()
186                .filter_map(|v| serde_json::from_value::<Property>(v.clone()).ok())
187                .collect();
188        }
189
190        // If it's an object with "values" key, extract properties from there
191        if let Some(obj) = self.properties.as_object() {
192            if let Some(values) = obj.get("values") {
193                if let Some(values_arr) = values.as_array() {
194                    return values_arr
195                        .iter()
196                        .filter_map(|v| {
197                            // Handle both direct property objects and nested property groups
198                            if v.get("type").and_then(|t| t.as_str()) == Some("property") {
199                                serde_json::from_value::<Property>(v.clone()).ok()
200                            } else if let Some(inner_values) = v.get("values") {
201                                // Recursively handle nested groups
202                                inner_values.as_array().and_then(|arr| {
203                                    arr.iter()
204                                        .filter_map(|inner| {
205                                            serde_json::from_value::<Property>(inner.clone()).ok()
206                                        })
207                                        .next()
208                                })
209                            } else {
210                                None
211                            }
212                        })
213                        .collect();
214                }
215            }
216        }
217
218        Vec::new()
219    }
220}
221
222/// Context for evaluating properties that may depend on cohorts or other flags
223pub struct EvaluationContext<'a> {
224    pub cohorts: &'a HashMap<String, CohortDefinition>,
225    pub flags: &'a HashMap<String, FeatureFlag>,
226    pub distinct_id: &'a str,
227}
228
229/// Configuration for multivariate (A/B/n) feature flags.
230#[derive(Debug, Clone, Serialize, Deserialize, Default)]
231pub struct MultivariateFilter {
232    /// List of variants with their rollout percentages
233    pub variants: Vec<MultivariateVariant>,
234}
235
236/// A single variant in a multivariate feature flag.
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct MultivariateVariant {
239    /// Unique key for this variant (e.g., "control", "test", "variant-a")
240    pub key: String,
241    /// Percentage of users who should see this variant (0-100)
242    pub rollout_percentage: f64,
243}
244
245/// Response from the PostHog feature flags API.
246///
247/// Supports both the v2 API format (with detailed flag information) and the
248/// legacy format (simple flag values and payloads).
249#[derive(Debug, Clone, Serialize, Deserialize)]
250#[serde(untagged)]
251pub enum FeatureFlagsResponse {
252    /// v2 API format from `/flags/?v=2` endpoint
253    V2 {
254        /// Map of flag keys to their detailed evaluation results
255        flags: HashMap<String, FlagDetail>,
256        /// Whether any errors occurred during flag computation
257        #[serde(rename = "errorsWhileComputingFlags")]
258        #[serde(default)]
259        errors_while_computing_flags: bool,
260    },
261    /// Legacy format from older decide endpoint
262    Legacy {
263        /// Map of flag keys to their values
264        #[serde(rename = "featureFlags")]
265        feature_flags: HashMap<String, FlagValue>,
266        /// Map of flag keys to their JSON payloads
267        #[serde(rename = "featureFlagPayloads")]
268        #[serde(default)]
269        feature_flag_payloads: HashMap<String, serde_json::Value>,
270        /// Any errors that occurred during evaluation
271        #[serde(default)]
272        errors: Option<Vec<String>>,
273    },
274}
275
276impl FeatureFlagsResponse {
277    /// Convert the response to a normalized format
278    pub fn normalize(
279        self,
280    ) -> (
281        HashMap<String, FlagValue>,
282        HashMap<String, serde_json::Value>,
283    ) {
284        match self {
285            FeatureFlagsResponse::V2 { flags, .. } => {
286                let mut feature_flags = HashMap::new();
287                let mut payloads = HashMap::new();
288
289                for (key, detail) in flags {
290                    if detail.enabled {
291                        if let Some(variant) = detail.variant {
292                            feature_flags.insert(key.clone(), FlagValue::String(variant));
293                        } else {
294                            feature_flags.insert(key.clone(), FlagValue::Boolean(true));
295                        }
296                    } else {
297                        feature_flags.insert(key.clone(), FlagValue::Boolean(false));
298                    }
299
300                    if let Some(metadata) = detail.metadata {
301                        if let Some(payload) = metadata.payload {
302                            payloads.insert(key, payload);
303                        }
304                    }
305                }
306
307                (feature_flags, payloads)
308            }
309            FeatureFlagsResponse::Legacy {
310                feature_flags,
311                feature_flag_payloads,
312                ..
313            } => (feature_flags, feature_flag_payloads),
314        }
315    }
316}
317
318/// Detailed information about a feature flag evaluation result.
319///
320/// Returned by the `/decide` endpoint with extended information about
321/// why a flag evaluated to a particular value.
322#[derive(Debug, Clone, Serialize, Deserialize)]
323pub struct FlagDetail {
324    /// The feature flag key
325    pub key: String,
326    /// Whether the flag is enabled for this user
327    pub enabled: bool,
328    /// The variant key if this is a multivariate flag
329    pub variant: Option<String>,
330    /// Reason explaining why the flag evaluated to this value
331    #[serde(default)]
332    pub reason: Option<FlagReason>,
333    /// Additional metadata about the flag
334    #[serde(default)]
335    pub metadata: Option<FlagMetadata>,
336}
337
338/// Explains why a feature flag evaluated to a particular value.
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct FlagReason {
341    /// Reason code (e.g., "condition_match", "out_of_rollout_bound")
342    pub code: String,
343    /// Index of the condition that matched (if applicable)
344    #[serde(default)]
345    pub condition_index: Option<usize>,
346    /// Human-readable description of the reason
347    #[serde(default)]
348    pub description: Option<String>,
349}
350
351/// Metadata about a feature flag from the PostHog server.
352#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct FlagMetadata {
354    /// Unique identifier for this flag
355    pub id: u64,
356    /// Version number of the flag definition
357    pub version: u32,
358    /// Optional description of what this flag controls
359    pub description: Option<String>,
360    /// Optional JSON payload associated with the flag
361    pub payload: Option<serde_json::Value>,
362}
363
364const LONG_SCALE: f64 = 0xFFFFFFFFFFFFFFFu64 as f64; // Must be exactly 15 F's to match Python SDK
365
366/// Compute a deterministic hash value for feature flag bucketing.
367///
368/// Uses SHA-1 to generate a consistent hash in the range [0, 1) for the given
369/// key, distinct_id, and salt combination. This ensures users get consistent
370/// flag values across requests.
371pub fn hash_key(key: &str, distinct_id: &str, salt: &str) -> f64 {
372    let hash_key = format!("{key}.{distinct_id}{salt}");
373    let mut hasher = Sha1::new();
374    hasher.update(hash_key.as_bytes());
375    let result = hasher.finalize();
376    let hex_str = format!("{result:x}");
377    let hash_val = u64::from_str_radix(&hex_str[..15], 16).unwrap_or(0);
378    hash_val as f64 / LONG_SCALE
379}
380
381/// Determine which variant a user should see for a multivariate flag.
382///
383/// Uses consistent hashing to assign users to variants based on their
384/// rollout percentages. Returns `None` if the flag has no variants or
385/// the user doesn't fall into any variant bucket.
386pub fn get_matching_variant(flag: &FeatureFlag, distinct_id: &str) -> Option<String> {
387    let hash_value = hash_key(&flag.key, distinct_id, VARIANT_HASH_SALT);
388    let variants = flag.filters.multivariate.as_ref()?.variants.as_slice();
389
390    let mut value_min = 0.0;
391    for variant in variants {
392        let value_max = value_min + variant.rollout_percentage / 100.0;
393        if hash_value >= value_min && hash_value < value_max {
394            return Some(variant.key.clone());
395        }
396        value_min = value_max;
397    }
398    None
399}
400
401#[must_use = "feature flag evaluation result should be used"]
402pub fn match_feature_flag(
403    flag: &FeatureFlag,
404    distinct_id: &str,
405    properties: &HashMap<String, serde_json::Value>,
406) -> Result<FlagValue, InconclusiveMatchError> {
407    if !flag.active {
408        return Ok(FlagValue::Boolean(false));
409    }
410
411    let conditions = &flag.filters.groups;
412
413    // Sort conditions to evaluate variant overrides first
414    let mut sorted_conditions = conditions.clone();
415    sorted_conditions.sort_by_key(|c| if c.variant.is_some() { 0 } else { 1 });
416
417    let mut is_inconclusive = false;
418
419    for condition in sorted_conditions {
420        match is_condition_match(flag, distinct_id, &condition, properties) {
421            Ok(true) => {
422                if let Some(variant_override) = &condition.variant {
423                    // Check if variant is valid
424                    if let Some(ref multivariate) = flag.filters.multivariate {
425                        let valid_variants: Vec<String> = multivariate
426                            .variants
427                            .iter()
428                            .map(|v| v.key.clone())
429                            .collect();
430
431                        if valid_variants.contains(variant_override) {
432                            return Ok(FlagValue::String(variant_override.clone()));
433                        }
434                    }
435                }
436
437                // Try to get matching variant or return true
438                if let Some(variant) = get_matching_variant(flag, distinct_id) {
439                    return Ok(FlagValue::String(variant));
440                }
441                return Ok(FlagValue::Boolean(true));
442            }
443            Ok(false) => continue,
444            Err(_) => {
445                is_inconclusive = true;
446            }
447        }
448    }
449
450    if is_inconclusive {
451        return Err(InconclusiveMatchError::new(
452            "Can't determine if feature flag is enabled or not with given properties",
453        ));
454    }
455
456    Ok(FlagValue::Boolean(false))
457}
458
459fn is_condition_match(
460    flag: &FeatureFlag,
461    distinct_id: &str,
462    condition: &FeatureFlagCondition,
463    properties: &HashMap<String, serde_json::Value>,
464) -> Result<bool, InconclusiveMatchError> {
465    // Check properties first
466    for prop in &condition.properties {
467        if !match_property(prop, properties)? {
468            return Ok(false);
469        }
470    }
471
472    // If all properties match (or no properties), check rollout percentage
473    if let Some(rollout_percentage) = condition.rollout_percentage {
474        let hash_value = hash_key(&flag.key, distinct_id, ROLLOUT_HASH_SALT);
475        if hash_value > (rollout_percentage / 100.0) {
476            return Ok(false);
477        }
478    }
479
480    Ok(true)
481}
482
483/// Match a feature flag with full context (cohorts, other flags)
484/// This version supports cohort membership checks and flag dependency checks
485#[must_use = "feature flag evaluation result should be used"]
486pub fn match_feature_flag_with_context(
487    flag: &FeatureFlag,
488    distinct_id: &str,
489    properties: &HashMap<String, serde_json::Value>,
490    ctx: &EvaluationContext,
491) -> Result<FlagValue, InconclusiveMatchError> {
492    if !flag.active {
493        return Ok(FlagValue::Boolean(false));
494    }
495
496    let conditions = &flag.filters.groups;
497
498    // Sort conditions to evaluate variant overrides first
499    let mut sorted_conditions = conditions.clone();
500    sorted_conditions.sort_by_key(|c| if c.variant.is_some() { 0 } else { 1 });
501
502    let mut is_inconclusive = false;
503
504    for condition in sorted_conditions {
505        match is_condition_match_with_context(flag, distinct_id, &condition, properties, ctx) {
506            Ok(true) => {
507                if let Some(variant_override) = &condition.variant {
508                    // Check if variant is valid
509                    if let Some(ref multivariate) = flag.filters.multivariate {
510                        let valid_variants: Vec<String> = multivariate
511                            .variants
512                            .iter()
513                            .map(|v| v.key.clone())
514                            .collect();
515
516                        if valid_variants.contains(variant_override) {
517                            return Ok(FlagValue::String(variant_override.clone()));
518                        }
519                    }
520                }
521
522                // Try to get matching variant or return true
523                if let Some(variant) = get_matching_variant(flag, distinct_id) {
524                    return Ok(FlagValue::String(variant));
525                }
526                return Ok(FlagValue::Boolean(true));
527            }
528            Ok(false) => continue,
529            Err(_) => {
530                is_inconclusive = true;
531            }
532        }
533    }
534
535    if is_inconclusive {
536        return Err(InconclusiveMatchError::new(
537            "Can't determine if feature flag is enabled or not with given properties",
538        ));
539    }
540
541    Ok(FlagValue::Boolean(false))
542}
543
544fn is_condition_match_with_context(
545    flag: &FeatureFlag,
546    distinct_id: &str,
547    condition: &FeatureFlagCondition,
548    properties: &HashMap<String, serde_json::Value>,
549    ctx: &EvaluationContext,
550) -> Result<bool, InconclusiveMatchError> {
551    // Check properties first (using context-aware matching for cohorts/flag dependencies)
552    for prop in &condition.properties {
553        if !match_property_with_context(prop, properties, ctx)? {
554            return Ok(false);
555        }
556    }
557
558    // If all properties match (or no properties), check rollout percentage
559    if let Some(rollout_percentage) = condition.rollout_percentage {
560        let hash_value = hash_key(&flag.key, distinct_id, ROLLOUT_HASH_SALT);
561        if hash_value > (rollout_percentage / 100.0) {
562            return Ok(false);
563        }
564    }
565
566    Ok(true)
567}
568
569/// Match a property with additional context for cohorts and flag dependencies
570pub fn match_property_with_context(
571    property: &Property,
572    properties: &HashMap<String, serde_json::Value>,
573    ctx: &EvaluationContext,
574) -> Result<bool, InconclusiveMatchError> {
575    // Check if this is a cohort membership check
576    if property.property_type.as_deref() == Some("cohort") {
577        return match_cohort_property(property, properties, ctx);
578    }
579
580    // Check if this is a flag dependency check
581    if property.key.starts_with("$feature/") {
582        return match_flag_dependency_property(property, ctx);
583    }
584
585    // Fall back to regular property matching
586    match_property(property, properties)
587}
588
589/// Evaluate cohort membership
590fn match_cohort_property(
591    property: &Property,
592    properties: &HashMap<String, serde_json::Value>,
593    ctx: &EvaluationContext,
594) -> Result<bool, InconclusiveMatchError> {
595    let cohort_id = property
596        .value
597        .as_str()
598        .ok_or_else(|| InconclusiveMatchError::new("Cohort ID must be a string"))?;
599
600    let cohort = ctx.cohorts.get(cohort_id).ok_or_else(|| {
601        InconclusiveMatchError::new(&format!("Cohort '{}' not found in local cache", cohort_id))
602    })?;
603
604    // Parse and evaluate all cohort properties against the user's properties
605    let cohort_properties = cohort.parse_properties();
606    let mut is_in_cohort = true;
607    for cohort_prop in &cohort_properties {
608        match match_property(cohort_prop, properties) {
609            Ok(true) => continue,
610            Ok(false) => {
611                is_in_cohort = false;
612                break;
613            }
614            Err(e) => {
615                // If we can't evaluate a cohort property, the cohort membership is inconclusive
616                return Err(InconclusiveMatchError::new(&format!(
617                    "Cannot evaluate cohort '{}' property '{}': {}",
618                    cohort_id, cohort_prop.key, e.message
619                )));
620            }
621        }
622    }
623
624    // Handle "in" vs "not_in" operator
625    Ok(match property.operator.as_str() {
626        "in" => is_in_cohort,
627        "not_in" => !is_in_cohort,
628        op => {
629            return Err(InconclusiveMatchError::new(&format!(
630                "Unknown cohort operator: {}",
631                op
632            )));
633        }
634    })
635}
636
637/// Evaluate flag dependency
638fn match_flag_dependency_property(
639    property: &Property,
640    ctx: &EvaluationContext,
641) -> Result<bool, InconclusiveMatchError> {
642    // Extract flag key from "$feature/flag-key"
643    let flag_key = property
644        .key
645        .strip_prefix("$feature/")
646        .ok_or_else(|| InconclusiveMatchError::new("Invalid flag dependency format"))?;
647
648    let flag = ctx.flags.get(flag_key).ok_or_else(|| {
649        InconclusiveMatchError::new(&format!("Flag '{}' not found in local cache", flag_key))
650    })?;
651
652    // Evaluate the dependent flag for this user (with empty properties to avoid recursion issues)
653    let empty_props = HashMap::new();
654    let flag_value = match_feature_flag(flag, ctx.distinct_id, &empty_props)?;
655
656    // Compare the flag value with the expected value
657    let expected = &property.value;
658
659    let matches = match (&flag_value, expected) {
660        (FlagValue::Boolean(b), serde_json::Value::Bool(expected_b)) => b == expected_b,
661        (FlagValue::String(s), serde_json::Value::String(expected_s)) => {
662            s.eq_ignore_ascii_case(expected_s)
663        }
664        (FlagValue::Boolean(true), serde_json::Value::String(s)) => {
665            // Flag is enabled (boolean true) but we're checking for a specific variant
666            // This should not match
667            s.is_empty() || s == "true"
668        }
669        (FlagValue::Boolean(false), serde_json::Value::String(s)) => s.is_empty() || s == "false",
670        (FlagValue::String(s), serde_json::Value::Bool(true)) => {
671            // Flag returns a variant string, checking for "enabled" (any variant is enabled)
672            !s.is_empty()
673        }
674        (FlagValue::String(_), serde_json::Value::Bool(false)) => false,
675        _ => false,
676    };
677
678    // Handle different operators
679    Ok(match property.operator.as_str() {
680        "exact" => matches,
681        "is_not" => !matches,
682        op => {
683            return Err(InconclusiveMatchError::new(&format!(
684                "Unknown flag dependency operator: {}",
685                op
686            )));
687        }
688    })
689}
690
691/// Parse a relative date string like "-7d", "-24h", "-2w", "-3m", "-1y"
692/// Returns the DateTime<Utc> that the relative date represents
693fn parse_relative_date(value: &str) -> Option<DateTime<Utc>> {
694    let value = value.trim();
695    // Need at least 3 chars: "-", digit(s), and unit (e.g., "-7d")
696    if value.len() < 3 || !value.starts_with('-') {
697        return None;
698    }
699
700    let (num_str, unit) = value[1..].split_at(value.len() - 2);
701    let num: i64 = num_str.parse().ok()?;
702
703    let duration = match unit {
704        "h" => chrono::Duration::hours(num),
705        "d" => chrono::Duration::days(num),
706        "w" => chrono::Duration::weeks(num),
707        "m" => chrono::Duration::days(num * 30), // Approximate month as 30 days
708        "y" => chrono::Duration::days(num * 365), // Approximate year as 365 days
709        _ => return None,
710    };
711
712    Some(Utc::now() - duration)
713}
714
715/// Parse a date value from a string (ISO date, ISO datetime, or relative date)
716fn parse_date_value(value: &serde_json::Value) -> Option<DateTime<Utc>> {
717    let date_str = value.as_str()?;
718
719    // Try relative date first (e.g., "-7d")
720    if date_str.starts_with('-') && date_str.len() > 1 {
721        if let Some(dt) = parse_relative_date(date_str) {
722            return Some(dt);
723        }
724    }
725
726    // Try ISO datetime with timezone (e.g., "2024-06-15T10:30:00Z")
727    if let Ok(dt) = DateTime::parse_from_rfc3339(date_str) {
728        return Some(dt.with_timezone(&Utc));
729    }
730
731    // Try ISO date only (e.g., "2024-06-15")
732    if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
733        return Some(
734            date.and_hms_opt(0, 0, 0)
735                .expect("midnight is always valid")
736                .and_utc(),
737        );
738    }
739
740    None
741}
742
743fn match_property(
744    property: &Property,
745    properties: &HashMap<String, serde_json::Value>,
746) -> Result<bool, InconclusiveMatchError> {
747    let value = match properties.get(&property.key) {
748        Some(v) => v,
749        None => {
750            // Handle is_not_set operator
751            if property.operator == "is_not_set" {
752                return Ok(true);
753            }
754            // Handle is_set operator
755            if property.operator == "is_set" {
756                return Ok(false);
757            }
758            // For other operators, missing property is inconclusive
759            return Err(InconclusiveMatchError::new(&format!(
760                "Property '{}' not found in provided properties",
761                property.key
762            )));
763        }
764    };
765
766    Ok(match property.operator.as_str() {
767        "exact" => {
768            if property.value.is_array() {
769                if let Some(arr) = property.value.as_array() {
770                    for val in arr {
771                        if compare_values(val, value) {
772                            return Ok(true);
773                        }
774                    }
775                    return Ok(false);
776                }
777            }
778            compare_values(&property.value, value)
779        }
780        "is_not" => {
781            if property.value.is_array() {
782                if let Some(arr) = property.value.as_array() {
783                    for val in arr {
784                        if compare_values(val, value) {
785                            return Ok(false);
786                        }
787                    }
788                    return Ok(true);
789                }
790            }
791            !compare_values(&property.value, value)
792        }
793        "is_set" => true,      // We already know the property exists
794        "is_not_set" => false, // We already know the property exists
795        "icontains" => {
796            let prop_str = value_to_string(value);
797            let search_str = value_to_string(&property.value);
798            prop_str.to_lowercase().contains(&search_str.to_lowercase())
799        }
800        "not_icontains" => {
801            let prop_str = value_to_string(value);
802            let search_str = value_to_string(&property.value);
803            !prop_str.to_lowercase().contains(&search_str.to_lowercase())
804        }
805        "regex" => {
806            let prop_str = value_to_string(value);
807            let regex_str = value_to_string(&property.value);
808            get_cached_regex(&regex_str)
809                .map(|re| re.is_match(&prop_str))
810                .unwrap_or(false)
811        }
812        "not_regex" => {
813            let prop_str = value_to_string(value);
814            let regex_str = value_to_string(&property.value);
815            get_cached_regex(&regex_str)
816                .map(|re| !re.is_match(&prop_str))
817                .unwrap_or(true)
818        }
819        "gt" | "gte" | "lt" | "lte" => compare_numeric(&property.operator, &property.value, value),
820        "is_date_before" | "is_date_after" => {
821            let target_date = parse_date_value(&property.value).ok_or_else(|| {
822                InconclusiveMatchError::new(&format!(
823                    "Unable to parse target date value: {:?}",
824                    property.value
825                ))
826            })?;
827
828            let prop_date = parse_date_value(value).ok_or_else(|| {
829                InconclusiveMatchError::new(&format!(
830                    "Unable to parse property date value for '{}': {:?}",
831                    property.key, value
832                ))
833            })?;
834
835            if property.operator == "is_date_before" {
836                prop_date < target_date
837            } else {
838                prop_date > target_date
839            }
840        }
841        unknown => {
842            return Err(InconclusiveMatchError::new(&format!(
843                "Unknown operator: {}",
844                unknown
845            )));
846        }
847    })
848}
849
850fn compare_values(a: &serde_json::Value, b: &serde_json::Value) -> bool {
851    // Case-insensitive string comparison
852    if let (Some(a_str), Some(b_str)) = (a.as_str(), b.as_str()) {
853        return a_str.eq_ignore_ascii_case(b_str);
854    }
855
856    // Direct comparison for other types
857    a == b
858}
859
860fn value_to_string(value: &serde_json::Value) -> String {
861    match value {
862        serde_json::Value::String(s) => s.clone(),
863        serde_json::Value::Number(n) => n.to_string(),
864        serde_json::Value::Bool(b) => b.to_string(),
865        _ => value.to_string(),
866    }
867}
868
869fn compare_numeric(
870    operator: &str,
871    property_value: &serde_json::Value,
872    value: &serde_json::Value,
873) -> bool {
874    let prop_num = match property_value {
875        serde_json::Value::Number(n) => n.as_f64(),
876        serde_json::Value::String(s) => s.parse::<f64>().ok(),
877        _ => None,
878    };
879
880    let val_num = match value {
881        serde_json::Value::Number(n) => n.as_f64(),
882        serde_json::Value::String(s) => s.parse::<f64>().ok(),
883        _ => None,
884    };
885
886    if let (Some(prop), Some(val)) = (prop_num, val_num) {
887        match operator {
888            "gt" => val > prop,
889            "gte" => val >= prop,
890            "lt" => val < prop,
891            "lte" => val <= prop,
892            _ => false,
893        }
894    } else {
895        // Fall back to string comparison
896        let prop_str = value_to_string(property_value);
897        let val_str = value_to_string(value);
898        match operator {
899            "gt" => val_str > prop_str,
900            "gte" => val_str >= prop_str,
901            "lt" => val_str < prop_str,
902            "lte" => val_str <= prop_str,
903            _ => false,
904        }
905    }
906}
907
908#[cfg(test)]
909mod tests {
910    use super::*;
911    use serde_json::json;
912
913    /// Test salt constant to avoid CodeQL warnings about empty cryptographic values
914    const TEST_SALT: &str = "test-salt";
915
916    #[test]
917    fn test_hash_key() {
918        let hash = hash_key("test-flag", "user-123", TEST_SALT);
919        assert!((0.0..=1.0).contains(&hash));
920
921        // Same inputs should produce same hash
922        let hash2 = hash_key("test-flag", "user-123", TEST_SALT);
923        assert_eq!(hash, hash2);
924
925        // Different inputs should produce different hash
926        let hash3 = hash_key("test-flag", "user-456", TEST_SALT);
927        assert_ne!(hash, hash3);
928    }
929
930    #[test]
931    fn test_simple_flag_match() {
932        let flag = FeatureFlag {
933            key: "test-flag".to_string(),
934            active: true,
935            filters: FeatureFlagFilters {
936                groups: vec![FeatureFlagCondition {
937                    properties: vec![],
938                    rollout_percentage: Some(100.0),
939                    variant: None,
940                }],
941                multivariate: None,
942                payloads: HashMap::new(),
943            },
944        };
945
946        let properties = HashMap::new();
947        let result = match_feature_flag(&flag, "user-123", &properties).unwrap();
948        assert_eq!(result, FlagValue::Boolean(true));
949    }
950
951    #[test]
952    fn test_property_matching() {
953        let prop = Property {
954            key: "country".to_string(),
955            value: json!("US"),
956            operator: "exact".to_string(),
957            property_type: None,
958        };
959
960        let mut properties = HashMap::new();
961        properties.insert("country".to_string(), json!("US"));
962
963        assert!(match_property(&prop, &properties).unwrap());
964
965        properties.insert("country".to_string(), json!("UK"));
966        assert!(!match_property(&prop, &properties).unwrap());
967    }
968
969    #[test]
970    fn test_multivariate_variants() {
971        let flag = FeatureFlag {
972            key: "test-flag".to_string(),
973            active: true,
974            filters: FeatureFlagFilters {
975                groups: vec![FeatureFlagCondition {
976                    properties: vec![],
977                    rollout_percentage: Some(100.0),
978                    variant: None,
979                }],
980                multivariate: Some(MultivariateFilter {
981                    variants: vec![
982                        MultivariateVariant {
983                            key: "control".to_string(),
984                            rollout_percentage: 50.0,
985                        },
986                        MultivariateVariant {
987                            key: "test".to_string(),
988                            rollout_percentage: 50.0,
989                        },
990                    ],
991                }),
992                payloads: HashMap::new(),
993            },
994        };
995
996        let properties = HashMap::new();
997        let result = match_feature_flag(&flag, "user-123", &properties).unwrap();
998
999        match result {
1000            FlagValue::String(variant) => {
1001                assert!(variant == "control" || variant == "test");
1002            }
1003            _ => panic!("Expected string variant"),
1004        }
1005    }
1006
1007    #[test]
1008    fn test_inactive_flag() {
1009        let flag = FeatureFlag {
1010            key: "inactive-flag".to_string(),
1011            active: false,
1012            filters: FeatureFlagFilters {
1013                groups: vec![FeatureFlagCondition {
1014                    properties: vec![],
1015                    rollout_percentage: Some(100.0),
1016                    variant: None,
1017                }],
1018                multivariate: None,
1019                payloads: HashMap::new(),
1020            },
1021        };
1022
1023        let properties = HashMap::new();
1024        let result = match_feature_flag(&flag, "user-123", &properties).unwrap();
1025        assert_eq!(result, FlagValue::Boolean(false));
1026    }
1027
1028    #[test]
1029    fn test_rollout_percentage() {
1030        let flag = FeatureFlag {
1031            key: "rollout-flag".to_string(),
1032            active: true,
1033            filters: FeatureFlagFilters {
1034                groups: vec![FeatureFlagCondition {
1035                    properties: vec![],
1036                    rollout_percentage: Some(30.0), // 30% rollout
1037                    variant: None,
1038                }],
1039                multivariate: None,
1040                payloads: HashMap::new(),
1041            },
1042        };
1043
1044        let properties = HashMap::new();
1045
1046        // Test with multiple users to ensure distribution
1047        let mut enabled_count = 0;
1048        for i in 0..1000 {
1049            let result = match_feature_flag(&flag, &format!("user-{}", i), &properties).unwrap();
1050            if result == FlagValue::Boolean(true) {
1051                enabled_count += 1;
1052            }
1053        }
1054
1055        // Should be roughly 30% enabled (allow for some variance)
1056        assert!(enabled_count > 250 && enabled_count < 350);
1057    }
1058
1059    #[test]
1060    fn test_regex_operator() {
1061        let prop = Property {
1062            key: "email".to_string(),
1063            value: json!(".*@company\\.com$"),
1064            operator: "regex".to_string(),
1065            property_type: None,
1066        };
1067
1068        let mut properties = HashMap::new();
1069        properties.insert("email".to_string(), json!("user@company.com"));
1070        assert!(match_property(&prop, &properties).unwrap());
1071
1072        properties.insert("email".to_string(), json!("user@example.com"));
1073        assert!(!match_property(&prop, &properties).unwrap());
1074    }
1075
1076    #[test]
1077    fn test_icontains_operator() {
1078        let prop = Property {
1079            key: "name".to_string(),
1080            value: json!("ADMIN"),
1081            operator: "icontains".to_string(),
1082            property_type: None,
1083        };
1084
1085        let mut properties = HashMap::new();
1086        properties.insert("name".to_string(), json!("admin_user"));
1087        assert!(match_property(&prop, &properties).unwrap());
1088
1089        properties.insert("name".to_string(), json!("regular_user"));
1090        assert!(!match_property(&prop, &properties).unwrap());
1091    }
1092
1093    #[test]
1094    fn test_numeric_operators() {
1095        // Greater than
1096        let prop_gt = Property {
1097            key: "age".to_string(),
1098            value: json!(18),
1099            operator: "gt".to_string(),
1100            property_type: None,
1101        };
1102
1103        let mut properties = HashMap::new();
1104        properties.insert("age".to_string(), json!(25));
1105        assert!(match_property(&prop_gt, &properties).unwrap());
1106
1107        properties.insert("age".to_string(), json!(15));
1108        assert!(!match_property(&prop_gt, &properties).unwrap());
1109
1110        // Less than or equal
1111        let prop_lte = Property {
1112            key: "score".to_string(),
1113            value: json!(100),
1114            operator: "lte".to_string(),
1115            property_type: None,
1116        };
1117
1118        properties.insert("score".to_string(), json!(100));
1119        assert!(match_property(&prop_lte, &properties).unwrap());
1120
1121        properties.insert("score".to_string(), json!(101));
1122        assert!(!match_property(&prop_lte, &properties).unwrap());
1123    }
1124
1125    #[test]
1126    fn test_is_set_operator() {
1127        let prop = Property {
1128            key: "email".to_string(),
1129            value: json!(true),
1130            operator: "is_set".to_string(),
1131            property_type: None,
1132        };
1133
1134        let mut properties = HashMap::new();
1135        properties.insert("email".to_string(), json!("test@example.com"));
1136        assert!(match_property(&prop, &properties).unwrap());
1137
1138        properties.remove("email");
1139        assert!(!match_property(&prop, &properties).unwrap());
1140    }
1141
1142    #[test]
1143    fn test_is_not_set_operator() {
1144        let prop = Property {
1145            key: "phone".to_string(),
1146            value: json!(true),
1147            operator: "is_not_set".to_string(),
1148            property_type: None,
1149        };
1150
1151        let mut properties = HashMap::new();
1152        assert!(match_property(&prop, &properties).unwrap());
1153
1154        properties.insert("phone".to_string(), json!("+1234567890"));
1155        assert!(!match_property(&prop, &properties).unwrap());
1156    }
1157
1158    #[test]
1159    fn test_empty_groups() {
1160        let flag = FeatureFlag {
1161            key: "empty-groups".to_string(),
1162            active: true,
1163            filters: FeatureFlagFilters {
1164                groups: vec![],
1165                multivariate: None,
1166                payloads: HashMap::new(),
1167            },
1168        };
1169
1170        let properties = HashMap::new();
1171        let result = match_feature_flag(&flag, "user-123", &properties).unwrap();
1172        assert_eq!(result, FlagValue::Boolean(false));
1173    }
1174
1175    #[test]
1176    fn test_hash_scale_constant() {
1177        // Verify the constant is exactly 15 F's (not 16)
1178        assert_eq!(LONG_SCALE, 0xFFFFFFFFFFFFFFFu64 as f64);
1179        assert_ne!(LONG_SCALE, 0xFFFFFFFFFFFFFFFFu64 as f64);
1180    }
1181
1182    // ==================== Tests for missing operators ====================
1183
1184    #[test]
1185    fn test_unknown_operator_returns_inconclusive_error() {
1186        let prop = Property {
1187            key: "status".to_string(),
1188            value: json!("active"),
1189            operator: "unknown_operator".to_string(),
1190            property_type: None,
1191        };
1192
1193        let mut properties = HashMap::new();
1194        properties.insert("status".to_string(), json!("active"));
1195
1196        let result = match_property(&prop, &properties);
1197        assert!(result.is_err());
1198        let err = result.unwrap_err();
1199        assert!(err.message.contains("unknown_operator"));
1200    }
1201
1202    #[test]
1203    fn test_is_date_before_with_relative_date() {
1204        let prop = Property {
1205            key: "signup_date".to_string(),
1206            value: json!("-7d"), // 7 days ago
1207            operator: "is_date_before".to_string(),
1208            property_type: None,
1209        };
1210
1211        let mut properties = HashMap::new();
1212        // Date 10 days ago should be before -7d
1213        let ten_days_ago = chrono::Utc::now() - chrono::Duration::days(10);
1214        properties.insert(
1215            "signup_date".to_string(),
1216            json!(ten_days_ago.format("%Y-%m-%d").to_string()),
1217        );
1218        assert!(match_property(&prop, &properties).unwrap());
1219
1220        // Date 3 days ago should NOT be before -7d
1221        let three_days_ago = chrono::Utc::now() - chrono::Duration::days(3);
1222        properties.insert(
1223            "signup_date".to_string(),
1224            json!(three_days_ago.format("%Y-%m-%d").to_string()),
1225        );
1226        assert!(!match_property(&prop, &properties).unwrap());
1227    }
1228
1229    #[test]
1230    fn test_is_date_after_with_relative_date() {
1231        let prop = Property {
1232            key: "last_seen".to_string(),
1233            value: json!("-30d"), // 30 days ago
1234            operator: "is_date_after".to_string(),
1235            property_type: None,
1236        };
1237
1238        let mut properties = HashMap::new();
1239        // Date 10 days ago should be after -30d
1240        let ten_days_ago = chrono::Utc::now() - chrono::Duration::days(10);
1241        properties.insert(
1242            "last_seen".to_string(),
1243            json!(ten_days_ago.format("%Y-%m-%d").to_string()),
1244        );
1245        assert!(match_property(&prop, &properties).unwrap());
1246
1247        // Date 60 days ago should NOT be after -30d
1248        let sixty_days_ago = chrono::Utc::now() - chrono::Duration::days(60);
1249        properties.insert(
1250            "last_seen".to_string(),
1251            json!(sixty_days_ago.format("%Y-%m-%d").to_string()),
1252        );
1253        assert!(!match_property(&prop, &properties).unwrap());
1254    }
1255
1256    #[test]
1257    fn test_is_date_before_with_iso_date() {
1258        let prop = Property {
1259            key: "expiry_date".to_string(),
1260            value: json!("2024-06-15"),
1261            operator: "is_date_before".to_string(),
1262            property_type: None,
1263        };
1264
1265        let mut properties = HashMap::new();
1266        properties.insert("expiry_date".to_string(), json!("2024-06-10"));
1267        assert!(match_property(&prop, &properties).unwrap());
1268
1269        properties.insert("expiry_date".to_string(), json!("2024-06-20"));
1270        assert!(!match_property(&prop, &properties).unwrap());
1271    }
1272
1273    #[test]
1274    fn test_is_date_after_with_iso_date() {
1275        let prop = Property {
1276            key: "start_date".to_string(),
1277            value: json!("2024-01-01"),
1278            operator: "is_date_after".to_string(),
1279            property_type: None,
1280        };
1281
1282        let mut properties = HashMap::new();
1283        properties.insert("start_date".to_string(), json!("2024-03-15"));
1284        assert!(match_property(&prop, &properties).unwrap());
1285
1286        properties.insert("start_date".to_string(), json!("2023-12-01"));
1287        assert!(!match_property(&prop, &properties).unwrap());
1288    }
1289
1290    #[test]
1291    fn test_is_date_with_relative_hours() {
1292        let prop = Property {
1293            key: "last_active".to_string(),
1294            value: json!("-24h"), // 24 hours ago
1295            operator: "is_date_after".to_string(),
1296            property_type: None,
1297        };
1298
1299        let mut properties = HashMap::new();
1300        // 12 hours ago should be after -24h
1301        let twelve_hours_ago = chrono::Utc::now() - chrono::Duration::hours(12);
1302        properties.insert(
1303            "last_active".to_string(),
1304            json!(twelve_hours_ago.to_rfc3339()),
1305        );
1306        assert!(match_property(&prop, &properties).unwrap());
1307
1308        // 48 hours ago should NOT be after -24h
1309        let forty_eight_hours_ago = chrono::Utc::now() - chrono::Duration::hours(48);
1310        properties.insert(
1311            "last_active".to_string(),
1312            json!(forty_eight_hours_ago.to_rfc3339()),
1313        );
1314        assert!(!match_property(&prop, &properties).unwrap());
1315    }
1316
1317    #[test]
1318    fn test_is_date_with_relative_weeks() {
1319        let prop = Property {
1320            key: "joined".to_string(),
1321            value: json!("-2w"), // 2 weeks ago
1322            operator: "is_date_before".to_string(),
1323            property_type: None,
1324        };
1325
1326        let mut properties = HashMap::new();
1327        // 3 weeks ago should be before -2w
1328        let three_weeks_ago = chrono::Utc::now() - chrono::Duration::weeks(3);
1329        properties.insert(
1330            "joined".to_string(),
1331            json!(three_weeks_ago.format("%Y-%m-%d").to_string()),
1332        );
1333        assert!(match_property(&prop, &properties).unwrap());
1334
1335        // 1 week ago should NOT be before -2w
1336        let one_week_ago = chrono::Utc::now() - chrono::Duration::weeks(1);
1337        properties.insert(
1338            "joined".to_string(),
1339            json!(one_week_ago.format("%Y-%m-%d").to_string()),
1340        );
1341        assert!(!match_property(&prop, &properties).unwrap());
1342    }
1343
1344    #[test]
1345    fn test_is_date_with_relative_months() {
1346        let prop = Property {
1347            key: "subscription_date".to_string(),
1348            value: json!("-3m"), // 3 months ago
1349            operator: "is_date_after".to_string(),
1350            property_type: None,
1351        };
1352
1353        let mut properties = HashMap::new();
1354        // 1 month ago should be after -3m
1355        let one_month_ago = chrono::Utc::now() - chrono::Duration::days(30);
1356        properties.insert(
1357            "subscription_date".to_string(),
1358            json!(one_month_ago.format("%Y-%m-%d").to_string()),
1359        );
1360        assert!(match_property(&prop, &properties).unwrap());
1361
1362        // 6 months ago should NOT be after -3m
1363        let six_months_ago = chrono::Utc::now() - chrono::Duration::days(180);
1364        properties.insert(
1365            "subscription_date".to_string(),
1366            json!(six_months_ago.format("%Y-%m-%d").to_string()),
1367        );
1368        assert!(!match_property(&prop, &properties).unwrap());
1369    }
1370
1371    #[test]
1372    fn test_is_date_with_relative_years() {
1373        let prop = Property {
1374            key: "created_at".to_string(),
1375            value: json!("-1y"), // 1 year ago
1376            operator: "is_date_before".to_string(),
1377            property_type: None,
1378        };
1379
1380        let mut properties = HashMap::new();
1381        // 2 years ago should be before -1y
1382        let two_years_ago = chrono::Utc::now() - chrono::Duration::days(730);
1383        properties.insert(
1384            "created_at".to_string(),
1385            json!(two_years_ago.format("%Y-%m-%d").to_string()),
1386        );
1387        assert!(match_property(&prop, &properties).unwrap());
1388
1389        // 6 months ago should NOT be before -1y
1390        let six_months_ago = chrono::Utc::now() - chrono::Duration::days(180);
1391        properties.insert(
1392            "created_at".to_string(),
1393            json!(six_months_ago.format("%Y-%m-%d").to_string()),
1394        );
1395        assert!(!match_property(&prop, &properties).unwrap());
1396    }
1397
1398    #[test]
1399    fn test_is_date_with_invalid_date_format() {
1400        let prop = Property {
1401            key: "date".to_string(),
1402            value: json!("-7d"),
1403            operator: "is_date_before".to_string(),
1404            property_type: None,
1405        };
1406
1407        let mut properties = HashMap::new();
1408        properties.insert("date".to_string(), json!("not-a-date"));
1409
1410        // Invalid date formats should return inconclusive
1411        let result = match_property(&prop, &properties);
1412        assert!(result.is_err());
1413    }
1414
1415    #[test]
1416    fn test_is_date_with_iso_datetime() {
1417        let prop = Property {
1418            key: "event_time".to_string(),
1419            value: json!("2024-06-15T10:30:00Z"),
1420            operator: "is_date_before".to_string(),
1421            property_type: None,
1422        };
1423
1424        let mut properties = HashMap::new();
1425        properties.insert("event_time".to_string(), json!("2024-06-15T08:00:00Z"));
1426        assert!(match_property(&prop, &properties).unwrap());
1427
1428        properties.insert("event_time".to_string(), json!("2024-06-15T12:00:00Z"));
1429        assert!(!match_property(&prop, &properties).unwrap());
1430    }
1431
1432    // ==================== Tests for cohort membership ====================
1433
1434    #[test]
1435    fn test_cohort_membership_in() {
1436        // Create a cohort that matches users with country = US
1437        let mut cohorts = HashMap::new();
1438        cohorts.insert(
1439            "cohort_1".to_string(),
1440            CohortDefinition::new(
1441                "cohort_1".to_string(),
1442                vec![Property {
1443                    key: "country".to_string(),
1444                    value: json!("US"),
1445                    operator: "exact".to_string(),
1446                    property_type: None,
1447                }],
1448            ),
1449        );
1450
1451        // Property filter checking cohort membership
1452        let prop = Property {
1453            key: "$cohort".to_string(),
1454            value: json!("cohort_1"),
1455            operator: "in".to_string(),
1456            property_type: Some("cohort".to_string()),
1457        };
1458
1459        // User with country = US should be in the cohort
1460        let mut properties = HashMap::new();
1461        properties.insert("country".to_string(), json!("US"));
1462
1463        let ctx = EvaluationContext {
1464            cohorts: &cohorts,
1465            flags: &HashMap::new(),
1466            distinct_id: "user-123",
1467        };
1468        assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
1469
1470        // User with country = UK should NOT be in the cohort
1471        properties.insert("country".to_string(), json!("UK"));
1472        assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
1473    }
1474
1475    #[test]
1476    fn test_cohort_membership_not_in() {
1477        let mut cohorts = HashMap::new();
1478        cohorts.insert(
1479            "cohort_blocked".to_string(),
1480            CohortDefinition::new(
1481                "cohort_blocked".to_string(),
1482                vec![Property {
1483                    key: "status".to_string(),
1484                    value: json!("blocked"),
1485                    operator: "exact".to_string(),
1486                    property_type: None,
1487                }],
1488            ),
1489        );
1490
1491        let prop = Property {
1492            key: "$cohort".to_string(),
1493            value: json!("cohort_blocked"),
1494            operator: "not_in".to_string(),
1495            property_type: Some("cohort".to_string()),
1496        };
1497
1498        let mut properties = HashMap::new();
1499        properties.insert("status".to_string(), json!("active"));
1500
1501        let ctx = EvaluationContext {
1502            cohorts: &cohorts,
1503            flags: &HashMap::new(),
1504            distinct_id: "user-123",
1505        };
1506        // User with status = active should NOT be in the blocked cohort (so not_in returns true)
1507        assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
1508
1509        // User with status = blocked IS in the cohort (so not_in returns false)
1510        properties.insert("status".to_string(), json!("blocked"));
1511        assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
1512    }
1513
1514    #[test]
1515    fn test_cohort_not_found_returns_inconclusive() {
1516        let cohorts = HashMap::new(); // No cohorts defined
1517
1518        let prop = Property {
1519            key: "$cohort".to_string(),
1520            value: json!("nonexistent_cohort"),
1521            operator: "in".to_string(),
1522            property_type: Some("cohort".to_string()),
1523        };
1524
1525        let properties = HashMap::new();
1526        let ctx = EvaluationContext {
1527            cohorts: &cohorts,
1528            flags: &HashMap::new(),
1529            distinct_id: "user-123",
1530        };
1531
1532        let result = match_property_with_context(&prop, &properties, &ctx);
1533        assert!(result.is_err());
1534        assert!(result.unwrap_err().message.contains("Cohort"));
1535    }
1536
1537    // ==================== Tests for flag dependencies ====================
1538
1539    #[test]
1540    fn test_flag_dependency_enabled() {
1541        let mut flags = HashMap::new();
1542        flags.insert(
1543            "prerequisite-flag".to_string(),
1544            FeatureFlag {
1545                key: "prerequisite-flag".to_string(),
1546                active: true,
1547                filters: FeatureFlagFilters {
1548                    groups: vec![FeatureFlagCondition {
1549                        properties: vec![],
1550                        rollout_percentage: Some(100.0),
1551                        variant: None,
1552                    }],
1553                    multivariate: None,
1554                    payloads: HashMap::new(),
1555                },
1556            },
1557        );
1558
1559        // Property checking if prerequisite-flag is enabled
1560        let prop = Property {
1561            key: "$feature/prerequisite-flag".to_string(),
1562            value: json!(true),
1563            operator: "exact".to_string(),
1564            property_type: None,
1565        };
1566
1567        let properties = HashMap::new();
1568        let ctx = EvaluationContext {
1569            cohorts: &HashMap::new(),
1570            flags: &flags,
1571            distinct_id: "user-123",
1572        };
1573
1574        // The prerequisite flag is enabled for user-123, so this should match
1575        assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
1576    }
1577
1578    #[test]
1579    fn test_flag_dependency_disabled() {
1580        let mut flags = HashMap::new();
1581        flags.insert(
1582            "disabled-flag".to_string(),
1583            FeatureFlag {
1584                key: "disabled-flag".to_string(),
1585                active: false, // Flag is inactive
1586                filters: FeatureFlagFilters {
1587                    groups: vec![],
1588                    multivariate: None,
1589                    payloads: HashMap::new(),
1590                },
1591            },
1592        );
1593
1594        // Property checking if disabled-flag is enabled
1595        let prop = Property {
1596            key: "$feature/disabled-flag".to_string(),
1597            value: json!(true),
1598            operator: "exact".to_string(),
1599            property_type: None,
1600        };
1601
1602        let properties = HashMap::new();
1603        let ctx = EvaluationContext {
1604            cohorts: &HashMap::new(),
1605            flags: &flags,
1606            distinct_id: "user-123",
1607        };
1608
1609        // The flag is disabled, so checking for true should fail
1610        assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
1611    }
1612
1613    #[test]
1614    fn test_flag_dependency_variant_match() {
1615        let mut flags = HashMap::new();
1616        flags.insert(
1617            "ab-test-flag".to_string(),
1618            FeatureFlag {
1619                key: "ab-test-flag".to_string(),
1620                active: true,
1621                filters: FeatureFlagFilters {
1622                    groups: vec![FeatureFlagCondition {
1623                        properties: vec![],
1624                        rollout_percentage: Some(100.0),
1625                        variant: None,
1626                    }],
1627                    multivariate: Some(MultivariateFilter {
1628                        variants: vec![
1629                            MultivariateVariant {
1630                                key: "control".to_string(),
1631                                rollout_percentage: 50.0,
1632                            },
1633                            MultivariateVariant {
1634                                key: "test".to_string(),
1635                                rollout_percentage: 50.0,
1636                            },
1637                        ],
1638                    }),
1639                    payloads: HashMap::new(),
1640                },
1641            },
1642        );
1643
1644        // Check if user is in "control" variant
1645        let prop = Property {
1646            key: "$feature/ab-test-flag".to_string(),
1647            value: json!("control"),
1648            operator: "exact".to_string(),
1649            property_type: None,
1650        };
1651
1652        let properties = HashMap::new();
1653        let ctx = EvaluationContext {
1654            cohorts: &HashMap::new(),
1655            flags: &flags,
1656            distinct_id: "user-gets-control", // This distinct_id should deterministically get "control"
1657        };
1658
1659        // The result depends on the hash - we just check it doesn't error
1660        let result = match_property_with_context(&prop, &properties, &ctx);
1661        assert!(result.is_ok());
1662    }
1663
1664    #[test]
1665    fn test_flag_dependency_not_found_returns_inconclusive() {
1666        let flags = HashMap::new(); // No flags defined
1667
1668        let prop = Property {
1669            key: "$feature/nonexistent-flag".to_string(),
1670            value: json!(true),
1671            operator: "exact".to_string(),
1672            property_type: None,
1673        };
1674
1675        let properties = HashMap::new();
1676        let ctx = EvaluationContext {
1677            cohorts: &HashMap::new(),
1678            flags: &flags,
1679            distinct_id: "user-123",
1680        };
1681
1682        let result = match_property_with_context(&prop, &properties, &ctx);
1683        assert!(result.is_err());
1684        assert!(result.unwrap_err().message.contains("Flag"));
1685    }
1686
1687    // ==================== Date parsing edge case tests ====================
1688
1689    #[test]
1690    fn test_parse_relative_date_edge_cases() {
1691        // These test the internal parse_relative_date function indirectly via match_property
1692        let prop = Property {
1693            key: "date".to_string(),
1694            value: json!("placeholder"),
1695            operator: "is_date_before".to_string(),
1696            property_type: None,
1697        };
1698
1699        let mut properties = HashMap::new();
1700        properties.insert("date".to_string(), json!("2024-01-01"));
1701
1702        // Empty string as target date should fail
1703        let empty_prop = Property {
1704            value: json!(""),
1705            ..prop.clone()
1706        };
1707        assert!(match_property(&empty_prop, &properties).is_err());
1708
1709        // Single dash should fail
1710        let dash_prop = Property {
1711            value: json!("-"),
1712            ..prop.clone()
1713        };
1714        assert!(match_property(&dash_prop, &properties).is_err());
1715
1716        // Missing unit (just "-7") should fail
1717        let no_unit_prop = Property {
1718            value: json!("-7"),
1719            ..prop.clone()
1720        };
1721        assert!(match_property(&no_unit_prop, &properties).is_err());
1722
1723        // Missing number (just "-d") should fail
1724        let no_number_prop = Property {
1725            value: json!("-d"),
1726            ..prop.clone()
1727        };
1728        assert!(match_property(&no_number_prop, &properties).is_err());
1729
1730        // Invalid unit should fail
1731        let invalid_unit_prop = Property {
1732            value: json!("-7x"),
1733            ..prop.clone()
1734        };
1735        assert!(match_property(&invalid_unit_prop, &properties).is_err());
1736    }
1737
1738    #[test]
1739    fn test_parse_relative_date_large_values() {
1740        // Very large relative dates should work
1741        let prop = Property {
1742            key: "created_at".to_string(),
1743            value: json!("-1000d"), // ~2.7 years ago
1744            operator: "is_date_before".to_string(),
1745            property_type: None,
1746        };
1747
1748        let mut properties = HashMap::new();
1749        // Date 5 years ago should be before -1000d
1750        let five_years_ago = chrono::Utc::now() - chrono::Duration::days(1825);
1751        properties.insert(
1752            "created_at".to_string(),
1753            json!(five_years_ago.format("%Y-%m-%d").to_string()),
1754        );
1755        assert!(match_property(&prop, &properties).unwrap());
1756    }
1757
1758    // ==================== Tests for invalid regex patterns ====================
1759
1760    #[test]
1761    fn test_regex_with_invalid_pattern_returns_false() {
1762        // Invalid regex pattern (unclosed group)
1763        let prop = Property {
1764            key: "email".to_string(),
1765            value: json!("(unclosed"),
1766            operator: "regex".to_string(),
1767            property_type: None,
1768        };
1769
1770        let mut properties = HashMap::new();
1771        properties.insert("email".to_string(), json!("test@example.com"));
1772
1773        // Invalid regex should return false (not match)
1774        assert!(!match_property(&prop, &properties).unwrap());
1775    }
1776
1777    #[test]
1778    fn test_not_regex_with_invalid_pattern_returns_true() {
1779        // Invalid regex pattern (unclosed group)
1780        let prop = Property {
1781            key: "email".to_string(),
1782            value: json!("(unclosed"),
1783            operator: "not_regex".to_string(),
1784            property_type: None,
1785        };
1786
1787        let mut properties = HashMap::new();
1788        properties.insert("email".to_string(), json!("test@example.com"));
1789
1790        // Invalid regex with not_regex should return true (no match means "not matching")
1791        assert!(match_property(&prop, &properties).unwrap());
1792    }
1793
1794    #[test]
1795    fn test_regex_with_various_invalid_patterns() {
1796        let invalid_patterns = vec![
1797            "(unclosed", // Unclosed group
1798            "[unclosed", // Unclosed bracket
1799            "*invalid",  // Invalid quantifier at start
1800            "(?P<bad",   // Unclosed named group
1801            r"\",        // Trailing backslash
1802        ];
1803
1804        for pattern in invalid_patterns {
1805            let prop = Property {
1806                key: "value".to_string(),
1807                value: json!(pattern),
1808                operator: "regex".to_string(),
1809                property_type: None,
1810            };
1811
1812            let mut properties = HashMap::new();
1813            properties.insert("value".to_string(), json!("test"));
1814
1815            // All invalid patterns should return false for regex
1816            assert!(
1817                !match_property(&prop, &properties).unwrap(),
1818                "Invalid pattern '{}' should return false for regex",
1819                pattern
1820            );
1821
1822            // And true for not_regex
1823            let not_regex_prop = Property {
1824                operator: "not_regex".to_string(),
1825                ..prop
1826            };
1827            assert!(
1828                match_property(&not_regex_prop, &properties).unwrap(),
1829                "Invalid pattern '{}' should return true for not_regex",
1830                pattern
1831            );
1832        }
1833    }
1834}