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