Skip to main content

sentry_options/
lib.rs

1//! Options client for reading validated configuration values.
2
3pub mod features;
4
5pub use features::{FeatureChecker, FeatureContext, features};
6
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, OnceLock};
9use std::time::Duration;
10
11pub use sentry_options_validation::{
12    DEFAULT_REFRESH_THRESHOLD, PropagationCallback, feature_property,
13};
14use sentry_options_validation::{
15    SchemaRegistry, ValidationError, ValuesStore, resolve_options_dir,
16};
17use serde_json::Value;
18use thiserror::Error;
19
20pub mod testing;
21
22static GLOBAL_OPTIONS: OnceLock<Options> = OnceLock::new();
23
24#[derive(Debug, Error)]
25pub enum OptionsError {
26    #[error("Options not initialized - call init() first")]
27    NotInitialized,
28
29    #[error("Options already initialized")]
30    AlreadyInitialized,
31
32    #[error("Unknown namespace: {0}")]
33    UnknownNamespace(String),
34
35    #[error("Unknown option '{key}' in namespace '{namespace}'")]
36    UnknownOption { namespace: String, key: String },
37
38    #[error("Schema error: {0}")]
39    Schema(#[from] ValidationError),
40}
41
42pub type Result<T> = std::result::Result<T, OptionsError>;
43
44/// Options store for reading configuration values.
45pub struct Options {
46    store: ValuesStore,
47}
48
49impl Options {
50    /// Returns a builder for constructing an [`Options`] instance or
51    /// initializing the global store.
52    ///
53    /// Configure optional parameters with the `with_*` methods, then finalize
54    /// with [`InitBuilder::build`] for a standalone instance or
55    /// [`InitBuilder::init`] for the global store.
56    pub fn builder<'a>() -> InitBuilder<'a> {
57        InitBuilder::new()
58    }
59
60    /// Load options using fallback chain: `SENTRY_OPTIONS_DIR` env var, then `/etc/sentry-options`
61    /// if it exists, otherwise `sentry-options/`.
62    /// Expects `{dir}/schemas/` and `{dir}/values/` subdirectories.
63    pub fn new() -> Result<Self> {
64        Self::from_directory(&resolve_options_dir())
65    }
66
67    /// Like [`new`], but with a propagation callback that fires `(namespace, delay_secs)`
68    /// whenever values are refreshed with a new `generated_at` timestamp.
69    pub fn new_with_propagation_callback(callback: PropagationCallback) -> Result<Self> {
70        let dir = resolve_options_dir();
71        let registry = SchemaRegistry::from_directory(&dir.join("schemas"))?;
72        Self::with_registry_and_values(
73            registry,
74            &dir.join("values"),
75            Some(DEFAULT_REFRESH_THRESHOLD),
76            Some(callback),
77        )
78    }
79
80    /// Load options from a specific directory (useful for testing).
81    /// Expects `{base_dir}/schemas/` and `{base_dir}/values/` subdirectories.
82    pub fn from_directory(base_dir: &Path) -> Result<Self> {
83        let registry = SchemaRegistry::from_directory(&base_dir.join("schemas"))?;
84        Self::with_registry_and_values(
85            registry,
86            &base_dir.join("values"),
87            Some(DEFAULT_REFRESH_THRESHOLD),
88            None,
89        )
90    }
91
92    /// Load options with schemas provided as in-memory JSON strings.
93    /// Values are loaded from disk using the standard fallback chain.
94    pub fn from_schemas(schemas: &[(&str, &str)]) -> Result<Self> {
95        let registry = SchemaRegistry::from_schemas(schemas)?;
96        Self::with_registry_and_values(
97            registry,
98            &resolve_options_dir().join("values"),
99            Some(DEFAULT_REFRESH_THRESHOLD),
100            None,
101        )
102    }
103
104    fn with_registry_and_values(
105        registry: SchemaRegistry,
106        values_dir: &Path,
107        refresh_threshold: Option<Duration>,
108        callback: Option<PropagationCallback>,
109    ) -> Result<Self> {
110        let mut builder = ValuesStore::builder(Arc::new(registry), values_dir)
111            .with_refresh_threshold(refresh_threshold);
112        if let Some(cb) = callback {
113            builder = builder.with_callback(cb);
114        }
115        Ok(Self {
116            store: builder.build()?,
117        })
118    }
119
120    /// Get an option value, returning the schema default if not set.
121    pub fn get(&self, namespace: &str, key: &str) -> Result<Value> {
122        self.get_inner(namespace, key, false)
123    }
124
125    /// Like [`get`](Self::get), but always refreshes. Refresh incurs a cost
126    /// so this should only be used in testing; use [`refresh`](Self::refresh)
127    /// to drive refreshes in production.
128    pub fn get_forced(&self, namespace: &str, key: &str) -> Result<Value> {
129        self.get_inner(namespace, key, true)
130    }
131
132    /// Refreshes values from disk, ignoring the staleness threshold.
133    ///
134    /// Returns whether a new snapshot was published, i.e. the files changed
135    /// on disk (by mtime) and reloaded successfully. On error the previous
136    /// snapshot is retained and served. Callbacks registered with
137    /// [`InitBuilder::with_callback`] fire on the calling thread before this
138    /// returns.
139    ///
140    /// Any call resets the refresh-on-read timer, so calling this more often
141    /// than the staleness threshold guarantees reads never refresh inline.
142    pub fn refresh(&self) -> Result<bool> {
143        Ok(self.store.refresh()?)
144    }
145
146    fn get_inner(&self, namespace: &str, key: &str, force_reload: bool) -> Result<Value> {
147        if let Some(value) = testing::get_override(namespace, key) {
148            return Ok(value);
149        }
150
151        let schema = self
152            .store
153            .registry()
154            .get(namespace)
155            .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
156
157        let values_guard = if force_reload {
158            self.store.force_load()
159        } else {
160            self.store.load()
161        };
162
163        if let Some(ns_values) = values_guard.get(namespace)
164            && let Some(value) = ns_values.get(key)
165        {
166            return Ok(value.clone());
167        }
168
169        let default = schema
170            .get_default(key)
171            .ok_or_else(|| OptionsError::UnknownOption {
172                namespace: namespace.to_string(),
173                key: key.to_string(),
174            })?;
175
176        Ok(default.clone())
177    }
178
179    /// Validate that a key exists in the schema and the value matches the expected type.
180    pub fn validate_override(&self, namespace: &str, key: &str, value: &Value) -> Result<()> {
181        let schema = self
182            .store
183            .registry()
184            .get(namespace)
185            .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
186
187        schema.validate_option(key, value)?;
188
189        Ok(())
190    }
191
192    /// Check if an option has a value.
193    ///
194    /// Returns true if the option is defined and has a value, will return
195    /// false if the option is defined and does not have a value.
196    ///
197    /// If the namespace or option are not defined, an Err will be returned.
198    pub fn isset(&self, namespace: &str, key: &str) -> Result<bool> {
199        let schema = self
200            .store
201            .registry()
202            .get(namespace)
203            .ok_or_else(|| OptionsError::UnknownNamespace(namespace.to_string()))?;
204
205        if !schema.is_known_key(key) {
206            return Err(OptionsError::UnknownOption {
207                namespace: namespace.into(),
208                key: key.into(),
209            });
210        }
211
212        let values_guard = self.store.load();
213        if let Some(ns_values) = values_guard.get(namespace) {
214            Ok(ns_values.contains_key(key))
215        } else {
216            Ok(false)
217        }
218    }
219}
220
221/// Builder for initializing the global options store.
222/// Define optional parameters with `with_*` methods, then finalize with `init()`
223///
224/// All settings are optional and independent:
225/// - `with_directory` overrides the base directory
226/// - `with_schemas` supplies schemas in memory instead of reading `{dir}/schemas/`
227/// - `with_additional_schemas` adds in-memory schemas alongside whichever base the above selects
228/// - `with_callback` registers a callback that fires on every value refresh.
229/// - `with_refresh_threshold` overrides or disables the staleness threshold
230///   for refresh-on-read.
231///
232#[derive(Default)]
233pub struct InitBuilder<'a> {
234    directory: Option<PathBuf>,
235    schemas: Option<&'a [(&'a str, &'a str)]>,
236    additional_schemas: Option<&'a [(&'a str, &'a str)]>,
237    callback: Option<PropagationCallback>,
238    refresh_threshold: Option<Option<Duration>>,
239}
240
241impl<'a> InitBuilder<'a> {
242    pub fn new() -> Self {
243        InitBuilder::default()
244    }
245
246    /// Override the base directory. Expects `{directory}/schemas/` and `{directory}/values/` subdirectories.
247    /// Otherwise, defaults to the fallback chain: `SENTRY_OPTIONS_DIR` env var, then `/etc/sentry-options`
248    pub fn with_directory(mut self, directory: impl Into<PathBuf>) -> Self {
249        self.directory = Some(directory.into());
250        self
251    }
252
253    /// Provide schemas as in-memory `(namespace, json)` pairs instead of reading
254    /// them from `{dir}/schemas/`, intended to be used with `include_str!`.
255    /// In other words, overrides schemas supplied locally.
256    pub fn with_schemas(mut self, schemas: &'a [(&'a str, &'a str)]) -> Self {
257        self.schemas = Some(schemas);
258        self
259    }
260
261    /// Add in-memory `(namespace, json)` schemas alongside the base regardless
262    /// if it came from `{dir}/schemas/` or [`with_schemas`](Self::with_schemas).
263    /// Errors on a namespace the base already has, rather than shadowing it.
264    pub fn with_additional_schemas(mut self, schemas: &'a [(&'a str, &'a str)]) -> Self {
265        self.additional_schemas = Some(schemas);
266        self
267    }
268
269    /// Register a callback that fires `(namespace, delay_secs)` whenever values
270    /// are refreshed with a new `generated_at` timestamp.
271    pub fn with_callback(mut self, callback: impl Fn(&str, f64) + Send + Sync + 'static) -> Self {
272        self.callback = Some(Box::new(callback));
273        self
274    }
275
276    /// Overrides the staleness threshold for refresh-on-read.
277    ///
278    /// Defaults to [`DEFAULT_REFRESH_THRESHOLD`] (5 seconds). Pass `None` to
279    /// disable refresh-on-read entirely; values then only change via
280    /// [`Options::refresh`].
281    pub fn with_refresh_threshold(mut self, threshold: impl Into<Option<Duration>>) -> Self {
282        self.refresh_threshold = Some(threshold.into());
283        self
284    }
285
286    /// Initialize the global options store from the inputs.
287    ///
288    /// Returns [`OptionsError::AlreadyInitialized`] if the store is already
289    /// initialized, leaving the existing configuration untouched. Callers that
290    /// want a no-op in that case can ignore the error with `.ok()`.
291    pub fn init(self) -> Result<()> {
292        if GLOBAL_OPTIONS.get().is_some() {
293            return Err(OptionsError::AlreadyInitialized);
294        }
295        GLOBAL_OPTIONS
296            .set(self.build()?)
297            .map_err(|_| OptionsError::AlreadyInitialized)
298    }
299
300    /// Builds an [`Options`] instance from the inputs, without initializing
301    /// the global store.
302    ///
303    /// Use this when you want to manage the lifetime of the options store yourself, or
304    /// when you want to have multiple independent options stores in the same process.
305    ///
306    /// Use [`init`] if you want to initialize the global store.
307    ///
308    /// Returns an error if the schemas are invalid or the values cannot be loaded.
309    pub fn build(self) -> Result<Options> {
310        let dir = self.directory.unwrap_or_else(resolve_options_dir);
311
312        // `with_schemas` picks the base, `with_additional_schemas` adds to it.
313        let mut registry = match self.schemas {
314            Some(s) => SchemaRegistry::from_schemas(s)?,
315            None => {
316                let schemas_dir = dir.join("schemas");
317                // A missing dir is only tolerable when in-memory schemas will fill
318                // the registry; otherwise the caller ends up with nothing.
319                if self.additional_schemas.is_some() && !schemas_dir.exists() {
320                    SchemaRegistry::new()
321                } else {
322                    SchemaRegistry::from_directory(&schemas_dir)?
323                }
324            }
325        };
326        if let Some(additional) = self.additional_schemas {
327            registry.add_schemas(additional)?;
328        }
329
330        let refresh_threshold = self
331            .refresh_threshold
332            .unwrap_or(Some(DEFAULT_REFRESH_THRESHOLD));
333        Options::with_registry_and_values(
334            registry,
335            &dir.join("values"),
336            refresh_threshold,
337            self.callback,
338        )
339    }
340}
341
342/// Initialize global options using the fallback chain: `SENTRY_OPTIONS_DIR` env
343/// var, then `/etc/sentry-options` if it exists, otherwise `sentry-options/`.
344///
345/// Shorthand for `Options::builder().init()`. Idempotent: if already
346/// initialized, returns `Ok(())` without re-loading. For directory, schema, or
347/// callback overrides, use [`Options::builder`].
348pub fn init() -> Result<()> {
349    ignore_already_initialized(InitBuilder::new().init())
350}
351
352/// Like [`init`], but with a callback that fires whenever values are refreshed
353/// from disk with a new `generated_at` timestamp. The callback receives
354/// `(namespace, delay_secs)`.
355#[deprecated(
356    since = "1.3.0",
357    note = "use `Options::builder().with_callback(cb).init()`"
358)]
359pub fn init_with_propagation_callback(callback: PropagationCallback) -> Result<()> {
360    ignore_already_initialized(InitBuilder::new().with_callback(callback).init())
361}
362
363/// Initialize global options with schemas provided as in-memory JSON strings.
364/// Values are loaded from disk using the standard fallback chain.
365///
366/// Idempotent: if already initialized (by `init()` or a prior `init_with_schemas()`),
367/// returns `Ok(())` without updating schemas.
368///
369/// Use this when schemas are embedded in the binary via `include_str!`:
370/// ```rust,ignore
371/// init_with_schemas(&[
372///     ("snuba", include_str!("sentry-options/schemas/snuba/schema.json")),
373/// ])?;
374/// ```
375#[deprecated(
376    since = "1.3.0",
377    note = "use `Options::builder().with_schemas(s).init()`"
378)]
379pub fn init_with_schemas(schemas: &[(&str, &str)]) -> Result<()> {
380    ignore_already_initialized(InitBuilder::new().with_schemas(schemas).init())
381}
382
383/// legacy, deprecated system was idempotent, this preserves the behaviour
384fn ignore_already_initialized(result: Result<()>) -> Result<()> {
385    match result {
386        Err(OptionsError::AlreadyInitialized) => Ok(()),
387        other => other,
388    }
389}
390
391/// Get a namespace handle for accessing options.
392///
393/// Returns an error if `init()` has not been called.
394pub fn options(namespace: &str) -> Result<NamespaceOptions> {
395    let opts = GLOBAL_OPTIONS.get().ok_or(OptionsError::NotInitialized)?;
396    Ok(NamespaceOptions {
397        namespace: namespace.to_string(),
398        options: opts,
399    })
400}
401
402/// Handle for accessing options within a specific namespace.
403pub struct NamespaceOptions {
404    namespace: String,
405    options: &'static Options,
406}
407
408impl NamespaceOptions {
409    /// Get an option value, returning the schema default if not set.
410    pub fn get(&self, key: &str) -> Result<Value> {
411        self.options.get(&self.namespace, key)
412    }
413
414    /// Like [`get`](Self::get), but always refreshes. Refresh incurs a cost
415    /// so this should only be used in testing.
416    pub fn get_forced(&self, key: &str) -> Result<Value> {
417        self.options.get_forced(&self.namespace, key)
418    }
419
420    /// Check if an option has a key defined, or if the default is being used.
421    pub fn isset(&self, key: &str) -> Result<bool> {
422        self.options.isset(&self.namespace, key)
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use serde_json::json;
430    use std::fs;
431    use tempfile::TempDir;
432
433    fn create_schema(dir: &Path, namespace: &str, schema: &str) {
434        let schema_dir = dir.join(namespace);
435        fs::create_dir_all(&schema_dir).unwrap();
436        fs::write(schema_dir.join("schema.json"), schema).unwrap();
437    }
438
439    fn create_values(dir: &Path, namespace: &str, values: &str) {
440        let ns_dir = dir.join(namespace);
441        fs::create_dir_all(&ns_dir).unwrap();
442        fs::write(ns_dir.join("values.json"), values).unwrap();
443    }
444
445    #[test]
446    fn test_get_value() {
447        let temp = TempDir::new().unwrap();
448        let schemas = temp.path().join("schemas");
449        let values = temp.path().join("values");
450        fs::create_dir_all(&schemas).unwrap();
451
452        create_schema(
453            &schemas,
454            "test",
455            r#"{
456                "version": "1.0",
457                "type": "object",
458                "properties": {
459                    "enabled": {
460                        "type": "boolean",
461                        "default": false,
462                        "description": "Enable feature"
463                    }
464                }
465            }"#,
466        );
467        create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
468
469        let options = Options::from_directory(temp.path()).unwrap();
470        assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
471    }
472
473    #[test]
474    fn test_with_additional_schemas_overlays_directory_schemas() {
475        let temp = TempDir::new().unwrap();
476        let schemas = temp.path().join("schemas");
477        let values = temp.path().join("values");
478        fs::create_dir_all(&schemas).unwrap();
479
480        // On-disk namespace.
481        create_schema(&schemas, "disk", BOOL_SCHEMA);
482        create_values(&values, "disk", r#"{"options": {"enabled": true}}"#);
483        // In-memory namespace: schema via with_additional_schemas, values on disk.
484        create_values(&values, "mem", r#"{"options": {"enabled": true}}"#);
485
486        let options = Options::builder()
487            .with_directory(temp.path())
488            .with_additional_schemas(&[("mem", BOOL_SCHEMA)])
489            .build()
490            .unwrap();
491
492        // The on-disk namespace still resolves...
493        assert_eq!(options.get("disk", "enabled").unwrap(), json!(true));
494        // ...and the in-memory namespace was added alongside it.
495        assert_eq!(options.get("mem", "enabled").unwrap(), json!(true));
496    }
497
498    #[test]
499    fn test_with_schemas_and_additional_schemas_compose() {
500        let temp = TempDir::new().unwrap();
501        let schemas = temp.path().join("schemas");
502        let values = temp.path().join("values");
503        fs::create_dir_all(&schemas).unwrap();
504
505        // On disk but replaced by with_schemas, so it must NOT resolve.
506        create_schema(&schemas, "disk", BOOL_SCHEMA);
507        create_values(&values, "disk", r#"{"options": {"enabled": true}}"#);
508        create_values(&values, "base", r#"{"options": {"enabled": true}}"#);
509        create_values(&values, "extra", r#"{"options": {"enabled": true}}"#);
510
511        let options = Options::builder()
512            .with_directory(temp.path())
513            .with_schemas(&[("base", BOOL_SCHEMA)])
514            .with_additional_schemas(&[("extra", BOOL_SCHEMA)])
515            .build()
516            .unwrap();
517
518        assert_eq!(options.get("base", "enabled").unwrap(), json!(true));
519        assert_eq!(options.get("extra", "enabled").unwrap(), json!(true));
520        assert!(options.get("disk", "enabled").is_err());
521    }
522
523    #[test]
524    fn test_with_additional_schemas_conflicting_namespace_errors() {
525        let temp = TempDir::new().unwrap();
526        let schemas = temp.path().join("schemas");
527        fs::create_dir_all(&schemas).unwrap();
528        create_schema(&schemas, "dup", BOOL_SCHEMA);
529
530        // Overlaying a namespace that already exists on disk must error rather
531        // than silently shadow it.
532        let result = Options::builder()
533            .with_directory(temp.path())
534            .with_additional_schemas(&[("dup", BOOL_SCHEMA)])
535            .build();
536
537        assert!(
538            result.is_err(),
539            "expected an error for a namespace already on disk"
540        );
541    }
542
543    #[test]
544    fn test_get_default() {
545        let temp = TempDir::new().unwrap();
546        let schemas = temp.path().join("schemas");
547        let values = temp.path().join("values");
548        fs::create_dir_all(&schemas).unwrap();
549        fs::create_dir_all(&values).unwrap();
550
551        create_schema(
552            &schemas,
553            "test",
554            r#"{
555                "version": "1.0",
556                "type": "object",
557                "properties": {
558                    "timeout": {
559                        "type": "integer",
560                        "default": 30,
561                        "description": "Timeout"
562                    }
563                }
564            }"#,
565        );
566
567        let options = Options::from_directory(temp.path()).unwrap();
568        assert_eq!(options.get("test", "timeout").unwrap(), json!(30));
569    }
570
571    #[test]
572    fn test_unknown_namespace() {
573        let temp = TempDir::new().unwrap();
574        let schemas = temp.path().join("schemas");
575        let values = temp.path().join("values");
576        fs::create_dir_all(&schemas).unwrap();
577        fs::create_dir_all(&values).unwrap();
578
579        create_schema(
580            &schemas,
581            "test",
582            r#"{"version": "1.0", "type": "object", "properties": {}}"#,
583        );
584
585        let options = Options::from_directory(temp.path()).unwrap();
586        assert!(matches!(
587            options.get("unknown", "key"),
588            Err(OptionsError::UnknownNamespace(_))
589        ));
590    }
591
592    #[test]
593    fn test_unknown_option() {
594        let temp = TempDir::new().unwrap();
595        let schemas = temp.path().join("schemas");
596        let values = temp.path().join("values");
597        fs::create_dir_all(&schemas).unwrap();
598        fs::create_dir_all(&values).unwrap();
599
600        create_schema(
601            &schemas,
602            "test",
603            r#"{
604                "version": "1.0",
605                "type": "object",
606                "properties": {
607                    "known": {"type": "string", "default": "x", "description": "Known"}
608                }
609            }"#,
610        );
611
612        let options = Options::from_directory(temp.path()).unwrap();
613        assert!(matches!(
614            options.get("test", "unknown"),
615            Err(OptionsError::UnknownOption { .. })
616        ));
617    }
618
619    #[test]
620    fn test_missing_values_dir() {
621        let temp = TempDir::new().unwrap();
622        let schemas = temp.path().join("schemas");
623        fs::create_dir_all(&schemas).unwrap();
624
625        create_schema(
626            &schemas,
627            "test",
628            r#"{
629                "version": "1.0",
630                "type": "object",
631                "properties": {
632                    "opt": {"type": "string", "default": "default_val", "description": "Opt"}
633                }
634            }"#,
635        );
636
637        let options = Options::from_directory(temp.path()).unwrap();
638        assert_eq!(options.get("test", "opt").unwrap(), json!("default_val"));
639    }
640
641    #[test]
642    fn isset_with_defined_and_undefined_keys() {
643        let temp = TempDir::new().unwrap();
644        let schemas = temp.path().join("schemas");
645        fs::create_dir_all(&schemas).unwrap();
646
647        let values = temp.path().join("values");
648        create_values(&values, "test", r#"{"options": {"has-value": "yes"}}"#);
649
650        create_schema(
651            &schemas,
652            "test",
653            r##"{
654                "version": "1.0",
655                "type": "object",
656                "properties": {
657                    "has-value": {"type": "string", "default": "", "description": ""},
658                    "defined-with-default": {"type": "string", "default": "default_val", "description": "Opt"},
659                    "feature.organizations:my-flag": {"$ref": "#/definitions/Feature"}
660                }
661            }"##,
662        );
663
664        let options = Options::from_directory(temp.path()).unwrap();
665        assert!(options.isset("test", "not-defined").is_err());
666        assert!(!options.isset("test", "defined-with-default").unwrap());
667        assert!(options.isset("test", "has-value").unwrap());
668        // Feature keys are known (no UnknownOption error) but unset -> false.
669        assert!(
670            !options
671                .isset("test", "feature.organizations:my-flag")
672                .unwrap()
673        );
674    }
675
676    #[test]
677    fn test_from_schemas_get_default() {
678        let schema = r#"{
679            "version": "1.0",
680            "type": "object",
681            "properties": {
682                "enabled": {
683                    "type": "boolean",
684                    "default": false,
685                    "description": "Enable feature"
686                }
687            }
688        }"#;
689
690        let registry = SchemaRegistry::from_schemas(&[("test", schema)]).unwrap();
691        let default = registry
692            .get("test")
693            .unwrap()
694            .get_default("enabled")
695            .unwrap();
696        assert_eq!(*default, json!(false));
697    }
698
699    #[test]
700    fn test_from_schemas_with_values() {
701        let temp = TempDir::new().unwrap();
702        let values_dir = temp.path().join("values");
703        create_values(&values_dir, "test", r#"{"options": {"enabled": true}}"#);
704
705        let schema = r#"{
706            "version": "1.0",
707            "type": "object",
708            "properties": {
709                "enabled": {
710                    "type": "boolean",
711                    "default": false,
712                    "description": "Enable feature"
713                }
714            }
715        }"#;
716
717        let registry = Arc::new(SchemaRegistry::from_schemas(&[("test", schema)]).unwrap());
718        let (loaded_values, _) = registry.load_values_json(&values_dir).unwrap();
719        assert_eq!(loaded_values["test"]["enabled"], json!(true));
720    }
721
722    #[test]
723    fn test_from_schemas_invalid_json() {
724        let result = SchemaRegistry::from_schemas(&[("test", "not valid json")]);
725        assert!(result.is_err());
726    }
727
728    const BOOL_SCHEMA: &str = r#"{
729        "version": "1.0",
730        "type": "object",
731        "properties": {
732            "enabled": {"type": "boolean", "default": false, "description": "x"}
733        }
734    }"#;
735
736    #[test]
737    fn builder_with_directory() {
738        let temp = TempDir::new().unwrap();
739        let schemas = temp.path().join("schemas");
740        let values = temp.path().join("values");
741        fs::create_dir_all(&schemas).unwrap();
742        create_schema(&schemas, "test", BOOL_SCHEMA);
743        create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
744
745        let options = Options::builder()
746            // without this, schemas wouldn't be found and this test would fail
747            .with_directory(temp.path())
748            .build()
749            .unwrap();
750        assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
751    }
752
753    #[test]
754    fn builder_with_schemas() {
755        let options = Options::builder()
756            // without this, init would fail as schemas are never saved on disk
757            .with_schemas(&[("test", BOOL_SCHEMA)])
758            .build()
759            .unwrap();
760        assert_eq!(options.get("test", "enabled").unwrap(), json!(false));
761    }
762
763    #[test]
764    fn get_forced_sees_change() {
765        let temp = TempDir::new().unwrap();
766        let schemas = temp.path().join("schemas");
767        let values = temp.path().join("values");
768        fs::create_dir_all(&schemas).unwrap();
769        create_schema(&schemas, "test", BOOL_SCHEMA);
770        create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
771
772        let options = Options::from_directory(temp.path()).unwrap();
773        assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
774
775        create_values(&values, "test", r#"{"options": {"enabled": false}}"#);
776
777        // Cached read still returns the old value
778        assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
779        // Forced read picks up the change
780        assert_eq!(options.get_forced("test", "enabled").unwrap(), json!(false));
781    }
782
783    #[test]
784    fn builder_refresh_threshold_none_with_manual_refresh() {
785        let temp = TempDir::new().unwrap();
786        let schemas = temp.path().join("schemas");
787        let values = temp.path().join("values");
788        fs::create_dir_all(&schemas).unwrap();
789        create_schema(&schemas, "test", BOOL_SCHEMA);
790        create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
791
792        let options = Options::builder()
793            .with_directory(temp.path())
794            .with_refresh_threshold(None)
795            .build()
796            .unwrap();
797        assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
798
799        create_values(&values, "test", r#"{"options": {"enabled": false}}"#);
800
801        // With refresh-on-read disabled, reads never pick up the change.
802        assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
803        // A manual refresh does.
804        assert!(options.refresh().unwrap());
805        assert_eq!(options.get("test", "enabled").unwrap(), json!(false));
806        // Nothing changed since the last refresh.
807        assert!(!options.refresh().unwrap());
808    }
809
810    #[test]
811    fn builder_refresh_threshold_custom() {
812        let temp = TempDir::new().unwrap();
813        let schemas = temp.path().join("schemas");
814        let values = temp.path().join("values");
815        fs::create_dir_all(&schemas).unwrap();
816        create_schema(&schemas, "test", BOOL_SCHEMA);
817        create_values(&values, "test", r#"{"options": {"enabled": true}}"#);
818
819        let options = Options::builder()
820            .with_directory(temp.path())
821            .with_refresh_threshold(Duration::ZERO)
822            .build()
823            .unwrap();
824        assert_eq!(options.get("test", "enabled").unwrap(), json!(true));
825
826        create_values(&values, "test", r#"{"options": {"enabled": false}}"#);
827
828        // A zero threshold refreshes on every read.
829        assert_eq!(options.get("test", "enabled").unwrap(), json!(false));
830    }
831}