az-dict-spec 2026.5.16

Dictionary specification data model and validation helpers.
Documentation
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeSet;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DictEnumItem<T>
where
    T: Copy + 'static,
{
    pub code: &'static str,
    pub label: &'static str,
    pub description: &'static str,
    pub raw_value: T,
    pub meta_json: Option<&'static str>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DictionarySpec {
    pub code: String,
    pub name: String,
    pub description: Option<String>,
    pub scope: String,
    pub raw_value_kind: RawValueKind,
    #[serde(default)]
    pub open_enum: bool,
    pub unknown_variant: Option<String>,
    #[serde(default)]
    pub sort_index: i64,
    #[serde(default)]
    pub items: Vec<DictionaryItemSpec>,
}

impl DictionarySpec {
    pub fn from_json_str(input: &str) -> Result<Self, DictSpecError> {
        let spec = serde_json::from_str::<Self>(input)?;
        spec.validate()?;
        Ok(spec)
    }

    pub fn to_pretty_json_string(&self) -> Result<String, DictSpecError> {
        self.validate()?;
        Ok(serde_json::to_string_pretty(self)?)
    }

    pub fn validate(&self) -> Result<(), DictSpecError> {
        ensure_non_empty("code", &self.code)?;
        ensure_non_empty("name", &self.name)?;
        ensure_non_empty("scope", &self.scope)?;
        if self.open_enum {
            ensure_non_empty(
                "unknownVariant",
                self.unknown_variant.as_deref().unwrap_or("Other"),
            )?;
        }
        if self.items.is_empty() {
            return Err(DictSpecError::Validation(
                "items cannot be empty".to_string(),
            ));
        }

        let mut item_codes = BTreeSet::new();
        let mut int_values = BTreeSet::new();
        let mut text_values = BTreeSet::new();
        for item in &self.items {
            item.validate(self.raw_value_kind)?;
            if !item_codes.insert(item.code.clone()) {
                return Err(DictSpecError::Validation(format!(
                    "duplicate item code: {}",
                    item.code
                )));
            }
            match self.raw_value_kind {
                RawValueKind::Int => {
                    let value = item.raw_int_value.expect("validated raw_int_value");
                    if !int_values.insert(value) {
                        return Err(DictSpecError::Validation(format!(
                            "duplicate rawIntValue: {value}"
                        )));
                    }
                }
                RawValueKind::String => {
                    let value = item
                        .raw_text_value
                        .as_deref()
                        .expect("validated raw_text_value");
                    if !text_values.insert(value.to_string()) {
                        return Err(DictSpecError::Validation(format!(
                            "duplicate rawTextValue: {value}"
                        )));
                    }
                }
            }
        }

        Ok(())
    }

    pub fn normalized_unknown_variant(&self) -> &str {
        self.unknown_variant
            .as_deref()
            .filter(|value| !value.trim().is_empty())
            .unwrap_or("Other")
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DictionaryItemSpec {
    pub code: String,
    pub label: String,
    pub description: Option<String>,
    pub raw_int_value: Option<i64>,
    pub raw_text_value: Option<String>,
    #[serde(default)]
    pub sort_index: i64,
    #[serde(default = "default_true")]
    pub enabled: bool,
    pub meta: Option<Value>,
}

impl DictionaryItemSpec {
    pub fn validate(&self, raw_value_kind: RawValueKind) -> Result<(), DictSpecError> {
        ensure_non_empty("item.code", &self.code)?;
        ensure_non_empty("item.label", &self.label)?;
        match raw_value_kind {
            RawValueKind::Int => {
                if self.raw_int_value.is_none() || self.raw_text_value.is_some() {
                    return Err(DictSpecError::Validation(format!(
                        "item {} must define rawIntValue only",
                        self.code
                    )));
                }
            }
            RawValueKind::String => {
                if self.raw_int_value.is_some() || self.raw_text_value.is_none() {
                    return Err(DictSpecError::Validation(format!(
                        "item {} must define rawTextValue only",
                        self.code
                    )));
                }
                ensure_non_empty(
                    "item.rawTextValue",
                    self.raw_text_value.as_deref().unwrap_or_default(),
                )?;
            }
        }
        Ok(())
    }

    pub fn description_text(&self) -> &str {
        self.description.as_deref().unwrap_or("")
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RawValueKind {
    Int,
    String,
}

#[derive(Debug, thiserror::Error)]
pub enum DictSpecError {
    #[error("invalid dictionary spec: {0}")]
    Validation(String),
    #[error("invalid dictionary spec json: {0}")]
    Json(#[from] serde_json::Error),
}

fn default_true() -> bool {
    true
}

fn ensure_non_empty(field: &str, value: &str) -> Result<(), DictSpecError> {
    if value.trim().is_empty() {
        return Err(DictSpecError::Validation(format!(
            "{field} cannot be empty"
        )));
    }
    Ok(())
}