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