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}
156
157#[derive(Debug)]
158struct Condition {
159    property: String,
160    operator: OperatorKind,
161    value: Value,
162}
163
164#[derive(Debug)]
165struct Segment {
166    rollout: u64,
167    conditions: Vec<Condition>,
168}
169
170#[derive(Debug)]
171struct Feature {
172    enabled: bool,
173    segments: Vec<Segment>,
174}
175
176impl Feature {
177    fn from_json(value: &Value) -> Option<Self> {
178        // Default to true to align with flagpole behavior.
179        let enabled = value
180            .get("enabled")
181            .and_then(|v| v.as_bool())
182            .unwrap_or(true);
183        let segments = value
184            .get("segments")?
185            .as_array()?
186            .iter()
187            .filter_map(Segment::from_json)
188            .collect();
189        Some(Feature { enabled, segments })
190    }
191
192    fn matches(&self, context: &FeatureContext) -> bool {
193        if !self.enabled {
194            return false;
195        }
196        for segment in &self.segments {
197            if segment.conditions_match(context) {
198                return segment.in_rollout(context);
199            }
200        }
201        false
202    }
203}
204
205impl Segment {
206    fn from_json(value: &Value) -> Option<Self> {
207        let rollout = value.get("rollout").and_then(|v| v.as_u64()).unwrap_or(100);
208        let conditions = value
209            .get("conditions")
210            .and_then(|v| v.as_array())
211            .map(|arr| arr.iter().filter_map(Condition::from_json).collect())
212            .unwrap_or_default();
213        Some(Segment {
214            rollout,
215            conditions,
216        })
217    }
218
219    fn conditions_match(&self, context: &FeatureContext) -> bool {
220        self.conditions.iter().all(|c| c.matches(context))
221    }
222
223    fn in_rollout(&self, context: &FeatureContext) -> bool {
224        if self.rollout == 0 {
225            return false;
226        }
227        if self.rollout >= 100 {
228            return true;
229        }
230        context.id() % 100 < self.rollout
231    }
232}
233
234impl Condition {
235    fn from_json(value: &Value) -> Option<Self> {
236        let property = value.get("property")?.as_str()?.to_string();
237        let operator = match value.get("operator")?.as_str()? {
238            "in" => OperatorKind::In,
239            "not_in" => OperatorKind::NotIn,
240            "contains" => OperatorKind::Contains,
241            "not_contains" => OperatorKind::NotContains,
242            "equals" => OperatorKind::Equals,
243            "not_equals" => OperatorKind::NotEquals,
244            _ => return None,
245        };
246        let value = value.get("value")?.clone();
247        Some(Condition {
248            property,
249            operator,
250            value,
251        })
252    }
253
254    fn matches(&self, context: &FeatureContext) -> bool {
255        let Some(ctx_val) = context.get(&self.property) else {
256            return false;
257        };
258        match &self.operator {
259            OperatorKind::In => eval_in(ctx_val, &self.value),
260            OperatorKind::NotIn => !eval_in(ctx_val, &self.value),
261            OperatorKind::Contains => eval_contains(ctx_val, &self.value),
262            OperatorKind::NotContains => !eval_contains(ctx_val, &self.value),
263            OperatorKind::Equals => eval_equals(ctx_val, &self.value),
264            OperatorKind::NotEquals => !eval_equals(ctx_val, &self.value),
265        }
266    }
267}
268
269/// Check if a scalar context value is contained in a condition array.
270/// String comparison is case-insensitive.
271fn eval_in(ctx_val: &Value, condition_val: &Value) -> bool {
272    let Some(arr) = condition_val.as_array() else {
273        return false;
274    };
275    match ctx_val {
276        Value::String(s) => {
277            let s_lower = s.to_lowercase();
278            arr.iter()
279                .any(|v| v.as_str().is_some_and(|cv| cv.to_lowercase() == s_lower))
280        }
281        Value::Number(n) => {
282            if let Some(i) = n.as_i64() {
283                arr.iter().any(|v| v.as_i64().is_some_and(|cv| cv == i))
284            } else if let Some(f) = n.as_f64() {
285                arr.iter().any(|v| v.as_f64().is_some_and(|cv| cv == f))
286            } else {
287                false
288            }
289        }
290        Value::Bool(b) => arr.iter().any(|v| v.as_bool().is_some_and(|cv| cv == *b)),
291        _ => false,
292    }
293}
294
295/// Check if a context array contains a condition scalar value.
296/// String comparison is case-insensitive.
297fn eval_contains(ctx_val: &Value, condition_val: &Value) -> bool {
298    let Some(ctx_arr) = ctx_val.as_array() else {
299        return false;
300    };
301    match condition_val {
302        Value::String(s) => {
303            let s_lower = s.to_lowercase();
304            ctx_arr
305                .iter()
306                .any(|v| v.as_str().is_some_and(|cv| cv.to_lowercase() == s_lower))
307        }
308        Value::Number(n) => {
309            if let Some(i) = n.as_i64() {
310                ctx_arr.iter().any(|v| v.as_i64().is_some_and(|cv| cv == i))
311            } else if let Some(f) = n.as_f64() {
312                ctx_arr.iter().any(|v| v.as_f64().is_some_and(|cv| cv == f))
313            } else {
314                false
315            }
316        }
317        Value::Bool(b) => ctx_arr
318            .iter()
319            .any(|v| v.as_bool().is_some_and(|cv| cv == *b)),
320        _ => false,
321    }
322}
323
324/// Check if a context value equals a condition value.
325/// Scalars are compared directly (strings case-insensitively).
326/// Arrays are compared element-wise with matching length.
327fn eval_equals(ctx_val: &Value, condition_val: &Value) -> bool {
328    match (ctx_val, condition_val) {
329        (Value::String(a), Value::String(b)) => a.to_lowercase() == b.to_lowercase(),
330        (Value::Number(a), Value::Number(b)) => {
331            // Compare as i64 first, fall back to f64
332            if let (Some(ai), Some(bi)) = (a.as_i64(), b.as_i64()) {
333                ai == bi
334            } else if let (Some(af), Some(bf)) = (a.as_f64(), b.as_f64()) {
335                af == bf
336            } else {
337                false
338            }
339        }
340        (Value::Bool(a), Value::Bool(b)) => a == b,
341        (Value::Array(a), Value::Array(b)) => {
342            a.len() == b.len() && a.iter().zip(b.iter()).all(|(av, bv)| eval_equals(av, bv))
343        }
344        _ => false,
345    }
346}
347
348#[derive(Debug, PartialEq)]
349enum DebugLogLevel {
350    None,
351    Parse,
352    Match,
353    All,
354}
355
356static DEBUG_LOG_LEVEL: OnceLock<DebugLogLevel> = OnceLock::new();
357static DEBUG_MATCH_SAMPLE_RATE: OnceLock<u64> = OnceLock::new();
358
359fn debug_log_level() -> &'static DebugLogLevel {
360    DEBUG_LOG_LEVEL.get_or_init(|| {
361        match std::env::var("SENTRY_OPTIONS_FEATURE_DEBUG_LOG")
362            .as_deref()
363            .unwrap_or("")
364        {
365            "all" => DebugLogLevel::All,
366            "parse" => DebugLogLevel::Parse,
367            "match" => DebugLogLevel::Match,
368            _ => DebugLogLevel::None,
369        }
370    })
371}
372
373fn debug_match_sample_rate() -> u64 {
374    *DEBUG_MATCH_SAMPLE_RATE.get_or_init(|| {
375        std::env::var("SENTRY_OPTIONS_FEATURE_DEBUG_LOG_SAMPLE_RATE")
376            .ok()
377            .and_then(|v| v.parse::<f64>().ok())
378            .map(|r| (r.clamp(0.0, 1.0) * 1000.0) as u64)
379            .unwrap_or(1000)
380    })
381}
382
383fn debug_log_parse(msg: &str) {
384    match debug_log_level() {
385        DebugLogLevel::Parse | DebugLogLevel::All => eprintln!("[sentry-options/parse] {msg}"),
386        _ => {}
387    }
388}
389
390fn debug_log_match(feature: &str, result: bool, context_id: u64) {
391    match debug_log_level() {
392        DebugLogLevel::Match | DebugLogLevel::All => {
393            if context_id % 1000 < debug_match_sample_rate() {
394                eprintln!(
395                    "[sentry-options/match] feature='{feature}' result={result} context_id={context_id}"
396                );
397            }
398        }
399        _ => {}
400    }
401}
402
403/// A handle for checking feature flags within a specific namespace.
404pub struct FeatureChecker {
405    namespace: String,
406    options: Option<&'static crate::Options>,
407}
408
409impl FeatureChecker {
410    pub fn new(namespace: String, options: &'static crate::Options) -> Self {
411        Self {
412            namespace,
413            options: Some(options),
414        }
415    }
416
417    /// Check whether a feature flag is enabled for a given context.
418    ///
419    /// Returns false if the feature is not defined, not enabled, conditions don't match,
420    /// or options have not been initialized.
421    pub fn has(&self, feature_name: &str, context: &FeatureContext) -> bool {
422        let Some(opts) = self.options else {
423            return false;
424        };
425        let key = format!("feature.{feature_name}");
426
427        let feature_val = match opts.get(&self.namespace, &key) {
428            Ok(v) => v,
429            Err(e) => {
430                debug_log_parse(&format!("Failed to get feature '{key}': {e}"));
431                return false;
432            }
433        };
434
435        let feature = match Feature::from_json(&feature_val) {
436            Some(f) => {
437                debug_log_parse(&format!("Parsed feature '{key}'"));
438                f
439            }
440            None => {
441                debug_log_parse(&format!("Failed to parse feature '{key}'"));
442                return false;
443            }
444        };
445
446        let result = feature.matches(context);
447        debug_log_match(feature_name, result, context.id());
448        result
449    }
450}
451
452/// Get a feature checker handle for a namespace.
453///
454/// Returns a handle that returns false for all checks if `init()` has not been called.
455pub fn features(namespace: &str) -> FeatureChecker {
456    FeatureChecker {
457        namespace: namespace.to_string(),
458        options: crate::GLOBAL_OPTIONS.get(),
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use crate::Options;
466    use serde_json::json;
467    use std::fs;
468    use std::path::Path;
469    use tempfile::TempDir;
470
471    fn create_schema(dir: &Path, namespace: &str, schema: &str) {
472        let schema_dir = dir.join(namespace);
473        fs::create_dir_all(&schema_dir).unwrap();
474        fs::write(schema_dir.join("schema.json"), schema).unwrap();
475    }
476
477    fn create_values(dir: &Path, namespace: &str, values: &str) {
478        let ns_dir = dir.join(namespace);
479        fs::create_dir_all(&ns_dir).unwrap();
480        fs::write(ns_dir.join("values.json"), values).unwrap();
481    }
482
483    const FEATURE_SCHEMA: &str = r##"{
484        "version": "1.0",
485        "type": "object",
486        "properties": {
487            "feature.organizations:test-feature": {
488                "$ref": "#/definitions/Feature"
489            }
490        }
491    }"##;
492
493    fn setup_feature_options(feature_json: &str) -> (Options, TempDir) {
494        let temp = TempDir::new().unwrap();
495        let schemas = temp.path().join("schemas");
496        fs::create_dir_all(&schemas).unwrap();
497        create_schema(&schemas, "test", FEATURE_SCHEMA);
498
499        let values = temp.path().join("values");
500        let values_json = format!(
501            r#"{{"options": {{"feature.organizations:test-feature": {}}}}}"#,
502            feature_json
503        );
504        create_values(&values, "test", &values_json);
505
506        let opts = Options::from_directory(temp.path()).unwrap();
507        (opts, temp)
508    }
509
510    fn feature_json(enabled: bool, rollout: u64, conditions: &str) -> String {
511        format!(
512            r#"{{
513                "name": "test-feature",
514                "enabled": {enabled},
515                "owner": {{"team": "test-team"}},
516                "created_at": "2024-01-01",
517                "segments": [{{
518                    "name": "test-segment",
519                    "rollout": {rollout},
520                    "conditions": [{conditions}]
521                }}]
522            }}"#
523        )
524    }
525
526    fn in_condition(property: &str, values: &str) -> String {
527        format!(r#"{{"property": "{property}", "operator": "in", "value": [{values}]}}"#)
528    }
529
530    fn check(opts: &Options, feature: &str, ctx: &FeatureContext) -> bool {
531        let key = format!("feature.{feature}");
532        let Ok(val) = opts.get("test", &key) else {
533            return false;
534        };
535        Feature::from_json(&val).is_some_and(|f| f.matches(ctx))
536    }
537
538    #[test]
539    fn test_feature_context_insert_and_get() {
540        let mut ctx = FeatureContext::new();
541        ctx.insert("org_id", json!(123));
542        ctx.insert("name", json!("sentry"));
543        ctx.insert("active", json!(true));
544
545        assert!(ctx.has("org_id"));
546        assert!(!ctx.has("missing"));
547        assert_eq!(ctx.get("org_id"), Some(&json!(123)));
548        assert_eq!(ctx.get("name"), Some(&json!("sentry")));
549    }
550
551    #[test]
552    fn test_feature_context_id_is_cached() {
553        let mut ctx = FeatureContext::new();
554        ctx.identity_fields(vec!["user_id"]);
555        ctx.insert("user_id", json!(42));
556
557        let id1 = ctx.id();
558        let id2 = ctx.id();
559        assert_eq!(id1, id2, "ID should be cached and consistent");
560    }
561
562    #[test]
563    fn test_feature_context_id_resets_on_identity_change() {
564        let mut ctx = FeatureContext::new();
565        ctx.insert("user_id", json!(1));
566        ctx.insert("org_id", json!(2));
567
568        ctx.identity_fields(vec!["user_id"]);
569        let id_user = ctx.id();
570
571        ctx.identity_fields(vec!["org_id"]);
572        let id_org = ctx.id();
573
574        assert_ne!(
575            id_user, id_org,
576            "Different identity fields should produce different IDs"
577        );
578    }
579
580    #[test]
581    fn test_feature_context_id_deterministic() {
582        let make_ctx = || {
583            let mut ctx = FeatureContext::new();
584            ctx.identity_fields(vec!["user_id", "org_id"]);
585            ctx.insert("user_id", json!(456));
586            ctx.insert("org_id", json!(123));
587            ctx
588        };
589
590        assert_eq!(make_ctx().id(), make_ctx().id());
591
592        let mut other_ctx = FeatureContext::new();
593        other_ctx.identity_fields(vec!["user_id", "org_id"]);
594        other_ctx.insert("user_id", json!(789));
595        other_ctx.insert("org_id", json!(123));
596
597        assert_ne!(make_ctx().id(), other_ctx.id());
598    }
599
600    #[test]
601    fn test_feature_context_id_value_align_with_python() {
602        // Context.id() determines rollout rates with modulo
603        // This implementation should generate the same rollout slots
604        // as the previous implementation did.
605        let ctx = FeatureContext::new();
606        assert_eq!(ctx.id() % 100, 5, "should match with python implementation");
607
608        let mut ctx = FeatureContext::new();
609        ctx.insert("foo", json!("bar"));
610        ctx.insert("baz", json!("barfoo"));
611        ctx.identity_fields(vec!["foo"]);
612        assert_eq!(ctx.id() % 100, 62);
613
614        // Undefined fields should not contribute to the id.
615        let mut ctx = FeatureContext::new();
616        ctx.insert("foo", json!("bar"));
617        ctx.insert("baz", json!("barfoo"));
618        ctx.identity_fields(vec!["foo", "whoops"]);
619        assert_eq!(ctx.id() % 100, 62);
620
621        let mut ctx = FeatureContext::new();
622        ctx.insert("foo", json!("bar"));
623        ctx.insert("baz", json!("barfoo"));
624        ctx.identity_fields(vec!["foo", "baz"]);
625        assert_eq!(ctx.id() % 100, 1);
626
627        let mut ctx = FeatureContext::new();
628        ctx.insert("foo", json!("bar"));
629        ctx.insert("baz", json!("barfoo"));
630        // When there is no overlap with identity fields and data,
631        // all fields should be used
632        ctx.identity_fields(vec!["whoops", "nope"]);
633        assert_eq!(ctx.id() % 100, 1);
634    }
635
636    #[test]
637    fn test_feature_prefix_is_added() {
638        let cond = in_condition("organization_id", "123");
639        let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
640
641        let mut ctx = FeatureContext::new();
642        ctx.insert("organization_id", json!(123));
643
644        assert!(check(&opts, "organizations:test-feature", &ctx));
645    }
646
647    #[test]
648    fn test_undefined_feature_returns_false() {
649        let cond = in_condition("organization_id", "123");
650        let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
651
652        let ctx = FeatureContext::new();
653        assert!(!check(&opts, "nonexistent", &ctx));
654    }
655
656    #[test]
657    fn test_missing_context_field_returns_false() {
658        let cond = in_condition("organization_id", "123");
659        let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
660
661        let ctx = FeatureContext::new();
662        assert!(!check(&opts, "organizations:test-feature", &ctx));
663    }
664
665    #[test]
666    fn test_matching_context_returns_true() {
667        let cond = in_condition("organization_id", "123, 456");
668        let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
669
670        let mut ctx = FeatureContext::new();
671        ctx.insert("organization_id", json!(123));
672
673        assert!(check(&opts, "organizations:test-feature", &ctx));
674    }
675
676    #[test]
677    fn test_non_matching_context_returns_false() {
678        let cond = in_condition("organization_id", "123, 456");
679        let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
680
681        let mut ctx = FeatureContext::new();
682        ctx.insert("organization_id", json!(999));
683
684        assert!(!check(&opts, "organizations:test-feature", &ctx));
685    }
686
687    #[test]
688    fn test_disabled_feature_returns_false() {
689        let cond = in_condition("organization_id", "123");
690        let (opts, _t) = setup_feature_options(&feature_json(false, 100, &cond));
691
692        let mut ctx = FeatureContext::new();
693        ctx.insert("organization_id", json!(123));
694
695        assert!(!check(&opts, "organizations:test-feature", &ctx));
696    }
697
698    #[test]
699    fn test_rollout_zero_returns_false() {
700        let cond = in_condition("organization_id", "123");
701        let (opts, _t) = setup_feature_options(&feature_json(true, 0, &cond));
702
703        let mut ctx = FeatureContext::new();
704        ctx.insert("organization_id", json!(123));
705
706        assert!(!check(&opts, "organizations:test-feature", &ctx));
707    }
708
709    #[test]
710    fn test_rollout_100_returns_true() {
711        let cond = in_condition("organization_id", "123");
712        let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
713
714        let mut ctx = FeatureContext::new();
715        ctx.insert("organization_id", json!(123));
716
717        assert!(check(&opts, "organizations:test-feature", &ctx));
718    }
719
720    #[test]
721    fn test_rollout_is_deterministic() {
722        let mut ctx = FeatureContext::new();
723        ctx.identity_fields(vec!["user_id"]);
724        ctx.insert("user_id", json!(42));
725        ctx.insert("organization_id", json!(123));
726
727        // Add 1 to get around fence post with < vs <=
728        let id_mod = (ctx.id() % 100) + 1;
729        let cond = in_condition("organization_id", "123");
730
731        let (opts_at, _t1) = setup_feature_options(&feature_json(true, id_mod, &cond));
732        assert!(check(&opts_at, "organizations:test-feature", &ctx));
733    }
734
735    #[test]
736    fn test_condition_in_string_case_insensitive() {
737        let cond = r#"{"property": "slug", "operator": "in", "value": ["Sentry", "ACME"]}"#;
738        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
739
740        let mut ctx = FeatureContext::new();
741        ctx.insert("slug", json!("sentry"));
742        assert!(check(&opts, "organizations:test-feature", &ctx));
743    }
744
745    #[test]
746    fn test_condition_not_in() {
747        let cond = r#"{"property": "organization_id", "operator": "not_in", "value": [999]}"#;
748        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
749
750        let mut ctx = FeatureContext::new();
751        ctx.insert("organization_id", json!(123));
752        assert!(check(&opts, "organizations:test-feature", &ctx));
753
754        let mut ctx2 = FeatureContext::new();
755        ctx2.insert("organization_id", json!(999));
756        assert!(!check(&opts, "organizations:test-feature", &ctx2));
757    }
758
759    #[test]
760    fn test_condition_contains() {
761        let cond = r#"{"property": "tags", "operator": "contains", "value": "beta"}"#;
762        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
763
764        let mut ctx = FeatureContext::new();
765        ctx.insert("tags", json!(["alpha", "beta"]));
766        assert!(check(&opts, "organizations:test-feature", &ctx));
767
768        let mut ctx2 = FeatureContext::new();
769        ctx2.insert("tags", json!(["alpha"]));
770        assert!(!check(&opts, "organizations:test-feature", &ctx2));
771    }
772
773    #[test]
774    fn test_condition_equals() {
775        let cond = r#"{"property": "plan", "operator": "equals", "value": "enterprise"}"#;
776        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
777
778        let mut ctx = FeatureContext::new();
779        ctx.insert("plan", json!("Enterprise"));
780        assert!(check(&opts, "organizations:test-feature", &ctx));
781
782        let mut ctx2 = FeatureContext::new();
783        ctx2.insert("plan", json!("free"));
784        assert!(!check(&opts, "organizations:test-feature", &ctx2));
785    }
786
787    #[test]
788    fn test_condition_equals_bool() {
789        let cond = r#"{"property": "is_free", "operator": "equals", "value": true}"#;
790        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
791
792        let mut ctx = FeatureContext::new();
793        ctx.insert("is_free", json!(true));
794        assert!(check(&opts, "organizations:test-feature", &ctx));
795
796        let mut ctx2 = FeatureContext::new();
797        ctx2.insert("is_free", json!(false));
798        assert!(!check(&opts, "organizations:test-feature", &ctx2));
799    }
800
801    #[test]
802    fn test_segment_with_no_conditions_always_matches() {
803        let feature = r#"{
804            "name": "test-feature",
805            "enabled": true,
806            "owner": {"team": "test-team"},
807            "created_at": "2024-01-01",
808            "segments": [{"name": "open", "rollout": 100, "conditions": []}]
809        }"#;
810        let (opts, _t) = setup_feature_options(feature);
811
812        let ctx = FeatureContext::new();
813        assert!(check(&opts, "organizations:test-feature", &ctx));
814    }
815
816    #[test]
817    fn test_feature_enabled_and_rollout_default_values() {
818        // This feature doesn't define .enabled or .segments[0].rollout
819        let feature = r#"{
820            "name": "test-feature",
821            "owner": {"team": "test-team"},
822            "created_at": "2024-01-01",
823            "segments": [
824                {
825                    "name": "first",
826                    "conditions": [{"property": "org_id", "operator": "in", "value":[1]}]
827                }
828            ]
829        }"#;
830        let (opts, _t) = setup_feature_options(feature);
831
832        let mut ctx = FeatureContext::new();
833        ctx.insert("org_id", 1);
834        ctx.identity_fields(vec!["org_id"]);
835        assert!(check(&opts, "organizations:test-feature", &ctx));
836    }
837
838    #[test]
839    fn test_feature_with_no_segments_returns_false() {
840        let feature = r#"{
841            "name": "test-feature",
842            "enabled": true,
843            "owner": {"team": "test-team"},
844            "created_at": "2024-01-01",
845            "segments": []
846        }"#;
847        let (opts, _t) = setup_feature_options(feature);
848
849        let ctx = FeatureContext::new();
850        assert!(!check(&opts, "organizations:test-feature", &ctx));
851    }
852
853    #[test]
854    fn test_multiple_segments_or_logic() {
855        let feature = r#"{
856            "name": "test-feature",
857            "enabled": true,
858            "owner": {"team": "test-team"},
859            "created_at": "2024-01-01",
860            "segments": [
861                {
862                    "name": "segment-a",
863                    "rollout": 100,
864                    "conditions": [{"property": "org_id", "operator": "in", "value": [1]}]
865                },
866                {
867                    "name": "segment-b",
868                    "rollout": 100,
869                    "conditions": [{"property": "org_id", "operator": "in", "value": [2]}]
870                }
871            ]
872        }"#;
873        let (opts, _t) = setup_feature_options(feature);
874
875        let mut ctx1 = FeatureContext::new();
876        ctx1.insert("org_id", json!(1));
877        assert!(check(&opts, "organizations:test-feature", &ctx1));
878
879        let mut ctx2 = FeatureContext::new();
880        ctx2.insert("org_id", json!(2));
881        assert!(check(&opts, "organizations:test-feature", &ctx2));
882
883        let mut ctx3 = FeatureContext::new();
884        ctx3.insert("org_id", json!(3));
885        assert!(!check(&opts, "organizations:test-feature", &ctx3));
886    }
887
888    #[test]
889    fn test_multiple_conditions_and_logic() {
890        let conds = r#"
891            {"property": "org_id", "operator": "in", "value": [123]},
892            {"property": "user_email", "operator": "in", "value": ["admin@example.com"]}
893        "#;
894        let (opts, _t) = setup_feature_options(&feature_json(true, 100, conds));
895
896        let mut ctx = FeatureContext::new();
897        ctx.insert("org_id", json!(123));
898        ctx.insert("user_email", json!("admin@example.com"));
899        assert!(check(&opts, "organizations:test-feature", &ctx));
900
901        let mut ctx2 = FeatureContext::new();
902        ctx2.insert("org_id", json!(123));
903        ctx2.insert("user_email", json!("other@example.com"));
904        assert!(!check(&opts, "organizations:test-feature", &ctx2));
905    }
906
907    #[test]
908    fn test_in_int_context_against_string_list_returns_false() {
909        let cond = r#"{"property": "org_id", "operator": "in", "value": ["123", "456"]}"#;
910        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
911
912        let mut ctx = FeatureContext::new();
913        ctx.insert("org_id", json!(123));
914        assert!(!check(&opts, "organizations:test-feature", &ctx));
915    }
916
917    #[test]
918    fn test_in_string_context_against_int_list_returns_false() {
919        let cond = r#"{"property": "slug", "operator": "in", "value": [123, 456]}"#;
920        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
921
922        let mut ctx = FeatureContext::new();
923        ctx.insert("slug", json!("123"));
924        assert!(!check(&opts, "organizations:test-feature", &ctx));
925    }
926
927    #[test]
928    fn test_in_bool_context_against_string_list_returns_false() {
929        let cond = r#"{"property": "active", "operator": "in", "value": ["true", "false"]}"#;
930        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
931
932        let mut ctx = FeatureContext::new();
933        ctx.insert("active", json!(true));
934        assert!(!check(&opts, "organizations:test-feature", &ctx));
935    }
936
937    #[test]
938    fn test_in_float_context_against_string_list_returns_false() {
939        let cond = r#"{"property": "score", "operator": "in", "value": ["0.5", "1.0"]}"#;
940        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
941
942        let mut ctx = FeatureContext::new();
943        ctx.insert("score", json!(0.5));
944        assert!(!check(&opts, "organizations:test-feature", &ctx));
945    }
946
947    #[test]
948    fn test_not_in_int_context_against_string_list_returns_true() {
949        // Type mismatch means "in" is false, so "not_in" is true
950        let cond = r#"{"property": "org_id", "operator": "not_in", "value": ["123", "456"]}"#;
951        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
952
953        let mut ctx = FeatureContext::new();
954        ctx.insert("org_id", json!(123));
955        assert!(check(&opts, "organizations:test-feature", &ctx));
956    }
957
958    #[test]
959    fn test_not_in_string_context_against_int_list_returns_true() {
960        // Type mismatch means "in" is false, so "not_in" is true
961        let cond = r#"{"property": "slug", "operator": "not_in", "value": [123, 456]}"#;
962        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
963
964        let mut ctx = FeatureContext::new();
965        ctx.insert("slug", json!("123"));
966        assert!(check(&opts, "organizations:test-feature", &ctx));
967    }
968}