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
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
32pub struct Options {
34 registry: Arc<SchemaRegistry>,
35 values: Arc<RwLock<HashMap<String, HashMap<String, Value>>>>,
36 _watcher: ValuesWatcher,
37}
38
39impl Options {
40 pub fn new() -> Result<Self> {
44 Self::from_directory(&resolve_options_dir())
45 }
46
47 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(®istry);
58 let watcher_values = Arc::clone(&values);
59 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 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
97pub fn init() -> Result<()> {
100 let opts = Options::new()?;
101 GLOBAL_OPTIONS
102 .set(opts)
103 .map_err(|_| OptionsError::AlreadyInitialized)?;
104 Ok(())
105}
106
107pub 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
120pub struct NamespaceOptions {
122 namespace: String,
123 options: &'static Options,
124}
125
126impl NamespaceOptions {
127 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}