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    #[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    /// Parses a canonical `snake_case` storage name.
64    #[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    /// Returns whether this type stores a multi-line string.
79    #[must_use]
80    pub const fn is_multiline(self) -> bool {
81        matches!(self, Self::Multiline)
82    }
83
84    /// Returns whether this type stores a JSON string array.
85    #[must_use]
86    pub const fn is_string_array(self) -> bool {
87        matches!(self, Self::StringArray)
88    }
89
90    /// Returns whether this type stores a JSON string enum set.
91    #[must_use]
92    pub const fn is_string_enum_set(self) -> bool {
93        matches!(self, Self::StringEnumSet)
94    }
95
96    /// Returns whether this type stores list-like strings.
97    #[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/// Origin of a stored configuration value.
110#[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    /// Value is defined by the product registry.
120    #[default]
121    #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "system"))]
122    System,
123    /// Value is user-defined and not backed by a product registry entry.
124    #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "custom"))]
125    Custom,
126}
127
128impl ConfigSource {
129    /// Returns the canonical `snake_case` storage name.
130    #[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    /// Parses a canonical `snake_case` storage name.
139    #[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/// Consumer visibility for a stored configuration value.
156#[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    /// Only backend code and privileged APIs may see the value.
166    #[default]
167    #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "private"))]
168    Private,
169    /// Anonymous clients may see the value.
170    #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "public"))]
171    Public,
172    /// Authenticated clients may see the value.
173    #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "authenticated"))]
174    Authenticated,
175}
176
177impl ConfigVisibility {
178    /// Returns the canonical `snake_case` storage name.
179    #[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    /// Parses a canonical `snake_case` storage name.
189    #[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    /// Returns whether anonymous clients may see this value.
200    #[must_use]
201    pub const fn visible_to_public(self) -> bool {
202        matches!(self, Self::Public)
203    }
204
205    /// Returns whether authenticated clients may see this value.
206    #[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/// API-facing configuration value.
219#[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    /// Scalar string value.
224    String(String),
225    /// JSON string-list value.
226    StringArray(Vec<String>),
227}
228
229impl ConfigValue {
230    /// Redacted value used when presenting sensitive configuration through APIs or audit logs.
231    pub const REDACTED: &'static str = "***REDACTED***";
232
233    /// Converts a storage string into an API-facing value for `value_type`.
234    ///
235    /// # Errors
236    ///
237    /// Returns [`ConfigError`] when the stored string does not match the declared value type.
238    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    /// Converts a storage string into an API-facing value and falls back to an empty value on
249    /// malformed stored data.
250    ///
251    /// Products should still validate writes strictly. This helper is for read/presentation paths
252    /// where a single bad database row should not break the entire admin config page.
253    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    /// Returns the canonical redacted API value.
269    #[must_use]
270    pub fn redacted() -> Self {
271        Self::String(Self::REDACTED.to_string())
272    }
273
274    /// Returns the empty API value appropriate for a declared value type.
275    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    /// Returns whether this value is logically empty.
284    #[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    /// Converts an API-facing value into a storage string for `value_type`.
293    ///
294    /// # Errors
295    ///
296    /// Returns [`ConfigError`] when the typed value does not match the requested storage type.
297    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    /// Converts the value into an audit-friendly string.
318    #[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
352/// Builds a configuration value for API presentation.
353///
354/// Sensitive values are redacted and malformed historical storage values fall back to an empty
355/// value for the declared type. Products should use this in API read/list paths instead of
356/// repeating redaction and lossy parsing logic in each service.
357pub 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
370/// Builds an audit-safe string from a stored configuration value.
371///
372/// Sensitive values are always represented by [`ConfigValue::REDACTED`]. Non-sensitive values use
373/// the same lossy read path as API presentation so audit recording does not fail because of one
374/// malformed historical row.
375pub 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
388/// Validates a storage string against a declared value type.
389///
390/// This performs only product-neutral structural checks. Domain-specific rules,
391/// such as enum membership or cross-field constraints, belong to registry
392/// normalizers and dependency validators.
393///
394/// # Errors
395///
396/// Returns [`ConfigError`] when the stored value violates its declared type contract.
397pub 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            // f64 parsing accepts NaN/inf literals; storing them would propagate
409            // non-finite values into every reader's arithmetic.
410            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
424/// Parses a JSON array of strings stored in a configuration value.
425///
426/// Product crates can use this before applying domain-specific normalization
427/// such as URL canonicalization, domain lower-casing, allow-list filtering, or
428/// duplicate removal. The `key` is only used to produce a precise validation
429/// error.
430///
431/// # Errors
432///
433/// Returns [`ConfigError`] when the stored string array is malformed JSON.
434pub 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
440/// Parses a single string enum value with legacy single-item array compatibility.
441///
442/// New `string_enum` config values should be stored as scalar strings. Some older Aster
443/// deployments stored single-select values as a JSON array with exactly one string because the UI
444/// previously treated them like enum sets. This helper keeps that migration-compatible shape in one
445/// place while product crates still own the concrete enum and accepted values.
446///
447/// # Errors
448///
449/// Returns [`ConfigError`] when selection count or a selected value is invalid.
450pub 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
478/// Parses a string enum set from a JSON array of strings.
479///
480/// Product crates still own the concrete enum, canonical names, allowed values,
481/// and default set. Forge only handles the shared storage shape and duplicate
482/// detection so every service reports consistent malformed enum-set config.
483///
484/// # Errors
485///
486/// Returns [`ConfigError`] when the enum set is malformed, duplicated, or unknown.
487pub 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
516/// Parses and normalizes a string enum set into authoritative order.
517///
518/// This is useful for `string_enum_set` config values whose storage order should
519/// stay stable regardless of the order provided by an API request. The returned
520/// values are the canonical storage strings from `display`.
521///
522/// # Errors
523///
524/// Returns [`ConfigError`] when the selected enum set cannot be normalized.
525pub 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        // f64 parsing accepts non-finite literals, but NaN/inf stored as config
711        // would propagate into every reader's arithmetic.
712        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}