Skip to main content

sentry_options_validation/
lib.rs

1//! Schema validation library for sentry-options.
2//!
3//! Schemas are loaded once into a [`SchemaRegistry`] and shared via `Arc`.
4//! Values are validated against schemas as complete objects.
5//!
6//! Values refresh lazily on read on the same thread. A read past
7//! the staleness window re-reads the values directory on the calling thread
8//! and publishes the new snapshot via `ArcSwap` (last-writer-wins). The full
9//! scheme is in `docs/architecture.md`; the memory-ordering invariants are
10//! documented inline in the `refresh` method below.
11
12use arc_swap::ArcSwap;
13use chrono::{DateTime, Utc};
14use serde_json::Value;
15use serde_json::json;
16use std::collections::{HashMap, HashSet};
17use std::fs;
18use std::path::{Path, PathBuf};
19use std::sync::{
20    Arc,
21    atomic::{AtomicU64, Ordering},
22};
23use std::time::{Duration, Instant, SystemTime};
24
25/// Embedded meta-schema for validating sentry-options schema files
26const NAMESPACE_SCHEMA_JSON: &str = include_str!("namespace-schema.json");
27
28/// Embedded Feature type definitions for injecting into namespace schemas that contain feature flags
29const FEATURE_SCHEMA_DEFS_JSON: &str = include_str!("feature-schema-defs.json");
30
31const SCHEMA_FILE_NAME: &str = "schema.json";
32const VALUES_FILE_NAME: &str = "values.json";
33
34/// Prefix marking an option key as a feature flag.
35const FEATURE_KEY_PREFIX: &str = "feature.";
36/// Regex a schema uses to declare features by pattern instead of by name.
37const FEATURE_KEY_PATTERN: &str = "^feature\\.";
38
39/// Default minimum age of a cached values snapshot before a read triggers a
40/// refresh. A per-thread jitter of up to 1 s is added on top to spread the
41/// reload across threads.
42pub const DEFAULT_REFRESH_THRESHOLD: Duration = Duration::from_secs(5);
43
44/// Production path where options are deployed via config map
45pub const PRODUCTION_OPTIONS_DIR: &str = "/etc/sentry-options";
46
47/// Local fallback path for development
48pub const LOCAL_OPTIONS_DIR: &str = "sentry-options";
49
50/// Environment variable to override options directory
51pub const OPTIONS_DIR_ENV: &str = "SENTRY_OPTIONS_DIR";
52
53/// Environment variable to suppress missing directory errors
54pub const OPTIONS_SUPPRESS_MISSING_DIR_ENV: &str = "SENTRY_OPTIONS_SUPPRESS_MISSING_DIR";
55
56/// Check if missing directory errors should be suppressed
57fn should_suppress_missing_dir_errors() -> bool {
58    std::env::var(OPTIONS_SUPPRESS_MISSING_DIR_ENV)
59        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
60        .unwrap_or(false)
61}
62
63/// Resolve options directory using fallback chain:
64/// 1. `SENTRY_OPTIONS_DIR` env var (if set)
65/// 2. `/etc/sentry-options` (if exists)
66/// 3. `sentry-options/` (local fallback)
67pub fn resolve_options_dir() -> PathBuf {
68    if let Ok(dir) = std::env::var(OPTIONS_DIR_ENV) {
69        return PathBuf::from(dir);
70    }
71
72    let prod_path = PathBuf::from(PRODUCTION_OPTIONS_DIR);
73    if prod_path.exists() {
74        return prod_path;
75    }
76
77    PathBuf::from(LOCAL_OPTIONS_DIR)
78}
79
80/// Result type for validation operations
81pub type ValidationResult<T> = Result<T, ValidationError>;
82
83/// A map of option values keyed by their namespace
84pub type ValuesByNamespace = HashMap<String, HashMap<String, Value>>;
85
86/// Errors that can occur during schema and value validation
87#[derive(Debug, thiserror::Error)]
88pub enum ValidationError {
89    #[error("Schema error in {file}: {message}")]
90    SchemaError { file: PathBuf, message: String },
91
92    #[error("Value error for {namespace}: {errors}")]
93    ValueError { namespace: String, errors: String },
94
95    #[error("Unknown namespace: {0}")]
96    UnknownNamespace(String),
97
98    #[error("Unknown option '{key}' in namespace '{namespace}'")]
99    UnknownOption { namespace: String, key: String },
100
101    #[error("Internal error: {0}")]
102    InternalError(String),
103
104    #[error("Failed to read file: {0}")]
105    FileRead(#[from] std::io::Error),
106
107    #[error("Failed to parse JSON: {0}")]
108    JSONParse(#[from] serde_json::Error),
109
110    #[error("{} validation error(s)", .0.len())]
111    ValidationErrors(Vec<ValidationError>),
112
113    #[error("Invalid {label} '{name}': {reason}")]
114    InvalidName {
115        label: String,
116        name: String,
117        reason: String,
118    },
119}
120
121/// Maximum length of a K8s DNS-1123 label (RFC 1123).
122pub const DNS1123_LABEL_MAX_LEN: usize = 63;
123
124/// Validate a name component is a valid K8s DNS-1123 label: lowercase
125/// alphanumeric and '-', starts and ends alphanumeric, at most 63 chars.
126///
127/// '.' is allowed in the configmap name, but not in `volume.name`
128/// so we reject it.
129pub fn validate_k8s_name_component(name: &str, label: &str) -> ValidationResult<()> {
130    if name.len() > DNS1123_LABEL_MAX_LEN {
131        return Err(ValidationError::InvalidName {
132            label: label.to_string(),
133            name: name.to_string(),
134            reason: format!("must be at most {} characters", DNS1123_LABEL_MAX_LEN),
135        });
136    }
137    if let Some(c) = name
138        .chars()
139        .find(|&c| !matches!(c, 'a'..='z' | '0'..='9' | '-'))
140    {
141        return Err(ValidationError::InvalidName {
142            label: label.to_string(),
143            name: name.to_string(),
144            reason: format!(
145                "character '{}' not allowed. Use lowercase alphanumeric or '-'",
146                c
147            ),
148        });
149    }
150    if !name.starts_with(|c: char| c.is_ascii_alphanumeric())
151        || !name.ends_with(|c: char| c.is_ascii_alphanumeric())
152    {
153        return Err(ValidationError::InvalidName {
154            label: label.to_string(),
155            name: name.to_string(),
156            reason: "must start and end with alphanumeric".to_string(),
157        });
158    }
159    Ok(())
160}
161
162/// The value a namespace schema pairs with each `feature.<name>` key, i.e.
163/// `{"$ref": "#/definitions/Feature"}`. Splice this in when assembling a schema
164/// in memory rather than hand-rolling the ref the meta-schema requires.
165pub fn feature_property() -> Value {
166    json!({ "$ref": "#/definitions/Feature" })
167}
168
169/// Metadata for a single option in a namespace schema
170#[derive(Debug, Clone)]
171pub struct OptionMetadata {
172    pub option_type: String,
173    pub property_schema: Value,
174    pub default: Value,
175}
176
177/// Schema for a namespace, containing validator and option metadata
178pub struct NamespaceSchema {
179    pub namespace: String,
180    pub options: HashMap<String, OptionMetadata>,
181    /// `feature.`-prefixed keys. Kept separate from `options`
182    /// so they aren't involved in schema-evolution checks
183    feature_keys: HashSet<String>,
184    /// All property keys from the schema, including feature flags that aren't in `options`.
185    all_keys: HashSet<String>,
186    /// Set when the schema declares features by pattern rather than by name, i.e. any
187    /// `feature.`-prefixed key is accepted. The set of valid names then lives in the
188    /// consuming service's code, not here.
189    open_features: bool,
190    validator: jsonschema::Validator,
191}
192
193impl NamespaceSchema {
194    /// Validate an entire values object against this schema
195    ///
196    /// # Arguments
197    /// * `values` - JSON object containing option key-value pairs
198    ///
199    /// # Errors
200    /// Returns error if values don't match the schema
201    pub fn validate_values(&self, values: &Value) -> ValidationResult<()> {
202        let output = self.validator.evaluate(values);
203        if output.flag().valid {
204            Ok(())
205        } else {
206            let errors: Vec<String> = output
207                .iter_errors()
208                .map(|e| {
209                    format!(
210                        "\n\t{} {}",
211                        e.instance_location.as_str().trim_start_matches("/"),
212                        e.error
213                    )
214                })
215                .collect();
216            Err(ValidationError::ValueError {
217                namespace: self.namespace.clone(),
218                errors: errors.join(""),
219            })
220        }
221    }
222
223    /// Get the default value for an option key.
224    /// Returns None if the key doesn't exist in the schema.
225    pub fn get_default(&self, key: &str) -> Option<&Value> {
226        self.options.get(key).map(|meta| &meta.default)
227    }
228
229    /// Whether `key` is a known key in this namespace, runtime
230    /// option or feature flag.
231    pub fn is_known_key(&self, key: &str) -> bool {
232        self.options.contains_key(key) || self.accepts_feature_key(key)
233    }
234
235    /// Whether `key` is a feature this namespace declares, either by name or by
236    /// pattern (see `open_features`).
237    fn accepts_feature_key(&self, key: &str) -> bool {
238        self.feature_keys.contains(key)
239            || (self.open_features && key.starts_with(FEATURE_KEY_PREFIX))
240    }
241
242    /// Whether a values key belongs to this namespace, i.e. survives loading rather
243    /// than being stripped as unknown. Unlike `is_known_key` this covers every
244    /// declared property, not just those with option metadata.
245    pub fn accepts_key(&self, key: &str) -> bool {
246        self.all_keys.contains(key) || self.accepts_feature_key(key)
247    }
248
249    /// Validate a single key-value pair against the schema.
250    ///
251    /// # Errors
252    /// Returns error if the key doesn't exist or the value doesn't match the expected type.
253    pub fn validate_option(&self, key: &str, value: &Value) -> ValidationResult<()> {
254        if !self.is_known_key(key) {
255            return Err(ValidationError::UnknownOption {
256                namespace: self.namespace.clone(),
257                key: key.to_string(),
258            });
259        }
260        let test_obj = json!({ key: value });
261        self.validate_values(&test_obj)
262    }
263}
264
265/// Registry for loading and storing schemas
266pub struct SchemaRegistry {
267    schemas: HashMap<String, Arc<NamespaceSchema>>,
268}
269
270impl SchemaRegistry {
271    /// Create a new empty schema registry
272    pub fn new() -> Self {
273        Self {
274            schemas: HashMap::new(),
275        }
276    }
277
278    /// Load schemas from a directory and create a registry
279    ///
280    /// Expects directory structure: `schemas/{namespace}/schema.json`
281    ///
282    /// # Arguments
283    /// * `schemas_dir` - Path to directory containing namespace subdirectories
284    ///
285    /// # Errors
286    /// Returns error if directory doesn't exist or any schema is invalid
287    pub fn from_directory(schemas_dir: &Path) -> ValidationResult<Self> {
288        let namespace_validator = Self::compile_namespace_validator()?;
289        let mut schemas_map = HashMap::new();
290
291        // TODO: Parallelize the loading of schemas for the performance gainz
292        for entry in fs::read_dir(schemas_dir)? {
293            let entry = entry?;
294
295            if !entry.file_type()?.is_dir() {
296                continue;
297            }
298
299            let namespace =
300                entry
301                    .file_name()
302                    .into_string()
303                    .map_err(|_| ValidationError::SchemaError {
304                        file: entry.path(),
305                        message: "Directory name contains invalid UTF-8".to_string(),
306                    })?;
307
308            validate_k8s_name_component(&namespace, "namespace name")?;
309
310            let schema_file = entry.path().join(SCHEMA_FILE_NAME);
311            let file = fs::File::open(&schema_file)?;
312            let schema_data: Value = serde_json::from_reader(file)?;
313
314            Self::validate_with_namespace_schema(&schema_data, &schema_file, &namespace_validator)?;
315            let schema = Self::parse_schema(schema_data, &namespace, &schema_file)?;
316            schemas_map.insert(namespace, schema);
317        }
318
319        Ok(Self {
320            schemas: schemas_map,
321        })
322    }
323
324    /// Build a registry from in-memory schema JSON strings.
325    ///
326    /// Each entry is a `(namespace, json)` pair. Applies the same validation
327    /// pipeline as `from_directory` (meta-schema check, constraint injection,
328    /// validator compilation) without reading from the filesystem.
329    pub fn from_schemas(schemas: &[(&str, &str)]) -> ValidationResult<Self> {
330        let mut registry = Self::new();
331        registry.add_schemas(schemas)?;
332        Ok(registry)
333    }
334
335    /// Add in-memory `(namespace, json)` schemas to whatever is already loaded.
336    /// Errors on an already-present namespace, so an in-memory schema can't
337    /// silently shadow one loaded from disk.
338    pub fn add_schemas(&mut self, schemas: &[(&str, &str)]) -> ValidationResult<()> {
339        let namespace_validator = Self::compile_namespace_validator()?;
340        let schema_file = Path::new("<embedded>");
341
342        for (namespace, json) in schemas {
343            validate_k8s_name_component(namespace, "namespace name")?;
344
345            let schema_data: Value =
346                serde_json::from_str(json).map_err(|e| ValidationError::SchemaError {
347                    file: schema_file.to_path_buf(),
348                    message: format!("Invalid JSON for namespace '{}': {}", namespace, e),
349                })?;
350
351            Self::validate_with_namespace_schema(&schema_data, schema_file, &namespace_validator)?;
352            let schema = Self::parse_schema(schema_data, namespace, schema_file)?;
353            if self.schemas.insert(namespace.to_string(), schema).is_some() {
354                return Err(ValidationError::SchemaError {
355                    file: schema_file.to_path_buf(),
356                    message: format!("Duplicate namespace '{}'", namespace),
357                });
358            }
359        }
360
361        Ok(())
362    }
363
364    /// Validate an entire values object for a namespace
365    pub fn validate_values(&self, namespace: &str, values: &Value) -> ValidationResult<()> {
366        let schema = self
367            .schemas
368            .get(namespace)
369            .ok_or_else(|| ValidationError::UnknownNamespace(namespace.to_string()))?;
370
371        schema.validate_values(values)
372    }
373
374    fn compile_namespace_validator() -> ValidationResult<jsonschema::Validator> {
375        let namespace_schema_value: Value =
376            serde_json::from_str(NAMESPACE_SCHEMA_JSON).map_err(|e| {
377                ValidationError::InternalError(format!("Invalid namespace-schema JSON: {}", e))
378            })?;
379        jsonschema::validator_for(&namespace_schema_value).map_err(|e| {
380            ValidationError::InternalError(format!("Failed to compile namespace-schema: {}", e))
381        })
382    }
383
384    /// Validate a schema against the namespace-schema
385    fn validate_with_namespace_schema(
386        schema_data: &Value,
387        path: &Path,
388        namespace_validator: &jsonschema::Validator,
389    ) -> ValidationResult<()> {
390        let output = namespace_validator.evaluate(schema_data);
391
392        if output.flag().valid {
393            Ok(())
394        } else {
395            let errors: Vec<String> = output
396                .iter_errors()
397                .map(|e| format!("Error: {}", e.error))
398                .collect();
399
400            Err(ValidationError::SchemaError {
401                file: path.to_path_buf(),
402                message: format!("Schema validation failed:\n{}", errors.join("\n")),
403            })
404        }
405    }
406
407    /// Validate that a default value matches its declared type using jsonschema
408    fn validate_default_type(
409        property_name: &str,
410        property_schema: &Value,
411        default_value: &Value,
412        path: &Path,
413    ) -> ValidationResult<()> {
414        // Validate the default value against the property schema
415        jsonschema::validate(property_schema, default_value).map_err(|e| {
416            ValidationError::SchemaError {
417                file: path.to_path_buf(),
418                message: format!(
419                    "Property '{}': default value does not match schema: {}",
420                    property_name, e
421                ),
422            }
423        })?;
424
425        Ok(())
426    }
427
428    /// Injects `required` (all non-optional field names) into an object-typed schema.
429    /// Also injects `additionalProperties: false` unless the schema already declares
430    /// `additionalProperties` explicitly — that signals a dynamic map where keys are
431    /// not known up front (e.g. `"additionalProperties": {"type": "string"}`).
432    /// e.g.
433    /// {
434    ///     "type": "object",
435    ///     "properties": {
436    ///       "host": { "type": "string" },
437    ///       "port": { "type": "integer" }
438    ///     },
439    ///     "required": ["host", "port"],                       <-- INJECTED
440    ///     "additionalProperties": false,                      <-- INJECTED
441    ///     "default": { "host": "localhost", "port": 8080 },
442    ///     "description": "..."
443    /// }
444    fn inject_object_constraints(schema: &mut Value) {
445        let Some(obj) = schema.as_object_mut() else {
446            return;
447        };
448
449        if obj.get("type").and_then(|t| t.as_str()) == Some("object") {
450            // Fixed-shape object: require every non-optional field. A dynamic map
451            // instead declares `additionalProperties` (a value schema) up front, so
452            // only inject `required` when there are declared `properties`.
453            if let Some(props) = obj.get("properties").and_then(|p| p.as_object()) {
454                let required: Vec<Value> = props
455                    .iter()
456                    .filter(|(_, v)| !v.get("optional").and_then(|o| o.as_bool()).unwrap_or(false))
457                    .map(|(k, _)| Value::String(k.clone()))
458                    .collect();
459                obj.insert("required".to_string(), Value::Array(required));
460            }
461            // Reject unknown keys unless the schema already declares an
462            // `additionalProperties` value type (i.e. it is a dynamic map).
463            if !obj.contains_key("additionalProperties") {
464                obj.insert("additionalProperties".to_string(), json!(false));
465            }
466        }
467
468        // Recurse so the same constraints apply at every level of a nested value
469        // schema: object-valued maps, arrays of objects, and nested fixed shapes.
470        if let Some(props) = obj.get_mut("properties").and_then(|p| p.as_object_mut()) {
471            for field in props.values_mut() {
472                Self::inject_object_constraints(field);
473            }
474        }
475        if let Some(additional) = obj.get_mut("additionalProperties")
476            && additional.is_object()
477        {
478            Self::inject_object_constraints(additional);
479        }
480        if let Some(items) = obj.get_mut("items") {
481            Self::inject_object_constraints(items);
482        }
483    }
484
485    /// Parse a schema JSON into NamespaceSchema
486    fn parse_schema(
487        mut schema: Value,
488        namespace: &str,
489        path: &Path,
490    ) -> ValidationResult<Arc<NamespaceSchema>> {
491        // Inject additionalProperties: false to reject unknown options
492        if let Some(obj) = schema.as_object_mut() {
493            obj.insert("additionalProperties".to_string(), json!(false));
494        }
495
496        // Inject object constraints (required + additionalProperties) at every level
497        // of each option's value schema so jsonschema validates the full shape.
498        if let Some(properties) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) {
499            for prop_value in properties.values_mut() {
500                Self::inject_object_constraints(prop_value);
501            }
502        }
503
504        // Extract option metadata and validate types.
505        let mut options = HashMap::new();
506        let mut feature_keys = HashSet::new();
507        let mut all_keys = HashSet::new();
508        // A `patternProperties` entry for the feature prefix declares features by
509        // pattern instead of listing every name, so any `feature.`-prefixed key is
510        // accepted and validated against the spliced-in Feature definition.
511        let open_features = schema
512            .get("patternProperties")
513            .and_then(|p| p.as_object())
514            .is_some_and(|p| p.contains_key(FEATURE_KEY_PATTERN));
515
516        let mut has_feature_keys = open_features;
517        if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
518            for (prop_name, prop_value) in properties {
519                all_keys.insert(prop_name.clone());
520                // Detect feature flags so that we can augment the schema with defs.
521                // They're tracked separately from `options` (see NamespaceSchema).
522                if prop_name.starts_with(FEATURE_KEY_PREFIX) {
523                    has_feature_keys = true;
524                    feature_keys.insert(prop_name.clone());
525                }
526                if let (Some(prop_type), Some(default_value)) = (
527                    prop_value.get("type").and_then(|t| t.as_str()),
528                    prop_value.get("default"),
529                ) {
530                    Self::validate_default_type(prop_name, prop_value, default_value, path)?;
531                    options.insert(
532                        prop_name.clone(),
533                        OptionMetadata {
534                            option_type: prop_type.to_string(),
535                            property_schema: prop_value.clone(),
536                            default: default_value.clone(),
537                        },
538                    );
539                }
540            }
541        }
542
543        // If an options schema includes a feature flag, splice in the definitions
544        // so that values can be validated.
545        if has_feature_keys {
546            let feature_defs: Value =
547                serde_json::from_str(FEATURE_SCHEMA_DEFS_JSON).map_err(|e| {
548                    ValidationError::InternalError(format!(
549                        "Invalid feature-schema-defs JSON: {}",
550                        e
551                    ))
552                })?;
553
554            if let Some(obj) = schema.as_object_mut() {
555                obj.insert("definitions".to_string(), feature_defs);
556            }
557        }
558
559        // Use the (potentially transformed) schema as the validator
560        let validator =
561            jsonschema::validator_for(&schema).map_err(|e| ValidationError::SchemaError {
562                file: path.to_path_buf(),
563                message: format!("Failed to compile validator: {}", e),
564            })?;
565
566        Ok(Arc::new(NamespaceSchema {
567            namespace: namespace.to_string(),
568            options,
569            feature_keys,
570            all_keys,
571            open_features,
572            validator,
573        }))
574    }
575
576    /// Get a namespace schema by name
577    pub fn get(&self, namespace: &str) -> Option<&Arc<NamespaceSchema>> {
578        self.schemas.get(namespace)
579    }
580
581    /// Get all loaded schemas (for schema evolution validation)
582    pub fn schemas(&self) -> &HashMap<String, Arc<NamespaceSchema>> {
583        &self.schemas
584    }
585
586    /// Load and validate JSON values from a directory.
587    /// Allows extra unknown option values to accommodate deployment race conditions
588    ///
589    /// Expects structure: `{values_dir}/{namespace}/values.json`
590    /// Values file must have format: `{"options": {"key": value, ...}, "generated_at": "..."}`
591    /// Skips namespaces without a values.json file.
592    /// Returns the values and a map of namespace -> `generated_at` timestamp.
593    pub fn load_values_json(
594        &self,
595        values_dir: &Path,
596    ) -> ValidationResult<(ValuesByNamespace, HashMap<String, String>)> {
597        let mut all_values = HashMap::new();
598        let mut generated_at_by_namespace: HashMap<String, String> = HashMap::new();
599
600        for namespace in self.schemas.keys() {
601            let values_file = values_dir.join(namespace).join(VALUES_FILE_NAME);
602
603            if !values_file.exists() {
604                continue;
605            }
606
607            let parsed: Value = serde_json::from_reader(fs::File::open(&values_file)?)?;
608
609            // Extract generated_at if present
610            if let Some(ts) = parsed.get("generated_at").and_then(|v| v.as_str()) {
611                generated_at_by_namespace.insert(namespace.clone(), ts.to_string());
612            }
613
614            let values = parsed
615                .get("options")
616                .ok_or_else(|| ValidationError::ValueError {
617                    namespace: namespace.clone(),
618                    errors: "values.json must have an 'options' key".to_string(),
619                })?;
620
621            // Strip unknown keys before validation to handle deployment race
622            // conditions where values are deployed before the schema update.
623            let values = self.strip_unknown_keys(namespace, values);
624
625            self.validate_values(namespace, &values)?;
626
627            if let Value::Object(obj) = values {
628                let ns_values: HashMap<String, Value> = obj.into_iter().collect();
629                all_values.insert(namespace.clone(), ns_values);
630            }
631        }
632
633        Ok((all_values, generated_at_by_namespace))
634    }
635
636    /// Remove keys from values that are not defined in the namespace schema.
637    /// Logs a warning for each removed key. Returns the filtered values object.
638    pub fn strip_unknown_keys(&self, namespace: &str, values: &Value) -> Value {
639        let schema = match self.schemas.get(namespace) {
640            Some(s) => s,
641            None => return values.clone(),
642        };
643
644        let obj = match values.as_object() {
645            Some(obj) => obj,
646            None => return values.clone(),
647        };
648
649        let unknown_keys: Vec<&String> = obj.keys().filter(|k| !schema.accepts_key(k)).collect();
650
651        if unknown_keys.is_empty() {
652            return values.clone();
653        }
654
655        for key in &unknown_keys {
656            tracing::warn!(
657                key = %key,
658                namespace = %namespace,
659                "Ignoring unknown option. This is expected during deployments when \
660                 values are updated before schemas.",
661            );
662        }
663
664        let filtered: serde_json::Map<String, Value> = obj
665            .iter()
666            .filter(|(k, _)| schema.accepts_key(k))
667            .map(|(k, v)| (k.clone(), v.clone()))
668            .collect();
669        Value::Object(filtered)
670    }
671}
672
673impl Default for SchemaRegistry {
674    fn default() -> Self {
675        Self::new()
676    }
677}
678
679/// Callback invoked when a namespace's values are updated. Receives
680/// `(namespace, delay_secs)` — the propagation delay from ConfigMap generation
681/// to the app reading the new values.
682pub type PropagationCallback = Box<dyn Fn(&str, f64) + Send + Sync>;
683
684/// Validates and serves option values, reloading them lazily on read and
685/// serving the current snapshot lock-free via `ArcSwap`. See `refresh` for the
686/// staleness window and memory-ordering details.
687pub struct ValuesStore {
688    registry: Arc<SchemaRegistry>,
689    values_dir: PathBuf,
690    values: ArcSwap<ValuesByNamespace>,
691    baseline: Instant,
692    last_refresh_offset_ns: AtomicU64,
693    /// Staleness threshold for refresh-on-read; `None` disables it, so values
694    /// only change via [`refresh`](Self::refresh).
695    refresh_threshold: Option<Duration>,
696    /// Last known `generated_at` per namespace, used to detect value changes.
697    /// Uses ArcSwap for lock-free, fork-safe access.
698    last_generated_at: ArcSwap<HashMap<String, String>>,
699    /// Optional callback invoked when propagation delay is measured.
700    on_propagation: Option<PropagationCallback>,
701    /// Last known modification times of values files, used to skip
702    /// redundant reloads when nothing has changed on disk.
703    last_mtimes: ArcSwap<HashMap<String, SystemTime>>,
704}
705
706/// Builder for constructing a [`ValuesStore`].
707///
708/// Created via [`ValuesStore::builder`]. Configure optional parameters with
709/// the `with_*` methods, then finalize with [`build`](Self::build).
710pub struct ValuesStoreBuilder {
711    registry: Arc<SchemaRegistry>,
712    values_dir: PathBuf,
713    refresh_threshold: Option<Duration>,
714    on_propagation: Option<PropagationCallback>,
715}
716
717impl ValuesStoreBuilder {
718    /// Overrides the staleness threshold for refresh-on-read.
719    ///
720    /// Defaults to [`DEFAULT_REFRESH_THRESHOLD`]. Pass `None` to disable
721    /// refresh-on-read entirely; values then only change via
722    /// [`ValuesStore::refresh`]. A zero threshold refreshes on every read.
723    pub fn with_refresh_threshold(mut self, threshold: impl Into<Option<Duration>>) -> Self {
724        self.refresh_threshold = threshold.into();
725        self
726    }
727
728    /// Registers a callback that fires whenever new values are detected.
729    /// The callback receives `(namespace, delay_secs)` on each value change.
730    pub fn with_callback(mut self, callback: PropagationCallback) -> Self {
731        self.on_propagation = Some(callback);
732        self
733    }
734
735    /// Builds the store and performs the initial values load synchronously.
736    pub fn build(self) -> ValidationResult<ValuesStore> {
737        let values_dir = &self.values_dir;
738        if !should_suppress_missing_dir_errors() && fs::metadata(values_dir).is_err() {
739            tracing::warn!(path = %values_dir.display(), "Values directory does not exist");
740        }
741
742        let baseline = Instant::now();
743        let initial_mtimes = ValuesStore::collect_mtimes(&self.registry, values_dir);
744        let (initial, generated_at_by_namespace) = self.registry.load_values_json(values_dir)?;
745        let last_refresh_offset_ns = AtomicU64::new(baseline.elapsed().as_nanos() as u64);
746
747        Ok(ValuesStore {
748            registry: self.registry,
749            values_dir: self.values_dir,
750            values: ArcSwap::from_pointee(initial),
751            baseline,
752            last_refresh_offset_ns,
753            refresh_threshold: self.refresh_threshold,
754            last_generated_at: ArcSwap::from_pointee(generated_at_by_namespace),
755            on_propagation: self.on_propagation,
756            last_mtimes: ArcSwap::from_pointee(initial_mtimes),
757        })
758    }
759}
760
761impl ValuesStore {
762    /// Builds a store with the default configuration and performs the initial
763    /// values load synchronously.
764    ///
765    /// Use [`builder`](Self::builder) to override the configuration.
766    pub fn new(registry: Arc<SchemaRegistry>, values_dir: &Path) -> ValidationResult<Self> {
767        Self::builder(registry, values_dir).build()
768    }
769
770    /// Builds a store with a callback that fires whenever new values are detected.
771    #[deprecated(note = "use `ValuesStore::builder(...).with_callback(...)` instead")]
772    pub fn with_propagation_callback(
773        registry: Arc<SchemaRegistry>,
774        values_dir: &Path,
775        callback: PropagationCallback,
776    ) -> ValidationResult<Self> {
777        Self::builder(registry, values_dir)
778            .with_callback(callback)
779            .build()
780    }
781
782    /// Returns a builder for constructing a store from the given registry and
783    /// values directory.
784    pub fn builder(registry: Arc<SchemaRegistry>, values_dir: &Path) -> ValuesStoreBuilder {
785        ValuesStoreBuilder {
786            registry,
787            values_dir: values_dir.to_path_buf(),
788            refresh_threshold: Some(DEFAULT_REFRESH_THRESHOLD),
789            on_propagation: None,
790        }
791    }
792
793    /// The registry the store was constructed with.
794    pub fn registry(&self) -> &Arc<SchemaRegistry> {
795        &self.registry
796    }
797
798    /// Stat each `{values_dir}/{namespace}/values.json` and return a map of
799    /// namespace → modification time. Missing files are omitted.
800    fn collect_mtimes(registry: &SchemaRegistry, values_dir: &Path) -> HashMap<String, SystemTime> {
801        let mut mtimes = HashMap::new();
802        for namespace in registry.schemas().keys() {
803            let path = values_dir.join(namespace).join(VALUES_FILE_NAME);
804            if let Ok(metadata) = fs::metadata(&path)
805                && let Ok(mtime) = metadata.modified()
806            {
807                mtimes.insert(namespace.clone(), mtime);
808            }
809        }
810        mtimes
811    }
812
813    /// Returns a guard onto the current values snapshot, refreshing first if
814    /// the cached snapshot is older than `refresh_threshold + jitter`.
815    pub fn load(&self) -> arc_swap::Guard<Arc<ValuesByNamespace>> {
816        self.maybe_refresh();
817        self.values.load()
818    }
819
820    /// Like [`load`](Self::load), but always refreshes, ignoring the staleness
821    /// threshold. The mtime check still applies, so unchanged files are not
822    /// re-read from disk.
823    pub fn force_load(&self) -> arc_swap::Guard<Arc<ValuesByNamespace>> {
824        if let Err(e) = self.refresh() {
825            self.log_refresh_error(&e);
826        }
827        self.values.load()
828    }
829
830    /// Refreshes values from disk, ignoring the staleness threshold.
831    ///
832    /// Returns whether a new snapshot was published, i.e. the files changed
833    /// on disk (by mtime) and reloaded successfully. On error the previous
834    /// snapshot is retained. Propagation callbacks fire on the calling thread
835    /// before this returns.
836    ///
837    /// Any call resets the refresh-on-read timer, so calling this more often
838    /// than the threshold guarantees reads never refresh inline.
839    pub fn refresh(&self) -> ValidationResult<bool> {
840        let now_ns = self.baseline.elapsed().as_nanos() as u64;
841        let last_ns = self.last_refresh_offset_ns.load(Ordering::Acquire);
842        self.refresh_at(last_ns, now_ns)
843    }
844
845    fn maybe_refresh(&self) {
846        let Some(threshold) = self.refresh_threshold else {
847            return;
848        };
849
850        let now_ns = self.baseline.elapsed().as_nanos() as u64;
851        let last_ns = self.last_refresh_offset_ns.load(Ordering::Acquire);
852        let elapsed_ns = now_ns.saturating_sub(last_ns);
853        let threshold_ns = threshold.as_nanos() as u64;
854        // Skip jitter for a zero threshold so callers (chiefly tests) can
855        // force every read to refresh.
856        let jitter_ns = if threshold.is_zero() {
857            0
858        } else {
859            stack_jitter_ns()
860        };
861
862        if elapsed_ns < threshold_ns.saturating_add(jitter_ns) {
863            return;
864        }
865
866        if let Err(e) = self.refresh_at(last_ns, now_ns) {
867            self.log_refresh_error(&e);
868        }
869    }
870
871    fn log_refresh_error(&self, error: &ValidationError) {
872        tracing::error!(
873            path = %self.values_dir.display(),
874            error = %error,
875            "Failed to reload values",
876        );
877    }
878
879    fn refresh_at(&self, observed_last_ns: u64, now_ns: u64) -> ValidationResult<bool> {
880        let current_mtimes = Self::collect_mtimes(&self.registry, &self.values_dir);
881        if *self.last_mtimes.load().as_ref() == current_mtimes {
882            let _ = self.last_refresh_offset_ns.compare_exchange(
883                observed_last_ns,
884                now_ns,
885                Ordering::AcqRel,
886                Ordering::Relaxed,
887            );
888            return Ok(false);
889        }
890
891        let reload_start = Instant::now();
892        let result = self.registry.load_values_json(&self.values_dir);
893
894        // Publish the new snapshot before bumping the timestamp. The CAS below
895        // uses AcqRel (Release on success); a reader that Acquire-loads the
896        // bumped timestamp and decides the snapshot is fresh enough to skip
897        // refreshing is then guaranteed to observe this `ArcSwap::store`.
898        // Reversing the order opens a window where the timestamp says "fresh"
899        // but the ArcSwap still holds the previous snapshot on weakly ordered
900        // architectures.
901        let new_generated_at = match result {
902            Ok((new_values, generated_at)) => {
903                let elapsed_ms = reload_start.elapsed().as_millis();
904                for namespace in new_values.keys() {
905                    tracing::info!(
906                        path = %self.values_dir.display(),
907                        namespace = %namespace,
908                        elapsed_ms,
909                        "Reloaded values",
910                    );
911                }
912
913                self.values.store(Arc::new(new_values));
914                self.last_mtimes.store(Arc::new(current_mtimes));
915                Ok(generated_at)
916            }
917            Err(e) => Err(e),
918        };
919
920        // Bump the timestamp regardless of success. On failure, the bumped
921        // timestamp keeps subsequent reads from hammering the filesystem until
922        // the next threshold window. Losing the CAS just means another thread
923        // already bumped — our snapshot, if any, is still published.
924        let _ = self.last_refresh_offset_ns.compare_exchange(
925            observed_last_ns,
926            now_ns,
927            Ordering::AcqRel,
928            Ordering::Relaxed,
929        );
930
931        let new_generated_at = new_generated_at?;
932
933        // Fire the propagation callback after the CAS so other threads see a
934        // fresh timestamp and don't redundantly re-read from disk while a
935        // potentially slow callback (e.g. Python/GIL) executes.
936        let last = self.last_generated_at.load();
937        if *last.as_ref() != new_generated_at {
938            if let Some(callback) = &self.on_propagation {
939                let applied_at = Utc::now();
940                for (ns, ts) in &new_generated_at {
941                    if last.get(ns).is_some_and(|old| old == ts) {
942                        continue;
943                    }
944                    match propagation_delay_secs(&applied_at, ts) {
945                        Some(delay) => {
946                            if let Err(e) =
947                                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
948                                    callback(ns, delay)
949                                }))
950                            {
951                                tracing::error!(
952                                    namespace = %ns,
953                                    error = ?e,
954                                    "Propagation callback panicked",
955                                );
956                            }
957                        }
958                        None => tracing::warn!(
959                            namespace = %ns,
960                            generated_at = %ts,
961                            "Bad generated_at timestamp",
962                        ),
963                    }
964                }
965            }
966            self.last_generated_at.store(Arc::new(new_generated_at));
967        }
968
969        Ok(true)
970    }
971}
972
973/// Parse an RFC3339 timestamp and return the delay in seconds from then to `now`.
974fn propagation_delay_secs(now: &DateTime<Utc>, generated_at: &str) -> Option<f64> {
975    let generated = DateTime::parse_from_rfc3339(generated_at).ok()?;
976    let delay = (*now - generated.with_timezone(&Utc)).num_milliseconds() as f64 / 1000.0;
977    Some(delay.max(0.0))
978}
979
980/// Per-call jitter in `[0, 1s)` nanoseconds, derived from the address of a
981/// stack local. Different threads have different stack bases, so the value
982/// differs across threads; the same thread + call site stays roughly stable.
983/// Cheap (~5 ns) and avoids `thread_local`.
984fn stack_jitter_ns() -> u64 {
985    let local = 0u8;
986    let addr = &local as *const u8 as usize as u64;
987    addr.wrapping_mul(0x9E37_79B9_7F4A_7C15) % 1_000_000_000
988}
989
990#[cfg(test)]
991mod tests {
992    use super::*;
993    use std::sync::Mutex;
994    use tempfile::TempDir;
995
996    fn create_test_schema(temp_dir: &TempDir, namespace: &str, schema_json: &str) -> PathBuf {
997        let schema_dir = temp_dir.path().join(namespace);
998        fs::create_dir_all(&schema_dir).unwrap();
999        let schema_file = schema_dir.join("schema.json");
1000        fs::write(&schema_file, schema_json).unwrap();
1001        schema_file
1002    }
1003
1004    fn create_test_schema_with_values(
1005        temp_dir: &TempDir,
1006        namespace: &str,
1007        schema_json: &str,
1008        values_json: &str,
1009    ) -> (PathBuf, PathBuf) {
1010        let schemas_dir = temp_dir.path().join("schemas");
1011        let values_dir = temp_dir.path().join("values");
1012
1013        let schema_dir = schemas_dir.join(namespace);
1014        fs::create_dir_all(&schema_dir).unwrap();
1015        let schema_file = schema_dir.join("schema.json");
1016        fs::write(&schema_file, schema_json).unwrap();
1017
1018        let ns_values_dir = values_dir.join(namespace);
1019        fs::create_dir_all(&ns_values_dir).unwrap();
1020        let values_file = ns_values_dir.join("values.json");
1021        fs::write(&values_file, values_json).unwrap();
1022
1023        (schemas_dir, values_dir)
1024    }
1025
1026    #[test]
1027    fn test_feature_property_matches_meta_schema() {
1028        // Fails if the shape drifts from what the meta-schema requires.
1029        let schema = json!({
1030            "version": "1.0",
1031            "type": "object",
1032            "properties": { "feature.red-bar": feature_property() },
1033        })
1034        .to_string();
1035        SchemaRegistry::from_schemas(&[("getsentry-features", &schema)]).unwrap();
1036    }
1037
1038    /// A namespace declaring features by pattern rather than by name.
1039    fn open_features_schema() -> String {
1040        json!({
1041            "version": "1.0",
1042            "type": "object",
1043            "patternProperties": { "^feature\\.": feature_property() },
1044        })
1045        .to_string()
1046    }
1047
1048    fn valid_feature_value() -> Value {
1049        json!({
1050            "created_at": "2026-01-01",
1051            "owner": {"team": "dev-infra"},
1052            "segments": [{"name": "all", "conditions": [], "rollout": 100}],
1053        })
1054    }
1055
1056    #[test]
1057    fn test_open_features_accepts_any_feature_key() {
1058        let registry = SchemaRegistry::from_schemas(&[("gs", &open_features_schema())]).unwrap();
1059        let schema = registry.get("gs").unwrap();
1060
1061        // Any feature name is declared by the pattern, so it survives loading and
1062        // validates against the spliced-in Feature definition.
1063        assert!(schema.accepts_key("feature.organizations:never-enumerated"));
1064        assert!(schema.is_known_key("feature.organizations:never-enumerated"));
1065        registry
1066            .validate_values(
1067                "gs",
1068                &json!({"feature.organizations:never-enumerated": valid_feature_value()}),
1069            )
1070            .unwrap();
1071    }
1072
1073    #[test]
1074    fn test_open_features_still_validates_shape() {
1075        let registry = SchemaRegistry::from_schemas(&[("gs", &open_features_schema())]).unwrap();
1076        // `segments` is required by the Feature definition: an open namespace accepts
1077        // any feature *name*, not any feature *value*.
1078        let result = registry.validate_values(
1079            "gs",
1080            &json!({"feature.organizations:bad": {"created_at": "2026-01-01"}}),
1081        );
1082        assert!(result.is_err(), "expected a Feature shape violation");
1083    }
1084
1085    #[test]
1086    fn test_open_features_does_not_accept_non_feature_keys() {
1087        let registry = SchemaRegistry::from_schemas(&[("gs", &open_features_schema())]).unwrap();
1088        let schema = registry.get("gs").unwrap();
1089
1090        assert!(!schema.accepts_key("some.option"));
1091        let stripped = registry.strip_unknown_keys(
1092            "gs",
1093            &json!({
1094                "feature.organizations:keep": valid_feature_value(),
1095                "some.option": 1,
1096            }),
1097        );
1098        let obj = stripped.as_object().unwrap();
1099        assert!(obj.contains_key("feature.organizations:keep"));
1100        assert!(!obj.contains_key("some.option"));
1101    }
1102
1103    #[test]
1104    fn test_enumerated_features_still_reject_undeclared_names() {
1105        let schema = json!({
1106            "version": "1.0",
1107            "type": "object",
1108            "properties": { "feature.organizations:declared": feature_property() },
1109        })
1110        .to_string();
1111        let registry = SchemaRegistry::from_schemas(&[("gs", &schema)]).unwrap();
1112        let schema = registry.get("gs").unwrap();
1113
1114        assert!(schema.accepts_key("feature.organizations:declared"));
1115        assert!(!schema.accepts_key("feature.organizations:undeclared"));
1116    }
1117
1118    #[test]
1119    fn test_validate_k8s_name_component_valid() {
1120        assert!(validate_k8s_name_component("relay", "namespace").is_ok());
1121        assert!(validate_k8s_name_component("my-service", "namespace").is_ok());
1122        assert!(validate_k8s_name_component("a1-b2-c3", "namespace").is_ok());
1123        assert!(validate_k8s_name_component(&"a".repeat(63), "namespace").is_ok());
1124    }
1125
1126    #[test]
1127    fn test_validate_k8s_name_component_rejects_uppercase() {
1128        let result = validate_k8s_name_component("MyService", "namespace");
1129        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
1130        assert!(result.unwrap_err().to_string().contains("'M' not allowed"));
1131    }
1132
1133    #[test]
1134    fn test_validate_k8s_name_component_rejects_underscore() {
1135        let result = validate_k8s_name_component("my_service", "target");
1136        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
1137        assert!(result.unwrap_err().to_string().contains("'_' not allowed"));
1138    }
1139
1140    #[test]
1141    fn test_validate_k8s_name_component_rejects_leading_hyphen() {
1142        let result = validate_k8s_name_component("-service", "namespace");
1143        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
1144        assert!(
1145            result
1146                .unwrap_err()
1147                .to_string()
1148                .contains("start and end with alphanumeric")
1149        );
1150    }
1151
1152    #[test]
1153    fn test_validate_k8s_name_component_rejects_dot() {
1154        let result = validate_k8s_name_component("my.service", "namespace");
1155        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
1156        assert!(result.unwrap_err().to_string().contains("'.' not allowed"));
1157    }
1158
1159    #[test]
1160    fn test_validate_k8s_name_component_rejects_too_long() {
1161        let result = validate_k8s_name_component(&"a".repeat(64), "namespace");
1162        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
1163        assert!(result.unwrap_err().to_string().contains("at most 63"));
1164    }
1165
1166    #[test]
1167    fn test_load_schema_valid() {
1168        let temp_dir = TempDir::new().unwrap();
1169        create_test_schema(
1170            &temp_dir,
1171            "test",
1172            r#"{
1173                "version": "1.0",
1174                "type": "object",
1175                "properties": {
1176                    "test-key": {
1177                        "type": "string",
1178                        "default": "test",
1179                        "description": "Test option"
1180                    }
1181                }
1182            }"#,
1183        );
1184
1185        SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1186    }
1187
1188    #[test]
1189    fn test_load_schema_missing_version() {
1190        let temp_dir = TempDir::new().unwrap();
1191        create_test_schema(
1192            &temp_dir,
1193            "test",
1194            r#"{
1195                "type": "object",
1196                "properties": {}
1197            }"#,
1198        );
1199
1200        let result = SchemaRegistry::from_directory(temp_dir.path());
1201        assert!(result.is_err());
1202        match result {
1203            Err(ValidationError::SchemaError { message, .. }) => {
1204                assert!(message.contains(
1205                    "Schema validation failed:
1206Error: \"version\" is a required property"
1207                ));
1208            }
1209            _ => panic!("Expected SchemaError for missing version"),
1210        }
1211    }
1212
1213    #[test]
1214    fn test_unknown_namespace() {
1215        let temp_dir = TempDir::new().unwrap();
1216        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1217
1218        let result = registry.validate_values("unknown", &json!({}));
1219        assert!(matches!(result, Err(ValidationError::UnknownNamespace(..))));
1220    }
1221
1222    #[test]
1223    fn test_multiple_namespaces() {
1224        let temp_dir = TempDir::new().unwrap();
1225        create_test_schema(
1226            &temp_dir,
1227            "ns1",
1228            r#"{
1229                "version": "1.0",
1230                "type": "object",
1231                "properties": {
1232                    "opt1": {
1233                        "type": "string",
1234                        "default": "default1",
1235                        "description": "First option"
1236                    }
1237                }
1238            }"#,
1239        );
1240        create_test_schema(
1241            &temp_dir,
1242            "ns2",
1243            r#"{
1244                "version": "2.0",
1245                "type": "object",
1246                "properties": {
1247                    "opt2": {
1248                        "type": "integer",
1249                        "default": 42,
1250                        "description": "Second option"
1251                    }
1252                }
1253            }"#,
1254        );
1255
1256        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1257        assert!(registry.schemas.contains_key("ns1"));
1258        assert!(registry.schemas.contains_key("ns2"));
1259    }
1260
1261    #[test]
1262    fn test_invalid_default_type() {
1263        let temp_dir = TempDir::new().unwrap();
1264        create_test_schema(
1265            &temp_dir,
1266            "test",
1267            r#"{
1268                "version": "1.0",
1269                "type": "object",
1270                "properties": {
1271                    "bad-default": {
1272                        "type": "integer",
1273                        "default": "not-a-number",
1274                        "description": "A bad default value"
1275                    }
1276                }
1277            }"#,
1278        );
1279
1280        let result = SchemaRegistry::from_directory(temp_dir.path());
1281        assert!(result.is_err());
1282        match result {
1283            Err(ValidationError::SchemaError { message, .. }) => {
1284                assert!(
1285                    message.contains("Property 'bad-default': default value does not match schema")
1286                );
1287                assert!(message.contains("\"not-a-number\" is not of type \"integer\""));
1288            }
1289            _ => panic!("Expected SchemaError for invalid default type"),
1290        }
1291    }
1292
1293    #[test]
1294    fn test_extra_properties() {
1295        let temp_dir = TempDir::new().unwrap();
1296        create_test_schema(
1297            &temp_dir,
1298            "test",
1299            r#"{
1300                "version": "1.0",
1301                "type": "object",
1302                "properties": {
1303                    "bad-property": {
1304                        "type": "integer",
1305                        "default": 0,
1306                        "description": "Test property",
1307                        "extra": "property"
1308                    }
1309                }
1310            }"#,
1311        );
1312
1313        let result = SchemaRegistry::from_directory(temp_dir.path());
1314        assert!(result.is_err());
1315        match result {
1316            Err(ValidationError::SchemaError { message, .. }) => {
1317                assert!(
1318                    message
1319                        .contains("Additional properties are not allowed ('extra' was unexpected)")
1320                );
1321            }
1322            _ => panic!("Expected SchemaError for extra properties"),
1323        }
1324    }
1325
1326    #[test]
1327    fn test_missing_description() {
1328        let temp_dir = TempDir::new().unwrap();
1329        create_test_schema(
1330            &temp_dir,
1331            "test",
1332            r#"{
1333                "version": "1.0",
1334                "type": "object",
1335                "properties": {
1336                    "missing-desc": {
1337                        "type": "string",
1338                        "default": "test"
1339                    }
1340                }
1341            }"#,
1342        );
1343
1344        let result = SchemaRegistry::from_directory(temp_dir.path());
1345        assert!(result.is_err());
1346        match result {
1347            Err(ValidationError::SchemaError { message, .. }) => {
1348                assert!(message.contains("\"description\" is a required property"));
1349            }
1350            _ => panic!("Expected SchemaError for missing description"),
1351        }
1352    }
1353
1354    #[test]
1355    fn test_from_schemas_rejects_duplicate_namespace() {
1356        let schema_a = r#"{
1357            "version": "1.0",
1358            "type": "object",
1359            "properties": {
1360                "opt": {"type": "string", "default": "a", "description": "A"}
1361            }
1362        }"#;
1363        let schema_b = r#"{
1364            "version": "1.0",
1365            "type": "object",
1366            "properties": {
1367                "opt": {"type": "string", "default": "b", "description": "B"}
1368            }
1369        }"#;
1370
1371        let result = SchemaRegistry::from_schemas(&[("test", schema_a), ("test", schema_b)]);
1372        match result {
1373            Err(ValidationError::SchemaError { message, .. }) => {
1374                assert!(message.contains("Duplicate namespace 'test'"));
1375            }
1376            _ => panic!("Expected SchemaError for duplicate namespace"),
1377        }
1378    }
1379
1380    #[test]
1381    fn test_invalid_directory_structure() {
1382        let temp_dir = TempDir::new().unwrap();
1383        // Create a namespace directory without schema.json file
1384        let schema_dir = temp_dir.path().join("missing-schema");
1385        fs::create_dir_all(&schema_dir).unwrap();
1386
1387        let result = SchemaRegistry::from_directory(temp_dir.path());
1388        assert!(result.is_err());
1389        match result {
1390            Err(ValidationError::FileRead(..)) => {
1391                // Expected error when schema.json file is missing
1392            }
1393            _ => panic!("Expected FileRead error for missing schema.json"),
1394        }
1395    }
1396
1397    #[test]
1398    fn test_get_default() {
1399        let temp_dir = TempDir::new().unwrap();
1400        create_test_schema(
1401            &temp_dir,
1402            "test",
1403            r#"{
1404                "version": "1.0",
1405                "type": "object",
1406                "properties": {
1407                    "string_opt": {
1408                        "type": "string",
1409                        "default": "hello",
1410                        "description": "A string option"
1411                    },
1412                    "int_opt": {
1413                        "type": "integer",
1414                        "default": 42,
1415                        "description": "An integer option"
1416                    }
1417                }
1418            }"#,
1419        );
1420
1421        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1422        let schema = registry.get("test").unwrap();
1423
1424        assert_eq!(schema.get_default("string_opt"), Some(&json!("hello")));
1425        assert_eq!(schema.get_default("int_opt"), Some(&json!(42)));
1426        assert_eq!(schema.get_default("unknown"), None);
1427    }
1428
1429    #[test]
1430    fn test_validate_values_valid() {
1431        let temp_dir = TempDir::new().unwrap();
1432        create_test_schema(
1433            &temp_dir,
1434            "test",
1435            r#"{
1436                "version": "1.0",
1437                "type": "object",
1438                "properties": {
1439                    "enabled": {
1440                        "type": "boolean",
1441                        "default": false,
1442                        "description": "Enable feature"
1443                    }
1444                }
1445            }"#,
1446        );
1447
1448        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1449        let result = registry.validate_values("test", &json!({"enabled": true}));
1450        assert!(result.is_ok());
1451    }
1452
1453    #[test]
1454    fn test_validate_values_invalid_type() {
1455        let temp_dir = TempDir::new().unwrap();
1456        create_test_schema(
1457            &temp_dir,
1458            "test",
1459            r#"{
1460                "version": "1.0",
1461                "type": "object",
1462                "properties": {
1463                    "count": {
1464                        "type": "integer",
1465                        "default": 0,
1466                        "description": "Count"
1467                    }
1468                }
1469            }"#,
1470        );
1471
1472        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1473        let result = registry.validate_values("test", &json!({"count": "not a number"}));
1474        assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1475    }
1476
1477    #[test]
1478    fn test_validate_values_unknown_option() {
1479        let temp_dir = TempDir::new().unwrap();
1480        create_test_schema(
1481            &temp_dir,
1482            "test",
1483            r#"{
1484                "version": "1.0",
1485                "type": "object",
1486                "properties": {
1487                    "known_option": {
1488                        "type": "string",
1489                        "default": "default",
1490                        "description": "A known option"
1491                    }
1492                }
1493            }"#,
1494        );
1495
1496        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1497
1498        // Valid known option should pass
1499        let result = registry.validate_values("test", &json!({"known_option": "value"}));
1500        assert!(result.is_ok());
1501
1502        // Unknown option should fail
1503        let result = registry.validate_values("test", &json!({"unknown_option": "value"}));
1504        assert!(result.is_err());
1505        match result {
1506            Err(ValidationError::ValueError { errors, .. }) => {
1507                assert!(errors.contains("Additional properties are not allowed"));
1508            }
1509            _ => panic!("Expected ValueError for unknown option"),
1510        }
1511    }
1512
1513    #[test]
1514    fn test_object_with_additional_properties() {
1515        let temp_dir = TempDir::new().unwrap();
1516        create_test_schema(
1517            &temp_dir,
1518            "test",
1519            r#"{
1520                "version": "1.0",
1521                "type": "object",
1522                "properties": {
1523                    "scopes": {
1524                        "type": "object",
1525                        "additionalProperties": {"type": "string"},
1526                        "default": {},
1527                        "description": "A dynamic string-to-string map"
1528                    }
1529                }
1530            }"#,
1531        );
1532
1533        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1534
1535        assert!(
1536            registry
1537                .validate_values("test", &json!({"scopes": {}}))
1538                .is_ok()
1539        );
1540        assert!(
1541            registry
1542                .validate_values(
1543                    "test",
1544                    &json!({"scopes": {"read": "true", "write": "false"}})
1545                )
1546                .is_ok()
1547        );
1548        assert!(matches!(
1549            registry.validate_values("test", &json!({"scopes": {"read": 42}})),
1550            Err(ValidationError::ValueError { .. })
1551        ));
1552    }
1553
1554    #[test]
1555    fn test_object_without_additional_properties_still_rejects_unknown_keys() {
1556        // Structured object schemas (with properties, no additionalProperties) must
1557        // still reject unknown keys after the fix.
1558        let temp_dir = TempDir::new().unwrap();
1559        create_test_schema(
1560            &temp_dir,
1561            "test",
1562            r#"{
1563                "version": "1.0",
1564                "type": "object",
1565                "properties": {
1566                    "config": {
1567                        "type": "object",
1568                        "properties": {
1569                            "host": {"type": "string"}
1570                        },
1571                        "default": {"host": "localhost"},
1572                        "description": "Server config"
1573                    }
1574                }
1575            }"#,
1576        );
1577
1578        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1579
1580        // Known key is valid
1581        let result = registry.validate_values("test", &json!({"config": {"host": "example.com"}}));
1582        assert!(result.is_ok());
1583
1584        // Unknown key must fail
1585        let result = registry.validate_values(
1586            "test",
1587            &json!({"config": {"host": "example.com", "unknown": "x"}}),
1588        );
1589        assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1590    }
1591
1592    #[test]
1593    fn test_object_with_fixed_properties_and_additional_properties_enforces_required() {
1594        // A schema that has both fixed properties and additionalProperties should still
1595        // enforce required on the declared fields.
1596        let temp_dir = TempDir::new().unwrap();
1597        create_test_schema(
1598            &temp_dir,
1599            "test",
1600            r#"{
1601                "version": "1.0",
1602                "type": "object",
1603                "properties": {
1604                    "config": {
1605                        "type": "object",
1606                        "properties": {
1607                            "name": {"type": "string"}
1608                        },
1609                        "additionalProperties": {"type": "string"},
1610                        "default": {"name": "default"},
1611                        "description": "Config with fixed and dynamic keys"
1612                    }
1613                }
1614            }"#,
1615        );
1616
1617        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1618
1619        // Fixed field present, extra dynamic keys allowed
1620        let result =
1621            registry.validate_values("test", &json!({"config": {"name": "x", "extra": "y"}}));
1622        assert!(result.is_ok());
1623
1624        // Missing required fixed field must fail
1625        let result = registry.validate_values("test", &json!({"config": {"extra": "y"}}));
1626        assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1627    }
1628
1629    #[test]
1630    fn test_load_values_json_valid() {
1631        let temp_dir = TempDir::new().unwrap();
1632        let schemas_dir = temp_dir.path().join("schemas");
1633        let values_dir = temp_dir.path().join("values");
1634
1635        let schema_dir = schemas_dir.join("test");
1636        fs::create_dir_all(&schema_dir).unwrap();
1637        fs::write(
1638            schema_dir.join("schema.json"),
1639            r#"{
1640                "version": "1.0",
1641                "type": "object",
1642                "properties": {
1643                    "enabled": {
1644                        "type": "boolean",
1645                        "default": false,
1646                        "description": "Enable feature"
1647                    },
1648                    "name": {
1649                        "type": "string",
1650                        "default": "default",
1651                        "description": "Name"
1652                    },
1653                    "count": {
1654                        "type": "integer",
1655                        "default": 0,
1656                        "description": "Count"
1657                    },
1658                    "rate": {
1659                        "type": "number",
1660                        "default": 0.0,
1661                        "description": "Rate"
1662                    }
1663                }
1664            }"#,
1665        )
1666        .unwrap();
1667
1668        let test_values_dir = values_dir.join("test");
1669        fs::create_dir_all(&test_values_dir).unwrap();
1670        fs::write(
1671            test_values_dir.join("values.json"),
1672            r#"{
1673                "options": {
1674                    "enabled": true,
1675                    "name": "test-name",
1676                    "count": 42,
1677                    "rate": 0.75
1678                }
1679            }"#,
1680        )
1681        .unwrap();
1682
1683        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1684        let (values, generated_at_by_namespace) = registry.load_values_json(&values_dir).unwrap();
1685
1686        assert_eq!(values.len(), 1);
1687        assert_eq!(values["test"]["enabled"], json!(true));
1688        assert_eq!(values["test"]["name"], json!("test-name"));
1689        assert_eq!(values["test"]["count"], json!(42));
1690        assert_eq!(values["test"]["rate"], json!(0.75));
1691        assert!(generated_at_by_namespace.is_empty());
1692    }
1693
1694    #[test]
1695    fn test_load_values_json_nonexistent_dir() {
1696        let temp_dir = TempDir::new().unwrap();
1697        create_test_schema(
1698            &temp_dir,
1699            "test",
1700            r#"{"version": "1.0", "type": "object", "properties": {}}"#,
1701        );
1702
1703        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1704        let (values, generated_at_by_namespace) = registry
1705            .load_values_json(&temp_dir.path().join("nonexistent"))
1706            .unwrap();
1707
1708        // No values.json files found, returns empty
1709        assert!(values.is_empty());
1710        assert!(generated_at_by_namespace.is_empty());
1711    }
1712
1713    #[test]
1714    fn test_load_values_json_strips_unknown_keys() {
1715        let temp_dir = TempDir::new().unwrap();
1716        let schemas_dir = temp_dir.path().join("schemas");
1717        let values_dir = temp_dir.path().join("values");
1718
1719        let schema_dir = schemas_dir.join("test");
1720        fs::create_dir_all(&schema_dir).unwrap();
1721        fs::write(
1722            schema_dir.join("schema.json"),
1723            r#"{
1724                "version": "1.0",
1725                "type": "object",
1726                "properties": {
1727                    "known-option": {
1728                        "type": "string",
1729                        "default": "default",
1730                        "description": "A known option"
1731                    }
1732                }
1733            }"#,
1734        )
1735        .unwrap();
1736
1737        let test_values_dir = values_dir.join("test");
1738        fs::create_dir_all(&test_values_dir).unwrap();
1739        fs::write(
1740            test_values_dir.join("values.json"),
1741            r#"{
1742                "options": {
1743                    "known-option": "hello",
1744                    "unknown-option": "should be stripped"
1745                }
1746            }"#,
1747        )
1748        .unwrap();
1749
1750        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1751        let (values, _) = registry.load_values_json(&values_dir).unwrap();
1752
1753        assert_eq!(values["test"]["known-option"], json!("hello"));
1754        assert!(!values["test"].contains_key("unknown-option"));
1755    }
1756
1757    #[test]
1758    fn test_load_values_json_skips_missing_values_file() {
1759        let temp_dir = TempDir::new().unwrap();
1760        let schemas_dir = temp_dir.path().join("schemas");
1761        let values_dir = temp_dir.path().join("values");
1762
1763        // Create two schemas
1764        let schema_dir1 = schemas_dir.join("with-values");
1765        fs::create_dir_all(&schema_dir1).unwrap();
1766        fs::write(
1767            schema_dir1.join("schema.json"),
1768            r#"{
1769                "version": "1.0",
1770                "type": "object",
1771                "properties": {
1772                    "opt": {"type": "string", "default": "x", "description": "Opt"}
1773                }
1774            }"#,
1775        )
1776        .unwrap();
1777
1778        let schema_dir2 = schemas_dir.join("without-values");
1779        fs::create_dir_all(&schema_dir2).unwrap();
1780        fs::write(
1781            schema_dir2.join("schema.json"),
1782            r#"{
1783                "version": "1.0",
1784                "type": "object",
1785                "properties": {
1786                    "opt": {"type": "string", "default": "x", "description": "Opt"}
1787                }
1788            }"#,
1789        )
1790        .unwrap();
1791
1792        // Only create values for one namespace
1793        let with_values_dir = values_dir.join("with-values");
1794        fs::create_dir_all(&with_values_dir).unwrap();
1795        fs::write(
1796            with_values_dir.join("values.json"),
1797            r#"{"options": {"opt": "y"}}"#,
1798        )
1799        .unwrap();
1800
1801        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1802        let (values, _) = registry.load_values_json(&values_dir).unwrap();
1803
1804        assert_eq!(values.len(), 1);
1805        assert!(values.contains_key("with-values"));
1806        assert!(!values.contains_key("without-values"));
1807    }
1808
1809    #[test]
1810    fn test_load_values_json_extracts_generated_at() {
1811        let temp_dir = TempDir::new().unwrap();
1812        let schemas_dir = temp_dir.path().join("schemas");
1813        let values_dir = temp_dir.path().join("values");
1814
1815        let schema_dir = schemas_dir.join("test");
1816        fs::create_dir_all(&schema_dir).unwrap();
1817        fs::write(
1818            schema_dir.join("schema.json"),
1819            r#"{
1820                "version": "1.0",
1821                "type": "object",
1822                "properties": {
1823                    "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1824                }
1825            }"#,
1826        )
1827        .unwrap();
1828
1829        let test_values_dir = values_dir.join("test");
1830        fs::create_dir_all(&test_values_dir).unwrap();
1831        fs::write(
1832            test_values_dir.join("values.json"),
1833            r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T18:30:00.123456+00:00"}"#,
1834        )
1835        .unwrap();
1836
1837        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1838        let (values, generated_at_by_namespace) = registry.load_values_json(&values_dir).unwrap();
1839
1840        assert_eq!(values["test"]["enabled"], json!(true));
1841        assert_eq!(
1842            generated_at_by_namespace.get("test"),
1843            Some(&"2024-01-21T18:30:00.123456+00:00".to_string())
1844        );
1845    }
1846
1847    #[test]
1848    fn test_propagation_event_emitted_on_generated_at_change() {
1849        let temp_dir = TempDir::new().unwrap();
1850        let schemas_dir = temp_dir.path().join("schemas");
1851        let values_dir = temp_dir.path().join("values");
1852
1853        let schema_dir = schemas_dir.join("test");
1854        fs::create_dir_all(&schema_dir).unwrap();
1855        fs::write(
1856            schema_dir.join("schema.json"),
1857            r#"{
1858                "version": "1.0",
1859                "type": "object",
1860                "properties": {
1861                    "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1862                }
1863            }"#,
1864        )
1865        .unwrap();
1866
1867        let test_values_dir = values_dir.join("test");
1868        fs::create_dir_all(&test_values_dir).unwrap();
1869        fs::write(
1870            test_values_dir.join("values.json"),
1871            r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T18:30:00+00:00"}"#,
1872        )
1873        .unwrap();
1874
1875        let events: Arc<Mutex<Vec<(String, f64)>>> = Arc::new(Mutex::new(Vec::new()));
1876        let events_clone = events.clone();
1877
1878        let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1879        let store = ValuesStore::builder(registry, &values_dir)
1880            .with_refresh_threshold(Duration::ZERO)
1881            .with_callback(Box::new(move |ns, delay| {
1882                events_clone.lock().unwrap().push((ns.to_string(), delay));
1883            }))
1884            .build()
1885            .unwrap();
1886
1887        // Initial load doesn't emit (generated_at set during construction).
1888        assert!(events.lock().unwrap().is_empty());
1889
1890        // Same generated_at — no event on reload.
1891        let _ = store.load();
1892        assert!(events.lock().unwrap().is_empty());
1893
1894        // Update the generated_at timestamp — should emit on next load.
1895        fs::write(
1896            test_values_dir.join("values.json"),
1897            r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T19:00:00+00:00"}"#,
1898        )
1899        .unwrap();
1900
1901        let _ = store.load();
1902        let captured = events.lock().unwrap();
1903        assert_eq!(captured.len(), 1);
1904        assert_eq!(captured[0].0, "test");
1905        assert!(captured[0].1 > 0.0);
1906    }
1907
1908    #[test]
1909    fn test_propagation_event_not_emitted_without_callback() {
1910        let temp_dir = TempDir::new().unwrap();
1911        let schemas_dir = temp_dir.path().join("schemas");
1912        let values_dir = temp_dir.path().join("values");
1913
1914        let schema_dir = schemas_dir.join("test");
1915        fs::create_dir_all(&schema_dir).unwrap();
1916        fs::write(
1917            schema_dir.join("schema.json"),
1918            r#"{
1919                "version": "1.0",
1920                "type": "object",
1921                "properties": {
1922                    "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1923                }
1924            }"#,
1925        )
1926        .unwrap();
1927
1928        let test_values_dir = values_dir.join("test");
1929        fs::create_dir_all(&test_values_dir).unwrap();
1930        fs::write(
1931            test_values_dir.join("values.json"),
1932            r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T18:30:00+00:00"}"#,
1933        )
1934        .unwrap();
1935
1936        let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1937        // No callback — should not panic or error.
1938        let store = ValuesStore::builder(registry, &values_dir)
1939            .with_refresh_threshold(Duration::ZERO)
1940            .build()
1941            .unwrap();
1942        let _ = store.load();
1943        // Just verify it doesn't crash.
1944    }
1945
1946    /// Write a boolean-only schema and a values file for `namespace` under
1947    /// `base`, returning the path of the written `values.json`.
1948    fn write_ns(base: &Path, namespace: &str, generated_at: &str) -> PathBuf {
1949        let schema_dir = base.join("schemas").join(namespace);
1950        fs::create_dir_all(&schema_dir).unwrap();
1951        fs::write(
1952            schema_dir.join("schema.json"),
1953            r#"{
1954                "version": "1.0",
1955                "type": "object",
1956                "properties": {
1957                    "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1958                }
1959            }"#,
1960        )
1961        .unwrap();
1962
1963        let ns_values_dir = base.join("values").join(namespace);
1964        fs::create_dir_all(&ns_values_dir).unwrap();
1965        let values_file = ns_values_dir.join("values.json");
1966        fs::write(
1967            &values_file,
1968            format!(r#"{{"options": {{"enabled": true}}, "generated_at": "{generated_at}"}}"#),
1969        )
1970        .unwrap();
1971        values_file
1972    }
1973
1974    #[test]
1975    fn test_propagation_delay_secs_parsing() {
1976        let now: DateTime<Utc> = "2024-01-21T19:00:00+00:00".parse().unwrap();
1977
1978        // Normal case: 30 minutes elapsed.
1979        assert_eq!(
1980            propagation_delay_secs(&now, "2024-01-21T18:30:00+00:00"),
1981            Some(1800.0)
1982        );
1983        // Clock skew (generated_at in the future) clamps to 0 rather than going negative.
1984        assert_eq!(
1985            propagation_delay_secs(&now, "2024-01-21T19:05:00+00:00"),
1986            Some(0.0)
1987        );
1988        // Malformed timestamp returns None.
1989        assert_eq!(propagation_delay_secs(&now, "not-a-timestamp"), None);
1990    }
1991
1992    #[test]
1993    fn test_propagation_callback_panic_is_caught() {
1994        let temp_dir = TempDir::new().unwrap();
1995        let base = temp_dir.path();
1996        let values_file = write_ns(base, "test", "2024-01-21T18:30:00+00:00");
1997
1998        let registry = Arc::new(SchemaRegistry::from_directory(&base.join("schemas")).unwrap());
1999        let store = ValuesStore::builder(registry, &base.join("values"))
2000            .with_refresh_threshold(Duration::ZERO)
2001            .with_callback(Box::new(|_ns, _delay| panic!("callback boom")))
2002            .build()
2003            .unwrap();
2004
2005        // Trigger a change so the panicking callback fires; load() must not unwind.
2006        fs::write(
2007            &values_file,
2008            r#"{"options": {"enabled": false}, "generated_at": "2024-01-21T19:00:00+00:00"}"#,
2009        )
2010        .unwrap();
2011        let _ = store.load();
2012
2013        // The values refresh still applied despite the callback panic.
2014        assert_eq!(
2015            store.values.load().get("test").unwrap().get("enabled"),
2016            Some(&json!(false))
2017        );
2018    }
2019
2020    #[test]
2021    fn test_propagation_malformed_generated_at_does_not_emit() {
2022        let temp_dir = TempDir::new().unwrap();
2023        let base = temp_dir.path();
2024        let values_file = write_ns(base, "test", "2024-01-21T18:30:00+00:00");
2025
2026        let events: Arc<Mutex<Vec<(String, f64)>>> = Arc::new(Mutex::new(Vec::new()));
2027        let events_clone = events.clone();
2028        let registry = Arc::new(SchemaRegistry::from_directory(&base.join("schemas")).unwrap());
2029        let store = ValuesStore::builder(registry, &base.join("values"))
2030            .with_refresh_threshold(Duration::ZERO)
2031            .with_callback(Box::new(move |ns, delay| {
2032                events_clone.lock().unwrap().push((ns.to_string(), delay));
2033            }))
2034            .build()
2035            .unwrap();
2036
2037        // Change generated_at to a malformed value: detected as a change, but
2038        // unparseable, so no event is emitted.
2039        fs::write(
2040            &values_file,
2041            r#"{"options": {"enabled": true}, "generated_at": "garbage"}"#,
2042        )
2043        .unwrap();
2044        let _ = store.load();
2045        assert!(events.lock().unwrap().is_empty());
2046    }
2047
2048    #[test]
2049    fn test_propagation_only_changed_namespace_emits() {
2050        let temp_dir = TempDir::new().unwrap();
2051        let base = temp_dir.path();
2052        write_ns(base, "alpha", "2024-01-21T18:00:00+00:00");
2053        let beta_values = write_ns(base, "beta", "2024-01-21T18:00:00+00:00");
2054
2055        let events: Arc<Mutex<Vec<(String, f64)>>> = Arc::new(Mutex::new(Vec::new()));
2056        let events_clone = events.clone();
2057        let registry = Arc::new(SchemaRegistry::from_directory(&base.join("schemas")).unwrap());
2058        let store = ValuesStore::builder(registry, &base.join("values"))
2059            .with_refresh_threshold(Duration::ZERO)
2060            .with_callback(Box::new(move |ns, delay| {
2061                events_clone.lock().unwrap().push((ns.to_string(), delay));
2062            }))
2063            .build()
2064            .unwrap();
2065
2066        // Only beta's generated_at changes; alpha is untouched.
2067        fs::write(
2068            &beta_values,
2069            r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T19:00:00+00:00"}"#,
2070        )
2071        .unwrap();
2072        let _ = store.load();
2073
2074        let captured = events.lock().unwrap();
2075        assert_eq!(captured.len(), 1);
2076        assert_eq!(captured[0].0, "beta");
2077    }
2078
2079    #[test]
2080    fn test_load_values_json_rejects_wrong_type() {
2081        let temp_dir = TempDir::new().unwrap();
2082        let schemas_dir = temp_dir.path().join("schemas");
2083        let values_dir = temp_dir.path().join("values");
2084
2085        let schema_dir = schemas_dir.join("test");
2086        fs::create_dir_all(&schema_dir).unwrap();
2087        fs::write(
2088            schema_dir.join("schema.json"),
2089            r#"{
2090                "version": "1.0",
2091                "type": "object",
2092                "properties": {
2093                    "count": {"type": "integer", "default": 0, "description": "Count"}
2094                }
2095            }"#,
2096        )
2097        .unwrap();
2098
2099        let test_values_dir = values_dir.join("test");
2100        fs::create_dir_all(&test_values_dir).unwrap();
2101        fs::write(
2102            test_values_dir.join("values.json"),
2103            r#"{"options": {"count": "not-a-number"}}"#,
2104        )
2105        .unwrap();
2106
2107        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2108        let result = registry.load_values_json(&values_dir);
2109
2110        assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2111    }
2112
2113    mod feature_flag_tests {
2114        use super::*;
2115
2116        const FEATURE_SCHEMA: &str = r##"{
2117            "version": "1.0",
2118            "type": "object",
2119            "properties": {
2120                "feature.organizations:fury-mode": {
2121                  "$ref": "#/definitions/Feature"
2122                }
2123            }
2124        }"##;
2125
2126        #[test]
2127        fn test_schema_with_valid_feature_flag() {
2128            let temp_dir = TempDir::new().unwrap();
2129            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2130            assert!(SchemaRegistry::from_directory(temp_dir.path()).is_ok());
2131        }
2132
2133        #[test]
2134        fn test_schema_with_feature_and_regular_option() {
2135            let temp_dir = TempDir::new().unwrap();
2136            create_test_schema(
2137                &temp_dir,
2138                "test",
2139                r##"{
2140                    "version": "1.0",
2141                    "type": "object",
2142                    "properties": {
2143                        "my-option": {
2144                            "type": "string",
2145                            "default": "hello",
2146                            "description": "A regular option"
2147                        },
2148                        "feature.organizations:fury-mode": {
2149                            "$ref": "#/definitions/Feature"
2150                        }
2151                    }
2152                }"##,
2153            );
2154            assert!(SchemaRegistry::from_directory(temp_dir.path()).is_ok());
2155        }
2156
2157        #[test]
2158        fn test_schema_with_invalid_feature_definition() {
2159            let temp_dir = TempDir::new().unwrap();
2160
2161            // namespace schema is invalid as feature flag is invalid.
2162            create_test_schema(
2163                &temp_dir,
2164                "test",
2165                r#"{
2166                    "version": "1.0",
2167                    "type": "object",
2168                    "properties": {
2169                        "feature.organizations:fury-mode": {
2170                            "nope": "nope"
2171                        }
2172                    }
2173                }"#,
2174            );
2175            let result = SchemaRegistry::from_directory(temp_dir.path());
2176            assert!(result.is_err());
2177        }
2178
2179        #[test]
2180        fn test_validate_values_with_valid_feature_flag() {
2181            let temp_dir = TempDir::new().unwrap();
2182            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2183            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2184
2185            let result = registry.validate_values(
2186                "test",
2187                &json!({
2188                    "feature.organizations:fury-mode": {
2189                        "owner": {"team": "hybrid-cloud"},
2190                        "segments": [],
2191                        "created_at": "2024-01-01"
2192                    }
2193                }),
2194            );
2195            assert!(result.is_ok());
2196        }
2197
2198        #[test]
2199        fn test_validate_values_with_feature_flag_missing_required_field_fails() {
2200            let temp_dir = TempDir::new().unwrap();
2201            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2202            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2203
2204            // Missing owner field
2205            let result = registry.validate_values(
2206                "test",
2207                &json!({
2208                    "feature.organizations:fury-mode": {
2209                        "segments": [],
2210                        "created_at": "2024-01-01"
2211                    }
2212                }),
2213            );
2214            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2215        }
2216
2217        #[test]
2218        fn test_validate_values_with_feature_flag_invalid_owner_fails() {
2219            let temp_dir = TempDir::new().unwrap();
2220            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2221            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2222
2223            // Owner missing required team field
2224            let result = registry.validate_values(
2225                "test",
2226                &json!({
2227                    "feature.organizations:fury-mode": {
2228                        "owner": {"email": "test@example.com"},
2229                        "segments": [],
2230                        "created_at": "2024-01-01"
2231                    }
2232                }),
2233            );
2234            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2235        }
2236
2237        #[test]
2238        fn test_validate_values_feature_with_segments_and_conditions() {
2239            let temp_dir = TempDir::new().unwrap();
2240            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2241            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2242
2243            let result = registry.validate_values(
2244                "test",
2245                &json!({
2246                    "feature.organizations:fury-mode": {
2247                        "owner": {"team": "hybrid-cloud"},
2248                        "enabled": true,
2249                        "created_at": "2024-01-01T00:00:00",
2250                        "segments": [
2251                            {
2252                                "name": "internal orgs",
2253                                "rollout": 50,
2254                                "conditions": [
2255                                    {
2256                                        "property": "organization_slug",
2257                                        "operator": "in",
2258                                        "value": ["sentry-test", "sentry"]
2259                                    }
2260                                ]
2261                            }
2262                        ]
2263                    }
2264                }),
2265            );
2266            assert!(result.is_ok());
2267        }
2268
2269        #[test]
2270        fn test_validate_values_feature_with_multiple_condition_operators() {
2271            let temp_dir = TempDir::new().unwrap();
2272            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2273            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2274
2275            let result = registry.validate_values(
2276                "test",
2277                &json!({
2278                    "feature.organizations:fury-mode": {
2279                        "owner": {"team": "hybrid-cloud"},
2280                        "created_at": "2024-01-01",
2281                        "segments": [
2282                            {
2283                                "name": "free accounts",
2284                                "conditions": [
2285                                    {
2286                                        "property": "subscription_is_free",
2287                                        "operator": "equals",
2288                                        "value": true
2289                                    }
2290                                ]
2291                            }
2292                        ]
2293                    }
2294                }),
2295            );
2296            assert!(result.is_ok());
2297        }
2298
2299        #[test]
2300        fn test_validate_values_feature_with_invalid_condition_operator_fails() {
2301            let temp_dir = TempDir::new().unwrap();
2302            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2303            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2304
2305            // Use an operator that doesn't exist
2306            let result = registry.validate_values(
2307                "test",
2308                &json!({
2309                    "feature.organizations:fury-mode": {
2310                        "owner": {"team": "hybrid-cloud"},
2311                        "created_at": "2024-01-01",
2312                        "segments": [
2313                            {
2314                                "name": "test segment",
2315                                "conditions": [
2316                                    {
2317                                        "property": "some_prop",
2318                                        "operator": "invalid_operator",
2319                                        "value": "some_value"
2320                                    }
2321                                ]
2322                            }
2323                        ]
2324                    }
2325                }),
2326            );
2327            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2328        }
2329
2330        #[test]
2331        fn test_schema_feature_flag_not_in_options_map() {
2332            // Feature flags are not added to default values
2333            let temp_dir = TempDir::new().unwrap();
2334            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2335            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2336            let schema = registry.get("test").unwrap();
2337
2338            assert!(
2339                schema
2340                    .get_default("feature.organizations:fury-mode")
2341                    .is_none()
2342            );
2343        }
2344
2345        #[test]
2346        fn test_validate_values_feature_and_regular_option_together() {
2347            let temp_dir = TempDir::new().unwrap();
2348            create_test_schema(
2349                &temp_dir,
2350                "test",
2351                r##"{
2352                    "version": "1.0",
2353                    "type": "object",
2354                    "properties": {
2355                        "my-option": {
2356                            "type": "string",
2357                            "default": "hello",
2358                            "description": "A regular option"
2359                        },
2360                        "feature.organizations:fury-mode": {
2361                            "$ref": "#/definitions/Feature"
2362                        }
2363                    }
2364                }"##,
2365            );
2366            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2367
2368            // Both options are valid
2369            let result = registry.validate_values(
2370                "test",
2371                &json!({
2372                    "my-option": "world",
2373                    "feature.organizations:fury-mode": {
2374                        "owner": {"team": "hybrid-cloud"},
2375                        "segments": [],
2376                        "created_at": "2024-01-01"
2377                    }
2378                }),
2379            );
2380            assert!(result.is_ok());
2381        }
2382
2383        #[test]
2384        fn test_feature_flag_is_known_key() {
2385            let temp_dir = TempDir::new().unwrap();
2386            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2387            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2388            let schema = registry.get("test").unwrap();
2389            // make sure it's known...
2390            assert!(schema.is_known_key("feature.organizations:fury-mode"));
2391            // ...but not a runtime option
2392            assert!(
2393                !schema
2394                    .options
2395                    .contains_key("feature.organizations:fury-mode")
2396            );
2397            assert!(!schema.is_known_key("feature.organizations:does-not-exist"));
2398        }
2399
2400        #[test]
2401        fn test_validate_option_accepts_valid_feature() {
2402            // Enables override_options(ns, {"feature.x": <Feature>}) with only the
2403            // schema present (no value in the store).
2404            let temp_dir = TempDir::new().unwrap();
2405            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2406            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2407            let schema = registry.get("test").unwrap();
2408
2409            let result = schema.validate_option(
2410                "feature.organizations:fury-mode",
2411                &json!({
2412                    "owner": {"team": "hybrid-cloud"},
2413                    "segments": [{"name": "all", "rollout": 100, "conditions": []}],
2414                    "created_at": "2024-01-01"
2415                }),
2416            );
2417            assert!(result.is_ok());
2418        }
2419
2420        #[test]
2421        fn test_validate_option_rejects_malformed_feature() {
2422            // A known feature key with a value that isn't a valid Feature must fail
2423            // validation (ValueError), not slip through as UnknownOption.
2424            let temp_dir = TempDir::new().unwrap();
2425            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2426            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2427            let schema = registry.get("test").unwrap();
2428
2429            // Missing required `owner` and `created_at`.
2430            let result =
2431                schema.validate_option("feature.organizations:fury-mode", &json!({"segments": []}));
2432            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2433        }
2434
2435        #[test]
2436        fn test_validate_values_feature_with_experiment_mode() {
2437            let temp_dir = TempDir::new().unwrap();
2438            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2439            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2440
2441            let result = registry.validate_values(
2442                "test",
2443                &json!({
2444                    "feature.organizations:fury-mode": {
2445                        "owner": {"team": "hybrid-cloud"},
2446                        "segments": [],
2447                        "created_at": "2024-01-01",
2448                        "experiment_mode": "simple"
2449                    }
2450                }),
2451            );
2452            assert!(result.is_ok());
2453        }
2454
2455        #[test]
2456        fn test_validate_values_feature_with_invalid_experiment_mode_fails() {
2457            let temp_dir = TempDir::new().unwrap();
2458            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2459            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2460
2461            let result = registry.validate_values(
2462                "test",
2463                &json!({
2464                    "feature.organizations:fury-mode": {
2465                        "owner": {"team": "hybrid-cloud"},
2466                        "segments": [],
2467                        "created_at": "2024-01-01",
2468                        "experiment_mode": "not-a-real-mode"
2469                    }
2470                }),
2471            );
2472            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2473        }
2474
2475        #[test]
2476        fn test_validate_values_feature_without_experiment_mode_still_valid() {
2477            let temp_dir = TempDir::new().unwrap();
2478            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
2479            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2480
2481            let result = registry.validate_values(
2482                "test",
2483                &json!({
2484                    "feature.organizations:fury-mode": {
2485                        "owner": {"team": "hybrid-cloud"},
2486                        "segments": [],
2487                        "created_at": "2024-01-01"
2488                    }
2489                }),
2490            );
2491            assert!(result.is_ok());
2492        }
2493    }
2494
2495    mod store_tests {
2496        use super::*;
2497
2498        /// Creates schema and values files for two namespaces: ns1 and ns2.
2499        fn setup_store_test() -> (TempDir, PathBuf, PathBuf) {
2500            let temp_dir = TempDir::new().unwrap();
2501            let schemas_dir = temp_dir.path().join("schemas");
2502            let values_dir = temp_dir.path().join("values");
2503
2504            let ns1_schema = schemas_dir.join("ns1");
2505            fs::create_dir_all(&ns1_schema).unwrap();
2506            fs::write(
2507                ns1_schema.join("schema.json"),
2508                r#"{
2509                    "version": "1.0",
2510                    "type": "object",
2511                    "properties": {
2512                        "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
2513                    }
2514                }"#,
2515            )
2516            .unwrap();
2517
2518            let ns1_values = values_dir.join("ns1");
2519            fs::create_dir_all(&ns1_values).unwrap();
2520            fs::write(
2521                ns1_values.join("values.json"),
2522                r#"{"options": {"enabled": true}}"#,
2523            )
2524            .unwrap();
2525
2526            let ns2_schema = schemas_dir.join("ns2");
2527            fs::create_dir_all(&ns2_schema).unwrap();
2528            fs::write(
2529                ns2_schema.join("schema.json"),
2530                r#"{
2531                    "version": "1.0",
2532                    "type": "object",
2533                    "properties": {
2534                        "count": {"type": "integer", "default": 0, "description": "Count"}
2535                    }
2536                }"#,
2537            )
2538            .unwrap();
2539
2540            let ns2_values = values_dir.join("ns2");
2541            fs::create_dir_all(&ns2_values).unwrap();
2542            fs::write(
2543                ns2_values.join("values.json"),
2544                r#"{"options": {"count": 42}}"#,
2545            )
2546            .unwrap();
2547
2548            (temp_dir, schemas_dir, values_dir)
2549        }
2550
2551        fn store_with_zero_threshold(schemas_dir: &Path, values_dir: &Path) -> ValuesStore {
2552            let registry = Arc::new(SchemaRegistry::from_directory(schemas_dir).unwrap());
2553            ValuesStore::builder(registry, values_dir)
2554                .with_refresh_threshold(Duration::ZERO)
2555                .build()
2556                .unwrap()
2557        }
2558
2559        fn store_without_refresh_on_read(schemas_dir: &Path, values_dir: &Path) -> ValuesStore {
2560            let registry = Arc::new(SchemaRegistry::from_directory(schemas_dir).unwrap());
2561            ValuesStore::builder(registry, values_dir)
2562                .with_refresh_threshold(None)
2563                .build()
2564                .unwrap()
2565        }
2566
2567        #[test]
2568        fn test_initial_load_populates_values() {
2569            let (_temp, schemas_dir, values_dir) = setup_store_test();
2570            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
2571            let store = ValuesStore::builder(registry, &values_dir).build().unwrap();
2572
2573            let guard = store.load();
2574            assert_eq!(guard["ns1"]["enabled"], json!(true));
2575            assert_eq!(guard["ns2"]["count"], json!(42));
2576        }
2577
2578        #[test]
2579        fn test_read_within_threshold_serves_cached() {
2580            // Default 5 s threshold: a modification followed by an immediate
2581            // read should still see the cached value.
2582            let (_temp, schemas_dir, values_dir) = setup_store_test();
2583            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
2584            let store = ValuesStore::builder(registry, &values_dir).build().unwrap();
2585
2586            // Confirm initial value, then modify the file.
2587            assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2588            fs::write(
2589                values_dir.join("ns1").join("values.json"),
2590                r#"{"options": {"enabled": false}}"#,
2591            )
2592            .unwrap();
2593
2594            // Within the threshold window, the cached value is still served.
2595            assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2596        }
2597
2598        #[test]
2599        fn test_read_after_threshold_refreshes() {
2600            let (_temp, schemas_dir, values_dir) = setup_store_test();
2601            let store = store_with_zero_threshold(&schemas_dir, &values_dir);
2602
2603            // Initial values.
2604            assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2605            assert_eq!(store.load()["ns2"]["count"], json!(42));
2606
2607            // Modify both namespaces.
2608            fs::write(
2609                values_dir.join("ns1").join("values.json"),
2610                r#"{"options": {"enabled": false}}"#,
2611            )
2612            .unwrap();
2613            fs::write(
2614                values_dir.join("ns2").join("values.json"),
2615                r#"{"options": {"count": 100}}"#,
2616            )
2617            .unwrap();
2618
2619            // With a zero threshold, the next read refreshes.
2620            let guard = store.load();
2621            assert_eq!(guard["ns1"]["enabled"], json!(false));
2622            assert_eq!(guard["ns2"]["count"], json!(100));
2623        }
2624
2625        #[test]
2626        fn test_refresh_failure_keeps_old_values() {
2627            let (_temp, schemas_dir, values_dir) = setup_store_test();
2628            let store = store_with_zero_threshold(&schemas_dir, &values_dir);
2629
2630            assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2631
2632            // Replace ns1 values with a type-incompatible payload.
2633            fs::write(
2634                values_dir.join("ns1").join("values.json"),
2635                r#"{"options": {"enabled": "not-a-boolean"}}"#,
2636            )
2637            .unwrap();
2638
2639            // Refresh attempt fails; old value still served.
2640            assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2641        }
2642
2643        #[test]
2644        fn test_concurrent_reads_observe_new_values() {
2645            use std::thread;
2646
2647            let (_temp, schemas_dir, values_dir) = setup_store_test();
2648            let store = Arc::new(store_with_zero_threshold(&schemas_dir, &values_dir));
2649
2650            // Prime: every thread sees the initial value.
2651            assert_eq!(store.load()["ns2"]["count"], json!(42));
2652
2653            fs::write(
2654                values_dir.join("ns2").join("values.json"),
2655                r#"{"options": {"count": 7}}"#,
2656            )
2657            .unwrap();
2658
2659            let mut handles = Vec::new();
2660            for _ in 0..8 {
2661                let store = Arc::clone(&store);
2662                handles.push(thread::spawn(move || {
2663                    let guard = store.load();
2664                    guard["ns2"]["count"].clone()
2665                }));
2666            }
2667            for h in handles {
2668                assert_eq!(h.join().unwrap(), json!(7));
2669            }
2670        }
2671
2672        #[test]
2673        fn test_refresh_returns_true_on_change_false_when_unchanged() {
2674            let (_temp, schemas_dir, values_dir) = setup_store_test();
2675            let store = store_without_refresh_on_read(&schemas_dir, &values_dir);
2676
2677            // Nothing changed on disk since construction.
2678            assert!(!store.refresh().unwrap());
2679
2680            fs::write(
2681                values_dir.join("ns1").join("values.json"),
2682                r#"{"options": {"enabled": false}}"#,
2683            )
2684            .unwrap();
2685            assert!(store.refresh().unwrap());
2686
2687            // Unchanged again after the reload.
2688            assert!(!store.refresh().unwrap());
2689        }
2690
2691        #[test]
2692        fn test_disabled_threshold_never_refreshes_on_read() {
2693            let (_temp, schemas_dir, values_dir) = setup_store_test();
2694            let store = store_without_refresh_on_read(&schemas_dir, &values_dir);
2695
2696            assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2697            fs::write(
2698                values_dir.join("ns1").join("values.json"),
2699                r#"{"options": {"enabled": false}}"#,
2700            )
2701            .unwrap();
2702
2703            // Reads never refresh with a disabled threshold.
2704            assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2705
2706            // A manual refresh picks up the change.
2707            assert!(store.refresh().unwrap());
2708            assert_eq!(store.load()["ns1"]["enabled"], json!(false));
2709        }
2710
2711        #[test]
2712        fn test_refresh_error_keeps_snapshot_and_recovers() {
2713            let (_temp, schemas_dir, values_dir) = setup_store_test();
2714            let store = store_without_refresh_on_read(&schemas_dir, &values_dir);
2715            let ns1_values = values_dir.join("ns1").join("values.json");
2716
2717            fs::write(&ns1_values, "not json").unwrap();
2718            assert!(store.refresh().is_err());
2719            assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2720
2721            fs::write(&ns1_values, r#"{"options": {"enabled": false}}"#).unwrap();
2722            assert!(store.refresh().unwrap());
2723            assert_eq!(store.load()["ns1"]["enabled"], json!(false));
2724        }
2725
2726        #[test]
2727        fn test_refresh_fires_propagation_callback() {
2728            let temp_dir = TempDir::new().unwrap();
2729            let base = temp_dir.path();
2730            let values_file = write_ns(base, "test", "2024-01-21T18:30:00+00:00");
2731
2732            let events: Arc<Mutex<Vec<(String, f64)>>> = Arc::new(Mutex::new(Vec::new()));
2733            let events_clone = events.clone();
2734            let registry = Arc::new(SchemaRegistry::from_directory(&base.join("schemas")).unwrap());
2735            let store = ValuesStore::builder(registry, &base.join("values"))
2736                .with_refresh_threshold(None)
2737                .with_callback(Box::new(move |ns, delay| {
2738                    events_clone.lock().unwrap().push((ns.to_string(), delay));
2739                }))
2740                .build()
2741                .unwrap();
2742
2743            fs::write(
2744                &values_file,
2745                r#"{"options": {"enabled": false}, "generated_at": "2024-01-21T19:00:00+00:00"}"#,
2746            )
2747            .unwrap();
2748            assert!(store.refresh().unwrap());
2749
2750            let recorded = events.lock().unwrap();
2751            assert_eq!(recorded.len(), 1);
2752            assert_eq!(recorded[0].0, "test");
2753        }
2754
2755        #[test]
2756        fn test_refresh_resets_lazy_timer() {
2757            let (_temp, schemas_dir, values_dir) = setup_store_test();
2758            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
2759            let store = ValuesStore::builder(registry, &values_dir)
2760                .with_refresh_threshold(Duration::from_secs(3600))
2761                .build()
2762                .unwrap();
2763
2764            assert!(!store.refresh().unwrap());
2765
2766            // A rewrite within the (freshly reset) window is not picked up by
2767            // reads — the manual refresh preempted the lazy refresh.
2768            fs::write(
2769                values_dir.join("ns1").join("values.json"),
2770                r#"{"options": {"enabled": false}}"#,
2771            )
2772            .unwrap();
2773            assert_eq!(store.load()["ns1"]["enabled"], json!(true));
2774        }
2775
2776        #[test]
2777        fn test_refresh_runs_within_staleness_window() {
2778            let (_temp, schemas_dir, values_dir) = setup_store_test();
2779            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
2780            let store = ValuesStore::builder(registry, &values_dir)
2781                .with_refresh_threshold(Duration::from_secs(3600))
2782                .build()
2783                .unwrap();
2784
2785            // Immediately after construction, well within the staleness
2786            // window, a manual refresh still reloads changed files.
2787            fs::write(
2788                values_dir.join("ns1").join("values.json"),
2789                r#"{"options": {"enabled": false}}"#,
2790            )
2791            .unwrap();
2792            assert!(store.refresh().unwrap());
2793            assert_eq!(store.load()["ns1"]["enabled"], json!(false));
2794        }
2795    }
2796    mod array_tests {
2797        use super::*;
2798
2799        #[test]
2800        fn test_basic_schema_validation() {
2801            let temp_dir = TempDir::new().unwrap();
2802            for (a_type, default) in [
2803                ("boolean", ""), // empty array test
2804                ("boolean", "true"),
2805                ("integer", "1"),
2806                ("number", "1.2"),
2807                ("string", "\"wow\""),
2808            ] {
2809                create_test_schema(
2810                    &temp_dir,
2811                    "test",
2812                    &format!(
2813                        r#"{{
2814                        "version": "1.0",
2815                        "type": "object",
2816                        "properties": {{
2817                            "array-key": {{
2818                                "type": "array",
2819                                "items": {{"type": "{}"}},
2820                                "default": [{}],
2821                                "description": "Array option"
2822                                }}
2823                            }}
2824                        }}"#,
2825                        a_type, default
2826                    ),
2827                );
2828
2829                SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2830            }
2831        }
2832
2833        #[test]
2834        fn test_missing_items_object_rejection() {
2835            let temp_dir = TempDir::new().unwrap();
2836            create_test_schema(
2837                &temp_dir,
2838                "test",
2839                r#"{
2840                    "version": "1.0",
2841                    "type": "object",
2842                    "properties": {
2843                        "array-key": {
2844                            "type": "array",
2845                            "default": [1,2,3],
2846                            "description": "Array option"
2847                        }
2848                    }
2849                }"#,
2850            );
2851
2852            let result = SchemaRegistry::from_directory(temp_dir.path());
2853            assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
2854        }
2855
2856        #[test]
2857        fn test_malformed_items_rejection() {
2858            let temp_dir = TempDir::new().unwrap();
2859            create_test_schema(
2860                &temp_dir,
2861                "test",
2862                r#"{
2863                    "version": "1.0",
2864                    "type": "object",
2865                    "properties": {
2866                        "array-key": {
2867                            "type": "array",
2868                            "items": {"type": ""},
2869                            "default": [1,2,3],
2870                            "description": "Array option"
2871                        }
2872                    }
2873                }"#,
2874            );
2875
2876            let result = SchemaRegistry::from_directory(temp_dir.path());
2877            assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
2878        }
2879
2880        #[test]
2881        fn test_schema_default_type_mismatch_rejection() {
2882            let temp_dir = TempDir::new().unwrap();
2883            // also tests real number rejection when type is integer
2884            create_test_schema(
2885                &temp_dir,
2886                "test",
2887                r#"{
2888                    "version": "1.0",
2889                    "type": "object",
2890                    "properties": {
2891                        "array-key": {
2892                            "type": "array",
2893                            "items": {"type": "integer"},
2894                            "default": [1,2,3.3],
2895                            "description": "Array option"
2896                        }
2897                    }
2898                }"#,
2899            );
2900
2901            let result = SchemaRegistry::from_directory(temp_dir.path());
2902            assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
2903        }
2904
2905        #[test]
2906        fn test_schema_default_heterogeneous_rejection() {
2907            let temp_dir = TempDir::new().unwrap();
2908            create_test_schema(
2909                &temp_dir,
2910                "test",
2911                r#"{
2912                    "version": "1.0",
2913                    "type": "object",
2914                    "properties": {
2915                        "array-key": {
2916                            "type": "array",
2917                            "items": {"type": "integer"},
2918                            "default": [1,2,"uh oh!"],
2919                            "description": "Array option"
2920                        }
2921                    }
2922                }"#,
2923            );
2924
2925            let result = SchemaRegistry::from_directory(temp_dir.path());
2926            assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
2927        }
2928
2929        #[test]
2930        fn test_load_values_valid() {
2931            let temp_dir = TempDir::new().unwrap();
2932            let (schemas_dir, values_dir) = create_test_schema_with_values(
2933                &temp_dir,
2934                "test",
2935                r#"{
2936                    "version": "1.0",
2937                    "type": "object",
2938                    "properties": {
2939                        "array-key": {
2940                            "type": "array",
2941                            "items": {"type": "integer"},
2942                            "default": [1,2,3],
2943                            "description": "Array option"
2944                        }
2945                    }
2946                }"#,
2947                r#"{
2948                    "options": {
2949                        "array-key": [4,5,6]
2950                    }
2951                }"#,
2952            );
2953
2954            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2955            let (values, generated_at_by_namespace) =
2956                registry.load_values_json(&values_dir).unwrap();
2957
2958            assert_eq!(values.len(), 1);
2959            assert_eq!(values["test"]["array-key"], json!([4, 5, 6]));
2960            assert!(generated_at_by_namespace.is_empty());
2961        }
2962
2963        #[test]
2964        fn test_reject_values_not_an_array() {
2965            let temp_dir = TempDir::new().unwrap();
2966            let (schemas_dir, values_dir) = create_test_schema_with_values(
2967                &temp_dir,
2968                "test",
2969                r#"{
2970                    "version": "1.0",
2971                    "type": "object",
2972                    "properties": {
2973                        "array-key": {
2974                            "type": "array",
2975                            "items": {"type": "integer"},
2976                            "default": [1,2,3],
2977                            "description": "Array option"
2978                        }
2979                    }
2980                }"#,
2981                // sneaky! not an array
2982                r#"{
2983                    "options": {
2984                        "array-key": "[]"
2985                    }
2986                }"#,
2987            );
2988
2989            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2990            let result = registry.load_values_json(&values_dir);
2991
2992            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2993        }
2994
2995        #[test]
2996        fn test_reject_values_mismatch() {
2997            let temp_dir = TempDir::new().unwrap();
2998            let (schemas_dir, values_dir) = create_test_schema_with_values(
2999                &temp_dir,
3000                "test",
3001                r#"{
3002                    "version": "1.0",
3003                    "type": "object",
3004                    "properties": {
3005                        "array-key": {
3006                            "type": "array",
3007                            "items": {"type": "integer"},
3008                            "default": [1,2,3],
3009                            "description": "Array option"
3010                        }
3011                    }
3012                }"#,
3013                r#"{
3014                    "options": {
3015                        "array-key": ["a","b","c"]
3016                    }
3017                }"#,
3018            );
3019
3020            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3021            let result = registry.load_values_json(&values_dir);
3022
3023            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3024        }
3025    }
3026
3027    mod object_tests {
3028        use super::*;
3029
3030        #[test]
3031        fn test_object_schema_loads() {
3032            let temp_dir = TempDir::new().unwrap();
3033            create_test_schema(
3034                &temp_dir,
3035                "test",
3036                r#"{
3037                    "version": "1.0",
3038                    "type": "object",
3039                    "properties": {
3040                        "config": {
3041                            "type": "object",
3042                            "properties": {
3043                                "host": {"type": "string"},
3044                                "port": {"type": "integer"},
3045                                "rate": {"type": "number"},
3046                                "enabled": {"type": "boolean"}
3047                            },
3048                            "default": {"host": "localhost", "port": 8080, "rate": 0.5, "enabled": true},
3049                            "description": "Service config"
3050                        }
3051                    }
3052                }"#,
3053            );
3054
3055            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
3056            let schema = registry.get("test").unwrap();
3057            assert_eq!(schema.options["config"].option_type, "object");
3058        }
3059
3060        #[test]
3061        fn test_object_missing_properties_rejected() {
3062            let temp_dir = TempDir::new().unwrap();
3063            create_test_schema(
3064                &temp_dir,
3065                "test",
3066                r#"{
3067                    "version": "1.0",
3068                    "type": "object",
3069                    "properties": {
3070                        "config": {
3071                            "type": "object",
3072                            "default": {"host": "localhost"},
3073                            "description": "Missing properties field"
3074                        }
3075                    }
3076                }"#,
3077            );
3078
3079            let result = SchemaRegistry::from_directory(temp_dir.path());
3080            assert!(result.is_err());
3081        }
3082
3083        #[test]
3084        fn test_object_default_wrong_type_rejected() {
3085            let temp_dir = TempDir::new().unwrap();
3086            create_test_schema(
3087                &temp_dir,
3088                "test",
3089                r#"{
3090                    "version": "1.0",
3091                    "type": "object",
3092                    "properties": {
3093                        "config": {
3094                            "type": "object",
3095                            "properties": {
3096                                "host": {"type": "string"},
3097                                "port": {"type": "integer"}
3098                            },
3099                            "default": {"host": "localhost", "port": "not-a-number"},
3100                            "description": "Bad default"
3101                        }
3102                    }
3103                }"#,
3104            );
3105
3106            let result = SchemaRegistry::from_directory(temp_dir.path());
3107            assert!(result.is_err());
3108        }
3109
3110        #[test]
3111        fn test_object_default_missing_field_rejected() {
3112            let temp_dir = TempDir::new().unwrap();
3113            create_test_schema(
3114                &temp_dir,
3115                "test",
3116                r#"{
3117                    "version": "1.0",
3118                    "type": "object",
3119                    "properties": {
3120                        "config": {
3121                            "type": "object",
3122                            "properties": {
3123                                "host": {"type": "string"},
3124                                "port": {"type": "integer"}
3125                            },
3126                            "default": {"host": "localhost"},
3127                            "description": "Missing port in default"
3128                        }
3129                    }
3130                }"#,
3131            );
3132
3133            let result = SchemaRegistry::from_directory(temp_dir.path());
3134            assert!(result.is_err());
3135        }
3136
3137        #[test]
3138        fn test_object_default_extra_field_rejected() {
3139            let temp_dir = TempDir::new().unwrap();
3140            create_test_schema(
3141                &temp_dir,
3142                "test",
3143                r#"{
3144                    "version": "1.0",
3145                    "type": "object",
3146                    "properties": {
3147                        "config": {
3148                            "type": "object",
3149                            "properties": {
3150                                "host": {"type": "string"}
3151                            },
3152                            "default": {"host": "localhost", "extra": "field"},
3153                            "description": "Extra field in default"
3154                        }
3155                    }
3156                }"#,
3157            );
3158
3159            let result = SchemaRegistry::from_directory(temp_dir.path());
3160            assert!(result.is_err());
3161        }
3162
3163        #[test]
3164        fn test_object_values_valid() {
3165            let temp_dir = TempDir::new().unwrap();
3166            let (schemas_dir, values_dir) = create_test_schema_with_values(
3167                &temp_dir,
3168                "test",
3169                r#"{
3170                    "version": "1.0",
3171                    "type": "object",
3172                    "properties": {
3173                        "config": {
3174                            "type": "object",
3175                            "properties": {
3176                                "host": {"type": "string"},
3177                                "port": {"type": "integer"}
3178                            },
3179                            "default": {"host": "localhost", "port": 8080},
3180                            "description": "Service config"
3181                        }
3182                    }
3183                }"#,
3184                r#"{
3185                    "options": {
3186                        "config": {"host": "example.com", "port": 9090}
3187                    }
3188                }"#,
3189            );
3190
3191            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3192            let result = registry.load_values_json(&values_dir);
3193            assert!(result.is_ok());
3194        }
3195
3196        #[test]
3197        fn test_object_values_wrong_field_type_rejected() {
3198            let temp_dir = TempDir::new().unwrap();
3199            let (schemas_dir, values_dir) = create_test_schema_with_values(
3200                &temp_dir,
3201                "test",
3202                r#"{
3203                    "version": "1.0",
3204                    "type": "object",
3205                    "properties": {
3206                        "config": {
3207                            "type": "object",
3208                            "properties": {
3209                                "host": {"type": "string"},
3210                                "port": {"type": "integer"}
3211                            },
3212                            "default": {"host": "localhost", "port": 8080},
3213                            "description": "Service config"
3214                        }
3215                    }
3216                }"#,
3217                r#"{
3218                    "options": {
3219                        "config": {"host": "example.com", "port": "not-a-number"}
3220                    }
3221                }"#,
3222            );
3223
3224            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3225            let result = registry.load_values_json(&values_dir);
3226            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3227        }
3228
3229        #[test]
3230        fn test_object_values_extra_field_rejected() {
3231            let temp_dir = TempDir::new().unwrap();
3232            let (schemas_dir, values_dir) = create_test_schema_with_values(
3233                &temp_dir,
3234                "test",
3235                r#"{
3236                    "version": "1.0",
3237                    "type": "object",
3238                    "properties": {
3239                        "config": {
3240                            "type": "object",
3241                            "properties": {
3242                                "host": {"type": "string"}
3243                            },
3244                            "default": {"host": "localhost"},
3245                            "description": "Service config"
3246                        }
3247                    }
3248                }"#,
3249                r#"{
3250                    "options": {
3251                        "config": {"host": "example.com", "extra": "field"}
3252                    }
3253                }"#,
3254            );
3255
3256            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3257            let result = registry.load_values_json(&values_dir);
3258            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3259        }
3260
3261        #[test]
3262        fn test_object_values_missing_field_rejected() {
3263            let temp_dir = TempDir::new().unwrap();
3264            let (schemas_dir, values_dir) = create_test_schema_with_values(
3265                &temp_dir,
3266                "test",
3267                r#"{
3268                    "version": "1.0",
3269                    "type": "object",
3270                    "properties": {
3271                        "config": {
3272                            "type": "object",
3273                            "properties": {
3274                                "host": {"type": "string"},
3275                                "port": {"type": "integer"}
3276                            },
3277                            "default": {"host": "localhost", "port": 8080},
3278                            "description": "Service config"
3279                        }
3280                    }
3281                }"#,
3282                r#"{
3283                    "options": {
3284                        "config": {"host": "example.com"}
3285                    }
3286                }"#,
3287            );
3288
3289            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3290            let result = registry.load_values_json(&values_dir);
3291            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3292        }
3293
3294        // Array of objects tests
3295
3296        #[test]
3297        fn test_array_of_objects_schema_loads() {
3298            let temp_dir = TempDir::new().unwrap();
3299            create_test_schema(
3300                &temp_dir,
3301                "test",
3302                r#"{
3303                    "version": "1.0",
3304                    "type": "object",
3305                    "properties": {
3306                        "endpoints": {
3307                            "type": "array",
3308                            "items": {
3309                                "type": "object",
3310                                "properties": {
3311                                    "url": {"type": "string"},
3312                                    "weight": {"type": "integer"}
3313                                }
3314                            },
3315                            "default": [{"url": "https://a.example.com", "weight": 1}],
3316                            "description": "Endpoints"
3317                        }
3318                    }
3319                }"#,
3320            );
3321
3322            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
3323            let schema = registry.get("test").unwrap();
3324            assert_eq!(schema.options["endpoints"].option_type, "array");
3325        }
3326
3327        #[test]
3328        fn test_array_of_objects_empty_default() {
3329            let temp_dir = TempDir::new().unwrap();
3330            create_test_schema(
3331                &temp_dir,
3332                "test",
3333                r#"{
3334                    "version": "1.0",
3335                    "type": "object",
3336                    "properties": {
3337                        "endpoints": {
3338                            "type": "array",
3339                            "items": {
3340                                "type": "object",
3341                                "properties": {
3342                                    "url": {"type": "string"},
3343                                    "weight": {"type": "integer"}
3344                                }
3345                            },
3346                            "default": [],
3347                            "description": "Endpoints"
3348                        }
3349                    }
3350                }"#,
3351            );
3352
3353            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
3354            assert!(registry.get("test").is_some());
3355        }
3356
3357        #[test]
3358        fn test_array_of_objects_default_wrong_field_type_rejected() {
3359            let temp_dir = TempDir::new().unwrap();
3360            create_test_schema(
3361                &temp_dir,
3362                "test",
3363                r#"{
3364                    "version": "1.0",
3365                    "type": "object",
3366                    "properties": {
3367                        "endpoints": {
3368                            "type": "array",
3369                            "items": {
3370                                "type": "object",
3371                                "properties": {
3372                                    "url": {"type": "string"},
3373                                    "weight": {"type": "integer"}
3374                                }
3375                            },
3376                            "default": [{"url": "https://a.example.com", "weight": "not-a-number"}],
3377                            "description": "Endpoints"
3378                        }
3379                    }
3380                }"#,
3381            );
3382
3383            let result = SchemaRegistry::from_directory(temp_dir.path());
3384            assert!(result.is_err());
3385        }
3386
3387        #[test]
3388        fn test_array_of_objects_missing_items_properties_rejected() {
3389            let temp_dir = TempDir::new().unwrap();
3390            create_test_schema(
3391                &temp_dir,
3392                "test",
3393                r#"{
3394                    "version": "1.0",
3395                    "type": "object",
3396                    "properties": {
3397                        "endpoints": {
3398                            "type": "array",
3399                            "items": {
3400                                "type": "object"
3401                            },
3402                            "default": [],
3403                            "description": "Missing properties in items"
3404                        }
3405                    }
3406                }"#,
3407            );
3408
3409            let result = SchemaRegistry::from_directory(temp_dir.path());
3410            assert!(result.is_err());
3411        }
3412
3413        #[test]
3414        fn test_array_of_objects_values_valid() {
3415            let temp_dir = TempDir::new().unwrap();
3416            let (schemas_dir, values_dir) = create_test_schema_with_values(
3417                &temp_dir,
3418                "test",
3419                r#"{
3420                    "version": "1.0",
3421                    "type": "object",
3422                    "properties": {
3423                        "endpoints": {
3424                            "type": "array",
3425                            "items": {
3426                                "type": "object",
3427                                "properties": {
3428                                    "url": {"type": "string"},
3429                                    "weight": {"type": "integer"}
3430                                }
3431                            },
3432                            "default": [],
3433                            "description": "Endpoints"
3434                        }
3435                    }
3436                }"#,
3437                r#"{
3438                    "options": {
3439                        "endpoints": [
3440                            {"url": "https://a.example.com", "weight": 1},
3441                            {"url": "https://b.example.com", "weight": 2}
3442                        ]
3443                    }
3444                }"#,
3445            );
3446
3447            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3448            let result = registry.load_values_json(&values_dir);
3449            assert!(result.is_ok());
3450        }
3451
3452        #[test]
3453        fn test_array_of_objects_values_wrong_item_shape_rejected() {
3454            let temp_dir = TempDir::new().unwrap();
3455            let (schemas_dir, values_dir) = create_test_schema_with_values(
3456                &temp_dir,
3457                "test",
3458                r#"{
3459                    "version": "1.0",
3460                    "type": "object",
3461                    "properties": {
3462                        "endpoints": {
3463                            "type": "array",
3464                            "items": {
3465                                "type": "object",
3466                                "properties": {
3467                                    "url": {"type": "string"},
3468                                    "weight": {"type": "integer"}
3469                                }
3470                            },
3471                            "default": [],
3472                            "description": "Endpoints"
3473                        }
3474                    }
3475                }"#,
3476                r#"{
3477                    "options": {
3478                        "endpoints": [
3479                            {"url": "https://a.example.com", "weight": "not-a-number"}
3480                        ]
3481                    }
3482                }"#,
3483            );
3484
3485            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3486            let result = registry.load_values_json(&values_dir);
3487            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3488        }
3489
3490        #[test]
3491        fn test_array_of_objects_values_extra_field_rejected() {
3492            let temp_dir = TempDir::new().unwrap();
3493            let (schemas_dir, values_dir) = create_test_schema_with_values(
3494                &temp_dir,
3495                "test",
3496                r#"{
3497                    "version": "1.0",
3498                    "type": "object",
3499                    "properties": {
3500                        "endpoints": {
3501                            "type": "array",
3502                            "items": {
3503                                "type": "object",
3504                                "properties": {
3505                                    "url": {"type": "string"}
3506                                }
3507                            },
3508                            "default": [],
3509                            "description": "Endpoints"
3510                        }
3511                    }
3512                }"#,
3513                r#"{
3514                    "options": {
3515                        "endpoints": [
3516                            {"url": "https://a.example.com", "extra": "field"}
3517                        ]
3518                    }
3519                }"#,
3520            );
3521
3522            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3523            let result = registry.load_values_json(&values_dir);
3524            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3525        }
3526
3527        #[test]
3528        fn test_array_of_objects_values_missing_field_rejected() {
3529            let temp_dir = TempDir::new().unwrap();
3530            let (schemas_dir, values_dir) = create_test_schema_with_values(
3531                &temp_dir,
3532                "test",
3533                r#"{
3534                    "version": "1.0",
3535                    "type": "object",
3536                    "properties": {
3537                        "endpoints": {
3538                            "type": "array",
3539                            "items": {
3540                                "type": "object",
3541                                "properties": {
3542                                    "url": {"type": "string"},
3543                                    "weight": {"type": "integer"}
3544                                }
3545                            },
3546                            "default": [],
3547                            "description": "Endpoints"
3548                        }
3549                    }
3550                }"#,
3551                r#"{
3552                    "options": {
3553                        "endpoints": [
3554                            {"url": "https://a.example.com"}
3555                        ]
3556                    }
3557                }"#,
3558            );
3559
3560            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3561            let result = registry.load_values_json(&values_dir);
3562            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
3563        }
3564
3565        // Optional field tests
3566
3567        #[test]
3568        fn test_object_optional_field_can_be_omitted_from_default() {
3569            let temp_dir = TempDir::new().unwrap();
3570            create_test_schema(
3571                &temp_dir,
3572                "test",
3573                r#"{
3574                    "version": "1.0",
3575                    "type": "object",
3576                    "properties": {
3577                        "config": {
3578                            "type": "object",
3579                            "properties": {
3580                                "host": {"type": "string"},
3581                                "debug": {"type": "boolean", "optional": true}
3582                            },
3583                            "default": {"host": "localhost"},
3584                            "description": "Config with optional field"
3585                        }
3586                    }
3587                }"#,
3588            );
3589
3590            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
3591            let schema = registry.get("test").unwrap();
3592            assert_eq!(schema.options["config"].option_type, "object");
3593        }
3594
3595        #[test]
3596        fn test_object_optional_field_can_be_included_in_default() {
3597            let temp_dir = TempDir::new().unwrap();
3598            create_test_schema(
3599                &temp_dir,
3600                "test",
3601                r#"{
3602                    "version": "1.0",
3603                    "type": "object",
3604                    "properties": {
3605                        "config": {
3606                            "type": "object",
3607                            "properties": {
3608                                "host": {"type": "string"},
3609                                "debug": {"type": "boolean", "optional": true}
3610                            },
3611                            "default": {"host": "localhost", "debug": true},
3612                            "description": "Config with optional field included"
3613                        }
3614                    }
3615                }"#,
3616            );
3617
3618            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
3619            assert!(registry.get("test").is_some());
3620        }
3621
3622        #[test]
3623        fn test_object_optional_field_wrong_type_rejected() {
3624            let temp_dir = TempDir::new().unwrap();
3625            create_test_schema(
3626                &temp_dir,
3627                "test",
3628                r#"{
3629                    "version": "1.0",
3630                    "type": "object",
3631                    "properties": {
3632                        "config": {
3633                            "type": "object",
3634                            "properties": {
3635                                "host": {"type": "string"},
3636                                "debug": {"type": "boolean", "optional": true}
3637                            },
3638                            "default": {"host": "localhost", "debug": "not-a-bool"},
3639                            "description": "Optional field wrong type"
3640                        }
3641                    }
3642                }"#,
3643            );
3644
3645            let result = SchemaRegistry::from_directory(temp_dir.path());
3646            assert!(result.is_err());
3647        }
3648
3649        #[test]
3650        fn test_object_required_field_still_required_with_optional_present() {
3651            let temp_dir = TempDir::new().unwrap();
3652            create_test_schema(
3653                &temp_dir,
3654                "test",
3655                r#"{
3656                    "version": "1.0",
3657                    "type": "object",
3658                    "properties": {
3659                        "config": {
3660                            "type": "object",
3661                            "properties": {
3662                                "host": {"type": "string"},
3663                                "port": {"type": "integer"},
3664                                "debug": {"type": "boolean", "optional": true}
3665                            },
3666                            "default": {"debug": true},
3667                            "description": "Missing required fields"
3668                        }
3669                    }
3670                }"#,
3671            );
3672
3673            let result = SchemaRegistry::from_directory(temp_dir.path());
3674            assert!(result.is_err());
3675        }
3676
3677        #[test]
3678        fn test_object_optional_field_omitted_from_values() {
3679            let temp_dir = TempDir::new().unwrap();
3680            let (schemas_dir, values_dir) = create_test_schema_with_values(
3681                &temp_dir,
3682                "test",
3683                r#"{
3684                    "version": "1.0",
3685                    "type": "object",
3686                    "properties": {
3687                        "config": {
3688                            "type": "object",
3689                            "properties": {
3690                                "host": {"type": "string"},
3691                                "debug": {"type": "boolean", "optional": true}
3692                            },
3693                            "default": {"host": "localhost"},
3694                            "description": "Config"
3695                        }
3696                    }
3697                }"#,
3698                r#"{
3699                    "options": {
3700                        "config": {"host": "example.com"}
3701                    }
3702                }"#,
3703            );
3704
3705            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3706            let result = registry.load_values_json(&values_dir);
3707            assert!(result.is_ok());
3708        }
3709
3710        #[test]
3711        fn test_object_optional_field_included_in_values() {
3712            let temp_dir = TempDir::new().unwrap();
3713            let (schemas_dir, values_dir) = create_test_schema_with_values(
3714                &temp_dir,
3715                "test",
3716                r#"{
3717                    "version": "1.0",
3718                    "type": "object",
3719                    "properties": {
3720                        "config": {
3721                            "type": "object",
3722                            "properties": {
3723                                "host": {"type": "string"},
3724                                "debug": {"type": "boolean", "optional": true}
3725                            },
3726                            "default": {"host": "localhost"},
3727                            "description": "Config"
3728                        }
3729                    }
3730                }"#,
3731                r#"{
3732                    "options": {
3733                        "config": {"host": "example.com", "debug": true}
3734                    }
3735                }"#,
3736            );
3737
3738            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3739            let result = registry.load_values_json(&values_dir);
3740            assert!(result.is_ok());
3741        }
3742
3743        #[test]
3744        fn test_array_of_objects_optional_field_omitted() {
3745            let temp_dir = TempDir::new().unwrap();
3746            let (schemas_dir, values_dir) = create_test_schema_with_values(
3747                &temp_dir,
3748                "test",
3749                r#"{
3750                    "version": "1.0",
3751                    "type": "object",
3752                    "properties": {
3753                        "endpoints": {
3754                            "type": "array",
3755                            "items": {
3756                                "type": "object",
3757                                "properties": {
3758                                    "url": {"type": "string"},
3759                                    "weight": {"type": "integer", "optional": true}
3760                                }
3761                            },
3762                            "default": [],
3763                            "description": "Endpoints"
3764                        }
3765                    }
3766                }"#,
3767                r#"{
3768                    "options": {
3769                        "endpoints": [
3770                            {"url": "https://a.example.com"},
3771                            {"url": "https://b.example.com", "weight": 2}
3772                        ]
3773                    }
3774                }"#,
3775            );
3776
3777            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
3778            let result = registry.load_values_json(&values_dir);
3779            assert!(result.is_ok());
3780        }
3781    }
3782
3783    #[test]
3784    fn test_object_valued_map_validates_nested_values() {
3785        // {orgId: {max_candidates: int}} — a map whose values are objects, previously
3786        // inexpressible because map values were scalar-only.
3787        let registry = SchemaRegistry::from_schemas(&[(
3788            "test",
3789            r#"{
3790                "version": "1.0",
3791                "type": "object",
3792                "properties": {
3793                    "org_tweaks": {
3794                        "type": "object",
3795                        "additionalProperties": {
3796                            "type": "object",
3797                            "additionalProperties": {"type": "integer"}
3798                        },
3799                        "default": {},
3800                        "description": ""
3801                    }
3802                }
3803            }"#,
3804        )])
3805        .unwrap();
3806
3807        assert!(
3808            registry
3809                .validate_values(
3810                    "test",
3811                    &json!({"org_tweaks": {"1": {"max_candidates": 20}}})
3812                )
3813                .is_ok()
3814        );
3815        assert!(
3816            registry
3817                .validate_values("test", &json!({"org_tweaks": {}}))
3818                .is_ok()
3819        );
3820        // inner value of the wrong type
3821        assert!(matches!(
3822            registry.validate_values(
3823                "test",
3824                &json!({"org_tweaks": {"1": {"max_candidates": "x"}}})
3825            ),
3826            Err(ValidationError::ValueError { .. })
3827        ));
3828        // map value is not an object
3829        assert!(matches!(
3830            registry.validate_values("test", &json!({"org_tweaks": {"1": 20}})),
3831            Err(ValidationError::ValueError { .. })
3832        ));
3833    }
3834
3835    #[test]
3836    fn test_array_valued_map_validates_nested_values() {
3837        // {key: [string]} — a map whose values are arrays.
3838        let registry = SchemaRegistry::from_schemas(&[(
3839            "test",
3840            r#"{
3841                "version": "1.0",
3842                "type": "object",
3843                "properties": {
3844                    "webhook_logging": {
3845                        "type": "object",
3846                        "additionalProperties": {
3847                            "type": "array",
3848                            "items": {"type": "string"}
3849                        },
3850                        "default": {},
3851                        "description": ""
3852                    }
3853                }
3854            }"#,
3855        )])
3856        .unwrap();
3857
3858        assert!(
3859            registry
3860                .validate_values(
3861                    "test",
3862                    &json!({"webhook_logging": {"a": [], "b": ["x", "y"]}})
3863                )
3864                .is_ok()
3865        );
3866        // array element of the wrong type
3867        assert!(matches!(
3868            registry.validate_values("test", &json!({"webhook_logging": {"a": [1]}})),
3869            Err(ValidationError::ValueError { .. })
3870        ));
3871        // map value is not an array
3872        assert!(matches!(
3873            registry.validate_values("test", &json!({"webhook_logging": {"a": "x"}})),
3874            Err(ValidationError::ValueError { .. })
3875        ));
3876    }
3877
3878    #[test]
3879    fn test_nested_fixed_object_enforces_required_and_rejects_unknown() {
3880        // Map value is a fixed-shape object: `required` and `additionalProperties: false`
3881        // must be injected at depth, not only at the top level.
3882        let registry = SchemaRegistry::from_schemas(&[(
3883            "test",
3884            r#"{
3885                "version": "1.0",
3886                "type": "object",
3887                "properties": {
3888                    "quotas": {
3889                        "type": "object",
3890                        "additionalProperties": {
3891                            "type": "object",
3892                            "properties": {
3893                                "limit": {"type": "integer"},
3894                                "window": {"type": "integer"}
3895                            }
3896                        },
3897                        "default": {},
3898                        "description": ""
3899                    }
3900                }
3901            }"#,
3902        )])
3903        .unwrap();
3904
3905        assert!(
3906            registry
3907                .validate_values("test", &json!({"quotas": {"o": {"limit": 1, "window": 2}}}))
3908                .is_ok()
3909        );
3910        // missing a required nested field
3911        assert!(matches!(
3912            registry.validate_values("test", &json!({"quotas": {"o": {"limit": 1}}})),
3913            Err(ValidationError::ValueError { .. })
3914        ));
3915        // unknown nested field rejected (additionalProperties: false injected at depth)
3916        assert!(matches!(
3917            registry.validate_values(
3918                "test",
3919                &json!({"quotas": {"o": {"limit": 1, "window": 2, "x": 3}}})
3920            ),
3921            Err(ValidationError::ValueError { .. })
3922        ));
3923    }
3924
3925    #[test]
3926    fn test_optional_marker_works_at_depth() {
3927        let registry = SchemaRegistry::from_schemas(&[(
3928            "test",
3929            r#"{
3930                "version": "1.0",
3931                "type": "object",
3932                "properties": {
3933                    "items_map": {
3934                        "type": "object",
3935                        "additionalProperties": {
3936                            "type": "object",
3937                            "properties": {
3938                                "url": {"type": "string"},
3939                                "weight": {"type": "integer", "optional": true}
3940                            }
3941                        },
3942                        "default": {},
3943                        "description": ""
3944                    }
3945                }
3946            }"#,
3947        )])
3948        .unwrap();
3949
3950        // optional field omitted -> ok
3951        assert!(
3952            registry
3953                .validate_values("test", &json!({"items_map": {"a": {"url": "x"}}}))
3954                .is_ok()
3955        );
3956        // required field omitted -> fail
3957        assert!(matches!(
3958            registry.validate_values("test", &json!({"items_map": {"a": {"weight": 1}}})),
3959            Err(ValidationError::ValueError { .. })
3960        ));
3961    }
3962
3963    #[test]
3964    fn test_three_levels_of_nesting() {
3965        // object -> array -> object -> scalar map
3966        let registry = SchemaRegistry::from_schemas(&[(
3967            "test",
3968            r#"{
3969                "version": "1.0",
3970                "type": "object",
3971                "properties": {
3972                    "deep": {
3973                        "type": "object",
3974                        "additionalProperties": {
3975                            "type": "array",
3976                            "items": {
3977                                "type": "object",
3978                                "additionalProperties": {"type": "integer"}
3979                            }
3980                        },
3981                        "default": {},
3982                        "description": ""
3983                    }
3984                }
3985            }"#,
3986        )])
3987        .unwrap();
3988
3989        assert!(
3990            registry
3991                .validate_values("test", &json!({"deep": {"a": [{"x": 1}, {"y": 2}]}}))
3992                .is_ok()
3993        );
3994        assert!(matches!(
3995            registry.validate_values("test", &json!({"deep": {"a": [{"x": "no"}]}})),
3996            Err(ValidationError::ValueError { .. })
3997        ));
3998    }
3999
4000    #[test]
4001    fn test_meta_schema_rejects_malformed_nested_value_types() {
4002        // object value with neither `properties` nor `additionalProperties`
4003        assert!(matches!(
4004            SchemaRegistry::from_schemas(&[(
4005                "test",
4006                r#"{"version":"1.0","type":"object","properties":{
4007                    "m":{"type":"object","additionalProperties":{"type":"object"},"default":{},"description":""}}}"#,
4008            )]),
4009            Err(ValidationError::SchemaError { .. })
4010        ));
4011        // array value without `items`
4012        assert!(matches!(
4013            SchemaRegistry::from_schemas(&[(
4014                "test",
4015                r#"{"version":"1.0","type":"object","properties":{
4016                    "m":{"type":"object","additionalProperties":{"type":"array"},"default":{},"description":""}}}"#,
4017            )]),
4018            Err(ValidationError::SchemaError { .. })
4019        ));
4020        // unknown keyword inside a nested value type
4021        assert!(matches!(
4022            SchemaRegistry::from_schemas(&[(
4023                "test",
4024                r#"{"version":"1.0","type":"object","properties":{
4025                    "m":{"type":"object","additionalProperties":{"type":"string","bogus":1},"default":{},"description":""}}}"#,
4026            )]),
4027            Err(ValidationError::SchemaError { .. })
4028        ));
4029    }
4030
4031    #[test]
4032    fn test_pre_existing_shapes_still_validate() {
4033        // Scalar map and fixed-shape object (the only shapes allowed before) keep working.
4034        let registry = SchemaRegistry::from_schemas(&[(
4035            "test",
4036            r#"{
4037                "version": "1.0",
4038                "type": "object",
4039                "properties": {
4040                    "scopes": {"type": "object", "additionalProperties": {"type": "string"}, "default": {}, "description": ""},
4041                    "config": {"type": "object", "properties": {"host": {"type": "string"}}, "default": {"host": "h"}, "description": ""}
4042                }
4043            }"#,
4044        )])
4045        .unwrap();
4046
4047        assert!(
4048            registry
4049                .validate_values(
4050                    "test",
4051                    &json!({"scopes": {"r": "1"}, "config": {"host": "x"}})
4052                )
4053                .is_ok()
4054        );
4055        assert!(matches!(
4056            registry.validate_values("test", &json!({"config": {"host": "x", "extra": "y"}})),
4057            Err(ValidationError::ValueError { .. })
4058        ));
4059    }
4060}