1use 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
33pub struct Options {
35 registry: Arc<SchemaRegistry>,
36 values: Arc<RwLock<HashMap<String, HashMap<String, Value>>>>,
37 _watcher: ValuesWatcher,
38}
39
40impl Options {
41 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 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(®istry);
62 let watcher_values = Arc::clone(&values);
63 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 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
101pub fn init() -> Result<()> {
103 let opts = Options::new()?;
104 GLOBAL_OPTIONS
105 .set(opts)
106 .map_err(|_| OptionsError::AlreadyInitialized)?;
107 Ok(())
108}
109
110pub 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
123pub struct NamespaceOptions {
125 namespace: String,
126 options: &'static Options,
127}
128
129impl NamespaceOptions {
130 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}