Skip to main content

sentry_options/
features.rs

1//! Feature flag evaluation.
2//!
3//! Provides [`FeatureContext`] and [`FeatureChecker`] for evaluating feature
4//! flags stored in the options system.
5
6use num::bigint::{BigInt, Sign};
7use std::cell::Cell;
8use std::collections::HashMap;
9
10use serde_json::Value;
11use sha1::{Digest, Sha1};
12
13/// Produce a Python-compatible string representation for identity hashing.
14fn value_to_id_string(value: &Value) -> String {
15    match value {
16        Value::String(s) => s.clone(),
17        Value::Number(n) => {
18            if let Some(i) = n.as_i64() {
19                i.to_string()
20            } else if let Some(f) = n.as_f64() {
21                // Match Python's str() output: 1.0 -> "1.0", 1.5 -> "1.5"
22                if f.fract() == 0.0 {
23                    format!("{f:.1}")
24                } else {
25                    f.to_string()
26                }
27            } else {
28                n.to_string()
29            }
30        }
31        Value::Bool(b) => if *b { "True" } else { "False" }.to_string(),
32        Value::Array(arr) => {
33            let items: Vec<String> = arr.iter().map(value_to_id_string).collect();
34            format!("[{}]", items.join(", "))
35        }
36        Value::Null => "None".to_string(),
37        Value::Object(_) => value.to_string(),
38    }
39}
40
41/// Application context passed to feature flag evaluation.
42///
43/// Contains arbitrary key-value data used to evaluate feature flag conditions.
44/// The identity fields determine which fields are used for rollout bucketing.
45pub struct FeatureContext {
46    data: HashMap<String, Value>,
47    identity_fields: Vec<String>,
48    cached_id: Cell<Option<u64>>,
49}
50
51impl FeatureContext {
52    pub fn new() -> Self {
53        Self {
54            data: HashMap::new(),
55            identity_fields: Vec::new(),
56            cached_id: Cell::new(None),
57        }
58    }
59
60    /// Set the fields used to compute this context's rollout identity.
61    ///
62    /// Fields are sorted lexicographically before hashing. Calling this
63    /// resets the cached identity value.
64    pub fn identity_fields(&mut self, fields: Vec<&str>) {
65        self.identity_fields = fields.into_iter().map(|s| s.to_string()).collect();
66        self.cached_id.set(None);
67    }
68
69    /// Insert a key-value pair into the context.
70    pub fn insert(&mut self, key: &str, value: impl Into<Value>) {
71        self.data.insert(key.to_string(), value.into());
72        self.cached_id.set(None);
73    }
74
75    /// Get a context value by key.
76    pub fn get(&self, key: &str) -> Option<&Value> {
77        self.data.get(key)
78    }
79
80    /// Check if a key is present in the context.
81    pub fn has(&self, key: &str) -> bool {
82        self.data.contains_key(key)
83    }
84
85    /// Compute and return this context's rollout identity.
86    ///
87    /// The result is cached and reset when identity fields or context data change.
88    /// When no identity fields are set, all context keys are used (non-deterministic
89    /// rollout across contexts with different keys).
90    /// The id value is mostly used to id % 100 <= rollout.
91    pub fn id(&self) -> u64 {
92        if let Some(id) = self.cached_id.get() {
93            return id;
94        }
95        let id = self.compute_id();
96        self.cached_id.set(Some(id));
97        id
98    }
99
100    /// Compute the id for a FeatureContext.
101    ///
102    /// The original python implementation used a bigint value
103    /// derived from the sha1 hash.
104    ///
105    /// This method returns a u64 which contains the lower place
106    /// values of the bigint so that our rollout modulo math is
107    /// consistent with the original python implementation.
108    fn compute_id(&self) -> u64 {
109        let mut identity_fields: Vec<&String> = self
110            .identity_fields
111            .iter()
112            .filter(|f| self.data.contains_key(f.as_str()))
113            .collect();
114        if identity_fields.is_empty() {
115            identity_fields = self.data.keys().collect();
116        }
117        identity_fields.sort();
118
119        let mut parts: Vec<String> = Vec::with_capacity(identity_fields.len() * 2);
120        for key in identity_fields {
121            parts.push(key.clone());
122            parts.push(value_to_id_string(&self.data[key.as_str()]));
123        }
124        let mut hasher = Sha1::new();
125        hasher.update(parts.join(":").as_bytes());
126        let digest = hasher.finalize();
127
128        // Create a BigInt to preserve all the 20bytes of the hash digest.
129        let bigint = BigInt::from_bytes_be(Sign::Plus, digest.as_slice());
130
131        // We only need the lower places from the big int to retain compatibility.
132        // modulo will trim off the u64 overflow, and let us break the bigint
133        // into its pieces (there will only be one).
134        let small: BigInt = bigint % 1000000000;
135        let id_parts = small.to_u64_digits().1;
136        if id_parts.is_empty() { 0 } else { id_parts[0] }
137    }
138}
139
140impl Default for FeatureContext {
141    fn default() -> Self {
142        Self::new()
143    }
144}
145
146#[derive(Debug)]
147enum OperatorKind {
148    In,
149    NotIn,
150    Contains,
151    NotContains,
152    Equals,
153    NotEquals,
154    Matches,
155    NotMatches,
156}
157
158#[derive(Debug)]
159struct Condition {
160    property: String,
161    operator: OperatorKind,
162    value: Value,
163}
164
165#[derive(Debug)]
166struct Segment {
167    rollout: u64,
168    conditions: Vec<Condition>,
169}
170
171#[derive(Debug)]
172struct Feature {
173    enabled: bool,
174    segments: Vec<Segment>,
175}
176
177impl Feature {
178    fn from_json(value: &Value) -> Option<Self> {
179        // Default to true to align with flagpole behavior.
180        let enabled = value
181            .get("enabled")
182            .and_then(|v| v.as_bool())
183            .unwrap_or(true);
184        let segments = value
185            .get("segments")?
186            .as_array()?
187            .iter()
188            .filter_map(Segment::from_json)
189            .collect();
190        Some(Feature { enabled, segments })
191    }
192
193    fn matches(&self, context: &FeatureContext) -> bool {
194        if !self.enabled {
195            return false;
196        }
197        for segment in &self.segments {
198            if segment.conditions_match(context) {
199                return segment.in_rollout(context);
200            }
201        }
202        false
203    }
204}
205
206impl Segment {
207    fn from_json(value: &Value) -> Option<Self> {
208        let rollout = value.get("rollout").and_then(|v| v.as_u64()).unwrap_or(100);
209        let conditions = value
210            .get("conditions")
211            .and_then(|v| v.as_array())
212            .map(|arr| arr.iter().filter_map(Condition::from_json).collect())
213            .unwrap_or_default();
214        Some(Segment {
215            rollout,
216            conditions,
217        })
218    }
219
220    fn conditions_match(&self, context: &FeatureContext) -> bool {
221        self.conditions.iter().all(|c| c.matches(context))
222    }
223
224    fn in_rollout(&self, context: &FeatureContext) -> bool {
225        if self.rollout == 0 {
226            return false;
227        }
228        if self.rollout >= 100 {
229            return true;
230        }
231        context.id() % 100 <= self.rollout
232    }
233}
234
235impl Condition {
236    fn from_json(value: &Value) -> Option<Self> {
237        let property = value.get("property")?.as_str()?.to_string();
238        let operator = match value.get("operator")?.as_str()? {
239            "in" => OperatorKind::In,
240            "not_in" => OperatorKind::NotIn,
241            "contains" => OperatorKind::Contains,
242            "not_contains" => OperatorKind::NotContains,
243            "equals" => OperatorKind::Equals,
244            "not_equals" => OperatorKind::NotEquals,
245            "matches" => OperatorKind::Matches,
246            "not_matches" => OperatorKind::NotMatches,
247            _ => return None,
248        };
249        let value = value.get("value")?.clone();
250        Some(Condition {
251            property,
252            operator,
253            value,
254        })
255    }
256
257    fn matches(&self, context: &FeatureContext) -> bool {
258        let ctx_val = context.get(&self.property);
259        let result = match &self.operator {
260            OperatorKind::In => eval_in(ctx_val, &self.value),
261            OperatorKind::NotIn => eval_in(ctx_val, &self.value).map(|m| !m),
262            OperatorKind::Contains => eval_contains(ctx_val, &self.value),
263            OperatorKind::NotContains => eval_contains(ctx_val, &self.value).map(|m| !m),
264            OperatorKind::Equals => eval_equals(ctx_val, &self.value),
265            OperatorKind::NotEquals => eval_equals(ctx_val, &self.value).map(|m| !m),
266            OperatorKind::Matches => eval_matches(ctx_val, &self.value),
267            OperatorKind::NotMatches => eval_matches(ctx_val, &self.value).map(|m| !m),
268        };
269        // A comparison that cannot be made is a non-match even when negated:
270        // flagpole raises for these, and the exception is caught above the
271        // evaluator rather than inverted.
272        result.unwrap_or(false)
273    }
274}
275
276/// For scalar ctx_val, check if it's contained in the condition array.
277/// For array ctx_val, check if there is any intersection with the condition array.
278/// String comparison is case-insensitive.
279///
280/// `None` means the comparison could not be made.
281fn eval_in(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
282    let arr = condition_val.as_array()?;
283    match ctx_val {
284        None => Some(false),
285        Some(Value::Object(_)) => None,
286        Some(Value::Array(ctx_arr)) => Some(ctx_arr.iter().any(|v| scalar_in(v, arr))),
287        Some(scalar) => Some(scalar_in(scalar, arr)),
288    }
289}
290
291fn scalar_in(ctx_val: &Value, arr: &[Value]) -> bool {
292    match ctx_val {
293        Value::String(s) => {
294            let s_lower = s.to_lowercase();
295            arr.iter()
296                .any(|v| v.as_str().is_some_and(|cv| cv.to_lowercase() == s_lower))
297        }
298        Value::Number(n) => arr
299            .iter()
300            .any(|v| v.as_number().is_some_and(|cv| numbers_equal(n, cv))),
301        Value::Bool(b) => arr.iter().any(|v| v.as_bool().is_some_and(|cv| cv == *b)),
302        _ => false,
303    }
304}
305
306/// Compare two JSON numbers regardless of their type (int vs float)
307/// 1 == 1, 1.0 == 1, and 1.0 == 1.0
308fn numbers_equal(a: &serde_json::Number, b: &serde_json::Number) -> bool {
309    // !f64 as opposed to (i64 || u64)
310    if !a.is_f64() && !b.is_f64() {
311        return a == b;
312    }
313    // mismatched types are cast to float
314    matches!((a.as_f64(), b.as_f64()), (Some(x), Some(y)) if x == y)
315}
316
317/// Check if a context array contains a condition scalar value.
318/// String comparison is case-insensitive.
319///
320/// A property that is missing or is not a list cannot be searched at all, so
321/// `not_contains` does not grant the feature for it either.
322fn eval_contains(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
323    let ctx_arr = ctx_val?.as_array()?;
324    match condition_val {
325        Value::String(s) => {
326            let s_lower = s.to_lowercase();
327            Some(
328                ctx_arr
329                    .iter()
330                    .any(|v| v.as_str().is_some_and(|cv| cv.to_lowercase() == s_lower)),
331            )
332        }
333        Value::Number(n) => Some(
334            ctx_arr
335                .iter()
336                .any(|v| v.as_number().is_some_and(|cv| numbers_equal(n, cv))),
337        ),
338        Value::Bool(b) => Some(
339            ctx_arr
340                .iter()
341                .any(|v| v.as_bool().is_some_and(|cv| cv == *b)),
342        ),
343        _ => None,
344    }
345}
346
347/// Check if a context value equals a condition value.
348/// A missing property equals nothing, and is not treated as unusable.
349fn eval_equals(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
350    Some(match ctx_val {
351        Some(ctx_val) => values_equal(ctx_val, condition_val),
352        None => false,
353    })
354}
355
356/// Scalars are compared directly (strings case-insensitively).
357/// Arrays are compared element-wise with matching length.
358fn values_equal(ctx_val: &Value, condition_val: &Value) -> bool {
359    match (ctx_val, condition_val) {
360        (Value::String(a), Value::String(b)) => a.to_lowercase() == b.to_lowercase(),
361        (Value::Number(a), Value::Number(b)) => numbers_equal(a, b),
362        (Value::Bool(a), Value::Bool(b)) => a == b,
363        (Value::Array(a), Value::Array(b)) => {
364            a.len() == b.len() && a.iter().zip(b.iter()).all(|(av, bv)| values_equal(av, bv))
365        }
366        _ => false,
367    }
368}
369
370/// Match a value string against a single star-only glob pattern (case-insensitive).
371/// '*' matches zero or more characters. All other characters, including '?' and '[',
372/// are treated as literals.
373fn glob_star_match(pattern: &str, value: &str) -> bool {
374    let pattern = pattern.to_lowercase();
375    let value = value.to_lowercase();
376    let parts: Vec<&str> = pattern.split('*').collect();
377    // No wildcard — require exact equality.
378    if parts.len() == 1 {
379        return value == pattern;
380    }
381    // Check prefix anchor.
382    if !value.starts_with(parts[0]) {
383        return false;
384    }
385    // Check suffix anchor (skip when the last part is empty, i.e. pattern ends with '*').
386    if !parts[parts.len() - 1].is_empty() && !value.ends_with(parts[parts.len() - 1]) {
387        return false;
388    }
389    // Search window: after the prefix, before the suffix.
390    let end = if parts[parts.len() - 1].is_empty() {
391        value.len()
392    } else {
393        value.len() - parts[parts.len() - 1].len()
394    };
395    let mut start = parts[0].len();
396    // The prefix and suffix anchors overlap, meaning the
397    // value is shorter than prefix + suffix combined — no valid match possible.
398    if start > end {
399        return false;
400    }
401    // Walk middle segments left-to-right, advancing the cursor on each hit.
402    for part in &parts[1..parts.len() - 1] {
403        if part.is_empty() {
404            // Skip consecutive '*'s.
405            continue;
406        }
407        match value[start..end].find(*part) {
408            Some(idx) => start += idx + part.len(),
409            None => return false,
410        }
411    }
412    true
413}
414
415/// Check if a string context value matches any pattern in the condition array.
416/// Patterns use star-only glob semantics; comparison is case-insensitive.
417/// Returns false for non-string context values.
418fn eval_matches(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
419    let arr = condition_val.as_array()?;
420    match ctx_val {
421        // A container cannot be glob matched against.
422        Some(Value::Array(_) | Value::Object(_)) => None,
423        None => Some(false),
424        Some(scalar) => {
425            let Some(s) = scalar.as_str() else {
426                return Some(false);
427            };
428            Some(arr.iter().any(|v| {
429                v.as_str()
430                    .is_some_and(|pattern| glob_star_match(pattern, s))
431            }))
432        }
433    }
434}
435
436/// A handle for checking feature flags within a specific namespace.
437/// Why a feature could not be evaluated. Distinct from [`crate::OptionsError`]:
438/// an unset feature is not an error here (see [`FeatureChecker::try_has`]), and a
439/// value that fails to parse as a `Feature` is.
440#[derive(Debug, thiserror::Error)]
441pub enum FeatureError {
442    #[error("Options not initialized - call init() first")]
443    NotInitialized,
444
445    #[error("Value for '{key}' is not a valid feature")]
446    InvalidValue { key: String },
447
448    #[error(transparent)]
449    Options(#[from] crate::OptionsError),
450}
451
452pub struct FeatureChecker {
453    namespace: String,
454    options: Option<&'static crate::Options>,
455}
456
457impl FeatureChecker {
458    pub fn new(namespace: String, options: &'static crate::Options) -> Self {
459        Self {
460            namespace,
461            options: Some(options),
462        }
463    }
464
465    /// Check whether a feature flag is enabled for a given context.
466    ///
467    /// Returns false if the feature is not defined, not enabled, conditions don't match,
468    /// or options have not been initialized.
469    pub fn has(&self, feature_name: &str, context: &FeatureContext) -> bool {
470        // Deliberately collapses every failure to false, use `try_has` when the distinction matters.
471        match self.try_has(feature_name, context) {
472            Ok(Some(result)) => result,
473            Ok(None) => false,
474            Err(e) => {
475                tracing::debug!(feature = feature_name, error = %e, "Feature evaluation failed");
476                false
477            }
478        }
479    }
480
481    /// Like [`has`](Self::has), but distinguishes a feature that evaluated to false
482    /// from one that could not be evaluated at all.
483    ///
484    /// - `Ok(Some(bool))` -- evaluated: disabled, no matching segment, or matched
485    /// - `Ok(None)` -- no value set for this feature, so there was nothing to evaluate
486    /// - `Err(_)` -- evaluation failed: see [`FeatureError`]
487    ///
488    /// Prefer `has` on hot paths.
489    pub fn try_has(
490        &self,
491        feature_name: &str,
492        context: &FeatureContext,
493    ) -> Result<Option<bool>, FeatureError> {
494        let opts = self.options.ok_or(FeatureError::NotInitialized)?;
495        let key = format!("feature.{feature_name}");
496
497        let feature_val = match opts.get(&self.namespace, &key) {
498            Ok(v) => v,
499            // A feature the schema knows about but that has no value set is absent,
500            // not an error: nothing has been rolled out yet.
501            Err(crate::OptionsError::UnknownOption { .. }) => return Ok(None),
502            Err(e) => return Err(e.into()),
503        };
504
505        let feature = Feature::from_json(&feature_val)
506            .ok_or_else(|| FeatureError::InvalidValue { key: key.clone() })?;
507
508        let result = feature.matches(context);
509        tracing::debug!(
510            feature = feature_name,
511            result,
512            context_id = context.id(),
513            "Feature match result"
514        );
515        Ok(Some(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_in_list_property_matches_on_overlap() {
828        let cond = r#"{"property": "plan_family", "operator": "in", "value": ["business"]}"#;
829        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
830
831        let mut ctx = FeatureContext::new();
832        ctx.insert("plan_family", json!(["enterprise business", "Business"]));
833        assert!(check(&opts, "organizations:test-feature", &ctx));
834
835        let mut ctx2 = FeatureContext::new();
836        ctx2.insert("plan_family", json!(["team"]));
837        assert!(!check(&opts, "organizations:test-feature", &ctx2));
838
839        let mut ctx3 = FeatureContext::new();
840        ctx3.insert("plan_family", json!([]));
841        assert!(!check(&opts, "organizations:test-feature", &ctx3));
842    }
843
844    #[test]
845    fn test_condition_not_in_list_property_requires_no_overlap() {
846        let cond = r#"{"property": "plan_family", "operator": "not_in", "value": ["business"]}"#;
847        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
848
849        let mut ctx = FeatureContext::new();
850        ctx.insert("plan_family", json!(["enterprise business", "business"]));
851        assert!(!check(&opts, "organizations:test-feature", &ctx));
852
853        let mut ctx2 = FeatureContext::new();
854        ctx2.insert("plan_family", json!(["team"]));
855        assert!(check(&opts, "organizations:test-feature", &ctx2));
856    }
857
858    #[test]
859    fn test_condition_in_list_property_no_type_coercion() {
860        let cond = r#"{"property": "org_id", "operator": "in", "value": ["123"]}"#;
861        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
862
863        let mut ctx = FeatureContext::new();
864        ctx.insert("org_id", json!([123]));
865        assert!(!check(&opts, "organizations:test-feature", &ctx));
866
867        let int_cond = r#"{"property": "org_id", "operator": "in", "value": [123]}"#;
868        let (int_opts, _t2) = setup_feature_options(&feature_json(true, 100, int_cond));
869        assert!(check(&int_opts, "organizations:test-feature", &ctx));
870    }
871
872    #[test]
873    fn test_condition_in_matches_across_int_and_float() {
874        let cond = r#"{"property": "org_id", "operator": "in", "value": [123.0]}"#;
875        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
876
877        let mut ctx = FeatureContext::new();
878        ctx.insert("org_id", json!(123));
879        assert!(check(&opts, "organizations:test-feature", &ctx));
880
881        let mut list_ctx = FeatureContext::new();
882        list_ctx.insert("org_id", json!([123]));
883        assert!(check(&opts, "organizations:test-feature", &list_ctx));
884
885        let mut ctx2 = FeatureContext::new();
886        ctx2.insert("org_id", json!(124));
887        assert!(!check(&opts, "organizations:test-feature", &ctx2));
888    }
889
890    #[test]
891    fn test_condition_in_large_ints_compare_exactly() {
892        // 2^53 + 1 and 2^53 collide as f64, so ints must not round-trip through it.
893        let cond = r#"{"property": "org_id", "operator": "in", "value": [9007199254740993]}"#;
894        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
895
896        let mut ctx = FeatureContext::new();
897        ctx.insert("org_id", json!(9007199254740992i64));
898        assert!(!check(&opts, "organizations:test-feature", &ctx));
899
900        let mut ctx2 = FeatureContext::new();
901        ctx2.insert("org_id", json!(9007199254740993i64));
902        assert!(check(&opts, "organizations:test-feature", &ctx2));
903
904        // Above i64::MAX, where as_i64() gives up but the values are still exact.
905        let big = r#"{"property": "org_id", "operator": "in", "value": [18446744073709551615]}"#;
906        let (big_opts, _t2) = setup_feature_options(&feature_json(true, 100, big));
907
908        let mut ctx3 = FeatureContext::new();
909        ctx3.insert("org_id", json!(18446744073709551614u64));
910        assert!(!check(&big_opts, "organizations:test-feature", &ctx3));
911
912        let mut ctx4 = FeatureContext::new();
913        ctx4.insert("org_id", json!(18446744073709551615u64));
914        assert!(check(&big_opts, "organizations:test-feature", &ctx4));
915    }
916
917    #[test]
918    fn test_condition_contains() {
919        let cond = r#"{"property": "tags", "operator": "contains", "value": "beta"}"#;
920        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
921
922        let mut ctx = FeatureContext::new();
923        ctx.insert("tags", json!(["alpha", "beta"]));
924        assert!(check(&opts, "organizations:test-feature", &ctx));
925
926        let mut ctx2 = FeatureContext::new();
927        ctx2.insert("tags", json!(["alpha"]));
928        assert!(!check(&opts, "organizations:test-feature", &ctx2));
929    }
930
931    #[test]
932    fn test_condition_contains_matches_across_int_and_float() {
933        let int_cond = r#"{"property": "ids", "operator": "contains", "value": 123}"#;
934        let (int_opts, _t) = setup_feature_options(&feature_json(true, 100, int_cond));
935
936        let float_cond = r#"{"property": "ids", "operator": "contains", "value": 123.0}"#;
937        let (float_opts, _t2) = setup_feature_options(&feature_json(true, 100, float_cond));
938
939        let mut int_ctx = FeatureContext::new();
940        int_ctx.insert("ids", json!([123]));
941        let mut float_ctx = FeatureContext::new();
942        float_ctx.insert("ids", json!([123.0]));
943
944        // Matches regardless of which side holds the float.
945        assert!(check(&int_opts, "organizations:test-feature", &int_ctx));
946        assert!(check(&int_opts, "organizations:test-feature", &float_ctx));
947        assert!(check(&float_opts, "organizations:test-feature", &int_ctx));
948        assert!(check(&float_opts, "organizations:test-feature", &float_ctx));
949
950        let mut other = FeatureContext::new();
951        other.insert("ids", json!([124]));
952        assert!(!check(&int_opts, "organizations:test-feature", &other));
953    }
954
955    #[test]
956    fn test_condition_equals() {
957        let cond = r#"{"property": "plan", "operator": "equals", "value": "enterprise"}"#;
958        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
959
960        let mut ctx = FeatureContext::new();
961        ctx.insert("plan", json!("Enterprise"));
962        assert!(check(&opts, "organizations:test-feature", &ctx));
963
964        let mut ctx2 = FeatureContext::new();
965        ctx2.insert("plan", json!("free"));
966        assert!(!check(&opts, "organizations:test-feature", &ctx2));
967    }
968
969    #[test]
970    fn test_condition_equals_large_ints_compare_exactly() {
971        // Above i64::MAX these used to collapse into f64 and compare equal.
972        let cond = r#"{"property": "org_id", "operator": "equals", "value": 18446744073709551615}"#;
973        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
974
975        let mut ctx = FeatureContext::new();
976        ctx.insert("org_id", json!(18446744073709551614u64));
977        assert!(!check(&opts, "organizations:test-feature", &ctx));
978
979        let mut ctx2 = FeatureContext::new();
980        ctx2.insert("org_id", json!(18446744073709551615u64));
981        assert!(check(&opts, "organizations:test-feature", &ctx2));
982    }
983
984    #[test]
985    fn test_condition_equals_bool() {
986        let cond = r#"{"property": "is_free", "operator": "equals", "value": true}"#;
987        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
988
989        let mut ctx = FeatureContext::new();
990        ctx.insert("is_free", json!(true));
991        assert!(check(&opts, "organizations:test-feature", &ctx));
992
993        let mut ctx2 = FeatureContext::new();
994        ctx2.insert("is_free", json!(false));
995        assert!(!check(&opts, "organizations:test-feature", &ctx2));
996    }
997
998    #[test]
999    fn test_segment_with_no_conditions_always_matches() {
1000        let feature = r#"{
1001            "name": "test-feature",
1002            "enabled": true,
1003            "owner": {"team": "test-team"},
1004            "created_at": "2024-01-01",
1005            "segments": [{"name": "open", "rollout": 100, "conditions": []}]
1006        }"#;
1007        let (opts, _t) = setup_feature_options(feature);
1008
1009        let ctx = FeatureContext::new();
1010        assert!(check(&opts, "organizations:test-feature", &ctx));
1011    }
1012
1013    #[test]
1014    fn test_feature_enabled_and_rollout_default_values() {
1015        // This feature doesn't define .enabled or .segments[0].rollout
1016        let feature = r#"{
1017            "name": "test-feature",
1018            "owner": {"team": "test-team"},
1019            "created_at": "2024-01-01",
1020            "segments": [
1021                {
1022                    "name": "first",
1023                    "conditions": [{"property": "org_id", "operator": "in", "value":[1]}]
1024                }
1025            ]
1026        }"#;
1027        let (opts, _t) = setup_feature_options(feature);
1028
1029        let mut ctx = FeatureContext::new();
1030        ctx.insert("org_id", 1);
1031        ctx.identity_fields(vec!["org_id"]);
1032        assert!(check(&opts, "organizations:test-feature", &ctx));
1033    }
1034
1035    #[test]
1036    fn test_feature_with_no_segments_returns_false() {
1037        let feature = r#"{
1038            "name": "test-feature",
1039            "enabled": true,
1040            "owner": {"team": "test-team"},
1041            "created_at": "2024-01-01",
1042            "segments": []
1043        }"#;
1044        let (opts, _t) = setup_feature_options(feature);
1045
1046        let ctx = FeatureContext::new();
1047        assert!(!check(&opts, "organizations:test-feature", &ctx));
1048    }
1049
1050    #[test]
1051    fn test_multiple_segments_or_logic() {
1052        let feature = r#"{
1053            "name": "test-feature",
1054            "enabled": true,
1055            "owner": {"team": "test-team"},
1056            "created_at": "2024-01-01",
1057            "segments": [
1058                {
1059                    "name": "segment-a",
1060                    "rollout": 100,
1061                    "conditions": [{"property": "org_id", "operator": "in", "value": [1]}]
1062                },
1063                {
1064                    "name": "segment-b",
1065                    "rollout": 100,
1066                    "conditions": [{"property": "org_id", "operator": "in", "value": [2]}]
1067                }
1068            ]
1069        }"#;
1070        let (opts, _t) = setup_feature_options(feature);
1071
1072        let mut ctx1 = FeatureContext::new();
1073        ctx1.insert("org_id", json!(1));
1074        assert!(check(&opts, "organizations:test-feature", &ctx1));
1075
1076        let mut ctx2 = FeatureContext::new();
1077        ctx2.insert("org_id", json!(2));
1078        assert!(check(&opts, "organizations:test-feature", &ctx2));
1079
1080        let mut ctx3 = FeatureContext::new();
1081        ctx3.insert("org_id", json!(3));
1082        assert!(!check(&opts, "organizations:test-feature", &ctx3));
1083    }
1084
1085    #[test]
1086    fn test_multiple_conditions_and_logic() {
1087        let conds = r#"
1088            {"property": "org_id", "operator": "in", "value": [123]},
1089            {"property": "user_email", "operator": "in", "value": ["admin@example.com"]}
1090        "#;
1091        let (opts, _t) = setup_feature_options(&feature_json(true, 100, conds));
1092
1093        let mut ctx = FeatureContext::new();
1094        ctx.insert("org_id", json!(123));
1095        ctx.insert("user_email", json!("admin@example.com"));
1096        assert!(check(&opts, "organizations:test-feature", &ctx));
1097
1098        let mut ctx2 = FeatureContext::new();
1099        ctx2.insert("org_id", json!(123));
1100        ctx2.insert("user_email", json!("other@example.com"));
1101        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1102    }
1103
1104    #[test]
1105    fn test_in_int_context_against_string_list_returns_false() {
1106        let cond = r#"{"property": "org_id", "operator": "in", "value": ["123", "456"]}"#;
1107        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1108
1109        let mut ctx = FeatureContext::new();
1110        ctx.insert("org_id", json!(123));
1111        assert!(!check(&opts, "organizations:test-feature", &ctx));
1112    }
1113
1114    #[test]
1115    fn test_in_string_context_against_int_list_returns_false() {
1116        let cond = r#"{"property": "slug", "operator": "in", "value": [123, 456]}"#;
1117        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1118
1119        let mut ctx = FeatureContext::new();
1120        ctx.insert("slug", json!("123"));
1121        assert!(!check(&opts, "organizations:test-feature", &ctx));
1122    }
1123
1124    #[test]
1125    fn test_in_bool_context_against_string_list_returns_false() {
1126        let cond = r#"{"property": "active", "operator": "in", "value": ["true", "false"]}"#;
1127        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1128
1129        let mut ctx = FeatureContext::new();
1130        ctx.insert("active", json!(true));
1131        assert!(!check(&opts, "organizations:test-feature", &ctx));
1132    }
1133
1134    #[test]
1135    fn test_in_float_context_against_string_list_returns_false() {
1136        let cond = r#"{"property": "score", "operator": "in", "value": ["0.5", "1.0"]}"#;
1137        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1138
1139        let mut ctx = FeatureContext::new();
1140        ctx.insert("score", json!(0.5));
1141        assert!(!check(&opts, "organizations:test-feature", &ctx));
1142    }
1143
1144    #[test]
1145    fn test_not_in_int_context_against_string_list_returns_true() {
1146        // Type mismatch means "in" is false, so "not_in" is true
1147        let cond = r#"{"property": "org_id", "operator": "not_in", "value": ["123", "456"]}"#;
1148        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1149
1150        let mut ctx = FeatureContext::new();
1151        ctx.insert("org_id", json!(123));
1152        assert!(check(&opts, "organizations:test-feature", &ctx));
1153    }
1154
1155    #[test]
1156    fn test_not_in_string_context_against_int_list_returns_true() {
1157        // Type mismatch means "in" is false, so "not_in" is true
1158        let cond = r#"{"property": "slug", "operator": "not_in", "value": [123, 456]}"#;
1159        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1160
1161        let mut ctx = FeatureContext::new();
1162        ctx.insert("slug", json!("123"));
1163        assert!(check(&opts, "organizations:test-feature", &ctx));
1164    }
1165
1166    #[test]
1167    fn test_condition_matches_literal() {
1168        let cond = r#"{"property": "slug", "operator": "matches", "value": ["sentry"]}"#;
1169        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1170
1171        let mut ctx = FeatureContext::new();
1172        ctx.insert("slug", json!("sentry"));
1173        assert!(check(&opts, "organizations:test-feature", &ctx));
1174
1175        let mut ctx2 = FeatureContext::new();
1176        ctx2.insert("slug", json!("getsentry"));
1177        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1178    }
1179
1180    #[test]
1181    fn test_condition_matches_prefix_wildcard() {
1182        let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
1183        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1184
1185        let mut ctx = FeatureContext::new();
1186        ctx.insert("slug", json!("jayonb73"));
1187        assert!(check(&opts, "organizations:test-feature", &ctx));
1188
1189        // '*' matches zero chars too
1190        let mut ctx2 = FeatureContext::new();
1191        ctx2.insert("slug", json!("jayonb"));
1192        assert!(check(&opts, "organizations:test-feature", &ctx2));
1193
1194        let mut ctx3 = FeatureContext::new();
1195        ctx3.insert("slug", json!("dangoldonb1"));
1196        assert!(!check(&opts, "organizations:test-feature", &ctx3));
1197    }
1198
1199    #[test]
1200    fn test_condition_matches_prefix_and_suffix_wildcard() {
1201        let cond = r#"{"property": "email", "operator": "matches", "value": ["jay.goss+onboarding*@sentry.io"]}"#;
1202        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1203
1204        let mut ctx = FeatureContext::new();
1205        ctx.insert("email", json!("jay.goss+onboarding70@sentry.io"));
1206        assert!(check(&opts, "organizations:test-feature", &ctx));
1207
1208        // '*' matches zero chars — prefix runs directly into suffix
1209        let mut ctx2 = FeatureContext::new();
1210        ctx2.insert("email", json!("jay.goss+onboarding@sentry.io"));
1211        assert!(check(&opts, "organizations:test-feature", &ctx2));
1212
1213        let mut ctx3 = FeatureContext::new();
1214        ctx3.insert("email", json!("jay.goss+onboarding70@example.com"));
1215        assert!(!check(&opts, "organizations:test-feature", &ctx3));
1216    }
1217
1218    #[test]
1219    fn test_condition_matches_suffix_wildcard() {
1220        let cond = r#"{"property": "email", "operator": "matches", "value": ["*@sentry.io"]}"#;
1221        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1222
1223        let mut ctx = FeatureContext::new();
1224        ctx.insert("email", json!("user@sentry.io"));
1225        assert!(check(&opts, "organizations:test-feature", &ctx));
1226
1227        let mut ctx2 = FeatureContext::new();
1228        ctx2.insert("email", json!("user@example.com"));
1229        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1230    }
1231
1232    #[test]
1233    fn test_condition_matches_multi_segment_wildcard() {
1234        let cond = r#"{"property": "name", "operator": "matches", "value": ["a*b*c"]}"#;
1235        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1236
1237        let mut ctx = FeatureContext::new();
1238        ctx.insert("name", json!("abc"));
1239        assert!(check(&opts, "organizations:test-feature", &ctx));
1240
1241        let mut ctx2 = FeatureContext::new();
1242        ctx2.insert("name", json!("aXbYc"));
1243        assert!(check(&opts, "organizations:test-feature", &ctx2));
1244
1245        let mut ctx3 = FeatureContext::new();
1246        ctx3.insert("name", json!("aXXbYYc"));
1247        assert!(check(&opts, "organizations:test-feature", &ctx3));
1248
1249        let mut ctx4 = FeatureContext::new();
1250        ctx4.insert("name", json!("aXXc"));
1251        assert!(!check(&opts, "organizations:test-feature", &ctx4));
1252    }
1253
1254    #[test]
1255    fn test_condition_matches_star_only_pattern() {
1256        let cond = r#"{"property": "slug", "operator": "matches", "value": ["*"]}"#;
1257        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1258
1259        let mut ctx = FeatureContext::new();
1260        ctx.insert("slug", json!("anything"));
1261        assert!(check(&opts, "organizations:test-feature", &ctx));
1262
1263        let mut ctx2 = FeatureContext::new();
1264        ctx2.insert("slug", json!(""));
1265        assert!(check(&opts, "organizations:test-feature", &ctx2));
1266    }
1267
1268    #[test]
1269    fn test_condition_matches_case_insensitive() {
1270        let cond = r#"{"property": "slug", "operator": "matches", "value": ["JAYONB*"]}"#;
1271        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1272
1273        let mut ctx = FeatureContext::new();
1274        ctx.insert("slug", json!("jayonb73"));
1275        assert!(check(&opts, "organizations:test-feature", &ctx));
1276
1277        // Pattern lowercase, value uppercase
1278        let cond2 = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
1279        let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
1280
1281        let mut ctx2 = FeatureContext::new();
1282        ctx2.insert("slug", json!("JAYONB73"));
1283        assert!(check(&opts2, "organizations:test-feature", &ctx2));
1284    }
1285
1286    #[test]
1287    fn test_condition_matches_no_match() {
1288        let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
1289        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1290
1291        let mut ctx = FeatureContext::new();
1292        ctx.insert("slug", json!("dangoldonb1"));
1293        assert!(!check(&opts, "organizations:test-feature", &ctx));
1294    }
1295
1296    #[test]
1297    fn test_condition_matches_multiple_patterns() {
1298        let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*", "dangoldonb*", "value-disc-*"]}"#;
1299        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1300
1301        let slugs = [
1302            ("jayonb73", true),
1303            ("dangoldonb3", true),
1304            ("value-disc-7", true),
1305            ("other-org", false),
1306        ];
1307        for (slug, expected) in slugs {
1308            let mut ctx = FeatureContext::new();
1309            ctx.insert("slug", json!(slug));
1310            assert_eq!(
1311                check(&opts, "organizations:test-feature", &ctx),
1312                expected,
1313                "slug={slug}"
1314            );
1315        }
1316    }
1317
1318    #[test]
1319    fn test_condition_matches_overlapping_prefix_suffix_anchors() {
1320        // "a*a" requires at least "aa" — a single "a" must not match.
1321        let cond = r#"{"property": "slug", "operator": "matches", "value": ["a*a"]}"#;
1322        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1323
1324        let mut ctx = FeatureContext::new();
1325        ctx.insert("slug", json!("a"));
1326        assert!(!check(&opts, "organizations:test-feature", &ctx));
1327
1328        let mut ctx2 = FeatureContext::new();
1329        ctx2.insert("slug", json!("aa"));
1330        assert!(check(&opts, "organizations:test-feature", &ctx2));
1331
1332        // "ab*ab" requires at least "abab" — "ab" alone must not match.
1333        let cond2 = r#"{"property": "slug", "operator": "matches", "value": ["ab*ab"]}"#;
1334        let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
1335
1336        let mut ctx3 = FeatureContext::new();
1337        ctx3.insert("slug", json!("ab"));
1338        assert!(!check(&opts2, "organizations:test-feature", &ctx3));
1339
1340        let mut ctx4 = FeatureContext::new();
1341        ctx4.insert("slug", json!("abab"));
1342        assert!(check(&opts2, "organizations:test-feature", &ctx4));
1343    }
1344
1345    #[test]
1346    fn test_condition_matches_non_string_context_returns_false() {
1347        // Non-string context values should not match any pattern — eval_matches returns false.
1348        let cond = r#"{"property": "org_id", "operator": "matches", "value": ["123*"]}"#;
1349        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1350
1351        let mut ctx = FeatureContext::new();
1352        ctx.insert("org_id", json!(123));
1353        assert!(!check(&opts, "organizations:test-feature", &ctx));
1354    }
1355
1356    #[test]
1357    fn test_condition_not_matches_literal() {
1358        let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["sentry"]}"#;
1359        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1360
1361        // Exact match → not_matches returns false
1362        let mut ctx = FeatureContext::new();
1363        ctx.insert("slug", json!("sentry"));
1364        assert!(!check(&opts, "organizations:test-feature", &ctx));
1365
1366        // No match → not_matches returns true
1367        let mut ctx2 = FeatureContext::new();
1368        ctx2.insert("slug", json!("getsentry"));
1369        assert!(check(&opts, "organizations:test-feature", &ctx2));
1370    }
1371
1372    #[test]
1373    fn test_condition_not_matches_prefix_wildcard() {
1374        let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*"]}"#;
1375        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1376
1377        let mut ctx = FeatureContext::new();
1378        ctx.insert("slug", json!("jayonb73"));
1379        assert!(!check(&opts, "organizations:test-feature", &ctx));
1380
1381        // '*' matches zero chars — bare prefix is still a match, so not_matches = false
1382        let mut ctx2 = FeatureContext::new();
1383        ctx2.insert("slug", json!("jayonb"));
1384        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1385
1386        let mut ctx3 = FeatureContext::new();
1387        ctx3.insert("slug", json!("dangoldonb1"));
1388        assert!(check(&opts, "organizations:test-feature", &ctx3));
1389    }
1390
1391    #[test]
1392    fn test_condition_not_matches_prefix_and_suffix_wildcard() {
1393        let cond = r#"{"property": "email", "operator": "not_matches", "value": ["jay.goss+onboarding*@sentry.io"]}"#;
1394        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1395
1396        let mut ctx = FeatureContext::new();
1397        ctx.insert("email", json!("jay.goss+onboarding70@sentry.io"));
1398        assert!(!check(&opts, "organizations:test-feature", &ctx));
1399
1400        let mut ctx2 = FeatureContext::new();
1401        ctx2.insert("email", json!("jay.goss+onboarding@sentry.io"));
1402        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1403
1404        let mut ctx3 = FeatureContext::new();
1405        ctx3.insert("email", json!("jay.goss+onboarding70@example.com"));
1406        assert!(check(&opts, "organizations:test-feature", &ctx3));
1407    }
1408
1409    #[test]
1410    fn test_condition_not_matches_suffix_wildcard() {
1411        let cond = r#"{"property": "email", "operator": "not_matches", "value": ["*@sentry.io"]}"#;
1412        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1413
1414        let mut ctx = FeatureContext::new();
1415        ctx.insert("email", json!("user@sentry.io"));
1416        assert!(!check(&opts, "organizations:test-feature", &ctx));
1417
1418        let mut ctx2 = FeatureContext::new();
1419        ctx2.insert("email", json!("user@example.com"));
1420        assert!(check(&opts, "organizations:test-feature", &ctx2));
1421    }
1422
1423    #[test]
1424    fn test_condition_not_matches_multi_segment_wildcard() {
1425        let cond = r#"{"property": "name", "operator": "not_matches", "value": ["a*b*c"]}"#;
1426        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1427
1428        let mut ctx = FeatureContext::new();
1429        ctx.insert("name", json!("abc"));
1430        assert!(!check(&opts, "organizations:test-feature", &ctx));
1431
1432        let mut ctx2 = FeatureContext::new();
1433        ctx2.insert("name", json!("aXbYc"));
1434        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1435
1436        let mut ctx3 = FeatureContext::new();
1437        ctx3.insert("name", json!("aXXbYYc"));
1438        assert!(!check(&opts, "organizations:test-feature", &ctx3));
1439
1440        // Does not match pattern → not_matches returns true
1441        let mut ctx4 = FeatureContext::new();
1442        ctx4.insert("name", json!("aXXc"));
1443        assert!(check(&opts, "organizations:test-feature", &ctx4));
1444    }
1445
1446    #[test]
1447    fn test_condition_not_matches_star_only_pattern() {
1448        // "*" matches everything — not_matches always returns false
1449        let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["*"]}"#;
1450        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1451
1452        let mut ctx = FeatureContext::new();
1453        ctx.insert("slug", json!("anything"));
1454        assert!(!check(&opts, "organizations:test-feature", &ctx));
1455
1456        let mut ctx2 = FeatureContext::new();
1457        ctx2.insert("slug", json!(""));
1458        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1459    }
1460
1461    #[test]
1462    fn test_condition_not_matches_case_insensitive() {
1463        let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["JAYONB*"]}"#;
1464        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1465
1466        // Pattern uppercase, value lowercase — still matches, so not_matches = false
1467        let mut ctx = FeatureContext::new();
1468        ctx.insert("slug", json!("jayonb73"));
1469        assert!(!check(&opts, "organizations:test-feature", &ctx));
1470
1471        // Pattern lowercase, value uppercase — still matches, so not_matches = false
1472        let cond2 = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*"]}"#;
1473        let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
1474
1475        let mut ctx2 = FeatureContext::new();
1476        ctx2.insert("slug", json!("JAYONB73"));
1477        assert!(!check(&opts2, "organizations:test-feature", &ctx2));
1478    }
1479
1480    #[test]
1481    fn test_condition_not_matches_no_match() {
1482        let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*"]}"#;
1483        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1484
1485        let mut ctx = FeatureContext::new();
1486        ctx.insert("slug", json!("dangoldonb1"));
1487        assert!(check(&opts, "organizations:test-feature", &ctx));
1488    }
1489
1490    #[test]
1491    fn test_condition_not_matches_multiple_patterns() {
1492        let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*", "dangoldonb*", "value-disc-*"]}"#;
1493        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1494
1495        // Any pattern matches → not_matches returns false
1496        let slugs_false = ["jayonb73", "dangoldonb3", "value-disc-7"];
1497        for slug in slugs_false {
1498            let mut ctx = FeatureContext::new();
1499            ctx.insert("slug", json!(slug));
1500            assert!(
1501                !check(&opts, "organizations:test-feature", &ctx),
1502                "slug={slug} should not match"
1503            );
1504        }
1505
1506        // No pattern matches → not_matches returns true
1507        let mut ctx = FeatureContext::new();
1508        ctx.insert("slug", json!("other-org"));
1509        assert!(check(&opts, "organizations:test-feature", &ctx));
1510    }
1511
1512    #[test]
1513    fn test_condition_not_matches_overlapping_prefix_suffix_anchors() {
1514        // "a*a" requires at least "aa" — "a" alone doesn't match, so not_matches = true
1515        let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["a*a"]}"#;
1516        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1517
1518        let mut ctx = FeatureContext::new();
1519        ctx.insert("slug", json!("a"));
1520        assert!(check(&opts, "organizations:test-feature", &ctx));
1521
1522        let mut ctx2 = FeatureContext::new();
1523        ctx2.insert("slug", json!("aa"));
1524        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1525    }
1526
1527    #[test]
1528    fn test_condition_not_matches_non_string_context_returns_true() {
1529        // eval_matches returns false for non-string → not_matches returns true
1530        let cond = r#"{"property": "org_id", "operator": "not_matches", "value": ["123*"]}"#;
1531        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1532
1533        let mut ctx = FeatureContext::new();
1534        ctx.insert("org_id", json!(123));
1535        assert!(check(&opts, "organizations:test-feature", &ctx));
1536    }
1537
1538    #[test]
1539    fn test_id_string_is_stable() {
1540        for (value, expected) in [
1541            (json!("sentry"), "sentry"),
1542            (json!(123), "123"),
1543            (json!(-7), "-7"),
1544            (json!(true), "True"),
1545            (json!(false), "False"),
1546            (json!(null), "None"),
1547            (json!(1.0), "1.0"),
1548            (json!(1.5), "1.5"),
1549            (json!(["a", "b"]), "[a, b]"),
1550            (json!([1, 2]), "[1, 2]"),
1551            (json!([]), "[]"),
1552        ] {
1553            assert_eq!(value_to_id_string(&value), expected, "for {value}");
1554        }
1555    }
1556
1557    fn condition_matches(operator: &str, value: &str, ctx: &FeatureContext) -> bool {
1558        let json = format!(r#"{{"property": "prop", "operator": "{operator}", "value": {value}}}"#);
1559        Condition::from_json(&serde_json::from_str(&json).unwrap())
1560            .expect("condition should parse")
1561            .matches(ctx)
1562    }
1563
1564    #[test]
1565    fn test_missing_property_matches_only_negated_conditions() {
1566        let ctx = FeatureContext::new();
1567        for (operator, value, expected) in [
1568            ("in", "[1]", false),
1569            ("not_in", "[1]", true),
1570            ("equals", "1", false),
1571            ("not_equals", "1", true),
1572            ("matches", r#"["a"]"#, false),
1573            ("not_matches", r#"["a"]"#, true),
1574            // `contains` has no list to search, so neither form matches.
1575            ("contains", r#""a""#, false),
1576            ("not_contains", r#""a""#, false),
1577        ] {
1578            assert_eq!(
1579                condition_matches(operator, value, &ctx),
1580                expected,
1581                "{operator}"
1582            );
1583        }
1584    }
1585
1586    #[test]
1587    fn test_unusable_property_type_matches_neither_form() {
1588        let mut map = FeatureContext::new();
1589        map.insert("prop", json!({"k": "v"}));
1590        let mut scalar = FeatureContext::new();
1591        scalar.insert("prop", json!("a"));
1592        let mut list = FeatureContext::new();
1593        list.insert("prop", json!(["b"]));
1594
1595        for (operator, value, ctx) in [
1596            ("in", "[1]", &map),
1597            ("not_in", "[1]", &map),
1598            ("contains", r#""a""#, &scalar),
1599            ("not_contains", r#""a""#, &scalar),
1600            ("matches", r#"["b"]"#, &list),
1601            ("not_matches", r#"["b"]"#, &list),
1602        ] {
1603            assert!(!condition_matches(operator, value, ctx), "{operator}");
1604        }
1605
1606        // A usable list that lacks the value still matches when negated.
1607        assert!(condition_matches("not_contains", r#""a""#, &list));
1608    }
1609
1610    /// FeatureChecker holds a `&'static Options`, so tests leak one.
1611    fn checker(opts: Options, namespace: &str) -> FeatureChecker {
1612        FeatureChecker::new(namespace.to_string(), Box::leak(Box::new(opts)))
1613    }
1614
1615    /// Schema declaring the feature, with a values file that sets nothing.
1616    fn setup_without_value() -> (Options, TempDir) {
1617        let temp = TempDir::new().unwrap();
1618        let schemas = temp.path().join("schemas");
1619        fs::create_dir_all(&schemas).unwrap();
1620        create_schema(&schemas, "test", FEATURE_SCHEMA);
1621        create_values(&temp.path().join("values"), "test", r#"{"options": {}}"#);
1622        (Options::from_directory(temp.path()).unwrap(), temp)
1623    }
1624
1625    #[test]
1626    fn test_try_has_evaluated() {
1627        let (opts, _temp) = setup_feature_options(&feature_json(true, 100, ""));
1628        let checker = checker(opts, "test");
1629
1630        let result = checker.try_has("organizations:test-feature", &FeatureContext::new());
1631        assert_eq!(result.unwrap(), Some(true));
1632    }
1633
1634    #[test]
1635    fn test_try_has_evaluated_false_is_not_absent() {
1636        // A disabled feature evaluated fine; it just isn't on. This is the case a
1637        // bare bool conflates with "could not evaluate".
1638        let (opts, _temp) = setup_feature_options(&feature_json(false, 100, ""));
1639        let checker = checker(opts, "test");
1640
1641        let result = checker.try_has("organizations:test-feature", &FeatureContext::new());
1642        assert_eq!(result.unwrap(), Some(false));
1643    }
1644
1645    #[test]
1646    fn test_try_has_absent_when_no_value_set() {
1647        let (opts, _temp) = setup_without_value();
1648        let checker = checker(opts, "test");
1649
1650        let result = checker.try_has("organizations:test-feature", &FeatureContext::new());
1651        assert_eq!(result.unwrap(), None);
1652    }
1653
1654    #[test]
1655    fn test_try_has_absent_when_feature_not_in_schema() {
1656        let (opts, _temp) = setup_feature_options(&feature_json(true, 100, ""));
1657        let checker = checker(opts, "test");
1658
1659        let result = checker.try_has("organizations:never-declared", &FeatureContext::new());
1660        assert_eq!(result.unwrap(), None);
1661    }
1662
1663    #[test]
1664    fn test_try_has_errors_on_unknown_namespace() {
1665        let (opts, _temp) = setup_feature_options(&feature_json(true, 100, ""));
1666        let checker = checker(opts, "not-a-namespace");
1667
1668        assert!(matches!(
1669            checker.try_has("organizations:test-feature", &FeatureContext::new()),
1670            Err(FeatureError::Options(
1671                crate::OptionsError::UnknownNamespace(_)
1672            ))
1673        ));
1674    }
1675
1676    #[test]
1677    fn test_try_has_errors_when_uninitialized() {
1678        let checker = FeatureChecker {
1679            namespace: "test".to_string(),
1680            options: None,
1681        };
1682
1683        assert!(matches!(
1684            checker.try_has("organizations:test-feature", &FeatureContext::new()),
1685            Err(FeatureError::NotInitialized)
1686        ));
1687    }
1688
1689    #[test]
1690    fn test_has_still_collapses_every_failure_to_false() {
1691        // has() is the fail-soft read path and must not start propagating.
1692        let (opts, _temp) = setup_without_value();
1693        let absent = checker(opts, "test");
1694        assert!(!absent.has("organizations:test-feature", &FeatureContext::new()));
1695
1696        let (opts, _temp) = setup_feature_options(&feature_json(true, 100, ""));
1697        let unknown_ns = checker(opts, "not-a-namespace");
1698        assert!(!unknown_ns.has("organizations:test-feature", &FeatureContext::new()));
1699
1700        let uninitialized = FeatureChecker {
1701            namespace: "test".to_string(),
1702            options: None,
1703        };
1704        assert!(!uninitialized.has("organizations:test-feature", &FeatureContext::new()));
1705    }
1706}