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("Unknown namespace: {0}")]
24    UnknownNamespace(String),
25
26    #[error("Unknown option '{key}' in namespace '{namespace}'")]
27    UnknownOption { namespace: String, key: String },
28
29    #[error("Schema error: {0}")]
30    Schema(#[from] ValidationError),
31}
32
33pub type Result<T> = std::result::Result<T, OptionsError>;
34
35/// Options store for reading configuration values.
36pub struct Options {
37    registry: Arc<SchemaRegistry>,
38    values: Arc<RwLock<HashMap<String, HashMap<String, Value>>>>,
39    _watcher: ValuesWatcher,
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 schemas_dir = base_dir.join("schemas");
54        let values_dir = base_dir.join("values");
55
56        let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir)?);
57        let (loaded_values, _) = registry.load_values_json(&values_dir)?;
58        let values = Arc::new(RwLock::new(loaded_values));
59
60        let watcher_registry = Arc::clone(&registry);
61        let watcher_values = Arc::clone(&values);
62        // will automatically stop thread when dropped out of scope
63        let watcher = ValuesWatcher::new(values_dir.as_path(), watcher_registry, watcher_values)?;
64
65        Ok(Self {
66            registry,
67            values,
68            _watcher: watcher,
69        })
70    }
71
72    /// Get an option value, returning the schema default if not set.
73    pub fn get(&self, namespace: &str, key: &str) -> Result<Value> {
74        if let Some(value) = testing::get_override(namespace, key) {
75            return Ok(value);
76        }
77
78        let schema = self
79            .registry
80            .get(namespace)
81            .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
82
83        let values_guard = self
84            .values
85            .read()
86            .unwrap_or_else(|poisoned| poisoned.into_inner());
87        if let Some(ns_values) = values_guard.get(namespace)
88            && let Some(value) = ns_values.get(key)
89        {
90            return Ok(value.clone());
91        }
92
93        let default = schema
94            .get_default(key)
95            .ok_or_else(|| OptionsError::UnknownOption {
96                namespace: namespace.to_string(),
97                key: key.to_string(),
98            })?;
99
100        Ok(default.clone())
101    }
102
103    /// Validate that a key exists in the schema and the value matches the expected type.
104    pub fn validate_override(&self, namespace: &str, key: &str, value: &Value) -> Result<()> {
105        let schema = self
106            .registry
107            .get(namespace)
108            .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
109
110        schema.validate_option(key, value)?;
111
112        Ok(())
113    }
114    /// Check if an option has a value.
115    ///
116    /// Returns true if the option is defined and has a value, will return
117    /// false if the option is defined and does not have a value.
118    ///
119    /// If the namespace or option are not defined, an Err will be returned.
120    pub fn isset(&self, namespace: &str, key: &str) -> Result<bool> {
121        let schema = self
122            .registry
123            .get(namespace)
124            .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
125
126        if !schema.options.contains_key(key) {
127            return Err(OptionsError::UnknownOption {
128                namespace: namespace.into(),
129                key: key.into(),
130            });
131        }
132
133        let values_guard = self
134            .values
135            .read()
136            .unwrap_or_else(|poisoned| poisoned.into_inner());
137
138        if let Some(ns_values) = values_guard.get(namespace) {
139            Ok(ns_values.contains_key(key))
140        } else {
141            Ok(false)
142        }
143    }
144}
145
146/// Initialize global options using fallback chain: `SENTRY_OPTIONS_DIR` env var,
147/// then `/etc/sentry-options` if it exists, otherwise `sentry-options/`.
148pub fn init() -> Result<()> {
149    if GLOBAL_OPTIONS.get().is_some() {
150        return Ok(());
151    }
152    let opts = Options::new()?;
153    let _ = GLOBAL_OPTIONS.set(opts);
154    Ok(())
155}
156
157/// Get a namespace handle for accessing options.
158///
159/// Panics if `init()` has not been called.
160pub fn options(namespace: &str) -> NamespaceOptions {
161    let opts = GLOBAL_OPTIONS
162        .get()
163        .expect("options not initialized - call init() first");
164    NamespaceOptions {
165        namespace: namespace.to_string(),
166        options: opts,
167    }
168}
169
170/// Handle for accessing options within a specific namespace.
171pub struct NamespaceOptions {
172    namespace: String,
173    options: &'static Options,
174}
175
176impl NamespaceOptions {
177    /// Get an option value, returning the schema default if not set.
178    pub fn get(&self, key: &str) -> Result<Value> {
179        self.options.get(&self.namespace, key)
180    }
181
182    /// Check if an option has a key defined, or if the default is being used.
183    pub fn isset(&self, key: &str) -> Result<bool> {
184        self.options.isset(&self.namespace, key)
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use serde_json::json;
192    use std::fs;
193    use tempfile::TempDir;
194
195    fn create_schema(dir: &Path, namespace: &str, schema: &str) {
196        let schema_dir = dir.join(namespace);
197        fs::create_dir_all(&schema_dir).unwrap();
198        fs::write(schema_dir.join("schema.json"), schema).unwrap();
199    }
200
201    fn create_values(dir: &Path, namespace: &str, values: &str) {
202        let ns_dir = dir.join(namespace);
203        fs::create_dir_all(&ns_dir).unwrap();
204        fs::write(ns_dir.join("values.json"), values).unwrap();
205    }
206
207    #[test]
208    fn test_get_value() {
209        let temp = TempDir::new().unwrap();
210        let schemas = temp.path().join("schemas");
211        let values = temp.path().join("values");
212        fs::create_dir_all(&schemas).unwrap();
213
214        create_schema(
215            &schemas,
216            "test",
217            r#"{
218                "version": "1.0",
219                "type": "object",
220                "properties": {
221                    "enabled": {
222                        "type": "boolean",
223                        "default": false,
224                        "description": "Enable feature"
225                    }
226                }
227            }"#,
228        );
229        create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
230
231        let options = Options::from_directory(temp.path()).unwrap();
232        assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
233    }
234
235    #[test]
236    fn test_get_default() {
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        fs::create_dir_all(&values).unwrap();
242
243        create_schema(
244            &schemas,
245            "test",
246            r#"{
247                "version": "1.0",
248                "type": "object",
249                "properties": {
250                    "timeout": {
251                        "type": "integer",
252                        "default": 30,
253                        "description": "Timeout"
254                    }
255                }
256            }"#,
257        );
258
259        let options = Options::from_directory(temp.path()).unwrap();
260        assert_eq!(options.get("test", "timeout").unwrap(), json!(30));
261    }
262
263    #[test]
264    fn test_unknown_namespace() {
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#"{"version": "1.0", "type": "object", "properties": {}}"#,
275        );
276
277        let options = Options::from_directory(temp.path()).unwrap();
278        assert!(matches!(
279            options.get("unknown", "key"),
280            Err(OptionsError::UnknownNamespace(_))
281        ));
282    }
283
284    #[test]
285    fn test_unknown_option() {
286        let temp = TempDir::new().unwrap();
287        let schemas = temp.path().join("schemas");
288        let values = temp.path().join("values");
289        fs::create_dir_all(&schemas).unwrap();
290        fs::create_dir_all(&values).unwrap();
291
292        create_schema(
293            &schemas,
294            "test",
295            r#"{
296                "version": "1.0",
297                "type": "object",
298                "properties": {
299                    "known": {"type": "string", "default": "x", "description": "Known"}
300                }
301            }"#,
302        );
303
304        let options = Options::from_directory(temp.path()).unwrap();
305        assert!(matches!(
306            options.get("test", "unknown"),
307            Err(OptionsError::UnknownOption { .. })
308        ));
309    }
310
311    #[test]
312    fn test_missing_values_dir() {
313        let temp = TempDir::new().unwrap();
314        let schemas = temp.path().join("schemas");
315        fs::create_dir_all(&schemas).unwrap();
316
317        create_schema(
318            &schemas,
319            "test",
320            r#"{
321                "version": "1.0",
322                "type": "object",
323                "properties": {
324                    "opt": {"type": "string", "default": "default_val", "description": "Opt"}
325                }
326            }"#,
327        );
328
329        let options = Options::from_directory(temp.path()).unwrap();
330        assert_eq!(options.get("test", "opt").unwrap(), json!("default_val"));
331    }
332
333    #[test]
334    fn isset_with_defined_and_undefined_keys() {
335        let temp = TempDir::new().unwrap();
336        let schemas = temp.path().join("schemas");
337        fs::create_dir_all(&schemas).unwrap();
338
339        let values = temp.path().join("values");
340        create_values(&values, "test", r#"{"options": {"has-value": "yes"}}"#);
341
342        create_schema(
343            &schemas,
344            "test",
345            r#"{
346                "version": "1.0",
347                "type": "object",
348                "properties": {
349                    "has-value": {"type": "string", "default": "", "description": ""},
350                    "defined-with-default": {"type": "string", "default": "default_val", "description": "Opt"}
351                }
352            }"#,
353        );
354
355        let options = Options::from_directory(temp.path()).unwrap();
356        assert!(options.isset("test", "not-defined").is_err());
357        assert!(!options.isset("test", "defined-with-default").unwrap());
358        assert!(options.isset("test", "has-value").unwrap());
359    }
360}