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