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 const PRODUCTION_OPTIONS_DIR: &str = "/etc/sentry-options";
31
32pub const LOCAL_OPTIONS_DIR: &str = "sentry-options";
34
35pub const OPTIONS_DIR_ENV: &str = "SENTRY_OPTIONS_DIR";
37
38pub 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
55pub type ValidationResult<T> = Result<T, ValidationError>;
57
58pub type ValuesByNamespace = HashMap<String, HashMap<String, Value>>;
60
61#[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#[derive(Debug, Clone)]
88pub struct OptionMetadata {
89 pub option_type: String,
90 pub default: Value,
91}
92
93pub struct NamespaceSchema {
95 pub namespace: String,
96 pub options: HashMap<String, OptionMetadata>,
97 validator: jsonschema::Validator,
98}
99
100impl NamespaceSchema {
101 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 pub fn get_default(&self, key: &str) -> Option<&Value> {
124 self.options.get(key).map(|meta| &meta.default)
125 }
126}
127
128pub struct SchemaRegistry {
130 schemas: HashMap<String, Arc<NamespaceSchema>>,
131}
132
133impl SchemaRegistry {
134 pub fn new() -> Self {
136 Self {
137 schemas: HashMap::new(),
138 }
139 }
140
141 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 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 fn load_all_schemas(
174 schemas_dir: &Path,
175 ) -> ValidationResult<HashMap<String, Arc<NamespaceSchema>>> {
176 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 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 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 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 fn validate_default_type(
251 property_name: &str,
252 property_type: &str,
253 default_value: &Value,
254 path: &Path,
255 ) -> ValidationResult<()> {
256 let type_schema = serde_json::json!({
258 "type": property_type
259 });
260
261 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 fn parse_schema(
277 mut schema: Value,
278 namespace: &str,
279 path: &Path,
280 ) -> ValidationResult<Arc<NamespaceSchema>> {
281 if let Some(obj) = schema.as_object_mut() {
283 obj.insert("additionalProperties".to_string(), json!(false));
284 }
285
286 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 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 pub fn get(&self, namespace: &str) -> Option<&Arc<NamespaceSchema>> {
322 self.schemas.get(namespace)
323 }
324
325 pub fn schemas(&self) -> &HashMap<String, Arc<NamespaceSchema>> {
327 &self.schemas
328 }
329
330 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
362pub struct ValuesWatcher {
377 stop_signal: Arc<AtomicBool>,
378 thread: Option<JoinHandle<()>>,
379}
380
381impl ValuesWatcher {
382 pub fn new(
384 values_path: &Path,
385 registry: Arc<SchemaRegistry>,
386 values: Arc<RwLock<ValuesByNamespace>>,
387 ) -> ValidationResult<Self> {
388 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(®istry);
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 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 if let Some(current_mtime) = Self::get_mtime(&values_path)
431 && Some(current_mtime) != last_mtime
432 {
433 Self::reload_values(&values_path, ®istry, &values);
434 last_mtime = Some(current_mtime);
435 }
436
437 thread::sleep(Duration::from_secs(POLLING_DELAY));
438 }
439 }
440
441 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 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 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 }
487 Err(e) => {
488 eprintln!(
489 "Failed to reload values from {}: {}",
490 values_path.display(),
491 e
492 );
493 }
494 }
495 }
496
497 fn update_values(values: &Arc<RwLock<ValuesByNamespace>>, new_values: ValuesByNamespace) {
499 let mut guard = values.write().unwrap();
501 *guard = new_values;
502 }
503
504 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 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 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 }
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 let result = registry.validate_values("test", &json!({"known_option": "value"}));
843 assert!(result.is_ok());
844
845 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 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 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 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 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 let mtime1 = ValuesWatcher::get_mtime(&values_dir);
1073 assert!(mtime1.is_some());
1074
1075 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 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 {
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 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 ValuesWatcher::reload_values(&values_dir, ®istry, &values);
1127
1128 {
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 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, ®istry, &values);
1157
1158 {
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(®istry), 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}