1use serde::{Deserialize, Serialize};
9use std::fmt;
10
11use crate::{ConfigCoreError, Result};
12#[cfg(feature = "sea-orm")]
13use sea_orm::entity::prelude::*;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
17#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(utoipa::ToSchema))]
18#[cfg_attr(feature = "sea-orm", derive(EnumIter, DeriveActiveEnum))]
19#[cfg_attr(
20 feature = "sea-orm",
21 sea_orm(rs_type = "String", db_type = "String(StringLen::N(32))")
22)]
23#[serde(rename_all = "snake_case")]
24pub enum ConfigValueType {
25 #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "string"))]
27 String,
28 #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "multiline"))]
30 Multiline,
31 #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "string_array"))]
33 StringArray,
34 #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "string_enum"))]
36 StringEnum,
37 #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "string_enum_set"))]
39 StringEnumSet,
40 #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "number"))]
42 Number,
43 #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "boolean"))]
45 Boolean,
46}
47
48impl ConfigValueType {
49 pub const fn as_str(self) -> &'static str {
51 match self {
52 Self::String => "string",
53 Self::Multiline => "multiline",
54 Self::StringArray => "string_array",
55 Self::StringEnum => "string_enum",
56 Self::StringEnumSet => "string_enum_set",
57 Self::Number => "number",
58 Self::Boolean => "boolean",
59 }
60 }
61
62 pub fn from_str_name(value: &str) -> Option<Self> {
64 match value {
65 "string" => Some(Self::String),
66 "multiline" => Some(Self::Multiline),
67 "string_array" => Some(Self::StringArray),
68 "string_enum" => Some(Self::StringEnum),
69 "string_enum_set" => Some(Self::StringEnumSet),
70 "number" => Some(Self::Number),
71 "boolean" => Some(Self::Boolean),
72 _ => None,
73 }
74 }
75
76 pub const fn is_multiline(self) -> bool {
78 matches!(self, Self::Multiline)
79 }
80
81 pub const fn is_string_array(self) -> bool {
83 matches!(self, Self::StringArray)
84 }
85
86 pub const fn is_string_enum_set(self) -> bool {
88 matches!(self, Self::StringEnumSet)
89 }
90
91 pub const fn is_string_list(self) -> bool {
93 matches!(self, Self::StringArray | Self::StringEnumSet)
94 }
95}
96
97impl fmt::Display for ConfigValueType {
98 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
99 formatter.write_str(self.as_str())
100 }
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
105#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(utoipa::ToSchema))]
106#[cfg_attr(feature = "sea-orm", derive(EnumIter, DeriveActiveEnum))]
107#[cfg_attr(
108 feature = "sea-orm",
109 sea_orm(rs_type = "String", db_type = "String(StringLen::N(16))")
110)]
111#[serde(rename_all = "snake_case")]
112pub enum ConfigSource {
113 #[default]
115 #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "system"))]
116 System,
117 #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "custom"))]
119 Custom,
120}
121
122impl ConfigSource {
123 pub const fn as_str(self) -> &'static str {
125 match self {
126 Self::System => "system",
127 Self::Custom => "custom",
128 }
129 }
130
131 pub fn from_str_name(value: &str) -> Option<Self> {
133 match value {
134 "system" => Some(Self::System),
135 "custom" => Some(Self::Custom),
136 _ => None,
137 }
138 }
139}
140
141impl fmt::Display for ConfigSource {
142 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
143 formatter.write_str(self.as_str())
144 }
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
149#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(utoipa::ToSchema))]
150#[cfg_attr(feature = "sea-orm", derive(EnumIter, DeriveActiveEnum))]
151#[cfg_attr(
152 feature = "sea-orm",
153 sea_orm(rs_type = "String", db_type = "String(StringLen::N(16))")
154)]
155#[serde(rename_all = "snake_case")]
156pub enum ConfigVisibility {
157 #[default]
159 #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "private"))]
160 Private,
161 #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "public"))]
163 Public,
164 #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "authenticated"))]
166 Authenticated,
167}
168
169impl ConfigVisibility {
170 pub const fn as_str(self) -> &'static str {
172 match self {
173 Self::Private => "private",
174 Self::Public => "public",
175 Self::Authenticated => "authenticated",
176 }
177 }
178
179 pub fn from_str_name(value: &str) -> Option<Self> {
181 match value {
182 "private" => Some(Self::Private),
183 "public" => Some(Self::Public),
184 "authenticated" => Some(Self::Authenticated),
185 _ => None,
186 }
187 }
188
189 pub const fn visible_to_public(self) -> bool {
191 matches!(self, Self::Public)
192 }
193
194 pub const fn visible_to_authenticated(self) -> bool {
196 matches!(self, Self::Public | Self::Authenticated)
197 }
198}
199
200impl fmt::Display for ConfigVisibility {
201 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
202 formatter.write_str(self.as_str())
203 }
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(untagged)]
209#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(utoipa::ToSchema))]
210pub enum ConfigValue {
211 String(String),
213 StringArray(Vec<String>),
215}
216
217impl ConfigValue {
218 pub const REDACTED: &'static str = "***REDACTED***";
220
221 pub fn from_storage(value_type: impl Into<ConfigValueType>, value: String) -> Result<Self> {
223 let value_type = value_type.into();
224 if !value_type.is_string_list() {
225 return Ok(Self::String(value));
226 }
227
228 let items = serde_json::from_str::<Vec<String>>(&value)?;
229 Ok(Self::StringArray(items))
230 }
231
232 pub fn from_storage_lossy(
238 value_type: impl Into<ConfigValueType>,
239 value: String,
240 on_invalid: impl FnOnce(&ConfigCoreError),
241 ) -> Self {
242 let value_type = value_type.into();
243 match Self::from_storage(value_type, value) {
244 Ok(value) => value,
245 Err(error) => {
246 on_invalid(&error);
247 Self::empty_for_type(value_type)
248 }
249 }
250 }
251
252 pub fn redacted() -> Self {
254 Self::String(Self::REDACTED.to_string())
255 }
256
257 pub fn empty_for_type(value_type: impl Into<ConfigValueType>) -> Self {
259 if value_type.into().is_string_list() {
260 Self::StringArray(Vec::new())
261 } else {
262 Self::String(String::new())
263 }
264 }
265
266 pub fn is_empty(&self) -> bool {
268 match self {
269 Self::String(value) => value.trim().is_empty(),
270 Self::StringArray(values) => values.is_empty(),
271 }
272 }
273
274 pub fn to_storage_for_type(&self, value_type: impl Into<ConfigValueType>) -> Result<String> {
276 let value_type = value_type.into();
277 match (value_type, self) {
278 (
279 ConfigValueType::StringArray | ConfigValueType::StringEnumSet,
280 Self::StringArray(values),
281 ) => serde_json::to_string(values).map_err(Into::into),
282 (ConfigValueType::StringArray | ConfigValueType::StringEnumSet, Self::String(_)) => {
283 Err(ConfigCoreError::invalid_value(format!(
284 "{} config value must be a JSON array",
285 value_type.as_str()
286 )))
287 }
288 (_, Self::String(value)) => Ok(value.clone()),
289 (_, Self::StringArray(_)) => Err(ConfigCoreError::invalid_value(
290 "string array values are only supported for string_array and string_enum_set config keys",
291 )),
292 }
293 }
294
295 pub fn to_audit_string(&self) -> String {
297 match self {
298 Self::String(value) => value.clone(),
299 Self::StringArray(values) => serde_json::to_string(values)
300 .unwrap_or_else(|_| "<invalid string list value>".to_string()),
301 }
302 }
303}
304
305impl From<&str> for ConfigValue {
306 fn from(value: &str) -> Self {
307 Self::String(value.to_string())
308 }
309}
310
311impl From<&String> for ConfigValue {
312 fn from(value: &String) -> Self {
313 Self::String(value.clone())
314 }
315}
316
317impl From<String> for ConfigValue {
318 fn from(value: String) -> Self {
319 Self::String(value)
320 }
321}
322
323impl From<Vec<String>> for ConfigValue {
324 fn from(value: Vec<String>) -> Self {
325 Self::StringArray(value)
326 }
327}
328
329pub fn present_config_value(
335 value_type: impl Into<ConfigValueType>,
336 value: String,
337 is_sensitive: bool,
338 on_invalid: impl FnOnce(&ConfigCoreError),
339) -> ConfigValue {
340 if is_sensitive {
341 ConfigValue::redacted()
342 } else {
343 ConfigValue::from_storage_lossy(value_type, value, on_invalid)
344 }
345}
346
347pub fn config_value_audit_string(
353 value_type: impl Into<ConfigValueType>,
354 value: String,
355 is_sensitive: bool,
356 on_invalid: impl FnOnce(&ConfigCoreError),
357) -> String {
358 if is_sensitive {
359 ConfigValue::REDACTED.to_string()
360 } else {
361 ConfigValue::from_storage_lossy(value_type, value, on_invalid).to_audit_string()
362 }
363}
364
365pub fn validate_storage_value(value_type: ConfigValueType, value: &str) -> Result<()> {
371 let trimmed = value.trim();
372 match value_type {
373 ConfigValueType::Boolean => {
374 if trimmed != "true" && trimmed != "false" {
375 return Err(ConfigCoreError::invalid_value(
376 "boolean config must be 'true' or 'false'",
377 ));
378 }
379 }
380 ConfigValueType::Number => {
381 if !trimmed.parse::<f64>().is_ok_and(f64::is_finite) {
384 return Err(ConfigCoreError::invalid_value(
385 "number config must be a valid finite number",
386 ));
387 }
388 }
389 ConfigValueType::StringArray | ConfigValueType::StringEnumSet => {
390 parse_string_array_config_value(trimmed, value_type.as_str())?;
391 }
392 ConfigValueType::String | ConfigValueType::StringEnum | ConfigValueType::Multiline => {}
393 }
394 Ok(())
395}
396
397pub fn parse_string_array_config_value(value: &str, key: &str) -> Result<Vec<String>> {
404 serde_json::from_str::<Vec<String>>(value.trim()).map_err(|error| {
405 ConfigCoreError::invalid_value(format!("{key} must be a JSON array of strings: {error}"))
406 })
407}
408
409pub fn parse_single_string_enum_selection<T>(
416 value: &str,
417 key: &str,
418 allowed_values: &str,
419 parse: impl Fn(&str) -> Option<T>,
420) -> Result<T> {
421 let trimmed = value.trim();
422 let selected = if trimmed.starts_with('[') {
423 let items = serde_json::from_str::<Vec<String>>(trimmed).map_err(|error| {
424 ConfigCoreError::invalid_value(format!(
425 "{key} must be a string enum or a legacy JSON array with exactly one value: {error}",
426 ))
427 })?;
428 let [item] = items.as_slice() else {
429 return Err(ConfigCoreError::invalid_value(format!(
430 "{key} must select exactly one value",
431 )));
432 };
433 item.clone()
434 } else {
435 trimmed.to_string()
436 };
437
438 parse(&selected).ok_or_else(|| {
439 ConfigCoreError::invalid_value(format!("{key} must be one of: {allowed_values}"))
440 })
441}
442
443pub fn parse_string_enum_set_selection<T>(
449 value: &str,
450 key: &str,
451 item_name: &str,
452 parse: impl Fn(&str) -> Option<T>,
453) -> Result<Vec<T>>
454where
455 T: Copy + Eq,
456{
457 let values = parse_string_array_config_value(value, key)?;
458 let mut selected = Vec::with_capacity(values.len());
459
460 for raw in values {
461 let Some(item) = parse(&raw) else {
462 return Err(ConfigCoreError::invalid_value(format!(
463 "unknown {item_name} '{raw}' in {key}"
464 )));
465 };
466 if selected.contains(&item) {
467 return Err(ConfigCoreError::invalid_value(format!(
468 "duplicate {item_name} '{raw}' in {key}"
469 )));
470 }
471 selected.push(item);
472 }
473
474 Ok(selected)
475}
476
477pub fn normalize_string_enum_set_selection<T>(
483 value: &str,
484 key: &str,
485 item_name: &str,
486 authoritative_order: &[T],
487 parse: impl Fn(&str) -> Option<T>,
488 display: impl Fn(T) -> &'static str,
489) -> Result<Vec<&'static str>>
490where
491 T: Copy + Eq,
492{
493 let selected = parse_string_enum_set_selection(value, key, item_name, parse)?;
494 Ok(authoritative_order
495 .iter()
496 .copied()
497 .filter(|item| selected.contains(item))
498 .map(display)
499 .collect())
500}
501
502#[cfg(test)]
503mod tests {
504 use super::{
505 ConfigSource, ConfigValue, ConfigValueType, ConfigVisibility, config_value_audit_string,
506 normalize_string_enum_set_selection, parse_single_string_enum_selection,
507 parse_string_array_config_value, parse_string_enum_set_selection, present_config_value,
508 validate_storage_value,
509 };
510
511 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
512 enum TestEnum {
513 Fast,
514 Balanced,
515 Quality,
516 }
517
518 const TEST_ENUMS: &[TestEnum] = &[TestEnum::Fast, TestEnum::Balanced, TestEnum::Quality];
519
520 fn parse_test_enum(value: &str) -> Option<TestEnum> {
521 match value {
522 "fast" => Some(TestEnum::Fast),
523 "balanced" => Some(TestEnum::Balanced),
524 "quality" => Some(TestEnum::Quality),
525 _ => None,
526 }
527 }
528
529 fn display_test_enum(value: TestEnum) -> &'static str {
530 match value {
531 TestEnum::Fast => "fast",
532 TestEnum::Balanced => "balanced",
533 TestEnum::Quality => "quality",
534 }
535 }
536
537 #[test]
538 fn value_type_round_trips_storage_names() {
539 let cases = [
540 (ConfigValueType::String, "string"),
541 (ConfigValueType::Multiline, "multiline"),
542 (ConfigValueType::StringArray, "string_array"),
543 (ConfigValueType::StringEnum, "string_enum"),
544 (ConfigValueType::StringEnumSet, "string_enum_set"),
545 (ConfigValueType::Number, "number"),
546 (ConfigValueType::Boolean, "boolean"),
547 ];
548
549 for (value_type, name) in cases {
550 assert_eq!(value_type.as_str(), name);
551 assert_eq!(value_type.to_string(), name);
552 assert_eq!(ConfigValueType::from_str_name(name), Some(value_type));
553 }
554 assert_eq!(ConfigValueType::from_str_name("unknown"), None);
555 }
556
557 #[test]
558 fn source_and_visibility_round_trip_storage_names() {
559 assert_eq!(
560 ConfigSource::from_str_name("system"),
561 Some(ConfigSource::System)
562 );
563 assert_eq!(
564 ConfigSource::from_str_name("custom"),
565 Some(ConfigSource::Custom)
566 );
567 assert_eq!(ConfigSource::from_str_name("other"), None);
568
569 assert_eq!(
570 ConfigVisibility::from_str_name("private"),
571 Some(ConfigVisibility::Private)
572 );
573 assert_eq!(
574 ConfigVisibility::from_str_name("public"),
575 Some(ConfigVisibility::Public)
576 );
577 assert_eq!(
578 ConfigVisibility::from_str_name("authenticated"),
579 Some(ConfigVisibility::Authenticated)
580 );
581 assert_eq!(ConfigVisibility::from_str_name("other"), None);
582 assert!(ConfigVisibility::Public.visible_to_public());
583 assert!(ConfigVisibility::Authenticated.visible_to_authenticated());
584 }
585
586 #[test]
587 fn config_value_converts_storage_arrays_and_scalars() {
588 assert_eq!(
589 ConfigValue::from_storage(ConfigValueType::String, "hello".to_string()).unwrap(),
590 ConfigValue::String("hello".to_string())
591 );
592 assert_eq!(
593 ConfigValue::from_storage(ConfigValueType::StringArray, r#"["a","b"]"#.to_string(),)
594 .unwrap(),
595 ConfigValue::StringArray(vec!["a".to_string(), "b".to_string()])
596 );
597
598 let array = ConfigValue::StringArray(vec!["a".to_string(), "b".to_string()]);
599 assert_eq!(
600 array
601 .to_storage_for_type(ConfigValueType::StringEnumSet)
602 .unwrap(),
603 r#"["a","b"]"#
604 );
605 assert!(array.to_storage_for_type(ConfigValueType::String).is_err());
606 }
607
608 #[test]
609 fn config_value_presentation_redacts_and_falls_back_lossily() {
610 let mut saw_error = false;
611 let value = present_config_value(
612 ConfigValueType::StringArray,
613 "not json".to_string(),
614 false,
615 |_| saw_error = true,
616 );
617 assert!(saw_error);
618 assert_eq!(value, ConfigValue::StringArray(Vec::new()));
619
620 let value = present_config_value(
621 ConfigValueType::StringArray,
622 r#"["secret"]"#.to_string(),
623 true,
624 |_| unreachable!("redacted values should not parse storage"),
625 );
626 assert_eq!(
627 value,
628 ConfigValue::String(ConfigValue::REDACTED.to_string())
629 );
630 }
631
632 #[test]
633 fn config_value_audit_string_redacts_and_serializes_lossily() {
634 let audit = config_value_audit_string(
635 ConfigValueType::StringArray,
636 r#"["b","a"]"#.to_string(),
637 false,
638 |_| unreachable!("valid storage should not report errors"),
639 );
640 assert_eq!(audit, r#"["b","a"]"#);
641
642 let audit =
643 config_value_audit_string(ConfigValueType::String, "secret".to_string(), true, |_| {
644 unreachable!("redacted values should not parse storage")
645 });
646 assert_eq!(audit, ConfigValue::REDACTED);
647
648 let mut saw_error = false;
649 let audit = config_value_audit_string(
650 ConfigValueType::StringArray,
651 "not json".to_string(),
652 false,
653 |_| saw_error = true,
654 );
655 assert!(saw_error);
656 assert_eq!(audit, "[]");
657 }
658
659 #[test]
660 fn storage_value_validation_enforces_structural_types() {
661 assert!(validate_storage_value(ConfigValueType::Boolean, "true").is_ok());
662 assert!(validate_storage_value(ConfigValueType::Boolean, "yes").is_err());
663
664 assert!(validate_storage_value(ConfigValueType::Number, "1.5").is_ok());
665 assert!(validate_storage_value(ConfigValueType::Number, "abc").is_err());
666
667 assert!(validate_storage_value(ConfigValueType::Number, "NaN").is_err());
670 assert!(validate_storage_value(ConfigValueType::Number, "inf").is_err());
671 assert!(validate_storage_value(ConfigValueType::Number, "-inf").is_err());
672 assert!(validate_storage_value(ConfigValueType::Number, "1e308").is_ok());
673
674 assert!(validate_storage_value(ConfigValueType::StringArray, r#"["a"]"#).is_ok());
675 assert!(validate_storage_value(ConfigValueType::StringArray, r#""a""#).is_err());
676 assert!(validate_storage_value(ConfigValueType::StringEnumSet, r#"["a"]"#).is_ok());
677 assert!(validate_storage_value(ConfigValueType::StringEnumSet, r#""a""#).is_err());
678
679 assert!(validate_storage_value(ConfigValueType::String, "anything").is_ok());
680 assert!(validate_storage_value(ConfigValueType::StringEnum, "anything").is_ok());
681 assert!(validate_storage_value(ConfigValueType::Multiline, "line\nline").is_ok());
682 }
683
684 #[test]
685 fn single_string_enum_selection_accepts_scalar_and_legacy_single_array() {
686 let parse = |value: &str| match value {
687 "fast" | "quality" => Some(value.to_string()),
688 _ => None,
689 };
690
691 assert_eq!(
692 parse_single_string_enum_selection(
693 " fast ",
694 "preview_profile",
695 "fast or quality",
696 parse
697 )
698 .unwrap(),
699 "fast"
700 );
701 assert_eq!(
702 parse_single_string_enum_selection(
703 r#"["quality"]"#,
704 "preview_profile",
705 "fast or quality",
706 parse,
707 )
708 .unwrap(),
709 "quality"
710 );
711 }
712
713 #[test]
714 fn single_string_enum_selection_rejects_invalid_legacy_arrays_and_values() {
715 let parse = |value: &str| (value == "fast").then_some(value.to_string());
716
717 assert!(
718 parse_single_string_enum_selection(r#"[]"#, "preview_profile", "fast", parse).is_err()
719 );
720 assert!(
721 parse_single_string_enum_selection(
722 r#"["fast","quality"]"#,
723 "preview_profile",
724 "fast",
725 parse,
726 )
727 .is_err()
728 );
729 assert!(
730 parse_single_string_enum_selection(r#"["unknown"]"#, "preview_profile", "fast", parse,)
731 .is_err()
732 );
733 assert!(
734 parse_single_string_enum_selection("unknown", "preview_profile", "fast", parse)
735 .is_err()
736 );
737 }
738
739 #[test]
740 fn string_array_config_value_parses_json_string_arrays() {
741 assert_eq!(
742 parse_string_array_config_value(r#"["a","b"]"#, "domains").unwrap(),
743 vec!["a".to_string(), "b".to_string()]
744 );
745 assert!(parse_string_array_config_value(r#""a""#, "domains").is_err());
746 assert!(parse_string_array_config_value(r#"[1]"#, "domains").is_err());
747 }
748
749 #[test]
750 fn string_enum_set_selection_rejects_unknown_and_duplicate_values() {
751 assert_eq!(
752 parse_string_enum_set_selection(
753 r#"["quality","fast"]"#,
754 "preview_profiles",
755 "preview profile",
756 parse_test_enum,
757 )
758 .unwrap(),
759 vec![TestEnum::Quality, TestEnum::Fast]
760 );
761 assert!(
762 parse_string_enum_set_selection(
763 r#"["fast","unknown"]"#,
764 "preview_profiles",
765 "preview profile",
766 parse_test_enum,
767 )
768 .is_err()
769 );
770 assert!(
771 parse_string_enum_set_selection(
772 r#"["fast","fast"]"#,
773 "preview_profiles",
774 "preview profile",
775 parse_test_enum,
776 )
777 .is_err()
778 );
779 }
780
781 #[test]
782 fn string_enum_set_selection_normalizes_to_authoritative_order() {
783 assert_eq!(
784 normalize_string_enum_set_selection(
785 r#"["quality","fast"]"#,
786 "preview_profiles",
787 "preview profile",
788 TEST_ENUMS,
789 parse_test_enum,
790 display_test_enum,
791 )
792 .unwrap(),
793 vec!["fast", "quality"]
794 );
795 assert_eq!(
796 normalize_string_enum_set_selection(
797 r#"[]"#,
798 "preview_profiles",
799 "preview profile",
800 TEST_ENUMS,
801 parse_test_enum,
802 display_test_enum,
803 )
804 .unwrap(),
805 Vec::<&'static str>::new()
806 );
807 }
808}