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