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