ri/config/mod.rs
1//! Copyright © 2025-2026 Wenze Wei. All Rights Reserved.
2//!
3//! This file is part of Ri.
4//! The Ri project belongs to the Dunimd Team.
5//!
6//! Licensed under the Apache License, Version 2.0 (the "License");
7//! You may not use this file except in compliance with the License.
8//! You may obtain a copy of the License at
9//!
10//! http://www.apache.org/licenses/LICENSE-2.0
11//!
12//! Unless required by applicable law or agreed to in writing, software
13//! distributed under the License is distributed on an "AS IS" BASIS,
14//! WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15//! See the License for the specific language governing permissions and
16//! limitations under the License.
17
18//! # Configuration Management
19//!
20//! This module provides a comprehensive configuration management system for Ri, supporting
21//! multiple configuration sources, hot reload, and flexible configuration access.
22//!
23//! ## Key Components
24//!
25//! - **RiConfig**: Basic configuration storage with typed access methods
26//! - **RiConfigManager**: Configuration manager that handles multiple sources and hot reload
27//! - **RiConfigSource**: Internal enum for different configuration source types
28//!
29//! ## Design Principles
30//!
31//! 1. **Multiple Sources**: Supports configuration from files (JSON, YAML, TOML) and environment variables
32//! 2. **Source Priority**: Environment variables override file configuration
33//! 3. **Typed Access**: Provides type-safe methods for accessing configuration values
34//! 4. **Flattened Structure**: All configuration is flattened into a single key-value store with dot notation
35//! 5. **Hot Reload Support**: Simplified hot reload implementation with full support planned for future
36//! 6. **Default Sources**: Automatically loads configuration from common locations
37//!
38//! ## Usage
39//!
40//! ```rust
41//! use ri::prelude::*;
42//!
43//! fn example() -> RiResult<()> {
44//! // Create a new config manager
45//! let mut config_manager = RiConfigManager::new();
46//!
47//! // Add configuration sources
48//! config_manager.add_file_source("config.yaml");
49//! config_manager.add_environment_source();
50//!
51//! // Load configuration
52//! config_manager.load()?;
53//!
54//! // Access configuration values
55//! let config = config_manager.config();
56//! let port = config.get_u64("server.port").unwrap_or(8080);
57//! let debug = config.get_bool("app.debug").unwrap_or(false);
58//!
59//! Ok(())
60//! }
61//! ```
62
63use std::collections::HashMap as FxHashMap;
64use std::fs;
65use std::path::{Path, PathBuf};
66use std::sync::{Arc, RwLock};
67use tokio::sync::RwLock as TokioRwLock;
68use tokio::task::JoinHandle;
69use yaml_rust::{YamlLoader, Yaml};
70
71#[cfg(feature = "config_hot_reload")]
72use notify::{RecommendedWatcher, RecursiveMode, Watcher};
73
74#[cfg(feature = "pyo3")]
75use crate::hooks::RiHookKind;
76#[cfg(feature = "pyo3")]
77use crate::core::RiServiceContext;
78
79/// Basic configuration storage with typed access methods.
80///
81/// This struct provides a simple key-value store for configuration values, with
82/// type-safe methods for accessing values as different types.
83#[cfg_attr(feature = "pyo3", pyo3::prelude::pyclass)]
84#[derive(Clone)]
85pub struct RiConfig {
86 /// Internal storage for configuration values
87 values: FxHashMap<String, String>,
88}
89
90impl Default for RiConfig {
91 fn default() -> Self {
92 Self::new()
93 }
94}
95
96impl RiConfig {
97 /// Creates a new empty configuration.
98 ///
99 /// Returns a new `RiConfig` instance with an empty key-value store.
100 pub fn new() -> Self {
101 RiConfig { values: FxHashMap::default() }
102 }
103
104 /// Creates a configuration from a YAML string.
105 ///
106 /// The YAML document(s) are flattened into dot-notation keys, matching the
107 /// behavior of `RiConfigManager` file loading (e.g. `server.port`).
108 ///
109 /// # Errors
110 ///
111 /// Returns an error if the YAML string cannot be parsed.
112 pub fn from_yaml_str(yaml: &str) -> Result<Self, crate::core::RiError> {
113 let docs = yaml_rust::YamlLoader::load_from_str(yaml)
114 .map_err(|e| crate::core::RiError::Other(format!("YAML parse error: {e}")))?;
115 let mut config = RiConfig::new();
116 for doc in &docs {
117 RiConfigManager::flatten_yaml_public(doc, "", &mut config);
118 }
119 Ok(config)
120 }
121
122 /// Returns a list of sensitive configuration key patterns.
123 ///
124 /// # Security
125 ///
126 /// These patterns are used to identify sensitive configuration values
127 /// that should be masked when logging or displaying configuration.
128 fn sensitive_key_patterns() -> Vec<&'static str> {
129 vec![
130 "password",
131 "secret",
132 "key",
133 "token",
134 "api_key",
135 "apikey",
136 "auth",
137 "credential",
138 "private",
139 "pass",
140 ]
141 }
142
143 /// Checks if a configuration key is sensitive.
144 ///
145 /// # Security
146 ///
147 /// This method checks if the key matches any sensitive patterns.
148 /// Sensitive values should be masked when logging or displaying.
149 pub fn is_sensitive_key(key: &str) -> bool {
150 let key_lower = key.to_lowercase();
151 for pattern in Self::sensitive_key_patterns() {
152 if key_lower.contains(pattern) {
153 return true;
154 }
155 }
156 false
157 }
158
159 /// Masks a sensitive value for safe display.
160 ///
161 /// # Security
162 ///
163 /// This method masks the middle portion of a sensitive value,
164 /// showing only the first 2 and last 2 characters.
165 pub fn mask_sensitive_value(value: &str) -> String {
166 if value.len() <= 4 {
167 return "*".repeat(value.len().max(4));
168 }
169
170 let first_chars = &value[..2];
171 let last_chars = &value[value.len()-2..];
172 let middle_len = value.len() - 4;
173
174 format!("{}{}{}", first_chars, "*".repeat(middle_len), last_chars)
175 }
176
177 /// Gets a configuration value, masking sensitive values.
178 ///
179 /// # Security
180 ///
181 /// This method returns the value, but masks it if the key is sensitive.
182 /// Use this for logging or displaying configuration values.
183 pub fn get_masked(&self, key: &str) -> Option<String> {
184 self.values.get(key).map(|v| {
185 if Self::is_sensitive_key(key) {
186 Self::mask_sensitive_value(v)
187 } else {
188 v.clone()
189 }
190 })
191 }
192
193 /// Gets all configuration values with sensitive values masked.
194 ///
195 /// # Security
196 ///
197 /// This method returns all key-value pairs with sensitive values masked.
198 /// Use this for logging or displaying configuration.
199 pub fn all_values_masked(&self) -> FxHashMap<String, String> {
200 self.values.iter()
201 .map(|(k, v)| {
202 if Self::is_sensitive_key(k) {
203 (k.clone(), Self::mask_sensitive_value(v))
204 } else {
205 (k.clone(), v.clone())
206 }
207 })
208 .collect()
209 }
210
211 /// Sets a configuration value.
212 ///
213 /// # Parameters
214 ///
215 /// - `key`: The configuration key (typically using dot notation, e.g., "server.port")
216 /// - `value`: The configuration value as a string
217 pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
218 self.values.insert(key.into(), value.into());
219 }
220
221 /// Gets a configuration value as a string.
222 ///
223 /// # Parameters
224 ///
225 /// - `key`: The configuration key to look up
226 ///
227 /// # Returns
228 ///
229 /// An `Option<&String>` containing the value if it exists
230 pub fn get(&self, key: &str) -> Option<&String> {
231 self.values.get(key)
232 }
233
234 /// Gets a configuration value as a string slice.
235 ///
236 /// # Parameters
237 ///
238 /// - `key`: The configuration key to look up
239 ///
240 /// # Returns
241 ///
242 /// An `Option<&str>` containing the value if it exists
243 pub fn get_str(&self, key: &str) -> Option<&str> {
244 self.values.get(key).map(|s| s.as_str())
245 }
246
247 /// Gets a configuration value as a boolean.
248 ///
249 /// Supports the following truthy values: "true", "1", "yes", "on"
250 /// Supports the following falsy values: "false", "0", "no", "off"
251 ///
252 /// # Parameters
253 ///
254 /// - `key`: The configuration key to look up
255 ///
256 /// # Returns
257 ///
258 /// An `Option<bool>` containing the parsed boolean value if the key exists and can be parsed
259 pub fn get_bool(&self, key: &str) -> Option<bool> {
260 self.values.get(key).and_then(|s| {
261 let v = s.trim().to_ascii_lowercase();
262 match v.as_str() {
263 "true" | "1" | "yes" | "on" => Some(true),
264 "false" | "0" | "no" | "off" => Some(false),
265 _ => None,
266 }
267 })
268 }
269
270 /// Gets a configuration value as a 64-bit signed integer.
271 ///
272 /// # Parameters
273 ///
274 /// - `key`: The configuration key to look up
275 ///
276 /// # Returns
277 ///
278 /// An `Option<i64>` containing the parsed integer value if the key exists and can be parsed
279 pub fn get_i64(&self, key: &str) -> Option<i64> {
280 self.values.get(key).and_then(|s| s.trim().parse::<i64>().ok())
281 }
282
283 /// Gets a configuration value as a 64-bit signed integer with bounds checking.
284 ///
285 /// # Parameters
286 ///
287 /// - `key`: The configuration key to look up
288 /// - `min`: Minimum allowed value
289 /// - `max`: Maximum allowed value
290 ///
291 /// # Returns
292 ///
293 /// An `Option<i64>` containing the parsed and validated integer value
294 pub fn get_i64_with_bounds(&self, key: &str, min: i64, max: i64) -> Option<i64> {
295 self.get_i64(key).filter(|&v| v >= min && v <= max)
296 }
297
298 /// Gets a configuration value as a 64-bit unsigned integer.
299 ///
300 /// # Parameters
301 ///
302 /// - `key`: The configuration key to look up
303 ///
304 /// # Returns
305 ///
306 /// An `Option<u64>` containing the parsed integer value if the key exists and can be parsed
307 pub fn get_u64(&self, key: &str) -> Option<u64> {
308 self.values.get(key).and_then(|s| s.trim().parse::<u64>().ok())
309 }
310
311 /// Gets a configuration value as a 64-bit unsigned integer with bounds checking.
312 ///
313 /// # Parameters
314 ///
315 /// - `key`: The configuration key to look up
316 /// - `min`: Minimum allowed value (default: 0)
317 /// - `max`: Maximum allowed value
318 ///
319 /// # Returns
320 ///
321 /// An `Option<u64>` containing the parsed and validated integer value
322 pub fn get_u64_with_bounds(&self, key: &str, min: u64, max: u64) -> Option<u64> {
323 self.get_u64(key).filter(|&v| v >= min && v <= max)
324 }
325
326 /// Gets a configuration value as a positive 64-bit unsigned integer (must be > 0).
327 ///
328 /// # Parameters
329 ///
330 /// - `key`: The configuration key to look up
331 ///
332 /// # Returns
333 ///
334 /// An `Option<u64>` containing the positive integer value
335 pub fn get_positive_u64(&self, key: &str) -> Option<u64> {
336 self.get_u64(key).filter(|&v| v > 0)
337 }
338
339 /// Gets a configuration value as a port number (1-65535).
340 ///
341 /// # Parameters
342 ///
343 /// - `key`: The configuration key to look up
344 ///
345 /// # Returns
346 ///
347 /// An `Option<u16>` containing the valid port number
348 pub fn get_port(&self, key: &str) -> Option<u16> {
349 self.get_u64_with_bounds(key, 1, 65535).map(|v| v as u16)
350 }
351
352 /// Gets a configuration value as a 32-bit floating point number.
353 ///
354 /// # Parameters
355 ///
356 /// - `key`: The configuration key to look up
357 ///
358 /// # Returns
359 ///
360 /// An `Option<f32>` containing the parsed float value if the key exists and can be parsed
361 pub fn get_f32(&self, key: &str) -> Option<f32> {
362 self.values.get(key).and_then(|s| s.trim().parse::<f32>().ok())
363 }
364
365 /// Gets a configuration value as a 32-bit floating point number with bounds checking.
366 ///
367 /// # Parameters
368 ///
369 /// - `key`: The configuration key to look up
370 /// - `min`: Minimum allowed value
371 /// - `max`: Maximum allowed value
372 ///
373 /// # Returns
374 ///
375 /// An `Option<f32>` containing the parsed and validated float value
376 pub fn get_f32_with_bounds(&self, key: &str, min: f32, max: f32) -> Option<f32> {
377 self.get_f32(key).filter(|&v| v >= min && v <= max)
378 }
379
380 /// Gets a configuration value as a percentage (0.0-100.0).
381 ///
382 /// # Parameters
383 ///
384 /// - `key`: The configuration key to look up
385 ///
386 /// # Returns
387 ///
388 /// An `Option<f32>` containing the percentage value
389 pub fn get_percentage(&self, key: &str) -> Option<f32> {
390 self.get_f32_with_bounds(key, 0.0, 100.0)
391 }
392
393 /// Gets a configuration value as a rate (0.0-1.0).
394 ///
395 /// # Parameters
396 ///
397 /// - `key`: The configuration key to look up
398 ///
399 /// # Returns
400 ///
401 /// An `Option<f32>` containing the rate value
402 pub fn get_rate(&self, key: &str) -> Option<f32> {
403 self.get_f32_with_bounds(key, 0.0, 1.0)
404 }
405
406 /// Merges another configuration into this one.
407 ///
408 /// Values from the other configuration will override existing values with the same keys.
409 ///
410 /// # Parameters
411 ///
412 /// - `other`: The other configuration to merge into this one
413 pub fn merge(&mut self, other: &RiConfig) {
414 for (k, v) in &other.values {
415 self.values.insert(k.clone(), v.clone());
416 }
417 }
418
419 /// Clears all configuration values.
420 ///
421 /// Removes all key-value pairs from the configuration.
422 pub fn clear(&mut self) {
423 self.values.clear();
424 }
425
426 pub fn get_or_default<T>(&self, key: &str, default: T) -> T
427 where
428 T: std::str::FromStr,
429 T::Err: std::fmt::Debug,
430 {
431 self.values.get(key).and_then(|s| s.trim().parse::<T>().ok()).unwrap_or(default)
432 }
433
434 pub fn get_f64(&self, key: &str) -> Option<f64> {
435 self.values.get(key).and_then(|s| s.trim().parse::<f64>().ok())
436 }
437
438 pub fn get_usize(&self, key: &str) -> Option<usize> {
439 self.values.get(key).and_then(|s| s.trim().parse::<usize>().ok())
440 }
441
442 pub fn get_i32(&self, key: &str) -> Option<i32> {
443 self.values.get(key).and_then(|s| s.trim().parse::<i32>().ok())
444 }
445
446 pub fn get_u32(&self, key: &str) -> Option<u32> {
447 self.values.get(key).and_then(|s| s.trim().parse::<u32>().ok())
448 }
449
450 /// Gets a configuration value as a 32-bit unsigned integer with bounds checking.
451 ///
452 /// # Parameters
453 ///
454 /// - `key`: The configuration key to look up
455 /// - `min`: Minimum allowed value
456 /// - `max`: Maximum allowed value
457 ///
458 /// # Returns
459 ///
460 /// An `Option<u32>` containing the parsed and validated integer value
461 pub fn get_u32_with_bounds(&self, key: &str, min: u32, max: u32) -> Option<u32> {
462 self.get_u32(key).filter(|&v| v >= min && v <= max)
463 }
464
465 /// Gets a configuration value as a timeout value in seconds (1-86400).
466 ///
467 /// # Parameters
468 ///
469 /// - `key`: The configuration key to look up
470 ///
471 /// # Returns
472 ///
473 /// An `Option<u32>` containing the timeout in seconds
474 pub fn get_timeout_secs(&self, key: &str) -> Option<u32> {
475 self.get_u32_with_bounds(key, 1, 86400)
476 }
477
478 /// Gets a configuration value as a retry count (0-100).
479 ///
480 /// # Parameters
481 ///
482 /// - `key`: The configuration key to look up
483 ///
484 /// # Returns
485 ///
486 /// An `Option<u32>` containing the retry count
487 pub fn get_retry_count(&self, key: &str) -> Option<u32> {
488 self.get_u32_with_bounds(key, 0, 100)
489 }
490
491 pub fn keys(&self) -> Vec<&str> {
492 self.values.keys().map(|s| s.as_str()).collect()
493 }
494
495 pub fn all_values(&self) -> Vec<&str> {
496 self.values.values().map(|s| s.as_str()).collect()
497 }
498
499 pub fn has_key(&self, key: &str) -> bool {
500 self.values.contains_key(key)
501 }
502
503 pub fn count(&self) -> usize {
504 self.values.len()
505 }
506
507 #[cfg(feature = "pyo3")]
508 pub fn is_empty(&self) -> bool {
509 self.values.is_empty()
510 }
511}
512
513#[cfg(feature = "pyo3")]
514/// Python constructor for RiConfig
515#[pyo3::prelude::pymethods]
516impl RiConfig {
517 #[new]
518 fn py_new() -> Self {
519 Self::new()
520 }
521
522 #[pyo3(name = "set")]
523 fn set_impl(&mut self, key: String, value: String) {
524 self.set(key, value);
525 }
526
527 #[pyo3(name = "get")]
528 fn get_impl(&self, key: String) -> Option<String> {
529 self.get(&key).cloned()
530 }
531
532 #[pyo3(name = "get_f64")]
533 fn get_f64_impl(&self, key: String) -> Option<f64> {
534 self.get_f64(&key)
535 }
536
537 #[pyo3(name = "get_usize")]
538 fn get_usize_impl(&self, key: String) -> Option<usize> {
539 self.get_usize(&key)
540 }
541
542 #[pyo3(name = "keys")]
543 fn py_keys(&self) -> Vec<String> {
544 self.keys().iter().map(|s| s.to_string()).collect()
545 }
546
547 #[pyo3(name = "values")]
548 fn py_values(&self) -> Vec<String> {
549 self.all_values().iter().map(|s| s.to_string()).collect()
550 }
551
552 #[pyo3(name = "contains")]
553 fn py_contains(&self, key: String) -> bool {
554 self.has_key(&key)
555 }
556
557 #[pyo3(name = "len")]
558 fn py_len(&self) -> usize {
559 self.count()
560 }
561}
562
563/// Internal enum for different configuration source types.
564///
565/// This enum represents the different types of configuration sources that the
566/// `RiConfigManager` can handle.
567#[derive(Clone)]
568enum RiConfigSource {
569 /// File-based configuration source
570 File(PathBuf),
571 /// Environment variable configuration source
572 Environment,
573}
574
575/// Public-facing configuration manager with hot reload support.
576///
577/// This struct manages multiple configuration sources, loads configuration values,
578/// and provides access to the configuration. It supports hot reload and multiple
579/// configuration formats.
580#[cfg_attr(feature = "pyo3", pyo3::prelude::pyclass)]
581pub struct RiConfigManager {
582 /// Internal configuration storage
583 config: Arc<RwLock<RiConfig>>,
584 /// List of configuration sources to load from
585 sources: Vec<RiConfigSource>,
586 /// Optional hook bus for emitting config reload events
587 #[cfg(feature = "pyo3")]
588 hooks: Option<Arc<crate::hooks::RiHookBus>>,
589 /// Hot reload watcher handle
590 #[cfg(feature = "config_hot_reload")]
591 watcher: Option<Arc<RecommendedWatcher>>,
592 /// Background task handle for the watcher
593 watcher_task: Arc<TokioRwLock<Option<JoinHandle<()>>>>,
594 /// Monitored file paths for hot reload
595 monitored_paths: Arc<TokioRwLock<Vec<PathBuf>>>,
596 /// Callback for config changes
597 #[cfg(feature = "config_hot_reload")]
598 change_callback: Option<Arc<dyn Fn() + Send + Sync>>,
599}
600
601impl Default for RiConfigManager {
602 fn default() -> Self {
603 Self::new()
604 }
605}
606
607impl Clone for RiConfigManager {
608 fn clone(&self) -> Self {
609 RiConfigManager {
610 config: self.config.clone(),
611 sources: self.sources.clone(),
612 #[cfg(feature = "pyo3")]
613 hooks: self.hooks.clone(),
614 #[cfg(feature = "config_hot_reload")]
615 watcher: self.watcher.clone(),
616 watcher_task: self.watcher_task.clone(),
617 monitored_paths: self.monitored_paths.clone(),
618 #[cfg(feature = "config_hot_reload")]
619 change_callback: self.change_callback.clone(),
620 }
621 }
622}
623
624impl RiConfigManager {
625 /// Creates a new empty configuration manager.
626 ///
627 /// Returns a new `RiConfigManager` instance with no configuration sources.
628 pub fn new() -> Self {
629 RiConfigManager {
630 config: Arc::new(RwLock::new(RiConfig::new())),
631 sources: Vec::new(),
632 #[cfg(feature = "pyo3")]
633 hooks: None,
634 #[cfg(feature = "config_hot_reload")]
635 watcher: None,
636 watcher_task: Arc::new(TokioRwLock::new(None)),
637 monitored_paths: Arc::new(TokioRwLock::new(Vec::new())),
638 #[cfg(feature = "config_hot_reload")]
639 change_callback: None,
640 }
641 }
642
643 /// Creates a new configuration manager with the provided hook bus.
644 ///
645 /// This method allows the config manager to emit hooks when configuration is reloaded.
646 ///
647 /// # Parameters
648 ///
649 /// - `hooks`: The hook bus to use for emitting events
650 ///
651 /// # Returns
652 ///
653 /// A new `RiConfigManager` instance with the provided hook bus
654 #[cfg(feature = "pyo3")]
655 pub fn with_hooks(hooks: Arc<crate::hooks::RiHookBus>) -> Self {
656 RiConfigManager {
657 config: Arc::new(RwLock::new(RiConfig::new())),
658 sources: Vec::new(),
659 hooks: Some(hooks),
660 #[cfg(feature = "config_hot_reload")]
661 watcher: None,
662 watcher_task: Arc::new(TokioRwLock::new(None)),
663 monitored_paths: Arc::new(TokioRwLock::new(Vec::new())),
664 #[cfg(feature = "config_hot_reload")]
665 change_callback: None,
666 }
667 }
668
669 /// Adds a file-based configuration source.
670 ///
671 /// # Parameters
672 ///
673 /// - `path`: The path to the configuration file
674 ///
675 /// Supported file formats: JSON, YAML, TOML
676 pub fn add_file_source(&mut self, path: impl AsRef<Path>) {
677 self.sources.push(RiConfigSource::File(path.as_ref().to_path_buf()));
678 }
679
680 /// Adds environment variables as a configuration source.
681 ///
682 /// Environment variables with the prefix `Ri_` are loaded as configuration values.
683 /// Double underscores (`__`) in environment variable names are converted to dots.
684 /// For example, `Ri_SERVER__PORT=8080` becomes `server.port=8080`.
685 pub fn add_environment_source(&mut self) {
686 self.sources.push(RiConfigSource::Environment);
687 }
688
689 /// Notifies registered hooks when configuration is reloaded.
690 #[cfg(feature = "pyo3")]
691 fn notify_config_reload(&self, _path: &str) {
692 if let Some(hooks) = &self.hooks {
693 let _ = hooks.emit_with(
694 &RiHookKind::ConfigReload,
695 &RiServiceContext::new_default().unwrap_or_else(|_| {
696 RiServiceContext::new_with(
697 crate::fs::RiFileSystem::new_auto_root().unwrap_or_else(|_| crate::fs::RiFileSystem::new_with_root(std::env::current_dir().unwrap_or_default())),
698 crate::log::RiLogger::new(&crate::log::RiLogConfig::default(), crate::fs::RiFileSystem::new_with_root(std::env::current_dir().unwrap_or_default())),
699 crate::config::RiConfigManager::new(),
700 crate::hooks::RiHookBus::new(),
701 None,
702 )
703 }),
704 Some("config_manager"),
705 None,
706 );
707 }
708 }
709
710 /// Loads configuration from all registered sources.
711 ///
712 /// This method loads configuration from all registered sources in the order they were added,
713 /// with later sources overriding earlier ones.
714 ///
715 /// # Returns
716 ///
717 /// A `Result<(), RiError>` indicating success or failure
718 pub fn load(&mut self) -> Result<(), crate::core::RiError> {
719 let mut cfg = RiConfig::new();
720
721 for source in &self.sources {
722 match source {
723 RiConfigSource::File(path) => {
724 self.load_file(path, &mut cfg)?;
725 #[cfg(feature = "pyo3")]
726 self.notify_config_reload(path.to_str().unwrap_or(""));
727 }
728 RiConfigSource::Environment => {
729 self.load_environment(&mut cfg);
730 }
731 }
732 }
733
734 *self.config.write().expect("Failed to lock config for writing") = cfg;
735
736 Ok(())
737 }
738
739 /// Creates a new configuration manager with default sources.
740 ///
741 /// This method creates a new `RiConfigManager` with the following default sources:
742 /// 1. Configuration files in the `config` directory (dms.yaml, dms.yml, dms.toml, dms.json)
743 /// 2. Environment variables with the prefix `Ri_`
744 ///
745 /// It also loads the configuration immediately.
746 ///
747 /// # Returns
748 ///
749 /// A new `RiConfigManager` instance with default sources and loaded configuration
750 pub fn new_default() -> Self {
751 let mut manager = Self::new();
752
753 // Add default configuration sources
754 if let Ok(cwd) = std::env::current_dir() {
755 let config_dir = cwd.join("config");
756
757 // Add all supported config files in order of priority (lowest to highest)
758 manager.add_file_source(config_dir.join("dms.yaml"));
759 manager.add_file_source(config_dir.join("dms.yml"));
760 manager.add_file_source(config_dir.join("dms.toml"));
761 manager.add_file_source(config_dir.join("dms.json"));
762 }
763
764 // Add environment variables as highest priority
765 manager.add_environment_source();
766
767 // Load configuration immediately
768 let _ = manager.load();
769
770 manager
771 }
772
773 /// Loads configuration from a file.
774 ///
775 /// This method loads configuration from a file, parses it based on its extension,
776 /// and flattens it into the provided configuration object.
777 ///
778 /// # Parameters
779 ///
780 /// - `path`: The path to the configuration file
781 /// - `cfg`: The configuration object to load values into
782 ///
783 /// # Returns
784 ///
785 /// A `Result<(), RiError>` indicating success or failure
786 fn load_file(&self, path: &Path, cfg: &mut RiConfig) -> Result<(), crate::core::RiError> {
787 if !path.exists() {
788 return Ok(());
789 }
790
791 let text = fs::read_to_string(path)?;
792 let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
793
794 match extension.to_lowercase().as_str() {
795 "json" => {
796 if let Ok(map) = serde_json::from_str::<serde_json::Value>(&text) {
797 self.flatten_json(&map, "", cfg);
798 }
799 }
800 "yaml" | "yml" => {
801 if let Ok(yaml_docs) = YamlLoader::load_from_str(&text) {
802 for doc in yaml_docs {
803 self.flatten_yaml(&doc, "", cfg);
804 }
805 }
806 }
807 "toml" => {
808 if let Ok(toml) = toml::from_str(&text) {
809 self.flatten_toml(&toml, "", cfg);
810 }
811 }
812 _ => {
813 // Ignore unsupported file types
814 }
815 }
816
817 Ok(())
818 }
819
820 /// Flattens a JSON value into the configuration.
821 ///
822 /// This method recursively flattens a JSON value into the configuration using dot notation.
823 ///
824 /// # Parameters
825 ///
826 /// - `value`: The JSON value to flatten
827 /// - `prefix`: The current prefix for keys (used for recursion)
828 /// - `cfg`: The configuration object to load values into
829 fn flatten_json(&self, value: &serde_json::Value, prefix: &str, cfg: &mut RiConfig) {
830 Self::flatten_json_static(value, prefix, cfg);
831 }
832
833 /// Static version of `flatten_json` for recursion.
834 ///
835 /// This static method is used for recursion to avoid the "parameter is only used in recursion" warning.
836 ///
837 /// # Parameters
838 ///
839 /// - `value`: The JSON value to flatten
840 /// - `prefix`: The current prefix for keys (used for recursion)
841 /// - `cfg`: The configuration object to load values into
842 fn flatten_json_static(value: &serde_json::Value, prefix: &str, cfg: &mut RiConfig) {
843 match value {
844 serde_json::Value::Object(map) => {
845 for (k, v) in map {
846 let new_prefix = if prefix.is_empty() {
847 k.clone()
848 } else {
849 format!("{prefix}.{k}")
850 };
851 Self::flatten_json_static(v, &new_prefix, cfg);
852 }
853 }
854 serde_json::Value::Array(arr) => {
855 for (i, v) in arr.iter().enumerate() {
856 let new_prefix = format!("{prefix}.{i}");
857 Self::flatten_json_static(v, &new_prefix, cfg);
858 }
859 }
860 serde_json::Value::String(s) => {
861 cfg.set(prefix, s);
862 }
863 serde_json::Value::Number(n) => {
864 cfg.set(prefix, n.to_string());
865 }
866 serde_json::Value::Bool(b) => {
867 cfg.set(prefix, b.to_string());
868 }
869 serde_json::Value::Null => {
870 cfg.set(prefix, "");
871 }
872 }
873 }
874
875 /// Flattens a YAML value into the configuration.
876 ///
877 /// This method recursively flattens a YAML value into the configuration using dot notation.
878 ///
879 /// # Parameters
880 ///
881 /// - `value`: The YAML value to flatten
882 /// - `prefix`: The current prefix for keys (used for recursion)
883 /// - `cfg`: The configuration object to load values into
884 fn flatten_yaml(&self, value: &Yaml, prefix: &str, cfg: &mut RiConfig) {
885 Self::flatten_yaml_static(value, prefix, cfg);
886 }
887
888 /// Public wrapper around the YAML flattening logic.
889 ///
890 /// This exposes the same dot-notation flattening used when loading YAML
891 /// configuration files so other components (e.g. `RiConfig::from_yaml_str`)
892 /// can reuse it.
893 pub fn flatten_yaml_public(value: &Yaml, prefix: &str, cfg: &mut RiConfig) {
894 Self::flatten_yaml_static(value, prefix, cfg);
895 }
896
897 /// Static version of `flatten_yaml` for recursion.
898 ///
899 /// This static method is used for recursion to avoid the "parameter is only used in recursion" warning.
900 ///
901 /// # Parameters
902 ///
903 /// - `value`: The YAML value to flatten
904 /// - `prefix`: The current prefix for keys (used for recursion)
905 /// - `cfg`: The configuration object to load values into
906 fn flatten_yaml_static(value: &Yaml, prefix: &str, cfg: &mut RiConfig) {
907 match value {
908 Yaml::Hash(map) => {
909 for (k, v) in map {
910 if let Yaml::String(key) = k {
911 let new_prefix = if prefix.is_empty() {
912 key.clone()
913 } else {
914 format!("{prefix}.{key}")
915 };
916 Self::flatten_yaml_static(v, &new_prefix, cfg);
917 }
918 }
919 }
920 Yaml::Array(arr) => {
921 for (i, v) in arr.iter().enumerate() {
922 let new_prefix = format!("{prefix}.{i}");
923 Self::flatten_yaml_static(v, &new_prefix, cfg);
924 }
925 }
926 Yaml::String(s) => {
927 cfg.set(prefix, s);
928 }
929 Yaml::Integer(n) => {
930 cfg.set(prefix, n.to_string());
931 }
932 Yaml::Real(r) => {
933 cfg.set(prefix, r);
934 }
935 Yaml::Boolean(b) => {
936 cfg.set(prefix, b.to_string());
937 }
938 Yaml::Null => {
939 cfg.set(prefix, "");
940 }
941 _ => {
942 // Ignore other YAML types
943 }
944 }
945 }
946
947 /// Flattens a TOML value into the configuration.
948 ///
949 /// This method recursively flattens a TOML value into the configuration using dot notation.
950 ///
951 /// # Parameters
952 ///
953 /// - `value`: The TOML value to flatten
954 /// - `prefix`: The current prefix for keys (used for recursion)
955 /// - `cfg`: The configuration object to load values into
956 fn flatten_toml(&self, value: &toml::Value, prefix: &str, cfg: &mut RiConfig) {
957 Self::flatten_toml_static(value, prefix, cfg);
958 }
959
960 /// Static version of `flatten_toml` for recursion.
961 ///
962 /// This static method is used for recursion to avoid the "parameter is only used in recursion" warning.
963 ///
964 /// # Parameters
965 ///
966 /// - `value`: The TOML value to flatten
967 /// - `prefix`: The current prefix for keys (used for recursion)
968 /// - `cfg`: The configuration object to load values into
969 fn flatten_toml_static(value: &toml::Value, prefix: &str, cfg: &mut RiConfig) {
970 match value {
971 toml::Value::Table(table) => {
972 for (k, v) in table {
973 let new_prefix = if prefix.is_empty() {
974 k.clone()
975 } else {
976 format!("{prefix}.{k}")
977 };
978 Self::flatten_toml_static(v, &new_prefix, cfg);
979 }
980 }
981 toml::Value::Array(arr) => {
982 for (i, v) in arr.iter().enumerate() {
983 let new_prefix = format!("{prefix}.{i}");
984 Self::flatten_toml_static(v, &new_prefix, cfg);
985 }
986 }
987 toml::Value::String(s) => {
988 cfg.set(prefix, s);
989 }
990 toml::Value::Integer(n) => {
991 cfg.set(prefix, n.to_string());
992 }
993 toml::Value::Float(f) => {
994 cfg.set(prefix, f.to_string());
995 }
996 toml::Value::Boolean(b) => {
997 cfg.set(prefix, b.to_string());
998 }
999 toml::Value::Datetime(dt) => {
1000 cfg.set(prefix, dt.to_string());
1001 }
1002 }
1003 }
1004
1005 /// Loads configuration from environment variables.
1006 ///
1007 /// This method loads environment variables with the prefix `Ri_` into the configuration.
1008 /// Double underscores (`__`) in environment variable names are converted to dots.
1009 ///
1010 /// # Parameters
1011 ///
1012 /// - `cfg`: The configuration object to load values into
1013 fn load_environment(&self, cfg: &mut RiConfig) {
1014 for (name, value) in std::env::vars() {
1015 if let Some(rest) = name.strip_prefix("Ri_") {
1016 let key_parts: Vec<String> = rest
1017 .split("__")
1018 .map(|part| part.to_ascii_lowercase())
1019 .collect();
1020 let key = key_parts.join(".");
1021 if !key.is_empty() {
1022 cfg.set(key, value);
1023 }
1024 }
1025 }
1026 }
1027
1028 /// Starts the configuration watcher for hot reload.
1029 ///
1030 /// This method starts watching all registered file-based configuration sources
1031 /// for changes. When a configuration file is modified, it will be automatically
1032 /// reloaded and the change callback (if registered) will be invoked.
1033 ///
1034 /// # Returns
1035 ///
1036 /// A `Result<(), RiError>` indicating success or failure
1037 #[cfg(feature = "config_hot_reload")]
1038 pub async fn start_watcher(&mut self) -> Result<(), crate::core::RiError> {
1039 self.start_watcher_with_callback::<fn()>(None).await
1040 }
1041
1042 /// Starts the configuration watcher with a custom change callback.
1043 ///
1044 /// This method starts watching all registered file-based configuration sources
1045 /// for changes. When a configuration file is modified, it will be automatically
1046 /// reloaded and the provided callback will be invoked.
1047 ///
1048 /// # Parameters
1049 ///
1050 /// - `callback`: Optional callback function to invoke when configuration changes
1051 ///
1052 /// # Returns
1053 ///
1054 /// A `Result<(), RiError>` indicating success or failure
1055 #[cfg(feature = "config_hot_reload")]
1056 pub async fn start_watcher_with_callback<F>(&mut self, callback: Option<Arc<dyn Fn() + Send + Sync>>) -> Result<(), crate::core::RiError> {
1057 let (tx, mut rx) = tokio::sync::mpsc::channel::<notify::Result<notify::Event>>(100);
1058
1059 let mut watcher = RecommendedWatcher::new(
1060 move |res| {
1061 let _ = tx.blocking_send(res);
1062 },
1063 notify::Config::default(),
1064 ).map_err(|e| crate::core::RiError::Config(format!("Failed to create config watcher: {}", e)))?;
1065
1066 let mut monitored = Vec::with_capacity(self.sources.len());
1067
1068 for source in &self.sources {
1069 if let RiConfigSource::File(path) = source {
1070 if path.exists() {
1071 watcher.watch(path, RecursiveMode::NonRecursive)
1072 .map_err(|e| crate::core::RiError::Config(format!("Failed to watch config file {}: {}", path.display(), e)))?;
1073 monitored.push(path.clone());
1074 }
1075 }
1076 }
1077
1078 let monitored_paths = self.monitored_paths.clone();
1079 let manager = self.clone();
1080 let change_callback = callback.clone();
1081
1082 let task = tokio::spawn(async move {
1083 while let Some(event) = rx.recv().await {
1084 match event {
1085 Ok(event) => {
1086 if let Some(paths) = event.paths.first() {
1087 let changed_path = paths.clone();
1088
1089 {
1090 let mut paths_guard = monitored_paths.write().await;
1091 if !paths_guard.contains(&changed_path) {
1092 paths_guard.push(changed_path.clone());
1093 }
1094 }
1095
1096 log::info!("Config file changed: {}", changed_path.display());
1097
1098 if let Err(e) = manager.reload_file(&changed_path).await {
1099 log::error!("Failed to reload config file {}: {}", changed_path.display(), e);
1100 }
1101
1102 if let Some(ref cb) = change_callback {
1103 cb();
1104 }
1105
1106 #[cfg(feature = "pyo3")]
1107 manager.notify_config_reload(changed_path.to_str().unwrap_or(""));
1108 }
1109 }
1110 Err(e) => {
1111 log::warn!("Config watcher error: {:?}", e);
1112 }
1113 }
1114 }
1115 });
1116
1117 self.watcher = Some(Arc::new(watcher));
1118 *self.watcher_task.write().await = Some(task);
1119 self.change_callback = callback;
1120
1121 let mut paths_guard = self.monitored_paths.write().await;
1122 *paths_guard = monitored;
1123
1124 Ok(())
1125 }
1126
1127 /// Reloads configuration from a specific file.
1128 ///
1129 /// # Parameters
1130 ///
1131 /// - `path`: The path to the configuration file to reload
1132 ///
1133 /// # Returns
1134 ///
1135 /// A `Result<(), RiError>` indicating success or failure
1136 #[cfg(feature = "config_hot_reload")]
1137 async fn reload_file(&self, path: &PathBuf) -> Result<(), crate::core::RiError> {
1138 let mut new_config = self.config.read().expect("Failed to lock config for reading").clone();
1139 self.load_file(path, &mut new_config)?;
1140
1141 *self.config.write().expect("Failed to lock config for writing") = new_config;
1142
1143 Ok(())
1144 }
1145
1146 /// Stops the configuration watcher.
1147 ///
1148 /// This method stops the configuration watcher and cleans up associated resources.
1149 ///
1150 /// # Returns
1151 ///
1152 /// A `Result<(), RiError>` indicating success or failure
1153 #[cfg(feature = "config_hot_reload")]
1154 pub async fn stop_watcher(&mut self) -> Result<(), crate::core::RiError> {
1155 let task = self.watcher_task.write().await.take();
1156 if let Some(task) = task {
1157 task.abort();
1158 }
1159
1160 self.watcher = None;
1161
1162 let mut paths_guard = self.monitored_paths.write().await;
1163 paths_guard.clear();
1164
1165 Ok(())
1166 }
1167
1168 /// Gets the list of monitored configuration file paths.
1169 ///
1170 /// # Returns
1171 ///
1172 /// A vector of paths being monitored for changes
1173 pub async fn get_monitored_paths(&self) -> Vec<PathBuf> {
1174 self.monitored_paths.read().await.clone()
1175 }
1176
1177 /// Starts the configuration watcher for hot reload.
1178 ///
1179 /// This is a no-op implementation when the `config_hot_reload` feature is not enabled.
1180 ///
1181 /// # Returns
1182 ///
1183 /// A `Result<(), RiError>` indicating success or failure
1184 #[cfg(not(feature = "config_hot_reload"))]
1185 pub async fn start_watcher(&mut self) -> Result<(), crate::core::RiError> {
1186 Ok(())
1187 }
1188
1189 /// Gets a reference to the loaded configuration.
1190 ///
1191 /// # Returns
1192 ///
1193 /// A `RiConfig` clone of the loaded configuration
1194 pub fn config(&self) -> RiConfig {
1195 self.config.read().expect("Failed to lock config for reading").clone()
1196 }
1197
1198 /// Gets a mutable reference to the loaded configuration.
1199 ///
1200 /// # Returns
1201 ///
1202 /// A `std::sync::RwLockWriteGuard<RiConfig>` for the loaded configuration
1203 pub fn config_mut(&mut self) -> std::sync::RwLockWriteGuard<'_, RiConfig> {
1204 self.config.write().expect("Failed to lock config for writing")
1205 }
1206}
1207
1208#[cfg(feature = "pyo3")]
1209/// Python constructor for RiConfigManager
1210#[pyo3::prelude::pymethods]
1211impl RiConfigManager {
1212 #[new]
1213 fn py_new() -> Self {
1214 Self::new()
1215 }
1216
1217 /// Adds a file-based configuration source from Python
1218 ///
1219 /// ## Supported Formats
1220 ///
1221 /// - `.json`: JSON configuration format
1222 /// - `.yaml`, `.yml`: YAML configuration format
1223 /// - `.toml`: TOML configuration format
1224 #[pyo3(name = "add_file_source")]
1225 fn add_file_source_impl(&mut self, path: String) {
1226 self.add_file_source(path);
1227 }
1228
1229 /// Adds environment variables as a configuration source from Python
1230 ///
1231 /// Environment variables are prefixed with `Ri_` and double underscores
1232 /// are converted to dots (`.`) in the configuration key hierarchy.
1233 /// Example: `Ri_DATABASE__HOST` becomes `database.host`
1234 #[pyo3(name = "add_environment_source")]
1235 fn add_environment_source_impl(&mut self) {
1236 self.add_environment_source();
1237 }
1238
1239 /// Gets a configuration value as string from Python
1240 ///
1241 /// This method retrieves a configuration value by key, returning it as a string.
1242 /// If the key does not exist, returns `None`.
1243 ///
1244 /// ## Parameters
1245 ///
1246 /// - `key`: Configuration key string (dot-notation supported, e.g., "server.port")
1247 ///
1248 /// ## Returns
1249 ///
1250 /// Optional string containing the configuration value if found, `None` otherwise
1251 ///
1252 /// ## Example
1253 ///
1254 /// ```python
1255 /// manager = RiConfigManager.new_default()
1256 /// value = manager.get("server.port")
1257 /// if value:
1258 /// print(f"Server port: {value}")
1259 /// ```
1260 #[pyo3(name = "get")]
1261 fn get_config_impl(&self, key: String) -> Option<String> {
1262 self.config().get(&key).cloned()
1263 }
1264}
1265
1266#[cfg_attr(feature = "pyo3", pyo3::prelude::pyclass)]
1267#[derive(Clone, Debug)]
1268pub struct RiConfigValidator {
1269 required_keys: Vec<String>,
1270 port_keys: Vec<String>,
1271 timeout_keys: Vec<String>,
1272 secret_keys: Vec<String>,
1273 url_keys: Vec<String>,
1274 positive_int_keys: Vec<String>,
1275}
1276
1277impl Default for RiConfigValidator {
1278 fn default() -> Self {
1279 Self::new()
1280 }
1281}
1282
1283impl RiConfigValidator {
1284 pub fn new() -> Self {
1285 RiConfigValidator {
1286 required_keys: Vec::new(),
1287 port_keys: vec!["server.port".to_string(), "cache.redis.port".to_string(), "database.port".to_string()],
1288 timeout_keys: vec!["server.timeout".to_string(), "cache.ttl".to_string(), "session.timeout".to_string()],
1289 secret_keys: vec!["auth.jwt.secret".to_string(), "auth.password.salt".to_string(), "encryption.key".to_string()],
1290 url_keys: vec!["database.url".to_string(), "cache.redis.url".to_string(), "mq.url".to_string()],
1291 positive_int_keys: vec!["pool.size".to_string(), "worker.count".to_string(), "retry.max".to_string()],
1292 }
1293 }
1294
1295 pub fn add_required(&mut self, key: String) -> &mut Self {
1296 self.required_keys.push(key);
1297 self
1298 }
1299
1300 pub fn add_port_check(&mut self, key: String) -> &mut Self {
1301 self.port_keys.push(key);
1302 self
1303 }
1304
1305 pub fn add_timeout_check(&mut self, key: String) -> &mut Self {
1306 self.timeout_keys.push(key);
1307 self
1308 }
1309
1310 pub fn add_secret_check(&mut self, key: String) -> &mut Self {
1311 self.secret_keys.push(key);
1312 self
1313 }
1314
1315 pub fn add_url_check(&mut self, key: String) -> &mut Self {
1316 self.url_keys.push(key);
1317 self
1318 }
1319
1320 pub fn add_positive_int_check(&mut self, key: String) -> &mut Self {
1321 self.positive_int_keys.push(key);
1322 self
1323 }
1324
1325 pub fn validate_config(&self, config: &RiConfig) -> Result<(), crate::core::RiError> {
1326 for key in &self.required_keys {
1327 if !config.has_key(key) {
1328 return Err(crate::core::RiError::Config(format!(
1329 "Missing required configuration key: {}", key
1330 )));
1331 }
1332 }
1333
1334 for key in &self.port_keys {
1335 if let Some(port) = config.get_port(key) {
1336 if port == 0 {
1337 return Err(crate::core::RiError::Config(format!(
1338 "Invalid port number for {}: must be between 1 and 65535", key
1339 )));
1340 }
1341 }
1342 }
1343
1344 for key in &self.timeout_keys {
1345 if let Some(timeout) = config.get_timeout_secs(key) {
1346 if timeout == 0 {
1347 return Err(crate::core::RiError::Config(format!(
1348 "Invalid timeout for {}: must be between 1 and 86400 seconds", key
1349 )));
1350 }
1351 }
1352 }
1353
1354 for key in &self.secret_keys {
1355 if let Some(secret) = config.get_str(key) {
1356 if secret.len() < 8 {
1357 return Err(crate::core::RiError::Config(format!(
1358 "Secret key {} is too short: minimum length is 8 characters", key
1359 )));
1360 }
1361 if secret == "secret" || secret == "password" || secret == "123456" {
1362 return Err(crate::core::RiError::Config(format!(
1363 "Insecure secret key detected for {}: using default or weak value", key
1364 )));
1365 }
1366 }
1367 }
1368
1369 for key in &self.url_keys {
1370 if let Some(url) = config.get_str(key) {
1371 if !url.starts_with("http://") && !url.starts_with("https://")
1372 && !url.starts_with("redis://") && !url.starts_with("postgresql://")
1373 && !url.starts_with("mysql://") && !url.starts_with("amqp://")
1374 && !url.starts_with("kafka://") && !url.starts_with("sqlite://")
1375 {
1376 return Err(crate::core::RiError::Config(format!(
1377 "Invalid URL format for {}: {}", key, url
1378 )));
1379 }
1380 }
1381 }
1382
1383 for key in &self.positive_int_keys {
1384 if let Some(value) = config.get_u32(key) {
1385 if value == 0 {
1386 return Err(crate::core::RiError::Config(format!(
1387 "Invalid value for {}: must be a positive integer", key
1388 )));
1389 }
1390 }
1391 }
1392
1393 Ok(())
1394 }
1395
1396 pub fn validate_with_requirements(
1397 &self,
1398 config: &RiConfig,
1399 requirements: &[String],
1400 ) -> Result<(), crate::core::RiError> {
1401 for key in requirements {
1402 if !config.has_key(key) {
1403 return Err(crate::core::RiError::Config(format!(
1404 "Missing required configuration key: {}", key
1405 )));
1406 }
1407 }
1408 self.validate_config(config)
1409 }
1410}
1411
1412#[cfg(feature = "pyo3")]
1413#[pyo3::prelude::pymethods]
1414impl RiConfigValidator {
1415 #[new]
1416 fn py_new() -> Self {
1417 Self::new()
1418 }
1419
1420 #[pyo3(name = "require")]
1421 fn py_add_required(&mut self, key: String) {
1422 self.required_keys.push(key);
1423 }
1424
1425 #[pyo3(name = "require_port")]
1426 fn py_add_port_check(&mut self, key: String) {
1427 self.port_keys.push(key);
1428 }
1429
1430 #[pyo3(name = "require_timeout")]
1431 fn py_add_timeout_check(&mut self, key: String) {
1432 self.timeout_keys.push(key);
1433 }
1434
1435 #[pyo3(name = "require_secret")]
1436 fn py_add_secret_check(&mut self, key: String) {
1437 self.secret_keys.push(key);
1438 }
1439
1440 #[pyo3(name = "require_url")]
1441 fn py_add_url_check(&mut self, key: String) {
1442 self.url_keys.push(key);
1443 }
1444
1445 #[pyo3(name = "require_positive_int")]
1446 fn py_add_positive_int_check(&mut self, key: String) {
1447 self.positive_int_keys.push(key);
1448 }
1449
1450 #[pyo3(name = "validate")]
1451 fn py_validate(&self, config: &RiConfig) -> bool {
1452 self.validate_config(config).is_ok()
1453 }
1454}