1use 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
34pub struct Options {
36 registry: Arc<SchemaRegistry>,
37 values: Arc<RwLock<HashMap<String, HashMap<String, Value>>>>,
38 _watcher: ValuesWatcher,
39}
40
41impl Options {
42 pub fn new() -> Result<Self> {
46 Self::from_directory(&resolve_options_dir())
47 }
48
49 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(®istry);
60 let watcher_values = Arc::clone(&values);
61 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 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 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 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
145pub fn init() -> Result<()> {
148 let opts = Options::new()?;
149 GLOBAL_OPTIONS
150 .set(opts)
151 .map_err(|_| OptionsError::AlreadyInitialized)?;
152 Ok(())
153}
154
155pub 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
168pub struct NamespaceOptions {
170 namespace: String,
171 options: &'static Options,
172}
173
174impl NamespaceOptions {
175 pub fn get(&self, key: &str) -> Result<Value> {
177 self.options.get(&self.namespace, key)
178 }
179
180 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}