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::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
37/// Options store for reading configuration values.
38pub struct Options {
39    store: ValuesStore,
40}
41
42impl Options {
43    /// Load options using fallback chain: `SENTRY_OPTIONS_DIR` env var, then `/etc/sentry-options`
44    /// if it exists, otherwise `sentry-options/`.
45    /// Expects `{dir}/schemas/` and `{dir}/values/` subdirectories.
46    pub fn new() -> Result<Self> {
47        Self::from_directory(&resolve_options_dir())
48    }
49
50    /// Load options from a specific directory (useful for testing).
51    /// Expects `{base_dir}/schemas/` and `{base_dir}/values/` subdirectories.
52    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    /// Load options with schemas provided as in-memory JSON strings.
58    /// Values are loaded from disk using the standard fallback chain.
59    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    /// Get an option value, returning the schema default if not set.
70    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    /// Validate that a key exists in the schema and the value matches the expected type.
99    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    /// Check if an option has a value.
112    ///
113    /// Returns true if the option is defined and has a value, will return
114    /// false if the option is defined and does not have a value.
115    ///
116    /// If the namespace or option are not defined, an Err will be returned.
117    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
140/// Initialize global options using fallback chain: `SENTRY_OPTIONS_DIR` env var,
141/// then `/etc/sentry-options` if it exists, otherwise `sentry-options/`.
142///
143/// Idempotent: if already initialized, returns `Ok(())` without re-loading.
144pub 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
153/// Initialize global options with schemas provided as in-memory JSON strings.
154/// Values are loaded from disk using the standard fallback chain.
155///
156/// Idempotent: if already initialized (by `init()` or a prior `init_with_schemas()`),
157/// returns `Ok(())` without updating schemas.
158///
159/// Use this when schemas are embedded in the binary via `include_str!`:
160/// ```rust,ignore
161/// init_with_schemas(&[
162///     ("snuba", include_str!("sentry-options/schemas/snuba/schema.json")),
163/// ])?;
164/// ```
165pub 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
174/// Get a namespace handle for accessing options.
175///
176/// Returns an error if `init()` has not been called.
177pub 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
185/// Handle for accessing options within a specific namespace.
186pub struct NamespaceOptions {
187    namespace: String,
188    options: &'static Options,
189}
190
191impl NamespaceOptions {
192    /// Get an option value, returning the schema default if not set.
193    pub fn get(&self, key: &str) -> Result<Value> {
194        self.options.get(&self.namespace, key)
195    }
196
197    /// Check if an option has a key defined, or if the default is being used.
198    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}