1pub mod features;
4
5pub use features::{FeatureChecker, FeatureContext, features};
6
7use std::path::Path;
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("Unknown namespace: {0}")]
27 UnknownNamespace(String),
28
29 #[error("Unknown option '{key}' in namespace '{namespace}'")]
30 UnknownOption { namespace: String, key: String },
31
32 #[error("Schema error: {0}")]
33 Schema(#[from] ValidationError),
34}
35
36pub type Result<T> = std::result::Result<T, OptionsError>;
37
38pub struct Options {
40 store: ValuesStore,
41}
42
43impl Options {
44 pub fn new() -> Result<Self> {
48 Self::from_directory(&resolve_options_dir())
49 }
50
51 pub fn new_with_propagation_callback(callback: PropagationCallback) -> Result<Self> {
54 let dir = resolve_options_dir();
55 let registry = SchemaRegistry::from_directory(&dir.join("schemas"))?;
56 Self::with_registry_and_values(registry, &dir.join("values"), Some(callback))
57 }
58
59 pub fn from_directory(base_dir: &Path) -> Result<Self> {
62 let registry = SchemaRegistry::from_directory(&base_dir.join("schemas"))?;
63 Self::with_registry_and_values(registry, &base_dir.join("values"), None)
64 }
65
66 pub fn from_schemas(schemas: &[(&str, &str)]) -> Result<Self> {
69 let registry = SchemaRegistry::from_schemas(schemas)?;
70 Self::with_registry_and_values(registry, &resolve_options_dir().join("values"), None)
71 }
72
73 fn with_registry_and_values(
74 registry: SchemaRegistry,
75 values_dir: &Path,
76 callback: Option<PropagationCallback>,
77 ) -> Result<Self> {
78 let registry = Arc::new(registry);
79 let store = match callback {
80 Some(cb) => ValuesStore::with_propagation_callback(registry, values_dir, cb)?,
81 None => ValuesStore::new(registry, values_dir)?,
82 };
83 Ok(Self { store })
84 }
85
86 pub fn get(&self, namespace: &str, key: &str) -> Result<Value> {
88 if let Some(value) = testing::get_override(namespace, key) {
89 return Ok(value);
90 }
91
92 let schema = self
93 .store
94 .registry()
95 .get(namespace)
96 .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
97
98 let values_guard = self.store.load();
99 if let Some(ns_values) = values_guard.get(namespace)
100 && let Some(value) = ns_values.get(key)
101 {
102 return Ok(value.clone());
103 }
104
105 let default = schema
106 .get_default(key)
107 .ok_or_else(|| OptionsError::UnknownOption {
108 namespace: namespace.to_string(),
109 key: key.to_string(),
110 })?;
111
112 Ok(default.clone())
113 }
114
115 pub fn validate_override(&self, namespace: &str, key: &str, value: &Value) -> Result<()> {
117 let schema = self
118 .store
119 .registry()
120 .get(namespace)
121 .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
122
123 schema.validate_option(key, value)?;
124
125 Ok(())
126 }
127
128 pub fn isset(&self, namespace: &str, key: &str) -> Result<bool> {
135 let schema = self
136 .store
137 .registry()
138 .get(namespace)
139 .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
140
141 if !schema.options.contains_key(key) {
142 return Err(OptionsError::UnknownOption {
143 namespace: namespace.into(),
144 key: key.into(),
145 });
146 }
147
148 let values_guard = self.store.load();
149 if let Some(ns_values) = values_guard.get(namespace) {
150 Ok(ns_values.contains_key(key))
151 } else {
152 Ok(false)
153 }
154 }
155}
156
157pub fn init() -> Result<()> {
162 if GLOBAL_OPTIONS.get().is_some() {
163 return Ok(());
164 }
165 let opts = Options::new()?;
166 let _ = GLOBAL_OPTIONS.set(opts);
167 Ok(())
168}
169
170pub fn init_with_propagation_callback(callback: PropagationCallback) -> Result<()> {
174 if GLOBAL_OPTIONS.get().is_some() {
175 return Ok(());
176 }
177 let opts = Options::new_with_propagation_callback(callback)?;
178 let _ = GLOBAL_OPTIONS.set(opts);
179 Ok(())
180}
181
182pub fn init_with_schemas(schemas: &[(&str, &str)]) -> Result<()> {
195 if GLOBAL_OPTIONS.get().is_some() {
196 return Ok(());
197 }
198 let opts = Options::from_schemas(schemas)?;
199 let _ = GLOBAL_OPTIONS.set(opts);
200 Ok(())
201}
202
203pub fn options(namespace: &str) -> Result<NamespaceOptions> {
207 let opts = GLOBAL_OPTIONS.get().ok_or(OptionsError::NotInitialized)?;
208 Ok(NamespaceOptions {
209 namespace: namespace.to_string(),
210 options: opts,
211 })
212}
213
214pub struct NamespaceOptions {
216 namespace: String,
217 options: &'static Options,
218}
219
220impl NamespaceOptions {
221 pub fn get(&self, key: &str) -> Result<Value> {
223 self.options.get(&self.namespace, key)
224 }
225
226 pub fn isset(&self, key: &str) -> Result<bool> {
228 self.options.isset(&self.namespace, key)
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use serde_json::json;
236 use std::fs;
237 use tempfile::TempDir;
238
239 fn create_schema(dir: &Path, namespace: &str, schema: &str) {
240 let schema_dir = dir.join(namespace);
241 fs::create_dir_all(&schema_dir).unwrap();
242 fs::write(schema_dir.join("schema.json"), schema).unwrap();
243 }
244
245 fn create_values(dir: &Path, namespace: &str, values: &str) {
246 let ns_dir = dir.join(namespace);
247 fs::create_dir_all(&ns_dir).unwrap();
248 fs::write(ns_dir.join("values.json"), values).unwrap();
249 }
250
251 #[test]
252 fn test_get_value() {
253 let temp = TempDir::new().unwrap();
254 let schemas = temp.path().join("schemas");
255 let values = temp.path().join("values");
256 fs::create_dir_all(&schemas).unwrap();
257
258 create_schema(
259 &schemas,
260 "test",
261 r#"{
262 "version": "1.0",
263 "type": "object",
264 "properties": {
265 "enabled": {
266 "type": "boolean",
267 "default": false,
268 "description": "Enable feature"
269 }
270 }
271 }"#,
272 );
273 create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
274
275 let options = Options::from_directory(temp.path()).unwrap();
276 assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
277 }
278
279 #[test]
280 fn test_get_default() {
281 let temp = TempDir::new().unwrap();
282 let schemas = temp.path().join("schemas");
283 let values = temp.path().join("values");
284 fs::create_dir_all(&schemas).unwrap();
285 fs::create_dir_all(&values).unwrap();
286
287 create_schema(
288 &schemas,
289 "test",
290 r#"{
291 "version": "1.0",
292 "type": "object",
293 "properties": {
294 "timeout": {
295 "type": "integer",
296 "default": 30,
297 "description": "Timeout"
298 }
299 }
300 }"#,
301 );
302
303 let options = Options::from_directory(temp.path()).unwrap();
304 assert_eq!(options.get("test", "timeout").unwrap(), json!(30));
305 }
306
307 #[test]
308 fn test_unknown_namespace() {
309 let temp = TempDir::new().unwrap();
310 let schemas = temp.path().join("schemas");
311 let values = temp.path().join("values");
312 fs::create_dir_all(&schemas).unwrap();
313 fs::create_dir_all(&values).unwrap();
314
315 create_schema(
316 &schemas,
317 "test",
318 r#"{"version": "1.0", "type": "object", "properties": {}}"#,
319 );
320
321 let options = Options::from_directory(temp.path()).unwrap();
322 assert!(matches!(
323 options.get("unknown", "key"),
324 Err(OptionsError::UnknownNamespace(_))
325 ));
326 }
327
328 #[test]
329 fn test_unknown_option() {
330 let temp = TempDir::new().unwrap();
331 let schemas = temp.path().join("schemas");
332 let values = temp.path().join("values");
333 fs::create_dir_all(&schemas).unwrap();
334 fs::create_dir_all(&values).unwrap();
335
336 create_schema(
337 &schemas,
338 "test",
339 r#"{
340 "version": "1.0",
341 "type": "object",
342 "properties": {
343 "known": {"type": "string", "default": "x", "description": "Known"}
344 }
345 }"#,
346 );
347
348 let options = Options::from_directory(temp.path()).unwrap();
349 assert!(matches!(
350 options.get("test", "unknown"),
351 Err(OptionsError::UnknownOption { .. })
352 ));
353 }
354
355 #[test]
356 fn test_missing_values_dir() {
357 let temp = TempDir::new().unwrap();
358 let schemas = temp.path().join("schemas");
359 fs::create_dir_all(&schemas).unwrap();
360
361 create_schema(
362 &schemas,
363 "test",
364 r#"{
365 "version": "1.0",
366 "type": "object",
367 "properties": {
368 "opt": {"type": "string", "default": "default_val", "description": "Opt"}
369 }
370 }"#,
371 );
372
373 let options = Options::from_directory(temp.path()).unwrap();
374 assert_eq!(options.get("test", "opt").unwrap(), json!("default_val"));
375 }
376
377 #[test]
378 fn isset_with_defined_and_undefined_keys() {
379 let temp = TempDir::new().unwrap();
380 let schemas = temp.path().join("schemas");
381 fs::create_dir_all(&schemas).unwrap();
382
383 let values = temp.path().join("values");
384 create_values(&values, "test", r#"{"options": {"has-value": "yes"}}"#);
385
386 create_schema(
387 &schemas,
388 "test",
389 r#"{
390 "version": "1.0",
391 "type": "object",
392 "properties": {
393 "has-value": {"type": "string", "default": "", "description": ""},
394 "defined-with-default": {"type": "string", "default": "default_val", "description": "Opt"}
395 }
396 }"#,
397 );
398
399 let options = Options::from_directory(temp.path()).unwrap();
400 assert!(options.isset("test", "not-defined").is_err());
401 assert!(!options.isset("test", "defined-with-default").unwrap());
402 assert!(options.isset("test", "has-value").unwrap());
403 }
404
405 #[test]
406 fn test_from_schemas_get_default() {
407 let schema = r#"{
408 "version": "1.0",
409 "type": "object",
410 "properties": {
411 "enabled": {
412 "type": "boolean",
413 "default": false,
414 "description": "Enable feature"
415 }
416 }
417 }"#;
418
419 let registry = SchemaRegistry::from_schemas(&[("test", schema)]).unwrap();
420 let default = registry
421 .get("test")
422 .unwrap()
423 .get_default("enabled")
424 .unwrap();
425 assert_eq!(*default, json!(false));
426 }
427
428 #[test]
429 fn test_from_schemas_with_values() {
430 let temp = TempDir::new().unwrap();
431 let values_dir = temp.path().join("values");
432 create_values(&values_dir, "test", r#"{"options": {"enabled": true}}"#);
433
434 let schema = r#"{
435 "version": "1.0",
436 "type": "object",
437 "properties": {
438 "enabled": {
439 "type": "boolean",
440 "default": false,
441 "description": "Enable feature"
442 }
443 }
444 }"#;
445
446 let registry = Arc::new(SchemaRegistry::from_schemas(&[("test", schema)]).unwrap());
447 let (loaded_values, _) = registry.load_values_json(&values_dir).unwrap();
448 assert_eq!(loaded_values["test"]["enabled"], json!(true));
449 }
450
451 #[test]
452 fn test_from_schemas_invalid_json() {
453 let result = SchemaRegistry::from_schemas(&[("test", "not valid json")]);
454 assert!(result.is_err());
455 }
456}