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};
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 [`ConfigCoreError`] 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 [`ConfigCoreError`] when registry keys are empty or duplicated.
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 [`ConfigCoreError`] 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 only the structural storage shape for a known key.
218    ///
219    /// This does not run the product normalizer or dependency validator. A structurally valid
220    /// value can still violate product rules. Use [`Self::normalize_value`] or
221    /// [`Self::value_to_storage_for_key`] for the full validation pipeline before persistence.
222    ///
223    /// # Errors
224    ///
225    /// Returns [`ConfigCoreError`] when the key is unknown or the storage shape is invalid.
226    pub fn validate_value_structure(&self, key: &str, value: &str) -> Result<()> {
227        let definition = self.require(key)?;
228        validate_storage_value(definition.value_type, value)
229    }
230
231    /// Validates and normalizes a logical value for a known key.
232    ///
233    /// Runs, in order: key lookup, input structure validation, the optional product normalizer,
234    /// normalized output structure validation, and the optional dependency validator. The
235    /// dependency validator receives the normalized value and the caller-supplied lookup.
236    ///
237    /// The input must use the definition's logical storage shape, for example a JSON string
238    /// array for `string_array`. For encrypted secrets, validate plaintext here before the
239    /// product encodes its storage envelope; do not normalize ciphertext as plaintext.
240    /// This method has no persistence, snapshot, audit, or notification side effects.
241    ///
242    /// # Errors
243    ///
244    /// Returns [`ConfigCoreError`] when the key is unknown, either structure check fails, or
245    /// the product normalizer or dependency validator rejects the value. Later stages do not run
246    /// after a failure.
247    pub fn normalize_value(
248        &self,
249        lookup: &dyn ConfigValueLookup,
250        key: &str,
251        value: &str,
252    ) -> Result<String> {
253        let definition = self.require(key)?;
254        validate_storage_value(definition.value_type, value)?;
255
256        let normalized = match definition.normalize_fn {
257            Some(normalize) => normalize(lookup, key, value)?,
258            None => value.to_string(),
259        };
260        validate_storage_value(definition.value_type, &normalized)?;
261
262        if let Some(validate) = definition.dependency_validator_fn {
263            validate(lookup, key, &normalized)?;
264        }
265
266        Ok(normalized)
267    }
268
269    /// Converts an API-facing value into normalized storage for a known key.
270    ///
271    /// Converts according to the registered type, then runs [`Self::normalize_value`]. Secret
272    /// encoding, persistence, audit, snapshot updates, and notification remain caller-owned.
273    ///
274    /// # Errors
275    ///
276    /// Returns [`ConfigCoreError`] when the key is unknown, conversion or structure validation
277    /// fails, or the normalizer or dependency validator rejects the value.
278    pub fn value_to_normalized_storage(
279        &self,
280        lookup: &dyn ConfigValueLookup,
281        key: &str,
282        value: &ConfigValue,
283    ) -> Result<String> {
284        let definition = self.require(key)?;
285        let storage = value.to_storage_for_type(definition.value_type)?;
286        self.normalize_value(lookup, key, &storage)
287    }
288
289    /// Converts an API-facing value into storage for either a registered key or a custom key.
290    ///
291    /// Registered keys use their declared [`ConfigValueType`] and run through the full registry
292    /// normalization pipeline. Custom keys are treated as scalar strings by default, leaving their
293    /// validation, visibility, permissions, and persistence policy to the product crate.
294    /// For registered keys the order is type conversion, input structure validation, normalizer,
295    /// output structure validation, then dependency validation. Secret encoding must follow this
296    /// pipeline; this method does not persist values or update runtime state.
297    ///
298    /// # Errors
299    ///
300    /// Returns [`ConfigCoreError`] when conversion or structure validation fails, or a registered
301    /// key's normalizer or dependency validator rejects the value. Unknown keys use the custom
302    /// scalar-string path rather than returning an unknown-key error.
303    pub fn value_to_storage_for_key(
304        &self,
305        lookup: &dyn ConfigValueLookup,
306        key: &str,
307        value: &ConfigValue,
308    ) -> Result<String> {
309        match self.get(key) {
310            Some(definition) => {
311                let storage = value.to_storage_for_type(definition.value_type)?;
312                self.normalize_value(lookup, key, &storage)
313            }
314            None => value.to_storage_for_type(ConfigValueType::String),
315        }
316    }
317
318    /// Applies definition metadata to a system-owned stored row.
319    #[must_use]
320    pub fn apply_definition(&self, mut config: StoredConfig) -> StoredConfig {
321        if config.source != ConfigSource::System {
322            return config;
323        }
324
325        let Some(definition) = self.get(&config.key) else {
326            return config;
327        };
328
329        config.value_type = definition.value_type;
330        config.requires_restart = definition.requires_restart;
331        config.is_sensitive = definition.is_sensitive;
332        config.visibility = definition.visibility;
333        config.category = definition.category.to_string();
334        config.description = definition.description.to_string();
335        config
336    }
337
338    /// Builds default seed rows from the registry.
339    ///
340    /// Defaults are normalized in registry order. If a normalizer or
341    /// dependency validator consults another config key, that dependency should
342    /// appear earlier in the registry.
343    ///
344    /// # Errors
345    ///
346    /// Returns [`ConfigCoreError`] when a default fails structure validation, normalization,
347    /// or dependency validation.
348    pub fn default_seed_records(&self) -> Result<Vec<ConfigSeedRecord>> {
349        self.validate_unique_keys()?;
350
351        let mut lookup = BTreeMap::<String, String>::new();
352        let mut rows = Vec::with_capacity(self.definitions.len());
353
354        for definition in self.definitions {
355            let raw = (definition.default_fn)();
356            let normalized = self.normalize_value(&lookup, definition.key, &raw)?;
357            lookup.insert(definition.key.to_string(), normalized.clone());
358            rows.push(ConfigSeedRecord {
359                key: definition.key.to_string(),
360                value: normalized,
361                value_type: definition.value_type,
362                requires_restart: definition.requires_restart,
363                is_sensitive: definition.is_sensitive,
364                source: ConfigSource::System,
365                visibility: definition.visibility,
366                category: definition.category.to_string(),
367                description: definition.description.to_string(),
368            });
369        }
370
371        Ok(rows)
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use std::collections::HashMap;
378    use std::sync::atomic::{AtomicUsize, Ordering};
379
380    use super::{
381        ConfigDefinition, ConfigRegistry, ConfigSeedRecord, ConfigValueLookup, ConfigValueType,
382    };
383    use crate::{ConfigSource, ConfigValue, ConfigVisibility, StoredConfig};
384
385    fn default_value() -> String {
386        "default".to_string()
387    }
388
389    fn default_toggle() -> String {
390        "true".to_string()
391    }
392
393    #[expect(
394        clippy::unnecessary_wraps,
395        reason = "This test normalizer must match the fallible ConfigNormalizer function-pointer contract."
396    )]
397    fn trim_value(
398        _lookup: &dyn ConfigValueLookup,
399        _key: &str,
400        value: &str,
401    ) -> crate::Result<String> {
402        Ok(value.trim().to_string())
403    }
404
405    fn require_enabled(
406        lookup: &dyn ConfigValueLookup,
407        _key: &str,
408        _value: &str,
409    ) -> crate::Result<()> {
410        match lookup.get_config_value("enabled").as_deref() {
411            Some("true") => Ok(()),
412            Some(_) => Err(crate::ConfigCoreError::invalid_value(
413                "feature requires enabled=true",
414            )),
415            None => Err(crate::ConfigCoreError::invalid_value(
416                "feature requires enabled to be present",
417            )),
418        }
419    }
420
421    const PRIMARY: ConfigDefinition = ConfigDefinition {
422        key: "primary",
423        label_i18n_key: "primary_label",
424        description_i18n_key: "primary_desc",
425        value_type: ConfigValueType::String,
426        default_fn: default_value,
427        normalize_fn: Some(trim_value),
428        dependency_validator_fn: None,
429        requires_restart: false,
430        is_sensitive: false,
431        visibility: ConfigVisibility::Private,
432        category: "general",
433        description: "primary setting",
434    };
435
436    const ENABLED: ConfigDefinition = ConfigDefinition {
437        key: "enabled",
438        label_i18n_key: "enabled_label",
439        description_i18n_key: "enabled_desc",
440        value_type: ConfigValueType::Boolean,
441        default_fn: default_toggle,
442        normalize_fn: None,
443        dependency_validator_fn: None,
444        requires_restart: false,
445        is_sensitive: false,
446        visibility: ConfigVisibility::Private,
447        category: "general",
448        description: "enabled flag",
449    };
450
451    const DEPENDENT: ConfigDefinition = ConfigDefinition {
452        key: "dependent",
453        label_i18n_key: "dependent_label",
454        description_i18n_key: "dependent_desc",
455        value_type: ConfigValueType::String,
456        default_fn: default_value,
457        normalize_fn: Some(trim_value),
458        dependency_validator_fn: Some(require_enabled),
459        requires_restart: true,
460        is_sensitive: true,
461        visibility: ConfigVisibility::Authenticated,
462        category: "general",
463        description: "dependent setting",
464    };
465
466    const DUPLICATE: ConfigDefinition = ConfigDefinition {
467        key: "primary",
468        ..PRIMARY
469    };
470
471    static DEFAULT_CALLS: AtomicUsize = AtomicUsize::new(0);
472
473    fn counting_default() -> String {
474        DEFAULT_CALLS.fetch_add(1, Ordering::Relaxed);
475        "counted".to_string()
476    }
477
478    #[test]
479    fn registry_finds_definitions_by_key() {
480        let registry = ConfigRegistry::new(&[PRIMARY]);
481
482        let definition = registry.require("primary").unwrap();
483
484        assert_eq!(definition.key, "primary");
485        assert!(registry.contains_key("primary"));
486        assert!(!registry.contains_key("missing"));
487    }
488
489    #[test]
490    fn static_key_validation_accepts_empty_and_single_definition_lists() {
491        const _: () = crate::__assert_unique_config_definition_keys(&[]);
492        const _: () = crate::__assert_unique_config_definition_keys(&[PRIMARY]);
493        crate::__assert_unique_config_definition_keys(&[]);
494        crate::__assert_unique_config_definition_keys(&[PRIMARY]);
495    }
496
497    #[test]
498    fn static_key_validation_distinguishes_lengths_bytes_and_unicode() {
499        const DEFINITIONS: &[ConfigDefinition] = &[
500            ConfigDefinition {
501                key: "a",
502                ..PRIMARY
503            },
504            ConfigDefinition {
505                key: "ab",
506                ..PRIMARY
507            },
508            ConfigDefinition {
509                key: "ac",
510                ..PRIMARY
511            },
512            ConfigDefinition {
513                key: "aB",
514                ..PRIMARY
515            },
516            ConfigDefinition {
517                key: "\u{e9}",
518                ..PRIMARY
519            },
520            ConfigDefinition {
521                key: "\u{ea}",
522                ..PRIMARY
523            },
524        ];
525        const _: () = crate::__assert_unique_config_definition_keys(DEFINITIONS);
526        crate::__assert_unique_config_definition_keys(DEFINITIONS);
527    }
528
529    #[test]
530    #[should_panic(expected = "duplicate config definition key")]
531    fn static_key_validation_rejects_adjacent_duplicates() {
532        crate::__assert_unique_config_definition_keys(&[PRIMARY, DUPLICATE]);
533    }
534
535    #[test]
536    #[should_panic(expected = "duplicate config definition key")]
537    fn static_key_validation_rejects_non_adjacent_duplicates() {
538        crate::__assert_unique_config_definition_keys(&[ENABLED, PRIMARY, DEPENDENT, DUPLICATE]);
539    }
540
541    #[test]
542    #[should_panic(expected = "config definition key cannot be empty")]
543    fn static_key_validation_rejects_empty_single_key() {
544        crate::__assert_unique_config_definition_keys(&[ConfigDefinition { key: "", ..PRIMARY }]);
545    }
546
547    #[test]
548    #[should_panic(expected = "config definition key cannot be empty")]
549    fn static_key_validation_rejects_empty_last_key() {
550        crate::__assert_unique_config_definition_keys(&[
551            PRIMARY,
552            ENABLED,
553            ConfigDefinition { key: "", ..PRIMARY },
554        ]);
555    }
556
557    #[test]
558    fn registry_rejects_duplicate_keys_and_unknown_categories() {
559        let duplicate_registry = ConfigRegistry::new(&[PRIMARY, DUPLICATE]);
560        assert!(duplicate_registry.validate_unique_keys().is_err());
561
562        let category_registry = ConfigRegistry::new(&[PRIMARY]);
563        assert!(category_registry.validate_categories(&["general"]).is_ok());
564        assert!(category_registry.validate_categories(&["other"]).is_err());
565    }
566
567    #[test]
568    fn default_seed_records_rejects_invalid_keys_before_generating_rows() {
569        const DUPLICATE_WITH_COUNTING_DEFAULT: ConfigDefinition = ConfigDefinition {
570            key: "primary",
571            default_fn: counting_default,
572            ..PRIMARY
573        };
574        const EMPTY: ConfigDefinition = ConfigDefinition { key: "", ..PRIMARY };
575        const WHITESPACE_ONLY: ConfigDefinition = ConfigDefinition {
576            key: "   ",
577            ..PRIMARY
578        };
579        const TABBED: ConfigDefinition = ConfigDefinition {
580            key: "\t\n",
581            ..PRIMARY
582        };
583
584        DEFAULT_CALLS.store(0, Ordering::Relaxed);
585        let duplicate_registry = ConfigRegistry::new(&[PRIMARY, DUPLICATE]);
586        assert!(duplicate_registry.default_seed_records().is_err());
587
588        let duplicate_with_counting_default =
589            ConfigRegistry::new(&[PRIMARY, DUPLICATE_WITH_COUNTING_DEFAULT]);
590        assert!(
591            duplicate_with_counting_default
592                .default_seed_records()
593                .is_err()
594        );
595        assert_eq!(DEFAULT_CALLS.load(Ordering::Relaxed), 0);
596
597        let empty_registry = ConfigRegistry::new(&[EMPTY]);
598        assert!(empty_registry.default_seed_records().is_err());
599
600        let whitespace_registry = ConfigRegistry::new(&[WHITESPACE_ONLY]);
601        assert!(whitespace_registry.default_seed_records().is_err());
602
603        let tabbed_registry = ConfigRegistry::new(&[TABBED]);
604        assert!(tabbed_registry.default_seed_records().is_err());
605    }
606
607    #[test]
608    fn registry_normalizes_and_validates_known_values() {
609        let registry = ConfigRegistry::new(&[ENABLED, DEPENDENT]);
610        let lookup = HashMap::from([("enabled".to_string(), "true".to_string())]);
611
612        assert_eq!(
613            registry
614                .normalize_value(&lookup, "dependent", "  demo  ")
615                .unwrap(),
616            "demo"
617        );
618        assert!(registry.normalize_value(&lookup, "enabled", "yes").is_err());
619    }
620
621    #[test]
622    fn registry_dependency_validation_uses_lookup() {
623        let registry = ConfigRegistry::new(&[ENABLED, DEPENDENT]);
624        let failing_lookup = HashMap::from([("enabled".to_string(), "false".to_string())]);
625
626        assert!(
627            registry
628                .normalize_value(&failing_lookup, "dependent", "value")
629                .is_err()
630        );
631    }
632
633    #[test]
634    fn function_lookup_can_back_runtime_readers() {
635        let lookup = |key: &str| (key == "enabled").then_some("true".to_string());
636
637        assert_eq!(lookup.get_config_value("enabled"), Some("true".to_string()));
638        assert_eq!(lookup.get_config_value("missing"), None);
639    }
640
641    #[test]
642    fn registry_converts_api_values_into_normalized_storage() {
643        let registry = ConfigRegistry::new(&[PRIMARY]);
644
645        assert_eq!(
646            registry
647                .value_to_normalized_storage(
648                    &HashMap::new(),
649                    "primary",
650                    &ConfigValue::from("  x  ")
651                )
652                .unwrap(),
653            "x"
654        );
655    }
656
657    #[test]
658    fn registry_converts_registered_and_custom_values_for_storage() {
659        let registry = ConfigRegistry::new(&[PRIMARY]);
660
661        assert_eq!(
662            registry
663                .value_to_storage_for_key(&HashMap::new(), "primary", &ConfigValue::from("  x  "))
664                .unwrap(),
665            "x"
666        );
667        assert_eq!(
668            registry
669                .value_to_storage_for_key(
670                    &HashMap::new(),
671                    "custom.banner",
672                    &ConfigValue::from("  x  ")
673                )
674                .unwrap(),
675            "  x  "
676        );
677        assert!(
678            registry
679                .value_to_storage_for_key(
680                    &HashMap::new(),
681                    "custom.list",
682                    &ConfigValue::StringArray(vec!["x".to_string()])
683                )
684                .is_err()
685        );
686    }
687
688    #[test]
689    fn registry_applies_definition_metadata_to_system_rows() {
690        let registry = ConfigRegistry::new(&[DEPENDENT]);
691        let row = StoredConfig {
692            id: 7,
693            key: "dependent".to_string(),
694            value: "value".to_string(),
695            value_type: ConfigValueType::String,
696            requires_restart: false,
697            is_sensitive: false,
698            source: ConfigSource::System,
699            visibility: ConfigVisibility::Private,
700            category: String::new(),
701            description: String::new(),
702        };
703
704        let applied = registry.apply_definition(row);
705        assert_eq!(applied.value_type, ConfigValueType::String);
706        assert!(applied.requires_restart);
707        assert!(applied.is_sensitive);
708        assert_eq!(applied.visibility, ConfigVisibility::Authenticated);
709        assert_eq!(applied.category, "general");
710        assert_eq!(applied.description, "dependent setting");
711    }
712
713    #[test]
714    fn registry_builds_normalized_default_seed_records() {
715        let registry = ConfigRegistry::new(&[ENABLED, DEPENDENT]);
716
717        assert_eq!(
718            registry.default_seed_records().unwrap(),
719            vec![
720                ConfigSeedRecord {
721                    key: "enabled".to_string(),
722                    value: "true".to_string(),
723                    value_type: ConfigValueType::Boolean,
724                    requires_restart: false,
725                    is_sensitive: false,
726                    source: ConfigSource::System,
727                    visibility: ConfigVisibility::Private,
728                    category: "general".to_string(),
729                    description: "enabled flag".to_string(),
730                },
731                ConfigSeedRecord {
732                    key: "dependent".to_string(),
733                    value: "default".to_string(),
734                    value_type: ConfigValueType::String,
735                    requires_restart: true,
736                    is_sensitive: true,
737                    source: ConfigSource::System,
738                    visibility: ConfigVisibility::Authenticated,
739                    category: "general".to_string(),
740                    description: "dependent setting".to_string(),
741                },
742            ]
743        );
744    }
745}