Skip to main content

posthog_rs/
feature_flags.rs

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