Skip to main content

posthog_rs/
feature_flags.rs

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