Skip to main content

sentry_options/
lib.rs

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