1use 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
19pub trait ConfigValueLookup {
21 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
46pub type ConfigNormalizer =
48 fn(lookup: &dyn ConfigValueLookup, key: &str, value: &str) -> Result<String>;
49
50pub type ConfigDependencyValidator =
52 fn(lookup: &dyn ConfigValueLookup, key: &str, normalized_value: &str) -> Result<()>;
53
54#[derive(Debug, Clone, Copy)]
56pub struct ConfigDefinition {
57 pub key: &'static str,
59 pub label_i18n_key: &'static str,
61 pub description_i18n_key: &'static str,
63 pub value_type: ConfigValueType,
65 pub default_fn: fn() -> String,
67 pub normalize_fn: Option<ConfigNormalizer>,
69 pub dependency_validator_fn: Option<ConfigDependencyValidator>,
71 pub requires_restart: bool,
73 pub is_sensitive: bool,
75 pub visibility: ConfigVisibility,
77 pub category: &'static str,
79 pub description: &'static str,
81}
82
83impl ConfigDefinition {
84 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#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct ConfigSeedRecord {
112 pub key: String,
114 pub value: String,
116 pub value_type: ConfigValueType,
118 pub requires_restart: bool,
120 pub is_sensitive: bool,
122 pub source: ConfigSource,
124 pub visibility: ConfigVisibility,
126 pub category: String,
128 pub description: String,
130}
131
132#[derive(Debug)]
134pub struct ConfigRegistry {
135 definitions: &'static [ConfigDefinition],
136}
137
138impl ConfigRegistry {
139 pub const fn new(definitions: &'static [ConfigDefinition]) -> Self {
141 Self { definitions }
142 }
143
144 pub const fn definitions(&self) -> &'static [ConfigDefinition] {
146 self.definitions
147 }
148
149 pub fn get(&self, key: &str) -> Option<&'static ConfigDefinition> {
151 self.definitions
152 .iter()
153 .find(|definition| definition.key == key)
154 }
155
156 pub fn contains_key(&self, key: &str) -> bool {
158 self.get(key).is_some()
159 }
160
161 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 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 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 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 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 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 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 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 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}