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 serde_json::Value;
8use serde_json::json;
9use std::collections::HashMap;
10use std::fs;
11use std::panic::{self, AssertUnwindSafe};
12use std::path::{Path, PathBuf};
13use std::sync::RwLock;
14use std::sync::{
15    Arc,
16    atomic::{AtomicBool, Ordering},
17};
18use std::thread::{self, JoinHandle};
19use std::time::Duration;
20
21/// Embedded meta-schema for validating sentry-options schema files
22const NAMESPACE_SCHEMA_JSON: &str = include_str!("namespace-schema.json");
23const SCHEMA_FILE_NAME: &str = "schema.json";
24const VALUES_FILE_NAME: &str = "values.json";
25
26/// Time between file polls in seconds
27const POLLING_DELAY: u64 = 5;
28
29/// Result type for validation operations
30pub type ValidationResult<T> = Result<T, ValidationError>;
31
32/// A map of option values keyed by their namespace
33pub type ValuesByNamespace = HashMap<String, HashMap<String, Value>>;
34
35/// Errors that can occur during schema and value validation
36#[derive(Debug, thiserror::Error)]
37pub enum ValidationError {
38    #[error("Schema error in {file}: {message}")]
39    SchemaError { file: PathBuf, message: String },
40
41    #[error("Value error for {namespace}: {errors}")]
42    ValueError { namespace: String, errors: String },
43
44    #[error("Unknown namespace: {0}")]
45    UnknownNamespace(String),
46
47    #[error("Internal error: {0}")]
48    InternalError(String),
49
50    #[error("Failed to read file: {0}")]
51    FileRead(#[from] std::io::Error),
52
53    #[error("Failed to parse JSON: {0}")]
54    JSONParse(#[from] serde_json::Error),
55}
56
57/// Schema for a namespace, containing validator and defaults
58pub struct NamespaceSchema {
59    pub namespace: String,
60    defaults: HashMap<String, Value>,
61    validator: jsonschema::Validator,
62}
63
64impl NamespaceSchema {
65    /// Validate an entire values object against this schema
66    ///
67    /// # Arguments
68    /// * `values` - JSON object containing option key-value pairs
69    ///
70    /// # Errors
71    /// Returns error if values don't match the schema
72    pub fn validate_values(&self, values: &Value) -> ValidationResult<()> {
73        let output = self.validator.evaluate(values);
74        if output.flag().valid {
75            Ok(())
76        } else {
77            let errors: Vec<String> = output.iter_errors().map(|e| e.error.to_string()).collect();
78            Err(ValidationError::ValueError {
79                namespace: self.namespace.clone(),
80                errors: errors.join(", "),
81            })
82        }
83    }
84
85    /// Get the default value for an option key.
86    /// Returns None if the key doesn't exist in the schema.
87    pub fn get_default(&self, key: &str) -> Option<&Value> {
88        self.defaults.get(key)
89    }
90}
91
92/// Registry for loading and storing schemas
93pub struct SchemaRegistry {
94    schemas: HashMap<String, Arc<NamespaceSchema>>,
95}
96
97impl SchemaRegistry {
98    /// Create a new empty schema registry
99    pub fn new() -> Self {
100        Self {
101            schemas: HashMap::new(),
102        }
103    }
104
105    /// Load schemas from a directory and create a registry
106    ///
107    /// Expects directory structure: `schemas/{namespace}/schema.json`
108    ///
109    /// # Arguments
110    /// * `schemas_dir` - Path to directory containing namespace subdirectories
111    ///
112    /// # Errors
113    /// Returns error if directory doesn't exist or any schema is invalid
114    pub fn from_directory(schemas_dir: &Path) -> ValidationResult<Self> {
115        let schemas = Self::load_all_schemas(schemas_dir)?;
116        Ok(Self { schemas })
117    }
118
119    /// Validate an entire values object for a namespace
120    ///
121    /// # Arguments
122    /// * `namespace` - Namespace name
123    /// * `values` - JSON object containing option key-value pairs
124    ///
125    /// # Errors
126    /// Returns error if namespace doesn't exist or values don't match schema
127    pub fn validate_values(&self, namespace: &str, values: &Value) -> ValidationResult<()> {
128        let schema = self
129            .schemas
130            .get(namespace)
131            .ok_or_else(|| ValidationError::UnknownNamespace(namespace.to_string()))?;
132
133        schema.validate_values(values)
134    }
135
136    /// Load all schemas from a directory
137    fn load_all_schemas(
138        schemas_dir: &Path,
139    ) -> ValidationResult<HashMap<String, Arc<NamespaceSchema>>> {
140        // Compile namespace-schema once for all schemas
141        let namespace_schema_value: Value =
142            serde_json::from_str(NAMESPACE_SCHEMA_JSON).map_err(|e| {
143                ValidationError::InternalError(format!("Invalid namespace-schema JSON: {}", e))
144            })?;
145        let namespace_validator =
146            jsonschema::validator_for(&namespace_schema_value).map_err(|e| {
147                ValidationError::InternalError(format!("Failed to compile namespace-schema: {}", e))
148            })?;
149
150        let mut schemas = HashMap::new();
151
152        // TODO: Parallelize the loading of schemas for the performance gainz
153        for entry in fs::read_dir(schemas_dir)? {
154            let entry = entry?;
155
156            if !entry.file_type()?.is_dir() {
157                continue;
158            }
159
160            let namespace =
161                entry
162                    .file_name()
163                    .into_string()
164                    .map_err(|_| ValidationError::SchemaError {
165                        file: entry.path(),
166                        message: "Directory name contains invalid UTF-8".to_string(),
167                    })?;
168
169            let schema_file = entry.path().join(SCHEMA_FILE_NAME);
170            let schema = Self::load_schema(&schema_file, &namespace, &namespace_validator)?;
171            schemas.insert(namespace, schema);
172        }
173
174        Ok(schemas)
175    }
176
177    /// Load a schema from a file
178    fn load_schema(
179        path: &Path,
180        namespace: &str,
181        namespace_validator: &jsonschema::Validator,
182    ) -> ValidationResult<Arc<NamespaceSchema>> {
183        let file = fs::File::open(path)?;
184        let schema_data: Value = serde_json::from_reader(file)?;
185
186        Self::validate_with_namespace_schema(&schema_data, path, namespace_validator)?;
187        Self::parse_schema(schema_data, namespace, path)
188    }
189
190    /// Validate a schema against the namespace-schema
191    fn validate_with_namespace_schema(
192        schema_data: &Value,
193        path: &Path,
194        namespace_validator: &jsonschema::Validator,
195    ) -> ValidationResult<()> {
196        let output = namespace_validator.evaluate(schema_data);
197
198        if output.flag().valid {
199            Ok(())
200        } else {
201            let errors: Vec<String> = output
202                .iter_errors()
203                .map(|e| format!("Error: {}", e.error))
204                .collect();
205
206            Err(ValidationError::SchemaError {
207                file: path.to_path_buf(),
208                message: format!("Schema validation failed:\n{}", errors.join("\n")),
209            })
210        }
211    }
212
213    /// Validate that a default value matches its declared type using jsonschema
214    fn validate_default_type(
215        property_name: &str,
216        property_type: &str,
217        default_value: &Value,
218        path: &Path,
219    ) -> ValidationResult<()> {
220        // Build a mini JSON Schema for just this type
221        let type_schema = serde_json::json!({
222            "type": property_type
223        });
224
225        // Validate the default value against the type
226        jsonschema::validate(&type_schema, default_value).map_err(|e| {
227            ValidationError::SchemaError {
228                file: path.to_path_buf(),
229                message: format!(
230                    "Property '{}': default value does not match type '{}': {}",
231                    property_name, property_type, e
232                ),
233            }
234        })?;
235
236        Ok(())
237    }
238
239    /// Parse a schema JSON into NamespaceSchema
240    fn parse_schema(
241        mut schema: Value,
242        namespace: &str,
243        path: &Path,
244    ) -> ValidationResult<Arc<NamespaceSchema>> {
245        // Inject additionalProperties: false to reject unknown options
246        if let Some(obj) = schema.as_object_mut() {
247            obj.insert("additionalProperties".to_string(), json!(false));
248        }
249
250        // Use the schema file directly as the validator
251        let validator =
252            jsonschema::validator_for(&schema).map_err(|e| ValidationError::SchemaError {
253                file: path.to_path_buf(),
254                message: format!("Failed to compile validator: {}", e),
255            })?;
256
257        // Extract defaults and validate types
258        let mut defaults = HashMap::new();
259        if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
260            for (prop_name, prop_value) in properties {
261                if let (Some(prop_type), Some(default_value)) = (
262                    prop_value.get("type").and_then(|t| t.as_str()),
263                    prop_value.get("default"),
264                ) {
265                    Self::validate_default_type(prop_name, prop_type, default_value, path)?;
266                    defaults.insert(prop_name.clone(), default_value.clone());
267                }
268            }
269        }
270
271        Ok(Arc::new(NamespaceSchema {
272            namespace: namespace.to_string(),
273            defaults,
274            validator,
275        }))
276    }
277
278    /// Get a namespace schema by name
279    pub fn get(&self, namespace: &str) -> Option<&Arc<NamespaceSchema>> {
280        self.schemas.get(namespace)
281    }
282
283    /// Load and validate JSON values from a directory.
284    /// Expects structure: `{values_dir}/{namespace}/values.json`
285    /// Skips namespaces without a values.json file.
286    pub fn load_values_json(&self, values_dir: &Path) -> ValidationResult<ValuesByNamespace> {
287        let mut all_values = HashMap::new();
288
289        for namespace in self.schemas.keys() {
290            let values_file = values_dir.join(namespace).join(VALUES_FILE_NAME);
291
292            if !values_file.exists() {
293                continue;
294            }
295
296            let values: Value = serde_json::from_reader(fs::File::open(&values_file)?)?;
297            self.validate_values(namespace, &values)?;
298
299            if let Value::Object(obj) = values {
300                let ns_values: HashMap<String, Value> = obj.into_iter().collect();
301                all_values.insert(namespace.clone(), ns_values);
302            }
303        }
304
305        Ok(all_values)
306    }
307}
308
309impl Default for SchemaRegistry {
310    fn default() -> Self {
311        Self::new()
312    }
313}
314
315/// Watches the values directory for changes, reloading if there are any.
316/// If the directory does not exist we do not panic
317///
318/// Does not do an initial fetch, assumes the caller has already loaded values.
319/// Child thread may panic if we run out of memory or cannot create more threads.
320///
321/// Uses polling for now, could use `inotify` or similar later on.
322///
323/// Some important notes:
324/// - If the thread panics and dies, there is no built in mechanism to catch it and restart
325/// - If a config map is unmounted, we won't reload until the next file modification (because we don't catch the deletion event)
326/// - If any namespace fails validation, we keep all old values (even the namespaces that passed validation)
327/// - If we have a steady stream of readers our writer may starve for a while trying to acquire the lock
328/// - stop() will block until the thread gets joined
329pub struct ValuesWatcher {
330    stop_signal: Arc<AtomicBool>,
331    thread: Option<JoinHandle<()>>,
332}
333
334impl ValuesWatcher {
335    /// Creates a new ValuesWatcher struct and spins up the watcher thread
336    pub fn new(
337        values_path: &Path,
338        registry: Arc<SchemaRegistry>,
339        values: Arc<RwLock<ValuesByNamespace>>,
340    ) -> ValidationResult<Self> {
341        // output an error but keep passing
342        if fs::metadata(values_path).is_err() {
343            eprintln!("Values directory does not exist: {}", values_path.display());
344        }
345
346        let stop_signal = Arc::new(AtomicBool::new(false));
347
348        let thread_signal = Arc::clone(&stop_signal);
349        let thread_path = values_path.to_path_buf();
350        let thread_registry = Arc::clone(&registry);
351        let thread_values = Arc::clone(&values);
352        let thread = thread::Builder::new()
353            .name("sentry-options-watcher".into())
354            .spawn(move || {
355                let result = panic::catch_unwind(AssertUnwindSafe(|| {
356                    Self::run(thread_signal, thread_path, thread_registry, thread_values);
357                }));
358                if let Err(e) = result {
359                    eprintln!("Watcher thread panicked with: {:?}", e);
360                }
361            })?;
362
363        Ok(Self {
364            stop_signal,
365            thread: Some(thread),
366        })
367    }
368
369    /// Reloads the values if the modified time has changed.
370    ///
371    /// Continuously polls the values directory and reloads all values
372    /// if any modification is detected.
373    fn run(
374        stop_signal: Arc<AtomicBool>,
375        values_path: PathBuf,
376        registry: Arc<SchemaRegistry>,
377        values: Arc<RwLock<ValuesByNamespace>>,
378    ) {
379        let mut last_mtime = Self::get_mtime(&values_path);
380
381        while !stop_signal.load(Ordering::Relaxed) {
382            // does not reload values if get_mtime fails
383            if let Some(current_mtime) = Self::get_mtime(&values_path)
384                && Some(current_mtime) != last_mtime
385            {
386                Self::reload_values(&values_path, &registry, &values);
387                last_mtime = Some(current_mtime);
388            }
389
390            thread::sleep(Duration::from_secs(POLLING_DELAY));
391        }
392    }
393
394    /// Get the most recent modification time across all namespace values.json files
395    /// Returns None if no valid values files are found
396    fn get_mtime(values_dir: &Path) -> Option<std::time::SystemTime> {
397        let mut latest_mtime = None;
398
399        let entries = match fs::read_dir(values_dir) {
400            Ok(e) => e,
401            Err(e) => {
402                eprintln!("Failed to read directory {}: {}", values_dir.display(), e);
403                return None;
404            }
405        };
406
407        for entry in entries.flatten() {
408            // skip if not a dir
409            if !entry
410                .file_type()
411                .map(|file_type| file_type.is_dir())
412                .unwrap_or(false)
413            {
414                continue;
415            }
416
417            let values_file = entry.path().join(VALUES_FILE_NAME);
418            if let Ok(metadata) = fs::metadata(&values_file)
419                && let Ok(mtime) = metadata.modified()
420                && latest_mtime.is_none_or(|latest| mtime > latest)
421            {
422                latest_mtime = Some(mtime);
423            }
424        }
425
426        latest_mtime
427    }
428
429    /// Reload values from disk, validate them, and update the shared map
430    fn reload_values(
431        values_path: &Path,
432        registry: &SchemaRegistry,
433        values: &Arc<RwLock<ValuesByNamespace>>,
434    ) {
435        match registry.load_values_json(values_path) {
436            Ok(new_values) => {
437                Self::update_values(values, new_values);
438                // TODO: add some logging here so we know when options refresh
439            }
440            Err(e) => {
441                eprintln!(
442                    "Failed to reload values from {}: {}",
443                    values_path.display(),
444                    e
445                );
446            }
447        }
448    }
449
450    /// Update the values map with the new values
451    fn update_values(values: &Arc<RwLock<ValuesByNamespace>>, new_values: ValuesByNamespace) {
452        // safe to unwrap, we only have one thread and if it panics we die anyways
453        let mut guard = values.write().unwrap();
454        *guard = new_values;
455    }
456
457    /// Stops the watcher thread, waiting for it to join.
458    /// May take up to POLLING_DELAY seconds
459    pub fn stop(&mut self) {
460        self.stop_signal.store(true, Ordering::Relaxed);
461        if let Some(thread) = self.thread.take() {
462            let _ = thread.join();
463        }
464    }
465
466    /// Returns whether the watcher thread is still running
467    pub fn is_alive(&self) -> bool {
468        self.thread.as_ref().is_some_and(|t| !t.is_finished())
469    }
470}
471
472impl Drop for ValuesWatcher {
473    fn drop(&mut self) {
474        self.stop();
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use tempfile::TempDir;
482
483    fn create_test_schema(temp_dir: &TempDir, namespace: &str, schema_json: &str) -> PathBuf {
484        let schema_dir = temp_dir.path().join(namespace);
485        fs::create_dir_all(&schema_dir).unwrap();
486        let schema_file = schema_dir.join("schema.json");
487        fs::write(&schema_file, schema_json).unwrap();
488        schema_file
489    }
490
491    #[test]
492    fn test_load_schema_valid() {
493        let temp_dir = TempDir::new().unwrap();
494        create_test_schema(
495            &temp_dir,
496            "test",
497            r#"{
498                "version": "1.0",
499                "type": "object",
500                "properties": {
501                    "test-key": {
502                        "type": "string",
503                        "default": "test",
504                        "description": "Test option"
505                    }
506                }
507            }"#,
508        );
509
510        SchemaRegistry::from_directory(temp_dir.path()).unwrap();
511    }
512
513    #[test]
514    fn test_load_schema_missing_version() {
515        let temp_dir = TempDir::new().unwrap();
516        create_test_schema(
517            &temp_dir,
518            "test",
519            r#"{
520                "type": "object",
521                "properties": {}
522            }"#,
523        );
524
525        let result = SchemaRegistry::from_directory(temp_dir.path());
526        assert!(result.is_err());
527        match result {
528            Err(ValidationError::SchemaError { message, .. }) => {
529                assert!(message.contains(
530                    "Schema validation failed:
531Error: \"version\" is a required property"
532                ));
533            }
534            _ => panic!("Expected SchemaError for missing version"),
535        }
536    }
537
538    #[test]
539    fn test_unknown_namespace() {
540        let temp_dir = TempDir::new().unwrap();
541        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
542
543        let result = registry.validate_values("unknown", &json!({}));
544        assert!(matches!(result, Err(ValidationError::UnknownNamespace(..))));
545    }
546
547    #[test]
548    fn test_multiple_namespaces() {
549        let temp_dir = TempDir::new().unwrap();
550        create_test_schema(
551            &temp_dir,
552            "ns1",
553            r#"{
554                "version": "1.0",
555                "type": "object",
556                "properties": {
557                    "opt1": {
558                        "type": "string",
559                        "default": "default1",
560                        "description": "First option"
561                    }
562                }
563            }"#,
564        );
565        create_test_schema(
566            &temp_dir,
567            "ns2",
568            r#"{
569                "version": "2.0",
570                "type": "object",
571                "properties": {
572                    "opt2": {
573                        "type": "integer",
574                        "default": 42,
575                        "description": "Second option"
576                    }
577                }
578            }"#,
579        );
580
581        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
582        assert!(registry.schemas.contains_key("ns1"));
583        assert!(registry.schemas.contains_key("ns2"));
584    }
585
586    #[test]
587    fn test_invalid_default_type() {
588        let temp_dir = TempDir::new().unwrap();
589        create_test_schema(
590            &temp_dir,
591            "test",
592            r#"{
593                "version": "1.0",
594                "type": "object",
595                "properties": {
596                    "bad-default": {
597                        "type": "integer",
598                        "default": "not-a-number",
599                        "description": "A bad default value"
600                    }
601                }
602            }"#,
603        );
604
605        let result = SchemaRegistry::from_directory(temp_dir.path());
606        assert!(result.is_err());
607        match result {
608            Err(ValidationError::SchemaError { message, .. }) => {
609                assert!(message.contains("Property 'bad-default': default value does not match type 'integer': \"not-a-number\" is not of type \"integer\""));
610            }
611            _ => panic!("Expected SchemaError for invalid default type"),
612        }
613    }
614
615    #[test]
616    fn test_extra_properties() {
617        let temp_dir = TempDir::new().unwrap();
618        create_test_schema(
619            &temp_dir,
620            "test",
621            r#"{
622                "version": "1.0",
623                "type": "object",
624                "properties": {
625                    "bad-property": {
626                        "type": "integer",
627                        "default": 0,
628                        "description": "Test property",
629                        "extra": "property"
630                    }
631                }
632            }"#,
633        );
634
635        let result = SchemaRegistry::from_directory(temp_dir.path());
636        assert!(result.is_err());
637        match result {
638            Err(ValidationError::SchemaError { message, .. }) => {
639                assert!(
640                    message
641                        .contains("Additional properties are not allowed ('extra' was unexpected)")
642                );
643            }
644            _ => panic!("Expected SchemaError for extra properties"),
645        }
646    }
647
648    #[test]
649    fn test_missing_description() {
650        let temp_dir = TempDir::new().unwrap();
651        create_test_schema(
652            &temp_dir,
653            "test",
654            r#"{
655                "version": "1.0",
656                "type": "object",
657                "properties": {
658                    "missing-desc": {
659                        "type": "string",
660                        "default": "test"
661                    }
662                }
663            }"#,
664        );
665
666        let result = SchemaRegistry::from_directory(temp_dir.path());
667        assert!(result.is_err());
668        match result {
669            Err(ValidationError::SchemaError { message, .. }) => {
670                assert!(message.contains("\"description\" is a required property"));
671            }
672            _ => panic!("Expected SchemaError for missing description"),
673        }
674    }
675
676    #[test]
677    fn test_invalid_directory_structure() {
678        let temp_dir = TempDir::new().unwrap();
679        // Create a namespace directory without schema.json file
680        let schema_dir = temp_dir.path().join("missing_schema");
681        fs::create_dir_all(&schema_dir).unwrap();
682
683        let result = SchemaRegistry::from_directory(temp_dir.path());
684        assert!(result.is_err());
685        match result {
686            Err(ValidationError::FileRead(..)) => {
687                // Expected error when schema.json file is missing
688            }
689            _ => panic!("Expected FileRead error for missing schema.json"),
690        }
691    }
692
693    #[test]
694    fn test_get_default() {
695        let temp_dir = TempDir::new().unwrap();
696        create_test_schema(
697            &temp_dir,
698            "test",
699            r#"{
700                "version": "1.0",
701                "type": "object",
702                "properties": {
703                    "string_opt": {
704                        "type": "string",
705                        "default": "hello",
706                        "description": "A string option"
707                    },
708                    "int_opt": {
709                        "type": "integer",
710                        "default": 42,
711                        "description": "An integer option"
712                    }
713                }
714            }"#,
715        );
716
717        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
718        let schema = registry.get("test").unwrap();
719
720        assert_eq!(schema.get_default("string_opt"), Some(&json!("hello")));
721        assert_eq!(schema.get_default("int_opt"), Some(&json!(42)));
722        assert_eq!(schema.get_default("unknown"), None);
723    }
724
725    #[test]
726    fn test_validate_values_valid() {
727        let temp_dir = TempDir::new().unwrap();
728        create_test_schema(
729            &temp_dir,
730            "test",
731            r#"{
732                "version": "1.0",
733                "type": "object",
734                "properties": {
735                    "enabled": {
736                        "type": "boolean",
737                        "default": false,
738                        "description": "Enable feature"
739                    }
740                }
741            }"#,
742        );
743
744        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
745        let result = registry.validate_values("test", &json!({"enabled": true}));
746        assert!(result.is_ok());
747    }
748
749    #[test]
750    fn test_validate_values_invalid_type() {
751        let temp_dir = TempDir::new().unwrap();
752        create_test_schema(
753            &temp_dir,
754            "test",
755            r#"{
756                "version": "1.0",
757                "type": "object",
758                "properties": {
759                    "count": {
760                        "type": "integer",
761                        "default": 0,
762                        "description": "Count"
763                    }
764                }
765            }"#,
766        );
767
768        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
769        let result = registry.validate_values("test", &json!({"count": "not a number"}));
770        assert!(matches!(result, Err(ValidationError::ValueError { .. })));
771    }
772
773    #[test]
774    fn test_validate_values_unknown_option() {
775        let temp_dir = TempDir::new().unwrap();
776        create_test_schema(
777            &temp_dir,
778            "test",
779            r#"{
780                "version": "1.0",
781                "type": "object",
782                "properties": {
783                    "known_option": {
784                        "type": "string",
785                        "default": "default",
786                        "description": "A known option"
787                    }
788                }
789            }"#,
790        );
791
792        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
793
794        // Valid known option should pass
795        let result = registry.validate_values("test", &json!({"known_option": "value"}));
796        assert!(result.is_ok());
797
798        // Unknown option should fail
799        let result = registry.validate_values("test", &json!({"unknown_option": "value"}));
800        assert!(result.is_err());
801        match result {
802            Err(ValidationError::ValueError { errors, .. }) => {
803                assert!(errors.contains("Additional properties are not allowed"));
804            }
805            _ => panic!("Expected ValueError for unknown option"),
806        }
807    }
808
809    #[test]
810    fn test_load_values_json_valid() {
811        let temp_dir = TempDir::new().unwrap();
812        let schemas_dir = temp_dir.path().join("schemas");
813        let values_dir = temp_dir.path().join("values");
814
815        let schema_dir = schemas_dir.join("test");
816        fs::create_dir_all(&schema_dir).unwrap();
817        fs::write(
818            schema_dir.join("schema.json"),
819            r#"{
820                "version": "1.0",
821                "type": "object",
822                "properties": {
823                    "enabled": {
824                        "type": "boolean",
825                        "default": false,
826                        "description": "Enable feature"
827                    },
828                    "name": {
829                        "type": "string",
830                        "default": "default",
831                        "description": "Name"
832                    },
833                    "count": {
834                        "type": "integer",
835                        "default": 0,
836                        "description": "Count"
837                    },
838                    "rate": {
839                        "type": "number",
840                        "default": 0.0,
841                        "description": "Rate"
842                    }
843                }
844            }"#,
845        )
846        .unwrap();
847
848        let test_values_dir = values_dir.join("test");
849        fs::create_dir_all(&test_values_dir).unwrap();
850        fs::write(
851            test_values_dir.join("values.json"),
852            r#"{
853                "enabled": true,
854                "name": "test-name",
855                "count": 42,
856                "rate": 0.75
857            }"#,
858        )
859        .unwrap();
860
861        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
862        let values = registry.load_values_json(&values_dir).unwrap();
863
864        assert_eq!(values.len(), 1);
865        assert_eq!(values["test"]["enabled"], json!(true));
866        assert_eq!(values["test"]["name"], json!("test-name"));
867        assert_eq!(values["test"]["count"], json!(42));
868        assert_eq!(values["test"]["rate"], json!(0.75));
869    }
870
871    #[test]
872    fn test_load_values_json_nonexistent_dir() {
873        let temp_dir = TempDir::new().unwrap();
874        create_test_schema(
875            &temp_dir,
876            "test",
877            r#"{"version": "1.0", "type": "object", "properties": {}}"#,
878        );
879
880        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
881        let values = registry
882            .load_values_json(&temp_dir.path().join("nonexistent"))
883            .unwrap();
884
885        // No values.json files found, returns empty
886        assert!(values.is_empty());
887    }
888
889    #[test]
890    fn test_load_values_json_skips_missing_values_file() {
891        let temp_dir = TempDir::new().unwrap();
892        let schemas_dir = temp_dir.path().join("schemas");
893        let values_dir = temp_dir.path().join("values");
894
895        // Create two schemas
896        let schema_dir1 = schemas_dir.join("with_values");
897        fs::create_dir_all(&schema_dir1).unwrap();
898        fs::write(
899            schema_dir1.join("schema.json"),
900            r#"{
901                "version": "1.0",
902                "type": "object",
903                "properties": {
904                    "opt": {"type": "string", "default": "x", "description": "Opt"}
905                }
906            }"#,
907        )
908        .unwrap();
909
910        let schema_dir2 = schemas_dir.join("without_values");
911        fs::create_dir_all(&schema_dir2).unwrap();
912        fs::write(
913            schema_dir2.join("schema.json"),
914            r#"{
915                "version": "1.0",
916                "type": "object",
917                "properties": {
918                    "opt": {"type": "string", "default": "x", "description": "Opt"}
919                }
920            }"#,
921        )
922        .unwrap();
923
924        // Only create values for one namespace
925        let with_values_dir = values_dir.join("with_values");
926        fs::create_dir_all(&with_values_dir).unwrap();
927        fs::write(with_values_dir.join("values.json"), r#"{"opt": "y"}"#).unwrap();
928
929        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
930        let values = registry.load_values_json(&values_dir).unwrap();
931
932        assert_eq!(values.len(), 1);
933        assert!(values.contains_key("with_values"));
934        assert!(!values.contains_key("without_values"));
935    }
936
937    #[test]
938    fn test_load_values_json_rejects_wrong_type() {
939        let temp_dir = TempDir::new().unwrap();
940        let schemas_dir = temp_dir.path().join("schemas");
941        let values_dir = temp_dir.path().join("values");
942
943        let schema_dir = schemas_dir.join("test");
944        fs::create_dir_all(&schema_dir).unwrap();
945        fs::write(
946            schema_dir.join("schema.json"),
947            r#"{
948                "version": "1.0",
949                "type": "object",
950                "properties": {
951                    "count": {"type": "integer", "default": 0, "description": "Count"}
952                }
953            }"#,
954        )
955        .unwrap();
956
957        let test_values_dir = values_dir.join("test");
958        fs::create_dir_all(&test_values_dir).unwrap();
959        fs::write(
960            test_values_dir.join("values.json"),
961            r#"{"count": "not-a-number"}"#,
962        )
963        .unwrap();
964
965        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
966        let result = registry.load_values_json(&values_dir);
967
968        assert!(matches!(result, Err(ValidationError::ValueError { .. })));
969    }
970
971    mod watcher_tests {
972        use super::*;
973        use std::thread;
974
975        /// Creates schema and values files for two namespaces: ns1, and ns2
976        fn setup_watcher_test() -> (TempDir, PathBuf, PathBuf) {
977            let temp_dir = TempDir::new().unwrap();
978            let schemas_dir = temp_dir.path().join("schemas");
979            let values_dir = temp_dir.path().join("values");
980
981            let ns1_schema = schemas_dir.join("ns1");
982            fs::create_dir_all(&ns1_schema).unwrap();
983            fs::write(
984                ns1_schema.join("schema.json"),
985                r#"{
986                    "version": "1.0",
987                    "type": "object",
988                    "properties": {
989                        "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
990                    }
991                }"#,
992            )
993            .unwrap();
994
995            let ns1_values = values_dir.join("ns1");
996            fs::create_dir_all(&ns1_values).unwrap();
997            fs::write(ns1_values.join("values.json"), r#"{"enabled": true}"#).unwrap();
998
999            let ns2_schema = schemas_dir.join("ns2");
1000            fs::create_dir_all(&ns2_schema).unwrap();
1001            fs::write(
1002                ns2_schema.join("schema.json"),
1003                r#"{
1004                    "version": "1.0",
1005                    "type": "object",
1006                    "properties": {
1007                        "count": {"type": "integer", "default": 0, "description": "Count"}
1008                    }
1009                }"#,
1010            )
1011            .unwrap();
1012
1013            let ns2_values = values_dir.join("ns2");
1014            fs::create_dir_all(&ns2_values).unwrap();
1015            fs::write(ns2_values.join("values.json"), r#"{"count": 42}"#).unwrap();
1016
1017            (temp_dir, schemas_dir, values_dir)
1018        }
1019
1020        #[test]
1021        fn test_get_mtime_returns_most_recent() {
1022            let (_temp, _schemas, values_dir) = setup_watcher_test();
1023
1024            // Get initial mtime
1025            let mtime1 = ValuesWatcher::get_mtime(&values_dir);
1026            assert!(mtime1.is_some());
1027
1028            // Modify one namespace
1029            thread::sleep(std::time::Duration::from_millis(10));
1030            fs::write(
1031                values_dir.join("ns1").join("values.json"),
1032                r#"{"enabled": false}"#,
1033            )
1034            .unwrap();
1035
1036            // Should detect the change
1037            let mtime2 = ValuesWatcher::get_mtime(&values_dir);
1038            assert!(mtime2.is_some());
1039            assert!(mtime2 > mtime1);
1040        }
1041
1042        #[test]
1043        fn test_get_mtime_with_missing_directory() {
1044            let temp = TempDir::new().unwrap();
1045            let nonexistent = temp.path().join("nonexistent");
1046
1047            let mtime = ValuesWatcher::get_mtime(&nonexistent);
1048            assert!(mtime.is_none());
1049        }
1050
1051        #[test]
1052        fn test_reload_values_updates_map() {
1053            let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1054
1055            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1056            let initial_values = registry.load_values_json(&values_dir).unwrap();
1057            let values = Arc::new(RwLock::new(initial_values));
1058
1059            // ensure initial values are correct
1060            {
1061                let guard = values.read().unwrap();
1062                assert_eq!(guard["ns1"]["enabled"], json!(true));
1063                assert_eq!(guard["ns2"]["count"], json!(42));
1064            }
1065
1066            // modify
1067            fs::write(
1068                values_dir.join("ns1").join("values.json"),
1069                r#"{"enabled": false}"#,
1070            )
1071            .unwrap();
1072            fs::write(
1073                values_dir.join("ns2").join("values.json"),
1074                r#"{"count": 100}"#,
1075            )
1076            .unwrap();
1077
1078            // force a reload
1079            ValuesWatcher::reload_values(&values_dir, &registry, &values);
1080
1081            // ensure new values are correct
1082            {
1083                let guard = values.read().unwrap();
1084                assert_eq!(guard["ns1"]["enabled"], json!(false));
1085                assert_eq!(guard["ns2"]["count"], json!(100));
1086            }
1087        }
1088
1089        #[test]
1090        fn test_old_values_persist_with_invalid_data() {
1091            let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1092
1093            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1094            let initial_values = registry.load_values_json(&values_dir).unwrap();
1095            let values = Arc::new(RwLock::new(initial_values));
1096
1097            let initial_enabled = {
1098                let guard = values.read().unwrap();
1099                guard["ns1"]["enabled"].clone()
1100            };
1101
1102            // won't pass validation
1103            fs::write(
1104                values_dir.join("ns1").join("values.json"),
1105                r#"{"enabled": "not-a-boolean"}"#,
1106            )
1107            .unwrap();
1108
1109            ValuesWatcher::reload_values(&values_dir, &registry, &values);
1110
1111            // ensure old value persists
1112            {
1113                let guard = values.read().unwrap();
1114                assert_eq!(guard["ns1"]["enabled"], initial_enabled);
1115            }
1116        }
1117
1118        #[test]
1119        fn test_watcher_creation_and_termination() {
1120            let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1121
1122            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1123            let initial_values = registry.load_values_json(&values_dir).unwrap();
1124            let values = Arc::new(RwLock::new(initial_values));
1125
1126            let mut watcher =
1127                ValuesWatcher::new(&values_dir, Arc::clone(&registry), Arc::clone(&values))
1128                    .expect("Failed to create watcher");
1129
1130            assert!(watcher.is_alive());
1131            watcher.stop();
1132            assert!(!watcher.is_alive());
1133        }
1134    }
1135}