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 chrono::{DateTime, NaiveDate, NaiveDateTime};
7use num::bigint::{BigInt, Sign};
8use std::cell::{Cell, OnceCell};
9use std::collections::HashMap;
10
11use serde_json::Value;
12use sha1::{Digest, Sha1};
13
14/// Features created after this date bucket their percentage rollouts
15/// by feature name as well as by context identity, so two features at the
16/// same rollout reach different populations instead of the same low buckets.
17/// Features created on or before it, or whose `created_at` does not parse,
18/// keep bucketing on the identity alone: changing that would move their
19/// in-flight partial rollouts between organizations.
20const FEATURE_BUCKETING_EPOCH: NaiveDate = NaiveDate::from_ymd_opt(2026, 10, 5).unwrap();
21
22/// Parse a feature's creation date.
23///
24/// Accepts dates and datetimes. Timestamps with an offset are converted to
25/// UTC before taking the date; values without an offset use their date as
26/// written. Invalid values return `None`.
27fn parse_created_at(raw: &str) -> Option<NaiveDate> {
28    if let Ok(date) = NaiveDate::parse_from_str(raw, "%Y-%m-%d") {
29        return Some(date);
30    }
31    if let Ok(datetime) = NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S%.f") {
32        return Some(datetime.date());
33    }
34    DateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S%.f%z")
35        .ok()
36        .map(|datetime| datetime.naive_utc().date())
37}
38
39/// Whether a feature with this `created_at` buckets rollouts by feature name.
40fn created_after_epoch(created_at: Option<&str>) -> bool {
41    created_at
42        .and_then(parse_created_at)
43        .is_some_and(|created_at| created_at > FEATURE_BUCKETING_EPOCH)
44}
45
46/// Produce a Python-compatible string representation for identity hashing.
47fn value_to_id_string(value: &Value) -> String {
48    match value {
49        Value::String(s) => s.clone(),
50        Value::Number(n) => {
51            if let Some(i) = n.as_i64() {
52                i.to_string()
53            } else if let Some(f) = n.as_f64() {
54                // Match Python's str() output: 1.0 -> "1.0", 1.5 -> "1.5"
55                if f.fract() == 0.0 {
56                    format!("{f:.1}")
57                } else {
58                    f.to_string()
59                }
60            } else {
61                n.to_string()
62            }
63        }
64        Value::Bool(b) => if *b { "True" } else { "False" }.to_string(),
65        Value::Array(arr) => {
66            let items: Vec<String> = arr.iter().map(value_to_id_string).collect();
67            format!("[{}]", items.join(", "))
68        }
69        Value::Null => "None".to_string(),
70        Value::Object(_) => value.to_string(),
71    }
72}
73
74/// Application context passed to feature flag evaluation.
75///
76/// Contains arbitrary key-value data used to evaluate feature flag conditions.
77/// The identity fields determine which fields are used for rollout bucketing.
78pub struct FeatureContext {
79    data: HashMap<String, Value>,
80    identity_fields: Vec<String>,
81    /// The identity string and its hash, cached until the data or identity
82    /// fields change.
83    cached_identity: OnceCell<String>,
84    cached_id: Cell<Option<u64>>,
85}
86
87impl FeatureContext {
88    pub fn new() -> Self {
89        Self {
90            data: HashMap::new(),
91            identity_fields: Vec::new(),
92            cached_identity: OnceCell::new(),
93            cached_id: Cell::new(None),
94        }
95    }
96
97    /// Set the fields used to compute this context's rollout identity.
98    ///
99    /// Fields are sorted lexicographically before hashing. Calling this
100    /// resets the cached identity value.
101    pub fn identity_fields(&mut self, fields: Vec<&str>) {
102        self.identity_fields = fields.into_iter().map(|s| s.to_string()).collect();
103        self.cached_id.set(None);
104        self.cached_identity.take();
105    }
106
107    /// Insert a key-value pair into the context.
108    pub fn insert(&mut self, key: &str, value: impl Into<Value>) {
109        self.data.insert(key.to_string(), value.into());
110        self.cached_id.set(None);
111        self.cached_identity.take();
112    }
113
114    /// Get a context value by key.
115    pub fn get(&self, key: &str) -> Option<&Value> {
116        self.data.get(key)
117    }
118
119    /// Check if a key is present in the context.
120    pub fn has(&self, key: &str) -> bool {
121        self.data.contains_key(key)
122    }
123
124    /// Compute and return this context's rollout identity.
125    ///
126    /// The result is cached and reset when identity fields or context data change.
127    /// When no identity fields are set, all context keys are used (non-deterministic
128    /// rollout across contexts with different keys).
129    /// The id value is mostly used to id % 100 <= rollout.
130    pub fn id(&self) -> u64 {
131        if let Some(id) = self.cached_id.get() {
132            return id;
133        }
134        let id = self.compute_id(None);
135        self.cached_id.set(Some(id));
136        id
137    }
138
139    /// The id a percentage rollout buckets this context on.
140    ///
141    /// Without a feature name this is [`id`](Self::id). With one, the name is
142    /// hashed as one more identity entry in front of the identity fields, so
143    /// each feature has its own rollout population; see
144    /// `FEATURE_BUCKETING_EPOCH` for which features use it.
145    pub fn bucket_id(&self, feature_name: Option<&str>) -> u64 {
146        match feature_name {
147            None => self.id(),
148            Some(name) => self.compute_id(Some(name)),
149        }
150    }
151
152    fn compute_identity(&self) -> String {
153        let mut identity_fields: Vec<&String> = self
154            .identity_fields
155            .iter()
156            .filter(|f| self.data.contains_key(f.as_str()))
157            .collect();
158        if identity_fields.is_empty() {
159            identity_fields = self.data.keys().collect();
160        }
161        identity_fields.sort();
162
163        let mut parts: Vec<String> = Vec::with_capacity(identity_fields.len() * 2);
164        for key in identity_fields {
165            parts.push(key.clone());
166            parts.push(value_to_id_string(&self.data[key.as_str()]));
167        }
168        parts.join(":")
169    }
170
171    /// Compute the id for a FeatureContext.
172    ///
173    /// The original python implementation used a bigint value
174    /// derived from the sha1 hash.
175    ///
176    /// This method returns a u64 which contains the lower place
177    /// values of the bigint so that our rollout modulo math is
178    /// consistent with the original python implementation.
179    fn compute_id(&self, feature_name: Option<&str>) -> u64 {
180        let identity = self.cached_identity.get_or_init(|| self.compute_identity());
181        let mut hasher = Sha1::new();
182        if let Some(name) = feature_name {
183            hasher.update(name.as_bytes());
184            hasher.update(b":");
185        }
186        hasher.update(identity.as_bytes());
187        let digest = hasher.finalize();
188
189        // Create a BigInt to preserve all the 20bytes of the hash digest.
190        let bigint = BigInt::from_bytes_be(Sign::Plus, digest.as_slice());
191
192        // We only need the lower places from the big int to retain compatibility.
193        // modulo will trim off the u64 overflow, and let us break the bigint
194        // into its pieces (there will only be one).
195        let small: BigInt = bigint % 1000000000;
196        let id_parts = small.to_u64_digits().1;
197        if id_parts.is_empty() { 0 } else { id_parts[0] }
198    }
199}
200
201impl Default for FeatureContext {
202    fn default() -> Self {
203        Self::new()
204    }
205}
206
207#[derive(Debug)]
208enum OperatorKind {
209    In,
210    NotIn,
211    Contains,
212    NotContains,
213    Equals,
214    NotEquals,
215    Matches,
216    NotMatches,
217}
218
219#[derive(Debug)]
220struct Condition {
221    property: String,
222    operator: OperatorKind,
223    value: Value,
224}
225
226#[derive(Debug)]
227struct Segment {
228    rollout: u64,
229    conditions: Vec<Condition>,
230}
231
232#[derive(Debug)]
233struct Feature {
234    name: String,
235    enabled: bool,
236    segments: Vec<Segment>,
237    buckets_by_feature: bool,
238}
239
240impl Feature {
241    fn from_json(name: &str, value: &Value) -> Option<Self> {
242        // Default to true to align with flagpole behavior.
243        let enabled = value
244            .get("enabled")
245            .and_then(|v| v.as_bool())
246            .unwrap_or(true);
247        let segments = value
248            .get("segments")?
249            .as_array()?
250            .iter()
251            .filter_map(Segment::from_json)
252            .collect();
253        Some(Feature {
254            name: name.to_owned(),
255            enabled,
256            segments,
257            buckets_by_feature: created_after_epoch(
258                value.get("created_at").and_then(Value::as_str),
259            ),
260        })
261    }
262
263    fn matches(&self, context: &FeatureContext) -> bool {
264        if !self.enabled {
265            return false;
266        }
267        let feature_name = self.buckets_by_feature.then_some(self.name.as_str());
268        for segment in &self.segments {
269            if segment.conditions_match(context) {
270                return segment.in_rollout(context, feature_name);
271            }
272        }
273        false
274    }
275}
276
277impl Segment {
278    fn from_json(value: &Value) -> Option<Self> {
279        let rollout = value.get("rollout").and_then(|v| v.as_u64()).unwrap_or(100);
280        let conditions = value
281            .get("conditions")
282            .and_then(|v| v.as_array())
283            .map(|arr| arr.iter().filter_map(Condition::from_json).collect())
284            .unwrap_or_default();
285        Some(Segment {
286            rollout,
287            conditions,
288        })
289    }
290
291    fn conditions_match(&self, context: &FeatureContext) -> bool {
292        self.conditions.iter().all(|c| c.matches(context))
293    }
294
295    fn in_rollout(&self, context: &FeatureContext, feature_name: Option<&str>) -> bool {
296        if self.rollout == 0 {
297            return false;
298        }
299        if self.rollout >= 100 {
300            return true;
301        }
302        context.bucket_id(feature_name) % 100 <= self.rollout
303    }
304}
305
306impl Condition {
307    fn from_json(value: &Value) -> Option<Self> {
308        let property = value.get("property")?.as_str()?.to_string();
309        let operator = match value.get("operator")?.as_str()? {
310            "in" => OperatorKind::In,
311            "not_in" => OperatorKind::NotIn,
312            "contains" => OperatorKind::Contains,
313            "not_contains" => OperatorKind::NotContains,
314            "equals" => OperatorKind::Equals,
315            "not_equals" => OperatorKind::NotEquals,
316            "matches" => OperatorKind::Matches,
317            "not_matches" => OperatorKind::NotMatches,
318            _ => return None,
319        };
320        let value = value.get("value")?.clone();
321        Some(Condition {
322            property,
323            operator,
324            value,
325        })
326    }
327
328    fn matches(&self, context: &FeatureContext) -> bool {
329        let ctx_val = context.get(&self.property);
330        let result = match &self.operator {
331            OperatorKind::In => eval_in(ctx_val, &self.value),
332            OperatorKind::NotIn => eval_in(ctx_val, &self.value).map(|m| !m),
333            OperatorKind::Contains => eval_contains(ctx_val, &self.value),
334            OperatorKind::NotContains => eval_contains(ctx_val, &self.value).map(|m| !m),
335            OperatorKind::Equals => eval_equals(ctx_val, &self.value),
336            OperatorKind::NotEquals => eval_equals(ctx_val, &self.value).map(|m| !m),
337            OperatorKind::Matches => eval_matches(ctx_val, &self.value),
338            OperatorKind::NotMatches => eval_matches(ctx_val, &self.value).map(|m| !m),
339        };
340        // A comparison that cannot be made is a non-match even when negated:
341        // flagpole raises for these, and the exception is caught above the
342        // evaluator rather than inverted.
343        result.unwrap_or(false)
344    }
345}
346
347/// For scalar ctx_val, check if it's contained in the condition array.
348/// For array ctx_val, check if there is any intersection with the condition array.
349/// String comparison is case-insensitive.
350///
351/// `None` means the comparison could not be made.
352fn eval_in(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
353    let arr = condition_val.as_array()?;
354    match ctx_val {
355        None => Some(false),
356        Some(Value::Object(_)) => None,
357        Some(Value::Array(ctx_arr)) => Some(ctx_arr.iter().any(|v| scalar_in(v, arr))),
358        Some(scalar) => Some(scalar_in(scalar, arr)),
359    }
360}
361
362fn scalar_in(ctx_val: &Value, arr: &[Value]) -> bool {
363    match ctx_val {
364        Value::String(s) => {
365            let s_lower = s.to_lowercase();
366            arr.iter()
367                .any(|v| v.as_str().is_some_and(|cv| cv.to_lowercase() == s_lower))
368        }
369        Value::Number(n) => arr
370            .iter()
371            .any(|v| v.as_number().is_some_and(|cv| numbers_equal(n, cv))),
372        Value::Bool(b) => arr.iter().any(|v| v.as_bool().is_some_and(|cv| cv == *b)),
373        _ => false,
374    }
375}
376
377/// Compare two JSON numbers regardless of their type (int vs float)
378/// 1 == 1, 1.0 == 1, and 1.0 == 1.0
379fn numbers_equal(a: &serde_json::Number, b: &serde_json::Number) -> bool {
380    // !f64 as opposed to (i64 || u64)
381    if !a.is_f64() && !b.is_f64() {
382        return a == b;
383    }
384    // mismatched types are cast to float
385    matches!((a.as_f64(), b.as_f64()), (Some(x), Some(y)) if x == y)
386}
387
388/// Check if a context array contains a condition scalar value.
389/// String comparison is case-insensitive.
390///
391/// A property that is missing or is not a list cannot be searched at all, so
392/// `not_contains` does not grant the feature for it either.
393fn eval_contains(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
394    let ctx_arr = ctx_val?.as_array()?;
395    match condition_val {
396        Value::String(s) => {
397            let s_lower = s.to_lowercase();
398            Some(
399                ctx_arr
400                    .iter()
401                    .any(|v| v.as_str().is_some_and(|cv| cv.to_lowercase() == s_lower)),
402            )
403        }
404        Value::Number(n) => Some(
405            ctx_arr
406                .iter()
407                .any(|v| v.as_number().is_some_and(|cv| numbers_equal(n, cv))),
408        ),
409        Value::Bool(b) => Some(
410            ctx_arr
411                .iter()
412                .any(|v| v.as_bool().is_some_and(|cv| cv == *b)),
413        ),
414        _ => None,
415    }
416}
417
418/// Check if a context value equals a condition value.
419/// A missing property equals nothing, and is not treated as unusable.
420fn eval_equals(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
421    Some(match ctx_val {
422        Some(ctx_val) => values_equal(ctx_val, condition_val),
423        None => false,
424    })
425}
426
427/// Scalars are compared directly (strings case-insensitively).
428/// Arrays are compared element-wise with matching length.
429fn values_equal(ctx_val: &Value, condition_val: &Value) -> bool {
430    match (ctx_val, condition_val) {
431        (Value::String(a), Value::String(b)) => a.to_lowercase() == b.to_lowercase(),
432        (Value::Number(a), Value::Number(b)) => numbers_equal(a, b),
433        (Value::Bool(a), Value::Bool(b)) => a == b,
434        (Value::Array(a), Value::Array(b)) => {
435            a.len() == b.len() && a.iter().zip(b.iter()).all(|(av, bv)| values_equal(av, bv))
436        }
437        _ => false,
438    }
439}
440
441/// Match a value string against a single star-only glob pattern (case-insensitive).
442/// '*' matches zero or more characters. All other characters, including '?' and '[',
443/// are treated as literals.
444fn glob_star_match(pattern: &str, value: &str) -> bool {
445    let pattern = pattern.to_lowercase();
446    let value = value.to_lowercase();
447    let parts: Vec<&str> = pattern.split('*').collect();
448    // No wildcard — require exact equality.
449    if parts.len() == 1 {
450        return value == pattern;
451    }
452    // Check prefix anchor.
453    if !value.starts_with(parts[0]) {
454        return false;
455    }
456    // Check suffix anchor (skip when the last part is empty, i.e. pattern ends with '*').
457    if !parts[parts.len() - 1].is_empty() && !value.ends_with(parts[parts.len() - 1]) {
458        return false;
459    }
460    // Search window: after the prefix, before the suffix.
461    let end = if parts[parts.len() - 1].is_empty() {
462        value.len()
463    } else {
464        value.len() - parts[parts.len() - 1].len()
465    };
466    let mut start = parts[0].len();
467    // The prefix and suffix anchors overlap, meaning the
468    // value is shorter than prefix + suffix combined — no valid match possible.
469    if start > end {
470        return false;
471    }
472    // Walk middle segments left-to-right, advancing the cursor on each hit.
473    for part in &parts[1..parts.len() - 1] {
474        if part.is_empty() {
475            // Skip consecutive '*'s.
476            continue;
477        }
478        match value[start..end].find(*part) {
479            Some(idx) => start += idx + part.len(),
480            None => return false,
481        }
482    }
483    true
484}
485
486/// Check if a string context value matches any pattern in the condition array.
487/// Patterns use star-only glob semantics; comparison is case-insensitive.
488/// Returns false for non-string context values.
489fn eval_matches(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
490    let arr = condition_val.as_array()?;
491    match ctx_val {
492        // A container cannot be glob matched against.
493        Some(Value::Array(_) | Value::Object(_)) => None,
494        None => Some(false),
495        Some(scalar) => {
496            let Some(s) = scalar.as_str() else {
497                return Some(false);
498            };
499            Some(arr.iter().any(|v| {
500                v.as_str()
501                    .is_some_and(|pattern| glob_star_match(pattern, s))
502            }))
503        }
504    }
505}
506
507/// A handle for checking feature flags within a specific namespace.
508/// Why a feature could not be evaluated. Distinct from [`crate::OptionsError`]:
509/// an unset feature is not an error here (see [`FeatureChecker::try_has`]), and a
510/// value that fails to parse as a `Feature` is.
511#[derive(Debug, thiserror::Error)]
512pub enum FeatureError {
513    #[error("Options not initialized - call init() first")]
514    NotInitialized,
515
516    #[error("Value for '{key}' is not a valid feature")]
517    InvalidValue { key: String },
518
519    #[error(transparent)]
520    Options(#[from] crate::OptionsError),
521}
522
523pub struct FeatureChecker {
524    namespace: String,
525    options: Option<&'static crate::Options>,
526}
527
528impl FeatureChecker {
529    pub fn new(namespace: String, options: &'static crate::Options) -> Self {
530        Self {
531            namespace,
532            options: Some(options),
533        }
534    }
535
536    /// Check whether a feature flag is enabled for a given context.
537    ///
538    /// Returns false if the feature is not defined, not enabled, conditions don't match,
539    /// or options have not been initialized.
540    pub fn has(&self, feature_name: &str, context: &FeatureContext) -> bool {
541        // Deliberately collapses every failure to false, use `try_has` when the distinction matters.
542        match self.try_has(feature_name, context) {
543            Ok(Some(result)) => result,
544            Ok(None) => false,
545            Err(e) => {
546                tracing::debug!(feature = feature_name, error = %e, "Feature evaluation failed");
547                false
548            }
549        }
550    }
551
552    /// Like [`has`](Self::has), but distinguishes a feature that evaluated to false
553    /// from one that could not be evaluated at all.
554    ///
555    /// - `Ok(Some(bool))` -- evaluated: disabled, no matching segment, or matched
556    /// - `Ok(None)` -- no value set for this feature, so there was nothing to evaluate
557    /// - `Err(_)` -- evaluation failed: see [`FeatureError`]
558    ///
559    /// Prefer `has` on hot paths.
560    pub fn try_has(
561        &self,
562        feature_name: &str,
563        context: &FeatureContext,
564    ) -> Result<Option<bool>, FeatureError> {
565        let opts = self.options.ok_or(FeatureError::NotInitialized)?;
566        let key = format!("feature.{feature_name}");
567
568        let feature_val = match opts.get(&self.namespace, &key) {
569            Ok(v) => v,
570            // A feature the schema knows about but that has no value set is absent,
571            // not an error: nothing has been rolled out yet.
572            Err(crate::OptionsError::UnknownOption { .. }) => return Ok(None),
573            Err(e) => return Err(e.into()),
574        };
575
576        let feature = Feature::from_json(feature_name, &feature_val)
577            .ok_or_else(|| FeatureError::InvalidValue { key: key.clone() })?;
578
579        let result = feature.matches(context);
580        tracing::debug!(
581            feature = feature_name,
582            result,
583            bucket_id = context.bucket_id(feature.buckets_by_feature.then_some(feature_name)),
584            "Feature match result"
585        );
586        Ok(Some(result))
587    }
588}
589
590/// Get a feature checker handle for a namespace.
591///
592/// Returns a handle that returns false for all checks if `init()` has not been called.
593pub fn features(namespace: &str) -> FeatureChecker {
594    FeatureChecker {
595        namespace: namespace.to_string(),
596        options: crate::GLOBAL_OPTIONS.get(),
597    }
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603    use crate::Options;
604    use serde_json::json;
605    use std::fs;
606    use std::path::Path;
607    use tempfile::TempDir;
608
609    fn create_schema(dir: &Path, namespace: &str, schema: &str) {
610        let schema_dir = dir.join(namespace);
611        fs::create_dir_all(&schema_dir).unwrap();
612        fs::write(schema_dir.join("schema.json"), schema).unwrap();
613    }
614
615    fn create_values(dir: &Path, namespace: &str, values: &str) {
616        let ns_dir = dir.join(namespace);
617        fs::create_dir_all(&ns_dir).unwrap();
618        fs::write(ns_dir.join("values.json"), values).unwrap();
619    }
620
621    const FEATURE_SCHEMA: &str = r##"{
622        "version": "1.0",
623        "type": "object",
624        "properties": {
625            "feature.organizations:test-feature": {
626                "$ref": "#/definitions/Feature"
627            }
628        }
629    }"##;
630
631    fn setup_feature_options(feature_json: &str) -> (Options, TempDir) {
632        let temp = TempDir::new().unwrap();
633        let schemas = temp.path().join("schemas");
634        fs::create_dir_all(&schemas).unwrap();
635        create_schema(&schemas, "test", FEATURE_SCHEMA);
636
637        let values = temp.path().join("values");
638        let values_json = format!(
639            r#"{{"options": {{"feature.organizations:test-feature": {}}}}}"#,
640            feature_json
641        );
642        create_values(&values, "test", &values_json);
643
644        let opts = Options::from_directory(temp.path()).unwrap();
645        (opts, temp)
646    }
647
648    fn feature_json(enabled: bool, rollout: u64, conditions: &str) -> String {
649        format!(
650            r#"{{
651                "name": "test-feature",
652                "enabled": {enabled},
653                "owner": {{"team": "test-team"}},
654                "created_at": "2024-01-01",
655                "segments": [{{
656                    "name": "test-segment",
657                    "rollout": {rollout},
658                    "conditions": [{conditions}]
659                }}]
660            }}"#
661        )
662    }
663
664    fn in_condition(property: &str, values: &str) -> String {
665        format!(r#"{{"property": "{property}", "operator": "in", "value": [{values}]}}"#)
666    }
667
668    fn check(opts: &Options, feature: &str, ctx: &FeatureContext) -> bool {
669        let key = format!("feature.{feature}");
670        let Ok(val) = opts.get("test", &key) else {
671            return false;
672        };
673        Feature::from_json(feature, &val).is_some_and(|parsed_feature| parsed_feature.matches(ctx))
674    }
675
676    #[test]
677    fn test_feature_context_insert_and_get() {
678        let mut ctx = FeatureContext::new();
679        ctx.insert("org_id", json!(123));
680        ctx.insert("name", json!("sentry"));
681        ctx.insert("active", json!(true));
682
683        assert!(ctx.has("org_id"));
684        assert!(!ctx.has("missing"));
685        assert_eq!(ctx.get("org_id"), Some(&json!(123)));
686        assert_eq!(ctx.get("name"), Some(&json!("sentry")));
687    }
688
689    #[test]
690    fn test_feature_context_id_is_cached() {
691        let mut ctx = FeatureContext::new();
692        ctx.identity_fields(vec!["user_id"]);
693        ctx.insert("user_id", json!(42));
694
695        let id1 = ctx.id();
696        let id2 = ctx.id();
697        assert_eq!(id1, id2, "ID should be cached and consistent");
698    }
699
700    #[test]
701    fn test_feature_context_id_resets_on_identity_change() {
702        let mut ctx = FeatureContext::new();
703        ctx.insert("user_id", json!(1));
704        ctx.insert("org_id", json!(2));
705
706        ctx.identity_fields(vec!["user_id"]);
707        let id_user = ctx.id();
708
709        ctx.identity_fields(vec!["org_id"]);
710        let id_org = ctx.id();
711
712        assert_ne!(
713            id_user, id_org,
714            "Different identity fields should produce different IDs"
715        );
716    }
717
718    #[test]
719    fn test_bucket_id_matches_bigint_reference() {
720        use num::ToPrimitive;
721        use num::bigint::{BigInt, Sign};
722
723        let cases = [
724            (0, "organizations:a"),
725            (123, "organizations:performance-view"),
726            (456, "organizations:performance-view"),
727            (123, "organizations:dashboards-edit"),
728            // Include a long name to exercise hashing across SHA-1 blocks.
729            (
730                10_000,
731                "organizations:a-feature-name-long-enough-to-span-multiple-sha1-blocks",
732            ),
733        ];
734        for (organization_id, feature_name) in cases {
735            let mut context = FeatureContext::new();
736            context.insert("organization_id", json!(organization_id));
737            context.identity_fields(vec!["organization_id"]);
738            let input = format!("{feature_name}:organization_id:{organization_id}");
739            let digest = Sha1::digest(input.as_bytes());
740            let bigint = BigInt::from_bytes_be(Sign::Plus, digest.as_slice());
741            let expected: BigInt = bigint % 1_000_000_000;
742            assert_eq!(
743                context.bucket_id(Some(feature_name)),
744                expected.to_u64().unwrap(),
745                "Hash mismatch for {input}"
746            );
747        }
748    }
749
750    #[test]
751    fn test_bucket_id_resets_on_context_changes() {
752        for feature_name in [None, Some("organizations:test-feature")] {
753            let mut context = FeatureContext::new();
754            context.insert("organization_id", json!(123));
755            context.insert("user_id", json!(42));
756            context.identity_fields(vec!["organization_id"]);
757            let original_id = context.bucket_id(feature_name);
758
759            context.insert("organization_id", json!(456));
760            let updated_id = context.bucket_id(feature_name);
761            assert_ne!(original_id, updated_id);
762
763            context.identity_fields(vec!["user_id"]);
764            assert_ne!(updated_id, context.bucket_id(feature_name));
765        }
766    }
767
768    #[test]
769    fn test_feature_context_id_deterministic() {
770        let make_ctx = || {
771            let mut ctx = FeatureContext::new();
772            ctx.identity_fields(vec!["user_id", "org_id"]);
773            ctx.insert("user_id", json!(456));
774            ctx.insert("org_id", json!(123));
775            ctx
776        };
777
778        assert_eq!(make_ctx().id(), make_ctx().id());
779
780        let mut other_ctx = FeatureContext::new();
781        other_ctx.identity_fields(vec!["user_id", "org_id"]);
782        other_ctx.insert("user_id", json!(789));
783        other_ctx.insert("org_id", json!(123));
784
785        assert_ne!(make_ctx().id(), other_ctx.id());
786    }
787
788    #[test]
789    fn test_feature_context_id_value_align_with_python() {
790        // Context.id() determines rollout rates with modulo
791        // This implementation should generate the same rollout slots
792        // as the previous implementation did.
793        let ctx = FeatureContext::new();
794        assert_eq!(ctx.id() % 100, 5, "should match with python implementation");
795
796        let mut ctx = FeatureContext::new();
797        ctx.insert("foo", json!("bar"));
798        ctx.insert("baz", json!("barfoo"));
799        ctx.identity_fields(vec!["foo"]);
800        assert_eq!(ctx.id() % 100, 62);
801
802        // Undefined fields should not contribute to the id.
803        let mut ctx = FeatureContext::new();
804        ctx.insert("foo", json!("bar"));
805        ctx.insert("baz", json!("barfoo"));
806        ctx.identity_fields(vec!["foo", "whoops"]);
807        assert_eq!(ctx.id() % 100, 62);
808
809        let mut ctx = FeatureContext::new();
810        ctx.insert("foo", json!("bar"));
811        ctx.insert("baz", json!("barfoo"));
812        ctx.identity_fields(vec!["foo", "baz"]);
813        assert_eq!(ctx.id() % 100, 1);
814
815        let mut ctx = FeatureContext::new();
816        ctx.insert("foo", json!("bar"));
817        ctx.insert("baz", json!("barfoo"));
818        // When there is no overlap with identity fields and data,
819        // all fields should be used
820        ctx.identity_fields(vec!["whoops", "nope"]);
821        assert_eq!(ctx.id() % 100, 1);
822    }
823
824    #[test]
825    fn test_feature_prefix_is_added() {
826        let cond = in_condition("organization_id", "123");
827        let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
828
829        let mut ctx = FeatureContext::new();
830        ctx.insert("organization_id", json!(123));
831
832        assert!(check(&opts, "organizations:test-feature", &ctx));
833    }
834
835    #[test]
836    fn test_undefined_feature_returns_false() {
837        let cond = in_condition("organization_id", "123");
838        let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
839
840        let ctx = FeatureContext::new();
841        assert!(!check(&opts, "nonexistent", &ctx));
842    }
843
844    #[test]
845    fn test_missing_context_field_returns_false() {
846        let cond = in_condition("organization_id", "123");
847        let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
848
849        let ctx = FeatureContext::new();
850        assert!(!check(&opts, "organizations:test-feature", &ctx));
851    }
852
853    #[test]
854    fn test_matching_context_returns_true() {
855        let cond = in_condition("organization_id", "123, 456");
856        let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
857
858        let mut ctx = FeatureContext::new();
859        ctx.insert("organization_id", json!(123));
860
861        assert!(check(&opts, "organizations:test-feature", &ctx));
862    }
863
864    #[test]
865    fn test_non_matching_context_returns_false() {
866        let cond = in_condition("organization_id", "123, 456");
867        let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
868
869        let mut ctx = FeatureContext::new();
870        ctx.insert("organization_id", json!(999));
871
872        assert!(!check(&opts, "organizations:test-feature", &ctx));
873    }
874
875    #[test]
876    fn test_disabled_feature_returns_false() {
877        let cond = in_condition("organization_id", "123");
878        let (opts, _t) = setup_feature_options(&feature_json(false, 100, &cond));
879
880        let mut ctx = FeatureContext::new();
881        ctx.insert("organization_id", json!(123));
882
883        assert!(!check(&opts, "organizations:test-feature", &ctx));
884    }
885
886    #[test]
887    fn test_rollout_zero_returns_false() {
888        let cond = in_condition("organization_id", "123");
889        let (opts, _t) = setup_feature_options(&feature_json(true, 0, &cond));
890
891        let mut ctx = FeatureContext::new();
892        ctx.insert("organization_id", json!(123));
893
894        assert!(!check(&opts, "organizations:test-feature", &ctx));
895    }
896
897    #[test]
898    fn test_rollout_100_returns_true() {
899        let cond = in_condition("organization_id", "123");
900        let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
901
902        let mut ctx = FeatureContext::new();
903        ctx.insert("organization_id", json!(123));
904
905        assert!(check(&opts, "organizations:test-feature", &ctx));
906    }
907
908    #[test]
909    fn test_rollout_is_deterministic() {
910        let mut ctx = FeatureContext::new();
911        ctx.identity_fields(vec!["user_id"]);
912        ctx.insert("user_id", json!(42));
913        ctx.insert("organization_id", json!(123));
914
915        // Add 1 to get around fence post with < vs <=
916        let id_mod = (ctx.id() % 100) + 1;
917        let cond = in_condition("organization_id", "123");
918
919        let (opts_at, _t1) = setup_feature_options(&feature_json(true, id_mod, &cond));
920        assert!(check(&opts_at, "organizations:test-feature", &ctx));
921    }
922
923    #[test]
924    fn test_bucket_id_pinned_values() {
925        // Pinned bucket values, reproducible with:
926        //   int(hashlib.sha1(b"<feature>:organization_id:123").hexdigest(), 16) % 100
927        // organization_slug is excluded because identity_fields limits the hash to organization_id.
928        let mut ctx = FeatureContext::new();
929        ctx.insert("organization_id", json!(123));
930        ctx.insert("organization_slug", json!("sentry"));
931        ctx.identity_fields(vec!["organization_id"]);
932
933        assert_eq!(ctx.bucket_id(None), ctx.id());
934        assert_eq!(
935            ctx.bucket_id(Some("organizations:performance-view")) % 100,
936            64
937        );
938        assert_eq!(
939            ctx.bucket_id(Some("organizations:dashboards-edit")) % 100,
940            75
941        );
942
943        let ctx = FeatureContext::new();
944        assert_eq!(
945            ctx.bucket_id(Some("organizations:performance-view")) % 100,
946            85
947        );
948    }
949
950    #[test]
951    fn test_created_after_epoch() {
952        let cases = [
953            ("2026-10-05", false),
954            ("2026-10-05T00:00:00", false),
955            ("2026-10-05T00:00:01", false),
956            ("2026-10-05T00:00:00.000001", false),
957            ("2026-10-05T23:59:59.999999999", false),
958            ("2026-10-06", true),
959            ("2026-10-06T00:00:00", true),
960            ("2026-10-04T23:00:00-02:00", false),
961            ("2026-10-05T23:00:00-02:00", true),
962            ("2026-10-05T23:00:00-0200", true),
963            ("2026-10-06T01:00:00+02:00", false),
964            ("2026-10-06T01:00:00+0200", false),
965            ("2024-01-01", false),
966            ("None", false),
967            ("not a date", false),
968        ];
969        for (created_at, expected) in cases {
970            assert_eq!(
971                created_after_epoch(Some(created_at)),
972                expected,
973                "{created_at}"
974            );
975        }
976        assert!(!created_after_epoch(None));
977    }
978
979    #[test]
980    fn test_rollout_keeps_identity_bucketing_for_features_created_before_epoch() {
981        // Organization 123 is bucket 56 on identity alone. Under the feature
982        // name it would be 64 (see the test below), which rollout 56 excludes.
983        let mut ctx = FeatureContext::new();
984        ctx.insert("organization_id", json!(123));
985        ctx.identity_fields(vec!["organization_id"]);
986        assert_eq!(ctx.id() % 100, 56);
987
988        let feature = |rollout: u64| {
989            let value = json!({
990                "created_at": "2024-01-01",
991                "segments": [{"name": "all", "rollout": rollout, "conditions": []}]
992            });
993            Feature::from_json("organizations:performance-view", &value)
994                .unwrap()
995                .matches(&ctx)
996        };
997        assert!(feature(56));
998        assert!(!feature(55));
999    }
1000
1001    #[test]
1002    fn test_rollout_buckets_by_feature_for_features_created_after_epoch() {
1003        // Organization 123 lands in a different bucket under each feature, so
1004        // the two features at the same rollout reach different populations.
1005        let mut ctx = FeatureContext::new();
1006        ctx.insert("organization_id", json!(123));
1007        ctx.identity_fields(vec!["organization_id"]);
1008        assert_eq!(
1009            ctx.bucket_id(Some("organizations:performance-view")) % 100,
1010            64
1011        );
1012        assert_eq!(
1013            ctx.bucket_id(Some("organizations:dashboards-edit")) % 100,
1014            75
1015        );
1016
1017        let feature = |name: &str, rollout: u64| {
1018            let value = json!({
1019                "created_at": "2026-12-01",
1020                "segments": [{"name": "all", "rollout": rollout, "conditions": []}]
1021            });
1022            Feature::from_json(name, &value).unwrap().matches(&ctx)
1023        };
1024        assert!(feature("organizations:performance-view", 64));
1025        assert!(!feature("organizations:performance-view", 63));
1026        assert!(!feature("organizations:dashboards-edit", 64));
1027    }
1028
1029    #[test]
1030    fn test_first_matching_segment_decides_rollout() {
1031        let mut context = FeatureContext::new();
1032        context.insert("organization_id", json!(123));
1033        context.identity_fields(vec!["organization_id"]);
1034
1035        for (rollout, expected) in [
1036            (0, false),
1037            (63, false),
1038            (64, true),
1039            (100, true),
1040            (101, true),
1041        ] {
1042            let value = json!({
1043                "created_at": "2026-12-01",
1044                "segments": [
1045                    {"name": "first", "rollout": rollout, "conditions": []},
1046                    {"name": "second", "rollout": 100, "conditions": []}
1047                ]
1048            });
1049            let feature = Feature::from_json("organizations:performance-view", &value).unwrap();
1050
1051            assert_eq!(feature.matches(&context), expected, "rollout {rollout}");
1052        }
1053    }
1054
1055    #[test]
1056    fn test_condition_in_string_case_insensitive() {
1057        let cond = r#"{"property": "slug", "operator": "in", "value": ["Sentry", "ACME"]}"#;
1058        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1059
1060        let mut ctx = FeatureContext::new();
1061        ctx.insert("slug", json!("sentry"));
1062        assert!(check(&opts, "organizations:test-feature", &ctx));
1063    }
1064
1065    #[test]
1066    fn test_condition_not_in() {
1067        let cond = r#"{"property": "organization_id", "operator": "not_in", "value": [999]}"#;
1068        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1069
1070        let mut ctx = FeatureContext::new();
1071        ctx.insert("organization_id", json!(123));
1072        assert!(check(&opts, "organizations:test-feature", &ctx));
1073
1074        let mut ctx2 = FeatureContext::new();
1075        ctx2.insert("organization_id", json!(999));
1076        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1077    }
1078
1079    #[test]
1080    fn test_condition_in_list_property_matches_on_overlap() {
1081        let cond = r#"{"property": "plan_family", "operator": "in", "value": ["business"]}"#;
1082        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1083
1084        let mut ctx = FeatureContext::new();
1085        ctx.insert("plan_family", json!(["enterprise business", "Business"]));
1086        assert!(check(&opts, "organizations:test-feature", &ctx));
1087
1088        let mut ctx2 = FeatureContext::new();
1089        ctx2.insert("plan_family", json!(["team"]));
1090        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1091
1092        let mut ctx3 = FeatureContext::new();
1093        ctx3.insert("plan_family", json!([]));
1094        assert!(!check(&opts, "organizations:test-feature", &ctx3));
1095    }
1096
1097    #[test]
1098    fn test_condition_not_in_list_property_requires_no_overlap() {
1099        let cond = r#"{"property": "plan_family", "operator": "not_in", "value": ["business"]}"#;
1100        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1101
1102        let mut ctx = FeatureContext::new();
1103        ctx.insert("plan_family", json!(["enterprise business", "business"]));
1104        assert!(!check(&opts, "organizations:test-feature", &ctx));
1105
1106        let mut ctx2 = FeatureContext::new();
1107        ctx2.insert("plan_family", json!(["team"]));
1108        assert!(check(&opts, "organizations:test-feature", &ctx2));
1109    }
1110
1111    #[test]
1112    fn test_condition_in_list_property_no_type_coercion() {
1113        let cond = r#"{"property": "org_id", "operator": "in", "value": ["123"]}"#;
1114        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1115
1116        let mut ctx = FeatureContext::new();
1117        ctx.insert("org_id", json!([123]));
1118        assert!(!check(&opts, "organizations:test-feature", &ctx));
1119
1120        let int_cond = r#"{"property": "org_id", "operator": "in", "value": [123]}"#;
1121        let (int_opts, _t2) = setup_feature_options(&feature_json(true, 100, int_cond));
1122        assert!(check(&int_opts, "organizations:test-feature", &ctx));
1123    }
1124
1125    #[test]
1126    fn test_condition_in_matches_across_int_and_float() {
1127        let cond = r#"{"property": "org_id", "operator": "in", "value": [123.0]}"#;
1128        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1129
1130        let mut ctx = FeatureContext::new();
1131        ctx.insert("org_id", json!(123));
1132        assert!(check(&opts, "organizations:test-feature", &ctx));
1133
1134        let mut list_ctx = FeatureContext::new();
1135        list_ctx.insert("org_id", json!([123]));
1136        assert!(check(&opts, "organizations:test-feature", &list_ctx));
1137
1138        let mut ctx2 = FeatureContext::new();
1139        ctx2.insert("org_id", json!(124));
1140        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1141    }
1142
1143    #[test]
1144    fn test_condition_in_large_ints_compare_exactly() {
1145        // 2^53 + 1 and 2^53 collide as f64, so ints must not round-trip through it.
1146        let cond = r#"{"property": "org_id", "operator": "in", "value": [9007199254740993]}"#;
1147        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1148
1149        let mut ctx = FeatureContext::new();
1150        ctx.insert("org_id", json!(9007199254740992i64));
1151        assert!(!check(&opts, "organizations:test-feature", &ctx));
1152
1153        let mut ctx2 = FeatureContext::new();
1154        ctx2.insert("org_id", json!(9007199254740993i64));
1155        assert!(check(&opts, "organizations:test-feature", &ctx2));
1156
1157        // Above i64::MAX, where as_i64() gives up but the values are still exact.
1158        let big = r#"{"property": "org_id", "operator": "in", "value": [18446744073709551615]}"#;
1159        let (big_opts, _t2) = setup_feature_options(&feature_json(true, 100, big));
1160
1161        let mut ctx3 = FeatureContext::new();
1162        ctx3.insert("org_id", json!(18446744073709551614u64));
1163        assert!(!check(&big_opts, "organizations:test-feature", &ctx3));
1164
1165        let mut ctx4 = FeatureContext::new();
1166        ctx4.insert("org_id", json!(18446744073709551615u64));
1167        assert!(check(&big_opts, "organizations:test-feature", &ctx4));
1168    }
1169
1170    #[test]
1171    fn test_condition_contains() {
1172        let cond = r#"{"property": "tags", "operator": "contains", "value": "beta"}"#;
1173        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1174
1175        let mut ctx = FeatureContext::new();
1176        ctx.insert("tags", json!(["alpha", "beta"]));
1177        assert!(check(&opts, "organizations:test-feature", &ctx));
1178
1179        let mut ctx2 = FeatureContext::new();
1180        ctx2.insert("tags", json!(["alpha"]));
1181        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1182    }
1183
1184    #[test]
1185    fn test_condition_contains_matches_across_int_and_float() {
1186        let int_cond = r#"{"property": "ids", "operator": "contains", "value": 123}"#;
1187        let (int_opts, _t) = setup_feature_options(&feature_json(true, 100, int_cond));
1188
1189        let float_cond = r#"{"property": "ids", "operator": "contains", "value": 123.0}"#;
1190        let (float_opts, _t2) = setup_feature_options(&feature_json(true, 100, float_cond));
1191
1192        let mut int_ctx = FeatureContext::new();
1193        int_ctx.insert("ids", json!([123]));
1194        let mut float_ctx = FeatureContext::new();
1195        float_ctx.insert("ids", json!([123.0]));
1196
1197        // Matches regardless of which side holds the float.
1198        assert!(check(&int_opts, "organizations:test-feature", &int_ctx));
1199        assert!(check(&int_opts, "organizations:test-feature", &float_ctx));
1200        assert!(check(&float_opts, "organizations:test-feature", &int_ctx));
1201        assert!(check(&float_opts, "organizations:test-feature", &float_ctx));
1202
1203        let mut other = FeatureContext::new();
1204        other.insert("ids", json!([124]));
1205        assert!(!check(&int_opts, "organizations:test-feature", &other));
1206    }
1207
1208    #[test]
1209    fn test_condition_equals() {
1210        let cond = r#"{"property": "plan", "operator": "equals", "value": "enterprise"}"#;
1211        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1212
1213        let mut ctx = FeatureContext::new();
1214        ctx.insert("plan", json!("Enterprise"));
1215        assert!(check(&opts, "organizations:test-feature", &ctx));
1216
1217        let mut ctx2 = FeatureContext::new();
1218        ctx2.insert("plan", json!("free"));
1219        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1220    }
1221
1222    #[test]
1223    fn test_condition_equals_large_ints_compare_exactly() {
1224        // Above i64::MAX these used to collapse into f64 and compare equal.
1225        let cond = r#"{"property": "org_id", "operator": "equals", "value": 18446744073709551615}"#;
1226        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1227
1228        let mut ctx = FeatureContext::new();
1229        ctx.insert("org_id", json!(18446744073709551614u64));
1230        assert!(!check(&opts, "organizations:test-feature", &ctx));
1231
1232        let mut ctx2 = FeatureContext::new();
1233        ctx2.insert("org_id", json!(18446744073709551615u64));
1234        assert!(check(&opts, "organizations:test-feature", &ctx2));
1235    }
1236
1237    #[test]
1238    fn test_condition_equals_bool() {
1239        let cond = r#"{"property": "is_free", "operator": "equals", "value": true}"#;
1240        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1241
1242        let mut ctx = FeatureContext::new();
1243        ctx.insert("is_free", json!(true));
1244        assert!(check(&opts, "organizations:test-feature", &ctx));
1245
1246        let mut ctx2 = FeatureContext::new();
1247        ctx2.insert("is_free", json!(false));
1248        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1249    }
1250
1251    #[test]
1252    fn test_segment_with_no_conditions_always_matches() {
1253        let feature = r#"{
1254            "name": "test-feature",
1255            "enabled": true,
1256            "owner": {"team": "test-team"},
1257            "created_at": "2024-01-01",
1258            "segments": [{"name": "open", "rollout": 100, "conditions": []}]
1259        }"#;
1260        let (opts, _t) = setup_feature_options(feature);
1261
1262        let ctx = FeatureContext::new();
1263        assert!(check(&opts, "organizations:test-feature", &ctx));
1264    }
1265
1266    #[test]
1267    fn test_feature_enabled_and_rollout_default_values() {
1268        // This feature doesn't define .enabled or .segments[0].rollout
1269        let feature = r#"{
1270            "name": "test-feature",
1271            "owner": {"team": "test-team"},
1272            "created_at": "2024-01-01",
1273            "segments": [
1274                {
1275                    "name": "first",
1276                    "conditions": [{"property": "org_id", "operator": "in", "value":[1]}]
1277                }
1278            ]
1279        }"#;
1280        let (opts, _t) = setup_feature_options(feature);
1281
1282        let mut ctx = FeatureContext::new();
1283        ctx.insert("org_id", 1);
1284        ctx.identity_fields(vec!["org_id"]);
1285        assert!(check(&opts, "organizations:test-feature", &ctx));
1286    }
1287
1288    #[test]
1289    fn test_feature_with_no_segments_returns_false() {
1290        let feature = r#"{
1291            "name": "test-feature",
1292            "enabled": true,
1293            "owner": {"team": "test-team"},
1294            "created_at": "2024-01-01",
1295            "segments": []
1296        }"#;
1297        let (opts, _t) = setup_feature_options(feature);
1298
1299        let ctx = FeatureContext::new();
1300        assert!(!check(&opts, "organizations:test-feature", &ctx));
1301    }
1302
1303    #[test]
1304    fn test_multiple_segments_or_logic() {
1305        let feature = r#"{
1306            "name": "test-feature",
1307            "enabled": true,
1308            "owner": {"team": "test-team"},
1309            "created_at": "2024-01-01",
1310            "segments": [
1311                {
1312                    "name": "segment-a",
1313                    "rollout": 100,
1314                    "conditions": [{"property": "org_id", "operator": "in", "value": [1]}]
1315                },
1316                {
1317                    "name": "segment-b",
1318                    "rollout": 100,
1319                    "conditions": [{"property": "org_id", "operator": "in", "value": [2]}]
1320                }
1321            ]
1322        }"#;
1323        let (opts, _t) = setup_feature_options(feature);
1324
1325        let mut ctx1 = FeatureContext::new();
1326        ctx1.insert("org_id", json!(1));
1327        assert!(check(&opts, "organizations:test-feature", &ctx1));
1328
1329        let mut ctx2 = FeatureContext::new();
1330        ctx2.insert("org_id", json!(2));
1331        assert!(check(&opts, "organizations:test-feature", &ctx2));
1332
1333        let mut ctx3 = FeatureContext::new();
1334        ctx3.insert("org_id", json!(3));
1335        assert!(!check(&opts, "organizations:test-feature", &ctx3));
1336    }
1337
1338    #[test]
1339    fn test_multiple_conditions_and_logic() {
1340        let conds = r#"
1341            {"property": "org_id", "operator": "in", "value": [123]},
1342            {"property": "user_email", "operator": "in", "value": ["admin@example.com"]}
1343        "#;
1344        let (opts, _t) = setup_feature_options(&feature_json(true, 100, conds));
1345
1346        let mut ctx = FeatureContext::new();
1347        ctx.insert("org_id", json!(123));
1348        ctx.insert("user_email", json!("admin@example.com"));
1349        assert!(check(&opts, "organizations:test-feature", &ctx));
1350
1351        let mut ctx2 = FeatureContext::new();
1352        ctx2.insert("org_id", json!(123));
1353        ctx2.insert("user_email", json!("other@example.com"));
1354        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1355    }
1356
1357    #[test]
1358    fn test_in_int_context_against_string_list_returns_false() {
1359        let cond = r#"{"property": "org_id", "operator": "in", "value": ["123", "456"]}"#;
1360        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1361
1362        let mut ctx = FeatureContext::new();
1363        ctx.insert("org_id", json!(123));
1364        assert!(!check(&opts, "organizations:test-feature", &ctx));
1365    }
1366
1367    #[test]
1368    fn test_in_string_context_against_int_list_returns_false() {
1369        let cond = r#"{"property": "slug", "operator": "in", "value": [123, 456]}"#;
1370        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1371
1372        let mut ctx = FeatureContext::new();
1373        ctx.insert("slug", json!("123"));
1374        assert!(!check(&opts, "organizations:test-feature", &ctx));
1375    }
1376
1377    #[test]
1378    fn test_in_bool_context_against_string_list_returns_false() {
1379        let cond = r#"{"property": "active", "operator": "in", "value": ["true", "false"]}"#;
1380        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1381
1382        let mut ctx = FeatureContext::new();
1383        ctx.insert("active", json!(true));
1384        assert!(!check(&opts, "organizations:test-feature", &ctx));
1385    }
1386
1387    #[test]
1388    fn test_in_float_context_against_string_list_returns_false() {
1389        let cond = r#"{"property": "score", "operator": "in", "value": ["0.5", "1.0"]}"#;
1390        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1391
1392        let mut ctx = FeatureContext::new();
1393        ctx.insert("score", json!(0.5));
1394        assert!(!check(&opts, "organizations:test-feature", &ctx));
1395    }
1396
1397    #[test]
1398    fn test_not_in_int_context_against_string_list_returns_true() {
1399        // Type mismatch means "in" is false, so "not_in" is true
1400        let cond = r#"{"property": "org_id", "operator": "not_in", "value": ["123", "456"]}"#;
1401        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1402
1403        let mut ctx = FeatureContext::new();
1404        ctx.insert("org_id", json!(123));
1405        assert!(check(&opts, "organizations:test-feature", &ctx));
1406    }
1407
1408    #[test]
1409    fn test_not_in_string_context_against_int_list_returns_true() {
1410        // Type mismatch means "in" is false, so "not_in" is true
1411        let cond = r#"{"property": "slug", "operator": "not_in", "value": [123, 456]}"#;
1412        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1413
1414        let mut ctx = FeatureContext::new();
1415        ctx.insert("slug", json!("123"));
1416        assert!(check(&opts, "organizations:test-feature", &ctx));
1417    }
1418
1419    #[test]
1420    fn test_condition_matches_literal() {
1421        let cond = r#"{"property": "slug", "operator": "matches", "value": ["sentry"]}"#;
1422        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1423
1424        let mut ctx = FeatureContext::new();
1425        ctx.insert("slug", json!("sentry"));
1426        assert!(check(&opts, "organizations:test-feature", &ctx));
1427
1428        let mut ctx2 = FeatureContext::new();
1429        ctx2.insert("slug", json!("getsentry"));
1430        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1431    }
1432
1433    #[test]
1434    fn test_condition_matches_prefix_wildcard() {
1435        let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
1436        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1437
1438        let mut ctx = FeatureContext::new();
1439        ctx.insert("slug", json!("jayonb73"));
1440        assert!(check(&opts, "organizations:test-feature", &ctx));
1441
1442        // '*' matches zero chars too
1443        let mut ctx2 = FeatureContext::new();
1444        ctx2.insert("slug", json!("jayonb"));
1445        assert!(check(&opts, "organizations:test-feature", &ctx2));
1446
1447        let mut ctx3 = FeatureContext::new();
1448        ctx3.insert("slug", json!("dangoldonb1"));
1449        assert!(!check(&opts, "organizations:test-feature", &ctx3));
1450    }
1451
1452    #[test]
1453    fn test_condition_matches_prefix_and_suffix_wildcard() {
1454        let cond = r#"{"property": "email", "operator": "matches", "value": ["jay.goss+onboarding*@sentry.io"]}"#;
1455        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1456
1457        let mut ctx = FeatureContext::new();
1458        ctx.insert("email", json!("jay.goss+onboarding70@sentry.io"));
1459        assert!(check(&opts, "organizations:test-feature", &ctx));
1460
1461        // '*' matches zero chars — prefix runs directly into suffix
1462        let mut ctx2 = FeatureContext::new();
1463        ctx2.insert("email", json!("jay.goss+onboarding@sentry.io"));
1464        assert!(check(&opts, "organizations:test-feature", &ctx2));
1465
1466        let mut ctx3 = FeatureContext::new();
1467        ctx3.insert("email", json!("jay.goss+onboarding70@example.com"));
1468        assert!(!check(&opts, "organizations:test-feature", &ctx3));
1469    }
1470
1471    #[test]
1472    fn test_condition_matches_suffix_wildcard() {
1473        let cond = r#"{"property": "email", "operator": "matches", "value": ["*@sentry.io"]}"#;
1474        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1475
1476        let mut ctx = FeatureContext::new();
1477        ctx.insert("email", json!("user@sentry.io"));
1478        assert!(check(&opts, "organizations:test-feature", &ctx));
1479
1480        let mut ctx2 = FeatureContext::new();
1481        ctx2.insert("email", json!("user@example.com"));
1482        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1483    }
1484
1485    #[test]
1486    fn test_condition_matches_multi_segment_wildcard() {
1487        let cond = r#"{"property": "name", "operator": "matches", "value": ["a*b*c"]}"#;
1488        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1489
1490        let mut ctx = FeatureContext::new();
1491        ctx.insert("name", json!("abc"));
1492        assert!(check(&opts, "organizations:test-feature", &ctx));
1493
1494        let mut ctx2 = FeatureContext::new();
1495        ctx2.insert("name", json!("aXbYc"));
1496        assert!(check(&opts, "organizations:test-feature", &ctx2));
1497
1498        let mut ctx3 = FeatureContext::new();
1499        ctx3.insert("name", json!("aXXbYYc"));
1500        assert!(check(&opts, "organizations:test-feature", &ctx3));
1501
1502        let mut ctx4 = FeatureContext::new();
1503        ctx4.insert("name", json!("aXXc"));
1504        assert!(!check(&opts, "organizations:test-feature", &ctx4));
1505    }
1506
1507    #[test]
1508    fn test_condition_matches_star_only_pattern() {
1509        let cond = r#"{"property": "slug", "operator": "matches", "value": ["*"]}"#;
1510        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1511
1512        let mut ctx = FeatureContext::new();
1513        ctx.insert("slug", json!("anything"));
1514        assert!(check(&opts, "organizations:test-feature", &ctx));
1515
1516        let mut ctx2 = FeatureContext::new();
1517        ctx2.insert("slug", json!(""));
1518        assert!(check(&opts, "organizations:test-feature", &ctx2));
1519    }
1520
1521    #[test]
1522    fn test_condition_matches_case_insensitive() {
1523        let cond = r#"{"property": "slug", "operator": "matches", "value": ["JAYONB*"]}"#;
1524        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1525
1526        let mut ctx = FeatureContext::new();
1527        ctx.insert("slug", json!("jayonb73"));
1528        assert!(check(&opts, "organizations:test-feature", &ctx));
1529
1530        // Pattern lowercase, value uppercase
1531        let cond2 = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
1532        let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
1533
1534        let mut ctx2 = FeatureContext::new();
1535        ctx2.insert("slug", json!("JAYONB73"));
1536        assert!(check(&opts2, "organizations:test-feature", &ctx2));
1537    }
1538
1539    #[test]
1540    fn test_condition_matches_no_match() {
1541        let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
1542        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1543
1544        let mut ctx = FeatureContext::new();
1545        ctx.insert("slug", json!("dangoldonb1"));
1546        assert!(!check(&opts, "organizations:test-feature", &ctx));
1547    }
1548
1549    #[test]
1550    fn test_condition_matches_multiple_patterns() {
1551        let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*", "dangoldonb*", "value-disc-*"]}"#;
1552        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1553
1554        let slugs = [
1555            ("jayonb73", true),
1556            ("dangoldonb3", true),
1557            ("value-disc-7", true),
1558            ("other-org", false),
1559        ];
1560        for (slug, expected) in slugs {
1561            let mut ctx = FeatureContext::new();
1562            ctx.insert("slug", json!(slug));
1563            assert_eq!(
1564                check(&opts, "organizations:test-feature", &ctx),
1565                expected,
1566                "slug={slug}"
1567            );
1568        }
1569    }
1570
1571    #[test]
1572    fn test_condition_matches_overlapping_prefix_suffix_anchors() {
1573        // "a*a" requires at least "aa" — a single "a" must not match.
1574        let cond = r#"{"property": "slug", "operator": "matches", "value": ["a*a"]}"#;
1575        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1576
1577        let mut ctx = FeatureContext::new();
1578        ctx.insert("slug", json!("a"));
1579        assert!(!check(&opts, "organizations:test-feature", &ctx));
1580
1581        let mut ctx2 = FeatureContext::new();
1582        ctx2.insert("slug", json!("aa"));
1583        assert!(check(&opts, "organizations:test-feature", &ctx2));
1584
1585        // "ab*ab" requires at least "abab" — "ab" alone must not match.
1586        let cond2 = r#"{"property": "slug", "operator": "matches", "value": ["ab*ab"]}"#;
1587        let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
1588
1589        let mut ctx3 = FeatureContext::new();
1590        ctx3.insert("slug", json!("ab"));
1591        assert!(!check(&opts2, "organizations:test-feature", &ctx3));
1592
1593        let mut ctx4 = FeatureContext::new();
1594        ctx4.insert("slug", json!("abab"));
1595        assert!(check(&opts2, "organizations:test-feature", &ctx4));
1596    }
1597
1598    #[test]
1599    fn test_condition_matches_non_string_context_returns_false() {
1600        // Non-string context values should not match any pattern — eval_matches returns false.
1601        let cond = r#"{"property": "org_id", "operator": "matches", "value": ["123*"]}"#;
1602        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1603
1604        let mut ctx = FeatureContext::new();
1605        ctx.insert("org_id", json!(123));
1606        assert!(!check(&opts, "organizations:test-feature", &ctx));
1607    }
1608
1609    #[test]
1610    fn test_condition_not_matches_literal() {
1611        let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["sentry"]}"#;
1612        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1613
1614        // Exact match → not_matches returns false
1615        let mut ctx = FeatureContext::new();
1616        ctx.insert("slug", json!("sentry"));
1617        assert!(!check(&opts, "organizations:test-feature", &ctx));
1618
1619        // No match → not_matches returns true
1620        let mut ctx2 = FeatureContext::new();
1621        ctx2.insert("slug", json!("getsentry"));
1622        assert!(check(&opts, "organizations:test-feature", &ctx2));
1623    }
1624
1625    #[test]
1626    fn test_condition_not_matches_prefix_wildcard() {
1627        let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*"]}"#;
1628        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1629
1630        let mut ctx = FeatureContext::new();
1631        ctx.insert("slug", json!("jayonb73"));
1632        assert!(!check(&opts, "organizations:test-feature", &ctx));
1633
1634        // '*' matches zero chars — bare prefix is still a match, so not_matches = false
1635        let mut ctx2 = FeatureContext::new();
1636        ctx2.insert("slug", json!("jayonb"));
1637        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1638
1639        let mut ctx3 = FeatureContext::new();
1640        ctx3.insert("slug", json!("dangoldonb1"));
1641        assert!(check(&opts, "organizations:test-feature", &ctx3));
1642    }
1643
1644    #[test]
1645    fn test_condition_not_matches_prefix_and_suffix_wildcard() {
1646        let cond = r#"{"property": "email", "operator": "not_matches", "value": ["jay.goss+onboarding*@sentry.io"]}"#;
1647        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1648
1649        let mut ctx = FeatureContext::new();
1650        ctx.insert("email", json!("jay.goss+onboarding70@sentry.io"));
1651        assert!(!check(&opts, "organizations:test-feature", &ctx));
1652
1653        let mut ctx2 = FeatureContext::new();
1654        ctx2.insert("email", json!("jay.goss+onboarding@sentry.io"));
1655        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1656
1657        let mut ctx3 = FeatureContext::new();
1658        ctx3.insert("email", json!("jay.goss+onboarding70@example.com"));
1659        assert!(check(&opts, "organizations:test-feature", &ctx3));
1660    }
1661
1662    #[test]
1663    fn test_condition_not_matches_suffix_wildcard() {
1664        let cond = r#"{"property": "email", "operator": "not_matches", "value": ["*@sentry.io"]}"#;
1665        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1666
1667        let mut ctx = FeatureContext::new();
1668        ctx.insert("email", json!("user@sentry.io"));
1669        assert!(!check(&opts, "organizations:test-feature", &ctx));
1670
1671        let mut ctx2 = FeatureContext::new();
1672        ctx2.insert("email", json!("user@example.com"));
1673        assert!(check(&opts, "organizations:test-feature", &ctx2));
1674    }
1675
1676    #[test]
1677    fn test_condition_not_matches_multi_segment_wildcard() {
1678        let cond = r#"{"property": "name", "operator": "not_matches", "value": ["a*b*c"]}"#;
1679        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1680
1681        let mut ctx = FeatureContext::new();
1682        ctx.insert("name", json!("abc"));
1683        assert!(!check(&opts, "organizations:test-feature", &ctx));
1684
1685        let mut ctx2 = FeatureContext::new();
1686        ctx2.insert("name", json!("aXbYc"));
1687        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1688
1689        let mut ctx3 = FeatureContext::new();
1690        ctx3.insert("name", json!("aXXbYYc"));
1691        assert!(!check(&opts, "organizations:test-feature", &ctx3));
1692
1693        // Does not match pattern → not_matches returns true
1694        let mut ctx4 = FeatureContext::new();
1695        ctx4.insert("name", json!("aXXc"));
1696        assert!(check(&opts, "organizations:test-feature", &ctx4));
1697    }
1698
1699    #[test]
1700    fn test_condition_not_matches_star_only_pattern() {
1701        // "*" matches everything — not_matches always returns false
1702        let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["*"]}"#;
1703        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1704
1705        let mut ctx = FeatureContext::new();
1706        ctx.insert("slug", json!("anything"));
1707        assert!(!check(&opts, "organizations:test-feature", &ctx));
1708
1709        let mut ctx2 = FeatureContext::new();
1710        ctx2.insert("slug", json!(""));
1711        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1712    }
1713
1714    #[test]
1715    fn test_condition_not_matches_case_insensitive() {
1716        let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["JAYONB*"]}"#;
1717        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1718
1719        // Pattern uppercase, value lowercase — still matches, so not_matches = false
1720        let mut ctx = FeatureContext::new();
1721        ctx.insert("slug", json!("jayonb73"));
1722        assert!(!check(&opts, "organizations:test-feature", &ctx));
1723
1724        // Pattern lowercase, value uppercase — still matches, so not_matches = false
1725        let cond2 = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*"]}"#;
1726        let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
1727
1728        let mut ctx2 = FeatureContext::new();
1729        ctx2.insert("slug", json!("JAYONB73"));
1730        assert!(!check(&opts2, "organizations:test-feature", &ctx2));
1731    }
1732
1733    #[test]
1734    fn test_condition_not_matches_no_match() {
1735        let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*"]}"#;
1736        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1737
1738        let mut ctx = FeatureContext::new();
1739        ctx.insert("slug", json!("dangoldonb1"));
1740        assert!(check(&opts, "organizations:test-feature", &ctx));
1741    }
1742
1743    #[test]
1744    fn test_condition_not_matches_multiple_patterns() {
1745        let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*", "dangoldonb*", "value-disc-*"]}"#;
1746        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1747
1748        // Any pattern matches → not_matches returns false
1749        let slugs_false = ["jayonb73", "dangoldonb3", "value-disc-7"];
1750        for slug in slugs_false {
1751            let mut ctx = FeatureContext::new();
1752            ctx.insert("slug", json!(slug));
1753            assert!(
1754                !check(&opts, "organizations:test-feature", &ctx),
1755                "slug={slug} should not match"
1756            );
1757        }
1758
1759        // No pattern matches → not_matches returns true
1760        let mut ctx = FeatureContext::new();
1761        ctx.insert("slug", json!("other-org"));
1762        assert!(check(&opts, "organizations:test-feature", &ctx));
1763    }
1764
1765    #[test]
1766    fn test_condition_not_matches_overlapping_prefix_suffix_anchors() {
1767        // "a*a" requires at least "aa" — "a" alone doesn't match, so not_matches = true
1768        let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["a*a"]}"#;
1769        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1770
1771        let mut ctx = FeatureContext::new();
1772        ctx.insert("slug", json!("a"));
1773        assert!(check(&opts, "organizations:test-feature", &ctx));
1774
1775        let mut ctx2 = FeatureContext::new();
1776        ctx2.insert("slug", json!("aa"));
1777        assert!(!check(&opts, "organizations:test-feature", &ctx2));
1778    }
1779
1780    #[test]
1781    fn test_condition_not_matches_non_string_context_returns_true() {
1782        // eval_matches returns false for non-string → not_matches returns true
1783        let cond = r#"{"property": "org_id", "operator": "not_matches", "value": ["123*"]}"#;
1784        let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1785
1786        let mut ctx = FeatureContext::new();
1787        ctx.insert("org_id", json!(123));
1788        assert!(check(&opts, "organizations:test-feature", &ctx));
1789    }
1790
1791    #[test]
1792    fn test_id_string_is_stable() {
1793        for (value, expected) in [
1794            (json!("sentry"), "sentry"),
1795            (json!(123), "123"),
1796            (json!(-7), "-7"),
1797            (json!(true), "True"),
1798            (json!(false), "False"),
1799            (json!(null), "None"),
1800            (json!(1.0), "1.0"),
1801            (json!(1.5), "1.5"),
1802            (json!(["a", "b"]), "[a, b]"),
1803            (json!([1, 2]), "[1, 2]"),
1804            (json!([]), "[]"),
1805        ] {
1806            assert_eq!(value_to_id_string(&value), expected, "for {value}");
1807        }
1808    }
1809
1810    fn condition_matches(operator: &str, value: &str, ctx: &FeatureContext) -> bool {
1811        let json = format!(r#"{{"property": "prop", "operator": "{operator}", "value": {value}}}"#);
1812        Condition::from_json(&serde_json::from_str(&json).unwrap())
1813            .expect("condition should parse")
1814            .matches(ctx)
1815    }
1816
1817    #[test]
1818    fn test_missing_property_matches_only_negated_conditions() {
1819        let ctx = FeatureContext::new();
1820        for (operator, value, expected) in [
1821            ("in", "[1]", false),
1822            ("not_in", "[1]", true),
1823            ("equals", "1", false),
1824            ("not_equals", "1", true),
1825            ("matches", r#"["a"]"#, false),
1826            ("not_matches", r#"["a"]"#, true),
1827            // `contains` has no list to search, so neither form matches.
1828            ("contains", r#""a""#, false),
1829            ("not_contains", r#""a""#, false),
1830        ] {
1831            assert_eq!(
1832                condition_matches(operator, value, &ctx),
1833                expected,
1834                "{operator}"
1835            );
1836        }
1837    }
1838
1839    #[test]
1840    fn test_unusable_property_type_matches_neither_form() {
1841        let mut map = FeatureContext::new();
1842        map.insert("prop", json!({"k": "v"}));
1843        let mut scalar = FeatureContext::new();
1844        scalar.insert("prop", json!("a"));
1845        let mut list = FeatureContext::new();
1846        list.insert("prop", json!(["b"]));
1847
1848        for (operator, value, ctx) in [
1849            ("in", "[1]", &map),
1850            ("not_in", "[1]", &map),
1851            ("contains", r#""a""#, &scalar),
1852            ("not_contains", r#""a""#, &scalar),
1853            ("matches", r#"["b"]"#, &list),
1854            ("not_matches", r#"["b"]"#, &list),
1855        ] {
1856            assert!(!condition_matches(operator, value, ctx), "{operator}");
1857        }
1858
1859        // A usable list that lacks the value still matches when negated.
1860        assert!(condition_matches("not_contains", r#""a""#, &list));
1861    }
1862
1863    /// FeatureChecker holds a `&'static Options`, so tests leak one.
1864    fn checker(opts: Options, namespace: &str) -> FeatureChecker {
1865        FeatureChecker::new(namespace.to_string(), Box::leak(Box::new(opts)))
1866    }
1867
1868    /// Schema declaring the feature, with a values file that sets nothing.
1869    fn setup_without_value() -> (Options, TempDir) {
1870        let temp = TempDir::new().unwrap();
1871        let schemas = temp.path().join("schemas");
1872        fs::create_dir_all(&schemas).unwrap();
1873        create_schema(&schemas, "test", FEATURE_SCHEMA);
1874        create_values(&temp.path().join("values"), "test", r#"{"options": {}}"#);
1875        (Options::from_directory(temp.path()).unwrap(), temp)
1876    }
1877
1878    #[test]
1879    fn test_try_has_evaluated() {
1880        let (opts, _temp) = setup_feature_options(&feature_json(true, 100, ""));
1881        let checker = checker(opts, "test");
1882
1883        let result = checker.try_has("organizations:test-feature", &FeatureContext::new());
1884        assert_eq!(result.unwrap(), Some(true));
1885    }
1886
1887    #[test]
1888    fn test_try_has_evaluated_false_is_not_absent() {
1889        // A disabled feature evaluated fine; it just isn't on. This is the case a
1890        // bare bool conflates with "could not evaluate".
1891        let (opts, _temp) = setup_feature_options(&feature_json(false, 100, ""));
1892        let checker = checker(opts, "test");
1893
1894        let result = checker.try_has("organizations:test-feature", &FeatureContext::new());
1895        assert_eq!(result.unwrap(), Some(false));
1896    }
1897
1898    #[test]
1899    fn test_try_has_absent_when_no_value_set() {
1900        let (opts, _temp) = setup_without_value();
1901        let checker = checker(opts, "test");
1902
1903        let result = checker.try_has("organizations:test-feature", &FeatureContext::new());
1904        assert_eq!(result.unwrap(), None);
1905    }
1906
1907    #[test]
1908    fn test_try_has_absent_when_feature_not_in_schema() {
1909        let (opts, _temp) = setup_feature_options(&feature_json(true, 100, ""));
1910        let checker = checker(opts, "test");
1911
1912        let result = checker.try_has("organizations:never-declared", &FeatureContext::new());
1913        assert_eq!(result.unwrap(), None);
1914    }
1915
1916    #[test]
1917    fn test_try_has_errors_on_unknown_namespace() {
1918        let (opts, _temp) = setup_feature_options(&feature_json(true, 100, ""));
1919        let checker = checker(opts, "not-a-namespace");
1920
1921        assert!(matches!(
1922            checker.try_has("organizations:test-feature", &FeatureContext::new()),
1923            Err(FeatureError::Options(
1924                crate::OptionsError::UnknownNamespace(_)
1925            ))
1926        ));
1927    }
1928
1929    #[test]
1930    fn test_try_has_errors_when_uninitialized() {
1931        let checker = FeatureChecker {
1932            namespace: "test".to_string(),
1933            options: None,
1934        };
1935
1936        assert!(matches!(
1937            checker.try_has("organizations:test-feature", &FeatureContext::new()),
1938            Err(FeatureError::NotInitialized)
1939        ));
1940    }
1941
1942    #[test]
1943    fn test_has_still_collapses_every_failure_to_false() {
1944        // has() is the fail-soft read path and must not start propagating.
1945        let (opts, _temp) = setup_without_value();
1946        let absent = checker(opts, "test");
1947        assert!(!absent.has("organizations:test-feature", &FeatureContext::new()));
1948
1949        let (opts, _temp) = setup_feature_options(&feature_json(true, 100, ""));
1950        let unknown_ns = checker(opts, "not-a-namespace");
1951        assert!(!unknown_ns.has("organizations:test-feature", &FeatureContext::new()));
1952
1953        let uninitialized = FeatureChecker {
1954            namespace: "test".to_string(),
1955            options: None,
1956        };
1957        assert!(!uninitialized.has("organizations:test-feature", &FeatureContext::new()));
1958    }
1959}