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