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