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