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