Skip to main content

aster_forge_config/
runtime.rs

1//! In-process runtime configuration snapshots.
2//!
3//! Runtime configuration is read often and updated rarely. This module keeps a
4//! cloneable snapshot behind a lock, applies single-key changes, computes reload
5//! diffs, and delegates persistence loading to a store trait implemented by
6//! product crates.
7
8use 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/// Stored representation of a configuration row.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct StoredConfig {
24    /// Database identifier owned by the product storage layer.
25    pub id: i64,
26    /// Stable storage key.
27    pub key: String,
28    /// Storage string.
29    pub value: String,
30    /// Storage and API value kind.
31    pub value_type: ConfigValueType,
32    /// Whether a running process should ignore hot updates after first load.
33    pub requires_restart: bool,
34    /// Whether the value must be redacted in API and audit output.
35    pub is_sensitive: bool,
36    /// Source of this value.
37    pub source: ConfigSource,
38    /// Consumer visibility.
39    pub visibility: ConfigVisibility,
40    /// Product-defined category.
41    pub category: String,
42    /// Backend-facing description.
43    pub description: String,
44}
45
46/// Record type that can be stored in a runtime configuration snapshot.
47///
48/// Product crates can implement this trait for their database entity model when
49/// they need the runtime cache to preserve product-only columns such as audit
50/// metadata, timestamps, namespaces, or SeaORM enum wrappers. Forge only needs
51/// a stable key, a storage string, and the restart boundary to provide common
52/// snapshot behavior.
53pub trait RuntimeConfigRecord: Clone + PartialEq {
54    /// Returns the stable configuration key.
55    fn config_key(&self) -> &str;
56
57    /// Returns the storage string for this configuration row.
58    fn config_value(&self) -> &str;
59
60    /// Returns whether hot updates should be ignored after first load.
61    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/// Trait implemented by product storage adapters that can load config rows for
79/// [`AsyncRuntimeConfig`].
80#[async_trait]
81pub trait AsyncConfigStore: Send + Sync {
82    /// Loads every configuration row visible to this process.
83    async fn load_all(&self) -> Result<Vec<StoredConfig>>;
84}
85
86/// Immutable generic snapshot used by synchronous runtime caches.
87#[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    /// Creates a snapshot from stored rows, keyed by config key.
105    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    /// Returns the stored model for `key`.
115    pub fn get_model(&self, key: &str) -> Option<&T> {
116        self.values.get(key)
117    }
118
119    /// Returns the storage string for `key`.
120    pub fn get(&self, key: &str) -> Option<&str> {
121        self.get_model(key).map(RuntimeConfigRecord::config_value)
122    }
123
124    /// Parses a bool-like storage string for `key`.
125    pub fn get_bool(&self, key: &str) -> Option<bool> {
126        let value = self.get(key)?;
127        parse_bool_like_value(value)
128    }
129
130    /// Parses an i64 storage string for `key`.
131    pub fn get_i64(&self, key: &str) -> Option<i64> {
132        self.get(key)?.trim().parse().ok()
133    }
134
135    /// Parses a u64 storage string for `key`.
136    pub fn get_u64(&self, key: &str) -> Option<u64> {
137        self.get(key)?.trim().parse().ok()
138    }
139
140    /// Returns a string value or `default`.
141    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    /// Returns a bool value or `default`.
148    pub fn get_bool_or(&self, key: &str, default: bool) -> bool {
149        self.get_bool(key).unwrap_or(default)
150    }
151
152    /// Returns an i64 value or `default`.
153    pub fn get_i64_or(&self, key: &str, default: i64) -> i64 {
154        self.get_i64(key).unwrap_or(default)
155    }
156
157    /// Returns a u64 value or `default`.
158    pub fn get_u64_or(&self, key: &str, default: u64) -> u64 {
159        self.get_u64(key).unwrap_or(default)
160    }
161
162    /// Returns all values.
163    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/// Immutable snapshot exposed by [`AsyncRuntimeConfig`].
178#[derive(Debug, Clone, Default, PartialEq, Eq)]
179pub struct AsyncConfigSnapshot {
180    values: HashMap<String, StoredConfig>,
181}
182
183impl AsyncConfigSnapshot {
184    /// Creates a snapshot from stored rows, keyed by config key.
185    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    /// Returns the stored model for `key`.
195    pub fn get_model(&self, key: &str) -> Option<&StoredConfig> {
196        self.values.get(key)
197    }
198
199    /// Returns the storage string for `key`.
200    pub fn get(&self, key: &str) -> Option<&str> {
201        self.get_model(key).map(|config| config.value.as_str())
202    }
203
204    /// Parses a bool-like storage string for `key`.
205    pub fn get_bool(&self, key: &str) -> Option<bool> {
206        let value = self.get(key)?;
207        parse_bool_like_value(value)
208    }
209
210    /// Parses an i64 storage string for `key`.
211    pub fn get_i64(&self, key: &str) -> Option<i64> {
212        self.get(key)?.trim().parse().ok()
213    }
214
215    /// Parses a u64 storage string for `key`.
216    pub fn get_u64(&self, key: &str) -> Option<u64> {
217        self.get(key)?.trim().parse().ok()
218    }
219
220    /// Returns a string value or `default`.
221    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    /// Returns a bool value or `default`.
228    pub fn get_bool_or(&self, key: &str, default: bool) -> bool {
229        self.get_bool(key).unwrap_or(default)
230    }
231
232    /// Returns an i64 value or `default`.
233    pub fn get_i64_or(&self, key: &str, default: i64) -> i64 {
234        self.get_i64(key).unwrap_or(default)
235    }
236
237    /// Returns a u64 value or `default`.
238    pub fn get_u64_or(&self, key: &str, default: u64) -> u64 {
239        self.get_u64(key).unwrap_or(default)
240    }
241
242    /// Returns all values.
243    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/// Description of one change applied to a runtime snapshot.
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub enum RuntimeConfigChange<T = StoredConfig> {
257    /// Key was inserted or changed.
258    Upserted(T),
259    /// Key was removed.
260    Removed(String),
261}
262
263/// Synchronous runtime configuration cache.
264///
265/// This type is intended for hot read paths where configuration is loaded from
266/// storage asynchronously at the boundary, but request handlers, middleware,
267/// policy builders, and task registries need cheap synchronous reads from an
268/// in-memory snapshot.
269#[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    /// Creates an empty synchronous runtime cache.
279    pub fn new() -> Self {
280        Self {
281            snapshot: StdRwLock::new(SyncConfigSnapshot::default()),
282        }
283    }
284
285    /// Replaces the snapshot from a full record list and returns the diff.
286    ///
287    /// Restart-only rows keep their in-process record when the key already
288    /// exists (value and flags stay until restart), mirroring [`Self::apply`];
289    /// the diff does not report those keys as changed. Removals are applied
290    /// immediately, matching [`Self::remove`].
291    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    /// Returns a cloned snapshot for lock-free derived-state processing.
301    pub fn snapshot(&self) -> SyncConfigSnapshot<T> {
302        self.read_snapshot().clone()
303    }
304
305    /// Returns the stored model for `key`.
306    pub fn get_model(&self, key: &str) -> Option<T> {
307        self.read_snapshot().get_model(key).cloned()
308    }
309
310    /// Returns the storage string for `key`.
311    pub fn get(&self, key: &str) -> Option<String> {
312        self.read_snapshot().get(key).map(ToOwned::to_owned)
313    }
314
315    /// Parses a bool-like storage string for `key`.
316    pub fn get_bool(&self, key: &str) -> Option<bool> {
317        self.read_snapshot().get_bool(key)
318    }
319
320    /// Parses an i64 storage string for `key`.
321    pub fn get_i64(&self, key: &str) -> Option<i64> {
322        self.read_snapshot().get_i64(key)
323    }
324
325    /// Parses a u64 storage string for `key`.
326    pub fn get_u64(&self, key: &str) -> Option<u64> {
327        self.read_snapshot().get_u64(key)
328    }
329
330    /// Returns a string value or `default`.
331    pub fn get_string_or(&self, key: &str, default: &str) -> String {
332        self.read_snapshot().get_string_or(key, default)
333    }
334
335    /// Returns a bool value or `default`.
336    pub fn get_bool_or(&self, key: &str, default: bool) -> bool {
337        self.read_snapshot().get_bool_or(key, default)
338    }
339
340    /// Returns an i64 value or `default`.
341    pub fn get_i64_or(&self, key: &str, default: i64) -> i64 {
342        self.read_snapshot().get_i64_or(key, default)
343    }
344
345    /// Returns a u64 value or `default`.
346    pub fn get_u64_or(&self, key: &str, default: u64) -> u64 {
347        self.read_snapshot().get_u64_or(key, default)
348    }
349
350    /// Applies one row to the snapshot.
351    ///
352    /// If the incoming row requires restart and the key already exists, the
353    /// update is ignored to preserve the in-process value until restart.
354    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    /// Removes one key from the snapshot.
367    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/// Async runtime configuration cache.
391///
392/// This type uses `tokio::sync::RwLock` and is intended for async-first
393/// services that want to load and query runtime configuration through async
394/// boundaries. Services with synchronous hot read paths should use
395/// [`SyncRuntimeConfig`] instead.
396#[derive(Debug, Default)]
397pub struct AsyncRuntimeConfig {
398    snapshot: RwLock<AsyncConfigSnapshot>,
399}
400
401impl AsyncRuntimeConfig {
402    /// Creates an empty async runtime cache.
403    pub fn new() -> Self {
404        Self {
405            snapshot: RwLock::new(AsyncConfigSnapshot::default()),
406        }
407    }
408
409    /// Reloads all values from `store` and returns the diff.
410    ///
411    /// Restart-only rows keep their in-process record when the key already
412    /// exists (value and flags stay until restart), mirroring [`Self::apply`];
413    /// the diff does not report those keys as changed. Removals are applied
414    /// immediately, matching [`Self::remove`].
415    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    /// Returns a cloned snapshot for lock-free derived-state processing.
428    pub async fn snapshot(&self) -> AsyncConfigSnapshot {
429        self.snapshot.read().await.clone()
430    }
431
432    /// Returns the stored model for `key`.
433    pub async fn get_model(&self, key: &str) -> Option<StoredConfig> {
434        self.snapshot.read().await.get_model(key).cloned()
435    }
436
437    /// Returns the storage string for `key`.
438    pub async fn get(&self, key: &str) -> Option<String> {
439        self.snapshot.read().await.get(key).map(ToOwned::to_owned)
440    }
441
442    /// Applies one row to the snapshot.
443    ///
444    /// If the incoming row requires restart and the key already exists, the
445    /// update is ignored to preserve the in-process value until restart.
446    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    /// Removes one key from the snapshot.
458    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
467/// Keeps the in-process record for restart-only keys across a full snapshot
468/// replacement, mirroring the single-row guard in `apply`: when the incoming
469/// record is marked `requires_restart` and the key already exists, the stored
470/// record (value and flags) is kept until the process restarts. Removals are
471/// not guarded, matching `remove`.
472fn 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
529/// Parses a bool-like runtime configuration value.
530pub 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
538/// Parses a strict `true`/`false` runtime configuration value.
539pub 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
547/// Parses a positive `u64` runtime configuration value.
548pub fn parse_positive_u64(value: &str) -> Option<u64> {
549    let parsed = value.trim().parse::<u64>().ok()?;
550    (parsed > 0).then_some(parsed)
551}
552
553/// Parses a positive `u32` runtime configuration value.
554pub fn parse_positive_u32(value: &str) -> Option<u32> {
555    let parsed = value.trim().parse::<u32>().ok()?;
556    (parsed > 0).then_some(parsed)
557}
558
559/// Parses a non-negative `u64` runtime configuration value.
560pub fn parse_non_negative_u64(value: &str) -> Option<u64> {
561    value.trim().parse::<u64>().ok()
562}
563
564/// Parses a `u64` runtime configuration value within an inclusive range.
565pub 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
570/// Parses a `u8` runtime configuration value within an inclusive range.
571pub 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
576/// Parses a positive `i32` runtime configuration value.
577pub fn parse_positive_i32(value: &str) -> Option<i32> {
578    let parsed = value.trim().parse::<i32>().ok()?;
579    (parsed > 0).then_some(parsed)
580}
581
582/// Parses a finite `f32` runtime configuration value.
583pub 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
588/// Normalizes a positive integer runtime configuration value for storage.
589pub 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
596/// Normalizes a non-negative integer runtime configuration value for storage.
597pub 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
604/// Normalizes a bounded `u64` runtime configuration value for storage.
605pub 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
617/// Normalizes a bool-like runtime configuration value for storage.
618///
619/// Accepted input forms match [`parse_bool_like_value`]. The stored value is
620/// always the canonical string `true` or `false`.
621pub 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
628/// Normalizes a strict `true`/`false` runtime configuration value for storage.
629pub 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
636/// Normalizes a positive `u32` runtime configuration value for storage.
637pub 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
644/// Normalizes a bounded `u8` runtime configuration value for storage.
645pub 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
657/// Normalizes a finite `f32` runtime configuration value for storage.
658pub 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
664/// Reads a positive `u64` from a runtime configuration lookup.
665pub 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
681/// Reads a positive `u32` from a runtime configuration lookup.
682pub 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
698/// Reads a non-negative `u64` from a runtime configuration lookup.
699pub 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
715/// Reads a `u64` within an inclusive range from a runtime configuration lookup.
716pub 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
738/// Reads a bounded `u8` from a runtime configuration lookup.
739pub 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
761/// Reads a positive `i32` from a runtime configuration lookup.
762pub 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
778/// Reads a finite `f32` from a runtime configuration lookup.
779pub 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
795/// Reads a bool-like value from a runtime configuration lookup.
796pub 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
812/// Reads a positive `usize` from a runtime configuration lookup.
813pub 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        // First load inserts restart-only keys normally: nothing to preserve yet.
921        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                // Restart-only and already present: kept until restart.
932                config("static_key", "new", true),
933                // Hot key updates as usual.
934                config("hot_key", "v2", false),
935                // Restart-only but new: inserted on first sight.
936                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        // First load inserts restart-only keys normally: nothing to preserve yet.
996        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            // Restart-only and already present: kept until restart.
1004            config("static_key", "new", true),
1005            // The incoming row's flag decides, so this row is also kept;
1006            // the restart-required flag update itself is deferred.
1007            config("flagged_key", "new", true),
1008            // Hot key updates as usual.
1009            config("hot_key", "v2", false),
1010            // Restart-only but new: inserted on first sight.
1011            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        // The whole record is preserved, including its flags.
1025        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}