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