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