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 new() -> Result<Self> {
51 Self::from_directory(&resolve_options_dir())
52 }
53
54 pub fn new_with_propagation_callback(callback: PropagationCallback) -> Result<Self> {
57 let dir = resolve_options_dir();
58 let registry = SchemaRegistry::from_directory(&dir.join("schemas"))?;
59 Self::with_registry_and_values(registry, &dir.join("values"), Some(callback))
60 }
61
62 pub fn from_directory(base_dir: &Path) -> Result<Self> {
65 let registry = SchemaRegistry::from_directory(&base_dir.join("schemas"))?;
66 Self::with_registry_and_values(registry, &base_dir.join("values"), None)
67 }
68
69 pub fn from_schemas(schemas: &[(&str, &str)]) -> Result<Self> {
72 let registry = SchemaRegistry::from_schemas(schemas)?;
73 Self::with_registry_and_values(registry, &resolve_options_dir().join("values"), None)
74 }
75
76 fn with_registry_and_values(
77 registry: SchemaRegistry,
78 values_dir: &Path,
79 callback: Option<PropagationCallback>,
80 ) -> Result<Self> {
81 let registry = Arc::new(registry);
82 let store = match callback {
83 Some(cb) => ValuesStore::with_propagation_callback(registry, values_dir, cb)?,
84 None => ValuesStore::new(registry, values_dir)?,
85 };
86 Ok(Self { store })
87 }
88
89 pub fn get(&self, namespace: &str, key: &str) -> Result<Value> {
91 self.get_inner(namespace, key, false)
92 }
93
94 pub fn get_forced(&self, namespace: &str, key: &str) -> Result<Value> {
97 self.get_inner(namespace, key, true)
98 }
99
100 fn get_inner(&self, namespace: &str, key: &str, force_reload: bool) -> Result<Value> {
101 if let Some(value) = testing::get_override(namespace, key) {
102 return Ok(value);
103 }
104
105 let schema = self
106 .store
107 .registry()
108 .get(namespace)
109 .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
110
111 let values_guard = if force_reload {
112 self.store.force_load()
113 } else {
114 self.store.load()
115 };
116
117 if let Some(ns_values) = values_guard.get(namespace)
118 && let Some(value) = ns_values.get(key)
119 {
120 return Ok(value.clone());
121 }
122
123 let default = schema
124 .get_default(key)
125 .ok_or_else(|| OptionsError::UnknownOption {
126 namespace: namespace.to_string(),
127 key: key.to_string(),
128 })?;
129
130 Ok(default.clone())
131 }
132
133 pub fn validate_override(&self, namespace: &str, key: &str, value: &Value) -> Result<()> {
135 let schema = self
136 .store
137 .registry()
138 .get(namespace)
139 .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
140
141 schema.validate_option(key, value)?;
142
143 Ok(())
144 }
145
146 pub fn isset(&self, namespace: &str, key: &str) -> Result<bool> {
153 let schema = self
154 .store
155 .registry()
156 .get(namespace)
157 .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
158
159 if !schema.is_known_key(key) {
160 return Err(OptionsError::UnknownOption {
161 namespace: namespace.into(),
162 key: key.into(),
163 });
164 }
165
166 let values_guard = self.store.load();
167 if let Some(ns_values) = values_guard.get(namespace) {
168 Ok(ns_values.contains_key(key))
169 } else {
170 Ok(false)
171 }
172 }
173}
174
175#[derive(Default)]
184pub struct InitBuilder<'a> {
185 directory: Option<PathBuf>,
186 schemas: Option<&'a [(&'a str, &'a str)]>,
187 callback: Option<PropagationCallback>,
188}
189
190impl<'a> InitBuilder<'a> {
191 pub fn new() -> Self {
192 InitBuilder::default()
193 }
194
195 pub fn with_directory(mut self, directory: impl Into<PathBuf>) -> Self {
198 self.directory = Some(directory.into());
199 self
200 }
201
202 pub fn with_schemas(mut self, schemas: &'a [(&'a str, &'a str)]) -> Self {
205 self.schemas = Some(schemas);
206 self
207 }
208
209 pub fn with_callback(mut self, callback: PropagationCallback) -> Self {
212 self.callback = Some(callback);
213 self
214 }
215
216 pub fn init(self) -> Result<()> {
222 if GLOBAL_OPTIONS.get().is_some() {
223 return Err(OptionsError::AlreadyInitialized);
224 }
225 GLOBAL_OPTIONS
226 .set(self.build_options()?)
227 .map_err(|_| OptionsError::AlreadyInitialized)
228 }
229
230 fn build_options(self) -> Result<Options> {
233 let dir = self.directory.unwrap_or_else(resolve_options_dir);
234
235 let registry = match self.schemas {
236 Some(s) => SchemaRegistry::from_schemas(s)?,
237 None => SchemaRegistry::from_directory(&dir.join("schemas"))?,
238 };
239
240 Options::with_registry_and_values(registry, &dir.join("values"), self.callback)
241 }
242}
243
244pub fn init() -> Result<()> {
251 ignore_already_initialized(InitBuilder::new().init())
252}
253
254#[deprecated(
258 since = "1.3.0",
259 note = "use `InitBuilder::new().with_callback(cb).init()`"
260)]
261pub fn init_with_propagation_callback(callback: PropagationCallback) -> Result<()> {
262 ignore_already_initialized(InitBuilder::new().with_callback(callback).init())
263}
264
265#[deprecated(
278 since = "1.3.0",
279 note = "use `InitBuilder::new().with_schemas(s).init()`"
280)]
281pub fn init_with_schemas(schemas: &[(&str, &str)]) -> Result<()> {
282 ignore_already_initialized(InitBuilder::new().with_schemas(schemas).init())
283}
284
285fn ignore_already_initialized(result: Result<()>) -> Result<()> {
287 match result {
288 Err(OptionsError::AlreadyInitialized) => Ok(()),
289 other => other,
290 }
291}
292
293pub fn options(namespace: &str) -> Result<NamespaceOptions> {
297 let opts = GLOBAL_OPTIONS.get().ok_or(OptionsError::NotInitialized)?;
298 Ok(NamespaceOptions {
299 namespace: namespace.to_string(),
300 options: opts,
301 })
302}
303
304pub struct NamespaceOptions {
306 namespace: String,
307 options: &'static Options,
308}
309
310impl NamespaceOptions {
311 pub fn get(&self, key: &str) -> Result<Value> {
313 self.options.get(&self.namespace, key)
314 }
315
316 pub fn get_forced(&self, key: &str) -> Result<Value> {
319 self.options.get_forced(&self.namespace, key)
320 }
321
322 pub fn isset(&self, key: &str) -> Result<bool> {
324 self.options.isset(&self.namespace, key)
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use serde_json::json;
332 use std::fs;
333 use tempfile::TempDir;
334
335 fn create_schema(dir: &Path, namespace: &str, schema: &str) {
336 let schema_dir = dir.join(namespace);
337 fs::create_dir_all(&schema_dir).unwrap();
338 fs::write(schema_dir.join("schema.json"), schema).unwrap();
339 }
340
341 fn create_values(dir: &Path, namespace: &str, values: &str) {
342 let ns_dir = dir.join(namespace);
343 fs::create_dir_all(&ns_dir).unwrap();
344 fs::write(ns_dir.join("values.json"), values).unwrap();
345 }
346
347 #[test]
348 fn test_get_value() {
349 let temp = TempDir::new().unwrap();
350 let schemas = temp.path().join("schemas");
351 let values = temp.path().join("values");
352 fs::create_dir_all(&schemas).unwrap();
353
354 create_schema(
355 &schemas,
356 "test",
357 r#"{
358 "version": "1.0",
359 "type": "object",
360 "properties": {
361 "enabled": {
362 "type": "boolean",
363 "default": false,
364 "description": "Enable feature"
365 }
366 }
367 }"#,
368 );
369 create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
370
371 let options = Options::from_directory(temp.path()).unwrap();
372 assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
373 }
374
375 #[test]
376 fn test_get_default() {
377 let temp = TempDir::new().unwrap();
378 let schemas = temp.path().join("schemas");
379 let values = temp.path().join("values");
380 fs::create_dir_all(&schemas).unwrap();
381 fs::create_dir_all(&values).unwrap();
382
383 create_schema(
384 &schemas,
385 "test",
386 r#"{
387 "version": "1.0",
388 "type": "object",
389 "properties": {
390 "timeout": {
391 "type": "integer",
392 "default": 30,
393 "description": "Timeout"
394 }
395 }
396 }"#,
397 );
398
399 let options = Options::from_directory(temp.path()).unwrap();
400 assert_eq!(options.get("test", "timeout").unwrap(), json!(30));
401 }
402
403 #[test]
404 fn test_unknown_namespace() {
405 let temp = TempDir::new().unwrap();
406 let schemas = temp.path().join("schemas");
407 let values = temp.path().join("values");
408 fs::create_dir_all(&schemas).unwrap();
409 fs::create_dir_all(&values).unwrap();
410
411 create_schema(
412 &schemas,
413 "test",
414 r#"{"version": "1.0", "type": "object", "properties": {}}"#,
415 );
416
417 let options = Options::from_directory(temp.path()).unwrap();
418 assert!(matches!(
419 options.get("unknown", "key"),
420 Err(OptionsError::UnknownNamespace(_))
421 ));
422 }
423
424 #[test]
425 fn test_unknown_option() {
426 let temp = TempDir::new().unwrap();
427 let schemas = temp.path().join("schemas");
428 let values = temp.path().join("values");
429 fs::create_dir_all(&schemas).unwrap();
430 fs::create_dir_all(&values).unwrap();
431
432 create_schema(
433 &schemas,
434 "test",
435 r#"{
436 "version": "1.0",
437 "type": "object",
438 "properties": {
439 "known": {"type": "string", "default": "x", "description": "Known"}
440 }
441 }"#,
442 );
443
444 let options = Options::from_directory(temp.path()).unwrap();
445 assert!(matches!(
446 options.get("test", "unknown"),
447 Err(OptionsError::UnknownOption { .. })
448 ));
449 }
450
451 #[test]
452 fn test_missing_values_dir() {
453 let temp = TempDir::new().unwrap();
454 let schemas = temp.path().join("schemas");
455 fs::create_dir_all(&schemas).unwrap();
456
457 create_schema(
458 &schemas,
459 "test",
460 r#"{
461 "version": "1.0",
462 "type": "object",
463 "properties": {
464 "opt": {"type": "string", "default": "default_val", "description": "Opt"}
465 }
466 }"#,
467 );
468
469 let options = Options::from_directory(temp.path()).unwrap();
470 assert_eq!(options.get("test", "opt").unwrap(), json!("default_val"));
471 }
472
473 #[test]
474 fn isset_with_defined_and_undefined_keys() {
475 let temp = TempDir::new().unwrap();
476 let schemas = temp.path().join("schemas");
477 fs::create_dir_all(&schemas).unwrap();
478
479 let values = temp.path().join("values");
480 create_values(&values, "test", r#"{"options": {"has-value": "yes"}}"#);
481
482 create_schema(
483 &schemas,
484 "test",
485 r##"{
486 "version": "1.0",
487 "type": "object",
488 "properties": {
489 "has-value": {"type": "string", "default": "", "description": ""},
490 "defined-with-default": {"type": "string", "default": "default_val", "description": "Opt"},
491 "feature.organizations:my-flag": {"$ref": "#/definitions/Feature"}
492 }
493 }"##,
494 );
495
496 let options = Options::from_directory(temp.path()).unwrap();
497 assert!(options.isset("test", "not-defined").is_err());
498 assert!(!options.isset("test", "defined-with-default").unwrap());
499 assert!(options.isset("test", "has-value").unwrap());
500 assert!(
502 !options
503 .isset("test", "feature.organizations:my-flag")
504 .unwrap()
505 );
506 }
507
508 #[test]
509 fn test_from_schemas_get_default() {
510 let schema = r#"{
511 "version": "1.0",
512 "type": "object",
513 "properties": {
514 "enabled": {
515 "type": "boolean",
516 "default": false,
517 "description": "Enable feature"
518 }
519 }
520 }"#;
521
522 let registry = SchemaRegistry::from_schemas(&[("test", schema)]).unwrap();
523 let default = registry
524 .get("test")
525 .unwrap()
526 .get_default("enabled")
527 .unwrap();
528 assert_eq!(*default, json!(false));
529 }
530
531 #[test]
532 fn test_from_schemas_with_values() {
533 let temp = TempDir::new().unwrap();
534 let values_dir = temp.path().join("values");
535 create_values(&values_dir, "test", r#"{"options": {"enabled": true}}"#);
536
537 let schema = r#"{
538 "version": "1.0",
539 "type": "object",
540 "properties": {
541 "enabled": {
542 "type": "boolean",
543 "default": false,
544 "description": "Enable feature"
545 }
546 }
547 }"#;
548
549 let registry = Arc::new(SchemaRegistry::from_schemas(&[("test", schema)]).unwrap());
550 let (loaded_values, _) = registry.load_values_json(&values_dir).unwrap();
551 assert_eq!(loaded_values["test"]["enabled"], json!(true));
552 }
553
554 #[test]
555 fn test_from_schemas_invalid_json() {
556 let result = SchemaRegistry::from_schemas(&[("test", "not valid json")]);
557 assert!(result.is_err());
558 }
559
560 const BOOL_SCHEMA: &str = r#"{
561 "version": "1.0",
562 "type": "object",
563 "properties": {
564 "enabled": {"type": "boolean", "default": false, "description": "x"}
565 }
566 }"#;
567
568 #[test]
569 fn builder_with_directory() {
570 let temp = TempDir::new().unwrap();
571 let schemas = temp.path().join("schemas");
572 let values = temp.path().join("values");
573 fs::create_dir_all(&schemas).unwrap();
574 create_schema(&schemas, "test", BOOL_SCHEMA);
575 create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
576
577 let options = InitBuilder::new()
578 .with_directory(temp.path())
580 .build_options()
581 .unwrap();
582 assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
583 }
584
585 #[test]
586 fn builder_with_schemas() {
587 let options = InitBuilder::new()
588 .with_schemas(&[("test", BOOL_SCHEMA)])
590 .build_options()
591 .unwrap();
592 assert_eq!(options.get("test", "enabled").unwrap(), json!(false));
593 }
594
595 #[test]
596 fn get_forced_sees_change() {
597 let temp = TempDir::new().unwrap();
598 let schemas = temp.path().join("schemas");
599 let values = temp.path().join("values");
600 fs::create_dir_all(&schemas).unwrap();
601 create_schema(&schemas, "test", BOOL_SCHEMA);
602 create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
603
604 let options = Options::from_directory(temp.path()).unwrap();
605 assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
606
607 create_values(&values, "test", r#"{"options": {"enabled": false}}"#);
608
609 assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
611 assert_eq!(options.get_forced("test", "enabled").unwrap(), json!(false));
613 }
614}