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