1use num::bigint::{BigInt, Sign};
7use std::cell::Cell;
8use std::collections::HashMap;
9use std::sync::OnceLock;
10
11use serde_json::Value;
12use sha1::{Digest, Sha1};
13
14fn value_to_id_string(value: &Value) -> String {
16 match value {
17 Value::String(s) => s.clone(),
18 Value::Number(n) => {
19 if let Some(i) = n.as_i64() {
20 i.to_string()
21 } else if let Some(f) = n.as_f64() {
22 if f.fract() == 0.0 {
24 format!("{f:.1}")
25 } else {
26 f.to_string()
27 }
28 } else {
29 n.to_string()
30 }
31 }
32 Value::Bool(b) => if *b { "True" } else { "False" }.to_string(),
33 Value::Array(arr) => {
34 let items: Vec<String> = arr.iter().map(value_to_id_string).collect();
35 format!("[{}]", items.join(", "))
36 }
37 Value::Null => "None".to_string(),
38 Value::Object(_) => value.to_string(),
39 }
40}
41
42pub struct FeatureContext {
47 data: HashMap<String, Value>,
48 identity_fields: Vec<String>,
49 cached_id: Cell<Option<u64>>,
50}
51
52impl FeatureContext {
53 pub fn new() -> Self {
54 Self {
55 data: HashMap::new(),
56 identity_fields: Vec::new(),
57 cached_id: Cell::new(None),
58 }
59 }
60
61 pub fn identity_fields(&mut self, fields: Vec<&str>) {
66 self.identity_fields = fields.into_iter().map(|s| s.to_string()).collect();
67 self.cached_id.set(None);
68 }
69
70 pub fn insert(&mut self, key: &str, value: impl Into<Value>) {
72 self.data.insert(key.to_string(), value.into());
73 self.cached_id.set(None);
74 }
75
76 pub fn get(&self, key: &str) -> Option<&Value> {
78 self.data.get(key)
79 }
80
81 pub fn has(&self, key: &str) -> bool {
83 self.data.contains_key(key)
84 }
85
86 pub fn id(&self) -> u64 {
93 if let Some(id) = self.cached_id.get() {
94 return id;
95 }
96 let id = self.compute_id();
97 self.cached_id.set(Some(id));
98 id
99 }
100
101 fn compute_id(&self) -> u64 {
110 let mut identity_fields: Vec<&String> = self
111 .identity_fields
112 .iter()
113 .filter(|f| self.data.contains_key(f.as_str()))
114 .collect();
115 if identity_fields.is_empty() {
116 identity_fields = self.data.keys().collect();
117 }
118 identity_fields.sort();
119
120 let mut parts: Vec<String> = Vec::with_capacity(identity_fields.len() * 2);
121 for key in identity_fields {
122 parts.push(key.clone());
123 parts.push(value_to_id_string(&self.data[key.as_str()]));
124 }
125 let mut hasher = Sha1::new();
126 hasher.update(parts.join(":").as_bytes());
127 let digest = hasher.finalize();
128
129 let bigint = BigInt::from_bytes_be(Sign::Plus, digest.as_slice());
131
132 let small: BigInt = bigint % 1000000000;
136 let id_parts = small.to_u64_digits().1;
137 if id_parts.is_empty() { 0 } else { id_parts[0] }
138 }
139}
140
141impl Default for FeatureContext {
142 fn default() -> Self {
143 Self::new()
144 }
145}
146
147#[derive(Debug)]
148enum OperatorKind {
149 In,
150 NotIn,
151 Contains,
152 NotContains,
153 Equals,
154 NotEquals,
155 Matches,
156}
157
158#[derive(Debug)]
159struct Condition {
160 property: String,
161 operator: OperatorKind,
162 value: Value,
163}
164
165#[derive(Debug)]
166struct Segment {
167 rollout: u64,
168 conditions: Vec<Condition>,
169}
170
171#[derive(Debug)]
172struct Feature {
173 enabled: bool,
174 segments: Vec<Segment>,
175}
176
177impl Feature {
178 fn from_json(value: &Value) -> Option<Self> {
179 let enabled = value
181 .get("enabled")
182 .and_then(|v| v.as_bool())
183 .unwrap_or(true);
184 let segments = value
185 .get("segments")?
186 .as_array()?
187 .iter()
188 .filter_map(Segment::from_json)
189 .collect();
190 Some(Feature { enabled, segments })
191 }
192
193 fn matches(&self, context: &FeatureContext) -> bool {
194 if !self.enabled {
195 return false;
196 }
197 for segment in &self.segments {
198 if segment.conditions_match(context) {
199 return segment.in_rollout(context);
200 }
201 }
202 false
203 }
204}
205
206impl Segment {
207 fn from_json(value: &Value) -> Option<Self> {
208 let rollout = value.get("rollout").and_then(|v| v.as_u64()).unwrap_or(100);
209 let conditions = value
210 .get("conditions")
211 .and_then(|v| v.as_array())
212 .map(|arr| arr.iter().filter_map(Condition::from_json).collect())
213 .unwrap_or_default();
214 Some(Segment {
215 rollout,
216 conditions,
217 })
218 }
219
220 fn conditions_match(&self, context: &FeatureContext) -> bool {
221 self.conditions.iter().all(|c| c.matches(context))
222 }
223
224 fn in_rollout(&self, context: &FeatureContext) -> bool {
225 if self.rollout == 0 {
226 return false;
227 }
228 if self.rollout >= 100 {
229 return true;
230 }
231 context.id() % 100 < self.rollout
232 }
233}
234
235impl Condition {
236 fn from_json(value: &Value) -> Option<Self> {
237 let property = value.get("property")?.as_str()?.to_string();
238 let operator = match value.get("operator")?.as_str()? {
239 "in" => OperatorKind::In,
240 "not_in" => OperatorKind::NotIn,
241 "contains" => OperatorKind::Contains,
242 "not_contains" => OperatorKind::NotContains,
243 "equals" => OperatorKind::Equals,
244 "not_equals" => OperatorKind::NotEquals,
245 "matches" => OperatorKind::Matches,
246 _ => return None,
247 };
248 let value = value.get("value")?.clone();
249 Some(Condition {
250 property,
251 operator,
252 value,
253 })
254 }
255
256 fn matches(&self, context: &FeatureContext) -> bool {
257 let Some(ctx_val) = context.get(&self.property) else {
258 return false;
259 };
260 match &self.operator {
261 OperatorKind::In => eval_in(ctx_val, &self.value),
262 OperatorKind::NotIn => !eval_in(ctx_val, &self.value),
263 OperatorKind::Contains => eval_contains(ctx_val, &self.value),
264 OperatorKind::NotContains => !eval_contains(ctx_val, &self.value),
265 OperatorKind::Equals => eval_equals(ctx_val, &self.value),
266 OperatorKind::NotEquals => !eval_equals(ctx_val, &self.value),
267 OperatorKind::Matches => eval_matches(ctx_val, &self.value),
268 }
269 }
270}
271
272fn eval_in(ctx_val: &Value, condition_val: &Value) -> bool {
275 let Some(arr) = condition_val.as_array() else {
276 return false;
277 };
278 match ctx_val {
279 Value::String(s) => {
280 let s_lower = s.to_lowercase();
281 arr.iter()
282 .any(|v| v.as_str().is_some_and(|cv| cv.to_lowercase() == s_lower))
283 }
284 Value::Number(n) => {
285 if let Some(i) = n.as_i64() {
286 arr.iter().any(|v| v.as_i64().is_some_and(|cv| cv == i))
287 } else if let Some(f) = n.as_f64() {
288 arr.iter().any(|v| v.as_f64().is_some_and(|cv| cv == f))
289 } else {
290 false
291 }
292 }
293 Value::Bool(b) => arr.iter().any(|v| v.as_bool().is_some_and(|cv| cv == *b)),
294 _ => false,
295 }
296}
297
298fn eval_contains(ctx_val: &Value, condition_val: &Value) -> bool {
301 let Some(ctx_arr) = ctx_val.as_array() else {
302 return false;
303 };
304 match condition_val {
305 Value::String(s) => {
306 let s_lower = s.to_lowercase();
307 ctx_arr
308 .iter()
309 .any(|v| v.as_str().is_some_and(|cv| cv.to_lowercase() == s_lower))
310 }
311 Value::Number(n) => {
312 if let Some(i) = n.as_i64() {
313 ctx_arr.iter().any(|v| v.as_i64().is_some_and(|cv| cv == i))
314 } else if let Some(f) = n.as_f64() {
315 ctx_arr.iter().any(|v| v.as_f64().is_some_and(|cv| cv == f))
316 } else {
317 false
318 }
319 }
320 Value::Bool(b) => ctx_arr
321 .iter()
322 .any(|v| v.as_bool().is_some_and(|cv| cv == *b)),
323 _ => false,
324 }
325}
326
327fn eval_equals(ctx_val: &Value, condition_val: &Value) -> bool {
331 match (ctx_val, condition_val) {
332 (Value::String(a), Value::String(b)) => a.to_lowercase() == b.to_lowercase(),
333 (Value::Number(a), Value::Number(b)) => {
334 if let (Some(ai), Some(bi)) = (a.as_i64(), b.as_i64()) {
336 ai == bi
337 } else if let (Some(af), Some(bf)) = (a.as_f64(), b.as_f64()) {
338 af == bf
339 } else {
340 false
341 }
342 }
343 (Value::Bool(a), Value::Bool(b)) => a == b,
344 (Value::Array(a), Value::Array(b)) => {
345 a.len() == b.len() && a.iter().zip(b.iter()).all(|(av, bv)| eval_equals(av, bv))
346 }
347 _ => false,
348 }
349}
350
351fn glob_star_match(pattern: &str, value: &str) -> bool {
355 let pattern = pattern.to_lowercase();
356 let value = value.to_lowercase();
357 let parts: Vec<&str> = pattern.split('*').collect();
358 if parts.len() == 1 {
360 return value == pattern;
361 }
362 if !value.starts_with(parts[0]) {
364 return false;
365 }
366 if !parts[parts.len() - 1].is_empty() && !value.ends_with(parts[parts.len() - 1]) {
368 return false;
369 }
370 let end = if parts[parts.len() - 1].is_empty() {
372 value.len()
373 } else {
374 value.len() - parts[parts.len() - 1].len()
375 };
376 let mut start = parts[0].len();
377 if start > end {
380 return false;
381 }
382 for part in &parts[1..parts.len() - 1] {
384 if part.is_empty() {
385 continue;
387 }
388 match value[start..end].find(*part) {
389 Some(idx) => start += idx + part.len(),
390 None => return false,
391 }
392 }
393 true
394}
395
396fn eval_matches(ctx_val: &Value, condition_val: &Value) -> bool {
400 let Some(s) = ctx_val.as_str() else {
401 return false;
402 };
403 let Some(arr) = condition_val.as_array() else {
404 return false;
405 };
406 arr.iter().any(|v| {
407 v.as_str()
408 .is_some_and(|pattern| glob_star_match(pattern, s))
409 })
410}
411
412#[derive(Debug, PartialEq)]
413enum DebugLogLevel {
414 None,
415 Parse,
416 Match,
417 All,
418}
419
420static DEBUG_LOG_LEVEL: OnceLock<DebugLogLevel> = OnceLock::new();
421static DEBUG_MATCH_SAMPLE_RATE: OnceLock<u64> = OnceLock::new();
422
423fn debug_log_level() -> &'static DebugLogLevel {
424 DEBUG_LOG_LEVEL.get_or_init(|| {
425 match std::env::var("SENTRY_OPTIONS_FEATURE_DEBUG_LOG")
426 .as_deref()
427 .unwrap_or("")
428 {
429 "all" => DebugLogLevel::All,
430 "parse" => DebugLogLevel::Parse,
431 "match" => DebugLogLevel::Match,
432 _ => DebugLogLevel::None,
433 }
434 })
435}
436
437fn debug_match_sample_rate() -> u64 {
438 *DEBUG_MATCH_SAMPLE_RATE.get_or_init(|| {
439 std::env::var("SENTRY_OPTIONS_FEATURE_DEBUG_LOG_SAMPLE_RATE")
440 .ok()
441 .and_then(|v| v.parse::<f64>().ok())
442 .map(|r| (r.clamp(0.0, 1.0) * 1000.0) as u64)
443 .unwrap_or(1000)
444 })
445}
446
447fn debug_log_parse(msg: &str) {
448 match debug_log_level() {
449 DebugLogLevel::Parse | DebugLogLevel::All => eprintln!("[sentry-options/parse] {msg}"),
450 _ => {}
451 }
452}
453
454fn debug_log_match(feature: &str, result: bool, context_id: u64) {
455 match debug_log_level() {
456 DebugLogLevel::Match | DebugLogLevel::All
457 if context_id % 1000 < debug_match_sample_rate() =>
458 {
459 eprintln!(
460 "[sentry-options/match] feature='{feature}' result={result} context_id={context_id}"
461 );
462 }
463 _ => {}
464 }
465}
466
467pub struct FeatureChecker {
469 namespace: String,
470 options: Option<&'static crate::Options>,
471}
472
473impl FeatureChecker {
474 pub fn new(namespace: String, options: &'static crate::Options) -> Self {
475 Self {
476 namespace,
477 options: Some(options),
478 }
479 }
480
481 pub fn has(&self, feature_name: &str, context: &FeatureContext) -> bool {
486 let Some(opts) = self.options else {
487 return false;
488 };
489 let key = format!("feature.{feature_name}");
490
491 let feature_val = match opts.get(&self.namespace, &key) {
492 Ok(v) => v,
493 Err(e) => {
494 debug_log_parse(&format!("Failed to get feature '{key}': {e}"));
495 return false;
496 }
497 };
498
499 let feature = match Feature::from_json(&feature_val) {
500 Some(f) => {
501 debug_log_parse(&format!("Parsed feature '{key}'"));
502 f
503 }
504 None => {
505 debug_log_parse(&format!("Failed to parse feature '{key}'"));
506 return false;
507 }
508 };
509
510 let result = feature.matches(context);
511 debug_log_match(feature_name, result, context.id());
512 result
513 }
514}
515
516pub fn features(namespace: &str) -> FeatureChecker {
520 FeatureChecker {
521 namespace: namespace.to_string(),
522 options: crate::GLOBAL_OPTIONS.get(),
523 }
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529 use crate::Options;
530 use serde_json::json;
531 use std::fs;
532 use std::path::Path;
533 use tempfile::TempDir;
534
535 fn create_schema(dir: &Path, namespace: &str, schema: &str) {
536 let schema_dir = dir.join(namespace);
537 fs::create_dir_all(&schema_dir).unwrap();
538 fs::write(schema_dir.join("schema.json"), schema).unwrap();
539 }
540
541 fn create_values(dir: &Path, namespace: &str, values: &str) {
542 let ns_dir = dir.join(namespace);
543 fs::create_dir_all(&ns_dir).unwrap();
544 fs::write(ns_dir.join("values.json"), values).unwrap();
545 }
546
547 const FEATURE_SCHEMA: &str = r##"{
548 "version": "1.0",
549 "type": "object",
550 "properties": {
551 "feature.organizations:test-feature": {
552 "$ref": "#/definitions/Feature"
553 }
554 }
555 }"##;
556
557 fn setup_feature_options(feature_json: &str) -> (Options, TempDir) {
558 let temp = TempDir::new().unwrap();
559 let schemas = temp.path().join("schemas");
560 fs::create_dir_all(&schemas).unwrap();
561 create_schema(&schemas, "test", FEATURE_SCHEMA);
562
563 let values = temp.path().join("values");
564 let values_json = format!(
565 r#"{{"options": {{"feature.organizations:test-feature": {}}}}}"#,
566 feature_json
567 );
568 create_values(&values, "test", &values_json);
569
570 let opts = Options::from_directory(temp.path()).unwrap();
571 (opts, temp)
572 }
573
574 fn feature_json(enabled: bool, rollout: u64, conditions: &str) -> String {
575 format!(
576 r#"{{
577 "name": "test-feature",
578 "enabled": {enabled},
579 "owner": {{"team": "test-team"}},
580 "created_at": "2024-01-01",
581 "segments": [{{
582 "name": "test-segment",
583 "rollout": {rollout},
584 "conditions": [{conditions}]
585 }}]
586 }}"#
587 )
588 }
589
590 fn in_condition(property: &str, values: &str) -> String {
591 format!(r#"{{"property": "{property}", "operator": "in", "value": [{values}]}}"#)
592 }
593
594 fn check(opts: &Options, feature: &str, ctx: &FeatureContext) -> bool {
595 let key = format!("feature.{feature}");
596 let Ok(val) = opts.get("test", &key) else {
597 return false;
598 };
599 Feature::from_json(&val).is_some_and(|f| f.matches(ctx))
600 }
601
602 #[test]
603 fn test_feature_context_insert_and_get() {
604 let mut ctx = FeatureContext::new();
605 ctx.insert("org_id", json!(123));
606 ctx.insert("name", json!("sentry"));
607 ctx.insert("active", json!(true));
608
609 assert!(ctx.has("org_id"));
610 assert!(!ctx.has("missing"));
611 assert_eq!(ctx.get("org_id"), Some(&json!(123)));
612 assert_eq!(ctx.get("name"), Some(&json!("sentry")));
613 }
614
615 #[test]
616 fn test_feature_context_id_is_cached() {
617 let mut ctx = FeatureContext::new();
618 ctx.identity_fields(vec!["user_id"]);
619 ctx.insert("user_id", json!(42));
620
621 let id1 = ctx.id();
622 let id2 = ctx.id();
623 assert_eq!(id1, id2, "ID should be cached and consistent");
624 }
625
626 #[test]
627 fn test_feature_context_id_resets_on_identity_change() {
628 let mut ctx = FeatureContext::new();
629 ctx.insert("user_id", json!(1));
630 ctx.insert("org_id", json!(2));
631
632 ctx.identity_fields(vec!["user_id"]);
633 let id_user = ctx.id();
634
635 ctx.identity_fields(vec!["org_id"]);
636 let id_org = ctx.id();
637
638 assert_ne!(
639 id_user, id_org,
640 "Different identity fields should produce different IDs"
641 );
642 }
643
644 #[test]
645 fn test_feature_context_id_deterministic() {
646 let make_ctx = || {
647 let mut ctx = FeatureContext::new();
648 ctx.identity_fields(vec!["user_id", "org_id"]);
649 ctx.insert("user_id", json!(456));
650 ctx.insert("org_id", json!(123));
651 ctx
652 };
653
654 assert_eq!(make_ctx().id(), make_ctx().id());
655
656 let mut other_ctx = FeatureContext::new();
657 other_ctx.identity_fields(vec!["user_id", "org_id"]);
658 other_ctx.insert("user_id", json!(789));
659 other_ctx.insert("org_id", json!(123));
660
661 assert_ne!(make_ctx().id(), other_ctx.id());
662 }
663
664 #[test]
665 fn test_feature_context_id_value_align_with_python() {
666 let ctx = FeatureContext::new();
670 assert_eq!(ctx.id() % 100, 5, "should match with python implementation");
671
672 let mut ctx = FeatureContext::new();
673 ctx.insert("foo", json!("bar"));
674 ctx.insert("baz", json!("barfoo"));
675 ctx.identity_fields(vec!["foo"]);
676 assert_eq!(ctx.id() % 100, 62);
677
678 let mut ctx = FeatureContext::new();
680 ctx.insert("foo", json!("bar"));
681 ctx.insert("baz", json!("barfoo"));
682 ctx.identity_fields(vec!["foo", "whoops"]);
683 assert_eq!(ctx.id() % 100, 62);
684
685 let mut ctx = FeatureContext::new();
686 ctx.insert("foo", json!("bar"));
687 ctx.insert("baz", json!("barfoo"));
688 ctx.identity_fields(vec!["foo", "baz"]);
689 assert_eq!(ctx.id() % 100, 1);
690
691 let mut ctx = FeatureContext::new();
692 ctx.insert("foo", json!("bar"));
693 ctx.insert("baz", json!("barfoo"));
694 ctx.identity_fields(vec!["whoops", "nope"]);
697 assert_eq!(ctx.id() % 100, 1);
698 }
699
700 #[test]
701 fn test_feature_prefix_is_added() {
702 let cond = in_condition("organization_id", "123");
703 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
704
705 let mut ctx = FeatureContext::new();
706 ctx.insert("organization_id", json!(123));
707
708 assert!(check(&opts, "organizations:test-feature", &ctx));
709 }
710
711 #[test]
712 fn test_undefined_feature_returns_false() {
713 let cond = in_condition("organization_id", "123");
714 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
715
716 let ctx = FeatureContext::new();
717 assert!(!check(&opts, "nonexistent", &ctx));
718 }
719
720 #[test]
721 fn test_missing_context_field_returns_false() {
722 let cond = in_condition("organization_id", "123");
723 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
724
725 let ctx = FeatureContext::new();
726 assert!(!check(&opts, "organizations:test-feature", &ctx));
727 }
728
729 #[test]
730 fn test_matching_context_returns_true() {
731 let cond = in_condition("organization_id", "123, 456");
732 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
733
734 let mut ctx = FeatureContext::new();
735 ctx.insert("organization_id", json!(123));
736
737 assert!(check(&opts, "organizations:test-feature", &ctx));
738 }
739
740 #[test]
741 fn test_non_matching_context_returns_false() {
742 let cond = in_condition("organization_id", "123, 456");
743 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
744
745 let mut ctx = FeatureContext::new();
746 ctx.insert("organization_id", json!(999));
747
748 assert!(!check(&opts, "organizations:test-feature", &ctx));
749 }
750
751 #[test]
752 fn test_disabled_feature_returns_false() {
753 let cond = in_condition("organization_id", "123");
754 let (opts, _t) = setup_feature_options(&feature_json(false, 100, &cond));
755
756 let mut ctx = FeatureContext::new();
757 ctx.insert("organization_id", json!(123));
758
759 assert!(!check(&opts, "organizations:test-feature", &ctx));
760 }
761
762 #[test]
763 fn test_rollout_zero_returns_false() {
764 let cond = in_condition("organization_id", "123");
765 let (opts, _t) = setup_feature_options(&feature_json(true, 0, &cond));
766
767 let mut ctx = FeatureContext::new();
768 ctx.insert("organization_id", json!(123));
769
770 assert!(!check(&opts, "organizations:test-feature", &ctx));
771 }
772
773 #[test]
774 fn test_rollout_100_returns_true() {
775 let cond = in_condition("organization_id", "123");
776 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
777
778 let mut ctx = FeatureContext::new();
779 ctx.insert("organization_id", json!(123));
780
781 assert!(check(&opts, "organizations:test-feature", &ctx));
782 }
783
784 #[test]
785 fn test_rollout_is_deterministic() {
786 let mut ctx = FeatureContext::new();
787 ctx.identity_fields(vec!["user_id"]);
788 ctx.insert("user_id", json!(42));
789 ctx.insert("organization_id", json!(123));
790
791 let id_mod = (ctx.id() % 100) + 1;
793 let cond = in_condition("organization_id", "123");
794
795 let (opts_at, _t1) = setup_feature_options(&feature_json(true, id_mod, &cond));
796 assert!(check(&opts_at, "organizations:test-feature", &ctx));
797 }
798
799 #[test]
800 fn test_condition_in_string_case_insensitive() {
801 let cond = r#"{"property": "slug", "operator": "in", "value": ["Sentry", "ACME"]}"#;
802 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
803
804 let mut ctx = FeatureContext::new();
805 ctx.insert("slug", json!("sentry"));
806 assert!(check(&opts, "organizations:test-feature", &ctx));
807 }
808
809 #[test]
810 fn test_condition_not_in() {
811 let cond = r#"{"property": "organization_id", "operator": "not_in", "value": [999]}"#;
812 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
813
814 let mut ctx = FeatureContext::new();
815 ctx.insert("organization_id", json!(123));
816 assert!(check(&opts, "organizations:test-feature", &ctx));
817
818 let mut ctx2 = FeatureContext::new();
819 ctx2.insert("organization_id", json!(999));
820 assert!(!check(&opts, "organizations:test-feature", &ctx2));
821 }
822
823 #[test]
824 fn test_condition_contains() {
825 let cond = r#"{"property": "tags", "operator": "contains", "value": "beta"}"#;
826 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
827
828 let mut ctx = FeatureContext::new();
829 ctx.insert("tags", json!(["alpha", "beta"]));
830 assert!(check(&opts, "organizations:test-feature", &ctx));
831
832 let mut ctx2 = FeatureContext::new();
833 ctx2.insert("tags", json!(["alpha"]));
834 assert!(!check(&opts, "organizations:test-feature", &ctx2));
835 }
836
837 #[test]
838 fn test_condition_equals() {
839 let cond = r#"{"property": "plan", "operator": "equals", "value": "enterprise"}"#;
840 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
841
842 let mut ctx = FeatureContext::new();
843 ctx.insert("plan", json!("Enterprise"));
844 assert!(check(&opts, "organizations:test-feature", &ctx));
845
846 let mut ctx2 = FeatureContext::new();
847 ctx2.insert("plan", json!("free"));
848 assert!(!check(&opts, "organizations:test-feature", &ctx2));
849 }
850
851 #[test]
852 fn test_condition_equals_bool() {
853 let cond = r#"{"property": "is_free", "operator": "equals", "value": true}"#;
854 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
855
856 let mut ctx = FeatureContext::new();
857 ctx.insert("is_free", json!(true));
858 assert!(check(&opts, "organizations:test-feature", &ctx));
859
860 let mut ctx2 = FeatureContext::new();
861 ctx2.insert("is_free", json!(false));
862 assert!(!check(&opts, "organizations:test-feature", &ctx2));
863 }
864
865 #[test]
866 fn test_segment_with_no_conditions_always_matches() {
867 let feature = r#"{
868 "name": "test-feature",
869 "enabled": true,
870 "owner": {"team": "test-team"},
871 "created_at": "2024-01-01",
872 "segments": [{"name": "open", "rollout": 100, "conditions": []}]
873 }"#;
874 let (opts, _t) = setup_feature_options(feature);
875
876 let ctx = FeatureContext::new();
877 assert!(check(&opts, "organizations:test-feature", &ctx));
878 }
879
880 #[test]
881 fn test_feature_enabled_and_rollout_default_values() {
882 let feature = r#"{
884 "name": "test-feature",
885 "owner": {"team": "test-team"},
886 "created_at": "2024-01-01",
887 "segments": [
888 {
889 "name": "first",
890 "conditions": [{"property": "org_id", "operator": "in", "value":[1]}]
891 }
892 ]
893 }"#;
894 let (opts, _t) = setup_feature_options(feature);
895
896 let mut ctx = FeatureContext::new();
897 ctx.insert("org_id", 1);
898 ctx.identity_fields(vec!["org_id"]);
899 assert!(check(&opts, "organizations:test-feature", &ctx));
900 }
901
902 #[test]
903 fn test_feature_with_no_segments_returns_false() {
904 let feature = r#"{
905 "name": "test-feature",
906 "enabled": true,
907 "owner": {"team": "test-team"},
908 "created_at": "2024-01-01",
909 "segments": []
910 }"#;
911 let (opts, _t) = setup_feature_options(feature);
912
913 let ctx = FeatureContext::new();
914 assert!(!check(&opts, "organizations:test-feature", &ctx));
915 }
916
917 #[test]
918 fn test_multiple_segments_or_logic() {
919 let feature = r#"{
920 "name": "test-feature",
921 "enabled": true,
922 "owner": {"team": "test-team"},
923 "created_at": "2024-01-01",
924 "segments": [
925 {
926 "name": "segment-a",
927 "rollout": 100,
928 "conditions": [{"property": "org_id", "operator": "in", "value": [1]}]
929 },
930 {
931 "name": "segment-b",
932 "rollout": 100,
933 "conditions": [{"property": "org_id", "operator": "in", "value": [2]}]
934 }
935 ]
936 }"#;
937 let (opts, _t) = setup_feature_options(feature);
938
939 let mut ctx1 = FeatureContext::new();
940 ctx1.insert("org_id", json!(1));
941 assert!(check(&opts, "organizations:test-feature", &ctx1));
942
943 let mut ctx2 = FeatureContext::new();
944 ctx2.insert("org_id", json!(2));
945 assert!(check(&opts, "organizations:test-feature", &ctx2));
946
947 let mut ctx3 = FeatureContext::new();
948 ctx3.insert("org_id", json!(3));
949 assert!(!check(&opts, "organizations:test-feature", &ctx3));
950 }
951
952 #[test]
953 fn test_multiple_conditions_and_logic() {
954 let conds = r#"
955 {"property": "org_id", "operator": "in", "value": [123]},
956 {"property": "user_email", "operator": "in", "value": ["admin@example.com"]}
957 "#;
958 let (opts, _t) = setup_feature_options(&feature_json(true, 100, conds));
959
960 let mut ctx = FeatureContext::new();
961 ctx.insert("org_id", json!(123));
962 ctx.insert("user_email", json!("admin@example.com"));
963 assert!(check(&opts, "organizations:test-feature", &ctx));
964
965 let mut ctx2 = FeatureContext::new();
966 ctx2.insert("org_id", json!(123));
967 ctx2.insert("user_email", json!("other@example.com"));
968 assert!(!check(&opts, "organizations:test-feature", &ctx2));
969 }
970
971 #[test]
972 fn test_in_int_context_against_string_list_returns_false() {
973 let cond = r#"{"property": "org_id", "operator": "in", "value": ["123", "456"]}"#;
974 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
975
976 let mut ctx = FeatureContext::new();
977 ctx.insert("org_id", json!(123));
978 assert!(!check(&opts, "organizations:test-feature", &ctx));
979 }
980
981 #[test]
982 fn test_in_string_context_against_int_list_returns_false() {
983 let cond = r#"{"property": "slug", "operator": "in", "value": [123, 456]}"#;
984 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
985
986 let mut ctx = FeatureContext::new();
987 ctx.insert("slug", json!("123"));
988 assert!(!check(&opts, "organizations:test-feature", &ctx));
989 }
990
991 #[test]
992 fn test_in_bool_context_against_string_list_returns_false() {
993 let cond = r#"{"property": "active", "operator": "in", "value": ["true", "false"]}"#;
994 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
995
996 let mut ctx = FeatureContext::new();
997 ctx.insert("active", json!(true));
998 assert!(!check(&opts, "organizations:test-feature", &ctx));
999 }
1000
1001 #[test]
1002 fn test_in_float_context_against_string_list_returns_false() {
1003 let cond = r#"{"property": "score", "operator": "in", "value": ["0.5", "1.0"]}"#;
1004 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1005
1006 let mut ctx = FeatureContext::new();
1007 ctx.insert("score", json!(0.5));
1008 assert!(!check(&opts, "organizations:test-feature", &ctx));
1009 }
1010
1011 #[test]
1012 fn test_not_in_int_context_against_string_list_returns_true() {
1013 let cond = r#"{"property": "org_id", "operator": "not_in", "value": ["123", "456"]}"#;
1015 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1016
1017 let mut ctx = FeatureContext::new();
1018 ctx.insert("org_id", json!(123));
1019 assert!(check(&opts, "organizations:test-feature", &ctx));
1020 }
1021
1022 #[test]
1023 fn test_not_in_string_context_against_int_list_returns_true() {
1024 let cond = r#"{"property": "slug", "operator": "not_in", "value": [123, 456]}"#;
1026 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1027
1028 let mut ctx = FeatureContext::new();
1029 ctx.insert("slug", json!("123"));
1030 assert!(check(&opts, "organizations:test-feature", &ctx));
1031 }
1032
1033 #[test]
1034 fn test_condition_matches_literal() {
1035 let cond = r#"{"property": "slug", "operator": "matches", "value": ["sentry"]}"#;
1036 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1037
1038 let mut ctx = FeatureContext::new();
1039 ctx.insert("slug", json!("sentry"));
1040 assert!(check(&opts, "organizations:test-feature", &ctx));
1041
1042 let mut ctx2 = FeatureContext::new();
1043 ctx2.insert("slug", json!("getsentry"));
1044 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1045 }
1046
1047 #[test]
1048 fn test_condition_matches_prefix_wildcard() {
1049 let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
1050 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1051
1052 let mut ctx = FeatureContext::new();
1053 ctx.insert("slug", json!("jayonb73"));
1054 assert!(check(&opts, "organizations:test-feature", &ctx));
1055
1056 let mut ctx2 = FeatureContext::new();
1058 ctx2.insert("slug", json!("jayonb"));
1059 assert!(check(&opts, "organizations:test-feature", &ctx2));
1060
1061 let mut ctx3 = FeatureContext::new();
1062 ctx3.insert("slug", json!("dangoldonb1"));
1063 assert!(!check(&opts, "organizations:test-feature", &ctx3));
1064 }
1065
1066 #[test]
1067 fn test_condition_matches_prefix_and_suffix_wildcard() {
1068 let cond = r#"{"property": "email", "operator": "matches", "value": ["jay.goss+onboarding*@sentry.io"]}"#;
1069 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1070
1071 let mut ctx = FeatureContext::new();
1072 ctx.insert("email", json!("jay.goss+onboarding70@sentry.io"));
1073 assert!(check(&opts, "organizations:test-feature", &ctx));
1074
1075 let mut ctx2 = FeatureContext::new();
1077 ctx2.insert("email", json!("jay.goss+onboarding@sentry.io"));
1078 assert!(check(&opts, "organizations:test-feature", &ctx2));
1079
1080 let mut ctx3 = FeatureContext::new();
1081 ctx3.insert("email", json!("jay.goss+onboarding70@example.com"));
1082 assert!(!check(&opts, "organizations:test-feature", &ctx3));
1083 }
1084
1085 #[test]
1086 fn test_condition_matches_suffix_wildcard() {
1087 let cond = r#"{"property": "email", "operator": "matches", "value": ["*@sentry.io"]}"#;
1088 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1089
1090 let mut ctx = FeatureContext::new();
1091 ctx.insert("email", json!("user@sentry.io"));
1092 assert!(check(&opts, "organizations:test-feature", &ctx));
1093
1094 let mut ctx2 = FeatureContext::new();
1095 ctx2.insert("email", json!("user@example.com"));
1096 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1097 }
1098
1099 #[test]
1100 fn test_condition_matches_multi_segment_wildcard() {
1101 let cond = r#"{"property": "name", "operator": "matches", "value": ["a*b*c"]}"#;
1102 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1103
1104 let mut ctx = FeatureContext::new();
1105 ctx.insert("name", json!("abc"));
1106 assert!(check(&opts, "organizations:test-feature", &ctx));
1107
1108 let mut ctx2 = FeatureContext::new();
1109 ctx2.insert("name", json!("aXbYc"));
1110 assert!(check(&opts, "organizations:test-feature", &ctx2));
1111
1112 let mut ctx3 = FeatureContext::new();
1113 ctx3.insert("name", json!("aXXbYYc"));
1114 assert!(check(&opts, "organizations:test-feature", &ctx3));
1115
1116 let mut ctx4 = FeatureContext::new();
1117 ctx4.insert("name", json!("aXXc"));
1118 assert!(!check(&opts, "organizations:test-feature", &ctx4));
1119 }
1120
1121 #[test]
1122 fn test_condition_matches_star_only_pattern() {
1123 let cond = r#"{"property": "slug", "operator": "matches", "value": ["*"]}"#;
1124 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1125
1126 let mut ctx = FeatureContext::new();
1127 ctx.insert("slug", json!("anything"));
1128 assert!(check(&opts, "organizations:test-feature", &ctx));
1129
1130 let mut ctx2 = FeatureContext::new();
1131 ctx2.insert("slug", json!(""));
1132 assert!(check(&opts, "organizations:test-feature", &ctx2));
1133 }
1134
1135 #[test]
1136 fn test_condition_matches_case_insensitive() {
1137 let cond = r#"{"property": "slug", "operator": "matches", "value": ["JAYONB*"]}"#;
1138 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1139
1140 let mut ctx = FeatureContext::new();
1141 ctx.insert("slug", json!("jayonb73"));
1142 assert!(check(&opts, "organizations:test-feature", &ctx));
1143
1144 let cond2 = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
1146 let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
1147
1148 let mut ctx2 = FeatureContext::new();
1149 ctx2.insert("slug", json!("JAYONB73"));
1150 assert!(check(&opts2, "organizations:test-feature", &ctx2));
1151 }
1152
1153 #[test]
1154 fn test_condition_matches_no_match() {
1155 let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
1156 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1157
1158 let mut ctx = FeatureContext::new();
1159 ctx.insert("slug", json!("dangoldonb1"));
1160 assert!(!check(&opts, "organizations:test-feature", &ctx));
1161 }
1162
1163 #[test]
1164 fn test_condition_matches_multiple_patterns() {
1165 let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*", "dangoldonb*", "value-disc-*"]}"#;
1166 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1167
1168 let slugs = [
1169 ("jayonb73", true),
1170 ("dangoldonb3", true),
1171 ("value-disc-7", true),
1172 ("other-org", false),
1173 ];
1174 for (slug, expected) in slugs {
1175 let mut ctx = FeatureContext::new();
1176 ctx.insert("slug", json!(slug));
1177 assert_eq!(
1178 check(&opts, "organizations:test-feature", &ctx),
1179 expected,
1180 "slug={slug}"
1181 );
1182 }
1183 }
1184
1185 #[test]
1186 fn test_condition_matches_overlapping_prefix_suffix_anchors() {
1187 let cond = r#"{"property": "slug", "operator": "matches", "value": ["a*a"]}"#;
1189 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1190
1191 let mut ctx = FeatureContext::new();
1192 ctx.insert("slug", json!("a"));
1193 assert!(!check(&opts, "organizations:test-feature", &ctx));
1194
1195 let mut ctx2 = FeatureContext::new();
1196 ctx2.insert("slug", json!("aa"));
1197 assert!(check(&opts, "organizations:test-feature", &ctx2));
1198
1199 let cond2 = r#"{"property": "slug", "operator": "matches", "value": ["ab*ab"]}"#;
1201 let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
1202
1203 let mut ctx3 = FeatureContext::new();
1204 ctx3.insert("slug", json!("ab"));
1205 assert!(!check(&opts2, "organizations:test-feature", &ctx3));
1206
1207 let mut ctx4 = FeatureContext::new();
1208 ctx4.insert("slug", json!("abab"));
1209 assert!(check(&opts2, "organizations:test-feature", &ctx4));
1210 }
1211
1212 #[test]
1213 fn test_condition_matches_non_string_context_returns_false() {
1214 let cond = r#"{"property": "org_id", "operator": "matches", "value": ["123*"]}"#;
1216 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1217
1218 let mut ctx = FeatureContext::new();
1219 ctx.insert("org_id", json!(123));
1220 assert!(!check(&opts, "organizations:test-feature", &ctx));
1221 }
1222}