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}
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            _ => return None,
247        };
248        let value = value.get("value")?.clone();
249        Some(Condition {
250            property,
251            operator,
252            value,
253        })
254    }
255
256    fn matches(&self, context: &FeatureContext) -> bool {
257        let Some(ctx_val) = context.get(&self.property) else {
258            return false;
259        };
260        match &self.operator {
261            OperatorKind::In => eval_in(ctx_val, &self.value),
262            OperatorKind::NotIn => !eval_in(ctx_val, &self.value),
263            OperatorKind::Contains => eval_contains(ctx_val, &self.value),
264            OperatorKind::NotContains => !eval_contains(ctx_val, &self.value),
265            OperatorKind::Equals => eval_equals(ctx_val, &self.value),
266            OperatorKind::NotEquals => !eval_equals(ctx_val, &self.value),
267            OperatorKind::Matches => eval_matches(ctx_val, &self.value),
268        }
269    }
270}
271
272/// Check if a scalar context value is contained in a condition array.
273/// String comparison is case-insensitive.
274fn eval_in(ctx_val: &Value, condition_val: &Value) -> bool {
275    let Some(arr) = condition_val.as_array() else {
276        return false;
277    };
278    match ctx_val {
279        Value::String(s) => {
280            let s_lower = s.to_lowercase();
281            arr.iter()
282                .any(|v| v.as_str().is_some_and(|cv| cv.to_lowercase() == s_lower))
283        }
284        Value::Number(n) => {
285            if let Some(i) = n.as_i64() {
286                arr.iter().any(|v| v.as_i64().is_some_and(|cv| cv == i))
287            } else if let Some(f) = n.as_f64() {
288                arr.iter().any(|v| v.as_f64().is_some_and(|cv| cv == f))
289            } else {
290                false
291            }
292        }
293        Value::Bool(b) => arr.iter().any(|v| v.as_bool().is_some_and(|cv| cv == *b)),
294        _ => false,
295    }
296}
297
298/// Check if a context array contains a condition scalar value.
299/// String comparison is case-insensitive.
300fn eval_contains(ctx_val: &Value, condition_val: &Value) -> bool {
301    let Some(ctx_arr) = ctx_val.as_array() else {
302        return false;
303    };
304    match condition_val {
305        Value::String(s) => {
306            let s_lower = s.to_lowercase();
307            ctx_arr
308                .iter()
309                .any(|v| v.as_str().is_some_and(|cv| cv.to_lowercase() == s_lower))
310        }
311        Value::Number(n) => {
312            if let Some(i) = n.as_i64() {
313                ctx_arr.iter().any(|v| v.as_i64().is_some_and(|cv| cv == i))
314            } else if let Some(f) = n.as_f64() {
315                ctx_arr.iter().any(|v| v.as_f64().is_some_and(|cv| cv == f))
316            } else {
317                false
318            }
319        }
320        Value::Bool(b) => ctx_arr
321            .iter()
322            .any(|v| v.as_bool().is_some_and(|cv| cv == *b)),
323        _ => false,
324    }
325}
326
327/// Check if a context value equals a condition value.
328/// Scalars are compared directly (strings case-insensitively).
329/// Arrays are compared element-wise with matching length.
330fn eval_equals(ctx_val: &Value, condition_val: &Value) -> bool {
331    match (ctx_val, condition_val) {
332        (Value::String(a), Value::String(b)) => a.to_lowercase() == b.to_lowercase(),
333        (Value::Number(a), Value::Number(b)) => {
334            // Compare as i64 first, fall back to f64
335            if let (Some(ai), Some(bi)) = (a.as_i64(), b.as_i64()) {
336                ai == bi
337            } else if let (Some(af), Some(bf)) = (a.as_f64(), b.as_f64()) {
338                af == bf
339            } else {
340                false
341            }
342        }
343        (Value::Bool(a), Value::Bool(b)) => a == b,
344        (Value::Array(a), Value::Array(b)) => {
345            a.len() == b.len() && a.iter().zip(b.iter()).all(|(av, bv)| eval_equals(av, bv))
346        }
347        _ => false,
348    }
349}
350
351/// Match a value string against a single star-only glob pattern (case-insensitive).
352/// '*' matches zero or more characters. All other characters, including '?' and '[',
353/// are treated as literals.
354fn glob_star_match(pattern: &str, value: &str) -> bool {
355    let pattern = pattern.to_lowercase();
356    let value = value.to_lowercase();
357    let parts: Vec<&str> = pattern.split('*').collect();
358    // No wildcard — require exact equality.
359    if parts.len() == 1 {
360        return value == pattern;
361    }
362    // Check prefix anchor.
363    if !value.starts_with(parts[0]) {
364        return false;
365    }
366    // Check suffix anchor (skip when the last part is empty, i.e. pattern ends with '*').
367    if !parts[parts.len() - 1].is_empty() && !value.ends_with(parts[parts.len() - 1]) {
368        return false;
369    }
370    // Search window: after the prefix, before the suffix.
371    let end = if parts[parts.len() - 1].is_empty() {
372        value.len()
373    } else {
374        value.len() - parts[parts.len() - 1].len()
375    };
376    let mut start = parts[0].len();
377    // The prefix and suffix anchors overlap, meaning the
378    // value is shorter than prefix + suffix combined — no valid match possible.
379    if start > end {
380        return false;
381    }
382    // Walk middle segments left-to-right, advancing the cursor on each hit.
383    for part in &parts[1..parts.len() - 1] {
384        if part.is_empty() {
385            // Skip consecutive '*'s.
386            continue;
387        }
388        match value[start..end].find(*part) {
389            Some(idx) => start += idx + part.len(),
390            None => return false,
391        }
392    }
393    true
394}
395
396/// Check if a string context value matches any pattern in the condition array.
397/// Patterns use star-only glob semantics; comparison is case-insensitive.
398/// Returns false for non-string context values.
399fn eval_matches(ctx_val: &Value, condition_val: &Value) -> bool {
400    let Some(s) = ctx_val.as_str() else {
401        return false;
402    };
403    let Some(arr) = condition_val.as_array() else {
404        return false;
405    };
406    arr.iter().any(|v| {
407        v.as_str()
408            .is_some_and(|pattern| glob_star_match(pattern, s))
409    })
410}
411
412#[derive(Debug, PartialEq)]
413enum DebugLogLevel {
414    None,
415    Parse,
416    Match,
417    All,
418}
419
420static DEBUG_LOG_LEVEL: OnceLock<DebugLogLevel> = OnceLock::new();
421static DEBUG_MATCH_SAMPLE_RATE: OnceLock<u64> = OnceLock::new();
422
423fn debug_log_level() -> &'static DebugLogLevel {
424    DEBUG_LOG_LEVEL.get_or_init(|| {
425        match std::env::var("SENTRY_OPTIONS_FEATURE_DEBUG_LOG")
426            .as_deref()
427            .unwrap_or("")
428        {
429            "all" => DebugLogLevel::All,
430            "parse" => DebugLogLevel::Parse,
431            "match" => DebugLogLevel::Match,
432            _ => DebugLogLevel::None,
433        }
434    })
435}
436
437fn debug_match_sample_rate() -> u64 {
438    *DEBUG_MATCH_SAMPLE_RATE.get_or_init(|| {
439        std::env::var("SENTRY_OPTIONS_FEATURE_DEBUG_LOG_SAMPLE_RATE")
440            .ok()
441            .and_then(|v| v.parse::<f64>().ok())
442            .map(|r| (r.clamp(0.0, 1.0) * 1000.0) as u64)
443            .unwrap_or(1000)
444    })
445}
446
447fn debug_log_parse(msg: &str) {
448    match debug_log_level() {
449        DebugLogLevel::Parse | DebugLogLevel::All => eprintln!("[sentry-options/parse] {msg}"),
450        _ => {}
451    }
452}
453
454fn debug_log_match(feature: &str, result: bool, context_id: u64) {
455    match debug_log_level() {
456        DebugLogLevel::Match | DebugLogLevel::All
457            if context_id % 1000 < debug_match_sample_rate() =>
458        {
459            eprintln!(
460                "[sentry-options/match] feature='{feature}' result={result} context_id={context_id}"
461            );
462        }
463        _ => {}
464    }
465}
466
467/// A handle for checking feature flags within a specific namespace.
468pub struct FeatureChecker {
469    namespace: String,
470    options: Option<&'static crate::Options>,
471}
472
473impl FeatureChecker {
474    pub fn new(namespace: String, options: &'static crate::Options) -> Self {
475        Self {
476            namespace,
477            options: Some(options),
478        }
479    }
480
481    /// Check whether a feature flag is enabled for a given context.
482    ///
483    /// Returns false if the feature is not defined, not enabled, conditions don't match,
484    /// or options have not been initialized.
485    pub fn has(&self, feature_name: &str, context: &FeatureContext) -> bool {
486        let Some(opts) = self.options else {
487            return false;
488        };
489        let key = format!("feature.{feature_name}");
490
491        let feature_val = match opts.get(&self.namespace, &key) {
492            Ok(v) => v,
493            Err(e) => {
494                debug_log_parse(&format!("Failed to get feature '{key}': {e}"));
495                return false;
496            }
497        };
498
499        let feature = match Feature::from_json(&feature_val) {
500            Some(f) => {
501                debug_log_parse(&format!("Parsed feature '{key}'"));
502                f
503            }
504            None => {
505                debug_log_parse(&format!("Failed to parse feature '{key}'"));
506                return false;
507            }
508        };
509
510        let result = feature.matches(context);
511        debug_log_match(feature_name, result, context.id());
512        result
513    }
514}
515
516/// Get a feature checker handle for a namespace.
517///
518/// Returns a handle that returns false for all checks if `init()` has not been called.
519pub fn features(namespace: &str) -> FeatureChecker {
520    FeatureChecker {
521        namespace: namespace.to_string(),
522        options: crate::GLOBAL_OPTIONS.get(),
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529    use crate::Options;
530    use serde_json::json;
531    use std::fs;
532    use std::path::Path;
533    use tempfile::TempDir;
534
535    fn create_schema(dir: &Path, namespace: &str, schema: &str) {
536        let schema_dir = dir.join(namespace);
537        fs::create_dir_all(&schema_dir).unwrap();
538        fs::write(schema_dir.join("schema.json"), schema).unwrap();
539    }
540
541    fn create_values(dir: &Path, namespace: &str, values: &str) {
542        let ns_dir = dir.join(namespace);
543        fs::create_dir_all(&ns_dir).unwrap();
544        fs::write(ns_dir.join("values.json"), values).unwrap();
545    }
546
547    const FEATURE_SCHEMA: &str = r##"{
548        "version": "1.0",
549        "type": "object",
550        "properties": {
551            "feature.organizations:test-feature": {
552                "$ref": "#/definitions/Feature"
553            }
554        }
555    }"##;
556
557    fn setup_feature_options(feature_json: &str) -> (Options, TempDir) {
558        let temp = TempDir::new().unwrap();
559        let schemas = temp.path().join("schemas");
560        fs::create_dir_all(&schemas).unwrap();
561        create_schema(&schemas, "test", FEATURE_SCHEMA);
562
563        let values = temp.path().join("values");
564        let values_json = format!(
565            r#"{{"options": {{"feature.organizations:test-feature": {}}}}}"#,
566            feature_json
567        );
568        create_values(&values, "test", &values_json);
569
570        let opts = Options::from_directory(temp.path()).unwrap();
571        (opts, temp)
572    }
573
574    fn feature_json(enabled: bool, rollout: u64, conditions: &str) -> String {
575        format!(
576            r#"{{
577                "name": "test-feature",
578                "enabled": {enabled},
579                "owner": {{"team": "test-team"}},
580                "created_at": "2024-01-01",
581                "segments": [{{
582                    "name": "test-segment",
583                    "rollout": {rollout},
584                    "conditions": [{conditions}]
585                }}]
586            }}"#
587        )
588    }
589
590    fn in_condition(property: &str, values: &str) -> String {
591        format!(r#"{{"property": "{property}", "operator": "in", "value": [{values}]}}"#)
592    }
593
594    fn check(opts: &Options, feature: &str, ctx: &FeatureContext) -> bool {
595        let key = format!("feature.{feature}");
596        let Ok(val) = opts.get("test", &key) else {
597            return false;
598        };
599        Feature::from_json(&val).is_some_and(|f| f.matches(ctx))
600    }
601
602    #[test]
603    fn test_feature_context_insert_and_get() {
604        let mut ctx = FeatureContext::new();
605        ctx.insert("org_id", json!(123));
606        ctx.insert("name", json!("sentry"));
607        ctx.insert("active", json!(true));
608
609        assert!(ctx.has("org_id"));
610        assert!(!ctx.has("missing"));
611        assert_eq!(ctx.get("org_id"), Some(&json!(123)));
612        assert_eq!(ctx.get("name"), Some(&json!("sentry")));
613    }
614
615    #[test]
616    fn test_feature_context_id_is_cached() {
617        let mut ctx = FeatureContext::new();
618        ctx.identity_fields(vec!["user_id"]);
619        ctx.insert("user_id", json!(42));
620
621        let id1 = ctx.id();
622        let id2 = ctx.id();
623        assert_eq!(id1, id2, "ID should be cached and consistent");
624    }
625
626    #[test]
627    fn test_feature_context_id_resets_on_identity_change() {
628        let mut ctx = FeatureContext::new();
629        ctx.insert("user_id", json!(1));
630        ctx.insert("org_id", json!(2));
631
632        ctx.identity_fields(vec!["user_id"]);
633        let id_user = ctx.id();
634
635        ctx.identity_fields(vec!["org_id"]);
636        let id_org = ctx.id();
637
638        assert_ne!(
639            id_user, id_org,
640            "Different identity fields should produce different IDs"
641        );
642    }
643
644    #[test]
645    fn test_feature_context_id_deterministic() {
646        let make_ctx = || {
647            let mut ctx = FeatureContext::new();
648            ctx.identity_fields(vec!["user_id", "org_id"]);
649            ctx.insert("user_id", json!(456));
650            ctx.insert("org_id", json!(123));
651            ctx
652        };
653
654        assert_eq!(make_ctx().id(), make_ctx().id());
655
656        let mut other_ctx = FeatureContext::new();
657        other_ctx.identity_fields(vec!["user_id", "org_id"]);
658        other_ctx.insert("user_id", json!(789));
659        other_ctx.insert("org_id", json!(123));
660
661        assert_ne!(make_ctx().id(), other_ctx.id());
662    }
663
664    #[test]
665    fn test_feature_context_id_value_align_with_python() {
666        // Context.id() determines rollout rates with modulo
667        // This implementation should generate the same rollout slots
668        // as the previous implementation did.
669        let ctx = FeatureContext::new();
670        assert_eq!(ctx.id() % 100, 5, "should match with python implementation");
671
672        let mut ctx = FeatureContext::new();
673        ctx.insert("foo", json!("bar"));
674        ctx.insert("baz", json!("barfoo"));
675        ctx.identity_fields(vec!["foo"]);
676        assert_eq!(ctx.id() % 100, 62);
677
678        // Undefined fields should not contribute to the id.
679        let mut ctx = FeatureContext::new();
680        ctx.insert("foo", json!("bar"));
681        ctx.insert("baz", json!("barfoo"));
682        ctx.identity_fields(vec!["foo", "whoops"]);
683        assert_eq!(ctx.id() % 100, 62);
684
685        let mut ctx = FeatureContext::new();
686        ctx.insert("foo", json!("bar"));
687        ctx.insert("baz", json!("barfoo"));
688        ctx.identity_fields(vec!["foo", "baz"]);
689        assert_eq!(ctx.id() % 100, 1);
690
691        let mut ctx = FeatureContext::new();
692        ctx.insert("foo", json!("bar"));
693        ctx.insert("baz", json!("barfoo"));
694        // When there is no overlap with identity fields and data,
695        // all fields should be used
696        ctx.identity_fields(vec!["whoops", "nope"]);
697        assert_eq!(ctx.id() % 100, 1);
698    }
699
700    #[test]
701    fn test_feature_prefix_is_added() {
702        let cond = in_condition("organization_id", "123");
703        let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
704
705        let mut ctx = FeatureContext::new();
706        ctx.insert("organization_id", json!(123));
707
708        assert!(check(&opts, "organizations:test-feature", &ctx));
709    }
710
711    #[test]
712    fn test_undefined_feature_returns_false() {
713        let cond = in_condition("organization_id", "123");
714        let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
715
716        let ctx = FeatureContext::new();
717        assert!(!check(&opts, "nonexistent", &ctx));
718    }
719
720    #[test]
721    fn test_missing_context_field_returns_false() {
722        let cond = in_condition("organization_id", "123");
723        let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
724
725        let ctx = FeatureContext::new();
726        assert!(!check(&opts, "organizations:test-feature", &ctx));
727    }
728
729    #[test]
730    fn test_matching_context_returns_true() {
731        let cond = in_condition("organization_id", "123, 456");
732        let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
733
734        let mut ctx = FeatureContext::new();
735        ctx.insert("organization_id", json!(123));
736
737        assert!(check(&opts, "organizations:test-feature", &ctx));
738    }
739
740    #[test]
741    fn test_non_matching_context_returns_false() {
742        let cond = in_condition("organization_id", "123, 456");
743        let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
744
745        let mut ctx = FeatureContext::new();
746        ctx.insert("organization_id", json!(999));
747
748        assert!(!check(&opts, "organizations:test-feature", &ctx));
749    }
750
751    #[test]
752    fn test_disabled_feature_returns_false() {
753        let cond = in_condition("organization_id", "123");
754        let (opts, _t) = setup_feature_options(&feature_json(false, 100, &cond));
755
756        let mut ctx = FeatureContext::new();
757        ctx.insert("organization_id", json!(123));
758
759        assert!(!check(&opts, "organizations:test-feature", &ctx));
760    }
761
762    #[test]
763    fn test_rollout_zero_returns_false() {
764        let cond = in_condition("organization_id", "123");
765        let (opts, _t) = setup_feature_options(&feature_json(true, 0, &cond));
766
767        let mut ctx = FeatureContext::new();
768        ctx.insert("organization_id", json!(123));
769
770        assert!(!check(&opts, "organizations:test-feature", &ctx));
771    }
772
773    #[test]
774    fn test_rollout_100_returns_true() {
775        let cond = in_condition("organization_id", "123");
776        let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
777
778        let mut ctx = FeatureContext::new();
779        ctx.insert("organization_id", json!(123));
780
781        assert!(check(&opts, "organizations:test-feature", &ctx));
782    }
783
784    #[test]
785    fn test_rollout_is_deterministic() {
786        let mut ctx = FeatureContext::new();
787        ctx.identity_fields(vec!["user_id"]);
788        ctx.insert("user_id", json!(42));
789        ctx.insert("organization_id", json!(123));
790
791        // Add 1 to get around fence post with < vs <=
792        let id_mod = (ctx.id() % 100) + 1;
793        let cond = in_condition("organization_id", "123");
794
795        let (opts_at, _t1) = setup_feature_options(&feature_json(true, id_mod, &cond));
796        assert!(check(&opts_at, "organizations:test-feature", &ctx));
797    }
798
799    #[test]
800    fn test_condition_in_string_case_insensitive() {
801        let cond = r#"{"property": "slug", "operator": "in", "value": ["Sentry", "ACME"]}"#;
802        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
803
804        let mut ctx = FeatureContext::new();
805        ctx.insert("slug", json!("sentry"));
806        assert!(check(&opts, "organizations:test-feature", &ctx));
807    }
808
809    #[test]
810    fn test_condition_not_in() {
811        let cond = r#"{"property": "organization_id", "operator": "not_in", "value": [999]}"#;
812        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
813
814        let mut ctx = FeatureContext::new();
815        ctx.insert("organization_id", json!(123));
816        assert!(check(&opts, "organizations:test-feature", &ctx));
817
818        let mut ctx2 = FeatureContext::new();
819        ctx2.insert("organization_id", json!(999));
820        assert!(!check(&opts, "organizations:test-feature", &ctx2));
821    }
822
823    #[test]
824    fn test_condition_contains() {
825        let cond = r#"{"property": "tags", "operator": "contains", "value": "beta"}"#;
826        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
827
828        let mut ctx = FeatureContext::new();
829        ctx.insert("tags", json!(["alpha", "beta"]));
830        assert!(check(&opts, "organizations:test-feature", &ctx));
831
832        let mut ctx2 = FeatureContext::new();
833        ctx2.insert("tags", json!(["alpha"]));
834        assert!(!check(&opts, "organizations:test-feature", &ctx2));
835    }
836
837    #[test]
838    fn test_condition_equals() {
839        let cond = r#"{"property": "plan", "operator": "equals", "value": "enterprise"}"#;
840        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
841
842        let mut ctx = FeatureContext::new();
843        ctx.insert("plan", json!("Enterprise"));
844        assert!(check(&opts, "organizations:test-feature", &ctx));
845
846        let mut ctx2 = FeatureContext::new();
847        ctx2.insert("plan", json!("free"));
848        assert!(!check(&opts, "organizations:test-feature", &ctx2));
849    }
850
851    #[test]
852    fn test_condition_equals_bool() {
853        let cond = r#"{"property": "is_free", "operator": "equals", "value": true}"#;
854        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
855
856        let mut ctx = FeatureContext::new();
857        ctx.insert("is_free", json!(true));
858        assert!(check(&opts, "organizations:test-feature", &ctx));
859
860        let mut ctx2 = FeatureContext::new();
861        ctx2.insert("is_free", json!(false));
862        assert!(!check(&opts, "organizations:test-feature", &ctx2));
863    }
864
865    #[test]
866    fn test_segment_with_no_conditions_always_matches() {
867        let feature = r#"{
868            "name": "test-feature",
869            "enabled": true,
870            "owner": {"team": "test-team"},
871            "created_at": "2024-01-01",
872            "segments": [{"name": "open", "rollout": 100, "conditions": []}]
873        }"#;
874        let (opts, _t) = setup_feature_options(feature);
875
876        let ctx = FeatureContext::new();
877        assert!(check(&opts, "organizations:test-feature", &ctx));
878    }
879
880    #[test]
881    fn test_feature_enabled_and_rollout_default_values() {
882        // This feature doesn't define .enabled or .segments[0].rollout
883        let feature = r#"{
884            "name": "test-feature",
885            "owner": {"team": "test-team"},
886            "created_at": "2024-01-01",
887            "segments": [
888                {
889                    "name": "first",
890                    "conditions": [{"property": "org_id", "operator": "in", "value":[1]}]
891                }
892            ]
893        }"#;
894        let (opts, _t) = setup_feature_options(feature);
895
896        let mut ctx = FeatureContext::new();
897        ctx.insert("org_id", 1);
898        ctx.identity_fields(vec!["org_id"]);
899        assert!(check(&opts, "organizations:test-feature", &ctx));
900    }
901
902    #[test]
903    fn test_feature_with_no_segments_returns_false() {
904        let feature = r#"{
905            "name": "test-feature",
906            "enabled": true,
907            "owner": {"team": "test-team"},
908            "created_at": "2024-01-01",
909            "segments": []
910        }"#;
911        let (opts, _t) = setup_feature_options(feature);
912
913        let ctx = FeatureContext::new();
914        assert!(!check(&opts, "organizations:test-feature", &ctx));
915    }
916
917    #[test]
918    fn test_multiple_segments_or_logic() {
919        let feature = r#"{
920            "name": "test-feature",
921            "enabled": true,
922            "owner": {"team": "test-team"},
923            "created_at": "2024-01-01",
924            "segments": [
925                {
926                    "name": "segment-a",
927                    "rollout": 100,
928                    "conditions": [{"property": "org_id", "operator": "in", "value": [1]}]
929                },
930                {
931                    "name": "segment-b",
932                    "rollout": 100,
933                    "conditions": [{"property": "org_id", "operator": "in", "value": [2]}]
934                }
935            ]
936        }"#;
937        let (opts, _t) = setup_feature_options(feature);
938
939        let mut ctx1 = FeatureContext::new();
940        ctx1.insert("org_id", json!(1));
941        assert!(check(&opts, "organizations:test-feature", &ctx1));
942
943        let mut ctx2 = FeatureContext::new();
944        ctx2.insert("org_id", json!(2));
945        assert!(check(&opts, "organizations:test-feature", &ctx2));
946
947        let mut ctx3 = FeatureContext::new();
948        ctx3.insert("org_id", json!(3));
949        assert!(!check(&opts, "organizations:test-feature", &ctx3));
950    }
951
952    #[test]
953    fn test_multiple_conditions_and_logic() {
954        let conds = r#"
955            {"property": "org_id", "operator": "in", "value": [123]},
956            {"property": "user_email", "operator": "in", "value": ["admin@example.com"]}
957        "#;
958        let (opts, _t) = setup_feature_options(&feature_json(true, 100, conds));
959
960        let mut ctx = FeatureContext::new();
961        ctx.insert("org_id", json!(123));
962        ctx.insert("user_email", json!("admin@example.com"));
963        assert!(check(&opts, "organizations:test-feature", &ctx));
964
965        let mut ctx2 = FeatureContext::new();
966        ctx2.insert("org_id", json!(123));
967        ctx2.insert("user_email", json!("other@example.com"));
968        assert!(!check(&opts, "organizations:test-feature", &ctx2));
969    }
970
971    #[test]
972    fn test_in_int_context_against_string_list_returns_false() {
973        let cond = r#"{"property": "org_id", "operator": "in", "value": ["123", "456"]}"#;
974        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
975
976        let mut ctx = FeatureContext::new();
977        ctx.insert("org_id", json!(123));
978        assert!(!check(&opts, "organizations:test-feature", &ctx));
979    }
980
981    #[test]
982    fn test_in_string_context_against_int_list_returns_false() {
983        let cond = r#"{"property": "slug", "operator": "in", "value": [123, 456]}"#;
984        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
985
986        let mut ctx = FeatureContext::new();
987        ctx.insert("slug", json!("123"));
988        assert!(!check(&opts, "organizations:test-feature", &ctx));
989    }
990
991    #[test]
992    fn test_in_bool_context_against_string_list_returns_false() {
993        let cond = r#"{"property": "active", "operator": "in", "value": ["true", "false"]}"#;
994        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
995
996        let mut ctx = FeatureContext::new();
997        ctx.insert("active", json!(true));
998        assert!(!check(&opts, "organizations:test-feature", &ctx));
999    }
1000
1001    #[test]
1002    fn test_in_float_context_against_string_list_returns_false() {
1003        let cond = r#"{"property": "score", "operator": "in", "value": ["0.5", "1.0"]}"#;
1004        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1005
1006        let mut ctx = FeatureContext::new();
1007        ctx.insert("score", json!(0.5));
1008        assert!(!check(&opts, "organizations:test-feature", &ctx));
1009    }
1010
1011    #[test]
1012    fn test_not_in_int_context_against_string_list_returns_true() {
1013        // Type mismatch means "in" is false, so "not_in" is true
1014        let cond = r#"{"property": "org_id", "operator": "not_in", "value": ["123", "456"]}"#;
1015        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1016
1017        let mut ctx = FeatureContext::new();
1018        ctx.insert("org_id", json!(123));
1019        assert!(check(&opts, "organizations:test-feature", &ctx));
1020    }
1021
1022    #[test]
1023    fn test_not_in_string_context_against_int_list_returns_true() {
1024        // Type mismatch means "in" is false, so "not_in" is true
1025        let cond = r#"{"property": "slug", "operator": "not_in", "value": [123, 456]}"#;
1026        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1027
1028        let mut ctx = FeatureContext::new();
1029        ctx.insert("slug", json!("123"));
1030        assert!(check(&opts, "organizations:test-feature", &ctx));
1031    }
1032
1033    #[test]
1034    fn test_condition_matches_literal() {
1035        let cond = r#"{"property": "slug", "operator": "matches", "value": ["sentry"]}"#;
1036        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1037
1038        let mut ctx = FeatureContext::new();
1039        ctx.insert("slug", json!("sentry"));
1040        assert!(check(&opts, "organizations:test-feature", &ctx));
1041
1042        let mut ctx2 = FeatureContext::new();
1043        ctx2.insert("slug", json!("getsentry"));
1044        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1045    }
1046
1047    #[test]
1048    fn test_condition_matches_prefix_wildcard() {
1049        let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
1050        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1051
1052        let mut ctx = FeatureContext::new();
1053        ctx.insert("slug", json!("jayonb73"));
1054        assert!(check(&opts, "organizations:test-feature", &ctx));
1055
1056        // '*' matches zero chars too
1057        let mut ctx2 = FeatureContext::new();
1058        ctx2.insert("slug", json!("jayonb"));
1059        assert!(check(&opts, "organizations:test-feature", &ctx2));
1060
1061        let mut ctx3 = FeatureContext::new();
1062        ctx3.insert("slug", json!("dangoldonb1"));
1063        assert!(!check(&opts, "organizations:test-feature", &ctx3));
1064    }
1065
1066    #[test]
1067    fn test_condition_matches_prefix_and_suffix_wildcard() {
1068        let cond = r#"{"property": "email", "operator": "matches", "value": ["jay.goss+onboarding*@sentry.io"]}"#;
1069        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1070
1071        let mut ctx = FeatureContext::new();
1072        ctx.insert("email", json!("jay.goss+onboarding70@sentry.io"));
1073        assert!(check(&opts, "organizations:test-feature", &ctx));
1074
1075        // '*' matches zero chars — prefix runs directly into suffix
1076        let mut ctx2 = FeatureContext::new();
1077        ctx2.insert("email", json!("jay.goss+onboarding@sentry.io"));
1078        assert!(check(&opts, "organizations:test-feature", &ctx2));
1079
1080        let mut ctx3 = FeatureContext::new();
1081        ctx3.insert("email", json!("jay.goss+onboarding70@example.com"));
1082        assert!(!check(&opts, "organizations:test-feature", &ctx3));
1083    }
1084
1085    #[test]
1086    fn test_condition_matches_suffix_wildcard() {
1087        let cond = r#"{"property": "email", "operator": "matches", "value": ["*@sentry.io"]}"#;
1088        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1089
1090        let mut ctx = FeatureContext::new();
1091        ctx.insert("email", json!("user@sentry.io"));
1092        assert!(check(&opts, "organizations:test-feature", &ctx));
1093
1094        let mut ctx2 = FeatureContext::new();
1095        ctx2.insert("email", json!("user@example.com"));
1096        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1097    }
1098
1099    #[test]
1100    fn test_condition_matches_multi_segment_wildcard() {
1101        let cond = r#"{"property": "name", "operator": "matches", "value": ["a*b*c"]}"#;
1102        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1103
1104        let mut ctx = FeatureContext::new();
1105        ctx.insert("name", json!("abc"));
1106        assert!(check(&opts, "organizations:test-feature", &ctx));
1107
1108        let mut ctx2 = FeatureContext::new();
1109        ctx2.insert("name", json!("aXbYc"));
1110        assert!(check(&opts, "organizations:test-feature", &ctx2));
1111
1112        let mut ctx3 = FeatureContext::new();
1113        ctx3.insert("name", json!("aXXbYYc"));
1114        assert!(check(&opts, "organizations:test-feature", &ctx3));
1115
1116        let mut ctx4 = FeatureContext::new();
1117        ctx4.insert("name", json!("aXXc"));
1118        assert!(!check(&opts, "organizations:test-feature", &ctx4));
1119    }
1120
1121    #[test]
1122    fn test_condition_matches_star_only_pattern() {
1123        let cond = r#"{"property": "slug", "operator": "matches", "value": ["*"]}"#;
1124        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1125
1126        let mut ctx = FeatureContext::new();
1127        ctx.insert("slug", json!("anything"));
1128        assert!(check(&opts, "organizations:test-feature", &ctx));
1129
1130        let mut ctx2 = FeatureContext::new();
1131        ctx2.insert("slug", json!(""));
1132        assert!(check(&opts, "organizations:test-feature", &ctx2));
1133    }
1134
1135    #[test]
1136    fn test_condition_matches_case_insensitive() {
1137        let cond = r#"{"property": "slug", "operator": "matches", "value": ["JAYONB*"]}"#;
1138        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1139
1140        let mut ctx = FeatureContext::new();
1141        ctx.insert("slug", json!("jayonb73"));
1142        assert!(check(&opts, "organizations:test-feature", &ctx));
1143
1144        // Pattern lowercase, value uppercase
1145        let cond2 = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
1146        let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
1147
1148        let mut ctx2 = FeatureContext::new();
1149        ctx2.insert("slug", json!("JAYONB73"));
1150        assert!(check(&opts2, "organizations:test-feature", &ctx2));
1151    }
1152
1153    #[test]
1154    fn test_condition_matches_no_match() {
1155        let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
1156        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1157
1158        let mut ctx = FeatureContext::new();
1159        ctx.insert("slug", json!("dangoldonb1"));
1160        assert!(!check(&opts, "organizations:test-feature", &ctx));
1161    }
1162
1163    #[test]
1164    fn test_condition_matches_multiple_patterns() {
1165        let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*", "dangoldonb*", "value-disc-*"]}"#;
1166        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1167
1168        let slugs = [
1169            ("jayonb73", true),
1170            ("dangoldonb3", true),
1171            ("value-disc-7", true),
1172            ("other-org", false),
1173        ];
1174        for (slug, expected) in slugs {
1175            let mut ctx = FeatureContext::new();
1176            ctx.insert("slug", json!(slug));
1177            assert_eq!(
1178                check(&opts, "organizations:test-feature", &ctx),
1179                expected,
1180                "slug={slug}"
1181            );
1182        }
1183    }
1184
1185    #[test]
1186    fn test_condition_matches_overlapping_prefix_suffix_anchors() {
1187        // "a*a" requires at least "aa" — a single "a" must not match.
1188        let cond = r#"{"property": "slug", "operator": "matches", "value": ["a*a"]}"#;
1189        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1190
1191        let mut ctx = FeatureContext::new();
1192        ctx.insert("slug", json!("a"));
1193        assert!(!check(&opts, "organizations:test-feature", &ctx));
1194
1195        let mut ctx2 = FeatureContext::new();
1196        ctx2.insert("slug", json!("aa"));
1197        assert!(check(&opts, "organizations:test-feature", &ctx2));
1198
1199        // "ab*ab" requires at least "abab" — "ab" alone must not match.
1200        let cond2 = r#"{"property": "slug", "operator": "matches", "value": ["ab*ab"]}"#;
1201        let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
1202
1203        let mut ctx3 = FeatureContext::new();
1204        ctx3.insert("slug", json!("ab"));
1205        assert!(!check(&opts2, "organizations:test-feature", &ctx3));
1206
1207        let mut ctx4 = FeatureContext::new();
1208        ctx4.insert("slug", json!("abab"));
1209        assert!(check(&opts2, "organizations:test-feature", &ctx4));
1210    }
1211
1212    #[test]
1213    fn test_condition_matches_non_string_context_returns_false() {
1214        // Non-string context values should not match any pattern — eval_matches returns false.
1215        let cond = r#"{"property": "org_id", "operator": "matches", "value": ["123*"]}"#;
1216        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1217
1218        let mut ctx = FeatureContext::new();
1219        ctx.insert("org_id", json!(123));
1220        assert!(!check(&opts, "organizations:test-feature", &ctx));
1221    }
1222}