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 pub fn from_configs(configs: Vec<T>) -> Self {
106 Self {
107 values: configs
108 .into_iter()
109 .map(|config| (config.config_key().to_string(), config))
110 .collect(),
111 }
112 }
113
114 pub fn get_model(&self, key: &str) -> Option<&T> {
116 self.values.get(key)
117 }
118
119 pub fn get(&self, key: &str) -> Option<&str> {
121 self.get_model(key).map(RuntimeConfigRecord::config_value)
122 }
123
124 pub fn get_bool(&self, key: &str) -> Option<bool> {
126 let value = self.get(key)?;
127 parse_bool_like_value(value)
128 }
129
130 pub fn get_i64(&self, key: &str) -> Option<i64> {
132 self.get(key)?.trim().parse().ok()
133 }
134
135 pub fn get_u64(&self, key: &str) -> Option<u64> {
137 self.get(key)?.trim().parse().ok()
138 }
139
140 pub fn get_string_or(&self, key: &str, default: &str) -> String {
142 self.get(key)
143 .map(ToOwned::to_owned)
144 .unwrap_or_else(|| default.to_string())
145 }
146
147 pub fn get_bool_or(&self, key: &str, default: bool) -> bool {
149 self.get_bool(key).unwrap_or(default)
150 }
151
152 pub fn get_i64_or(&self, key: &str, default: i64) -> i64 {
154 self.get_i64(key).unwrap_or(default)
155 }
156
157 pub fn get_u64_or(&self, key: &str, default: u64) -> u64 {
159 self.get_u64(key).unwrap_or(default)
160 }
161
162 pub fn values(&self) -> &HashMap<String, T> {
164 &self.values
165 }
166}
167
168impl<T> ConfigValueLookup for SyncConfigSnapshot<T>
169where
170 T: RuntimeConfigRecord,
171{
172 fn get_config_value(&self, key: &str) -> Option<String> {
173 self.get(key).map(ToOwned::to_owned)
174 }
175}
176
177#[derive(Debug, Clone, Default, PartialEq, Eq)]
179pub struct AsyncConfigSnapshot {
180 values: HashMap<String, StoredConfig>,
181}
182
183impl AsyncConfigSnapshot {
184 pub fn from_configs(configs: Vec<StoredConfig>) -> Self {
186 Self {
187 values: configs
188 .into_iter()
189 .map(|config| (config.key.clone(), config))
190 .collect(),
191 }
192 }
193
194 pub fn get_model(&self, key: &str) -> Option<&StoredConfig> {
196 self.values.get(key)
197 }
198
199 pub fn get(&self, key: &str) -> Option<&str> {
201 self.get_model(key).map(|config| config.value.as_str())
202 }
203
204 pub fn get_bool(&self, key: &str) -> Option<bool> {
206 let value = self.get(key)?;
207 parse_bool_like_value(value)
208 }
209
210 pub fn get_i64(&self, key: &str) -> Option<i64> {
212 self.get(key)?.trim().parse().ok()
213 }
214
215 pub fn get_u64(&self, key: &str) -> Option<u64> {
217 self.get(key)?.trim().parse().ok()
218 }
219
220 pub fn get_string_or(&self, key: &str, default: &str) -> String {
222 self.get(key)
223 .map(ToOwned::to_owned)
224 .unwrap_or_else(|| default.to_string())
225 }
226
227 pub fn get_bool_or(&self, key: &str, default: bool) -> bool {
229 self.get_bool(key).unwrap_or(default)
230 }
231
232 pub fn get_i64_or(&self, key: &str, default: i64) -> i64 {
234 self.get_i64(key).unwrap_or(default)
235 }
236
237 pub fn get_u64_or(&self, key: &str, default: u64) -> u64 {
239 self.get_u64(key).unwrap_or(default)
240 }
241
242 pub fn values(&self) -> &HashMap<String, StoredConfig> {
244 &self.values
245 }
246}
247
248impl ConfigValueLookup for AsyncConfigSnapshot {
249 fn get_config_value(&self, key: &str) -> Option<String> {
250 self.get(key).map(ToOwned::to_owned)
251 }
252}
253
254#[derive(Debug, Clone, PartialEq, Eq)]
256pub enum RuntimeConfigChange<T = StoredConfig> {
257 Upserted(T),
259 Removed(String),
261}
262
263#[derive(Debug, Default)]
270pub struct SyncRuntimeConfig<T = StoredConfig> {
271 snapshot: StdRwLock<SyncConfigSnapshot<T>>,
272}
273
274impl<T> SyncRuntimeConfig<T>
275where
276 T: RuntimeConfigRecord,
277{
278 pub fn new() -> Self {
280 Self {
281 snapshot: StdRwLock::new(SyncConfigSnapshot::default()),
282 }
283 }
284
285 pub fn replace(&self, configs: Vec<T>) -> Vec<RuntimeConfigChange<T>> {
292 let mut next = SyncConfigSnapshot::from_configs(configs);
293 let mut guard = self.write_snapshot();
294 preserve_restart_only_records(&guard.values, &mut next.values);
295 let changes = diff_sync_snapshots(&guard, &next);
296 *guard = next;
297 changes
298 }
299
300 pub fn snapshot(&self) -> SyncConfigSnapshot<T> {
302 self.read_snapshot().clone()
303 }
304
305 pub fn get_model(&self, key: &str) -> Option<T> {
307 self.read_snapshot().get_model(key).cloned()
308 }
309
310 pub fn get(&self, key: &str) -> Option<String> {
312 self.read_snapshot().get(key).map(ToOwned::to_owned)
313 }
314
315 pub fn get_bool(&self, key: &str) -> Option<bool> {
317 self.read_snapshot().get_bool(key)
318 }
319
320 pub fn get_i64(&self, key: &str) -> Option<i64> {
322 self.read_snapshot().get_i64(key)
323 }
324
325 pub fn get_u64(&self, key: &str) -> Option<u64> {
327 self.read_snapshot().get_u64(key)
328 }
329
330 pub fn get_string_or(&self, key: &str, default: &str) -> String {
332 self.read_snapshot().get_string_or(key, default)
333 }
334
335 pub fn get_bool_or(&self, key: &str, default: bool) -> bool {
337 self.read_snapshot().get_bool_or(key, default)
338 }
339
340 pub fn get_i64_or(&self, key: &str, default: i64) -> i64 {
342 self.read_snapshot().get_i64_or(key, default)
343 }
344
345 pub fn get_u64_or(&self, key: &str, default: u64) -> u64 {
347 self.read_snapshot().get_u64_or(key, default)
348 }
349
350 pub fn apply(&self, config: T) -> Option<RuntimeConfigChange<T>> {
355 let mut guard = self.write_snapshot();
356 let key = config.config_key().to_string();
357 if config.config_requires_restart() && guard.values.contains_key(&key) {
358 return None;
359 }
360
361 let changed = guard.values.get(&key) != Some(&config);
362 guard.values.insert(key, config.clone());
363 changed.then_some(RuntimeConfigChange::Upserted(config))
364 }
365
366 pub fn remove(&self, key: &str) -> Option<RuntimeConfigChange<T>> {
368 let mut guard = self.write_snapshot();
369 guard
370 .values
371 .remove(key)
372 .map(|_| RuntimeConfigChange::Removed(key.to_string()))
373 }
374
375 fn read_snapshot(&self) -> StdRwLockReadGuard<'_, SyncConfigSnapshot<T>> {
376 match self.snapshot.read() {
377 Ok(guard) => guard,
378 Err(poisoned) => poisoned.into_inner(),
379 }
380 }
381
382 fn write_snapshot(&self) -> StdRwLockWriteGuard<'_, SyncConfigSnapshot<T>> {
383 match self.snapshot.write() {
384 Ok(guard) => guard,
385 Err(poisoned) => poisoned.into_inner(),
386 }
387 }
388}
389
390#[derive(Debug, Default)]
397pub struct AsyncRuntimeConfig {
398 snapshot: RwLock<AsyncConfigSnapshot>,
399}
400
401impl AsyncRuntimeConfig {
402 pub fn new() -> Self {
404 Self {
405 snapshot: RwLock::new(AsyncConfigSnapshot::default()),
406 }
407 }
408
409 pub async fn reload<S>(&self, store: &S) -> Result<Vec<RuntimeConfigChange>>
416 where
417 S: AsyncConfigStore + ?Sized,
418 {
419 let mut next = AsyncConfigSnapshot::from_configs(store.load_all().await?);
420 let mut guard = self.snapshot.write().await;
421 preserve_restart_only_records(&guard.values, &mut next.values);
422 let changes = diff_snapshots(&guard, &next);
423 *guard = next;
424 Ok(changes)
425 }
426
427 pub async fn snapshot(&self) -> AsyncConfigSnapshot {
429 self.snapshot.read().await.clone()
430 }
431
432 pub async fn get_model(&self, key: &str) -> Option<StoredConfig> {
434 self.snapshot.read().await.get_model(key).cloned()
435 }
436
437 pub async fn get(&self, key: &str) -> Option<String> {
439 self.snapshot.read().await.get(key).map(ToOwned::to_owned)
440 }
441
442 pub async fn apply(&self, config: StoredConfig) -> Option<RuntimeConfigChange> {
447 let mut guard = self.snapshot.write().await;
448 if config.requires_restart && guard.values.contains_key(&config.key) {
449 return None;
450 }
451
452 let changed = guard.values.get(&config.key) != Some(&config);
453 guard.values.insert(config.key.clone(), config.clone());
454 changed.then_some(RuntimeConfigChange::Upserted(config))
455 }
456
457 pub async fn remove(&self, key: &str) -> Option<RuntimeConfigChange> {
459 let mut guard = self.snapshot.write().await;
460 guard
461 .values
462 .remove(key)
463 .map(|_| RuntimeConfigChange::Removed(key.to_string()))
464 }
465}
466
467fn preserve_restart_only_records<T>(previous: &HashMap<String, T>, next: &mut HashMap<String, T>)
473where
474 T: RuntimeConfigRecord,
475{
476 for (key, incoming) in next.iter_mut() {
477 if !incoming.config_requires_restart() {
478 continue;
479 }
480 if let Some(existing) = previous.get(key) {
481 *incoming = existing.clone();
482 }
483 }
484}
485
486fn diff_snapshots(
487 previous: &AsyncConfigSnapshot,
488 next: &AsyncConfigSnapshot,
489) -> Vec<RuntimeConfigChange> {
490 let mut keys = BTreeSet::new();
491 keys.extend(previous.values.keys().map(String::as_str));
492 keys.extend(next.values.keys().map(String::as_str));
493
494 let mut changes = Vec::new();
495 for key in keys {
496 match (previous.values.get(key), next.values.get(key)) {
497 (Some(old), Some(new)) if old == new => {}
498 (_, Some(new)) => changes.push(RuntimeConfigChange::Upserted(new.clone())),
499 (Some(_), None) => changes.push(RuntimeConfigChange::Removed(key.to_string())),
500 (None, None) => {}
501 }
502 }
503 changes
504}
505
506fn diff_sync_snapshots<T>(
507 previous: &SyncConfigSnapshot<T>,
508 next: &SyncConfigSnapshot<T>,
509) -> Vec<RuntimeConfigChange<T>>
510where
511 T: RuntimeConfigRecord,
512{
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
529pub fn parse_bool_like_value(value: &str) -> Option<bool> {
531 match value.trim().to_ascii_lowercase().as_str() {
532 "true" | "1" | "yes" | "on" => Some(true),
533 "false" | "0" | "no" | "off" => Some(false),
534 _ => None,
535 }
536}
537
538pub fn parse_strict_bool_value(value: &str) -> Option<bool> {
540 match value.trim() {
541 "true" => Some(true),
542 "false" => Some(false),
543 _ => None,
544 }
545}
546
547pub fn parse_positive_u64(value: &str) -> Option<u64> {
549 let parsed = value.trim().parse::<u64>().ok()?;
550 (parsed > 0).then_some(parsed)
551}
552
553pub fn parse_positive_u32(value: &str) -> Option<u32> {
555 let parsed = value.trim().parse::<u32>().ok()?;
556 (parsed > 0).then_some(parsed)
557}
558
559pub fn parse_non_negative_u64(value: &str) -> Option<u64> {
561 value.trim().parse::<u64>().ok()
562}
563
564pub fn parse_bounded_u64(value: &str, min: u64, max: u64) -> Option<u64> {
566 let parsed = value.trim().parse::<u64>().ok()?;
567 (min..=max).contains(&parsed).then_some(parsed)
568}
569
570pub fn parse_bounded_u8(value: &str, min: u8, max: u8) -> Option<u8> {
572 let parsed = value.trim().parse::<u8>().ok()?;
573 (min..=max).contains(&parsed).then_some(parsed)
574}
575
576pub fn parse_positive_i32(value: &str) -> Option<i32> {
578 let parsed = value.trim().parse::<i32>().ok()?;
579 (parsed > 0).then_some(parsed)
580}
581
582pub fn parse_finite_f32(value: &str) -> Option<f32> {
584 let parsed = value.trim().parse::<f32>().ok()?;
585 parsed.is_finite().then_some(parsed)
586}
587
588pub fn normalize_positive_u64_config_value(key: &str, value: &str) -> Result<String> {
590 let parsed = parse_positive_u64(value).ok_or_else(|| {
591 ConfigCoreError::invalid_value(format!("{key} must be a positive integer"))
592 })?;
593 Ok(parsed.to_string())
594}
595
596pub fn normalize_non_negative_u64_config_value(key: &str, value: &str) -> Result<String> {
598 let parsed = parse_non_negative_u64(value).ok_or_else(|| {
599 ConfigCoreError::invalid_value(format!("{key} must be a non-negative integer"))
600 })?;
601 Ok(parsed.to_string())
602}
603
604pub fn normalize_bounded_u64_config_value(
606 key: &str,
607 value: &str,
608 min: u64,
609 max: u64,
610) -> Result<String> {
611 let parsed = parse_bounded_u64(value, min, max).ok_or_else(|| {
612 ConfigCoreError::invalid_value(format!("{key} must be between {min} and {max}"))
613 })?;
614 Ok(parsed.to_string())
615}
616
617pub fn normalize_bool_config_value(key: &str, value: &str) -> Result<String> {
622 let parsed = parse_bool_like_value(value).ok_or_else(|| {
623 ConfigCoreError::invalid_value(format!("{key} must be 'true' or 'false'"))
624 })?;
625 Ok(if parsed { "true" } else { "false" }.to_string())
626}
627
628pub fn normalize_strict_bool_config_value(key: &str, value: &str) -> Result<String> {
630 let parsed = parse_strict_bool_value(value).ok_or_else(|| {
631 ConfigCoreError::invalid_value(format!("{key} must be 'true' or 'false'"))
632 })?;
633 Ok(if parsed { "true" } else { "false" }.to_string())
634}
635
636pub fn normalize_positive_u32_config_value(key: &str, value: &str) -> Result<String> {
638 let parsed = parse_positive_u32(value).ok_or_else(|| {
639 ConfigCoreError::invalid_value(format!("{key} must be a positive integer"))
640 })?;
641 Ok(parsed.to_string())
642}
643
644pub fn normalize_bounded_u8_config_value(
646 key: &str,
647 value: &str,
648 min: u8,
649 max: u8,
650) -> Result<String> {
651 let parsed = parse_bounded_u8(value, min, max).ok_or_else(|| {
652 ConfigCoreError::invalid_value(format!("{key} must be between {min} and {max}"))
653 })?;
654 Ok(parsed.to_string())
655}
656
657pub fn normalize_finite_f32_config_value(key: &str, value: &str) -> Result<String> {
659 let parsed = parse_finite_f32(value)
660 .ok_or_else(|| ConfigCoreError::invalid_value(format!("{key} must be a finite number")))?;
661 Ok(parsed.to_string())
662}
663
664pub fn read_positive_u64<L>(lookup: &L, key: &str, default: u64) -> u64
666where
667 L: ConfigValueLookup + ?Sized,
668{
669 match lookup.get_config_value(key) {
670 Some(raw) => match parse_positive_u64(&raw) {
671 Some(value) => value,
672 None => {
673 tracing::warn!(key, value = %raw, "invalid runtime config; using default");
674 default
675 }
676 },
677 None => default,
678 }
679}
680
681pub fn read_positive_u32<L>(lookup: &L, key: &str, default: u32) -> u32
683where
684 L: ConfigValueLookup + ?Sized,
685{
686 match lookup.get_config_value(key) {
687 Some(raw) => match parse_positive_u32(&raw) {
688 Some(value) => value,
689 None => {
690 tracing::warn!(key, value = %raw, "invalid runtime config; using default");
691 default
692 }
693 },
694 None => default,
695 }
696}
697
698pub fn read_non_negative_u64<L>(lookup: &L, key: &str, default: u64) -> u64
700where
701 L: ConfigValueLookup + ?Sized,
702{
703 match lookup.get_config_value(key) {
704 Some(raw) => match parse_non_negative_u64(&raw) {
705 Some(value) => value,
706 None => {
707 tracing::warn!(key, value = %raw, "invalid runtime config; using default");
708 default
709 }
710 },
711 None => default,
712 }
713}
714
715pub fn read_bounded_u64<L>(lookup: &L, key: &str, default: u64, min: u64, max: u64) -> u64
717where
718 L: ConfigValueLookup + ?Sized,
719{
720 match lookup.get_config_value(key) {
721 Some(raw) => match parse_bounded_u64(&raw, min, max) {
722 Some(value) => value,
723 None => {
724 tracing::warn!(
725 key,
726 value = %raw,
727 min,
728 max,
729 "invalid runtime config; using default"
730 );
731 default
732 }
733 },
734 None => default,
735 }
736}
737
738pub fn read_bounded_u8<L>(lookup: &L, key: &str, default: u8, min: u8, max: u8) -> u8
740where
741 L: ConfigValueLookup + ?Sized,
742{
743 match lookup.get_config_value(key) {
744 Some(raw) => match parse_bounded_u8(&raw, min, max) {
745 Some(value) => value,
746 None => {
747 tracing::warn!(
748 key,
749 value = %raw,
750 min,
751 max,
752 "invalid runtime config; using default"
753 );
754 default
755 }
756 },
757 None => default,
758 }
759}
760
761pub fn read_positive_i32<L>(lookup: &L, key: &str, default: i32) -> i32
763where
764 L: ConfigValueLookup + ?Sized,
765{
766 match lookup.get_config_value(key) {
767 Some(raw) => match parse_positive_i32(&raw) {
768 Some(value) => value,
769 None => {
770 tracing::warn!(key, value = %raw, "invalid runtime config; using default");
771 default
772 }
773 },
774 None => default,
775 }
776}
777
778pub fn read_finite_f32<L>(lookup: &L, key: &str, default: f32) -> f32
780where
781 L: ConfigValueLookup + ?Sized,
782{
783 match lookup.get_config_value(key) {
784 Some(raw) => match parse_finite_f32(&raw) {
785 Some(value) => value,
786 None => {
787 tracing::warn!(key, value = %raw, "invalid runtime config; using default");
788 default
789 }
790 },
791 None => default,
792 }
793}
794
795pub fn read_bool<L>(lookup: &L, key: &str, default: bool) -> bool
797where
798 L: ConfigValueLookup + ?Sized,
799{
800 match lookup.get_config_value(key) {
801 Some(raw) => match parse_bool_like_value(&raw) {
802 Some(value) => value,
803 None => {
804 tracing::warn!(key, value = %raw, "invalid runtime boolean config; using default");
805 default
806 }
807 },
808 None => default,
809 }
810}
811
812pub fn read_positive_usize<L>(lookup: &L, key: &str, default: usize) -> usize
814where
815 L: ConfigValueLookup + ?Sized,
816{
817 let default_u64 = u64::try_from(default).unwrap_or(u64::MAX);
818 match usize::try_from(read_positive_u64(lookup, key, default_u64)) {
819 Ok(value) => value,
820 Err(_) => {
821 tracing::warn!(key, "{key} exceeds usize; using default");
822 default
823 }
824 }
825}
826
827#[cfg(test)]
828mod tests {
829 use async_trait::async_trait;
830
831 use super::{
832 AsyncConfigStore, AsyncRuntimeConfig, RuntimeConfigChange, StoredConfig, SyncRuntimeConfig,
833 normalize_bool_config_value, normalize_bounded_u8_config_value,
834 normalize_bounded_u64_config_value, normalize_finite_f32_config_value,
835 normalize_non_negative_u64_config_value, normalize_positive_u32_config_value,
836 normalize_positive_u64_config_value, normalize_strict_bool_config_value,
837 parse_bool_like_value, parse_bounded_u8, parse_bounded_u64, parse_finite_f32,
838 parse_non_negative_u64, parse_positive_i32, parse_positive_u32, parse_positive_u64,
839 parse_strict_bool_value, read_bool, read_bounded_u8, read_bounded_u64, read_finite_f32,
840 read_non_negative_u64, read_positive_i32, read_positive_u32, read_positive_u64,
841 read_positive_usize,
842 };
843 use crate::{ConfigSource, ConfigValueType, ConfigVisibility, Result};
844
845 fn config(key: &str, value: &str, requires_restart: bool) -> StoredConfig {
846 StoredConfig {
847 id: 1,
848 key: key.to_string(),
849 value: value.to_string(),
850 value_type: ConfigValueType::String,
851 requires_restart,
852 is_sensitive: false,
853 source: ConfigSource::System,
854 visibility: ConfigVisibility::Private,
855 category: "general".to_string(),
856 description: "test config".to_string(),
857 }
858 }
859
860 struct StaticStore(Vec<StoredConfig>);
861
862 #[async_trait]
863 impl AsyncConfigStore for StaticStore {
864 async fn load_all(&self) -> Result<Vec<StoredConfig>> {
865 Ok(self.0.clone())
866 }
867 }
868
869 #[tokio::test]
870 async fn reload_replaces_snapshot_and_reports_changes() {
871 let runtime_config = AsyncRuntimeConfig::new();
872
873 let changes = runtime_config
874 .reload(&StaticStore(vec![config("enabled", "yes", false)]))
875 .await
876 .unwrap();
877
878 assert_eq!(changes.len(), 1);
879 assert_eq!(
880 runtime_config.snapshot().await.get_bool("enabled"),
881 Some(true)
882 );
883
884 let changes = runtime_config
885 .reload(&StaticStore(vec![config("limit", "10", false)]))
886 .await
887 .unwrap();
888
889 assert_eq!(
890 changes,
891 vec![
892 RuntimeConfigChange::Removed("enabled".to_string()),
893 RuntimeConfigChange::Upserted(config("limit", "10", false)),
894 ]
895 );
896 assert_eq!(runtime_config.snapshot().await.get_u64("limit"), Some(10));
897 }
898
899 #[tokio::test]
900 async fn apply_ignores_hot_update_for_restart_required_existing_value() {
901 let runtime_config = AsyncRuntimeConfig::new();
902 runtime_config
903 .apply(config("static_key", "old", false))
904 .await;
905
906 let change = runtime_config
907 .apply(config("static_key", "new", true))
908 .await;
909
910 assert_eq!(change, None);
911 assert_eq!(
912 runtime_config.get("static_key").await.as_deref(),
913 Some("old")
914 );
915 }
916
917 #[tokio::test]
918 async fn reload_preserves_restart_required_existing_values() {
919 let runtime_config = AsyncRuntimeConfig::new();
920 runtime_config
922 .reload(&StaticStore(vec![
923 config("static_key", "old", true),
924 config("hot_key", "v1", false),
925 ]))
926 .await
927 .unwrap();
928
929 let changes = runtime_config
930 .reload(&StaticStore(vec![
931 config("static_key", "new", true),
933 config("hot_key", "v2", false),
935 config("fresh_static", "fresh", true),
937 ]))
938 .await
939 .unwrap();
940
941 assert_eq!(
942 changes,
943 vec![
944 RuntimeConfigChange::Upserted(config("fresh_static", "fresh", true)),
945 RuntimeConfigChange::Upserted(config("hot_key", "v2", false)),
946 ]
947 );
948 assert_eq!(
949 runtime_config.get("static_key").await.as_deref(),
950 Some("old")
951 );
952 assert_eq!(runtime_config.get("hot_key").await.as_deref(), Some("v2"));
953 assert_eq!(
954 runtime_config.get("fresh_static").await.as_deref(),
955 Some("fresh")
956 );
957 }
958
959 #[test]
960 fn sync_runtime_config_supports_hot_reads_and_diffs() {
961 let runtime_config = SyncRuntimeConfig::new();
962
963 let changes = runtime_config.replace(vec![config("enabled", "yes", false)]);
964
965 assert_eq!(changes.len(), 1);
966 assert_eq!(runtime_config.get_bool("enabled"), Some(true));
967
968 let changes = runtime_config.replace(vec![config("limit", "10", false)]);
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.get_u64("limit"), Some(10));
978 assert_eq!(runtime_config.snapshot().get("limit"), Some("10"));
979 }
980
981 #[test]
982 fn sync_runtime_config_ignores_restart_required_hot_update() {
983 let runtime_config = SyncRuntimeConfig::new();
984 runtime_config.apply(config("static_key", "old", false));
985
986 let change = runtime_config.apply(config("static_key", "new", true));
987
988 assert_eq!(change, None);
989 assert_eq!(runtime_config.get("static_key").as_deref(), Some("old"));
990 }
991
992 #[test]
993 fn sync_replace_preserves_restart_required_existing_values() {
994 let runtime_config = SyncRuntimeConfig::new();
995 runtime_config.replace(vec![
997 config("static_key", "old", true),
998 config("flagged_key", "old", false),
999 config("hot_key", "v1", false),
1000 ]);
1001
1002 let changes = runtime_config.replace(vec![
1003 config("static_key", "new", true),
1005 config("flagged_key", "new", true),
1008 config("hot_key", "v2", false),
1010 config("fresh_static", "fresh", true),
1012 ]);
1013
1014 assert_eq!(
1015 changes,
1016 vec![
1017 RuntimeConfigChange::Upserted(config("fresh_static", "fresh", true)),
1018 RuntimeConfigChange::Upserted(config("hot_key", "v2", false)),
1019 ]
1020 );
1021 assert_eq!(runtime_config.get("static_key").as_deref(), Some("old"));
1022 assert_eq!(runtime_config.get("hot_key").as_deref(), Some("v2"));
1023 assert_eq!(runtime_config.get("fresh_static").as_deref(), Some("fresh"));
1024 assert_eq!(
1026 runtime_config.get_model("flagged_key"),
1027 Some(config("flagged_key", "old", false))
1028 );
1029 }
1030
1031 #[test]
1032 fn runtime_value_parsers_accept_expected_shapes() {
1033 assert_eq!(parse_bool_like_value(" yes "), Some(true));
1034 assert_eq!(parse_bool_like_value("off"), Some(false));
1035 assert_eq!(parse_bool_like_value("maybe"), None);
1036 assert_eq!(parse_strict_bool_value(" true "), Some(true));
1037 assert_eq!(parse_strict_bool_value("false"), Some(false));
1038 assert_eq!(parse_strict_bool_value("yes"), None);
1039 assert_eq!(parse_positive_u64("42"), Some(42));
1040 assert_eq!(parse_positive_u64("0"), None);
1041 assert_eq!(parse_positive_u32("42"), Some(42));
1042 assert_eq!(parse_positive_u32("4294967296"), None);
1043 assert_eq!(parse_non_negative_u64("0"), Some(0));
1044 assert_eq!(parse_non_negative_u64("-1"), None);
1045 assert_eq!(parse_bounded_u64("5", 4, 8), Some(5));
1046 assert_eq!(parse_bounded_u64("3", 4, 8), None);
1047 assert_eq!(parse_bounded_u64("9", 4, 8), None);
1048 assert_eq!(parse_bounded_u8("4", 1, 4), Some(4));
1049 assert_eq!(parse_bounded_u8("5", 1, 4), None);
1050 assert_eq!(parse_positive_i32("12"), Some(12));
1051 assert_eq!(parse_positive_i32("2147483648"), None);
1052 assert_eq!(parse_finite_f32("1.5"), Some(1.5));
1053 assert_eq!(parse_finite_f32("NaN"), None);
1054 assert_eq!(parse_finite_f32("inf"), None);
1055 }
1056
1057 #[test]
1058 fn runtime_value_readers_use_defaults_for_invalid_values() {
1059 let lookup = std::collections::HashMap::from([
1060 ("positive".to_string(), "5".to_string()),
1061 ("zero".to_string(), "0".to_string()),
1062 ("bool".to_string(), "on".to_string()),
1063 ("bad".to_string(), "nope".to_string()),
1064 ("bounded".to_string(), "7".to_string()),
1065 ("bounded_u8".to_string(), "4".to_string()),
1066 ("out_of_range".to_string(), "12".to_string()),
1067 ("too_large_i32".to_string(), "2147483648".to_string()),
1068 ("finite".to_string(), "2.5".to_string()),
1069 ("nan".to_string(), "NaN".to_string()),
1070 ]);
1071
1072 assert_eq!(read_positive_u64(&lookup, "positive", 1), 5);
1073 assert_eq!(read_positive_u32(&lookup, "positive", 1), 5);
1074 assert_eq!(read_positive_u64(&lookup, "zero", 1), 1);
1075 assert_eq!(read_non_negative_u64(&lookup, "zero", 9), 0);
1076 assert_eq!(read_bounded_u64(&lookup, "bounded", 1, 4, 8), 7);
1077 assert_eq!(read_bounded_u8(&lookup, "bounded_u8", 1, 1, 4), 4);
1078 assert_eq!(read_bounded_u64(&lookup, "out_of_range", 1, 4, 8), 1);
1079 assert_eq!(read_bounded_u8(&lookup, "out_of_range", 1, 1, 4), 1);
1080 assert!(read_bool(&lookup, "bool", false));
1081 assert!(read_bool(&lookup, "bad", true));
1082 assert_eq!(read_positive_i32(&lookup, "too_large_i32", 3), 3);
1083 assert_eq!(read_finite_f32(&lookup, "finite", 1.0), 2.5);
1084 assert_eq!(read_finite_f32(&lookup, "nan", 1.0), 1.0);
1085 assert_eq!(read_positive_usize(&lookup, "positive", 1), 5);
1086 }
1087
1088 #[test]
1089 fn positive_u64_normalizer_trims_and_rejects_invalid_values() {
1090 assert_eq!(
1091 normalize_positive_u64_config_value("interval", " 60 ").unwrap(),
1092 "60"
1093 );
1094 assert!(normalize_positive_u64_config_value("interval", "0").is_err());
1095 assert!(normalize_positive_u64_config_value("interval", "abc").is_err());
1096 }
1097
1098 #[test]
1099 fn numeric_normalizers_trim_and_reject_invalid_values() {
1100 assert_eq!(
1101 normalize_bool_config_value("enabled", " yes ").unwrap(),
1102 "true"
1103 );
1104 assert_eq!(
1105 normalize_bool_config_value("enabled", "OFF").unwrap(),
1106 "false"
1107 );
1108 assert!(normalize_bool_config_value("enabled", "sometimes").is_err());
1109 assert_eq!(
1110 normalize_strict_bool_config_value("strict_enabled", " true ").unwrap(),
1111 "true"
1112 );
1113 assert!(normalize_strict_bool_config_value("strict_enabled", "yes").is_err());
1114 assert_eq!(
1115 normalize_positive_u32_config_value("width", " 430 ").unwrap(),
1116 "430"
1117 );
1118 assert!(normalize_positive_u32_config_value("width", "0").is_err());
1119 assert_eq!(
1120 normalize_non_negative_u64_config_value("max_age", " 0 ").unwrap(),
1121 "0"
1122 );
1123 assert!(normalize_non_negative_u64_config_value("max_age", "-1").is_err());
1124 assert_eq!(
1125 normalize_bounded_u64_config_value("length", "5", 4, 8).unwrap(),
1126 "5"
1127 );
1128 assert!(normalize_bounded_u64_config_value("length", "9", 4, 8).is_err());
1129 assert_eq!(
1130 normalize_bounded_u8_config_value("supersampling", "4", 1, 4).unwrap(),
1131 "4"
1132 );
1133 assert!(normalize_bounded_u8_config_value("supersampling", "5", 1, 4).is_err());
1134 assert_eq!(
1135 normalize_finite_f32_config_value("scale", " 11.5 ").unwrap(),
1136 "11.5"
1137 );
1138 assert!(normalize_finite_f32_config_value("scale", "NaN").is_err());
1139 }
1140}