1pub mod features;
4
5pub use features::{FeatureChecker, FeatureContext, features};
6
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, OnceLock};
9
10pub use sentry_options_validation::PropagationCallback;
11use sentry_options_validation::{
12 SchemaRegistry, ValidationError, ValuesStore, resolve_options_dir,
13};
14use serde_json::Value;
15use thiserror::Error;
16
17pub mod testing;
18
19static GLOBAL_OPTIONS: OnceLock<Options> = OnceLock::new();
20
21#[derive(Debug, Error)]
22pub enum OptionsError {
23 #[error("Options not initialized - call init() first")]
24 NotInitialized,
25
26 #[error("Options already initialized")]
27 AlreadyInitialized,
28
29 #[error("Unknown namespace: {0}")]
30 UnknownNamespace(String),
31
32 #[error("Unknown option '{key}' in namespace '{namespace}'")]
33 UnknownOption { namespace: String, key: String },
34
35 #[error("Schema error: {0}")]
36 Schema(#[from] ValidationError),
37}
38
39pub type Result<T> = std::result::Result<T, OptionsError>;
40
41pub struct Options {
43 store: ValuesStore,
44}
45
46impl Options {
47 pub fn builder<'a>() -> InitBuilder<'a> {
54 InitBuilder::new()
55 }
56
57 pub fn new() -> Result<Self> {
61 Self::from_directory(&resolve_options_dir())
62 }
63
64 pub fn new_with_propagation_callback(callback: PropagationCallback) -> Result<Self> {
67 let dir = resolve_options_dir();
68 let registry = SchemaRegistry::from_directory(&dir.join("schemas"))?;
69 Self::with_registry_and_values(registry, &dir.join("values"), Some(callback))
70 }
71
72 pub fn from_directory(base_dir: &Path) -> Result<Self> {
75 let registry = SchemaRegistry::from_directory(&base_dir.join("schemas"))?;
76 Self::with_registry_and_values(registry, &base_dir.join("values"), None)
77 }
78
79 pub fn from_schemas(schemas: &[(&str, &str)]) -> Result<Self> {
82 let registry = SchemaRegistry::from_schemas(schemas)?;
83 Self::with_registry_and_values(registry, &resolve_options_dir().join("values"), None)
84 }
85
86 fn with_registry_and_values(
87 registry: SchemaRegistry,
88 values_dir: &Path,
89 callback: Option<PropagationCallback>,
90 ) -> Result<Self> {
91 let registry = Arc::new(registry);
92 let store = match callback {
93 Some(cb) => ValuesStore::with_propagation_callback(registry, values_dir, cb)?,
94 None => ValuesStore::new(registry, values_dir)?,
95 };
96 Ok(Self { store })
97 }
98
99 pub fn get(&self, namespace: &str, key: &str) -> Result<Value> {
101 self.get_inner(namespace, key, false)
102 }
103
104 pub fn get_forced(&self, namespace: &str, key: &str) -> Result<Value> {
107 self.get_inner(namespace, key, true)
108 }
109
110 fn get_inner(&self, namespace: &str, key: &str, force_reload: bool) -> Result<Value> {
111 if let Some(value) = testing::get_override(namespace, key) {
112 return Ok(value);
113 }
114
115 let schema = self
116 .store
117 .registry()
118 .get(namespace)
119 .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
120
121 let values_guard = if force_reload {
122 self.store.force_load()
123 } else {
124 self.store.load()
125 };
126
127 if let Some(ns_values) = values_guard.get(namespace)
128 && let Some(value) = ns_values.get(key)
129 {
130 return Ok(value.clone());
131 }
132
133 let default = schema
134 .get_default(key)
135 .ok_or_else(|| OptionsError::UnknownOption {
136 namespace: namespace.to_string(),
137 key: key.to_string(),
138 })?;
139
140 Ok(default.clone())
141 }
142
143 pub fn validate_override(&self, namespace: &str, key: &str, value: &Value) -> Result<()> {
145 let schema = self
146 .store
147 .registry()
148 .get(namespace)
149 .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
150
151 schema.validate_option(key, value)?;
152
153 Ok(())
154 }
155
156 pub fn isset(&self, namespace: &str, key: &str) -> Result<bool> {
163 let schema = self
164 .store
165 .registry()
166 .get(namespace)
167 .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
168
169 if !schema.is_known_key(key) {
170 return Err(OptionsError::UnknownOption {
171 namespace: namespace.into(),
172 key: key.into(),
173 });
174 }
175
176 let values_guard = self.store.load();
177 if let Some(ns_values) = values_guard.get(namespace) {
178 Ok(ns_values.contains_key(key))
179 } else {
180 Ok(false)
181 }
182 }
183}
184
185#[derive(Default)]
194pub struct InitBuilder<'a> {
195 directory: Option<PathBuf>,
196 schemas: Option<&'a [(&'a str, &'a str)]>,
197 callback: Option<PropagationCallback>,
198}
199
200impl<'a> InitBuilder<'a> {
201 pub fn new() -> Self {
202 InitBuilder::default()
203 }
204
205 pub fn with_directory(mut self, directory: impl Into<PathBuf>) -> Self {
208 self.directory = Some(directory.into());
209 self
210 }
211
212 pub fn with_schemas(mut self, schemas: &'a [(&'a str, &'a str)]) -> Self {
215 self.schemas = Some(schemas);
216 self
217 }
218
219 pub fn with_callback(mut self, callback: impl Fn(&str, f64) + Send + Sync + 'static) -> Self {
222 self.callback = Some(Box::new(callback));
223 self
224 }
225
226 pub fn init(self) -> Result<()> {
232 if GLOBAL_OPTIONS.get().is_some() {
233 return Err(OptionsError::AlreadyInitialized);
234 }
235 GLOBAL_OPTIONS
236 .set(self.build()?)
237 .map_err(|_| OptionsError::AlreadyInitialized)
238 }
239
240 pub fn build(self) -> Result<Options> {
250 let dir = self.directory.unwrap_or_else(resolve_options_dir);
251
252 let registry = match self.schemas {
253 Some(s) => SchemaRegistry::from_schemas(s)?,
254 None => SchemaRegistry::from_directory(&dir.join("schemas"))?,
255 };
256
257 Options::with_registry_and_values(registry, &dir.join("values"), self.callback)
258 }
259}
260
261pub fn init() -> Result<()> {
268 ignore_already_initialized(InitBuilder::new().init())
269}
270
271#[deprecated(
275 since = "1.3.0",
276 note = "use `Options::builder().with_callback(cb).init()`"
277)]
278pub fn init_with_propagation_callback(callback: PropagationCallback) -> Result<()> {
279 ignore_already_initialized(InitBuilder::new().with_callback(callback).init())
280}
281
282#[deprecated(
295 since = "1.3.0",
296 note = "use `Options::builder().with_schemas(s).init()`"
297)]
298pub fn init_with_schemas(schemas: &[(&str, &str)]) -> Result<()> {
299 ignore_already_initialized(InitBuilder::new().with_schemas(schemas).init())
300}
301
302fn ignore_already_initialized(result: Result<()>) -> Result<()> {
304 match result {
305 Err(OptionsError::AlreadyInitialized) => Ok(()),
306 other => other,
307 }
308}
309
310pub fn options(namespace: &str) -> Result<NamespaceOptions> {
314 let opts = GLOBAL_OPTIONS.get().ok_or(OptionsError::NotInitialized)?;
315 Ok(NamespaceOptions {
316 namespace: namespace.to_string(),
317 options: opts,
318 })
319}
320
321pub struct NamespaceOptions {
323 namespace: String,
324 options: &'static Options,
325}
326
327impl NamespaceOptions {
328 pub fn get(&self, key: &str) -> Result<Value> {
330 self.options.get(&self.namespace, key)
331 }
332
333 pub fn get_forced(&self, key: &str) -> Result<Value> {
336 self.options.get_forced(&self.namespace, key)
337 }
338
339 pub fn isset(&self, key: &str) -> Result<bool> {
341 self.options.isset(&self.namespace, key)
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use serde_json::json;
349 use std::fs;
350 use tempfile::TempDir;
351
352 fn create_schema(dir: &Path, namespace: &str, schema: &str) {
353 let schema_dir = dir.join(namespace);
354 fs::create_dir_all(&schema_dir).unwrap();
355 fs::write(schema_dir.join("schema.json"), schema).unwrap();
356 }
357
358 fn create_values(dir: &Path, namespace: &str, values: &str) {
359 let ns_dir = dir.join(namespace);
360 fs::create_dir_all(&ns_dir).unwrap();
361 fs::write(ns_dir.join("values.json"), values).unwrap();
362 }
363
364 #[test]
365 fn test_get_value() {
366 let temp = TempDir::new().unwrap();
367 let schemas = temp.path().join("schemas");
368 let values = temp.path().join("values");
369 fs::create_dir_all(&schemas).unwrap();
370
371 create_schema(
372 &schemas,
373 "test",
374 r#"{
375 "version": "1.0",
376 "type": "object",
377 "properties": {
378 "enabled": {
379 "type": "boolean",
380 "default": false,
381 "description": "Enable feature"
382 }
383 }
384 }"#,
385 );
386 create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
387
388 let options = Options::from_directory(temp.path()).unwrap();
389 assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
390 }
391
392 #[test]
393 fn test_get_default() {
394 let temp = TempDir::new().unwrap();
395 let schemas = temp.path().join("schemas");
396 let values = temp.path().join("values");
397 fs::create_dir_all(&schemas).unwrap();
398 fs::create_dir_all(&values).unwrap();
399
400 create_schema(
401 &schemas,
402 "test",
403 r#"{
404 "version": "1.0",
405 "type": "object",
406 "properties": {
407 "timeout": {
408 "type": "integer",
409 "default": 30,
410 "description": "Timeout"
411 }
412 }
413 }"#,
414 );
415
416 let options = Options::from_directory(temp.path()).unwrap();
417 assert_eq!(options.get("test", "timeout").unwrap(), json!(30));
418 }
419
420 #[test]
421 fn test_unknown_namespace() {
422 let temp = TempDir::new().unwrap();
423 let schemas = temp.path().join("schemas");
424 let values = temp.path().join("values");
425 fs::create_dir_all(&schemas).unwrap();
426 fs::create_dir_all(&values).unwrap();
427
428 create_schema(
429 &schemas,
430 "test",
431 r#"{"version": "1.0", "type": "object", "properties": {}}"#,
432 );
433
434 let options = Options::from_directory(temp.path()).unwrap();
435 assert!(matches!(
436 options.get("unknown", "key"),
437 Err(OptionsError::UnknownNamespace(_))
438 ));
439 }
440
441 #[test]
442 fn test_unknown_option() {
443 let temp = TempDir::new().unwrap();
444 let schemas = temp.path().join("schemas");
445 let values = temp.path().join("values");
446 fs::create_dir_all(&schemas).unwrap();
447 fs::create_dir_all(&values).unwrap();
448
449 create_schema(
450 &schemas,
451 "test",
452 r#"{
453 "version": "1.0",
454 "type": "object",
455 "properties": {
456 "known": {"type": "string", "default": "x", "description": "Known"}
457 }
458 }"#,
459 );
460
461 let options = Options::from_directory(temp.path()).unwrap();
462 assert!(matches!(
463 options.get("test", "unknown"),
464 Err(OptionsError::UnknownOption { .. })
465 ));
466 }
467
468 #[test]
469 fn test_missing_values_dir() {
470 let temp = TempDir::new().unwrap();
471 let schemas = temp.path().join("schemas");
472 fs::create_dir_all(&schemas).unwrap();
473
474 create_schema(
475 &schemas,
476 "test",
477 r#"{
478 "version": "1.0",
479 "type": "object",
480 "properties": {
481 "opt": {"type": "string", "default": "default_val", "description": "Opt"}
482 }
483 }"#,
484 );
485
486 let options = Options::from_directory(temp.path()).unwrap();
487 assert_eq!(options.get("test", "opt").unwrap(), json!("default_val"));
488 }
489
490 #[test]
491 fn isset_with_defined_and_undefined_keys() {
492 let temp = TempDir::new().unwrap();
493 let schemas = temp.path().join("schemas");
494 fs::create_dir_all(&schemas).unwrap();
495
496 let values = temp.path().join("values");
497 create_values(&values, "test", r#"{"options": {"has-value": "yes"}}"#);
498
499 create_schema(
500 &schemas,
501 "test",
502 r##"{
503 "version": "1.0",
504 "type": "object",
505 "properties": {
506 "has-value": {"type": "string", "default": "", "description": ""},
507 "defined-with-default": {"type": "string", "default": "default_val", "description": "Opt"},
508 "feature.organizations:my-flag": {"$ref": "#/definitions/Feature"}
509 }
510 }"##,
511 );
512
513 let options = Options::from_directory(temp.path()).unwrap();
514 assert!(options.isset("test", "not-defined").is_err());
515 assert!(!options.isset("test", "defined-with-default").unwrap());
516 assert!(options.isset("test", "has-value").unwrap());
517 assert!(
519 !options
520 .isset("test", "feature.organizations:my-flag")
521 .unwrap()
522 );
523 }
524
525 #[test]
526 fn test_from_schemas_get_default() {
527 let schema = r#"{
528 "version": "1.0",
529 "type": "object",
530 "properties": {
531 "enabled": {
532 "type": "boolean",
533 "default": false,
534 "description": "Enable feature"
535 }
536 }
537 }"#;
538
539 let registry = SchemaRegistry::from_schemas(&[("test", schema)]).unwrap();
540 let default = registry
541 .get("test")
542 .unwrap()
543 .get_default("enabled")
544 .unwrap();
545 assert_eq!(*default, json!(false));
546 }
547
548 #[test]
549 fn test_from_schemas_with_values() {
550 let temp = TempDir::new().unwrap();
551 let values_dir = temp.path().join("values");
552 create_values(&values_dir, "test", r#"{"options": {"enabled": true}}"#);
553
554 let schema = r#"{
555 "version": "1.0",
556 "type": "object",
557 "properties": {
558 "enabled": {
559 "type": "boolean",
560 "default": false,
561 "description": "Enable feature"
562 }
563 }
564 }"#;
565
566 let registry = Arc::new(SchemaRegistry::from_schemas(&[("test", schema)]).unwrap());
567 let (loaded_values, _) = registry.load_values_json(&values_dir).unwrap();
568 assert_eq!(loaded_values["test"]["enabled"], json!(true));
569 }
570
571 #[test]
572 fn test_from_schemas_invalid_json() {
573 let result = SchemaRegistry::from_schemas(&[("test", "not valid json")]);
574 assert!(result.is_err());
575 }
576
577 const BOOL_SCHEMA: &str = r#"{
578 "version": "1.0",
579 "type": "object",
580 "properties": {
581 "enabled": {"type": "boolean", "default": false, "description": "x"}
582 }
583 }"#;
584
585 #[test]
586 fn builder_with_directory() {
587 let temp = TempDir::new().unwrap();
588 let schemas = temp.path().join("schemas");
589 let values = temp.path().join("values");
590 fs::create_dir_all(&schemas).unwrap();
591 create_schema(&schemas, "test", BOOL_SCHEMA);
592 create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
593
594 let options = Options::builder()
595 .with_directory(temp.path())
597 .build()
598 .unwrap();
599 assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
600 }
601
602 #[test]
603 fn builder_with_schemas() {
604 let options = Options::builder()
605 .with_schemas(&[("test", BOOL_SCHEMA)])
607 .build()
608 .unwrap();
609 assert_eq!(options.get("test", "enabled").unwrap(), json!(false));
610 }
611
612 #[test]
613 fn get_forced_sees_change() {
614 let temp = TempDir::new().unwrap();
615 let schemas = temp.path().join("schemas");
616 let values = temp.path().join("values");
617 fs::create_dir_all(&schemas).unwrap();
618 create_schema(&schemas, "test", BOOL_SCHEMA);
619 create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
620
621 let options = Options::from_directory(temp.path()).unwrap();
622 assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
623
624 create_values(&values, "test", r#"{"options": {"enabled": false}}"#);
625
626 assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
628 assert_eq!(options.get_forced("test", "enabled").unwrap(), json!(false));
630 }
631}