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, HashSet};
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    /// Whether the flag is linked to an experiment, as reported by the
106    /// local-evaluation definitions endpoint. Tri-state: `Some(true)`/`Some(false)`
107    /// when the server reports it, `None` when it does not (older server). Drives
108    /// the `$feature_flag_has_experiment` property and event minimization.
109    #[serde(default)]
110    pub has_experiment: Option<bool>,
111}
112
113/// Targeting rules and configuration for a feature flag.
114#[derive(Debug, Clone, Serialize, Deserialize, Default)]
115pub struct FeatureFlagFilters {
116    /// List of condition groups (evaluated with OR logic between groups)
117    #[serde(default)]
118    pub groups: Vec<FeatureFlagCondition>,
119    /// Multivariate configuration for A/B tests with multiple variants
120    #[serde(default)]
121    pub multivariate: Option<MultivariateFilter>,
122    /// JSON payloads associated with flag variants
123    #[serde(default)]
124    pub payloads: HashMap<String, serde_json::Value>,
125    /// Group type index this flag targets at the flag level. `None` means person
126    /// targeting (or mixed, when individual conditions set their own).
127    #[serde(default)]
128    pub aggregation_group_type_index: Option<i32>,
129    /// When `true`, local evaluation stops and returns a definitive disabled
130    /// result as soon as a condition group's property filters match (or it has
131    /// no property filters) but the rollout percentage excludes the user,
132    /// instead of falling through to later condition groups. Defaults to
133    /// `false`, which preserves the legacy fall-through behavior.
134    #[serde(default)]
135    pub early_exit: bool,
136}
137
138/// A single condition group within a feature flag's targeting rules.
139///
140/// All properties within a condition must match (AND logic), and the user
141/// must fall within the rollout percentage to be included.
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct FeatureFlagCondition {
144    /// Property filters that must all match (AND logic)
145    #[serde(default)]
146    pub properties: Vec<Property>,
147    /// Percentage of matching users who should see this flag (0-100)
148    pub rollout_percentage: Option<f64>,
149    /// Specific variant to serve for this condition (for variant overrides)
150    pub variant: Option<String>,
151    /// Optional per-condition aggregation override used by mixed-targeting flags.
152    /// When set, this condition targets the specified group type instead of the
153    /// flag-level aggregation. `None` means person targeting under a mixed flag.
154    #[serde(default)]
155    pub aggregation_group_type_index: Option<i32>,
156}
157
158/// A property filter used in feature flag targeting.
159///
160/// Supports various operators for matching user properties against expected values.
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct Property {
163    /// The property key to match (e.g., "email", "country", "$feature/other-flag")
164    pub key: String,
165    /// The value to compare against
166    pub value: serde_json::Value,
167    /// Comparison operator. Supported property operators include `"exact"`,
168    /// `"is_not"`, `"icontains"`, `"not_icontains"`, `"starts_with"`,
169    /// `"not_starts_with"`, `"ends_with"`, `"not_ends_with"`, `"regex"`,
170    /// `"not_regex"`, `"gt"`, `"gte"`, `"lt"`, `"lte"`, `"is_set"`,
171    /// `"is_not_set"`, `"is_date_before"`, `"is_date_after"`, and the
172    /// `"semver_*"` operators used by PostHog version targeting.
173    #[serde(
174        default = "default_operator",
175        deserialize_with = "deserialize_operator"
176    )]
177    pub operator: String,
178    /// Property type. Use `Some("cohort")` for cohort membership checks; flag
179    /// dependency checks use a property key that starts with `$feature/`.
180    #[serde(rename = "type")]
181    pub property_type: Option<String>,
182}
183
184fn default_operator() -> String {
185    "exact".to_string()
186}
187
188fn deserialize_operator<'de, D>(deserializer: D) -> Result<String, D::Error>
189where
190    D: serde::Deserializer<'de>,
191{
192    Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_else(default_operator))
193}
194
195#[derive(Deserialize)]
196struct CohortProperty {
197    #[serde(flatten)]
198    property: Property,
199    #[serde(default)]
200    negation: bool,
201}
202
203/// Definition of a cohort for local evaluation
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct CohortDefinition {
206    /// Unique identifier for the cohort.
207    pub id: String,
208    /// Properties can be either:
209    /// - A JSON object with "type" and "values" for complex property groups
210    /// - Or a direct `Vec<Property>` for simple cases
211    #[serde(default)]
212    pub properties: serde_json::Value,
213}
214
215impl CohortDefinition {
216    /// Create a new cohort definition with a simple property list.
217    ///
218    /// # Parameters
219    ///
220    /// - `id`: Cohort identifier.
221    /// - `properties`: Property filters that define cohort membership.
222    pub fn new(id: String, properties: Vec<Property>) -> Self {
223        Self {
224            id,
225            properties: serde_json::to_value(properties).unwrap_or_default(),
226        }
227    }
228
229    /// Parse the properties from the JSON structure.
230    ///
231    /// PostHog cohort properties come in format:
232    /// `{"type": "AND", "values": [{"type": "property", "key": "...", "value": "...", "operator": "..."}]}`.
233    pub fn parse_properties(&self) -> Vec<Property> {
234        // If it's an array, treat it as direct property list
235        if let Some(arr) = self.properties.as_array() {
236            return arr
237                .iter()
238                .filter_map(|v| serde_json::from_value::<Property>(v.clone()).ok())
239                .collect();
240        }
241
242        // If it's an object with "values" key, extract properties from there
243        if let Some(obj) = self.properties.as_object() {
244            if let Some(values) = obj.get("values") {
245                if let Some(values_arr) = values.as_array() {
246                    return values_arr
247                        .iter()
248                        .filter_map(|v| {
249                            // Handle both direct property objects and nested property groups
250                            if v.get("type").and_then(|t| t.as_str()) == Some("property") {
251                                serde_json::from_value::<Property>(v.clone()).ok()
252                            } else if let Some(inner_values) = v.get("values") {
253                                // Recursively handle nested groups
254                                inner_values.as_array().and_then(|arr| {
255                                    arr.iter()
256                                        .filter_map(|inner| {
257                                            serde_json::from_value::<Property>(inner.clone()).ok()
258                                        })
259                                        .next()
260                                })
261                            } else {
262                                None
263                            }
264                        })
265                        .collect();
266                }
267            }
268        }
269
270        Vec::new()
271    }
272}
273
274/// Context for evaluating properties that may depend on cohorts or other flags.
275///
276/// `groups`, `group_properties`, and `group_type_mapping` are used to resolve
277/// group-targeted and mixed-targeting flags. A group condition (one whose
278/// `aggregation_group_type_index` is set, either at the flag or condition level)
279/// is bucketed on the group key and matched against group properties looked up
280/// via the group type mapping.
281pub struct EvaluationContext<'a> {
282    /// Cohort definitions available to local evaluation, keyed by cohort ID.
283    pub cohorts: &'a HashMap<String, CohortDefinition>,
284    /// Feature flag definitions available to evaluate flag dependencies, keyed
285    /// by flag key.
286    pub flags: &'a HashMap<String, FeatureFlag>,
287    /// Distinct ID used for person-targeted flag bucketing.
288    pub distinct_id: &'a str,
289    /// Group keys for group-targeted flags, keyed by group type.
290    pub groups: &'a HashMap<String, String>,
291    /// Group properties for group-targeted flags, keyed by group type and then
292    /// property name.
293    pub group_properties: &'a HashMap<String, HashMap<String, serde_json::Value>>,
294    /// Mapping from PostHog group type index to group type name.
295    pub group_type_mapping: &'a HashMap<String, String>,
296}
297
298/// Configuration for multivariate (A/B/n) feature flags.
299#[derive(Debug, Clone, Serialize, Deserialize, Default)]
300pub struct MultivariateFilter {
301    /// List of variants with their rollout percentages
302    pub variants: Vec<MultivariateVariant>,
303}
304
305/// A single variant in a multivariate feature flag.
306#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct MultivariateVariant {
308    /// Unique key for this variant (e.g., "control", "test", "variant-a")
309    pub key: String,
310    /// Percentage of users who should see this variant (0-100)
311    pub rollout_percentage: f64,
312}
313
314/// Response from the PostHog feature flags API.
315///
316/// Supports both the v2 API format (with detailed flag information) and the
317/// legacy format (simple flag values and payloads).
318#[derive(Debug, Clone, Serialize, Deserialize)]
319#[serde(untagged)]
320pub enum FeatureFlagsResponse {
321    /// v2 API format from `/flags/?v=2` endpoint
322    V2 {
323        /// Map of flag keys to their detailed evaluation results
324        flags: HashMap<String, FlagDetail>,
325        /// Whether any errors occurred during flag computation
326        #[serde(rename = "errorsWhileComputingFlags")]
327        #[serde(default)]
328        errors_while_computing_flags: bool,
329        /// Whether the response was returned without evaluation because the
330        /// project is over its feature-flag quota.
331        #[serde(rename = "quotaLimited")]
332        #[serde(default)]
333        quota_limited: bool,
334        /// Unique identifier for this evaluation request, propagated to
335        /// `$feature_flag_called` events as `$feature_flag_request_id`
336        /// for experiment exposure tracking.
337        #[serde(rename = "requestId")]
338        #[serde(default)]
339        request_id: Option<String>,
340        /// Server-controlled gate: when `true`, `$feature_flag_called` events for
341        /// non-experiment flags evaluated from this response are minimized to a
342        /// strict property allowlist. Absent (older server) fails safe to `false`.
343        #[serde(rename = "minimalFlagCalledEvents")]
344        #[serde(default)]
345        minimal_flag_called_events: bool,
346    },
347    /// Legacy format from older decide endpoint
348    Legacy {
349        /// Map of flag keys to their values
350        #[serde(rename = "featureFlags")]
351        feature_flags: HashMap<String, FlagValue>,
352        /// Map of flag keys to their JSON payloads
353        #[serde(rename = "featureFlagPayloads")]
354        #[serde(default)]
355        feature_flag_payloads: HashMap<String, serde_json::Value>,
356        /// Any errors that occurred during evaluation
357        #[serde(default)]
358        errors: Option<Vec<String>>,
359    },
360}
361
362impl FeatureFlagsResponse {
363    /// Convert the response to normalized flag values and payloads.
364    ///
365    /// # Returns
366    ///
367    /// A tuple of `(feature_flags, feature_flag_payloads)`, each keyed by flag
368    /// key.
369    pub fn normalize(
370        self,
371    ) -> (
372        HashMap<String, FlagValue>,
373        HashMap<String, serde_json::Value>,
374    ) {
375        match self {
376            FeatureFlagsResponse::V2 { flags, .. } => {
377                let mut feature_flags = HashMap::new();
378                let mut payloads = HashMap::new();
379
380                for (key, detail) in flags {
381                    if detail.enabled {
382                        if let Some(variant) = detail.variant {
383                            feature_flags.insert(key.clone(), FlagValue::String(variant));
384                        } else {
385                            feature_flags.insert(key.clone(), FlagValue::Boolean(true));
386                        }
387                    } else {
388                        feature_flags.insert(key.clone(), FlagValue::Boolean(false));
389                    }
390
391                    if let Some(metadata) = detail.metadata {
392                        if let Some(payload) = metadata.payload {
393                            payloads.insert(key, payload);
394                        }
395                    }
396                }
397
398                (feature_flags, payloads)
399            }
400            FeatureFlagsResponse::Legacy {
401                feature_flags,
402                feature_flag_payloads,
403                ..
404            } => (feature_flags, feature_flag_payloads),
405        }
406    }
407}
408
409/// Detailed information about a feature flag evaluation result.
410///
411/// Returned by the `/flags/?v=2` endpoint with extended information about why a
412/// flag evaluated to a particular value.
413#[derive(Debug, Clone, Serialize, Deserialize)]
414pub struct FlagDetail {
415    /// The feature flag key
416    pub key: String,
417    /// Whether the flag is enabled for this user
418    pub enabled: bool,
419    /// The variant key if this is a multivariate flag
420    pub variant: Option<String>,
421    /// Reason explaining why the flag evaluated to this value
422    #[serde(default)]
423    pub reason: Option<FlagReason>,
424    /// Additional metadata about the flag
425    #[serde(default)]
426    pub metadata: Option<FlagMetadata>,
427}
428
429/// Explains why a feature flag evaluated to a particular value.
430#[derive(Debug, Clone, Serialize, Deserialize)]
431pub struct FlagReason {
432    /// Reason code (e.g., "condition_match", "out_of_rollout_bound")
433    pub code: String,
434    /// Index of the condition that matched (if applicable)
435    #[serde(default)]
436    pub condition_index: Option<usize>,
437    /// Human-readable description of the reason
438    #[serde(default)]
439    pub description: Option<String>,
440}
441
442/// Metadata about a feature flag from the PostHog server.
443#[derive(Debug, Clone, Serialize, Deserialize)]
444pub struct FlagMetadata {
445    /// Unique identifier for this flag
446    pub id: u64,
447    /// Version number of the flag definition
448    pub version: u32,
449    /// Optional description of what this flag controls
450    pub description: Option<String>,
451    /// Optional JSON payload associated with the flag
452    pub payload: Option<serde_json::Value>,
453    /// Whether the flag is linked to an experiment. Tri-state: `Some(true)`/
454    /// `Some(false)` when the `/flags?v=2` metadata reports it, `None` when it is
455    /// absent (older server or degraded response). Drives the
456    /// `$feature_flag_has_experiment` property and event minimization.
457    #[serde(default)]
458    pub has_experiment: Option<bool>,
459}
460
461const LONG_SCALE: f64 = 0xFFFFFFFFFFFFFFFu64 as f64; // Must be exactly 15 F's to match Python SDK
462
463/// Compute a deterministic hash value for feature flag bucketing.
464///
465/// Uses SHA-1 to generate a consistent hash in the range [0, 1) for the given
466/// key, distinct_id, and salt combination. This ensures users get consistent
467/// flag values across requests.
468pub fn hash_key(key: &str, distinct_id: &str, salt: &str) -> f64 {
469    let hash_key = format!("{key}.{distinct_id}{salt}");
470    let mut hasher = Sha1::new();
471    hasher.update(hash_key.as_bytes());
472    let result = hasher.finalize();
473    // The top 60 bits of the digest, i.e. the integer value of its first 15 hex
474    // digits. Matches the Python SDK's `int(sha1(...).hexdigest()[:15], 16)`.
475    let hash_val = result
476        .first_chunk::<8>()
477        .map_or(0, |head| u64::from_be_bytes(*head) >> 4);
478    hash_val as f64 / LONG_SCALE
479}
480
481/// Determine which variant a user should see for a multivariate flag.
482///
483/// Uses consistent hashing to assign users to variants based on their
484/// rollout percentages. Returns `None` if the flag has no variants or
485/// the user doesn't fall into any variant bucket.
486pub fn get_matching_variant(flag: &FeatureFlag, distinct_id: &str) -> Option<String> {
487    let hash_value = hash_key(&flag.key, distinct_id, VARIANT_HASH_SALT);
488    let variants = flag.filters.multivariate.as_ref()?.variants.as_slice();
489
490    let mut value_min = 0.0;
491    for variant in variants {
492        let value_max = value_min + variant.rollout_percentage / 100.0;
493        if hash_value >= value_min && hash_value < value_max {
494            return Some(variant.key.clone());
495        }
496        value_min = value_max;
497    }
498    None
499}
500
501/// Result of resolving a condition's effective bucketing + properties.
502enum ConditionTarget<'a> {
503    /// Use these for bucketing and property matching.
504    Use {
505        bucketing: String,
506        properties: &'a HashMap<String, serde_json::Value>,
507    },
508    /// Skip this condition (group type unknown or required group not passed in).
509    Skip,
510    /// Required group properties not provided — try other conditions, surface
511    /// inconclusive if nothing else matches.
512    Inconclusive,
513}
514
515/// Resolve effective bucketing id and properties for a single condition based on
516/// the (possibly per-condition) aggregation group type index. Pure-person flags
517/// fall through with `distinct_id` and `person_properties`. Group conditions
518/// (either flag-level or per-condition aggregation) require the corresponding
519/// group key and group properties to be present.
520fn resolve_condition_target<'a>(
521    condition: &FeatureFlagCondition,
522    flag_aggregation: Option<i32>,
523    distinct_id: &str,
524    person_properties: &'a HashMap<String, serde_json::Value>,
525    groups: &HashMap<String, String>,
526    group_properties: &'a HashMap<String, HashMap<String, serde_json::Value>>,
527    group_type_mapping: &HashMap<String, String>,
528) -> ConditionTarget<'a> {
529    // Per-condition aggregation falls back to the flag-level value when absent.
530    // The two together drive whether this condition is person- or group-targeted.
531    let effective_aggregation = condition.aggregation_group_type_index.or(flag_aggregation);
532
533    match effective_aggregation {
534        None => ConditionTarget::Use {
535            bucketing: distinct_id.to_string(),
536            properties: person_properties,
537        },
538        Some(idx) => {
539            let key = idx.to_string();
540            let Some(group_type) = group_type_mapping.get(&key) else {
541                return ConditionTarget::Skip;
542            };
543            let Some(group_key) = groups.get(group_type) else {
544                return ConditionTarget::Skip;
545            };
546            let Some(props) = group_properties.get(group_type) else {
547                return ConditionTarget::Inconclusive;
548            };
549            ConditionTarget::Use {
550                bucketing: group_key.clone(),
551                properties: props,
552            }
553        }
554    }
555}
556
557/// Evaluate a feature flag definition against person and optional group
558/// context.
559///
560/// # Parameters
561///
562/// - `flag`: Feature flag definition to evaluate.
563/// - `distinct_id`: Distinct ID used for person bucketing.
564/// - `person_properties`: Person properties available to release conditions.
565/// - `groups`: Group keys for group-targeted flags, keyed by group type.
566/// - `group_properties`: Group properties for group-targeted flags.
567/// - `group_type_mapping`: Mapping from PostHog group type index to group type
568///   name.
569///
570/// # Returns
571///
572/// The matched flag value: `Boolean(false)` for inactive or unmatched flags,
573/// `Boolean(true)` for matched boolean flags, or `String(variant)` for matched
574/// multivariate flags.
575///
576/// # Errors
577///
578/// Returns [`InconclusiveMatchError`] when required properties are missing or an
579/// operator cannot be evaluated locally.
580#[must_use = "feature flag evaluation result should be used"]
581#[allow(clippy::too_many_arguments)]
582pub fn match_feature_flag(
583    flag: &FeatureFlag,
584    distinct_id: &str,
585    person_properties: &HashMap<String, serde_json::Value>,
586    groups: &HashMap<String, String>,
587    group_properties: &HashMap<String, HashMap<String, serde_json::Value>>,
588    group_type_mapping: &HashMap<String, String>,
589) -> Result<FlagValue, InconclusiveMatchError> {
590    if !flag.active {
591        return Ok(FlagValue::Boolean(false));
592    }
593
594    let conditions = &flag.filters.groups;
595    let flag_aggregation = flag.filters.aggregation_group_type_index;
596
597    // Sort conditions to evaluate variant overrides first
598    let mut sorted_conditions = conditions.clone();
599    sorted_conditions.sort_by_key(|c| if c.variant.is_some() { 0 } else { 1 });
600
601    let mut is_inconclusive = false;
602
603    for condition in sorted_conditions {
604        let (effective_bucketing, effective_properties) = match resolve_condition_target(
605            &condition,
606            flag_aggregation,
607            distinct_id,
608            person_properties,
609            groups,
610            group_properties,
611            group_type_mapping,
612        ) {
613            ConditionTarget::Use {
614                bucketing,
615                properties,
616            } => (bucketing, properties),
617            ConditionTarget::Skip => continue,
618            ConditionTarget::Inconclusive => {
619                is_inconclusive = true;
620                continue;
621            }
622        };
623
624        match is_condition_match(flag, &effective_bucketing, &condition, effective_properties) {
625            Ok(ConditionMatch::Match) => {
626                if let Some(variant_override) = &condition.variant {
627                    // Check if variant is valid
628                    if let Some(ref multivariate) = flag.filters.multivariate {
629                        let valid_variants: Vec<String> = multivariate
630                            .variants
631                            .iter()
632                            .map(|v| v.key.clone())
633                            .collect();
634
635                        if valid_variants.contains(variant_override) {
636                            return Ok(FlagValue::String(variant_override.clone()));
637                        }
638                    }
639                }
640
641                // Try to get matching variant or return true
642                if let Some(variant) = get_matching_variant(flag, &effective_bucketing) {
643                    return Ok(FlagValue::String(variant));
644                }
645                return Ok(FlagValue::Boolean(true));
646            }
647            Ok(ConditionMatch::OutOfRolloutBound) => {
648                // The user's properties matched this group but the rollout
649                // excluded them. With early_exit enabled the flag is
650                // definitively disabled; otherwise fall through to later groups.
651                // Only short-circuit when no prior group was inconclusive — an
652                // inconclusive result means we can't evaluate locally and must
653                // fall back to the server, so it takes priority over early_exit.
654                if flag.filters.early_exit && !is_inconclusive {
655                    return Ok(FlagValue::Boolean(false));
656                }
657            }
658            Ok(ConditionMatch::NoMatch) => continue,
659            Err(_) => {
660                is_inconclusive = true;
661            }
662        }
663    }
664
665    if is_inconclusive {
666        return Err(InconclusiveMatchError::new(
667            "Can't determine if feature flag is enabled or not with given properties",
668        ));
669    }
670
671    Ok(FlagValue::Boolean(false))
672}
673
674/// Outcome of evaluating a single condition group, mirroring the PostHog Rust
675/// evaluation engine's tri-state so the local loop can distinguish a
676/// property-filter miss (always fall through) from a rollout exclusion (which
677/// can short-circuit when `early_exit` is enabled).
678#[derive(Debug, Clone, Copy, PartialEq, Eq)]
679enum ConditionMatch {
680    /// Property filters matched (or there were none) and the rollout included
681    /// the user — the flag matches.
682    Match,
683    /// A property filter did not match — always continue to the next group.
684    NoMatch,
685    /// Property filters matched (or there were none) but the rollout excluded
686    /// the user.
687    OutOfRolloutBound,
688}
689
690fn is_condition_match(
691    flag: &FeatureFlag,
692    bucketing_id: &str,
693    condition: &FeatureFlagCondition,
694    properties: &HashMap<String, serde_json::Value>,
695) -> Result<ConditionMatch, InconclusiveMatchError> {
696    // Check properties first
697    for prop in &condition.properties {
698        if !match_property(prop, properties)? {
699            return Ok(ConditionMatch::NoMatch);
700        }
701    }
702
703    // If all properties match (or no properties), check rollout percentage
704    if let Some(rollout_percentage) = condition.rollout_percentage {
705        let hash_value = hash_key(&flag.key, bucketing_id, ROLLOUT_HASH_SALT);
706        if hash_value > (rollout_percentage / 100.0) {
707            return Ok(ConditionMatch::OutOfRolloutBound);
708        }
709    }
710
711    Ok(ConditionMatch::Match)
712}
713
714/// Match a feature flag with full context (cohorts, other flags).
715///
716/// This version supports cohort membership checks and flag dependency checks.
717/// `person_properties` carries person-level property values; group-level
718/// properties are looked up from `ctx.group_properties` when a condition (or
719/// the flag itself) targets a group via `aggregation_group_type_index`.
720///
721/// # Errors
722///
723/// Returns [`InconclusiveMatchError`] when local evaluation cannot determine a
724/// result with the provided context.
725#[must_use = "feature flag evaluation result should be used"]
726pub fn match_feature_flag_with_context(
727    flag: &FeatureFlag,
728    person_properties: &HashMap<String, serde_json::Value>,
729    ctx: &EvaluationContext,
730) -> Result<FlagValue, InconclusiveMatchError> {
731    if !flag.active {
732        return Ok(FlagValue::Boolean(false));
733    }
734
735    let conditions = &flag.filters.groups;
736    let flag_aggregation = flag.filters.aggregation_group_type_index;
737
738    // Sort conditions to evaluate variant overrides first
739    let mut sorted_conditions = conditions.clone();
740    sorted_conditions.sort_by_key(|c| if c.variant.is_some() { 0 } else { 1 });
741
742    let mut is_inconclusive = false;
743
744    for condition in sorted_conditions {
745        let (effective_bucketing, effective_properties) = match resolve_condition_target(
746            &condition,
747            flag_aggregation,
748            ctx.distinct_id,
749            person_properties,
750            ctx.groups,
751            ctx.group_properties,
752            ctx.group_type_mapping,
753        ) {
754            ConditionTarget::Use {
755                bucketing,
756                properties,
757            } => (bucketing, properties),
758            ConditionTarget::Skip => continue,
759            ConditionTarget::Inconclusive => {
760                is_inconclusive = true;
761                continue;
762            }
763        };
764
765        match is_condition_match_with_context(
766            flag,
767            &effective_bucketing,
768            &condition,
769            effective_properties,
770            ctx,
771        ) {
772            Ok(ConditionMatch::Match) => {
773                if let Some(variant_override) = &condition.variant {
774                    // Check if variant is valid
775                    if let Some(ref multivariate) = flag.filters.multivariate {
776                        let valid_variants: Vec<String> = multivariate
777                            .variants
778                            .iter()
779                            .map(|v| v.key.clone())
780                            .collect();
781
782                        if valid_variants.contains(variant_override) {
783                            return Ok(FlagValue::String(variant_override.clone()));
784                        }
785                    }
786                }
787
788                // Try to get matching variant or return true
789                if let Some(variant) = get_matching_variant(flag, &effective_bucketing) {
790                    return Ok(FlagValue::String(variant));
791                }
792                return Ok(FlagValue::Boolean(true));
793            }
794            Ok(ConditionMatch::OutOfRolloutBound) => {
795                // The user's properties matched this group but the rollout
796                // excluded them. With early_exit enabled the flag is
797                // definitively disabled; otherwise fall through to later groups.
798                // Only short-circuit when no prior group was inconclusive — an
799                // inconclusive result means we can't evaluate locally and must
800                // fall back to the server, so it takes priority over early_exit.
801                if flag.filters.early_exit && !is_inconclusive {
802                    return Ok(FlagValue::Boolean(false));
803                }
804            }
805            Ok(ConditionMatch::NoMatch) => continue,
806            Err(_) => {
807                is_inconclusive = true;
808            }
809        }
810    }
811
812    if is_inconclusive {
813        return Err(InconclusiveMatchError::new(
814            "Can't determine if feature flag is enabled or not with given properties",
815        ));
816    }
817
818    Ok(FlagValue::Boolean(false))
819}
820
821fn is_condition_match_with_context(
822    flag: &FeatureFlag,
823    bucketing_id: &str,
824    condition: &FeatureFlagCondition,
825    properties: &HashMap<String, serde_json::Value>,
826    ctx: &EvaluationContext,
827) -> Result<ConditionMatch, InconclusiveMatchError> {
828    // Check properties first (using context-aware matching for cohorts/flag dependencies)
829    for prop in &condition.properties {
830        if !match_property_with_context(prop, properties, ctx)? {
831            return Ok(ConditionMatch::NoMatch);
832        }
833    }
834
835    // If all properties match (or no properties), check rollout percentage
836    if let Some(rollout_percentage) = condition.rollout_percentage {
837        let hash_value = hash_key(&flag.key, bucketing_id, ROLLOUT_HASH_SALT);
838        if hash_value > (rollout_percentage / 100.0) {
839            return Ok(ConditionMatch::OutOfRolloutBound);
840        }
841    }
842
843    Ok(ConditionMatch::Match)
844}
845
846/// Match a property with additional context for cohorts and flag dependencies.
847///
848/// Use this when evaluating local feature flag conditions that can reference
849/// cohort membership (`type = "cohort"`) or another feature flag via
850/// `$feature/<flag-key>`.
851///
852/// # Errors
853///
854/// Returns [`InconclusiveMatchError`] when the property, cohort, or dependent
855/// flag cannot be evaluated from the supplied context.
856pub fn match_property_with_context(
857    property: &Property,
858    properties: &HashMap<String, serde_json::Value>,
859    ctx: &EvaluationContext,
860) -> Result<bool, InconclusiveMatchError> {
861    // Check if this is a cohort membership check
862    if property.property_type.as_deref() == Some("cohort") {
863        return match_cohort_property(property, properties, ctx);
864    }
865
866    // Check if this is a flag dependency check
867    if property.key.starts_with("$feature/") {
868        return match_flag_dependency_property(property, ctx);
869    }
870
871    // Fall back to regular property matching
872    match_property(property, properties)
873}
874
875/// Evaluate cohort membership referenced by a flag property (`type = "cohort"`).
876fn match_cohort_property(
877    property: &Property,
878    properties: &HashMap<String, serde_json::Value>,
879    ctx: &EvaluationContext,
880) -> Result<bool, InconclusiveMatchError> {
881    let cohort_id = cohort_id_to_string(&property.value)
882        .ok_or_else(|| InconclusiveMatchError::new("Cohort ID must be a string or number"))?;
883
884    let mut active_cohorts = HashSet::new();
885    let is_in_cohort = match_cohort_by_id(&cohort_id, properties, ctx, &mut active_cohorts, 0)
886        .map_err(CohortMatchError::into_inconclusive)?;
887
888    // Cohort references omit the operator in API payloads, so "exact" means membership.
889    Ok(match property.operator.as_str() {
890        "exact" | "in" => is_in_cohort,
891        "not_in" => !is_in_cohort,
892        op => {
893            return Err(InconclusiveMatchError::new(&format!(
894                "Unknown cohort operator: {}",
895                op
896            )));
897        }
898    })
899}
900
901/// Cohort IDs arrive as either a JSON string or a number depending on where the
902/// reference lives (top-level flag property vs a nested cohort filter). Normalize
903/// both to the string keys used in the cohort cache.
904fn cohort_id_to_string(value: &serde_json::Value) -> Option<String> {
905    match value {
906        serde_json::Value::String(s) => Some(s.clone()),
907        serde_json::Value::Number(n) => Some(n.to_string()),
908        _ => None,
909    }
910}
911
912#[derive(Debug)]
913enum CohortMatchError {
914    Inconclusive(InconclusiveMatchError),
915    InvalidDefinition(InconclusiveMatchError),
916    MissingCohort(InconclusiveMatchError),
917}
918
919impl CohortMatchError {
920    fn into_inconclusive(self) -> InconclusiveMatchError {
921        match self {
922            Self::Inconclusive(error)
923            | Self::InvalidDefinition(error)
924            | Self::MissingCohort(error) => error,
925        }
926    }
927
928    fn requires_server_evaluation(&self) -> bool {
929        !matches!(self, Self::Inconclusive(_))
930    }
931}
932
933/// How deeply local cohort evaluation will recurse through cohort references
934/// and nested property groups combined.
935///
936/// A cycle check alone bounds repeats, not depth: a chain of distinct cohorts is
937/// acyclic and still recurses once per link. Counting only cohort references is
938/// also insufficient because each cohort can contain nested property groups.
939/// Real cohort nesting is a handful of levels at most.
940const MAX_COHORT_RESOLUTION_DEPTH: usize = 100;
941
942/// Look up a cohort by ID and evaluate its property group against `properties`.
943///
944/// `active_cohorts` holds the IDs on the current resolution path so a cohort
945/// that references itself, directly or through others, is rejected as a cycle.
946/// `resolution_depth` bounds the combined path through cohort references and
947/// nested property groups. IDs are removed on the way back out, so a cohort
948/// referenced twice down sibling branches still resolves normally.
949fn match_cohort_by_id(
950    cohort_id: &str,
951    properties: &HashMap<String, serde_json::Value>,
952    ctx: &EvaluationContext,
953    active_cohorts: &mut HashSet<String>,
954    resolution_depth: usize,
955) -> Result<bool, CohortMatchError> {
956    let cohort = ctx.cohorts.get(cohort_id).ok_or_else(|| {
957        CohortMatchError::MissingCohort(InconclusiveMatchError::new(&format!(
958            "Cohort '{}' not found in local cache",
959            cohort_id
960        )))
961    })?;
962
963    if resolution_depth >= MAX_COHORT_RESOLUTION_DEPTH {
964        return Err(CohortMatchError::InvalidDefinition(
965            InconclusiveMatchError::new(&format!(
966                "Cohort '{}' is nested deeper than the limit of {}",
967                cohort_id, MAX_COHORT_RESOLUTION_DEPTH
968            )),
969        ));
970    }
971
972    if !active_cohorts.insert(cohort_id.to_string()) {
973        return Err(CohortMatchError::InvalidDefinition(
974            InconclusiveMatchError::new(&format!(
975                "Cohort '{}' is part of a reference cycle",
976                cohort_id
977            )),
978        ));
979    }
980
981    let result = match_property_group(
982        &cohort.properties,
983        properties,
984        ctx,
985        active_cohorts,
986        resolution_depth,
987    );
988    active_cohorts.remove(cohort_id);
989    result
990}
991
992/// Recursively evaluate a cohort property group against a user's properties.
993///
994/// The `/flags/definitions/?send_cohorts` payload nests groups as
995/// `{"type": "AND"|"OR", "values": [...]}`, where each `values` entry is either
996/// another group (it has its own `values`), a cohort reference
997/// (`{"type": "cohort", "value": <id>, "negation": <bool>}`), or a leaf property
998/// filter. This mirrors posthog-python's `match_property_group`: `AND` requires
999/// every entry to match, `OR` requires any, and a bare JSON array is treated as
1000/// an implicit `AND` of leaf properties (as produced by
1001/// [`CohortDefinition::new`]).
1002fn match_property_group(
1003    group: &serde_json::Value,
1004    properties: &HashMap<String, serde_json::Value>,
1005    ctx: &EvaluationContext,
1006    active_cohorts: &mut HashSet<String>,
1007    resolution_depth: usize,
1008) -> Result<bool, CohortMatchError> {
1009    if resolution_depth >= MAX_COHORT_RESOLUTION_DEPTH {
1010        return Err(CohortMatchError::InvalidDefinition(
1011            InconclusiveMatchError::new(&format!(
1012                "Cohort property groups are nested deeper than the limit of {}",
1013                MAX_COHORT_RESOLUTION_DEPTH
1014            )),
1015        ));
1016    }
1017
1018    if let Some(arr) = group.as_array() {
1019        return match_property_group_values(
1020            "AND",
1021            arr,
1022            properties,
1023            ctx,
1024            active_cohorts,
1025            resolution_depth,
1026        );
1027    }
1028
1029    let Some(obj) = group.as_object() else {
1030        return Err(CohortMatchError::InvalidDefinition(
1031            InconclusiveMatchError::new("Cohort property group must be an object or array"),
1032        ));
1033    };
1034
1035    // The backend serializes a valid empty PropertyGroup as an empty object.
1036    if obj.is_empty() {
1037        return Ok(true);
1038    }
1039
1040    let group_type = obj.get("type").and_then(|t| t.as_str()).unwrap_or("AND");
1041
1042    let Some(values) = obj.get("values").and_then(|v| v.as_array()) else {
1043        return Err(CohortMatchError::InvalidDefinition(
1044            InconclusiveMatchError::new("Cohort property group values must be an array"),
1045        ));
1046    };
1047
1048    match_property_group_values(
1049        group_type,
1050        values,
1051        properties,
1052        ctx,
1053        active_cohorts,
1054        resolution_depth,
1055    )
1056}
1057
1058/// Combine the `values` of a property group under AND/OR semantics.
1059///
1060/// An ordinary inconclusive entry (missing property, unknown operator, ...) is
1061/// remembered but deferred: a definitive result (`false` under AND, `true` under
1062/// OR) still resolves the group. Every entry is inspected so missing cohorts and
1063/// malformed definitions always force server-side evaluation regardless of
1064/// branch order.
1065fn match_property_group_values(
1066    group_type: &str,
1067    values: &[serde_json::Value],
1068    properties: &HashMap<String, serde_json::Value>,
1069    ctx: &EvaluationContext,
1070    active_cohorts: &mut HashSet<String>,
1071    resolution_depth: usize,
1072) -> Result<bool, CohortMatchError> {
1073    if values.is_empty() {
1074        return Ok(true);
1075    }
1076
1077    let is_and = !group_type.eq_ignore_ascii_case("OR");
1078    let mut decisive_result = None;
1079    let mut inconclusive: Option<CohortMatchError> = None;
1080
1081    for value in values {
1082        let result = if value.get("values").is_some() {
1083            // Nested property group.
1084            match_property_group(value, properties, ctx, active_cohorts, resolution_depth + 1)
1085        } else if value.get("type").and_then(|t| t.as_str()) == Some("cohort") {
1086            // A cohort filter nested inside another cohort.
1087            match_nested_cohort(value, properties, ctx, active_cohorts, resolution_depth + 1)
1088        } else {
1089            // Leaf property filter.
1090            match serde_json::from_value::<CohortProperty>(value.clone()) {
1091                Ok(prop) => match_property_with_context(&prop.property, properties, ctx)
1092                    .map(|matches| matches != prop.negation)
1093                    .map_err(CohortMatchError::Inconclusive),
1094                Err(e) => Err(CohortMatchError::InvalidDefinition(
1095                    InconclusiveMatchError::new(&format!("Unable to parse cohort property: {}", e)),
1096                )),
1097            }
1098        };
1099
1100        match result {
1101            Ok(true) if !is_and => decisive_result = Some(true),
1102            Ok(false) if is_and => decisive_result = Some(false),
1103            Ok(_) => {}
1104            Err(error) if error.requires_server_evaluation() => return Err(error),
1105            Err(error) => inconclusive = Some(error),
1106        }
1107    }
1108
1109    if let Some(result) = decisive_result {
1110        return Ok(result);
1111    }
1112
1113    if let Some(error) = inconclusive {
1114        return Err(error);
1115    }
1116
1117    // AND: every entry matched. OR: none matched.
1118    Ok(is_and)
1119}
1120
1121/// Evaluate a cohort reference nested inside another cohort's property group,
1122/// honoring the optional `negation` flag.
1123fn match_nested_cohort(
1124    value: &serde_json::Value,
1125    properties: &HashMap<String, serde_json::Value>,
1126    ctx: &EvaluationContext,
1127    active_cohorts: &mut HashSet<String>,
1128    resolution_depth: usize,
1129) -> Result<bool, CohortMatchError> {
1130    let cohort_id = value
1131        .get("value")
1132        .and_then(cohort_id_to_string)
1133        .ok_or_else(|| {
1134            CohortMatchError::InvalidDefinition(InconclusiveMatchError::new(
1135                "Nested cohort ID must be a string or number",
1136            ))
1137        })?;
1138
1139    let negation = value
1140        .get("negation")
1141        .and_then(|n| n.as_bool())
1142        .unwrap_or(false);
1143
1144    let is_member = match_cohort_by_id(
1145        &cohort_id,
1146        properties,
1147        ctx,
1148        active_cohorts,
1149        resolution_depth,
1150    )?;
1151    Ok(is_member != negation)
1152}
1153
1154/// Evaluate flag dependency
1155fn match_flag_dependency_property(
1156    property: &Property,
1157    ctx: &EvaluationContext,
1158) -> Result<bool, InconclusiveMatchError> {
1159    // Extract flag key from "$feature/flag-key"
1160    let flag_key = property
1161        .key
1162        .strip_prefix("$feature/")
1163        .ok_or_else(|| InconclusiveMatchError::new("Invalid flag dependency format"))?;
1164
1165    let flag = ctx.flags.get(flag_key).ok_or_else(|| {
1166        InconclusiveMatchError::new(&format!("Flag '{}' not found in local cache", flag_key))
1167    })?;
1168
1169    // Evaluate the dependent flag for this user (with empty properties to avoid recursion issues).
1170    // Group context flows through from the outer ctx so dependent group/mixed flags can resolve.
1171    let empty_props = HashMap::new();
1172    let flag_value = match_feature_flag(
1173        flag,
1174        ctx.distinct_id,
1175        &empty_props,
1176        ctx.groups,
1177        ctx.group_properties,
1178        ctx.group_type_mapping,
1179    )?;
1180
1181    // Compare the flag value with the expected value
1182    let expected = &property.value;
1183
1184    let matches = match (&flag_value, expected) {
1185        (FlagValue::Boolean(b), serde_json::Value::Bool(expected_b)) => b == expected_b,
1186        (FlagValue::String(s), serde_json::Value::String(expected_s)) => {
1187            s.eq_ignore_ascii_case(expected_s)
1188        }
1189        (FlagValue::Boolean(true), serde_json::Value::String(s)) => {
1190            // Flag is enabled (boolean true) but we're checking for a specific variant
1191            // This should not match
1192            s.is_empty() || s == "true"
1193        }
1194        (FlagValue::Boolean(false), serde_json::Value::String(s)) => s.is_empty() || s == "false",
1195        (FlagValue::String(s), serde_json::Value::Bool(true)) => {
1196            // Flag returns a variant string, checking for "enabled" (any variant is enabled)
1197            !s.is_empty()
1198        }
1199        (FlagValue::String(_), serde_json::Value::Bool(false)) => false,
1200        _ => false,
1201    };
1202
1203    // Handle different operators
1204    Ok(match property.operator.as_str() {
1205        "exact" => matches,
1206        "is_not" => !matches,
1207        op => {
1208            return Err(InconclusiveMatchError::new(&format!(
1209                "Unknown flag dependency operator: {}",
1210                op
1211            )));
1212        }
1213    })
1214}
1215
1216/// Parse a relative date string like "-7d", "-24h", "-2w", "-3m", "-1y"
1217/// Returns the DateTime<Utc> that the relative date represents
1218fn parse_relative_date(value: &str) -> Option<DateTime<Utc>> {
1219    let value = value.trim();
1220    // Need at least 3 chars: "-", digit(s), and unit (e.g., "-7d")
1221    if value.len() < 3 || !value.starts_with('-') {
1222        return None;
1223    }
1224
1225    let (num_str, unit) = value[1..].split_at(value.len() - 2);
1226    let num: i64 = num_str.parse().ok()?;
1227
1228    let duration = match unit {
1229        "h" => chrono::Duration::hours(num),
1230        "d" => chrono::Duration::days(num),
1231        "w" => chrono::Duration::weeks(num),
1232        "m" => chrono::Duration::days(num * 30), // Approximate month as 30 days
1233        "y" => chrono::Duration::days(num * 365), // Approximate year as 365 days
1234        _ => return None,
1235    };
1236
1237    Some(Utc::now() - duration)
1238}
1239
1240/// Parse a date value from a string (ISO date, ISO datetime, or relative date)
1241fn parse_date_value(value: &serde_json::Value) -> Option<DateTime<Utc>> {
1242    let date_str = value.as_str()?;
1243
1244    // Try relative date first (e.g., "-7d")
1245    if date_str.starts_with('-') && date_str.len() > 1 {
1246        if let Some(dt) = parse_relative_date(date_str) {
1247            return Some(dt);
1248        }
1249    }
1250
1251    // Try ISO datetime with timezone (e.g., "2024-06-15T10:30:00Z")
1252    if let Ok(dt) = DateTime::parse_from_rfc3339(date_str) {
1253        return Some(dt.with_timezone(&Utc));
1254    }
1255
1256    // Try ISO date only (e.g., "2024-06-15")
1257    if let Ok(date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
1258        return Some(
1259            date.and_hms_opt(0, 0, 0)
1260                .expect("midnight is always valid")
1261                .and_utc(),
1262        );
1263    }
1264
1265    None
1266}
1267
1268/// A parsed semantic version as (major, minor, patch)
1269type SemverTuple = (u64, u64, u64);
1270
1271/// Parse a semantic version string into a (major, minor, patch) tuple.
1272///
1273/// Rules:
1274/// 1. Strip leading/trailing whitespace
1275/// 2. Strip `v` or `V` prefix (e.g., "v1.2.3" → "1.2.3")
1276/// 3. Strip pre-release and build metadata suffixes (split on `-` or `+`, take first part)
1277/// 4. Split on `.` and parse first 3 components as integers
1278/// 5. Default missing components to 0 (e.g., "1.2" → (1, 2, 0), "1" → (1, 0, 0))
1279/// 6. Ignore extra components beyond the third (e.g., "1.2.3.4" → (1, 2, 3))
1280/// 7. Return None for invalid input (empty string, non-numeric parts, leading dot,
1281///    or numeric components with leading zeros per semver 2.0.0 §2)
1282fn parse_semver(value: &str) -> Option<SemverTuple> {
1283    let value = value.trim();
1284    if value.is_empty() {
1285        return None;
1286    }
1287
1288    // Strip v/V prefix
1289    let value = value
1290        .strip_prefix('v')
1291        .or_else(|| value.strip_prefix('V'))
1292        .unwrap_or(value);
1293    if value.is_empty() {
1294        return None;
1295    }
1296
1297    // Strip pre-release/build metadata (everything after - or +)
1298    let value = value.split(['-', '+']).next().unwrap_or(value);
1299    if value.is_empty() {
1300        return None;
1301    }
1302
1303    // Leading dot is invalid
1304    if value.starts_with('.') {
1305        return None;
1306    }
1307
1308    // Split on dots and parse components
1309    let parts: Vec<&str> = value.split('.').collect();
1310    if parts.is_empty() {
1311        return None;
1312    }
1313
1314    let major = parse_semver_numeric(parts.first()?)?;
1315    let minor = parts.get(1).map_or(Some(0), |s| parse_semver_numeric(s))?;
1316    let patch = parts.get(2).map_or(Some(0), |s| parse_semver_numeric(s))?;
1317
1318    Some((major, minor, patch))
1319}
1320
1321/// Parse a single semver numeric identifier.
1322///
1323/// Per semver 2.0.0 §2, numeric identifiers MUST NOT include leading zeros, so
1324/// "07" and "001" are rejected while the literal "0" remains valid.
1325fn parse_semver_numeric(part: &str) -> Option<u64> {
1326    if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
1327        return None;
1328    }
1329    if part.len() > 1 && part.starts_with('0') {
1330        return None;
1331    }
1332    part.parse().ok()
1333}
1334
1335/// Parse a wildcard pattern like "1.*" or "1.2.*" and return (lower_bound, upper_bound)
1336/// Returns None if the pattern is invalid
1337fn parse_semver_wildcard(pattern: &str) -> Option<(SemverTuple, SemverTuple)> {
1338    let pattern = pattern.trim();
1339    if pattern.is_empty() {
1340        return None;
1341    }
1342
1343    // Strip v/V prefix
1344    let pattern = pattern
1345        .strip_prefix('v')
1346        .or_else(|| pattern.strip_prefix('V'))
1347        .unwrap_or(pattern);
1348    if pattern.is_empty() {
1349        return None;
1350    }
1351
1352    let parts: Vec<&str> = pattern.split('.').collect();
1353
1354    match parts.as_slice() {
1355        // "X.*" pattern
1356        [major_str, "*"] => {
1357            let major = parse_semver_numeric(major_str)?;
1358            Some(((major, 0, 0), (major + 1, 0, 0)))
1359        }
1360        // "X.Y.*" pattern
1361        [major_str, minor_str, "*"] => {
1362            let major = parse_semver_numeric(major_str)?;
1363            let minor = parse_semver_numeric(minor_str)?;
1364            Some(((major, minor, 0), (major, minor + 1, 0)))
1365        }
1366        _ => None,
1367    }
1368}
1369
1370/// Compute bounds for tilde range: ~X.Y.Z means >=X.Y.Z and <X.(Y+1).0
1371fn compute_tilde_bounds(version: SemverTuple) -> (SemverTuple, SemverTuple) {
1372    let (major, minor, patch) = version;
1373    ((major, minor, patch), (major, minor + 1, 0))
1374}
1375
1376/// Compute bounds for caret range per semver spec:
1377/// - ^X.Y.Z where X > 0: >=X.Y.Z <(X+1).0.0
1378/// - ^0.Y.Z where Y > 0: >=0.Y.Z <0.(Y+1).0
1379/// - ^0.0.Z: >=0.0.Z <0.0.(Z+1)
1380fn compute_caret_bounds(version: SemverTuple) -> (SemverTuple, SemverTuple) {
1381    let (major, minor, patch) = version;
1382    if major > 0 {
1383        ((major, minor, patch), (major + 1, 0, 0))
1384    } else if minor > 0 {
1385        ((0, minor, patch), (0, minor + 1, 0))
1386    } else {
1387        ((0, 0, patch), (0, 0, patch + 1))
1388    }
1389}
1390
1391fn parse_target_semver(
1392    target_value: &serde_json::Value,
1393) -> Result<SemverTuple, InconclusiveMatchError> {
1394    let target_str = value_to_string(target_value);
1395    parse_semver(&target_str).ok_or_else(|| {
1396        InconclusiveMatchError::new(&format!(
1397            "Unable to parse target semver value: {:?}",
1398            target_value
1399        ))
1400    })
1401}
1402
1403fn match_property(
1404    property: &Property,
1405    properties: &HashMap<String, serde_json::Value>,
1406) -> Result<bool, InconclusiveMatchError> {
1407    let value = match properties.get(&property.key) {
1408        Some(v) => v,
1409        None => {
1410            return Err(InconclusiveMatchError::new(&format!(
1411                "Property '{}' not found in provided properties",
1412                property.key
1413            )));
1414        }
1415    };
1416
1417    let parse_property_semver = || {
1418        let prop_str = value_to_string(value);
1419        parse_semver(&prop_str).ok_or_else(|| {
1420            InconclusiveMatchError::new(&format!(
1421                "Unable to parse property semver value for '{}': {:?}",
1422                property.key, value
1423            ))
1424        })
1425    };
1426    let parse_semver_operands = || {
1427        Ok((
1428            parse_property_semver()?,
1429            parse_target_semver(&property.value)?,
1430        ))
1431    };
1432
1433    Ok(match property.operator.as_str() {
1434        "exact" => compute_exact_match(&property.value, value),
1435        "is_not" => !compute_exact_match(&property.value, value),
1436        "is_set" => true,      // We already know the property exists
1437        "is_not_set" => false, // We already know the property exists
1438        "icontains" => {
1439            let prop_str = value_to_string(value);
1440            let search_str = value_to_string(&property.value);
1441            prop_str
1442                .to_ascii_lowercase()
1443                .contains(&search_str.to_ascii_lowercase())
1444        }
1445        "not_icontains" => {
1446            let prop_str = value_to_string(value);
1447            let search_str = value_to_string(&property.value);
1448            !prop_str
1449                .to_ascii_lowercase()
1450                .contains(&search_str.to_ascii_lowercase())
1451        }
1452        "starts_with" => {
1453            let prop_str = value_to_string(value);
1454            let search_str = value_to_string(&property.value);
1455            prop_str
1456                .to_ascii_lowercase()
1457                .starts_with(&search_str.to_ascii_lowercase())
1458        }
1459        "not_starts_with" => {
1460            let prop_str = value_to_string(value);
1461            let search_str = value_to_string(&property.value);
1462            !prop_str
1463                .to_ascii_lowercase()
1464                .starts_with(&search_str.to_ascii_lowercase())
1465        }
1466        "ends_with" => {
1467            let prop_str = value_to_string(value);
1468            let search_str = value_to_string(&property.value);
1469            prop_str
1470                .to_ascii_lowercase()
1471                .ends_with(&search_str.to_ascii_lowercase())
1472        }
1473        "not_ends_with" => {
1474            let prop_str = value_to_string(value);
1475            let search_str = value_to_string(&property.value);
1476            !prop_str
1477                .to_ascii_lowercase()
1478                .ends_with(&search_str.to_ascii_lowercase())
1479        }
1480        "regex" => {
1481            let prop_str = value_to_string(value);
1482            let regex_str = value_to_string(&property.value);
1483            get_cached_regex(&regex_str)
1484                .map(|re| re.is_match(&prop_str))
1485                .unwrap_or(false)
1486        }
1487        "not_regex" => {
1488            let prop_str = value_to_string(value);
1489            let regex_str = value_to_string(&property.value);
1490            get_cached_regex(&regex_str)
1491                .map(|re| !re.is_match(&prop_str))
1492                .unwrap_or(true)
1493        }
1494        "gt" | "gte" | "lt" | "lte" => compare_numeric(&property.operator, &property.value, value),
1495        "is_date_before" | "is_date_after" => {
1496            let target_date = parse_date_value(&property.value).ok_or_else(|| {
1497                InconclusiveMatchError::new(&format!(
1498                    "Unable to parse target date value: {:?}",
1499                    property.value
1500                ))
1501            })?;
1502
1503            let prop_date = parse_date_value(value).ok_or_else(|| {
1504                InconclusiveMatchError::new(&format!(
1505                    "Unable to parse property date value for '{}': {:?}",
1506                    property.key, value
1507                ))
1508            })?;
1509
1510            if property.operator == "is_date_before" {
1511                prop_date < target_date
1512            } else {
1513                prop_date > target_date
1514            }
1515        }
1516        // Semver comparison operators
1517        "semver_eq" | "semver_neq" | "semver_gt" | "semver_gte" | "semver_lt" | "semver_lte" => {
1518            let (prop_version, target_version) = parse_semver_operands()?;
1519
1520            match property.operator.as_str() {
1521                "semver_eq" => prop_version == target_version,
1522                "semver_neq" => prop_version != target_version,
1523                "semver_gt" => prop_version > target_version,
1524                "semver_gte" => prop_version >= target_version,
1525                "semver_lt" => prop_version < target_version,
1526                "semver_lte" => prop_version <= target_version,
1527                _ => unreachable!(),
1528            }
1529        }
1530        "semver_tilde" => {
1531            let (prop_version, target_version) = parse_semver_operands()?;
1532            let (lower, upper) = compute_tilde_bounds(target_version);
1533            prop_version >= lower && prop_version < upper
1534        }
1535        "semver_caret" => {
1536            let (prop_version, target_version) = parse_semver_operands()?;
1537            let (lower, upper) = compute_caret_bounds(target_version);
1538            prop_version >= lower && prop_version < upper
1539        }
1540        "semver_wildcard" => {
1541            let prop_version = parse_property_semver()?;
1542            let target_str = value_to_string(&property.value);
1543
1544            let (lower, upper) = parse_semver_wildcard(&target_str).ok_or_else(|| {
1545                InconclusiveMatchError::new(&format!(
1546                    "Unable to parse target semver wildcard pattern: {:?}",
1547                    property.value
1548                ))
1549            })?;
1550
1551            prop_version >= lower && prop_version < upper
1552        }
1553        unknown => {
1554            return Err(InconclusiveMatchError::new(&format!(
1555                "Unknown operator: {}",
1556                unknown
1557            )));
1558        }
1559    })
1560}
1561
1562fn compute_exact_match(value: &serde_json::Value, override_value: &serde_json::Value) -> bool {
1563    if is_truthy_or_falsy_property_value(value) {
1564        return is_truthy_property_value(value) == is_truthy_property_value(override_value);
1565    }
1566
1567    if let Some(values) = value.as_array() {
1568        return values
1569            .iter()
1570            .any(|candidate| compare_values(candidate, override_value));
1571    }
1572
1573    compare_values(value, override_value)
1574}
1575
1576fn is_truthy_or_falsy_property_value(value: &serde_json::Value) -> bool {
1577    match value {
1578        serde_json::Value::Bool(_) => true,
1579        serde_json::Value::String(value) => {
1580            value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("false")
1581        }
1582        serde_json::Value::Array(values) => values.iter().all(is_truthy_or_falsy_property_value),
1583        _ => false,
1584    }
1585}
1586
1587fn is_truthy_property_value(value: &serde_json::Value) -> bool {
1588    match value {
1589        serde_json::Value::Bool(value) => *value,
1590        serde_json::Value::String(value) => value.eq_ignore_ascii_case("true"),
1591        serde_json::Value::Array(values) => values.iter().all(is_truthy_property_value),
1592        _ => false,
1593    }
1594}
1595
1596fn compare_values(a: &serde_json::Value, b: &serde_json::Value) -> bool {
1597    value_to_string(a).to_lowercase() == value_to_string(b).to_lowercase()
1598}
1599
1600fn value_to_string(value: &serde_json::Value) -> String {
1601    match value {
1602        serde_json::Value::String(s) => s.clone(),
1603        serde_json::Value::Number(n) => n.to_string(),
1604        serde_json::Value::Bool(b) => b.to_string(),
1605        _ => value.to_string(),
1606    }
1607}
1608
1609fn compare_numeric(
1610    operator: &str,
1611    property_value: &serde_json::Value,
1612    value: &serde_json::Value,
1613) -> bool {
1614    let prop_num = match property_value {
1615        serde_json::Value::Number(n) => n.as_f64(),
1616        serde_json::Value::String(s) => s.parse::<f64>().ok(),
1617        _ => None,
1618    };
1619
1620    let val_num = match value {
1621        serde_json::Value::Number(n) => n.as_f64(),
1622        serde_json::Value::String(s) => s.parse::<f64>().ok(),
1623        _ => None,
1624    };
1625
1626    if let (Some(prop), Some(val)) = (prop_num, val_num) {
1627        match operator {
1628            "gt" => val > prop,
1629            "gte" => val >= prop,
1630            "lt" => val < prop,
1631            "lte" => val <= prop,
1632            _ => false,
1633        }
1634    } else {
1635        // Fall back to string comparison
1636        let prop_str = value_to_string(property_value);
1637        let val_str = value_to_string(value);
1638        match operator {
1639            "gt" => val_str > prop_str,
1640            "gte" => val_str >= prop_str,
1641            "lt" => val_str < prop_str,
1642            "lte" => val_str <= prop_str,
1643            _ => false,
1644        }
1645    }
1646}
1647
1648#[cfg(test)]
1649mod tests {
1650    use super::*;
1651    use serde_json::json;
1652
1653    /// Test salt constant to avoid CodeQL warnings about empty cryptographic values
1654    const TEST_SALT: &str = "test-salt";
1655
1656    #[test]
1657    fn test_hash_key() {
1658        let hash = hash_key("test-flag", "user-123", TEST_SALT);
1659        assert!((0.0..=1.0).contains(&hash));
1660
1661        // Same inputs should produce same hash
1662        let hash2 = hash_key("test-flag", "user-123", TEST_SALT);
1663        assert_eq!(hash, hash2);
1664
1665        // Different inputs should produce different hash
1666        let hash3 = hash_key("test-flag", "user-456", TEST_SALT);
1667        assert_ne!(hash, hash3);
1668    }
1669
1670    /// Bucketing must stay bit-identical across SDKs, so pin known values.
1671    /// Generated with the Python SDK's algorithm:
1672    /// `int(hashlib.sha1(f"{key}.{distinct_id}{salt}".encode()).hexdigest()[:15], 16) / 0xfffffffffffffff`
1673    #[test]
1674    fn test_hash_key_matches_known_vectors() {
1675        for (key, distinct_id, salt, expected) in [
1676            ("test-flag", "user-123", TEST_SALT, 0.982_062_667_408_254_5),
1677            ("test-flag", "user-456", TEST_SALT, 0.695_145_973_300_181_1),
1678            (
1679                "beta-feature",
1680                "distinct_id",
1681                ROLLOUT_HASH_SALT,
1682                0.875_596_347_947_407_8,
1683            ),
1684            (
1685                "beta-feature",
1686                "distinct_id",
1687                VARIANT_HASH_SALT,
1688                0.228_302_715_824_090_7,
1689            ),
1690            (
1691                "multivariate-flag",
1692                "user_1",
1693                ROLLOUT_HASH_SALT,
1694                0.223_607_742_058_685_7,
1695            ),
1696        ] {
1697            assert_eq!(
1698                hash_key(key, distinct_id, salt),
1699                expected,
1700                "hash_key({key:?}, {distinct_id:?}, {salt:?}) drifted from the other SDKs"
1701            );
1702        }
1703    }
1704
1705    #[test]
1706    fn test_simple_flag_match() {
1707        let flag = FeatureFlag {
1708            key: "test-flag".to_string(),
1709            active: true,
1710            has_experiment: None,
1711            filters: FeatureFlagFilters {
1712                groups: vec![FeatureFlagCondition {
1713                    properties: vec![],
1714                    rollout_percentage: Some(100.0),
1715                    variant: None,
1716                    aggregation_group_type_index: None,
1717                }],
1718                multivariate: None,
1719                payloads: HashMap::new(),
1720                aggregation_group_type_index: None,
1721                early_exit: false,
1722            },
1723        };
1724
1725        let properties = HashMap::new();
1726        let result = match_feature_flag(
1727            &flag,
1728            "user-123",
1729            &properties,
1730            &HashMap::new(),
1731            &HashMap::new(),
1732            &HashMap::new(),
1733        )
1734        .unwrap();
1735        assert_eq!(result, FlagValue::Boolean(true));
1736    }
1737
1738    #[test]
1739    fn test_property_matching() {
1740        let prop = Property {
1741            key: "country".to_string(),
1742            value: json!("US"),
1743            operator: "exact".to_string(),
1744            property_type: None,
1745        };
1746
1747        let mut properties = HashMap::new();
1748        properties.insert("country".to_string(), json!("US"));
1749
1750        assert!(match_property(&prop, &properties).unwrap());
1751
1752        properties.insert("country".to_string(), json!("UK"));
1753        assert!(!match_property(&prop, &properties).unwrap());
1754    }
1755
1756    #[test]
1757    fn test_property_case_folding_matches_flags_service() {
1758        let matches = |operator: &str, expected, actual| {
1759            let property = Property {
1760                key: "key".to_string(),
1761                value: expected,
1762                operator: operator.to_string(),
1763                property_type: None,
1764            };
1765            match_property(&property, &HashMap::from([("key".to_string(), actual)])).unwrap()
1766        };
1767
1768        // Serde preserves a float's decimal point; exact matching must not collapse it to an integer.
1769        assert_eq!(value_to_string(&json!(323.0)), "323.0");
1770
1771        let cases = [
1772            // Exact matching stringifies both sides and applies Unicode lowercase.
1773            ("exact", json!("PRO"), json!("pro"), true),
1774            ("exact", json!("Ä"), json!("ä"), true),
1775            ("exact", json!("ß"), json!("ss"), false),
1776            ("exact", json!("Σ"), json!("ς"), false),
1777            ("exact", json!("ΟΣ"), json!("ος"), true),
1778            ("exact", json!("ΟΣ"), json!("οσ"), false),
1779            ("exact", json!("İ"), json!("i\u{0307}"), true),
1780            ("exact", json!("İ"), json!("i"), false),
1781            ("exact", json!(323), json!("323"), true),
1782            ("exact", json!(["FREE", "PRÖ"]), json!("prö"), true),
1783            ("is_not", json!(["FREE", "PRÖ"]), json!("prö"), false),
1784            ("is_not", json!(["FREE", "PRÖ"]), json!("team"), true),
1785            ("exact", json!("323.0"), json!(323.0), true),
1786            ("exact", json!("323"), json!(323.0), false),
1787            // Substring and anchored operators fold ASCII only.
1788            ("icontains", json!("ADMIN"), json!("admin-user"), true),
1789            ("icontains", json!("Ä"), json!("äbc"), false),
1790            ("not_icontains", json!("Ä"), json!("äbc"), true),
1791            ("starts_with", json!("Ä"), json!("äbc"), false),
1792            ("not_starts_with", json!("Ä"), json!("äbc"), true),
1793            ("ends_with", json!("Ä"), json!("bcä"), false),
1794            ("not_ends_with", json!("Ä"), json!("bcä"), true),
1795        ];
1796
1797        for (operator, expected, actual, should_match) in cases {
1798            let result = matches(operator, expected.clone(), actual.clone());
1799            assert_eq!(
1800                result, should_match,
1801                "operator {operator} comparing {actual} against {expected}"
1802            );
1803        }
1804    }
1805
1806    #[test]
1807    fn test_exact_boolean_coercion_matches_flags_service() {
1808        let matches = |operator: &str, expected, actual| {
1809            let property = Property {
1810                key: "key".to_string(),
1811                value: expected,
1812                operator: operator.to_string(),
1813                property_type: None,
1814            };
1815            match_property(&property, &HashMap::from([("key".to_string(), actual)])).unwrap()
1816        };
1817
1818        let cases = [
1819            // Boolean-like filters compare aggregate truthiness before array membership.
1820            (json!(false), json!("banana"), true),
1821            (json!("false"), json!(0), true),
1822            (json!(["false"]), json!(null), true),
1823            (json!(["true", "false"]), json!("true"), false),
1824            (json!(["true", "false"]), json!("pro"), true),
1825            // Empty arrays are boolean-like and truthy because all() on an empty iterator is true.
1826            (json!([]), json!(true), true),
1827            (json!([]), json!("true"), true),
1828            (json!([]), json!([]), true),
1829            (json!([]), json!([true]), true),
1830            (json!([]), json!(false), false),
1831            (json!([]), json!("banana"), false),
1832            // Non-boolean-like arrays retain ordinary ANY membership.
1833            (json!(["FREE", "PRO"]), json!("pro"), true),
1834            (json!(["FREE", "PRO"]), json!("team"), false),
1835        ];
1836
1837        for (expected, actual, exact_match) in cases {
1838            assert_eq!(
1839                matches("exact", expected.clone(), actual.clone()),
1840                exact_match,
1841                "exact comparing {actual} against {expected}"
1842            );
1843            assert_eq!(
1844                matches("is_not", expected.clone(), actual.clone()),
1845                !exact_match,
1846                "is_not comparing {actual} against {expected}"
1847            );
1848        }
1849    }
1850
1851    #[test]
1852    fn test_null_property_operator_defaults_to_exact() {
1853        let prop: Property = serde_json::from_value(json!({
1854            "key": "country",
1855            "value": "US",
1856            "operator": null,
1857            "type": "person"
1858        }))
1859        .unwrap();
1860
1861        assert_eq!(prop.operator, "exact");
1862    }
1863
1864    #[test]
1865    fn test_multivariate_variants() {
1866        let flag = FeatureFlag {
1867            key: "test-flag".to_string(),
1868            active: true,
1869            has_experiment: None,
1870            filters: FeatureFlagFilters {
1871                groups: vec![FeatureFlagCondition {
1872                    properties: vec![],
1873                    rollout_percentage: Some(100.0),
1874                    variant: None,
1875                    aggregation_group_type_index: None,
1876                }],
1877                multivariate: Some(MultivariateFilter {
1878                    variants: vec![
1879                        MultivariateVariant {
1880                            key: "control".to_string(),
1881                            rollout_percentage: 50.0,
1882                        },
1883                        MultivariateVariant {
1884                            key: "test".to_string(),
1885                            rollout_percentage: 50.0,
1886                        },
1887                    ],
1888                }),
1889                payloads: HashMap::new(),
1890                aggregation_group_type_index: None,
1891                early_exit: false,
1892            },
1893        };
1894
1895        let properties = HashMap::new();
1896        let result = match_feature_flag(
1897            &flag,
1898            "user-123",
1899            &properties,
1900            &HashMap::new(),
1901            &HashMap::new(),
1902            &HashMap::new(),
1903        )
1904        .unwrap();
1905
1906        match result {
1907            FlagValue::String(variant) => {
1908                assert!(variant == "control" || variant == "test");
1909            }
1910            _ => panic!("Expected string variant"),
1911        }
1912    }
1913
1914    #[test]
1915    fn test_inactive_flag() {
1916        let flag = FeatureFlag {
1917            key: "inactive-flag".to_string(),
1918            active: false,
1919            has_experiment: None,
1920            filters: FeatureFlagFilters {
1921                groups: vec![FeatureFlagCondition {
1922                    properties: vec![],
1923                    rollout_percentage: Some(100.0),
1924                    variant: None,
1925                    aggregation_group_type_index: None,
1926                }],
1927                multivariate: None,
1928                payloads: HashMap::new(),
1929                aggregation_group_type_index: None,
1930                early_exit: false,
1931            },
1932        };
1933
1934        let properties = HashMap::new();
1935        let result = match_feature_flag(
1936            &flag,
1937            "user-123",
1938            &properties,
1939            &HashMap::new(),
1940            &HashMap::new(),
1941            &HashMap::new(),
1942        )
1943        .unwrap();
1944        assert_eq!(result, FlagValue::Boolean(false));
1945    }
1946
1947    #[test]
1948    fn test_rollout_percentage() {
1949        let flag = FeatureFlag {
1950            key: "rollout-flag".to_string(),
1951            active: true,
1952            has_experiment: None,
1953            filters: FeatureFlagFilters {
1954                groups: vec![FeatureFlagCondition {
1955                    properties: vec![],
1956                    rollout_percentage: Some(30.0), // 30% rollout
1957                    variant: None,
1958                    aggregation_group_type_index: None,
1959                }],
1960                multivariate: None,
1961                payloads: HashMap::new(),
1962                aggregation_group_type_index: None,
1963                early_exit: false,
1964            },
1965        };
1966
1967        let properties = HashMap::new();
1968
1969        // Test with multiple users to ensure distribution
1970        let mut enabled_count = 0;
1971        for i in 0..1000 {
1972            let result = match_feature_flag(
1973                &flag,
1974                &format!("user-{}", i),
1975                &properties,
1976                &HashMap::new(),
1977                &HashMap::new(),
1978                &HashMap::new(),
1979            )
1980            .unwrap();
1981            if result == FlagValue::Boolean(true) {
1982                enabled_count += 1;
1983            }
1984        }
1985
1986        // Should be roughly 30% enabled (allow for some variance)
1987        assert!(enabled_count > 250 && enabled_count < 350);
1988    }
1989
1990    #[test]
1991    fn test_regex_operator() {
1992        let prop = Property {
1993            key: "email".to_string(),
1994            value: json!(".*@company\\.com$"),
1995            operator: "regex".to_string(),
1996            property_type: None,
1997        };
1998
1999        let mut properties = HashMap::new();
2000        properties.insert("email".to_string(), json!("user@company.com"));
2001        assert!(match_property(&prop, &properties).unwrap());
2002
2003        properties.insert("email".to_string(), json!("user@example.com"));
2004        assert!(!match_property(&prop, &properties).unwrap());
2005    }
2006
2007    #[test]
2008    fn test_icontains_operator() {
2009        let prop = Property {
2010            key: "name".to_string(),
2011            value: json!("ADMIN"),
2012            operator: "icontains".to_string(),
2013            property_type: None,
2014        };
2015
2016        let mut properties = HashMap::new();
2017        properties.insert("name".to_string(), json!("admin_user"));
2018        assert!(match_property(&prop, &properties).unwrap());
2019
2020        properties.insert("name".to_string(), json!("regular_user"));
2021        assert!(!match_property(&prop, &properties).unwrap());
2022    }
2023
2024    #[test]
2025    fn test_starts_with_operator() {
2026        let prop = Property {
2027            key: "name".to_string(),
2028            value: json!("Val"),
2029            operator: "starts_with".to_string(),
2030            property_type: None,
2031        };
2032
2033        // Case-insensitive match anchored to the start.
2034        let mut properties = HashMap::new();
2035        properties.insert("name".to_string(), json!("value"));
2036        assert!(match_property(&prop, &properties).unwrap());
2037
2038        properties.insert("name".to_string(), json!("VALUE"));
2039        assert!(match_property(&prop, &properties).unwrap());
2040
2041        // Substring present but not at the start.
2042        properties.insert("name".to_string(), json!("prevalue"));
2043        assert!(!match_property(&prop, &properties).unwrap());
2044
2045        properties.insert("name".to_string(), json!("Alakazam"));
2046        assert!(!match_property(&prop, &properties).unwrap());
2047
2048        // Numeric property values are stringified before matching.
2049        let numeric_prop = Property {
2050            key: "name".to_string(),
2051            value: json!("3"),
2052            operator: "starts_with".to_string(),
2053            property_type: None,
2054        };
2055
2056        properties.insert("name".to_string(), json!(323));
2057        assert!(match_property(&numeric_prop, &properties).unwrap());
2058
2059        properties.insert("name".to_string(), json!(123));
2060        assert!(!match_property(&numeric_prop, &properties).unwrap());
2061
2062        let negated_prop = Property {
2063            key: "name".to_string(),
2064            value: json!("Val"),
2065            operator: "not_starts_with".to_string(),
2066            property_type: None,
2067        };
2068
2069        properties.insert("name".to_string(), json!("value"));
2070        assert!(!match_property(&negated_prop, &properties).unwrap());
2071
2072        properties.insert("name".to_string(), json!("prevalue"));
2073        assert!(match_property(&negated_prop, &properties).unwrap());
2074
2075        // Missing property is inconclusive.
2076        assert!(match_property(&prop, &HashMap::new()).is_err());
2077    }
2078
2079    #[test]
2080    fn test_ends_with_operator() {
2081        let prop = Property {
2082            key: "name".to_string(),
2083            value: json!("lUe"),
2084            operator: "ends_with".to_string(),
2085            property_type: None,
2086        };
2087
2088        // Case-insensitive match anchored to the end.
2089        let mut properties = HashMap::new();
2090        properties.insert("name".to_string(), json!("value"));
2091        assert!(match_property(&prop, &properties).unwrap());
2092
2093        properties.insert("name".to_string(), json!("VALUE"));
2094        assert!(match_property(&prop, &properties).unwrap());
2095
2096        // Substring present but not at the end.
2097        properties.insert("name".to_string(), json!("value2"));
2098        assert!(!match_property(&prop, &properties).unwrap());
2099
2100        properties.insert("name".to_string(), json!("Alakazam"));
2101        assert!(!match_property(&prop, &properties).unwrap());
2102
2103        // Numeric property values are stringified before matching.
2104        let numeric_prop = Property {
2105            key: "name".to_string(),
2106            value: json!("3"),
2107            operator: "ends_with".to_string(),
2108            property_type: None,
2109        };
2110
2111        properties.insert("name".to_string(), json!(323));
2112        assert!(match_property(&numeric_prop, &properties).unwrap());
2113
2114        properties.insert("name".to_string(), json!(321));
2115        assert!(!match_property(&numeric_prop, &properties).unwrap());
2116
2117        let negated_prop = Property {
2118            key: "name".to_string(),
2119            value: json!("lUe"),
2120            operator: "not_ends_with".to_string(),
2121            property_type: None,
2122        };
2123
2124        properties.insert("name".to_string(), json!("value"));
2125        assert!(!match_property(&negated_prop, &properties).unwrap());
2126
2127        properties.insert("name".to_string(), json!("value2"));
2128        assert!(match_property(&negated_prop, &properties).unwrap());
2129
2130        // Missing property is inconclusive.
2131        assert!(match_property(&prop, &HashMap::new()).is_err());
2132    }
2133
2134    #[test]
2135    fn test_numeric_operators() {
2136        // Greater than
2137        let prop_gt = Property {
2138            key: "age".to_string(),
2139            value: json!(18),
2140            operator: "gt".to_string(),
2141            property_type: None,
2142        };
2143
2144        let mut properties = HashMap::new();
2145        properties.insert("age".to_string(), json!(25));
2146        assert!(match_property(&prop_gt, &properties).unwrap());
2147
2148        properties.insert("age".to_string(), json!(15));
2149        assert!(!match_property(&prop_gt, &properties).unwrap());
2150
2151        // Less than or equal
2152        let prop_lte = Property {
2153            key: "score".to_string(),
2154            value: json!(100),
2155            operator: "lte".to_string(),
2156            property_type: None,
2157        };
2158
2159        properties.insert("score".to_string(), json!(100));
2160        assert!(match_property(&prop_lte, &properties).unwrap());
2161
2162        properties.insert("score".to_string(), json!(101));
2163        assert!(!match_property(&prop_lte, &properties).unwrap());
2164    }
2165
2166    #[test]
2167    fn test_is_set_operator() {
2168        let prop = Property {
2169            key: "email".to_string(),
2170            value: json!(true),
2171            operator: "is_set".to_string(),
2172            property_type: None,
2173        };
2174
2175        let mut properties = HashMap::new();
2176        for value in [
2177            json!(null),
2178            json!(false),
2179            json!(0),
2180            json!(""),
2181            json!([]),
2182            json!({}),
2183        ] {
2184            properties.insert("email".to_string(), value);
2185            assert!(match_property(&prop, &properties).unwrap());
2186        }
2187
2188        properties.remove("email");
2189        assert!(matches!(
2190            match_property(&prop, &properties),
2191            Err(InconclusiveMatchError { .. })
2192        ));
2193    }
2194
2195    #[test]
2196    fn test_is_not_set_operator() {
2197        let prop = Property {
2198            key: "phone".to_string(),
2199            value: json!(true),
2200            operator: "is_not_set".to_string(),
2201            property_type: None,
2202        };
2203
2204        let mut properties = HashMap::new();
2205        for value in [
2206            json!(null),
2207            json!(false),
2208            json!(0),
2209            json!(""),
2210            json!([]),
2211            json!({}),
2212        ] {
2213            properties.insert("phone".to_string(), value);
2214            assert!(!match_property(&prop, &properties).unwrap());
2215        }
2216
2217        properties.remove("phone");
2218        assert!(matches!(
2219            match_property(&prop, &properties),
2220            Err(InconclusiveMatchError { .. })
2221        ));
2222    }
2223
2224    #[test]
2225    fn test_empty_groups() {
2226        let flag = FeatureFlag {
2227            key: "empty-groups".to_string(),
2228            active: true,
2229            has_experiment: None,
2230            filters: FeatureFlagFilters {
2231                groups: vec![],
2232                multivariate: None,
2233                payloads: HashMap::new(),
2234                aggregation_group_type_index: None,
2235                early_exit: false,
2236            },
2237        };
2238
2239        let properties = HashMap::new();
2240        let result = match_feature_flag(
2241            &flag,
2242            "user-123",
2243            &properties,
2244            &HashMap::new(),
2245            &HashMap::new(),
2246            &HashMap::new(),
2247        )
2248        .unwrap();
2249        assert_eq!(result, FlagValue::Boolean(false));
2250    }
2251
2252    #[test]
2253    fn test_hash_scale_constant() {
2254        // Verify the constant is exactly 15 F's (not 16)
2255        assert_eq!(LONG_SCALE, 0xFFFFFFFFFFFFFFFu64 as f64);
2256        assert_ne!(LONG_SCALE, 0xFFFFFFFFFFFFFFFFu64 as f64);
2257    }
2258
2259    // ==================== Tests for missing operators ====================
2260
2261    #[test]
2262    fn test_unknown_operator_returns_inconclusive_error() {
2263        let prop = Property {
2264            key: "status".to_string(),
2265            value: json!("active"),
2266            operator: "unknown_operator".to_string(),
2267            property_type: None,
2268        };
2269
2270        let mut properties = HashMap::new();
2271        properties.insert("status".to_string(), json!("active"));
2272
2273        let result = match_property(&prop, &properties);
2274        assert!(result.is_err());
2275        let err = result.unwrap_err();
2276        assert!(err.message.contains("unknown_operator"));
2277    }
2278
2279    #[test]
2280    fn test_is_date_before_with_relative_date() {
2281        let prop = Property {
2282            key: "signup_date".to_string(),
2283            value: json!("-7d"), // 7 days ago
2284            operator: "is_date_before".to_string(),
2285            property_type: None,
2286        };
2287
2288        let mut properties = HashMap::new();
2289        // Date 10 days ago should be before -7d
2290        let ten_days_ago = chrono::Utc::now() - chrono::Duration::days(10);
2291        properties.insert(
2292            "signup_date".to_string(),
2293            json!(ten_days_ago.format("%Y-%m-%d").to_string()),
2294        );
2295        assert!(match_property(&prop, &properties).unwrap());
2296
2297        // Date 3 days ago should NOT be before -7d
2298        let three_days_ago = chrono::Utc::now() - chrono::Duration::days(3);
2299        properties.insert(
2300            "signup_date".to_string(),
2301            json!(three_days_ago.format("%Y-%m-%d").to_string()),
2302        );
2303        assert!(!match_property(&prop, &properties).unwrap());
2304    }
2305
2306    #[test]
2307    fn test_is_date_after_with_relative_date() {
2308        let prop = Property {
2309            key: "last_seen".to_string(),
2310            value: json!("-30d"), // 30 days ago
2311            operator: "is_date_after".to_string(),
2312            property_type: None,
2313        };
2314
2315        let mut properties = HashMap::new();
2316        // Date 10 days ago should be after -30d
2317        let ten_days_ago = chrono::Utc::now() - chrono::Duration::days(10);
2318        properties.insert(
2319            "last_seen".to_string(),
2320            json!(ten_days_ago.format("%Y-%m-%d").to_string()),
2321        );
2322        assert!(match_property(&prop, &properties).unwrap());
2323
2324        // Date 60 days ago should NOT be after -30d
2325        let sixty_days_ago = chrono::Utc::now() - chrono::Duration::days(60);
2326        properties.insert(
2327            "last_seen".to_string(),
2328            json!(sixty_days_ago.format("%Y-%m-%d").to_string()),
2329        );
2330        assert!(!match_property(&prop, &properties).unwrap());
2331    }
2332
2333    #[test]
2334    fn test_is_date_before_with_iso_date() {
2335        let prop = Property {
2336            key: "expiry_date".to_string(),
2337            value: json!("2024-06-15"),
2338            operator: "is_date_before".to_string(),
2339            property_type: None,
2340        };
2341
2342        let mut properties = HashMap::new();
2343        properties.insert("expiry_date".to_string(), json!("2024-06-10"));
2344        assert!(match_property(&prop, &properties).unwrap());
2345
2346        properties.insert("expiry_date".to_string(), json!("2024-06-20"));
2347        assert!(!match_property(&prop, &properties).unwrap());
2348    }
2349
2350    #[test]
2351    fn test_is_date_after_with_iso_date() {
2352        let prop = Property {
2353            key: "start_date".to_string(),
2354            value: json!("2024-01-01"),
2355            operator: "is_date_after".to_string(),
2356            property_type: None,
2357        };
2358
2359        let mut properties = HashMap::new();
2360        properties.insert("start_date".to_string(), json!("2024-03-15"));
2361        assert!(match_property(&prop, &properties).unwrap());
2362
2363        properties.insert("start_date".to_string(), json!("2023-12-01"));
2364        assert!(!match_property(&prop, &properties).unwrap());
2365    }
2366
2367    #[test]
2368    fn test_is_date_with_relative_hours() {
2369        let prop = Property {
2370            key: "last_active".to_string(),
2371            value: json!("-24h"), // 24 hours ago
2372            operator: "is_date_after".to_string(),
2373            property_type: None,
2374        };
2375
2376        let mut properties = HashMap::new();
2377        // 12 hours ago should be after -24h
2378        let twelve_hours_ago = chrono::Utc::now() - chrono::Duration::hours(12);
2379        properties.insert(
2380            "last_active".to_string(),
2381            json!(twelve_hours_ago.to_rfc3339()),
2382        );
2383        assert!(match_property(&prop, &properties).unwrap());
2384
2385        // 48 hours ago should NOT be after -24h
2386        let forty_eight_hours_ago = chrono::Utc::now() - chrono::Duration::hours(48);
2387        properties.insert(
2388            "last_active".to_string(),
2389            json!(forty_eight_hours_ago.to_rfc3339()),
2390        );
2391        assert!(!match_property(&prop, &properties).unwrap());
2392    }
2393
2394    #[test]
2395    fn test_is_date_with_relative_weeks() {
2396        let prop = Property {
2397            key: "joined".to_string(),
2398            value: json!("-2w"), // 2 weeks ago
2399            operator: "is_date_before".to_string(),
2400            property_type: None,
2401        };
2402
2403        let mut properties = HashMap::new();
2404        // 3 weeks ago should be before -2w
2405        let three_weeks_ago = chrono::Utc::now() - chrono::Duration::weeks(3);
2406        properties.insert(
2407            "joined".to_string(),
2408            json!(three_weeks_ago.format("%Y-%m-%d").to_string()),
2409        );
2410        assert!(match_property(&prop, &properties).unwrap());
2411
2412        // 1 week ago should NOT be before -2w
2413        let one_week_ago = chrono::Utc::now() - chrono::Duration::weeks(1);
2414        properties.insert(
2415            "joined".to_string(),
2416            json!(one_week_ago.format("%Y-%m-%d").to_string()),
2417        );
2418        assert!(!match_property(&prop, &properties).unwrap());
2419    }
2420
2421    #[test]
2422    fn test_is_date_with_relative_months() {
2423        let prop = Property {
2424            key: "subscription_date".to_string(),
2425            value: json!("-3m"), // 3 months ago
2426            operator: "is_date_after".to_string(),
2427            property_type: None,
2428        };
2429
2430        let mut properties = HashMap::new();
2431        // 1 month ago should be after -3m
2432        let one_month_ago = chrono::Utc::now() - chrono::Duration::days(30);
2433        properties.insert(
2434            "subscription_date".to_string(),
2435            json!(one_month_ago.format("%Y-%m-%d").to_string()),
2436        );
2437        assert!(match_property(&prop, &properties).unwrap());
2438
2439        // 6 months ago should NOT be after -3m
2440        let six_months_ago = chrono::Utc::now() - chrono::Duration::days(180);
2441        properties.insert(
2442            "subscription_date".to_string(),
2443            json!(six_months_ago.format("%Y-%m-%d").to_string()),
2444        );
2445        assert!(!match_property(&prop, &properties).unwrap());
2446    }
2447
2448    #[test]
2449    fn test_is_date_with_relative_years() {
2450        let prop = Property {
2451            key: "created_at".to_string(),
2452            value: json!("-1y"), // 1 year ago
2453            operator: "is_date_before".to_string(),
2454            property_type: None,
2455        };
2456
2457        let mut properties = HashMap::new();
2458        // 2 years ago should be before -1y
2459        let two_years_ago = chrono::Utc::now() - chrono::Duration::days(730);
2460        properties.insert(
2461            "created_at".to_string(),
2462            json!(two_years_ago.format("%Y-%m-%d").to_string()),
2463        );
2464        assert!(match_property(&prop, &properties).unwrap());
2465
2466        // 6 months ago should NOT be before -1y
2467        let six_months_ago = chrono::Utc::now() - chrono::Duration::days(180);
2468        properties.insert(
2469            "created_at".to_string(),
2470            json!(six_months_ago.format("%Y-%m-%d").to_string()),
2471        );
2472        assert!(!match_property(&prop, &properties).unwrap());
2473    }
2474
2475    #[test]
2476    fn test_is_date_with_invalid_date_format() {
2477        let prop = Property {
2478            key: "date".to_string(),
2479            value: json!("-7d"),
2480            operator: "is_date_before".to_string(),
2481            property_type: None,
2482        };
2483
2484        let mut properties = HashMap::new();
2485        properties.insert("date".to_string(), json!("not-a-date"));
2486
2487        // Invalid date formats should return inconclusive
2488        let result = match_property(&prop, &properties);
2489        assert!(result.is_err());
2490    }
2491
2492    #[test]
2493    fn test_is_date_with_iso_datetime() {
2494        let prop = Property {
2495            key: "event_time".to_string(),
2496            value: json!("2024-06-15T10:30:00Z"),
2497            operator: "is_date_before".to_string(),
2498            property_type: None,
2499        };
2500
2501        let mut properties = HashMap::new();
2502        properties.insert("event_time".to_string(), json!("2024-06-15T08:00:00Z"));
2503        assert!(match_property(&prop, &properties).unwrap());
2504
2505        properties.insert("event_time".to_string(), json!("2024-06-15T12:00:00Z"));
2506        assert!(!match_property(&prop, &properties).unwrap());
2507    }
2508
2509    // ==================== Tests for cohort membership ====================
2510
2511    #[test]
2512    fn test_cohort_membership_in() {
2513        // Create a cohort that matches users with country = US
2514        let mut cohorts = HashMap::new();
2515        cohorts.insert(
2516            "cohort_1".to_string(),
2517            CohortDefinition::new(
2518                "cohort_1".to_string(),
2519                vec![Property {
2520                    key: "country".to_string(),
2521                    value: json!("US"),
2522                    operator: "exact".to_string(),
2523                    property_type: None,
2524                }],
2525            ),
2526        );
2527
2528        // Property filter checking cohort membership
2529        let prop = Property {
2530            key: "$cohort".to_string(),
2531            value: json!("cohort_1"),
2532            operator: "in".to_string(),
2533            property_type: Some("cohort".to_string()),
2534        };
2535
2536        // User with country = US should be in the cohort
2537        let mut properties = HashMap::new();
2538        properties.insert("country".to_string(), json!("US"));
2539
2540        let ctx = EvaluationContext {
2541            cohorts: &cohorts,
2542            flags: &HashMap::new(),
2543            distinct_id: "user-123",
2544            groups: &HashMap::new(),
2545            group_properties: &HashMap::new(),
2546            group_type_mapping: &HashMap::new(),
2547        };
2548        assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
2549
2550        // User with country = UK should NOT be in the cohort
2551        properties.insert("country".to_string(), json!("UK"));
2552        assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
2553    }
2554
2555    #[test]
2556    fn test_cohort_membership_not_in() {
2557        let mut cohorts = HashMap::new();
2558        cohorts.insert(
2559            "cohort_blocked".to_string(),
2560            CohortDefinition::new(
2561                "cohort_blocked".to_string(),
2562                vec![Property {
2563                    key: "status".to_string(),
2564                    value: json!("blocked"),
2565                    operator: "exact".to_string(),
2566                    property_type: None,
2567                }],
2568            ),
2569        );
2570
2571        let prop = Property {
2572            key: "$cohort".to_string(),
2573            value: json!("cohort_blocked"),
2574            operator: "not_in".to_string(),
2575            property_type: Some("cohort".to_string()),
2576        };
2577
2578        let mut properties = HashMap::new();
2579        properties.insert("status".to_string(), json!("active"));
2580
2581        let ctx = EvaluationContext {
2582            cohorts: &cohorts,
2583            flags: &HashMap::new(),
2584            distinct_id: "user-123",
2585            groups: &HashMap::new(),
2586            group_properties: &HashMap::new(),
2587            group_type_mapping: &HashMap::new(),
2588        };
2589        // User with status = active should NOT be in the blocked cohort (so not_in returns true)
2590        assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
2591
2592        // User with status = blocked IS in the cohort (so not_in returns false)
2593        properties.insert("status".to_string(), json!("blocked"));
2594        assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
2595    }
2596
2597    #[test]
2598    fn test_cohort_not_found_returns_inconclusive() {
2599        let cohorts = HashMap::new(); // No cohorts defined
2600
2601        let prop = Property {
2602            key: "$cohort".to_string(),
2603            value: json!("nonexistent_cohort"),
2604            operator: "in".to_string(),
2605            property_type: Some("cohort".to_string()),
2606        };
2607
2608        let properties = HashMap::new();
2609        let ctx = EvaluationContext {
2610            cohorts: &cohorts,
2611            flags: &HashMap::new(),
2612            distinct_id: "user-123",
2613            groups: &HashMap::new(),
2614            group_properties: &HashMap::new(),
2615            group_type_mapping: &HashMap::new(),
2616        };
2617
2618        let result = match_property_with_context(&prop, &properties, &ctx);
2619        assert!(result.is_err());
2620        assert!(result.unwrap_err().message.contains("Cohort"));
2621    }
2622
2623    /// Build an `EvaluationContext` over just a cohort map (no flags/groups),
2624    /// which is all cohort-membership tests need.
2625    fn cohort_ctx(cohorts: &HashMap<String, CohortDefinition>) -> EvaluationContext<'_> {
2626        EvaluationContext {
2627            cohorts,
2628            flags: EMPTY_FLAGS.get_or_init(HashMap::new),
2629            distinct_id: "user-123",
2630            groups: EMPTY_GROUPS.get_or_init(HashMap::new),
2631            group_properties: EMPTY_GROUP_PROPS.get_or_init(HashMap::new),
2632            group_type_mapping: EMPTY_GROUP_MAPPING.get_or_init(HashMap::new),
2633        }
2634    }
2635
2636    static EMPTY_FLAGS: OnceLock<HashMap<String, FeatureFlag>> = OnceLock::new();
2637    static EMPTY_GROUPS: OnceLock<HashMap<String, String>> = OnceLock::new();
2638    static EMPTY_GROUP_PROPS: OnceLock<HashMap<String, HashMap<String, serde_json::Value>>> =
2639        OnceLock::new();
2640    static EMPTY_GROUP_MAPPING: OnceLock<HashMap<String, String>> = OnceLock::new();
2641
2642    /// A cohort whose properties are an `OR` group must match when *either*
2643    /// branch matches — the old flatten-and-AND logic broke this entirely.
2644    #[test]
2645    fn test_cohort_or_group() {
2646        let mut cohorts = HashMap::new();
2647        cohorts.insert(
2648            "cohort_or".to_string(),
2649            CohortDefinition {
2650                id: "cohort_or".to_string(),
2651                properties: json!({
2652                    "type": "OR",
2653                    "values": [
2654                        {"key": "country", "value": "US", "operator": "exact", "type": "person"},
2655                        {"key": "country", "value": "CA", "operator": "exact", "type": "person"},
2656                    ],
2657                }),
2658            },
2659        );
2660
2661        let prop = Property {
2662            key: "$cohort".to_string(),
2663            value: json!("cohort_or"),
2664            operator: "in".to_string(),
2665            property_type: Some("cohort".to_string()),
2666        };
2667
2668        let ctx = cohort_ctx(&cohorts);
2669
2670        // Either branch of the OR is enough.
2671        let mut properties = HashMap::new();
2672        properties.insert("country".to_string(), json!("US"));
2673        assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
2674
2675        properties.insert("country".to_string(), json!("CA"));
2676        assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
2677
2678        // Neither branch matches.
2679        properties.insert("country".to_string(), json!("UK"));
2680        assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
2681    }
2682
2683    /// A nested group inside an `AND` must be evaluated as a whole group, not
2684    /// collapsed to its first property (the old `.next()` bug).
2685    #[test]
2686    fn test_cohort_nested_and_of_or() {
2687        let mut cohorts = HashMap::new();
2688        cohorts.insert(
2689            "cohort_nested".to_string(),
2690            CohortDefinition {
2691                id: "cohort_nested".to_string(),
2692                properties: json!({
2693                    "type": "AND",
2694                    "values": [
2695                        {"key": "plan", "value": "paid", "operator": "exact", "type": "person"},
2696                        {
2697                            "type": "OR",
2698                            "values": [
2699                                {"key": "country", "value": "US", "operator": "exact", "type": "person"},
2700                                {"key": "country", "value": "CA", "operator": "exact", "type": "person"},
2701                            ],
2702                        },
2703                    ],
2704                }),
2705            },
2706        );
2707
2708        let prop = Property {
2709            key: "$cohort".to_string(),
2710            value: json!("cohort_nested"),
2711            operator: "in".to_string(),
2712            property_type: Some("cohort".to_string()),
2713        };
2714
2715        let ctx = cohort_ctx(&cohorts);
2716
2717        // paid + (US or CA) → in cohort.
2718        let mut properties = HashMap::new();
2719        properties.insert("plan".to_string(), json!("paid"));
2720        properties.insert("country".to_string(), json!("CA"));
2721        assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
2722
2723        // paid but country outside the OR → not in cohort. Under the old
2724        // first-property-only logic the OR group was dropped and this matched.
2725        properties.insert("country".to_string(), json!("UK"));
2726        assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
2727
2728        // Wrong plan → the leading AND term fails.
2729        properties.insert("plan".to_string(), json!("free"));
2730        properties.insert("country".to_string(), json!("US"));
2731        assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
2732    }
2733
2734    /// A cohort referenced from inside another cohort's group is resolved
2735    /// recursively, honoring the `negation` flag.
2736    #[test]
2737    fn test_cohort_nested_cohort_reference() {
2738        let mut cohorts = HashMap::new();
2739        cohorts.insert(
2740            "child".to_string(),
2741            CohortDefinition {
2742                id: "child".to_string(),
2743                properties: json!({
2744                    "type": "AND",
2745                    "values": [
2746                        {"key": "country", "value": "US", "operator": "exact", "type": "person"},
2747                    ],
2748                }),
2749            },
2750        );
2751        cohorts.insert(
2752            "parent".to_string(),
2753            CohortDefinition {
2754                id: "parent".to_string(),
2755                properties: json!({
2756                    "type": "AND",
2757                    "values": [
2758                        {"type": "cohort", "value": "child", "negation": false},
2759                    ],
2760                }),
2761            },
2762        );
2763
2764        let prop = Property {
2765            key: "$cohort".to_string(),
2766            value: json!("parent"),
2767            operator: "in".to_string(),
2768            property_type: Some("cohort".to_string()),
2769        };
2770
2771        let ctx = cohort_ctx(&cohorts);
2772
2773        let mut properties = HashMap::new();
2774        properties.insert("country".to_string(), json!("US"));
2775        assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
2776
2777        properties.insert("country".to_string(), json!("UK"));
2778        assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
2779    }
2780
2781    /// A cohort cycle must surface as inconclusive so the flag falls back to
2782    /// server-side evaluation. Recursing until the stack runs out aborts the
2783    /// process, which takes the caller's whole server down.
2784    #[test]
2785    fn test_cyclic_cohort_reference_is_inconclusive() {
2786        let mut cohorts = HashMap::new();
2787        cohorts.insert(
2788            "a".to_string(),
2789            CohortDefinition {
2790                id: "a".to_string(),
2791                properties: json!({
2792                    "type": "AND",
2793                    "values": [{"type": "cohort", "value": "b", "negation": false}],
2794                }),
2795            },
2796        );
2797        cohorts.insert(
2798            "b".to_string(),
2799            CohortDefinition {
2800                id: "b".to_string(),
2801                properties: json!({
2802                    "type": "AND",
2803                    "values": [{"type": "cohort", "value": "a", "negation": false}],
2804                }),
2805            },
2806        );
2807
2808        let prop = Property {
2809            key: "$cohort".to_string(),
2810            value: json!("a"),
2811            operator: "in".to_string(),
2812            property_type: Some("cohort".to_string()),
2813        };
2814        let ctx = cohort_ctx(&cohorts);
2815
2816        let error = match_property_with_context(&prop, &HashMap::new(), &ctx)
2817            .expect_err("a cohort cycle must not resolve locally");
2818        assert!(
2819            error.to_string().contains("cycle"),
2820            "error should name the cycle, got: {}",
2821            error
2822        );
2823    }
2824
2825    /// A cycle guard alone only bounds repeats, not depth. A chain of distinct
2826    /// cohorts is acyclic, so it never trips the cycle check and recurses once
2827    /// per link. Each cohort is its own shallow map entry, so the manifest also
2828    /// clears serde_json's nesting limit on the way in.
2829    #[test]
2830    fn test_deep_acyclic_cohort_chain_is_inconclusive() {
2831        let chain_len = MAX_COHORT_RESOLUTION_DEPTH + 50;
2832        let mut cohorts = HashMap::new();
2833        for link in 0..chain_len {
2834            let properties = if link == chain_len - 1 {
2835                json!({
2836                    "type": "AND",
2837                    "values": [
2838                        {"key": "country", "value": "US", "operator": "exact", "type": "person"},
2839                    ],
2840                })
2841            } else {
2842                json!({
2843                    "type": "AND",
2844                    "values": [{"type": "cohort", "value": (link + 1).to_string()}],
2845                })
2846            };
2847            cohorts.insert(
2848                link.to_string(),
2849                CohortDefinition {
2850                    id: link.to_string(),
2851                    properties,
2852                },
2853            );
2854        }
2855
2856        let prop = Property {
2857            key: "$cohort".to_string(),
2858            value: json!("0"),
2859            operator: "in".to_string(),
2860            property_type: Some("cohort".to_string()),
2861        };
2862        let ctx = cohort_ctx(&cohorts);
2863        let mut properties = HashMap::new();
2864        properties.insert("country".to_string(), json!("US"));
2865
2866        assert!(
2867            match_property_with_context(&prop, &properties, &ctx).is_err(),
2868            "a cohort chain deeper than the limit must not resolve locally"
2869        );
2870    }
2871
2872    /// The depth bound has to leave realistic nesting alone.
2873    #[test]
2874    fn test_cohort_chain_within_the_depth_limit_still_resolves() {
2875        let chain_len = 10;
2876        let mut cohorts = HashMap::new();
2877        for link in 0..chain_len {
2878            let properties = if link == chain_len - 1 {
2879                json!({
2880                    "type": "AND",
2881                    "values": [
2882                        {"key": "country", "value": "US", "operator": "exact", "type": "person"},
2883                    ],
2884                })
2885            } else {
2886                json!({
2887                    "type": "AND",
2888                    "values": [{"type": "cohort", "value": (link + 1).to_string()}],
2889                })
2890            };
2891            cohorts.insert(
2892                link.to_string(),
2893                CohortDefinition {
2894                    id: link.to_string(),
2895                    properties,
2896                },
2897            );
2898        }
2899
2900        let prop = Property {
2901            key: "$cohort".to_string(),
2902            value: json!("0"),
2903            operator: "in".to_string(),
2904            property_type: Some("cohort".to_string()),
2905        };
2906        let ctx = cohort_ctx(&cohorts);
2907        let mut properties = HashMap::new();
2908        properties.insert("country".to_string(), json!("US"));
2909
2910        assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
2911    }
2912
2913    #[test]
2914    fn test_self_referencing_cohort_is_inconclusive() {
2915        let mut cohorts = HashMap::new();
2916        cohorts.insert(
2917            "loop".to_string(),
2918            CohortDefinition {
2919                id: "loop".to_string(),
2920                properties: json!({
2921                    "type": "AND",
2922                    "values": [{"type": "cohort", "value": "loop", "negation": false}],
2923                }),
2924            },
2925        );
2926
2927        let prop = Property {
2928            key: "$cohort".to_string(),
2929            value: json!("loop"),
2930            operator: "in".to_string(),
2931            property_type: Some("cohort".to_string()),
2932        };
2933        let ctx = cohort_ctx(&cohorts);
2934
2935        assert!(match_property_with_context(&prop, &HashMap::new(), &ctx).is_err());
2936    }
2937
2938    /// Referencing the same cohort twice down different branches is a diamond,
2939    /// not a cycle. Tracking visited IDs for the whole evaluation instead of the
2940    /// active path would wrongly reject this.
2941    #[test]
2942    fn test_repeated_cohort_reference_is_not_a_cycle() {
2943        let mut cohorts = HashMap::new();
2944        cohorts.insert(
2945            "shared".to_string(),
2946            CohortDefinition {
2947                id: "shared".to_string(),
2948                properties: json!({
2949                    "type": "AND",
2950                    "values": [
2951                        {"key": "country", "value": "US", "operator": "exact", "type": "person"},
2952                    ],
2953                }),
2954            },
2955        );
2956        for branch in ["left", "right"] {
2957            cohorts.insert(
2958                branch.to_string(),
2959                CohortDefinition {
2960                    id: branch.to_string(),
2961                    properties: json!({
2962                        "type": "AND",
2963                        "values": [{"type": "cohort", "value": "shared", "negation": false}],
2964                    }),
2965                },
2966            );
2967        }
2968        cohorts.insert(
2969            "parent".to_string(),
2970            CohortDefinition {
2971                id: "parent".to_string(),
2972                properties: json!({
2973                    "type": "AND",
2974                    "values": [
2975                        {"type": "cohort", "value": "left", "negation": false},
2976                        {"type": "cohort", "value": "right", "negation": false},
2977                    ],
2978                }),
2979            },
2980        );
2981
2982        let prop = Property {
2983            key: "$cohort".to_string(),
2984            value: json!("parent"),
2985            operator: "in".to_string(),
2986            property_type: Some("cohort".to_string()),
2987        };
2988        let ctx = cohort_ctx(&cohorts);
2989
2990        let mut properties = HashMap::new();
2991        properties.insert("country".to_string(), json!("US"));
2992        assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
2993
2994        properties.insert("country".to_string(), json!("UK"));
2995        assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
2996    }
2997
2998    #[test]
2999    fn test_cohort_leaf_negation() {
3000        let mut cohorts = HashMap::new();
3001        cohorts.insert(
3002            "negated_leaf".to_string(),
3003            CohortDefinition {
3004                id: "negated_leaf".to_string(),
3005                properties: json!({
3006                    "type": "AND",
3007                    "values": [
3008                        {
3009                            "key": "country",
3010                            "value": "US",
3011                            "operator": "exact",
3012                            "type": "person",
3013                            "negation": true
3014                        },
3015                    ],
3016                }),
3017            },
3018        );
3019
3020        let prop = Property {
3021            key: "$cohort".to_string(),
3022            value: json!("negated_leaf"),
3023            operator: "in".to_string(),
3024            property_type: Some("cohort".to_string()),
3025        };
3026        let ctx = cohort_ctx(&cohorts);
3027        let mut properties = HashMap::new();
3028
3029        properties.insert("country".to_string(), json!("US"));
3030        assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
3031
3032        properties.insert("country".to_string(), json!("UK"));
3033        assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
3034    }
3035
3036    #[test]
3037    fn test_missing_nested_cohort_is_not_suppressed() {
3038        let missing_cohort = json!({"type": "cohort", "value": "missing"});
3039        let country_leaf = json!({
3040            "key": "country",
3041            "value": "US",
3042            "operator": "exact",
3043            "type": "person"
3044        });
3045        let mut cohorts = HashMap::new();
3046        cohorts.insert(
3047            "or_parent".to_string(),
3048            CohortDefinition {
3049                id: "or_parent".to_string(),
3050                properties: json!({
3051                    "type": "OR",
3052                    "values": [country_leaf.clone(), missing_cohort.clone()],
3053                }),
3054            },
3055        );
3056        cohorts.insert(
3057            "and_parent".to_string(),
3058            CohortDefinition {
3059                id: "and_parent".to_string(),
3060                properties: json!({
3061                    "type": "AND",
3062                    "values": [country_leaf, missing_cohort],
3063                }),
3064            },
3065        );
3066
3067        let ctx = cohort_ctx(&cohorts);
3068        for (cohort_id, country) in [("or_parent", "US"), ("and_parent", "UK")] {
3069            let prop = Property {
3070                key: "$cohort".to_string(),
3071                value: json!(cohort_id),
3072                operator: "in".to_string(),
3073                property_type: Some("cohort".to_string()),
3074            };
3075            let properties = HashMap::from([("country".to_string(), json!(country))]);
3076
3077            assert!(match_property_with_context(&prop, &properties, &ctx).is_err());
3078        }
3079    }
3080
3081    #[test]
3082    fn test_malformed_cohort_groups_are_inconclusive() {
3083        let mut cohorts = HashMap::new();
3084        cohorts.insert(
3085            "scalar".to_string(),
3086            CohortDefinition {
3087                id: "scalar".to_string(),
3088                properties: json!("invalid"),
3089            },
3090        );
3091        cohorts.insert(
3092            "object_values".to_string(),
3093            CohortDefinition {
3094                id: "object_values".to_string(),
3095                properties: json!({"type": "AND", "values": {}}),
3096            },
3097        );
3098        cohorts.insert(
3099            "missing_values".to_string(),
3100            CohortDefinition {
3101                id: "missing_values".to_string(),
3102                properties: json!({"type": "AND"}),
3103            },
3104        );
3105        cohorts.insert(
3106            "empty_object".to_string(),
3107            CohortDefinition {
3108                id: "empty_object".to_string(),
3109                properties: json!({}),
3110            },
3111        );
3112        cohorts.insert(
3113            "empty_values".to_string(),
3114            CohortDefinition {
3115                id: "empty_values".to_string(),
3116                properties: json!({"type": "AND", "values": []}),
3117            },
3118        );
3119
3120        let ctx = cohort_ctx(&cohorts);
3121        let properties = HashMap::new();
3122        for cohort_id in ["scalar", "object_values", "missing_values"] {
3123            let prop = Property {
3124                key: "$cohort".to_string(),
3125                value: json!(cohort_id),
3126                operator: "in".to_string(),
3127                property_type: Some("cohort".to_string()),
3128            };
3129
3130            assert!(match_property_with_context(&prop, &properties, &ctx).is_err());
3131        }
3132
3133        for cohort_id in ["empty_object", "empty_values"] {
3134            let empty = Property {
3135                key: "$cohort".to_string(),
3136                value: json!(cohort_id),
3137                operator: "in".to_string(),
3138                property_type: Some("cohort".to_string()),
3139            };
3140            assert!(match_property_with_context(&empty, &properties, &ctx).unwrap());
3141        }
3142    }
3143
3144    // ==================== Tests for flag dependencies ====================
3145
3146    #[test]
3147    fn test_flag_dependency_enabled() {
3148        let mut flags = HashMap::new();
3149        flags.insert(
3150            "prerequisite-flag".to_string(),
3151            FeatureFlag {
3152                key: "prerequisite-flag".to_string(),
3153                active: true,
3154                has_experiment: None,
3155                filters: FeatureFlagFilters {
3156                    groups: vec![FeatureFlagCondition {
3157                        properties: vec![],
3158                        rollout_percentage: Some(100.0),
3159                        variant: None,
3160                        aggregation_group_type_index: None,
3161                    }],
3162                    multivariate: None,
3163                    payloads: HashMap::new(),
3164                    aggregation_group_type_index: None,
3165                    early_exit: false,
3166                },
3167            },
3168        );
3169
3170        // Property checking if prerequisite-flag is enabled
3171        let prop = Property {
3172            key: "$feature/prerequisite-flag".to_string(),
3173            value: json!(true),
3174            operator: "exact".to_string(),
3175            property_type: None,
3176        };
3177
3178        let properties = HashMap::new();
3179        let ctx = EvaluationContext {
3180            cohorts: &HashMap::new(),
3181            flags: &flags,
3182            distinct_id: "user-123",
3183            groups: &HashMap::new(),
3184            group_properties: &HashMap::new(),
3185            group_type_mapping: &HashMap::new(),
3186        };
3187
3188        // The prerequisite flag is enabled for user-123, so this should match
3189        assert!(match_property_with_context(&prop, &properties, &ctx).unwrap());
3190    }
3191
3192    #[test]
3193    fn test_flag_dependency_disabled() {
3194        let mut flags = HashMap::new();
3195        flags.insert(
3196            "disabled-flag".to_string(),
3197            FeatureFlag {
3198                key: "disabled-flag".to_string(),
3199                active: false, // Flag is inactive
3200                has_experiment: None,
3201                filters: FeatureFlagFilters {
3202                    groups: vec![],
3203                    multivariate: None,
3204                    payloads: HashMap::new(),
3205                    aggregation_group_type_index: None,
3206                    early_exit: false,
3207                },
3208            },
3209        );
3210
3211        // Property checking if disabled-flag is enabled
3212        let prop = Property {
3213            key: "$feature/disabled-flag".to_string(),
3214            value: json!(true),
3215            operator: "exact".to_string(),
3216            property_type: None,
3217        };
3218
3219        let properties = HashMap::new();
3220        let ctx = EvaluationContext {
3221            cohorts: &HashMap::new(),
3222            flags: &flags,
3223            distinct_id: "user-123",
3224            groups: &HashMap::new(),
3225            group_properties: &HashMap::new(),
3226            group_type_mapping: &HashMap::new(),
3227        };
3228
3229        // The flag is disabled, so checking for true should fail
3230        assert!(!match_property_with_context(&prop, &properties, &ctx).unwrap());
3231    }
3232
3233    #[test]
3234    fn test_flag_dependency_variant_match() {
3235        let mut flags = HashMap::new();
3236        flags.insert(
3237            "ab-test-flag".to_string(),
3238            FeatureFlag {
3239                key: "ab-test-flag".to_string(),
3240                active: true,
3241                has_experiment: None,
3242                filters: FeatureFlagFilters {
3243                    groups: vec![FeatureFlagCondition {
3244                        properties: vec![],
3245                        rollout_percentage: Some(100.0),
3246                        variant: None,
3247                        aggregation_group_type_index: None,
3248                    }],
3249                    multivariate: Some(MultivariateFilter {
3250                        variants: vec![
3251                            MultivariateVariant {
3252                                key: "control".to_string(),
3253                                rollout_percentage: 50.0,
3254                            },
3255                            MultivariateVariant {
3256                                key: "test".to_string(),
3257                                rollout_percentage: 50.0,
3258                            },
3259                        ],
3260                    }),
3261                    payloads: HashMap::new(),
3262                    aggregation_group_type_index: None,
3263                    early_exit: false,
3264                },
3265            },
3266        );
3267
3268        // Check if user is in "control" variant
3269        let prop = Property {
3270            key: "$feature/ab-test-flag".to_string(),
3271            value: json!("control"),
3272            operator: "exact".to_string(),
3273            property_type: None,
3274        };
3275
3276        let properties = HashMap::new();
3277        let ctx = EvaluationContext {
3278            cohorts: &HashMap::new(),
3279            flags: &flags,
3280            distinct_id: "user-gets-control", // This distinct_id should deterministically get "control"
3281            groups: &HashMap::new(),
3282            group_properties: &HashMap::new(),
3283            group_type_mapping: &HashMap::new(),
3284        };
3285
3286        // The result depends on the hash - we just check it doesn't error
3287        let result = match_property_with_context(&prop, &properties, &ctx);
3288        assert!(result.is_ok());
3289    }
3290
3291    #[test]
3292    fn test_flag_dependency_not_found_returns_inconclusive() {
3293        let flags = HashMap::new(); // No flags defined
3294
3295        let prop = Property {
3296            key: "$feature/nonexistent-flag".to_string(),
3297            value: json!(true),
3298            operator: "exact".to_string(),
3299            property_type: None,
3300        };
3301
3302        let properties = HashMap::new();
3303        let ctx = EvaluationContext {
3304            cohorts: &HashMap::new(),
3305            flags: &flags,
3306            distinct_id: "user-123",
3307            groups: &HashMap::new(),
3308            group_properties: &HashMap::new(),
3309            group_type_mapping: &HashMap::new(),
3310        };
3311
3312        let result = match_property_with_context(&prop, &properties, &ctx);
3313        assert!(result.is_err());
3314        assert!(result.unwrap_err().message.contains("Flag"));
3315    }
3316
3317    // ==================== Date parsing edge case tests ====================
3318
3319    #[test]
3320    fn test_parse_relative_date_edge_cases() {
3321        // These test the internal parse_relative_date function indirectly via match_property
3322        let prop = Property {
3323            key: "date".to_string(),
3324            value: json!("placeholder"),
3325            operator: "is_date_before".to_string(),
3326            property_type: None,
3327        };
3328
3329        let mut properties = HashMap::new();
3330        properties.insert("date".to_string(), json!("2024-01-01"));
3331
3332        // Empty string as target date should fail
3333        let empty_prop = Property {
3334            value: json!(""),
3335            ..prop.clone()
3336        };
3337        assert!(match_property(&empty_prop, &properties).is_err());
3338
3339        // Single dash should fail
3340        let dash_prop = Property {
3341            value: json!("-"),
3342            ..prop.clone()
3343        };
3344        assert!(match_property(&dash_prop, &properties).is_err());
3345
3346        // Missing unit (just "-7") should fail
3347        let no_unit_prop = Property {
3348            value: json!("-7"),
3349            ..prop.clone()
3350        };
3351        assert!(match_property(&no_unit_prop, &properties).is_err());
3352
3353        // Missing number (just "-d") should fail
3354        let no_number_prop = Property {
3355            value: json!("-d"),
3356            ..prop.clone()
3357        };
3358        assert!(match_property(&no_number_prop, &properties).is_err());
3359
3360        // Invalid unit should fail
3361        let invalid_unit_prop = Property {
3362            value: json!("-7x"),
3363            ..prop.clone()
3364        };
3365        assert!(match_property(&invalid_unit_prop, &properties).is_err());
3366    }
3367
3368    #[test]
3369    fn test_parse_relative_date_large_values() {
3370        // Very large relative dates should work
3371        let prop = Property {
3372            key: "created_at".to_string(),
3373            value: json!("-1000d"), // ~2.7 years ago
3374            operator: "is_date_before".to_string(),
3375            property_type: None,
3376        };
3377
3378        let mut properties = HashMap::new();
3379        // Date 5 years ago should be before -1000d
3380        let five_years_ago = chrono::Utc::now() - chrono::Duration::days(1825);
3381        properties.insert(
3382            "created_at".to_string(),
3383            json!(five_years_ago.format("%Y-%m-%d").to_string()),
3384        );
3385        assert!(match_property(&prop, &properties).unwrap());
3386    }
3387
3388    // ==================== Tests for invalid regex patterns ====================
3389
3390    #[test]
3391    fn test_regex_with_invalid_pattern_returns_false() {
3392        // Invalid regex pattern (unclosed group)
3393        let prop = Property {
3394            key: "email".to_string(),
3395            value: json!("(unclosed"),
3396            operator: "regex".to_string(),
3397            property_type: None,
3398        };
3399
3400        let mut properties = HashMap::new();
3401        properties.insert("email".to_string(), json!("test@example.com"));
3402
3403        // Invalid regex should return false (not match)
3404        assert!(!match_property(&prop, &properties).unwrap());
3405    }
3406
3407    #[test]
3408    fn test_not_regex_with_invalid_pattern_returns_true() {
3409        // Invalid regex pattern (unclosed group)
3410        let prop = Property {
3411            key: "email".to_string(),
3412            value: json!("(unclosed"),
3413            operator: "not_regex".to_string(),
3414            property_type: None,
3415        };
3416
3417        let mut properties = HashMap::new();
3418        properties.insert("email".to_string(), json!("test@example.com"));
3419
3420        // Invalid regex with not_regex should return true (no match means "not matching")
3421        assert!(match_property(&prop, &properties).unwrap());
3422    }
3423
3424    #[test]
3425    fn test_regex_with_various_invalid_patterns() {
3426        let invalid_patterns = vec![
3427            "(unclosed", // Unclosed group
3428            "[unclosed", // Unclosed bracket
3429            "*invalid",  // Invalid quantifier at start
3430            "(?P<bad",   // Unclosed named group
3431            r"\",        // Trailing backslash
3432        ];
3433
3434        for pattern in invalid_patterns {
3435            let prop = Property {
3436                key: "value".to_string(),
3437                value: json!(pattern),
3438                operator: "regex".to_string(),
3439                property_type: None,
3440            };
3441
3442            let mut properties = HashMap::new();
3443            properties.insert("value".to_string(), json!("test"));
3444
3445            // All invalid patterns should return false for regex
3446            assert!(
3447                !match_property(&prop, &properties).unwrap(),
3448                "Invalid pattern '{}' should return false for regex",
3449                pattern
3450            );
3451
3452            // And true for not_regex
3453            let not_regex_prop = Property {
3454                operator: "not_regex".to_string(),
3455                ..prop
3456            };
3457            assert!(
3458                match_property(&not_regex_prop, &properties).unwrap(),
3459                "Invalid pattern '{}' should return true for not_regex",
3460                pattern
3461            );
3462        }
3463    }
3464
3465    // ==================== Semver parsing tests ====================
3466
3467    #[test]
3468    fn test_parse_semver_basic() {
3469        assert_eq!(parse_semver("1.2.3"), Some((1, 2, 3)));
3470        assert_eq!(parse_semver("0.0.0"), Some((0, 0, 0)));
3471        assert_eq!(parse_semver("10.20.30"), Some((10, 20, 30)));
3472    }
3473
3474    #[test]
3475    fn test_parse_semver_v_prefix() {
3476        assert_eq!(parse_semver("v1.2.3"), Some((1, 2, 3)));
3477        assert_eq!(parse_semver("V1.2.3"), Some((1, 2, 3)));
3478    }
3479
3480    #[test]
3481    fn test_parse_semver_whitespace() {
3482        assert_eq!(parse_semver("  1.2.3  "), Some((1, 2, 3)));
3483        assert_eq!(parse_semver(" v1.2.3 "), Some((1, 2, 3)));
3484    }
3485
3486    #[test]
3487    fn test_parse_semver_prerelease_stripped() {
3488        assert_eq!(parse_semver("1.2.3-alpha"), Some((1, 2, 3)));
3489        assert_eq!(parse_semver("1.2.3-beta.1"), Some((1, 2, 3)));
3490        assert_eq!(parse_semver("1.2.3-rc.1+build.123"), Some((1, 2, 3)));
3491        assert_eq!(parse_semver("1.2.3+build.456"), Some((1, 2, 3)));
3492    }
3493
3494    #[test]
3495    fn test_parse_semver_partial_versions() {
3496        assert_eq!(parse_semver("1.2"), Some((1, 2, 0)));
3497        assert_eq!(parse_semver("1"), Some((1, 0, 0)));
3498        assert_eq!(parse_semver("v1.2"), Some((1, 2, 0)));
3499    }
3500
3501    #[test]
3502    fn test_parse_semver_extra_components_ignored() {
3503        assert_eq!(parse_semver("1.2.3.4"), Some((1, 2, 3)));
3504        assert_eq!(parse_semver("1.2.3.4.5.6"), Some((1, 2, 3)));
3505    }
3506
3507    #[test]
3508    fn test_parse_semver_leading_zeros_rejected() {
3509        // Per semver 2.0.0 §2, numeric identifiers must not include leading zeros.
3510        assert_eq!(parse_semver("01.02.03"), None);
3511        assert_eq!(parse_semver("001.002.003"), None);
3512        assert_eq!(parse_semver("1.07.3"), None);
3513        assert_eq!(parse_semver("1.2.03"), None);
3514        assert_eq!(parse_semver("v01.2.3"), None);
3515
3516        // Literal "0" components remain valid.
3517        assert_eq!(parse_semver("0.1.0"), Some((0, 1, 0)));
3518        assert_eq!(parse_semver("1.0.0"), Some((1, 0, 0)));
3519        assert_eq!(parse_semver("0.0.0"), Some((0, 0, 0)));
3520    }
3521
3522    #[test]
3523    fn test_parse_semver_invalid() {
3524        assert_eq!(parse_semver(""), None);
3525        assert_eq!(parse_semver("   "), None);
3526        assert_eq!(parse_semver("v"), None);
3527        assert_eq!(parse_semver(".1.2.3"), None);
3528        assert_eq!(parse_semver("abc"), None);
3529        assert_eq!(parse_semver("1.abc.3"), None);
3530        assert_eq!(parse_semver("1.2.abc"), None);
3531        assert_eq!(parse_semver("not-a-version"), None);
3532    }
3533
3534    // ==================== Semver eq/neq tests ====================
3535
3536    #[test]
3537    fn test_semver_eq_basic() {
3538        let prop = Property {
3539            key: "version".to_string(),
3540            value: json!("1.2.3"),
3541            operator: "semver_eq".to_string(),
3542            property_type: None,
3543        };
3544
3545        let mut properties = HashMap::new();
3546
3547        properties.insert("version".to_string(), json!("1.2.3"));
3548        assert!(match_property(&prop, &properties).unwrap());
3549
3550        properties.insert("version".to_string(), json!("1.2.4"));
3551        assert!(!match_property(&prop, &properties).unwrap());
3552
3553        properties.insert("version".to_string(), json!("1.3.3"));
3554        assert!(!match_property(&prop, &properties).unwrap());
3555
3556        properties.insert("version".to_string(), json!("2.2.3"));
3557        assert!(!match_property(&prop, &properties).unwrap());
3558    }
3559
3560    #[test]
3561    fn test_semver_eq_with_v_prefix() {
3562        let prop = Property {
3563            key: "version".to_string(),
3564            value: json!("1.2.3"),
3565            operator: "semver_eq".to_string(),
3566            property_type: None,
3567        };
3568
3569        let mut properties = HashMap::new();
3570
3571        // v-prefix on property value
3572        properties.insert("version".to_string(), json!("v1.2.3"));
3573        assert!(match_property(&prop, &properties).unwrap());
3574
3575        // v-prefix on target value
3576        let prop_with_v = Property {
3577            value: json!("v1.2.3"),
3578            ..prop.clone()
3579        };
3580        properties.insert("version".to_string(), json!("1.2.3"));
3581        assert!(match_property(&prop_with_v, &properties).unwrap());
3582    }
3583
3584    #[test]
3585    fn test_semver_eq_prerelease_stripped() {
3586        let prop = Property {
3587            key: "version".to_string(),
3588            value: json!("1.2.3"),
3589            operator: "semver_eq".to_string(),
3590            property_type: None,
3591        };
3592
3593        let mut properties = HashMap::new();
3594
3595        properties.insert("version".to_string(), json!("1.2.3-alpha"));
3596        assert!(match_property(&prop, &properties).unwrap());
3597
3598        properties.insert("version".to_string(), json!("1.2.3-beta.1"));
3599        assert!(match_property(&prop, &properties).unwrap());
3600
3601        properties.insert("version".to_string(), json!("1.2.3+build.456"));
3602        assert!(match_property(&prop, &properties).unwrap());
3603    }
3604
3605    #[test]
3606    fn test_semver_eq_partial_versions() {
3607        let prop = Property {
3608            key: "version".to_string(),
3609            value: json!("1.2.0"),
3610            operator: "semver_eq".to_string(),
3611            property_type: None,
3612        };
3613
3614        let mut properties = HashMap::new();
3615
3616        // "1.2" should equal "1.2.0"
3617        properties.insert("version".to_string(), json!("1.2"));
3618        assert!(match_property(&prop, &properties).unwrap());
3619
3620        // Target as partial version
3621        let partial_prop = Property {
3622            value: json!("1.2"),
3623            ..prop.clone()
3624        };
3625        properties.insert("version".to_string(), json!("1.2.0"));
3626        assert!(match_property(&partial_prop, &properties).unwrap());
3627    }
3628
3629    #[test]
3630    fn test_semver_neq() {
3631        let prop = Property {
3632            key: "version".to_string(),
3633            value: json!("1.2.3"),
3634            operator: "semver_neq".to_string(),
3635            property_type: None,
3636        };
3637
3638        let mut properties = HashMap::new();
3639
3640        properties.insert("version".to_string(), json!("1.2.3"));
3641        assert!(!match_property(&prop, &properties).unwrap());
3642
3643        properties.insert("version".to_string(), json!("1.2.4"));
3644        assert!(match_property(&prop, &properties).unwrap());
3645
3646        properties.insert("version".to_string(), json!("2.0.0"));
3647        assert!(match_property(&prop, &properties).unwrap());
3648    }
3649
3650    // ==================== Semver gt/gte/lt/lte tests ====================
3651
3652    #[test]
3653    fn test_semver_gt() {
3654        let prop = Property {
3655            key: "version".to_string(),
3656            value: json!("1.2.3"),
3657            operator: "semver_gt".to_string(),
3658            property_type: None,
3659        };
3660
3661        let mut properties = HashMap::new();
3662
3663        // Greater versions
3664        properties.insert("version".to_string(), json!("1.2.4"));
3665        assert!(match_property(&prop, &properties).unwrap());
3666
3667        properties.insert("version".to_string(), json!("1.3.0"));
3668        assert!(match_property(&prop, &properties).unwrap());
3669
3670        properties.insert("version".to_string(), json!("2.0.0"));
3671        assert!(match_property(&prop, &properties).unwrap());
3672
3673        // Equal version
3674        properties.insert("version".to_string(), json!("1.2.3"));
3675        assert!(!match_property(&prop, &properties).unwrap());
3676
3677        // Lesser versions
3678        properties.insert("version".to_string(), json!("1.2.2"));
3679        assert!(!match_property(&prop, &properties).unwrap());
3680
3681        properties.insert("version".to_string(), json!("1.1.9"));
3682        assert!(!match_property(&prop, &properties).unwrap());
3683
3684        properties.insert("version".to_string(), json!("0.9.9"));
3685        assert!(!match_property(&prop, &properties).unwrap());
3686    }
3687
3688    #[test]
3689    fn test_semver_gte() {
3690        let prop = Property {
3691            key: "version".to_string(),
3692            value: json!("1.2.3"),
3693            operator: "semver_gte".to_string(),
3694            property_type: None,
3695        };
3696
3697        let mut properties = HashMap::new();
3698
3699        // Greater versions
3700        properties.insert("version".to_string(), json!("1.2.4"));
3701        assert!(match_property(&prop, &properties).unwrap());
3702
3703        properties.insert("version".to_string(), json!("2.0.0"));
3704        assert!(match_property(&prop, &properties).unwrap());
3705
3706        // Equal version
3707        properties.insert("version".to_string(), json!("1.2.3"));
3708        assert!(match_property(&prop, &properties).unwrap());
3709
3710        // Lesser versions
3711        properties.insert("version".to_string(), json!("1.2.2"));
3712        assert!(!match_property(&prop, &properties).unwrap());
3713
3714        properties.insert("version".to_string(), json!("0.9.9"));
3715        assert!(!match_property(&prop, &properties).unwrap());
3716    }
3717
3718    #[test]
3719    fn test_semver_lt() {
3720        let prop = Property {
3721            key: "version".to_string(),
3722            value: json!("1.2.3"),
3723            operator: "semver_lt".to_string(),
3724            property_type: None,
3725        };
3726
3727        let mut properties = HashMap::new();
3728
3729        // Lesser versions
3730        properties.insert("version".to_string(), json!("1.2.2"));
3731        assert!(match_property(&prop, &properties).unwrap());
3732
3733        properties.insert("version".to_string(), json!("1.1.9"));
3734        assert!(match_property(&prop, &properties).unwrap());
3735
3736        properties.insert("version".to_string(), json!("0.9.9"));
3737        assert!(match_property(&prop, &properties).unwrap());
3738
3739        // Equal version
3740        properties.insert("version".to_string(), json!("1.2.3"));
3741        assert!(!match_property(&prop, &properties).unwrap());
3742
3743        // Greater versions
3744        properties.insert("version".to_string(), json!("1.2.4"));
3745        assert!(!match_property(&prop, &properties).unwrap());
3746
3747        properties.insert("version".to_string(), json!("2.0.0"));
3748        assert!(!match_property(&prop, &properties).unwrap());
3749    }
3750
3751    #[test]
3752    fn test_semver_lte() {
3753        let prop = Property {
3754            key: "version".to_string(),
3755            value: json!("1.2.3"),
3756            operator: "semver_lte".to_string(),
3757            property_type: None,
3758        };
3759
3760        let mut properties = HashMap::new();
3761
3762        // Lesser versions
3763        properties.insert("version".to_string(), json!("1.2.2"));
3764        assert!(match_property(&prop, &properties).unwrap());
3765
3766        properties.insert("version".to_string(), json!("0.9.9"));
3767        assert!(match_property(&prop, &properties).unwrap());
3768
3769        // Equal version
3770        properties.insert("version".to_string(), json!("1.2.3"));
3771        assert!(match_property(&prop, &properties).unwrap());
3772
3773        // Greater versions
3774        properties.insert("version".to_string(), json!("1.2.4"));
3775        assert!(!match_property(&prop, &properties).unwrap());
3776
3777        properties.insert("version".to_string(), json!("2.0.0"));
3778        assert!(!match_property(&prop, &properties).unwrap());
3779    }
3780
3781    // ==================== Semver tilde tests ====================
3782
3783    #[test]
3784    fn test_semver_tilde_basic() {
3785        // ~1.2.3 means >=1.2.3 <1.3.0
3786        let prop = Property {
3787            key: "version".to_string(),
3788            value: json!("1.2.3"),
3789            operator: "semver_tilde".to_string(),
3790            property_type: None,
3791        };
3792
3793        let mut properties = HashMap::new();
3794
3795        // Exact match
3796        properties.insert("version".to_string(), json!("1.2.3"));
3797        assert!(match_property(&prop, &properties).unwrap());
3798
3799        // Within range
3800        properties.insert("version".to_string(), json!("1.2.4"));
3801        assert!(match_property(&prop, &properties).unwrap());
3802
3803        properties.insert("version".to_string(), json!("1.2.99"));
3804        assert!(match_property(&prop, &properties).unwrap());
3805
3806        // At upper bound (excluded)
3807        properties.insert("version".to_string(), json!("1.3.0"));
3808        assert!(!match_property(&prop, &properties).unwrap());
3809
3810        // Above upper bound
3811        properties.insert("version".to_string(), json!("1.3.1"));
3812        assert!(!match_property(&prop, &properties).unwrap());
3813
3814        properties.insert("version".to_string(), json!("2.0.0"));
3815        assert!(!match_property(&prop, &properties).unwrap());
3816
3817        // Below lower bound
3818        properties.insert("version".to_string(), json!("1.2.2"));
3819        assert!(!match_property(&prop, &properties).unwrap());
3820
3821        properties.insert("version".to_string(), json!("1.1.9"));
3822        assert!(!match_property(&prop, &properties).unwrap());
3823    }
3824
3825    #[test]
3826    fn test_semver_tilde_zero_versions() {
3827        // ~0.2.3 means >=0.2.3 <0.3.0
3828        let prop = Property {
3829            key: "version".to_string(),
3830            value: json!("0.2.3"),
3831            operator: "semver_tilde".to_string(),
3832            property_type: None,
3833        };
3834
3835        let mut properties = HashMap::new();
3836
3837        properties.insert("version".to_string(), json!("0.2.3"));
3838        assert!(match_property(&prop, &properties).unwrap());
3839
3840        properties.insert("version".to_string(), json!("0.2.9"));
3841        assert!(match_property(&prop, &properties).unwrap());
3842
3843        properties.insert("version".to_string(), json!("0.3.0"));
3844        assert!(!match_property(&prop, &properties).unwrap());
3845
3846        properties.insert("version".to_string(), json!("0.2.2"));
3847        assert!(!match_property(&prop, &properties).unwrap());
3848    }
3849
3850    // ==================== Semver caret tests ====================
3851
3852    #[test]
3853    fn test_semver_caret_major_nonzero() {
3854        // ^1.2.3 means >=1.2.3 <2.0.0
3855        let prop = Property {
3856            key: "version".to_string(),
3857            value: json!("1.2.3"),
3858            operator: "semver_caret".to_string(),
3859            property_type: None,
3860        };
3861
3862        let mut properties = HashMap::new();
3863
3864        // Exact match
3865        properties.insert("version".to_string(), json!("1.2.3"));
3866        assert!(match_property(&prop, &properties).unwrap());
3867
3868        // Within range
3869        properties.insert("version".to_string(), json!("1.2.4"));
3870        assert!(match_property(&prop, &properties).unwrap());
3871
3872        properties.insert("version".to_string(), json!("1.3.0"));
3873        assert!(match_property(&prop, &properties).unwrap());
3874
3875        properties.insert("version".to_string(), json!("1.99.99"));
3876        assert!(match_property(&prop, &properties).unwrap());
3877
3878        // At upper bound (excluded)
3879        properties.insert("version".to_string(), json!("2.0.0"));
3880        assert!(!match_property(&prop, &properties).unwrap());
3881
3882        // Above upper bound
3883        properties.insert("version".to_string(), json!("2.0.1"));
3884        assert!(!match_property(&prop, &properties).unwrap());
3885
3886        // Below lower bound
3887        properties.insert("version".to_string(), json!("1.2.2"));
3888        assert!(!match_property(&prop, &properties).unwrap());
3889
3890        properties.insert("version".to_string(), json!("0.9.9"));
3891        assert!(!match_property(&prop, &properties).unwrap());
3892    }
3893
3894    #[test]
3895    fn test_semver_caret_major_zero_minor_nonzero() {
3896        // ^0.2.3 means >=0.2.3 <0.3.0
3897        let prop = Property {
3898            key: "version".to_string(),
3899            value: json!("0.2.3"),
3900            operator: "semver_caret".to_string(),
3901            property_type: None,
3902        };
3903
3904        let mut properties = HashMap::new();
3905
3906        // Exact match
3907        properties.insert("version".to_string(), json!("0.2.3"));
3908        assert!(match_property(&prop, &properties).unwrap());
3909
3910        // Within range
3911        properties.insert("version".to_string(), json!("0.2.4"));
3912        assert!(match_property(&prop, &properties).unwrap());
3913
3914        properties.insert("version".to_string(), json!("0.2.99"));
3915        assert!(match_property(&prop, &properties).unwrap());
3916
3917        // At upper bound (excluded)
3918        properties.insert("version".to_string(), json!("0.3.0"));
3919        assert!(!match_property(&prop, &properties).unwrap());
3920
3921        // Above upper bound
3922        properties.insert("version".to_string(), json!("0.3.1"));
3923        assert!(!match_property(&prop, &properties).unwrap());
3924
3925        properties.insert("version".to_string(), json!("1.0.0"));
3926        assert!(!match_property(&prop, &properties).unwrap());
3927
3928        // Below lower bound
3929        properties.insert("version".to_string(), json!("0.2.2"));
3930        assert!(!match_property(&prop, &properties).unwrap());
3931
3932        properties.insert("version".to_string(), json!("0.1.9"));
3933        assert!(!match_property(&prop, &properties).unwrap());
3934    }
3935
3936    #[test]
3937    fn test_semver_caret_major_zero_minor_zero() {
3938        // ^0.0.3 means >=0.0.3 <0.0.4
3939        let prop = Property {
3940            key: "version".to_string(),
3941            value: json!("0.0.3"),
3942            operator: "semver_caret".to_string(),
3943            property_type: None,
3944        };
3945
3946        let mut properties = HashMap::new();
3947
3948        // Exact match
3949        properties.insert("version".to_string(), json!("0.0.3"));
3950        assert!(match_property(&prop, &properties).unwrap());
3951
3952        // At upper bound (excluded)
3953        properties.insert("version".to_string(), json!("0.0.4"));
3954        assert!(!match_property(&prop, &properties).unwrap());
3955
3956        // Above upper bound
3957        properties.insert("version".to_string(), json!("0.0.5"));
3958        assert!(!match_property(&prop, &properties).unwrap());
3959
3960        properties.insert("version".to_string(), json!("0.1.0"));
3961        assert!(!match_property(&prop, &properties).unwrap());
3962
3963        // Below lower bound
3964        properties.insert("version".to_string(), json!("0.0.2"));
3965        assert!(!match_property(&prop, &properties).unwrap());
3966    }
3967
3968    // ==================== Semver wildcard tests ====================
3969
3970    #[test]
3971    fn test_semver_wildcard_major() {
3972        // 1.* means >=1.0.0 <2.0.0
3973        let prop = Property {
3974            key: "version".to_string(),
3975            value: json!("1.*"),
3976            operator: "semver_wildcard".to_string(),
3977            property_type: None,
3978        };
3979
3980        let mut properties = HashMap::new();
3981
3982        // At lower bound
3983        properties.insert("version".to_string(), json!("1.0.0"));
3984        assert!(match_property(&prop, &properties).unwrap());
3985
3986        // Within range
3987        properties.insert("version".to_string(), json!("1.2.3"));
3988        assert!(match_property(&prop, &properties).unwrap());
3989
3990        properties.insert("version".to_string(), json!("1.99.99"));
3991        assert!(match_property(&prop, &properties).unwrap());
3992
3993        // At upper bound (excluded)
3994        properties.insert("version".to_string(), json!("2.0.0"));
3995        assert!(!match_property(&prop, &properties).unwrap());
3996
3997        // Above upper bound
3998        properties.insert("version".to_string(), json!("2.0.1"));
3999        assert!(!match_property(&prop, &properties).unwrap());
4000
4001        // Below lower bound
4002        properties.insert("version".to_string(), json!("0.9.9"));
4003        assert!(!match_property(&prop, &properties).unwrap());
4004    }
4005
4006    #[test]
4007    fn test_semver_wildcard_minor() {
4008        // 1.2.* means >=1.2.0 <1.3.0
4009        let prop = Property {
4010            key: "version".to_string(),
4011            value: json!("1.2.*"),
4012            operator: "semver_wildcard".to_string(),
4013            property_type: None,
4014        };
4015
4016        let mut properties = HashMap::new();
4017
4018        // At lower bound
4019        properties.insert("version".to_string(), json!("1.2.0"));
4020        assert!(match_property(&prop, &properties).unwrap());
4021
4022        // Within range
4023        properties.insert("version".to_string(), json!("1.2.3"));
4024        assert!(match_property(&prop, &properties).unwrap());
4025
4026        properties.insert("version".to_string(), json!("1.2.99"));
4027        assert!(match_property(&prop, &properties).unwrap());
4028
4029        // At upper bound (excluded)
4030        properties.insert("version".to_string(), json!("1.3.0"));
4031        assert!(!match_property(&prop, &properties).unwrap());
4032
4033        // Above upper bound
4034        properties.insert("version".to_string(), json!("1.3.1"));
4035        assert!(!match_property(&prop, &properties).unwrap());
4036
4037        properties.insert("version".to_string(), json!("2.0.0"));
4038        assert!(!match_property(&prop, &properties).unwrap());
4039
4040        // Below lower bound
4041        properties.insert("version".to_string(), json!("1.1.9"));
4042        assert!(!match_property(&prop, &properties).unwrap());
4043    }
4044
4045    #[test]
4046    fn test_semver_wildcard_zero() {
4047        // 0.* means >=0.0.0 <1.0.0
4048        let prop = Property {
4049            key: "version".to_string(),
4050            value: json!("0.*"),
4051            operator: "semver_wildcard".to_string(),
4052            property_type: None,
4053        };
4054
4055        let mut properties = HashMap::new();
4056
4057        properties.insert("version".to_string(), json!("0.0.0"));
4058        assert!(match_property(&prop, &properties).unwrap());
4059
4060        properties.insert("version".to_string(), json!("0.99.99"));
4061        assert!(match_property(&prop, &properties).unwrap());
4062
4063        properties.insert("version".to_string(), json!("1.0.0"));
4064        assert!(!match_property(&prop, &properties).unwrap());
4065    }
4066
4067    // ==================== Semver error handling tests ====================
4068
4069    #[test]
4070    fn test_semver_invalid_property_value() {
4071        let prop = Property {
4072            key: "version".to_string(),
4073            value: json!("1.2.3"),
4074            operator: "semver_eq".to_string(),
4075            property_type: None,
4076        };
4077
4078        let mut properties = HashMap::new();
4079
4080        // Invalid semver strings
4081        properties.insert("version".to_string(), json!("not-a-version"));
4082        assert!(match_property(&prop, &properties).is_err());
4083
4084        properties.insert("version".to_string(), json!(""));
4085        assert!(match_property(&prop, &properties).is_err());
4086
4087        properties.insert("version".to_string(), json!(".1.2.3"));
4088        assert!(match_property(&prop, &properties).is_err());
4089
4090        properties.insert("version".to_string(), json!("abc.def.ghi"));
4091        assert!(match_property(&prop, &properties).is_err());
4092    }
4093
4094    #[test]
4095    fn test_semver_invalid_target_value() {
4096        let mut properties = HashMap::new();
4097        properties.insert("version".to_string(), json!("1.2.3"));
4098
4099        // Invalid target semver
4100        let prop = Property {
4101            key: "version".to_string(),
4102            value: json!("not-valid"),
4103            operator: "semver_eq".to_string(),
4104            property_type: None,
4105        };
4106        assert!(match_property(&prop, &properties).is_err());
4107
4108        let prop = Property {
4109            key: "version".to_string(),
4110            value: json!(""),
4111            operator: "semver_gt".to_string(),
4112            property_type: None,
4113        };
4114        assert!(match_property(&prop, &properties).is_err());
4115    }
4116
4117    #[test]
4118    fn test_semver_invalid_wildcard_pattern() {
4119        let mut properties = HashMap::new();
4120        properties.insert("version".to_string(), json!("1.2.3"));
4121
4122        // Invalid wildcard patterns
4123        let invalid_patterns = vec![
4124            "*",       // Just wildcard
4125            "*.2.3",   // Wildcard in wrong position
4126            "1.*.3",   // Wildcard in wrong position
4127            "1.2.3.*", // Too many parts
4128            "abc.*",   // Non-numeric major
4129        ];
4130
4131        for pattern in invalid_patterns {
4132            let prop = Property {
4133                key: "version".to_string(),
4134                value: json!(pattern),
4135                operator: "semver_wildcard".to_string(),
4136                property_type: None,
4137            };
4138            assert!(
4139                match_property(&prop, &properties).is_err(),
4140                "Pattern '{}' should be invalid",
4141                pattern
4142            );
4143        }
4144    }
4145
4146    #[test]
4147    fn test_semver_missing_property() {
4148        let prop = Property {
4149            key: "version".to_string(),
4150            value: json!("1.2.3"),
4151            operator: "semver_eq".to_string(),
4152            property_type: None,
4153        };
4154
4155        let properties = HashMap::new(); // Empty properties
4156        assert!(match_property(&prop, &properties).is_err());
4157    }
4158
4159    #[test]
4160    fn test_semver_null_property_value() {
4161        let prop = Property {
4162            key: "version".to_string(),
4163            value: json!("1.2.3"),
4164            operator: "semver_eq".to_string(),
4165            property_type: None,
4166        };
4167
4168        let mut properties = HashMap::new();
4169        properties.insert("version".to_string(), json!(null));
4170
4171        // null converts to "null" string which is not a valid semver
4172        assert!(match_property(&prop, &properties).is_err());
4173    }
4174
4175    #[test]
4176    fn test_semver_numeric_property_value() {
4177        // When property value is a number, it gets converted to string
4178        let prop = Property {
4179            key: "version".to_string(),
4180            value: json!("1.0.0"),
4181            operator: "semver_eq".to_string(),
4182            property_type: None,
4183        };
4184
4185        let mut properties = HashMap::new();
4186        // Number 1 becomes "1" which parses as (1, 0, 0)
4187        properties.insert("version".to_string(), json!(1));
4188        assert!(match_property(&prop, &properties).unwrap());
4189    }
4190
4191    // ==================== Semver edge cases ====================
4192
4193    #[test]
4194    fn test_semver_four_part_versions() {
4195        let prop = Property {
4196            key: "version".to_string(),
4197            value: json!("1.2.3.4"),
4198            operator: "semver_eq".to_string(),
4199            property_type: None,
4200        };
4201
4202        let mut properties = HashMap::new();
4203
4204        // 1.2.3.4 should equal 1.2.3 (extra parts ignored)
4205        properties.insert("version".to_string(), json!("1.2.3"));
4206        assert!(match_property(&prop, &properties).unwrap());
4207
4208        properties.insert("version".to_string(), json!("1.2.3.4"));
4209        assert!(match_property(&prop, &properties).unwrap());
4210
4211        properties.insert("version".to_string(), json!("1.2.3.999"));
4212        assert!(match_property(&prop, &properties).unwrap());
4213    }
4214
4215    #[test]
4216    fn test_semver_large_version_numbers() {
4217        let prop = Property {
4218            key: "version".to_string(),
4219            value: json!("1000.2000.3000"),
4220            operator: "semver_eq".to_string(),
4221            property_type: None,
4222        };
4223
4224        let mut properties = HashMap::new();
4225        properties.insert("version".to_string(), json!("1000.2000.3000"));
4226        assert!(match_property(&prop, &properties).unwrap());
4227    }
4228
4229    #[test]
4230    fn test_semver_comparison_ordering() {
4231        // Test that version ordering is correct across major/minor/patch
4232        let cases = vec![
4233            ("0.0.1", "0.0.2", "semver_lt", true),
4234            ("0.1.0", "0.0.99", "semver_gt", true),
4235            ("1.0.0", "0.99.99", "semver_gt", true),
4236            ("1.0.0", "1.0.0", "semver_eq", true),
4237            ("2.0.0", "10.0.0", "semver_lt", true), // Numeric, not string comparison
4238            ("9.0.0", "10.0.0", "semver_lt", true), // Numeric, not string comparison
4239            ("1.9.0", "1.10.0", "semver_lt", true), // Numeric, not string comparison
4240            ("1.2.9", "1.2.10", "semver_lt", true), // Numeric, not string comparison
4241        ];
4242
4243        for (prop_val, target_val, op, expected) in cases {
4244            let prop = Property {
4245                key: "version".to_string(),
4246                value: json!(target_val),
4247                operator: op.to_string(),
4248                property_type: None,
4249            };
4250
4251            let mut properties = HashMap::new();
4252            properties.insert("version".to_string(), json!(prop_val));
4253
4254            assert_eq!(
4255                match_property(&prop, &properties).unwrap(),
4256                expected,
4257                "{} {} {} should be {}",
4258                prop_val,
4259                op,
4260                target_val,
4261                expected
4262            );
4263        }
4264    }
4265
4266    #[test]
4267    fn test_match_property_semver_rejects_leading_zeros() {
4268        // Per semver 2.0.0 §2, numeric identifiers must not include leading zeros.
4269        // Both property (override) values and target (flag) values should fail to
4270        // parse, surfacing InconclusiveMatchError so the condition does not match.
4271
4272        let bad_versions = ["1.07.3", "01.02.03", "1.2.03", "v01.2.3", "001.0.0"];
4273
4274        // Override values are rejected across semver_eq.
4275        for bad in bad_versions {
4276            let prop = Property {
4277                key: "version".to_string(),
4278                value: json!("1.2.3"),
4279                operator: "semver_eq".to_string(),
4280                property_type: None,
4281            };
4282            let mut properties = HashMap::new();
4283            properties.insert("version".to_string(), json!(bad));
4284            assert!(
4285                match_property(&prop, &properties).is_err(),
4286                "override '{}' should be rejected",
4287                bad
4288            );
4289        }
4290
4291        // Literal "0" components still work for semver_eq.
4292        for good in ["0.1.0", "1.0.0", "0.0.0"] {
4293            let prop = Property {
4294                key: "version".to_string(),
4295                value: json!(good),
4296                operator: "semver_eq".to_string(),
4297                property_type: None,
4298            };
4299            let mut properties = HashMap::new();
4300            properties.insert("version".to_string(), json!(good));
4301            assert!(
4302                match_property(&prop, &properties).unwrap(),
4303                "'{}' should parse and match itself",
4304                good
4305            );
4306        }
4307
4308        // Flag (target) values are rejected across the remaining operators.
4309        let mut properties = HashMap::new();
4310        properties.insert("version".to_string(), json!("1.2.3"));
4311
4312        for op in ["semver_gt", "semver_caret", "semver_tilde"] {
4313            for bad in bad_versions {
4314                let prop = Property {
4315                    key: "version".to_string(),
4316                    value: json!(bad),
4317                    operator: op.to_string(),
4318                    property_type: None,
4319                };
4320                assert!(
4321                    match_property(&prop, &properties).is_err(),
4322                    "target '{}' for {} should be rejected",
4323                    bad,
4324                    op
4325                );
4326            }
4327        }
4328
4329        // Wildcard patterns with leading-zero numeric components are rejected.
4330        for bad_pattern in ["01.*", "1.07.*", "v01.2.*"] {
4331            let prop = Property {
4332                key: "version".to_string(),
4333                value: json!(bad_pattern),
4334                operator: "semver_wildcard".to_string(),
4335                property_type: None,
4336            };
4337            assert!(
4338                match_property(&prop, &properties).is_err(),
4339                "wildcard target '{}' should be rejected",
4340                bad_pattern
4341            );
4342        }
4343    }
4344
4345    // ==================== Tests for early_exit ====================
4346
4347    /// Build a two-group flag where the first group always lands
4348    /// out-of-rollout-bound (no property filters, 0% rollout) and the second
4349    /// group always matches (no property filters, 100% rollout). Without
4350    /// `early_exit`, evaluation falls through to the matching second group.
4351    fn early_exit_flag(early_exit: bool) -> FeatureFlag {
4352        FeatureFlag {
4353            key: "early-exit-flag".to_string(),
4354            active: true,
4355            has_experiment: None,
4356            filters: FeatureFlagFilters {
4357                groups: vec![
4358                    // Group 1: matches on properties (none) but rollout excludes
4359                    // everyone -> OUT_OF_ROLLOUT_BOUND.
4360                    FeatureFlagCondition {
4361                        properties: vec![],
4362                        rollout_percentage: Some(0.0),
4363                        variant: None,
4364                        aggregation_group_type_index: None,
4365                    },
4366                    // Group 2: would match everyone -> MATCH.
4367                    FeatureFlagCondition {
4368                        properties: vec![],
4369                        rollout_percentage: Some(100.0),
4370                        variant: None,
4371                        aggregation_group_type_index: None,
4372                    },
4373                ],
4374                multivariate: None,
4375                payloads: HashMap::new(),
4376                aggregation_group_type_index: None,
4377                early_exit,
4378            },
4379        }
4380    }
4381
4382    macro_rules! test_early_exit {
4383        ($name:ident, $early_exit:expr, $expected:expr) => {
4384            #[test]
4385            fn $name() {
4386                let flag = early_exit_flag($early_exit);
4387                let result = match_feature_flag(
4388                    &flag,
4389                    "user-123",
4390                    &HashMap::new(),
4391                    &HashMap::new(),
4392                    &HashMap::new(),
4393                    &HashMap::new(),
4394                )
4395                .unwrap();
4396                assert_eq!(result, $expected);
4397            }
4398        };
4399    }
4400
4401    test_early_exit!(
4402        test_early_exit_enabled_returns_false_without_evaluating_later_group,
4403        true,
4404        FlagValue::Boolean(false)
4405    );
4406    test_early_exit!(
4407        test_early_exit_unset_falls_through_to_matching_group,
4408        false,
4409        FlagValue::Boolean(true)
4410    );
4411
4412    #[test]
4413    fn test_early_exit_default_is_false_from_json() {
4414        // A flag definition that omits `early_exit` must deserialize to false
4415        // and preserve the legacy fall-through behavior.
4416        let flag: FeatureFlag = serde_json::from_value(json!({
4417            "key": "early-exit-flag",
4418            "active": true,
4419            "filters": {
4420                "groups": [
4421                    { "properties": [], "rollout_percentage": 0.0, "variant": null },
4422                    { "properties": [], "rollout_percentage": 100.0, "variant": null }
4423                ]
4424            }
4425        }))
4426        .unwrap();
4427        assert!(!flag.filters.early_exit);
4428        let result = match_feature_flag(
4429            &flag,
4430            "user-123",
4431            &HashMap::new(),
4432            &HashMap::new(),
4433            &HashMap::new(),
4434            &HashMap::new(),
4435        )
4436        .unwrap();
4437        assert_eq!(result, FlagValue::Boolean(true));
4438    }
4439
4440    #[test]
4441    fn test_early_exit_explicit_false_falls_through() {
4442        let flag: FeatureFlag = serde_json::from_value(json!({
4443            "key": "early-exit-flag",
4444            "active": true,
4445            "filters": {
4446                "early_exit": false,
4447                "groups": [
4448                    { "properties": [], "rollout_percentage": 0.0, "variant": null },
4449                    { "properties": [], "rollout_percentage": 100.0, "variant": null }
4450                ]
4451            }
4452        }))
4453        .unwrap();
4454        assert!(!flag.filters.early_exit);
4455        let result = match_feature_flag(
4456            &flag,
4457            "user-123",
4458            &HashMap::new(),
4459            &HashMap::new(),
4460            &HashMap::new(),
4461            &HashMap::new(),
4462        )
4463        .unwrap();
4464        assert_eq!(result, FlagValue::Boolean(true));
4465    }
4466
4467    #[test]
4468    fn test_early_exit_property_mismatch_does_not_short_circuit() {
4469        // First group fails on a property filter (NO_MATCH), not rollout, so
4470        // even with early_exit enabled we must fall through to the matching
4471        // second group.
4472        let flag = FeatureFlag {
4473            key: "early-exit-flag".to_string(),
4474            active: true,
4475            has_experiment: None,
4476            filters: FeatureFlagFilters {
4477                groups: vec![
4478                    FeatureFlagCondition {
4479                        properties: vec![Property {
4480                            key: "country".to_string(),
4481                            value: json!("US"),
4482                            operator: "exact".to_string(),
4483                            property_type: None,
4484                        }],
4485                        rollout_percentage: Some(100.0),
4486                        variant: None,
4487                        aggregation_group_type_index: None,
4488                    },
4489                    FeatureFlagCondition {
4490                        properties: vec![],
4491                        rollout_percentage: Some(100.0),
4492                        variant: None,
4493                        aggregation_group_type_index: None,
4494                    },
4495                ],
4496                multivariate: None,
4497                payloads: HashMap::new(),
4498                aggregation_group_type_index: None,
4499                early_exit: true,
4500            },
4501        };
4502
4503        let mut properties = HashMap::new();
4504        properties.insert("country".to_string(), json!("UK")); // does not match "US"
4505
4506        let result = match_feature_flag(
4507            &flag,
4508            "user-123",
4509            &properties,
4510            &HashMap::new(),
4511            &HashMap::new(),
4512            &HashMap::new(),
4513        )
4514        .unwrap();
4515        // Property mismatch must not trigger early-exit; second group matches.
4516        assert_eq!(result, FlagValue::Boolean(true));
4517    }
4518
4519    macro_rules! test_early_exit_with_context {
4520        ($name:ident, $early_exit:expr, $expected:expr) => {
4521            #[test]
4522            fn $name() {
4523                let flag = early_exit_flag($early_exit);
4524                let ctx = EvaluationContext {
4525                    cohorts: &HashMap::new(),
4526                    flags: &HashMap::new(),
4527                    distinct_id: "user-123",
4528                    groups: &HashMap::new(),
4529                    group_properties: &HashMap::new(),
4530                    group_type_mapping: &HashMap::new(),
4531                };
4532                let result = match_feature_flag_with_context(&flag, &HashMap::new(), &ctx).unwrap();
4533                assert_eq!(result, $expected);
4534            }
4535        };
4536    }
4537
4538    test_early_exit_with_context!(
4539        test_early_exit_enabled_short_circuits_with_context,
4540        true,
4541        FlagValue::Boolean(false)
4542    );
4543    test_early_exit_with_context!(
4544        test_early_exit_unset_falls_through_with_context,
4545        false,
4546        FlagValue::Boolean(true)
4547    );
4548
4549    #[test]
4550    fn test_early_exit_does_not_short_circuit_when_prior_group_inconclusive() {
4551        // Group 1: group-targeted (aggregation_group_type_index = 0). The
4552        // group_type_mapping resolves "0" → "company" and groups supplies the
4553        // company key, but group_properties has no entry for "company" →
4554        // ConditionTarget::Inconclusive → is_inconclusive = true.
4555        // Group 2: person-targeted, rollout 0% → OutOfRolloutBound.
4556        // With early_exit = true, the !is_inconclusive guard must prevent
4557        // short-circuiting, and the overall result must be InconclusiveMatchError.
4558        let flag = FeatureFlag {
4559            key: "early-exit-flag".to_string(),
4560            active: true,
4561            has_experiment: None,
4562            filters: FeatureFlagFilters {
4563                groups: vec![
4564                    FeatureFlagCondition {
4565                        properties: vec![],
4566                        rollout_percentage: Some(100.0),
4567                        variant: None,
4568                        aggregation_group_type_index: Some(0),
4569                    },
4570                    FeatureFlagCondition {
4571                        properties: vec![],
4572                        rollout_percentage: Some(0.0),
4573                        variant: None,
4574                        aggregation_group_type_index: None,
4575                    },
4576                ],
4577                multivariate: None,
4578                payloads: HashMap::new(),
4579                aggregation_group_type_index: None,
4580                early_exit: true,
4581            },
4582        };
4583
4584        let mut group_type_mapping = HashMap::new();
4585        group_type_mapping.insert("0".to_string(), "company".to_string());
4586
4587        let mut groups = HashMap::new();
4588        groups.insert("company".to_string(), "acme".to_string());
4589
4590        // group_properties intentionally omitted for "company" → Inconclusive
4591        let result = match_feature_flag(
4592            &flag,
4593            "user-123",
4594            &HashMap::new(),
4595            &groups,
4596            &HashMap::new(), // empty group_properties
4597            &group_type_mapping,
4598        );
4599        assert!(
4600            result.is_err(),
4601            "expected InconclusiveMatchError, got {:?}",
4602            result
4603        );
4604    }
4605}