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