Skip to main content

aster_forge_config/
value.rs

1//! Shared value metadata and API/storage value conversion.
2//!
3//! Aster services store runtime configuration as strings, with selected keys
4//! presented as JSON arrays for list-like values. This module keeps that
5//! conversion consistent while leaving product-specific validation and default
6//! generation to registries owned by each service.
7
8use serde::{Deserialize, Serialize};
9use std::fmt;
10
11use crate::{ConfigCoreError, Result};
12#[cfg(feature = "sea-orm")]
13use sea_orm::entity::prelude::*;
14
15/// Supported system configuration value types.
16#[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    /// A single-line string.
26    #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "string"))]
27    String,
28    /// A multi-line string.
29    #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "multiline"))]
30    Multiline,
31    /// A JSON array of strings.
32    #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "string_array"))]
33    StringArray,
34    /// One value selected from a known string enum.
35    #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "string_enum"))]
36    StringEnum,
37    /// A JSON array of values selected from a known string enum.
38    #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "string_enum_set"))]
39    StringEnumSet,
40    /// A numeric value stored as a string.
41    #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "number"))]
42    Number,
43    /// A boolean value stored as a string.
44    #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "boolean"))]
45    Boolean,
46}
47
48impl ConfigValueType {
49    /// Returns the canonical snake_case storage name.
50    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    /// Parses a canonical snake_case storage name.
63    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    /// Returns whether this type stores a multi-line string.
77    pub const fn is_multiline(self) -> bool {
78        matches!(self, Self::Multiline)
79    }
80
81    /// Returns whether this type stores a JSON string array.
82    pub const fn is_string_array(self) -> bool {
83        matches!(self, Self::StringArray)
84    }
85
86    /// Returns whether this type stores a JSON string enum set.
87    pub const fn is_string_enum_set(self) -> bool {
88        matches!(self, Self::StringEnumSet)
89    }
90
91    /// Returns whether this type stores list-like strings.
92    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/// Origin of a stored configuration value.
104#[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    /// Value is defined by the product registry.
114    #[default]
115    #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "system"))]
116    System,
117    /// Value is user-defined and not backed by a product registry entry.
118    #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "custom"))]
119    Custom,
120}
121
122impl ConfigSource {
123    /// Returns the canonical snake_case storage name.
124    pub const fn as_str(self) -> &'static str {
125        match self {
126            Self::System => "system",
127            Self::Custom => "custom",
128        }
129    }
130
131    /// Parses a canonical snake_case storage name.
132    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/// Consumer visibility for a stored configuration value.
148#[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    /// Only backend code and privileged APIs may see the value.
158    #[default]
159    #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "private"))]
160    Private,
161    /// Anonymous clients may see the value.
162    #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "public"))]
163    Public,
164    /// Authenticated clients may see the value.
165    #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "authenticated"))]
166    Authenticated,
167}
168
169impl ConfigVisibility {
170    /// Returns the canonical snake_case storage name.
171    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    /// Parses a canonical snake_case storage name.
180    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    /// Returns whether anonymous clients may see this value.
190    pub const fn visible_to_public(self) -> bool {
191        matches!(self, Self::Public)
192    }
193
194    /// Returns whether authenticated clients may see this value.
195    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/// API-facing configuration value.
207#[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    /// Scalar string value.
212    String(String),
213    /// JSON string-list value.
214    StringArray(Vec<String>),
215}
216
217impl ConfigValue {
218    /// Redacted value used when presenting sensitive configuration through APIs or audit logs.
219    pub const REDACTED: &'static str = "***REDACTED***";
220
221    /// Converts a storage string into an API-facing value for `value_type`.
222    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    /// Converts a storage string into an API-facing value and falls back to an empty value on
233    /// malformed stored data.
234    ///
235    /// Products should still validate writes strictly. This helper is for read/presentation paths
236    /// where a single bad database row should not break the entire admin config page.
237    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    /// Returns the canonical redacted API value.
253    pub fn redacted() -> Self {
254        Self::String(Self::REDACTED.to_string())
255    }
256
257    /// Returns the empty API value appropriate for a declared value type.
258    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    /// Returns whether this value is logically empty.
267    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    /// Converts an API-facing value into a storage string for `value_type`.
275    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    /// Converts the value into an audit-friendly string.
296    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
329/// Builds a configuration value for API presentation.
330///
331/// Sensitive values are redacted and malformed historical storage values fall back to an empty
332/// value for the declared type. Products should use this in API read/list paths instead of
333/// repeating redaction and lossy parsing logic in each service.
334pub 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
347/// Builds an audit-safe string from a stored configuration value.
348///
349/// Sensitive values are always represented by [`ConfigValue::REDACTED`]. Non-sensitive values use
350/// the same lossy read path as API presentation so audit recording does not fail because of one
351/// malformed historical row.
352pub 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
365/// Validates a storage string against a declared value type.
366///
367/// This performs only product-neutral structural checks. Domain-specific rules,
368/// such as enum membership or cross-field constraints, belong to registry
369/// normalizers and dependency validators.
370pub 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            // f64 parsing accepts NaN/inf literals; storing them would propagate
382            // non-finite values into every reader's arithmetic.
383            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
397/// Parses a JSON array of strings stored in a configuration value.
398///
399/// Product crates can use this before applying domain-specific normalization
400/// such as URL canonicalization, domain lower-casing, allow-list filtering, or
401/// duplicate removal. The `key` is only used to produce a precise validation
402/// error.
403pub 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
409/// Parses a single string enum value with legacy single-item array compatibility.
410///
411/// New `string_enum` config values should be stored as scalar strings. Some older Aster
412/// deployments stored single-select values as a JSON array with exactly one string because the UI
413/// previously treated them like enum sets. This helper keeps that migration-compatible shape in one
414/// place while product crates still own the concrete enum and accepted values.
415pub 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
443/// Parses a string enum set from a JSON array of strings.
444///
445/// Product crates still own the concrete enum, canonical names, allowed values,
446/// and default set. Forge only handles the shared storage shape and duplicate
447/// detection so every service reports consistent malformed enum-set config.
448pub 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
477/// Parses and normalizes a string enum set into authoritative order.
478///
479/// This is useful for `string_enum_set` config values whose storage order should
480/// stay stable regardless of the order provided by an API request. The returned
481/// values are the canonical storage strings from `display`.
482pub 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        // f64 parsing accepts non-finite literals, but NaN/inf stored as config
668        // would propagate into every reader's arithmetic.
669        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}