Skip to main content

sentry_options/
lib.rs

1//! Options client for reading validated configuration values.
2
3pub mod features;
4
5pub use features::{FeatureChecker, FeatureContext, features};
6
7use std::collections::HashMap;
8use std::path::Path;
9use std::sync::{Arc, OnceLock, RwLock};
10
11use sentry_options_validation::{
12    SchemaRegistry, ValidationError, ValuesWatcher, 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
38/// Options store for reading configuration values.
39pub struct Options {
40    registry: Arc<SchemaRegistry>,
41    values: Arc<RwLock<HashMap<String, HashMap<String, Value>>>>,
42    _watcher: ValuesWatcher,
43}
44
45impl Options {
46    /// Load options using fallback chain: `SENTRY_OPTIONS_DIR` env var, then `/etc/sentry-options`
47    /// if it exists, otherwise `sentry-options/`.
48    /// Expects `{dir}/schemas/` and `{dir}/values/` subdirectories.
49    pub fn new() -> Result<Self> {
50        Self::from_directory(&resolve_options_dir())
51    }
52
53    /// Load options from a specific directory (useful for testing).
54    /// Expects `{base_dir}/schemas/` and `{base_dir}/values/` subdirectories.
55    pub fn from_directory(base_dir: &Path) -> Result<Self> {
56        let registry = SchemaRegistry::from_directory(&base_dir.join("schemas"))?;
57        Self::with_registry_and_values(registry, &base_dir.join("values"))
58    }
59
60    /// Load options with schemas provided as in-memory JSON strings.
61    /// Values are loaded from disk using the standard fallback chain.
62    pub fn from_schemas(schemas: &[(&str, &str)]) -> Result<Self> {
63        let registry = SchemaRegistry::from_schemas(schemas)?;
64        Self::with_registry_and_values(registry, &resolve_options_dir().join("values"))
65    }
66
67    fn with_registry_and_values(registry: SchemaRegistry, values_dir: &Path) -> Result<Self> {
68        let registry = Arc::new(registry);
69        let (loaded_values, _) = registry.load_values_json(values_dir)?;
70        let values = Arc::new(RwLock::new(loaded_values));
71        let watcher = ValuesWatcher::new(values_dir, Arc::clone(&registry), Arc::clone(&values))?;
72        Ok(Self {
73            registry,
74            values,
75            _watcher: watcher,
76        })
77    }
78
79    /// Get an option value, returning the schema default if not set.
80    pub fn get(&self, namespace: &str, key: &str) -> Result<Value> {
81        if let Some(value) = testing::get_override(namespace, key) {
82            return Ok(value);
83        }
84
85        let schema = self
86            .registry
87            .get(namespace)
88            .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
89
90        let values_guard = self
91            .values
92            .read()
93            .unwrap_or_else(|poisoned| poisoned.into_inner());
94        if let Some(ns_values) = values_guard.get(namespace)
95            && let Some(value) = ns_values.get(key)
96        {
97            return Ok(value.clone());
98        }
99
100        let default = schema
101            .get_default(key)
102            .ok_or_else(|| OptionsError::UnknownOption {
103                namespace: namespace.to_string(),
104                key: key.to_string(),
105            })?;
106
107        Ok(default.clone())
108    }
109
110    /// Validate that a key exists in the schema and the value matches the expected type.
111    pub fn validate_override(&self, namespace: &str, key: &str, value: &Value) -> Result<()> {
112        let schema = self
113            .registry
114            .get(namespace)
115            .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
116
117        schema.validate_option(key, value)?;
118
119        Ok(())
120    }
121    /// Check if an option has a value.
122    ///
123    /// Returns true if the option is defined and has a value, will return
124    /// false if the option is defined and does not have a value.
125    ///
126    /// If the namespace or option are not defined, an Err will be returned.
127    pub fn isset(&self, namespace: &str, key: &str) -> Result<bool> {
128        let schema = self
129            .registry
130            .get(namespace)
131            .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
132
133        if !schema.options.contains_key(key) {
134            return Err(OptionsError::UnknownOption {
135                namespace: namespace.into(),
136                key: key.into(),
137            });
138        }
139
140        let values_guard = self
141            .values
142            .read()
143            .unwrap_or_else(|poisoned| poisoned.into_inner());
144
145        if let Some(ns_values) = values_guard.get(namespace) {
146            Ok(ns_values.contains_key(key))
147        } else {
148            Ok(false)
149        }
150    }
151}
152
153/// Initialize global options using fallback chain: `SENTRY_OPTIONS_DIR` env var,
154/// then `/etc/sentry-options` if it exists, otherwise `sentry-options/`.
155///
156/// Idempotent: if already initialized, returns `Ok(())` without re-loading.
157pub fn init() -> Result<()> {
158    if GLOBAL_OPTIONS.get().is_some() {
159        return Ok(());
160    }
161    let opts = Options::new()?;
162    let _ = GLOBAL_OPTIONS.set(opts);
163    Ok(())
164}
165
166/// Initialize global options with schemas provided as in-memory JSON strings.
167/// Values are loaded from disk using the standard fallback chain.
168///
169/// Idempotent: if already initialized (by `init()` or a prior `init_with_schemas()`),
170/// returns `Ok(())` without updating schemas.
171///
172/// Use this when schemas are embedded in the binary via `include_str!`:
173/// ```rust,ignore
174/// init_with_schemas(&[
175///     ("snuba", include_str!("sentry-options/schemas/snuba/schema.json")),
176/// ])?;
177/// ```
178pub fn init_with_schemas(schemas: &[(&str, &str)]) -> Result<()> {
179    if GLOBAL_OPTIONS.get().is_some() {
180        return Ok(());
181    }
182    let opts = Options::from_schemas(schemas)?;
183    let _ = GLOBAL_OPTIONS.set(opts);
184    Ok(())
185}
186
187/// Get a namespace handle for accessing options.
188///
189/// Returns an error if `init()` has not been called.
190pub fn options(namespace: &str) -> Result<NamespaceOptions> {
191    let opts = GLOBAL_OPTIONS.get().ok_or(OptionsError::NotInitialized)?;
192    Ok(NamespaceOptions {
193        namespace: namespace.to_string(),
194        options: opts,
195    })
196}
197
198/// Handle for accessing options within a specific namespace.
199pub struct NamespaceOptions {
200    namespace: String,
201    options: &'static Options,
202}
203
204impl NamespaceOptions {
205    /// Get an option value, returning the schema default if not set.
206    pub fn get(&self, key: &str) -> Result<Value> {
207        self.options.get(&self.namespace, key)
208    }
209
210    /// Check if an option has a key defined, or if the default is being used.
211    pub fn isset(&self, key: &str) -> Result<bool> {
212        self.options.isset(&self.namespace, key)
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use serde_json::json;
220    use std::fs;
221    use tempfile::TempDir;
222
223    fn create_schema(dir: &Path, namespace: &str, schema: &str) {
224        let schema_dir = dir.join(namespace);
225        fs::create_dir_all(&schema_dir).unwrap();
226        fs::write(schema_dir.join("schema.json"), schema).unwrap();
227    }
228
229    fn create_values(dir: &Path, namespace: &str, values: &str) {
230        let ns_dir = dir.join(namespace);
231        fs::create_dir_all(&ns_dir).unwrap();
232        fs::write(ns_dir.join("values.json"), values).unwrap();
233    }
234
235    #[test]
236    fn test_get_value() {
237        let temp = TempDir::new().unwrap();
238        let schemas = temp.path().join("schemas");
239        let values = temp.path().join("values");
240        fs::create_dir_all(&schemas).unwrap();
241
242        create_schema(
243            &schemas,
244            "test",
245            r#"{
246                "version": "1.0",
247                "type": "object",
248                "properties": {
249                    "enabled": {
250                        "type": "boolean",
251                        "default": false,
252                        "description": "Enable feature"
253                    }
254                }
255            }"#,
256        );
257        create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
258
259        let options = Options::from_directory(temp.path()).unwrap();
260        assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
261    }
262
263    #[test]
264    fn test_get_default() {
265        let temp = TempDir::new().unwrap();
266        let schemas = temp.path().join("schemas");
267        let values = temp.path().join("values");
268        fs::create_dir_all(&schemas).unwrap();
269        fs::create_dir_all(&values).unwrap();
270
271        create_schema(
272            &schemas,
273            "test",
274            r#"{
275                "version": "1.0",
276                "type": "object",
277                "properties": {
278                    "timeout": {
279                        "type": "integer",
280                        "default": 30,
281                        "description": "Timeout"
282                    }
283                }
284            }"#,
285        );
286
287        let options = Options::from_directory(temp.path()).unwrap();
288        assert_eq!(options.get("test", "timeout").unwrap(), json!(30));
289    }
290
291    #[test]
292    fn test_unknown_namespace() {
293        let temp = TempDir::new().unwrap();
294        let schemas = temp.path().join("schemas");
295        let values = temp.path().join("values");
296        fs::create_dir_all(&schemas).unwrap();
297        fs::create_dir_all(&values).unwrap();
298
299        create_schema(
300            &schemas,
301            "test",
302            r#"{"version": "1.0", "type": "object", "properties": {}}"#,
303        );
304
305        let options = Options::from_directory(temp.path()).unwrap();
306        assert!(matches!(
307            options.get("unknown", "key"),
308            Err(OptionsError::UnknownNamespace(_))
309        ));
310    }
311
312    #[test]
313    fn test_unknown_option() {
314        let temp = TempDir::new().unwrap();
315        let schemas = temp.path().join("schemas");
316        let values = temp.path().join("values");
317        fs::create_dir_all(&schemas).unwrap();
318        fs::create_dir_all(&values).unwrap();
319
320        create_schema(
321            &schemas,
322            "test",
323            r#"{
324                "version": "1.0",
325                "type": "object",
326                "properties": {
327                    "known": {"type": "string", "default": "x", "description": "Known"}
328                }
329            }"#,
330        );
331
332        let options = Options::from_directory(temp.path()).unwrap();
333        assert!(matches!(
334            options.get("test", "unknown"),
335            Err(OptionsError::UnknownOption { .. })
336        ));
337    }
338
339    #[test]
340    fn test_missing_values_dir() {
341        let temp = TempDir::new().unwrap();
342        let schemas = temp.path().join("schemas");
343        fs::create_dir_all(&schemas).unwrap();
344
345        create_schema(
346            &schemas,
347            "test",
348            r#"{
349                "version": "1.0",
350                "type": "object",
351                "properties": {
352                    "opt": {"type": "string", "default": "default_val", "description": "Opt"}
353                }
354            }"#,
355        );
356
357        let options = Options::from_directory(temp.path()).unwrap();
358        assert_eq!(options.get("test", "opt").unwrap(), json!("default_val"));
359    }
360
361    #[test]
362    fn isset_with_defined_and_undefined_keys() {
363        let temp = TempDir::new().unwrap();
364        let schemas = temp.path().join("schemas");
365        fs::create_dir_all(&schemas).unwrap();
366
367        let values = temp.path().join("values");
368        create_values(&values, "test", r#"{"options": {"has-value": "yes"}}"#);
369
370        create_schema(
371            &schemas,
372            "test",
373            r#"{
374                "version": "1.0",
375                "type": "object",
376                "properties": {
377                    "has-value": {"type": "string", "default": "", "description": ""},
378                    "defined-with-default": {"type": "string", "default": "default_val", "description": "Opt"}
379                }
380            }"#,
381        );
382
383        let options = Options::from_directory(temp.path()).unwrap();
384        assert!(options.isset("test", "not-defined").is_err());
385        assert!(!options.isset("test", "defined-with-default").unwrap());
386        assert!(options.isset("test", "has-value").unwrap());
387    }
388
389    #[test]
390    fn test_from_schemas_get_default() {
391        let schema = r#"{
392            "version": "1.0",
393            "type": "object",
394            "properties": {
395                "enabled": {
396                    "type": "boolean",
397                    "default": false,
398                    "description": "Enable feature"
399                }
400            }
401        }"#;
402
403        let registry = SchemaRegistry::from_schemas(&[("test", schema)]).unwrap();
404        let default = registry
405            .get("test")
406            .unwrap()
407            .get_default("enabled")
408            .unwrap();
409        assert_eq!(*default, json!(false));
410    }
411
412    #[test]
413    fn test_from_schemas_with_values() {
414        let temp = TempDir::new().unwrap();
415        let values_dir = temp.path().join("values");
416        create_values(&values_dir, "test", r#"{"options": {"enabled": true}}"#);
417
418        let schema = r#"{
419            "version": "1.0",
420            "type": "object",
421            "properties": {
422                "enabled": {
423                    "type": "boolean",
424                    "default": false,
425                    "description": "Enable feature"
426                }
427            }
428        }"#;
429
430        let registry = Arc::new(SchemaRegistry::from_schemas(&[("test", schema)]).unwrap());
431        let (loaded_values, _) = registry.load_values_json(&values_dir).unwrap();
432        assert_eq!(loaded_values["test"]["enabled"], json!(true));
433    }
434
435    #[test]
436    fn test_from_schemas_invalid_json() {
437        let result = SchemaRegistry::from_schemas(&[("test", "not valid json")]);
438        assert!(result.is_err());
439    }
440}