1use chrono::{DateTime, Utc};
8use sentry::ClientOptions;
9use sentry::transports::DefaultTransportFactory;
10use serde_json::Value;
11use serde_json::json;
12use std::collections::HashMap;
13use std::fs;
14use std::panic::{self, AssertUnwindSafe};
15use std::path::{Path, PathBuf};
16use std::sync::RwLock;
17use std::sync::{
18 Arc, OnceLock,
19 atomic::{AtomicBool, Ordering},
20};
21use std::thread::{self, JoinHandle};
22use std::time::{Duration, Instant};
23
24const NAMESPACE_SCHEMA_JSON: &str = include_str!("namespace-schema.json");
26const SCHEMA_FILE_NAME: &str = "schema.json";
27const VALUES_FILE_NAME: &str = "values.json";
28
29const POLLING_DELAY: u64 = 5;
31
32#[cfg(not(test))]
35const SENTRY_OPTIONS_DSN: &str =
36 "https://d3598a07e9f23a9acee9e2718cfd17bd@o1.ingest.us.sentry.io/4510750163927040";
37
38#[cfg(test)]
40const SENTRY_OPTIONS_DSN: &str = "";
41
42static SENTRY_HUB: OnceLock<Arc<sentry::Hub>> = OnceLock::new();
46
47fn get_sentry_hub() -> &'static Arc<sentry::Hub> {
48 SENTRY_HUB.get_or_init(|| {
49 let client = Arc::new(sentry::Client::from((
50 SENTRY_OPTIONS_DSN,
51 ClientOptions {
52 traces_sample_rate: 1.0,
53 transport: Some(Arc::new(DefaultTransportFactory)),
55 ..Default::default()
56 },
57 )));
58 Arc::new(sentry::Hub::new(
59 Some(client),
60 Arc::new(sentry::Scope::default()),
61 ))
62 })
63}
64
65pub const PRODUCTION_OPTIONS_DIR: &str = "/etc/sentry-options";
67
68pub const LOCAL_OPTIONS_DIR: &str = "sentry-options";
70
71pub const OPTIONS_DIR_ENV: &str = "SENTRY_OPTIONS_DIR";
73
74pub const OPTIONS_SUPPRESS_MISSING_DIR_ENV: &str = "SENTRY_OPTIONS_SUPPRESS_MISSING_DIR";
76
77fn should_suppress_missing_dir_errors() -> bool {
79 std::env::var(OPTIONS_SUPPRESS_MISSING_DIR_ENV)
80 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
81 .unwrap_or(false)
82}
83
84pub fn resolve_options_dir() -> PathBuf {
89 if let Ok(dir) = std::env::var(OPTIONS_DIR_ENV) {
90 return PathBuf::from(dir);
91 }
92
93 let prod_path = PathBuf::from(PRODUCTION_OPTIONS_DIR);
94 if prod_path.exists() {
95 return prod_path;
96 }
97
98 PathBuf::from(LOCAL_OPTIONS_DIR)
99}
100
101pub type ValidationResult<T> = Result<T, ValidationError>;
103
104pub type ValuesByNamespace = HashMap<String, HashMap<String, Value>>;
106
107#[derive(Debug, thiserror::Error)]
109pub enum ValidationError {
110 #[error("Schema error in {file}: {message}")]
111 SchemaError { file: PathBuf, message: String },
112
113 #[error("Value error for {namespace}: {errors}")]
114 ValueError { namespace: String, errors: String },
115
116 #[error("Unknown namespace: {0}")]
117 UnknownNamespace(String),
118
119 #[error("Unknown option '{key}' in namespace '{namespace}'")]
120 UnknownOption { namespace: String, key: String },
121
122 #[error("Internal error: {0}")]
123 InternalError(String),
124
125 #[error("Failed to read file: {0}")]
126 FileRead(#[from] std::io::Error),
127
128 #[error("Failed to parse JSON: {0}")]
129 JSONParse(#[from] serde_json::Error),
130
131 #[error("{} validation error(s)", .0.len())]
132 ValidationErrors(Vec<ValidationError>),
133
134 #[error("Invalid {label} '{name}': {reason}")]
135 InvalidName {
136 label: String,
137 name: String,
138 reason: String,
139 },
140}
141
142pub fn validate_k8s_name_component(name: &str, label: &str) -> ValidationResult<()> {
144 if let Some(c) = name
145 .chars()
146 .find(|&c| !matches!(c, 'a'..='z' | '0'..='9' | '-' | '.'))
147 {
148 return Err(ValidationError::InvalidName {
149 label: label.to_string(),
150 name: name.to_string(),
151 reason: format!(
152 "character '{}' not allowed. Use lowercase alphanumeric, '-', or '.'",
153 c
154 ),
155 });
156 }
157 if !name.starts_with(|c: char| c.is_ascii_alphanumeric())
158 || !name.ends_with(|c: char| c.is_ascii_alphanumeric())
159 {
160 return Err(ValidationError::InvalidName {
161 label: label.to_string(),
162 name: name.to_string(),
163 reason: "must start and end with alphanumeric".to_string(),
164 });
165 }
166 Ok(())
167}
168
169#[derive(Debug, Clone)]
171pub struct OptionMetadata {
172 pub option_type: String,
173 pub property_schema: Value,
174 pub default: Value,
175}
176
177pub struct NamespaceSchema {
179 pub namespace: String,
180 pub options: HashMap<String, OptionMetadata>,
181 validator: jsonschema::Validator,
182}
183
184impl NamespaceSchema {
185 pub fn validate_values(&self, values: &Value) -> ValidationResult<()> {
193 let output = self.validator.evaluate(values);
194 if output.flag().valid {
195 Ok(())
196 } else {
197 let errors: Vec<String> = output
198 .iter_errors()
199 .map(|e| {
200 format!(
201 "\n\t{} {}",
202 e.instance_location.as_str().trim_start_matches("/"),
203 e.error
204 )
205 })
206 .collect();
207 Err(ValidationError::ValueError {
208 namespace: self.namespace.clone(),
209 errors: errors.join(""),
210 })
211 }
212 }
213
214 pub fn get_default(&self, key: &str) -> Option<&Value> {
217 self.options.get(key).map(|meta| &meta.default)
218 }
219
220 pub fn validate_option(&self, key: &str, value: &Value) -> ValidationResult<()> {
225 if !self.options.contains_key(key) {
226 return Err(ValidationError::UnknownOption {
227 namespace: self.namespace.clone(),
228 key: key.to_string(),
229 });
230 }
231 let test_obj = json!({ key: value });
232 self.validate_values(&test_obj)
233 }
234}
235
236pub struct SchemaRegistry {
238 schemas: HashMap<String, Arc<NamespaceSchema>>,
239}
240
241impl SchemaRegistry {
242 pub fn new() -> Self {
244 Self {
245 schemas: HashMap::new(),
246 }
247 }
248
249 pub fn from_directory(schemas_dir: &Path) -> ValidationResult<Self> {
259 let schemas = Self::load_all_schemas(schemas_dir)?;
260 Ok(Self { schemas })
261 }
262
263 pub fn validate_values(&self, namespace: &str, values: &Value) -> ValidationResult<()> {
272 let schema = self
273 .schemas
274 .get(namespace)
275 .ok_or_else(|| ValidationError::UnknownNamespace(namespace.to_string()))?;
276
277 schema.validate_values(values)
278 }
279
280 fn load_all_schemas(
282 schemas_dir: &Path,
283 ) -> ValidationResult<HashMap<String, Arc<NamespaceSchema>>> {
284 let namespace_schema_value: Value =
286 serde_json::from_str(NAMESPACE_SCHEMA_JSON).map_err(|e| {
287 ValidationError::InternalError(format!("Invalid namespace-schema JSON: {}", e))
288 })?;
289 let namespace_validator =
290 jsonschema::validator_for(&namespace_schema_value).map_err(|e| {
291 ValidationError::InternalError(format!("Failed to compile namespace-schema: {}", e))
292 })?;
293
294 let mut schemas = HashMap::new();
295
296 for entry in fs::read_dir(schemas_dir)? {
298 let entry = entry?;
299
300 if !entry.file_type()?.is_dir() {
301 continue;
302 }
303
304 let namespace =
305 entry
306 .file_name()
307 .into_string()
308 .map_err(|_| ValidationError::SchemaError {
309 file: entry.path(),
310 message: "Directory name contains invalid UTF-8".to_string(),
311 })?;
312
313 validate_k8s_name_component(&namespace, "namespace name")?;
314
315 let schema_file = entry.path().join(SCHEMA_FILE_NAME);
316 let schema = Self::load_schema(&schema_file, &namespace, &namespace_validator)?;
317 schemas.insert(namespace, schema);
318 }
319
320 Ok(schemas)
321 }
322
323 fn load_schema(
325 path: &Path,
326 namespace: &str,
327 namespace_validator: &jsonschema::Validator,
328 ) -> ValidationResult<Arc<NamespaceSchema>> {
329 let file = fs::File::open(path)?;
330 let schema_data: Value = serde_json::from_reader(file)?;
331
332 Self::validate_with_namespace_schema(&schema_data, path, namespace_validator)?;
333 Self::parse_schema(schema_data, namespace, path)
334 }
335
336 fn validate_with_namespace_schema(
338 schema_data: &Value,
339 path: &Path,
340 namespace_validator: &jsonschema::Validator,
341 ) -> ValidationResult<()> {
342 let output = namespace_validator.evaluate(schema_data);
343
344 if output.flag().valid {
345 Ok(())
346 } else {
347 let errors: Vec<String> = output
348 .iter_errors()
349 .map(|e| format!("Error: {}", e.error))
350 .collect();
351
352 Err(ValidationError::SchemaError {
353 file: path.to_path_buf(),
354 message: format!("Schema validation failed:\n{}", errors.join("\n")),
355 })
356 }
357 }
358
359 fn validate_default_type(
361 property_name: &str,
362 property_schema: &Value,
363 default_value: &Value,
364 path: &Path,
365 ) -> ValidationResult<()> {
366 jsonschema::validate(property_schema, default_value).map_err(|e| {
368 ValidationError::SchemaError {
369 file: path.to_path_buf(),
370 message: format!(
371 "Property '{}': default value does not match schema: {}",
372 property_name, e
373 ),
374 }
375 })?;
376
377 Ok(())
378 }
379
380 fn inject_object_constraints(schema: &mut Value) {
396 if let Some(obj) = schema.as_object_mut()
397 && let Some(props) = obj.get("properties").and_then(|p| p.as_object())
398 {
399 let required: Vec<Value> = props
400 .iter()
401 .filter(|(_, v)| !v.get("optional").and_then(|o| o.as_bool()).unwrap_or(false))
402 .map(|(k, _)| Value::String(k.clone()))
403 .collect();
404 obj.insert("required".to_string(), Value::Array(required));
405 obj.insert("additionalProperties".to_string(), json!(false));
406 }
407 }
408
409 fn parse_schema(
411 mut schema: Value,
412 namespace: &str,
413 path: &Path,
414 ) -> ValidationResult<Arc<NamespaceSchema>> {
415 if let Some(obj) = schema.as_object_mut() {
417 obj.insert("additionalProperties".to_string(), json!(false));
418 }
419
420 if let Some(properties) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) {
423 for prop_value in properties.values_mut() {
424 let prop_type = prop_value
425 .get("type")
426 .and_then(|t| t.as_str())
427 .unwrap_or("");
428
429 if prop_type == "object" {
430 Self::inject_object_constraints(prop_value);
431 } else if prop_type == "array"
432 && let Some(items) = prop_value.get_mut("items")
433 {
434 let items_type = items.get("type").and_then(|t| t.as_str()).unwrap_or("");
435 if items_type == "object" {
436 Self::inject_object_constraints(items);
437 }
438 }
439 }
440 }
441
442 let validator =
444 jsonschema::validator_for(&schema).map_err(|e| ValidationError::SchemaError {
445 file: path.to_path_buf(),
446 message: format!("Failed to compile validator: {}", e),
447 })?;
448
449 let mut options = HashMap::new();
451 if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
452 for (prop_name, prop_value) in properties {
453 if let (Some(prop_type), Some(default_value)) = (
454 prop_value.get("type").and_then(|t| t.as_str()),
455 prop_value.get("default"),
456 ) {
457 Self::validate_default_type(prop_name, prop_value, default_value, path)?;
458 options.insert(
459 prop_name.clone(),
460 OptionMetadata {
461 option_type: prop_type.to_string(),
462 property_schema: prop_value.clone(),
463 default: default_value.clone(),
464 },
465 );
466 }
467 }
468 }
469
470 Ok(Arc::new(NamespaceSchema {
471 namespace: namespace.to_string(),
472 options,
473 validator,
474 }))
475 }
476
477 pub fn get(&self, namespace: &str) -> Option<&Arc<NamespaceSchema>> {
479 self.schemas.get(namespace)
480 }
481
482 pub fn schemas(&self) -> &HashMap<String, Arc<NamespaceSchema>> {
484 &self.schemas
485 }
486
487 pub fn load_values_json(
493 &self,
494 values_dir: &Path,
495 ) -> ValidationResult<(ValuesByNamespace, HashMap<String, String>)> {
496 let mut all_values = HashMap::new();
497 let mut generated_at_by_namespace: HashMap<String, String> = HashMap::new();
498
499 for namespace in self.schemas.keys() {
500 let values_file = values_dir.join(namespace).join(VALUES_FILE_NAME);
501
502 if !values_file.exists() {
503 continue;
504 }
505
506 let parsed: Value = serde_json::from_reader(fs::File::open(&values_file)?)?;
507
508 if let Some(ts) = parsed.get("generated_at").and_then(|v| v.as_str()) {
510 generated_at_by_namespace.insert(namespace.clone(), ts.to_string());
511 }
512
513 let values = parsed
514 .get("options")
515 .ok_or_else(|| ValidationError::ValueError {
516 namespace: namespace.clone(),
517 errors: "values.json must have an 'options' key".to_string(),
518 })?;
519
520 self.validate_values(namespace, values)?;
521
522 if let Value::Object(obj) = values.clone() {
523 let ns_values: HashMap<String, Value> = obj.into_iter().collect();
524 all_values.insert(namespace.clone(), ns_values);
525 }
526 }
527
528 Ok((all_values, generated_at_by_namespace))
529 }
530}
531
532impl Default for SchemaRegistry {
533 fn default() -> Self {
534 Self::new()
535 }
536}
537
538pub struct ValuesWatcher {
553 stop_signal: Arc<AtomicBool>,
554 thread: Option<JoinHandle<()>>,
555}
556
557impl ValuesWatcher {
558 pub fn new(
560 values_path: &Path,
561 registry: Arc<SchemaRegistry>,
562 values: Arc<RwLock<ValuesByNamespace>>,
563 ) -> ValidationResult<Self> {
564 if !should_suppress_missing_dir_errors() && fs::metadata(values_path).is_err() {
566 eprintln!("Values directory does not exist: {}", values_path.display());
567 }
568
569 let stop_signal = Arc::new(AtomicBool::new(false));
570
571 let thread_signal = Arc::clone(&stop_signal);
572 let thread_path = values_path.to_path_buf();
573 let thread_registry = Arc::clone(®istry);
574 let thread_values = Arc::clone(&values);
575 let thread = thread::Builder::new()
576 .name("sentry-options-watcher".into())
577 .spawn(move || {
578 let result = panic::catch_unwind(AssertUnwindSafe(|| {
579 Self::run(thread_signal, thread_path, thread_registry, thread_values);
580 }));
581 if let Err(e) = result {
582 eprintln!("Watcher thread panicked with: {:?}", e);
583 }
584 })?;
585
586 Ok(Self {
587 stop_signal,
588 thread: Some(thread),
589 })
590 }
591
592 fn run(
597 stop_signal: Arc<AtomicBool>,
598 values_path: PathBuf,
599 registry: Arc<SchemaRegistry>,
600 values: Arc<RwLock<ValuesByNamespace>>,
601 ) {
602 let mut last_mtime = Self::get_mtime(&values_path);
603
604 while !stop_signal.load(Ordering::Relaxed) {
605 if let Some(current_mtime) = Self::get_mtime(&values_path)
607 && Some(current_mtime) != last_mtime
608 {
609 Self::reload_values(&values_path, ®istry, &values);
610 last_mtime = Some(current_mtime);
611 }
612
613 thread::sleep(Duration::from_secs(POLLING_DELAY));
614 }
615 }
616
617 fn get_mtime(values_dir: &Path) -> Option<std::time::SystemTime> {
620 let mut latest_mtime = None;
621
622 let entries = match fs::read_dir(values_dir) {
623 Ok(e) => e,
624 Err(e) => {
625 if !should_suppress_missing_dir_errors() {
626 eprintln!("Failed to read directory {}: {}", values_dir.display(), e);
627 }
628 return None;
629 }
630 };
631
632 for entry in entries.flatten() {
633 if !entry
635 .file_type()
636 .map(|file_type| file_type.is_dir())
637 .unwrap_or(false)
638 {
639 continue;
640 }
641
642 let values_file = entry.path().join(VALUES_FILE_NAME);
643 if let Ok(metadata) = fs::metadata(&values_file)
644 && let Ok(mtime) = metadata.modified()
645 && latest_mtime.is_none_or(|latest| mtime > latest)
646 {
647 latest_mtime = Some(mtime);
648 }
649 }
650
651 latest_mtime
652 }
653
654 fn reload_values(
657 values_path: &Path,
658 registry: &SchemaRegistry,
659 values: &Arc<RwLock<ValuesByNamespace>>,
660 ) {
661 let reload_start = Instant::now();
662
663 match registry.load_values_json(values_path) {
664 Ok((new_values, generated_at_by_namespace)) => {
665 let namespaces: Vec<String> = new_values.keys().cloned().collect();
666 Self::update_values(values, new_values);
667
668 let reload_duration = reload_start.elapsed();
669 Self::emit_reload_spans(&namespaces, reload_duration, &generated_at_by_namespace);
670 }
671 Err(e) => {
672 eprintln!(
673 "Failed to reload values from {}: {}",
674 values_path.display(),
675 e
676 );
677 }
678 }
679 }
680
681 fn emit_reload_spans(
684 namespaces: &[String],
685 reload_duration: Duration,
686 generated_at_by_namespace: &HashMap<String, String>,
687 ) {
688 let hub = get_sentry_hub();
689 let applied_at = Utc::now();
690 let reload_duration_ms = reload_duration.as_secs_f64() * 1000.0;
691
692 for namespace in namespaces {
693 let mut tx_ctx = sentry::TransactionContext::new(namespace, "sentry_options.reload");
694 tx_ctx.set_sampled(true);
695
696 let transaction = hub.start_transaction(tx_ctx);
697 transaction.set_data("reload_duration_ms", reload_duration_ms.into());
698 transaction.set_data("applied_at", applied_at.to_rfc3339().into());
699
700 if let Some(ts) = generated_at_by_namespace.get(namespace) {
701 transaction.set_data("generated_at", ts.as_str().into());
702
703 if let Ok(generated_time) = DateTime::parse_from_rfc3339(ts) {
704 let delay_secs = (applied_at - generated_time.with_timezone(&Utc))
705 .num_milliseconds() as f64
706 / 1000.0;
707 transaction.set_data("propagation_delay_secs", delay_secs.into());
708 }
709 }
710
711 transaction.finish();
712 }
713 }
714
715 fn update_values(values: &Arc<RwLock<ValuesByNamespace>>, new_values: ValuesByNamespace) {
717 let mut guard = values.write().unwrap();
719 *guard = new_values;
720 }
721
722 pub fn stop(&mut self) {
725 self.stop_signal.store(true, Ordering::Relaxed);
726 if let Some(thread) = self.thread.take() {
727 let _ = thread.join();
728 }
729 }
730
731 pub fn is_alive(&self) -> bool {
733 self.thread.as_ref().is_some_and(|t| !t.is_finished())
734 }
735}
736
737impl Drop for ValuesWatcher {
738 fn drop(&mut self) {
739 self.stop();
740 }
741}
742
743#[cfg(test)]
744mod tests {
745 use super::*;
746 use tempfile::TempDir;
747
748 fn create_test_schema(temp_dir: &TempDir, namespace: &str, schema_json: &str) -> PathBuf {
749 let schema_dir = temp_dir.path().join(namespace);
750 fs::create_dir_all(&schema_dir).unwrap();
751 let schema_file = schema_dir.join("schema.json");
752 fs::write(&schema_file, schema_json).unwrap();
753 schema_file
754 }
755
756 fn create_test_schema_with_values(
757 temp_dir: &TempDir,
758 namespace: &str,
759 schema_json: &str,
760 values_json: &str,
761 ) -> (PathBuf, PathBuf) {
762 let schemas_dir = temp_dir.path().join("schemas");
763 let values_dir = temp_dir.path().join("values");
764
765 let schema_dir = schemas_dir.join(namespace);
766 fs::create_dir_all(&schema_dir).unwrap();
767 let schema_file = schema_dir.join("schema.json");
768 fs::write(&schema_file, schema_json).unwrap();
769
770 let ns_values_dir = values_dir.join(namespace);
771 fs::create_dir_all(&ns_values_dir).unwrap();
772 let values_file = ns_values_dir.join("values.json");
773 fs::write(&values_file, values_json).unwrap();
774
775 (schemas_dir, values_dir)
776 }
777
778 #[test]
779 fn test_validate_k8s_name_component_valid() {
780 assert!(validate_k8s_name_component("relay", "namespace").is_ok());
781 assert!(validate_k8s_name_component("my-service", "namespace").is_ok());
782 assert!(validate_k8s_name_component("my.service", "namespace").is_ok());
783 assert!(validate_k8s_name_component("a1-b2.c3", "namespace").is_ok());
784 }
785
786 #[test]
787 fn test_validate_k8s_name_component_rejects_uppercase() {
788 let result = validate_k8s_name_component("MyService", "namespace");
789 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
790 assert!(result.unwrap_err().to_string().contains("'M' not allowed"));
791 }
792
793 #[test]
794 fn test_validate_k8s_name_component_rejects_underscore() {
795 let result = validate_k8s_name_component("my_service", "target");
796 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
797 assert!(result.unwrap_err().to_string().contains("'_' not allowed"));
798 }
799
800 #[test]
801 fn test_validate_k8s_name_component_rejects_leading_hyphen() {
802 let result = validate_k8s_name_component("-service", "namespace");
803 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
804 assert!(
805 result
806 .unwrap_err()
807 .to_string()
808 .contains("start and end with alphanumeric")
809 );
810 }
811
812 #[test]
813 fn test_validate_k8s_name_component_rejects_trailing_dot() {
814 let result = validate_k8s_name_component("service.", "namespace");
815 assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
816 assert!(
817 result
818 .unwrap_err()
819 .to_string()
820 .contains("start and end with alphanumeric")
821 );
822 }
823
824 #[test]
825 fn test_load_schema_valid() {
826 let temp_dir = TempDir::new().unwrap();
827 create_test_schema(
828 &temp_dir,
829 "test",
830 r#"{
831 "version": "1.0",
832 "type": "object",
833 "properties": {
834 "test-key": {
835 "type": "string",
836 "default": "test",
837 "description": "Test option"
838 }
839 }
840 }"#,
841 );
842
843 SchemaRegistry::from_directory(temp_dir.path()).unwrap();
844 }
845
846 #[test]
847 fn test_load_schema_missing_version() {
848 let temp_dir = TempDir::new().unwrap();
849 create_test_schema(
850 &temp_dir,
851 "test",
852 r#"{
853 "type": "object",
854 "properties": {}
855 }"#,
856 );
857
858 let result = SchemaRegistry::from_directory(temp_dir.path());
859 assert!(result.is_err());
860 match result {
861 Err(ValidationError::SchemaError { message, .. }) => {
862 assert!(message.contains(
863 "Schema validation failed:
864Error: \"version\" is a required property"
865 ));
866 }
867 _ => panic!("Expected SchemaError for missing version"),
868 }
869 }
870
871 #[test]
872 fn test_unknown_namespace() {
873 let temp_dir = TempDir::new().unwrap();
874 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
875
876 let result = registry.validate_values("unknown", &json!({}));
877 assert!(matches!(result, Err(ValidationError::UnknownNamespace(..))));
878 }
879
880 #[test]
881 fn test_multiple_namespaces() {
882 let temp_dir = TempDir::new().unwrap();
883 create_test_schema(
884 &temp_dir,
885 "ns1",
886 r#"{
887 "version": "1.0",
888 "type": "object",
889 "properties": {
890 "opt1": {
891 "type": "string",
892 "default": "default1",
893 "description": "First option"
894 }
895 }
896 }"#,
897 );
898 create_test_schema(
899 &temp_dir,
900 "ns2",
901 r#"{
902 "version": "2.0",
903 "type": "object",
904 "properties": {
905 "opt2": {
906 "type": "integer",
907 "default": 42,
908 "description": "Second option"
909 }
910 }
911 }"#,
912 );
913
914 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
915 assert!(registry.schemas.contains_key("ns1"));
916 assert!(registry.schemas.contains_key("ns2"));
917 }
918
919 #[test]
920 fn test_invalid_default_type() {
921 let temp_dir = TempDir::new().unwrap();
922 create_test_schema(
923 &temp_dir,
924 "test",
925 r#"{
926 "version": "1.0",
927 "type": "object",
928 "properties": {
929 "bad-default": {
930 "type": "integer",
931 "default": "not-a-number",
932 "description": "A bad default value"
933 }
934 }
935 }"#,
936 );
937
938 let result = SchemaRegistry::from_directory(temp_dir.path());
939 assert!(result.is_err());
940 match result {
941 Err(ValidationError::SchemaError { message, .. }) => {
942 assert!(
943 message.contains("Property 'bad-default': default value does not match schema")
944 );
945 assert!(message.contains("\"not-a-number\" is not of type \"integer\""));
946 }
947 _ => panic!("Expected SchemaError for invalid default type"),
948 }
949 }
950
951 #[test]
952 fn test_extra_properties() {
953 let temp_dir = TempDir::new().unwrap();
954 create_test_schema(
955 &temp_dir,
956 "test",
957 r#"{
958 "version": "1.0",
959 "type": "object",
960 "properties": {
961 "bad-property": {
962 "type": "integer",
963 "default": 0,
964 "description": "Test property",
965 "extra": "property"
966 }
967 }
968 }"#,
969 );
970
971 let result = SchemaRegistry::from_directory(temp_dir.path());
972 assert!(result.is_err());
973 match result {
974 Err(ValidationError::SchemaError { message, .. }) => {
975 assert!(
976 message
977 .contains("Additional properties are not allowed ('extra' was unexpected)")
978 );
979 }
980 _ => panic!("Expected SchemaError for extra properties"),
981 }
982 }
983
984 #[test]
985 fn test_missing_description() {
986 let temp_dir = TempDir::new().unwrap();
987 create_test_schema(
988 &temp_dir,
989 "test",
990 r#"{
991 "version": "1.0",
992 "type": "object",
993 "properties": {
994 "missing-desc": {
995 "type": "string",
996 "default": "test"
997 }
998 }
999 }"#,
1000 );
1001
1002 let result = SchemaRegistry::from_directory(temp_dir.path());
1003 assert!(result.is_err());
1004 match result {
1005 Err(ValidationError::SchemaError { message, .. }) => {
1006 assert!(message.contains("\"description\" is a required property"));
1007 }
1008 _ => panic!("Expected SchemaError for missing description"),
1009 }
1010 }
1011
1012 #[test]
1013 fn test_invalid_directory_structure() {
1014 let temp_dir = TempDir::new().unwrap();
1015 let schema_dir = temp_dir.path().join("missing-schema");
1017 fs::create_dir_all(&schema_dir).unwrap();
1018
1019 let result = SchemaRegistry::from_directory(temp_dir.path());
1020 assert!(result.is_err());
1021 match result {
1022 Err(ValidationError::FileRead(..)) => {
1023 }
1025 _ => panic!("Expected FileRead error for missing schema.json"),
1026 }
1027 }
1028
1029 #[test]
1030 fn test_get_default() {
1031 let temp_dir = TempDir::new().unwrap();
1032 create_test_schema(
1033 &temp_dir,
1034 "test",
1035 r#"{
1036 "version": "1.0",
1037 "type": "object",
1038 "properties": {
1039 "string_opt": {
1040 "type": "string",
1041 "default": "hello",
1042 "description": "A string option"
1043 },
1044 "int_opt": {
1045 "type": "integer",
1046 "default": 42,
1047 "description": "An integer option"
1048 }
1049 }
1050 }"#,
1051 );
1052
1053 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1054 let schema = registry.get("test").unwrap();
1055
1056 assert_eq!(schema.get_default("string_opt"), Some(&json!("hello")));
1057 assert_eq!(schema.get_default("int_opt"), Some(&json!(42)));
1058 assert_eq!(schema.get_default("unknown"), None);
1059 }
1060
1061 #[test]
1062 fn test_validate_values_valid() {
1063 let temp_dir = TempDir::new().unwrap();
1064 create_test_schema(
1065 &temp_dir,
1066 "test",
1067 r#"{
1068 "version": "1.0",
1069 "type": "object",
1070 "properties": {
1071 "enabled": {
1072 "type": "boolean",
1073 "default": false,
1074 "description": "Enable feature"
1075 }
1076 }
1077 }"#,
1078 );
1079
1080 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1081 let result = registry.validate_values("test", &json!({"enabled": true}));
1082 assert!(result.is_ok());
1083 }
1084
1085 #[test]
1086 fn test_validate_values_invalid_type() {
1087 let temp_dir = TempDir::new().unwrap();
1088 create_test_schema(
1089 &temp_dir,
1090 "test",
1091 r#"{
1092 "version": "1.0",
1093 "type": "object",
1094 "properties": {
1095 "count": {
1096 "type": "integer",
1097 "default": 0,
1098 "description": "Count"
1099 }
1100 }
1101 }"#,
1102 );
1103
1104 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1105 let result = registry.validate_values("test", &json!({"count": "not a number"}));
1106 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1107 }
1108
1109 #[test]
1110 fn test_validate_values_unknown_option() {
1111 let temp_dir = TempDir::new().unwrap();
1112 create_test_schema(
1113 &temp_dir,
1114 "test",
1115 r#"{
1116 "version": "1.0",
1117 "type": "object",
1118 "properties": {
1119 "known_option": {
1120 "type": "string",
1121 "default": "default",
1122 "description": "A known option"
1123 }
1124 }
1125 }"#,
1126 );
1127
1128 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1129
1130 let result = registry.validate_values("test", &json!({"known_option": "value"}));
1132 assert!(result.is_ok());
1133
1134 let result = registry.validate_values("test", &json!({"unknown_option": "value"}));
1136 assert!(result.is_err());
1137 match result {
1138 Err(ValidationError::ValueError { errors, .. }) => {
1139 assert!(errors.contains("Additional properties are not allowed"));
1140 }
1141 _ => panic!("Expected ValueError for unknown option"),
1142 }
1143 }
1144
1145 #[test]
1146 fn test_load_values_json_valid() {
1147 let temp_dir = TempDir::new().unwrap();
1148 let schemas_dir = temp_dir.path().join("schemas");
1149 let values_dir = temp_dir.path().join("values");
1150
1151 let schema_dir = schemas_dir.join("test");
1152 fs::create_dir_all(&schema_dir).unwrap();
1153 fs::write(
1154 schema_dir.join("schema.json"),
1155 r#"{
1156 "version": "1.0",
1157 "type": "object",
1158 "properties": {
1159 "enabled": {
1160 "type": "boolean",
1161 "default": false,
1162 "description": "Enable feature"
1163 },
1164 "name": {
1165 "type": "string",
1166 "default": "default",
1167 "description": "Name"
1168 },
1169 "count": {
1170 "type": "integer",
1171 "default": 0,
1172 "description": "Count"
1173 },
1174 "rate": {
1175 "type": "number",
1176 "default": 0.0,
1177 "description": "Rate"
1178 }
1179 }
1180 }"#,
1181 )
1182 .unwrap();
1183
1184 let test_values_dir = values_dir.join("test");
1185 fs::create_dir_all(&test_values_dir).unwrap();
1186 fs::write(
1187 test_values_dir.join("values.json"),
1188 r#"{
1189 "options": {
1190 "enabled": true,
1191 "name": "test-name",
1192 "count": 42,
1193 "rate": 0.75
1194 }
1195 }"#,
1196 )
1197 .unwrap();
1198
1199 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1200 let (values, generated_at_by_namespace) = registry.load_values_json(&values_dir).unwrap();
1201
1202 assert_eq!(values.len(), 1);
1203 assert_eq!(values["test"]["enabled"], json!(true));
1204 assert_eq!(values["test"]["name"], json!("test-name"));
1205 assert_eq!(values["test"]["count"], json!(42));
1206 assert_eq!(values["test"]["rate"], json!(0.75));
1207 assert!(generated_at_by_namespace.is_empty());
1208 }
1209
1210 #[test]
1211 fn test_load_values_json_nonexistent_dir() {
1212 let temp_dir = TempDir::new().unwrap();
1213 create_test_schema(
1214 &temp_dir,
1215 "test",
1216 r#"{"version": "1.0", "type": "object", "properties": {}}"#,
1217 );
1218
1219 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1220 let (values, generated_at_by_namespace) = registry
1221 .load_values_json(&temp_dir.path().join("nonexistent"))
1222 .unwrap();
1223
1224 assert!(values.is_empty());
1226 assert!(generated_at_by_namespace.is_empty());
1227 }
1228
1229 #[test]
1230 fn test_load_values_json_skips_missing_values_file() {
1231 let temp_dir = TempDir::new().unwrap();
1232 let schemas_dir = temp_dir.path().join("schemas");
1233 let values_dir = temp_dir.path().join("values");
1234
1235 let schema_dir1 = schemas_dir.join("with-values");
1237 fs::create_dir_all(&schema_dir1).unwrap();
1238 fs::write(
1239 schema_dir1.join("schema.json"),
1240 r#"{
1241 "version": "1.0",
1242 "type": "object",
1243 "properties": {
1244 "opt": {"type": "string", "default": "x", "description": "Opt"}
1245 }
1246 }"#,
1247 )
1248 .unwrap();
1249
1250 let schema_dir2 = schemas_dir.join("without-values");
1251 fs::create_dir_all(&schema_dir2).unwrap();
1252 fs::write(
1253 schema_dir2.join("schema.json"),
1254 r#"{
1255 "version": "1.0",
1256 "type": "object",
1257 "properties": {
1258 "opt": {"type": "string", "default": "x", "description": "Opt"}
1259 }
1260 }"#,
1261 )
1262 .unwrap();
1263
1264 let with_values_dir = values_dir.join("with-values");
1266 fs::create_dir_all(&with_values_dir).unwrap();
1267 fs::write(
1268 with_values_dir.join("values.json"),
1269 r#"{"options": {"opt": "y"}}"#,
1270 )
1271 .unwrap();
1272
1273 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1274 let (values, _) = registry.load_values_json(&values_dir).unwrap();
1275
1276 assert_eq!(values.len(), 1);
1277 assert!(values.contains_key("with-values"));
1278 assert!(!values.contains_key("without-values"));
1279 }
1280
1281 #[test]
1282 fn test_load_values_json_extracts_generated_at() {
1283 let temp_dir = TempDir::new().unwrap();
1284 let schemas_dir = temp_dir.path().join("schemas");
1285 let values_dir = temp_dir.path().join("values");
1286
1287 let schema_dir = schemas_dir.join("test");
1288 fs::create_dir_all(&schema_dir).unwrap();
1289 fs::write(
1290 schema_dir.join("schema.json"),
1291 r#"{
1292 "version": "1.0",
1293 "type": "object",
1294 "properties": {
1295 "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1296 }
1297 }"#,
1298 )
1299 .unwrap();
1300
1301 let test_values_dir = values_dir.join("test");
1302 fs::create_dir_all(&test_values_dir).unwrap();
1303 fs::write(
1304 test_values_dir.join("values.json"),
1305 r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T18:30:00.123456+00:00"}"#,
1306 )
1307 .unwrap();
1308
1309 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1310 let (values, generated_at_by_namespace) = registry.load_values_json(&values_dir).unwrap();
1311
1312 assert_eq!(values["test"]["enabled"], json!(true));
1313 assert_eq!(
1314 generated_at_by_namespace.get("test"),
1315 Some(&"2024-01-21T18:30:00.123456+00:00".to_string())
1316 );
1317 }
1318
1319 #[test]
1320 fn test_load_values_json_rejects_wrong_type() {
1321 let temp_dir = TempDir::new().unwrap();
1322 let schemas_dir = temp_dir.path().join("schemas");
1323 let values_dir = temp_dir.path().join("values");
1324
1325 let schema_dir = schemas_dir.join("test");
1326 fs::create_dir_all(&schema_dir).unwrap();
1327 fs::write(
1328 schema_dir.join("schema.json"),
1329 r#"{
1330 "version": "1.0",
1331 "type": "object",
1332 "properties": {
1333 "count": {"type": "integer", "default": 0, "description": "Count"}
1334 }
1335 }"#,
1336 )
1337 .unwrap();
1338
1339 let test_values_dir = values_dir.join("test");
1340 fs::create_dir_all(&test_values_dir).unwrap();
1341 fs::write(
1342 test_values_dir.join("values.json"),
1343 r#"{"options": {"count": "not-a-number"}}"#,
1344 )
1345 .unwrap();
1346
1347 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1348 let result = registry.load_values_json(&values_dir);
1349
1350 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1351 }
1352
1353 mod watcher_tests {
1354 use super::*;
1355 use std::thread;
1356
1357 fn setup_watcher_test() -> (TempDir, PathBuf, PathBuf) {
1359 let temp_dir = TempDir::new().unwrap();
1360 let schemas_dir = temp_dir.path().join("schemas");
1361 let values_dir = temp_dir.path().join("values");
1362
1363 let ns1_schema = schemas_dir.join("ns1");
1364 fs::create_dir_all(&ns1_schema).unwrap();
1365 fs::write(
1366 ns1_schema.join("schema.json"),
1367 r#"{
1368 "version": "1.0",
1369 "type": "object",
1370 "properties": {
1371 "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1372 }
1373 }"#,
1374 )
1375 .unwrap();
1376
1377 let ns1_values = values_dir.join("ns1");
1378 fs::create_dir_all(&ns1_values).unwrap();
1379 fs::write(
1380 ns1_values.join("values.json"),
1381 r#"{"options": {"enabled": true}}"#,
1382 )
1383 .unwrap();
1384
1385 let ns2_schema = schemas_dir.join("ns2");
1386 fs::create_dir_all(&ns2_schema).unwrap();
1387 fs::write(
1388 ns2_schema.join("schema.json"),
1389 r#"{
1390 "version": "1.0",
1391 "type": "object",
1392 "properties": {
1393 "count": {"type": "integer", "default": 0, "description": "Count"}
1394 }
1395 }"#,
1396 )
1397 .unwrap();
1398
1399 let ns2_values = values_dir.join("ns2");
1400 fs::create_dir_all(&ns2_values).unwrap();
1401 fs::write(
1402 ns2_values.join("values.json"),
1403 r#"{"options": {"count": 42}}"#,
1404 )
1405 .unwrap();
1406
1407 (temp_dir, schemas_dir, values_dir)
1408 }
1409
1410 #[test]
1411 fn test_get_mtime_returns_most_recent() {
1412 let (_temp, _schemas, values_dir) = setup_watcher_test();
1413
1414 let mtime1 = ValuesWatcher::get_mtime(&values_dir);
1416 assert!(mtime1.is_some());
1417
1418 thread::sleep(std::time::Duration::from_millis(10));
1420 fs::write(
1421 values_dir.join("ns1").join("values.json"),
1422 r#"{"options": {"enabled": false}}"#,
1423 )
1424 .unwrap();
1425
1426 let mtime2 = ValuesWatcher::get_mtime(&values_dir);
1428 assert!(mtime2.is_some());
1429 assert!(mtime2 > mtime1);
1430 }
1431
1432 #[test]
1433 fn test_get_mtime_with_missing_directory() {
1434 let temp = TempDir::new().unwrap();
1435 let nonexistent = temp.path().join("nonexistent");
1436
1437 let mtime = ValuesWatcher::get_mtime(&nonexistent);
1438 assert!(mtime.is_none());
1439 }
1440
1441 #[test]
1442 fn test_reload_values_updates_map() {
1443 let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1444
1445 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1446 let (initial_values, _) = registry.load_values_json(&values_dir).unwrap();
1447 let values = Arc::new(RwLock::new(initial_values));
1448
1449 {
1451 let guard = values.read().unwrap();
1452 assert_eq!(guard["ns1"]["enabled"], json!(true));
1453 assert_eq!(guard["ns2"]["count"], json!(42));
1454 }
1455
1456 fs::write(
1458 values_dir.join("ns1").join("values.json"),
1459 r#"{"options": {"enabled": false}}"#,
1460 )
1461 .unwrap();
1462 fs::write(
1463 values_dir.join("ns2").join("values.json"),
1464 r#"{"options": {"count": 100}}"#,
1465 )
1466 .unwrap();
1467
1468 ValuesWatcher::reload_values(&values_dir, ®istry, &values);
1470
1471 {
1473 let guard = values.read().unwrap();
1474 assert_eq!(guard["ns1"]["enabled"], json!(false));
1475 assert_eq!(guard["ns2"]["count"], json!(100));
1476 }
1477 }
1478
1479 #[test]
1480 fn test_old_values_persist_with_invalid_data() {
1481 let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1482
1483 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1484 let (initial_values, _) = registry.load_values_json(&values_dir).unwrap();
1485 let values = Arc::new(RwLock::new(initial_values));
1486
1487 let initial_enabled = {
1488 let guard = values.read().unwrap();
1489 guard["ns1"]["enabled"].clone()
1490 };
1491
1492 fs::write(
1494 values_dir.join("ns1").join("values.json"),
1495 r#"{"options": {"enabled": "not-a-boolean"}}"#,
1496 )
1497 .unwrap();
1498
1499 ValuesWatcher::reload_values(&values_dir, ®istry, &values);
1500
1501 {
1503 let guard = values.read().unwrap();
1504 assert_eq!(guard["ns1"]["enabled"], initial_enabled);
1505 }
1506 }
1507
1508 #[test]
1509 fn test_watcher_creation_and_termination() {
1510 let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1511
1512 let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1513 let (initial_values, _) = registry.load_values_json(&values_dir).unwrap();
1514 let values = Arc::new(RwLock::new(initial_values));
1515
1516 let mut watcher =
1517 ValuesWatcher::new(&values_dir, Arc::clone(®istry), Arc::clone(&values))
1518 .expect("Failed to create watcher");
1519
1520 assert!(watcher.is_alive());
1521 watcher.stop();
1522 assert!(!watcher.is_alive());
1523 }
1524 }
1525 mod array_tests {
1526 use super::*;
1527
1528 #[test]
1529 fn test_basic_schema_validation() {
1530 let temp_dir = TempDir::new().unwrap();
1531 for (a_type, default) in [
1532 ("boolean", ""), ("boolean", "true"),
1534 ("integer", "1"),
1535 ("number", "1.2"),
1536 ("string", "\"wow\""),
1537 ] {
1538 create_test_schema(
1539 &temp_dir,
1540 "test",
1541 &format!(
1542 r#"{{
1543 "version": "1.0",
1544 "type": "object",
1545 "properties": {{
1546 "array-key": {{
1547 "type": "array",
1548 "items": {{"type": "{}"}},
1549 "default": [{}],
1550 "description": "Array option"
1551 }}
1552 }}
1553 }}"#,
1554 a_type, default
1555 ),
1556 );
1557
1558 SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1559 }
1560 }
1561
1562 #[test]
1563 fn test_missing_items_object_rejection() {
1564 let temp_dir = TempDir::new().unwrap();
1565 create_test_schema(
1566 &temp_dir,
1567 "test",
1568 r#"{
1569 "version": "1.0",
1570 "type": "object",
1571 "properties": {
1572 "array-key": {
1573 "type": "array",
1574 "default": [1,2,3],
1575 "description": "Array option"
1576 }
1577 }
1578 }"#,
1579 );
1580
1581 let result = SchemaRegistry::from_directory(temp_dir.path());
1582 assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
1583 }
1584
1585 #[test]
1586 fn test_malformed_items_rejection() {
1587 let temp_dir = TempDir::new().unwrap();
1588 create_test_schema(
1589 &temp_dir,
1590 "test",
1591 r#"{
1592 "version": "1.0",
1593 "type": "object",
1594 "properties": {
1595 "array-key": {
1596 "type": "array",
1597 "items": {"type": ""},
1598 "default": [1,2,3],
1599 "description": "Array option"
1600 }
1601 }
1602 }"#,
1603 );
1604
1605 let result = SchemaRegistry::from_directory(temp_dir.path());
1606 assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
1607 }
1608
1609 #[test]
1610 fn test_schema_default_type_mismatch_rejection() {
1611 let temp_dir = TempDir::new().unwrap();
1612 create_test_schema(
1614 &temp_dir,
1615 "test",
1616 r#"{
1617 "version": "1.0",
1618 "type": "object",
1619 "properties": {
1620 "array-key": {
1621 "type": "array",
1622 "items": {"type": "integer"},
1623 "default": [1,2,3.3],
1624 "description": "Array option"
1625 }
1626 }
1627 }"#,
1628 );
1629
1630 let result = SchemaRegistry::from_directory(temp_dir.path());
1631 assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
1632 }
1633
1634 #[test]
1635 fn test_schema_default_heterogeneous_rejection() {
1636 let temp_dir = TempDir::new().unwrap();
1637 create_test_schema(
1638 &temp_dir,
1639 "test",
1640 r#"{
1641 "version": "1.0",
1642 "type": "object",
1643 "properties": {
1644 "array-key": {
1645 "type": "array",
1646 "items": {"type": "integer"},
1647 "default": [1,2,"uh oh!"],
1648 "description": "Array option"
1649 }
1650 }
1651 }"#,
1652 );
1653
1654 let result = SchemaRegistry::from_directory(temp_dir.path());
1655 assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
1656 }
1657
1658 #[test]
1659 fn test_load_values_valid() {
1660 let temp_dir = TempDir::new().unwrap();
1661 let (schemas_dir, values_dir) = create_test_schema_with_values(
1662 &temp_dir,
1663 "test",
1664 r#"{
1665 "version": "1.0",
1666 "type": "object",
1667 "properties": {
1668 "array-key": {
1669 "type": "array",
1670 "items": {"type": "integer"},
1671 "default": [1,2,3],
1672 "description": "Array option"
1673 }
1674 }
1675 }"#,
1676 r#"{
1677 "options": {
1678 "array-key": [4,5,6]
1679 }
1680 }"#,
1681 );
1682
1683 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1684 let (values, generated_at_by_namespace) =
1685 registry.load_values_json(&values_dir).unwrap();
1686
1687 assert_eq!(values.len(), 1);
1688 assert_eq!(values["test"]["array-key"], json!([4, 5, 6]));
1689 assert!(generated_at_by_namespace.is_empty());
1690 }
1691
1692 #[test]
1693 fn test_reject_values_not_an_array() {
1694 let temp_dir = TempDir::new().unwrap();
1695 let (schemas_dir, values_dir) = create_test_schema_with_values(
1696 &temp_dir,
1697 "test",
1698 r#"{
1699 "version": "1.0",
1700 "type": "object",
1701 "properties": {
1702 "array-key": {
1703 "type": "array",
1704 "items": {"type": "integer"},
1705 "default": [1,2,3],
1706 "description": "Array option"
1707 }
1708 }
1709 }"#,
1710 r#"{
1712 "options": {
1713 "array-key": "[]"
1714 }
1715 }"#,
1716 );
1717
1718 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1719 let result = registry.load_values_json(&values_dir);
1720
1721 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1722 }
1723
1724 #[test]
1725 fn test_reject_values_mismatch() {
1726 let temp_dir = TempDir::new().unwrap();
1727 let (schemas_dir, values_dir) = create_test_schema_with_values(
1728 &temp_dir,
1729 "test",
1730 r#"{
1731 "version": "1.0",
1732 "type": "object",
1733 "properties": {
1734 "array-key": {
1735 "type": "array",
1736 "items": {"type": "integer"},
1737 "default": [1,2,3],
1738 "description": "Array option"
1739 }
1740 }
1741 }"#,
1742 r#"{
1743 "options": {
1744 "array-key": ["a","b","c"]
1745 }
1746 }"#,
1747 );
1748
1749 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1750 let result = registry.load_values_json(&values_dir);
1751
1752 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1753 }
1754 }
1755
1756 mod object_tests {
1757 use super::*;
1758
1759 #[test]
1760 fn test_object_schema_loads() {
1761 let temp_dir = TempDir::new().unwrap();
1762 create_test_schema(
1763 &temp_dir,
1764 "test",
1765 r#"{
1766 "version": "1.0",
1767 "type": "object",
1768 "properties": {
1769 "config": {
1770 "type": "object",
1771 "properties": {
1772 "host": {"type": "string"},
1773 "port": {"type": "integer"},
1774 "rate": {"type": "number"},
1775 "enabled": {"type": "boolean"}
1776 },
1777 "default": {"host": "localhost", "port": 8080, "rate": 0.5, "enabled": true},
1778 "description": "Service config"
1779 }
1780 }
1781 }"#,
1782 );
1783
1784 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1785 let schema = registry.get("test").unwrap();
1786 assert_eq!(schema.options["config"].option_type, "object");
1787 }
1788
1789 #[test]
1790 fn test_object_missing_properties_rejected() {
1791 let temp_dir = TempDir::new().unwrap();
1792 create_test_schema(
1793 &temp_dir,
1794 "test",
1795 r#"{
1796 "version": "1.0",
1797 "type": "object",
1798 "properties": {
1799 "config": {
1800 "type": "object",
1801 "default": {"host": "localhost"},
1802 "description": "Missing properties field"
1803 }
1804 }
1805 }"#,
1806 );
1807
1808 let result = SchemaRegistry::from_directory(temp_dir.path());
1809 assert!(result.is_err());
1810 }
1811
1812 #[test]
1813 fn test_object_default_wrong_type_rejected() {
1814 let temp_dir = TempDir::new().unwrap();
1815 create_test_schema(
1816 &temp_dir,
1817 "test",
1818 r#"{
1819 "version": "1.0",
1820 "type": "object",
1821 "properties": {
1822 "config": {
1823 "type": "object",
1824 "properties": {
1825 "host": {"type": "string"},
1826 "port": {"type": "integer"}
1827 },
1828 "default": {"host": "localhost", "port": "not-a-number"},
1829 "description": "Bad default"
1830 }
1831 }
1832 }"#,
1833 );
1834
1835 let result = SchemaRegistry::from_directory(temp_dir.path());
1836 assert!(result.is_err());
1837 }
1838
1839 #[test]
1840 fn test_object_default_missing_field_rejected() {
1841 let temp_dir = TempDir::new().unwrap();
1842 create_test_schema(
1843 &temp_dir,
1844 "test",
1845 r#"{
1846 "version": "1.0",
1847 "type": "object",
1848 "properties": {
1849 "config": {
1850 "type": "object",
1851 "properties": {
1852 "host": {"type": "string"},
1853 "port": {"type": "integer"}
1854 },
1855 "default": {"host": "localhost"},
1856 "description": "Missing port in default"
1857 }
1858 }
1859 }"#,
1860 );
1861
1862 let result = SchemaRegistry::from_directory(temp_dir.path());
1863 assert!(result.is_err());
1864 }
1865
1866 #[test]
1867 fn test_object_default_extra_field_rejected() {
1868 let temp_dir = TempDir::new().unwrap();
1869 create_test_schema(
1870 &temp_dir,
1871 "test",
1872 r#"{
1873 "version": "1.0",
1874 "type": "object",
1875 "properties": {
1876 "config": {
1877 "type": "object",
1878 "properties": {
1879 "host": {"type": "string"}
1880 },
1881 "default": {"host": "localhost", "extra": "field"},
1882 "description": "Extra field in default"
1883 }
1884 }
1885 }"#,
1886 );
1887
1888 let result = SchemaRegistry::from_directory(temp_dir.path());
1889 assert!(result.is_err());
1890 }
1891
1892 #[test]
1893 fn test_object_values_valid() {
1894 let temp_dir = TempDir::new().unwrap();
1895 let (schemas_dir, values_dir) = create_test_schema_with_values(
1896 &temp_dir,
1897 "test",
1898 r#"{
1899 "version": "1.0",
1900 "type": "object",
1901 "properties": {
1902 "config": {
1903 "type": "object",
1904 "properties": {
1905 "host": {"type": "string"},
1906 "port": {"type": "integer"}
1907 },
1908 "default": {"host": "localhost", "port": 8080},
1909 "description": "Service config"
1910 }
1911 }
1912 }"#,
1913 r#"{
1914 "options": {
1915 "config": {"host": "example.com", "port": 9090}
1916 }
1917 }"#,
1918 );
1919
1920 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1921 let result = registry.load_values_json(&values_dir);
1922 assert!(result.is_ok());
1923 }
1924
1925 #[test]
1926 fn test_object_values_wrong_field_type_rejected() {
1927 let temp_dir = TempDir::new().unwrap();
1928 let (schemas_dir, values_dir) = create_test_schema_with_values(
1929 &temp_dir,
1930 "test",
1931 r#"{
1932 "version": "1.0",
1933 "type": "object",
1934 "properties": {
1935 "config": {
1936 "type": "object",
1937 "properties": {
1938 "host": {"type": "string"},
1939 "port": {"type": "integer"}
1940 },
1941 "default": {"host": "localhost", "port": 8080},
1942 "description": "Service config"
1943 }
1944 }
1945 }"#,
1946 r#"{
1947 "options": {
1948 "config": {"host": "example.com", "port": "not-a-number"}
1949 }
1950 }"#,
1951 );
1952
1953 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1954 let result = registry.load_values_json(&values_dir);
1955 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1956 }
1957
1958 #[test]
1959 fn test_object_values_extra_field_rejected() {
1960 let temp_dir = TempDir::new().unwrap();
1961 let (schemas_dir, values_dir) = create_test_schema_with_values(
1962 &temp_dir,
1963 "test",
1964 r#"{
1965 "version": "1.0",
1966 "type": "object",
1967 "properties": {
1968 "config": {
1969 "type": "object",
1970 "properties": {
1971 "host": {"type": "string"}
1972 },
1973 "default": {"host": "localhost"},
1974 "description": "Service config"
1975 }
1976 }
1977 }"#,
1978 r#"{
1979 "options": {
1980 "config": {"host": "example.com", "extra": "field"}
1981 }
1982 }"#,
1983 );
1984
1985 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1986 let result = registry.load_values_json(&values_dir);
1987 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1988 }
1989
1990 #[test]
1991 fn test_object_values_missing_field_rejected() {
1992 let temp_dir = TempDir::new().unwrap();
1993 let (schemas_dir, values_dir) = create_test_schema_with_values(
1994 &temp_dir,
1995 "test",
1996 r#"{
1997 "version": "1.0",
1998 "type": "object",
1999 "properties": {
2000 "config": {
2001 "type": "object",
2002 "properties": {
2003 "host": {"type": "string"},
2004 "port": {"type": "integer"}
2005 },
2006 "default": {"host": "localhost", "port": 8080},
2007 "description": "Service config"
2008 }
2009 }
2010 }"#,
2011 r#"{
2012 "options": {
2013 "config": {"host": "example.com"}
2014 }
2015 }"#,
2016 );
2017
2018 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2019 let result = registry.load_values_json(&values_dir);
2020 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2021 }
2022
2023 #[test]
2026 fn test_array_of_objects_schema_loads() {
2027 let temp_dir = TempDir::new().unwrap();
2028 create_test_schema(
2029 &temp_dir,
2030 "test",
2031 r#"{
2032 "version": "1.0",
2033 "type": "object",
2034 "properties": {
2035 "endpoints": {
2036 "type": "array",
2037 "items": {
2038 "type": "object",
2039 "properties": {
2040 "url": {"type": "string"},
2041 "weight": {"type": "integer"}
2042 }
2043 },
2044 "default": [{"url": "https://a.example.com", "weight": 1}],
2045 "description": "Endpoints"
2046 }
2047 }
2048 }"#,
2049 );
2050
2051 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2052 let schema = registry.get("test").unwrap();
2053 assert_eq!(schema.options["endpoints"].option_type, "array");
2054 }
2055
2056 #[test]
2057 fn test_array_of_objects_empty_default() {
2058 let temp_dir = TempDir::new().unwrap();
2059 create_test_schema(
2060 &temp_dir,
2061 "test",
2062 r#"{
2063 "version": "1.0",
2064 "type": "object",
2065 "properties": {
2066 "endpoints": {
2067 "type": "array",
2068 "items": {
2069 "type": "object",
2070 "properties": {
2071 "url": {"type": "string"},
2072 "weight": {"type": "integer"}
2073 }
2074 },
2075 "default": [],
2076 "description": "Endpoints"
2077 }
2078 }
2079 }"#,
2080 );
2081
2082 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2083 assert!(registry.get("test").is_some());
2084 }
2085
2086 #[test]
2087 fn test_array_of_objects_default_wrong_field_type_rejected() {
2088 let temp_dir = TempDir::new().unwrap();
2089 create_test_schema(
2090 &temp_dir,
2091 "test",
2092 r#"{
2093 "version": "1.0",
2094 "type": "object",
2095 "properties": {
2096 "endpoints": {
2097 "type": "array",
2098 "items": {
2099 "type": "object",
2100 "properties": {
2101 "url": {"type": "string"},
2102 "weight": {"type": "integer"}
2103 }
2104 },
2105 "default": [{"url": "https://a.example.com", "weight": "not-a-number"}],
2106 "description": "Endpoints"
2107 }
2108 }
2109 }"#,
2110 );
2111
2112 let result = SchemaRegistry::from_directory(temp_dir.path());
2113 assert!(result.is_err());
2114 }
2115
2116 #[test]
2117 fn test_array_of_objects_missing_items_properties_rejected() {
2118 let temp_dir = TempDir::new().unwrap();
2119 create_test_schema(
2120 &temp_dir,
2121 "test",
2122 r#"{
2123 "version": "1.0",
2124 "type": "object",
2125 "properties": {
2126 "endpoints": {
2127 "type": "array",
2128 "items": {
2129 "type": "object"
2130 },
2131 "default": [],
2132 "description": "Missing properties in items"
2133 }
2134 }
2135 }"#,
2136 );
2137
2138 let result = SchemaRegistry::from_directory(temp_dir.path());
2139 assert!(result.is_err());
2140 }
2141
2142 #[test]
2143 fn test_array_of_objects_values_valid() {
2144 let temp_dir = TempDir::new().unwrap();
2145 let (schemas_dir, values_dir) = create_test_schema_with_values(
2146 &temp_dir,
2147 "test",
2148 r#"{
2149 "version": "1.0",
2150 "type": "object",
2151 "properties": {
2152 "endpoints": {
2153 "type": "array",
2154 "items": {
2155 "type": "object",
2156 "properties": {
2157 "url": {"type": "string"},
2158 "weight": {"type": "integer"}
2159 }
2160 },
2161 "default": [],
2162 "description": "Endpoints"
2163 }
2164 }
2165 }"#,
2166 r#"{
2167 "options": {
2168 "endpoints": [
2169 {"url": "https://a.example.com", "weight": 1},
2170 {"url": "https://b.example.com", "weight": 2}
2171 ]
2172 }
2173 }"#,
2174 );
2175
2176 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2177 let result = registry.load_values_json(&values_dir);
2178 assert!(result.is_ok());
2179 }
2180
2181 #[test]
2182 fn test_array_of_objects_values_wrong_item_shape_rejected() {
2183 let temp_dir = TempDir::new().unwrap();
2184 let (schemas_dir, values_dir) = create_test_schema_with_values(
2185 &temp_dir,
2186 "test",
2187 r#"{
2188 "version": "1.0",
2189 "type": "object",
2190 "properties": {
2191 "endpoints": {
2192 "type": "array",
2193 "items": {
2194 "type": "object",
2195 "properties": {
2196 "url": {"type": "string"},
2197 "weight": {"type": "integer"}
2198 }
2199 },
2200 "default": [],
2201 "description": "Endpoints"
2202 }
2203 }
2204 }"#,
2205 r#"{
2206 "options": {
2207 "endpoints": [
2208 {"url": "https://a.example.com", "weight": "not-a-number"}
2209 ]
2210 }
2211 }"#,
2212 );
2213
2214 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2215 let result = registry.load_values_json(&values_dir);
2216 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2217 }
2218
2219 #[test]
2220 fn test_array_of_objects_values_extra_field_rejected() {
2221 let temp_dir = TempDir::new().unwrap();
2222 let (schemas_dir, values_dir) = create_test_schema_with_values(
2223 &temp_dir,
2224 "test",
2225 r#"{
2226 "version": "1.0",
2227 "type": "object",
2228 "properties": {
2229 "endpoints": {
2230 "type": "array",
2231 "items": {
2232 "type": "object",
2233 "properties": {
2234 "url": {"type": "string"}
2235 }
2236 },
2237 "default": [],
2238 "description": "Endpoints"
2239 }
2240 }
2241 }"#,
2242 r#"{
2243 "options": {
2244 "endpoints": [
2245 {"url": "https://a.example.com", "extra": "field"}
2246 ]
2247 }
2248 }"#,
2249 );
2250
2251 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2252 let result = registry.load_values_json(&values_dir);
2253 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2254 }
2255
2256 #[test]
2257 fn test_array_of_objects_values_missing_field_rejected() {
2258 let temp_dir = TempDir::new().unwrap();
2259 let (schemas_dir, values_dir) = create_test_schema_with_values(
2260 &temp_dir,
2261 "test",
2262 r#"{
2263 "version": "1.0",
2264 "type": "object",
2265 "properties": {
2266 "endpoints": {
2267 "type": "array",
2268 "items": {
2269 "type": "object",
2270 "properties": {
2271 "url": {"type": "string"},
2272 "weight": {"type": "integer"}
2273 }
2274 },
2275 "default": [],
2276 "description": "Endpoints"
2277 }
2278 }
2279 }"#,
2280 r#"{
2281 "options": {
2282 "endpoints": [
2283 {"url": "https://a.example.com"}
2284 ]
2285 }
2286 }"#,
2287 );
2288
2289 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2290 let result = registry.load_values_json(&values_dir);
2291 assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2292 }
2293
2294 #[test]
2297 fn test_object_optional_field_can_be_omitted_from_default() {
2298 let temp_dir = TempDir::new().unwrap();
2299 create_test_schema(
2300 &temp_dir,
2301 "test",
2302 r#"{
2303 "version": "1.0",
2304 "type": "object",
2305 "properties": {
2306 "config": {
2307 "type": "object",
2308 "properties": {
2309 "host": {"type": "string"},
2310 "debug": {"type": "boolean", "optional": true}
2311 },
2312 "default": {"host": "localhost"},
2313 "description": "Config with optional field"
2314 }
2315 }
2316 }"#,
2317 );
2318
2319 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2320 let schema = registry.get("test").unwrap();
2321 assert_eq!(schema.options["config"].option_type, "object");
2322 }
2323
2324 #[test]
2325 fn test_object_optional_field_can_be_included_in_default() {
2326 let temp_dir = TempDir::new().unwrap();
2327 create_test_schema(
2328 &temp_dir,
2329 "test",
2330 r#"{
2331 "version": "1.0",
2332 "type": "object",
2333 "properties": {
2334 "config": {
2335 "type": "object",
2336 "properties": {
2337 "host": {"type": "string"},
2338 "debug": {"type": "boolean", "optional": true}
2339 },
2340 "default": {"host": "localhost", "debug": true},
2341 "description": "Config with optional field included"
2342 }
2343 }
2344 }"#,
2345 );
2346
2347 let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2348 assert!(registry.get("test").is_some());
2349 }
2350
2351 #[test]
2352 fn test_object_optional_field_wrong_type_rejected() {
2353 let temp_dir = TempDir::new().unwrap();
2354 create_test_schema(
2355 &temp_dir,
2356 "test",
2357 r#"{
2358 "version": "1.0",
2359 "type": "object",
2360 "properties": {
2361 "config": {
2362 "type": "object",
2363 "properties": {
2364 "host": {"type": "string"},
2365 "debug": {"type": "boolean", "optional": true}
2366 },
2367 "default": {"host": "localhost", "debug": "not-a-bool"},
2368 "description": "Optional field wrong type"
2369 }
2370 }
2371 }"#,
2372 );
2373
2374 let result = SchemaRegistry::from_directory(temp_dir.path());
2375 assert!(result.is_err());
2376 }
2377
2378 #[test]
2379 fn test_object_required_field_still_required_with_optional_present() {
2380 let temp_dir = TempDir::new().unwrap();
2381 create_test_schema(
2382 &temp_dir,
2383 "test",
2384 r#"{
2385 "version": "1.0",
2386 "type": "object",
2387 "properties": {
2388 "config": {
2389 "type": "object",
2390 "properties": {
2391 "host": {"type": "string"},
2392 "port": {"type": "integer"},
2393 "debug": {"type": "boolean", "optional": true}
2394 },
2395 "default": {"debug": true},
2396 "description": "Missing required fields"
2397 }
2398 }
2399 }"#,
2400 );
2401
2402 let result = SchemaRegistry::from_directory(temp_dir.path());
2403 assert!(result.is_err());
2404 }
2405
2406 #[test]
2407 fn test_object_optional_field_omitted_from_values() {
2408 let temp_dir = TempDir::new().unwrap();
2409 let (schemas_dir, values_dir) = create_test_schema_with_values(
2410 &temp_dir,
2411 "test",
2412 r#"{
2413 "version": "1.0",
2414 "type": "object",
2415 "properties": {
2416 "config": {
2417 "type": "object",
2418 "properties": {
2419 "host": {"type": "string"},
2420 "debug": {"type": "boolean", "optional": true}
2421 },
2422 "default": {"host": "localhost"},
2423 "description": "Config"
2424 }
2425 }
2426 }"#,
2427 r#"{
2428 "options": {
2429 "config": {"host": "example.com"}
2430 }
2431 }"#,
2432 );
2433
2434 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2435 let result = registry.load_values_json(&values_dir);
2436 assert!(result.is_ok());
2437 }
2438
2439 #[test]
2440 fn test_object_optional_field_included_in_values() {
2441 let temp_dir = TempDir::new().unwrap();
2442 let (schemas_dir, values_dir) = create_test_schema_with_values(
2443 &temp_dir,
2444 "test",
2445 r#"{
2446 "version": "1.0",
2447 "type": "object",
2448 "properties": {
2449 "config": {
2450 "type": "object",
2451 "properties": {
2452 "host": {"type": "string"},
2453 "debug": {"type": "boolean", "optional": true}
2454 },
2455 "default": {"host": "localhost"},
2456 "description": "Config"
2457 }
2458 }
2459 }"#,
2460 r#"{
2461 "options": {
2462 "config": {"host": "example.com", "debug": true}
2463 }
2464 }"#,
2465 );
2466
2467 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2468 let result = registry.load_values_json(&values_dir);
2469 assert!(result.is_ok());
2470 }
2471
2472 #[test]
2473 fn test_array_of_objects_optional_field_omitted() {
2474 let temp_dir = TempDir::new().unwrap();
2475 let (schemas_dir, values_dir) = create_test_schema_with_values(
2476 &temp_dir,
2477 "test",
2478 r#"{
2479 "version": "1.0",
2480 "type": "object",
2481 "properties": {
2482 "endpoints": {
2483 "type": "array",
2484 "items": {
2485 "type": "object",
2486 "properties": {
2487 "url": {"type": "string"},
2488 "weight": {"type": "integer", "optional": true}
2489 }
2490 },
2491 "default": [],
2492 "description": "Endpoints"
2493 }
2494 }
2495 }"#,
2496 r#"{
2497 "options": {
2498 "endpoints": [
2499 {"url": "https://a.example.com"},
2500 {"url": "https://b.example.com", "weight": 2}
2501 ]
2502 }
2503 }"#,
2504 );
2505
2506 let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2507 let result = registry.load_values_json(&values_dir);
2508 assert!(result.is_ok());
2509 }
2510 }
2511}