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 #[must_use]
51 pub const fn as_str(self) -> &'static str {
52 match self {
53 Self::String => "string",
54 Self::Multiline => "multiline",
55 Self::StringArray => "string_array",
56 Self::StringEnum => "string_enum",
57 Self::StringEnumSet => "string_enum_set",
58 Self::Number => "number",
59 Self::Boolean => "boolean",
60 }
61 }
62
63 #[must_use]
65 pub fn from_str_name(value: &str) -> Option<Self> {
66 match value {
67 "string" => Some(Self::String),
68 "multiline" => Some(Self::Multiline),
69 "string_array" => Some(Self::StringArray),
70 "string_enum" => Some(Self::StringEnum),
71 "string_enum_set" => Some(Self::StringEnumSet),
72 "number" => Some(Self::Number),
73 "boolean" => Some(Self::Boolean),
74 _ => None,
75 }
76 }
77
78 #[must_use]
80 pub const fn is_multiline(self) -> bool {
81 matches!(self, Self::Multiline)
82 }
83
84 #[must_use]
86 pub const fn is_string_array(self) -> bool {
87 matches!(self, Self::StringArray)
88 }
89
90 #[must_use]
92 pub const fn is_string_enum_set(self) -> bool {
93 matches!(self, Self::StringEnumSet)
94 }
95
96 #[must_use]
98 pub const fn is_string_list(self) -> bool {
99 matches!(self, Self::StringArray | Self::StringEnumSet)
100 }
101}
102
103impl fmt::Display for ConfigValueType {
104 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
105 formatter.write_str(self.as_str())
106 }
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
111#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(utoipa::ToSchema))]
112#[cfg_attr(feature = "sea-orm", derive(EnumIter, DeriveActiveEnum))]
113#[cfg_attr(
114 feature = "sea-orm",
115 sea_orm(rs_type = "String", db_type = "String(StringLen::N(16))")
116)]
117#[serde(rename_all = "snake_case")]
118pub enum ConfigSource {
119 #[default]
121 #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "system"))]
122 System,
123 #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "custom"))]
125 Custom,
126}
127
128impl ConfigSource {
129 #[must_use]
131 pub const fn as_str(self) -> &'static str {
132 match self {
133 Self::System => "system",
134 Self::Custom => "custom",
135 }
136 }
137
138 #[must_use]
140 pub fn from_str_name(value: &str) -> Option<Self> {
141 match value {
142 "system" => Some(Self::System),
143 "custom" => Some(Self::Custom),
144 _ => None,
145 }
146 }
147}
148
149impl fmt::Display for ConfigSource {
150 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
151 formatter.write_str(self.as_str())
152 }
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
157#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(utoipa::ToSchema))]
158#[cfg_attr(feature = "sea-orm", derive(EnumIter, DeriveActiveEnum))]
159#[cfg_attr(
160 feature = "sea-orm",
161 sea_orm(rs_type = "String", db_type = "String(StringLen::N(16))")
162)]
163#[serde(rename_all = "snake_case")]
164pub enum ConfigVisibility {
165 #[default]
167 #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "private"))]
168 Private,
169 #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "public"))]
171 Public,
172 #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "authenticated"))]
174 Authenticated,
175}
176
177impl ConfigVisibility {
178 #[must_use]
180 pub const fn as_str(self) -> &'static str {
181 match self {
182 Self::Private => "private",
183 Self::Public => "public",
184 Self::Authenticated => "authenticated",
185 }
186 }
187
188 #[must_use]
190 pub fn from_str_name(value: &str) -> Option<Self> {
191 match value {
192 "private" => Some(Self::Private),
193 "public" => Some(Self::Public),
194 "authenticated" => Some(Self::Authenticated),
195 _ => None,
196 }
197 }
198
199 #[must_use]
201 pub const fn visible_to_public(self) -> bool {
202 matches!(self, Self::Public)
203 }
204
205 #[must_use]
207 pub const fn visible_to_authenticated(self) -> bool {
208 matches!(self, Self::Public | Self::Authenticated)
209 }
210}
211
212impl fmt::Display for ConfigVisibility {
213 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
214 formatter.write_str(self.as_str())
215 }
216}
217
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(untagged)]
221#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(utoipa::ToSchema))]
222pub enum ConfigValue {
223 String(String),
225 StringArray(Vec<String>),
227}
228
229impl ConfigValue {
230 pub const REDACTED: &'static str = "***REDACTED***";
232
233 pub fn from_storage(value_type: impl Into<ConfigValueType>, value: String) -> Result<Self> {
239 let value_type = value_type.into();
240 if !value_type.is_string_list() {
241 return Ok(Self::String(value));
242 }
243
244 let items = serde_json::from_str::<Vec<String>>(&value)?;
245 Ok(Self::StringArray(items))
246 }
247
248 pub fn from_storage_lossy(
254 value_type: impl Into<ConfigValueType>,
255 value: String,
256 on_invalid: impl FnOnce(&ConfigCoreError),
257 ) -> Self {
258 let value_type = value_type.into();
259 match Self::from_storage(value_type, value) {
260 Ok(value) => value,
261 Err(error) => {
262 on_invalid(&error);
263 Self::empty_for_type(value_type)
264 }
265 }
266 }
267
268 #[must_use]
270 pub fn redacted() -> Self {
271 Self::String(Self::REDACTED.to_string())
272 }
273
274 pub fn empty_for_type(value_type: impl Into<ConfigValueType>) -> Self {
276 if value_type.into().is_string_list() {
277 Self::StringArray(Vec::new())
278 } else {
279 Self::String(String::new())
280 }
281 }
282
283 #[must_use]
285 pub fn is_empty(&self) -> bool {
286 match self {
287 Self::String(value) => value.trim().is_empty(),
288 Self::StringArray(values) => values.is_empty(),
289 }
290 }
291
292 pub fn to_storage_for_type(&self, value_type: impl Into<ConfigValueType>) -> Result<String> {
298 let value_type = value_type.into();
299 match (value_type, self) {
300 (
301 ConfigValueType::StringArray | ConfigValueType::StringEnumSet,
302 Self::StringArray(values),
303 ) => serde_json::to_string(values).map_err(Into::into),
304 (ConfigValueType::StringArray | ConfigValueType::StringEnumSet, Self::String(_)) => {
305 Err(ConfigCoreError::invalid_value(format!(
306 "{} config value must be a JSON array",
307 value_type.as_str()
308 )))
309 }
310 (_, Self::String(value)) => Ok(value.clone()),
311 (_, Self::StringArray(_)) => Err(ConfigCoreError::invalid_value(
312 "string array values are only supported for string_array and string_enum_set config keys",
313 )),
314 }
315 }
316
317 #[must_use]
319 pub fn to_audit_string(&self) -> String {
320 match self {
321 Self::String(value) => value.clone(),
322 Self::StringArray(values) => serde_json::to_string(values)
323 .unwrap_or_else(|_| "<invalid string list value>".to_string()),
324 }
325 }
326}
327
328impl From<&str> for ConfigValue {
329 fn from(value: &str) -> Self {
330 Self::String(value.to_string())
331 }
332}
333
334impl From<&String> for ConfigValue {
335 fn from(value: &String) -> Self {
336 Self::String(value.clone())
337 }
338}
339
340impl From<String> for ConfigValue {
341 fn from(value: String) -> Self {
342 Self::String(value)
343 }
344}
345
346impl From<Vec<String>> for ConfigValue {
347 fn from(value: Vec<String>) -> Self {
348 Self::StringArray(value)
349 }
350}
351
352pub fn present_config_value(
358 value_type: impl Into<ConfigValueType>,
359 value: String,
360 is_sensitive: bool,
361 on_invalid: impl FnOnce(&ConfigCoreError),
362) -> ConfigValue {
363 if is_sensitive {
364 ConfigValue::redacted()
365 } else {
366 ConfigValue::from_storage_lossy(value_type, value, on_invalid)
367 }
368}
369
370pub fn config_value_audit_string(
376 value_type: impl Into<ConfigValueType>,
377 value: String,
378 is_sensitive: bool,
379 on_invalid: impl FnOnce(&ConfigCoreError),
380) -> String {
381 if is_sensitive {
382 ConfigValue::REDACTED.to_string()
383 } else {
384 ConfigValue::from_storage_lossy(value_type, value, on_invalid).to_audit_string()
385 }
386}
387
388pub fn validate_storage_value(value_type: ConfigValueType, value: &str) -> Result<()> {
398 let trimmed = value.trim();
399 match value_type {
400 ConfigValueType::Boolean => {
401 if trimmed != "true" && trimmed != "false" {
402 return Err(ConfigCoreError::invalid_value(
403 "boolean config must be 'true' or 'false'",
404 ));
405 }
406 }
407 ConfigValueType::Number => {
408 if !trimmed.parse::<f64>().is_ok_and(f64::is_finite) {
411 return Err(ConfigCoreError::invalid_value(
412 "number config must be a valid finite number",
413 ));
414 }
415 }
416 ConfigValueType::StringArray | ConfigValueType::StringEnumSet => {
417 parse_string_array_config_value(trimmed, value_type.as_str())?;
418 }
419 ConfigValueType::String | ConfigValueType::StringEnum | ConfigValueType::Multiline => {}
420 }
421 Ok(())
422}
423
424pub fn parse_string_array_config_value(value: &str, key: &str) -> Result<Vec<String>> {
435 serde_json::from_str::<Vec<String>>(value.trim()).map_err(|error| {
436 ConfigCoreError::invalid_value(format!("{key} must be a JSON array of strings: {error}"))
437 })
438}
439
440pub fn parse_single_string_enum_selection<T>(
451 value: &str,
452 key: &str,
453 allowed_values: &str,
454 parse: impl Fn(&str) -> Option<T>,
455) -> Result<T> {
456 let trimmed = value.trim();
457 let selected = if trimmed.starts_with('[') {
458 let items = serde_json::from_str::<Vec<String>>(trimmed).map_err(|error| {
459 ConfigCoreError::invalid_value(format!(
460 "{key} must be a string enum or a legacy JSON array with exactly one value: {error}",
461 ))
462 })?;
463 let [item] = items.as_slice() else {
464 return Err(ConfigCoreError::invalid_value(format!(
465 "{key} must select exactly one value",
466 )));
467 };
468 item.clone()
469 } else {
470 trimmed.to_string()
471 };
472
473 parse(&selected).ok_or_else(|| {
474 ConfigCoreError::invalid_value(format!("{key} must be one of: {allowed_values}"))
475 })
476}
477
478pub fn parse_string_enum_set_selection<T>(
488 value: &str,
489 key: &str,
490 item_name: &str,
491 parse: impl Fn(&str) -> Option<T>,
492) -> Result<Vec<T>>
493where
494 T: Copy + Eq,
495{
496 let values = parse_string_array_config_value(value, key)?;
497 let mut selected = Vec::with_capacity(values.len());
498
499 for raw in values {
500 let Some(item) = parse(&raw) else {
501 return Err(ConfigCoreError::invalid_value(format!(
502 "unknown {item_name} '{raw}' in {key}"
503 )));
504 };
505 if selected.contains(&item) {
506 return Err(ConfigCoreError::invalid_value(format!(
507 "duplicate {item_name} '{raw}' in {key}"
508 )));
509 }
510 selected.push(item);
511 }
512
513 Ok(selected)
514}
515
516pub fn normalize_string_enum_set_selection<T>(
526 value: &str,
527 key: &str,
528 item_name: &str,
529 authoritative_order: &[T],
530 parse: impl Fn(&str) -> Option<T>,
531 display: impl Fn(T) -> &'static str,
532) -> Result<Vec<&'static str>>
533where
534 T: Copy + Eq,
535{
536 let selected = parse_string_enum_set_selection(value, key, item_name, parse)?;
537 Ok(authoritative_order
538 .iter()
539 .copied()
540 .filter(|item| selected.contains(item))
541 .map(display)
542 .collect())
543}
544
545#[cfg(test)]
546mod tests {
547 use super::{
548 ConfigSource, ConfigValue, ConfigValueType, ConfigVisibility, config_value_audit_string,
549 normalize_string_enum_set_selection, parse_single_string_enum_selection,
550 parse_string_array_config_value, parse_string_enum_set_selection, present_config_value,
551 validate_storage_value,
552 };
553
554 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
555 enum TestEnum {
556 Fast,
557 Balanced,
558 Quality,
559 }
560
561 const TEST_ENUMS: &[TestEnum] = &[TestEnum::Fast, TestEnum::Balanced, TestEnum::Quality];
562
563 fn parse_test_enum(value: &str) -> Option<TestEnum> {
564 match value {
565 "fast" => Some(TestEnum::Fast),
566 "balanced" => Some(TestEnum::Balanced),
567 "quality" => Some(TestEnum::Quality),
568 _ => None,
569 }
570 }
571
572 fn display_test_enum(value: TestEnum) -> &'static str {
573 match value {
574 TestEnum::Fast => "fast",
575 TestEnum::Balanced => "balanced",
576 TestEnum::Quality => "quality",
577 }
578 }
579
580 #[test]
581 fn value_type_round_trips_storage_names() {
582 let cases = [
583 (ConfigValueType::String, "string"),
584 (ConfigValueType::Multiline, "multiline"),
585 (ConfigValueType::StringArray, "string_array"),
586 (ConfigValueType::StringEnum, "string_enum"),
587 (ConfigValueType::StringEnumSet, "string_enum_set"),
588 (ConfigValueType::Number, "number"),
589 (ConfigValueType::Boolean, "boolean"),
590 ];
591
592 for (value_type, name) in cases {
593 assert_eq!(value_type.as_str(), name);
594 assert_eq!(value_type.to_string(), name);
595 assert_eq!(ConfigValueType::from_str_name(name), Some(value_type));
596 }
597 assert_eq!(ConfigValueType::from_str_name("unknown"), None);
598 }
599
600 #[test]
601 fn source_and_visibility_round_trip_storage_names() {
602 assert_eq!(
603 ConfigSource::from_str_name("system"),
604 Some(ConfigSource::System)
605 );
606 assert_eq!(
607 ConfigSource::from_str_name("custom"),
608 Some(ConfigSource::Custom)
609 );
610 assert_eq!(ConfigSource::from_str_name("other"), None);
611
612 assert_eq!(
613 ConfigVisibility::from_str_name("private"),
614 Some(ConfigVisibility::Private)
615 );
616 assert_eq!(
617 ConfigVisibility::from_str_name("public"),
618 Some(ConfigVisibility::Public)
619 );
620 assert_eq!(
621 ConfigVisibility::from_str_name("authenticated"),
622 Some(ConfigVisibility::Authenticated)
623 );
624 assert_eq!(ConfigVisibility::from_str_name("other"), None);
625 assert!(ConfigVisibility::Public.visible_to_public());
626 assert!(ConfigVisibility::Authenticated.visible_to_authenticated());
627 }
628
629 #[test]
630 fn config_value_converts_storage_arrays_and_scalars() {
631 assert_eq!(
632 ConfigValue::from_storage(ConfigValueType::String, "hello".to_string()).unwrap(),
633 ConfigValue::String("hello".to_string())
634 );
635 assert_eq!(
636 ConfigValue::from_storage(ConfigValueType::StringArray, r#"["a","b"]"#.to_string(),)
637 .unwrap(),
638 ConfigValue::StringArray(vec!["a".to_string(), "b".to_string()])
639 );
640
641 let array = ConfigValue::StringArray(vec!["a".to_string(), "b".to_string()]);
642 assert_eq!(
643 array
644 .to_storage_for_type(ConfigValueType::StringEnumSet)
645 .unwrap(),
646 r#"["a","b"]"#
647 );
648 assert!(array.to_storage_for_type(ConfigValueType::String).is_err());
649 }
650
651 #[test]
652 fn config_value_presentation_redacts_and_falls_back_lossily() {
653 let mut saw_error = false;
654 let value = present_config_value(
655 ConfigValueType::StringArray,
656 "not json".to_string(),
657 false,
658 |_| saw_error = true,
659 );
660 assert!(saw_error);
661 assert_eq!(value, ConfigValue::StringArray(Vec::new()));
662
663 let value = present_config_value(
664 ConfigValueType::StringArray,
665 r#"["secret"]"#.to_string(),
666 true,
667 |_| unreachable!("redacted values should not parse storage"),
668 );
669 assert_eq!(
670 value,
671 ConfigValue::String(ConfigValue::REDACTED.to_string())
672 );
673 }
674
675 #[test]
676 fn config_value_audit_string_redacts_and_serializes_lossily() {
677 let audit = config_value_audit_string(
678 ConfigValueType::StringArray,
679 r#"["b","a"]"#.to_string(),
680 false,
681 |_| unreachable!("valid storage should not report errors"),
682 );
683 assert_eq!(audit, r#"["b","a"]"#);
684
685 let audit =
686 config_value_audit_string(ConfigValueType::String, "secret".to_string(), true, |_| {
687 unreachable!("redacted values should not parse storage")
688 });
689 assert_eq!(audit, ConfigValue::REDACTED);
690
691 let mut saw_error = false;
692 let audit = config_value_audit_string(
693 ConfigValueType::StringArray,
694 "not json".to_string(),
695 false,
696 |_| saw_error = true,
697 );
698 assert!(saw_error);
699 assert_eq!(audit, "[]");
700 }
701
702 #[test]
703 fn storage_value_validation_enforces_structural_types() {
704 assert!(validate_storage_value(ConfigValueType::Boolean, "true").is_ok());
705 assert!(validate_storage_value(ConfigValueType::Boolean, "yes").is_err());
706
707 assert!(validate_storage_value(ConfigValueType::Number, "1.5").is_ok());
708 assert!(validate_storage_value(ConfigValueType::Number, "abc").is_err());
709
710 assert!(validate_storage_value(ConfigValueType::Number, "NaN").is_err());
713 assert!(validate_storage_value(ConfigValueType::Number, "inf").is_err());
714 assert!(validate_storage_value(ConfigValueType::Number, "-inf").is_err());
715 assert!(validate_storage_value(ConfigValueType::Number, "1e308").is_ok());
716
717 assert!(validate_storage_value(ConfigValueType::StringArray, r#"["a"]"#).is_ok());
718 assert!(validate_storage_value(ConfigValueType::StringArray, r#""a""#).is_err());
719 assert!(validate_storage_value(ConfigValueType::StringEnumSet, r#"["a"]"#).is_ok());
720 assert!(validate_storage_value(ConfigValueType::StringEnumSet, r#""a""#).is_err());
721
722 assert!(validate_storage_value(ConfigValueType::String, "anything").is_ok());
723 assert!(validate_storage_value(ConfigValueType::StringEnum, "anything").is_ok());
724 assert!(validate_storage_value(ConfigValueType::Multiline, "line\nline").is_ok());
725 }
726
727 #[test]
728 fn single_string_enum_selection_accepts_scalar_and_legacy_single_array() {
729 let parse = |value: &str| match value {
730 "fast" | "quality" => Some(value.to_string()),
731 _ => None,
732 };
733
734 assert_eq!(
735 parse_single_string_enum_selection(
736 " fast ",
737 "preview_profile",
738 "fast or quality",
739 parse
740 )
741 .unwrap(),
742 "fast"
743 );
744 assert_eq!(
745 parse_single_string_enum_selection(
746 r#"["quality"]"#,
747 "preview_profile",
748 "fast or quality",
749 parse,
750 )
751 .unwrap(),
752 "quality"
753 );
754 }
755
756 #[test]
757 fn single_string_enum_selection_rejects_invalid_legacy_arrays_and_values() {
758 let parse = |value: &str| (value == "fast").then_some(value.to_string());
759
760 assert!(
761 parse_single_string_enum_selection(r"[]", "preview_profile", "fast", parse).is_err()
762 );
763 assert!(
764 parse_single_string_enum_selection(
765 r#"["fast","quality"]"#,
766 "preview_profile",
767 "fast",
768 parse,
769 )
770 .is_err()
771 );
772 assert!(
773 parse_single_string_enum_selection(r#"["unknown"]"#, "preview_profile", "fast", parse,)
774 .is_err()
775 );
776 assert!(
777 parse_single_string_enum_selection("unknown", "preview_profile", "fast", parse)
778 .is_err()
779 );
780 }
781
782 #[test]
783 fn string_array_config_value_parses_json_string_arrays() {
784 assert_eq!(
785 parse_string_array_config_value(r#"["a","b"]"#, "domains").unwrap(),
786 vec!["a".to_string(), "b".to_string()]
787 );
788 assert!(parse_string_array_config_value(r#""a""#, "domains").is_err());
789 assert!(parse_string_array_config_value(r"[1]", "domains").is_err());
790 }
791
792 #[test]
793 fn string_enum_set_selection_rejects_unknown_and_duplicate_values() {
794 assert_eq!(
795 parse_string_enum_set_selection(
796 r#"["quality","fast"]"#,
797 "preview_profiles",
798 "preview profile",
799 parse_test_enum,
800 )
801 .unwrap(),
802 vec![TestEnum::Quality, TestEnum::Fast]
803 );
804 assert!(
805 parse_string_enum_set_selection(
806 r#"["fast","unknown"]"#,
807 "preview_profiles",
808 "preview profile",
809 parse_test_enum,
810 )
811 .is_err()
812 );
813 assert!(
814 parse_string_enum_set_selection(
815 r#"["fast","fast"]"#,
816 "preview_profiles",
817 "preview profile",
818 parse_test_enum,
819 )
820 .is_err()
821 );
822 }
823
824 #[test]
825 fn string_enum_set_selection_normalizes_to_authoritative_order() {
826 assert_eq!(
827 normalize_string_enum_set_selection(
828 r#"["quality","fast"]"#,
829 "preview_profiles",
830 "preview profile",
831 TEST_ENUMS,
832 parse_test_enum,
833 display_test_enum,
834 )
835 .unwrap(),
836 vec!["fast", "quality"]
837 );
838 assert_eq!(
839 normalize_string_enum_set_selection(
840 r"[]",
841 "preview_profiles",
842 "preview profile",
843 TEST_ENUMS,
844 parse_test_enum,
845 display_test_enum,
846 )
847 .unwrap(),
848 Vec::<&'static str>::new()
849 );
850 }
851}