Skip to main content

sentry_options/
features.rs

1//! Feature flag evaluation.
2//!
3//! Provides [`FeatureContext`] and [`FeatureChecker`] for evaluating feature
4//! flags stored in the options system.
5
6use num::bigint::{BigInt, Sign};
7use std::cell::Cell;
8use std::collections::HashMap;
9
10use serde_json::Value;
11use sha1::{Digest, Sha1};
12
13/// Produce a Python-compatible string representation for identity hashing.
14fn value_to_id_string(value: &Value) -> String {
15    match value {
16        Value::String(s) => s.clone(),
17        Value::Number(n) => {
18            if let Some(i) = n.as_i64() {
19                i.to_string()
20            } else if let Some(f) = n.as_f64() {
21                // Match Python's str() output: 1.0 -> "1.0", 1.5 -> "1.5"
22                if f.fract() == 0.0 {
23                    format!("{f:.1}")
24                } else {
25                    f.to_string()
26                }
27            } else {
28                n.to_string()
29            }
30        }
31        Value::Bool(b) => if *b { "True" } else { "False" }.to_string(),
32        Value::Array(arr) => {
33            let items: Vec<String> = arr.iter().map(value_to_id_string).collect();
34            format!("[{}]", items.join(", "))
35        }
36        Value::Null => "None".to_string(),
37        Value::Object(_) => value.to_string(),
38    }
39}
40
41/// Application context passed to feature flag evaluation.
42///
43/// Contains arbitrary key-value data used to evaluate feature flag conditions.
44/// The identity fields determine which fields are used for rollout bucketing.
45pub struct FeatureContext {
46    data: HashMap<String, Value>,
47    identity_fields: Vec<String>,
48    cached_id: Cell<Option<u64>>,
49}
50
51impl FeatureContext {
52    pub fn new() -> Self {
53        Self {
54            data: HashMap::new(),
55            identity_fields: Vec::new(),
56            cached_id: Cell::new(None),
57        }
58    }
59
60    /// Set the fields used to compute this context's rollout identity.
61    ///
62    /// Fields are sorted lexicographically before hashing. Calling this
63    /// resets the cached identity value.
64    pub fn identity_fields(&mut self, fields: Vec<&str>) {
65        self.identity_fields = fields.into_iter().map(|s| s.to_string()).collect();
66        self.cached_id.set(None);
67    }
68
69    /// Insert a key-value pair into the context.
70    pub fn insert(&mut self, key: &str, value: impl Into<Value>) {
71        self.data.insert(key.to_string(), value.into());
72        self.cached_id.set(None);
73    }
74
75    /// Get a context value by key.
76    pub fn get(&self, key: &str) -> Option<&Value> {
77        self.data.get(key)
78    }
79
80    /// Check if a key is present in the context.
81    pub fn has(&self, key: &str) -> bool {
82        self.data.contains_key(key)
83    }
84
85    /// Compute and return this context's rollout identity.
86    ///
87    /// The result is cached and reset when identity fields or context data change.
88    /// When no identity fields are set, all context keys are used (non-deterministic
89    /// rollout across contexts with different keys).
90    /// The id value is mostly used to id % 100 <= rollout.
91    pub fn id(&self) -> u64 {
92        if let Some(id) = self.cached_id.get() {
93            return id;
94        }
95        let id = self.compute_id();
96        self.cached_id.set(Some(id));
97        id
98    }
99
100    /// Compute the id for a FeatureContext.
101    ///
102    /// The original python implementation used a bigint value
103    /// derived from the sha1 hash.
104    ///
105    /// This method returns a u64 which contains the lower place
106    /// values of the bigint so that our rollout modulo math is
107    /// consistent with the original python implementation.
108    fn compute_id(&self) -> u64 {
109        let mut identity_fields: Vec<&String> = self
110            .identity_fields
111            .iter()
112            .filter(|f| self.data.contains_key(f.as_str()))
113            .collect();
114        if identity_fields.is_empty() {
115            identity_fields = self.data.keys().collect();
116        }
117        identity_fields.sort();
118
119        let mut parts: Vec<String> = Vec::with_capacity(identity_fields.len() * 2);
120        for key in identity_fields {
121            parts.push(key.clone());
122            parts.push(value_to_id_string(&self.data[key.as_str()]));
123        }
124        let mut hasher = Sha1::new();
125        hasher.update(parts.join(":").as_bytes());
126        let digest = hasher.finalize();
127
128        // Create a BigInt to preserve all the 20bytes of the hash digest.
129        let bigint = BigInt::from_bytes_be(Sign::Plus, digest.as_slice());
130
131        // We only need the lower places from the big int to retain compatibility.
132        // modulo will trim off the u64 overflow, and let us break the bigint
133        // into its pieces (there will only be one).
134        let small: BigInt = bigint % 1000000000;
135        let id_parts = small.to_u64_digits().1;
136        if id_parts.is_empty() { 0 } else { id_parts[0] }
137    }
138}
139
140impl Default for FeatureContext {
141    fn default() -> Self {
142        Self::new()
143    }
144}
145
146#[derive(Debug)]
147enum OperatorKind {
148    In,
149    NotIn,
150    Contains,
151    NotContains,
152    Equals,
153    NotEquals,
154    Matches,
155    NotMatches,
156}
157
158#[derive(Debug)]
159struct Condition {
160    property: String,
161    operator: OperatorKind,
162    value: Value,
163}
164
165#[derive(Debug)]
166struct Segment {
167    rollout: u64,
168    conditions: Vec<Condition>,
169}
170
171#[derive(Debug)]
172struct Feature {
173    enabled: bool,
174    segments: Vec<Segment>,
175}
176
177impl Feature {
178    fn from_json(value: &Value) -> Option<Self> {
179        // Default to true to align with flagpole behavior.
180        let enabled = value
181            .get("enabled")
182            .and_then(|v| v.as_bool())
183            .unwrap_or(true);
184        let segments = value
185            .get("segments")?
186            .as_array()?
187            .iter()
188            .filter_map(Segment::from_json)
189            .collect();
190        Some(Feature { enabled, segments })
191    }
192
193    fn matches(&self, context: &FeatureContext) -> bool {
194        if !self.enabled {
195            return false;
196        }
197        for segment in &self.segments {
198            if segment.conditions_match(context) {
199                return segment.in_rollout(context);
200            }
201        }
202        false
203    }
204}
205
206impl Segment {
207    fn from_json(value: &Value) -> Option<Self> {
208        let rollout = value.get("rollout").and_then(|v| v.as_u64()).unwrap_or(100);
209        let conditions = value
210            .get("conditions")
211            .and_then(|v| v.as_array())
212            .map(|arr| arr.iter().filter_map(Condition::from_json).collect())
213            .unwrap_or_default();
214        Some(Segment {
215            rollout,
216            conditions,
217        })
218    }
219
220    fn conditions_match(&self, context: &FeatureContext) -> bool {
221        self.conditions.iter().all(|c| c.matches(context))
222    }
223
224    fn in_rollout(&self, context: &FeatureContext) -> bool {
225        if self.rollout == 0 {
226            return false;
227        }
228        if self.rollout >= 100 {
229            return true;
230        }
231        context.id() % 100 <= self.rollout
232    }
233}
234
235impl Condition {
236    fn from_json(value: &Value) -> Option<Self> {
237        let property = value.get("property")?.as_str()?.to_string();
238        let operator = match value.get("operator")?.as_str()? {
239            "in" => OperatorKind::In,
240            "not_in" => OperatorKind::NotIn,
241            "contains" => OperatorKind::Contains,
242            "not_contains" => OperatorKind::NotContains,
243            "equals" => OperatorKind::Equals,
244            "not_equals" => OperatorKind::NotEquals,
245            "matches" => OperatorKind::Matches,
246            "not_matches" => OperatorKind::NotMatches,
247            _ => return None,
248        };
249        let value = value.get("value")?.clone();
250        Some(Condition {
251            property,
252            operator,
253            value,
254        })
255    }
256
257    fn matches(&self, context: &FeatureContext) -> bool {
258        let ctx_val = context.get(&self.property);
259        let result = match &self.operator {
260            OperatorKind::In => eval_in(ctx_val, &self.value),
261            OperatorKind::NotIn => eval_in(ctx_val, &self.value).map(|m| !m),
262            OperatorKind::Contains => eval_contains(ctx_val, &self.value),
263            OperatorKind::NotContains => eval_contains(ctx_val, &self.value).map(|m| !m),
264            OperatorKind::Equals => eval_equals(ctx_val, &self.value),
265            OperatorKind::NotEquals => eval_equals(ctx_val, &self.value).map(|m| !m),
266            OperatorKind::Matches => eval_matches(ctx_val, &self.value),
267            OperatorKind::NotMatches => eval_matches(ctx_val, &self.value).map(|m| !m),
268        };
269        // A comparison that cannot be made is a non-match even when negated:
270        // flagpole raises for these, and the exception is caught above the
271        // evaluator rather than inverted.
272        result.unwrap_or(false)
273    }
274}
275
276/// For scalar ctx_val, check if it's contained in the condition array.
277/// For array ctx_val, check if there is any intersection with the condition array.
278/// String comparison is case-insensitive.
279///
280/// `None` means the comparison could not be made.
281fn eval_in(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
282    let arr = condition_val.as_array()?;
283    match ctx_val {
284        None => Some(false),
285        Some(Value::Object(_)) => None,
286        Some(Value::Array(ctx_arr)) => Some(ctx_arr.iter().any(|v| scalar_in(v, arr))),
287        Some(scalar) => Some(scalar_in(scalar, arr)),
288    }
289}
290
291fn scalar_in(ctx_val: &Value, arr: &[Value]) -> bool {
292    match ctx_val {
293        Value::String(s) => {
294            let s_lower = s.to_lowercase();
295            arr.iter()
296                .any(|v| v.as_str().is_some_and(|cv| cv.to_lowercase() == s_lower))
297        }
298        Value::Number(n) => arr
299            .iter()
300            .any(|v| v.as_number().is_some_and(|cv| numbers_equal(n, cv))),
301        Value::Bool(b) => arr.iter().any(|v| v.as_bool().is_some_and(|cv| cv == *b)),
302        _ => false,
303    }
304}
305
306/// Compare two JSON numbers regardless of their type (int vs float)
307/// 1 == 1, 1.0 == 1, and 1.0 == 1.0
308fn numbers_equal(a: &serde_json::Number, b: &serde_json::Number) -> bool {
309    // !f64 as opposed to (i64 || u64)
310    if !a.is_f64() && !b.is_f64() {
311        return a == b;
312    }
313    // mismatched types are cast to float
314    matches!((a.as_f64(), b.as_f64()), (Some(x), Some(y)) if x == y)
315}
316
317/// Check if a context array contains a condition scalar value.
318/// String comparison is case-insensitive.
319///
320/// A property that is missing or is not a list cannot be searched at all, so
321/// `not_contains` does not grant the feature for it either.
322fn eval_contains(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
323    let ctx_arr = ctx_val?.as_array()?;
324    match condition_val {
325        Value::String(s) => {
326            let s_lower = s.to_lowercase();
327            Some(
328                ctx_arr
329                    .iter()
330                    .any(|v| v.as_str().is_some_and(|cv| cv.to_lowercase() == s_lower)),
331            )
332        }
333        Value::Number(n) => Some(
334            ctx_arr
335                .iter()
336                .any(|v| v.as_number().is_some_and(|cv| numbers_equal(n, cv))),
337        ),
338        Value::Bool(b) => Some(
339            ctx_arr
340                .iter()
341                .any(|v| v.as_bool().is_some_and(|cv| cv == *b)),
342        ),
343        _ => None,
344    }
345}
346
347/// Check if a context value equals a condition value.
348/// A missing property equals nothing, and is not treated as unusable.
349fn eval_equals(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
350    Some(match ctx_val {
351        Some(ctx_val) => values_equal(ctx_val, condition_val),
352        None => false,
353    })
354}
355
356/// Scalars are compared directly (strings case-insensitively).
357/// Arrays are compared element-wise with matching length.
358fn values_equal(ctx_val: &Value, condition_val: &Value) -> bool {
359    match (ctx_val, condition_val) {
360        (Value::String(a), Value::String(b)) => a.to_lowercase() == b.to_lowercase(),
361        (Value::Number(a), Value::Number(b)) => numbers_equal(a, b),
362        (Value::Bool(a), Value::Bool(b)) => a == b,
363        (Value::Array(a), Value::Array(b)) => {
364            a.len() == b.len() && a.iter().zip(b.iter()).all(|(av, bv)| values_equal(av, bv))
365        }
366        _ => false,
367    }
368}
369
370/// Match a value string against a single star-only glob pattern (case-insensitive).
371/// '*' matches zero or more characters. All other characters, including '?' and '[',
372/// are treated as literals.
373fn glob_star_match(pattern: &str, value: &str) -> bool {
374    let pattern = pattern.to_lowercase();
375    let value = value.to_lowercase();
376    let parts: Vec<&str> = pattern.split('*').collect();
377    // No wildcard — require exact equality.
378    if parts.len() == 1 {
379        return value == pattern;
380    }
381    // Check prefix anchor.
382    if !value.starts_with(parts[0]) {
383        return false;
384    }
385    // Check suffix anchor (skip when the last part is empty, i.e. pattern ends with '*').
386    if !parts[parts.len() - 1].is_empty() && !value.ends_with(parts[parts.len() - 1]) {
387        return false;
388    }
389    // Search window: after the prefix, before the suffix.
390    let end = if parts[parts.len() - 1].is_empty() {
391        value.len()
392    } else {
393        value.len() - parts[parts.len() - 1].len()
394    };
395    let mut start = parts[0].len();
396    // The prefix and suffix anchors overlap, meaning the
397    // value is shorter than prefix + suffix combined — no valid match possible.
398    if start > end {
399        return false;
400    }
401    // Walk middle segments left-to-right, advancing the cursor on each hit.
402    for part in &parts[1..parts.len() - 1] {
403        if part.is_empty() {
404            // Skip consecutive '*'s.
405            continue;
406        }
407        match value[start..end].find(*part) {
408            Some(idx) => start += idx + part.len(),
409            None => return false,
410        }
411    }
412    true
413}
414
415/// Check if a string context value matches any pattern in the condition array.
416/// Patterns use star-only glob semantics; comparison is case-insensitive.
417/// Returns false for non-string context values.
418fn eval_matches(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
419    let arr = condition_val.as_array()?;
420    match ctx_val {
421        // A container cannot be glob matched against.
422        Some(Value::Array(_) | Value::Object(_)) => None,
423        None => Some(false),
424        Some(scalar) => {
425            let Some(s) = scalar.as_str() else {
426                return Some(false);
427            };
428            Some(arr.iter().any(|v| {
429                v.as_str()
430                    .is_some_and(|pattern| glob_star_match(pattern, s))
431            }))
432        }
433    }
434}
435
436/// A handle for checking feature flags within a specific namespace.
437pub struct FeatureChecker {
438    namespace: String,
439    options: Option<&'static crate::Options>,
440}
441
442impl FeatureChecker {
443    pub fn new(namespace: String, options: &'static crate::Options) -> Self {
444        Self {
445            namespace,
446            options: Some(options),
447        }
448    }
449
450    /// Check whether a feature flag is enabled for a given context.
451    ///
452    /// Returns false if the feature is not defined, not enabled, conditions don't match,
453    /// or options have not been initialized.
454    pub fn has(&self, feature_name: &str, context: &FeatureContext) -> bool {
455        let Some(opts) = self.options else {
456            return false;
457        };
458        let key = format!("feature.{feature_name}");
459
460        let feature_val = match opts.get(&self.namespace, &key) {
461            Ok(v) => v,
462            Err(e) => {
463                tracing::debug!(key = %key, error = %e, "Failed to get feature");
464                return false;
465            }
466        };
467
468        let feature = match Feature::from_json(&feature_val) {
469            Some(f) => {
470                tracing::debug!(key = %key, "Parsed feature");
471                f
472            }
473            None => {
474                tracing::debug!(key = %key, "Failed to parse feature");
475                return false;
476            }
477        };
478
479        let result = feature.matches(context);
480        tracing::debug!(
481            feature = feature_name,
482            result,
483            context_id = context.id(),
484            "Feature match result"
485        );
486        result
487    }
488}
489
490/// Get a feature checker handle for a namespace.
491///
492/// Returns a handle that returns false for all checks if `init()` has not been called.
493pub fn features(namespace: &str) -> FeatureChecker {
494    FeatureChecker {
495        namespace: namespace.to_string(),
496        options: crate::GLOBAL_OPTIONS.get(),
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use crate::Options;
504    use serde_json::json;
505    use std::fs;
506    use std::path::Path;
507    use tempfile::TempDir;
508
509    fn create_schema(dir: &Path, namespace: &str, schema: &str) {
510        let schema_dir = dir.join(namespace);
511        fs::create_dir_all(&schema_dir).unwrap();
512        fs::write(schema_dir.join("schema.json"), schema).unwrap();
513    }
514
515    fn create_values(dir: &Path, namespace: &str, values: &str) {
516        let ns_dir = dir.join(namespace);
517        fs::create_dir_all(&ns_dir).unwrap();
518        fs::write(ns_dir.join("values.json"), values).unwrap();
519    }
520
521    const FEATURE_SCHEMA: &str = r##"{
522        "version": "1.0",
523        "type": "object",
524        "properties": {
525            "feature.organizations:test-feature": {
526                "$ref": "#/definitions/Feature"
527            }
528        }
529    }"##;
530
531    fn setup_feature_options(feature_json: &str) -> (Options, TempDir) {
532        let temp = TempDir::new().unwrap();
533        let schemas = temp.path().join("schemas");
534        fs::create_dir_all(&schemas).unwrap();
535        create_schema(&schemas, "test", FEATURE_SCHEMA);
536
537        let values = temp.path().join("values");
538        let values_json = format!(
539            r#"{{"options": {{"feature.organizations:test-feature": {}}}}}"#,
540            feature_json
541        );
542        create_values(&values, "test", &values_json);
543
544        let opts = Options::from_directory(temp.path()).unwrap();
545        (opts, temp)
546    }
547
548    fn feature_json(enabled: bool, rollout: u64, conditions: &str) -> String {
549        format!(
550            r#"{{
551                "name": "test-feature",
552                "enabled": {enabled},
553                "owner": {{"team": "test-team"}},
554                "created_at": "2024-01-01",
555                "segments": [{{
556                    "name": "test-segment",
557                    "rollout": {rollout},
558                    "conditions": [{conditions}]
559                }}]
560            }}"#
561        )
562    }
563
564    fn in_condition(property: &str, values: &str) -> String {
565        format!(r#"{{"property": "{property}", "operator": "in", "value": [{values}]}}"#)
566    }
567
568    fn check(opts: &Options, feature: &str, ctx: &FeatureContext) -> bool {
569        let key = format!("feature.{feature}");
570        let Ok(val) = opts.get("test", &key) else {
571            return false;
572        };
573        Feature::from_json(&val).is_some_and(|f| f.matches(ctx))
574    }
575
576    #[test]
577    fn test_feature_context_insert_and_get() {
578        let mut ctx = FeatureContext::new();
579        ctx.insert("org_id", json!(123));
580        ctx.insert("name", json!("sentry"));
581        ctx.insert("active", json!(true));
582
583        assert!(ctx.has("org_id"));
584        assert!(!ctx.has("missing"));
585        assert_eq!(ctx.get("org_id"), Some(&json!(123)));
586        assert_eq!(ctx.get("name"), Some(&json!("sentry")));
587    }
588
589    #[test]
590    fn test_feature_context_id_is_cached() {
591        let mut ctx = FeatureContext::new();
592        ctx.identity_fields(vec!["user_id"]);
593        ctx.insert("user_id", json!(42));
594
595        let id1 = ctx.id();
596        let id2 = ctx.id();
597        assert_eq!(id1, id2, "ID should be cached and consistent");
598    }
599
600    #[test]
601    fn test_feature_context_id_resets_on_identity_change() {
602        let mut ctx = FeatureContext::new();
603        ctx.insert("user_id", json!(1));
604        ctx.insert("org_id", json!(2));
605
606        ctx.identity_fields(vec!["user_id"]);
607        let id_user = ctx.id();
608
609        ctx.identity_fields(vec!["org_id"]);
610        let id_org = ctx.id();
611
612        assert_ne!(
613            id_user, id_org,
614            "Different identity fields should produce different IDs"
615        );
616    }
617
618    #[test]
619    fn test_feature_context_id_deterministic() {
620        let make_ctx = || {
621            let mut ctx = FeatureContext::new();
622            ctx.identity_fields(vec!["user_id", "org_id"]);
623            ctx.insert("user_id", json!(456));
624            ctx.insert("org_id", json!(123));
625            ctx
626        };
627
628        assert_eq!(make_ctx().id(), make_ctx().id());
629
630        let mut other_ctx = FeatureContext::new();
631        other_ctx.identity_fields(vec!["user_id", "org_id"]);
632        other_ctx.insert("user_id", json!(789));
633        other_ctx.insert("org_id", json!(123));
634
635        assert_ne!(make_ctx().id(), other_ctx.id());
636    }
637
638    #[test]
639    fn test_feature_context_id_value_align_with_python() {
640        // Context.id() determines rollout rates with modulo
641        // This implementation should generate the same rollout slots
642        // as the previous implementation did.
643        let ctx = FeatureContext::new();
644        assert_eq!(ctx.id() % 100, 5, "should match with python implementation");
645
646        let mut ctx = FeatureContext::new();
647        ctx.insert("foo", json!("bar"));
648        ctx.insert("baz", json!("barfoo"));
649        ctx.identity_fields(vec!["foo"]);
650        assert_eq!(ctx.id() % 100, 62);
651
652        // Undefined fields should not contribute to the id.
653        let mut ctx = FeatureContext::new();
654        ctx.insert("foo", json!("bar"));
655        ctx.insert("baz", json!("barfoo"));
656        ctx.identity_fields(vec!["foo", "whoops"]);
657        assert_eq!(ctx.id() % 100, 62);
658
659        let mut ctx = FeatureContext::new();
660        ctx.insert("foo", json!("bar"));
661        ctx.insert("baz", json!("barfoo"));
662        ctx.identity_fields(vec!["foo", "baz"]);
663        assert_eq!(ctx.id() % 100, 1);
664
665        let mut ctx = FeatureContext::new();
666        ctx.insert("foo", json!("bar"));
667        ctx.insert("baz", json!("barfoo"));
668        // When there is no overlap with identity fields and data,
669        // all fields should be used
670        ctx.identity_fields(vec!["whoops", "nope"]);
671        assert_eq!(ctx.id() % 100, 1);
672    }
673
674    #[test]
675    fn test_feature_prefix_is_added() {
676        let cond = in_condition("organization_id", "123");
677        let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
678
679        let mut ctx = FeatureContext::new();
680        ctx.insert("organization_id", json!(123));
681
682        assert!(check(&opts, "organizations:test-feature", &ctx));
683    }
684
685    #[test]
686    fn test_undefined_feature_returns_false() {
687        let cond = in_condition("organization_id", "123");
688        let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
689
690        let ctx = FeatureContext::new();
691        assert!(!check(&opts, "nonexistent", &ctx));
692    }
693
694    #[test]
695    fn test_missing_context_field_returns_false() {
696        let cond = in_condition("organization_id", "123");
697        let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
698
699        let ctx = FeatureContext::new();
700        assert!(!check(&opts, "organizations:test-feature", &ctx));
701    }
702
703    #[test]
704    fn test_matching_context_returns_true() {
705        let cond = in_condition("organization_id", "123, 456");
706        let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
707
708        let mut ctx = FeatureContext::new();
709        ctx.insert("organization_id", json!(123));
710
711        assert!(check(&opts, "organizations:test-feature", &ctx));
712    }
713
714    #[test]
715    fn test_non_matching_context_returns_false() {
716        let cond = in_condition("organization_id", "123, 456");
717        let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
718
719        let mut ctx = FeatureContext::new();
720        ctx.insert("organization_id", json!(999));
721
722        assert!(!check(&opts, "organizations:test-feature", &ctx));
723    }
724
725    #[test]
726    fn test_disabled_feature_returns_false() {
727        let cond = in_condition("organization_id", "123");
728        let (opts, _t) = setup_feature_options(&feature_json(false, 100, &cond));
729
730        let mut ctx = FeatureContext::new();
731        ctx.insert("organization_id", json!(123));
732
733        assert!(!check(&opts, "organizations:test-feature", &ctx));
734    }
735
736    #[test]
737    fn test_rollout_zero_returns_false() {
738        let cond = in_condition("organization_id", "123");
739        let (opts, _t) = setup_feature_options(&feature_json(true, 0, &cond));
740
741        let mut ctx = FeatureContext::new();
742        ctx.insert("organization_id", json!(123));
743
744        assert!(!check(&opts, "organizations:test-feature", &ctx));
745    }
746
747    #[test]
748    fn test_rollout_100_returns_true() {
749        let cond = in_condition("organization_id", "123");
750        let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
751
752        let mut ctx = FeatureContext::new();
753        ctx.insert("organization_id", json!(123));
754
755        assert!(check(&opts, "organizations:test-feature", &ctx));
756    }
757
758    #[test]
759    fn test_rollout_is_deterministic() {
760        let mut ctx = FeatureContext::new();
761        ctx.identity_fields(vec!["user_id"]);
762        ctx.insert("user_id", json!(42));
763        ctx.insert("organization_id", json!(123));
764
765        // Add 1 to get around fence post with < vs <=
766        let id_mod = (ctx.id() % 100) + 1;
767        let cond = in_condition("organization_id", "123");
768
769        let (opts_at, _t1) = setup_feature_options(&feature_json(true, id_mod, &cond));
770        assert!(check(&opts_at, "organizations:test-feature", &ctx));
771    }
772
773    #[test]
774    fn test_condition_in_string_case_insensitive() {
775        let cond = r#"{"property": "slug", "operator": "in", "value": ["Sentry", "ACME"]}"#;
776        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
777
778        let mut ctx = FeatureContext::new();
779        ctx.insert("slug", json!("sentry"));
780        assert!(check(&opts, "organizations:test-feature", &ctx));
781    }
782
783    #[test]
784    fn test_condition_not_in() {
785        let cond = r#"{"property": "organization_id", "operator": "not_in", "value": [999]}"#;
786        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
787
788        let mut ctx = FeatureContext::new();
789        ctx.insert("organization_id", json!(123));
790        assert!(check(&opts, "organizations:test-feature", &ctx));
791
792        let mut ctx2 = FeatureContext::new();
793        ctx2.insert("organization_id", json!(999));
794        assert!(!check(&opts, "organizations:test-feature", &ctx2));
795    }
796
797    #[test]
798    fn test_condition_in_list_property_matches_on_overlap() {
799        let cond = r#"{"property": "plan_family", "operator": "in", "value": ["business"]}"#;
800        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
801
802        let mut ctx = FeatureContext::new();
803        ctx.insert("plan_family", json!(["enterprise business", "Business"]));
804        assert!(check(&opts, "organizations:test-feature", &ctx));
805
806        let mut ctx2 = FeatureContext::new();
807        ctx2.insert("plan_family", json!(["team"]));
808        assert!(!check(&opts, "organizations:test-feature", &ctx2));
809
810        let mut ctx3 = FeatureContext::new();
811        ctx3.insert("plan_family", json!([]));
812        assert!(!check(&opts, "organizations:test-feature", &ctx3));
813    }
814
815    #[test]
816    fn test_condition_not_in_list_property_requires_no_overlap() {
817        let cond = r#"{"property": "plan_family", "operator": "not_in", "value": ["business"]}"#;
818        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
819
820        let mut ctx = FeatureContext::new();
821        ctx.insert("plan_family", json!(["enterprise business", "business"]));
822        assert!(!check(&opts, "organizations:test-feature", &ctx));
823
824        let mut ctx2 = FeatureContext::new();
825        ctx2.insert("plan_family", json!(["team"]));
826        assert!(check(&opts, "organizations:test-feature", &ctx2));
827    }
828
829    #[test]
830    fn test_condition_in_list_property_no_type_coercion() {
831        let cond = r#"{"property": "org_id", "operator": "in", "value": ["123"]}"#;
832        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
833
834        let mut ctx = FeatureContext::new();
835        ctx.insert("org_id", json!([123]));
836        assert!(!check(&opts, "organizations:test-feature", &ctx));
837
838        let int_cond = r#"{"property": "org_id", "operator": "in", "value": [123]}"#;
839        let (int_opts, _t2) = setup_feature_options(&feature_json(true, 100, int_cond));
840        assert!(check(&int_opts, "organizations:test-feature", &ctx));
841    }
842
843    #[test]
844    fn test_condition_in_matches_across_int_and_float() {
845        let cond = r#"{"property": "org_id", "operator": "in", "value": [123.0]}"#;
846        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
847
848        let mut ctx = FeatureContext::new();
849        ctx.insert("org_id", json!(123));
850        assert!(check(&opts, "organizations:test-feature", &ctx));
851
852        let mut list_ctx = FeatureContext::new();
853        list_ctx.insert("org_id", json!([123]));
854        assert!(check(&opts, "organizations:test-feature", &list_ctx));
855
856        let mut ctx2 = FeatureContext::new();
857        ctx2.insert("org_id", json!(124));
858        assert!(!check(&opts, "organizations:test-feature", &ctx2));
859    }
860
861    #[test]
862    fn test_condition_in_large_ints_compare_exactly() {
863        // 2^53 + 1 and 2^53 collide as f64, so ints must not round-trip through it.
864        let cond = r#"{"property": "org_id", "operator": "in", "value": [9007199254740993]}"#;
865        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
866
867        let mut ctx = FeatureContext::new();
868        ctx.insert("org_id", json!(9007199254740992i64));
869        assert!(!check(&opts, "organizations:test-feature", &ctx));
870
871        let mut ctx2 = FeatureContext::new();
872        ctx2.insert("org_id", json!(9007199254740993i64));
873        assert!(check(&opts, "organizations:test-feature", &ctx2));
874
875        // Above i64::MAX, where as_i64() gives up but the values are still exact.
876        let big = r#"{"property": "org_id", "operator": "in", "value": [18446744073709551615]}"#;
877        let (big_opts, _t2) = setup_feature_options(&feature_json(true, 100, big));
878
879        let mut ctx3 = FeatureContext::new();
880        ctx3.insert("org_id", json!(18446744073709551614u64));
881        assert!(!check(&big_opts, "organizations:test-feature", &ctx3));
882
883        let mut ctx4 = FeatureContext::new();
884        ctx4.insert("org_id", json!(18446744073709551615u64));
885        assert!(check(&big_opts, "organizations:test-feature", &ctx4));
886    }
887
888    #[test]
889    fn test_condition_contains() {
890        let cond = r#"{"property": "tags", "operator": "contains", "value": "beta"}"#;
891        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
892
893        let mut ctx = FeatureContext::new();
894        ctx.insert("tags", json!(["alpha", "beta"]));
895        assert!(check(&opts, "organizations:test-feature", &ctx));
896
897        let mut ctx2 = FeatureContext::new();
898        ctx2.insert("tags", json!(["alpha"]));
899        assert!(!check(&opts, "organizations:test-feature", &ctx2));
900    }
901
902    #[test]
903    fn test_condition_contains_matches_across_int_and_float() {
904        let int_cond = r#"{"property": "ids", "operator": "contains", "value": 123}"#;
905        let (int_opts, _t) = setup_feature_options(&feature_json(true, 100, int_cond));
906
907        let float_cond = r#"{"property": "ids", "operator": "contains", "value": 123.0}"#;
908        let (float_opts, _t2) = setup_feature_options(&feature_json(true, 100, float_cond));
909
910        let mut int_ctx = FeatureContext::new();
911        int_ctx.insert("ids", json!([123]));
912        let mut float_ctx = FeatureContext::new();
913        float_ctx.insert("ids", json!([123.0]));
914
915        // Matches regardless of which side holds the float.
916        assert!(check(&int_opts, "organizations:test-feature", &int_ctx));
917        assert!(check(&int_opts, "organizations:test-feature", &float_ctx));
918        assert!(check(&float_opts, "organizations:test-feature", &int_ctx));
919        assert!(check(&float_opts, "organizations:test-feature", &float_ctx));
920
921        let mut other = FeatureContext::new();
922        other.insert("ids", json!([124]));
923        assert!(!check(&int_opts, "organizations:test-feature", &other));
924    }
925
926    #[test]
927    fn test_condition_equals() {
928        let cond = r#"{"property": "plan", "operator": "equals", "value": "enterprise"}"#;
929        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
930
931        let mut ctx = FeatureContext::new();
932        ctx.insert("plan", json!("Enterprise"));
933        assert!(check(&opts, "organizations:test-feature", &ctx));
934
935        let mut ctx2 = FeatureContext::new();
936        ctx2.insert("plan", json!("free"));
937        assert!(!check(&opts, "organizations:test-feature", &ctx2));
938    }
939
940    #[test]
941    fn test_condition_equals_large_ints_compare_exactly() {
942        // Above i64::MAX these used to collapse into f64 and compare equal.
943        let cond = r#"{"property": "org_id", "operator": "equals", "value": 18446744073709551615}"#;
944        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
945
946        let mut ctx = FeatureContext::new();
947        ctx.insert("org_id", json!(18446744073709551614u64));
948        assert!(!check(&opts, "organizations:test-feature", &ctx));
949
950        let mut ctx2 = FeatureContext::new();
951        ctx2.insert("org_id", json!(18446744073709551615u64));
952        assert!(check(&opts, "organizations:test-feature", &ctx2));
953    }
954
955    #[test]
956    fn test_condition_equals_bool() {
957        let cond = r#"{"property": "is_free", "operator": "equals", "value": true}"#;
958        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
959
960        let mut ctx = FeatureContext::new();
961        ctx.insert("is_free", json!(true));
962        assert!(check(&opts, "organizations:test-feature", &ctx));
963
964        let mut ctx2 = FeatureContext::new();
965        ctx2.insert("is_free", json!(false));
966        assert!(!check(&opts, "organizations:test-feature", &ctx2));
967    }
968
969    #[test]
970    fn test_segment_with_no_conditions_always_matches() {
971        let feature = r#"{
972            "name": "test-feature",
973            "enabled": true,
974            "owner": {"team": "test-team"},
975            "created_at": "2024-01-01",
976            "segments": [{"name": "open", "rollout": 100, "conditions": []}]
977        }"#;
978        let (opts, _t) = setup_feature_options(feature);
979
980        let ctx = FeatureContext::new();
981        assert!(check(&opts, "organizations:test-feature", &ctx));
982    }
983
984    #[test]
985    fn test_feature_enabled_and_rollout_default_values() {
986        // This feature doesn't define .enabled or .segments[0].rollout
987        let feature = r#"{
988            "name": "test-feature",
989            "owner": {"team": "test-team"},
990            "created_at": "2024-01-01",
991            "segments": [
992                {
993                    "name": "first",
994                    "conditions": [{"property": "org_id", "operator": "in", "value":[1]}]
995                }
996            ]
997        }"#;
998        let (opts, _t) = setup_feature_options(feature);
999
1000        let mut ctx = FeatureContext::new();
1001        ctx.insert("org_id", 1);
1002        ctx.identity_fields(vec!["org_id"]);
1003        assert!(check(&opts, "organizations:test-feature", &ctx));
1004    }
1005
1006    #[test]
1007    fn test_feature_with_no_segments_returns_false() {
1008        let feature = r#"{
1009            "name": "test-feature",
1010            "enabled": true,
1011            "owner": {"team": "test-team"},
1012            "created_at": "2024-01-01",
1013            "segments": []
1014        }"#;
1015        let (opts, _t) = setup_feature_options(feature);
1016
1017        let ctx = FeatureContext::new();
1018        assert!(!check(&opts, "organizations:test-feature", &ctx));
1019    }
1020
1021    #[test]
1022    fn test_multiple_segments_or_logic() {
1023        let feature = r#"{
1024            "name": "test-feature",
1025            "enabled": true,
1026            "owner": {"team": "test-team"},
1027            "created_at": "2024-01-01",
1028            "segments": [
1029                {
1030                    "name": "segment-a",
1031                    "rollout": 100,
1032                    "conditions": [{"property": "org_id", "operator": "in", "value": [1]}]
1033                },
1034                {
1035                    "name": "segment-b",
1036                    "rollout": 100,
1037                    "conditions": [{"property": "org_id", "operator": "in", "value": [2]}]
1038                }
1039            ]
1040        }"#;
1041        let (opts, _t) = setup_feature_options(feature);
1042
1043        let mut ctx1 = FeatureContext::new();
1044        ctx1.insert("org_id", json!(1));
1045        assert!(check(&opts, "organizations:test-feature", &ctx1));
1046
1047        let mut ctx2 = FeatureContext::new();
1048        ctx2.insert("org_id", json!(2));
1049        assert!(check(&opts, "organizations:test-feature", &ctx2));
1050
1051        let mut ctx3 = FeatureContext::new();
1052        ctx3.insert("org_id", json!(3));
1053        assert!(!check(&opts, "organizations:test-feature", &ctx3));
1054    }
1055
1056    #[test]
1057    fn test_multiple_conditions_and_logic() {
1058        let conds = r#"
1059            {"property": "org_id", "operator": "in", "value": [123]},
1060            {"property": "user_email", "operator": "in", "value": ["admin@example.com"]}
1061        "#;
1062        let (opts, _t) = setup_feature_options(&feature_json(true, 100, conds));
1063
1064        let mut ctx = FeatureContext::new();
1065        ctx.insert("org_id", json!(123));
1066        ctx.insert("user_email", json!("admin@example.com"));
1067        assert!(check(&opts, "organizations:test-feature", &ctx));
1068
1069        let mut ctx2 = FeatureContext::new();
1070        ctx2.insert("org_id", json!(123));
1071        ctx2.insert("user_email", json!("other@example.com"));
1072        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1073    }
1074
1075    #[test]
1076    fn test_in_int_context_against_string_list_returns_false() {
1077        let cond = r#"{"property": "org_id", "operator": "in", "value": ["123", "456"]}"#;
1078        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1079
1080        let mut ctx = FeatureContext::new();
1081        ctx.insert("org_id", json!(123));
1082        assert!(!check(&opts, "organizations:test-feature", &ctx));
1083    }
1084
1085    #[test]
1086    fn test_in_string_context_against_int_list_returns_false() {
1087        let cond = r#"{"property": "slug", "operator": "in", "value": [123, 456]}"#;
1088        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1089
1090        let mut ctx = FeatureContext::new();
1091        ctx.insert("slug", json!("123"));
1092        assert!(!check(&opts, "organizations:test-feature", &ctx));
1093    }
1094
1095    #[test]
1096    fn test_in_bool_context_against_string_list_returns_false() {
1097        let cond = r#"{"property": "active", "operator": "in", "value": ["true", "false"]}"#;
1098        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1099
1100        let mut ctx = FeatureContext::new();
1101        ctx.insert("active", json!(true));
1102        assert!(!check(&opts, "organizations:test-feature", &ctx));
1103    }
1104
1105    #[test]
1106    fn test_in_float_context_against_string_list_returns_false() {
1107        let cond = r#"{"property": "score", "operator": "in", "value": ["0.5", "1.0"]}"#;
1108        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1109
1110        let mut ctx = FeatureContext::new();
1111        ctx.insert("score", json!(0.5));
1112        assert!(!check(&opts, "organizations:test-feature", &ctx));
1113    }
1114
1115    #[test]
1116    fn test_not_in_int_context_against_string_list_returns_true() {
1117        // Type mismatch means "in" is false, so "not_in" is true
1118        let cond = r#"{"property": "org_id", "operator": "not_in", "value": ["123", "456"]}"#;
1119        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1120
1121        let mut ctx = FeatureContext::new();
1122        ctx.insert("org_id", json!(123));
1123        assert!(check(&opts, "organizations:test-feature", &ctx));
1124    }
1125
1126    #[test]
1127    fn test_not_in_string_context_against_int_list_returns_true() {
1128        // Type mismatch means "in" is false, so "not_in" is true
1129        let cond = r#"{"property": "slug", "operator": "not_in", "value": [123, 456]}"#;
1130        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1131
1132        let mut ctx = FeatureContext::new();
1133        ctx.insert("slug", json!("123"));
1134        assert!(check(&opts, "organizations:test-feature", &ctx));
1135    }
1136
1137    #[test]
1138    fn test_condition_matches_literal() {
1139        let cond = r#"{"property": "slug", "operator": "matches", "value": ["sentry"]}"#;
1140        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1141
1142        let mut ctx = FeatureContext::new();
1143        ctx.insert("slug", json!("sentry"));
1144        assert!(check(&opts, "organizations:test-feature", &ctx));
1145
1146        let mut ctx2 = FeatureContext::new();
1147        ctx2.insert("slug", json!("getsentry"));
1148        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1149    }
1150
1151    #[test]
1152    fn test_condition_matches_prefix_wildcard() {
1153        let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
1154        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1155
1156        let mut ctx = FeatureContext::new();
1157        ctx.insert("slug", json!("jayonb73"));
1158        assert!(check(&opts, "organizations:test-feature", &ctx));
1159
1160        // '*' matches zero chars too
1161        let mut ctx2 = FeatureContext::new();
1162        ctx2.insert("slug", json!("jayonb"));
1163        assert!(check(&opts, "organizations:test-feature", &ctx2));
1164
1165        let mut ctx3 = FeatureContext::new();
1166        ctx3.insert("slug", json!("dangoldonb1"));
1167        assert!(!check(&opts, "organizations:test-feature", &ctx3));
1168    }
1169
1170    #[test]
1171    fn test_condition_matches_prefix_and_suffix_wildcard() {
1172        let cond = r#"{"property": "email", "operator": "matches", "value": ["jay.goss+onboarding*@sentry.io"]}"#;
1173        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1174
1175        let mut ctx = FeatureContext::new();
1176        ctx.insert("email", json!("jay.goss+onboarding70@sentry.io"));
1177        assert!(check(&opts, "organizations:test-feature", &ctx));
1178
1179        // '*' matches zero chars — prefix runs directly into suffix
1180        let mut ctx2 = FeatureContext::new();
1181        ctx2.insert("email", json!("jay.goss+onboarding@sentry.io"));
1182        assert!(check(&opts, "organizations:test-feature", &ctx2));
1183
1184        let mut ctx3 = FeatureContext::new();
1185        ctx3.insert("email", json!("jay.goss+onboarding70@example.com"));
1186        assert!(!check(&opts, "organizations:test-feature", &ctx3));
1187    }
1188
1189    #[test]
1190    fn test_condition_matches_suffix_wildcard() {
1191        let cond = r#"{"property": "email", "operator": "matches", "value": ["*@sentry.io"]}"#;
1192        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1193
1194        let mut ctx = FeatureContext::new();
1195        ctx.insert("email", json!("user@sentry.io"));
1196        assert!(check(&opts, "organizations:test-feature", &ctx));
1197
1198        let mut ctx2 = FeatureContext::new();
1199        ctx2.insert("email", json!("user@example.com"));
1200        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1201    }
1202
1203    #[test]
1204    fn test_condition_matches_multi_segment_wildcard() {
1205        let cond = r#"{"property": "name", "operator": "matches", "value": ["a*b*c"]}"#;
1206        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1207
1208        let mut ctx = FeatureContext::new();
1209        ctx.insert("name", json!("abc"));
1210        assert!(check(&opts, "organizations:test-feature", &ctx));
1211
1212        let mut ctx2 = FeatureContext::new();
1213        ctx2.insert("name", json!("aXbYc"));
1214        assert!(check(&opts, "organizations:test-feature", &ctx2));
1215
1216        let mut ctx3 = FeatureContext::new();
1217        ctx3.insert("name", json!("aXXbYYc"));
1218        assert!(check(&opts, "organizations:test-feature", &ctx3));
1219
1220        let mut ctx4 = FeatureContext::new();
1221        ctx4.insert("name", json!("aXXc"));
1222        assert!(!check(&opts, "organizations:test-feature", &ctx4));
1223    }
1224
1225    #[test]
1226    fn test_condition_matches_star_only_pattern() {
1227        let cond = r#"{"property": "slug", "operator": "matches", "value": ["*"]}"#;
1228        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1229
1230        let mut ctx = FeatureContext::new();
1231        ctx.insert("slug", json!("anything"));
1232        assert!(check(&opts, "organizations:test-feature", &ctx));
1233
1234        let mut ctx2 = FeatureContext::new();
1235        ctx2.insert("slug", json!(""));
1236        assert!(check(&opts, "organizations:test-feature", &ctx2));
1237    }
1238
1239    #[test]
1240    fn test_condition_matches_case_insensitive() {
1241        let cond = r#"{"property": "slug", "operator": "matches", "value": ["JAYONB*"]}"#;
1242        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1243
1244        let mut ctx = FeatureContext::new();
1245        ctx.insert("slug", json!("jayonb73"));
1246        assert!(check(&opts, "organizations:test-feature", &ctx));
1247
1248        // Pattern lowercase, value uppercase
1249        let cond2 = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
1250        let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
1251
1252        let mut ctx2 = FeatureContext::new();
1253        ctx2.insert("slug", json!("JAYONB73"));
1254        assert!(check(&opts2, "organizations:test-feature", &ctx2));
1255    }
1256
1257    #[test]
1258    fn test_condition_matches_no_match() {
1259        let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
1260        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1261
1262        let mut ctx = FeatureContext::new();
1263        ctx.insert("slug", json!("dangoldonb1"));
1264        assert!(!check(&opts, "organizations:test-feature", &ctx));
1265    }
1266
1267    #[test]
1268    fn test_condition_matches_multiple_patterns() {
1269        let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*", "dangoldonb*", "value-disc-*"]}"#;
1270        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1271
1272        let slugs = [
1273            ("jayonb73", true),
1274            ("dangoldonb3", true),
1275            ("value-disc-7", true),
1276            ("other-org", false),
1277        ];
1278        for (slug, expected) in slugs {
1279            let mut ctx = FeatureContext::new();
1280            ctx.insert("slug", json!(slug));
1281            assert_eq!(
1282                check(&opts, "organizations:test-feature", &ctx),
1283                expected,
1284                "slug={slug}"
1285            );
1286        }
1287    }
1288
1289    #[test]
1290    fn test_condition_matches_overlapping_prefix_suffix_anchors() {
1291        // "a*a" requires at least "aa" — a single "a" must not match.
1292        let cond = r#"{"property": "slug", "operator": "matches", "value": ["a*a"]}"#;
1293        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1294
1295        let mut ctx = FeatureContext::new();
1296        ctx.insert("slug", json!("a"));
1297        assert!(!check(&opts, "organizations:test-feature", &ctx));
1298
1299        let mut ctx2 = FeatureContext::new();
1300        ctx2.insert("slug", json!("aa"));
1301        assert!(check(&opts, "organizations:test-feature", &ctx2));
1302
1303        // "ab*ab" requires at least "abab" — "ab" alone must not match.
1304        let cond2 = r#"{"property": "slug", "operator": "matches", "value": ["ab*ab"]}"#;
1305        let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
1306
1307        let mut ctx3 = FeatureContext::new();
1308        ctx3.insert("slug", json!("ab"));
1309        assert!(!check(&opts2, "organizations:test-feature", &ctx3));
1310
1311        let mut ctx4 = FeatureContext::new();
1312        ctx4.insert("slug", json!("abab"));
1313        assert!(check(&opts2, "organizations:test-feature", &ctx4));
1314    }
1315
1316    #[test]
1317    fn test_condition_matches_non_string_context_returns_false() {
1318        // Non-string context values should not match any pattern — eval_matches returns false.
1319        let cond = r#"{"property": "org_id", "operator": "matches", "value": ["123*"]}"#;
1320        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1321
1322        let mut ctx = FeatureContext::new();
1323        ctx.insert("org_id", json!(123));
1324        assert!(!check(&opts, "organizations:test-feature", &ctx));
1325    }
1326
1327    #[test]
1328    fn test_condition_not_matches_literal() {
1329        let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["sentry"]}"#;
1330        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1331
1332        // Exact match → not_matches returns false
1333        let mut ctx = FeatureContext::new();
1334        ctx.insert("slug", json!("sentry"));
1335        assert!(!check(&opts, "organizations:test-feature", &ctx));
1336
1337        // No match → not_matches returns true
1338        let mut ctx2 = FeatureContext::new();
1339        ctx2.insert("slug", json!("getsentry"));
1340        assert!(check(&opts, "organizations:test-feature", &ctx2));
1341    }
1342
1343    #[test]
1344    fn test_condition_not_matches_prefix_wildcard() {
1345        let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*"]}"#;
1346        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1347
1348        let mut ctx = FeatureContext::new();
1349        ctx.insert("slug", json!("jayonb73"));
1350        assert!(!check(&opts, "organizations:test-feature", &ctx));
1351
1352        // '*' matches zero chars — bare prefix is still a match, so not_matches = false
1353        let mut ctx2 = FeatureContext::new();
1354        ctx2.insert("slug", json!("jayonb"));
1355        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1356
1357        let mut ctx3 = FeatureContext::new();
1358        ctx3.insert("slug", json!("dangoldonb1"));
1359        assert!(check(&opts, "organizations:test-feature", &ctx3));
1360    }
1361
1362    #[test]
1363    fn test_condition_not_matches_prefix_and_suffix_wildcard() {
1364        let cond = r#"{"property": "email", "operator": "not_matches", "value": ["jay.goss+onboarding*@sentry.io"]}"#;
1365        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1366
1367        let mut ctx = FeatureContext::new();
1368        ctx.insert("email", json!("jay.goss+onboarding70@sentry.io"));
1369        assert!(!check(&opts, "organizations:test-feature", &ctx));
1370
1371        let mut ctx2 = FeatureContext::new();
1372        ctx2.insert("email", json!("jay.goss+onboarding@sentry.io"));
1373        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1374
1375        let mut ctx3 = FeatureContext::new();
1376        ctx3.insert("email", json!("jay.goss+onboarding70@example.com"));
1377        assert!(check(&opts, "organizations:test-feature", &ctx3));
1378    }
1379
1380    #[test]
1381    fn test_condition_not_matches_suffix_wildcard() {
1382        let cond = r#"{"property": "email", "operator": "not_matches", "value": ["*@sentry.io"]}"#;
1383        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1384
1385        let mut ctx = FeatureContext::new();
1386        ctx.insert("email", json!("user@sentry.io"));
1387        assert!(!check(&opts, "organizations:test-feature", &ctx));
1388
1389        let mut ctx2 = FeatureContext::new();
1390        ctx2.insert("email", json!("user@example.com"));
1391        assert!(check(&opts, "organizations:test-feature", &ctx2));
1392    }
1393
1394    #[test]
1395    fn test_condition_not_matches_multi_segment_wildcard() {
1396        let cond = r#"{"property": "name", "operator": "not_matches", "value": ["a*b*c"]}"#;
1397        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1398
1399        let mut ctx = FeatureContext::new();
1400        ctx.insert("name", json!("abc"));
1401        assert!(!check(&opts, "organizations:test-feature", &ctx));
1402
1403        let mut ctx2 = FeatureContext::new();
1404        ctx2.insert("name", json!("aXbYc"));
1405        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1406
1407        let mut ctx3 = FeatureContext::new();
1408        ctx3.insert("name", json!("aXXbYYc"));
1409        assert!(!check(&opts, "organizations:test-feature", &ctx3));
1410
1411        // Does not match pattern → not_matches returns true
1412        let mut ctx4 = FeatureContext::new();
1413        ctx4.insert("name", json!("aXXc"));
1414        assert!(check(&opts, "organizations:test-feature", &ctx4));
1415    }
1416
1417    #[test]
1418    fn test_condition_not_matches_star_only_pattern() {
1419        // "*" matches everything — not_matches always returns false
1420        let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["*"]}"#;
1421        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1422
1423        let mut ctx = FeatureContext::new();
1424        ctx.insert("slug", json!("anything"));
1425        assert!(!check(&opts, "organizations:test-feature", &ctx));
1426
1427        let mut ctx2 = FeatureContext::new();
1428        ctx2.insert("slug", json!(""));
1429        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1430    }
1431
1432    #[test]
1433    fn test_condition_not_matches_case_insensitive() {
1434        let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["JAYONB*"]}"#;
1435        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1436
1437        // Pattern uppercase, value lowercase — still matches, so not_matches = false
1438        let mut ctx = FeatureContext::new();
1439        ctx.insert("slug", json!("jayonb73"));
1440        assert!(!check(&opts, "organizations:test-feature", &ctx));
1441
1442        // Pattern lowercase, value uppercase — still matches, so not_matches = false
1443        let cond2 = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*"]}"#;
1444        let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
1445
1446        let mut ctx2 = FeatureContext::new();
1447        ctx2.insert("slug", json!("JAYONB73"));
1448        assert!(!check(&opts2, "organizations:test-feature", &ctx2));
1449    }
1450
1451    #[test]
1452    fn test_condition_not_matches_no_match() {
1453        let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*"]}"#;
1454        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1455
1456        let mut ctx = FeatureContext::new();
1457        ctx.insert("slug", json!("dangoldonb1"));
1458        assert!(check(&opts, "organizations:test-feature", &ctx));
1459    }
1460
1461    #[test]
1462    fn test_condition_not_matches_multiple_patterns() {
1463        let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*", "dangoldonb*", "value-disc-*"]}"#;
1464        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1465
1466        // Any pattern matches → not_matches returns false
1467        let slugs_false = ["jayonb73", "dangoldonb3", "value-disc-7"];
1468        for slug in slugs_false {
1469            let mut ctx = FeatureContext::new();
1470            ctx.insert("slug", json!(slug));
1471            assert!(
1472                !check(&opts, "organizations:test-feature", &ctx),
1473                "slug={slug} should not match"
1474            );
1475        }
1476
1477        // No pattern matches → not_matches returns true
1478        let mut ctx = FeatureContext::new();
1479        ctx.insert("slug", json!("other-org"));
1480        assert!(check(&opts, "organizations:test-feature", &ctx));
1481    }
1482
1483    #[test]
1484    fn test_condition_not_matches_overlapping_prefix_suffix_anchors() {
1485        // "a*a" requires at least "aa" — "a" alone doesn't match, so not_matches = true
1486        let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["a*a"]}"#;
1487        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1488
1489        let mut ctx = FeatureContext::new();
1490        ctx.insert("slug", json!("a"));
1491        assert!(check(&opts, "organizations:test-feature", &ctx));
1492
1493        let mut ctx2 = FeatureContext::new();
1494        ctx2.insert("slug", json!("aa"));
1495        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1496    }
1497
1498    #[test]
1499    fn test_condition_not_matches_non_string_context_returns_true() {
1500        // eval_matches returns false for non-string → not_matches returns true
1501        let cond = r#"{"property": "org_id", "operator": "not_matches", "value": ["123*"]}"#;
1502        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1503
1504        let mut ctx = FeatureContext::new();
1505        ctx.insert("org_id", json!(123));
1506        assert!(check(&opts, "organizations:test-feature", &ctx));
1507    }
1508
1509    #[test]
1510    fn test_id_string_is_stable() {
1511        for (value, expected) in [
1512            (json!("sentry"), "sentry"),
1513            (json!(123), "123"),
1514            (json!(-7), "-7"),
1515            (json!(true), "True"),
1516            (json!(false), "False"),
1517            (json!(null), "None"),
1518            (json!(1.0), "1.0"),
1519            (json!(1.5), "1.5"),
1520            (json!(["a", "b"]), "[a, b]"),
1521            (json!([1, 2]), "[1, 2]"),
1522            (json!([]), "[]"),
1523        ] {
1524            assert_eq!(value_to_id_string(&value), expected, "for {value}");
1525        }
1526    }
1527
1528    fn condition_matches(operator: &str, value: &str, ctx: &FeatureContext) -> bool {
1529        let json = format!(r#"{{"property": "prop", "operator": "{operator}", "value": {value}}}"#);
1530        Condition::from_json(&serde_json::from_str(&json).unwrap())
1531            .expect("condition should parse")
1532            .matches(ctx)
1533    }
1534
1535    #[test]
1536    fn test_missing_property_matches_only_negated_conditions() {
1537        let ctx = FeatureContext::new();
1538        for (operator, value, expected) in [
1539            ("in", "[1]", false),
1540            ("not_in", "[1]", true),
1541            ("equals", "1", false),
1542            ("not_equals", "1", true),
1543            ("matches", r#"["a"]"#, false),
1544            ("not_matches", r#"["a"]"#, true),
1545            // `contains` has no list to search, so neither form matches.
1546            ("contains", r#""a""#, false),
1547            ("not_contains", r#""a""#, false),
1548        ] {
1549            assert_eq!(
1550                condition_matches(operator, value, &ctx),
1551                expected,
1552                "{operator}"
1553            );
1554        }
1555    }
1556
1557    #[test]
1558    fn test_unusable_property_type_matches_neither_form() {
1559        let mut map = FeatureContext::new();
1560        map.insert("prop", json!({"k": "v"}));
1561        let mut scalar = FeatureContext::new();
1562        scalar.insert("prop", json!("a"));
1563        let mut list = FeatureContext::new();
1564        list.insert("prop", json!(["b"]));
1565
1566        for (operator, value, ctx) in [
1567            ("in", "[1]", &map),
1568            ("not_in", "[1]", &map),
1569            ("contains", r#""a""#, &scalar),
1570            ("not_contains", r#""a""#, &scalar),
1571            ("matches", r#"["b"]"#, &list),
1572            ("not_matches", r#"["b"]"#, &list),
1573        ] {
1574            assert!(!condition_matches(operator, value, ctx), "{operator}");
1575        }
1576
1577        // A usable list that lacks the value still matches when negated.
1578        assert!(condition_matches("not_contains", r#""a""#, &list));
1579    }
1580}