aster_forge_config/
registry.rs

1//! Configuration definition registry.
2//!
3//! Product crates register their keys here as static metadata. The registry is
4//! then shared by default initialization, validation, admin APIs, frontend
5//! settings pages, and code-generated documentation without every subsystem
6//! inventing its own copy of the same definition list.
7
8use std::collections::{BTreeMap, BTreeSet, HashMap};
9use std::hash::BuildHasher;
10
11use crate::{
12    ConfigCoreError, ConfigSource, ConfigValue, ConfigValueType, ConfigVisibility, Result,
13    StoredConfig, validate_storage_value,
14};
15
16fn empty_default_value() -> String {
17    String::new()
18}
19
20/// Lookup used by configuration normalizers and dependency validators.
21pub trait ConfigValueLookup {
22    /// Returns the current storage string for `key`.
23    fn get_config_value(&self, key: &str) -> Option<String>;
24}
25
26impl<S: BuildHasher> ConfigValueLookup for HashMap<String, String, S> {
27    fn get_config_value(&self, key: &str) -> Option<String> {
28        self.get(key).cloned()
29    }
30}
31
32impl ConfigValueLookup for BTreeMap<String, String> {
33    fn get_config_value(&self, key: &str) -> Option<String> {
34        self.get(key).cloned()
35    }
36}
37
38impl<F> ConfigValueLookup for F
39where
40    F: Fn(&str) -> Option<String>,
41{
42    fn get_config_value(&self, key: &str) -> Option<String> {
43        self(key)
44    }
45}
46
47/// Product-owned value normalizer.
48pub type ConfigNormalizer =
49    fn(lookup: &dyn ConfigValueLookup, key: &str, value: &str) -> Result<String>;
50
51/// Product-owned cross-field validator.
52pub type ConfigDependencyValidator =
53    fn(lookup: &dyn ConfigValueLookup, key: &str, normalized_value: &str) -> Result<()>;
54
55/// Product-owned metadata for one configuration key.
56#[derive(Debug, Clone, Copy)]
57pub struct ConfigDefinition {
58    /// Stable storage key.
59    pub key: &'static str,
60    /// Frontend i18n key for the display label.
61    pub label_i18n_key: &'static str,
62    /// Frontend i18n key for the description.
63    pub description_i18n_key: &'static str,
64    /// Storage and API value kind.
65    pub value_type: ConfigValueType,
66    /// Function returning the default storage value.
67    pub default_fn: fn() -> String,
68    /// Optional product-owned value normalizer.
69    pub normalize_fn: Option<ConfigNormalizer>,
70    /// Optional product-owned cross-field validator.
71    pub dependency_validator_fn: Option<ConfigDependencyValidator>,
72    /// Whether changes require process restart before they can take effect.
73    pub requires_restart: bool,
74    /// Whether values should be redacted in presentation and audit output.
75    pub is_sensitive: bool,
76    /// Default consumer visibility for this system-defined value.
77    pub visibility: ConfigVisibility,
78    /// Product-defined category key.
79    pub category: &'static str,
80    /// Backend-facing description used when initializing storage rows.
81    pub description: &'static str,
82}
83
84impl ConfigDefinition {
85    /// Returns a baseline private system definition for struct update syntax.
86    ///
87    /// Product registries usually repeat the same neutral metadata for most
88    /// entries: system-owned source, private visibility, no normalizer, and no
89    /// dependency validator. This helper keeps static definition lists compact
90    /// while still requiring each product to spell out the storage key, type,
91    /// default, category, and descriptions that define its public contract.
92    #[must_use]
93    pub const fn private_system() -> Self {
94        Self {
95            key: "",
96            label_i18n_key: "",
97            description_i18n_key: "",
98            value_type: ConfigValueType::String,
99            default_fn: empty_default_value,
100            normalize_fn: None,
101            dependency_validator_fn: None,
102            requires_restart: false,
103            is_sensitive: false,
104            visibility: ConfigVisibility::Private,
105            category: "",
106            description: "",
107        }
108    }
109}
110
111/// Seed row produced from a registry definition.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct ConfigSeedRecord {
114    /// Stable storage key.
115    pub key: String,
116    /// Normalized storage string.
117    pub value: String,
118    /// Storage and API value kind.
119    pub value_type: ConfigValueType,
120    /// Whether changes require restart before they can take effect.
121    pub requires_restart: bool,
122    /// Whether values should be redacted in presentation and audit output.
123    pub is_sensitive: bool,
124    /// Source of this seeded value.
125    pub source: ConfigSource,
126    /// Default consumer visibility.
127    pub visibility: ConfigVisibility,
128    /// Product-defined category.
129    pub category: String,
130    /// Backend-facing description.
131    pub description: String,
132}
133
134/// Static registry of product configuration definitions.
135#[derive(Debug)]
136pub struct ConfigRegistry {
137    definitions: &'static [ConfigDefinition],
138}
139
140impl ConfigRegistry {
141    /// Creates a registry from a static definition slice.
142    #[must_use]
143    pub const fn new(definitions: &'static [ConfigDefinition]) -> Self {
144        Self { definitions }
145    }
146
147    /// Returns all registered definitions.
148    #[must_use]
149    pub const fn definitions(&self) -> &'static [ConfigDefinition] {
150        self.definitions
151    }
152
153    /// Returns the definition for `key`.
154    #[must_use]
155    pub fn get(&self, key: &str) -> Option<&'static ConfigDefinition> {
156        self.definitions
157            .iter()
158            .find(|definition| definition.key == key)
159    }
160
161    /// Returns whether `key` is registered.
162    #[must_use]
163    pub fn contains_key(&self, key: &str) -> bool {
164        self.get(key).is_some()
165    }
166
167    /// Returns the definition for `key` or an unknown-key error.
168    ///
169    /// # Errors
170    ///
171    /// Returns [`ConfigError`] when the requested configuration key is not registered.
172    pub fn require(&self, key: &str) -> Result<&'static ConfigDefinition> {
173        self.get(key)
174            .ok_or_else(|| ConfigCoreError::UnknownKey(key.to_string()))
175    }
176
177    /// Validates that keys are non-empty and unique.
178    ///
179    /// # Errors
180    ///
181    /// Returns [`ConfigError`] when the registry contains duplicate configuration keys.
182    pub fn validate_unique_keys(&self) -> Result<()> {
183        let mut seen = BTreeSet::new();
184        for definition in self.definitions {
185            if definition.key.trim().is_empty() {
186                return Err(ConfigCoreError::invalid_value(
187                    "config definition key cannot be empty",
188                ));
189            }
190            if !seen.insert(definition.key) {
191                return Err(ConfigCoreError::invalid_value(format!(
192                    "duplicate config definition key '{}'",
193                    definition.key
194                )));
195            }
196        }
197        Ok(())
198    }
199
200    /// Validates that all categories belong to `allowed_categories`.
201    ///
202    /// # Errors
203    ///
204    /// Returns [`ConfigError`] when a definition category violates registry constraints.
205    pub fn validate_categories(&self, allowed_categories: &[&str]) -> Result<()> {
206        for definition in self.definitions {
207            if !allowed_categories.contains(&definition.category) {
208                return Err(ConfigCoreError::invalid_value(format!(
209                    "config key '{}' uses unknown category '{}'",
210                    definition.key, definition.category
211                )));
212            }
213        }
214        Ok(())
215    }
216
217    /// Validates a storage string for a known key.
218    ///
219    /// # Errors
220    ///
221    /// Returns [`ConfigError`] when the stored value or a dependency rule is invalid.
222    pub fn validate_value(&self, key: &str, value: &str) -> Result<()> {
223        let definition = self.require(key)?;
224        validate_storage_value(definition.value_type, value)
225    }
226
227    /// Normalizes a storage string for a known key.
228    ///
229    /// The input is expected to already match the structural storage shape for
230    /// the definition's value type, for example a JSON string array for
231    /// `string_array`.
232    ///
233    /// # Errors
234    ///
235    /// Returns [`ConfigError`] when the definition normalizer rejects the value.
236    pub fn normalize_value(
237        &self,
238        lookup: &dyn ConfigValueLookup,
239        key: &str,
240        value: &str,
241    ) -> Result<String> {
242        let definition = self.require(key)?;
243        validate_storage_value(definition.value_type, value)?;
244
245        let normalized = match definition.normalize_fn {
246            Some(normalize) => normalize(lookup, key, value)?,
247            None => value.to_string(),
248        };
249        validate_storage_value(definition.value_type, &normalized)?;
250
251        if let Some(validate) = definition.dependency_validator_fn {
252            validate(lookup, key, &normalized)?;
253        }
254
255        Ok(normalized)
256    }
257
258    /// Converts an API-facing value into normalized storage for a known key.
259    ///
260    /// # Errors
261    ///
262    /// Returns [`ConfigError`] when the typed value cannot be normalized for storage.
263    pub fn value_to_normalized_storage(
264        &self,
265        lookup: &dyn ConfigValueLookup,
266        key: &str,
267        value: &ConfigValue,
268    ) -> Result<String> {
269        let definition = self.require(key)?;
270        let storage = value.to_storage_for_type(definition.value_type)?;
271        self.normalize_value(lookup, key, &storage)
272    }
273
274    /// Converts an API-facing value into storage for either a registered key or a custom key.
275    ///
276    /// Registered keys use their declared [`ConfigValueType`] and run through the full registry
277    /// normalization pipeline. Custom keys are treated as scalar strings by default, leaving their
278    /// visibility, permissions, and persistence policy to the product crate.
279    ///
280    /// # Errors
281    ///
282    /// Returns [`ConfigError`] when the key is unknown or its typed value is invalid.
283    pub fn value_to_storage_for_key(
284        &self,
285        lookup: &dyn ConfigValueLookup,
286        key: &str,
287        value: &ConfigValue,
288    ) -> Result<String> {
289        match self.get(key) {
290            Some(definition) => {
291                let storage = value.to_storage_for_type(definition.value_type)?;
292                self.normalize_value(lookup, key, &storage)
293            }
294            None => value.to_storage_for_type(ConfigValueType::String),
295        }
296    }
297
298    /// Applies definition metadata to a system-owned stored row.
299    #[must_use]
300    pub fn apply_definition(&self, mut config: StoredConfig) -> StoredConfig {
301        if config.source != ConfigSource::System {
302            return config;
303        }
304
305        let Some(definition) = self.get(&config.key) else {
306            return config;
307        };
308
309        config.value_type = definition.value_type;
310        config.requires_restart = definition.requires_restart;
311        config.is_sensitive = definition.is_sensitive;
312        config.visibility = definition.visibility;
313        config.category = definition.category.to_string();
314        config.description = definition.description.to_string();
315        config
316    }
317
318    /// Builds default seed rows from the registry.
319    ///
320    /// Defaults are normalized in registry order. If a normalizer or
321    /// dependency validator consults another config key, that dependency should
322    /// appear earlier in the registry.
323    ///
324    /// # Errors
325    ///
326    /// Returns [`ConfigError`] when definitions are duplicated or a default value is invalid.
327    pub fn default_seed_records(&self) -> Result<Vec<ConfigSeedRecord>> {
328        let mut lookup = BTreeMap::<String, String>::new();
329        let mut rows = Vec::with_capacity(self.definitions.len());
330
331        for definition in self.definitions {
332            let raw = (definition.default_fn)();
333            let normalized = self.normalize_value(&lookup, definition.key, &raw)?;
334            lookup.insert(definition.key.to_string(), normalized.clone());
335            rows.push(ConfigSeedRecord {
336                key: definition.key.to_string(),
337                value: normalized,
338                value_type: definition.value_type,
339                requires_restart: definition.requires_restart,
340                is_sensitive: definition.is_sensitive,
341                source: ConfigSource::System,
342                visibility: definition.visibility,
343                category: definition.category.to_string(),
344                description: definition.description.to_string(),
345            });
346        }
347
348        Ok(rows)
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use std::collections::HashMap;
355
356    use super::{
357        ConfigDefinition, ConfigRegistry, ConfigSeedRecord, ConfigValueLookup, ConfigValueType,
358    };
359    use crate::{ConfigSource, ConfigValue, ConfigVisibility, StoredConfig};
360
361    fn default_value() -> String {
362        "default".to_string()
363    }
364
365    fn default_toggle() -> String {
366        "true".to_string()
367    }
368
369    #[expect(
370        clippy::unnecessary_wraps,
371        reason = "This test normalizer must match the fallible ConfigNormalizer function-pointer contract."
372    )]
373    fn trim_value(
374        _lookup: &dyn ConfigValueLookup,
375        _key: &str,
376        value: &str,
377    ) -> crate::Result<String> {
378        Ok(value.trim().to_string())
379    }
380
381    fn require_enabled(
382        lookup: &dyn ConfigValueLookup,
383        _key: &str,
384        _value: &str,
385    ) -> crate::Result<()> {
386        match lookup.get_config_value("enabled").as_deref() {
387            Some("true") => Ok(()),
388            Some(_) => Err(crate::ConfigCoreError::invalid_value(
389                "feature requires enabled=true",
390            )),
391            None => Err(crate::ConfigCoreError::invalid_value(
392                "feature requires enabled to be present",
393            )),
394        }
395    }
396
397    const PRIMARY: ConfigDefinition = ConfigDefinition {
398        key: "primary",
399        label_i18n_key: "primary_label",
400        description_i18n_key: "primary_desc",
401        value_type: ConfigValueType::String,
402        default_fn: default_value,
403        normalize_fn: Some(trim_value),
404        dependency_validator_fn: None,
405        requires_restart: false,
406        is_sensitive: false,
407        visibility: ConfigVisibility::Private,
408        category: "general",
409        description: "primary setting",
410    };
411
412    const ENABLED: ConfigDefinition = ConfigDefinition {
413        key: "enabled",
414        label_i18n_key: "enabled_label",
415        description_i18n_key: "enabled_desc",
416        value_type: ConfigValueType::Boolean,
417        default_fn: default_toggle,
418        normalize_fn: None,
419        dependency_validator_fn: None,
420        requires_restart: false,
421        is_sensitive: false,
422        visibility: ConfigVisibility::Private,
423        category: "general",
424        description: "enabled flag",
425    };
426
427    const DEPENDENT: ConfigDefinition = ConfigDefinition {
428        key: "dependent",
429        label_i18n_key: "dependent_label",
430        description_i18n_key: "dependent_desc",
431        value_type: ConfigValueType::String,
432        default_fn: default_value,
433        normalize_fn: Some(trim_value),
434        dependency_validator_fn: Some(require_enabled),
435        requires_restart: true,
436        is_sensitive: true,
437        visibility: ConfigVisibility::Authenticated,
438        category: "general",
439        description: "dependent setting",
440    };
441
442    const DUPLICATE: ConfigDefinition = ConfigDefinition {
443        key: "primary",
444        ..PRIMARY
445    };
446
447    #[test]
448    fn registry_finds_definitions_by_key() {
449        let registry = ConfigRegistry::new(&[PRIMARY]);
450
451        let definition = registry.require("primary").unwrap();
452
453        assert_eq!(definition.key, "primary");
454        assert!(registry.contains_key("primary"));
455        assert!(!registry.contains_key("missing"));
456    }
457
458    #[test]
459    fn registry_rejects_duplicate_keys_and_unknown_categories() {
460        let duplicate_registry = ConfigRegistry::new(&[PRIMARY, DUPLICATE]);
461        assert!(duplicate_registry.validate_unique_keys().is_err());
462
463        let category_registry = ConfigRegistry::new(&[PRIMARY]);
464        assert!(category_registry.validate_categories(&["general"]).is_ok());
465        assert!(category_registry.validate_categories(&["other"]).is_err());
466    }
467
468    #[test]
469    fn registry_normalizes_and_validates_known_values() {
470        let registry = ConfigRegistry::new(&[ENABLED, DEPENDENT]);
471        let lookup = HashMap::from([("enabled".to_string(), "true".to_string())]);
472
473        assert_eq!(
474            registry
475                .normalize_value(&lookup, "dependent", "  demo  ")
476                .unwrap(),
477            "demo"
478        );
479        assert!(registry.normalize_value(&lookup, "enabled", "yes").is_err());
480    }
481
482    #[test]
483    fn registry_dependency_validation_uses_lookup() {
484        let registry = ConfigRegistry::new(&[ENABLED, DEPENDENT]);
485        let failing_lookup = HashMap::from([("enabled".to_string(), "false".to_string())]);
486
487        assert!(
488            registry
489                .normalize_value(&failing_lookup, "dependent", "value")
490                .is_err()
491        );
492    }
493
494    #[test]
495    fn function_lookup_can_back_runtime_readers() {
496        let lookup = |key: &str| (key == "enabled").then_some("true".to_string());
497
498        assert_eq!(lookup.get_config_value("enabled"), Some("true".to_string()));
499        assert_eq!(lookup.get_config_value("missing"), None);
500    }
501
502    #[test]
503    fn registry_converts_api_values_into_normalized_storage() {
504        let registry = ConfigRegistry::new(&[PRIMARY]);
505
506        assert_eq!(
507            registry
508                .value_to_normalized_storage(
509                    &HashMap::new(),
510                    "primary",
511                    &ConfigValue::from("  x  ")
512                )
513                .unwrap(),
514            "x"
515        );
516    }
517
518    #[test]
519    fn registry_converts_registered_and_custom_values_for_storage() {
520        let registry = ConfigRegistry::new(&[PRIMARY]);
521
522        assert_eq!(
523            registry
524                .value_to_storage_for_key(&HashMap::new(), "primary", &ConfigValue::from("  x  "))
525                .unwrap(),
526            "x"
527        );
528        assert_eq!(
529            registry
530                .value_to_storage_for_key(
531                    &HashMap::new(),
532                    "custom.banner",
533                    &ConfigValue::from("  x  ")
534                )
535                .unwrap(),
536            "  x  "
537        );
538        assert!(
539            registry
540                .value_to_storage_for_key(
541                    &HashMap::new(),
542                    "custom.list",
543                    &ConfigValue::StringArray(vec!["x".to_string()])
544                )
545                .is_err()
546        );
547    }
548
549    #[test]
550    fn registry_applies_definition_metadata_to_system_rows() {
551        let registry = ConfigRegistry::new(&[DEPENDENT]);
552        let row = StoredConfig {
553            id: 7,
554            key: "dependent".to_string(),
555            value: "value".to_string(),
556            value_type: ConfigValueType::String,
557            requires_restart: false,
558            is_sensitive: false,
559            source: ConfigSource::System,
560            visibility: ConfigVisibility::Private,
561            category: String::new(),
562            description: String::new(),
563        };
564
565        let applied = registry.apply_definition(row);
566        assert_eq!(applied.value_type, ConfigValueType::String);
567        assert!(applied.requires_restart);
568        assert!(applied.is_sensitive);
569        assert_eq!(applied.visibility, ConfigVisibility::Authenticated);
570        assert_eq!(applied.category, "general");
571        assert_eq!(applied.description, "dependent setting");
572    }
573
574    #[test]
575    fn registry_builds_normalized_default_seed_records() {
576        let registry = ConfigRegistry::new(&[ENABLED, DEPENDENT]);
577
578        assert_eq!(
579            registry.default_seed_records().unwrap(),
580            vec![
581                ConfigSeedRecord {
582                    key: "enabled".to_string(),
583                    value: "true".to_string(),
584                    value_type: ConfigValueType::Boolean,
585                    requires_restart: false,
586                    is_sensitive: false,
587                    source: ConfigSource::System,
588                    visibility: ConfigVisibility::Private,
589                    category: "general".to_string(),
590                    description: "enabled flag".to_string(),
591                },
592                ConfigSeedRecord {
593                    key: "dependent".to_string(),
594                    value: "default".to_string(),
595                    value_type: ConfigValueType::String,
596                    requires_restart: true,
597                    is_sensitive: true,
598                    source: ConfigSource::System,
599                    visibility: ConfigVisibility::Authenticated,
600                    category: "general".to_string(),
601                    description: "dependent setting".to_string(),
602                },
603            ]
604        );
605    }
606}