use confique::Config;
use confique::meta::{Expr, MapEntry, MapKey};
use serde_json::{Map, Value, json};
use crate::runtime::LeafType;
use crate::spec::{DocSource, FieldKindRef, FieldRef, LeafDefault, LeafRef, SchemaRef};
const SCHEMA_DIALECT: &str = "https://json-schema.org/draft/2020-12/schema";
pub fn generate_schema<C: Config>() -> Value {
generate_schema_from_ref(SchemaRef::from_meta(&C::META))
}
pub(crate) fn generate_schema_from_ref(schema: SchemaRef<'_>) -> Value {
let mut root = schema_to_object(schema);
if let Value::Object(map) = &mut root {
map.insert("$schema".into(), Value::String(SCHEMA_DIALECT.into()));
}
root
}
fn schema_to_object(schema: SchemaRef<'_>) -> Value {
let mut obj = Map::new();
obj.insert("type".into(), Value::String("object".into()));
obj.insert("title".into(), Value::String(schema.name().into()));
let schema_doc = schema.doc();
if !schema_doc.is_empty() {
obj.insert("description".into(), Value::String(join_doc(schema_doc)));
}
let mut properties = Map::new();
let mut required = Vec::new();
for field in schema.fields() {
let (name, prop, is_required) = field_to_property(field);
if is_required {
required.push(Value::String(name.clone()));
}
properties.insert(name, prop);
}
obj.insert("properties".into(), Value::Object(properties));
if !required.is_empty() {
obj.insert("required".into(), Value::Array(required));
}
obj.insert("additionalProperties".into(), Value::Bool(false));
Value::Object(obj)
}
fn field_to_property(field: FieldRef<'_>) -> (String, Value, bool) {
match field.kind {
FieldKindRef::Nested { schema: nested } => {
let mut schema = schema_to_object(nested);
if !field.doc.is_empty()
&& let Value::Object(map) = &mut schema
{
map.insert("description".into(), Value::String(join_doc(field.doc)));
}
(field.name.into(), schema, true)
}
FieldKindRef::ArrayOf { schema: item } => {
let mut prop = Map::new();
if !field.doc.is_empty() {
prop.insert("description".into(), Value::String(join_doc(field.doc)));
}
prop.insert("type".into(), Value::String("array".into()));
prop.insert("items".into(), schema_to_object(item));
(field.name.into(), Value::Object(prop), false)
}
FieldKindRef::Leaf(leaf) => {
let mut prop = Map::new();
if !field.doc.is_empty() {
prop.insert("description".into(), Value::String(join_doc(field.doc)));
}
populate_leaf(&mut prop, leaf);
(field.name.into(), Value::Object(prop), !leaf.optional)
}
}
}
fn populate_leaf(prop: &mut Map<String, Value>, leaf: LeafRef<'_>) {
if let Some(ty) = leaf.ty
&& let Some(name) = leaf_type_json_name(ty)
{
prop.insert("type".into(), Value::String(name.into()));
if let LeafType::Array(item) = ty
&& let Some(item_name) = leaf_type_json_name(item)
{
let mut items = Map::new();
items.insert("type".into(), Value::String(item_name.into()));
prop.insert("items".into(), Value::Object(items));
}
}
if let Some(default) = leaf.default {
match default {
LeafDefault::Expr(expr) => {
if leaf.ty.is_none()
&& let Some(ty) = infer_type(expr)
{
prop.insert("type".into(), Value::String(ty.into()));
}
if let Some(default_value) = expr_to_json(expr) {
prop.insert("default".into(), default_value);
}
}
LeafDefault::Toml(value) => {
if let Some(default_value) = toml_value_to_json(value) {
prop.insert("default".into(), default_value);
}
}
}
}
if let Some(env_name) = leaf.env {
prop.insert("x-env".into(), Value::String(env_name.into()));
}
if let Some(values) = leaf.allowed_values {
let enum_array: Vec<Value> = values.iter().filter_map(toml_value_to_json).collect();
if !enum_array.is_empty() {
prop.insert("enum".into(), Value::Array(enum_array));
}
}
}
fn leaf_type_json_name(ty: &LeafType) -> Option<&'static str> {
match ty {
LeafType::String => Some("string"),
LeafType::Integer => Some("integer"),
LeafType::Float => Some("number"),
LeafType::Bool => Some("boolean"),
LeafType::DateTime => Some("string"),
LeafType::Array(_) => Some("array"),
LeafType::Map(_) => Some("object"),
LeafType::Enum { values } => values.first().and_then(toml_value_json_type),
LeafType::Value => None,
}
}
fn toml_value_json_type(value: &toml::Value) -> Option<&'static str> {
match value {
toml::Value::String(_) => Some("string"),
toml::Value::Integer(_) => Some("integer"),
toml::Value::Float(_) => Some("number"),
toml::Value::Boolean(_) => Some("boolean"),
_ => None,
}
}
fn infer_type(expr: &Expr) -> Option<&'static str> {
match expr {
Expr::Str(_) => Some("string"),
Expr::Integer(_) => Some("integer"),
Expr::Float(_) => Some("number"),
Expr::Bool(_) => Some("boolean"),
Expr::Array(_) => Some("array"),
Expr::Map(_) => Some("object"),
_ => None,
}
}
fn expr_to_json(expr: &Expr) -> Option<Value> {
match expr {
Expr::Str(s) => Some(Value::String((*s).into())),
Expr::Integer(i) => Some(json!(i)),
Expr::Float(f) => Some(json!(f)),
Expr::Bool(b) => Some(Value::Bool(*b)),
Expr::Array(items) => Some(Value::Array(
items.iter().filter_map(expr_to_json).collect(),
)),
Expr::Map(entries) => {
let mut obj = Map::new();
for MapEntry { key, value } in *entries {
let Some(key_str) = map_key_to_string(key) else {
continue;
};
let Some(val) = expr_to_json(value) else {
continue;
};
obj.insert(key_str, val);
}
Some(Value::Object(obj))
}
_ => None,
}
}
fn map_key_to_string(key: &MapKey) -> Option<String> {
match key {
MapKey::Str(s) => Some((*s).into()),
MapKey::Integer(i) => Some(i.to_string()),
MapKey::Float(f) => Some(f.to_string()),
MapKey::Bool(b) => Some(b.to_string()),
_ => None,
}
}
fn toml_value_to_json(value: &toml::Value) -> Option<Value> {
match value {
toml::Value::String(s) => Some(Value::String(s.clone())),
toml::Value::Integer(i) => Some(json!(i)),
toml::Value::Float(f) => Some(json!(f)),
toml::Value::Boolean(b) => Some(Value::Bool(*b)),
_ => None,
}
}
fn join_doc(source: DocSource<'_>) -> String {
source
.iter()
.map(|l| l.trim())
.collect::<Vec<_>>()
.join(" ")
.trim()
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fixtures::test::TestConfig;
fn schema() -> Value {
generate_schema::<TestConfig>()
}
#[test]
fn root_has_schema_dialect_and_type_object() {
let s = schema();
assert_eq!(s["$schema"], SCHEMA_DIALECT);
assert_eq!(s["type"], "object");
assert_eq!(s["title"], "TestConfig");
}
#[test]
fn root_lists_top_level_properties() {
let s = schema();
let props = s["properties"].as_object().unwrap();
assert!(props.contains_key("host"));
assert!(props.contains_key("port"));
assert!(props.contains_key("debug"));
assert!(props.contains_key("database"));
}
#[test]
fn types_inferred_from_defaults() {
let s = schema();
let props = &s["properties"];
assert_eq!(props["host"]["type"], "string");
assert_eq!(props["port"]["type"], "integer");
assert_eq!(props["debug"]["type"], "boolean");
}
#[test]
fn defaults_emitted_on_properties() {
let s = schema();
let props = &s["properties"];
assert_eq!(props["host"]["default"], "localhost");
assert_eq!(props["port"]["default"], 8080);
assert_eq!(props["debug"]["default"], false);
}
#[test]
fn doc_comments_become_descriptions() {
let s = schema();
let props = &s["properties"];
assert!(
props["host"]["description"]
.as_str()
.unwrap()
.contains("host")
);
assert!(
props["port"]["description"]
.as_str()
.unwrap()
.contains("port")
);
}
#[test]
fn nested_struct_becomes_object_with_own_properties() {
let s = schema();
let db = &s["properties"]["database"];
assert_eq!(db["type"], "object");
assert_eq!(db["title"], "TestDbConfig");
let db_props = db["properties"].as_object().unwrap();
assert!(db_props.contains_key("url"));
assert!(db_props.contains_key("pool_size"));
assert_eq!(db_props["pool_size"]["type"], "integer");
assert_eq!(db_props["pool_size"]["default"], 5);
}
#[test]
fn required_array_excludes_optional_fields() {
let s = schema();
let root_required: Vec<&str> = s["required"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(root_required.contains(&"host"));
assert!(root_required.contains(&"port"));
assert!(root_required.contains(&"debug"));
assert!(root_required.contains(&"database"));
let db_required: Vec<&str> = s["properties"]["database"]["required"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(db_required.contains(&"pool_size"));
assert!(!db_required.contains(&"url"));
}
#[test]
fn optional_field_still_appears_in_properties() {
let s = schema();
let db_props = s["properties"]["database"]["properties"]
.as_object()
.unwrap();
assert!(db_props.contains_key("url"));
assert!(!db_props["url"].as_object().unwrap().contains_key("type"));
assert!(!db_props["url"].as_object().unwrap().contains_key("default"));
}
#[test]
fn additional_properties_false_on_objects() {
let s = schema();
assert_eq!(s["additionalProperties"], false);
assert_eq!(s["properties"]["database"]["additionalProperties"], false);
}
#[test]
fn optional_field_has_no_null_default_key() {
let s = schema();
let url = &s["properties"]["database"]["properties"]["url"];
let url_obj = url.as_object().unwrap();
assert!(
!url_obj.contains_key("default"),
"optional field must not have a default key: {url}"
);
}
#[test]
fn schema_serializes_to_valid_json() {
let s = schema();
let json_text = serde_json::to_string_pretty(&s).unwrap();
let reparsed: Value = serde_json::from_str(&json_text).unwrap();
assert_eq!(reparsed, s);
}
}