1use 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
20pub trait ConfigValueLookup {
22 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
47pub type ConfigNormalizer =
49 fn(lookup: &dyn ConfigValueLookup, key: &str, value: &str) -> Result<String>;
50
51pub type ConfigDependencyValidator =
53 fn(lookup: &dyn ConfigValueLookup, key: &str, normalized_value: &str) -> Result<()>;
54
55#[derive(Debug, Clone, Copy)]
57pub struct ConfigDefinition {
58 pub key: &'static str,
60 pub label_i18n_key: &'static str,
62 pub description_i18n_key: &'static str,
64 pub value_type: ConfigValueType,
66 pub default_fn: fn() -> String,
68 pub normalize_fn: Option<ConfigNormalizer>,
70 pub dependency_validator_fn: Option<ConfigDependencyValidator>,
72 pub requires_restart: bool,
74 pub is_sensitive: bool,
76 pub visibility: ConfigVisibility,
78 pub category: &'static str,
80 pub description: &'static str,
82}
83
84impl ConfigDefinition {
85 #[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#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct ConfigSeedRecord {
114 pub key: String,
116 pub value: String,
118 pub value_type: ConfigValueType,
120 pub requires_restart: bool,
122 pub is_sensitive: bool,
124 pub source: ConfigSource,
126 pub visibility: ConfigVisibility,
128 pub category: String,
130 pub description: String,
132}
133
134#[derive(Debug)]
136pub struct ConfigRegistry {
137 definitions: &'static [ConfigDefinition],
138}
139
140impl ConfigRegistry {
141 #[must_use]
143 pub const fn new(definitions: &'static [ConfigDefinition]) -> Self {
144 Self { definitions }
145 }
146
147 #[must_use]
149 pub const fn definitions(&self) -> &'static [ConfigDefinition] {
150 self.definitions
151 }
152
153 #[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 #[must_use]
163 pub fn contains_key(&self, key: &str) -> bool {
164 self.get(key).is_some()
165 }
166
167 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 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 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 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 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 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 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 #[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 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}