Skip to main content

sentry_options_validation/
lib.rs

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