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 chrono::{DateTime, Utc};
8use sentry::ClientOptions;
9use sentry::transports::DefaultTransportFactory;
10use serde_json::Value;
11use serde_json::json;
12use std::collections::HashMap;
13use std::fs;
14use std::panic::{self, AssertUnwindSafe};
15use std::path::{Path, PathBuf};
16use std::sync::RwLock;
17use std::sync::{
18    Arc, OnceLock,
19    atomic::{AtomicBool, Ordering},
20};
21use std::thread::{self, JoinHandle};
22use std::time::{Duration, Instant};
23
24/// Embedded meta-schema for validating sentry-options schema files
25const NAMESPACE_SCHEMA_JSON: &str = include_str!("namespace-schema.json");
26const SCHEMA_FILE_NAME: &str = "schema.json";
27const VALUES_FILE_NAME: &str = "values.json";
28
29/// Time between file polls in seconds
30const POLLING_DELAY: u64 = 5;
31
32/// Dedicated Sentry DSN for sentry-options observability.
33/// This is separate from the host application's Sentry setup.
34#[cfg(not(test))]
35const SENTRY_OPTIONS_DSN: &str =
36    "https://d3598a07e9f23a9acee9e2718cfd17bd@o1.ingest.us.sentry.io/4510750163927040";
37
38/// Disabled DSN for tests - empty string creates a disabled client
39#[cfg(test)]
40const SENTRY_OPTIONS_DSN: &str = "";
41
42/// Lazily-initialized dedicated Sentry Hub for sentry-options.
43/// Uses a custom Client that is completely isolated from the host application's Sentry setup.
44/// In test mode, creates a disabled client (empty DSN) so no spans are sent.
45static SENTRY_HUB: OnceLock<Arc<sentry::Hub>> = OnceLock::new();
46
47fn get_sentry_hub() -> &'static Arc<sentry::Hub> {
48    SENTRY_HUB.get_or_init(|| {
49        let client = Arc::new(sentry::Client::from((
50            SENTRY_OPTIONS_DSN,
51            ClientOptions {
52                traces_sample_rate: 1.0,
53                // Explicitly set transport factory - required when not using sentry::init()
54                transport: Some(Arc::new(DefaultTransportFactory)),
55                ..Default::default()
56            },
57        )));
58        Arc::new(sentry::Hub::new(
59            Some(client),
60            Arc::new(sentry::Scope::default()),
61        ))
62    })
63}
64
65/// Production path where options are deployed via config map
66pub const PRODUCTION_OPTIONS_DIR: &str = "/etc/sentry-options";
67
68/// Local fallback path for development
69pub const LOCAL_OPTIONS_DIR: &str = "sentry-options";
70
71/// Environment variable to override options directory
72pub const OPTIONS_DIR_ENV: &str = "SENTRY_OPTIONS_DIR";
73
74/// Environment variable to suppress missing directory errors
75pub const OPTIONS_SUPPRESS_MISSING_DIR_ENV: &str = "SENTRY_OPTIONS_SUPPRESS_MISSING_DIR";
76
77/// Check if missing directory errors should be suppressed
78fn should_suppress_missing_dir_errors() -> bool {
79    std::env::var(OPTIONS_SUPPRESS_MISSING_DIR_ENV)
80        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
81        .unwrap_or(false)
82}
83
84/// Resolve options directory using fallback chain:
85/// 1. `SENTRY_OPTIONS_DIR` env var (if set)
86/// 2. `/etc/sentry-options` (if exists)
87/// 3. `sentry-options/` (local fallback)
88pub fn resolve_options_dir() -> PathBuf {
89    if let Ok(dir) = std::env::var(OPTIONS_DIR_ENV) {
90        return PathBuf::from(dir);
91    }
92
93    let prod_path = PathBuf::from(PRODUCTION_OPTIONS_DIR);
94    if prod_path.exists() {
95        return prod_path;
96    }
97
98    PathBuf::from(LOCAL_OPTIONS_DIR)
99}
100
101/// Result type for validation operations
102pub type ValidationResult<T> = Result<T, ValidationError>;
103
104/// A map of option values keyed by their namespace
105pub type ValuesByNamespace = HashMap<String, HashMap<String, Value>>;
106
107/// Errors that can occur during schema and value validation
108#[derive(Debug, thiserror::Error)]
109pub enum ValidationError {
110    #[error("Schema error in {file}: {message}")]
111    SchemaError { file: PathBuf, message: String },
112
113    #[error("Value error for {namespace}: {errors}")]
114    ValueError { namespace: String, errors: String },
115
116    #[error("Unknown namespace: {0}")]
117    UnknownNamespace(String),
118
119    #[error("Unknown option '{key}' in namespace '{namespace}'")]
120    UnknownOption { namespace: String, key: String },
121
122    #[error("Internal error: {0}")]
123    InternalError(String),
124
125    #[error("Failed to read file: {0}")]
126    FileRead(#[from] std::io::Error),
127
128    #[error("Failed to parse JSON: {0}")]
129    JSONParse(#[from] serde_json::Error),
130
131    #[error("{} validation error(s)", .0.len())]
132    ValidationErrors(Vec<ValidationError>),
133
134    #[error("Invalid {label} '{name}': {reason}")]
135    InvalidName {
136        label: String,
137        name: String,
138        reason: String,
139    },
140}
141
142/// Validate a name component is valid for K8s (lowercase alphanumeric, '-', '.')
143pub fn validate_k8s_name_component(name: &str, label: &str) -> ValidationResult<()> {
144    if let Some(c) = name
145        .chars()
146        .find(|&c| !matches!(c, 'a'..='z' | '0'..='9' | '-' | '.'))
147    {
148        return Err(ValidationError::InvalidName {
149            label: label.to_string(),
150            name: name.to_string(),
151            reason: format!(
152                "character '{}' not allowed. Use lowercase alphanumeric, '-', or '.'",
153                c
154            ),
155        });
156    }
157    if !name.starts_with(|c: char| c.is_ascii_alphanumeric())
158        || !name.ends_with(|c: char| c.is_ascii_alphanumeric())
159    {
160        return Err(ValidationError::InvalidName {
161            label: label.to_string(),
162            name: name.to_string(),
163            reason: "must start and end with alphanumeric".to_string(),
164        });
165    }
166    Ok(())
167}
168
169/// Metadata for a single option in a namespace schema
170#[derive(Debug, Clone)]
171pub struct OptionMetadata {
172    pub option_type: String,
173    pub property_schema: Value,
174    pub default: Value,
175}
176
177/// Schema for a namespace, containing validator and option metadata
178pub struct NamespaceSchema {
179    pub namespace: String,
180    pub options: HashMap<String, OptionMetadata>,
181    validator: jsonschema::Validator,
182}
183
184impl NamespaceSchema {
185    /// Validate an entire values object against this schema
186    ///
187    /// # Arguments
188    /// * `values` - JSON object containing option key-value pairs
189    ///
190    /// # Errors
191    /// Returns error if values don't match the schema
192    pub fn validate_values(&self, values: &Value) -> ValidationResult<()> {
193        let output = self.validator.evaluate(values);
194        if output.flag().valid {
195            Ok(())
196        } else {
197            let errors: Vec<String> = output
198                .iter_errors()
199                .map(|e| {
200                    format!(
201                        "\n\t{} {}",
202                        e.instance_location.as_str().trim_start_matches("/"),
203                        e.error
204                    )
205                })
206                .collect();
207            Err(ValidationError::ValueError {
208                namespace: self.namespace.clone(),
209                errors: errors.join(""),
210            })
211        }
212    }
213
214    /// Get the default value for an option key.
215    /// Returns None if the key doesn't exist in the schema.
216    pub fn get_default(&self, key: &str) -> Option<&Value> {
217        self.options.get(key).map(|meta| &meta.default)
218    }
219
220    /// Validate a single key-value pair against the schema.
221    ///
222    /// # Errors
223    /// Returns error if the key doesn't exist or the value doesn't match the expected type.
224    pub fn validate_option(&self, key: &str, value: &Value) -> ValidationResult<()> {
225        if !self.options.contains_key(key) {
226            return Err(ValidationError::UnknownOption {
227                namespace: self.namespace.clone(),
228                key: key.to_string(),
229            });
230        }
231        let test_obj = json!({ key: value });
232        self.validate_values(&test_obj)
233    }
234}
235
236/// Registry for loading and storing schemas
237pub struct SchemaRegistry {
238    schemas: HashMap<String, Arc<NamespaceSchema>>,
239}
240
241impl SchemaRegistry {
242    /// Create a new empty schema registry
243    pub fn new() -> Self {
244        Self {
245            schemas: HashMap::new(),
246        }
247    }
248
249    /// Load schemas from a directory and create a registry
250    ///
251    /// Expects directory structure: `schemas/{namespace}/schema.json`
252    ///
253    /// # Arguments
254    /// * `schemas_dir` - Path to directory containing namespace subdirectories
255    ///
256    /// # Errors
257    /// Returns error if directory doesn't exist or any schema is invalid
258    pub fn from_directory(schemas_dir: &Path) -> ValidationResult<Self> {
259        let schemas = Self::load_all_schemas(schemas_dir)?;
260        Ok(Self { schemas })
261    }
262
263    /// Validate an entire values object for a namespace
264    ///
265    /// # Arguments
266    /// * `namespace` - Namespace name
267    /// * `values` - JSON object containing option key-value pairs
268    ///
269    /// # Errors
270    /// Returns error if namespace doesn't exist or values don't match schema
271    pub fn validate_values(&self, namespace: &str, values: &Value) -> ValidationResult<()> {
272        let schema = self
273            .schemas
274            .get(namespace)
275            .ok_or_else(|| ValidationError::UnknownNamespace(namespace.to_string()))?;
276
277        schema.validate_values(values)
278    }
279
280    /// Load all schemas from a directory
281    fn load_all_schemas(
282        schemas_dir: &Path,
283    ) -> ValidationResult<HashMap<String, Arc<NamespaceSchema>>> {
284        // Compile namespace-schema once for all schemas
285        let namespace_schema_value: Value =
286            serde_json::from_str(NAMESPACE_SCHEMA_JSON).map_err(|e| {
287                ValidationError::InternalError(format!("Invalid namespace-schema JSON: {}", e))
288            })?;
289        let namespace_validator =
290            jsonschema::validator_for(&namespace_schema_value).map_err(|e| {
291                ValidationError::InternalError(format!("Failed to compile namespace-schema: {}", e))
292            })?;
293
294        let mut schemas = HashMap::new();
295
296        // TODO: Parallelize the loading of schemas for the performance gainz
297        for entry in fs::read_dir(schemas_dir)? {
298            let entry = entry?;
299
300            if !entry.file_type()?.is_dir() {
301                continue;
302            }
303
304            let namespace =
305                entry
306                    .file_name()
307                    .into_string()
308                    .map_err(|_| ValidationError::SchemaError {
309                        file: entry.path(),
310                        message: "Directory name contains invalid UTF-8".to_string(),
311                    })?;
312
313            validate_k8s_name_component(&namespace, "namespace name")?;
314
315            let schema_file = entry.path().join(SCHEMA_FILE_NAME);
316            let schema = Self::load_schema(&schema_file, &namespace, &namespace_validator)?;
317            schemas.insert(namespace, schema);
318        }
319
320        Ok(schemas)
321    }
322
323    /// Load a schema from a file
324    fn load_schema(
325        path: &Path,
326        namespace: &str,
327        namespace_validator: &jsonschema::Validator,
328    ) -> ValidationResult<Arc<NamespaceSchema>> {
329        let file = fs::File::open(path)?;
330        let schema_data: Value = serde_json::from_reader(file)?;
331
332        Self::validate_with_namespace_schema(&schema_data, path, namespace_validator)?;
333        Self::parse_schema(schema_data, namespace, path)
334    }
335
336    /// Validate a schema against the namespace-schema
337    fn validate_with_namespace_schema(
338        schema_data: &Value,
339        path: &Path,
340        namespace_validator: &jsonschema::Validator,
341    ) -> ValidationResult<()> {
342        let output = namespace_validator.evaluate(schema_data);
343
344        if output.flag().valid {
345            Ok(())
346        } else {
347            let errors: Vec<String> = output
348                .iter_errors()
349                .map(|e| format!("Error: {}", e.error))
350                .collect();
351
352            Err(ValidationError::SchemaError {
353                file: path.to_path_buf(),
354                message: format!("Schema validation failed:\n{}", errors.join("\n")),
355            })
356        }
357    }
358
359    /// Validate that a default value matches its declared type using jsonschema
360    fn validate_default_type(
361        property_name: &str,
362        property_schema: &Value,
363        default_value: &Value,
364        path: &Path,
365    ) -> ValidationResult<()> {
366        // Validate the default value against the property schema
367        jsonschema::validate(property_schema, default_value).map_err(|e| {
368            ValidationError::SchemaError {
369                file: path.to_path_buf(),
370                message: format!(
371                    "Property '{}': default value does not match schema: {}",
372                    property_name, e
373                ),
374            }
375        })?;
376
377        Ok(())
378    }
379
380    /// Inject `required` (all field names) and `additionalProperties: false`
381    /// into an object-typed schema. This ensures all properties are required
382    /// and no new properties can be included
383    /// e.g.
384    /// {
385    ///     "type": "object",
386    ///     "properties": {
387    ///       "host": { "type": "string" },
388    ///       "port": { "type": "integer" }
389    ///     },
390    ///     "required": ["host", "port"],                       <-- INJECTED
391    ///     "additionalProperties": false,                      <-- INJECTED
392    ///     "default": { "host": "localhost", "port": 8080 },
393    ///     "description": "..."
394    /// }
395    fn inject_object_constraints(schema: &mut Value) {
396        if let Some(obj) = schema.as_object_mut()
397            && let Some(props) = obj.get("properties").and_then(|p| p.as_object())
398        {
399            let required: Vec<Value> = props
400                .iter()
401                .filter(|(_, v)| !v.get("optional").and_then(|o| o.as_bool()).unwrap_or(false))
402                .map(|(k, _)| Value::String(k.clone()))
403                .collect();
404            obj.insert("required".to_string(), Value::Array(required));
405            obj.insert("additionalProperties".to_string(), json!(false));
406        }
407    }
408
409    /// Parse a schema JSON into NamespaceSchema
410    fn parse_schema(
411        mut schema: Value,
412        namespace: &str,
413        path: &Path,
414    ) -> ValidationResult<Arc<NamespaceSchema>> {
415        // Inject additionalProperties: false to reject unknown options
416        if let Some(obj) = schema.as_object_mut() {
417            obj.insert("additionalProperties".to_string(), json!(false));
418        }
419
420        // Inject object constraints (required + additionalProperties) for object-typed options
421        // so that jsonschema validates the full shape of object values.
422        if let Some(properties) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) {
423            for prop_value in properties.values_mut() {
424                let prop_type = prop_value
425                    .get("type")
426                    .and_then(|t| t.as_str())
427                    .unwrap_or("");
428
429                if prop_type == "object" {
430                    Self::inject_object_constraints(prop_value);
431                } else if prop_type == "array"
432                    && let Some(items) = prop_value.get_mut("items")
433                {
434                    let items_type = items.get("type").and_then(|t| t.as_str()).unwrap_or("");
435                    if items_type == "object" {
436                        Self::inject_object_constraints(items);
437                    }
438                }
439            }
440        }
441
442        // Use the schema file directly as the validator
443        let validator =
444            jsonschema::validator_for(&schema).map_err(|e| ValidationError::SchemaError {
445                file: path.to_path_buf(),
446                message: format!("Failed to compile validator: {}", e),
447            })?;
448
449        // Extract option metadata and validate types
450        let mut options = HashMap::new();
451        if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
452            for (prop_name, prop_value) in properties {
453                if let (Some(prop_type), Some(default_value)) = (
454                    prop_value.get("type").and_then(|t| t.as_str()),
455                    prop_value.get("default"),
456                ) {
457                    Self::validate_default_type(prop_name, prop_value, default_value, path)?;
458                    options.insert(
459                        prop_name.clone(),
460                        OptionMetadata {
461                            option_type: prop_type.to_string(),
462                            property_schema: prop_value.clone(),
463                            default: default_value.clone(),
464                        },
465                    );
466                }
467            }
468        }
469
470        Ok(Arc::new(NamespaceSchema {
471            namespace: namespace.to_string(),
472            options,
473            validator,
474        }))
475    }
476
477    /// Get a namespace schema by name
478    pub fn get(&self, namespace: &str) -> Option<&Arc<NamespaceSchema>> {
479        self.schemas.get(namespace)
480    }
481
482    /// Get all loaded schemas (for schema evolution validation)
483    pub fn schemas(&self) -> &HashMap<String, Arc<NamespaceSchema>> {
484        &self.schemas
485    }
486
487    /// Load and validate JSON values from a directory.
488    /// Expects structure: `{values_dir}/{namespace}/values.json`
489    /// Values file must have format: `{"options": {"key": value, ...}, "generated_at": "..."}`
490    /// Skips namespaces without a values.json file.
491    /// Returns the values and a map of namespace -> `generated_at` timestamp.
492    pub fn load_values_json(
493        &self,
494        values_dir: &Path,
495    ) -> ValidationResult<(ValuesByNamespace, HashMap<String, String>)> {
496        let mut all_values = HashMap::new();
497        let mut generated_at_by_namespace: HashMap<String, String> = HashMap::new();
498
499        for namespace in self.schemas.keys() {
500            let values_file = values_dir.join(namespace).join(VALUES_FILE_NAME);
501
502            if !values_file.exists() {
503                continue;
504            }
505
506            let parsed: Value = serde_json::from_reader(fs::File::open(&values_file)?)?;
507
508            // Extract generated_at if present
509            if let Some(ts) = parsed.get("generated_at").and_then(|v| v.as_str()) {
510                generated_at_by_namespace.insert(namespace.clone(), ts.to_string());
511            }
512
513            let values = parsed
514                .get("options")
515                .ok_or_else(|| ValidationError::ValueError {
516                    namespace: namespace.clone(),
517                    errors: "values.json must have an 'options' key".to_string(),
518                })?;
519
520            self.validate_values(namespace, values)?;
521
522            if let Value::Object(obj) = values.clone() {
523                let ns_values: HashMap<String, Value> = obj.into_iter().collect();
524                all_values.insert(namespace.clone(), ns_values);
525            }
526        }
527
528        Ok((all_values, generated_at_by_namespace))
529    }
530}
531
532impl Default for SchemaRegistry {
533    fn default() -> Self {
534        Self::new()
535    }
536}
537
538/// Watches the values directory for changes, reloading if there are any.
539/// If the directory does not exist we do not panic
540///
541/// Does not do an initial fetch, assumes the caller has already loaded values.
542/// Child thread may panic if we run out of memory or cannot create more threads.
543///
544/// Uses polling for now, could use `inotify` or similar later on.
545///
546/// Some important notes:
547/// - If the thread panics and dies, there is no built in mechanism to catch it and restart
548/// - If a config map is unmounted, we won't reload until the next file modification (because we don't catch the deletion event)
549/// - If any namespace fails validation, we keep all old values (even the namespaces that passed validation)
550/// - If we have a steady stream of readers our writer may starve for a while trying to acquire the lock
551/// - stop() will block until the thread gets joined
552pub struct ValuesWatcher {
553    stop_signal: Arc<AtomicBool>,
554    thread: Option<JoinHandle<()>>,
555}
556
557impl ValuesWatcher {
558    /// Creates a new ValuesWatcher struct and spins up the watcher thread
559    pub fn new(
560        values_path: &Path,
561        registry: Arc<SchemaRegistry>,
562        values: Arc<RwLock<ValuesByNamespace>>,
563    ) -> ValidationResult<Self> {
564        // output an error but keep passing
565        if !should_suppress_missing_dir_errors() && fs::metadata(values_path).is_err() {
566            eprintln!("Values directory does not exist: {}", values_path.display());
567        }
568
569        let stop_signal = Arc::new(AtomicBool::new(false));
570
571        let thread_signal = Arc::clone(&stop_signal);
572        let thread_path = values_path.to_path_buf();
573        let thread_registry = Arc::clone(&registry);
574        let thread_values = Arc::clone(&values);
575        let thread = thread::Builder::new()
576            .name("sentry-options-watcher".into())
577            .spawn(move || {
578                let result = panic::catch_unwind(AssertUnwindSafe(|| {
579                    Self::run(thread_signal, thread_path, thread_registry, thread_values);
580                }));
581                if let Err(e) = result {
582                    eprintln!("Watcher thread panicked with: {:?}", e);
583                }
584            })?;
585
586        Ok(Self {
587            stop_signal,
588            thread: Some(thread),
589        })
590    }
591
592    /// Reloads the values if the modified time has changed.
593    ///
594    /// Continuously polls the values directory and reloads all values
595    /// if any modification is detected.
596    fn run(
597        stop_signal: Arc<AtomicBool>,
598        values_path: PathBuf,
599        registry: Arc<SchemaRegistry>,
600        values: Arc<RwLock<ValuesByNamespace>>,
601    ) {
602        let mut last_mtime = Self::get_mtime(&values_path);
603
604        while !stop_signal.load(Ordering::Relaxed) {
605            // does not reload values if get_mtime fails
606            if let Some(current_mtime) = Self::get_mtime(&values_path)
607                && Some(current_mtime) != last_mtime
608            {
609                Self::reload_values(&values_path, &registry, &values);
610                last_mtime = Some(current_mtime);
611            }
612
613            thread::sleep(Duration::from_secs(POLLING_DELAY));
614        }
615    }
616
617    /// Get the most recent modification time across all namespace values.json files
618    /// Returns None if no valid values files are found
619    fn get_mtime(values_dir: &Path) -> Option<std::time::SystemTime> {
620        let mut latest_mtime = None;
621
622        let entries = match fs::read_dir(values_dir) {
623            Ok(e) => e,
624            Err(e) => {
625                if !should_suppress_missing_dir_errors() {
626                    eprintln!("Failed to read directory {}: {}", values_dir.display(), e);
627                }
628                return None;
629            }
630        };
631
632        for entry in entries.flatten() {
633            // skip if not a dir
634            if !entry
635                .file_type()
636                .map(|file_type| file_type.is_dir())
637                .unwrap_or(false)
638            {
639                continue;
640            }
641
642            let values_file = entry.path().join(VALUES_FILE_NAME);
643            if let Ok(metadata) = fs::metadata(&values_file)
644                && let Ok(mtime) = metadata.modified()
645                && latest_mtime.is_none_or(|latest| mtime > latest)
646            {
647                latest_mtime = Some(mtime);
648            }
649        }
650
651        latest_mtime
652    }
653
654    /// Reload values from disk, validate them, and update the shared map.
655    /// Emits a Sentry transaction per namespace with timing and propagation delay metrics.
656    fn reload_values(
657        values_path: &Path,
658        registry: &SchemaRegistry,
659        values: &Arc<RwLock<ValuesByNamespace>>,
660    ) {
661        let reload_start = Instant::now();
662
663        match registry.load_values_json(values_path) {
664            Ok((new_values, generated_at_by_namespace)) => {
665                let namespaces: Vec<String> = new_values.keys().cloned().collect();
666                Self::update_values(values, new_values);
667
668                let reload_duration = reload_start.elapsed();
669                Self::emit_reload_spans(&namespaces, reload_duration, &generated_at_by_namespace);
670            }
671            Err(e) => {
672                eprintln!(
673                    "Failed to reload values from {}: {}",
674                    values_path.display(),
675                    e
676                );
677            }
678        }
679    }
680
681    /// Emit a Sentry transaction per namespace with reload timing and propagation delay metrics.
682    /// Uses a dedicated Sentry Hub isolated from the host application's Sentry setup.
683    fn emit_reload_spans(
684        namespaces: &[String],
685        reload_duration: Duration,
686        generated_at_by_namespace: &HashMap<String, String>,
687    ) {
688        let hub = get_sentry_hub();
689        let applied_at = Utc::now();
690        let reload_duration_ms = reload_duration.as_secs_f64() * 1000.0;
691
692        for namespace in namespaces {
693            let mut tx_ctx = sentry::TransactionContext::new(namespace, "sentry_options.reload");
694            tx_ctx.set_sampled(true);
695
696            let transaction = hub.start_transaction(tx_ctx);
697            transaction.set_data("reload_duration_ms", reload_duration_ms.into());
698            transaction.set_data("applied_at", applied_at.to_rfc3339().into());
699
700            if let Some(ts) = generated_at_by_namespace.get(namespace) {
701                transaction.set_data("generated_at", ts.as_str().into());
702
703                if let Ok(generated_time) = DateTime::parse_from_rfc3339(ts) {
704                    let delay_secs = (applied_at - generated_time.with_timezone(&Utc))
705                        .num_milliseconds() as f64
706                        / 1000.0;
707                    transaction.set_data("propagation_delay_secs", delay_secs.into());
708                }
709            }
710
711            transaction.finish();
712        }
713    }
714
715    /// Update the values map with the new values
716    fn update_values(values: &Arc<RwLock<ValuesByNamespace>>, new_values: ValuesByNamespace) {
717        // safe to unwrap, we only have one thread and if it panics we die anyways
718        let mut guard = values.write().unwrap();
719        *guard = new_values;
720    }
721
722    /// Stops the watcher thread, waiting for it to join.
723    /// May take up to POLLING_DELAY seconds
724    pub fn stop(&mut self) {
725        self.stop_signal.store(true, Ordering::Relaxed);
726        if let Some(thread) = self.thread.take() {
727            let _ = thread.join();
728        }
729    }
730
731    /// Returns whether the watcher thread is still running
732    pub fn is_alive(&self) -> bool {
733        self.thread.as_ref().is_some_and(|t| !t.is_finished())
734    }
735}
736
737impl Drop for ValuesWatcher {
738    fn drop(&mut self) {
739        self.stop();
740    }
741}
742
743#[cfg(test)]
744mod tests {
745    use super::*;
746    use tempfile::TempDir;
747
748    fn create_test_schema(temp_dir: &TempDir, namespace: &str, schema_json: &str) -> PathBuf {
749        let schema_dir = temp_dir.path().join(namespace);
750        fs::create_dir_all(&schema_dir).unwrap();
751        let schema_file = schema_dir.join("schema.json");
752        fs::write(&schema_file, schema_json).unwrap();
753        schema_file
754    }
755
756    fn create_test_schema_with_values(
757        temp_dir: &TempDir,
758        namespace: &str,
759        schema_json: &str,
760        values_json: &str,
761    ) -> (PathBuf, PathBuf) {
762        let schemas_dir = temp_dir.path().join("schemas");
763        let values_dir = temp_dir.path().join("values");
764
765        let schema_dir = schemas_dir.join(namespace);
766        fs::create_dir_all(&schema_dir).unwrap();
767        let schema_file = schema_dir.join("schema.json");
768        fs::write(&schema_file, schema_json).unwrap();
769
770        let ns_values_dir = values_dir.join(namespace);
771        fs::create_dir_all(&ns_values_dir).unwrap();
772        let values_file = ns_values_dir.join("values.json");
773        fs::write(&values_file, values_json).unwrap();
774
775        (schemas_dir, values_dir)
776    }
777
778    #[test]
779    fn test_validate_k8s_name_component_valid() {
780        assert!(validate_k8s_name_component("relay", "namespace").is_ok());
781        assert!(validate_k8s_name_component("my-service", "namespace").is_ok());
782        assert!(validate_k8s_name_component("my.service", "namespace").is_ok());
783        assert!(validate_k8s_name_component("a1-b2.c3", "namespace").is_ok());
784    }
785
786    #[test]
787    fn test_validate_k8s_name_component_rejects_uppercase() {
788        let result = validate_k8s_name_component("MyService", "namespace");
789        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
790        assert!(result.unwrap_err().to_string().contains("'M' not allowed"));
791    }
792
793    #[test]
794    fn test_validate_k8s_name_component_rejects_underscore() {
795        let result = validate_k8s_name_component("my_service", "target");
796        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
797        assert!(result.unwrap_err().to_string().contains("'_' not allowed"));
798    }
799
800    #[test]
801    fn test_validate_k8s_name_component_rejects_leading_hyphen() {
802        let result = validate_k8s_name_component("-service", "namespace");
803        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
804        assert!(
805            result
806                .unwrap_err()
807                .to_string()
808                .contains("start and end with alphanumeric")
809        );
810    }
811
812    #[test]
813    fn test_validate_k8s_name_component_rejects_trailing_dot() {
814        let result = validate_k8s_name_component("service.", "namespace");
815        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
816        assert!(
817            result
818                .unwrap_err()
819                .to_string()
820                .contains("start and end with alphanumeric")
821        );
822    }
823
824    #[test]
825    fn test_load_schema_valid() {
826        let temp_dir = TempDir::new().unwrap();
827        create_test_schema(
828            &temp_dir,
829            "test",
830            r#"{
831                "version": "1.0",
832                "type": "object",
833                "properties": {
834                    "test-key": {
835                        "type": "string",
836                        "default": "test",
837                        "description": "Test option"
838                    }
839                }
840            }"#,
841        );
842
843        SchemaRegistry::from_directory(temp_dir.path()).unwrap();
844    }
845
846    #[test]
847    fn test_load_schema_missing_version() {
848        let temp_dir = TempDir::new().unwrap();
849        create_test_schema(
850            &temp_dir,
851            "test",
852            r#"{
853                "type": "object",
854                "properties": {}
855            }"#,
856        );
857
858        let result = SchemaRegistry::from_directory(temp_dir.path());
859        assert!(result.is_err());
860        match result {
861            Err(ValidationError::SchemaError { message, .. }) => {
862                assert!(message.contains(
863                    "Schema validation failed:
864Error: \"version\" is a required property"
865                ));
866            }
867            _ => panic!("Expected SchemaError for missing version"),
868        }
869    }
870
871    #[test]
872    fn test_unknown_namespace() {
873        let temp_dir = TempDir::new().unwrap();
874        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
875
876        let result = registry.validate_values("unknown", &json!({}));
877        assert!(matches!(result, Err(ValidationError::UnknownNamespace(..))));
878    }
879
880    #[test]
881    fn test_multiple_namespaces() {
882        let temp_dir = TempDir::new().unwrap();
883        create_test_schema(
884            &temp_dir,
885            "ns1",
886            r#"{
887                "version": "1.0",
888                "type": "object",
889                "properties": {
890                    "opt1": {
891                        "type": "string",
892                        "default": "default1",
893                        "description": "First option"
894                    }
895                }
896            }"#,
897        );
898        create_test_schema(
899            &temp_dir,
900            "ns2",
901            r#"{
902                "version": "2.0",
903                "type": "object",
904                "properties": {
905                    "opt2": {
906                        "type": "integer",
907                        "default": 42,
908                        "description": "Second option"
909                    }
910                }
911            }"#,
912        );
913
914        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
915        assert!(registry.schemas.contains_key("ns1"));
916        assert!(registry.schemas.contains_key("ns2"));
917    }
918
919    #[test]
920    fn test_invalid_default_type() {
921        let temp_dir = TempDir::new().unwrap();
922        create_test_schema(
923            &temp_dir,
924            "test",
925            r#"{
926                "version": "1.0",
927                "type": "object",
928                "properties": {
929                    "bad-default": {
930                        "type": "integer",
931                        "default": "not-a-number",
932                        "description": "A bad default value"
933                    }
934                }
935            }"#,
936        );
937
938        let result = SchemaRegistry::from_directory(temp_dir.path());
939        assert!(result.is_err());
940        match result {
941            Err(ValidationError::SchemaError { message, .. }) => {
942                assert!(
943                    message.contains("Property 'bad-default': default value does not match schema")
944                );
945                assert!(message.contains("\"not-a-number\" is not of type \"integer\""));
946            }
947            _ => panic!("Expected SchemaError for invalid default type"),
948        }
949    }
950
951    #[test]
952    fn test_extra_properties() {
953        let temp_dir = TempDir::new().unwrap();
954        create_test_schema(
955            &temp_dir,
956            "test",
957            r#"{
958                "version": "1.0",
959                "type": "object",
960                "properties": {
961                    "bad-property": {
962                        "type": "integer",
963                        "default": 0,
964                        "description": "Test property",
965                        "extra": "property"
966                    }
967                }
968            }"#,
969        );
970
971        let result = SchemaRegistry::from_directory(temp_dir.path());
972        assert!(result.is_err());
973        match result {
974            Err(ValidationError::SchemaError { message, .. }) => {
975                assert!(
976                    message
977                        .contains("Additional properties are not allowed ('extra' was unexpected)")
978                );
979            }
980            _ => panic!("Expected SchemaError for extra properties"),
981        }
982    }
983
984    #[test]
985    fn test_missing_description() {
986        let temp_dir = TempDir::new().unwrap();
987        create_test_schema(
988            &temp_dir,
989            "test",
990            r#"{
991                "version": "1.0",
992                "type": "object",
993                "properties": {
994                    "missing-desc": {
995                        "type": "string",
996                        "default": "test"
997                    }
998                }
999            }"#,
1000        );
1001
1002        let result = SchemaRegistry::from_directory(temp_dir.path());
1003        assert!(result.is_err());
1004        match result {
1005            Err(ValidationError::SchemaError { message, .. }) => {
1006                assert!(message.contains("\"description\" is a required property"));
1007            }
1008            _ => panic!("Expected SchemaError for missing description"),
1009        }
1010    }
1011
1012    #[test]
1013    fn test_invalid_directory_structure() {
1014        let temp_dir = TempDir::new().unwrap();
1015        // Create a namespace directory without schema.json file
1016        let schema_dir = temp_dir.path().join("missing-schema");
1017        fs::create_dir_all(&schema_dir).unwrap();
1018
1019        let result = SchemaRegistry::from_directory(temp_dir.path());
1020        assert!(result.is_err());
1021        match result {
1022            Err(ValidationError::FileRead(..)) => {
1023                // Expected error when schema.json file is missing
1024            }
1025            _ => panic!("Expected FileRead error for missing schema.json"),
1026        }
1027    }
1028
1029    #[test]
1030    fn test_get_default() {
1031        let temp_dir = TempDir::new().unwrap();
1032        create_test_schema(
1033            &temp_dir,
1034            "test",
1035            r#"{
1036                "version": "1.0",
1037                "type": "object",
1038                "properties": {
1039                    "string_opt": {
1040                        "type": "string",
1041                        "default": "hello",
1042                        "description": "A string option"
1043                    },
1044                    "int_opt": {
1045                        "type": "integer",
1046                        "default": 42,
1047                        "description": "An integer option"
1048                    }
1049                }
1050            }"#,
1051        );
1052
1053        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1054        let schema = registry.get("test").unwrap();
1055
1056        assert_eq!(schema.get_default("string_opt"), Some(&json!("hello")));
1057        assert_eq!(schema.get_default("int_opt"), Some(&json!(42)));
1058        assert_eq!(schema.get_default("unknown"), None);
1059    }
1060
1061    #[test]
1062    fn test_validate_values_valid() {
1063        let temp_dir = TempDir::new().unwrap();
1064        create_test_schema(
1065            &temp_dir,
1066            "test",
1067            r#"{
1068                "version": "1.0",
1069                "type": "object",
1070                "properties": {
1071                    "enabled": {
1072                        "type": "boolean",
1073                        "default": false,
1074                        "description": "Enable feature"
1075                    }
1076                }
1077            }"#,
1078        );
1079
1080        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1081        let result = registry.validate_values("test", &json!({"enabled": true}));
1082        assert!(result.is_ok());
1083    }
1084
1085    #[test]
1086    fn test_validate_values_invalid_type() {
1087        let temp_dir = TempDir::new().unwrap();
1088        create_test_schema(
1089            &temp_dir,
1090            "test",
1091            r#"{
1092                "version": "1.0",
1093                "type": "object",
1094                "properties": {
1095                    "count": {
1096                        "type": "integer",
1097                        "default": 0,
1098                        "description": "Count"
1099                    }
1100                }
1101            }"#,
1102        );
1103
1104        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1105        let result = registry.validate_values("test", &json!({"count": "not a number"}));
1106        assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1107    }
1108
1109    #[test]
1110    fn test_validate_values_unknown_option() {
1111        let temp_dir = TempDir::new().unwrap();
1112        create_test_schema(
1113            &temp_dir,
1114            "test",
1115            r#"{
1116                "version": "1.0",
1117                "type": "object",
1118                "properties": {
1119                    "known_option": {
1120                        "type": "string",
1121                        "default": "default",
1122                        "description": "A known option"
1123                    }
1124                }
1125            }"#,
1126        );
1127
1128        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1129
1130        // Valid known option should pass
1131        let result = registry.validate_values("test", &json!({"known_option": "value"}));
1132        assert!(result.is_ok());
1133
1134        // Unknown option should fail
1135        let result = registry.validate_values("test", &json!({"unknown_option": "value"}));
1136        assert!(result.is_err());
1137        match result {
1138            Err(ValidationError::ValueError { errors, .. }) => {
1139                assert!(errors.contains("Additional properties are not allowed"));
1140            }
1141            _ => panic!("Expected ValueError for unknown option"),
1142        }
1143    }
1144
1145    #[test]
1146    fn test_load_values_json_valid() {
1147        let temp_dir = TempDir::new().unwrap();
1148        let schemas_dir = temp_dir.path().join("schemas");
1149        let values_dir = temp_dir.path().join("values");
1150
1151        let schema_dir = schemas_dir.join("test");
1152        fs::create_dir_all(&schema_dir).unwrap();
1153        fs::write(
1154            schema_dir.join("schema.json"),
1155            r#"{
1156                "version": "1.0",
1157                "type": "object",
1158                "properties": {
1159                    "enabled": {
1160                        "type": "boolean",
1161                        "default": false,
1162                        "description": "Enable feature"
1163                    },
1164                    "name": {
1165                        "type": "string",
1166                        "default": "default",
1167                        "description": "Name"
1168                    },
1169                    "count": {
1170                        "type": "integer",
1171                        "default": 0,
1172                        "description": "Count"
1173                    },
1174                    "rate": {
1175                        "type": "number",
1176                        "default": 0.0,
1177                        "description": "Rate"
1178                    }
1179                }
1180            }"#,
1181        )
1182        .unwrap();
1183
1184        let test_values_dir = values_dir.join("test");
1185        fs::create_dir_all(&test_values_dir).unwrap();
1186        fs::write(
1187            test_values_dir.join("values.json"),
1188            r#"{
1189                "options": {
1190                    "enabled": true,
1191                    "name": "test-name",
1192                    "count": 42,
1193                    "rate": 0.75
1194                }
1195            }"#,
1196        )
1197        .unwrap();
1198
1199        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1200        let (values, generated_at_by_namespace) = registry.load_values_json(&values_dir).unwrap();
1201
1202        assert_eq!(values.len(), 1);
1203        assert_eq!(values["test"]["enabled"], json!(true));
1204        assert_eq!(values["test"]["name"], json!("test-name"));
1205        assert_eq!(values["test"]["count"], json!(42));
1206        assert_eq!(values["test"]["rate"], json!(0.75));
1207        assert!(generated_at_by_namespace.is_empty());
1208    }
1209
1210    #[test]
1211    fn test_load_values_json_nonexistent_dir() {
1212        let temp_dir = TempDir::new().unwrap();
1213        create_test_schema(
1214            &temp_dir,
1215            "test",
1216            r#"{"version": "1.0", "type": "object", "properties": {}}"#,
1217        );
1218
1219        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1220        let (values, generated_at_by_namespace) = registry
1221            .load_values_json(&temp_dir.path().join("nonexistent"))
1222            .unwrap();
1223
1224        // No values.json files found, returns empty
1225        assert!(values.is_empty());
1226        assert!(generated_at_by_namespace.is_empty());
1227    }
1228
1229    #[test]
1230    fn test_load_values_json_skips_missing_values_file() {
1231        let temp_dir = TempDir::new().unwrap();
1232        let schemas_dir = temp_dir.path().join("schemas");
1233        let values_dir = temp_dir.path().join("values");
1234
1235        // Create two schemas
1236        let schema_dir1 = schemas_dir.join("with-values");
1237        fs::create_dir_all(&schema_dir1).unwrap();
1238        fs::write(
1239            schema_dir1.join("schema.json"),
1240            r#"{
1241                "version": "1.0",
1242                "type": "object",
1243                "properties": {
1244                    "opt": {"type": "string", "default": "x", "description": "Opt"}
1245                }
1246            }"#,
1247        )
1248        .unwrap();
1249
1250        let schema_dir2 = schemas_dir.join("without-values");
1251        fs::create_dir_all(&schema_dir2).unwrap();
1252        fs::write(
1253            schema_dir2.join("schema.json"),
1254            r#"{
1255                "version": "1.0",
1256                "type": "object",
1257                "properties": {
1258                    "opt": {"type": "string", "default": "x", "description": "Opt"}
1259                }
1260            }"#,
1261        )
1262        .unwrap();
1263
1264        // Only create values for one namespace
1265        let with_values_dir = values_dir.join("with-values");
1266        fs::create_dir_all(&with_values_dir).unwrap();
1267        fs::write(
1268            with_values_dir.join("values.json"),
1269            r#"{"options": {"opt": "y"}}"#,
1270        )
1271        .unwrap();
1272
1273        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1274        let (values, _) = registry.load_values_json(&values_dir).unwrap();
1275
1276        assert_eq!(values.len(), 1);
1277        assert!(values.contains_key("with-values"));
1278        assert!(!values.contains_key("without-values"));
1279    }
1280
1281    #[test]
1282    fn test_load_values_json_extracts_generated_at() {
1283        let temp_dir = TempDir::new().unwrap();
1284        let schemas_dir = temp_dir.path().join("schemas");
1285        let values_dir = temp_dir.path().join("values");
1286
1287        let schema_dir = schemas_dir.join("test");
1288        fs::create_dir_all(&schema_dir).unwrap();
1289        fs::write(
1290            schema_dir.join("schema.json"),
1291            r#"{
1292                "version": "1.0",
1293                "type": "object",
1294                "properties": {
1295                    "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1296                }
1297            }"#,
1298        )
1299        .unwrap();
1300
1301        let test_values_dir = values_dir.join("test");
1302        fs::create_dir_all(&test_values_dir).unwrap();
1303        fs::write(
1304            test_values_dir.join("values.json"),
1305            r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T18:30:00.123456+00:00"}"#,
1306        )
1307        .unwrap();
1308
1309        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1310        let (values, generated_at_by_namespace) = registry.load_values_json(&values_dir).unwrap();
1311
1312        assert_eq!(values["test"]["enabled"], json!(true));
1313        assert_eq!(
1314            generated_at_by_namespace.get("test"),
1315            Some(&"2024-01-21T18:30:00.123456+00:00".to_string())
1316        );
1317    }
1318
1319    #[test]
1320    fn test_load_values_json_rejects_wrong_type() {
1321        let temp_dir = TempDir::new().unwrap();
1322        let schemas_dir = temp_dir.path().join("schemas");
1323        let values_dir = temp_dir.path().join("values");
1324
1325        let schema_dir = schemas_dir.join("test");
1326        fs::create_dir_all(&schema_dir).unwrap();
1327        fs::write(
1328            schema_dir.join("schema.json"),
1329            r#"{
1330                "version": "1.0",
1331                "type": "object",
1332                "properties": {
1333                    "count": {"type": "integer", "default": 0, "description": "Count"}
1334                }
1335            }"#,
1336        )
1337        .unwrap();
1338
1339        let test_values_dir = values_dir.join("test");
1340        fs::create_dir_all(&test_values_dir).unwrap();
1341        fs::write(
1342            test_values_dir.join("values.json"),
1343            r#"{"options": {"count": "not-a-number"}}"#,
1344        )
1345        .unwrap();
1346
1347        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1348        let result = registry.load_values_json(&values_dir);
1349
1350        assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1351    }
1352
1353    mod watcher_tests {
1354        use super::*;
1355        use std::thread;
1356
1357        /// Creates schema and values files for two namespaces: ns1, and ns2
1358        fn setup_watcher_test() -> (TempDir, PathBuf, PathBuf) {
1359            let temp_dir = TempDir::new().unwrap();
1360            let schemas_dir = temp_dir.path().join("schemas");
1361            let values_dir = temp_dir.path().join("values");
1362
1363            let ns1_schema = schemas_dir.join("ns1");
1364            fs::create_dir_all(&ns1_schema).unwrap();
1365            fs::write(
1366                ns1_schema.join("schema.json"),
1367                r#"{
1368                    "version": "1.0",
1369                    "type": "object",
1370                    "properties": {
1371                        "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1372                    }
1373                }"#,
1374            )
1375            .unwrap();
1376
1377            let ns1_values = values_dir.join("ns1");
1378            fs::create_dir_all(&ns1_values).unwrap();
1379            fs::write(
1380                ns1_values.join("values.json"),
1381                r#"{"options": {"enabled": true}}"#,
1382            )
1383            .unwrap();
1384
1385            let ns2_schema = schemas_dir.join("ns2");
1386            fs::create_dir_all(&ns2_schema).unwrap();
1387            fs::write(
1388                ns2_schema.join("schema.json"),
1389                r#"{
1390                    "version": "1.0",
1391                    "type": "object",
1392                    "properties": {
1393                        "count": {"type": "integer", "default": 0, "description": "Count"}
1394                    }
1395                }"#,
1396            )
1397            .unwrap();
1398
1399            let ns2_values = values_dir.join("ns2");
1400            fs::create_dir_all(&ns2_values).unwrap();
1401            fs::write(
1402                ns2_values.join("values.json"),
1403                r#"{"options": {"count": 42}}"#,
1404            )
1405            .unwrap();
1406
1407            (temp_dir, schemas_dir, values_dir)
1408        }
1409
1410        #[test]
1411        fn test_get_mtime_returns_most_recent() {
1412            let (_temp, _schemas, values_dir) = setup_watcher_test();
1413
1414            // Get initial mtime
1415            let mtime1 = ValuesWatcher::get_mtime(&values_dir);
1416            assert!(mtime1.is_some());
1417
1418            // Modify one namespace
1419            thread::sleep(std::time::Duration::from_millis(10));
1420            fs::write(
1421                values_dir.join("ns1").join("values.json"),
1422                r#"{"options": {"enabled": false}}"#,
1423            )
1424            .unwrap();
1425
1426            // Should detect the change
1427            let mtime2 = ValuesWatcher::get_mtime(&values_dir);
1428            assert!(mtime2.is_some());
1429            assert!(mtime2 > mtime1);
1430        }
1431
1432        #[test]
1433        fn test_get_mtime_with_missing_directory() {
1434            let temp = TempDir::new().unwrap();
1435            let nonexistent = temp.path().join("nonexistent");
1436
1437            let mtime = ValuesWatcher::get_mtime(&nonexistent);
1438            assert!(mtime.is_none());
1439        }
1440
1441        #[test]
1442        fn test_reload_values_updates_map() {
1443            let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1444
1445            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1446            let (initial_values, _) = registry.load_values_json(&values_dir).unwrap();
1447            let values = Arc::new(RwLock::new(initial_values));
1448
1449            // ensure initial values are correct
1450            {
1451                let guard = values.read().unwrap();
1452                assert_eq!(guard["ns1"]["enabled"], json!(true));
1453                assert_eq!(guard["ns2"]["count"], json!(42));
1454            }
1455
1456            // modify
1457            fs::write(
1458                values_dir.join("ns1").join("values.json"),
1459                r#"{"options": {"enabled": false}}"#,
1460            )
1461            .unwrap();
1462            fs::write(
1463                values_dir.join("ns2").join("values.json"),
1464                r#"{"options": {"count": 100}}"#,
1465            )
1466            .unwrap();
1467
1468            // force a reload
1469            ValuesWatcher::reload_values(&values_dir, &registry, &values);
1470
1471            // ensure new values are correct
1472            {
1473                let guard = values.read().unwrap();
1474                assert_eq!(guard["ns1"]["enabled"], json!(false));
1475                assert_eq!(guard["ns2"]["count"], json!(100));
1476            }
1477        }
1478
1479        #[test]
1480        fn test_old_values_persist_with_invalid_data() {
1481            let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1482
1483            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1484            let (initial_values, _) = registry.load_values_json(&values_dir).unwrap();
1485            let values = Arc::new(RwLock::new(initial_values));
1486
1487            let initial_enabled = {
1488                let guard = values.read().unwrap();
1489                guard["ns1"]["enabled"].clone()
1490            };
1491
1492            // won't pass validation
1493            fs::write(
1494                values_dir.join("ns1").join("values.json"),
1495                r#"{"options": {"enabled": "not-a-boolean"}}"#,
1496            )
1497            .unwrap();
1498
1499            ValuesWatcher::reload_values(&values_dir, &registry, &values);
1500
1501            // ensure old value persists
1502            {
1503                let guard = values.read().unwrap();
1504                assert_eq!(guard["ns1"]["enabled"], initial_enabled);
1505            }
1506        }
1507
1508        #[test]
1509        fn test_watcher_creation_and_termination() {
1510            let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1511
1512            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1513            let (initial_values, _) = registry.load_values_json(&values_dir).unwrap();
1514            let values = Arc::new(RwLock::new(initial_values));
1515
1516            let mut watcher =
1517                ValuesWatcher::new(&values_dir, Arc::clone(&registry), Arc::clone(&values))
1518                    .expect("Failed to create watcher");
1519
1520            assert!(watcher.is_alive());
1521            watcher.stop();
1522            assert!(!watcher.is_alive());
1523        }
1524    }
1525    mod array_tests {
1526        use super::*;
1527
1528        #[test]
1529        fn test_basic_schema_validation() {
1530            let temp_dir = TempDir::new().unwrap();
1531            for (a_type, default) in [
1532                ("boolean", ""), // empty array test
1533                ("boolean", "true"),
1534                ("integer", "1"),
1535                ("number", "1.2"),
1536                ("string", "\"wow\""),
1537            ] {
1538                create_test_schema(
1539                    &temp_dir,
1540                    "test",
1541                    &format!(
1542                        r#"{{
1543                        "version": "1.0",
1544                        "type": "object",
1545                        "properties": {{
1546                            "array-key": {{
1547                                "type": "array",
1548                                "items": {{"type": "{}"}},
1549                                "default": [{}],
1550                                "description": "Array option"
1551                                }}
1552                            }}
1553                        }}"#,
1554                        a_type, default
1555                    ),
1556                );
1557
1558                SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1559            }
1560        }
1561
1562        #[test]
1563        fn test_missing_items_object_rejection() {
1564            let temp_dir = TempDir::new().unwrap();
1565            create_test_schema(
1566                &temp_dir,
1567                "test",
1568                r#"{
1569                    "version": "1.0",
1570                    "type": "object",
1571                    "properties": {
1572                        "array-key": {
1573                            "type": "array",
1574                            "default": [1,2,3],
1575                            "description": "Array option"
1576                        }
1577                    }
1578                }"#,
1579            );
1580
1581            let result = SchemaRegistry::from_directory(temp_dir.path());
1582            assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
1583        }
1584
1585        #[test]
1586        fn test_malformed_items_rejection() {
1587            let temp_dir = TempDir::new().unwrap();
1588            create_test_schema(
1589                &temp_dir,
1590                "test",
1591                r#"{
1592                    "version": "1.0",
1593                    "type": "object",
1594                    "properties": {
1595                        "array-key": {
1596                            "type": "array",
1597                            "items": {"type": ""},
1598                            "default": [1,2,3],
1599                            "description": "Array option"
1600                        }
1601                    }
1602                }"#,
1603            );
1604
1605            let result = SchemaRegistry::from_directory(temp_dir.path());
1606            assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
1607        }
1608
1609        #[test]
1610        fn test_schema_default_type_mismatch_rejection() {
1611            let temp_dir = TempDir::new().unwrap();
1612            // also tests real number rejection when type is integer
1613            create_test_schema(
1614                &temp_dir,
1615                "test",
1616                r#"{
1617                    "version": "1.0",
1618                    "type": "object",
1619                    "properties": {
1620                        "array-key": {
1621                            "type": "array",
1622                            "items": {"type": "integer"},
1623                            "default": [1,2,3.3],
1624                            "description": "Array option"
1625                        }
1626                    }
1627                }"#,
1628            );
1629
1630            let result = SchemaRegistry::from_directory(temp_dir.path());
1631            assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
1632        }
1633
1634        #[test]
1635        fn test_schema_default_heterogeneous_rejection() {
1636            let temp_dir = TempDir::new().unwrap();
1637            create_test_schema(
1638                &temp_dir,
1639                "test",
1640                r#"{
1641                    "version": "1.0",
1642                    "type": "object",
1643                    "properties": {
1644                        "array-key": {
1645                            "type": "array",
1646                            "items": {"type": "integer"},
1647                            "default": [1,2,"uh oh!"],
1648                            "description": "Array option"
1649                        }
1650                    }
1651                }"#,
1652            );
1653
1654            let result = SchemaRegistry::from_directory(temp_dir.path());
1655            assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
1656        }
1657
1658        #[test]
1659        fn test_load_values_valid() {
1660            let temp_dir = TempDir::new().unwrap();
1661            let (schemas_dir, values_dir) = create_test_schema_with_values(
1662                &temp_dir,
1663                "test",
1664                r#"{
1665                    "version": "1.0",
1666                    "type": "object",
1667                    "properties": {
1668                        "array-key": {
1669                            "type": "array",
1670                            "items": {"type": "integer"},
1671                            "default": [1,2,3],
1672                            "description": "Array option"
1673                        }
1674                    }
1675                }"#,
1676                r#"{
1677                    "options": {
1678                        "array-key": [4,5,6]
1679                    }
1680                }"#,
1681            );
1682
1683            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1684            let (values, generated_at_by_namespace) =
1685                registry.load_values_json(&values_dir).unwrap();
1686
1687            assert_eq!(values.len(), 1);
1688            assert_eq!(values["test"]["array-key"], json!([4, 5, 6]));
1689            assert!(generated_at_by_namespace.is_empty());
1690        }
1691
1692        #[test]
1693        fn test_reject_values_not_an_array() {
1694            let temp_dir = TempDir::new().unwrap();
1695            let (schemas_dir, values_dir) = create_test_schema_with_values(
1696                &temp_dir,
1697                "test",
1698                r#"{
1699                    "version": "1.0",
1700                    "type": "object",
1701                    "properties": {
1702                        "array-key": {
1703                            "type": "array",
1704                            "items": {"type": "integer"},
1705                            "default": [1,2,3],
1706                            "description": "Array option"
1707                        }
1708                    }
1709                }"#,
1710                // sneaky! not an array
1711                r#"{
1712                    "options": {
1713                        "array-key": "[]"
1714                    }
1715                }"#,
1716            );
1717
1718            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1719            let result = registry.load_values_json(&values_dir);
1720
1721            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1722        }
1723
1724        #[test]
1725        fn test_reject_values_mismatch() {
1726            let temp_dir = TempDir::new().unwrap();
1727            let (schemas_dir, values_dir) = create_test_schema_with_values(
1728                &temp_dir,
1729                "test",
1730                r#"{
1731                    "version": "1.0",
1732                    "type": "object",
1733                    "properties": {
1734                        "array-key": {
1735                            "type": "array",
1736                            "items": {"type": "integer"},
1737                            "default": [1,2,3],
1738                            "description": "Array option"
1739                        }
1740                    }
1741                }"#,
1742                r#"{
1743                    "options": {
1744                        "array-key": ["a","b","c"]
1745                    }
1746                }"#,
1747            );
1748
1749            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1750            let result = registry.load_values_json(&values_dir);
1751
1752            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1753        }
1754    }
1755
1756    mod object_tests {
1757        use super::*;
1758
1759        #[test]
1760        fn test_object_schema_loads() {
1761            let temp_dir = TempDir::new().unwrap();
1762            create_test_schema(
1763                &temp_dir,
1764                "test",
1765                r#"{
1766                    "version": "1.0",
1767                    "type": "object",
1768                    "properties": {
1769                        "config": {
1770                            "type": "object",
1771                            "properties": {
1772                                "host": {"type": "string"},
1773                                "port": {"type": "integer"},
1774                                "rate": {"type": "number"},
1775                                "enabled": {"type": "boolean"}
1776                            },
1777                            "default": {"host": "localhost", "port": 8080, "rate": 0.5, "enabled": true},
1778                            "description": "Service config"
1779                        }
1780                    }
1781                }"#,
1782            );
1783
1784            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1785            let schema = registry.get("test").unwrap();
1786            assert_eq!(schema.options["config"].option_type, "object");
1787        }
1788
1789        #[test]
1790        fn test_object_missing_properties_rejected() {
1791            let temp_dir = TempDir::new().unwrap();
1792            create_test_schema(
1793                &temp_dir,
1794                "test",
1795                r#"{
1796                    "version": "1.0",
1797                    "type": "object",
1798                    "properties": {
1799                        "config": {
1800                            "type": "object",
1801                            "default": {"host": "localhost"},
1802                            "description": "Missing properties field"
1803                        }
1804                    }
1805                }"#,
1806            );
1807
1808            let result = SchemaRegistry::from_directory(temp_dir.path());
1809            assert!(result.is_err());
1810        }
1811
1812        #[test]
1813        fn test_object_default_wrong_type_rejected() {
1814            let temp_dir = TempDir::new().unwrap();
1815            create_test_schema(
1816                &temp_dir,
1817                "test",
1818                r#"{
1819                    "version": "1.0",
1820                    "type": "object",
1821                    "properties": {
1822                        "config": {
1823                            "type": "object",
1824                            "properties": {
1825                                "host": {"type": "string"},
1826                                "port": {"type": "integer"}
1827                            },
1828                            "default": {"host": "localhost", "port": "not-a-number"},
1829                            "description": "Bad default"
1830                        }
1831                    }
1832                }"#,
1833            );
1834
1835            let result = SchemaRegistry::from_directory(temp_dir.path());
1836            assert!(result.is_err());
1837        }
1838
1839        #[test]
1840        fn test_object_default_missing_field_rejected() {
1841            let temp_dir = TempDir::new().unwrap();
1842            create_test_schema(
1843                &temp_dir,
1844                "test",
1845                r#"{
1846                    "version": "1.0",
1847                    "type": "object",
1848                    "properties": {
1849                        "config": {
1850                            "type": "object",
1851                            "properties": {
1852                                "host": {"type": "string"},
1853                                "port": {"type": "integer"}
1854                            },
1855                            "default": {"host": "localhost"},
1856                            "description": "Missing port in default"
1857                        }
1858                    }
1859                }"#,
1860            );
1861
1862            let result = SchemaRegistry::from_directory(temp_dir.path());
1863            assert!(result.is_err());
1864        }
1865
1866        #[test]
1867        fn test_object_default_extra_field_rejected() {
1868            let temp_dir = TempDir::new().unwrap();
1869            create_test_schema(
1870                &temp_dir,
1871                "test",
1872                r#"{
1873                    "version": "1.0",
1874                    "type": "object",
1875                    "properties": {
1876                        "config": {
1877                            "type": "object",
1878                            "properties": {
1879                                "host": {"type": "string"}
1880                            },
1881                            "default": {"host": "localhost", "extra": "field"},
1882                            "description": "Extra field in default"
1883                        }
1884                    }
1885                }"#,
1886            );
1887
1888            let result = SchemaRegistry::from_directory(temp_dir.path());
1889            assert!(result.is_err());
1890        }
1891
1892        #[test]
1893        fn test_object_values_valid() {
1894            let temp_dir = TempDir::new().unwrap();
1895            let (schemas_dir, values_dir) = create_test_schema_with_values(
1896                &temp_dir,
1897                "test",
1898                r#"{
1899                    "version": "1.0",
1900                    "type": "object",
1901                    "properties": {
1902                        "config": {
1903                            "type": "object",
1904                            "properties": {
1905                                "host": {"type": "string"},
1906                                "port": {"type": "integer"}
1907                            },
1908                            "default": {"host": "localhost", "port": 8080},
1909                            "description": "Service config"
1910                        }
1911                    }
1912                }"#,
1913                r#"{
1914                    "options": {
1915                        "config": {"host": "example.com", "port": 9090}
1916                    }
1917                }"#,
1918            );
1919
1920            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1921            let result = registry.load_values_json(&values_dir);
1922            assert!(result.is_ok());
1923        }
1924
1925        #[test]
1926        fn test_object_values_wrong_field_type_rejected() {
1927            let temp_dir = TempDir::new().unwrap();
1928            let (schemas_dir, values_dir) = create_test_schema_with_values(
1929                &temp_dir,
1930                "test",
1931                r#"{
1932                    "version": "1.0",
1933                    "type": "object",
1934                    "properties": {
1935                        "config": {
1936                            "type": "object",
1937                            "properties": {
1938                                "host": {"type": "string"},
1939                                "port": {"type": "integer"}
1940                            },
1941                            "default": {"host": "localhost", "port": 8080},
1942                            "description": "Service config"
1943                        }
1944                    }
1945                }"#,
1946                r#"{
1947                    "options": {
1948                        "config": {"host": "example.com", "port": "not-a-number"}
1949                    }
1950                }"#,
1951            );
1952
1953            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1954            let result = registry.load_values_json(&values_dir);
1955            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1956        }
1957
1958        #[test]
1959        fn test_object_values_extra_field_rejected() {
1960            let temp_dir = TempDir::new().unwrap();
1961            let (schemas_dir, values_dir) = create_test_schema_with_values(
1962                &temp_dir,
1963                "test",
1964                r#"{
1965                    "version": "1.0",
1966                    "type": "object",
1967                    "properties": {
1968                        "config": {
1969                            "type": "object",
1970                            "properties": {
1971                                "host": {"type": "string"}
1972                            },
1973                            "default": {"host": "localhost"},
1974                            "description": "Service config"
1975                        }
1976                    }
1977                }"#,
1978                r#"{
1979                    "options": {
1980                        "config": {"host": "example.com", "extra": "field"}
1981                    }
1982                }"#,
1983            );
1984
1985            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1986            let result = registry.load_values_json(&values_dir);
1987            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1988        }
1989
1990        #[test]
1991        fn test_object_values_missing_field_rejected() {
1992            let temp_dir = TempDir::new().unwrap();
1993            let (schemas_dir, values_dir) = create_test_schema_with_values(
1994                &temp_dir,
1995                "test",
1996                r#"{
1997                    "version": "1.0",
1998                    "type": "object",
1999                    "properties": {
2000                        "config": {
2001                            "type": "object",
2002                            "properties": {
2003                                "host": {"type": "string"},
2004                                "port": {"type": "integer"}
2005                            },
2006                            "default": {"host": "localhost", "port": 8080},
2007                            "description": "Service config"
2008                        }
2009                    }
2010                }"#,
2011                r#"{
2012                    "options": {
2013                        "config": {"host": "example.com"}
2014                    }
2015                }"#,
2016            );
2017
2018            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2019            let result = registry.load_values_json(&values_dir);
2020            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2021        }
2022
2023        // Array of objects tests
2024
2025        #[test]
2026        fn test_array_of_objects_schema_loads() {
2027            let temp_dir = TempDir::new().unwrap();
2028            create_test_schema(
2029                &temp_dir,
2030                "test",
2031                r#"{
2032                    "version": "1.0",
2033                    "type": "object",
2034                    "properties": {
2035                        "endpoints": {
2036                            "type": "array",
2037                            "items": {
2038                                "type": "object",
2039                                "properties": {
2040                                    "url": {"type": "string"},
2041                                    "weight": {"type": "integer"}
2042                                }
2043                            },
2044                            "default": [{"url": "https://a.example.com", "weight": 1}],
2045                            "description": "Endpoints"
2046                        }
2047                    }
2048                }"#,
2049            );
2050
2051            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2052            let schema = registry.get("test").unwrap();
2053            assert_eq!(schema.options["endpoints"].option_type, "array");
2054        }
2055
2056        #[test]
2057        fn test_array_of_objects_empty_default() {
2058            let temp_dir = TempDir::new().unwrap();
2059            create_test_schema(
2060                &temp_dir,
2061                "test",
2062                r#"{
2063                    "version": "1.0",
2064                    "type": "object",
2065                    "properties": {
2066                        "endpoints": {
2067                            "type": "array",
2068                            "items": {
2069                                "type": "object",
2070                                "properties": {
2071                                    "url": {"type": "string"},
2072                                    "weight": {"type": "integer"}
2073                                }
2074                            },
2075                            "default": [],
2076                            "description": "Endpoints"
2077                        }
2078                    }
2079                }"#,
2080            );
2081
2082            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2083            assert!(registry.get("test").is_some());
2084        }
2085
2086        #[test]
2087        fn test_array_of_objects_default_wrong_field_type_rejected() {
2088            let temp_dir = TempDir::new().unwrap();
2089            create_test_schema(
2090                &temp_dir,
2091                "test",
2092                r#"{
2093                    "version": "1.0",
2094                    "type": "object",
2095                    "properties": {
2096                        "endpoints": {
2097                            "type": "array",
2098                            "items": {
2099                                "type": "object",
2100                                "properties": {
2101                                    "url": {"type": "string"},
2102                                    "weight": {"type": "integer"}
2103                                }
2104                            },
2105                            "default": [{"url": "https://a.example.com", "weight": "not-a-number"}],
2106                            "description": "Endpoints"
2107                        }
2108                    }
2109                }"#,
2110            );
2111
2112            let result = SchemaRegistry::from_directory(temp_dir.path());
2113            assert!(result.is_err());
2114        }
2115
2116        #[test]
2117        fn test_array_of_objects_missing_items_properties_rejected() {
2118            let temp_dir = TempDir::new().unwrap();
2119            create_test_schema(
2120                &temp_dir,
2121                "test",
2122                r#"{
2123                    "version": "1.0",
2124                    "type": "object",
2125                    "properties": {
2126                        "endpoints": {
2127                            "type": "array",
2128                            "items": {
2129                                "type": "object"
2130                            },
2131                            "default": [],
2132                            "description": "Missing properties in items"
2133                        }
2134                    }
2135                }"#,
2136            );
2137
2138            let result = SchemaRegistry::from_directory(temp_dir.path());
2139            assert!(result.is_err());
2140        }
2141
2142        #[test]
2143        fn test_array_of_objects_values_valid() {
2144            let temp_dir = TempDir::new().unwrap();
2145            let (schemas_dir, values_dir) = create_test_schema_with_values(
2146                &temp_dir,
2147                "test",
2148                r#"{
2149                    "version": "1.0",
2150                    "type": "object",
2151                    "properties": {
2152                        "endpoints": {
2153                            "type": "array",
2154                            "items": {
2155                                "type": "object",
2156                                "properties": {
2157                                    "url": {"type": "string"},
2158                                    "weight": {"type": "integer"}
2159                                }
2160                            },
2161                            "default": [],
2162                            "description": "Endpoints"
2163                        }
2164                    }
2165                }"#,
2166                r#"{
2167                    "options": {
2168                        "endpoints": [
2169                            {"url": "https://a.example.com", "weight": 1},
2170                            {"url": "https://b.example.com", "weight": 2}
2171                        ]
2172                    }
2173                }"#,
2174            );
2175
2176            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2177            let result = registry.load_values_json(&values_dir);
2178            assert!(result.is_ok());
2179        }
2180
2181        #[test]
2182        fn test_array_of_objects_values_wrong_item_shape_rejected() {
2183            let temp_dir = TempDir::new().unwrap();
2184            let (schemas_dir, values_dir) = create_test_schema_with_values(
2185                &temp_dir,
2186                "test",
2187                r#"{
2188                    "version": "1.0",
2189                    "type": "object",
2190                    "properties": {
2191                        "endpoints": {
2192                            "type": "array",
2193                            "items": {
2194                                "type": "object",
2195                                "properties": {
2196                                    "url": {"type": "string"},
2197                                    "weight": {"type": "integer"}
2198                                }
2199                            },
2200                            "default": [],
2201                            "description": "Endpoints"
2202                        }
2203                    }
2204                }"#,
2205                r#"{
2206                    "options": {
2207                        "endpoints": [
2208                            {"url": "https://a.example.com", "weight": "not-a-number"}
2209                        ]
2210                    }
2211                }"#,
2212            );
2213
2214            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2215            let result = registry.load_values_json(&values_dir);
2216            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2217        }
2218
2219        #[test]
2220        fn test_array_of_objects_values_extra_field_rejected() {
2221            let temp_dir = TempDir::new().unwrap();
2222            let (schemas_dir, values_dir) = create_test_schema_with_values(
2223                &temp_dir,
2224                "test",
2225                r#"{
2226                    "version": "1.0",
2227                    "type": "object",
2228                    "properties": {
2229                        "endpoints": {
2230                            "type": "array",
2231                            "items": {
2232                                "type": "object",
2233                                "properties": {
2234                                    "url": {"type": "string"}
2235                                }
2236                            },
2237                            "default": [],
2238                            "description": "Endpoints"
2239                        }
2240                    }
2241                }"#,
2242                r#"{
2243                    "options": {
2244                        "endpoints": [
2245                            {"url": "https://a.example.com", "extra": "field"}
2246                        ]
2247                    }
2248                }"#,
2249            );
2250
2251            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2252            let result = registry.load_values_json(&values_dir);
2253            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2254        }
2255
2256        #[test]
2257        fn test_array_of_objects_values_missing_field_rejected() {
2258            let temp_dir = TempDir::new().unwrap();
2259            let (schemas_dir, values_dir) = create_test_schema_with_values(
2260                &temp_dir,
2261                "test",
2262                r#"{
2263                    "version": "1.0",
2264                    "type": "object",
2265                    "properties": {
2266                        "endpoints": {
2267                            "type": "array",
2268                            "items": {
2269                                "type": "object",
2270                                "properties": {
2271                                    "url": {"type": "string"},
2272                                    "weight": {"type": "integer"}
2273                                }
2274                            },
2275                            "default": [],
2276                            "description": "Endpoints"
2277                        }
2278                    }
2279                }"#,
2280                r#"{
2281                    "options": {
2282                        "endpoints": [
2283                            {"url": "https://a.example.com"}
2284                        ]
2285                    }
2286                }"#,
2287            );
2288
2289            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2290            let result = registry.load_values_json(&values_dir);
2291            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2292        }
2293
2294        // Optional field tests
2295
2296        #[test]
2297        fn test_object_optional_field_can_be_omitted_from_default() {
2298            let temp_dir = TempDir::new().unwrap();
2299            create_test_schema(
2300                &temp_dir,
2301                "test",
2302                r#"{
2303                    "version": "1.0",
2304                    "type": "object",
2305                    "properties": {
2306                        "config": {
2307                            "type": "object",
2308                            "properties": {
2309                                "host": {"type": "string"},
2310                                "debug": {"type": "boolean", "optional": true}
2311                            },
2312                            "default": {"host": "localhost"},
2313                            "description": "Config with optional field"
2314                        }
2315                    }
2316                }"#,
2317            );
2318
2319            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2320            let schema = registry.get("test").unwrap();
2321            assert_eq!(schema.options["config"].option_type, "object");
2322        }
2323
2324        #[test]
2325        fn test_object_optional_field_can_be_included_in_default() {
2326            let temp_dir = TempDir::new().unwrap();
2327            create_test_schema(
2328                &temp_dir,
2329                "test",
2330                r#"{
2331                    "version": "1.0",
2332                    "type": "object",
2333                    "properties": {
2334                        "config": {
2335                            "type": "object",
2336                            "properties": {
2337                                "host": {"type": "string"},
2338                                "debug": {"type": "boolean", "optional": true}
2339                            },
2340                            "default": {"host": "localhost", "debug": true},
2341                            "description": "Config with optional field included"
2342                        }
2343                    }
2344                }"#,
2345            );
2346
2347            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2348            assert!(registry.get("test").is_some());
2349        }
2350
2351        #[test]
2352        fn test_object_optional_field_wrong_type_rejected() {
2353            let temp_dir = TempDir::new().unwrap();
2354            create_test_schema(
2355                &temp_dir,
2356                "test",
2357                r#"{
2358                    "version": "1.0",
2359                    "type": "object",
2360                    "properties": {
2361                        "config": {
2362                            "type": "object",
2363                            "properties": {
2364                                "host": {"type": "string"},
2365                                "debug": {"type": "boolean", "optional": true}
2366                            },
2367                            "default": {"host": "localhost", "debug": "not-a-bool"},
2368                            "description": "Optional field wrong type"
2369                        }
2370                    }
2371                }"#,
2372            );
2373
2374            let result = SchemaRegistry::from_directory(temp_dir.path());
2375            assert!(result.is_err());
2376        }
2377
2378        #[test]
2379        fn test_object_required_field_still_required_with_optional_present() {
2380            let temp_dir = TempDir::new().unwrap();
2381            create_test_schema(
2382                &temp_dir,
2383                "test",
2384                r#"{
2385                    "version": "1.0",
2386                    "type": "object",
2387                    "properties": {
2388                        "config": {
2389                            "type": "object",
2390                            "properties": {
2391                                "host": {"type": "string"},
2392                                "port": {"type": "integer"},
2393                                "debug": {"type": "boolean", "optional": true}
2394                            },
2395                            "default": {"debug": true},
2396                            "description": "Missing required fields"
2397                        }
2398                    }
2399                }"#,
2400            );
2401
2402            let result = SchemaRegistry::from_directory(temp_dir.path());
2403            assert!(result.is_err());
2404        }
2405
2406        #[test]
2407        fn test_object_optional_field_omitted_from_values() {
2408            let temp_dir = TempDir::new().unwrap();
2409            let (schemas_dir, values_dir) = create_test_schema_with_values(
2410                &temp_dir,
2411                "test",
2412                r#"{
2413                    "version": "1.0",
2414                    "type": "object",
2415                    "properties": {
2416                        "config": {
2417                            "type": "object",
2418                            "properties": {
2419                                "host": {"type": "string"},
2420                                "debug": {"type": "boolean", "optional": true}
2421                            },
2422                            "default": {"host": "localhost"},
2423                            "description": "Config"
2424                        }
2425                    }
2426                }"#,
2427                r#"{
2428                    "options": {
2429                        "config": {"host": "example.com"}
2430                    }
2431                }"#,
2432            );
2433
2434            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2435            let result = registry.load_values_json(&values_dir);
2436            assert!(result.is_ok());
2437        }
2438
2439        #[test]
2440        fn test_object_optional_field_included_in_values() {
2441            let temp_dir = TempDir::new().unwrap();
2442            let (schemas_dir, values_dir) = create_test_schema_with_values(
2443                &temp_dir,
2444                "test",
2445                r#"{
2446                    "version": "1.0",
2447                    "type": "object",
2448                    "properties": {
2449                        "config": {
2450                            "type": "object",
2451                            "properties": {
2452                                "host": {"type": "string"},
2453                                "debug": {"type": "boolean", "optional": true}
2454                            },
2455                            "default": {"host": "localhost"},
2456                            "description": "Config"
2457                        }
2458                    }
2459                }"#,
2460                r#"{
2461                    "options": {
2462                        "config": {"host": "example.com", "debug": true}
2463                    }
2464                }"#,
2465            );
2466
2467            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2468            let result = registry.load_values_json(&values_dir);
2469            assert!(result.is_ok());
2470        }
2471
2472        #[test]
2473        fn test_array_of_objects_optional_field_omitted() {
2474            let temp_dir = TempDir::new().unwrap();
2475            let (schemas_dir, values_dir) = create_test_schema_with_values(
2476                &temp_dir,
2477                "test",
2478                r#"{
2479                    "version": "1.0",
2480                    "type": "object",
2481                    "properties": {
2482                        "endpoints": {
2483                            "type": "array",
2484                            "items": {
2485                                "type": "object",
2486                                "properties": {
2487                                    "url": {"type": "string"},
2488                                    "weight": {"type": "integer", "optional": true}
2489                                }
2490                            },
2491                            "default": [],
2492                            "description": "Endpoints"
2493                        }
2494                    }
2495                }"#,
2496                r#"{
2497                    "options": {
2498                        "endpoints": [
2499                            {"url": "https://a.example.com"},
2500                            {"url": "https://b.example.com", "weight": 2}
2501                        ]
2502                    }
2503                }"#,
2504            );
2505
2506            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2507            let result = registry.load_values_json(&values_dir);
2508            assert!(result.is_ok());
2509        }
2510    }
2511}