1pub mod features;
4
5pub use features::{FeatureChecker, FeatureContext, FeatureError, features};
6
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, OnceLock};
9use std::time::Duration;
10
11pub use sentry_options_validation::{
12 DEFAULT_REFRESH_THRESHOLD, PropagationCallback, feature_property,
13};
14use sentry_options_validation::{
15 SchemaRegistry, ValidationError, ValuesStore, resolve_options_dir,
16};
17use serde_json::Value;
18use thiserror::Error;
19
20pub mod testing;
21
22static GLOBAL_OPTIONS: OnceLock<Options> = OnceLock::new();
23
24#[derive(Debug, Error)]
25pub enum OptionsError {
26 #[error("Options not initialized - call init() first")]
27 NotInitialized,
28
29 #[error("Options already initialized")]
30 AlreadyInitialized,
31
32 #[error("Unknown namespace: {0}")]
33 UnknownNamespace(String),
34
35 #[error("Unknown option '{key}' in namespace '{namespace}'")]
36 UnknownOption { namespace: String, key: String },
37
38 #[error("Schema error: {0}")]
39 Schema(#[from] ValidationError),
40}
41
42pub type Result<T> = std::result::Result<T, OptionsError>;
43
44pub struct Options {
46 store: ValuesStore,
47}
48
49impl Options {
50 pub fn builder<'a>() -> InitBuilder<'a> {
57 InitBuilder::new()
58 }
59
60 pub fn new() -> Result<Self> {
64 Self::from_directory(&resolve_options_dir())
65 }
66
67 pub fn new_with_propagation_callback(callback: PropagationCallback) -> Result<Self> {
70 let dir = resolve_options_dir();
71 let registry = SchemaRegistry::from_directory(&dir.join("schemas"))?;
72 Self::with_registry_and_values(
73 registry,
74 &dir.join("values"),
75 Some(DEFAULT_REFRESH_THRESHOLD),
76 Some(callback),
77 )
78 }
79
80 pub fn from_directory(base_dir: &Path) -> Result<Self> {
83 let registry = SchemaRegistry::from_directory(&base_dir.join("schemas"))?;
84 Self::with_registry_and_values(
85 registry,
86 &base_dir.join("values"),
87 Some(DEFAULT_REFRESH_THRESHOLD),
88 None,
89 )
90 }
91
92 pub fn from_schemas(schemas: &[(&str, &str)]) -> Result<Self> {
95 let registry = SchemaRegistry::from_schemas(schemas)?;
96 Self::with_registry_and_values(
97 registry,
98 &resolve_options_dir().join("values"),
99 Some(DEFAULT_REFRESH_THRESHOLD),
100 None,
101 )
102 }
103
104 fn with_registry_and_values(
105 registry: SchemaRegistry,
106 values_dir: &Path,
107 refresh_threshold: Option<Duration>,
108 callback: Option<PropagationCallback>,
109 ) -> Result<Self> {
110 let mut builder = ValuesStore::builder(Arc::new(registry), values_dir)
111 .with_refresh_threshold(refresh_threshold);
112 if let Some(cb) = callback {
113 builder = builder.with_callback(cb);
114 }
115 Ok(Self {
116 store: builder.build()?,
117 })
118 }
119
120 pub fn get(&self, namespace: &str, key: &str) -> Result<Value> {
122 self.get_inner(namespace, key, false)
123 }
124
125 pub fn get_forced(&self, namespace: &str, key: &str) -> Result<Value> {
129 self.get_inner(namespace, key, true)
130 }
131
132 pub fn refresh(&self) -> Result<bool> {
143 Ok(self.store.refresh()?)
144 }
145
146 fn get_inner(&self, namespace: &str, key: &str, force_reload: bool) -> Result<Value> {
147 if let Some(value) = testing::get_override(namespace, key) {
148 return Ok(value);
149 }
150
151 let schema = self
152 .store
153 .registry()
154 .get(namespace)
155 .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
156
157 let values_guard = if force_reload {
158 self.store.force_load()
159 } else {
160 self.store.load()
161 };
162
163 if let Some(ns_values) = values_guard.get(namespace)
164 && let Some(value) = ns_values.get(key)
165 {
166 return Ok(value.clone());
167 }
168
169 let default = schema
170 .get_default(key)
171 .ok_or_else(|| OptionsError::UnknownOption {
172 namespace: namespace.to_string(),
173 key: key.to_string(),
174 })?;
175
176 Ok(default.clone())
177 }
178
179 pub fn validate_override(&self, namespace: &str, key: &str, value: &Value) -> Result<()> {
181 let schema = self
182 .store
183 .registry()
184 .get(namespace)
185 .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
186
187 schema.validate_option(key, value)?;
188
189 Ok(())
190 }
191
192 pub fn isset(&self, namespace: &str, key: &str) -> Result<bool> {
199 let schema = self
200 .store
201 .registry()
202 .get(namespace)
203 .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
204
205 if !schema.is_known_key(key) {
206 return Err(OptionsError::UnknownOption {
207 namespace: namespace.into(),
208 key: key.into(),
209 });
210 }
211
212 let values_guard = self.store.load();
213 if let Some(ns_values) = values_guard.get(namespace) {
214 Ok(ns_values.contains_key(key))
215 } else {
216 Ok(false)
217 }
218 }
219}
220
221#[derive(Default)]
233pub struct InitBuilder<'a> {
234 directory: Option<PathBuf>,
235 schemas: Option<&'a [(&'a str, &'a str)]>,
236 additional_schemas: Option<&'a [(&'a str, &'a str)]>,
237 callback: Option<PropagationCallback>,
238 refresh_threshold: Option<Option<Duration>>,
239}
240
241impl<'a> InitBuilder<'a> {
242 pub fn new() -> Self {
243 InitBuilder::default()
244 }
245
246 pub fn with_directory(mut self, directory: impl Into<PathBuf>) -> Self {
249 self.directory = Some(directory.into());
250 self
251 }
252
253 pub fn with_schemas(mut self, schemas: &'a [(&'a str, &'a str)]) -> Self {
257 self.schemas = Some(schemas);
258 self
259 }
260
261 pub fn with_additional_schemas(mut self, schemas: &'a [(&'a str, &'a str)]) -> Self {
265 self.additional_schemas = Some(schemas);
266 self
267 }
268
269 pub fn with_callback(mut self, callback: impl Fn(&str, f64) + Send + Sync + 'static) -> Self {
272 self.callback = Some(Box::new(callback));
273 self
274 }
275
276 pub fn with_refresh_threshold(mut self, threshold: impl Into<Option<Duration>>) -> Self {
282 self.refresh_threshold = Some(threshold.into());
283 self
284 }
285
286 pub fn init(self) -> Result<()> {
292 if GLOBAL_OPTIONS.get().is_some() {
293 return Err(OptionsError::AlreadyInitialized);
294 }
295 GLOBAL_OPTIONS
296 .set(self.build()?)
297 .map_err(|_| OptionsError::AlreadyInitialized)
298 }
299
300 pub fn build(self) -> Result<Options> {
310 let dir = self.directory.unwrap_or_else(resolve_options_dir);
311
312 let mut registry = match self.schemas {
314 Some(s) => SchemaRegistry::from_schemas(s)?,
315 None => {
316 let schemas_dir = dir.join("schemas");
317 if self.additional_schemas.is_some() && !schemas_dir.exists() {
320 SchemaRegistry::new()
321 } else {
322 SchemaRegistry::from_directory(&schemas_dir)?
323 }
324 }
325 };
326 if let Some(additional) = self.additional_schemas {
327 registry.add_schemas(additional)?;
328 }
329
330 let refresh_threshold = self
331 .refresh_threshold
332 .unwrap_or(Some(DEFAULT_REFRESH_THRESHOLD));
333 Options::with_registry_and_values(
334 registry,
335 &dir.join("values"),
336 refresh_threshold,
337 self.callback,
338 )
339 }
340}
341
342pub fn init() -> Result<()> {
349 ignore_already_initialized(InitBuilder::new().init())
350}
351
352#[deprecated(
356 since = "1.3.0",
357 note = "use `Options::builder().with_callback(cb).init()`"
358)]
359pub fn init_with_propagation_callback(callback: PropagationCallback) -> Result<()> {
360 ignore_already_initialized(InitBuilder::new().with_callback(callback).init())
361}
362
363#[deprecated(
376 since = "1.3.0",
377 note = "use `Options::builder().with_schemas(s).init()`"
378)]
379pub fn init_with_schemas(schemas: &[(&str, &str)]) -> Result<()> {
380 ignore_already_initialized(InitBuilder::new().with_schemas(schemas).init())
381}
382
383fn ignore_already_initialized(result: Result<()>) -> Result<()> {
385 match result {
386 Err(OptionsError::AlreadyInitialized) => Ok(()),
387 other => other,
388 }
389}
390
391pub fn options(namespace: &str) -> Result<NamespaceOptions> {
395 let opts = GLOBAL_OPTIONS.get().ok_or(OptionsError::NotInitialized)?;
396 Ok(NamespaceOptions {
397 namespace: namespace.to_string(),
398 options: opts,
399 })
400}
401
402pub struct NamespaceOptions {
404 namespace: String,
405 options: &'static Options,
406}
407
408impl NamespaceOptions {
409 pub fn get(&self, key: &str) -> Result<Value> {
411 self.options.get(&self.namespace, key)
412 }
413
414 pub fn get_forced(&self, key: &str) -> Result<Value> {
417 self.options.get_forced(&self.namespace, key)
418 }
419
420 pub fn isset(&self, key: &str) -> Result<bool> {
422 self.options.isset(&self.namespace, key)
423 }
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429 use serde_json::json;
430 use std::fs;
431 use tempfile::TempDir;
432
433 fn create_schema(dir: &Path, namespace: &str, schema: &str) {
434 let schema_dir = dir.join(namespace);
435 fs::create_dir_all(&schema_dir).unwrap();
436 fs::write(schema_dir.join("schema.json"), schema).unwrap();
437 }
438
439 fn create_values(dir: &Path, namespace: &str, values: &str) {
440 let ns_dir = dir.join(namespace);
441 fs::create_dir_all(&ns_dir).unwrap();
442 fs::write(ns_dir.join("values.json"), values).unwrap();
443 }
444
445 #[test]
446 fn test_get_value() {
447 let temp = TempDir::new().unwrap();
448 let schemas = temp.path().join("schemas");
449 let values = temp.path().join("values");
450 fs::create_dir_all(&schemas).unwrap();
451
452 create_schema(
453 &schemas,
454 "test",
455 r#"{
456 "version": "1.0",
457 "type": "object",
458 "properties": {
459 "enabled": {
460 "type": "boolean",
461 "default": false,
462 "description": "Enable feature"
463 }
464 }
465 }"#,
466 );
467 create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
468
469 let options = Options::from_directory(temp.path()).unwrap();
470 assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
471 }
472
473 #[test]
474 fn test_with_additional_schemas_overlays_directory_schemas() {
475 let temp = TempDir::new().unwrap();
476 let schemas = temp.path().join("schemas");
477 let values = temp.path().join("values");
478 fs::create_dir_all(&schemas).unwrap();
479
480 create_schema(&schemas, "disk", BOOL_SCHEMA);
482 create_values(&values, "disk", r#"{"options": {"enabled": true}}"#);
483 create_values(&values, "mem", r#"{"options": {"enabled": true}}"#);
485
486 let options = Options::builder()
487 .with_directory(temp.path())
488 .with_additional_schemas(&[("mem", BOOL_SCHEMA)])
489 .build()
490 .unwrap();
491
492 assert_eq!(options.get("disk", "enabled").unwrap(), json!(true));
494 assert_eq!(options.get("mem", "enabled").unwrap(), json!(true));
496 }
497
498 #[test]
499 fn test_with_schemas_and_additional_schemas_compose() {
500 let temp = TempDir::new().unwrap();
501 let schemas = temp.path().join("schemas");
502 let values = temp.path().join("values");
503 fs::create_dir_all(&schemas).unwrap();
504
505 create_schema(&schemas, "disk", BOOL_SCHEMA);
507 create_values(&values, "disk", r#"{"options": {"enabled": true}}"#);
508 create_values(&values, "base", r#"{"options": {"enabled": true}}"#);
509 create_values(&values, "extra", r#"{"options": {"enabled": true}}"#);
510
511 let options = Options::builder()
512 .with_directory(temp.path())
513 .with_schemas(&[("base", BOOL_SCHEMA)])
514 .with_additional_schemas(&[("extra", BOOL_SCHEMA)])
515 .build()
516 .unwrap();
517
518 assert_eq!(options.get("base", "enabled").unwrap(), json!(true));
519 assert_eq!(options.get("extra", "enabled").unwrap(), json!(true));
520 assert!(options.get("disk", "enabled").is_err());
521 }
522
523 #[test]
524 fn test_with_additional_schemas_conflicting_namespace_errors() {
525 let temp = TempDir::new().unwrap();
526 let schemas = temp.path().join("schemas");
527 fs::create_dir_all(&schemas).unwrap();
528 create_schema(&schemas, "dup", BOOL_SCHEMA);
529
530 let result = Options::builder()
533 .with_directory(temp.path())
534 .with_additional_schemas(&[("dup", BOOL_SCHEMA)])
535 .build();
536
537 assert!(
538 result.is_err(),
539 "expected an error for a namespace already on disk"
540 );
541 }
542
543 #[test]
544 fn test_get_default() {
545 let temp = TempDir::new().unwrap();
546 let schemas = temp.path().join("schemas");
547 let values = temp.path().join("values");
548 fs::create_dir_all(&schemas).unwrap();
549 fs::create_dir_all(&values).unwrap();
550
551 create_schema(
552 &schemas,
553 "test",
554 r#"{
555 "version": "1.0",
556 "type": "object",
557 "properties": {
558 "timeout": {
559 "type": "integer",
560 "default": 30,
561 "description": "Timeout"
562 }
563 }
564 }"#,
565 );
566
567 let options = Options::from_directory(temp.path()).unwrap();
568 assert_eq!(options.get("test", "timeout").unwrap(), json!(30));
569 }
570
571 #[test]
572 fn test_unknown_namespace() {
573 let temp = TempDir::new().unwrap();
574 let schemas = temp.path().join("schemas");
575 let values = temp.path().join("values");
576 fs::create_dir_all(&schemas).unwrap();
577 fs::create_dir_all(&values).unwrap();
578
579 create_schema(
580 &schemas,
581 "test",
582 r#"{"version": "1.0", "type": "object", "properties": {}}"#,
583 );
584
585 let options = Options::from_directory(temp.path()).unwrap();
586 assert!(matches!(
587 options.get("unknown", "key"),
588 Err(OptionsError::UnknownNamespace(_))
589 ));
590 }
591
592 #[test]
593 fn test_unknown_option() {
594 let temp = TempDir::new().unwrap();
595 let schemas = temp.path().join("schemas");
596 let values = temp.path().join("values");
597 fs::create_dir_all(&schemas).unwrap();
598 fs::create_dir_all(&values).unwrap();
599
600 create_schema(
601 &schemas,
602 "test",
603 r#"{
604 "version": "1.0",
605 "type": "object",
606 "properties": {
607 "known": {"type": "string", "default": "x", "description": "Known"}
608 }
609 }"#,
610 );
611
612 let options = Options::from_directory(temp.path()).unwrap();
613 assert!(matches!(
614 options.get("test", "unknown"),
615 Err(OptionsError::UnknownOption { .. })
616 ));
617 }
618
619 #[test]
620 fn test_missing_values_dir() {
621 let temp = TempDir::new().unwrap();
622 let schemas = temp.path().join("schemas");
623 fs::create_dir_all(&schemas).unwrap();
624
625 create_schema(
626 &schemas,
627 "test",
628 r#"{
629 "version": "1.0",
630 "type": "object",
631 "properties": {
632 "opt": {"type": "string", "default": "default_val", "description": "Opt"}
633 }
634 }"#,
635 );
636
637 let options = Options::from_directory(temp.path()).unwrap();
638 assert_eq!(options.get("test", "opt").unwrap(), json!("default_val"));
639 }
640
641 #[test]
642 fn isset_with_defined_and_undefined_keys() {
643 let temp = TempDir::new().unwrap();
644 let schemas = temp.path().join("schemas");
645 fs::create_dir_all(&schemas).unwrap();
646
647 let values = temp.path().join("values");
648 create_values(&values, "test", r#"{"options": {"has-value": "yes"}}"#);
649
650 create_schema(
651 &schemas,
652 "test",
653 r##"{
654 "version": "1.0",
655 "type": "object",
656 "properties": {
657 "has-value": {"type": "string", "default": "", "description": ""},
658 "defined-with-default": {"type": "string", "default": "default_val", "description": "Opt"},
659 "feature.organizations:my-flag": {"$ref": "#/definitions/Feature"}
660 }
661 }"##,
662 );
663
664 let options = Options::from_directory(temp.path()).unwrap();
665 assert!(options.isset("test", "not-defined").is_err());
666 assert!(!options.isset("test", "defined-with-default").unwrap());
667 assert!(options.isset("test", "has-value").unwrap());
668 assert!(
670 !options
671 .isset("test", "feature.organizations:my-flag")
672 .unwrap()
673 );
674 }
675
676 #[test]
677 fn test_from_schemas_get_default() {
678 let schema = r#"{
679 "version": "1.0",
680 "type": "object",
681 "properties": {
682 "enabled": {
683 "type": "boolean",
684 "default": false,
685 "description": "Enable feature"
686 }
687 }
688 }"#;
689
690 let registry = SchemaRegistry::from_schemas(&[("test", schema)]).unwrap();
691 let default = registry
692 .get("test")
693 .unwrap()
694 .get_default("enabled")
695 .unwrap();
696 assert_eq!(*default, json!(false));
697 }
698
699 #[test]
700 fn test_from_schemas_with_values() {
701 let temp = TempDir::new().unwrap();
702 let values_dir = temp.path().join("values");
703 create_values(&values_dir, "test", r#"{"options": {"enabled": true}}"#);
704
705 let schema = r#"{
706 "version": "1.0",
707 "type": "object",
708 "properties": {
709 "enabled": {
710 "type": "boolean",
711 "default": false,
712 "description": "Enable feature"
713 }
714 }
715 }"#;
716
717 let registry = Arc::new(SchemaRegistry::from_schemas(&[("test", schema)]).unwrap());
718 let (loaded_values, _) = registry.load_values_json(&values_dir).unwrap();
719 assert_eq!(loaded_values["test"]["enabled"], json!(true));
720 }
721
722 #[test]
723 fn test_from_schemas_invalid_json() {
724 let result = SchemaRegistry::from_schemas(&[("test", "not valid json")]);
725 assert!(result.is_err());
726 }
727
728 const BOOL_SCHEMA: &str = r#"{
729 "version": "1.0",
730 "type": "object",
731 "properties": {
732 "enabled": {"type": "boolean", "default": false, "description": "x"}
733 }
734 }"#;
735
736 #[test]
737 fn builder_with_directory() {
738 let temp = TempDir::new().unwrap();
739 let schemas = temp.path().join("schemas");
740 let values = temp.path().join("values");
741 fs::create_dir_all(&schemas).unwrap();
742 create_schema(&schemas, "test", BOOL_SCHEMA);
743 create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
744
745 let options = Options::builder()
746 .with_directory(temp.path())
748 .build()
749 .unwrap();
750 assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
751 }
752
753 #[test]
754 fn builder_with_schemas() {
755 let options = Options::builder()
756 .with_schemas(&[("test", BOOL_SCHEMA)])
758 .build()
759 .unwrap();
760 assert_eq!(options.get("test", "enabled").unwrap(), json!(false));
761 }
762
763 #[test]
764 fn get_forced_sees_change() {
765 let temp = TempDir::new().unwrap();
766 let schemas = temp.path().join("schemas");
767 let values = temp.path().join("values");
768 fs::create_dir_all(&schemas).unwrap();
769 create_schema(&schemas, "test", BOOL_SCHEMA);
770 create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
771
772 let options = Options::from_directory(temp.path()).unwrap();
773 assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
774
775 create_values(&values, "test", r#"{"options": {"enabled": false}}"#);
776
777 assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
779 assert_eq!(options.get_forced("test", "enabled").unwrap(), json!(false));
781 }
782
783 #[test]
784 fn builder_refresh_threshold_none_with_manual_refresh() {
785 let temp = TempDir::new().unwrap();
786 let schemas = temp.path().join("schemas");
787 let values = temp.path().join("values");
788 fs::create_dir_all(&schemas).unwrap();
789 create_schema(&schemas, "test", BOOL_SCHEMA);
790 create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
791
792 let options = Options::builder()
793 .with_directory(temp.path())
794 .with_refresh_threshold(None)
795 .build()
796 .unwrap();
797 assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
798
799 create_values(&values, "test", r#"{"options": {"enabled": false}}"#);
800
801 assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
803 assert!(options.refresh().unwrap());
805 assert_eq!(options.get("test", "enabled").unwrap(), json!(false));
806 assert!(!options.refresh().unwrap());
808 }
809
810 #[test]
811 fn builder_refresh_threshold_custom() {
812 let temp = TempDir::new().unwrap();
813 let schemas = temp.path().join("schemas");
814 let values = temp.path().join("values");
815 fs::create_dir_all(&schemas).unwrap();
816 create_schema(&schemas, "test", BOOL_SCHEMA);
817 create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
818
819 let options = Options::builder()
820 .with_directory(temp.path())
821 .with_refresh_threshold(Duration::ZERO)
822 .build()
823 .unwrap();
824 assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
825
826 create_values(&values, "test", r#"{"options": {"enabled": false}}"#);
827
828 assert_eq!(options.get("test", "enabled").unwrap(), json!(false));
830 }
831}