1use std::collections::{BTreeSet, HashMap};
9use std::sync::{
10 RwLock as StdRwLock, RwLockReadGuard as StdRwLockReadGuard,
11 RwLockWriteGuard as StdRwLockWriteGuard,
12};
13
14use async_trait::async_trait;
15use tokio::sync::RwLock;
16
17use crate::{
18 ConfigCoreError, ConfigSource, ConfigValueLookup, ConfigValueType, ConfigVisibility, Result,
19};
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct StoredConfig {
24 pub id: i64,
26 pub key: String,
28 pub value: String,
30 pub value_type: ConfigValueType,
32 pub requires_restart: bool,
34 pub is_sensitive: bool,
36 pub source: ConfigSource,
38 pub visibility: ConfigVisibility,
40 pub category: String,
42 pub description: String,
44}
45
46pub trait RuntimeConfigRecord: Clone + PartialEq {
54 fn config_key(&self) -> &str;
56
57 fn config_value(&self) -> &str;
59
60 fn config_requires_restart(&self) -> bool;
62}
63
64impl RuntimeConfigRecord for StoredConfig {
65 fn config_key(&self) -> &str {
66 &self.key
67 }
68
69 fn config_value(&self) -> &str {
70 &self.value
71 }
72
73 fn config_requires_restart(&self) -> bool {
74 self.requires_restart
75 }
76}
77
78#[async_trait]
81pub trait AsyncConfigStore: Send + Sync {
82 async fn load_all(&self) -> Result<Vec<StoredConfig>>;
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct SyncConfigSnapshot<T = StoredConfig> {
89 values: HashMap<String, T>,
90}
91
92impl<T> Default for SyncConfigSnapshot<T> {
93 fn default() -> Self {
94 Self {
95 values: HashMap::new(),
96 }
97 }
98}
99
100impl<T> SyncConfigSnapshot<T>
101where
102 T: RuntimeConfigRecord,
103{
104 #[must_use]
106 pub fn from_configs(configs: Vec<T>) -> Self {
107 Self {
108 values: configs
109 .into_iter()
110 .map(|config| (config.config_key().to_string(), config))
111 .collect(),
112 }
113 }
114
115 #[must_use]
117 pub fn get_model(&self, key: &str) -> Option<&T> {
118 self.values.get(key)
119 }
120
121 pub fn get(&self, key: &str) -> Option<&str> {
123 self.get_model(key).map(RuntimeConfigRecord::config_value)
124 }
125
126 #[must_use]
128 pub fn get_bool(&self, key: &str) -> Option<bool> {
129 let value = self.get(key)?;
130 parse_bool_like_value(value)
131 }
132
133 #[must_use]
135 pub fn get_i64(&self, key: &str) -> Option<i64> {
136 self.get(key)?.trim().parse().ok()
137 }
138
139 #[must_use]
141 pub fn get_u64(&self, key: &str) -> Option<u64> {
142 self.get(key)?.trim().parse().ok()
143 }
144
145 pub fn get_string_or(&self, key: &str, default: &str) -> String {
147 self.get(key)
148 .map_or_else(|| default.to_string(), ToOwned::to_owned)
149 }
150
151 #[must_use]
153 pub fn get_bool_or(&self, key: &str, default: bool) -> bool {
154 self.get_bool(key).unwrap_or(default)
155 }
156
157 #[must_use]
159 pub fn get_i64_or(&self, key: &str, default: i64) -> i64 {
160 self.get_i64(key).unwrap_or(default)
161 }
162
163 #[must_use]
165 pub fn get_u64_or(&self, key: &str, default: u64) -> u64 {
166 self.get_u64(key).unwrap_or(default)
167 }
168
169 #[must_use]
171 pub fn values(&self) -> &HashMap<String, T> {
172 &self.values
173 }
174}
175
176impl<T> ConfigValueLookup for SyncConfigSnapshot<T>
177where
178 T: RuntimeConfigRecord,
179{
180 fn get_config_value(&self, key: &str) -> Option<String> {
181 self.get(key).map(ToOwned::to_owned)
182 }
183}
184
185#[derive(Debug, Clone, Default, PartialEq, Eq)]
187pub struct AsyncConfigSnapshot {
188 values: HashMap<String, StoredConfig>,
189}
190
191impl AsyncConfigSnapshot {
192 #[must_use]
194 pub fn from_configs(configs: Vec<StoredConfig>) -> Self {
195 Self {
196 values: configs
197 .into_iter()
198 .map(|config| (config.key.clone(), config))
199 .collect(),
200 }
201 }
202
203 #[must_use]
205 pub fn get_model(&self, key: &str) -> Option<&StoredConfig> {
206 self.values.get(key)
207 }
208
209 #[must_use]
211 pub fn get(&self, key: &str) -> Option<&str> {
212 self.get_model(key).map(|config| config.value.as_str())
213 }
214
215 #[must_use]
217 pub fn get_bool(&self, key: &str) -> Option<bool> {
218 let value = self.get(key)?;
219 parse_bool_like_value(value)
220 }
221
222 #[must_use]
224 pub fn get_i64(&self, key: &str) -> Option<i64> {
225 self.get(key)?.trim().parse().ok()
226 }
227
228 #[must_use]
230 pub fn get_u64(&self, key: &str) -> Option<u64> {
231 self.get(key)?.trim().parse().ok()
232 }
233
234 pub fn get_string_or(&self, key: &str, default: &str) -> String {
236 self.get(key)
237 .map_or_else(|| default.to_string(), ToOwned::to_owned)
238 }
239
240 #[must_use]
242 pub fn get_bool_or(&self, key: &str, default: bool) -> bool {
243 self.get_bool(key).unwrap_or(default)
244 }
245
246 #[must_use]
248 pub fn get_i64_or(&self, key: &str, default: i64) -> i64 {
249 self.get_i64(key).unwrap_or(default)
250 }
251
252 #[must_use]
254 pub fn get_u64_or(&self, key: &str, default: u64) -> u64 {
255 self.get_u64(key).unwrap_or(default)
256 }
257
258 #[must_use]
260 pub fn values(&self) -> &HashMap<String, StoredConfig> {
261 &self.values
262 }
263}
264
265impl ConfigValueLookup for AsyncConfigSnapshot {
266 fn get_config_value(&self, key: &str) -> Option<String> {
267 self.get(key).map(ToOwned::to_owned)
268 }
269}
270
271#[derive(Debug, Clone, PartialEq, Eq)]
273pub enum RuntimeConfigChange<T = StoredConfig> {
274 Upserted(T),
276 Removed(String),
278}
279
280#[derive(Debug, Default)]
287pub struct SyncRuntimeConfig<T = StoredConfig> {
288 snapshot: StdRwLock<SyncConfigSnapshot<T>>,
289}
290
291impl<T> SyncRuntimeConfig<T>
292where
293 T: RuntimeConfigRecord,
294{
295 #[must_use]
297 pub fn new() -> Self {
298 Self {
299 snapshot: StdRwLock::new(SyncConfigSnapshot::default()),
300 }
301 }
302
303 pub fn replace(&self, configs: Vec<T>) -> Vec<RuntimeConfigChange<T>> {
310 let mut next = SyncConfigSnapshot::from_configs(configs);
311 let mut guard = self.write_snapshot();
312 preserve_restart_only_records(&guard.values, &mut next.values);
313 let changes = diff_sync_snapshots(&guard, &next);
314 *guard = next;
315 changes
316 }
317
318 pub fn snapshot(&self) -> SyncConfigSnapshot<T> {
320 self.read_snapshot().clone()
321 }
322
323 pub fn get_model(&self, key: &str) -> Option<T> {
325 self.read_snapshot().get_model(key).cloned()
326 }
327
328 pub fn get(&self, key: &str) -> Option<String> {
330 self.read_snapshot().get(key).map(ToOwned::to_owned)
331 }
332
333 pub fn get_bool(&self, key: &str) -> Option<bool> {
335 self.read_snapshot().get_bool(key)
336 }
337
338 pub fn get_i64(&self, key: &str) -> Option<i64> {
340 self.read_snapshot().get_i64(key)
341 }
342
343 pub fn get_u64(&self, key: &str) -> Option<u64> {
345 self.read_snapshot().get_u64(key)
346 }
347
348 pub fn get_string_or(&self, key: &str, default: &str) -> String {
350 self.read_snapshot().get_string_or(key, default)
351 }
352
353 pub fn get_bool_or(&self, key: &str, default: bool) -> bool {
355 self.read_snapshot().get_bool_or(key, default)
356 }
357
358 pub fn get_i64_or(&self, key: &str, default: i64) -> i64 {
360 self.read_snapshot().get_i64_or(key, default)
361 }
362
363 pub fn get_u64_or(&self, key: &str, default: u64) -> u64 {
365 self.read_snapshot().get_u64_or(key, default)
366 }
367
368 pub fn apply(&self, config: T) -> Option<RuntimeConfigChange<T>> {
373 let mut guard = self.write_snapshot();
374 let key = config.config_key().to_string();
375 if config.config_requires_restart() && guard.values.contains_key(&key) {
376 return None;
377 }
378
379 let changed = guard.values.get(&key) != Some(&config);
380 guard.values.insert(key, config.clone());
381 changed.then_some(RuntimeConfigChange::Upserted(config))
382 }
383
384 pub fn remove(&self, key: &str) -> Option<RuntimeConfigChange<T>> {
386 let mut guard = self.write_snapshot();
387 guard
388 .values
389 .remove(key)
390 .map(|_| RuntimeConfigChange::Removed(key.to_string()))
391 }
392
393 fn read_snapshot(&self) -> StdRwLockReadGuard<'_, SyncConfigSnapshot<T>> {
394 match self.snapshot.read() {
395 Ok(guard) => guard,
396 Err(poisoned) => poisoned.into_inner(),
397 }
398 }
399
400 fn write_snapshot(&self) -> StdRwLockWriteGuard<'_, SyncConfigSnapshot<T>> {
401 match self.snapshot.write() {
402 Ok(guard) => guard,
403 Err(poisoned) => poisoned.into_inner(),
404 }
405 }
406}
407
408#[derive(Debug, Default)]
415pub struct AsyncRuntimeConfig {
416 snapshot: RwLock<AsyncConfigSnapshot>,
417}
418
419impl AsyncRuntimeConfig {
420 #[must_use]
422 pub fn new() -> Self {
423 Self {
424 snapshot: RwLock::new(AsyncConfigSnapshot::default()),
425 }
426 }
427
428 pub async fn reload<S>(&self, store: &S) -> Result<Vec<RuntimeConfigChange>>
439 where
440 S: AsyncConfigStore + ?Sized,
441 {
442 let mut next = AsyncConfigSnapshot::from_configs(store.load_all().await?);
443 let mut guard = self.snapshot.write().await;
444 preserve_restart_only_records(&guard.values, &mut next.values);
445 let changes = diff_snapshots(&guard, &next);
446 *guard = next;
447 Ok(changes)
448 }
449
450 pub async fn snapshot(&self) -> AsyncConfigSnapshot {
452 self.snapshot.read().await.clone()
453 }
454
455 pub async fn get_model(&self, key: &str) -> Option<StoredConfig> {
457 self.snapshot.read().await.get_model(key).cloned()
458 }
459
460 pub async fn get(&self, key: &str) -> Option<String> {
462 self.snapshot.read().await.get(key).map(ToOwned::to_owned)
463 }
464
465 pub async fn apply(&self, config: StoredConfig) -> Option<RuntimeConfigChange> {
470 let mut guard = self.snapshot.write().await;
471 if config.requires_restart && guard.values.contains_key(&config.key) {
472 return None;
473 }
474
475 let changed = guard.values.get(&config.key) != Some(&config);
476 guard.values.insert(config.key.clone(), config.clone());
477 changed.then_some(RuntimeConfigChange::Upserted(config))
478 }
479
480 pub async fn remove(&self, key: &str) -> Option<RuntimeConfigChange> {
482 let mut guard = self.snapshot.write().await;
483 guard
484 .values
485 .remove(key)
486 .map(|_| RuntimeConfigChange::Removed(key.to_string()))
487 }
488}
489
490fn preserve_restart_only_records<T>(previous: &HashMap<String, T>, next: &mut HashMap<String, T>)
496where
497 T: RuntimeConfigRecord,
498{
499 for (key, incoming) in next.iter_mut() {
500 if !incoming.config_requires_restart() {
501 continue;
502 }
503 if let Some(existing) = previous.get(key) {
504 *incoming = existing.clone();
505 }
506 }
507}
508
509fn diff_snapshots(
510 previous: &AsyncConfigSnapshot,
511 next: &AsyncConfigSnapshot,
512) -> Vec<RuntimeConfigChange> {
513 let mut keys = BTreeSet::new();
514 keys.extend(previous.values.keys().map(String::as_str));
515 keys.extend(next.values.keys().map(String::as_str));
516
517 let mut changes = Vec::new();
518 for key in keys {
519 match (previous.values.get(key), next.values.get(key)) {
520 (Some(old), Some(new)) if old == new => {}
521 (_, Some(new)) => changes.push(RuntimeConfigChange::Upserted(new.clone())),
522 (Some(_), None) => changes.push(RuntimeConfigChange::Removed(key.to_string())),
523 (None, None) => {}
524 }
525 }
526 changes
527}
528
529fn diff_sync_snapshots<T>(
530 previous: &SyncConfigSnapshot<T>,
531 next: &SyncConfigSnapshot<T>,
532) -> Vec<RuntimeConfigChange<T>>
533where
534 T: RuntimeConfigRecord,
535{
536 let mut keys = BTreeSet::new();
537 keys.extend(previous.values.keys().map(String::as_str));
538 keys.extend(next.values.keys().map(String::as_str));
539
540 let mut changes = Vec::new();
541 for key in keys {
542 match (previous.values.get(key), next.values.get(key)) {
543 (Some(old), Some(new)) if old == new => {}
544 (_, Some(new)) => changes.push(RuntimeConfigChange::Upserted(new.clone())),
545 (Some(_), None) => changes.push(RuntimeConfigChange::Removed(key.to_string())),
546 (None, None) => {}
547 }
548 }
549 changes
550}
551
552#[must_use]
554pub fn parse_bool_like_value(value: &str) -> Option<bool> {
555 match value.trim().to_ascii_lowercase().as_str() {
556 "true" | "1" | "yes" | "on" => Some(true),
557 "false" | "0" | "no" | "off" => Some(false),
558 _ => None,
559 }
560}
561
562#[must_use]
564pub fn parse_strict_bool_value(value: &str) -> Option<bool> {
565 match value.trim() {
566 "true" => Some(true),
567 "false" => Some(false),
568 _ => None,
569 }
570}
571
572#[must_use]
574pub fn parse_positive_u64(value: &str) -> Option<u64> {
575 let parsed = value.trim().parse::<u64>().ok()?;
576 (parsed > 0).then_some(parsed)
577}
578
579#[must_use]
581pub fn parse_positive_u32(value: &str) -> Option<u32> {
582 let parsed = value.trim().parse::<u32>().ok()?;
583 (parsed > 0).then_some(parsed)
584}
585
586#[must_use]
588pub fn parse_non_negative_u64(value: &str) -> Option<u64> {
589 value.trim().parse::<u64>().ok()
590}
591
592#[must_use]
594pub fn parse_bounded_u64(value: &str, min: u64, max: u64) -> Option<u64> {
595 let parsed = value.trim().parse::<u64>().ok()?;
596 (min..=max).contains(&parsed).then_some(parsed)
597}
598
599#[must_use]
601pub fn parse_bounded_u8(value: &str, min: u8, max: u8) -> Option<u8> {
602 let parsed = value.trim().parse::<u8>().ok()?;
603 (min..=max).contains(&parsed).then_some(parsed)
604}
605
606#[must_use]
608pub fn parse_positive_i32(value: &str) -> Option<i32> {
609 let parsed = value.trim().parse::<i32>().ok()?;
610 (parsed > 0).then_some(parsed)
611}
612
613#[must_use]
615pub fn parse_finite_f32(value: &str) -> Option<f32> {
616 let parsed = value.trim().parse::<f32>().ok()?;
617 parsed.is_finite().then_some(parsed)
618}
619
620pub fn normalize_positive_u64_config_value(key: &str, value: &str) -> Result<String> {
626 let parsed = parse_positive_u64(value).ok_or_else(|| {
627 ConfigCoreError::invalid_value(format!("{key} must be a positive integer"))
628 })?;
629 Ok(parsed.to_string())
630}
631
632pub fn normalize_non_negative_u64_config_value(key: &str, value: &str) -> Result<String> {
638 let parsed = parse_non_negative_u64(value).ok_or_else(|| {
639 ConfigCoreError::invalid_value(format!("{key} must be a non-negative integer"))
640 })?;
641 Ok(parsed.to_string())
642}
643
644pub fn normalize_bounded_u64_config_value(
650 key: &str,
651 value: &str,
652 min: u64,
653 max: u64,
654) -> Result<String> {
655 let parsed = parse_bounded_u64(value, min, max).ok_or_else(|| {
656 ConfigCoreError::invalid_value(format!("{key} must be between {min} and {max}"))
657 })?;
658 Ok(parsed.to_string())
659}
660
661pub fn normalize_bool_config_value(key: &str, value: &str) -> Result<String> {
670 let parsed = parse_bool_like_value(value).ok_or_else(|| {
671 ConfigCoreError::invalid_value(format!("{key} must be 'true' or 'false'"))
672 })?;
673 Ok(if parsed { "true" } else { "false" }.to_string())
674}
675
676pub fn normalize_strict_bool_config_value(key: &str, value: &str) -> Result<String> {
682 let parsed = parse_strict_bool_value(value).ok_or_else(|| {
683 ConfigCoreError::invalid_value(format!("{key} must be 'true' or 'false'"))
684 })?;
685 Ok(if parsed { "true" } else { "false" }.to_string())
686}
687
688pub fn normalize_positive_u32_config_value(key: &str, value: &str) -> Result<String> {
694 let parsed = parse_positive_u32(value).ok_or_else(|| {
695 ConfigCoreError::invalid_value(format!("{key} must be a positive integer"))
696 })?;
697 Ok(parsed.to_string())
698}
699
700pub fn normalize_bounded_u8_config_value(
706 key: &str,
707 value: &str,
708 min: u8,
709 max: u8,
710) -> Result<String> {
711 let parsed = parse_bounded_u8(value, min, max).ok_or_else(|| {
712 ConfigCoreError::invalid_value(format!("{key} must be between {min} and {max}"))
713 })?;
714 Ok(parsed.to_string())
715}
716
717pub fn normalize_finite_f32_config_value(key: &str, value: &str) -> Result<String> {
723 let parsed = parse_finite_f32(value)
724 .ok_or_else(|| ConfigCoreError::invalid_value(format!("{key} must be a finite number")))?;
725 Ok(parsed.to_string())
726}
727
728pub fn read_positive_u64<L>(lookup: &L, key: &str, default: u64) -> u64
730where
731 L: ConfigValueLookup + ?Sized,
732{
733 match lookup.get_config_value(key) {
734 Some(raw) => {
735 if let Some(value) = parse_positive_u64(&raw) {
736 value
737 } else {
738 tracing::warn!(key, value = %raw, "invalid runtime config; using default");
739 default
740 }
741 }
742 None => default,
743 }
744}
745
746pub fn read_positive_u32<L>(lookup: &L, key: &str, default: u32) -> u32
748where
749 L: ConfigValueLookup + ?Sized,
750{
751 match lookup.get_config_value(key) {
752 Some(raw) => {
753 if let Some(value) = parse_positive_u32(&raw) {
754 value
755 } else {
756 tracing::warn!(key, value = %raw, "invalid runtime config; using default");
757 default
758 }
759 }
760 None => default,
761 }
762}
763
764pub fn read_non_negative_u64<L>(lookup: &L, key: &str, default: u64) -> u64
766where
767 L: ConfigValueLookup + ?Sized,
768{
769 match lookup.get_config_value(key) {
770 Some(raw) => {
771 if let Some(value) = parse_non_negative_u64(&raw) {
772 value
773 } else {
774 tracing::warn!(key, value = %raw, "invalid runtime config; using default");
775 default
776 }
777 }
778 None => default,
779 }
780}
781
782pub fn read_bounded_u64<L>(lookup: &L, key: &str, default: u64, min: u64, max: u64) -> u64
784where
785 L: ConfigValueLookup + ?Sized,
786{
787 match lookup.get_config_value(key) {
788 Some(raw) => {
789 if let Some(value) = parse_bounded_u64(&raw, min, max) {
790 value
791 } else {
792 tracing::warn!(
793 key,
794 value = %raw,
795 min,
796 max,
797 "invalid runtime config; using default"
798 );
799 default
800 }
801 }
802 None => default,
803 }
804}
805
806pub fn read_bounded_u8<L>(lookup: &L, key: &str, default: u8, min: u8, max: u8) -> u8
808where
809 L: ConfigValueLookup + ?Sized,
810{
811 match lookup.get_config_value(key) {
812 Some(raw) => {
813 if let Some(value) = parse_bounded_u8(&raw, min, max) {
814 value
815 } else {
816 tracing::warn!(
817 key,
818 value = %raw,
819 min,
820 max,
821 "invalid runtime config; using default"
822 );
823 default
824 }
825 }
826 None => default,
827 }
828}
829
830pub fn read_positive_i32<L>(lookup: &L, key: &str, default: i32) -> i32
832where
833 L: ConfigValueLookup + ?Sized,
834{
835 match lookup.get_config_value(key) {
836 Some(raw) => {
837 if let Some(value) = parse_positive_i32(&raw) {
838 value
839 } else {
840 tracing::warn!(key, value = %raw, "invalid runtime config; using default");
841 default
842 }
843 }
844 None => default,
845 }
846}
847
848pub fn read_finite_f32<L>(lookup: &L, key: &str, default: f32) -> f32
850where
851 L: ConfigValueLookup + ?Sized,
852{
853 match lookup.get_config_value(key) {
854 Some(raw) => {
855 if let Some(value) = parse_finite_f32(&raw) {
856 value
857 } else {
858 tracing::warn!(key, value = %raw, "invalid runtime config; using default");
859 default
860 }
861 }
862 None => default,
863 }
864}
865
866pub fn read_bool<L>(lookup: &L, key: &str, default: bool) -> bool
868where
869 L: ConfigValueLookup + ?Sized,
870{
871 match lookup.get_config_value(key) {
872 Some(raw) => {
873 if let Some(value) = parse_bool_like_value(&raw) {
874 value
875 } else {
876 tracing::warn!(key, value = %raw, "invalid runtime boolean config; using default");
877 default
878 }
879 }
880 None => default,
881 }
882}
883
884pub fn read_positive_usize<L>(lookup: &L, key: &str, default: usize) -> usize
886where
887 L: ConfigValueLookup + ?Sized,
888{
889 let default_u64 = u64::try_from(default).unwrap_or(u64::MAX);
890 if let Ok(value) = usize::try_from(read_positive_u64(lookup, key, default_u64)) {
891 value
892 } else {
893 tracing::warn!(key, "{key} exceeds usize; using default");
894 default
895 }
896}
897
898#[cfg(test)]
899mod tests {
900 use async_trait::async_trait;
901
902 use super::{
903 AsyncConfigStore, AsyncRuntimeConfig, RuntimeConfigChange, StoredConfig, SyncRuntimeConfig,
904 normalize_bool_config_value, normalize_bounded_u8_config_value,
905 normalize_bounded_u64_config_value, normalize_finite_f32_config_value,
906 normalize_non_negative_u64_config_value, normalize_positive_u32_config_value,
907 normalize_positive_u64_config_value, normalize_strict_bool_config_value,
908 parse_bool_like_value, parse_bounded_u8, parse_bounded_u64, parse_finite_f32,
909 parse_non_negative_u64, parse_positive_i32, parse_positive_u32, parse_positive_u64,
910 parse_strict_bool_value, read_bool, read_bounded_u8, read_bounded_u64, read_finite_f32,
911 read_non_negative_u64, read_positive_i32, read_positive_u32, read_positive_u64,
912 read_positive_usize,
913 };
914 use crate::{ConfigSource, ConfigValueType, ConfigVisibility, Result};
915
916 fn config(key: &str, value: &str, requires_restart: bool) -> StoredConfig {
917 StoredConfig {
918 id: 1,
919 key: key.to_string(),
920 value: value.to_string(),
921 value_type: ConfigValueType::String,
922 requires_restart,
923 is_sensitive: false,
924 source: ConfigSource::System,
925 visibility: ConfigVisibility::Private,
926 category: "general".to_string(),
927 description: "test config".to_string(),
928 }
929 }
930
931 struct StaticStore(Vec<StoredConfig>);
932
933 #[async_trait]
934 impl AsyncConfigStore for StaticStore {
935 async fn load_all(&self) -> Result<Vec<StoredConfig>> {
936 Ok(self.0.clone())
937 }
938 }
939
940 #[tokio::test]
941 async fn reload_replaces_snapshot_and_reports_changes() {
942 let runtime_config = AsyncRuntimeConfig::new();
943
944 let changes = runtime_config
945 .reload(&StaticStore(vec![config("enabled", "yes", false)]))
946 .await
947 .unwrap();
948
949 assert_eq!(changes.len(), 1);
950 assert_eq!(
951 runtime_config.snapshot().await.get_bool("enabled"),
952 Some(true)
953 );
954
955 let changes = runtime_config
956 .reload(&StaticStore(vec![config("limit", "10", false)]))
957 .await
958 .unwrap();
959
960 assert_eq!(
961 changes,
962 vec![
963 RuntimeConfigChange::Removed("enabled".to_string()),
964 RuntimeConfigChange::Upserted(config("limit", "10", false)),
965 ]
966 );
967 assert_eq!(runtime_config.snapshot().await.get_u64("limit"), Some(10));
968 }
969
970 #[tokio::test]
971 async fn apply_ignores_hot_update_for_restart_required_existing_value() {
972 let runtime_config = AsyncRuntimeConfig::new();
973 runtime_config
974 .apply(config("static_key", "old", false))
975 .await;
976
977 let change = runtime_config
978 .apply(config("static_key", "new", true))
979 .await;
980
981 assert_eq!(change, None);
982 assert_eq!(
983 runtime_config.get("static_key").await.as_deref(),
984 Some("old")
985 );
986 }
987
988 #[tokio::test]
989 async fn reload_preserves_restart_required_existing_values() {
990 let runtime_config = AsyncRuntimeConfig::new();
991 runtime_config
993 .reload(&StaticStore(vec![
994 config("static_key", "old", true),
995 config("hot_key", "v1", false),
996 ]))
997 .await
998 .unwrap();
999
1000 let changes = runtime_config
1001 .reload(&StaticStore(vec![
1002 config("static_key", "new", true),
1004 config("hot_key", "v2", false),
1006 config("fresh_static", "fresh", true),
1008 ]))
1009 .await
1010 .unwrap();
1011
1012 assert_eq!(
1013 changes,
1014 vec![
1015 RuntimeConfigChange::Upserted(config("fresh_static", "fresh", true)),
1016 RuntimeConfigChange::Upserted(config("hot_key", "v2", false)),
1017 ]
1018 );
1019 assert_eq!(
1020 runtime_config.get("static_key").await.as_deref(),
1021 Some("old")
1022 );
1023 assert_eq!(runtime_config.get("hot_key").await.as_deref(), Some("v2"));
1024 assert_eq!(
1025 runtime_config.get("fresh_static").await.as_deref(),
1026 Some("fresh")
1027 );
1028 }
1029
1030 #[test]
1031 fn sync_runtime_config_supports_hot_reads_and_diffs() {
1032 let runtime_config = SyncRuntimeConfig::new();
1033
1034 let changes = runtime_config.replace(vec![config("enabled", "yes", false)]);
1035
1036 assert_eq!(changes.len(), 1);
1037 assert_eq!(runtime_config.get_bool("enabled"), Some(true));
1038
1039 let changes = runtime_config.replace(vec![config("limit", "10", false)]);
1040
1041 assert_eq!(
1042 changes,
1043 vec![
1044 RuntimeConfigChange::Removed("enabled".to_string()),
1045 RuntimeConfigChange::Upserted(config("limit", "10", false)),
1046 ]
1047 );
1048 assert_eq!(runtime_config.get_u64("limit"), Some(10));
1049 assert_eq!(runtime_config.snapshot().get("limit"), Some("10"));
1050 }
1051
1052 #[test]
1053 fn sync_runtime_config_ignores_restart_required_hot_update() {
1054 let runtime_config = SyncRuntimeConfig::new();
1055 runtime_config.apply(config("static_key", "old", false));
1056
1057 let change = runtime_config.apply(config("static_key", "new", true));
1058
1059 assert_eq!(change, None);
1060 assert_eq!(runtime_config.get("static_key").as_deref(), Some("old"));
1061 }
1062
1063 #[test]
1064 fn sync_replace_preserves_restart_required_existing_values() {
1065 let runtime_config = SyncRuntimeConfig::new();
1066 runtime_config.replace(vec![
1068 config("static_key", "old", true),
1069 config("flagged_key", "old", false),
1070 config("hot_key", "v1", false),
1071 ]);
1072
1073 let changes = runtime_config.replace(vec![
1074 config("static_key", "new", true),
1076 config("flagged_key", "new", true),
1079 config("hot_key", "v2", false),
1081 config("fresh_static", "fresh", true),
1083 ]);
1084
1085 assert_eq!(
1086 changes,
1087 vec![
1088 RuntimeConfigChange::Upserted(config("fresh_static", "fresh", true)),
1089 RuntimeConfigChange::Upserted(config("hot_key", "v2", false)),
1090 ]
1091 );
1092 assert_eq!(runtime_config.get("static_key").as_deref(), Some("old"));
1093 assert_eq!(runtime_config.get("hot_key").as_deref(), Some("v2"));
1094 assert_eq!(runtime_config.get("fresh_static").as_deref(), Some("fresh"));
1095 assert_eq!(
1097 runtime_config.get_model("flagged_key"),
1098 Some(config("flagged_key", "old", false))
1099 );
1100 }
1101
1102 #[test]
1103 fn runtime_value_parsers_accept_expected_shapes() {
1104 assert_eq!(parse_bool_like_value(" yes "), Some(true));
1105 assert_eq!(parse_bool_like_value("off"), Some(false));
1106 assert_eq!(parse_bool_like_value("maybe"), None);
1107 assert_eq!(parse_strict_bool_value(" true "), Some(true));
1108 assert_eq!(parse_strict_bool_value("false"), Some(false));
1109 assert_eq!(parse_strict_bool_value("yes"), None);
1110 assert_eq!(parse_positive_u64("42"), Some(42));
1111 assert_eq!(parse_positive_u64("0"), None);
1112 assert_eq!(parse_positive_u32("42"), Some(42));
1113 assert_eq!(parse_positive_u32("4294967296"), None);
1114 assert_eq!(parse_non_negative_u64("0"), Some(0));
1115 assert_eq!(parse_non_negative_u64("-1"), None);
1116 assert_eq!(parse_bounded_u64("5", 4, 8), Some(5));
1117 assert_eq!(parse_bounded_u64("3", 4, 8), None);
1118 assert_eq!(parse_bounded_u64("9", 4, 8), None);
1119 assert_eq!(parse_bounded_u8("4", 1, 4), Some(4));
1120 assert_eq!(parse_bounded_u8("5", 1, 4), None);
1121 assert_eq!(parse_positive_i32("12"), Some(12));
1122 assert_eq!(parse_positive_i32("2147483648"), None);
1123 assert_eq!(parse_finite_f32("1.5"), Some(1.5));
1124 assert_eq!(parse_finite_f32("NaN"), None);
1125 assert_eq!(parse_finite_f32("inf"), None);
1126 }
1127
1128 #[test]
1129 fn runtime_value_readers_use_defaults_for_invalid_values() {
1130 let lookup = std::collections::HashMap::from([
1131 ("positive".to_string(), "5".to_string()),
1132 ("zero".to_string(), "0".to_string()),
1133 ("bool".to_string(), "on".to_string()),
1134 ("bad".to_string(), "nope".to_string()),
1135 ("bounded".to_string(), "7".to_string()),
1136 ("bounded_u8".to_string(), "4".to_string()),
1137 ("out_of_range".to_string(), "12".to_string()),
1138 ("too_large_i32".to_string(), "2147483648".to_string()),
1139 ("finite".to_string(), "2.5".to_string()),
1140 ("nan".to_string(), "NaN".to_string()),
1141 ]);
1142
1143 assert_eq!(read_positive_u64(&lookup, "positive", 1), 5);
1144 assert_eq!(read_positive_u32(&lookup, "positive", 1), 5);
1145 assert_eq!(read_positive_u64(&lookup, "zero", 1), 1);
1146 assert_eq!(read_non_negative_u64(&lookup, "zero", 9), 0);
1147 assert_eq!(read_bounded_u64(&lookup, "bounded", 1, 4, 8), 7);
1148 assert_eq!(read_bounded_u8(&lookup, "bounded_u8", 1, 1, 4), 4);
1149 assert_eq!(read_bounded_u64(&lookup, "out_of_range", 1, 4, 8), 1);
1150 assert_eq!(read_bounded_u8(&lookup, "out_of_range", 1, 1, 4), 1);
1151 assert!(read_bool(&lookup, "bool", false));
1152 assert!(read_bool(&lookup, "bad", true));
1153 assert_eq!(read_positive_i32(&lookup, "too_large_i32", 3), 3);
1154 assert_eq!(
1155 read_finite_f32(&lookup, "finite", 1.0).to_bits(),
1156 2.5_f32.to_bits()
1157 );
1158 assert_eq!(
1159 read_finite_f32(&lookup, "nan", 1.0).to_bits(),
1160 1.0_f32.to_bits()
1161 );
1162 assert_eq!(read_positive_usize(&lookup, "positive", 1), 5);
1163 }
1164
1165 #[test]
1166 fn positive_u64_normalizer_trims_and_rejects_invalid_values() {
1167 assert_eq!(
1168 normalize_positive_u64_config_value("interval", " 60 ").unwrap(),
1169 "60"
1170 );
1171 assert!(normalize_positive_u64_config_value("interval", "0").is_err());
1172 assert!(normalize_positive_u64_config_value("interval", "abc").is_err());
1173 }
1174
1175 #[test]
1176 fn numeric_normalizers_trim_and_reject_invalid_values() {
1177 assert_eq!(
1178 normalize_bool_config_value("enabled", " yes ").unwrap(),
1179 "true"
1180 );
1181 assert_eq!(
1182 normalize_bool_config_value("enabled", "OFF").unwrap(),
1183 "false"
1184 );
1185 assert!(normalize_bool_config_value("enabled", "sometimes").is_err());
1186 assert_eq!(
1187 normalize_strict_bool_config_value("strict_enabled", " true ").unwrap(),
1188 "true"
1189 );
1190 assert!(normalize_strict_bool_config_value("strict_enabled", "yes").is_err());
1191 assert_eq!(
1192 normalize_positive_u32_config_value("width", " 430 ").unwrap(),
1193 "430"
1194 );
1195 assert!(normalize_positive_u32_config_value("width", "0").is_err());
1196 assert_eq!(
1197 normalize_non_negative_u64_config_value("max_age", " 0 ").unwrap(),
1198 "0"
1199 );
1200 assert!(normalize_non_negative_u64_config_value("max_age", "-1").is_err());
1201 assert_eq!(
1202 normalize_bounded_u64_config_value("length", "5", 4, 8).unwrap(),
1203 "5"
1204 );
1205 assert!(normalize_bounded_u64_config_value("length", "9", 4, 8).is_err());
1206 assert_eq!(
1207 normalize_bounded_u8_config_value("supersampling", "4", 1, 4).unwrap(),
1208 "4"
1209 );
1210 assert!(normalize_bounded_u8_config_value("supersampling", "5", 1, 4).is_err());
1211 assert_eq!(
1212 normalize_finite_f32_config_value("scale", " 11.5 ").unwrap(),
1213 "11.5"
1214 );
1215 assert!(normalize_finite_f32_config_value("scale", "NaN").is_err());
1216 }
1217}