Skip to main content

sentry_options_validation/
lib.rs

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