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