Skip to main content

sentry_options/
lib.rs

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