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