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