Skip to main content

sentry_options_validation/
lib.rs

1//! Schema validation library for sentry-options
2//!
3//! This library provides schema loading and validation for sentry-options.
4//! Schemas are loaded once and stored in Arc for efficient sharing.
5//! Values are validated against schemas as complete objects.
6
7use 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
24/// Embedded meta-schema for validating sentry-options schema files
25const NAMESPACE_SCHEMA_JSON: &str = include_str!("namespace-schema.json");
26
27/// Embedded Feature type definitions for injecting into namespace schemas that contain feature flags
28const FEATURE_SCHEMA_DEFS_JSON: &str = include_str!("feature-schema-defs.json");
29
30const SCHEMA_FILE_NAME: &str = "schema.json";
31const VALUES_FILE_NAME: &str = "values.json";
32
33/// Time between file polls in seconds
34const POLLING_DELAY: u64 = 5;
35
36/// Dedicated Sentry DSN for sentry-options observability.
37/// This is separate from the host application's Sentry setup.
38#[cfg(not(test))]
39const SENTRY_OPTIONS_DSN: &str =
40    "https://d3598a07e9f23a9acee9e2718cfd17bd@o1.ingest.us.sentry.io/4510750163927040";
41
42/// Disabled DSN for tests - empty string creates a disabled client
43#[cfg(test)]
44const SENTRY_OPTIONS_DSN: &str = "";
45
46/// Lazily-initialized dedicated Sentry Hub for sentry-options.
47/// Uses a custom Client that is completely isolated from the host application's Sentry setup.
48/// In test mode, creates a disabled client (empty DSN) so no spans are sent.
49static SENTRY_HUB: OnceLock<Arc<sentry::Hub>> = OnceLock::new();
50
51fn get_sentry_hub() -> &'static Arc<sentry::Hub> {
52    SENTRY_HUB.get_or_init(|| {
53        let client = Arc::new(sentry::Client::from((
54            SENTRY_OPTIONS_DSN,
55            ClientOptions {
56                traces_sample_rate: 1.0,
57                // Explicitly set transport factory - required when not using sentry::init()
58                transport: Some(Arc::new(DefaultTransportFactory)),
59                ..Default::default()
60            },
61        )));
62        Arc::new(sentry::Hub::new(
63            Some(client),
64            Arc::new(sentry::Scope::default()),
65        ))
66    })
67}
68
69/// Production path where options are deployed via config map
70pub const PRODUCTION_OPTIONS_DIR: &str = "/etc/sentry-options";
71
72/// Local fallback path for development
73pub const LOCAL_OPTIONS_DIR: &str = "sentry-options";
74
75/// Environment variable to override options directory
76pub const OPTIONS_DIR_ENV: &str = "SENTRY_OPTIONS_DIR";
77
78/// Environment variable to suppress missing directory errors
79pub const OPTIONS_SUPPRESS_MISSING_DIR_ENV: &str = "SENTRY_OPTIONS_SUPPRESS_MISSING_DIR";
80
81/// Check if missing directory errors should be suppressed
82fn should_suppress_missing_dir_errors() -> bool {
83    std::env::var(OPTIONS_SUPPRESS_MISSING_DIR_ENV)
84        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
85        .unwrap_or(false)
86}
87
88/// Resolve options directory using fallback chain:
89/// 1. `SENTRY_OPTIONS_DIR` env var (if set)
90/// 2. `/etc/sentry-options` (if exists)
91/// 3. `sentry-options/` (local fallback)
92pub fn resolve_options_dir() -> PathBuf {
93    if let Ok(dir) = std::env::var(OPTIONS_DIR_ENV) {
94        return PathBuf::from(dir);
95    }
96
97    let prod_path = PathBuf::from(PRODUCTION_OPTIONS_DIR);
98    if prod_path.exists() {
99        return prod_path;
100    }
101
102    PathBuf::from(LOCAL_OPTIONS_DIR)
103}
104
105/// Result type for validation operations
106pub type ValidationResult<T> = Result<T, ValidationError>;
107
108/// A map of option values keyed by their namespace
109pub type ValuesByNamespace = HashMap<String, HashMap<String, Value>>;
110
111/// Errors that can occur during schema and value validation
112#[derive(Debug, thiserror::Error)]
113pub enum ValidationError {
114    #[error("Schema error in {file}: {message}")]
115    SchemaError { file: PathBuf, message: String },
116
117    #[error("Value error for {namespace}: {errors}")]
118    ValueError { namespace: String, errors: String },
119
120    #[error("Unknown namespace: {0}")]
121    UnknownNamespace(String),
122
123    #[error("Unknown option '{key}' in namespace '{namespace}'")]
124    UnknownOption { namespace: String, key: String },
125
126    #[error("Internal error: {0}")]
127    InternalError(String),
128
129    #[error("Failed to read file: {0}")]
130    FileRead(#[from] std::io::Error),
131
132    #[error("Failed to parse JSON: {0}")]
133    JSONParse(#[from] serde_json::Error),
134
135    #[error("{} validation error(s)", .0.len())]
136    ValidationErrors(Vec<ValidationError>),
137
138    #[error("Invalid {label} '{name}': {reason}")]
139    InvalidName {
140        label: String,
141        name: String,
142        reason: String,
143    },
144}
145
146/// Validate a name component is valid for K8s (lowercase alphanumeric, '-', '.')
147pub fn validate_k8s_name_component(name: &str, label: &str) -> ValidationResult<()> {
148    if let Some(c) = name
149        .chars()
150        .find(|&c| !matches!(c, 'a'..='z' | '0'..='9' | '-' | '.'))
151    {
152        return Err(ValidationError::InvalidName {
153            label: label.to_string(),
154            name: name.to_string(),
155            reason: format!(
156                "character '{}' not allowed. Use lowercase alphanumeric, '-', or '.'",
157                c
158            ),
159        });
160    }
161    if !name.starts_with(|c: char| c.is_ascii_alphanumeric())
162        || !name.ends_with(|c: char| c.is_ascii_alphanumeric())
163    {
164        return Err(ValidationError::InvalidName {
165            label: label.to_string(),
166            name: name.to_string(),
167            reason: "must start and end with alphanumeric".to_string(),
168        });
169    }
170    Ok(())
171}
172
173/// Metadata for a single option in a namespace schema
174#[derive(Debug, Clone)]
175pub struct OptionMetadata {
176    pub option_type: String,
177    pub property_schema: Value,
178    pub default: Value,
179}
180
181/// Schema for a namespace, containing validator and option metadata
182pub struct NamespaceSchema {
183    pub namespace: String,
184    pub options: HashMap<String, OptionMetadata>,
185    validator: jsonschema::Validator,
186}
187
188impl NamespaceSchema {
189    /// Validate an entire values object against this schema
190    ///
191    /// # Arguments
192    /// * `values` - JSON object containing option key-value pairs
193    ///
194    /// # Errors
195    /// Returns error if values don't match the schema
196    pub fn validate_values(&self, values: &Value) -> ValidationResult<()> {
197        let output = self.validator.evaluate(values);
198        if output.flag().valid {
199            Ok(())
200        } else {
201            let errors: Vec<String> = output
202                .iter_errors()
203                .map(|e| {
204                    format!(
205                        "\n\t{} {}",
206                        e.instance_location.as_str().trim_start_matches("/"),
207                        e.error
208                    )
209                })
210                .collect();
211            Err(ValidationError::ValueError {
212                namespace: self.namespace.clone(),
213                errors: errors.join(""),
214            })
215        }
216    }
217
218    /// Get the default value for an option key.
219    /// Returns None if the key doesn't exist in the schema.
220    pub fn get_default(&self, key: &str) -> Option<&Value> {
221        self.options.get(key).map(|meta| &meta.default)
222    }
223
224    /// Validate a single key-value pair against the schema.
225    ///
226    /// # Errors
227    /// Returns error if the key doesn't exist or the value doesn't match the expected type.
228    pub fn validate_option(&self, key: &str, value: &Value) -> ValidationResult<()> {
229        if !self.options.contains_key(key) {
230            return Err(ValidationError::UnknownOption {
231                namespace: self.namespace.clone(),
232                key: key.to_string(),
233            });
234        }
235        let test_obj = json!({ key: value });
236        self.validate_values(&test_obj)
237    }
238}
239
240/// Registry for loading and storing schemas
241pub struct SchemaRegistry {
242    schemas: HashMap<String, Arc<NamespaceSchema>>,
243}
244
245impl SchemaRegistry {
246    /// Create a new empty schema registry
247    pub fn new() -> Self {
248        Self {
249            schemas: HashMap::new(),
250        }
251    }
252
253    /// Load schemas from a directory and create a registry
254    ///
255    /// Expects directory structure: `schemas/{namespace}/schema.json`
256    ///
257    /// # Arguments
258    /// * `schemas_dir` - Path to directory containing namespace subdirectories
259    ///
260    /// # Errors
261    /// Returns error if directory doesn't exist or any schema is invalid
262    pub fn from_directory(schemas_dir: &Path) -> ValidationResult<Self> {
263        let namespace_validator = Self::compile_namespace_validator()?;
264        let mut schemas_map = HashMap::new();
265
266        // TODO: Parallelize the loading of schemas for the performance gainz
267        for entry in fs::read_dir(schemas_dir)? {
268            let entry = entry?;
269
270            if !entry.file_type()?.is_dir() {
271                continue;
272            }
273
274            let namespace =
275                entry
276                    .file_name()
277                    .into_string()
278                    .map_err(|_| ValidationError::SchemaError {
279                        file: entry.path(),
280                        message: "Directory name contains invalid UTF-8".to_string(),
281                    })?;
282
283            validate_k8s_name_component(&namespace, "namespace name")?;
284
285            let schema_file = entry.path().join(SCHEMA_FILE_NAME);
286            let file = fs::File::open(&schema_file)?;
287            let schema_data: Value = serde_json::from_reader(file)?;
288
289            Self::validate_with_namespace_schema(&schema_data, &schema_file, &namespace_validator)?;
290            let schema = Self::parse_schema(schema_data, &namespace, &schema_file)?;
291            schemas_map.insert(namespace, schema);
292        }
293
294        Ok(Self {
295            schemas: schemas_map,
296        })
297    }
298
299    /// Build a registry from in-memory schema JSON strings.
300    ///
301    /// Each entry is a `(namespace, json)` pair. Applies the same validation
302    /// pipeline as `from_directory` (meta-schema check, constraint injection,
303    /// validator compilation) without reading from the filesystem.
304    pub fn from_schemas(schemas: &[(&str, &str)]) -> ValidationResult<Self> {
305        let namespace_validator = Self::compile_namespace_validator()?;
306        let schema_file = Path::new("<embedded>");
307        let mut schemas_map = HashMap::new();
308
309        for (namespace, json) in schemas {
310            validate_k8s_name_component(namespace, "namespace name")?;
311
312            let schema_data: Value =
313                serde_json::from_str(json).map_err(|e| ValidationError::SchemaError {
314                    file: schema_file.to_path_buf(),
315                    message: format!("Invalid JSON for namespace '{}': {}", namespace, e),
316                })?;
317
318            Self::validate_with_namespace_schema(&schema_data, schema_file, &namespace_validator)?;
319            let schema = Self::parse_schema(schema_data, namespace, schema_file)?;
320            schemas_map.insert(namespace.to_string(), schema);
321        }
322
323        Ok(Self {
324            schemas: schemas_map,
325        })
326    }
327
328    /// Validate an entire values object for a namespace
329    pub fn validate_values(&self, namespace: &str, values: &Value) -> ValidationResult<()> {
330        let schema = self
331            .schemas
332            .get(namespace)
333            .ok_or_else(|| ValidationError::UnknownNamespace(namespace.to_string()))?;
334
335        schema.validate_values(values)
336    }
337
338    fn compile_namespace_validator() -> ValidationResult<jsonschema::Validator> {
339        let namespace_schema_value: Value =
340            serde_json::from_str(NAMESPACE_SCHEMA_JSON).map_err(|e| {
341                ValidationError::InternalError(format!("Invalid namespace-schema JSON: {}", e))
342            })?;
343        jsonschema::validator_for(&namespace_schema_value).map_err(|e| {
344            ValidationError::InternalError(format!("Failed to compile namespace-schema: {}", e))
345        })
346    }
347
348    /// Validate a schema against the namespace-schema
349    fn validate_with_namespace_schema(
350        schema_data: &Value,
351        path: &Path,
352        namespace_validator: &jsonschema::Validator,
353    ) -> ValidationResult<()> {
354        let output = namespace_validator.evaluate(schema_data);
355
356        if output.flag().valid {
357            Ok(())
358        } else {
359            let errors: Vec<String> = output
360                .iter_errors()
361                .map(|e| format!("Error: {}", e.error))
362                .collect();
363
364            Err(ValidationError::SchemaError {
365                file: path.to_path_buf(),
366                message: format!("Schema validation failed:\n{}", errors.join("\n")),
367            })
368        }
369    }
370
371    /// Validate that a default value matches its declared type using jsonschema
372    fn validate_default_type(
373        property_name: &str,
374        property_schema: &Value,
375        default_value: &Value,
376        path: &Path,
377    ) -> ValidationResult<()> {
378        // Validate the default value against the property schema
379        jsonschema::validate(property_schema, default_value).map_err(|e| {
380            ValidationError::SchemaError {
381                file: path.to_path_buf(),
382                message: format!(
383                    "Property '{}': default value does not match schema: {}",
384                    property_name, e
385                ),
386            }
387        })?;
388
389        Ok(())
390    }
391
392    /// Inject `required` (all field names) and `additionalProperties: false`
393    /// into an object-typed schema. This ensures all properties are required
394    /// and no new properties can be included
395    /// e.g.
396    /// {
397    ///     "type": "object",
398    ///     "properties": {
399    ///       "host": { "type": "string" },
400    ///       "port": { "type": "integer" }
401    ///     },
402    ///     "required": ["host", "port"],                       <-- INJECTED
403    ///     "additionalProperties": false,                      <-- INJECTED
404    ///     "default": { "host": "localhost", "port": 8080 },
405    ///     "description": "..."
406    /// }
407    fn inject_object_constraints(schema: &mut Value) {
408        if let Some(obj) = schema.as_object_mut()
409            && let Some(props) = obj.get("properties").and_then(|p| p.as_object())
410        {
411            let required: Vec<Value> = props
412                .iter()
413                .filter(|(_, v)| !v.get("optional").and_then(|o| o.as_bool()).unwrap_or(false))
414                .map(|(k, _)| Value::String(k.clone()))
415                .collect();
416            obj.insert("required".to_string(), Value::Array(required));
417            obj.insert("additionalProperties".to_string(), json!(false));
418        }
419    }
420
421    /// Parse a schema JSON into NamespaceSchema
422    fn parse_schema(
423        mut schema: Value,
424        namespace: &str,
425        path: &Path,
426    ) -> ValidationResult<Arc<NamespaceSchema>> {
427        // Inject additionalProperties: false to reject unknown options
428        if let Some(obj) = schema.as_object_mut() {
429            obj.insert("additionalProperties".to_string(), json!(false));
430        }
431
432        // Inject object constraints (required + additionalProperties) for object-typed options
433        // so that jsonschema validates the full shape of object values.
434        if let Some(properties) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) {
435            for prop_value in properties.values_mut() {
436                let prop_type = prop_value
437                    .get("type")
438                    .and_then(|t| t.as_str())
439                    .unwrap_or("");
440
441                if prop_type == "object" {
442                    Self::inject_object_constraints(prop_value);
443                } else if prop_type == "array"
444                    && let Some(items) = prop_value.get_mut("items")
445                {
446                    let items_type = items.get("type").and_then(|t| t.as_str()).unwrap_or("");
447                    if items_type == "object" {
448                        Self::inject_object_constraints(items);
449                    }
450                }
451            }
452        }
453
454        // Extract option metadata and validate types.
455        let mut options = HashMap::new();
456        let mut has_feature_keys = false;
457        if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
458            for (prop_name, prop_value) in properties {
459                // Detect feature flags so that we can augment the schema with defs.
460                if prop_name.starts_with("feature.") {
461                    has_feature_keys = true;
462                }
463                if let (Some(prop_type), Some(default_value)) = (
464                    prop_value.get("type").and_then(|t| t.as_str()),
465                    prop_value.get("default"),
466                ) {
467                    Self::validate_default_type(prop_name, prop_value, default_value, path)?;
468                    options.insert(
469                        prop_name.clone(),
470                        OptionMetadata {
471                            option_type: prop_type.to_string(),
472                            property_schema: prop_value.clone(),
473                            default: default_value.clone(),
474                        },
475                    );
476                }
477            }
478        }
479
480        // If an options schema includes a feature flag, splice in the definitions
481        // so that values can be validated.
482        if has_feature_keys {
483            let feature_defs: Value =
484                serde_json::from_str(FEATURE_SCHEMA_DEFS_JSON).map_err(|e| {
485                    ValidationError::InternalError(format!(
486                        "Invalid feature-schema-defs JSON: {}",
487                        e
488                    ))
489                })?;
490
491            if let Some(obj) = schema.as_object_mut() {
492                obj.insert("definitions".to_string(), feature_defs);
493            }
494        }
495
496        // Use the (potentially transformed) schema as the validator
497        let validator =
498            jsonschema::validator_for(&schema).map_err(|e| ValidationError::SchemaError {
499                file: path.to_path_buf(),
500                message: format!("Failed to compile validator: {}", e),
501            })?;
502
503        Ok(Arc::new(NamespaceSchema {
504            namespace: namespace.to_string(),
505            options,
506            validator,
507        }))
508    }
509
510    /// Get a namespace schema by name
511    pub fn get(&self, namespace: &str) -> Option<&Arc<NamespaceSchema>> {
512        self.schemas.get(namespace)
513    }
514
515    /// Get all loaded schemas (for schema evolution validation)
516    pub fn schemas(&self) -> &HashMap<String, Arc<NamespaceSchema>> {
517        &self.schemas
518    }
519
520    /// Load and validate JSON values from a directory.
521    /// Expects structure: `{values_dir}/{namespace}/values.json`
522    /// Values file must have format: `{"options": {"key": value, ...}, "generated_at": "..."}`
523    /// Skips namespaces without a values.json file.
524    /// Returns the values and a map of namespace -> `generated_at` timestamp.
525    pub fn load_values_json(
526        &self,
527        values_dir: &Path,
528    ) -> ValidationResult<(ValuesByNamespace, HashMap<String, String>)> {
529        let mut all_values = HashMap::new();
530        let mut generated_at_by_namespace: HashMap<String, String> = HashMap::new();
531
532        for namespace in self.schemas.keys() {
533            let values_file = values_dir.join(namespace).join(VALUES_FILE_NAME);
534
535            if !values_file.exists() {
536                continue;
537            }
538
539            let parsed: Value = serde_json::from_reader(fs::File::open(&values_file)?)?;
540
541            // Extract generated_at if present
542            if let Some(ts) = parsed.get("generated_at").and_then(|v| v.as_str()) {
543                generated_at_by_namespace.insert(namespace.clone(), ts.to_string());
544            }
545
546            let values = parsed
547                .get("options")
548                .ok_or_else(|| ValidationError::ValueError {
549                    namespace: namespace.clone(),
550                    errors: "values.json must have an 'options' key".to_string(),
551                })?;
552
553            self.validate_values(namespace, values)?;
554
555            if let Value::Object(obj) = values.clone() {
556                let ns_values: HashMap<String, Value> = obj.into_iter().collect();
557                all_values.insert(namespace.clone(), ns_values);
558            }
559        }
560
561        Ok((all_values, generated_at_by_namespace))
562    }
563}
564
565impl Default for SchemaRegistry {
566    fn default() -> Self {
567        Self::new()
568    }
569}
570
571/// Watches the values directory for changes, reloading if there are any.
572/// If the directory does not exist we do not panic
573///
574/// Does not do an initial fetch, assumes the caller has already loaded values.
575/// Child thread may panic if we run out of memory or cannot create more threads.
576///
577/// Uses polling for now, could use `inotify` or similar later on.
578///
579/// Some important notes:
580/// - If the thread panics and dies, there is no built in mechanism to catch it and restart
581/// - If a config map is unmounted, we won't reload until the next file modification (because we don't catch the deletion event)
582/// - If any namespace fails validation, we keep all old values (even the namespaces that passed validation)
583/// - If we have a steady stream of readers our writer may starve for a while trying to acquire the lock
584/// - stop() will block until the thread gets joined
585pub struct ValuesWatcher {
586    stop_signal: Arc<AtomicBool>,
587    thread: Option<JoinHandle<()>>,
588}
589
590impl ValuesWatcher {
591    /// Creates a new ValuesWatcher struct and spins up the watcher thread
592    pub fn new(
593        values_path: &Path,
594        registry: Arc<SchemaRegistry>,
595        values: Arc<RwLock<ValuesByNamespace>>,
596    ) -> ValidationResult<Self> {
597        // output an error but keep passing
598        if !should_suppress_missing_dir_errors() && fs::metadata(values_path).is_err() {
599            eprintln!("Values directory does not exist: {}", values_path.display());
600        }
601
602        let stop_signal = Arc::new(AtomicBool::new(false));
603
604        let thread_signal = Arc::clone(&stop_signal);
605        let thread_path = values_path.to_path_buf();
606        let thread_registry = Arc::clone(&registry);
607        let thread_values = Arc::clone(&values);
608        let thread = thread::Builder::new()
609            .name("sentry-options-watcher".into())
610            .spawn(move || {
611                let result = panic::catch_unwind(AssertUnwindSafe(|| {
612                    Self::run(thread_signal, thread_path, thread_registry, thread_values);
613                }));
614                if let Err(e) = result {
615                    eprintln!("Watcher thread panicked with: {:?}", e);
616                }
617            })?;
618
619        Ok(Self {
620            stop_signal,
621            thread: Some(thread),
622        })
623    }
624
625    /// Reloads the values if the modified time has changed.
626    ///
627    /// Continuously polls the values directory and reloads all values
628    /// if any modification is detected.
629    fn run(
630        stop_signal: Arc<AtomicBool>,
631        values_path: PathBuf,
632        registry: Arc<SchemaRegistry>,
633        values: Arc<RwLock<ValuesByNamespace>>,
634    ) {
635        let mut last_mtime = Self::get_mtime(&values_path);
636
637        while !stop_signal.load(Ordering::Relaxed) {
638            // does not reload values if get_mtime fails
639            if let Some(current_mtime) = Self::get_mtime(&values_path)
640                && Some(current_mtime) != last_mtime
641            {
642                Self::reload_values(&values_path, &registry, &values);
643                last_mtime = Some(current_mtime);
644            }
645
646            thread::sleep(Duration::from_secs(POLLING_DELAY));
647        }
648    }
649
650    /// Get the most recent modification time across all namespace values.json files
651    /// Returns None if no valid values files are found
652    fn get_mtime(values_dir: &Path) -> Option<std::time::SystemTime> {
653        let mut latest_mtime = None;
654
655        let entries = match fs::read_dir(values_dir) {
656            Ok(e) => e,
657            Err(e) => {
658                if !should_suppress_missing_dir_errors() {
659                    eprintln!("Failed to read directory {}: {}", values_dir.display(), e);
660                }
661                return None;
662            }
663        };
664
665        for entry in entries.flatten() {
666            // skip if not a dir
667            if !entry
668                .file_type()
669                .map(|file_type| file_type.is_dir())
670                .unwrap_or(false)
671            {
672                continue;
673            }
674
675            let values_file = entry.path().join(VALUES_FILE_NAME);
676            if let Ok(metadata) = fs::metadata(&values_file)
677                && let Ok(mtime) = metadata.modified()
678                && latest_mtime.is_none_or(|latest| mtime > latest)
679            {
680                latest_mtime = Some(mtime);
681            }
682        }
683
684        latest_mtime
685    }
686
687    /// Reload values from disk, validate them, and update the shared map.
688    /// Emits a Sentry transaction per namespace with timing and propagation delay metrics.
689    fn reload_values(
690        values_path: &Path,
691        registry: &SchemaRegistry,
692        values: &Arc<RwLock<ValuesByNamespace>>,
693    ) {
694        let reload_start = Instant::now();
695
696        match registry.load_values_json(values_path) {
697            Ok((new_values, generated_at_by_namespace)) => {
698                let namespaces: Vec<String> = new_values.keys().cloned().collect();
699                Self::update_values(values, new_values);
700
701                let reload_duration = reload_start.elapsed();
702                Self::emit_reload_spans(&namespaces, reload_duration, &generated_at_by_namespace);
703            }
704            Err(e) => {
705                eprintln!(
706                    "Failed to reload values from {}: {}",
707                    values_path.display(),
708                    e
709                );
710            }
711        }
712    }
713
714    /// Emit a Sentry transaction per namespace with reload timing and propagation delay metrics.
715    /// Uses a dedicated Sentry Hub isolated from the host application's Sentry setup.
716    fn emit_reload_spans(
717        namespaces: &[String],
718        reload_duration: Duration,
719        generated_at_by_namespace: &HashMap<String, String>,
720    ) {
721        let hub = get_sentry_hub();
722        let applied_at = Utc::now();
723        let reload_duration_ms = reload_duration.as_secs_f64() * 1000.0;
724
725        for namespace in namespaces {
726            let mut tx_ctx = sentry::TransactionContext::new(namespace, "sentry_options.reload");
727            tx_ctx.set_sampled(true);
728
729            let transaction = hub.start_transaction(tx_ctx);
730            transaction.set_data("reload_duration_ms", reload_duration_ms.into());
731            transaction.set_data("applied_at", applied_at.to_rfc3339().into());
732
733            if let Some(ts) = generated_at_by_namespace.get(namespace) {
734                transaction.set_data("generated_at", ts.as_str().into());
735
736                if let Ok(generated_time) = DateTime::parse_from_rfc3339(ts) {
737                    let delay_secs = (applied_at - generated_time.with_timezone(&Utc))
738                        .num_milliseconds() as f64
739                        / 1000.0;
740                    transaction.set_data("propagation_delay_secs", delay_secs.into());
741                }
742            }
743
744            transaction.finish();
745        }
746    }
747
748    /// Update the values map with the new values
749    fn update_values(values: &Arc<RwLock<ValuesByNamespace>>, new_values: ValuesByNamespace) {
750        // safe to unwrap, we only have one thread and if it panics we die anyways
751        let mut guard = values.write().unwrap();
752        *guard = new_values;
753    }
754
755    /// Stops the watcher thread, waiting for it to join.
756    /// May take up to POLLING_DELAY seconds
757    pub fn stop(&mut self) {
758        self.stop_signal.store(true, Ordering::Relaxed);
759        if let Some(thread) = self.thread.take() {
760            let _ = thread.join();
761        }
762    }
763
764    /// Returns whether the watcher thread is still running
765    pub fn is_alive(&self) -> bool {
766        self.thread.as_ref().is_some_and(|t| !t.is_finished())
767    }
768}
769
770impl Drop for ValuesWatcher {
771    fn drop(&mut self) {
772        self.stop();
773    }
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779    use tempfile::TempDir;
780
781    fn create_test_schema(temp_dir: &TempDir, namespace: &str, schema_json: &str) -> PathBuf {
782        let schema_dir = temp_dir.path().join(namespace);
783        fs::create_dir_all(&schema_dir).unwrap();
784        let schema_file = schema_dir.join("schema.json");
785        fs::write(&schema_file, schema_json).unwrap();
786        schema_file
787    }
788
789    fn create_test_schema_with_values(
790        temp_dir: &TempDir,
791        namespace: &str,
792        schema_json: &str,
793        values_json: &str,
794    ) -> (PathBuf, PathBuf) {
795        let schemas_dir = temp_dir.path().join("schemas");
796        let values_dir = temp_dir.path().join("values");
797
798        let schema_dir = schemas_dir.join(namespace);
799        fs::create_dir_all(&schema_dir).unwrap();
800        let schema_file = schema_dir.join("schema.json");
801        fs::write(&schema_file, schema_json).unwrap();
802
803        let ns_values_dir = values_dir.join(namespace);
804        fs::create_dir_all(&ns_values_dir).unwrap();
805        let values_file = ns_values_dir.join("values.json");
806        fs::write(&values_file, values_json).unwrap();
807
808        (schemas_dir, values_dir)
809    }
810
811    #[test]
812    fn test_validate_k8s_name_component_valid() {
813        assert!(validate_k8s_name_component("relay", "namespace").is_ok());
814        assert!(validate_k8s_name_component("my-service", "namespace").is_ok());
815        assert!(validate_k8s_name_component("my.service", "namespace").is_ok());
816        assert!(validate_k8s_name_component("a1-b2.c3", "namespace").is_ok());
817    }
818
819    #[test]
820    fn test_validate_k8s_name_component_rejects_uppercase() {
821        let result = validate_k8s_name_component("MyService", "namespace");
822        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
823        assert!(result.unwrap_err().to_string().contains("'M' not allowed"));
824    }
825
826    #[test]
827    fn test_validate_k8s_name_component_rejects_underscore() {
828        let result = validate_k8s_name_component("my_service", "target");
829        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
830        assert!(result.unwrap_err().to_string().contains("'_' not allowed"));
831    }
832
833    #[test]
834    fn test_validate_k8s_name_component_rejects_leading_hyphen() {
835        let result = validate_k8s_name_component("-service", "namespace");
836        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
837        assert!(
838            result
839                .unwrap_err()
840                .to_string()
841                .contains("start and end with alphanumeric")
842        );
843    }
844
845    #[test]
846    fn test_validate_k8s_name_component_rejects_trailing_dot() {
847        let result = validate_k8s_name_component("service.", "namespace");
848        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
849        assert!(
850            result
851                .unwrap_err()
852                .to_string()
853                .contains("start and end with alphanumeric")
854        );
855    }
856
857    #[test]
858    fn test_load_schema_valid() {
859        let temp_dir = TempDir::new().unwrap();
860        create_test_schema(
861            &temp_dir,
862            "test",
863            r#"{
864                "version": "1.0",
865                "type": "object",
866                "properties": {
867                    "test-key": {
868                        "type": "string",
869                        "default": "test",
870                        "description": "Test option"
871                    }
872                }
873            }"#,
874        );
875
876        SchemaRegistry::from_directory(temp_dir.path()).unwrap();
877    }
878
879    #[test]
880    fn test_load_schema_missing_version() {
881        let temp_dir = TempDir::new().unwrap();
882        create_test_schema(
883            &temp_dir,
884            "test",
885            r#"{
886                "type": "object",
887                "properties": {}
888            }"#,
889        );
890
891        let result = SchemaRegistry::from_directory(temp_dir.path());
892        assert!(result.is_err());
893        match result {
894            Err(ValidationError::SchemaError { message, .. }) => {
895                assert!(message.contains(
896                    "Schema validation failed:
897Error: \"version\" is a required property"
898                ));
899            }
900            _ => panic!("Expected SchemaError for missing version"),
901        }
902    }
903
904    #[test]
905    fn test_unknown_namespace() {
906        let temp_dir = TempDir::new().unwrap();
907        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
908
909        let result = registry.validate_values("unknown", &json!({}));
910        assert!(matches!(result, Err(ValidationError::UnknownNamespace(..))));
911    }
912
913    #[test]
914    fn test_multiple_namespaces() {
915        let temp_dir = TempDir::new().unwrap();
916        create_test_schema(
917            &temp_dir,
918            "ns1",
919            r#"{
920                "version": "1.0",
921                "type": "object",
922                "properties": {
923                    "opt1": {
924                        "type": "string",
925                        "default": "default1",
926                        "description": "First option"
927                    }
928                }
929            }"#,
930        );
931        create_test_schema(
932            &temp_dir,
933            "ns2",
934            r#"{
935                "version": "2.0",
936                "type": "object",
937                "properties": {
938                    "opt2": {
939                        "type": "integer",
940                        "default": 42,
941                        "description": "Second option"
942                    }
943                }
944            }"#,
945        );
946
947        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
948        assert!(registry.schemas.contains_key("ns1"));
949        assert!(registry.schemas.contains_key("ns2"));
950    }
951
952    #[test]
953    fn test_invalid_default_type() {
954        let temp_dir = TempDir::new().unwrap();
955        create_test_schema(
956            &temp_dir,
957            "test",
958            r#"{
959                "version": "1.0",
960                "type": "object",
961                "properties": {
962                    "bad-default": {
963                        "type": "integer",
964                        "default": "not-a-number",
965                        "description": "A bad default value"
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.contains("Property 'bad-default': default value does not match schema")
977                );
978                assert!(message.contains("\"not-a-number\" is not of type \"integer\""));
979            }
980            _ => panic!("Expected SchemaError for invalid default type"),
981        }
982    }
983
984    #[test]
985    fn test_extra_properties() {
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                    "bad-property": {
995                        "type": "integer",
996                        "default": 0,
997                        "description": "Test property",
998                        "extra": "property"
999                    }
1000                }
1001            }"#,
1002        );
1003
1004        let result = SchemaRegistry::from_directory(temp_dir.path());
1005        assert!(result.is_err());
1006        match result {
1007            Err(ValidationError::SchemaError { message, .. }) => {
1008                assert!(
1009                    message
1010                        .contains("Additional properties are not allowed ('extra' was unexpected)")
1011                );
1012            }
1013            _ => panic!("Expected SchemaError for extra properties"),
1014        }
1015    }
1016
1017    #[test]
1018    fn test_missing_description() {
1019        let temp_dir = TempDir::new().unwrap();
1020        create_test_schema(
1021            &temp_dir,
1022            "test",
1023            r#"{
1024                "version": "1.0",
1025                "type": "object",
1026                "properties": {
1027                    "missing-desc": {
1028                        "type": "string",
1029                        "default": "test"
1030                    }
1031                }
1032            }"#,
1033        );
1034
1035        let result = SchemaRegistry::from_directory(temp_dir.path());
1036        assert!(result.is_err());
1037        match result {
1038            Err(ValidationError::SchemaError { message, .. }) => {
1039                assert!(message.contains("\"description\" is a required property"));
1040            }
1041            _ => panic!("Expected SchemaError for missing description"),
1042        }
1043    }
1044
1045    #[test]
1046    fn test_invalid_directory_structure() {
1047        let temp_dir = TempDir::new().unwrap();
1048        // Create a namespace directory without schema.json file
1049        let schema_dir = temp_dir.path().join("missing-schema");
1050        fs::create_dir_all(&schema_dir).unwrap();
1051
1052        let result = SchemaRegistry::from_directory(temp_dir.path());
1053        assert!(result.is_err());
1054        match result {
1055            Err(ValidationError::FileRead(..)) => {
1056                // Expected error when schema.json file is missing
1057            }
1058            _ => panic!("Expected FileRead error for missing schema.json"),
1059        }
1060    }
1061
1062    #[test]
1063    fn test_get_default() {
1064        let temp_dir = TempDir::new().unwrap();
1065        create_test_schema(
1066            &temp_dir,
1067            "test",
1068            r#"{
1069                "version": "1.0",
1070                "type": "object",
1071                "properties": {
1072                    "string_opt": {
1073                        "type": "string",
1074                        "default": "hello",
1075                        "description": "A string option"
1076                    },
1077                    "int_opt": {
1078                        "type": "integer",
1079                        "default": 42,
1080                        "description": "An integer option"
1081                    }
1082                }
1083            }"#,
1084        );
1085
1086        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1087        let schema = registry.get("test").unwrap();
1088
1089        assert_eq!(schema.get_default("string_opt"), Some(&json!("hello")));
1090        assert_eq!(schema.get_default("int_opt"), Some(&json!(42)));
1091        assert_eq!(schema.get_default("unknown"), None);
1092    }
1093
1094    #[test]
1095    fn test_validate_values_valid() {
1096        let temp_dir = TempDir::new().unwrap();
1097        create_test_schema(
1098            &temp_dir,
1099            "test",
1100            r#"{
1101                "version": "1.0",
1102                "type": "object",
1103                "properties": {
1104                    "enabled": {
1105                        "type": "boolean",
1106                        "default": false,
1107                        "description": "Enable feature"
1108                    }
1109                }
1110            }"#,
1111        );
1112
1113        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1114        let result = registry.validate_values("test", &json!({"enabled": true}));
1115        assert!(result.is_ok());
1116    }
1117
1118    #[test]
1119    fn test_validate_values_invalid_type() {
1120        let temp_dir = TempDir::new().unwrap();
1121        create_test_schema(
1122            &temp_dir,
1123            "test",
1124            r#"{
1125                "version": "1.0",
1126                "type": "object",
1127                "properties": {
1128                    "count": {
1129                        "type": "integer",
1130                        "default": 0,
1131                        "description": "Count"
1132                    }
1133                }
1134            }"#,
1135        );
1136
1137        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1138        let result = registry.validate_values("test", &json!({"count": "not a number"}));
1139        assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1140    }
1141
1142    #[test]
1143    fn test_validate_values_unknown_option() {
1144        let temp_dir = TempDir::new().unwrap();
1145        create_test_schema(
1146            &temp_dir,
1147            "test",
1148            r#"{
1149                "version": "1.0",
1150                "type": "object",
1151                "properties": {
1152                    "known_option": {
1153                        "type": "string",
1154                        "default": "default",
1155                        "description": "A known option"
1156                    }
1157                }
1158            }"#,
1159        );
1160
1161        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1162
1163        // Valid known option should pass
1164        let result = registry.validate_values("test", &json!({"known_option": "value"}));
1165        assert!(result.is_ok());
1166
1167        // Unknown option should fail
1168        let result = registry.validate_values("test", &json!({"unknown_option": "value"}));
1169        assert!(result.is_err());
1170        match result {
1171            Err(ValidationError::ValueError { errors, .. }) => {
1172                assert!(errors.contains("Additional properties are not allowed"));
1173            }
1174            _ => panic!("Expected ValueError for unknown option"),
1175        }
1176    }
1177
1178    #[test]
1179    fn test_load_values_json_valid() {
1180        let temp_dir = TempDir::new().unwrap();
1181        let schemas_dir = temp_dir.path().join("schemas");
1182        let values_dir = temp_dir.path().join("values");
1183
1184        let schema_dir = schemas_dir.join("test");
1185        fs::create_dir_all(&schema_dir).unwrap();
1186        fs::write(
1187            schema_dir.join("schema.json"),
1188            r#"{
1189                "version": "1.0",
1190                "type": "object",
1191                "properties": {
1192                    "enabled": {
1193                        "type": "boolean",
1194                        "default": false,
1195                        "description": "Enable feature"
1196                    },
1197                    "name": {
1198                        "type": "string",
1199                        "default": "default",
1200                        "description": "Name"
1201                    },
1202                    "count": {
1203                        "type": "integer",
1204                        "default": 0,
1205                        "description": "Count"
1206                    },
1207                    "rate": {
1208                        "type": "number",
1209                        "default": 0.0,
1210                        "description": "Rate"
1211                    }
1212                }
1213            }"#,
1214        )
1215        .unwrap();
1216
1217        let test_values_dir = values_dir.join("test");
1218        fs::create_dir_all(&test_values_dir).unwrap();
1219        fs::write(
1220            test_values_dir.join("values.json"),
1221            r#"{
1222                "options": {
1223                    "enabled": true,
1224                    "name": "test-name",
1225                    "count": 42,
1226                    "rate": 0.75
1227                }
1228            }"#,
1229        )
1230        .unwrap();
1231
1232        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1233        let (values, generated_at_by_namespace) = registry.load_values_json(&values_dir).unwrap();
1234
1235        assert_eq!(values.len(), 1);
1236        assert_eq!(values["test"]["enabled"], json!(true));
1237        assert_eq!(values["test"]["name"], json!("test-name"));
1238        assert_eq!(values["test"]["count"], json!(42));
1239        assert_eq!(values["test"]["rate"], json!(0.75));
1240        assert!(generated_at_by_namespace.is_empty());
1241    }
1242
1243    #[test]
1244    fn test_load_values_json_nonexistent_dir() {
1245        let temp_dir = TempDir::new().unwrap();
1246        create_test_schema(
1247            &temp_dir,
1248            "test",
1249            r#"{"version": "1.0", "type": "object", "properties": {}}"#,
1250        );
1251
1252        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1253        let (values, generated_at_by_namespace) = registry
1254            .load_values_json(&temp_dir.path().join("nonexistent"))
1255            .unwrap();
1256
1257        // No values.json files found, returns empty
1258        assert!(values.is_empty());
1259        assert!(generated_at_by_namespace.is_empty());
1260    }
1261
1262    #[test]
1263    fn test_load_values_json_skips_missing_values_file() {
1264        let temp_dir = TempDir::new().unwrap();
1265        let schemas_dir = temp_dir.path().join("schemas");
1266        let values_dir = temp_dir.path().join("values");
1267
1268        // Create two schemas
1269        let schema_dir1 = schemas_dir.join("with-values");
1270        fs::create_dir_all(&schema_dir1).unwrap();
1271        fs::write(
1272            schema_dir1.join("schema.json"),
1273            r#"{
1274                "version": "1.0",
1275                "type": "object",
1276                "properties": {
1277                    "opt": {"type": "string", "default": "x", "description": "Opt"}
1278                }
1279            }"#,
1280        )
1281        .unwrap();
1282
1283        let schema_dir2 = schemas_dir.join("without-values");
1284        fs::create_dir_all(&schema_dir2).unwrap();
1285        fs::write(
1286            schema_dir2.join("schema.json"),
1287            r#"{
1288                "version": "1.0",
1289                "type": "object",
1290                "properties": {
1291                    "opt": {"type": "string", "default": "x", "description": "Opt"}
1292                }
1293            }"#,
1294        )
1295        .unwrap();
1296
1297        // Only create values for one namespace
1298        let with_values_dir = values_dir.join("with-values");
1299        fs::create_dir_all(&with_values_dir).unwrap();
1300        fs::write(
1301            with_values_dir.join("values.json"),
1302            r#"{"options": {"opt": "y"}}"#,
1303        )
1304        .unwrap();
1305
1306        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1307        let (values, _) = registry.load_values_json(&values_dir).unwrap();
1308
1309        assert_eq!(values.len(), 1);
1310        assert!(values.contains_key("with-values"));
1311        assert!(!values.contains_key("without-values"));
1312    }
1313
1314    #[test]
1315    fn test_load_values_json_extracts_generated_at() {
1316        let temp_dir = TempDir::new().unwrap();
1317        let schemas_dir = temp_dir.path().join("schemas");
1318        let values_dir = temp_dir.path().join("values");
1319
1320        let schema_dir = schemas_dir.join("test");
1321        fs::create_dir_all(&schema_dir).unwrap();
1322        fs::write(
1323            schema_dir.join("schema.json"),
1324            r#"{
1325                "version": "1.0",
1326                "type": "object",
1327                "properties": {
1328                    "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1329                }
1330            }"#,
1331        )
1332        .unwrap();
1333
1334        let test_values_dir = values_dir.join("test");
1335        fs::create_dir_all(&test_values_dir).unwrap();
1336        fs::write(
1337            test_values_dir.join("values.json"),
1338            r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T18:30:00.123456+00:00"}"#,
1339        )
1340        .unwrap();
1341
1342        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1343        let (values, generated_at_by_namespace) = registry.load_values_json(&values_dir).unwrap();
1344
1345        assert_eq!(values["test"]["enabled"], json!(true));
1346        assert_eq!(
1347            generated_at_by_namespace.get("test"),
1348            Some(&"2024-01-21T18:30:00.123456+00:00".to_string())
1349        );
1350    }
1351
1352    #[test]
1353    fn test_load_values_json_rejects_wrong_type() {
1354        let temp_dir = TempDir::new().unwrap();
1355        let schemas_dir = temp_dir.path().join("schemas");
1356        let values_dir = temp_dir.path().join("values");
1357
1358        let schema_dir = schemas_dir.join("test");
1359        fs::create_dir_all(&schema_dir).unwrap();
1360        fs::write(
1361            schema_dir.join("schema.json"),
1362            r#"{
1363                "version": "1.0",
1364                "type": "object",
1365                "properties": {
1366                    "count": {"type": "integer", "default": 0, "description": "Count"}
1367                }
1368            }"#,
1369        )
1370        .unwrap();
1371
1372        let test_values_dir = values_dir.join("test");
1373        fs::create_dir_all(&test_values_dir).unwrap();
1374        fs::write(
1375            test_values_dir.join("values.json"),
1376            r#"{"options": {"count": "not-a-number"}}"#,
1377        )
1378        .unwrap();
1379
1380        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1381        let result = registry.load_values_json(&values_dir);
1382
1383        assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1384    }
1385
1386    mod feature_flag_tests {
1387        use super::*;
1388
1389        const FEATURE_SCHEMA: &str = r##"{
1390            "version": "1.0",
1391            "type": "object",
1392            "properties": {
1393                "feature.organizations:fury-mode": {
1394                  "$ref": "#/definitions/Feature"
1395                }
1396            }
1397        }"##;
1398
1399        #[test]
1400        fn test_schema_with_valid_feature_flag() {
1401            let temp_dir = TempDir::new().unwrap();
1402            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
1403            assert!(SchemaRegistry::from_directory(temp_dir.path()).is_ok());
1404        }
1405
1406        #[test]
1407        fn test_schema_with_feature_and_regular_option() {
1408            let temp_dir = TempDir::new().unwrap();
1409            create_test_schema(
1410                &temp_dir,
1411                "test",
1412                r##"{
1413                    "version": "1.0",
1414                    "type": "object",
1415                    "properties": {
1416                        "my-option": {
1417                            "type": "string",
1418                            "default": "hello",
1419                            "description": "A regular option"
1420                        },
1421                        "feature.organizations:fury-mode": {
1422                            "$ref": "#/definitions/Feature"
1423                        }
1424                    }
1425                }"##,
1426            );
1427            assert!(SchemaRegistry::from_directory(temp_dir.path()).is_ok());
1428        }
1429
1430        #[test]
1431        fn test_schema_with_invalid_feature_definition() {
1432            let temp_dir = TempDir::new().unwrap();
1433
1434            // namespace schema is invalid as feature flag is invalid.
1435            create_test_schema(
1436                &temp_dir,
1437                "test",
1438                r#"{
1439                    "version": "1.0",
1440                    "type": "object",
1441                    "properties": {
1442                        "feature.organizations:fury-mode": {
1443                            "nope": "nope"
1444                        }
1445                    }
1446                }"#,
1447            );
1448            let result = SchemaRegistry::from_directory(temp_dir.path());
1449            assert!(result.is_err());
1450        }
1451
1452        #[test]
1453        fn test_validate_values_with_valid_feature_flag() {
1454            let temp_dir = TempDir::new().unwrap();
1455            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
1456            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1457
1458            let result = registry.validate_values(
1459                "test",
1460                &json!({
1461                    "feature.organizations:fury-mode": {
1462                        "owner": {"team": "hybrid-cloud"},
1463                        "segments": [],
1464                        "created_at": "2024-01-01"
1465                    }
1466                }),
1467            );
1468            assert!(result.is_ok());
1469        }
1470
1471        #[test]
1472        fn test_validate_values_with_feature_flag_missing_required_field_fails() {
1473            let temp_dir = TempDir::new().unwrap();
1474            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
1475            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1476
1477            // Missing owner field
1478            let result = registry.validate_values(
1479                "test",
1480                &json!({
1481                    "feature.organizations:fury-mode": {
1482                        "segments": [],
1483                        "created_at": "2024-01-01"
1484                    }
1485                }),
1486            );
1487            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1488        }
1489
1490        #[test]
1491        fn test_validate_values_with_feature_flag_invalid_owner_fails() {
1492            let temp_dir = TempDir::new().unwrap();
1493            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
1494            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1495
1496            // Owner missing required team field
1497            let result = registry.validate_values(
1498                "test",
1499                &json!({
1500                    "feature.organizations:fury-mode": {
1501                        "owner": {"email": "test@example.com"},
1502                        "segments": [],
1503                        "created_at": "2024-01-01"
1504                    }
1505                }),
1506            );
1507            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1508        }
1509
1510        #[test]
1511        fn test_validate_values_feature_with_segments_and_conditions() {
1512            let temp_dir = TempDir::new().unwrap();
1513            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
1514            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1515
1516            let result = registry.validate_values(
1517                "test",
1518                &json!({
1519                    "feature.organizations:fury-mode": {
1520                        "owner": {"team": "hybrid-cloud"},
1521                        "enabled": true,
1522                        "created_at": "2024-01-01T00:00:00",
1523                        "segments": [
1524                            {
1525                                "name": "internal orgs",
1526                                "rollout": 50,
1527                                "conditions": [
1528                                    {
1529                                        "property": "organization_slug",
1530                                        "operator": "in",
1531                                        "value": ["sentry-test", "sentry"]
1532                                    }
1533                                ]
1534                            }
1535                        ]
1536                    }
1537                }),
1538            );
1539            assert!(result.is_ok());
1540        }
1541
1542        #[test]
1543        fn test_validate_values_feature_with_multiple_condition_operators() {
1544            let temp_dir = TempDir::new().unwrap();
1545            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
1546            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1547
1548            let result = registry.validate_values(
1549                "test",
1550                &json!({
1551                    "feature.organizations:fury-mode": {
1552                        "owner": {"team": "hybrid-cloud"},
1553                        "created_at": "2024-01-01",
1554                        "segments": [
1555                            {
1556                                "name": "free accounts",
1557                                "conditions": [
1558                                    {
1559                                        "property": "subscription_is_free",
1560                                        "operator": "equals",
1561                                        "value": true
1562                                    }
1563                                ]
1564                            }
1565                        ]
1566                    }
1567                }),
1568            );
1569            assert!(result.is_ok());
1570        }
1571
1572        #[test]
1573        fn test_validate_values_feature_with_invalid_condition_operator_fails() {
1574            let temp_dir = TempDir::new().unwrap();
1575            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
1576            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1577
1578            // Use an operator that doesn't exist
1579            let result = registry.validate_values(
1580                "test",
1581                &json!({
1582                    "feature.organizations:fury-mode": {
1583                        "owner": {"team": "hybrid-cloud"},
1584                        "created_at": "2024-01-01",
1585                        "segments": [
1586                            {
1587                                "name": "test segment",
1588                                "conditions": [
1589                                    {
1590                                        "property": "some_prop",
1591                                        "operator": "invalid_operator",
1592                                        "value": "some_value"
1593                                    }
1594                                ]
1595                            }
1596                        ]
1597                    }
1598                }),
1599            );
1600            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1601        }
1602
1603        #[test]
1604        fn test_schema_feature_flag_not_in_options_map() {
1605            // Feature flags are not added to default values
1606            let temp_dir = TempDir::new().unwrap();
1607            create_test_schema(&temp_dir, "test", FEATURE_SCHEMA);
1608            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1609            let schema = registry.get("test").unwrap();
1610
1611            assert!(
1612                schema
1613                    .get_default("feature.organizations:fury-mode")
1614                    .is_none()
1615            );
1616        }
1617
1618        #[test]
1619        fn test_validate_values_feature_and_regular_option_together() {
1620            let temp_dir = TempDir::new().unwrap();
1621            create_test_schema(
1622                &temp_dir,
1623                "test",
1624                r##"{
1625                    "version": "1.0",
1626                    "type": "object",
1627                    "properties": {
1628                        "my-option": {
1629                            "type": "string",
1630                            "default": "hello",
1631                            "description": "A regular option"
1632                        },
1633                        "feature.organizations:fury-mode": {
1634                            "$ref": "#/definitions/Feature"
1635                        }
1636                    }
1637                }"##,
1638            );
1639            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1640
1641            // Both options are valid
1642            let result = registry.validate_values(
1643                "test",
1644                &json!({
1645                    "my-option": "world",
1646                    "feature.organizations:fury-mode": {
1647                        "owner": {"team": "hybrid-cloud"},
1648                        "segments": [],
1649                        "created_at": "2024-01-01"
1650                    }
1651                }),
1652            );
1653            assert!(result.is_ok());
1654        }
1655    }
1656
1657    mod watcher_tests {
1658        use super::*;
1659        use std::thread;
1660
1661        /// Creates schema and values files for two namespaces: ns1, and ns2
1662        fn setup_watcher_test() -> (TempDir, PathBuf, PathBuf) {
1663            let temp_dir = TempDir::new().unwrap();
1664            let schemas_dir = temp_dir.path().join("schemas");
1665            let values_dir = temp_dir.path().join("values");
1666
1667            let ns1_schema = schemas_dir.join("ns1");
1668            fs::create_dir_all(&ns1_schema).unwrap();
1669            fs::write(
1670                ns1_schema.join("schema.json"),
1671                r#"{
1672                    "version": "1.0",
1673                    "type": "object",
1674                    "properties": {
1675                        "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1676                    }
1677                }"#,
1678            )
1679            .unwrap();
1680
1681            let ns1_values = values_dir.join("ns1");
1682            fs::create_dir_all(&ns1_values).unwrap();
1683            fs::write(
1684                ns1_values.join("values.json"),
1685                r#"{"options": {"enabled": true}}"#,
1686            )
1687            .unwrap();
1688
1689            let ns2_schema = schemas_dir.join("ns2");
1690            fs::create_dir_all(&ns2_schema).unwrap();
1691            fs::write(
1692                ns2_schema.join("schema.json"),
1693                r#"{
1694                    "version": "1.0",
1695                    "type": "object",
1696                    "properties": {
1697                        "count": {"type": "integer", "default": 0, "description": "Count"}
1698                    }
1699                }"#,
1700            )
1701            .unwrap();
1702
1703            let ns2_values = values_dir.join("ns2");
1704            fs::create_dir_all(&ns2_values).unwrap();
1705            fs::write(
1706                ns2_values.join("values.json"),
1707                r#"{"options": {"count": 42}}"#,
1708            )
1709            .unwrap();
1710
1711            (temp_dir, schemas_dir, values_dir)
1712        }
1713
1714        #[test]
1715        fn test_get_mtime_returns_most_recent() {
1716            let (_temp, _schemas, values_dir) = setup_watcher_test();
1717
1718            // Get initial mtime
1719            let mtime1 = ValuesWatcher::get_mtime(&values_dir);
1720            assert!(mtime1.is_some());
1721
1722            // Modify one namespace
1723            thread::sleep(std::time::Duration::from_millis(10));
1724            fs::write(
1725                values_dir.join("ns1").join("values.json"),
1726                r#"{"options": {"enabled": false}}"#,
1727            )
1728            .unwrap();
1729
1730            // Should detect the change
1731            let mtime2 = ValuesWatcher::get_mtime(&values_dir);
1732            assert!(mtime2.is_some());
1733            assert!(mtime2 > mtime1);
1734        }
1735
1736        #[test]
1737        fn test_get_mtime_with_missing_directory() {
1738            let temp = TempDir::new().unwrap();
1739            let nonexistent = temp.path().join("nonexistent");
1740
1741            let mtime = ValuesWatcher::get_mtime(&nonexistent);
1742            assert!(mtime.is_none());
1743        }
1744
1745        #[test]
1746        fn test_reload_values_updates_map() {
1747            let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1748
1749            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1750            let (initial_values, _) = registry.load_values_json(&values_dir).unwrap();
1751            let values = Arc::new(RwLock::new(initial_values));
1752
1753            // ensure initial values are correct
1754            {
1755                let guard = values.read().unwrap();
1756                assert_eq!(guard["ns1"]["enabled"], json!(true));
1757                assert_eq!(guard["ns2"]["count"], json!(42));
1758            }
1759
1760            // modify
1761            fs::write(
1762                values_dir.join("ns1").join("values.json"),
1763                r#"{"options": {"enabled": false}}"#,
1764            )
1765            .unwrap();
1766            fs::write(
1767                values_dir.join("ns2").join("values.json"),
1768                r#"{"options": {"count": 100}}"#,
1769            )
1770            .unwrap();
1771
1772            // force a reload
1773            ValuesWatcher::reload_values(&values_dir, &registry, &values);
1774
1775            // ensure new values are correct
1776            {
1777                let guard = values.read().unwrap();
1778                assert_eq!(guard["ns1"]["enabled"], json!(false));
1779                assert_eq!(guard["ns2"]["count"], json!(100));
1780            }
1781        }
1782
1783        #[test]
1784        fn test_old_values_persist_with_invalid_data() {
1785            let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1786
1787            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1788            let (initial_values, _) = registry.load_values_json(&values_dir).unwrap();
1789            let values = Arc::new(RwLock::new(initial_values));
1790
1791            let initial_enabled = {
1792                let guard = values.read().unwrap();
1793                guard["ns1"]["enabled"].clone()
1794            };
1795
1796            // won't pass validation
1797            fs::write(
1798                values_dir.join("ns1").join("values.json"),
1799                r#"{"options": {"enabled": "not-a-boolean"}}"#,
1800            )
1801            .unwrap();
1802
1803            ValuesWatcher::reload_values(&values_dir, &registry, &values);
1804
1805            // ensure old value persists
1806            {
1807                let guard = values.read().unwrap();
1808                assert_eq!(guard["ns1"]["enabled"], initial_enabled);
1809            }
1810        }
1811
1812        #[test]
1813        fn test_watcher_creation_and_termination() {
1814            let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1815
1816            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1817            let (initial_values, _) = registry.load_values_json(&values_dir).unwrap();
1818            let values = Arc::new(RwLock::new(initial_values));
1819
1820            let mut watcher =
1821                ValuesWatcher::new(&values_dir, Arc::clone(&registry), Arc::clone(&values))
1822                    .expect("Failed to create watcher");
1823
1824            assert!(watcher.is_alive());
1825            watcher.stop();
1826            assert!(!watcher.is_alive());
1827        }
1828    }
1829    mod array_tests {
1830        use super::*;
1831
1832        #[test]
1833        fn test_basic_schema_validation() {
1834            let temp_dir = TempDir::new().unwrap();
1835            for (a_type, default) in [
1836                ("boolean", ""), // empty array test
1837                ("boolean", "true"),
1838                ("integer", "1"),
1839                ("number", "1.2"),
1840                ("string", "\"wow\""),
1841            ] {
1842                create_test_schema(
1843                    &temp_dir,
1844                    "test",
1845                    &format!(
1846                        r#"{{
1847                        "version": "1.0",
1848                        "type": "object",
1849                        "properties": {{
1850                            "array-key": {{
1851                                "type": "array",
1852                                "items": {{"type": "{}"}},
1853                                "default": [{}],
1854                                "description": "Array option"
1855                                }}
1856                            }}
1857                        }}"#,
1858                        a_type, default
1859                    ),
1860                );
1861
1862                SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1863            }
1864        }
1865
1866        #[test]
1867        fn test_missing_items_object_rejection() {
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                        "array-key": {
1877                            "type": "array",
1878                            "default": [1,2,3],
1879                            "description": "Array option"
1880                        }
1881                    }
1882                }"#,
1883            );
1884
1885            let result = SchemaRegistry::from_directory(temp_dir.path());
1886            assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
1887        }
1888
1889        #[test]
1890        fn test_malformed_items_rejection() {
1891            let temp_dir = TempDir::new().unwrap();
1892            create_test_schema(
1893                &temp_dir,
1894                "test",
1895                r#"{
1896                    "version": "1.0",
1897                    "type": "object",
1898                    "properties": {
1899                        "array-key": {
1900                            "type": "array",
1901                            "items": {"type": ""},
1902                            "default": [1,2,3],
1903                            "description": "Array option"
1904                        }
1905                    }
1906                }"#,
1907            );
1908
1909            let result = SchemaRegistry::from_directory(temp_dir.path());
1910            assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
1911        }
1912
1913        #[test]
1914        fn test_schema_default_type_mismatch_rejection() {
1915            let temp_dir = TempDir::new().unwrap();
1916            // also tests real number rejection when type is integer
1917            create_test_schema(
1918                &temp_dir,
1919                "test",
1920                r#"{
1921                    "version": "1.0",
1922                    "type": "object",
1923                    "properties": {
1924                        "array-key": {
1925                            "type": "array",
1926                            "items": {"type": "integer"},
1927                            "default": [1,2,3.3],
1928                            "description": "Array option"
1929                        }
1930                    }
1931                }"#,
1932            );
1933
1934            let result = SchemaRegistry::from_directory(temp_dir.path());
1935            assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
1936        }
1937
1938        #[test]
1939        fn test_schema_default_heterogeneous_rejection() {
1940            let temp_dir = TempDir::new().unwrap();
1941            create_test_schema(
1942                &temp_dir,
1943                "test",
1944                r#"{
1945                    "version": "1.0",
1946                    "type": "object",
1947                    "properties": {
1948                        "array-key": {
1949                            "type": "array",
1950                            "items": {"type": "integer"},
1951                            "default": [1,2,"uh oh!"],
1952                            "description": "Array option"
1953                        }
1954                    }
1955                }"#,
1956            );
1957
1958            let result = SchemaRegistry::from_directory(temp_dir.path());
1959            assert!(matches!(result, Err(ValidationError::SchemaError { .. })));
1960        }
1961
1962        #[test]
1963        fn test_load_values_valid() {
1964            let temp_dir = TempDir::new().unwrap();
1965            let (schemas_dir, values_dir) = create_test_schema_with_values(
1966                &temp_dir,
1967                "test",
1968                r#"{
1969                    "version": "1.0",
1970                    "type": "object",
1971                    "properties": {
1972                        "array-key": {
1973                            "type": "array",
1974                            "items": {"type": "integer"},
1975                            "default": [1,2,3],
1976                            "description": "Array option"
1977                        }
1978                    }
1979                }"#,
1980                r#"{
1981                    "options": {
1982                        "array-key": [4,5,6]
1983                    }
1984                }"#,
1985            );
1986
1987            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1988            let (values, generated_at_by_namespace) =
1989                registry.load_values_json(&values_dir).unwrap();
1990
1991            assert_eq!(values.len(), 1);
1992            assert_eq!(values["test"]["array-key"], json!([4, 5, 6]));
1993            assert!(generated_at_by_namespace.is_empty());
1994        }
1995
1996        #[test]
1997        fn test_reject_values_not_an_array() {
1998            let temp_dir = TempDir::new().unwrap();
1999            let (schemas_dir, values_dir) = create_test_schema_with_values(
2000                &temp_dir,
2001                "test",
2002                r#"{
2003                    "version": "1.0",
2004                    "type": "object",
2005                    "properties": {
2006                        "array-key": {
2007                            "type": "array",
2008                            "items": {"type": "integer"},
2009                            "default": [1,2,3],
2010                            "description": "Array option"
2011                        }
2012                    }
2013                }"#,
2014                // sneaky! not an array
2015                r#"{
2016                    "options": {
2017                        "array-key": "[]"
2018                    }
2019                }"#,
2020            );
2021
2022            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2023            let result = registry.load_values_json(&values_dir);
2024
2025            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2026        }
2027
2028        #[test]
2029        fn test_reject_values_mismatch() {
2030            let temp_dir = TempDir::new().unwrap();
2031            let (schemas_dir, values_dir) = create_test_schema_with_values(
2032                &temp_dir,
2033                "test",
2034                r#"{
2035                    "version": "1.0",
2036                    "type": "object",
2037                    "properties": {
2038                        "array-key": {
2039                            "type": "array",
2040                            "items": {"type": "integer"},
2041                            "default": [1,2,3],
2042                            "description": "Array option"
2043                        }
2044                    }
2045                }"#,
2046                r#"{
2047                    "options": {
2048                        "array-key": ["a","b","c"]
2049                    }
2050                }"#,
2051            );
2052
2053            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2054            let result = registry.load_values_json(&values_dir);
2055
2056            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2057        }
2058    }
2059
2060    mod object_tests {
2061        use super::*;
2062
2063        #[test]
2064        fn test_object_schema_loads() {
2065            let temp_dir = TempDir::new().unwrap();
2066            create_test_schema(
2067                &temp_dir,
2068                "test",
2069                r#"{
2070                    "version": "1.0",
2071                    "type": "object",
2072                    "properties": {
2073                        "config": {
2074                            "type": "object",
2075                            "properties": {
2076                                "host": {"type": "string"},
2077                                "port": {"type": "integer"},
2078                                "rate": {"type": "number"},
2079                                "enabled": {"type": "boolean"}
2080                            },
2081                            "default": {"host": "localhost", "port": 8080, "rate": 0.5, "enabled": true},
2082                            "description": "Service config"
2083                        }
2084                    }
2085                }"#,
2086            );
2087
2088            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2089            let schema = registry.get("test").unwrap();
2090            assert_eq!(schema.options["config"].option_type, "object");
2091        }
2092
2093        #[test]
2094        fn test_object_missing_properties_rejected() {
2095            let temp_dir = TempDir::new().unwrap();
2096            create_test_schema(
2097                &temp_dir,
2098                "test",
2099                r#"{
2100                    "version": "1.0",
2101                    "type": "object",
2102                    "properties": {
2103                        "config": {
2104                            "type": "object",
2105                            "default": {"host": "localhost"},
2106                            "description": "Missing properties field"
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_object_default_wrong_type_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                        "config": {
2127                            "type": "object",
2128                            "properties": {
2129                                "host": {"type": "string"},
2130                                "port": {"type": "integer"}
2131                            },
2132                            "default": {"host": "localhost", "port": "not-a-number"},
2133                            "description": "Bad default"
2134                        }
2135                    }
2136                }"#,
2137            );
2138
2139            let result = SchemaRegistry::from_directory(temp_dir.path());
2140            assert!(result.is_err());
2141        }
2142
2143        #[test]
2144        fn test_object_default_missing_field_rejected() {
2145            let temp_dir = TempDir::new().unwrap();
2146            create_test_schema(
2147                &temp_dir,
2148                "test",
2149                r#"{
2150                    "version": "1.0",
2151                    "type": "object",
2152                    "properties": {
2153                        "config": {
2154                            "type": "object",
2155                            "properties": {
2156                                "host": {"type": "string"},
2157                                "port": {"type": "integer"}
2158                            },
2159                            "default": {"host": "localhost"},
2160                            "description": "Missing port in default"
2161                        }
2162                    }
2163                }"#,
2164            );
2165
2166            let result = SchemaRegistry::from_directory(temp_dir.path());
2167            assert!(result.is_err());
2168        }
2169
2170        #[test]
2171        fn test_object_default_extra_field_rejected() {
2172            let temp_dir = TempDir::new().unwrap();
2173            create_test_schema(
2174                &temp_dir,
2175                "test",
2176                r#"{
2177                    "version": "1.0",
2178                    "type": "object",
2179                    "properties": {
2180                        "config": {
2181                            "type": "object",
2182                            "properties": {
2183                                "host": {"type": "string"}
2184                            },
2185                            "default": {"host": "localhost", "extra": "field"},
2186                            "description": "Extra field in default"
2187                        }
2188                    }
2189                }"#,
2190            );
2191
2192            let result = SchemaRegistry::from_directory(temp_dir.path());
2193            assert!(result.is_err());
2194        }
2195
2196        #[test]
2197        fn test_object_values_valid() {
2198            let temp_dir = TempDir::new().unwrap();
2199            let (schemas_dir, values_dir) = create_test_schema_with_values(
2200                &temp_dir,
2201                "test",
2202                r#"{
2203                    "version": "1.0",
2204                    "type": "object",
2205                    "properties": {
2206                        "config": {
2207                            "type": "object",
2208                            "properties": {
2209                                "host": {"type": "string"},
2210                                "port": {"type": "integer"}
2211                            },
2212                            "default": {"host": "localhost", "port": 8080},
2213                            "description": "Service config"
2214                        }
2215                    }
2216                }"#,
2217                r#"{
2218                    "options": {
2219                        "config": {"host": "example.com", "port": 9090}
2220                    }
2221                }"#,
2222            );
2223
2224            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2225            let result = registry.load_values_json(&values_dir);
2226            assert!(result.is_ok());
2227        }
2228
2229        #[test]
2230        fn test_object_values_wrong_field_type_rejected() {
2231            let temp_dir = TempDir::new().unwrap();
2232            let (schemas_dir, values_dir) = create_test_schema_with_values(
2233                &temp_dir,
2234                "test",
2235                r#"{
2236                    "version": "1.0",
2237                    "type": "object",
2238                    "properties": {
2239                        "config": {
2240                            "type": "object",
2241                            "properties": {
2242                                "host": {"type": "string"},
2243                                "port": {"type": "integer"}
2244                            },
2245                            "default": {"host": "localhost", "port": 8080},
2246                            "description": "Service config"
2247                        }
2248                    }
2249                }"#,
2250                r#"{
2251                    "options": {
2252                        "config": {"host": "example.com", "port": "not-a-number"}
2253                    }
2254                }"#,
2255            );
2256
2257            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2258            let result = registry.load_values_json(&values_dir);
2259            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2260        }
2261
2262        #[test]
2263        fn test_object_values_extra_field_rejected() {
2264            let temp_dir = TempDir::new().unwrap();
2265            let (schemas_dir, values_dir) = create_test_schema_with_values(
2266                &temp_dir,
2267                "test",
2268                r#"{
2269                    "version": "1.0",
2270                    "type": "object",
2271                    "properties": {
2272                        "config": {
2273                            "type": "object",
2274                            "properties": {
2275                                "host": {"type": "string"}
2276                            },
2277                            "default": {"host": "localhost"},
2278                            "description": "Service config"
2279                        }
2280                    }
2281                }"#,
2282                r#"{
2283                    "options": {
2284                        "config": {"host": "example.com", "extra": "field"}
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]
2295        fn test_object_values_missing_field_rejected() {
2296            let temp_dir = TempDir::new().unwrap();
2297            let (schemas_dir, values_dir) = create_test_schema_with_values(
2298                &temp_dir,
2299                "test",
2300                r#"{
2301                    "version": "1.0",
2302                    "type": "object",
2303                    "properties": {
2304                        "config": {
2305                            "type": "object",
2306                            "properties": {
2307                                "host": {"type": "string"},
2308                                "port": {"type": "integer"}
2309                            },
2310                            "default": {"host": "localhost", "port": 8080},
2311                            "description": "Service config"
2312                        }
2313                    }
2314                }"#,
2315                r#"{
2316                    "options": {
2317                        "config": {"host": "example.com"}
2318                    }
2319                }"#,
2320            );
2321
2322            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2323            let result = registry.load_values_json(&values_dir);
2324            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2325        }
2326
2327        // Array of objects tests
2328
2329        #[test]
2330        fn test_array_of_objects_schema_loads() {
2331            let temp_dir = TempDir::new().unwrap();
2332            create_test_schema(
2333                &temp_dir,
2334                "test",
2335                r#"{
2336                    "version": "1.0",
2337                    "type": "object",
2338                    "properties": {
2339                        "endpoints": {
2340                            "type": "array",
2341                            "items": {
2342                                "type": "object",
2343                                "properties": {
2344                                    "url": {"type": "string"},
2345                                    "weight": {"type": "integer"}
2346                                }
2347                            },
2348                            "default": [{"url": "https://a.example.com", "weight": 1}],
2349                            "description": "Endpoints"
2350                        }
2351                    }
2352                }"#,
2353            );
2354
2355            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2356            let schema = registry.get("test").unwrap();
2357            assert_eq!(schema.options["endpoints"].option_type, "array");
2358        }
2359
2360        #[test]
2361        fn test_array_of_objects_empty_default() {
2362            let temp_dir = TempDir::new().unwrap();
2363            create_test_schema(
2364                &temp_dir,
2365                "test",
2366                r#"{
2367                    "version": "1.0",
2368                    "type": "object",
2369                    "properties": {
2370                        "endpoints": {
2371                            "type": "array",
2372                            "items": {
2373                                "type": "object",
2374                                "properties": {
2375                                    "url": {"type": "string"},
2376                                    "weight": {"type": "integer"}
2377                                }
2378                            },
2379                            "default": [],
2380                            "description": "Endpoints"
2381                        }
2382                    }
2383                }"#,
2384            );
2385
2386            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2387            assert!(registry.get("test").is_some());
2388        }
2389
2390        #[test]
2391        fn test_array_of_objects_default_wrong_field_type_rejected() {
2392            let temp_dir = TempDir::new().unwrap();
2393            create_test_schema(
2394                &temp_dir,
2395                "test",
2396                r#"{
2397                    "version": "1.0",
2398                    "type": "object",
2399                    "properties": {
2400                        "endpoints": {
2401                            "type": "array",
2402                            "items": {
2403                                "type": "object",
2404                                "properties": {
2405                                    "url": {"type": "string"},
2406                                    "weight": {"type": "integer"}
2407                                }
2408                            },
2409                            "default": [{"url": "https://a.example.com", "weight": "not-a-number"}],
2410                            "description": "Endpoints"
2411                        }
2412                    }
2413                }"#,
2414            );
2415
2416            let result = SchemaRegistry::from_directory(temp_dir.path());
2417            assert!(result.is_err());
2418        }
2419
2420        #[test]
2421        fn test_array_of_objects_missing_items_properties_rejected() {
2422            let temp_dir = TempDir::new().unwrap();
2423            create_test_schema(
2424                &temp_dir,
2425                "test",
2426                r#"{
2427                    "version": "1.0",
2428                    "type": "object",
2429                    "properties": {
2430                        "endpoints": {
2431                            "type": "array",
2432                            "items": {
2433                                "type": "object"
2434                            },
2435                            "default": [],
2436                            "description": "Missing properties in items"
2437                        }
2438                    }
2439                }"#,
2440            );
2441
2442            let result = SchemaRegistry::from_directory(temp_dir.path());
2443            assert!(result.is_err());
2444        }
2445
2446        #[test]
2447        fn test_array_of_objects_values_valid() {
2448            let temp_dir = TempDir::new().unwrap();
2449            let (schemas_dir, values_dir) = create_test_schema_with_values(
2450                &temp_dir,
2451                "test",
2452                r#"{
2453                    "version": "1.0",
2454                    "type": "object",
2455                    "properties": {
2456                        "endpoints": {
2457                            "type": "array",
2458                            "items": {
2459                                "type": "object",
2460                                "properties": {
2461                                    "url": {"type": "string"},
2462                                    "weight": {"type": "integer"}
2463                                }
2464                            },
2465                            "default": [],
2466                            "description": "Endpoints"
2467                        }
2468                    }
2469                }"#,
2470                r#"{
2471                    "options": {
2472                        "endpoints": [
2473                            {"url": "https://a.example.com", "weight": 1},
2474                            {"url": "https://b.example.com", "weight": 2}
2475                        ]
2476                    }
2477                }"#,
2478            );
2479
2480            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2481            let result = registry.load_values_json(&values_dir);
2482            assert!(result.is_ok());
2483        }
2484
2485        #[test]
2486        fn test_array_of_objects_values_wrong_item_shape_rejected() {
2487            let temp_dir = TempDir::new().unwrap();
2488            let (schemas_dir, values_dir) = create_test_schema_with_values(
2489                &temp_dir,
2490                "test",
2491                r#"{
2492                    "version": "1.0",
2493                    "type": "object",
2494                    "properties": {
2495                        "endpoints": {
2496                            "type": "array",
2497                            "items": {
2498                                "type": "object",
2499                                "properties": {
2500                                    "url": {"type": "string"},
2501                                    "weight": {"type": "integer"}
2502                                }
2503                            },
2504                            "default": [],
2505                            "description": "Endpoints"
2506                        }
2507                    }
2508                }"#,
2509                r#"{
2510                    "options": {
2511                        "endpoints": [
2512                            {"url": "https://a.example.com", "weight": "not-a-number"}
2513                        ]
2514                    }
2515                }"#,
2516            );
2517
2518            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2519            let result = registry.load_values_json(&values_dir);
2520            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2521        }
2522
2523        #[test]
2524        fn test_array_of_objects_values_extra_field_rejected() {
2525            let temp_dir = TempDir::new().unwrap();
2526            let (schemas_dir, values_dir) = create_test_schema_with_values(
2527                &temp_dir,
2528                "test",
2529                r#"{
2530                    "version": "1.0",
2531                    "type": "object",
2532                    "properties": {
2533                        "endpoints": {
2534                            "type": "array",
2535                            "items": {
2536                                "type": "object",
2537                                "properties": {
2538                                    "url": {"type": "string"}
2539                                }
2540                            },
2541                            "default": [],
2542                            "description": "Endpoints"
2543                        }
2544                    }
2545                }"#,
2546                r#"{
2547                    "options": {
2548                        "endpoints": [
2549                            {"url": "https://a.example.com", "extra": "field"}
2550                        ]
2551                    }
2552                }"#,
2553            );
2554
2555            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2556            let result = registry.load_values_json(&values_dir);
2557            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2558        }
2559
2560        #[test]
2561        fn test_array_of_objects_values_missing_field_rejected() {
2562            let temp_dir = TempDir::new().unwrap();
2563            let (schemas_dir, values_dir) = create_test_schema_with_values(
2564                &temp_dir,
2565                "test",
2566                r#"{
2567                    "version": "1.0",
2568                    "type": "object",
2569                    "properties": {
2570                        "endpoints": {
2571                            "type": "array",
2572                            "items": {
2573                                "type": "object",
2574                                "properties": {
2575                                    "url": {"type": "string"},
2576                                    "weight": {"type": "integer"}
2577                                }
2578                            },
2579                            "default": [],
2580                            "description": "Endpoints"
2581                        }
2582                    }
2583                }"#,
2584                r#"{
2585                    "options": {
2586                        "endpoints": [
2587                            {"url": "https://a.example.com"}
2588                        ]
2589                    }
2590                }"#,
2591            );
2592
2593            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2594            let result = registry.load_values_json(&values_dir);
2595            assert!(matches!(result, Err(ValidationError::ValueError { .. })));
2596        }
2597
2598        // Optional field tests
2599
2600        #[test]
2601        fn test_object_optional_field_can_be_omitted_from_default() {
2602            let temp_dir = TempDir::new().unwrap();
2603            create_test_schema(
2604                &temp_dir,
2605                "test",
2606                r#"{
2607                    "version": "1.0",
2608                    "type": "object",
2609                    "properties": {
2610                        "config": {
2611                            "type": "object",
2612                            "properties": {
2613                                "host": {"type": "string"},
2614                                "debug": {"type": "boolean", "optional": true}
2615                            },
2616                            "default": {"host": "localhost"},
2617                            "description": "Config with optional field"
2618                        }
2619                    }
2620                }"#,
2621            );
2622
2623            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2624            let schema = registry.get("test").unwrap();
2625            assert_eq!(schema.options["config"].option_type, "object");
2626        }
2627
2628        #[test]
2629        fn test_object_optional_field_can_be_included_in_default() {
2630            let temp_dir = TempDir::new().unwrap();
2631            create_test_schema(
2632                &temp_dir,
2633                "test",
2634                r#"{
2635                    "version": "1.0",
2636                    "type": "object",
2637                    "properties": {
2638                        "config": {
2639                            "type": "object",
2640                            "properties": {
2641                                "host": {"type": "string"},
2642                                "debug": {"type": "boolean", "optional": true}
2643                            },
2644                            "default": {"host": "localhost", "debug": true},
2645                            "description": "Config with optional field included"
2646                        }
2647                    }
2648                }"#,
2649            );
2650
2651            let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
2652            assert!(registry.get("test").is_some());
2653        }
2654
2655        #[test]
2656        fn test_object_optional_field_wrong_type_rejected() {
2657            let temp_dir = TempDir::new().unwrap();
2658            create_test_schema(
2659                &temp_dir,
2660                "test",
2661                r#"{
2662                    "version": "1.0",
2663                    "type": "object",
2664                    "properties": {
2665                        "config": {
2666                            "type": "object",
2667                            "properties": {
2668                                "host": {"type": "string"},
2669                                "debug": {"type": "boolean", "optional": true}
2670                            },
2671                            "default": {"host": "localhost", "debug": "not-a-bool"},
2672                            "description": "Optional field wrong type"
2673                        }
2674                    }
2675                }"#,
2676            );
2677
2678            let result = SchemaRegistry::from_directory(temp_dir.path());
2679            assert!(result.is_err());
2680        }
2681
2682        #[test]
2683        fn test_object_required_field_still_required_with_optional_present() {
2684            let temp_dir = TempDir::new().unwrap();
2685            create_test_schema(
2686                &temp_dir,
2687                "test",
2688                r#"{
2689                    "version": "1.0",
2690                    "type": "object",
2691                    "properties": {
2692                        "config": {
2693                            "type": "object",
2694                            "properties": {
2695                                "host": {"type": "string"},
2696                                "port": {"type": "integer"},
2697                                "debug": {"type": "boolean", "optional": true}
2698                            },
2699                            "default": {"debug": true},
2700                            "description": "Missing required fields"
2701                        }
2702                    }
2703                }"#,
2704            );
2705
2706            let result = SchemaRegistry::from_directory(temp_dir.path());
2707            assert!(result.is_err());
2708        }
2709
2710        #[test]
2711        fn test_object_optional_field_omitted_from_values() {
2712            let temp_dir = TempDir::new().unwrap();
2713            let (schemas_dir, values_dir) = create_test_schema_with_values(
2714                &temp_dir,
2715                "test",
2716                r#"{
2717                    "version": "1.0",
2718                    "type": "object",
2719                    "properties": {
2720                        "config": {
2721                            "type": "object",
2722                            "properties": {
2723                                "host": {"type": "string"},
2724                                "debug": {"type": "boolean", "optional": true}
2725                            },
2726                            "default": {"host": "localhost"},
2727                            "description": "Config"
2728                        }
2729                    }
2730                }"#,
2731                r#"{
2732                    "options": {
2733                        "config": {"host": "example.com"}
2734                    }
2735                }"#,
2736            );
2737
2738            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2739            let result = registry.load_values_json(&values_dir);
2740            assert!(result.is_ok());
2741        }
2742
2743        #[test]
2744        fn test_object_optional_field_included_in_values() {
2745            let temp_dir = TempDir::new().unwrap();
2746            let (schemas_dir, values_dir) = create_test_schema_with_values(
2747                &temp_dir,
2748                "test",
2749                r#"{
2750                    "version": "1.0",
2751                    "type": "object",
2752                    "properties": {
2753                        "config": {
2754                            "type": "object",
2755                            "properties": {
2756                                "host": {"type": "string"},
2757                                "debug": {"type": "boolean", "optional": true}
2758                            },
2759                            "default": {"host": "localhost"},
2760                            "description": "Config"
2761                        }
2762                    }
2763                }"#,
2764                r#"{
2765                    "options": {
2766                        "config": {"host": "example.com", "debug": true}
2767                    }
2768                }"#,
2769            );
2770
2771            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2772            let result = registry.load_values_json(&values_dir);
2773            assert!(result.is_ok());
2774        }
2775
2776        #[test]
2777        fn test_array_of_objects_optional_field_omitted() {
2778            let temp_dir = TempDir::new().unwrap();
2779            let (schemas_dir, values_dir) = create_test_schema_with_values(
2780                &temp_dir,
2781                "test",
2782                r#"{
2783                    "version": "1.0",
2784                    "type": "object",
2785                    "properties": {
2786                        "endpoints": {
2787                            "type": "array",
2788                            "items": {
2789                                "type": "object",
2790                                "properties": {
2791                                    "url": {"type": "string"},
2792                                    "weight": {"type": "integer", "optional": true}
2793                                }
2794                            },
2795                            "default": [],
2796                            "description": "Endpoints"
2797                        }
2798                    }
2799                }"#,
2800                r#"{
2801                    "options": {
2802                        "endpoints": [
2803                            {"url": "https://a.example.com"},
2804                            {"url": "https://b.example.com", "weight": 2}
2805                        ]
2806                    }
2807                }"#,
2808            );
2809
2810            let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
2811            let result = registry.load_values_json(&values_dir);
2812            assert!(result.is_ok());
2813        }
2814    }
2815}