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