Skip to main content

greentic_component/scaffold/
config_schema.rs

1#![cfg(feature = "cli")]
2
3use once_cell::sync::Lazy;
4use regex::Regex;
5use serde_json::{Value as JsonValue, json};
6
7use super::validate::ValidationError;
8
9static CONFIG_FIELD_RE: Lazy<Regex> =
10    Lazy::new(|| Regex::new(r"^[a-z][a-z0-9_]*$").expect("valid config field regex"));
11
12#[derive(Debug, Clone, PartialEq, Eq, Default)]
13pub struct ConfigSchemaInput {
14    pub fields: Vec<ConfigSchemaField>,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct ConfigSchemaField {
19    pub name: String,
20    pub field_type: ConfigSchemaFieldType,
21    pub required: bool,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum ConfigSchemaFieldType {
26    String,
27    Bool,
28    Integer,
29    Number,
30}
31
32impl ConfigSchemaInput {
33    pub fn manifest_schema(&self) -> JsonValue {
34        let mut properties = serde_json::Map::new();
35        let mut required = Vec::new();
36        for field in &self.fields {
37            properties.insert(field.name.clone(), field.field_type.json_schema());
38            if field.required {
39                required.push(JsonValue::String(field.name.clone()));
40            }
41        }
42
43        json!({
44            "type": "object",
45            "properties": properties,
46            "required": required,
47            "additionalProperties": false
48        })
49    }
50
51    pub fn component_schema_file(&self, component_name: &str) -> JsonValue {
52        let mut schema = self.manifest_schema();
53        if let Some(obj) = schema.as_object_mut() {
54            obj.insert(
55                "$schema".to_string(),
56                JsonValue::String("https://json-schema.org/draft/2020-12/schema".to_string()),
57            );
58            obj.insert(
59                "title".to_string(),
60                JsonValue::String(format!("{component_name} component configuration")),
61            );
62        }
63        schema
64    }
65
66    pub fn rust_schema_ir(&self) -> String {
67        if self.fields.is_empty() {
68            return r#"SchemaIr::Object {
69        properties: BTreeMap::new(),
70        required: Vec::new(),
71        additional: AdditionalProperties::Forbid,
72    }"#
73            .to_string();
74        }
75
76        let properties = self
77            .fields
78            .iter()
79            .map(|field| {
80                format!(
81                    "(\"{}\".to_string(), {})",
82                    field.name,
83                    field.field_type.rust_schema_ir()
84                )
85            })
86            .collect::<Vec<_>>()
87            .join(",\n            ");
88        let required = self
89            .fields
90            .iter()
91            .filter(|field| field.required)
92            .map(|field| format!("\"{}\".to_string()", field.name))
93            .collect::<Vec<_>>();
94        let required_expr = if required.is_empty() {
95            "Vec::new()".to_string()
96        } else {
97            format!("vec![{}]", required.join(", "))
98        };
99
100        format!(
101            r#"SchemaIr::Object {{
102        properties: BTreeMap::from([
103            {properties}
104        ]),
105        required: {required_expr},
106        additional: AdditionalProperties::Forbid,
107    }}"#
108        )
109    }
110}
111
112impl ConfigSchemaFieldType {
113    fn json_schema(self) -> JsonValue {
114        match self {
115            Self::String => json!({ "type": "string" }),
116            Self::Bool => json!({ "type": "boolean" }),
117            Self::Integer => json!({ "type": "integer" }),
118            Self::Number => json!({ "type": "number" }),
119        }
120    }
121
122    fn rust_schema_ir(self) -> &'static str {
123        match self {
124            Self::String => {
125                "SchemaIr::String { min_len: Some(0), max_len: None, regex: None, format: None }"
126            }
127            Self::Bool => "SchemaIr::Bool",
128            Self::Integer => "SchemaIr::Int { min: None, max: None }",
129            Self::Number => "SchemaIr::Float { min: None, max: None }",
130        }
131    }
132}
133
134pub fn parse_config_field(value: &str) -> Result<ConfigSchemaField, ValidationError> {
135    let mut parts = value.split(':').map(str::trim);
136    let name = parts.next().unwrap_or_default();
137    let field_type = parts.next().unwrap_or_default();
138    let required = parts.next().unwrap_or("optional");
139    if name.is_empty() || field_type.is_empty() || parts.next().is_some() {
140        return Err(ValidationError::InvalidConfigField(value.to_string()));
141    }
142    if !CONFIG_FIELD_RE.is_match(name) {
143        return Err(ValidationError::InvalidConfigFieldName(name.to_string()));
144    }
145    let field_type = parse_config_field_type(field_type)?;
146    let required = match required {
147        "required" => true,
148        "optional" => false,
149        other => {
150            return Err(ValidationError::InvalidConfigField(
151                value.replace(required, other),
152            ));
153        }
154    };
155    Ok(ConfigSchemaField {
156        name: name.to_string(),
157        field_type,
158        required,
159    })
160}
161
162fn parse_config_field_type(value: &str) -> Result<ConfigSchemaFieldType, ValidationError> {
163    match value {
164        "string" => Ok(ConfigSchemaFieldType::String),
165        "bool" | "boolean" => Ok(ConfigSchemaFieldType::Bool),
166        "int" | "integer" => Ok(ConfigSchemaFieldType::Integer),
167        "number" | "float" => Ok(ConfigSchemaFieldType::Number),
168        other => Err(ValidationError::InvalidConfigFieldType(other.to_string())),
169    }
170}