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