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