Skip to main content

posthog_rs/
feature_flags.rs

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