Skip to main content

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