1use 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
21const NAMESPACE_SCHEMA_JSON: &str = include_str!("namespace-schema.json");
23const SCHEMA_FILE_NAME: &str = "schema.json";
24const VALUES_FILE_NAME: &str = "values.json";
25
26const POLLING_DELAY: u64 = 5;
28
29pub type ValidationResult<T> = Result<T, ValidationError>;
31
32pub type ValuesByNamespace = HashMap<String, HashMap<String, Value>>;
34
35#[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
57pub struct NamespaceSchema {
59 pub namespace: String,
60 defaults: HashMap<String, Value>,
61 validator: jsonschema::Validator,
62}
63
64impl NamespaceSchema {
65 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 pub fn get_default(&self, key: &str) -> Option<&Value> {
88 self.defaults.get(key)
89 }
90}
91
92pub struct SchemaRegistry {
94 schemas: HashMap<String, Arc<NamespaceSchema>>,
95}
96
97impl SchemaRegistry {
98 pub fn new() -> Self {
100 Self {
101 schemas: HashMap::new(),
102 }
103 }
104
105 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 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 fn load_all_schemas(
138 schemas_dir: &Path,
139 ) -> ValidationResult<HashMap<String, Arc<NamespaceSchema>>> {
140 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 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 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 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 fn validate_default_type(
215 property_name: &str,
216 property_type: &str,
217 default_value: &Value,
218 path: &Path,
219 ) -> ValidationResult<()> {
220 let type_schema = serde_json::json!({
222 "type": property_type
223 });
224
225 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 fn parse_schema(
241 mut schema: Value,
242 namespace: &str,
243 path: &Path,
244 ) -> ValidationResult<Arc<NamespaceSchema>> {
245 if let Some(obj) = schema.as_object_mut() {
247 obj.insert("additionalProperties".to_string(), json!(false));
248 }
249
250 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 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 pub fn get(&self, namespace: &str) -> Option<&Arc<NamespaceSchema>> {
280 self.schemas.get(namespace)
281 }
282
283 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
315pub struct ValuesWatcher {
330 stop_signal: Arc<AtomicBool>,
331 thread: Option<JoinHandle<()>>,
332}
333
334impl ValuesWatcher {
335 pub fn new(
337 values_path: &Path,
338 registry: Arc<SchemaRegistry>,
339 values: Arc<RwLock<ValuesByNamespace>>,
340 ) -> ValidationResult<Self> {
341 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(®istry);
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 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 if let Some(current_mtime) = Self::get_mtime(&values_path)
384 && Some(current_mtime) != last_mtime
385 {
386 Self::reload_values(&values_path, ®istry, &values);
387 last_mtime = Some(current_mtime);
388 }
389
390 thread::sleep(Duration::from_secs(POLLING_DELAY));
391 }
392 }
393
394 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 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 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 }
440 Err(e) => {
441 eprintln!(
442 "Failed to reload values from {}: {}",
443 values_path.display(),
444 e
445 );
446 }
447 }
448 }
449
450 fn update_values(values: &Arc<RwLock<ValuesByNamespace>>, new_values: ValuesByNamespace) {
452 let mut guard = values.write().unwrap();
454 *guard = new_values;
455 }
456
457 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 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 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 }
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 let result = registry.validate_values("test", &json!({"known_option": "value"}));
796 assert!(result.is_ok());
797
798 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 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 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 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 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 let mtime1 = ValuesWatcher::get_mtime(&values_dir);
1026 assert!(mtime1.is_some());
1027
1028 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 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 {
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 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 ValuesWatcher::reload_values(&values_dir, ®istry, &values);
1080
1081 {
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 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, ®istry, &values);
1110
1111 {
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(®istry), 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}