schematic 0.19.7

A layered serde configuration and schema library.
Documentation
use crate::schema::RenderResult;
use indexmap::IndexMap;
use miette::miette;
use schematic_types::*;
use std::collections::{HashMap, VecDeque};
use std::mem;

/// Options to control the rendered template.
pub struct TemplateOptions {
    /// Include field comments in output.
    pub comments: bool,

    /// List of field names to render but comment out.
    pub comment_fields: Vec<String>,

    /// Characters to prefix a comment line.
    pub comment_prefix: String,

    /// Custom values for each field. Supports dot notation.
    pub custom_values: HashMap<String, Schema>,

    /// List of array and object field names to expand and render a fake item.
    pub expand_fields: Vec<String>,

    /// Content to append to the bottom of the output.
    pub footer: String,

    /// Content to prepend to the top of the output.
    pub header: String,

    /// List of field names to not render.
    pub hide_fields: Vec<String>,

    /// Character(s) to use for indentation.
    pub indent_char: String,

    /// Insert an extra newline between fields.
    pub newline_between_fields: bool,

    /// List of field names to only render.
    pub only_fields: Vec<String>,
}

impl Default for TemplateOptions {
    fn default() -> Self {
        Self {
            comments: true,
            comment_fields: vec![],
            comment_prefix: "# ".into(),
            custom_values: HashMap::new(),
            expand_fields: vec![],
            footer: String::new(),
            header: String::new(),
            hide_fields: vec![],
            indent_char: "  ".into(),
            newline_between_fields: true,
            only_fields: vec![],
        }
    }
}

pub fn lit_to_string(lit: &LiteralValue) -> String {
    match lit {
        LiteralValue::Bool(inner) => inner.to_string(),
        LiteralValue::F32(inner) => inner.to_string(),
        LiteralValue::F64(inner) => inner.to_string(),
        LiteralValue::Int(inner) => inner.to_string(),
        LiteralValue::UInt(inner) => inner.to_string(),
        LiteralValue::String(inner) => format!("\"{inner}\""),
    }
}

pub fn is_nested_type(schema: &SchemaType) -> bool {
    match schema {
        SchemaType::Struct(sct) => !sct.fields.is_empty(),
        SchemaType::Union(uni) => {
            if uni.has_null() && uni.variants_types.len() == 2 {
                uni.variants_types
                    .iter()
                    .find(|v| !v.is_null())
                    .is_some_and(|v| is_nested_type(v))
            } else {
                false
            }
        }
        _ => false,
    }
}

pub struct TemplateContext {
    pub depth: usize,
    pub options: TemplateOptions,

    stack: VecDeque<String>,
}

impl TemplateContext {
    pub fn new(options: TemplateOptions) -> Self {
        Self {
            depth: 0,
            options,
            stack: VecDeque::new(),
        }
    }

    pub fn indent(&self) -> String {
        if self.depth == 0 {
            String::new()
        } else {
            self.options.indent_char.repeat(self.depth)
        }
    }

    pub fn gap(&self) -> &str {
        if self.options.newline_between_fields {
            "\n\n"
        } else {
            "\n"
        }
    }

    pub fn create_field_comment(&self, field: &SchemaField) -> String {
        if !self.options.comments {
            return String::new();
        }

        let mut lines = vec![];
        let indent = self.indent();
        let prefix = self.get_comment_prefix();

        let mut push = |line: String| {
            lines.push(format!("{indent}{prefix}{line}"));
        };

        if let Some(comment) = &field.comment {
            comment
                .trim()
                .split('\n')
                .for_each(|c| push(c.trim().to_owned()));
        }

        if let Some(deprecated) = &field.deprecated {
            push(if deprecated.is_empty() {
                "@deprecated".into()
            } else {
                format!("@deprecated {deprecated}")
            });
        }

        if let Some(env_var) = &field.env_var
            && !env_var.is_empty()
        {
            push(format!("@env {env_var}"));
        }

        if let SchemaType::Enum(enu) = &field.schema.ty
            && let Ok(enum_values) = render_enum_values(enu)
            && !enum_values.is_empty()
        {
            push(format!("@values {enum_values}"));
        }

        if lines.is_empty() {
            return String::new();
        }

        let mut out = lines.join("\n");
        out.push('\n');
        out
    }

    pub fn create_field(&self, field: &SchemaField, property: String) -> String {
        let key = self.get_stack_key();

        format!(
            "{}{}{}{property}",
            self.create_field_comment(field),
            self.indent(),
            if self.options.comment_fields.contains(&key) {
                self.get_comment_prefix()
            } else {
                ""
            },
        )
    }

    pub fn get_comment_prefix(&self) -> &str {
        &self.options.comment_prefix
    }

    pub fn get_stack_key(&self) -> String {
        let mut key = String::new();
        let last_index = self.stack.len() - 1;

        for (index, item) in self.stack.iter().enumerate() {
            key.push_str(item);

            if index != last_index {
                key.push('.');
            }
        }

        key
    }

    pub fn get_stack_value(&self) -> Option<Schema> {
        let key = self.get_stack_key();

        self.options.custom_values.get(&key).cloned()
    }

    pub fn is_expanded(&self, key: &String) -> bool {
        self.options.expand_fields.contains(key)
    }

    pub fn is_hidden(&self, field: &SchemaField) -> bool {
        let key = self.get_stack_key();

        field.hidden
            || self.options.hide_fields.contains(&key)
            || !self.options.only_fields.is_empty() && !self.options.only_fields.contains(&key)
    }

    pub fn push_stack(&mut self, name: &str) {
        self.stack.push_back(name.to_owned());
    }

    pub fn pop_stack(&mut self) {
        self.stack.pop_back();
    }

    pub fn resolve_schema(&self, initial: &Schema, schemas: &IndexMap<String, Schema>) -> Schema {
        if let SchemaType::Reference(name) = &initial.ty {
            if let Some(schema) = schemas.get(name) {
                return schema.to_owned();
            }
        }

        initial.to_owned()
    }

    pub fn validate_schema_variant<'a>(
        &self,
        custom: Option<&'a Schema>,
        fallback: &'a Schema,
    ) -> &'a Schema {
        if let Some(custom) = custom {
            if mem::discriminant(&custom.ty) == mem::discriminant(&fallback.ty) {
                return custom;
            } else {
                panic!(
                    "Received an invalid custom value for `{}`, mismatched schema types.\n\nExpected: {:#?}\n\nReceived: {:#?}",
                    self.get_stack_key(),
                    fallback,
                    custom
                );
            }
        }

        fallback
    }
}

pub fn render_array(_array: &ArrayType) -> RenderResult {
    Ok("[]".into())
}

pub fn render_boolean(boolean: &BooleanType) -> RenderResult {
    if let Some(default) = &boolean.default {
        return Ok(lit_to_string(default));
    }

    Ok("false".into())
}

pub fn render_enum(enu: &EnumType) -> RenderResult {
    let index = enu.default_index.unwrap_or(0);

    if let Some(value) = enu.values.get(index) {
        return Ok(lit_to_string(value));
    }

    render_null()
}

pub fn render_enum_values(enu: &EnumType) -> RenderResult {
    let values: Vec<String> = match &enu.variants {
        Some(variants) => variants
            .iter()
            .filter_map(|(_, variant)| {
                if variant.hidden {
                    None
                } else if let SchemaType::Literal(lit) = &variant.schema.ty {
                    Some(lit_to_string(&lit.value))
                } else {
                    None
                }
            })
            .collect(),
        None => enu.values.iter().map(lit_to_string).collect(),
    };

    Ok(values.join(" | "))
}

pub fn render_float(float: &FloatType) -> RenderResult {
    if let Some(default) = &float.default {
        return Ok(lit_to_string(default));
    }

    Ok("0.0".into())
}

pub fn render_integer(integer: &IntegerType) -> RenderResult {
    if let Some(default) = &integer.default {
        return Ok(lit_to_string(default));
    }

    Ok("0".into())
}

pub fn render_literal(literal: &LiteralType) -> RenderResult {
    Ok(lit_to_string(&literal.value))
}

pub fn render_null() -> RenderResult {
    Ok("null".into())
}

pub fn render_object(_object: &ObjectType) -> RenderResult {
    Ok("{}".into())
}

pub fn render_reference(reference: &str) -> RenderResult {
    Ok(reference.into())
}

pub const EMPTY_STRING: &str = "\"\"";

pub fn render_string(string: &StringType) -> RenderResult {
    if let Some(default) = &string.default {
        return Ok(lit_to_string(default));
    }

    Ok(EMPTY_STRING.into())
}

pub fn render_tuple(
    tuple: &TupleType,
    mut render: impl FnMut(&Schema) -> RenderResult,
) -> RenderResult {
    let mut items = vec![];

    for item in &tuple.items_types {
        items.push(render(item)?);
    }

    Ok(format!("[{}]", items.join(", ")))
}

pub fn render_union(
    uni: &UnionType,
    mut render: impl FnMut(&Schema) -> RenderResult,
) -> RenderResult {
    if let Some(index) = &uni.default_index {
        if let Some(variant) = uni.variants_types.get(*index) {
            return render(variant);
        }
    }

    // We have a nullable type, so render the non-null value
    if uni.has_null() {
        if let Some(variant) = uni.variants_types.iter().find(|v| !v.is_null()) {
            return render(variant);
        }
    }

    if let Some(variant) = uni.variants_types.first() {
        return render(variant);
    }

    render_null()
}

pub fn render_unknown() -> RenderResult {
    render_null()
}

pub fn validate_root(schemas: &IndexMap<String, Schema>) -> miette::Result<Schema> {
    let Some(schema) = schemas.values().last() else {
        return Err(miette!(
            "At least 1 schema is required to generate a template."
        ));
    };

    if !schema.is_struct() {
        return Err(miette!("The last registered schema must be a struct type."));
    };

    Ok(schema.to_owned())
}