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