Skip to main content

sentry_options_validation/
lib.rs

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