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