sentry_options/
lib.rs

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