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    #[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    /// Returns the stored model for `key`.
116    #[must_use]
117    pub fn get_model(&self, key: &str) -> Option<&T> {
118        self.values.get(key)
119    }
120
121    /// Returns the storage string for `key`.
122    pub fn get(&self, key: &str) -> Option<&str> {
123        self.get_model(key).map(RuntimeConfigRecord::config_value)
124    }
125
126    /// Parses a bool-like storage string for `key`.
127    #[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    /// Parses an i64 storage string for `key`.
134    #[must_use]
135    pub fn get_i64(&self, key: &str) -> Option<i64> {
136        self.get(key)?.trim().parse().ok()
137    }
138
139    /// Parses a u64 storage string for `key`.
140    #[must_use]
141    pub fn get_u64(&self, key: &str) -> Option<u64> {
142        self.get(key)?.trim().parse().ok()
143    }
144
145    /// Returns a string value or `default`.
146    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    /// Returns a bool value or `default`.
152    #[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    /// Returns an i64 value or `default`.
158    #[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    /// Returns a u64 value or `default`.
164    #[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    /// Returns all values.
170    #[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/// Immutable snapshot exposed by [`AsyncRuntimeConfig`].
186#[derive(Debug, Clone, Default, PartialEq, Eq)]
187pub struct AsyncConfigSnapshot {
188    values: HashMap<String, StoredConfig>,
189}
190
191impl AsyncConfigSnapshot {
192    /// Creates a snapshot from stored rows, keyed by config key.
193    #[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    /// Returns the stored model for `key`.
204    #[must_use]
205    pub fn get_model(&self, key: &str) -> Option<&StoredConfig> {
206        self.values.get(key)
207    }
208
209    /// Returns the storage string for `key`.
210    #[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    /// Parses a bool-like storage string for `key`.
216    #[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    /// Parses an i64 storage string for `key`.
223    #[must_use]
224    pub fn get_i64(&self, key: &str) -> Option<i64> {
225        self.get(key)?.trim().parse().ok()
226    }
227
228    /// Parses a u64 storage string for `key`.
229    #[must_use]
230    pub fn get_u64(&self, key: &str) -> Option<u64> {
231        self.get(key)?.trim().parse().ok()
232    }
233
234    /// Returns a string value or `default`.
235    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    /// Returns a bool value or `default`.
241    #[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    /// Returns an i64 value or `default`.
247    #[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    /// Returns a u64 value or `default`.
253    #[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    /// Returns all values.
259    #[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/// Description of one change applied to a runtime snapshot.
272#[derive(Debug, Clone, PartialEq, Eq)]
273pub enum RuntimeConfigChange<T = StoredConfig> {
274    /// Key was inserted or changed.
275    Upserted(T),
276    /// Key was removed.
277    Removed(String),
278}
279
280/// Synchronous runtime configuration cache.
281///
282/// This type is intended for hot read paths where configuration is loaded from
283/// storage asynchronously at the boundary, but request handlers, middleware,
284/// policy builders, and task registries need cheap synchronous reads from an
285/// in-memory snapshot.
286#[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    /// Creates an empty synchronous runtime cache.
296    #[must_use]
297    pub fn new() -> Self {
298        Self {
299            snapshot: StdRwLock::new(SyncConfigSnapshot::default()),
300        }
301    }
302
303    /// Replaces the snapshot from a full record list and returns the diff.
304    ///
305    /// Restart-only rows keep their in-process record when the key already
306    /// exists (value and flags stay until restart), mirroring [`Self::apply`];
307    /// the diff does not report those keys as changed. Removals are applied
308    /// immediately, matching [`Self::remove`].
309    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    /// Returns a cloned snapshot for lock-free derived-state processing.
319    pub fn snapshot(&self) -> SyncConfigSnapshot<T> {
320        self.read_snapshot().clone()
321    }
322
323    /// Returns the stored model for `key`.
324    pub fn get_model(&self, key: &str) -> Option<T> {
325        self.read_snapshot().get_model(key).cloned()
326    }
327
328    /// Returns the storage string for `key`.
329    pub fn get(&self, key: &str) -> Option<String> {
330        self.read_snapshot().get(key).map(ToOwned::to_owned)
331    }
332
333    /// Parses a bool-like storage string for `key`.
334    pub fn get_bool(&self, key: &str) -> Option<bool> {
335        self.read_snapshot().get_bool(key)
336    }
337
338    /// Parses an i64 storage string for `key`.
339    pub fn get_i64(&self, key: &str) -> Option<i64> {
340        self.read_snapshot().get_i64(key)
341    }
342
343    /// Parses a u64 storage string for `key`.
344    pub fn get_u64(&self, key: &str) -> Option<u64> {
345        self.read_snapshot().get_u64(key)
346    }
347
348    /// Returns a string value or `default`.
349    pub fn get_string_or(&self, key: &str, default: &str) -> String {
350        self.read_snapshot().get_string_or(key, default)
351    }
352
353    /// Returns a bool value or `default`.
354    pub fn get_bool_or(&self, key: &str, default: bool) -> bool {
355        self.read_snapshot().get_bool_or(key, default)
356    }
357
358    /// Returns an i64 value or `default`.
359    pub fn get_i64_or(&self, key: &str, default: i64) -> i64 {
360        self.read_snapshot().get_i64_or(key, default)
361    }
362
363    /// Returns a u64 value or `default`.
364    pub fn get_u64_or(&self, key: &str, default: u64) -> u64 {
365        self.read_snapshot().get_u64_or(key, default)
366    }
367
368    /// Applies one row to the snapshot.
369    ///
370    /// If the incoming row requires restart and the key already exists, the
371    /// update is ignored to preserve the in-process value until restart.
372    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    /// Removes one key from the snapshot.
385    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/// Async runtime configuration cache.
409///
410/// This type uses `tokio::sync::RwLock` and is intended for async-first
411/// services that want to load and query runtime configuration through async
412/// boundaries. Services with synchronous hot read paths should use
413/// [`SyncRuntimeConfig`] instead.
414#[derive(Debug, Default)]
415pub struct AsyncRuntimeConfig {
416    snapshot: RwLock<AsyncConfigSnapshot>,
417}
418
419impl AsyncRuntimeConfig {
420    /// Creates an empty async runtime cache.
421    #[must_use]
422    pub fn new() -> Self {
423        Self {
424            snapshot: RwLock::new(AsyncConfigSnapshot::default()),
425        }
426    }
427
428    /// Reloads all values from `store` and returns the diff.
429    ///
430    /// Restart-only rows keep their in-process record when the key already
431    /// exists (value and flags stay until restart), mirroring [`Self::apply`];
432    /// the diff does not report those keys as changed. Removals are applied
433    /// immediately, matching [`Self::remove`].
434    ///
435    /// # Errors
436    ///
437    /// Returns [`ConfigError`] when the backing store cannot load the latest runtime snapshot.
438    pub async fn reload<S>(&self, store: &S) -> Result<Vec<RuntimeConfigChange>>
439    where
440        S: AsyncConfigStore + ?Sized,
441    {
442        let mut next = AsyncConfigSnapshot::from_configs(store.load_all().await?);
443        let mut guard = self.snapshot.write().await;
444        preserve_restart_only_records(&guard.values, &mut next.values);
445        let changes = diff_snapshots(&guard, &next);
446        *guard = next;
447        Ok(changes)
448    }
449
450    /// Returns a cloned snapshot for lock-free derived-state processing.
451    pub async fn snapshot(&self) -> AsyncConfigSnapshot {
452        self.snapshot.read().await.clone()
453    }
454
455    /// Returns the stored model for `key`.
456    pub async fn get_model(&self, key: &str) -> Option<StoredConfig> {
457        self.snapshot.read().await.get_model(key).cloned()
458    }
459
460    /// Returns the storage string for `key`.
461    pub async fn get(&self, key: &str) -> Option<String> {
462        self.snapshot.read().await.get(key).map(ToOwned::to_owned)
463    }
464
465    /// Applies one row to the snapshot.
466    ///
467    /// If the incoming row requires restart and the key already exists, the
468    /// update is ignored to preserve the in-process value until restart.
469    pub async fn apply(&self, config: StoredConfig) -> Option<RuntimeConfigChange> {
470        let mut guard = self.snapshot.write().await;
471        if config.requires_restart && guard.values.contains_key(&config.key) {
472            return None;
473        }
474
475        let changed = guard.values.get(&config.key) != Some(&config);
476        guard.values.insert(config.key.clone(), config.clone());
477        changed.then_some(RuntimeConfigChange::Upserted(config))
478    }
479
480    /// Removes one key from the snapshot.
481    pub async fn remove(&self, key: &str) -> Option<RuntimeConfigChange> {
482        let mut guard = self.snapshot.write().await;
483        guard
484            .values
485            .remove(key)
486            .map(|_| RuntimeConfigChange::Removed(key.to_string()))
487    }
488}
489
490/// Keeps the in-process record for restart-only keys across a full snapshot
491/// replacement, mirroring the single-row guard in `apply`: when the incoming
492/// record is marked `requires_restart` and the key already exists, the stored
493/// record (value and flags) is kept until the process restarts. Removals are
494/// not guarded, matching `remove`.
495fn preserve_restart_only_records<T>(previous: &HashMap<String, T>, next: &mut HashMap<String, T>)
496where
497    T: RuntimeConfigRecord,
498{
499    for (key, incoming) in next.iter_mut() {
500        if !incoming.config_requires_restart() {
501            continue;
502        }
503        if let Some(existing) = previous.get(key) {
504            *incoming = existing.clone();
505        }
506    }
507}
508
509fn diff_snapshots(
510    previous: &AsyncConfigSnapshot,
511    next: &AsyncConfigSnapshot,
512) -> Vec<RuntimeConfigChange> {
513    let mut keys = BTreeSet::new();
514    keys.extend(previous.values.keys().map(String::as_str));
515    keys.extend(next.values.keys().map(String::as_str));
516
517    let mut changes = Vec::new();
518    for key in keys {
519        match (previous.values.get(key), next.values.get(key)) {
520            (Some(old), Some(new)) if old == new => {}
521            (_, Some(new)) => changes.push(RuntimeConfigChange::Upserted(new.clone())),
522            (Some(_), None) => changes.push(RuntimeConfigChange::Removed(key.to_string())),
523            (None, None) => {}
524        }
525    }
526    changes
527}
528
529fn diff_sync_snapshots<T>(
530    previous: &SyncConfigSnapshot<T>,
531    next: &SyncConfigSnapshot<T>,
532) -> Vec<RuntimeConfigChange<T>>
533where
534    T: RuntimeConfigRecord,
535{
536    let mut keys = BTreeSet::new();
537    keys.extend(previous.values.keys().map(String::as_str));
538    keys.extend(next.values.keys().map(String::as_str));
539
540    let mut changes = Vec::new();
541    for key in keys {
542        match (previous.values.get(key), next.values.get(key)) {
543            (Some(old), Some(new)) if old == new => {}
544            (_, Some(new)) => changes.push(RuntimeConfigChange::Upserted(new.clone())),
545            (Some(_), None) => changes.push(RuntimeConfigChange::Removed(key.to_string())),
546            (None, None) => {}
547        }
548    }
549    changes
550}
551
552/// Parses a bool-like runtime configuration value.
553#[must_use]
554pub fn parse_bool_like_value(value: &str) -> Option<bool> {
555    match value.trim().to_ascii_lowercase().as_str() {
556        "true" | "1" | "yes" | "on" => Some(true),
557        "false" | "0" | "no" | "off" => Some(false),
558        _ => None,
559    }
560}
561
562/// Parses a strict `true`/`false` runtime configuration value.
563#[must_use]
564pub fn parse_strict_bool_value(value: &str) -> Option<bool> {
565    match value.trim() {
566        "true" => Some(true),
567        "false" => Some(false),
568        _ => None,
569    }
570}
571
572/// Parses a positive `u64` runtime configuration value.
573#[must_use]
574pub fn parse_positive_u64(value: &str) -> Option<u64> {
575    let parsed = value.trim().parse::<u64>().ok()?;
576    (parsed > 0).then_some(parsed)
577}
578
579/// Parses a positive `u32` runtime configuration value.
580#[must_use]
581pub fn parse_positive_u32(value: &str) -> Option<u32> {
582    let parsed = value.trim().parse::<u32>().ok()?;
583    (parsed > 0).then_some(parsed)
584}
585
586/// Parses a non-negative `u64` runtime configuration value.
587#[must_use]
588pub fn parse_non_negative_u64(value: &str) -> Option<u64> {
589    value.trim().parse::<u64>().ok()
590}
591
592/// Parses a `u64` runtime configuration value within an inclusive range.
593#[must_use]
594pub fn parse_bounded_u64(value: &str, min: u64, max: u64) -> Option<u64> {
595    let parsed = value.trim().parse::<u64>().ok()?;
596    (min..=max).contains(&parsed).then_some(parsed)
597}
598
599/// Parses a `u8` runtime configuration value within an inclusive range.
600#[must_use]
601pub fn parse_bounded_u8(value: &str, min: u8, max: u8) -> Option<u8> {
602    let parsed = value.trim().parse::<u8>().ok()?;
603    (min..=max).contains(&parsed).then_some(parsed)
604}
605
606/// Parses a positive `i32` runtime configuration value.
607#[must_use]
608pub fn parse_positive_i32(value: &str) -> Option<i32> {
609    let parsed = value.trim().parse::<i32>().ok()?;
610    (parsed > 0).then_some(parsed)
611}
612
613/// Parses a finite `f32` runtime configuration value.
614#[must_use]
615pub fn parse_finite_f32(value: &str) -> Option<f32> {
616    let parsed = value.trim().parse::<f32>().ok()?;
617    parsed.is_finite().then_some(parsed)
618}
619
620/// Normalizes a positive integer runtime configuration value for storage.
621///
622/// # Errors
623///
624/// Returns [`ConfigError`] when the value is malformed, out of range, or not finite as required.
625pub fn normalize_positive_u64_config_value(key: &str, value: &str) -> Result<String> {
626    let parsed = parse_positive_u64(value).ok_or_else(|| {
627        ConfigCoreError::invalid_value(format!("{key} must be a positive integer"))
628    })?;
629    Ok(parsed.to_string())
630}
631
632/// Normalizes a non-negative integer runtime configuration value for storage.
633///
634/// # Errors
635///
636/// Returns [`ConfigError`] when the value is malformed, out of range, or not finite as required.
637pub fn normalize_non_negative_u64_config_value(key: &str, value: &str) -> Result<String> {
638    let parsed = parse_non_negative_u64(value).ok_or_else(|| {
639        ConfigCoreError::invalid_value(format!("{key} must be a non-negative integer"))
640    })?;
641    Ok(parsed.to_string())
642}
643
644/// Normalizes a bounded `u64` runtime configuration value for storage.
645///
646/// # Errors
647///
648/// Returns [`ConfigError`] when the value is malformed, out of range, or not finite as required.
649pub fn normalize_bounded_u64_config_value(
650    key: &str,
651    value: &str,
652    min: u64,
653    max: u64,
654) -> Result<String> {
655    let parsed = parse_bounded_u64(value, min, max).ok_or_else(|| {
656        ConfigCoreError::invalid_value(format!("{key} must be between {min} and {max}"))
657    })?;
658    Ok(parsed.to_string())
659}
660
661/// Normalizes a bool-like runtime configuration value for storage.
662///
663/// Accepted input forms match [`parse_bool_like_value`]. The stored value is
664/// always the canonical string `true` or `false`.
665///
666/// # Errors
667///
668/// Returns [`ConfigError`] when the value is malformed, out of range, or not finite as required.
669pub fn normalize_bool_config_value(key: &str, value: &str) -> Result<String> {
670    let parsed = parse_bool_like_value(value).ok_or_else(|| {
671        ConfigCoreError::invalid_value(format!("{key} must be 'true' or 'false'"))
672    })?;
673    Ok(if parsed { "true" } else { "false" }.to_string())
674}
675
676/// Normalizes a strict `true`/`false` runtime configuration value for storage.
677///
678/// # Errors
679///
680/// Returns [`ConfigError`] when the value is malformed, out of range, or not finite as required.
681pub fn normalize_strict_bool_config_value(key: &str, value: &str) -> Result<String> {
682    let parsed = parse_strict_bool_value(value).ok_or_else(|| {
683        ConfigCoreError::invalid_value(format!("{key} must be 'true' or 'false'"))
684    })?;
685    Ok(if parsed { "true" } else { "false" }.to_string())
686}
687
688/// Normalizes a positive `u32` runtime configuration value for storage.
689///
690/// # Errors
691///
692/// Returns [`ConfigError`] when the value is malformed, out of range, or not finite as required.
693pub fn normalize_positive_u32_config_value(key: &str, value: &str) -> Result<String> {
694    let parsed = parse_positive_u32(value).ok_or_else(|| {
695        ConfigCoreError::invalid_value(format!("{key} must be a positive integer"))
696    })?;
697    Ok(parsed.to_string())
698}
699
700/// Normalizes a bounded `u8` runtime configuration value for storage.
701///
702/// # Errors
703///
704/// Returns [`ConfigError`] when the value is malformed, out of range, or not finite as required.
705pub fn normalize_bounded_u8_config_value(
706    key: &str,
707    value: &str,
708    min: u8,
709    max: u8,
710) -> Result<String> {
711    let parsed = parse_bounded_u8(value, min, max).ok_or_else(|| {
712        ConfigCoreError::invalid_value(format!("{key} must be between {min} and {max}"))
713    })?;
714    Ok(parsed.to_string())
715}
716
717/// Normalizes a finite `f32` runtime configuration value for storage.
718///
719/// # Errors
720///
721/// Returns [`ConfigError`] when the value is malformed, out of range, or not finite as required.
722pub fn normalize_finite_f32_config_value(key: &str, value: &str) -> Result<String> {
723    let parsed = parse_finite_f32(value)
724        .ok_or_else(|| ConfigCoreError::invalid_value(format!("{key} must be a finite number")))?;
725    Ok(parsed.to_string())
726}
727
728/// Reads a positive `u64` from a runtime configuration lookup.
729pub fn read_positive_u64<L>(lookup: &L, key: &str, default: u64) -> u64
730where
731    L: ConfigValueLookup + ?Sized,
732{
733    match lookup.get_config_value(key) {
734        Some(raw) => {
735            if let Some(value) = parse_positive_u64(&raw) {
736                value
737            } else {
738                tracing::warn!(key, value = %raw, "invalid runtime config; using default");
739                default
740            }
741        }
742        None => default,
743    }
744}
745
746/// Reads a positive `u32` from a runtime configuration lookup.
747pub fn read_positive_u32<L>(lookup: &L, key: &str, default: u32) -> u32
748where
749    L: ConfigValueLookup + ?Sized,
750{
751    match lookup.get_config_value(key) {
752        Some(raw) => {
753            if let Some(value) = parse_positive_u32(&raw) {
754                value
755            } else {
756                tracing::warn!(key, value = %raw, "invalid runtime config; using default");
757                default
758            }
759        }
760        None => default,
761    }
762}
763
764/// Reads a non-negative `u64` from a runtime configuration lookup.
765pub fn read_non_negative_u64<L>(lookup: &L, key: &str, default: u64) -> u64
766where
767    L: ConfigValueLookup + ?Sized,
768{
769    match lookup.get_config_value(key) {
770        Some(raw) => {
771            if let Some(value) = parse_non_negative_u64(&raw) {
772                value
773            } else {
774                tracing::warn!(key, value = %raw, "invalid runtime config; using default");
775                default
776            }
777        }
778        None => default,
779    }
780}
781
782/// Reads a `u64` within an inclusive range from a runtime configuration lookup.
783pub fn read_bounded_u64<L>(lookup: &L, key: &str, default: u64, min: u64, max: u64) -> u64
784where
785    L: ConfigValueLookup + ?Sized,
786{
787    match lookup.get_config_value(key) {
788        Some(raw) => {
789            if let Some(value) = parse_bounded_u64(&raw, min, max) {
790                value
791            } else {
792                tracing::warn!(
793                    key,
794                    value = %raw,
795                    min,
796                    max,
797                    "invalid runtime config; using default"
798                );
799                default
800            }
801        }
802        None => default,
803    }
804}
805
806/// Reads a bounded `u8` from a runtime configuration lookup.
807pub fn read_bounded_u8<L>(lookup: &L, key: &str, default: u8, min: u8, max: u8) -> u8
808where
809    L: ConfigValueLookup + ?Sized,
810{
811    match lookup.get_config_value(key) {
812        Some(raw) => {
813            if let Some(value) = parse_bounded_u8(&raw, min, max) {
814                value
815            } else {
816                tracing::warn!(
817                    key,
818                    value = %raw,
819                    min,
820                    max,
821                    "invalid runtime config; using default"
822                );
823                default
824            }
825        }
826        None => default,
827    }
828}
829
830/// Reads a positive `i32` from a runtime configuration lookup.
831pub fn read_positive_i32<L>(lookup: &L, key: &str, default: i32) -> i32
832where
833    L: ConfigValueLookup + ?Sized,
834{
835    match lookup.get_config_value(key) {
836        Some(raw) => {
837            if let Some(value) = parse_positive_i32(&raw) {
838                value
839            } else {
840                tracing::warn!(key, value = %raw, "invalid runtime config; using default");
841                default
842            }
843        }
844        None => default,
845    }
846}
847
848/// Reads a finite `f32` from a runtime configuration lookup.
849pub fn read_finite_f32<L>(lookup: &L, key: &str, default: f32) -> f32
850where
851    L: ConfigValueLookup + ?Sized,
852{
853    match lookup.get_config_value(key) {
854        Some(raw) => {
855            if let Some(value) = parse_finite_f32(&raw) {
856                value
857            } else {
858                tracing::warn!(key, value = %raw, "invalid runtime config; using default");
859                default
860            }
861        }
862        None => default,
863    }
864}
865
866/// Reads a bool-like value from a runtime configuration lookup.
867pub fn read_bool<L>(lookup: &L, key: &str, default: bool) -> bool
868where
869    L: ConfigValueLookup + ?Sized,
870{
871    match lookup.get_config_value(key) {
872        Some(raw) => {
873            if let Some(value) = parse_bool_like_value(&raw) {
874                value
875            } else {
876                tracing::warn!(key, value = %raw, "invalid runtime boolean config; using default");
877                default
878            }
879        }
880        None => default,
881    }
882}
883
884/// Reads a positive `usize` from a runtime configuration lookup.
885pub fn read_positive_usize<L>(lookup: &L, key: &str, default: usize) -> usize
886where
887    L: ConfigValueLookup + ?Sized,
888{
889    let default_u64 = u64::try_from(default).unwrap_or(u64::MAX);
890    if let Ok(value) = usize::try_from(read_positive_u64(lookup, key, default_u64)) {
891        value
892    } else {
893        tracing::warn!(key, "{key} exceeds usize; using default");
894        default
895    }
896}
897
898#[cfg(test)]
899mod tests {
900    use async_trait::async_trait;
901
902    use super::{
903        AsyncConfigStore, AsyncRuntimeConfig, RuntimeConfigChange, StoredConfig, SyncRuntimeConfig,
904        normalize_bool_config_value, normalize_bounded_u8_config_value,
905        normalize_bounded_u64_config_value, normalize_finite_f32_config_value,
906        normalize_non_negative_u64_config_value, normalize_positive_u32_config_value,
907        normalize_positive_u64_config_value, normalize_strict_bool_config_value,
908        parse_bool_like_value, parse_bounded_u8, parse_bounded_u64, parse_finite_f32,
909        parse_non_negative_u64, parse_positive_i32, parse_positive_u32, parse_positive_u64,
910        parse_strict_bool_value, read_bool, read_bounded_u8, read_bounded_u64, read_finite_f32,
911        read_non_negative_u64, read_positive_i32, read_positive_u32, read_positive_u64,
912        read_positive_usize,
913    };
914    use crate::{ConfigSource, ConfigValueType, ConfigVisibility, Result};
915
916    fn config(key: &str, value: &str, requires_restart: bool) -> StoredConfig {
917        StoredConfig {
918            id: 1,
919            key: key.to_string(),
920            value: value.to_string(),
921            value_type: ConfigValueType::String,
922            requires_restart,
923            is_sensitive: false,
924            source: ConfigSource::System,
925            visibility: ConfigVisibility::Private,
926            category: "general".to_string(),
927            description: "test config".to_string(),
928        }
929    }
930
931    struct StaticStore(Vec<StoredConfig>);
932
933    #[async_trait]
934    impl AsyncConfigStore for StaticStore {
935        async fn load_all(&self) -> Result<Vec<StoredConfig>> {
936            Ok(self.0.clone())
937        }
938    }
939
940    #[tokio::test]
941    async fn reload_replaces_snapshot_and_reports_changes() {
942        let runtime_config = AsyncRuntimeConfig::new();
943
944        let changes = runtime_config
945            .reload(&StaticStore(vec![config("enabled", "yes", false)]))
946            .await
947            .unwrap();
948
949        assert_eq!(changes.len(), 1);
950        assert_eq!(
951            runtime_config.snapshot().await.get_bool("enabled"),
952            Some(true)
953        );
954
955        let changes = runtime_config
956            .reload(&StaticStore(vec![config("limit", "10", false)]))
957            .await
958            .unwrap();
959
960        assert_eq!(
961            changes,
962            vec![
963                RuntimeConfigChange::Removed("enabled".to_string()),
964                RuntimeConfigChange::Upserted(config("limit", "10", false)),
965            ]
966        );
967        assert_eq!(runtime_config.snapshot().await.get_u64("limit"), Some(10));
968    }
969
970    #[tokio::test]
971    async fn apply_ignores_hot_update_for_restart_required_existing_value() {
972        let runtime_config = AsyncRuntimeConfig::new();
973        runtime_config
974            .apply(config("static_key", "old", false))
975            .await;
976
977        let change = runtime_config
978            .apply(config("static_key", "new", true))
979            .await;
980
981        assert_eq!(change, None);
982        assert_eq!(
983            runtime_config.get("static_key").await.as_deref(),
984            Some("old")
985        );
986    }
987
988    #[tokio::test]
989    async fn reload_preserves_restart_required_existing_values() {
990        let runtime_config = AsyncRuntimeConfig::new();
991        // First load inserts restart-only keys normally: nothing to preserve yet.
992        runtime_config
993            .reload(&StaticStore(vec![
994                config("static_key", "old", true),
995                config("hot_key", "v1", false),
996            ]))
997            .await
998            .unwrap();
999
1000        let changes = runtime_config
1001            .reload(&StaticStore(vec![
1002                // Restart-only and already present: kept until restart.
1003                config("static_key", "new", true),
1004                // Hot key updates as usual.
1005                config("hot_key", "v2", false),
1006                // Restart-only but new: inserted on first sight.
1007                config("fresh_static", "fresh", true),
1008            ]))
1009            .await
1010            .unwrap();
1011
1012        assert_eq!(
1013            changes,
1014            vec![
1015                RuntimeConfigChange::Upserted(config("fresh_static", "fresh", true)),
1016                RuntimeConfigChange::Upserted(config("hot_key", "v2", false)),
1017            ]
1018        );
1019        assert_eq!(
1020            runtime_config.get("static_key").await.as_deref(),
1021            Some("old")
1022        );
1023        assert_eq!(runtime_config.get("hot_key").await.as_deref(), Some("v2"));
1024        assert_eq!(
1025            runtime_config.get("fresh_static").await.as_deref(),
1026            Some("fresh")
1027        );
1028    }
1029
1030    #[test]
1031    fn sync_runtime_config_supports_hot_reads_and_diffs() {
1032        let runtime_config = SyncRuntimeConfig::new();
1033
1034        let changes = runtime_config.replace(vec![config("enabled", "yes", false)]);
1035
1036        assert_eq!(changes.len(), 1);
1037        assert_eq!(runtime_config.get_bool("enabled"), Some(true));
1038
1039        let changes = runtime_config.replace(vec![config("limit", "10", false)]);
1040
1041        assert_eq!(
1042            changes,
1043            vec![
1044                RuntimeConfigChange::Removed("enabled".to_string()),
1045                RuntimeConfigChange::Upserted(config("limit", "10", false)),
1046            ]
1047        );
1048        assert_eq!(runtime_config.get_u64("limit"), Some(10));
1049        assert_eq!(runtime_config.snapshot().get("limit"), Some("10"));
1050    }
1051
1052    #[test]
1053    fn sync_runtime_config_ignores_restart_required_hot_update() {
1054        let runtime_config = SyncRuntimeConfig::new();
1055        runtime_config.apply(config("static_key", "old", false));
1056
1057        let change = runtime_config.apply(config("static_key", "new", true));
1058
1059        assert_eq!(change, None);
1060        assert_eq!(runtime_config.get("static_key").as_deref(), Some("old"));
1061    }
1062
1063    #[test]
1064    fn sync_replace_preserves_restart_required_existing_values() {
1065        let runtime_config = SyncRuntimeConfig::new();
1066        // First load inserts restart-only keys normally: nothing to preserve yet.
1067        runtime_config.replace(vec![
1068            config("static_key", "old", true),
1069            config("flagged_key", "old", false),
1070            config("hot_key", "v1", false),
1071        ]);
1072
1073        let changes = runtime_config.replace(vec![
1074            // Restart-only and already present: kept until restart.
1075            config("static_key", "new", true),
1076            // The incoming row's flag decides, so this row is also kept;
1077            // the restart-required flag update itself is deferred.
1078            config("flagged_key", "new", true),
1079            // Hot key updates as usual.
1080            config("hot_key", "v2", false),
1081            // Restart-only but new: inserted on first sight.
1082            config("fresh_static", "fresh", true),
1083        ]);
1084
1085        assert_eq!(
1086            changes,
1087            vec![
1088                RuntimeConfigChange::Upserted(config("fresh_static", "fresh", true)),
1089                RuntimeConfigChange::Upserted(config("hot_key", "v2", false)),
1090            ]
1091        );
1092        assert_eq!(runtime_config.get("static_key").as_deref(), Some("old"));
1093        assert_eq!(runtime_config.get("hot_key").as_deref(), Some("v2"));
1094        assert_eq!(runtime_config.get("fresh_static").as_deref(), Some("fresh"));
1095        // The whole record is preserved, including its flags.
1096        assert_eq!(
1097            runtime_config.get_model("flagged_key"),
1098            Some(config("flagged_key", "old", false))
1099        );
1100    }
1101
1102    #[test]
1103    fn runtime_value_parsers_accept_expected_shapes() {
1104        assert_eq!(parse_bool_like_value(" yes "), Some(true));
1105        assert_eq!(parse_bool_like_value("off"), Some(false));
1106        assert_eq!(parse_bool_like_value("maybe"), None);
1107        assert_eq!(parse_strict_bool_value(" true "), Some(true));
1108        assert_eq!(parse_strict_bool_value("false"), Some(false));
1109        assert_eq!(parse_strict_bool_value("yes"), None);
1110        assert_eq!(parse_positive_u64("42"), Some(42));
1111        assert_eq!(parse_positive_u64("0"), None);
1112        assert_eq!(parse_positive_u32("42"), Some(42));
1113        assert_eq!(parse_positive_u32("4294967296"), None);
1114        assert_eq!(parse_non_negative_u64("0"), Some(0));
1115        assert_eq!(parse_non_negative_u64("-1"), None);
1116        assert_eq!(parse_bounded_u64("5", 4, 8), Some(5));
1117        assert_eq!(parse_bounded_u64("3", 4, 8), None);
1118        assert_eq!(parse_bounded_u64("9", 4, 8), None);
1119        assert_eq!(parse_bounded_u8("4", 1, 4), Some(4));
1120        assert_eq!(parse_bounded_u8("5", 1, 4), None);
1121        assert_eq!(parse_positive_i32("12"), Some(12));
1122        assert_eq!(parse_positive_i32("2147483648"), None);
1123        assert_eq!(parse_finite_f32("1.5"), Some(1.5));
1124        assert_eq!(parse_finite_f32("NaN"), None);
1125        assert_eq!(parse_finite_f32("inf"), None);
1126    }
1127
1128    #[test]
1129    fn runtime_value_readers_use_defaults_for_invalid_values() {
1130        let lookup = std::collections::HashMap::from([
1131            ("positive".to_string(), "5".to_string()),
1132            ("zero".to_string(), "0".to_string()),
1133            ("bool".to_string(), "on".to_string()),
1134            ("bad".to_string(), "nope".to_string()),
1135            ("bounded".to_string(), "7".to_string()),
1136            ("bounded_u8".to_string(), "4".to_string()),
1137            ("out_of_range".to_string(), "12".to_string()),
1138            ("too_large_i32".to_string(), "2147483648".to_string()),
1139            ("finite".to_string(), "2.5".to_string()),
1140            ("nan".to_string(), "NaN".to_string()),
1141        ]);
1142
1143        assert_eq!(read_positive_u64(&lookup, "positive", 1), 5);
1144        assert_eq!(read_positive_u32(&lookup, "positive", 1), 5);
1145        assert_eq!(read_positive_u64(&lookup, "zero", 1), 1);
1146        assert_eq!(read_non_negative_u64(&lookup, "zero", 9), 0);
1147        assert_eq!(read_bounded_u64(&lookup, "bounded", 1, 4, 8), 7);
1148        assert_eq!(read_bounded_u8(&lookup, "bounded_u8", 1, 1, 4), 4);
1149        assert_eq!(read_bounded_u64(&lookup, "out_of_range", 1, 4, 8), 1);
1150        assert_eq!(read_bounded_u8(&lookup, "out_of_range", 1, 1, 4), 1);
1151        assert!(read_bool(&lookup, "bool", false));
1152        assert!(read_bool(&lookup, "bad", true));
1153        assert_eq!(read_positive_i32(&lookup, "too_large_i32", 3), 3);
1154        assert_eq!(
1155            read_finite_f32(&lookup, "finite", 1.0).to_bits(),
1156            2.5_f32.to_bits()
1157        );
1158        assert_eq!(
1159            read_finite_f32(&lookup, "nan", 1.0).to_bits(),
1160            1.0_f32.to_bits()
1161        );
1162        assert_eq!(read_positive_usize(&lookup, "positive", 1), 5);
1163    }
1164
1165    #[test]
1166    fn positive_u64_normalizer_trims_and_rejects_invalid_values() {
1167        assert_eq!(
1168            normalize_positive_u64_config_value("interval", " 60 ").unwrap(),
1169            "60"
1170        );
1171        assert!(normalize_positive_u64_config_value("interval", "0").is_err());
1172        assert!(normalize_positive_u64_config_value("interval", "abc").is_err());
1173    }
1174
1175    #[test]
1176    fn numeric_normalizers_trim_and_reject_invalid_values() {
1177        assert_eq!(
1178            normalize_bool_config_value("enabled", " yes ").unwrap(),
1179            "true"
1180        );
1181        assert_eq!(
1182            normalize_bool_config_value("enabled", "OFF").unwrap(),
1183            "false"
1184        );
1185        assert!(normalize_bool_config_value("enabled", "sometimes").is_err());
1186        assert_eq!(
1187            normalize_strict_bool_config_value("strict_enabled", " true ").unwrap(),
1188            "true"
1189        );
1190        assert!(normalize_strict_bool_config_value("strict_enabled", "yes").is_err());
1191        assert_eq!(
1192            normalize_positive_u32_config_value("width", " 430 ").unwrap(),
1193            "430"
1194        );
1195        assert!(normalize_positive_u32_config_value("width", "0").is_err());
1196        assert_eq!(
1197            normalize_non_negative_u64_config_value("max_age", " 0 ").unwrap(),
1198            "0"
1199        );
1200        assert!(normalize_non_negative_u64_config_value("max_age", "-1").is_err());
1201        assert_eq!(
1202            normalize_bounded_u64_config_value("length", "5", 4, 8).unwrap(),
1203            "5"
1204        );
1205        assert!(normalize_bounded_u64_config_value("length", "9", 4, 8).is_err());
1206        assert_eq!(
1207            normalize_bounded_u8_config_value("supersampling", "4", 1, 4).unwrap(),
1208            "4"
1209        );
1210        assert!(normalize_bounded_u8_config_value("supersampling", "5", 1, 4).is_err());
1211        assert_eq!(
1212            normalize_finite_f32_config_value("scale", " 11.5 ").unwrap(),
1213            "11.5"
1214        );
1215        assert!(normalize_finite_f32_config_value("scale", "NaN").is_err());
1216    }
1217}