1pub mod features;
4
5pub use features::{FeatureChecker, FeatureContext, features};
6
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, OnceLock};
9use std::time::Duration;
10
11pub use sentry_options_validation::{DEFAULT_REFRESH_THRESHOLD, PropagationCallback};
12use sentry_options_validation::{
13 SchemaRegistry, ValidationError, ValuesStore, resolve_options_dir,
14};
15use serde_json::Value;
16use thiserror::Error;
17
18pub mod testing;
19
20static GLOBAL_OPTIONS: OnceLock<Options> = OnceLock::new();
21
22#[derive(Debug, Error)]
23pub enum OptionsError {
24 #[error("Options not initialized - call init() first")]
25 NotInitialized,
26
27 #[error("Options already initialized")]
28 AlreadyInitialized,
29
30 #[error("Unknown namespace: {0}")]
31 UnknownNamespace(String),
32
33 #[error("Unknown option '{key}' in namespace '{namespace}'")]
34 UnknownOption { namespace: String, key: String },
35
36 #[error("Schema error: {0}")]
37 Schema(#[from] ValidationError),
38}
39
40pub type Result<T> = std::result::Result<T, OptionsError>;
41
42pub struct Options {
44 store: ValuesStore,
45}
46
47impl Options {
48 pub fn builder<'a>() -> InitBuilder<'a> {
55 InitBuilder::new()
56 }
57
58 pub fn new() -> Result<Self> {
62 Self::from_directory(&resolve_options_dir())
63 }
64
65 pub fn new_with_propagation_callback(callback: PropagationCallback) -> Result<Self> {
68 let dir = resolve_options_dir();
69 let registry = SchemaRegistry::from_directory(&dir.join("schemas"))?;
70 Self::with_registry_and_values(
71 registry,
72 &dir.join("values"),
73 Some(DEFAULT_REFRESH_THRESHOLD),
74 Some(callback),
75 )
76 }
77
78 pub fn from_directory(base_dir: &Path) -> Result<Self> {
81 let registry = SchemaRegistry::from_directory(&base_dir.join("schemas"))?;
82 Self::with_registry_and_values(
83 registry,
84 &base_dir.join("values"),
85 Some(DEFAULT_REFRESH_THRESHOLD),
86 None,
87 )
88 }
89
90 pub fn from_schemas(schemas: &[(&str, &str)]) -> Result<Self> {
93 let registry = SchemaRegistry::from_schemas(schemas)?;
94 Self::with_registry_and_values(
95 registry,
96 &resolve_options_dir().join("values"),
97 Some(DEFAULT_REFRESH_THRESHOLD),
98 None,
99 )
100 }
101
102 fn with_registry_and_values(
103 registry: SchemaRegistry,
104 values_dir: &Path,
105 refresh_threshold: Option<Duration>,
106 callback: Option<PropagationCallback>,
107 ) -> Result<Self> {
108 let mut builder = ValuesStore::builder(Arc::new(registry), values_dir)
109 .with_refresh_threshold(refresh_threshold);
110 if let Some(cb) = callback {
111 builder = builder.with_callback(cb);
112 }
113 Ok(Self {
114 store: builder.build()?,
115 })
116 }
117
118 pub fn get(&self, namespace: &str, key: &str) -> Result<Value> {
120 self.get_inner(namespace, key, false)
121 }
122
123 pub fn get_forced(&self, namespace: &str, key: &str) -> Result<Value> {
127 self.get_inner(namespace, key, true)
128 }
129
130 pub fn refresh(&self) -> Result<bool> {
141 Ok(self.store.refresh()?)
142 }
143
144 fn get_inner(&self, namespace: &str, key: &str, force_reload: bool) -> Result<Value> {
145 if let Some(value) = testing::get_override(namespace, key) {
146 return Ok(value);
147 }
148
149 let schema = self
150 .store
151 .registry()
152 .get(namespace)
153 .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
154
155 let values_guard = if force_reload {
156 self.store.force_load()
157 } else {
158 self.store.load()
159 };
160
161 if let Some(ns_values) = values_guard.get(namespace)
162 && let Some(value) = ns_values.get(key)
163 {
164 return Ok(value.clone());
165 }
166
167 let default = schema
168 .get_default(key)
169 .ok_or_else(|| OptionsError::UnknownOption {
170 namespace: namespace.to_string(),
171 key: key.to_string(),
172 })?;
173
174 Ok(default.clone())
175 }
176
177 pub fn validate_override(&self, namespace: &str, key: &str, value: &Value) -> Result<()> {
179 let schema = self
180 .store
181 .registry()
182 .get(namespace)
183 .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
184
185 schema.validate_option(key, value)?;
186
187 Ok(())
188 }
189
190 pub fn isset(&self, namespace: &str, key: &str) -> Result<bool> {
197 let schema = self
198 .store
199 .registry()
200 .get(namespace)
201 .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
202
203 if !schema.is_known_key(key) {
204 return Err(OptionsError::UnknownOption {
205 namespace: namespace.into(),
206 key: key.into(),
207 });
208 }
209
210 let values_guard = self.store.load();
211 if let Some(ns_values) = values_guard.get(namespace) {
212 Ok(ns_values.contains_key(key))
213 } else {
214 Ok(false)
215 }
216 }
217}
218
219#[derive(Default)]
230pub struct InitBuilder<'a> {
231 directory: Option<PathBuf>,
232 schemas: Option<&'a [(&'a str, &'a str)]>,
233 callback: Option<PropagationCallback>,
234 refresh_threshold: Option<Option<Duration>>,
235}
236
237impl<'a> InitBuilder<'a> {
238 pub fn new() -> Self {
239 InitBuilder::default()
240 }
241
242 pub fn with_directory(mut self, directory: impl Into<PathBuf>) -> Self {
245 self.directory = Some(directory.into());
246 self
247 }
248
249 pub fn with_schemas(mut self, schemas: &'a [(&'a str, &'a str)]) -> Self {
252 self.schemas = Some(schemas);
253 self
254 }
255
256 pub fn with_callback(mut self, callback: impl Fn(&str, f64) + Send + Sync + 'static) -> Self {
259 self.callback = Some(Box::new(callback));
260 self
261 }
262
263 pub fn with_refresh_threshold(mut self, threshold: impl Into<Option<Duration>>) -> Self {
269 self.refresh_threshold = Some(threshold.into());
270 self
271 }
272
273 pub fn init(self) -> Result<()> {
279 if GLOBAL_OPTIONS.get().is_some() {
280 return Err(OptionsError::AlreadyInitialized);
281 }
282 GLOBAL_OPTIONS
283 .set(self.build()?)
284 .map_err(|_| OptionsError::AlreadyInitialized)
285 }
286
287 pub fn build(self) -> Result<Options> {
297 let dir = self.directory.unwrap_or_else(resolve_options_dir);
298
299 let registry = match self.schemas {
300 Some(s) => SchemaRegistry::from_schemas(s)?,
301 None => SchemaRegistry::from_directory(&dir.join("schemas"))?,
302 };
303
304 let refresh_threshold = self
305 .refresh_threshold
306 .unwrap_or(Some(DEFAULT_REFRESH_THRESHOLD));
307 Options::with_registry_and_values(
308 registry,
309 &dir.join("values"),
310 refresh_threshold,
311 self.callback,
312 )
313 }
314}
315
316pub fn init() -> Result<()> {
323 ignore_already_initialized(InitBuilder::new().init())
324}
325
326#[deprecated(
330 since = "1.3.0",
331 note = "use `Options::builder().with_callback(cb).init()`"
332)]
333pub fn init_with_propagation_callback(callback: PropagationCallback) -> Result<()> {
334 ignore_already_initialized(InitBuilder::new().with_callback(callback).init())
335}
336
337#[deprecated(
350 since = "1.3.0",
351 note = "use `Options::builder().with_schemas(s).init()`"
352)]
353pub fn init_with_schemas(schemas: &[(&str, &str)]) -> Result<()> {
354 ignore_already_initialized(InitBuilder::new().with_schemas(schemas).init())
355}
356
357fn ignore_already_initialized(result: Result<()>) -> Result<()> {
359 match result {
360 Err(OptionsError::AlreadyInitialized) => Ok(()),
361 other => other,
362 }
363}
364
365pub fn options(namespace: &str) -> Result<NamespaceOptions> {
369 let opts = GLOBAL_OPTIONS.get().ok_or(OptionsError::NotInitialized)?;
370 Ok(NamespaceOptions {
371 namespace: namespace.to_string(),
372 options: opts,
373 })
374}
375
376pub struct NamespaceOptions {
378 namespace: String,
379 options: &'static Options,
380}
381
382impl NamespaceOptions {
383 pub fn get(&self, key: &str) -> Result<Value> {
385 self.options.get(&self.namespace, key)
386 }
387
388 pub fn get_forced(&self, key: &str) -> Result<Value> {
391 self.options.get_forced(&self.namespace, key)
392 }
393
394 pub fn isset(&self, key: &str) -> Result<bool> {
396 self.options.isset(&self.namespace, key)
397 }
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403 use serde_json::json;
404 use std::fs;
405 use tempfile::TempDir;
406
407 fn create_schema(dir: &Path, namespace: &str, schema: &str) {
408 let schema_dir = dir.join(namespace);
409 fs::create_dir_all(&schema_dir).unwrap();
410 fs::write(schema_dir.join("schema.json"), schema).unwrap();
411 }
412
413 fn create_values(dir: &Path, namespace: &str, values: &str) {
414 let ns_dir = dir.join(namespace);
415 fs::create_dir_all(&ns_dir).unwrap();
416 fs::write(ns_dir.join("values.json"), values).unwrap();
417 }
418
419 #[test]
420 fn test_get_value() {
421 let temp = TempDir::new().unwrap();
422 let schemas = temp.path().join("schemas");
423 let values = temp.path().join("values");
424 fs::create_dir_all(&schemas).unwrap();
425
426 create_schema(
427 &schemas,
428 "test",
429 r#"{
430 "version": "1.0",
431 "type": "object",
432 "properties": {
433 "enabled": {
434 "type": "boolean",
435 "default": false,
436 "description": "Enable feature"
437 }
438 }
439 }"#,
440 );
441 create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
442
443 let options = Options::from_directory(temp.path()).unwrap();
444 assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
445 }
446
447 #[test]
448 fn test_get_default() {
449 let temp = TempDir::new().unwrap();
450 let schemas = temp.path().join("schemas");
451 let values = temp.path().join("values");
452 fs::create_dir_all(&schemas).unwrap();
453 fs::create_dir_all(&values).unwrap();
454
455 create_schema(
456 &schemas,
457 "test",
458 r#"{
459 "version": "1.0",
460 "type": "object",
461 "properties": {
462 "timeout": {
463 "type": "integer",
464 "default": 30,
465 "description": "Timeout"
466 }
467 }
468 }"#,
469 );
470
471 let options = Options::from_directory(temp.path()).unwrap();
472 assert_eq!(options.get("test", "timeout").unwrap(), json!(30));
473 }
474
475 #[test]
476 fn test_unknown_namespace() {
477 let temp = TempDir::new().unwrap();
478 let schemas = temp.path().join("schemas");
479 let values = temp.path().join("values");
480 fs::create_dir_all(&schemas).unwrap();
481 fs::create_dir_all(&values).unwrap();
482
483 create_schema(
484 &schemas,
485 "test",
486 r#"{"version": "1.0", "type": "object", "properties": {}}"#,
487 );
488
489 let options = Options::from_directory(temp.path()).unwrap();
490 assert!(matches!(
491 options.get("unknown", "key"),
492 Err(OptionsError::UnknownNamespace(_))
493 ));
494 }
495
496 #[test]
497 fn test_unknown_option() {
498 let temp = TempDir::new().unwrap();
499 let schemas = temp.path().join("schemas");
500 let values = temp.path().join("values");
501 fs::create_dir_all(&schemas).unwrap();
502 fs::create_dir_all(&values).unwrap();
503
504 create_schema(
505 &schemas,
506 "test",
507 r#"{
508 "version": "1.0",
509 "type": "object",
510 "properties": {
511 "known": {"type": "string", "default": "x", "description": "Known"}
512 }
513 }"#,
514 );
515
516 let options = Options::from_directory(temp.path()).unwrap();
517 assert!(matches!(
518 options.get("test", "unknown"),
519 Err(OptionsError::UnknownOption { .. })
520 ));
521 }
522
523 #[test]
524 fn test_missing_values_dir() {
525 let temp = TempDir::new().unwrap();
526 let schemas = temp.path().join("schemas");
527 fs::create_dir_all(&schemas).unwrap();
528
529 create_schema(
530 &schemas,
531 "test",
532 r#"{
533 "version": "1.0",
534 "type": "object",
535 "properties": {
536 "opt": {"type": "string", "default": "default_val", "description": "Opt"}
537 }
538 }"#,
539 );
540
541 let options = Options::from_directory(temp.path()).unwrap();
542 assert_eq!(options.get("test", "opt").unwrap(), json!("default_val"));
543 }
544
545 #[test]
546 fn isset_with_defined_and_undefined_keys() {
547 let temp = TempDir::new().unwrap();
548 let schemas = temp.path().join("schemas");
549 fs::create_dir_all(&schemas).unwrap();
550
551 let values = temp.path().join("values");
552 create_values(&values, "test", r#"{"options": {"has-value": "yes"}}"#);
553
554 create_schema(
555 &schemas,
556 "test",
557 r##"{
558 "version": "1.0",
559 "type": "object",
560 "properties": {
561 "has-value": {"type": "string", "default": "", "description": ""},
562 "defined-with-default": {"type": "string", "default": "default_val", "description": "Opt"},
563 "feature.organizations:my-flag": {"$ref": "#/definitions/Feature"}
564 }
565 }"##,
566 );
567
568 let options = Options::from_directory(temp.path()).unwrap();
569 assert!(options.isset("test", "not-defined").is_err());
570 assert!(!options.isset("test", "defined-with-default").unwrap());
571 assert!(options.isset("test", "has-value").unwrap());
572 assert!(
574 !options
575 .isset("test", "feature.organizations:my-flag")
576 .unwrap()
577 );
578 }
579
580 #[test]
581 fn test_from_schemas_get_default() {
582 let schema = r#"{
583 "version": "1.0",
584 "type": "object",
585 "properties": {
586 "enabled": {
587 "type": "boolean",
588 "default": false,
589 "description": "Enable feature"
590 }
591 }
592 }"#;
593
594 let registry = SchemaRegistry::from_schemas(&[("test", schema)]).unwrap();
595 let default = registry
596 .get("test")
597 .unwrap()
598 .get_default("enabled")
599 .unwrap();
600 assert_eq!(*default, json!(false));
601 }
602
603 #[test]
604 fn test_from_schemas_with_values() {
605 let temp = TempDir::new().unwrap();
606 let values_dir = temp.path().join("values");
607 create_values(&values_dir, "test", r#"{"options": {"enabled": true}}"#);
608
609 let schema = r#"{
610 "version": "1.0",
611 "type": "object",
612 "properties": {
613 "enabled": {
614 "type": "boolean",
615 "default": false,
616 "description": "Enable feature"
617 }
618 }
619 }"#;
620
621 let registry = Arc::new(SchemaRegistry::from_schemas(&[("test", schema)]).unwrap());
622 let (loaded_values, _) = registry.load_values_json(&values_dir).unwrap();
623 assert_eq!(loaded_values["test"]["enabled"], json!(true));
624 }
625
626 #[test]
627 fn test_from_schemas_invalid_json() {
628 let result = SchemaRegistry::from_schemas(&[("test", "not valid json")]);
629 assert!(result.is_err());
630 }
631
632 const BOOL_SCHEMA: &str = r#"{
633 "version": "1.0",
634 "type": "object",
635 "properties": {
636 "enabled": {"type": "boolean", "default": false, "description": "x"}
637 }
638 }"#;
639
640 #[test]
641 fn builder_with_directory() {
642 let temp = TempDir::new().unwrap();
643 let schemas = temp.path().join("schemas");
644 let values = temp.path().join("values");
645 fs::create_dir_all(&schemas).unwrap();
646 create_schema(&schemas, "test", BOOL_SCHEMA);
647 create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
648
649 let options = Options::builder()
650 .with_directory(temp.path())
652 .build()
653 .unwrap();
654 assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
655 }
656
657 #[test]
658 fn builder_with_schemas() {
659 let options = Options::builder()
660 .with_schemas(&[("test", BOOL_SCHEMA)])
662 .build()
663 .unwrap();
664 assert_eq!(options.get("test", "enabled").unwrap(), json!(false));
665 }
666
667 #[test]
668 fn get_forced_sees_change() {
669 let temp = TempDir::new().unwrap();
670 let schemas = temp.path().join("schemas");
671 let values = temp.path().join("values");
672 fs::create_dir_all(&schemas).unwrap();
673 create_schema(&schemas, "test", BOOL_SCHEMA);
674 create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
675
676 let options = Options::from_directory(temp.path()).unwrap();
677 assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
678
679 create_values(&values, "test", r#"{"options": {"enabled": false}}"#);
680
681 assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
683 assert_eq!(options.get_forced("test", "enabled").unwrap(), json!(false));
685 }
686
687 #[test]
688 fn builder_refresh_threshold_none_with_manual_refresh() {
689 let temp = TempDir::new().unwrap();
690 let schemas = temp.path().join("schemas");
691 let values = temp.path().join("values");
692 fs::create_dir_all(&schemas).unwrap();
693 create_schema(&schemas, "test", BOOL_SCHEMA);
694 create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
695
696 let options = Options::builder()
697 .with_directory(temp.path())
698 .with_refresh_threshold(None)
699 .build()
700 .unwrap();
701 assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
702
703 create_values(&values, "test", r#"{"options": {"enabled": false}}"#);
704
705 assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
707 assert!(options.refresh().unwrap());
709 assert_eq!(options.get("test", "enabled").unwrap(), json!(false));
710 assert!(!options.refresh().unwrap());
712 }
713
714 #[test]
715 fn builder_refresh_threshold_custom() {
716 let temp = TempDir::new().unwrap();
717 let schemas = temp.path().join("schemas");
718 let values = temp.path().join("values");
719 fs::create_dir_all(&schemas).unwrap();
720 create_schema(&schemas, "test", BOOL_SCHEMA);
721 create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
722
723 let options = Options::builder()
724 .with_directory(temp.path())
725 .with_refresh_threshold(Duration::ZERO)
726 .build()
727 .unwrap();
728 assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
729
730 create_values(&values, "test", r#"{"options": {"enabled": false}}"#);
731
732 assert_eq!(options.get("test", "enabled").unwrap(), json!(false));
734 }
735}