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
13static GLOBAL_OPTIONS: OnceLock<Options> = OnceLock::new();
14
15#[derive(Debug, Error)]
16pub enum OptionsError {
17    #[error("Unknown namespace: {0}")]
18    UnknownNamespace(String),
19
20    #[error("Unknown option '{key}' in namespace '{namespace}'")]
21    UnknownOption { namespace: String, key: String },
22
23    #[error("Schema error: {0}")]
24    Schema(#[from] ValidationError),
25
26    #[error("Options already initialized")]
27    AlreadyInitialized,
28}
29
30pub type Result<T> = std::result::Result<T, OptionsError>;
31
32/// Options store for reading configuration values.
33pub struct Options {
34    registry: Arc<SchemaRegistry>,
35    values: Arc<RwLock<HashMap<String, HashMap<String, Value>>>>,
36    _watcher: ValuesWatcher,
37}
38
39impl Options {
40    /// Load options using fallback chain: `SENTRY_OPTIONS_DIR` env var, then `/etc/sentry-options`
41    /// if it exists, otherwise `sentry-options/`.
42    /// Expects `{dir}/schemas/` and `{dir}/values/` subdirectories.
43    pub fn new() -> Result<Self> {
44        Self::from_directory(&resolve_options_dir())
45    }
46
47    /// Load options from a specific directory (useful for testing).
48    /// Expects `{base_dir}/schemas/` and `{base_dir}/values/` subdirectories.
49    pub fn from_directory(base_dir: &Path) -> Result<Self> {
50        let schemas_dir = base_dir.join("schemas");
51        let values_dir = base_dir.join("values");
52
53        let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir)?);
54        let (loaded_values, _) = registry.load_values_json(&values_dir)?;
55        let values = Arc::new(RwLock::new(loaded_values));
56
57        let watcher_registry = Arc::clone(&registry);
58        let watcher_values = Arc::clone(&values);
59        // will automatically stop thread when dropped out of scope
60        let watcher = ValuesWatcher::new(values_dir.as_path(), watcher_registry, watcher_values)?;
61
62        Ok(Self {
63            registry,
64            values,
65            _watcher: watcher,
66        })
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        let schema = self
72            .registry
73            .get(namespace)
74            .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
75
76        let values_guard = self
77            .values
78            .read()
79            .unwrap_or_else(|poisoned| poisoned.into_inner());
80        if let Some(ns_values) = values_guard.get(namespace)
81            && let Some(value) = ns_values.get(key)
82        {
83            return Ok(value.clone());
84        }
85
86        let default = schema
87            .get_default(key)
88            .ok_or_else(|| OptionsError::UnknownOption {
89                namespace: namespace.to_string(),
90                key: key.to_string(),
91            })?;
92
93        Ok(default.clone())
94    }
95}
96
97/// Initialize global options using fallback chain: `SENTRY_OPTIONS_DIR` env var,
98/// then `/etc/sentry-options` if it exists, otherwise `sentry-options/`.
99pub fn init() -> Result<()> {
100    let opts = Options::new()?;
101    GLOBAL_OPTIONS
102        .set(opts)
103        .map_err(|_| OptionsError::AlreadyInitialized)?;
104    Ok(())
105}
106
107/// Get a namespace handle for accessing options.
108///
109/// Panics if `init()` has not been called.
110pub fn options(namespace: &str) -> NamespaceOptions {
111    let opts = GLOBAL_OPTIONS
112        .get()
113        .expect("options not initialized - call init() first");
114    NamespaceOptions {
115        namespace: namespace.to_string(),
116        options: opts,
117    }
118}
119
120/// Handle for accessing options within a specific namespace.
121pub struct NamespaceOptions {
122    namespace: String,
123    options: &'static Options,
124}
125
126impl NamespaceOptions {
127    /// Get an option value, returning the schema default if not set.
128    pub fn get(&self, key: &str) -> Result<Value> {
129        self.options.get(&self.namespace, key)
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use serde_json::json;
137    use std::fs;
138    use tempfile::TempDir;
139
140    fn create_schema(dir: &Path, namespace: &str, schema: &str) {
141        let schema_dir = dir.join(namespace);
142        fs::create_dir_all(&schema_dir).unwrap();
143        fs::write(schema_dir.join("schema.json"), schema).unwrap();
144    }
145
146    fn create_values(dir: &Path, namespace: &str, values: &str) {
147        let ns_dir = dir.join(namespace);
148        fs::create_dir_all(&ns_dir).unwrap();
149        fs::write(ns_dir.join("values.json"), values).unwrap();
150    }
151
152    #[test]
153    fn test_get_value() {
154        let temp = TempDir::new().unwrap();
155        let schemas = temp.path().join("schemas");
156        let values = temp.path().join("values");
157        fs::create_dir_all(&schemas).unwrap();
158
159        create_schema(
160            &schemas,
161            "test",
162            r#"{
163                "version": "1.0",
164                "type": "object",
165                "properties": {
166                    "enabled": {
167                        "type": "boolean",
168                        "default": false,
169                        "description": "Enable feature"
170                    }
171                }
172            }"#,
173        );
174        create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
175
176        let options = Options::from_directory(temp.path()).unwrap();
177        assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
178    }
179
180    #[test]
181    fn test_get_default() {
182        let temp = TempDir::new().unwrap();
183        let schemas = temp.path().join("schemas");
184        let values = temp.path().join("values");
185        fs::create_dir_all(&schemas).unwrap();
186        fs::create_dir_all(&values).unwrap();
187
188        create_schema(
189            &schemas,
190            "test",
191            r#"{
192                "version": "1.0",
193                "type": "object",
194                "properties": {
195                    "timeout": {
196                        "type": "integer",
197                        "default": 30,
198                        "description": "Timeout"
199                    }
200                }
201            }"#,
202        );
203
204        let options = Options::from_directory(temp.path()).unwrap();
205        assert_eq!(options.get("test", "timeout").unwrap(), json!(30));
206    }
207
208    #[test]
209    fn test_unknown_namespace() {
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        fs::create_dir_all(&values).unwrap();
215
216        create_schema(
217            &schemas,
218            "test",
219            r#"{"version": "1.0", "type": "object", "properties": {}}"#,
220        );
221
222        let options = Options::from_directory(temp.path()).unwrap();
223        assert!(matches!(
224            options.get("unknown", "key"),
225            Err(OptionsError::UnknownNamespace(_))
226        ));
227    }
228
229    #[test]
230    fn test_unknown_option() {
231        let temp = TempDir::new().unwrap();
232        let schemas = temp.path().join("schemas");
233        let values = temp.path().join("values");
234        fs::create_dir_all(&schemas).unwrap();
235        fs::create_dir_all(&values).unwrap();
236
237        create_schema(
238            &schemas,
239            "test",
240            r#"{
241                "version": "1.0",
242                "type": "object",
243                "properties": {
244                    "known": {"type": "string", "default": "x", "description": "Known"}
245                }
246            }"#,
247        );
248
249        let options = Options::from_directory(temp.path()).unwrap();
250        assert!(matches!(
251            options.get("test", "unknown"),
252            Err(OptionsError::UnknownOption { .. })
253        ));
254    }
255
256    #[test]
257    fn test_missing_values_dir() {
258        let temp = TempDir::new().unwrap();
259        let schemas = temp.path().join("schemas");
260        fs::create_dir_all(&schemas).unwrap();
261
262        create_schema(
263            &schemas,
264            "test",
265            r#"{
266                "version": "1.0",
267                "type": "object",
268                "properties": {
269                    "opt": {"type": "string", "default": "default_val", "description": "Opt"}
270                }
271            }"#,
272        );
273
274        let options = Options::from_directory(temp.path()).unwrap();
275        assert_eq!(options.get("test", "opt").unwrap(), json!("default_val"));
276    }
277}