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    /// Create an inconclusive-match error with a human-readable message.
71    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/// A feature flag definition from PostHog.
93///
94/// Contains all the information needed to evaluate whether a flag should be
95/// enabled for a given user, including targeting rules and rollout percentages.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct FeatureFlag {
98    /// Unique identifier for the flag (e.g., "new-checkout-flow")
99    pub key: String,
100    /// Whether the flag is currently active. Inactive flags always return false.
101    pub active: bool,
102    /// Targeting rules and rollout configuration
103    #[serde(default)]
104    pub filters: FeatureFlagFilters,
105}
106
107/// Targeting rules and configuration for a feature flag.
108#[derive(Debug, Clone, Serialize, Deserialize, Default)]
109pub struct FeatureFlagFilters {
110    /// List of condition groups (evaluated with OR logic between groups)
111    #[serde(default)]
112    pub groups: Vec<FeatureFlagCondition>,
113    /// Multivariate configuration for A/B tests with multiple variants
114    #[serde(default)]
115    pub multivariate: Option<MultivariateFilter>,
116    /// JSON payloads associated with flag variants
117    #[serde(default)]
118    pub payloads: HashMap<String, serde_json::Value>,
119    /// Group type index this flag targets at the flag level. `None` means person
120    /// targeting (or mixed, when individual conditions set their own).
121    #[serde(default)]
122    pub aggregation_group_type_index: Option<i32>,
123    /// When `true`, local evaluation stops and returns a definitive disabled
124    /// result as soon as a condition group's property filters match (or it has
125    /// no property filters) but the rollout percentage excludes the user,
126    /// instead of falling through to later condition groups. Defaults to
127    /// `false`, which preserves the legacy fall-through behavior.
128    #[serde(default)]
129    pub early_exit: bool,
130}
131
132/// A single condition group within a feature flag's targeting rules.
133///
134/// All properties within a condition must match (AND logic), and the user
135/// must fall within the rollout percentage to be included.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct FeatureFlagCondition {
138    /// Property filters that must all match (AND logic)
139    #[serde(default)]
140    pub properties: Vec<Property>,
141    /// Percentage of matching users who should see this flag (0-100)
142    pub rollout_percentage: Option<f64>,
143    /// Specific variant to serve for this condition (for variant overrides)
144    pub variant: Option<String>,
145    /// Optional per-condition aggregation override used by mixed-targeting flags.
146    /// When set, this condition targets the specified group type instead of the
147    /// flag-level aggregation. `None` means person targeting under a mixed flag.
148    #[serde(default)]
149    pub aggregation_group_type_index: Option<i32>,
150}
151
152/// A property filter used in feature flag targeting.
153///
154/// Supports various operators for matching user properties against expected values.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct Property {
157    /// The property key to match (e.g., "email", "country", "$feature/other-flag")
158    pub key: String,
159    /// The value to compare against
160    pub value: serde_json::Value,
161    /// Comparison operator. Supported property operators include `"exact"`,
162    /// `"is_not"`, `"icontains"`, `"not_icontains"`, `"regex"`,
163    /// `"not_regex"`, `"gt"`, `"gte"`, `"lt"`, `"lte"`, `"is_set"`,
164    /// `"is_not_set"`, `"is_date_before"`, `"is_date_after"`, and the
165    /// `"semver_*"` operators used by PostHog version targeting.
166    #[serde(default = "default_operator")]
167    pub operator: String,
168    /// Property type. Use `Some("cohort")` for cohort membership checks; flag
169    /// dependency checks use a property key that starts with `$feature/`.
170    #[serde(rename = "type")]
171    pub property_type: Option<String>,
172}
173
174fn default_operator() -> String {
175    "exact".to_string()
176}
177
178/// Definition of a cohort for local evaluation
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct CohortDefinition {
181    /// Unique identifier for the cohort.
182    pub id: String,
183    /// Properties can be either:
184    /// - A JSON object with "type" and "values" for complex property groups
185    /// - Or a direct `Vec<Property>` for simple cases
186    #[serde(default)]
187    pub properties: serde_json::Value,
188}
189
190impl CohortDefinition {
191    /// Create a new cohort definition with a simple property list.
192    ///
193    /// # Parameters
194    ///
195    /// - `id`: Cohort identifier.
196    /// - `properties`: Property filters that define cohort membership.
197    pub fn new(id: String, properties: Vec<Property>) -> Self {
198        Self {
199            id,
200            properties: serde_json::to_value(properties).unwrap_or_default(),
201        }
202    }
203
204    /// Parse the properties from the JSON structure.
205    ///
206    /// PostHog cohort properties come in format:
207    /// `{"type": "AND", "values": [{"type": "property", "key": "...", "value": "...", "operator": "..."}]}`.
208    pub fn parse_properties(&self) -> Vec<Property> {
209        // If it's an array, treat it as direct property list
210        if let Some(arr) = self.properties.as_array() {
211            return arr
212                .iter()
213                .filter_map(|v| serde_json::from_value::<Property>(v.clone()).ok())
214                .collect();
215        }
216
217        // If it's an object with "values" key, extract properties from there
218        if let Some(obj) = self.properties.as_object() {
219            if let Some(values) = obj.get("values") {
220                if let Some(values_arr) = values.as_array() {
221                    return values_arr
222                        .iter()
223                        .filter_map(|v| {
224                            // Handle both direct property objects and nested property groups
225                            if v.get("type").and_then(|t| t.as_str()) == Some("property") {
226                                serde_json::from_value::<Property>(v.clone()).ok()
227                            } else if let Some(inner_values) = v.get("values") {
228                                // Recursively handle nested groups
229                                inner_values.as_array().and_then(|arr| {
230                                    arr.iter()
231                                        .filter_map(|inner| {
232                                            serde_json::from_value::<Property>(inner.clone()).ok()
233                                        })
234                                        .next()
235                                })
236                            } else {
237                                None
238                            }
239                        })
240                        .collect();
241                }
242            }
243        }
244
245        Vec::new()
246    }
247}
248
249/// Context for evaluating properties that may depend on cohorts or other flags.
250///
251/// `groups`, `group_properties`, and `group_type_mapping` are used to resolve
252/// group-targeted and mixed-targeting flags. A group condition (one whose
253/// `aggregation_group_type_index` is set, either at the flag or condition level)
254/// is bucketed on the group key and matched against group properties looked up
255/// via the group type mapping.
256pub struct EvaluationContext<'a> {
257    /// Cohort definitions available to local evaluation, keyed by cohort ID.
258    pub cohorts: &'a HashMap<String, CohortDefinition>,
259    /// Feature flag definitions available to evaluate flag dependencies, keyed
260    /// by flag key.
261    pub flags: &'a HashMap<String, FeatureFlag>,
262    /// Distinct ID used for person-targeted flag bucketing.
263    pub distinct_id: &'a str,
264    /// Group keys for group-targeted flags, keyed by group type.
265    pub groups: &'a HashMap<String, String>,
266    /// Group properties for group-targeted flags, keyed by group type and then
267    /// property name.
268    pub group_properties: &'a HashMap<String, HashMap<String, serde_json::Value>>,
269    /// Mapping from PostHog group type index to group type name.
270    pub group_type_mapping: &'a HashMap<String, String>,
271}
272
273/// Configuration for multivariate (A/B/n) feature flags.
274#[derive(Debug, Clone, Serialize, Deserialize, Default)]
275pub struct MultivariateFilter {
276    /// List of variants with their rollout percentages
277    pub variants: Vec<MultivariateVariant>,
278}
279
280/// A single variant in a multivariate feature flag.
281#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct MultivariateVariant {
283    /// Unique key for this variant (e.g., "control", "test", "variant-a")
284    pub key: String,
285    /// Percentage of users who should see this variant (0-100)
286    pub rollout_percentage: f64,
287}
288
289/// Response from the PostHog feature flags API.
290///
291/// Supports both the v2 API format (with detailed flag information) and the
292/// legacy format (simple flag values and payloads).
293#[derive(Debug, Clone, Serialize, Deserialize)]
294#[serde(untagged)]
295pub enum FeatureFlagsResponse {
296    /// v2 API format from `/flags/?v=2` endpoint
297    V2 {
298        /// Map of flag keys to their detailed evaluation results
299        flags: HashMap<String, FlagDetail>,
300        /// Whether any errors occurred during flag computation
301        #[serde(rename = "errorsWhileComputingFlags")]
302        #[serde(default)]
303        errors_while_computing_flags: bool,
304        /// Whether the response was returned without evaluation because the
305        /// project is over its feature-flag quota.
306        #[serde(rename = "quotaLimited")]
307        #[serde(default)]
308        quota_limited: bool,
309        /// Unique identifier for this evaluation request, propagated to
310        /// `$feature_flag_called` events as `$feature_flag_request_id`
311        /// for experiment exposure tracking.
312        #[serde(rename = "requestId")]
313        #[serde(default)]
314        request_id: Option<String>,
315    },
316    /// Legacy format from older decide endpoint
317    Legacy {
318        /// Map of flag keys to their values
319        #[serde(rename = "featureFlags")]
320        feature_flags: HashMap<String, FlagValue>,
321        /// Map of flag keys to their JSON payloads
322        #[serde(rename = "featureFlagPayloads")]
323        #[serde(default)]
324        feature_flag_payloads: HashMap<String, serde_json::Value>,
325        /// Any errors that occurred during evaluation
326        #[serde(default)]
327        errors: Option<Vec<String>>,
328    },
329}
330
331impl FeatureFlagsResponse {
332    /// Convert the response to normalized flag values and payloads.
333    ///
334    /// # Returns
335    ///
336    /// A tuple of `(feature_flags, feature_flag_payloads)`, each keyed by flag
337    /// key.
338    pub fn normalize(
339        self,
340    ) -> (
341        HashMap<String, FlagValue>,
342        HashMap<String, serde_json::Value>,
343    ) {
344        match self {
345            FeatureFlagsResponse::V2 { flags, .. } => {
346                let mut feature_flags = HashMap::new();
347                let mut payloads = HashMap::new();
348
349                for (key, detail) in flags {
350                    if detail.enabled {
351                        if let Some(variant) = detail.variant {
352                            feature_flags.insert(key.clone(), FlagValue::String(variant));
353                        } else {
354                            feature_flags.insert(key.clone(), FlagValue::Boolean(true));
355                        }
356                    } else {
357                        feature_flags.insert(key.clone(), FlagValue::Boolean(false));
358                    }
359
360                    if let Some(metadata) = detail.metadata {
361                        if let Some(payload) = metadata.payload {
362                            payloads.insert(key, payload);
363                        }
364                    }
365                }
366
367                (feature_flags, payloads)
368            }
369            FeatureFlagsResponse::Legacy {
370                feature_flags,
371                feature_flag_payloads,
372                ..
373            } => (feature_flags, feature_flag_payloads),
374        }
375    }
376}
377
378/// Detailed information about a feature flag evaluation result.
379///
380/// Returned by the `/flags/?v=2` endpoint with extended information about why a
381/// flag evaluated to a particular value.
382#[derive(Debug, Clone, Serialize, Deserialize)]
383pub struct FlagDetail {
384    /// The feature flag key
385    pub key: String,
386    /// Whether the flag is enabled for this user
387    pub enabled: bool,
388    /// The variant key if this is a multivariate flag
389    pub variant: Option<String>,
390    /// Reason explaining why the flag evaluated to this value
391    #[serde(default)]
392    pub reason: Option<FlagReason>,
393    /// Additional metadata about the flag
394    #[serde(default)]
395    pub metadata: Option<FlagMetadata>,
396}
397
398/// Explains why a feature flag evaluated to a particular value.
399#[derive(Debug, Clone, Serialize, Deserialize)]
400pub struct FlagReason {
401    /// Reason code (e.g., "condition_match", "out_of_rollout_bound")
402    pub code: String,
403    /// Index of the condition that matched (if applicable)
404    #[serde(default)]
405    pub condition_index: Option<usize>,
406    /// Human-readable description of the reason
407    #[serde(default)]
408    pub description: Option<String>,
409}
410
411/// Metadata about a feature flag from the PostHog server.
412#[derive(Debug, Clone, Serialize, Deserialize)]
413pub struct FlagMetadata {
414    /// Unique identifier for this flag
415    pub id: u64,
416    /// Version number of the flag definition
417    pub version: u32,
418    /// Optional description of what this flag controls
419    pub description: Option<String>,
420    /// Optional JSON payload associated with the flag
421    pub payload: Option<serde_json::Value>,
422}
423
424const LONG_SCALE: f64 = 0xFFFFFFFFFFFFFFFu64 as f64; // Must be exactly 15 F's to match Python SDK
425
426/// Compute a deterministic hash value for feature flag bucketing.
427///
428/// Uses SHA-1 to generate a consistent hash in the range [0, 1) for the given
429/// key, distinct_id, and salt combination. This ensures users get consistent
430/// flag values across requests.
431pub fn hash_key(key: &str, distinct_id: &str, salt: &str) -> f64 {
432    let hash_key = format!("{key}.{distinct_id}{salt}");
433    let mut hasher = Sha1::new();
434    hasher.update(hash_key.as_bytes());
435    let result = hasher.finalize();
436    let hex_str = format!("{result:x}");
437    let hash_val = u64::from_str_radix(&hex_str[..15], 16).unwrap_or(0);
438    hash_val as f64 / LONG_SCALE
439}
440
441/// Determine which variant a user should see for a multivariate flag.
442///
443/// Uses consistent hashing to assign users to variants based on their
444/// rollout percentages. Returns `None` if the flag has no variants or
445/// the user doesn't fall into any variant bucket.
446pub fn get_matching_variant(flag: &FeatureFlag, distinct_id: &str) -> Option<String> {
447    let hash_value = hash_key(&flag.key, distinct_id, VARIANT_HASH_SALT);
448    let variants = flag.filters.multivariate.as_ref()?.variants.as_slice();
449
450    let mut value_min = 0.0;
451    for variant in variants {
452        let value_max = value_min + variant.rollout_percentage / 100.0;
453        if hash_value >= value_min && hash_value < value_max {
454            return Some(variant.key.clone());
455        }
456        value_min = value_max;
457    }
458    None
459}
460
461/// Result of resolving a condition's effective bucketing + properties.
462enum ConditionTarget<'a> {
463    /// Use these for bucketing and property matching.
464    Use {
465        bucketing: String,
466        properties: &'a HashMap<String, serde_json::Value>,
467    },
468    /// Skip this condition (group type unknown or required group not passed in).
469    Skip,
470    /// Required group properties not provided — try other conditions, surface
471    /// inconclusive if nothing else matches.
472    Inconclusive,
473}
474
475/// Resolve effective bucketing id and properties for a single condition based on
476/// the (possibly per-condition) aggregation group type index. Pure-person flags
477/// fall through with `distinct_id` and `person_properties`. Group conditions
478/// (either flag-level or per-condition aggregation) require the corresponding
479/// group key and group properties to be present.
480fn resolve_condition_target<'a>(
481    condition: &FeatureFlagCondition,
482    flag_aggregation: Option<i32>,
483    distinct_id: &str,
484    person_properties: &'a HashMap<String, serde_json::Value>,
485    groups: &HashMap<String, String>,
486    group_properties: &'a HashMap<String, HashMap<String, serde_json::Value>>,
487    group_type_mapping: &HashMap<String, String>,
488) -> ConditionTarget<'a> {
489    // Per-condition aggregation falls back to the flag-level value when absent.
490    // The two together drive whether this condition is person- or group-targeted.
491    let effective_aggregation = condition.aggregation_group_type_index.or(flag_aggregation);
492
493    match effective_aggregation {
494        None => ConditionTarget::Use {
495            bucketing: distinct_id.to_string(),
496            properties: person_properties,
497        },
498        Some(idx) => {
499            let key = idx.to_string();
500            let Some(group_type) = group_type_mapping.get(&key) else {
501                return ConditionTarget::Skip;
502            };
503            let Some(group_key) = groups.get(group_type) else {
504                return ConditionTarget::Skip;
505            };
506            let Some(props) = group_properties.get(group_type) else {
507                return ConditionTarget::Inconclusive;
508            };
509            ConditionTarget::Use {
510                bucketing: group_key.clone(),
511                properties: props,
512            }
513        }
514    }
515}
516
517/// Evaluate a feature flag definition against person and optional group
518/// context.
519///
520/// # Parameters
521///
522/// - `flag`: Feature flag definition to evaluate.
523/// - `distinct_id`: Distinct ID used for person bucketing.
524/// - `person_properties`: Person properties available to release conditions.
525/// - `groups`: Group keys for group-targeted flags, keyed by group type.
526/// - `group_properties`: Group properties for group-targeted flags.
527/// - `group_type_mapping`: Mapping from PostHog group type index to group type
528///   name.
529///
530/// # Returns
531///
532/// The matched flag value: `Boolean(false)` for inactive or unmatched flags,
533/// `Boolean(true)` for matched boolean flags, or `String(variant)` for matched
534/// multivariate flags.
535///
536/// # Errors
537///
538/// Returns [`InconclusiveMatchError`] when required properties are missing or an
539/// operator cannot be evaluated locally.
540#[must_use = "feature flag evaluation result should be used"]
541#[allow(clippy::too_many_arguments)]
542pub fn match_feature_flag(
543    flag: &FeatureFlag,
544    distinct_id: &str,
545    person_properties: &HashMap<String, serde_json::Value>,
546    groups: &HashMap<String, String>,
547    group_properties: &HashMap<String, HashMap<String, serde_json::Value>>,
548    group_type_mapping: &HashMap<String, String>,
549) -> Result<FlagValue, InconclusiveMatchError> {
550    if !flag.active {
551        return Ok(FlagValue::Boolean(false));
552    }
553
554    let conditions = &flag.filters.groups;
555    let flag_aggregation = flag.filters.aggregation_group_type_index;
556
557    // Sort conditions to evaluate variant overrides first
558    let mut sorted_conditions = conditions.clone();
559    sorted_conditions.sort_by_key(|c| if c.variant.is_some() { 0 } else { 1 });
560
561    let mut is_inconclusive = false;
562
563    for condition in sorted_conditions {
564        let (effective_bucketing, effective_properties) = match resolve_condition_target(
565            &condition,
566            flag_aggregation,
567            distinct_id,
568            person_properties,
569            groups,
570            group_properties,
571            group_type_mapping,
572        ) {
573            ConditionTarget::Use {
574                bucketing,
575                properties,
576            } => (bucketing, properties),
577            ConditionTarget::Skip => continue,
578            ConditionTarget::Inconclusive => {
579                is_inconclusive = true;
580                continue;
581            }
582        };
583
584        match is_condition_match(flag, &effective_bucketing, &condition, effective_properties) {
585            Ok(ConditionMatch::Match) => {
586                if let Some(variant_override) = &condition.variant {
587                    // Check if variant is valid
588                    if let Some(ref multivariate) = flag.filters.multivariate {
589                        let valid_variants: Vec<String> = multivariate
590                            .variants
591                            .iter()
592                            .map(|v| v.key.clone())
593                            .collect();
594
595                        if valid_variants.contains(variant_override) {
596                            return Ok(FlagValue::String(variant_override.clone()));
597                        }
598                    }
599                }
600
601                // Try to get matching variant or return true
602                if let Some(variant) = get_matching_variant(flag, &effective_bucketing) {
603                    return Ok(FlagValue::String(variant));
604                }
605                return Ok(FlagValue::Boolean(true));
606            }
607            Ok(ConditionMatch::OutOfRolloutBound) => {
608                // The user's properties matched this group but the rollout
609                // excluded them. With early_exit enabled the flag is
610                // definitively disabled; otherwise fall through to later groups.
611                // Only short-circuit when no prior group was inconclusive — an
612                // inconclusive result means we can't evaluate locally and must
613                // fall back to the server, so it takes priority over early_exit.
614                if flag.filters.early_exit && !is_inconclusive {
615                    return Ok(FlagValue::Boolean(false));
616                }
617            }
618            Ok(ConditionMatch::NoMatch) => continue,
619            Err(_) => {
620                is_inconclusive = true;
621            }
622        }
623    }
624
625    if is_inconclusive {
626        return Err(InconclusiveMatchError::new(
627            "Can't determine if feature flag is enabled or not with given properties",
628        ));
629    }
630
631    Ok(FlagValue::Boolean(false))
632}
633
634/// Outcome of evaluating a single condition group, mirroring the PostHog Rust
635/// evaluation engine's tri-state so the local loop can distinguish a
636/// property-filter miss (always fall through) from a rollout exclusion (which
637/// can short-circuit when `early_exit` is enabled).
638#[derive(Debug, Clone, Copy, PartialEq, Eq)]
639enum ConditionMatch {
640    /// Property filters matched (or there were none) and the rollout included
641    /// the user — the flag matches.
642    Match,
643    /// A property filter did not match — always continue to the next group.
644    NoMatch,
645    /// Property filters matched (or there were none) but the rollout excluded
646    /// the user.
647    OutOfRolloutBound,
648}
649
650fn is_condition_match(
651    flag: &FeatureFlag,
652    bucketing_id: &str,
653    condition: &FeatureFlagCondition,
654    properties: &HashMap<String, serde_json::Value>,
655) -> Result<ConditionMatch, InconclusiveMatchError> {
656    // Check properties first
657    for prop in &condition.properties {
658        if !match_property(prop, properties)? {
659            return Ok(ConditionMatch::NoMatch);
660        }
661    }
662
663    // If all properties match (or no properties), check rollout percentage
664    if let Some(rollout_percentage) = condition.rollout_percentage {
665        let hash_value = hash_key(&flag.key, bucketing_id, ROLLOUT_HASH_SALT);
666        if hash_value > (rollout_percentage / 100.0) {
667            return Ok(ConditionMatch::OutOfRolloutBound);
668        }
669    }
670
671    Ok(ConditionMatch::Match)
672}
673
674/// Match a feature flag with full context (cohorts, other flags).
675///
676/// This version supports cohort membership checks and flag dependency checks.
677/// `person_properties` carries person-level property values; group-level
678/// properties are looked up from `ctx.group_properties` when a condition (or
679/// the flag itself) targets a group via `aggregation_group_type_index`.
680///
681/// # Errors
682///
683/// Returns [`InconclusiveMatchError`] when local evaluation cannot determine a
684/// result with the provided context.
685#[must_use = "feature flag evaluation result should be used"]
686pub fn match_feature_flag_with_context(
687    flag: &FeatureFlag,
688    person_properties: &HashMap<String, serde_json::Value>,
689    ctx: &EvaluationContext,
690) -> Result<FlagValue, InconclusiveMatchError> {
691    if !flag.active {
692        return Ok(FlagValue::Boolean(false));
693    }
694
695    let conditions = &flag.filters.groups;
696    let flag_aggregation = flag.filters.aggregation_group_type_index;
697
698    // Sort conditions to evaluate variant overrides first
699    let mut sorted_conditions = conditions.clone();
700    sorted_conditions.sort_by_key(|c| if c.variant.is_some() { 0 } else { 1 });
701
702    let mut is_inconclusive = false;
703
704    for condition in sorted_conditions {
705        let (effective_bucketing, effective_properties) = match resolve_condition_target(
706            &condition,
707            flag_aggregation,
708            ctx.distinct_id,
709            person_properties,
710            ctx.groups,
711            ctx.group_properties,
712            ctx.group_type_mapping,
713        ) {
714            ConditionTarget::Use {
715                bucketing,
716                properties,
717            } => (bucketing, properties),
718            ConditionTarget::Skip => continue,
719            ConditionTarget::Inconclusive => {
720                is_inconclusive = true;
721                continue;
722            }
723        };
724
725        match is_condition_match_with_context(
726            flag,
727            &effective_bucketing,
728            &condition,
729            effective_properties,
730            ctx,
731        ) {
732            Ok(ConditionMatch::Match) => {
733                if let Some(variant_override) = &condition.variant {
734                    // Check if variant is valid
735                    if let Some(ref multivariate) = flag.filters.multivariate {
736                        let valid_variants: Vec<String> = multivariate
737                            .variants
738                            .iter()
739                            .map(|v| v.key.clone())
740                            .collect();
741
742                        if valid_variants.contains(variant_override) {
743                            return Ok(FlagValue::String(variant_override.clone()));
744                        }
745                    }
746                }
747
748                // Try to get matching variant or return true
749                if let Some(variant) = get_matching_variant(flag, &effective_bucketing) {
750                    return Ok(FlagValue::String(variant));
751                }
752                return Ok(FlagValue::Boolean(true));
753            }
754            Ok(ConditionMatch::OutOfRolloutBound) => {
755                // The user's properties matched this group but the rollout
756                // excluded them. With early_exit enabled the flag is
757                // definitively disabled; otherwise fall through to later groups.
758                // Only short-circuit when no prior group was inconclusive — an
759                // inconclusive result means we can't evaluate locally and must
760                // fall back to the server, so it takes priority over early_exit.
761                if flag.filters.early_exit && !is_inconclusive {
762                    return Ok(FlagValue::Boolean(false));
763                }
764            }
765            Ok(ConditionMatch::NoMatch) => continue,
766            Err(_) => {
767                is_inconclusive = true;
768            }
769        }
770    }
771
772    if is_inconclusive {
773        return Err(InconclusiveMatchError::new(
774            "Can't determine if feature flag is enabled or not with given properties",
775        ));
776    }
777
778    Ok(FlagValue::Boolean(false))
779}
780
781fn is_condition_match_with_context(
782    flag: &FeatureFlag,
783    bucketing_id: &str,
784    condition: &FeatureFlagCondition,
785    properties: &HashMap<String, serde_json::Value>,
786    ctx: &EvaluationContext,
787) -> Result<ConditionMatch, InconclusiveMatchError> {
788    // Check properties first (using context-aware matching for cohorts/flag dependencies)
789    for prop in &condition.properties {
790        if !match_property_with_context(prop, properties, ctx)? {
791            return Ok(ConditionMatch::NoMatch);
792        }
793    }
794
795    // If all properties match (or no properties), check rollout percentage
796    if let Some(rollout_percentage) = condition.rollout_percentage {
797        let hash_value = hash_key(&flag.key, bucketing_id, ROLLOUT_HASH_SALT);
798        if hash_value > (rollout_percentage / 100.0) {
799            return Ok(ConditionMatch::OutOfRolloutBound);
800        }
801    }
802
803    Ok(ConditionMatch::Match)
804}
805
806/// Match a property with additional context for cohorts and flag dependencies.
807///
808/// Use this when evaluating local feature flag conditions that can reference
809/// cohort membership (`type = "cohort"`) or another feature flag via
810/// `$feature/<flag-key>`.
811///
812/// # Errors
813///
814/// Returns [`InconclusiveMatchError`] when the property, cohort, or dependent
815/// flag cannot be evaluated from the supplied context.
816pub fn match_property_with_context(
817    property: &Property,
818    properties: &HashMap<String, serde_json::Value>,
819    ctx: &EvaluationContext,
820) -> Result<bool, InconclusiveMatchError> {
821    // Check if this is a cohort membership check
822    if property.property_type.as_deref() == Some("cohort") {
823        return match_cohort_property(property, properties, ctx);
824    }
825
826    // Check if this is a flag dependency check
827    if property.key.starts_with("$feature/") {
828        return match_flag_dependency_property(property, ctx);
829    }
830
831    // Fall back to regular property matching
832    match_property(property, properties)
833}
834
835/// Evaluate cohort membership
836fn match_cohort_property(
837    property: &Property,
838    properties: &HashMap<String, serde_json::Value>,
839    ctx: &EvaluationContext,
840) -> Result<bool, InconclusiveMatchError> {
841    let cohort_id = property
842        .value
843        .as_str()
844        .ok_or_else(|| InconclusiveMatchError::new("Cohort ID must be a string"))?;
845
846    let cohort = ctx.cohorts.get(cohort_id).ok_or_else(|| {
847        InconclusiveMatchError::new(&format!("Cohort '{}' not found in local cache", cohort_id))
848    })?;
849
850    // Parse and evaluate all cohort properties against the user's properties
851    let cohort_properties = cohort.parse_properties();
852    let mut is_in_cohort = true;
853    for cohort_prop in &cohort_properties {
854        match match_property(cohort_prop, properties) {
855            Ok(true) => continue,
856            Ok(false) => {
857                is_in_cohort = false;
858                break;
859            }
860            Err(e) => {
861                // If we can't evaluate a cohort property, the cohort membership is inconclusive
862                return Err(InconclusiveMatchError::new(&format!(
863                    "Cannot evaluate cohort '{}' property '{}': {}",
864                    cohort_id, cohort_prop.key, e.message
865                )));
866            }
867        }
868    }
869
870    // Handle "in" vs "not_in" operator
871    Ok(match property.operator.as_str() {
872        "in" => is_in_cohort,
873        "not_in" => !is_in_cohort,
874        op => {
875            return Err(InconclusiveMatchError::new(&format!(
876                "Unknown cohort operator: {}",
877                op
878            )));
879        }
880    })
881}
882
883/// Evaluate flag dependency
884fn match_flag_dependency_property(
885    property: &Property,
886    ctx: &EvaluationContext,
887) -> Result<bool, InconclusiveMatchError> {
888    // Extract flag key from "$feature/flag-key"
889    let flag_key = property
890        .key
891        .strip_prefix("$feature/")
892        .ok_or_else(|| InconclusiveMatchError::new("Invalid flag dependency format"))?;
893
894    let flag = ctx.flags.get(flag_key).ok_or_else(|| {
895        InconclusiveMatchError::new(&format!("Flag '{}' not found in local cache", flag_key))
896    })?;
897
898    // Evaluate the dependent flag for this user (with empty properties to avoid recursion issues).
899    // Group context flows through from the outer ctx so dependent group/mixed flags can resolve.
900    let empty_props = HashMap::new();
901    let flag_value = match_feature_flag(
902        flag,
903        ctx.distinct_id,
904        &empty_props,
905        ctx.groups,
906        ctx.group_properties,
907        ctx.group_type_mapping,
908    )?;
909
910    // Compare the flag value with the expected value
911    let expected = &property.value;
912
913    let matches = match (&flag_value, expected) {
914        (FlagValue::Boolean(b), serde_json::Value::Bool(expected_b)) => b == expected_b,
915        (FlagValue::String(s), serde_json::Value::String(expected_s)) => {
916            s.eq_ignore_ascii_case(expected_s)
917        }
918        (FlagValue::Boolean(true), serde_json::Value::String(s)) => {
919            // Flag is enabled (boolean true) but we're checking for a specific variant
920            // This should not match
921            s.is_empty() || s == "true"
922        }
923        (FlagValue::Boolean(false), serde_json::Value::String(s)) => s.is_empty() || s == "false",
924        (FlagValue::String(s), serde_json::Value::Bool(true)) => {
925            // Flag returns a variant string, checking for "enabled" (any variant is enabled)
926            !s.is_empty()
927        }
928        (FlagValue::String(_), serde_json::Value::Bool(false)) => false,
929        _ => false,
930    };
931
932    // Handle different operators
933    Ok(match property.operator.as_str() {
934        "exact" => matches,
935        "is_not" => !matches,
936        op => {
937            return Err(InconclusiveMatchError::new(&format!(
938                "Unknown flag dependency operator: {}",
939                op
940            )));
941        }
942    })
943}
944
945/// Parse a relative date string like "-7d", "-24h", "-2w", "-3m", "-1y"
946/// Returns the DateTime<Utc> that the relative date represents
947fn parse_relative_date(value: &str) -> Option<DateTime<Utc>> {
948    let value = value.trim();
949    // Need at least 3 chars: "-", digit(s), and unit (e.g., "-7d")
950    if value.len() < 3 || !value.starts_with('-') {
951        return None;
952    }
953
954    let (num_str, unit) = value[1..].split_at(value.len() - 2);
955    let num: i64 = num_str.parse().ok()?;
956
957    let duration = match unit {
958        "h" => chrono::Duration::hours(num),
959        "d" => chrono::Duration::days(num),
960        "w" => chrono::Duration::weeks(num),
961        "m" => chrono::Duration::days(num * 30), // Approximate month as 30 days
962        "y" => chrono::Duration::days(num * 365), // Approximate year as 365 days
963        _ => return None,
964    };
965
966    Some(Utc::now() - duration)
967}
968
969/// Parse a date value from a string (ISO date, ISO datetime, or relative date)
970fn parse_date_value(value: &serde_json::Value) -> Option<DateTime<Utc>> {
971    let date_str = value.as_str()?;
972
973    // Try relative date first (e.g., "-7d")
974    if date_str.starts_with('-') && date_str.len() > 1 {
975        if let Some(dt) = parse_relative_date(date_str) {
976            return Some(dt);
977        }
978    }
979
980    // Try ISO datetime with timezone (e.g., "2024-06-15T10:30:00Z")
981    if let Ok(dt) = DateTime::parse_from_rfc3339(date_str) {
982        return Some(dt.with_timezone(&Utc));
983    }
984
985    // Try ISO date only (e.g., "2024-06-15")
986    if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
987        return Some(
988            date.and_hms_opt(0, 0, 0)
989                .expect("midnight is always valid")
990                .and_utc(),
991        );
992    }
993
994    None
995}
996
997/// A parsed semantic version as (major, minor, patch)
998type SemverTuple = (u64, u64, u64);
999
1000/// Parse a semantic version string into a (major, minor, patch) tuple.
1001///
1002/// Rules:
1003/// 1. Strip leading/trailing whitespace
1004/// 2. Strip `v` or `V` prefix (e.g., "v1.2.3" → "1.2.3")
1005/// 3. Strip pre-release and build metadata suffixes (split on `-` or `+`, take first part)
1006/// 4. Split on `.` and parse first 3 components as integers
1007/// 5. Default missing components to 0 (e.g., "1.2" → (1, 2, 0), "1" → (1, 0, 0))
1008/// 6. Ignore extra components beyond the third (e.g., "1.2.3.4" → (1, 2, 3))
1009/// 7. Return None for invalid input (empty string, non-numeric parts, leading dot,
1010///    or numeric components with leading zeros per semver 2.0.0 §2)
1011fn parse_semver(value: &str) -> Option<SemverTuple> {
1012    let value = value.trim();
1013    if value.is_empty() {
1014        return None;
1015    }
1016
1017    // Strip v/V prefix
1018    let value = value
1019        .strip_prefix('v')
1020        .or_else(|| value.strip_prefix('V'))
1021        .unwrap_or(value);
1022    if value.is_empty() {
1023        return None;
1024    }
1025
1026    // Strip pre-release/build metadata (everything after - or +)
1027    let value = value.split(['-', '+']).next().unwrap_or(value);
1028    if value.is_empty() {
1029        return None;
1030    }
1031
1032    // Leading dot is invalid
1033    if value.starts_with('.') {
1034        return None;
1035    }
1036
1037    // Split on dots and parse components
1038    let parts: Vec<&str> = value.split('.').collect();
1039    if parts.is_empty() {
1040        return None;
1041    }
1042
1043    let major = parse_semver_numeric(parts.first()?)?;
1044    let minor = parts.get(1).map_or(Some(0), |s| parse_semver_numeric(s))?;
1045    let patch = parts.get(2).map_or(Some(0), |s| parse_semver_numeric(s))?;
1046
1047    Some((major, minor, patch))
1048}
1049
1050/// Parse a single semver numeric identifier.
1051///
1052/// Per semver 2.0.0 §2, numeric identifiers MUST NOT include leading zeros, so
1053/// "07" and "001" are rejected while the literal "0" remains valid.
1054fn parse_semver_numeric(part: &str) -> Option<u64> {
1055    if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
1056        return None;
1057    }
1058    if part.len() > 1 && part.starts_with('0') {
1059        return None;
1060    }
1061    part.parse().ok()
1062}
1063
1064/// Parse a wildcard pattern like "1.*" or "1.2.*" and return (lower_bound, upper_bound)
1065/// Returns None if the pattern is invalid
1066fn parse_semver_wildcard(pattern: &str) -> Option<(SemverTuple, SemverTuple)> {
1067    let pattern = pattern.trim();
1068    if pattern.is_empty() {
1069        return None;
1070    }
1071
1072    // Strip v/V prefix
1073    let pattern = pattern
1074        .strip_prefix('v')
1075        .or_else(|| pattern.strip_prefix('V'))
1076        .unwrap_or(pattern);
1077    if pattern.is_empty() {
1078        return None;
1079    }
1080
1081    let parts: Vec<&str> = pattern.split('.').collect();
1082
1083    match parts.as_slice() {
1084        // "X.*" pattern
1085        [major_str, "*"] => {
1086            let major = parse_semver_numeric(major_str)?;
1087            Some(((major, 0, 0), (major + 1, 0, 0)))
1088        }
1089        // "X.Y.*" pattern
1090        [major_str, minor_str, "*"] => {
1091            let major = parse_semver_numeric(major_str)?;
1092            let minor = parse_semver_numeric(minor_str)?;
1093            Some(((major, minor, 0), (major, minor + 1, 0)))
1094        }
1095        _ => None,
1096    }
1097}
1098
1099/// Compute bounds for tilde range: ~X.Y.Z means >=X.Y.Z and <X.(Y+1).0
1100fn compute_tilde_bounds(version: SemverTuple) -> (SemverTuple, SemverTuple) {
1101    let (major, minor, patch) = version;
1102    ((major, minor, patch), (major, minor + 1, 0))
1103}
1104
1105/// Compute bounds for caret range per semver spec:
1106/// - ^X.Y.Z where X > 0: >=X.Y.Z <(X+1).0.0
1107/// - ^0.Y.Z where Y > 0: >=0.Y.Z <0.(Y+1).0
1108/// - ^0.0.Z: >=0.0.Z <0.0.(Z+1)
1109fn compute_caret_bounds(version: SemverTuple) -> (SemverTuple, SemverTuple) {
1110    let (major, minor, patch) = version;
1111    if major > 0 {
1112        ((major, minor, patch), (major + 1, 0, 0))
1113    } else if minor > 0 {
1114        ((0, minor, patch), (0, minor + 1, 0))
1115    } else {
1116        ((0, 0, patch), (0, 0, patch + 1))
1117    }
1118}
1119
1120fn parse_target_semver(
1121    target_value: &serde_json::Value,
1122) -> Result<SemverTuple, InconclusiveMatchError> {
1123    let target_str = value_to_string(target_value);
1124    parse_semver(&target_str).ok_or_else(|| {
1125        InconclusiveMatchError::new(&format!(
1126            "Unable to parse target semver value: {:?}",
1127            target_value
1128        ))
1129    })
1130}
1131
1132fn match_property(
1133    property: &Property,
1134    properties: &HashMap<String, serde_json::Value>,
1135) -> Result<bool, InconclusiveMatchError> {
1136    let value = match properties.get(&property.key) {
1137        Some(v) => v,
1138        None => {
1139            // Handle is_not_set operator
1140            if property.operator == "is_not_set" {
1141                return Ok(true);
1142            }
1143            // Handle is_set operator
1144            if property.operator == "is_set" {
1145                return Ok(false);
1146            }
1147            // For other operators, missing property is inconclusive
1148            return Err(InconclusiveMatchError::new(&format!(
1149                "Property '{}' not found in provided properties",
1150                property.key
1151            )));
1152        }
1153    };
1154
1155    let parse_property_semver = || {
1156        let prop_str = value_to_string(value);
1157        parse_semver(&prop_str).ok_or_else(|| {
1158            InconclusiveMatchError::new(&format!(
1159                "Unable to parse property semver value for '{}': {:?}",
1160                property.key, value
1161            ))
1162        })
1163    };
1164    let parse_semver_operands = || {
1165        Ok((
1166            parse_property_semver()?,
1167            parse_target_semver(&property.value)?,
1168        ))
1169    };
1170
1171    Ok(match property.operator.as_str() {
1172        "exact" => {
1173            if property.value.is_array() {
1174                if let Some(arr) = property.value.as_array() {
1175                    for val in arr {
1176                        if compare_values(val, value) {
1177                            return Ok(true);
1178                        }
1179                    }
1180                    return Ok(false);
1181                }
1182            }
1183            compare_values(&property.value, value)
1184        }
1185        "is_not" => {
1186            if property.value.is_array() {
1187                if let Some(arr) = property.value.as_array() {
1188                    for val in arr {
1189                        if compare_values(val, value) {
1190                            return Ok(false);
1191                        }
1192                    }
1193                    return Ok(true);
1194                }
1195            }
1196            !compare_values(&property.value, value)
1197        }
1198        "is_set" => true,      // We already know the property exists
1199        "is_not_set" => false, // We already know the property exists
1200        "icontains" => {
1201            let prop_str = value_to_string(value);
1202            let search_str = value_to_string(&property.value);
1203            prop_str.to_lowercase().contains(&search_str.to_lowercase())
1204        }
1205        "not_icontains" => {
1206            let prop_str = value_to_string(value);
1207            let search_str = value_to_string(&property.value);
1208            !prop_str.to_lowercase().contains(&search_str.to_lowercase())
1209        }
1210        "regex" => {
1211            let prop_str = value_to_string(value);
1212            let regex_str = value_to_string(&property.value);
1213            get_cached_regex(&regex_str)
1214                .map(|re| re.is_match(&prop_str))
1215                .unwrap_or(false)
1216        }
1217        "not_regex" => {
1218            let prop_str = value_to_string(value);
1219            let regex_str = value_to_string(&property.value);
1220            get_cached_regex(&regex_str)
1221                .map(|re| !re.is_match(&prop_str))
1222                .unwrap_or(true)
1223        }
1224        "gt" | "gte" | "lt" | "lte" => compare_numeric(&property.operator, &property.value, value),
1225        "is_date_before" | "is_date_after" => {
1226            let target_date = parse_date_value(&property.value).ok_or_else(|| {
1227                InconclusiveMatchError::new(&format!(
1228                    "Unable to parse target date value: {:?}",
1229                    property.value
1230                ))
1231            })?;
1232
1233            let prop_date = parse_date_value(value).ok_or_else(|| {
1234                InconclusiveMatchError::new(&format!(
1235                    "Unable to parse property date value for '{}': {:?}",
1236                    property.key, value
1237                ))
1238            })?;
1239
1240            if property.operator == "is_date_before" {
1241                prop_date < target_date
1242            } else {
1243                prop_date > target_date
1244            }
1245        }
1246        // Semver comparison operators
1247        "semver_eq" | "semver_neq" | "semver_gt" | "semver_gte" | "semver_lt" | "semver_lte" => {
1248            let (prop_version, target_version) = parse_semver_operands()?;
1249
1250            match property.operator.as_str() {
1251                "semver_eq" => prop_version == target_version,
1252                "semver_neq" => prop_version != target_version,
1253                "semver_gt" => prop_version > target_version,
1254                "semver_gte" => prop_version >= target_version,
1255                "semver_lt" => prop_version < target_version,
1256                "semver_lte" => prop_version <= target_version,
1257                _ => unreachable!(),
1258            }
1259        }
1260        "semver_tilde" => {
1261            let (prop_version, target_version) = parse_semver_operands()?;
1262            let (lower, upper) = compute_tilde_bounds(target_version);
1263            prop_version >= lower && prop_version < upper
1264        }
1265        "semver_caret" => {
1266            let (prop_version, target_version) = parse_semver_operands()?;
1267            let (lower, upper) = compute_caret_bounds(target_version);
1268            prop_version >= lower && prop_version < upper
1269        }
1270        "semver_wildcard" => {
1271            let prop_version = parse_property_semver()?;
1272            let target_str = value_to_string(&property.value);
1273
1274            let (lower, upper) = parse_semver_wildcard(&target_str).ok_or_else(|| {
1275                InconclusiveMatchError::new(&format!(
1276                    "Unable to parse target semver wildcard pattern: {:?}",
1277                    property.value
1278                ))
1279            })?;
1280
1281            prop_version >= lower && prop_version < upper
1282        }
1283        unknown => {
1284            return Err(InconclusiveMatchError::new(&format!(
1285                "Unknown operator: {}",
1286                unknown
1287            )));
1288        }
1289    })
1290}
1291
1292fn compare_values(a: &serde_json::Value, b: &serde_json::Value) -> bool {
1293    // Case-insensitive string comparison
1294    if let (Some(a_str), Some(b_str)) = (a.as_str(), b.as_str()) {
1295        return a_str.eq_ignore_ascii_case(b_str);
1296    }
1297
1298    // Direct comparison for other types
1299    a == b
1300}
1301
1302fn value_to_string(value: &serde_json::Value) -> String {
1303    match value {
1304        serde_json::Value::String(s) => s.clone(),
1305        serde_json::Value::Number(n) => n.to_string(),
1306        serde_json::Value::Bool(b) => b.to_string(),
1307        _ => value.to_string(),
1308    }
1309}
1310
1311fn compare_numeric(
1312    operator: &str,
1313    property_value: &serde_json::Value,
1314    value: &serde_json::Value,
1315) -> bool {
1316    let prop_num = match property_value {
1317        serde_json::Value::Number(n) => n.as_f64(),
1318        serde_json::Value::String(s) => s.parse::<f64>().ok(),
1319        _ => None,
1320    };
1321
1322    let val_num = match value {
1323        serde_json::Value::Number(n) => n.as_f64(),
1324        serde_json::Value::String(s) => s.parse::<f64>().ok(),
1325        _ => None,
1326    };
1327
1328    if let (Some(prop), Some(val)) = (prop_num, val_num) {
1329        match operator {
1330            "gt" => val > prop,
1331            "gte" => val >= prop,
1332            "lt" => val < prop,
1333            "lte" => val <= prop,
1334            _ => false,
1335        }
1336    } else {
1337        // Fall back to string comparison
1338        let prop_str = value_to_string(property_value);
1339        let val_str = value_to_string(value);
1340        match operator {
1341            "gt" => val_str > prop_str,
1342            "gte" => val_str >= prop_str,
1343            "lt" => val_str < prop_str,
1344            "lte" => val_str <= prop_str,
1345            _ => false,
1346        }
1347    }
1348}
1349
1350#[cfg(test)]
1351mod tests {
1352    use super::*;
1353    use serde_json::json;
1354
1355    /// Test salt constant to avoid CodeQL warnings about empty cryptographic values
1356    const TEST_SALT: &str = "test-salt";
1357
1358    #[test]
1359    fn test_hash_key() {
1360        let hash = hash_key("test-flag", "user-123", TEST_SALT);
1361        assert!((0.0..=1.0).contains(&hash));
1362
1363        // Same inputs should produce same hash
1364        let hash2 = hash_key("test-flag", "user-123", TEST_SALT);
1365        assert_eq!(hash, hash2);
1366
1367        // Different inputs should produce different hash
1368        let hash3 = hash_key("test-flag", "user-456", TEST_SALT);
1369        assert_ne!(hash, hash3);
1370    }
1371
1372    #[test]
1373    fn test_simple_flag_match() {
1374        let flag = FeatureFlag {
1375            key: "test-flag".to_string(),
1376            active: true,
1377            filters: FeatureFlagFilters {
1378                groups: vec![FeatureFlagCondition {
1379                    properties: vec![],
1380                    rollout_percentage: Some(100.0),
1381                    variant: None,
1382                    aggregation_group_type_index: None,
1383                }],
1384                multivariate: None,
1385                payloads: HashMap::new(),
1386                aggregation_group_type_index: None,
1387                early_exit: false,
1388            },
1389        };
1390
1391        let properties = HashMap::new();
1392        let result = match_feature_flag(
1393            &flag,
1394            "user-123",
1395            &properties,
1396            &HashMap::new(),
1397            &HashMap::new(),
1398            &HashMap::new(),
1399        )
1400        .unwrap();
1401        assert_eq!(result, FlagValue::Boolean(true));
1402    }
1403
1404    #[test]
1405    fn test_property_matching() {
1406        let prop = Property {
1407            key: "country".to_string(),
1408            value: json!("US"),
1409            operator: "exact".to_string(),
1410            property_type: None,
1411        };
1412
1413        let mut properties = HashMap::new();
1414        properties.insert("country".to_string(), json!("US"));
1415
1416        assert!(match_property(&prop, &properties).unwrap());
1417
1418        properties.insert("country".to_string(), json!("UK"));
1419        assert!(!match_property(&prop, &properties).unwrap());
1420    }
1421
1422    #[test]
1423    fn test_multivariate_variants() {
1424        let flag = FeatureFlag {
1425            key: "test-flag".to_string(),
1426            active: true,
1427            filters: FeatureFlagFilters {
1428                groups: vec![FeatureFlagCondition {
1429                    properties: vec![],
1430                    rollout_percentage: Some(100.0),
1431                    variant: None,
1432                    aggregation_group_type_index: None,
1433                }],
1434                multivariate: Some(MultivariateFilter {
1435                    variants: vec![
1436                        MultivariateVariant {
1437                            key: "control".to_string(),
1438                            rollout_percentage: 50.0,
1439                        },
1440                        MultivariateVariant {
1441                            key: "test".to_string(),
1442                            rollout_percentage: 50.0,
1443                        },
1444                    ],
1445                }),
1446                payloads: HashMap::new(),
1447                aggregation_group_type_index: None,
1448                early_exit: false,
1449            },
1450        };
1451
1452        let properties = HashMap::new();
1453        let result = match_feature_flag(
1454            &flag,
1455            "user-123",
1456            &properties,
1457            &HashMap::new(),
1458            &HashMap::new(),
1459            &HashMap::new(),
1460        )
1461        .unwrap();
1462
1463        match result {
1464            FlagValue::String(variant) => {
1465                assert!(variant == "control" || variant == "test");
1466            }
1467            _ => panic!("Expected string variant"),
1468        }
1469    }
1470
1471    #[test]
1472    fn test_inactive_flag() {
1473        let flag = FeatureFlag {
1474            key: "inactive-flag".to_string(),
1475            active: false,
1476            filters: FeatureFlagFilters {
1477                groups: vec![FeatureFlagCondition {
1478                    properties: vec![],
1479                    rollout_percentage: Some(100.0),
1480                    variant: None,
1481                    aggregation_group_type_index: None,
1482                }],
1483                multivariate: None,
1484                payloads: HashMap::new(),
1485                aggregation_group_type_index: None,
1486                early_exit: false,
1487            },
1488        };
1489
1490        let properties = HashMap::new();
1491        let result = match_feature_flag(
1492            &flag,
1493            "user-123",
1494            &properties,
1495            &HashMap::new(),
1496            &HashMap::new(),
1497            &HashMap::new(),
1498        )
1499        .unwrap();
1500        assert_eq!(result, FlagValue::Boolean(false));
1501    }
1502
1503    #[test]
1504    fn test_rollout_percentage() {
1505        let flag = FeatureFlag {
1506            key: "rollout-flag".to_string(),
1507            active: true,
1508            filters: FeatureFlagFilters {
1509                groups: vec![FeatureFlagCondition {
1510                    properties: vec![],
1511                    rollout_percentage: Some(30.0), // 30% rollout
1512                    variant: None,
1513                    aggregation_group_type_index: None,
1514                }],
1515                multivariate: None,
1516                payloads: HashMap::new(),
1517                aggregation_group_type_index: None,
1518                early_exit: false,
1519            },
1520        };
1521
1522        let properties = HashMap::new();
1523
1524        // Test with multiple users to ensure distribution
1525        let mut enabled_count = 0;
1526        for i in 0..1000 {
1527            let result = match_feature_flag(
1528                &flag,
1529                &format!("user-{}", i),
1530                &properties,
1531                &HashMap::new(),
1532                &HashMap::new(),
1533                &HashMap::new(),
1534            )
1535            .unwrap();
1536            if result == FlagValue::Boolean(true) {
1537                enabled_count += 1;
1538            }
1539        }
1540
1541        // Should be roughly 30% enabled (allow for some variance)
1542        assert!(enabled_count > 250 && enabled_count < 350);
1543    }
1544
1545    #[test]
1546    fn test_regex_operator() {
1547        let prop = Property {
1548            key: "email".to_string(),
1549            value: json!(".*@company\\.com$"),
1550            operator: "regex".to_string(),
1551            property_type: None,
1552        };
1553
1554        let mut properties = HashMap::new();
1555        properties.insert("email".to_string(), json!("user@company.com"));
1556        assert!(match_property(&prop, &properties).unwrap());
1557
1558        properties.insert("email".to_string(), json!("user@example.com"));
1559        assert!(!match_property(&prop, &properties).unwrap());
1560    }
1561
1562    #[test]
1563    fn test_icontains_operator() {
1564        let prop = Property {
1565            key: "name".to_string(),
1566            value: json!("ADMIN"),
1567            operator: "icontains".to_string(),
1568            property_type: None,
1569        };
1570
1571        let mut properties = HashMap::new();
1572        properties.insert("name".to_string(), json!("admin_user"));
1573        assert!(match_property(&prop, &properties).unwrap());
1574
1575        properties.insert("name".to_string(), json!("regular_user"));
1576        assert!(!match_property(&prop, &properties).unwrap());
1577    }
1578
1579    #[test]
1580    fn test_numeric_operators() {
1581        // Greater than
1582        let prop_gt = Property {
1583            key: "age".to_string(),
1584            value: json!(18),
1585            operator: "gt".to_string(),
1586            property_type: None,
1587        };
1588
1589        let mut properties = HashMap::new();
1590        properties.insert("age".to_string(), json!(25));
1591        assert!(match_property(&prop_gt, &properties).unwrap());
1592
1593        properties.insert("age".to_string(), json!(15));
1594        assert!(!match_property(&prop_gt, &properties).unwrap());
1595
1596        // Less than or equal
1597        let prop_lte = Property {
1598            key: "score".to_string(),
1599            value: json!(100),
1600            operator: "lte".to_string(),
1601            property_type: None,
1602        };
1603
1604        properties.insert("score".to_string(), json!(100));
1605        assert!(match_property(&prop_lte, &properties).unwrap());
1606
1607        properties.insert("score".to_string(), json!(101));
1608        assert!(!match_property(&prop_lte, &properties).unwrap());
1609    }
1610
1611    #[test]
1612    fn test_is_set_operator() {
1613        let prop = Property {
1614            key: "email".to_string(),
1615            value: json!(true),
1616            operator: "is_set".to_string(),
1617            property_type: None,
1618        };
1619
1620        let mut properties = HashMap::new();
1621        properties.insert("email".to_string(), json!("test@example.com"));
1622        assert!(match_property(&prop, &properties).unwrap());
1623
1624        properties.remove("email");
1625        assert!(!match_property(&prop, &properties).unwrap());
1626    }
1627
1628    #[test]
1629    fn test_is_not_set_operator() {
1630        let prop = Property {
1631            key: "phone".to_string(),
1632            value: json!(true),
1633            operator: "is_not_set".to_string(),
1634            property_type: None,
1635        };
1636
1637        let mut properties = HashMap::new();
1638        assert!(match_property(&prop, &properties).unwrap());
1639
1640        properties.insert("phone".to_string(), json!("+1234567890"));
1641        assert!(!match_property(&prop, &properties).unwrap());
1642    }
1643
1644    #[test]
1645    fn test_empty_groups() {
1646        let flag = FeatureFlag {
1647            key: "empty-groups".to_string(),
1648            active: true,
1649            filters: FeatureFlagFilters {
1650                groups: vec![],
1651                multivariate: None,
1652                payloads: HashMap::new(),
1653                aggregation_group_type_index: None,
1654                early_exit: false,
1655            },
1656        };
1657
1658        let properties = HashMap::new();
1659        let result = match_feature_flag(
1660            &flag,
1661            "user-123",
1662            &properties,
1663            &HashMap::new(),
1664            &HashMap::new(),
1665            &HashMap::new(),
1666        )
1667        .unwrap();
1668        assert_eq!(result, FlagValue::Boolean(false));
1669    }
1670
1671    #[test]
1672    fn test_hash_scale_constant() {
1673        // Verify the constant is exactly 15 F's (not 16)
1674        assert_eq!(LONG_SCALE, 0xFFFFFFFFFFFFFFFu64 as f64);
1675        assert_ne!(LONG_SCALE, 0xFFFFFFFFFFFFFFFFu64 as f64);
1676    }
1677
1678    // ==================== Tests for missing operators ====================
1679
1680    #[test]
1681    fn test_unknown_operator_returns_inconclusive_error() {
1682        let prop = Property {
1683            key: "status".to_string(),
1684            value: json!("active"),
1685            operator: "unknown_operator".to_string(),
1686            property_type: None,
1687        };
1688
1689        let mut properties = HashMap::new();
1690        properties.insert("status".to_string(), json!("active"));
1691
1692        let result = match_property(&prop, &properties);
1693        assert!(result.is_err());
1694        let err = result.unwrap_err();
1695        assert!(err.message.contains("unknown_operator"));
1696    }
1697
1698    #[test]
1699    fn test_is_date_before_with_relative_date() {
1700        let prop = Property {
1701            key: "signup_date".to_string(),
1702            value: json!("-7d"), // 7 days ago
1703            operator: "is_date_before".to_string(),
1704            property_type: None,
1705        };
1706
1707        let mut properties = HashMap::new();
1708        // Date 10 days ago should be before -7d
1709        let ten_days_ago = chrono::Utc::now() - chrono::Duration::days(10);
1710        properties.insert(
1711            "signup_date".to_string(),
1712            json!(ten_days_ago.format("%Y-%m-%d").to_string()),
1713        );
1714        assert!(match_property(&prop, &properties).unwrap());
1715
1716        // Date 3 days ago should NOT be before -7d
1717        let three_days_ago = chrono::Utc::now() - chrono::Duration::days(3);
1718        properties.insert(
1719            "signup_date".to_string(),
1720            json!(three_days_ago.format("%Y-%m-%d").to_string()),
1721        );
1722        assert!(!match_property(&prop, &properties).unwrap());
1723    }
1724
1725    #[test]
1726    fn test_is_date_after_with_relative_date() {
1727        let prop = Property {
1728            key: "last_seen".to_string(),
1729            value: json!("-30d"), // 30 days ago
1730            operator: "is_date_after".to_string(),
1731            property_type: None,
1732        };
1733
1734        let mut properties = HashMap::new();
1735        // Date 10 days ago should be after -30d
1736        let ten_days_ago = chrono::Utc::now() - chrono::Duration::days(10);
1737        properties.insert(
1738            "last_seen".to_string(),
1739            json!(ten_days_ago.format("%Y-%m-%d").to_string()),
1740        );
1741        assert!(match_property(&prop, &properties).unwrap());
1742
1743        // Date 60 days ago should NOT be after -30d
1744        let sixty_days_ago = chrono::Utc::now() - chrono::Duration::days(60);
1745        properties.insert(
1746            "last_seen".to_string(),
1747            json!(sixty_days_ago.format("%Y-%m-%d").to_string()),
1748        );
1749        assert!(!match_property(&prop, &properties).unwrap());
1750    }
1751
1752    #[test]
1753    fn test_is_date_before_with_iso_date() {
1754        let prop = Property {
1755            key: "expiry_date".to_string(),
1756            value: json!("2024-06-15"),
1757            operator: "is_date_before".to_string(),
1758            property_type: None,
1759        };
1760
1761        let mut properties = HashMap::new();
1762        properties.insert("expiry_date".to_string(), json!("2024-06-10"));
1763        assert!(match_property(&prop, &properties).unwrap());
1764
1765        properties.insert("expiry_date".to_string(), json!("2024-06-20"));
1766        assert!(!match_property(&prop, &properties).unwrap());
1767    }
1768
1769    #[test]
1770    fn test_is_date_after_with_iso_date() {
1771        let prop = Property {
1772            key: "start_date".to_string(),
1773            value: json!("2024-01-01"),
1774            operator: "is_date_after".to_string(),
1775            property_type: None,
1776        };
1777
1778        let mut properties = HashMap::new();
1779        properties.insert("start_date".to_string(), json!("2024-03-15"));
1780        assert!(match_property(&prop, &properties).unwrap());
1781
1782        properties.insert("start_date".to_string(), json!("2023-12-01"));
1783        assert!(!match_property(&prop, &properties).unwrap());
1784    }
1785
1786    #[test]
1787    fn test_is_date_with_relative_hours() {
1788        let prop = Property {
1789            key: "last_active".to_string(),
1790            value: json!("-24h"), // 24 hours ago
1791            operator: "is_date_after".to_string(),
1792            property_type: None,
1793        };
1794
1795        let mut properties = HashMap::new();
1796        // 12 hours ago should be after -24h
1797        let twelve_hours_ago = chrono::Utc::now() - chrono::Duration::hours(12);
1798        properties.insert(
1799            "last_active".to_string(),
1800            json!(twelve_hours_ago.to_rfc3339()),
1801        );
1802        assert!(match_property(&prop, &properties).unwrap());
1803
1804        // 48 hours ago should NOT be after -24h
1805        let forty_eight_hours_ago = chrono::Utc::now() - chrono::Duration::hours(48);
1806        properties.insert(
1807            "last_active".to_string(),
1808            json!(forty_eight_hours_ago.to_rfc3339()),
1809        );
1810        assert!(!match_property(&prop, &properties).unwrap());
1811    }
1812
1813    #[test]
1814    fn test_is_date_with_relative_weeks() {
1815        let prop = Property {
1816            key: "joined".to_string(),
1817            value: json!("-2w"), // 2 weeks ago
1818            operator: "is_date_before".to_string(),
1819            property_type: None,
1820        };
1821
1822        let mut properties = HashMap::new();
1823        // 3 weeks ago should be before -2w
1824        let three_weeks_ago = chrono::Utc::now() - chrono::Duration::weeks(3);
1825        properties.insert(
1826            "joined".to_string(),
1827            json!(three_weeks_ago.format("%Y-%m-%d").to_string()),
1828        );
1829        assert!(match_property(&prop, &properties).unwrap());
1830
1831        // 1 week ago should NOT be before -2w
1832        let one_week_ago = chrono::Utc::now() - chrono::Duration::weeks(1);
1833        properties.insert(
1834            "joined".to_string(),
1835            json!(one_week_ago.format("%Y-%m-%d").to_string()),
1836        );
1837        assert!(!match_property(&prop, &properties).unwrap());
1838    }
1839
1840    #[test]
1841    fn test_is_date_with_relative_months() {
1842        let prop = Property {
1843            key: "subscription_date".to_string(),
1844            value: json!("-3m"), // 3 months ago
1845            operator: "is_date_after".to_string(),
1846            property_type: None,
1847        };
1848
1849        let mut properties = HashMap::new();
1850        // 1 month ago should be after -3m
1851        let one_month_ago = chrono::Utc::now() - chrono::Duration::days(30);
1852        properties.insert(
1853            "subscription_date".to_string(),
1854            json!(one_month_ago.format("%Y-%m-%d").to_string()),
1855        );
1856        assert!(match_property(&prop, &properties).unwrap());
1857
1858        // 6 months ago should NOT be after -3m
1859        let six_months_ago = chrono::Utc::now() - chrono::Duration::days(180);
1860        properties.insert(
1861            "subscription_date".to_string(),
1862            json!(six_months_ago.format("%Y-%m-%d").to_string()),
1863        );
1864        assert!(!match_property(&prop, &properties).unwrap());
1865    }
1866
1867    #[test]
1868    fn test_is_date_with_relative_years() {
1869        let prop = Property {
1870            key: "created_at".to_string(),
1871            value: json!("-1y"), // 1 year ago
1872            operator: "is_date_before".to_string(),
1873            property_type: None,
1874        };
1875
1876        let mut properties = HashMap::new();
1877        // 2 years ago should be before -1y
1878        let two_years_ago = chrono::Utc::now() - chrono::Duration::days(730);
1879        properties.insert(
1880            "created_at".to_string(),
1881            json!(two_years_ago.format("%Y-%m-%d").to_string()),
1882        );
1883        assert!(match_property(&prop, &properties).unwrap());
1884
1885        // 6 months ago should NOT be before -1y
1886        let six_months_ago = chrono::Utc::now() - chrono::Duration::days(180);
1887        properties.insert(
1888            "created_at".to_string(),
1889            json!(six_months_ago.format("%Y-%m-%d").to_string()),
1890        );
1891        assert!(!match_property(&prop, &properties).unwrap());
1892    }
1893
1894    #[test]
1895    fn test_is_date_with_invalid_date_format() {
1896        let prop = Property {
1897            key: "date".to_string(),
1898            value: json!("-7d"),
1899            operator: "is_date_before".to_string(),
1900            property_type: None,
1901        };
1902
1903        let mut properties = HashMap::new();
1904        properties.insert("date".to_string(), json!("not-a-date"));
1905
1906        // Invalid date formats should return inconclusive
1907        let result = match_property(&prop, &properties);
1908        assert!(result.is_err());
1909    }
1910
1911    #[test]
1912    fn test_is_date_with_iso_datetime() {
1913        let prop = Property {
1914            key: "event_time".to_string(),
1915            value: json!("2024-06-15T10:30:00Z"),
1916            operator: "is_date_before".to_string(),
1917            property_type: None,
1918        };
1919
1920        let mut properties = HashMap::new();
1921        properties.insert("event_time".to_string(), json!("2024-06-15T08:00:00Z"));
1922        assert!(match_property(&prop, &properties).unwrap());
1923
1924        properties.insert("event_time".to_string(), json!("2024-06-15T12:00:00Z"));
1925        assert!(!match_property(&prop, &properties).unwrap());
1926    }
1927
1928    // ==================== Tests for cohort membership ====================
1929
1930    #[test]
1931    fn test_cohort_membership_in() {
1932        // Create a cohort that matches users with country = US
1933        let mut cohorts = HashMap::new();
1934        cohorts.insert(
1935            "cohort_1".to_string(),
1936            CohortDefinition::new(
1937                "cohort_1".to_string(),
1938                vec![Property {
1939                    key: "country".to_string(),
1940                    value: json!("US"),
1941                    operator: "exact".to_string(),
1942                    property_type: None,
1943                }],
1944            ),
1945        );
1946
1947        // Property filter checking cohort membership
1948        let prop = Property {
1949            key: "$cohort".to_string(),
1950            value: json!("cohort_1"),
1951            operator: "in".to_string(),
1952            property_type: Some("cohort".to_string()),
1953        };
1954
1955        // User with country = US should be in the cohort
1956        let mut properties = HashMap::new();
1957        properties.insert("country".to_string(), json!("US"));
1958
1959        let ctx = EvaluationContext {
1960            cohorts: &cohorts,
1961            flags: &HashMap::new(),
1962            distinct_id: "user-123",
1963            groups: &HashMap::new(),
1964            group_properties: &HashMap::new(),
1965            group_type_mapping: &HashMap::new(),
1966        };
1967        assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
1968
1969        // User with country = UK should NOT be in the cohort
1970        properties.insert("country".to_string(), json!("UK"));
1971        assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
1972    }
1973
1974    #[test]
1975    fn test_cohort_membership_not_in() {
1976        let mut cohorts = HashMap::new();
1977        cohorts.insert(
1978            "cohort_blocked".to_string(),
1979            CohortDefinition::new(
1980                "cohort_blocked".to_string(),
1981                vec![Property {
1982                    key: "status".to_string(),
1983                    value: json!("blocked"),
1984                    operator: "exact".to_string(),
1985                    property_type: None,
1986                }],
1987            ),
1988        );
1989
1990        let prop = Property {
1991            key: "$cohort".to_string(),
1992            value: json!("cohort_blocked"),
1993            operator: "not_in".to_string(),
1994            property_type: Some("cohort".to_string()),
1995        };
1996
1997        let mut properties = HashMap::new();
1998        properties.insert("status".to_string(), json!("active"));
1999
2000        let ctx = EvaluationContext {
2001            cohorts: &cohorts,
2002            flags: &HashMap::new(),
2003            distinct_id: "user-123",
2004            groups: &HashMap::new(),
2005            group_properties: &HashMap::new(),
2006            group_type_mapping: &HashMap::new(),
2007        };
2008        // User with status = active should NOT be in the blocked cohort (so not_in returns true)
2009        assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
2010
2011        // User with status = blocked IS in the cohort (so not_in returns false)
2012        properties.insert("status".to_string(), json!("blocked"));
2013        assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
2014    }
2015
2016    #[test]
2017    fn test_cohort_not_found_returns_inconclusive() {
2018        let cohorts = HashMap::new(); // No cohorts defined
2019
2020        let prop = Property {
2021            key: "$cohort".to_string(),
2022            value: json!("nonexistent_cohort"),
2023            operator: "in".to_string(),
2024            property_type: Some("cohort".to_string()),
2025        };
2026
2027        let properties = HashMap::new();
2028        let ctx = EvaluationContext {
2029            cohorts: &cohorts,
2030            flags: &HashMap::new(),
2031            distinct_id: "user-123",
2032            groups: &HashMap::new(),
2033            group_properties: &HashMap::new(),
2034            group_type_mapping: &HashMap::new(),
2035        };
2036
2037        let result = match_property_with_context(&prop, &properties, &ctx);
2038        assert!(result.is_err());
2039        assert!(result.unwrap_err().message.contains("Cohort"));
2040    }
2041
2042    // ==================== Tests for flag dependencies ====================
2043
2044    #[test]
2045    fn test_flag_dependency_enabled() {
2046        let mut flags = HashMap::new();
2047        flags.insert(
2048            "prerequisite-flag".to_string(),
2049            FeatureFlag {
2050                key: "prerequisite-flag".to_string(),
2051                active: true,
2052                filters: FeatureFlagFilters {
2053                    groups: vec![FeatureFlagCondition {
2054                        properties: vec![],
2055                        rollout_percentage: Some(100.0),
2056                        variant: None,
2057                        aggregation_group_type_index: None,
2058                    }],
2059                    multivariate: None,
2060                    payloads: HashMap::new(),
2061                    aggregation_group_type_index: None,
2062                    early_exit: false,
2063                },
2064            },
2065        );
2066
2067        // Property checking if prerequisite-flag is enabled
2068        let prop = Property {
2069            key: "$feature/prerequisite-flag".to_string(),
2070            value: json!(true),
2071            operator: "exact".to_string(),
2072            property_type: None,
2073        };
2074
2075        let properties = HashMap::new();
2076        let ctx = EvaluationContext {
2077            cohorts: &HashMap::new(),
2078            flags: &flags,
2079            distinct_id: "user-123",
2080            groups: &HashMap::new(),
2081            group_properties: &HashMap::new(),
2082            group_type_mapping: &HashMap::new(),
2083        };
2084
2085        // The prerequisite flag is enabled for user-123, so this should match
2086        assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
2087    }
2088
2089    #[test]
2090    fn test_flag_dependency_disabled() {
2091        let mut flags = HashMap::new();
2092        flags.insert(
2093            "disabled-flag".to_string(),
2094            FeatureFlag {
2095                key: "disabled-flag".to_string(),
2096                active: false, // Flag is inactive
2097                filters: FeatureFlagFilters {
2098                    groups: vec![],
2099                    multivariate: None,
2100                    payloads: HashMap::new(),
2101                    aggregation_group_type_index: None,
2102                    early_exit: false,
2103                },
2104            },
2105        );
2106
2107        // Property checking if disabled-flag is enabled
2108        let prop = Property {
2109            key: "$feature/disabled-flag".to_string(),
2110            value: json!(true),
2111            operator: "exact".to_string(),
2112            property_type: None,
2113        };
2114
2115        let properties = HashMap::new();
2116        let ctx = EvaluationContext {
2117            cohorts: &HashMap::new(),
2118            flags: &flags,
2119            distinct_id: "user-123",
2120            groups: &HashMap::new(),
2121            group_properties: &HashMap::new(),
2122            group_type_mapping: &HashMap::new(),
2123        };
2124
2125        // The flag is disabled, so checking for true should fail
2126        assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
2127    }
2128
2129    #[test]
2130    fn test_flag_dependency_variant_match() {
2131        let mut flags = HashMap::new();
2132        flags.insert(
2133            "ab-test-flag".to_string(),
2134            FeatureFlag {
2135                key: "ab-test-flag".to_string(),
2136                active: true,
2137                filters: FeatureFlagFilters {
2138                    groups: vec![FeatureFlagCondition {
2139                        properties: vec![],
2140                        rollout_percentage: Some(100.0),
2141                        variant: None,
2142                        aggregation_group_type_index: None,
2143                    }],
2144                    multivariate: Some(MultivariateFilter {
2145                        variants: vec![
2146                            MultivariateVariant {
2147                                key: "control".to_string(),
2148                                rollout_percentage: 50.0,
2149                            },
2150                            MultivariateVariant {
2151                                key: "test".to_string(),
2152                                rollout_percentage: 50.0,
2153                            },
2154                        ],
2155                    }),
2156                    payloads: HashMap::new(),
2157                    aggregation_group_type_index: None,
2158                    early_exit: false,
2159                },
2160            },
2161        );
2162
2163        // Check if user is in "control" variant
2164        let prop = Property {
2165            key: "$feature/ab-test-flag".to_string(),
2166            value: json!("control"),
2167            operator: "exact".to_string(),
2168            property_type: None,
2169        };
2170
2171        let properties = HashMap::new();
2172        let ctx = EvaluationContext {
2173            cohorts: &HashMap::new(),
2174            flags: &flags,
2175            distinct_id: "user-gets-control", // This distinct_id should deterministically get "control"
2176            groups: &HashMap::new(),
2177            group_properties: &HashMap::new(),
2178            group_type_mapping: &HashMap::new(),
2179        };
2180
2181        // The result depends on the hash - we just check it doesn't error
2182        let result = match_property_with_context(&prop, &properties, &ctx);
2183        assert!(result.is_ok());
2184    }
2185
2186    #[test]
2187    fn test_flag_dependency_not_found_returns_inconclusive() {
2188        let flags = HashMap::new(); // No flags defined
2189
2190        let prop = Property {
2191            key: "$feature/nonexistent-flag".to_string(),
2192            value: json!(true),
2193            operator: "exact".to_string(),
2194            property_type: None,
2195        };
2196
2197        let properties = HashMap::new();
2198        let ctx = EvaluationContext {
2199            cohorts: &HashMap::new(),
2200            flags: &flags,
2201            distinct_id: "user-123",
2202            groups: &HashMap::new(),
2203            group_properties: &HashMap::new(),
2204            group_type_mapping: &HashMap::new(),
2205        };
2206
2207        let result = match_property_with_context(&prop, &properties, &ctx);
2208        assert!(result.is_err());
2209        assert!(result.unwrap_err().message.contains("Flag"));
2210    }
2211
2212    // ==================== Date parsing edge case tests ====================
2213
2214    #[test]
2215    fn test_parse_relative_date_edge_cases() {
2216        // These test the internal parse_relative_date function indirectly via match_property
2217        let prop = Property {
2218            key: "date".to_string(),
2219            value: json!("placeholder"),
2220            operator: "is_date_before".to_string(),
2221            property_type: None,
2222        };
2223
2224        let mut properties = HashMap::new();
2225        properties.insert("date".to_string(), json!("2024-01-01"));
2226
2227        // Empty string as target date should fail
2228        let empty_prop = Property {
2229            value: json!(""),
2230            ..prop.clone()
2231        };
2232        assert!(match_property(&empty_prop, &properties).is_err());
2233
2234        // Single dash should fail
2235        let dash_prop = Property {
2236            value: json!("-"),
2237            ..prop.clone()
2238        };
2239        assert!(match_property(&dash_prop, &properties).is_err());
2240
2241        // Missing unit (just "-7") should fail
2242        let no_unit_prop = Property {
2243            value: json!("-7"),
2244            ..prop.clone()
2245        };
2246        assert!(match_property(&no_unit_prop, &properties).is_err());
2247
2248        // Missing number (just "-d") should fail
2249        let no_number_prop = Property {
2250            value: json!("-d"),
2251            ..prop.clone()
2252        };
2253        assert!(match_property(&no_number_prop, &properties).is_err());
2254
2255        // Invalid unit should fail
2256        let invalid_unit_prop = Property {
2257            value: json!("-7x"),
2258            ..prop.clone()
2259        };
2260        assert!(match_property(&invalid_unit_prop, &properties).is_err());
2261    }
2262
2263    #[test]
2264    fn test_parse_relative_date_large_values() {
2265        // Very large relative dates should work
2266        let prop = Property {
2267            key: "created_at".to_string(),
2268            value: json!("-1000d"), // ~2.7 years ago
2269            operator: "is_date_before".to_string(),
2270            property_type: None,
2271        };
2272
2273        let mut properties = HashMap::new();
2274        // Date 5 years ago should be before -1000d
2275        let five_years_ago = chrono::Utc::now() - chrono::Duration::days(1825);
2276        properties.insert(
2277            "created_at".to_string(),
2278            json!(five_years_ago.format("%Y-%m-%d").to_string()),
2279        );
2280        assert!(match_property(&prop, &properties).unwrap());
2281    }
2282
2283    // ==================== Tests for invalid regex patterns ====================
2284
2285    #[test]
2286    fn test_regex_with_invalid_pattern_returns_false() {
2287        // Invalid regex pattern (unclosed group)
2288        let prop = Property {
2289            key: "email".to_string(),
2290            value: json!("(unclosed"),
2291            operator: "regex".to_string(),
2292            property_type: None,
2293        };
2294
2295        let mut properties = HashMap::new();
2296        properties.insert("email".to_string(), json!("test@example.com"));
2297
2298        // Invalid regex should return false (not match)
2299        assert!(!match_property(&prop, &properties).unwrap());
2300    }
2301
2302    #[test]
2303    fn test_not_regex_with_invalid_pattern_returns_true() {
2304        // Invalid regex pattern (unclosed group)
2305        let prop = Property {
2306            key: "email".to_string(),
2307            value: json!("(unclosed"),
2308            operator: "not_regex".to_string(),
2309            property_type: None,
2310        };
2311
2312        let mut properties = HashMap::new();
2313        properties.insert("email".to_string(), json!("test@example.com"));
2314
2315        // Invalid regex with not_regex should return true (no match means "not matching")
2316        assert!(match_property(&prop, &properties).unwrap());
2317    }
2318
2319    #[test]
2320    fn test_regex_with_various_invalid_patterns() {
2321        let invalid_patterns = vec![
2322            "(unclosed", // Unclosed group
2323            "[unclosed", // Unclosed bracket
2324            "*invalid",  // Invalid quantifier at start
2325            "(?P<bad",   // Unclosed named group
2326            r"\",        // Trailing backslash
2327        ];
2328
2329        for pattern in invalid_patterns {
2330            let prop = Property {
2331                key: "value".to_string(),
2332                value: json!(pattern),
2333                operator: "regex".to_string(),
2334                property_type: None,
2335            };
2336
2337            let mut properties = HashMap::new();
2338            properties.insert("value".to_string(), json!("test"));
2339
2340            // All invalid patterns should return false for regex
2341            assert!(
2342                !match_property(&prop, &properties).unwrap(),
2343                "Invalid pattern '{}' should return false for regex",
2344                pattern
2345            );
2346
2347            // And true for not_regex
2348            let not_regex_prop = Property {
2349                operator: "not_regex".to_string(),
2350                ..prop
2351            };
2352            assert!(
2353                match_property(&not_regex_prop, &properties).unwrap(),
2354                "Invalid pattern '{}' should return true for not_regex",
2355                pattern
2356            );
2357        }
2358    }
2359
2360    // ==================== Semver parsing tests ====================
2361
2362    #[test]
2363    fn test_parse_semver_basic() {
2364        assert_eq!(parse_semver("1.2.3"), Some((1, 2, 3)));
2365        assert_eq!(parse_semver("0.0.0"), Some((0, 0, 0)));
2366        assert_eq!(parse_semver("10.20.30"), Some((10, 20, 30)));
2367    }
2368
2369    #[test]
2370    fn test_parse_semver_v_prefix() {
2371        assert_eq!(parse_semver("v1.2.3"), Some((1, 2, 3)));
2372        assert_eq!(parse_semver("V1.2.3"), Some((1, 2, 3)));
2373    }
2374
2375    #[test]
2376    fn test_parse_semver_whitespace() {
2377        assert_eq!(parse_semver("  1.2.3  "), Some((1, 2, 3)));
2378        assert_eq!(parse_semver(" v1.2.3 "), Some((1, 2, 3)));
2379    }
2380
2381    #[test]
2382    fn test_parse_semver_prerelease_stripped() {
2383        assert_eq!(parse_semver("1.2.3-alpha"), Some((1, 2, 3)));
2384        assert_eq!(parse_semver("1.2.3-beta.1"), Some((1, 2, 3)));
2385        assert_eq!(parse_semver("1.2.3-rc.1+build.123"), Some((1, 2, 3)));
2386        assert_eq!(parse_semver("1.2.3+build.456"), Some((1, 2, 3)));
2387    }
2388
2389    #[test]
2390    fn test_parse_semver_partial_versions() {
2391        assert_eq!(parse_semver("1.2"), Some((1, 2, 0)));
2392        assert_eq!(parse_semver("1"), Some((1, 0, 0)));
2393        assert_eq!(parse_semver("v1.2"), Some((1, 2, 0)));
2394    }
2395
2396    #[test]
2397    fn test_parse_semver_extra_components_ignored() {
2398        assert_eq!(parse_semver("1.2.3.4"), Some((1, 2, 3)));
2399        assert_eq!(parse_semver("1.2.3.4.5.6"), Some((1, 2, 3)));
2400    }
2401
2402    #[test]
2403    fn test_parse_semver_leading_zeros_rejected() {
2404        // Per semver 2.0.0 §2, numeric identifiers must not include leading zeros.
2405        assert_eq!(parse_semver("01.02.03"), None);
2406        assert_eq!(parse_semver("001.002.003"), None);
2407        assert_eq!(parse_semver("1.07.3"), None);
2408        assert_eq!(parse_semver("1.2.03"), None);
2409        assert_eq!(parse_semver("v01.2.3"), None);
2410
2411        // Literal "0" components remain valid.
2412        assert_eq!(parse_semver("0.1.0"), Some((0, 1, 0)));
2413        assert_eq!(parse_semver("1.0.0"), Some((1, 0, 0)));
2414        assert_eq!(parse_semver("0.0.0"), Some((0, 0, 0)));
2415    }
2416
2417    #[test]
2418    fn test_parse_semver_invalid() {
2419        assert_eq!(parse_semver(""), None);
2420        assert_eq!(parse_semver("   "), None);
2421        assert_eq!(parse_semver("v"), None);
2422        assert_eq!(parse_semver(".1.2.3"), None);
2423        assert_eq!(parse_semver("abc"), None);
2424        assert_eq!(parse_semver("1.abc.3"), None);
2425        assert_eq!(parse_semver("1.2.abc"), None);
2426        assert_eq!(parse_semver("not-a-version"), None);
2427    }
2428
2429    // ==================== Semver eq/neq tests ====================
2430
2431    #[test]
2432    fn test_semver_eq_basic() {
2433        let prop = Property {
2434            key: "version".to_string(),
2435            value: json!("1.2.3"),
2436            operator: "semver_eq".to_string(),
2437            property_type: None,
2438        };
2439
2440        let mut properties = HashMap::new();
2441
2442        properties.insert("version".to_string(), json!("1.2.3"));
2443        assert!(match_property(&prop, &properties).unwrap());
2444
2445        properties.insert("version".to_string(), json!("1.2.4"));
2446        assert!(!match_property(&prop, &properties).unwrap());
2447
2448        properties.insert("version".to_string(), json!("1.3.3"));
2449        assert!(!match_property(&prop, &properties).unwrap());
2450
2451        properties.insert("version".to_string(), json!("2.2.3"));
2452        assert!(!match_property(&prop, &properties).unwrap());
2453    }
2454
2455    #[test]
2456    fn test_semver_eq_with_v_prefix() {
2457        let prop = Property {
2458            key: "version".to_string(),
2459            value: json!("1.2.3"),
2460            operator: "semver_eq".to_string(),
2461            property_type: None,
2462        };
2463
2464        let mut properties = HashMap::new();
2465
2466        // v-prefix on property value
2467        properties.insert("version".to_string(), json!("v1.2.3"));
2468        assert!(match_property(&prop, &properties).unwrap());
2469
2470        // v-prefix on target value
2471        let prop_with_v = Property {
2472            value: json!("v1.2.3"),
2473            ..prop.clone()
2474        };
2475        properties.insert("version".to_string(), json!("1.2.3"));
2476        assert!(match_property(&prop_with_v, &properties).unwrap());
2477    }
2478
2479    #[test]
2480    fn test_semver_eq_prerelease_stripped() {
2481        let prop = Property {
2482            key: "version".to_string(),
2483            value: json!("1.2.3"),
2484            operator: "semver_eq".to_string(),
2485            property_type: None,
2486        };
2487
2488        let mut properties = HashMap::new();
2489
2490        properties.insert("version".to_string(), json!("1.2.3-alpha"));
2491        assert!(match_property(&prop, &properties).unwrap());
2492
2493        properties.insert("version".to_string(), json!("1.2.3-beta.1"));
2494        assert!(match_property(&prop, &properties).unwrap());
2495
2496        properties.insert("version".to_string(), json!("1.2.3+build.456"));
2497        assert!(match_property(&prop, &properties).unwrap());
2498    }
2499
2500    #[test]
2501    fn test_semver_eq_partial_versions() {
2502        let prop = Property {
2503            key: "version".to_string(),
2504            value: json!("1.2.0"),
2505            operator: "semver_eq".to_string(),
2506            property_type: None,
2507        };
2508
2509        let mut properties = HashMap::new();
2510
2511        // "1.2" should equal "1.2.0"
2512        properties.insert("version".to_string(), json!("1.2"));
2513        assert!(match_property(&prop, &properties).unwrap());
2514
2515        // Target as partial version
2516        let partial_prop = Property {
2517            value: json!("1.2"),
2518            ..prop.clone()
2519        };
2520        properties.insert("version".to_string(), json!("1.2.0"));
2521        assert!(match_property(&partial_prop, &properties).unwrap());
2522    }
2523
2524    #[test]
2525    fn test_semver_neq() {
2526        let prop = Property {
2527            key: "version".to_string(),
2528            value: json!("1.2.3"),
2529            operator: "semver_neq".to_string(),
2530            property_type: None,
2531        };
2532
2533        let mut properties = HashMap::new();
2534
2535        properties.insert("version".to_string(), json!("1.2.3"));
2536        assert!(!match_property(&prop, &properties).unwrap());
2537
2538        properties.insert("version".to_string(), json!("1.2.4"));
2539        assert!(match_property(&prop, &properties).unwrap());
2540
2541        properties.insert("version".to_string(), json!("2.0.0"));
2542        assert!(match_property(&prop, &properties).unwrap());
2543    }
2544
2545    // ==================== Semver gt/gte/lt/lte tests ====================
2546
2547    #[test]
2548    fn test_semver_gt() {
2549        let prop = Property {
2550            key: "version".to_string(),
2551            value: json!("1.2.3"),
2552            operator: "semver_gt".to_string(),
2553            property_type: None,
2554        };
2555
2556        let mut properties = HashMap::new();
2557
2558        // Greater versions
2559        properties.insert("version".to_string(), json!("1.2.4"));
2560        assert!(match_property(&prop, &properties).unwrap());
2561
2562        properties.insert("version".to_string(), json!("1.3.0"));
2563        assert!(match_property(&prop, &properties).unwrap());
2564
2565        properties.insert("version".to_string(), json!("2.0.0"));
2566        assert!(match_property(&prop, &properties).unwrap());
2567
2568        // Equal version
2569        properties.insert("version".to_string(), json!("1.2.3"));
2570        assert!(!match_property(&prop, &properties).unwrap());
2571
2572        // Lesser versions
2573        properties.insert("version".to_string(), json!("1.2.2"));
2574        assert!(!match_property(&prop, &properties).unwrap());
2575
2576        properties.insert("version".to_string(), json!("1.1.9"));
2577        assert!(!match_property(&prop, &properties).unwrap());
2578
2579        properties.insert("version".to_string(), json!("0.9.9"));
2580        assert!(!match_property(&prop, &properties).unwrap());
2581    }
2582
2583    #[test]
2584    fn test_semver_gte() {
2585        let prop = Property {
2586            key: "version".to_string(),
2587            value: json!("1.2.3"),
2588            operator: "semver_gte".to_string(),
2589            property_type: None,
2590        };
2591
2592        let mut properties = HashMap::new();
2593
2594        // Greater versions
2595        properties.insert("version".to_string(), json!("1.2.4"));
2596        assert!(match_property(&prop, &properties).unwrap());
2597
2598        properties.insert("version".to_string(), json!("2.0.0"));
2599        assert!(match_property(&prop, &properties).unwrap());
2600
2601        // Equal version
2602        properties.insert("version".to_string(), json!("1.2.3"));
2603        assert!(match_property(&prop, &properties).unwrap());
2604
2605        // Lesser versions
2606        properties.insert("version".to_string(), json!("1.2.2"));
2607        assert!(!match_property(&prop, &properties).unwrap());
2608
2609        properties.insert("version".to_string(), json!("0.9.9"));
2610        assert!(!match_property(&prop, &properties).unwrap());
2611    }
2612
2613    #[test]
2614    fn test_semver_lt() {
2615        let prop = Property {
2616            key: "version".to_string(),
2617            value: json!("1.2.3"),
2618            operator: "semver_lt".to_string(),
2619            property_type: None,
2620        };
2621
2622        let mut properties = HashMap::new();
2623
2624        // Lesser versions
2625        properties.insert("version".to_string(), json!("1.2.2"));
2626        assert!(match_property(&prop, &properties).unwrap());
2627
2628        properties.insert("version".to_string(), json!("1.1.9"));
2629        assert!(match_property(&prop, &properties).unwrap());
2630
2631        properties.insert("version".to_string(), json!("0.9.9"));
2632        assert!(match_property(&prop, &properties).unwrap());
2633
2634        // Equal version
2635        properties.insert("version".to_string(), json!("1.2.3"));
2636        assert!(!match_property(&prop, &properties).unwrap());
2637
2638        // Greater versions
2639        properties.insert("version".to_string(), json!("1.2.4"));
2640        assert!(!match_property(&prop, &properties).unwrap());
2641
2642        properties.insert("version".to_string(), json!("2.0.0"));
2643        assert!(!match_property(&prop, &properties).unwrap());
2644    }
2645
2646    #[test]
2647    fn test_semver_lte() {
2648        let prop = Property {
2649            key: "version".to_string(),
2650            value: json!("1.2.3"),
2651            operator: "semver_lte".to_string(),
2652            property_type: None,
2653        };
2654
2655        let mut properties = HashMap::new();
2656
2657        // Lesser versions
2658        properties.insert("version".to_string(), json!("1.2.2"));
2659        assert!(match_property(&prop, &properties).unwrap());
2660
2661        properties.insert("version".to_string(), json!("0.9.9"));
2662        assert!(match_property(&prop, &properties).unwrap());
2663
2664        // Equal version
2665        properties.insert("version".to_string(), json!("1.2.3"));
2666        assert!(match_property(&prop, &properties).unwrap());
2667
2668        // Greater versions
2669        properties.insert("version".to_string(), json!("1.2.4"));
2670        assert!(!match_property(&prop, &properties).unwrap());
2671
2672        properties.insert("version".to_string(), json!("2.0.0"));
2673        assert!(!match_property(&prop, &properties).unwrap());
2674    }
2675
2676    // ==================== Semver tilde tests ====================
2677
2678    #[test]
2679    fn test_semver_tilde_basic() {
2680        // ~1.2.3 means >=1.2.3 <1.3.0
2681        let prop = Property {
2682            key: "version".to_string(),
2683            value: json!("1.2.3"),
2684            operator: "semver_tilde".to_string(),
2685            property_type: None,
2686        };
2687
2688        let mut properties = HashMap::new();
2689
2690        // Exact match
2691        properties.insert("version".to_string(), json!("1.2.3"));
2692        assert!(match_property(&prop, &properties).unwrap());
2693
2694        // Within range
2695        properties.insert("version".to_string(), json!("1.2.4"));
2696        assert!(match_property(&prop, &properties).unwrap());
2697
2698        properties.insert("version".to_string(), json!("1.2.99"));
2699        assert!(match_property(&prop, &properties).unwrap());
2700
2701        // At upper bound (excluded)
2702        properties.insert("version".to_string(), json!("1.3.0"));
2703        assert!(!match_property(&prop, &properties).unwrap());
2704
2705        // Above upper bound
2706        properties.insert("version".to_string(), json!("1.3.1"));
2707        assert!(!match_property(&prop, &properties).unwrap());
2708
2709        properties.insert("version".to_string(), json!("2.0.0"));
2710        assert!(!match_property(&prop, &properties).unwrap());
2711
2712        // Below lower bound
2713        properties.insert("version".to_string(), json!("1.2.2"));
2714        assert!(!match_property(&prop, &properties).unwrap());
2715
2716        properties.insert("version".to_string(), json!("1.1.9"));
2717        assert!(!match_property(&prop, &properties).unwrap());
2718    }
2719
2720    #[test]
2721    fn test_semver_tilde_zero_versions() {
2722        // ~0.2.3 means >=0.2.3 <0.3.0
2723        let prop = Property {
2724            key: "version".to_string(),
2725            value: json!("0.2.3"),
2726            operator: "semver_tilde".to_string(),
2727            property_type: None,
2728        };
2729
2730        let mut properties = HashMap::new();
2731
2732        properties.insert("version".to_string(), json!("0.2.3"));
2733        assert!(match_property(&prop, &properties).unwrap());
2734
2735        properties.insert("version".to_string(), json!("0.2.9"));
2736        assert!(match_property(&prop, &properties).unwrap());
2737
2738        properties.insert("version".to_string(), json!("0.3.0"));
2739        assert!(!match_property(&prop, &properties).unwrap());
2740
2741        properties.insert("version".to_string(), json!("0.2.2"));
2742        assert!(!match_property(&prop, &properties).unwrap());
2743    }
2744
2745    // ==================== Semver caret tests ====================
2746
2747    #[test]
2748    fn test_semver_caret_major_nonzero() {
2749        // ^1.2.3 means >=1.2.3 <2.0.0
2750        let prop = Property {
2751            key: "version".to_string(),
2752            value: json!("1.2.3"),
2753            operator: "semver_caret".to_string(),
2754            property_type: None,
2755        };
2756
2757        let mut properties = HashMap::new();
2758
2759        // Exact match
2760        properties.insert("version".to_string(), json!("1.2.3"));
2761        assert!(match_property(&prop, &properties).unwrap());
2762
2763        // Within range
2764        properties.insert("version".to_string(), json!("1.2.4"));
2765        assert!(match_property(&prop, &properties).unwrap());
2766
2767        properties.insert("version".to_string(), json!("1.3.0"));
2768        assert!(match_property(&prop, &properties).unwrap());
2769
2770        properties.insert("version".to_string(), json!("1.99.99"));
2771        assert!(match_property(&prop, &properties).unwrap());
2772
2773        // At upper bound (excluded)
2774        properties.insert("version".to_string(), json!("2.0.0"));
2775        assert!(!match_property(&prop, &properties).unwrap());
2776
2777        // Above upper bound
2778        properties.insert("version".to_string(), json!("2.0.1"));
2779        assert!(!match_property(&prop, &properties).unwrap());
2780
2781        // Below lower bound
2782        properties.insert("version".to_string(), json!("1.2.2"));
2783        assert!(!match_property(&prop, &properties).unwrap());
2784
2785        properties.insert("version".to_string(), json!("0.9.9"));
2786        assert!(!match_property(&prop, &properties).unwrap());
2787    }
2788
2789    #[test]
2790    fn test_semver_caret_major_zero_minor_nonzero() {
2791        // ^0.2.3 means >=0.2.3 <0.3.0
2792        let prop = Property {
2793            key: "version".to_string(),
2794            value: json!("0.2.3"),
2795            operator: "semver_caret".to_string(),
2796            property_type: None,
2797        };
2798
2799        let mut properties = HashMap::new();
2800
2801        // Exact match
2802        properties.insert("version".to_string(), json!("0.2.3"));
2803        assert!(match_property(&prop, &properties).unwrap());
2804
2805        // Within range
2806        properties.insert("version".to_string(), json!("0.2.4"));
2807        assert!(match_property(&prop, &properties).unwrap());
2808
2809        properties.insert("version".to_string(), json!("0.2.99"));
2810        assert!(match_property(&prop, &properties).unwrap());
2811
2812        // At upper bound (excluded)
2813        properties.insert("version".to_string(), json!("0.3.0"));
2814        assert!(!match_property(&prop, &properties).unwrap());
2815
2816        // Above upper bound
2817        properties.insert("version".to_string(), json!("0.3.1"));
2818        assert!(!match_property(&prop, &properties).unwrap());
2819
2820        properties.insert("version".to_string(), json!("1.0.0"));
2821        assert!(!match_property(&prop, &properties).unwrap());
2822
2823        // Below lower bound
2824        properties.insert("version".to_string(), json!("0.2.2"));
2825        assert!(!match_property(&prop, &properties).unwrap());
2826
2827        properties.insert("version".to_string(), json!("0.1.9"));
2828        assert!(!match_property(&prop, &properties).unwrap());
2829    }
2830
2831    #[test]
2832    fn test_semver_caret_major_zero_minor_zero() {
2833        // ^0.0.3 means >=0.0.3 <0.0.4
2834        let prop = Property {
2835            key: "version".to_string(),
2836            value: json!("0.0.3"),
2837            operator: "semver_caret".to_string(),
2838            property_type: None,
2839        };
2840
2841        let mut properties = HashMap::new();
2842
2843        // Exact match
2844        properties.insert("version".to_string(), json!("0.0.3"));
2845        assert!(match_property(&prop, &properties).unwrap());
2846
2847        // At upper bound (excluded)
2848        properties.insert("version".to_string(), json!("0.0.4"));
2849        assert!(!match_property(&prop, &properties).unwrap());
2850
2851        // Above upper bound
2852        properties.insert("version".to_string(), json!("0.0.5"));
2853        assert!(!match_property(&prop, &properties).unwrap());
2854
2855        properties.insert("version".to_string(), json!("0.1.0"));
2856        assert!(!match_property(&prop, &properties).unwrap());
2857
2858        // Below lower bound
2859        properties.insert("version".to_string(), json!("0.0.2"));
2860        assert!(!match_property(&prop, &properties).unwrap());
2861    }
2862
2863    // ==================== Semver wildcard tests ====================
2864
2865    #[test]
2866    fn test_semver_wildcard_major() {
2867        // 1.* means >=1.0.0 <2.0.0
2868        let prop = Property {
2869            key: "version".to_string(),
2870            value: json!("1.*"),
2871            operator: "semver_wildcard".to_string(),
2872            property_type: None,
2873        };
2874
2875        let mut properties = HashMap::new();
2876
2877        // At lower bound
2878        properties.insert("version".to_string(), json!("1.0.0"));
2879        assert!(match_property(&prop, &properties).unwrap());
2880
2881        // Within range
2882        properties.insert("version".to_string(), json!("1.2.3"));
2883        assert!(match_property(&prop, &properties).unwrap());
2884
2885        properties.insert("version".to_string(), json!("1.99.99"));
2886        assert!(match_property(&prop, &properties).unwrap());
2887
2888        // At upper bound (excluded)
2889        properties.insert("version".to_string(), json!("2.0.0"));
2890        assert!(!match_property(&prop, &properties).unwrap());
2891
2892        // Above upper bound
2893        properties.insert("version".to_string(), json!("2.0.1"));
2894        assert!(!match_property(&prop, &properties).unwrap());
2895
2896        // Below lower bound
2897        properties.insert("version".to_string(), json!("0.9.9"));
2898        assert!(!match_property(&prop, &properties).unwrap());
2899    }
2900
2901    #[test]
2902    fn test_semver_wildcard_minor() {
2903        // 1.2.* means >=1.2.0 <1.3.0
2904        let prop = Property {
2905            key: "version".to_string(),
2906            value: json!("1.2.*"),
2907            operator: "semver_wildcard".to_string(),
2908            property_type: None,
2909        };
2910
2911        let mut properties = HashMap::new();
2912
2913        // At lower bound
2914        properties.insert("version".to_string(), json!("1.2.0"));
2915        assert!(match_property(&prop, &properties).unwrap());
2916
2917        // Within range
2918        properties.insert("version".to_string(), json!("1.2.3"));
2919        assert!(match_property(&prop, &properties).unwrap());
2920
2921        properties.insert("version".to_string(), json!("1.2.99"));
2922        assert!(match_property(&prop, &properties).unwrap());
2923
2924        // At upper bound (excluded)
2925        properties.insert("version".to_string(), json!("1.3.0"));
2926        assert!(!match_property(&prop, &properties).unwrap());
2927
2928        // Above upper bound
2929        properties.insert("version".to_string(), json!("1.3.1"));
2930        assert!(!match_property(&prop, &properties).unwrap());
2931
2932        properties.insert("version".to_string(), json!("2.0.0"));
2933        assert!(!match_property(&prop, &properties).unwrap());
2934
2935        // Below lower bound
2936        properties.insert("version".to_string(), json!("1.1.9"));
2937        assert!(!match_property(&prop, &properties).unwrap());
2938    }
2939
2940    #[test]
2941    fn test_semver_wildcard_zero() {
2942        // 0.* means >=0.0.0 <1.0.0
2943        let prop = Property {
2944            key: "version".to_string(),
2945            value: json!("0.*"),
2946            operator: "semver_wildcard".to_string(),
2947            property_type: None,
2948        };
2949
2950        let mut properties = HashMap::new();
2951
2952        properties.insert("version".to_string(), json!("0.0.0"));
2953        assert!(match_property(&prop, &properties).unwrap());
2954
2955        properties.insert("version".to_string(), json!("0.99.99"));
2956        assert!(match_property(&prop, &properties).unwrap());
2957
2958        properties.insert("version".to_string(), json!("1.0.0"));
2959        assert!(!match_property(&prop, &properties).unwrap());
2960    }
2961
2962    // ==================== Semver error handling tests ====================
2963
2964    #[test]
2965    fn test_semver_invalid_property_value() {
2966        let prop = Property {
2967            key: "version".to_string(),
2968            value: json!("1.2.3"),
2969            operator: "semver_eq".to_string(),
2970            property_type: None,
2971        };
2972
2973        let mut properties = HashMap::new();
2974
2975        // Invalid semver strings
2976        properties.insert("version".to_string(), json!("not-a-version"));
2977        assert!(match_property(&prop, &properties).is_err());
2978
2979        properties.insert("version".to_string(), json!(""));
2980        assert!(match_property(&prop, &properties).is_err());
2981
2982        properties.insert("version".to_string(), json!(".1.2.3"));
2983        assert!(match_property(&prop, &properties).is_err());
2984
2985        properties.insert("version".to_string(), json!("abc.def.ghi"));
2986        assert!(match_property(&prop, &properties).is_err());
2987    }
2988
2989    #[test]
2990    fn test_semver_invalid_target_value() {
2991        let mut properties = HashMap::new();
2992        properties.insert("version".to_string(), json!("1.2.3"));
2993
2994        // Invalid target semver
2995        let prop = Property {
2996            key: "version".to_string(),
2997            value: json!("not-valid"),
2998            operator: "semver_eq".to_string(),
2999            property_type: None,
3000        };
3001        assert!(match_property(&prop, &properties).is_err());
3002
3003        let prop = Property {
3004            key: "version".to_string(),
3005            value: json!(""),
3006            operator: "semver_gt".to_string(),
3007            property_type: None,
3008        };
3009        assert!(match_property(&prop, &properties).is_err());
3010    }
3011
3012    #[test]
3013    fn test_semver_invalid_wildcard_pattern() {
3014        let mut properties = HashMap::new();
3015        properties.insert("version".to_string(), json!("1.2.3"));
3016
3017        // Invalid wildcard patterns
3018        let invalid_patterns = vec![
3019            "*",       // Just wildcard
3020            "*.2.3",   // Wildcard in wrong position
3021            "1.*.3",   // Wildcard in wrong position
3022            "1.2.3.*", // Too many parts
3023            "abc.*",   // Non-numeric major
3024        ];
3025
3026        for pattern in invalid_patterns {
3027            let prop = Property {
3028                key: "version".to_string(),
3029                value: json!(pattern),
3030                operator: "semver_wildcard".to_string(),
3031                property_type: None,
3032            };
3033            assert!(
3034                match_property(&prop, &properties).is_err(),
3035                "Pattern '{}' should be invalid",
3036                pattern
3037            );
3038        }
3039    }
3040
3041    #[test]
3042    fn test_semver_missing_property() {
3043        let prop = Property {
3044            key: "version".to_string(),
3045            value: json!("1.2.3"),
3046            operator: "semver_eq".to_string(),
3047            property_type: None,
3048        };
3049
3050        let properties = HashMap::new(); // Empty properties
3051        assert!(match_property(&prop, &properties).is_err());
3052    }
3053
3054    #[test]
3055    fn test_semver_null_property_value() {
3056        let prop = Property {
3057            key: "version".to_string(),
3058            value: json!("1.2.3"),
3059            operator: "semver_eq".to_string(),
3060            property_type: None,
3061        };
3062
3063        let mut properties = HashMap::new();
3064        properties.insert("version".to_string(), json!(null));
3065
3066        // null converts to "null" string which is not a valid semver
3067        assert!(match_property(&prop, &properties).is_err());
3068    }
3069
3070    #[test]
3071    fn test_semver_numeric_property_value() {
3072        // When property value is a number, it gets converted to string
3073        let prop = Property {
3074            key: "version".to_string(),
3075            value: json!("1.0.0"),
3076            operator: "semver_eq".to_string(),
3077            property_type: None,
3078        };
3079
3080        let mut properties = HashMap::new();
3081        // Number 1 becomes "1" which parses as (1, 0, 0)
3082        properties.insert("version".to_string(), json!(1));
3083        assert!(match_property(&prop, &properties).unwrap());
3084    }
3085
3086    // ==================== Semver edge cases ====================
3087
3088    #[test]
3089    fn test_semver_four_part_versions() {
3090        let prop = Property {
3091            key: "version".to_string(),
3092            value: json!("1.2.3.4"),
3093            operator: "semver_eq".to_string(),
3094            property_type: None,
3095        };
3096
3097        let mut properties = HashMap::new();
3098
3099        // 1.2.3.4 should equal 1.2.3 (extra parts ignored)
3100        properties.insert("version".to_string(), json!("1.2.3"));
3101        assert!(match_property(&prop, &properties).unwrap());
3102
3103        properties.insert("version".to_string(), json!("1.2.3.4"));
3104        assert!(match_property(&prop, &properties).unwrap());
3105
3106        properties.insert("version".to_string(), json!("1.2.3.999"));
3107        assert!(match_property(&prop, &properties).unwrap());
3108    }
3109
3110    #[test]
3111    fn test_semver_large_version_numbers() {
3112        let prop = Property {
3113            key: "version".to_string(),
3114            value: json!("1000.2000.3000"),
3115            operator: "semver_eq".to_string(),
3116            property_type: None,
3117        };
3118
3119        let mut properties = HashMap::new();
3120        properties.insert("version".to_string(), json!("1000.2000.3000"));
3121        assert!(match_property(&prop, &properties).unwrap());
3122    }
3123
3124    #[test]
3125    fn test_semver_comparison_ordering() {
3126        // Test that version ordering is correct across major/minor/patch
3127        let cases = vec![
3128            ("0.0.1", "0.0.2", "semver_lt", true),
3129            ("0.1.0", "0.0.99", "semver_gt", true),
3130            ("1.0.0", "0.99.99", "semver_gt", true),
3131            ("1.0.0", "1.0.0", "semver_eq", true),
3132            ("2.0.0", "10.0.0", "semver_lt", true), // Numeric, not string comparison
3133            ("9.0.0", "10.0.0", "semver_lt", true), // Numeric, not string comparison
3134            ("1.9.0", "1.10.0", "semver_lt", true), // Numeric, not string comparison
3135            ("1.2.9", "1.2.10", "semver_lt", true), // Numeric, not string comparison
3136        ];
3137
3138        for (prop_val, target_val, op, expected) in cases {
3139            let prop = Property {
3140                key: "version".to_string(),
3141                value: json!(target_val),
3142                operator: op.to_string(),
3143                property_type: None,
3144            };
3145
3146            let mut properties = HashMap::new();
3147            properties.insert("version".to_string(), json!(prop_val));
3148
3149            assert_eq!(
3150                match_property(&prop, &properties).unwrap(),
3151                expected,
3152                "{} {} {} should be {}",
3153                prop_val,
3154                op,
3155                target_val,
3156                expected
3157            );
3158        }
3159    }
3160
3161    #[test]
3162    fn test_match_property_semver_rejects_leading_zeros() {
3163        // Per semver 2.0.0 §2, numeric identifiers must not include leading zeros.
3164        // Both property (override) values and target (flag) values should fail to
3165        // parse, surfacing InconclusiveMatchError so the condition does not match.
3166
3167        let bad_versions = ["1.07.3", "01.02.03", "1.2.03", "v01.2.3", "001.0.0"];
3168
3169        // Override values are rejected across semver_eq.
3170        for bad in bad_versions {
3171            let prop = Property {
3172                key: "version".to_string(),
3173                value: json!("1.2.3"),
3174                operator: "semver_eq".to_string(),
3175                property_type: None,
3176            };
3177            let mut properties = HashMap::new();
3178            properties.insert("version".to_string(), json!(bad));
3179            assert!(
3180                match_property(&prop, &properties).is_err(),
3181                "override '{}' should be rejected",
3182                bad
3183            );
3184        }
3185
3186        // Literal "0" components still work for semver_eq.
3187        for good in ["0.1.0", "1.0.0", "0.0.0"] {
3188            let prop = Property {
3189                key: "version".to_string(),
3190                value: json!(good),
3191                operator: "semver_eq".to_string(),
3192                property_type: None,
3193            };
3194            let mut properties = HashMap::new();
3195            properties.insert("version".to_string(), json!(good));
3196            assert!(
3197                match_property(&prop, &properties).unwrap(),
3198                "'{}' should parse and match itself",
3199                good
3200            );
3201        }
3202
3203        // Flag (target) values are rejected across the remaining operators.
3204        let mut properties = HashMap::new();
3205        properties.insert("version".to_string(), json!("1.2.3"));
3206
3207        for op in ["semver_gt", "semver_caret", "semver_tilde"] {
3208            for bad in bad_versions {
3209                let prop = Property {
3210                    key: "version".to_string(),
3211                    value: json!(bad),
3212                    operator: op.to_string(),
3213                    property_type: None,
3214                };
3215                assert!(
3216                    match_property(&prop, &properties).is_err(),
3217                    "target '{}' for {} should be rejected",
3218                    bad,
3219                    op
3220                );
3221            }
3222        }
3223
3224        // Wildcard patterns with leading-zero numeric components are rejected.
3225        for bad_pattern in ["01.*", "1.07.*", "v01.2.*"] {
3226            let prop = Property {
3227                key: "version".to_string(),
3228                value: json!(bad_pattern),
3229                operator: "semver_wildcard".to_string(),
3230                property_type: None,
3231            };
3232            assert!(
3233                match_property(&prop, &properties).is_err(),
3234                "wildcard target '{}' should be rejected",
3235                bad_pattern
3236            );
3237        }
3238    }
3239
3240    // ==================== Tests for early_exit ====================
3241
3242    /// Build a two-group flag where the first group always lands
3243    /// out-of-rollout-bound (no property filters, 0% rollout) and the second
3244    /// group always matches (no property filters, 100% rollout). Without
3245    /// `early_exit`, evaluation falls through to the matching second group.
3246    fn early_exit_flag(early_exit: bool) -> FeatureFlag {
3247        FeatureFlag {
3248            key: "early-exit-flag".to_string(),
3249            active: true,
3250            filters: FeatureFlagFilters {
3251                groups: vec![
3252                    // Group 1: matches on properties (none) but rollout excludes
3253                    // everyone -> OUT_OF_ROLLOUT_BOUND.
3254                    FeatureFlagCondition {
3255                        properties: vec![],
3256                        rollout_percentage: Some(0.0),
3257                        variant: None,
3258                        aggregation_group_type_index: None,
3259                    },
3260                    // Group 2: would match everyone -> MATCH.
3261                    FeatureFlagCondition {
3262                        properties: vec![],
3263                        rollout_percentage: Some(100.0),
3264                        variant: None,
3265                        aggregation_group_type_index: None,
3266                    },
3267                ],
3268                multivariate: None,
3269                payloads: HashMap::new(),
3270                aggregation_group_type_index: None,
3271                early_exit,
3272            },
3273        }
3274    }
3275
3276    macro_rules! test_early_exit {
3277        ($name:ident, $early_exit:expr, $expected:expr) => {
3278            #[test]
3279            fn $name() {
3280                let flag = early_exit_flag($early_exit);
3281                let result = match_feature_flag(
3282                    &flag,
3283                    "user-123",
3284                    &HashMap::new(),
3285                    &HashMap::new(),
3286                    &HashMap::new(),
3287                    &HashMap::new(),
3288                )
3289                .unwrap();
3290                assert_eq!(result, $expected);
3291            }
3292        };
3293    }
3294
3295    test_early_exit!(
3296        test_early_exit_enabled_returns_false_without_evaluating_later_group,
3297        true,
3298        FlagValue::Boolean(false)
3299    );
3300    test_early_exit!(
3301        test_early_exit_unset_falls_through_to_matching_group,
3302        false,
3303        FlagValue::Boolean(true)
3304    );
3305
3306    #[test]
3307    fn test_early_exit_default_is_false_from_json() {
3308        // A flag definition that omits `early_exit` must deserialize to false
3309        // and preserve the legacy fall-through behavior.
3310        let flag: FeatureFlag = serde_json::from_value(json!({
3311            "key": "early-exit-flag",
3312            "active": true,
3313            "filters": {
3314                "groups": [
3315                    { "properties": [], "rollout_percentage": 0.0, "variant": null },
3316                    { "properties": [], "rollout_percentage": 100.0, "variant": null }
3317                ]
3318            }
3319        }))
3320        .unwrap();
3321        assert!(!flag.filters.early_exit);
3322        let result = match_feature_flag(
3323            &flag,
3324            "user-123",
3325            &HashMap::new(),
3326            &HashMap::new(),
3327            &HashMap::new(),
3328            &HashMap::new(),
3329        )
3330        .unwrap();
3331        assert_eq!(result, FlagValue::Boolean(true));
3332    }
3333
3334    #[test]
3335    fn test_early_exit_explicit_false_falls_through() {
3336        let flag: FeatureFlag = serde_json::from_value(json!({
3337            "key": "early-exit-flag",
3338            "active": true,
3339            "filters": {
3340                "early_exit": false,
3341                "groups": [
3342                    { "properties": [], "rollout_percentage": 0.0, "variant": null },
3343                    { "properties": [], "rollout_percentage": 100.0, "variant": null }
3344                ]
3345            }
3346        }))
3347        .unwrap();
3348        assert!(!flag.filters.early_exit);
3349        let result = match_feature_flag(
3350            &flag,
3351            "user-123",
3352            &HashMap::new(),
3353            &HashMap::new(),
3354            &HashMap::new(),
3355            &HashMap::new(),
3356        )
3357        .unwrap();
3358        assert_eq!(result, FlagValue::Boolean(true));
3359    }
3360
3361    #[test]
3362    fn test_early_exit_property_mismatch_does_not_short_circuit() {
3363        // First group fails on a property filter (NO_MATCH), not rollout, so
3364        // even with early_exit enabled we must fall through to the matching
3365        // second group.
3366        let flag = FeatureFlag {
3367            key: "early-exit-flag".to_string(),
3368            active: true,
3369            filters: FeatureFlagFilters {
3370                groups: vec![
3371                    FeatureFlagCondition {
3372                        properties: vec![Property {
3373                            key: "country".to_string(),
3374                            value: json!("US"),
3375                            operator: "exact".to_string(),
3376                            property_type: None,
3377                        }],
3378                        rollout_percentage: Some(100.0),
3379                        variant: None,
3380                        aggregation_group_type_index: None,
3381                    },
3382                    FeatureFlagCondition {
3383                        properties: vec![],
3384                        rollout_percentage: Some(100.0),
3385                        variant: None,
3386                        aggregation_group_type_index: None,
3387                    },
3388                ],
3389                multivariate: None,
3390                payloads: HashMap::new(),
3391                aggregation_group_type_index: None,
3392                early_exit: true,
3393            },
3394        };
3395
3396        let mut properties = HashMap::new();
3397        properties.insert("country".to_string(), json!("UK")); // does not match "US"
3398
3399        let result = match_feature_flag(
3400            &flag,
3401            "user-123",
3402            &properties,
3403            &HashMap::new(),
3404            &HashMap::new(),
3405            &HashMap::new(),
3406        )
3407        .unwrap();
3408        // Property mismatch must not trigger early-exit; second group matches.
3409        assert_eq!(result, FlagValue::Boolean(true));
3410    }
3411
3412    macro_rules! test_early_exit_with_context {
3413        ($name:ident, $early_exit:expr, $expected:expr) => {
3414            #[test]
3415            fn $name() {
3416                let flag = early_exit_flag($early_exit);
3417                let ctx = EvaluationContext {
3418                    cohorts: &HashMap::new(),
3419                    flags: &HashMap::new(),
3420                    distinct_id: "user-123",
3421                    groups: &HashMap::new(),
3422                    group_properties: &HashMap::new(),
3423                    group_type_mapping: &HashMap::new(),
3424                };
3425                let result = match_feature_flag_with_context(&flag, &HashMap::new(), &ctx).unwrap();
3426                assert_eq!(result, $expected);
3427            }
3428        };
3429    }
3430
3431    test_early_exit_with_context!(
3432        test_early_exit_enabled_short_circuits_with_context,
3433        true,
3434        FlagValue::Boolean(false)
3435    );
3436    test_early_exit_with_context!(
3437        test_early_exit_unset_falls_through_with_context,
3438        false,
3439        FlagValue::Boolean(true)
3440    );
3441
3442    #[test]
3443    fn test_early_exit_does_not_short_circuit_when_prior_group_inconclusive() {
3444        // Group 1: group-targeted (aggregation_group_type_index = 0). The
3445        // group_type_mapping resolves "0" → "company" and groups supplies the
3446        // company key, but group_properties has no entry for "company" →
3447        // ConditionTarget::Inconclusive → is_inconclusive = true.
3448        // Group 2: person-targeted, rollout 0% → OutOfRolloutBound.
3449        // With early_exit = true, the !is_inconclusive guard must prevent
3450        // short-circuiting, and the overall result must be InconclusiveMatchError.
3451        let flag = FeatureFlag {
3452            key: "early-exit-flag".to_string(),
3453            active: true,
3454            filters: FeatureFlagFilters {
3455                groups: vec![
3456                    FeatureFlagCondition {
3457                        properties: vec![],
3458                        rollout_percentage: Some(100.0),
3459                        variant: None,
3460                        aggregation_group_type_index: Some(0),
3461                    },
3462                    FeatureFlagCondition {
3463                        properties: vec![],
3464                        rollout_percentage: Some(0.0),
3465                        variant: None,
3466                        aggregation_group_type_index: None,
3467                    },
3468                ],
3469                multivariate: None,
3470                payloads: HashMap::new(),
3471                aggregation_group_type_index: None,
3472                early_exit: true,
3473            },
3474        };
3475
3476        let mut group_type_mapping = HashMap::new();
3477        group_type_mapping.insert("0".to_string(), "company".to_string());
3478
3479        let mut groups = HashMap::new();
3480        groups.insert("company".to_string(), "acme".to_string());
3481
3482        // group_properties intentionally omitted for "company" → Inconclusive
3483        let result = match_feature_flag(
3484            &flag,
3485            "user-123",
3486            &HashMap::new(),
3487            &groups,
3488            &HashMap::new(), // empty group_properties
3489            &group_type_mapping,
3490        );
3491        assert!(
3492            result.is_err(),
3493            "expected InconclusiveMatchError, got {:?}",
3494            result
3495        );
3496    }
3497}