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::{Mutex, 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 update_lock: Mutex<()>,
420 snapshot: RwLock<AsyncConfigSnapshot>,
421}
422
423impl AsyncRuntimeConfig {
424 #[must_use]
426 pub fn new() -> Self {
427 Self {
428 update_lock: Mutex::new(()),
429 snapshot: RwLock::new(AsyncConfigSnapshot::default()),
430 }
431 }
432
433 pub async fn reload<S>(&self, store: &S) -> Result<Vec<RuntimeConfigChange>>
444 where
445 S: AsyncConfigStore + ?Sized,
446 {
447 let _update_guard = self.update_lock.lock().await;
450 let mut next = AsyncConfigSnapshot::from_configs(store.load_all().await?);
451 let mut guard = self.snapshot.write().await;
452 preserve_restart_only_records(&guard.values, &mut next.values);
453 let changes = diff_snapshots(&guard, &next);
454 *guard = next;
455 Ok(changes)
456 }
457
458 pub async fn snapshot(&self) -> AsyncConfigSnapshot {
460 self.snapshot.read().await.clone()
461 }
462
463 pub async fn get_model(&self, key: &str) -> Option<StoredConfig> {
465 self.snapshot.read().await.get_model(key).cloned()
466 }
467
468 pub async fn get(&self, key: &str) -> Option<String> {
470 self.snapshot.read().await.get(key).map(ToOwned::to_owned)
471 }
472
473 pub async fn apply(&self, config: StoredConfig) -> Option<RuntimeConfigChange> {
478 let _update_guard = self.update_lock.lock().await;
479 let mut guard = self.snapshot.write().await;
480 if config.requires_restart && guard.values.contains_key(&config.key) {
481 return None;
482 }
483
484 let changed = guard.values.get(&config.key) != Some(&config);
485 guard.values.insert(config.key.clone(), config.clone());
486 changed.then_some(RuntimeConfigChange::Upserted(config))
487 }
488
489 pub async fn remove(&self, key: &str) -> Option<RuntimeConfigChange> {
491 let _update_guard = self.update_lock.lock().await;
492 let mut guard = self.snapshot.write().await;
493 guard
494 .values
495 .remove(key)
496 .map(|_| RuntimeConfigChange::Removed(key.to_string()))
497 }
498}
499
500fn preserve_restart_only_records<T>(previous: &HashMap<String, T>, next: &mut HashMap<String, T>)
506where
507 T: RuntimeConfigRecord,
508{
509 for (key, incoming) in next.iter_mut() {
510 if !incoming.config_requires_restart() {
511 continue;
512 }
513 if let Some(existing) = previous.get(key) {
514 *incoming = existing.clone();
515 }
516 }
517}
518
519fn diff_snapshots(
520 previous: &AsyncConfigSnapshot,
521 next: &AsyncConfigSnapshot,
522) -> Vec<RuntimeConfigChange> {
523 let mut keys = BTreeSet::new();
524 keys.extend(previous.values.keys().map(String::as_str));
525 keys.extend(next.values.keys().map(String::as_str));
526
527 let mut changes = Vec::new();
528 for key in keys {
529 match (previous.values.get(key), next.values.get(key)) {
530 (Some(old), Some(new)) if old == new => {}
531 (_, Some(new)) => changes.push(RuntimeConfigChange::Upserted(new.clone())),
532 (Some(_), None) => changes.push(RuntimeConfigChange::Removed(key.to_string())),
533 (None, None) => {}
534 }
535 }
536 changes
537}
538
539fn diff_sync_snapshots<T>(
540 previous: &SyncConfigSnapshot<T>,
541 next: &SyncConfigSnapshot<T>,
542) -> Vec<RuntimeConfigChange<T>>
543where
544 T: RuntimeConfigRecord,
545{
546 let mut keys = BTreeSet::new();
547 keys.extend(previous.values.keys().map(String::as_str));
548 keys.extend(next.values.keys().map(String::as_str));
549
550 let mut changes = Vec::new();
551 for key in keys {
552 match (previous.values.get(key), next.values.get(key)) {
553 (Some(old), Some(new)) if old == new => {}
554 (_, Some(new)) => changes.push(RuntimeConfigChange::Upserted(new.clone())),
555 (Some(_), None) => changes.push(RuntimeConfigChange::Removed(key.to_string())),
556 (None, None) => {}
557 }
558 }
559 changes
560}
561
562#[must_use]
564pub fn parse_bool_like_value(value: &str) -> Option<bool> {
565 match value.trim().to_ascii_lowercase().as_str() {
566 "true" | "1" | "yes" | "on" => Some(true),
567 "false" | "0" | "no" | "off" => Some(false),
568 _ => None,
569 }
570}
571
572#[must_use]
574pub fn parse_strict_bool_value(value: &str) -> Option<bool> {
575 match value.trim() {
576 "true" => Some(true),
577 "false" => Some(false),
578 _ => None,
579 }
580}
581
582#[must_use]
584pub fn parse_positive_u64(value: &str) -> Option<u64> {
585 let parsed = value.trim().parse::<u64>().ok()?;
586 (parsed > 0).then_some(parsed)
587}
588
589#[must_use]
591pub fn parse_positive_u32(value: &str) -> Option<u32> {
592 let parsed = value.trim().parse::<u32>().ok()?;
593 (parsed > 0).then_some(parsed)
594}
595
596#[must_use]
598pub fn parse_non_negative_u64(value: &str) -> Option<u64> {
599 value.trim().parse::<u64>().ok()
600}
601
602#[must_use]
604pub fn parse_bounded_u64(value: &str, min: u64, max: u64) -> Option<u64> {
605 let parsed = value.trim().parse::<u64>().ok()?;
606 (min..=max).contains(&parsed).then_some(parsed)
607}
608
609#[must_use]
611pub fn parse_bounded_u8(value: &str, min: u8, max: u8) -> Option<u8> {
612 let parsed = value.trim().parse::<u8>().ok()?;
613 (min..=max).contains(&parsed).then_some(parsed)
614}
615
616#[must_use]
618pub fn parse_positive_i32(value: &str) -> Option<i32> {
619 let parsed = value.trim().parse::<i32>().ok()?;
620 (parsed > 0).then_some(parsed)
621}
622
623#[must_use]
625pub fn parse_finite_f32(value: &str) -> Option<f32> {
626 let parsed = value.trim().parse::<f32>().ok()?;
627 parsed.is_finite().then_some(parsed)
628}
629
630pub fn normalize_positive_u64_config_value(key: &str, value: &str) -> Result<String> {
636 let parsed = parse_positive_u64(value).ok_or_else(|| {
637 ConfigCoreError::invalid_value(format!("{key} must be a positive integer"))
638 })?;
639 Ok(parsed.to_string())
640}
641
642pub fn normalize_non_negative_u64_config_value(key: &str, value: &str) -> Result<String> {
648 let parsed = parse_non_negative_u64(value).ok_or_else(|| {
649 ConfigCoreError::invalid_value(format!("{key} must be a non-negative integer"))
650 })?;
651 Ok(parsed.to_string())
652}
653
654pub fn normalize_bounded_u64_config_value(
660 key: &str,
661 value: &str,
662 min: u64,
663 max: u64,
664) -> Result<String> {
665 let parsed = parse_bounded_u64(value, min, max).ok_or_else(|| {
666 ConfigCoreError::invalid_value(format!("{key} must be between {min} and {max}"))
667 })?;
668 Ok(parsed.to_string())
669}
670
671pub fn normalize_bool_config_value(key: &str, value: &str) -> Result<String> {
680 let parsed = parse_bool_like_value(value).ok_or_else(|| {
681 ConfigCoreError::invalid_value(format!("{key} must be 'true' or 'false'"))
682 })?;
683 Ok(if parsed { "true" } else { "false" }.to_string())
684}
685
686pub fn normalize_strict_bool_config_value(key: &str, value: &str) -> Result<String> {
692 let parsed = parse_strict_bool_value(value).ok_or_else(|| {
693 ConfigCoreError::invalid_value(format!("{key} must be 'true' or 'false'"))
694 })?;
695 Ok(if parsed { "true" } else { "false" }.to_string())
696}
697
698pub fn normalize_positive_u32_config_value(key: &str, value: &str) -> Result<String> {
704 let parsed = parse_positive_u32(value).ok_or_else(|| {
705 ConfigCoreError::invalid_value(format!("{key} must be a positive integer"))
706 })?;
707 Ok(parsed.to_string())
708}
709
710pub fn normalize_bounded_u8_config_value(
716 key: &str,
717 value: &str,
718 min: u8,
719 max: u8,
720) -> Result<String> {
721 let parsed = parse_bounded_u8(value, min, max).ok_or_else(|| {
722 ConfigCoreError::invalid_value(format!("{key} must be between {min} and {max}"))
723 })?;
724 Ok(parsed.to_string())
725}
726
727pub fn normalize_finite_f32_config_value(key: &str, value: &str) -> Result<String> {
733 let parsed = parse_finite_f32(value)
734 .ok_or_else(|| ConfigCoreError::invalid_value(format!("{key} must be a finite number")))?;
735 Ok(parsed.to_string())
736}
737
738pub fn read_positive_u64<L>(lookup: &L, key: &str, default: u64) -> u64
740where
741 L: ConfigValueLookup + ?Sized,
742{
743 match lookup.get_config_value(key) {
744 Some(raw) => {
745 if let Some(value) = parse_positive_u64(&raw) {
746 value
747 } else {
748 tracing::warn!(key, value = %raw, "invalid runtime config; using default");
749 default
750 }
751 }
752 None => default,
753 }
754}
755
756pub fn read_positive_u32<L>(lookup: &L, key: &str, default: u32) -> u32
758where
759 L: ConfigValueLookup + ?Sized,
760{
761 match lookup.get_config_value(key) {
762 Some(raw) => {
763 if let Some(value) = parse_positive_u32(&raw) {
764 value
765 } else {
766 tracing::warn!(key, value = %raw, "invalid runtime config; using default");
767 default
768 }
769 }
770 None => default,
771 }
772}
773
774pub fn read_non_negative_u64<L>(lookup: &L, key: &str, default: u64) -> u64
776where
777 L: ConfigValueLookup + ?Sized,
778{
779 match lookup.get_config_value(key) {
780 Some(raw) => {
781 if let Some(value) = parse_non_negative_u64(&raw) {
782 value
783 } else {
784 tracing::warn!(key, value = %raw, "invalid runtime config; using default");
785 default
786 }
787 }
788 None => default,
789 }
790}
791
792pub fn read_bounded_u64<L>(lookup: &L, key: &str, default: u64, min: u64, max: u64) -> u64
794where
795 L: ConfigValueLookup + ?Sized,
796{
797 match lookup.get_config_value(key) {
798 Some(raw) => {
799 if let Some(value) = parse_bounded_u64(&raw, min, max) {
800 value
801 } else {
802 tracing::warn!(
803 key,
804 value = %raw,
805 min,
806 max,
807 "invalid runtime config; using default"
808 );
809 default
810 }
811 }
812 None => default,
813 }
814}
815
816pub fn read_bounded_u8<L>(lookup: &L, key: &str, default: u8, min: u8, max: u8) -> u8
818where
819 L: ConfigValueLookup + ?Sized,
820{
821 match lookup.get_config_value(key) {
822 Some(raw) => {
823 if let Some(value) = parse_bounded_u8(&raw, min, max) {
824 value
825 } else {
826 tracing::warn!(
827 key,
828 value = %raw,
829 min,
830 max,
831 "invalid runtime config; using default"
832 );
833 default
834 }
835 }
836 None => default,
837 }
838}
839
840pub fn read_positive_i32<L>(lookup: &L, key: &str, default: i32) -> i32
842where
843 L: ConfigValueLookup + ?Sized,
844{
845 match lookup.get_config_value(key) {
846 Some(raw) => {
847 if let Some(value) = parse_positive_i32(&raw) {
848 value
849 } else {
850 tracing::warn!(key, value = %raw, "invalid runtime config; using default");
851 default
852 }
853 }
854 None => default,
855 }
856}
857
858pub fn read_finite_f32<L>(lookup: &L, key: &str, default: f32) -> f32
860where
861 L: ConfigValueLookup + ?Sized,
862{
863 match lookup.get_config_value(key) {
864 Some(raw) => {
865 if let Some(value) = parse_finite_f32(&raw) {
866 value
867 } else {
868 tracing::warn!(key, value = %raw, "invalid runtime config; using default");
869 default
870 }
871 }
872 None => default,
873 }
874}
875
876pub fn read_bool<L>(lookup: &L, key: &str, default: bool) -> bool
878where
879 L: ConfigValueLookup + ?Sized,
880{
881 match lookup.get_config_value(key) {
882 Some(raw) => {
883 if let Some(value) = parse_bool_like_value(&raw) {
884 value
885 } else {
886 tracing::warn!(key, value = %raw, "invalid runtime boolean config; using default");
887 default
888 }
889 }
890 None => default,
891 }
892}
893
894pub fn read_positive_usize<L>(lookup: &L, key: &str, default: usize) -> usize
896where
897 L: ConfigValueLookup + ?Sized,
898{
899 let default_u64 = u64::try_from(default).unwrap_or(u64::MAX);
900 if let Ok(value) = usize::try_from(read_positive_u64(lookup, key, default_u64)) {
901 value
902 } else {
903 tracing::warn!(key, "{key} exceeds usize; using default");
904 default
905 }
906}
907
908#[cfg(test)]
909mod tests {
910 use async_trait::async_trait;
911
912 use super::{
913 AsyncConfigStore, AsyncRuntimeConfig, RuntimeConfigChange, StoredConfig, SyncRuntimeConfig,
914 normalize_bool_config_value, normalize_bounded_u8_config_value,
915 normalize_bounded_u64_config_value, normalize_finite_f32_config_value,
916 normalize_non_negative_u64_config_value, normalize_positive_u32_config_value,
917 normalize_positive_u64_config_value, normalize_strict_bool_config_value,
918 parse_bool_like_value, parse_bounded_u8, parse_bounded_u64, parse_finite_f32,
919 parse_non_negative_u64, parse_positive_i32, parse_positive_u32, parse_positive_u64,
920 parse_strict_bool_value, read_bool, read_bounded_u8, read_bounded_u64, read_finite_f32,
921 read_non_negative_u64, read_positive_i32, read_positive_u32, read_positive_u64,
922 read_positive_usize,
923 };
924 use crate::{ConfigSource, ConfigValueType, ConfigVisibility, Result};
925
926 fn config(key: &str, value: &str, requires_restart: bool) -> StoredConfig {
927 StoredConfig {
928 id: 1,
929 key: key.to_string(),
930 value: value.to_string(),
931 value_type: ConfigValueType::String,
932 requires_restart,
933 is_sensitive: false,
934 source: ConfigSource::System,
935 visibility: ConfigVisibility::Private,
936 category: "general".to_string(),
937 description: "test config".to_string(),
938 }
939 }
940
941 struct StaticStore(Vec<StoredConfig>);
942
943 #[async_trait]
944 impl AsyncConfigStore for StaticStore {
945 async fn load_all(&self) -> Result<Vec<StoredConfig>> {
946 Ok(self.0.clone())
947 }
948 }
949
950 #[tokio::test]
951 async fn reload_replaces_snapshot_and_reports_changes() {
952 let runtime_config = AsyncRuntimeConfig::new();
953
954 let changes = runtime_config
955 .reload(&StaticStore(vec![config("enabled", "yes", false)]))
956 .await
957 .unwrap();
958
959 assert_eq!(changes.len(), 1);
960 assert_eq!(
961 runtime_config.snapshot().await.get_bool("enabled"),
962 Some(true)
963 );
964
965 let changes = runtime_config
966 .reload(&StaticStore(vec![config("limit", "10", false)]))
967 .await
968 .unwrap();
969
970 assert_eq!(
971 changes,
972 vec![
973 RuntimeConfigChange::Removed("enabled".to_string()),
974 RuntimeConfigChange::Upserted(config("limit", "10", false)),
975 ]
976 );
977 assert_eq!(runtime_config.snapshot().await.get_u64("limit"), Some(10));
978 }
979
980 #[tokio::test]
981 async fn apply_ignores_hot_update_for_restart_required_existing_value() {
982 let runtime_config = AsyncRuntimeConfig::new();
983 runtime_config
984 .apply(config("static_key", "old", false))
985 .await;
986
987 let change = runtime_config
988 .apply(config("static_key", "new", true))
989 .await;
990
991 assert_eq!(change, None);
992 assert_eq!(
993 runtime_config.get("static_key").await.as_deref(),
994 Some("old")
995 );
996 }
997
998 #[tokio::test]
999 async fn reload_preserves_restart_required_existing_values() {
1000 let runtime_config = AsyncRuntimeConfig::new();
1001 runtime_config
1003 .reload(&StaticStore(vec![
1004 config("static_key", "old", true),
1005 config("hot_key", "v1", false),
1006 ]))
1007 .await
1008 .unwrap();
1009
1010 let changes = runtime_config
1011 .reload(&StaticStore(vec![
1012 config("static_key", "new", true),
1014 config("hot_key", "v2", false),
1016 config("fresh_static", "fresh", true),
1018 ]))
1019 .await
1020 .unwrap();
1021
1022 assert_eq!(
1023 changes,
1024 vec![
1025 RuntimeConfigChange::Upserted(config("fresh_static", "fresh", true)),
1026 RuntimeConfigChange::Upserted(config("hot_key", "v2", false)),
1027 ]
1028 );
1029 assert_eq!(
1030 runtime_config.get("static_key").await.as_deref(),
1031 Some("old")
1032 );
1033 assert_eq!(runtime_config.get("hot_key").await.as_deref(), Some("v2"));
1034 assert_eq!(
1035 runtime_config.get("fresh_static").await.as_deref(),
1036 Some("fresh")
1037 );
1038 }
1039
1040 #[test]
1041 fn sync_runtime_config_supports_hot_reads_and_diffs() {
1042 let runtime_config = SyncRuntimeConfig::new();
1043
1044 let changes = runtime_config.replace(vec![config("enabled", "yes", false)]);
1045
1046 assert_eq!(changes.len(), 1);
1047 assert_eq!(runtime_config.get_bool("enabled"), Some(true));
1048
1049 let changes = runtime_config.replace(vec![config("limit", "10", false)]);
1050
1051 assert_eq!(
1052 changes,
1053 vec![
1054 RuntimeConfigChange::Removed("enabled".to_string()),
1055 RuntimeConfigChange::Upserted(config("limit", "10", false)),
1056 ]
1057 );
1058 assert_eq!(runtime_config.get_u64("limit"), Some(10));
1059 assert_eq!(runtime_config.snapshot().get("limit"), Some("10"));
1060 }
1061
1062 #[test]
1063 fn sync_runtime_config_ignores_restart_required_hot_update() {
1064 let runtime_config = SyncRuntimeConfig::new();
1065 runtime_config.apply(config("static_key", "old", false));
1066
1067 let change = runtime_config.apply(config("static_key", "new", true));
1068
1069 assert_eq!(change, None);
1070 assert_eq!(runtime_config.get("static_key").as_deref(), Some("old"));
1071 }
1072
1073 #[test]
1074 fn sync_replace_preserves_restart_required_existing_values() {
1075 let runtime_config = SyncRuntimeConfig::new();
1076 runtime_config.replace(vec![
1078 config("static_key", "old", true),
1079 config("flagged_key", "old", false),
1080 config("hot_key", "v1", false),
1081 ]);
1082
1083 let changes = runtime_config.replace(vec![
1084 config("static_key", "new", true),
1086 config("flagged_key", "new", true),
1089 config("hot_key", "v2", false),
1091 config("fresh_static", "fresh", true),
1093 ]);
1094
1095 assert_eq!(
1096 changes,
1097 vec![
1098 RuntimeConfigChange::Upserted(config("fresh_static", "fresh", true)),
1099 RuntimeConfigChange::Upserted(config("hot_key", "v2", false)),
1100 ]
1101 );
1102 assert_eq!(runtime_config.get("static_key").as_deref(), Some("old"));
1103 assert_eq!(runtime_config.get("hot_key").as_deref(), Some("v2"));
1104 assert_eq!(runtime_config.get("fresh_static").as_deref(), Some("fresh"));
1105 assert_eq!(
1107 runtime_config.get_model("flagged_key"),
1108 Some(config("flagged_key", "old", false))
1109 );
1110 }
1111
1112 #[test]
1113 fn runtime_value_parsers_accept_expected_shapes() {
1114 assert_eq!(parse_bool_like_value(" yes "), Some(true));
1115 assert_eq!(parse_bool_like_value("off"), Some(false));
1116 assert_eq!(parse_bool_like_value("maybe"), None);
1117 assert_eq!(parse_strict_bool_value(" true "), Some(true));
1118 assert_eq!(parse_strict_bool_value("false"), Some(false));
1119 assert_eq!(parse_strict_bool_value("yes"), None);
1120 assert_eq!(parse_positive_u64("42"), Some(42));
1121 assert_eq!(parse_positive_u64("0"), None);
1122 assert_eq!(parse_positive_u32("42"), Some(42));
1123 assert_eq!(parse_positive_u32("4294967296"), None);
1124 assert_eq!(parse_non_negative_u64("0"), Some(0));
1125 assert_eq!(parse_non_negative_u64("-1"), None);
1126 assert_eq!(parse_bounded_u64("5", 4, 8), Some(5));
1127 assert_eq!(parse_bounded_u64("3", 4, 8), None);
1128 assert_eq!(parse_bounded_u64("9", 4, 8), None);
1129 assert_eq!(parse_bounded_u8("4", 1, 4), Some(4));
1130 assert_eq!(parse_bounded_u8("5", 1, 4), None);
1131 assert_eq!(parse_positive_i32("12"), Some(12));
1132 assert_eq!(parse_positive_i32("2147483648"), None);
1133 assert_eq!(parse_finite_f32("1.5"), Some(1.5));
1134 assert_eq!(parse_finite_f32("NaN"), None);
1135 assert_eq!(parse_finite_f32("inf"), None);
1136 }
1137
1138 #[test]
1139 fn runtime_value_readers_use_defaults_for_invalid_values() {
1140 let lookup = std::collections::HashMap::from([
1141 ("positive".to_string(), "5".to_string()),
1142 ("zero".to_string(), "0".to_string()),
1143 ("bool".to_string(), "on".to_string()),
1144 ("bad".to_string(), "nope".to_string()),
1145 ("bounded".to_string(), "7".to_string()),
1146 ("bounded_u8".to_string(), "4".to_string()),
1147 ("out_of_range".to_string(), "12".to_string()),
1148 ("too_large_i32".to_string(), "2147483648".to_string()),
1149 ("finite".to_string(), "2.5".to_string()),
1150 ("nan".to_string(), "NaN".to_string()),
1151 ]);
1152
1153 assert_eq!(read_positive_u64(&lookup, "positive", 1), 5);
1154 assert_eq!(read_positive_u32(&lookup, "positive", 1), 5);
1155 assert_eq!(read_positive_u64(&lookup, "zero", 1), 1);
1156 assert_eq!(read_non_negative_u64(&lookup, "zero", 9), 0);
1157 assert_eq!(read_bounded_u64(&lookup, "bounded", 1, 4, 8), 7);
1158 assert_eq!(read_bounded_u8(&lookup, "bounded_u8", 1, 1, 4), 4);
1159 assert_eq!(read_bounded_u64(&lookup, "out_of_range", 1, 4, 8), 1);
1160 assert_eq!(read_bounded_u8(&lookup, "out_of_range", 1, 1, 4), 1);
1161 assert!(read_bool(&lookup, "bool", false));
1162 assert!(read_bool(&lookup, "bad", true));
1163 assert_eq!(read_positive_i32(&lookup, "too_large_i32", 3), 3);
1164 assert_eq!(
1165 read_finite_f32(&lookup, "finite", 1.0).to_bits(),
1166 2.5_f32.to_bits()
1167 );
1168 assert_eq!(
1169 read_finite_f32(&lookup, "nan", 1.0).to_bits(),
1170 1.0_f32.to_bits()
1171 );
1172 assert_eq!(read_positive_usize(&lookup, "positive", 1), 5);
1173 }
1174
1175 #[test]
1176 fn positive_u64_normalizer_trims_and_rejects_invalid_values() {
1177 assert_eq!(
1178 normalize_positive_u64_config_value("interval", " 60 ").unwrap(),
1179 "60"
1180 );
1181 assert!(normalize_positive_u64_config_value("interval", "0").is_err());
1182 assert!(normalize_positive_u64_config_value("interval", "abc").is_err());
1183 }
1184
1185 #[test]
1186 fn numeric_normalizers_trim_and_reject_invalid_values() {
1187 assert_eq!(
1188 normalize_bool_config_value("enabled", " yes ").unwrap(),
1189 "true"
1190 );
1191 assert_eq!(
1192 normalize_bool_config_value("enabled", "OFF").unwrap(),
1193 "false"
1194 );
1195 assert!(normalize_bool_config_value("enabled", "sometimes").is_err());
1196 assert_eq!(
1197 normalize_strict_bool_config_value("strict_enabled", " true ").unwrap(),
1198 "true"
1199 );
1200 assert!(normalize_strict_bool_config_value("strict_enabled", "yes").is_err());
1201 assert_eq!(
1202 normalize_positive_u32_config_value("width", " 430 ").unwrap(),
1203 "430"
1204 );
1205 assert!(normalize_positive_u32_config_value("width", "0").is_err());
1206 assert_eq!(
1207 normalize_non_negative_u64_config_value("max_age", " 0 ").unwrap(),
1208 "0"
1209 );
1210 assert!(normalize_non_negative_u64_config_value("max_age", "-1").is_err());
1211 assert_eq!(
1212 normalize_bounded_u64_config_value("length", "5", 4, 8).unwrap(),
1213 "5"
1214 );
1215 assert!(normalize_bounded_u64_config_value("length", "9", 4, 8).is_err());
1216 assert_eq!(
1217 normalize_bounded_u8_config_value("supersampling", "4", 1, 4).unwrap(),
1218 "4"
1219 );
1220 assert!(normalize_bounded_u8_config_value("supersampling", "5", 1, 4).is_err());
1221 assert_eq!(
1222 normalize_finite_f32_config_value("scale", " 11.5 ").unwrap(),
1223 "11.5"
1224 );
1225 assert!(normalize_finite_f32_config_value("scale", "NaN").is_err());
1226 }
1227}