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