greentic-component 0.4.75

High-level component loader and store for Greentic components
Documentation
#![cfg(feature = "cli")]

use once_cell::sync::Lazy;
use regex::Regex;
use serde_json::{Value as JsonValue, json};

use super::validate::ValidationError;

static CONFIG_FIELD_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r"^[a-z][a-z0-9_]*$").expect("valid config field regex"));

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ConfigSchemaInput {
    pub fields: Vec<ConfigSchemaField>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigSchemaField {
    pub name: String,
    pub field_type: ConfigSchemaFieldType,
    pub required: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigSchemaFieldType {
    String,
    Bool,
    Integer,
    Number,
}

impl ConfigSchemaInput {
    pub fn manifest_schema(&self) -> JsonValue {
        let mut properties = serde_json::Map::new();
        let mut required = Vec::new();
        for field in &self.fields {
            properties.insert(field.name.clone(), field.field_type.json_schema());
            if field.required {
                required.push(JsonValue::String(field.name.clone()));
            }
        }

        json!({
            "type": "object",
            "properties": properties,
            "required": required,
            "additionalProperties": false
        })
    }

    pub fn component_schema_file(&self, component_name: &str) -> JsonValue {
        let mut schema = self.manifest_schema();
        if let Some(obj) = schema.as_object_mut() {
            obj.insert(
                "$schema".to_string(),
                JsonValue::String("https://json-schema.org/draft/2020-12/schema".to_string()),
            );
            obj.insert(
                "title".to_string(),
                JsonValue::String(format!("{component_name} component configuration")),
            );
        }
        schema
    }

    pub fn rust_schema_ir(&self) -> String {
        if self.fields.is_empty() {
            return r#"SchemaIr::Object {
        properties: BTreeMap::new(),
        required: Vec::new(),
        additional: AdditionalProperties::Forbid,
    }"#
            .to_string();
        }

        let properties = self
            .fields
            .iter()
            .map(|field| {
                format!(
                    "(\"{}\".to_string(), {})",
                    field.name,
                    field.field_type.rust_schema_ir()
                )
            })
            .collect::<Vec<_>>()
            .join(",\n            ");
        let required = self
            .fields
            .iter()
            .filter(|field| field.required)
            .map(|field| format!("\"{}\".to_string()", field.name))
            .collect::<Vec<_>>();
        let required_expr = if required.is_empty() {
            "Vec::new()".to_string()
        } else {
            format!("vec![{}]", required.join(", "))
        };

        format!(
            r#"SchemaIr::Object {{
        properties: BTreeMap::from([
            {properties}
        ]),
        required: {required_expr},
        additional: AdditionalProperties::Forbid,
    }}"#
        )
    }
}

impl ConfigSchemaFieldType {
    fn json_schema(self) -> JsonValue {
        match self {
            Self::String => json!({ "type": "string" }),
            Self::Bool => json!({ "type": "boolean" }),
            Self::Integer => json!({ "type": "integer" }),
            Self::Number => json!({ "type": "number" }),
        }
    }

    fn rust_schema_ir(self) -> &'static str {
        match self {
            Self::String => {
                "SchemaIr::String { min_len: Some(0), max_len: None, regex: None, format: None }"
            }
            Self::Bool => "SchemaIr::Bool",
            Self::Integer => "SchemaIr::Int { min: None, max: None }",
            Self::Number => "SchemaIr::Float { min: None, max: None }",
        }
    }
}

pub fn parse_config_field(value: &str) -> Result<ConfigSchemaField, ValidationError> {
    let mut parts = value.split(':').map(str::trim);
    let name = parts.next().unwrap_or_default();
    let field_type = parts.next().unwrap_or_default();
    let required = parts.next().unwrap_or("optional");
    if name.is_empty() || field_type.is_empty() || parts.next().is_some() {
        return Err(ValidationError::InvalidConfigField(value.to_string()));
    }
    if !CONFIG_FIELD_RE.is_match(name) {
        return Err(ValidationError::InvalidConfigFieldName(name.to_string()));
    }
    let field_type = parse_config_field_type(field_type)?;
    let required = match required {
        "required" => true,
        "optional" => false,
        other => {
            return Err(ValidationError::InvalidConfigField(
                value.replace(required, other),
            ));
        }
    };
    Ok(ConfigSchemaField {
        name: name.to_string(),
        field_type,
        required,
    })
}

fn parse_config_field_type(value: &str) -> Result<ConfigSchemaFieldType, ValidationError> {
    match value {
        "string" => Ok(ConfigSchemaFieldType::String),
        "bool" | "boolean" => Ok(ConfigSchemaFieldType::Bool),
        "int" | "integer" => Ok(ConfigSchemaFieldType::Integer),
        "number" | "float" => Ok(ConfigSchemaFieldType::Number),
        other => Err(ValidationError::InvalidConfigFieldType(other.to_string())),
    }
}