Skip to main content

sentry_options_validation/
lib.rs

1//! Schema validation library for sentry-options
2//!
3//! This library provides schema loading and validation for sentry-options.
4//! Schemas are loaded once and stored in Arc for efficient sharing.
5//! Values are validated against schemas as complete objects.
6
7use chrono::{DateTime, Utc};
8use sentry::ClientOptions;
9use sentry::transports::DefaultTransportFactory;
10use serde_json::Value;
11use serde_json::json;
12use std::collections::HashMap;
13use std::fs;
14use std::panic::{self, AssertUnwindSafe};
15use std::path::{Path, PathBuf};
16use std::sync::RwLock;
17use std::sync::{
18    Arc, OnceLock,
19    atomic::{AtomicBool, Ordering},
20};
21use std::thread::{self, JoinHandle};
22use std::time::{Duration, Instant};
23
24/// Embedded meta-schema for validating sentry-options schema files
25const NAMESPACE_SCHEMA_JSON: &str = include_str!("namespace-schema.json");
26const SCHEMA_FILE_NAME: &str = "schema.json";
27const VALUES_FILE_NAME: &str = "values.json";
28
29/// Time between file polls in seconds
30const POLLING_DELAY: u64 = 5;
31
32/// Dedicated Sentry DSN for sentry-options observability.
33/// This is separate from the host application's Sentry setup.
34const SENTRY_OPTIONS_DSN: &str =
35    "https://d3598a07e9f23a9acee9e2718cfd17bd@o1.ingest.us.sentry.io/4510750163927040";
36
37/// Lazily-initialized dedicated Sentry Hub for sentry-options.
38/// Uses a custom Client that is completely isolated from the host application's Sentry setup.
39static SENTRY_HUB: OnceLock<Arc<sentry::Hub>> = OnceLock::new();
40
41fn get_sentry_hub() -> &'static Arc<sentry::Hub> {
42    SENTRY_HUB.get_or_init(|| {
43        let client = Arc::new(sentry::Client::from((
44            SENTRY_OPTIONS_DSN,
45            ClientOptions {
46                traces_sample_rate: 1.0,
47                // Explicitly set transport factory - required when not using sentry::init()
48                transport: Some(Arc::new(DefaultTransportFactory)),
49                ..Default::default()
50            },
51        )));
52        Arc::new(sentry::Hub::new(
53            Some(client),
54            Arc::new(sentry::Scope::default()),
55        ))
56    })
57}
58
59/// Production path where options are deployed via config map
60pub const PRODUCTION_OPTIONS_DIR: &str = "/etc/sentry-options";
61
62/// Local fallback path for development
63pub const LOCAL_OPTIONS_DIR: &str = "sentry-options";
64
65/// Environment variable to override options directory
66pub const OPTIONS_DIR_ENV: &str = "SENTRY_OPTIONS_DIR";
67
68/// Resolve options directory using fallback chain:
69/// 1. `SENTRY_OPTIONS_DIR` env var (if set)
70/// 2. `/etc/sentry-options` (if exists)
71/// 3. `sentry-options/` (local fallback)
72pub fn resolve_options_dir() -> PathBuf {
73    if let Ok(dir) = std::env::var(OPTIONS_DIR_ENV) {
74        return PathBuf::from(dir);
75    }
76
77    let prod_path = PathBuf::from(PRODUCTION_OPTIONS_DIR);
78    if prod_path.exists() {
79        return prod_path;
80    }
81
82    PathBuf::from(LOCAL_OPTIONS_DIR)
83}
84
85/// Result type for validation operations
86pub type ValidationResult<T> = Result<T, ValidationError>;
87
88/// A map of option values keyed by their namespace
89pub type ValuesByNamespace = HashMap<String, HashMap<String, Value>>;
90
91/// Errors that can occur during schema and value validation
92#[derive(Debug, thiserror::Error)]
93pub enum ValidationError {
94    #[error("Schema error in {file}: {message}")]
95    SchemaError { file: PathBuf, message: String },
96
97    #[error("Value error for {namespace}: {errors}")]
98    ValueError { namespace: String, errors: String },
99
100    #[error("Unknown namespace: {0}")]
101    UnknownNamespace(String),
102
103    #[error("Internal error: {0}")]
104    InternalError(String),
105
106    #[error("Failed to read file: {0}")]
107    FileRead(#[from] std::io::Error),
108
109    #[error("Failed to parse JSON: {0}")]
110    JSONParse(#[from] serde_json::Error),
111
112    #[error("{} validation error(s)", .0.len())]
113    ValidationErrors(Vec<ValidationError>),
114
115    #[error("Invalid {label} '{name}': {reason}")]
116    InvalidName {
117        label: String,
118        name: String,
119        reason: String,
120    },
121}
122
123/// Validate a name component is valid for K8s (lowercase alphanumeric, '-', '.')
124pub fn validate_k8s_name_component(name: &str, label: &str) -> ValidationResult<()> {
125    if let Some(c) = name
126        .chars()
127        .find(|&c| !matches!(c, 'a'..='z' | '0'..='9' | '-' | '.'))
128    {
129        return Err(ValidationError::InvalidName {
130            label: label.to_string(),
131            name: name.to_string(),
132            reason: format!(
133                "character '{}' not allowed. Use lowercase alphanumeric, '-', or '.'",
134                c
135            ),
136        });
137    }
138    if !name.starts_with(|c: char| c.is_ascii_alphanumeric())
139        || !name.ends_with(|c: char| c.is_ascii_alphanumeric())
140    {
141        return Err(ValidationError::InvalidName {
142            label: label.to_string(),
143            name: name.to_string(),
144            reason: "must start and end with alphanumeric".to_string(),
145        });
146    }
147    Ok(())
148}
149
150/// Metadata for a single option in a namespace schema
151#[derive(Debug, Clone)]
152pub struct OptionMetadata {
153    pub option_type: String,
154    pub default: Value,
155}
156
157/// Schema for a namespace, containing validator and option metadata
158pub struct NamespaceSchema {
159    pub namespace: String,
160    pub options: HashMap<String, OptionMetadata>,
161    validator: jsonschema::Validator,
162}
163
164impl NamespaceSchema {
165    /// Validate an entire values object against this schema
166    ///
167    /// # Arguments
168    /// * `values` - JSON object containing option key-value pairs
169    ///
170    /// # Errors
171    /// Returns error if values don't match the schema
172    pub fn validate_values(&self, values: &Value) -> ValidationResult<()> {
173        let output = self.validator.evaluate(values);
174        if output.flag().valid {
175            Ok(())
176        } else {
177            let errors: Vec<String> = output.iter_errors().map(|e| e.error.to_string()).collect();
178            Err(ValidationError::ValueError {
179                namespace: self.namespace.clone(),
180                errors: errors.join(", "),
181            })
182        }
183    }
184
185    /// Get the default value for an option key.
186    /// Returns None if the key doesn't exist in the schema.
187    pub fn get_default(&self, key: &str) -> Option<&Value> {
188        self.options.get(key).map(|meta| &meta.default)
189    }
190}
191
192/// Registry for loading and storing schemas
193pub struct SchemaRegistry {
194    schemas: HashMap<String, Arc<NamespaceSchema>>,
195}
196
197impl SchemaRegistry {
198    /// Create a new empty schema registry
199    pub fn new() -> Self {
200        Self {
201            schemas: HashMap::new(),
202        }
203    }
204
205    /// Load schemas from a directory and create a registry
206    ///
207    /// Expects directory structure: `schemas/{namespace}/schema.json`
208    ///
209    /// # Arguments
210    /// * `schemas_dir` - Path to directory containing namespace subdirectories
211    ///
212    /// # Errors
213    /// Returns error if directory doesn't exist or any schema is invalid
214    pub fn from_directory(schemas_dir: &Path) -> ValidationResult<Self> {
215        let schemas = Self::load_all_schemas(schemas_dir)?;
216        Ok(Self { schemas })
217    }
218
219    /// Validate an entire values object for a namespace
220    ///
221    /// # Arguments
222    /// * `namespace` - Namespace name
223    /// * `values` - JSON object containing option key-value pairs
224    ///
225    /// # Errors
226    /// Returns error if namespace doesn't exist or values don't match schema
227    pub fn validate_values(&self, namespace: &str, values: &Value) -> ValidationResult<()> {
228        let schema = self
229            .schemas
230            .get(namespace)
231            .ok_or_else(|| ValidationError::UnknownNamespace(namespace.to_string()))?;
232
233        schema.validate_values(values)
234    }
235
236    /// Load all schemas from a directory
237    fn load_all_schemas(
238        schemas_dir: &Path,
239    ) -> ValidationResult<HashMap<String, Arc<NamespaceSchema>>> {
240        // Compile namespace-schema once for all schemas
241        let namespace_schema_value: Value =
242            serde_json::from_str(NAMESPACE_SCHEMA_JSON).map_err(|e| {
243                ValidationError::InternalError(format!("Invalid namespace-schema JSON: {}", e))
244            })?;
245        let namespace_validator =
246            jsonschema::validator_for(&namespace_schema_value).map_err(|e| {
247                ValidationError::InternalError(format!("Failed to compile namespace-schema: {}", e))
248            })?;
249
250        let mut schemas = HashMap::new();
251
252        // TODO: Parallelize the loading of schemas for the performance gainz
253        for entry in fs::read_dir(schemas_dir)? {
254            let entry = entry?;
255
256            if !entry.file_type()?.is_dir() {
257                continue;
258            }
259
260            let namespace =
261                entry
262                    .file_name()
263                    .into_string()
264                    .map_err(|_| ValidationError::SchemaError {
265                        file: entry.path(),
266                        message: "Directory name contains invalid UTF-8".to_string(),
267                    })?;
268
269            validate_k8s_name_component(&namespace, "namespace name")?;
270
271            let schema_file = entry.path().join(SCHEMA_FILE_NAME);
272            let schema = Self::load_schema(&schema_file, &namespace, &namespace_validator)?;
273            schemas.insert(namespace, schema);
274        }
275
276        Ok(schemas)
277    }
278
279    /// Load a schema from a file
280    fn load_schema(
281        path: &Path,
282        namespace: &str,
283        namespace_validator: &jsonschema::Validator,
284    ) -> ValidationResult<Arc<NamespaceSchema>> {
285        let file = fs::File::open(path)?;
286        let schema_data: Value = serde_json::from_reader(file)?;
287
288        Self::validate_with_namespace_schema(&schema_data, path, namespace_validator)?;
289        Self::parse_schema(schema_data, namespace, path)
290    }
291
292    /// Validate a schema against the namespace-schema
293    fn validate_with_namespace_schema(
294        schema_data: &Value,
295        path: &Path,
296        namespace_validator: &jsonschema::Validator,
297    ) -> ValidationResult<()> {
298        let output = namespace_validator.evaluate(schema_data);
299
300        if output.flag().valid {
301            Ok(())
302        } else {
303            let errors: Vec<String> = output
304                .iter_errors()
305                .map(|e| format!("Error: {}", e.error))
306                .collect();
307
308            Err(ValidationError::SchemaError {
309                file: path.to_path_buf(),
310                message: format!("Schema validation failed:\n{}", errors.join("\n")),
311            })
312        }
313    }
314
315    /// Validate that a default value matches its declared type using jsonschema
316    fn validate_default_type(
317        property_name: &str,
318        property_type: &str,
319        default_value: &Value,
320        path: &Path,
321    ) -> ValidationResult<()> {
322        // Build a mini JSON Schema for just this type
323        let type_schema = serde_json::json!({
324            "type": property_type
325        });
326
327        // Validate the default value against the type
328        jsonschema::validate(&type_schema, default_value).map_err(|e| {
329            ValidationError::SchemaError {
330                file: path.to_path_buf(),
331                message: format!(
332                    "Property '{}': default value does not match type '{}': {}",
333                    property_name, property_type, e
334                ),
335            }
336        })?;
337
338        Ok(())
339    }
340
341    /// Parse a schema JSON into NamespaceSchema
342    fn parse_schema(
343        mut schema: Value,
344        namespace: &str,
345        path: &Path,
346    ) -> ValidationResult<Arc<NamespaceSchema>> {
347        // Inject additionalProperties: false to reject unknown options
348        if let Some(obj) = schema.as_object_mut() {
349            obj.insert("additionalProperties".to_string(), json!(false));
350        }
351
352        // Use the schema file directly as the validator
353        let validator =
354            jsonschema::validator_for(&schema).map_err(|e| ValidationError::SchemaError {
355                file: path.to_path_buf(),
356                message: format!("Failed to compile validator: {}", e),
357            })?;
358
359        // Extract option metadata and validate types
360        let mut options = HashMap::new();
361        if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
362            for (prop_name, prop_value) in properties {
363                if let (Some(prop_type), Some(default_value)) = (
364                    prop_value.get("type").and_then(|t| t.as_str()),
365                    prop_value.get("default"),
366                ) {
367                    Self::validate_default_type(prop_name, prop_type, default_value, path)?;
368                    options.insert(
369                        prop_name.clone(),
370                        OptionMetadata {
371                            option_type: prop_type.to_string(),
372                            default: default_value.clone(),
373                        },
374                    );
375                }
376            }
377        }
378
379        Ok(Arc::new(NamespaceSchema {
380            namespace: namespace.to_string(),
381            options,
382            validator,
383        }))
384    }
385
386    /// Get a namespace schema by name
387    pub fn get(&self, namespace: &str) -> Option<&Arc<NamespaceSchema>> {
388        self.schemas.get(namespace)
389    }
390
391    /// Get all loaded schemas (for schema evolution validation)
392    pub fn schemas(&self) -> &HashMap<String, Arc<NamespaceSchema>> {
393        &self.schemas
394    }
395
396    /// Load and validate JSON values from a directory.
397    /// Expects structure: `{values_dir}/{namespace}/values.json`
398    /// Values file must have format: `{"options": {"key": value, ...}, "generated_at": "..."}`
399    /// Skips namespaces without a values.json file.
400    /// Returns the values and a map of namespace -> `generated_at` timestamp.
401    pub fn load_values_json(
402        &self,
403        values_dir: &Path,
404    ) -> ValidationResult<(ValuesByNamespace, HashMap<String, String>)> {
405        let mut all_values = HashMap::new();
406        let mut generated_at_by_namespace: HashMap<String, String> = HashMap::new();
407
408        for namespace in self.schemas.keys() {
409            let values_file = values_dir.join(namespace).join(VALUES_FILE_NAME);
410
411            if !values_file.exists() {
412                continue;
413            }
414
415            let parsed: Value = serde_json::from_reader(fs::File::open(&values_file)?)?;
416
417            // Extract generated_at if present
418            if let Some(ts) = parsed.get("generated_at").and_then(|v| v.as_str()) {
419                generated_at_by_namespace.insert(namespace.clone(), ts.to_string());
420            }
421
422            let values = parsed
423                .get("options")
424                .ok_or_else(|| ValidationError::ValueError {
425                    namespace: namespace.clone(),
426                    errors: "values.json must have an 'options' key".to_string(),
427                })?;
428
429            self.validate_values(namespace, values)?;
430
431            if let Value::Object(obj) = values.clone() {
432                let ns_values: HashMap<String, Value> = obj.into_iter().collect();
433                all_values.insert(namespace.clone(), ns_values);
434            }
435        }
436
437        Ok((all_values, generated_at_by_namespace))
438    }
439}
440
441impl Default for SchemaRegistry {
442    fn default() -> Self {
443        Self::new()
444    }
445}
446
447/// Watches the values directory for changes, reloading if there are any.
448/// If the directory does not exist we do not panic
449///
450/// Does not do an initial fetch, assumes the caller has already loaded values.
451/// Child thread may panic if we run out of memory or cannot create more threads.
452///
453/// Uses polling for now, could use `inotify` or similar later on.
454///
455/// Some important notes:
456/// - If the thread panics and dies, there is no built in mechanism to catch it and restart
457/// - If a config map is unmounted, we won't reload until the next file modification (because we don't catch the deletion event)
458/// - If any namespace fails validation, we keep all old values (even the namespaces that passed validation)
459/// - If we have a steady stream of readers our writer may starve for a while trying to acquire the lock
460/// - stop() will block until the thread gets joined
461pub struct ValuesWatcher {
462    stop_signal: Arc<AtomicBool>,
463    thread: Option<JoinHandle<()>>,
464}
465
466impl ValuesWatcher {
467    /// Creates a new ValuesWatcher struct and spins up the watcher thread
468    pub fn new(
469        values_path: &Path,
470        registry: Arc<SchemaRegistry>,
471        values: Arc<RwLock<ValuesByNamespace>>,
472    ) -> ValidationResult<Self> {
473        // output an error but keep passing
474        if fs::metadata(values_path).is_err() {
475            eprintln!("Values directory does not exist: {}", values_path.display());
476        }
477
478        let stop_signal = Arc::new(AtomicBool::new(false));
479
480        let thread_signal = Arc::clone(&stop_signal);
481        let thread_path = values_path.to_path_buf();
482        let thread_registry = Arc::clone(&registry);
483        let thread_values = Arc::clone(&values);
484        let thread = thread::Builder::new()
485            .name("sentry-options-watcher".into())
486            .spawn(move || {
487                let result = panic::catch_unwind(AssertUnwindSafe(|| {
488                    Self::run(thread_signal, thread_path, thread_registry, thread_values);
489                }));
490                if let Err(e) = result {
491                    eprintln!("Watcher thread panicked with: {:?}", e);
492                }
493            })?;
494
495        Ok(Self {
496            stop_signal,
497            thread: Some(thread),
498        })
499    }
500
501    /// Reloads the values if the modified time has changed.
502    ///
503    /// Continuously polls the values directory and reloads all values
504    /// if any modification is detected.
505    fn run(
506        stop_signal: Arc<AtomicBool>,
507        values_path: PathBuf,
508        registry: Arc<SchemaRegistry>,
509        values: Arc<RwLock<ValuesByNamespace>>,
510    ) {
511        let mut last_mtime = Self::get_mtime(&values_path);
512
513        while !stop_signal.load(Ordering::Relaxed) {
514            // does not reload values if get_mtime fails
515            if let Some(current_mtime) = Self::get_mtime(&values_path)
516                && Some(current_mtime) != last_mtime
517            {
518                Self::reload_values(&values_path, &registry, &values);
519                last_mtime = Some(current_mtime);
520            }
521
522            thread::sleep(Duration::from_secs(POLLING_DELAY));
523        }
524    }
525
526    /// Get the most recent modification time across all namespace values.json files
527    /// Returns None if no valid values files are found
528    fn get_mtime(values_dir: &Path) -> Option<std::time::SystemTime> {
529        let mut latest_mtime = None;
530
531        let entries = match fs::read_dir(values_dir) {
532            Ok(e) => e,
533            Err(e) => {
534                eprintln!("Failed to read directory {}: {}", values_dir.display(), e);
535                return None;
536            }
537        };
538
539        for entry in entries.flatten() {
540            // skip if not a dir
541            if !entry
542                .file_type()
543                .map(|file_type| file_type.is_dir())
544                .unwrap_or(false)
545            {
546                continue;
547            }
548
549            let values_file = entry.path().join(VALUES_FILE_NAME);
550            if let Ok(metadata) = fs::metadata(&values_file)
551                && let Ok(mtime) = metadata.modified()
552                && latest_mtime.is_none_or(|latest| mtime > latest)
553            {
554                latest_mtime = Some(mtime);
555            }
556        }
557
558        latest_mtime
559    }
560
561    /// Reload values from disk, validate them, and update the shared map.
562    /// Emits a Sentry transaction per namespace with timing and propagation delay metrics.
563    fn reload_values(
564        values_path: &Path,
565        registry: &SchemaRegistry,
566        values: &Arc<RwLock<ValuesByNamespace>>,
567    ) {
568        let reload_start = Instant::now();
569
570        match registry.load_values_json(values_path) {
571            Ok((new_values, generated_at_by_namespace)) => {
572                let namespaces: Vec<String> = new_values.keys().cloned().collect();
573                Self::update_values(values, new_values);
574
575                let reload_duration = reload_start.elapsed();
576                Self::emit_reload_spans(&namespaces, reload_duration, &generated_at_by_namespace);
577            }
578            Err(e) => {
579                eprintln!(
580                    "Failed to reload values from {}: {}",
581                    values_path.display(),
582                    e
583                );
584            }
585        }
586    }
587
588    /// Emit a Sentry transaction per namespace with reload timing and propagation delay metrics.
589    /// Uses a dedicated Sentry Hub isolated from the host application's Sentry setup.
590    fn emit_reload_spans(
591        namespaces: &[String],
592        reload_duration: Duration,
593        generated_at_by_namespace: &HashMap<String, String>,
594    ) {
595        let hub = get_sentry_hub();
596        let applied_at = Utc::now();
597        let reload_duration_ms = reload_duration.as_secs_f64() * 1000.0;
598
599        for namespace in namespaces {
600            let mut tx_ctx = sentry::TransactionContext::new(namespace, "sentry_options.reload");
601            tx_ctx.set_sampled(true);
602
603            let transaction = hub.start_transaction(tx_ctx);
604            transaction.set_data("reload_duration_ms", reload_duration_ms.into());
605            transaction.set_data("applied_at", applied_at.to_rfc3339().into());
606
607            if let Some(ts) = generated_at_by_namespace.get(namespace) {
608                transaction.set_data("generated_at", ts.as_str().into());
609
610                if let Ok(generated_time) = DateTime::parse_from_rfc3339(ts) {
611                    let delay_secs = (applied_at - generated_time.with_timezone(&Utc))
612                        .num_milliseconds() as f64
613                        / 1000.0;
614                    transaction.set_data("propagation_delay_secs", delay_secs.into());
615                }
616            }
617
618            transaction.finish();
619        }
620    }
621
622    /// Update the values map with the new values
623    fn update_values(values: &Arc<RwLock<ValuesByNamespace>>, new_values: ValuesByNamespace) {
624        // safe to unwrap, we only have one thread and if it panics we die anyways
625        let mut guard = values.write().unwrap();
626        *guard = new_values;
627    }
628
629    /// Stops the watcher thread, waiting for it to join.
630    /// May take up to POLLING_DELAY seconds
631    pub fn stop(&mut self) {
632        self.stop_signal.store(true, Ordering::Relaxed);
633        if let Some(thread) = self.thread.take() {
634            let _ = thread.join();
635        }
636    }
637
638    /// Returns whether the watcher thread is still running
639    pub fn is_alive(&self) -> bool {
640        self.thread.as_ref().is_some_and(|t| !t.is_finished())
641    }
642}
643
644impl Drop for ValuesWatcher {
645    fn drop(&mut self) {
646        self.stop();
647    }
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653    use tempfile::TempDir;
654
655    fn create_test_schema(temp_dir: &TempDir, namespace: &str, schema_json: &str) -> PathBuf {
656        let schema_dir = temp_dir.path().join(namespace);
657        fs::create_dir_all(&schema_dir).unwrap();
658        let schema_file = schema_dir.join("schema.json");
659        fs::write(&schema_file, schema_json).unwrap();
660        schema_file
661    }
662
663    #[test]
664    fn test_validate_k8s_name_component_valid() {
665        assert!(validate_k8s_name_component("relay", "namespace").is_ok());
666        assert!(validate_k8s_name_component("my-service", "namespace").is_ok());
667        assert!(validate_k8s_name_component("my.service", "namespace").is_ok());
668        assert!(validate_k8s_name_component("a1-b2.c3", "namespace").is_ok());
669    }
670
671    #[test]
672    fn test_validate_k8s_name_component_rejects_uppercase() {
673        let result = validate_k8s_name_component("MyService", "namespace");
674        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
675        assert!(result.unwrap_err().to_string().contains("'M' not allowed"));
676    }
677
678    #[test]
679    fn test_validate_k8s_name_component_rejects_underscore() {
680        let result = validate_k8s_name_component("my_service", "target");
681        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
682        assert!(result.unwrap_err().to_string().contains("'_' not allowed"));
683    }
684
685    #[test]
686    fn test_validate_k8s_name_component_rejects_leading_hyphen() {
687        let result = validate_k8s_name_component("-service", "namespace");
688        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
689        assert!(
690            result
691                .unwrap_err()
692                .to_string()
693                .contains("start and end with alphanumeric")
694        );
695    }
696
697    #[test]
698    fn test_validate_k8s_name_component_rejects_trailing_dot() {
699        let result = validate_k8s_name_component("service.", "namespace");
700        assert!(matches!(result, Err(ValidationError::InvalidName { .. })));
701        assert!(
702            result
703                .unwrap_err()
704                .to_string()
705                .contains("start and end with alphanumeric")
706        );
707    }
708
709    #[test]
710    fn test_load_schema_valid() {
711        let temp_dir = TempDir::new().unwrap();
712        create_test_schema(
713            &temp_dir,
714            "test",
715            r#"{
716                "version": "1.0",
717                "type": "object",
718                "properties": {
719                    "test-key": {
720                        "type": "string",
721                        "default": "test",
722                        "description": "Test option"
723                    }
724                }
725            }"#,
726        );
727
728        SchemaRegistry::from_directory(temp_dir.path()).unwrap();
729    }
730
731    #[test]
732    fn test_load_schema_missing_version() {
733        let temp_dir = TempDir::new().unwrap();
734        create_test_schema(
735            &temp_dir,
736            "test",
737            r#"{
738                "type": "object",
739                "properties": {}
740            }"#,
741        );
742
743        let result = SchemaRegistry::from_directory(temp_dir.path());
744        assert!(result.is_err());
745        match result {
746            Err(ValidationError::SchemaError { message, .. }) => {
747                assert!(message.contains(
748                    "Schema validation failed:
749Error: \"version\" is a required property"
750                ));
751            }
752            _ => panic!("Expected SchemaError for missing version"),
753        }
754    }
755
756    #[test]
757    fn test_unknown_namespace() {
758        let temp_dir = TempDir::new().unwrap();
759        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
760
761        let result = registry.validate_values("unknown", &json!({}));
762        assert!(matches!(result, Err(ValidationError::UnknownNamespace(..))));
763    }
764
765    #[test]
766    fn test_multiple_namespaces() {
767        let temp_dir = TempDir::new().unwrap();
768        create_test_schema(
769            &temp_dir,
770            "ns1",
771            r#"{
772                "version": "1.0",
773                "type": "object",
774                "properties": {
775                    "opt1": {
776                        "type": "string",
777                        "default": "default1",
778                        "description": "First option"
779                    }
780                }
781            }"#,
782        );
783        create_test_schema(
784            &temp_dir,
785            "ns2",
786            r#"{
787                "version": "2.0",
788                "type": "object",
789                "properties": {
790                    "opt2": {
791                        "type": "integer",
792                        "default": 42,
793                        "description": "Second option"
794                    }
795                }
796            }"#,
797        );
798
799        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
800        assert!(registry.schemas.contains_key("ns1"));
801        assert!(registry.schemas.contains_key("ns2"));
802    }
803
804    #[test]
805    fn test_invalid_default_type() {
806        let temp_dir = TempDir::new().unwrap();
807        create_test_schema(
808            &temp_dir,
809            "test",
810            r#"{
811                "version": "1.0",
812                "type": "object",
813                "properties": {
814                    "bad-default": {
815                        "type": "integer",
816                        "default": "not-a-number",
817                        "description": "A bad default value"
818                    }
819                }
820            }"#,
821        );
822
823        let result = SchemaRegistry::from_directory(temp_dir.path());
824        assert!(result.is_err());
825        match result {
826            Err(ValidationError::SchemaError { message, .. }) => {
827                assert!(message.contains("Property 'bad-default': default value does not match type 'integer': \"not-a-number\" is not of type \"integer\""));
828            }
829            _ => panic!("Expected SchemaError for invalid default type"),
830        }
831    }
832
833    #[test]
834    fn test_extra_properties() {
835        let temp_dir = TempDir::new().unwrap();
836        create_test_schema(
837            &temp_dir,
838            "test",
839            r#"{
840                "version": "1.0",
841                "type": "object",
842                "properties": {
843                    "bad-property": {
844                        "type": "integer",
845                        "default": 0,
846                        "description": "Test property",
847                        "extra": "property"
848                    }
849                }
850            }"#,
851        );
852
853        let result = SchemaRegistry::from_directory(temp_dir.path());
854        assert!(result.is_err());
855        match result {
856            Err(ValidationError::SchemaError { message, .. }) => {
857                assert!(
858                    message
859                        .contains("Additional properties are not allowed ('extra' was unexpected)")
860                );
861            }
862            _ => panic!("Expected SchemaError for extra properties"),
863        }
864    }
865
866    #[test]
867    fn test_missing_description() {
868        let temp_dir = TempDir::new().unwrap();
869        create_test_schema(
870            &temp_dir,
871            "test",
872            r#"{
873                "version": "1.0",
874                "type": "object",
875                "properties": {
876                    "missing-desc": {
877                        "type": "string",
878                        "default": "test"
879                    }
880                }
881            }"#,
882        );
883
884        let result = SchemaRegistry::from_directory(temp_dir.path());
885        assert!(result.is_err());
886        match result {
887            Err(ValidationError::SchemaError { message, .. }) => {
888                assert!(message.contains("\"description\" is a required property"));
889            }
890            _ => panic!("Expected SchemaError for missing description"),
891        }
892    }
893
894    #[test]
895    fn test_invalid_directory_structure() {
896        let temp_dir = TempDir::new().unwrap();
897        // Create a namespace directory without schema.json file
898        let schema_dir = temp_dir.path().join("missing-schema");
899        fs::create_dir_all(&schema_dir).unwrap();
900
901        let result = SchemaRegistry::from_directory(temp_dir.path());
902        assert!(result.is_err());
903        match result {
904            Err(ValidationError::FileRead(..)) => {
905                // Expected error when schema.json file is missing
906            }
907            _ => panic!("Expected FileRead error for missing schema.json"),
908        }
909    }
910
911    #[test]
912    fn test_get_default() {
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                    "string_opt": {
922                        "type": "string",
923                        "default": "hello",
924                        "description": "A string option"
925                    },
926                    "int_opt": {
927                        "type": "integer",
928                        "default": 42,
929                        "description": "An integer option"
930                    }
931                }
932            }"#,
933        );
934
935        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
936        let schema = registry.get("test").unwrap();
937
938        assert_eq!(schema.get_default("string_opt"), Some(&json!("hello")));
939        assert_eq!(schema.get_default("int_opt"), Some(&json!(42)));
940        assert_eq!(schema.get_default("unknown"), None);
941    }
942
943    #[test]
944    fn test_validate_values_valid() {
945        let temp_dir = TempDir::new().unwrap();
946        create_test_schema(
947            &temp_dir,
948            "test",
949            r#"{
950                "version": "1.0",
951                "type": "object",
952                "properties": {
953                    "enabled": {
954                        "type": "boolean",
955                        "default": false,
956                        "description": "Enable feature"
957                    }
958                }
959            }"#,
960        );
961
962        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
963        let result = registry.validate_values("test", &json!({"enabled": true}));
964        assert!(result.is_ok());
965    }
966
967    #[test]
968    fn test_validate_values_invalid_type() {
969        let temp_dir = TempDir::new().unwrap();
970        create_test_schema(
971            &temp_dir,
972            "test",
973            r#"{
974                "version": "1.0",
975                "type": "object",
976                "properties": {
977                    "count": {
978                        "type": "integer",
979                        "default": 0,
980                        "description": "Count"
981                    }
982                }
983            }"#,
984        );
985
986        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
987        let result = registry.validate_values("test", &json!({"count": "not a number"}));
988        assert!(matches!(result, Err(ValidationError::ValueError { .. })));
989    }
990
991    #[test]
992    fn test_validate_values_unknown_option() {
993        let temp_dir = TempDir::new().unwrap();
994        create_test_schema(
995            &temp_dir,
996            "test",
997            r#"{
998                "version": "1.0",
999                "type": "object",
1000                "properties": {
1001                    "known_option": {
1002                        "type": "string",
1003                        "default": "default",
1004                        "description": "A known option"
1005                    }
1006                }
1007            }"#,
1008        );
1009
1010        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1011
1012        // Valid known option should pass
1013        let result = registry.validate_values("test", &json!({"known_option": "value"}));
1014        assert!(result.is_ok());
1015
1016        // Unknown option should fail
1017        let result = registry.validate_values("test", &json!({"unknown_option": "value"}));
1018        assert!(result.is_err());
1019        match result {
1020            Err(ValidationError::ValueError { errors, .. }) => {
1021                assert!(errors.contains("Additional properties are not allowed"));
1022            }
1023            _ => panic!("Expected ValueError for unknown option"),
1024        }
1025    }
1026
1027    #[test]
1028    fn test_load_values_json_valid() {
1029        let temp_dir = TempDir::new().unwrap();
1030        let schemas_dir = temp_dir.path().join("schemas");
1031        let values_dir = temp_dir.path().join("values");
1032
1033        let schema_dir = schemas_dir.join("test");
1034        fs::create_dir_all(&schema_dir).unwrap();
1035        fs::write(
1036            schema_dir.join("schema.json"),
1037            r#"{
1038                "version": "1.0",
1039                "type": "object",
1040                "properties": {
1041                    "enabled": {
1042                        "type": "boolean",
1043                        "default": false,
1044                        "description": "Enable feature"
1045                    },
1046                    "name": {
1047                        "type": "string",
1048                        "default": "default",
1049                        "description": "Name"
1050                    },
1051                    "count": {
1052                        "type": "integer",
1053                        "default": 0,
1054                        "description": "Count"
1055                    },
1056                    "rate": {
1057                        "type": "number",
1058                        "default": 0.0,
1059                        "description": "Rate"
1060                    }
1061                }
1062            }"#,
1063        )
1064        .unwrap();
1065
1066        let test_values_dir = values_dir.join("test");
1067        fs::create_dir_all(&test_values_dir).unwrap();
1068        fs::write(
1069            test_values_dir.join("values.json"),
1070            r#"{
1071                "options": {
1072                    "enabled": true,
1073                    "name": "test-name",
1074                    "count": 42,
1075                    "rate": 0.75
1076                }
1077            }"#,
1078        )
1079        .unwrap();
1080
1081        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1082        let (values, generated_at_by_namespace) = registry.load_values_json(&values_dir).unwrap();
1083
1084        assert_eq!(values.len(), 1);
1085        assert_eq!(values["test"]["enabled"], json!(true));
1086        assert_eq!(values["test"]["name"], json!("test-name"));
1087        assert_eq!(values["test"]["count"], json!(42));
1088        assert_eq!(values["test"]["rate"], json!(0.75));
1089        assert!(generated_at_by_namespace.is_empty());
1090    }
1091
1092    #[test]
1093    fn test_load_values_json_nonexistent_dir() {
1094        let temp_dir = TempDir::new().unwrap();
1095        create_test_schema(
1096            &temp_dir,
1097            "test",
1098            r#"{"version": "1.0", "type": "object", "properties": {}}"#,
1099        );
1100
1101        let registry = SchemaRegistry::from_directory(temp_dir.path()).unwrap();
1102        let (values, generated_at_by_namespace) = registry
1103            .load_values_json(&temp_dir.path().join("nonexistent"))
1104            .unwrap();
1105
1106        // No values.json files found, returns empty
1107        assert!(values.is_empty());
1108        assert!(generated_at_by_namespace.is_empty());
1109    }
1110
1111    #[test]
1112    fn test_load_values_json_skips_missing_values_file() {
1113        let temp_dir = TempDir::new().unwrap();
1114        let schemas_dir = temp_dir.path().join("schemas");
1115        let values_dir = temp_dir.path().join("values");
1116
1117        // Create two schemas
1118        let schema_dir1 = schemas_dir.join("with-values");
1119        fs::create_dir_all(&schema_dir1).unwrap();
1120        fs::write(
1121            schema_dir1.join("schema.json"),
1122            r#"{
1123                "version": "1.0",
1124                "type": "object",
1125                "properties": {
1126                    "opt": {"type": "string", "default": "x", "description": "Opt"}
1127                }
1128            }"#,
1129        )
1130        .unwrap();
1131
1132        let schema_dir2 = schemas_dir.join("without-values");
1133        fs::create_dir_all(&schema_dir2).unwrap();
1134        fs::write(
1135            schema_dir2.join("schema.json"),
1136            r#"{
1137                "version": "1.0",
1138                "type": "object",
1139                "properties": {
1140                    "opt": {"type": "string", "default": "x", "description": "Opt"}
1141                }
1142            }"#,
1143        )
1144        .unwrap();
1145
1146        // Only create values for one namespace
1147        let with_values_dir = values_dir.join("with-values");
1148        fs::create_dir_all(&with_values_dir).unwrap();
1149        fs::write(
1150            with_values_dir.join("values.json"),
1151            r#"{"options": {"opt": "y"}}"#,
1152        )
1153        .unwrap();
1154
1155        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1156        let (values, _) = registry.load_values_json(&values_dir).unwrap();
1157
1158        assert_eq!(values.len(), 1);
1159        assert!(values.contains_key("with-values"));
1160        assert!(!values.contains_key("without-values"));
1161    }
1162
1163    #[test]
1164    fn test_load_values_json_extracts_generated_at() {
1165        let temp_dir = TempDir::new().unwrap();
1166        let schemas_dir = temp_dir.path().join("schemas");
1167        let values_dir = temp_dir.path().join("values");
1168
1169        let schema_dir = schemas_dir.join("test");
1170        fs::create_dir_all(&schema_dir).unwrap();
1171        fs::write(
1172            schema_dir.join("schema.json"),
1173            r#"{
1174                "version": "1.0",
1175                "type": "object",
1176                "properties": {
1177                    "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1178                }
1179            }"#,
1180        )
1181        .unwrap();
1182
1183        let test_values_dir = values_dir.join("test");
1184        fs::create_dir_all(&test_values_dir).unwrap();
1185        fs::write(
1186            test_values_dir.join("values.json"),
1187            r#"{"options": {"enabled": true}, "generated_at": "2024-01-21T18:30:00.123456+00:00"}"#,
1188        )
1189        .unwrap();
1190
1191        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1192        let (values, generated_at_by_namespace) = registry.load_values_json(&values_dir).unwrap();
1193
1194        assert_eq!(values["test"]["enabled"], json!(true));
1195        assert_eq!(
1196            generated_at_by_namespace.get("test"),
1197            Some(&"2024-01-21T18:30:00.123456+00:00".to_string())
1198        );
1199    }
1200
1201    #[test]
1202    fn test_load_values_json_rejects_wrong_type() {
1203        let temp_dir = TempDir::new().unwrap();
1204        let schemas_dir = temp_dir.path().join("schemas");
1205        let values_dir = temp_dir.path().join("values");
1206
1207        let schema_dir = schemas_dir.join("test");
1208        fs::create_dir_all(&schema_dir).unwrap();
1209        fs::write(
1210            schema_dir.join("schema.json"),
1211            r#"{
1212                "version": "1.0",
1213                "type": "object",
1214                "properties": {
1215                    "count": {"type": "integer", "default": 0, "description": "Count"}
1216                }
1217            }"#,
1218        )
1219        .unwrap();
1220
1221        let test_values_dir = values_dir.join("test");
1222        fs::create_dir_all(&test_values_dir).unwrap();
1223        fs::write(
1224            test_values_dir.join("values.json"),
1225            r#"{"options": {"count": "not-a-number"}}"#,
1226        )
1227        .unwrap();
1228
1229        let registry = SchemaRegistry::from_directory(&schemas_dir).unwrap();
1230        let result = registry.load_values_json(&values_dir);
1231
1232        assert!(matches!(result, Err(ValidationError::ValueError { .. })));
1233    }
1234
1235    mod watcher_tests {
1236        use super::*;
1237        use std::thread;
1238
1239        /// Creates schema and values files for two namespaces: ns1, and ns2
1240        fn setup_watcher_test() -> (TempDir, PathBuf, PathBuf) {
1241            let temp_dir = TempDir::new().unwrap();
1242            let schemas_dir = temp_dir.path().join("schemas");
1243            let values_dir = temp_dir.path().join("values");
1244
1245            let ns1_schema = schemas_dir.join("ns1");
1246            fs::create_dir_all(&ns1_schema).unwrap();
1247            fs::write(
1248                ns1_schema.join("schema.json"),
1249                r#"{
1250                    "version": "1.0",
1251                    "type": "object",
1252                    "properties": {
1253                        "enabled": {"type": "boolean", "default": false, "description": "Enabled"}
1254                    }
1255                }"#,
1256            )
1257            .unwrap();
1258
1259            let ns1_values = values_dir.join("ns1");
1260            fs::create_dir_all(&ns1_values).unwrap();
1261            fs::write(
1262                ns1_values.join("values.json"),
1263                r#"{"options": {"enabled": true}}"#,
1264            )
1265            .unwrap();
1266
1267            let ns2_schema = schemas_dir.join("ns2");
1268            fs::create_dir_all(&ns2_schema).unwrap();
1269            fs::write(
1270                ns2_schema.join("schema.json"),
1271                r#"{
1272                    "version": "1.0",
1273                    "type": "object",
1274                    "properties": {
1275                        "count": {"type": "integer", "default": 0, "description": "Count"}
1276                    }
1277                }"#,
1278            )
1279            .unwrap();
1280
1281            let ns2_values = values_dir.join("ns2");
1282            fs::create_dir_all(&ns2_values).unwrap();
1283            fs::write(
1284                ns2_values.join("values.json"),
1285                r#"{"options": {"count": 42}}"#,
1286            )
1287            .unwrap();
1288
1289            (temp_dir, schemas_dir, values_dir)
1290        }
1291
1292        #[test]
1293        fn test_get_mtime_returns_most_recent() {
1294            let (_temp, _schemas, values_dir) = setup_watcher_test();
1295
1296            // Get initial mtime
1297            let mtime1 = ValuesWatcher::get_mtime(&values_dir);
1298            assert!(mtime1.is_some());
1299
1300            // Modify one namespace
1301            thread::sleep(std::time::Duration::from_millis(10));
1302            fs::write(
1303                values_dir.join("ns1").join("values.json"),
1304                r#"{"options": {"enabled": false}}"#,
1305            )
1306            .unwrap();
1307
1308            // Should detect the change
1309            let mtime2 = ValuesWatcher::get_mtime(&values_dir);
1310            assert!(mtime2.is_some());
1311            assert!(mtime2 > mtime1);
1312        }
1313
1314        #[test]
1315        fn test_get_mtime_with_missing_directory() {
1316            let temp = TempDir::new().unwrap();
1317            let nonexistent = temp.path().join("nonexistent");
1318
1319            let mtime = ValuesWatcher::get_mtime(&nonexistent);
1320            assert!(mtime.is_none());
1321        }
1322
1323        #[test]
1324        fn test_reload_values_updates_map() {
1325            let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1326
1327            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1328            let (initial_values, _) = registry.load_values_json(&values_dir).unwrap();
1329            let values = Arc::new(RwLock::new(initial_values));
1330
1331            // ensure initial values are correct
1332            {
1333                let guard = values.read().unwrap();
1334                assert_eq!(guard["ns1"]["enabled"], json!(true));
1335                assert_eq!(guard["ns2"]["count"], json!(42));
1336            }
1337
1338            // modify
1339            fs::write(
1340                values_dir.join("ns1").join("values.json"),
1341                r#"{"options": {"enabled": false}}"#,
1342            )
1343            .unwrap();
1344            fs::write(
1345                values_dir.join("ns2").join("values.json"),
1346                r#"{"options": {"count": 100}}"#,
1347            )
1348            .unwrap();
1349
1350            // force a reload
1351            ValuesWatcher::reload_values(&values_dir, &registry, &values);
1352
1353            // ensure new values are correct
1354            {
1355                let guard = values.read().unwrap();
1356                assert_eq!(guard["ns1"]["enabled"], json!(false));
1357                assert_eq!(guard["ns2"]["count"], json!(100));
1358            }
1359        }
1360
1361        #[test]
1362        fn test_old_values_persist_with_invalid_data() {
1363            let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1364
1365            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1366            let (initial_values, _) = registry.load_values_json(&values_dir).unwrap();
1367            let values = Arc::new(RwLock::new(initial_values));
1368
1369            let initial_enabled = {
1370                let guard = values.read().unwrap();
1371                guard["ns1"]["enabled"].clone()
1372            };
1373
1374            // won't pass validation
1375            fs::write(
1376                values_dir.join("ns1").join("values.json"),
1377                r#"{"options": {"enabled": "not-a-boolean"}}"#,
1378            )
1379            .unwrap();
1380
1381            ValuesWatcher::reload_values(&values_dir, &registry, &values);
1382
1383            // ensure old value persists
1384            {
1385                let guard = values.read().unwrap();
1386                assert_eq!(guard["ns1"]["enabled"], initial_enabled);
1387            }
1388        }
1389
1390        #[test]
1391        fn test_watcher_creation_and_termination() {
1392            let (_temp, schemas_dir, values_dir) = setup_watcher_test();
1393
1394            let registry = Arc::new(SchemaRegistry::from_directory(&schemas_dir).unwrap());
1395            let (initial_values, _) = registry.load_values_json(&values_dir).unwrap();
1396            let values = Arc::new(RwLock::new(initial_values));
1397
1398            let mut watcher =
1399                ValuesWatcher::new(&values_dir, Arc::clone(&registry), Arc::clone(&values))
1400                    .expect("Failed to create watcher");
1401
1402            assert!(watcher.is_alive());
1403            watcher.stop();
1404            assert!(!watcher.is_alive());
1405        }
1406    }
1407}