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("Internal error: {0}")]
120    InternalError(String),
121
122    #[error("Failed to read file: {0}")]
123    FileRead(#[from] std::io::Error),
124
125    #[error("Failed to parse JSON: {0}")]
126    JSONParse(#[from] serde_json::Error),
127
128    #[error("{} validation error(s)", .0.len())]
129    ValidationErrors(Vec<ValidationError>),
130
131    #[error("Invalid {label} '{name}': {reason}")]
132    InvalidName {
133        label: String,
134        name: String,
135        reason: String,
136    },
137}
138
139/// Validate a name component is valid for K8s (lowercase alphanumeric, '-', '.')
140pub fn validate_k8s_name_component(name: &str, label: &str) -> ValidationResult<()> {
141    if let Some(c) = name
142        .chars()
143        .find(|&c| !matches!(c, 'a'..='z' | '0'..='9' | '-' | '.'))
144    {
145        return Err(ValidationError::InvalidName {
146            label: label.to_string(),
147            name: name.to_string(),
148            reason: format!(
149                "character '{}' not allowed. Use lowercase alphanumeric, '-', or '.'",
150                c
151            ),
152        });
153    }
154    if !name.starts_with(|c: char| c.is_ascii_alphanumeric())
155        || !name.ends_with(|c: char| c.is_ascii_alphanumeric())
156    {
157        return Err(ValidationError::InvalidName {
158            label: label.to_string(),
159            name: name.to_string(),
160            reason: "must start and end with alphanumeric".to_string(),
161        });
162    }
163    Ok(())
164}
165
166/// Metadata for a single option in a namespace schema
167#[derive(Debug, Clone)]
168pub struct OptionMetadata {
169    pub option_type: String,
170    pub property_schema: Value,
171    pub default: Value,
172}
173
174/// Schema for a namespace, containing validator and option metadata
175pub struct NamespaceSchema {
176    pub namespace: String,
177    pub options: HashMap<String, OptionMetadata>,
178    validator: jsonschema::Validator,
179}
180
181impl NamespaceSchema {
182    /// Validate an entire values object against this schema
183    ///
184    /// # Arguments
185    /// * `values` - JSON object containing option key-value pairs
186    ///
187    /// # Errors
188    /// Returns error if values don't match the schema
189    pub fn validate_values(&self, values: &Value) -> ValidationResult<()> {
190        let output = self.validator.evaluate(values);
191        if output.flag().valid {
192            Ok(())
193        } else {
194            let errors: Vec<String> = output
195                .iter_errors()
196                .map(|e| {
197                    format!(
198                        "\n\t{} {}",
199                        e.instance_location.as_str().trim_start_matches("/"),
200                        e.error
201                    )
202                })
203                .collect();
204            Err(ValidationError::ValueError {
205                namespace: self.namespace.clone(),
206                errors: errors.join(""),
207            })
208        }
209    }
210
211    /// Get the default value for an option key.
212    /// Returns None if the key doesn't exist in the schema.
213    pub fn get_default(&self, key: &str) -> Option<&Value> {
214        self.options.get(key).map(|meta| &meta.default)
215    }
216}
217
218/// Registry for loading and storing schemas
219pub struct SchemaRegistry {
220    schemas: HashMap<String, Arc<NamespaceSchema>>,
221}
222
223impl SchemaRegistry {
224    /// Create a new empty schema registry
225    pub fn new() -> Self {
226        Self {
227            schemas: HashMap::new(),
228        }
229    }
230
231    /// Load schemas from a directory and create a registry
232    ///
233    /// Expects directory structure: `schemas/{namespace}/schema.json`
234    ///
235    /// # Arguments
236    /// * `schemas_dir` - Path to directory containing namespace subdirectories
237    ///
238    /// # Errors
239    /// Returns error if directory doesn't exist or any schema is invalid
240    pub fn from_directory(schemas_dir: &Path) -> ValidationResult<Self> {
241        let schemas = Self::load_all_schemas(schemas_dir)?;
242        Ok(Self { schemas })
243    }
244
245    /// Validate an entire values object for a namespace
246    ///
247    /// # Arguments
248    /// * `namespace` - Namespace name
249    /// * `values` - JSON object containing option key-value pairs
250    ///
251    /// # Errors
252    /// Returns error if namespace doesn't exist or values don't match schema
253    pub fn validate_values(&self, namespace: &str, values: &Value) -> ValidationResult<()> {
254        let schema = self
255            .schemas
256            .get(namespace)
257            .ok_or_else(|| ValidationError::UnknownNamespace(namespace.to_string()))?;
258
259        schema.validate_values(values)
260    }
261
262    /// Load all schemas from a directory
263    fn load_all_schemas(
264        schemas_dir: &Path,
265    ) -> ValidationResult<HashMap<String, Arc<NamespaceSchema>>> {
266        // Compile namespace-schema once for all schemas
267        let namespace_schema_value: Value =
268            serde_json::from_str(NAMESPACE_SCHEMA_JSON).map_err(|e| {
269                ValidationError::InternalError(format!("Invalid namespace-schema JSON: {}", e))
270            })?;
271        let namespace_validator =
272            jsonschema::validator_for(&namespace_schema_value).map_err(|e| {
273                ValidationError::InternalError(format!("Failed to compile namespace-schema: {}", e))
274            })?;
275
276        let mut schemas = HashMap::new();
277
278        // TODO: Parallelize the loading of schemas for the performance gainz
279        for entry in fs::read_dir(schemas_dir)? {
280            let entry = entry?;
281
282            if !entry.file_type()?.is_dir() {
283                continue;
284            }
285
286            let namespace =
287                entry
288                    .file_name()
289                    .into_string()
290                    .map_err(|_| ValidationError::SchemaError {
291                        file: entry.path(),
292                        message: "Directory name contains invalid UTF-8".to_string(),
293                    })?;
294
295            validate_k8s_name_component(&namespace, "namespace name")?;
296
297            let schema_file = entry.path().join(SCHEMA_FILE_NAME);
298            let schema = Self::load_schema(&schema_file, &namespace, &namespace_validator)?;
299            schemas.insert(namespace, schema);
300        }
301
302        Ok(schemas)
303    }
304
305    /// Load a schema from a file
306    fn load_schema(
307        path: &Path,
308        namespace: &str,
309        namespace_validator: &jsonschema::Validator,
310    ) -> ValidationResult<Arc<NamespaceSchema>> {
311        let file = fs::File::open(path)?;
312        let schema_data: Value = serde_json::from_reader(file)?;
313
314        Self::validate_with_namespace_schema(&schema_data, path, namespace_validator)?;
315        Self::parse_schema(schema_data, namespace, path)
316    }
317
318    /// Validate a schema against the namespace-schema
319    fn validate_with_namespace_schema(
320        schema_data: &Value,
321        path: &Path,
322        namespace_validator: &jsonschema::Validator,
323    ) -> ValidationResult<()> {
324        let output = namespace_validator.evaluate(schema_data);
325
326        if output.flag().valid {
327            Ok(())
328        } else {
329            let errors: Vec<String> = output
330                .iter_errors()
331                .map(|e| format!("Error: {}", e.error))
332                .collect();
333
334            Err(ValidationError::SchemaError {
335                file: path.to_path_buf(),
336                message: format!("Schema validation failed:\n{}", errors.join("\n")),
337            })
338        }
339    }
340
341    /// Validate that a default value matches its declared type using jsonschema
342    fn validate_default_type(
343        property_name: &str,
344        property_schema: &Value,
345        default_value: &Value,
346        path: &Path,
347    ) -> ValidationResult<()> {
348        // Validate the default value against the property schema
349        jsonschema::validate(property_schema, default_value).map_err(|e| {
350            ValidationError::SchemaError {
351                file: path.to_path_buf(),
352                message: format!(
353                    "Property '{}': default value does not match schema: {}",
354                    property_name, e
355                ),
356            }
357        })?;
358
359        Ok(())
360    }
361
362    /// Parse a schema JSON into NamespaceSchema
363    fn parse_schema(
364        mut schema: Value,
365        namespace: &str,
366        path: &Path,
367    ) -> ValidationResult<Arc<NamespaceSchema>> {
368        // Inject additionalProperties: false to reject unknown options
369        if let Some(obj) = schema.as_object_mut() {
370            obj.insert("additionalProperties".to_string(), json!(false));
371        }
372
373        // Use the schema file directly as the validator
374        let validator =
375            jsonschema::validator_for(&schema).map_err(|e| ValidationError::SchemaError {
376                file: path.to_path_buf(),
377                message: format!("Failed to compile validator: {}", e),
378            })?;
379
380        // Extract option metadata and validate types
381        let mut options = HashMap::new();
382        if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
383            for (prop_name, prop_value) in properties {
384                if let (Some(prop_type), Some(default_value)) = (
385                    prop_value.get("type").and_then(|t| t.as_str()),
386                    prop_value.get("default"),
387                ) {
388                    Self::validate_default_type(prop_name, prop_value, default_value, path)?;
389                    options.insert(
390                        prop_name.clone(),
391                        OptionMetadata {
392                            option_type: prop_type.to_string(),
393                            property_schema: prop_value.clone(),
394                            default: default_value.clone(),
395                        },
396                    );
397                }
398            }
399        }
400
401        Ok(Arc::new(NamespaceSchema {
402            namespace: namespace.to_string(),
403            options,
404            validator,
405        }))
406    }
407
408    /// Get a namespace schema by name
409    pub fn get(&self, namespace: &str) -> Option<&Arc<NamespaceSchema>> {
410        self.schemas.get(namespace)
411    }
412
413    /// Get all loaded schemas (for schema evolution validation)
414    pub fn schemas(&self) -> &HashMap<String, Arc<NamespaceSchema>> {
415        &self.schemas
416    }
417
418    /// Load and validate JSON values from a directory.
419    /// Expects structure: `{values_dir}/{namespace}/values.json`
420    /// Values file must have format: `{"options": {"key": value, ...}, "generated_at": "..."}`
421    /// Skips namespaces without a values.json file.
422    /// Returns the values and a map of namespace -> `generated_at` timestamp.
423    pub fn load_values_json(
424        &self,
425        values_dir: &Path,
426    ) -> ValidationResult<(ValuesByNamespace, HashMap<String, String>)> {
427        let mut all_values = HashMap::new();
428        let mut generated_at_by_namespace: HashMap<String, String> = HashMap::new();
429
430        for namespace in self.schemas.keys() {
431            let values_file = values_dir.join(namespace).join(VALUES_FILE_NAME);
432
433            if !values_file.exists() {
434                continue;
435            }
436
437            let parsed: Value = serde_json::from_reader(fs::File::open(&values_file)?)?;
438
439            // Extract generated_at if present
440            if let Some(ts) = parsed.get("generated_at").and_then(|v| v.as_str()) {
441                generated_at_by_namespace.insert(namespace.clone(), ts.to_string());
442            }
443
444            let values = parsed
445                .get("options")
446                .ok_or_else(|| ValidationError::ValueError {
447                    namespace: namespace.clone(),
448                    errors: "values.json must have an 'options' key".to_string(),
449                })?;
450
451            self.validate_values(namespace, values)?;
452
453            if let Value::Object(obj) = values.clone() {
454                let ns_values: HashMap<String, Value> = obj.into_iter().collect();
455                all_values.insert(namespace.clone(), ns_values);
456            }
457        }
458
459        Ok((all_values, generated_at_by_namespace))
460    }
461}
462
463impl Default for SchemaRegistry {
464    fn default() -> Self {
465        Self::new()
466    }
467}
468
469/// Watches the values directory for changes, reloading if there are any.
470/// If the directory does not exist we do not panic
471///
472/// Does not do an initial fetch, assumes the caller has already loaded values.
473/// Child thread may panic if we run out of memory or cannot create more threads.
474///
475/// Uses polling for now, could use `inotify` or similar later on.
476///
477/// Some important notes:
478/// - If the thread panics and dies, there is no built in mechanism to catch it and restart
479/// - If a config map is unmounted, we won't reload until the next file modification (because we don't catch the deletion event)
480/// - If any namespace fails validation, we keep all old values (even the namespaces that passed validation)
481/// - If we have a steady stream of readers our writer may starve for a while trying to acquire the lock
482/// - stop() will block until the thread gets joined
483pub struct ValuesWatcher {
484    stop_signal: Arc<AtomicBool>,
485    thread: Option<JoinHandle<()>>,
486}
487
488impl ValuesWatcher {
489    /// Creates a new ValuesWatcher struct and spins up the watcher thread
490    pub fn new(
491        values_path: &Path,
492        registry: Arc<SchemaRegistry>,
493        values: Arc<RwLock<ValuesByNamespace>>,
494    ) -> ValidationResult<Self> {
495        // output an error but keep passing
496        if !should_suppress_missing_dir_errors() && fs::metadata(values_path).is_err() {
497            eprintln!("Values directory does not exist: {}", values_path.display());
498        }
499
500        let stop_signal = Arc::new(AtomicBool::new(false));
501
502        let thread_signal = Arc::clone(&stop_signal);
503        let thread_path = values_path.to_path_buf();
504        let thread_registry = Arc::clone(&registry);
505        let thread_values = Arc::clone(&values);
506        let thread = thread::Builder::new()
507            .name("sentry-options-watcher".into())
508            .spawn(move || {
509                let result = panic::catch_unwind(AssertUnwindSafe(|| {
510                    Self::run(thread_signal, thread_path, thread_registry, thread_values);
511                }));
512                if let Err(e) = result {
513                    eprintln!("Watcher thread panicked with: {:?}", e);
514                }
515            })?;
516
517        Ok(Self {
518            stop_signal,
519            thread: Some(thread),
520        })
521    }
522
523    /// Reloads the values if the modified time has changed.
524    ///
525    /// Continuously polls the values directory and reloads all values
526    /// if any modification is detected.
527    fn run(
528        stop_signal: Arc<AtomicBool>,
529        values_path: PathBuf,
530        registry: Arc<SchemaRegistry>,
531        values: Arc<RwLock<ValuesByNamespace>>,
532    ) {
533        let mut last_mtime = Self::get_mtime(&values_path);
534
535        while !stop_signal.load(Ordering::Relaxed) {
536            // does not reload values if get_mtime fails
537            if let Some(current_mtime) = Self::get_mtime(&values_path)
538                && Some(current_mtime) != last_mtime
539            {
540                Self::reload_values(&values_path, &registry, &values);
541                last_mtime = Some(current_mtime);
542            }
543
544            thread::sleep(Duration::from_secs(POLLING_DELAY));
545        }
546    }
547
548    /// Get the most recent modification time across all namespace values.json files
549    /// Returns None if no valid values files are found
550    fn get_mtime(values_dir: &Path) -> Option<std::time::SystemTime> {
551        let mut latest_mtime = None;
552
553        let entries = match fs::read_dir(values_dir) {
554            Ok(e) => e,
555            Err(e) => {
556                if !should_suppress_missing_dir_errors() {
557                    eprintln!("Failed to read directory {}: {}", values_dir.display(), e);
558                }
559                return None;
560            }
561        };
562
563        for entry in entries.flatten() {
564            // skip if not a dir
565            if !entry
566                .file_type()
567                .map(|file_type| file_type.is_dir())
568                .unwrap_or(false)
569            {
570                continue;
571            }
572
573            let values_file = entry.path().join(VALUES_FILE_NAME);
574            if let Ok(metadata) = fs::metadata(&values_file)
575                && let Ok(mtime) = metadata.modified()
576                && latest_mtime.is_none_or(|latest| mtime > latest)
577            {
578                latest_mtime = Some(mtime);
579            }
580        }
581
582        latest_mtime
583    }
584
585    /// Reload values from disk, validate them, and update the shared map.
586    /// Emits a Sentry transaction per namespace with timing and propagation delay metrics.
587    fn reload_values(
588        values_path: &Path,
589        registry: &SchemaRegistry,
590        values: &Arc<RwLock<ValuesByNamespace>>,
591    ) {
592        let reload_start = Instant::now();
593
594        match registry.load_values_json(values_path) {
595            Ok((new_values, generated_at_by_namespace)) => {
596                let namespaces: Vec<String> = new_values.keys().cloned().collect();
597                Self::update_values(values, new_values);
598
599                let reload_duration = reload_start.elapsed();
600                Self::emit_reload_spans(&namespaces, reload_duration, &generated_at_by_namespace);
601            }
602            Err(e) => {
603                eprintln!(
604                    "Failed to reload values from {}: {}",
605                    values_path.display(),
606                    e
607                );
608            }
609        }
610    }
611
612    /// Emit a Sentry transaction per namespace with reload timing and propagation delay metrics.
613    /// Uses a dedicated Sentry Hub isolated from the host application's Sentry setup.
614    fn emit_reload_spans(
615        namespaces: &[String],
616        reload_duration: Duration,
617        generated_at_by_namespace: &HashMap<String, String>,
618    ) {
619        let hub = get_sentry_hub();
620        let applied_at = Utc::now();
621        let reload_duration_ms = reload_duration.as_secs_f64() * 1000.0;
622
623        for namespace in namespaces {
624            let mut tx_ctx = sentry::TransactionContext::new(namespace, "sentry_options.reload");
625            tx_ctx.set_sampled(true);
626
627            let transaction = hub.start_transaction(tx_ctx);
628            transaction.set_data("reload_duration_ms", reload_duration_ms.into());
629            transaction.set_data("applied_at", applied_at.to_rfc3339().into());
630
631            if let Some(ts) = generated_at_by_namespace.get(namespace) {
632                transaction.set_data("generated_at", ts.as_str().into());
633
634                if let Ok(generated_time) = DateTime::parse_from_rfc3339(ts) {
635                    let delay_secs = (applied_at - generated_time.with_timezone(&Utc))
636                        .num_milliseconds() as f64
637                        / 1000.0;
638                    transaction.set_data("propagation_delay_secs", delay_secs.into());
639                }
640            }
641
642            transaction.finish();
643        }
644    }
645
646    /// Update the values map with the new values
647    fn update_values(values: &Arc<RwLock<ValuesByNamespace>>, new_values: ValuesByNamespace) {
648        // safe to unwrap, we only have one thread and if it panics we die anyways
649        let mut guard = values.write().unwrap();
650        *guard = new_values;
651    }
652
653    /// Stops the watcher thread, waiting for it to join.
654    /// May take up to POLLING_DELAY seconds
655    pub fn stop(&mut self) {
656        self.stop_signal.store(true, Ordering::Relaxed);
657        if let Some(thread) = self.thread.take() {
658            let _ = thread.join();
659        }
660    }
661
662    /// Returns whether the watcher thread is still running
663    pub fn is_alive(&self) -> bool {
664        self.thread.as_ref().is_some_and(|t| !t.is_finished())
665    }
666}
667
668impl Drop for ValuesWatcher {
669    fn drop(&mut self) {
670        self.stop();
671    }
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677    use tempfile::TempDir;
678
679    fn create_test_schema(temp_dir: &TempDir, namespace: &str, schema_json: &str) -> PathBuf {
680        let schema_dir = temp_dir.path().join(namespace);
681        fs::create_dir_all(&schema_dir).unwrap();
682        let schema_file = schema_dir.join("schema.json");
683        fs::write(&schema_file, schema_json).unwrap();
684        schema_file
685    }
686
687    #[test]
688    fn test_validate_k8s_name_component_valid() {
689        assert!(validate_k8s_name_component("relay", "namespace").is_ok());
690        assert!(validate_k8s_name_component("my-service", "namespace").is_ok());
691        assert!(validate_k8s_name_component("my.service", "namespace").is_ok());
692        assert!(validate_k8s_name_component("a1-b2.c3", "namespace").is_ok());
693    }
694
695    #[test]
696    fn test_validate_k8s_name_component_rejects_uppercase() {
697        let result = validate_k8s_name_component("MyService", "namespace");
698        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
699        assert!(result.unwrap_err().to_string().contains("'M' not allowed"));
700    }
701
702    #[test]
703    fn test_validate_k8s_name_component_rejects_underscore() {
704        let result = validate_k8s_name_component("my_service", "target");
705        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
706        assert!(result.unwrap_err().to_string().contains("'_' not allowed"));
707    }
708
709    #[test]
710    fn test_validate_k8s_name_component_rejects_leading_hyphen() {
711        let result = validate_k8s_name_component("-service", "namespace");
712        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
713        assert!(
714            result
715                .unwrap_err()
716                .to_string()
717                .contains("start and end with alphanumeric")
718        );
719    }
720
721    #[test]
722    fn test_validate_k8s_name_component_rejects_trailing_dot() {
723        let result = validate_k8s_name_component("service.", "namespace");
724        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
725        assert!(
726            result
727                .unwrap_err()
728                .to_string()
729                .contains("start and end with alphanumeric")
730        );
731    }
732
733    #[test]
734    fn test_load_schema_valid() {
735        let temp_dir = TempDir::new().unwrap();
736        create_test_schema(
737            &temp_dir,
738            "test",
739            r#"{
740                "version": "1.0",
741                "type": "object",
742                "properties": {
743                    "test-key": {
744                        "type": "string",
745                        "default": "test",
746                        "description": "Test option"
747                    }
748                }
749            }"#,
750        );
751
752        SchemaRegistry::from_directory(temp_dir.path()).unwrap();
753    }
754
755    #[test]
756    fn test_load_schema_missing_version() {
757        let temp_dir = TempDir::new().unwrap();
758        create_test_schema(
759            &temp_dir,
760            "test",
761            r#"{
762                "type": "object",
763                "properties": {}
764            }"#,
765        );
766
767        let result = SchemaRegistry::from_directory(temp_dir.path());
768        assert!(result.is_err());
769        match result {
770            Err(ValidationError::SchemaError { message, .. }) => {
771                assert!(message.contains(
772                    "Schema validation failed:
773Error: \"version\" is a required property"
774                ));
775            }
776            _ => panic!("Expected SchemaError for missing version"),
777        }
778    }
779
780    #[test]
781    fn test_unknown_namespace() {
782        let temp_dir = TempDir::new().unwrap();
783        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
784
785        let result = registry.validate_values("unknown", &json!({}));
786        assert!(matches!(result, Err(ValidationError::UnknownNamespace(..))));
787    }
788
789    #[test]
790    fn test_multiple_namespaces() {
791        let temp_dir = TempDir::new().unwrap();
792        create_test_schema(
793            &temp_dir,
794            "ns1",
795            r#"{
796                "version": "1.0",
797                "type": "object",
798                "properties": {
799                    "opt1": {
800                        "type": "string",
801                        "default": "default1",
802                        "description": "First option"
803                    }
804                }
805            }"#,
806        );
807        create_test_schema(
808            &temp_dir,
809            "ns2",
810            r#"{
811                "version": "2.0",
812                "type": "object",
813                "properties": {
814                    "opt2": {
815                        "type": "integer",
816                        "default": 42,
817                        "description": "Second option"
818                    }
819                }
820            }"#,
821        );
822
823        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
824        assert!(registry.schemas.contains_key("ns1"));
825        assert!(registry.schemas.contains_key("ns2"));
826    }
827
828    #[test]
829    fn test_invalid_default_type() {
830        let temp_dir = TempDir::new().unwrap();
831        create_test_schema(
832            &temp_dir,
833            "test",
834            r#"{
835                "version": "1.0",
836                "type": "object",
837                "properties": {
838                    "bad-default": {
839                        "type": "integer",
840                        "default": "not-a-number",
841                        "description": "A bad default value"
842                    }
843                }
844            }"#,
845        );
846
847        let result = SchemaRegistry::from_directory(temp_dir.path());
848        assert!(result.is_err());
849        match result {
850            Err(ValidationError::SchemaError { message, .. }) => {
851                assert!(
852                    message.contains("Property 'bad-default': default value does not match schema")
853                );
854                assert!(message.contains("\"not-a-number\" is not of type \"integer\""));
855            }
856            _ => panic!("Expected SchemaError for invalid default type"),
857        }
858    }
859
860    #[test]
861    fn test_extra_properties() {
862        let temp_dir = TempDir::new().unwrap();
863        create_test_schema(
864            &temp_dir,
865            "test",
866            r#"{
867                "version": "1.0",
868                "type": "object",
869                "properties": {
870                    "bad-property": {
871                        "type": "integer",
872                        "default": 0,
873                        "description": "Test property",
874                        "extra": "property"
875                    }
876                }
877            }"#,
878        );
879
880        let result = SchemaRegistry::from_directory(temp_dir.path());
881        assert!(result.is_err());
882        match result {
883            Err(ValidationError::SchemaError { message, .. }) => {
884                assert!(
885                    message
886                        .contains("Additional properties are not allowed ('extra' was unexpected)")
887                );
888            }
889            _ => panic!("Expected SchemaError for extra properties"),
890        }
891    }
892
893    #[test]
894    fn test_missing_description() {
895        let temp_dir = TempDir::new().unwrap();
896        create_test_schema(
897            &temp_dir,
898            "test",
899            r#"{
900                "version": "1.0",
901                "type": "object",
902                "properties": {
903                    "missing-desc": {
904                        "type": "string",
905                        "default": "test"
906                    }
907                }
908            }"#,
909        );
910
911        let result = SchemaRegistry::from_directory(temp_dir.path());
912        assert!(result.is_err());
913        match result {
914            Err(ValidationError::SchemaError { message, .. }) => {
915                assert!(message.contains("\"description\" is a required property"));
916            }
917            _ => panic!("Expected SchemaError for missing description"),
918        }
919    }
920
921    #[test]
922    fn test_invalid_directory_structure() {
923        let temp_dir = TempDir::new().unwrap();
924        // Create a namespace directory without schema.json file
925        let schema_dir = temp_dir.path().join("missing-schema");
926        fs::create_dir_all(&schema_dir).unwrap();
927
928        let result = SchemaRegistry::from_directory(temp_dir.path());
929        assert!(result.is_err());
930        match result {
931            Err(ValidationError::FileRead(..)) => {
932                // Expected error when schema.json file is missing
933            }
934            _ => panic!("Expected FileRead error for missing schema.json"),
935        }
936    }
937
938    #[test]
939    fn test_get_default() {
940        let temp_dir = TempDir::new().unwrap();
941        create_test_schema(
942            &temp_dir,
943            "test",
944            r#"{
945                "version": "1.0",
946                "type": "object",
947                "properties": {
948                    "string_opt": {
949                        "type": "string",
950                        "default": "hello",
951                        "description": "A string option"
952                    },
953                    "int_opt": {
954                        "type": "integer",
955                        "default": 42,
956                        "description": "An integer option"
957                    }
958                }
959            }"#,
960        );
961
962        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
963        let schema = registry.get("test").unwrap();
964
965        assert_eq!(schema.get_default("string_opt"), Some(&json!("hello")));
966        assert_eq!(schema.get_default("int_opt"), Some(&json!(42)));
967        assert_eq!(schema.get_default("unknown"), None);
968    }
969
970    #[test]
971    fn test_validate_values_valid() {
972        let temp_dir = TempDir::new().unwrap();
973        create_test_schema(
974            &temp_dir,
975            "test",
976            r#"{
977                "version": "1.0",
978                "type": "object",
979                "properties": {
980                    "enabled": {
981                        "type": "boolean",
982                        "default": false,
983                        "description": "Enable feature"
984                    }
985                }
986            }"#,
987        );
988
989        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
990        let result = registry.validate_values("test", &json!({"enabled": true}));
991        assert!(result.is_ok());
992    }
993
994    #[test]
995    fn test_validate_values_invalid_type() {
996        let temp_dir = TempDir::new().unwrap();
997        create_test_schema(
998            &temp_dir,
999            "test",
1000            r#"{
1001                "version": "1.0",
1002                "type": "object",
1003                "properties": {
1004                    "count": {
1005                        "type": "integer",
1006                        "default": 0,
1007                        "description": "Count"
1008                    }
1009                }
1010            }"#,
1011        );
1012
1013        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1014        let result = registry.validate_values("test", &json!({"count": "not a number"}));
1015        assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1016    }
1017
1018    #[test]
1019    fn test_validate_values_unknown_option() {
1020        let temp_dir = TempDir::new().unwrap();
1021        create_test_schema(
1022            &temp_dir,
1023            "test",
1024            r#"{
1025                "version": "1.0",
1026                "type": "object",
1027                "properties": {
1028                    "known_option": {
1029                        "type": "string",
1030                        "default": "default",
1031                        "description": "A known option"
1032                    }
1033                }
1034            }"#,
1035        );
1036
1037        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1038
1039        // Valid known option should pass
1040        let result = registry.validate_values("test", &json!({"known_option": "value"}));
1041        assert!(result.is_ok());
1042
1043        // Unknown option should fail
1044        let result = registry.validate_values("test", &json!({"unknown_option": "value"}));
1045        assert!(result.is_err());
1046        match result {
1047            Err(ValidationError::ValueError { errors, .. }) => {
1048                assert!(errors.contains("Additional properties are not allowed"));
1049            }
1050            _ => panic!("Expected ValueError for unknown option"),
1051        }
1052    }
1053
1054    #[test]
1055    fn test_load_values_json_valid() {
1056        let temp_dir = TempDir::new().unwrap();
1057        let schemas_dir = temp_dir.path().join("schemas");
1058        let values_dir = temp_dir.path().join("values");
1059
1060        let schema_dir = schemas_dir.join("test");
1061        fs::create_dir_all(&schema_dir).unwrap();
1062        fs::write(
1063            schema_dir.join("schema.json"),
1064            r#"{
1065                "version": "1.0",
1066                "type": "object",
1067                "properties": {
1068                    "enabled": {
1069                        "type": "boolean",
1070                        "default": false,
1071                        "description": "Enable feature"
1072                    },
1073                    "name": {
1074                        "type": "string",
1075                        "default": "default",
1076                        "description": "Name"
1077                    },
1078                    "count": {
1079                        "type": "integer",
1080                        "default": 0,
1081                        "description": "Count"
1082                    },
1083                    "rate": {
1084                        "type": "number",
1085                        "default": 0.0,
1086                        "description": "Rate"
1087                    }
1088                }
1089            }"#,
1090        )
1091        .unwrap();
1092
1093        let test_values_dir = values_dir.join("test");
1094        fs::create_dir_all(&test_values_dir).unwrap();
1095        fs::write(
1096            test_values_dir.join("values.json"),
1097            r#"{
1098                "options": {
1099                    "enabled": true,
1100                    "name": "test-name",
1101                    "count": 42,
1102                    "rate": 0.75
1103                }
1104            }"#,
1105        )
1106        .unwrap();
1107
1108        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1109        let (values, generated_at_by_namespace) = registry.load_values_json(&values_dir).unwrap();
1110
1111        assert_eq!(values.len(), 1);
1112        assert_eq!(values["test"]["enabled"], json!(true));
1113        assert_eq!(values["test"]["name"], json!("test-name"));
1114        assert_eq!(values["test"]["count"], json!(42));
1115        assert_eq!(values["test"]["rate"], json!(0.75));
1116        assert!(generated_at_by_namespace.is_empty());
1117    }
1118
1119    #[test]
1120    fn test_load_values_json_nonexistent_dir() {
1121        let temp_dir = TempDir::new().unwrap();
1122        create_test_schema(
1123            &temp_dir,
1124            "test",
1125            r#"{"version": "1.0", "type": "object", "properties": {}}"#,
1126        );
1127
1128        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1129        let (values, generated_at_by_namespace) = registry
1130            .load_values_json(&temp_dir.path().join("nonexistent"))
1131            .unwrap();
1132
1133        // No values.json files found, returns empty
1134        assert!(values.is_empty());
1135        assert!(generated_at_by_namespace.is_empty());
1136    }
1137
1138    #[test]
1139    fn test_load_values_json_skips_missing_values_file() {
1140        let temp_dir = TempDir::new().unwrap();
1141        let schemas_dir = temp_dir.path().join("schemas");
1142        let values_dir = temp_dir.path().join("values");
1143
1144        // Create two schemas
1145        let schema_dir1 = schemas_dir.join("with-values");
1146        fs::create_dir_all(&schema_dir1).unwrap();
1147        fs::write(
1148            schema_dir1.join("schema.json"),
1149            r#"{
1150                "version": "1.0",
1151                "type": "object",
1152                "properties": {
1153                    "opt": {"type": "string", "default": "x", "description": "Opt"}
1154                }
1155            }"#,
1156        )
1157        .unwrap();
1158
1159        let schema_dir2 = schemas_dir.join("without-values");
1160        fs::create_dir_all(&schema_dir2).unwrap();
1161        fs::write(
1162            schema_dir2.join("schema.json"),
1163            r#"{
1164                "version": "1.0",
1165                "type": "object",
1166                "properties": {
1167                    "opt": {"type": "string", "default": "x", "description": "Opt"}
1168                }
1169            }"#,
1170        )
1171        .unwrap();
1172
1173        // Only create values for one namespace
1174        let with_values_dir = values_dir.join("with-values");
1175        fs::create_dir_all(&with_values_dir).unwrap();
1176        fs::write(
1177            with_values_dir.join("values.json"),
1178            r#"{"options": {"opt": "y"}}"#,
1179        )
1180        .unwrap();
1181
1182        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1183        let (values, _) = registry.load_values_json(&values_dir).unwrap();
1184
1185        assert_eq!(values.len(), 1);
1186        assert!(values.contains_key("with-values"));
1187        assert!(!values.contains_key("without-values"));
1188    }
1189
1190    #[test]
1191    fn test_load_values_json_extracts_generated_at() {
1192        let temp_dir = TempDir::new().unwrap();
1193        let schemas_dir = temp_dir.path().join("schemas");
1194        let values_dir = temp_dir.path().join("values");
1195
1196        let schema_dir = schemas_dir.join("test");
1197        fs::create_dir_all(&schema_dir).unwrap();
1198        fs::write(
1199            schema_dir.join("schema.json"),
1200            r#"{
1201                "version": "1.0",
1202                "type": "object",
1203                "properties": {
1204                    "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1205                }
1206            }"#,
1207        )
1208        .unwrap();
1209
1210        let test_values_dir = values_dir.join("test");
1211        fs::create_dir_all(&test_values_dir).unwrap();
1212        fs::write(
1213            test_values_dir.join("values.json"),
1214            r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T18:30:00.123456+00:00"}"#,
1215        )
1216        .unwrap();
1217
1218        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1219        let (values, generated_at_by_namespace) = registry.load_values_json(&values_dir).unwrap();
1220
1221        assert_eq!(values["test"]["enabled"], json!(true));
1222        assert_eq!(
1223            generated_at_by_namespace.get("test"),
1224            Some(&"2024-01-21T18:30:00.123456+00:00".to_string())
1225        );
1226    }
1227
1228    #[test]
1229    fn test_load_values_json_rejects_wrong_type() {
1230        let temp_dir = TempDir::new().unwrap();
1231        let schemas_dir = temp_dir.path().join("schemas");
1232        let values_dir = temp_dir.path().join("values");
1233
1234        let schema_dir = schemas_dir.join("test");
1235        fs::create_dir_all(&schema_dir).unwrap();
1236        fs::write(
1237            schema_dir.join("schema.json"),
1238            r#"{
1239                "version": "1.0",
1240                "type": "object",
1241                "properties": {
1242                    "count": {"type": "integer", "default": 0, "description": "Count"}
1243                }
1244            }"#,
1245        )
1246        .unwrap();
1247
1248        let test_values_dir = values_dir.join("test");
1249        fs::create_dir_all(&test_values_dir).unwrap();
1250        fs::write(
1251            test_values_dir.join("values.json"),
1252            r#"{"options": {"count": "not-a-number"}}"#,
1253        )
1254        .unwrap();
1255
1256        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1257        let result = registry.load_values_json(&values_dir);
1258
1259        assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1260    }
1261
1262    mod watcher_tests {
1263        use super::*;
1264        use std::thread;
1265
1266        /// Creates schema and values files for two namespaces: ns1, and ns2
1267        fn setup_watcher_test() -> (TempDir, PathBuf, PathBuf) {
1268            let temp_dir = TempDir::new().unwrap();
1269            let schemas_dir = temp_dir.path().join("schemas");
1270            let values_dir = temp_dir.path().join("values");
1271
1272            let ns1_schema = schemas_dir.join("ns1");
1273            fs::create_dir_all(&ns1_schema).unwrap();
1274            fs::write(
1275                ns1_schema.join("schema.json"),
1276                r#"{
1277                    "version": "1.0",
1278                    "type": "object",
1279                    "properties": {
1280                        "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1281                    }
1282                }"#,
1283            )
1284            .unwrap();
1285
1286            let ns1_values = values_dir.join("ns1");
1287            fs::create_dir_all(&ns1_values).unwrap();
1288            fs::write(
1289                ns1_values.join("values.json"),
1290                r#"{"options": {"enabled": true}}"#,
1291            )
1292            .unwrap();
1293
1294            let ns2_schema = schemas_dir.join("ns2");
1295            fs::create_dir_all(&ns2_schema).unwrap();
1296            fs::write(
1297                ns2_schema.join("schema.json"),
1298                r#"{
1299                    "version": "1.0",
1300                    "type": "object",
1301                    "properties": {
1302                        "count": {"type": "integer", "default": 0, "description": "Count"}
1303                    }
1304                }"#,
1305            )
1306            .unwrap();
1307
1308            let ns2_values = values_dir.join("ns2");
1309            fs::create_dir_all(&ns2_values).unwrap();
1310            fs::write(
1311                ns2_values.join("values.json"),
1312                r#"{"options": {"count": 42}}"#,
1313            )
1314            .unwrap();
1315
1316            (temp_dir, schemas_dir, values_dir)
1317        }
1318
1319        #[test]
1320        fn test_get_mtime_returns_most_recent() {
1321            let (_temp, _schemas, values_dir) = setup_watcher_test();
1322
1323            // Get initial mtime
1324            let mtime1 = ValuesWatcher::get_mtime(&values_dir);
1325            assert!(mtime1.is_some());
1326
1327            // Modify one namespace
1328            thread::sleep(std::time::Duration::from_millis(10));
1329            fs::write(
1330                values_dir.join("ns1").join("values.json"),
1331                r#"{"options": {"enabled": false}}"#,
1332            )
1333            .unwrap();
1334
1335            // Should detect the change
1336            let mtime2 = ValuesWatcher::get_mtime(&values_dir);
1337            assert!(mtime2.is_some());
1338            assert!(mtime2 > mtime1);
1339        }
1340
1341        #[test]
1342        fn test_get_mtime_with_missing_directory() {
1343            let temp = TempDir::new().unwrap();
1344            let nonexistent = temp.path().join("nonexistent");
1345
1346            let mtime = ValuesWatcher::get_mtime(&nonexistent);
1347            assert!(mtime.is_none());
1348        }
1349
1350        #[test]
1351        fn test_reload_values_updates_map() {
1352            let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1353
1354            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1355            let (initial_values, _) = registry.load_values_json(&values_dir).unwrap();
1356            let values = Arc::new(RwLock::new(initial_values));
1357
1358            // ensure initial values are correct
1359            {
1360                let guard = values.read().unwrap();
1361                assert_eq!(guard["ns1"]["enabled"], json!(true));
1362                assert_eq!(guard["ns2"]["count"], json!(42));
1363            }
1364
1365            // modify
1366            fs::write(
1367                values_dir.join("ns1").join("values.json"),
1368                r#"{"options": {"enabled": false}}"#,
1369            )
1370            .unwrap();
1371            fs::write(
1372                values_dir.join("ns2").join("values.json"),
1373                r#"{"options": {"count": 100}}"#,
1374            )
1375            .unwrap();
1376
1377            // force a reload
1378            ValuesWatcher::reload_values(&values_dir, &registry, &values);
1379
1380            // ensure new values are correct
1381            {
1382                let guard = values.read().unwrap();
1383                assert_eq!(guard["ns1"]["enabled"], json!(false));
1384                assert_eq!(guard["ns2"]["count"], json!(100));
1385            }
1386        }
1387
1388        #[test]
1389        fn test_old_values_persist_with_invalid_data() {
1390            let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1391
1392            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1393            let (initial_values, _) = registry.load_values_json(&values_dir).unwrap();
1394            let values = Arc::new(RwLock::new(initial_values));
1395
1396            let initial_enabled = {
1397                let guard = values.read().unwrap();
1398                guard["ns1"]["enabled"].clone()
1399            };
1400
1401            // won't pass validation
1402            fs::write(
1403                values_dir.join("ns1").join("values.json"),
1404                r#"{"options": {"enabled": "not-a-boolean"}}"#,
1405            )
1406            .unwrap();
1407
1408            ValuesWatcher::reload_values(&values_dir, &registry, &values);
1409
1410            // ensure old value persists
1411            {
1412                let guard = values.read().unwrap();
1413                assert_eq!(guard["ns1"]["enabled"], initial_enabled);
1414            }
1415        }
1416
1417        #[test]
1418        fn test_watcher_creation_and_termination() {
1419            let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1420
1421            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1422            let (initial_values, _) = registry.load_values_json(&values_dir).unwrap();
1423            let values = Arc::new(RwLock::new(initial_values));
1424
1425            let mut watcher =
1426                ValuesWatcher::new(&values_dir, Arc::clone(&registry), Arc::clone(&values))
1427                    .expect("Failed to create watcher");
1428
1429            assert!(watcher.is_alive());
1430            watcher.stop();
1431            assert!(!watcher.is_alive());
1432        }
1433    }
1434    mod array_tests {
1435        use super::*;
1436
1437        /// Helper to create a test schema and values file for testing
1438        fn create_test_schema_with_values(
1439            temp_dir: &TempDir,
1440            namespace: &str,
1441            schema_json: &str,
1442            values_json: &str,
1443        ) -> (PathBuf, PathBuf) {
1444            let schemas_dir = temp_dir.path().join("schemas");
1445            let values_dir = temp_dir.path().join("values");
1446
1447            let schema_dir = schemas_dir.join(namespace);
1448            fs::create_dir_all(&schema_dir).unwrap();
1449            let schema_file = schema_dir.join("schema.json");
1450            fs::write(&schema_file, schema_json).unwrap();
1451
1452            let ns_values_dir = values_dir.join(namespace);
1453            fs::create_dir_all(&ns_values_dir).unwrap();
1454            let values_file = ns_values_dir.join("values.json");
1455            fs::write(&values_file, values_json).unwrap();
1456
1457            (schemas_dir, values_dir)
1458        }
1459
1460        #[test]
1461        fn test_basic_schema_validation() {
1462            let temp_dir = TempDir::new().unwrap();
1463            for (a_type, default) in [
1464                ("boolean", ""), // empty array test
1465                ("boolean", "true"),
1466                ("integer", "1"),
1467                ("number", "1.2"),
1468                ("string", "\"wow\""),
1469            ] {
1470                create_test_schema(
1471                    &temp_dir,
1472                    "test",
1473                    &format!(
1474                        r#"{{
1475                        "version": "1.0",
1476                        "type": "object",
1477                        "properties": {{
1478                            "array-key": {{
1479                                "type": "array",
1480                                "items": {{"type": "{}"}},
1481                                "default": [{}],
1482                                "description": "Array option"
1483                                }}
1484                            }}
1485                        }}"#,
1486                        a_type, default
1487                    ),
1488                );
1489
1490                SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1491            }
1492        }
1493
1494        #[test]
1495        fn test_missing_items_object_rejection() {
1496            let temp_dir = TempDir::new().unwrap();
1497            create_test_schema(
1498                &temp_dir,
1499                "test",
1500                r#"{
1501                    "version": "1.0",
1502                    "type": "object",
1503                    "properties": {
1504                        "array-key": {
1505                            "type": "array",
1506                            "default": [1,2,3],
1507                            "description": "Array option"
1508                        }
1509                    }
1510                }"#,
1511            );
1512
1513            let result = SchemaRegistry::from_directory(temp_dir.path());
1514            assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
1515        }
1516
1517        #[test]
1518        fn test_malformed_items_rejection() {
1519            let temp_dir = TempDir::new().unwrap();
1520            create_test_schema(
1521                &temp_dir,
1522                "test",
1523                r#"{
1524                    "version": "1.0",
1525                    "type": "object",
1526                    "properties": {
1527                        "array-key": {
1528                            "type": "array",
1529                            "items": {"type": ""},
1530                            "default": [1,2,3],
1531                            "description": "Array option"
1532                        }
1533                    }
1534                }"#,
1535            );
1536
1537            let result = SchemaRegistry::from_directory(temp_dir.path());
1538            assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
1539        }
1540
1541        #[test]
1542        fn test_schema_default_type_mismatch_rejection() {
1543            let temp_dir = TempDir::new().unwrap();
1544            // also tests real number rejection when type is integer
1545            create_test_schema(
1546                &temp_dir,
1547                "test",
1548                r#"{
1549                    "version": "1.0",
1550                    "type": "object",
1551                    "properties": {
1552                        "array-key": {
1553                            "type": "array",
1554                            "items": {"type": "integer"},
1555                            "default": [1,2,3.3],
1556                            "description": "Array option"
1557                        }
1558                    }
1559                }"#,
1560            );
1561
1562            let result = SchemaRegistry::from_directory(temp_dir.path());
1563            assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
1564        }
1565
1566        #[test]
1567        fn test_schema_default_heterogeneous_rejection() {
1568            let temp_dir = TempDir::new().unwrap();
1569            create_test_schema(
1570                &temp_dir,
1571                "test",
1572                r#"{
1573                    "version": "1.0",
1574                    "type": "object",
1575                    "properties": {
1576                        "array-key": {
1577                            "type": "array",
1578                            "items": {"type": "integer"},
1579                            "default": [1,2,"uh oh!"],
1580                            "description": "Array option"
1581                        }
1582                    }
1583                }"#,
1584            );
1585
1586            let result = SchemaRegistry::from_directory(temp_dir.path());
1587            assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
1588        }
1589
1590        #[test]
1591        fn test_load_values_valid() {
1592            let temp_dir = TempDir::new().unwrap();
1593            let (schemas_dir, values_dir) = create_test_schema_with_values(
1594                &temp_dir,
1595                "test",
1596                r#"{
1597                    "version": "1.0",
1598                    "type": "object",
1599                    "properties": {
1600                        "array-key": {
1601                            "type": "array",
1602                            "items": {"type": "integer"},
1603                            "default": [1,2,3],
1604                            "description": "Array option"
1605                        }
1606                    }
1607                }"#,
1608                r#"{
1609                    "options": {
1610                        "array-key": [4,5,6]
1611                    }
1612                }"#,
1613            );
1614
1615            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1616            let (values, generated_at_by_namespace) =
1617                registry.load_values_json(&values_dir).unwrap();
1618
1619            assert_eq!(values.len(), 1);
1620            assert_eq!(values["test"]["array-key"], json!([4, 5, 6]));
1621            assert!(generated_at_by_namespace.is_empty());
1622        }
1623
1624        #[test]
1625        fn test_reject_values_not_an_array() {
1626            let temp_dir = TempDir::new().unwrap();
1627            let (schemas_dir, values_dir) = create_test_schema_with_values(
1628                &temp_dir,
1629                "test",
1630                r#"{
1631                    "version": "1.0",
1632                    "type": "object",
1633                    "properties": {
1634                        "array-key": {
1635                            "type": "array",
1636                            "items": {"type": "integer"},
1637                            "default": [1,2,3],
1638                            "description": "Array option"
1639                        }
1640                    }
1641                }"#,
1642                // sneaky! not an array
1643                r#"{
1644                    "options": {
1645                        "array-key": "[]"
1646                    }
1647                }"#,
1648            );
1649
1650            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1651            let result = registry.load_values_json(&values_dir);
1652
1653            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1654        }
1655
1656        #[test]
1657        fn test_reject_values_mismatch() {
1658            let temp_dir = TempDir::new().unwrap();
1659            let (schemas_dir, values_dir) = create_test_schema_with_values(
1660                &temp_dir,
1661                "test",
1662                r#"{
1663                    "version": "1.0",
1664                    "type": "object",
1665                    "properties": {
1666                        "array-key": {
1667                            "type": "array",
1668                            "items": {"type": "integer"},
1669                            "default": [1,2,3],
1670                            "description": "Array option"
1671                        }
1672                    }
1673                }"#,
1674                r#"{
1675                    "options": {
1676                        "array-key": ["a","b","c"]
1677                    }
1678                }"#,
1679            );
1680
1681            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1682            let result = registry.load_values_json(&values_dir);
1683
1684            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1685        }
1686    }
1687}