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