use confique::Config;
use confique::meta::{Expr, Field, FieldKind, LeafKind, MapEntry, MapKey, Meta};
use serde_json::{Map, Value, json};
const SCHEMA_DIALECT: &str = "https://json-schema.org/draft/2020-12/schema";
pub fn generate_schema<C: Config>() -> Value {
let mut root = meta_to_object(&C::META);
if let Value::Object(map) = &mut root {
map.insert("$schema".into(), Value::String(SCHEMA_DIALECT.into()));
}
root
}
fn meta_to_object(meta: &Meta) -> Value {
let mut obj = Map::new();
obj.insert("type".into(), Value::String("object".into()));
obj.insert("title".into(), Value::String(meta.name.into()));
if !meta.doc.is_empty() {
obj.insert("description".into(), Value::String(join_doc(meta.doc)));
}
let mut properties = Map::new();
let mut required = Vec::new();
for field in meta.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: &Field) -> (String, Value, bool) {
match &field.kind {
FieldKind::Nested { meta } => {
let mut schema = meta_to_object(meta);
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)
}
FieldKind::Leaf { env, kind } => {
let mut prop = Map::new();
if !field.doc.is_empty() {
prop.insert("description".into(), Value::String(join_doc(field.doc)));
}
let default = match kind {
LeafKind::Required { default } => default.as_ref(),
LeafKind::Optional => None,
};
if let Some(expr) = default {
if 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);
}
}
if let Some(env_name) = env {
prop.insert("x-env".into(), Value::String((*env_name).into()));
}
let required = matches!(kind, LeafKind::Required { .. });
(field.name.into(), Value::Object(prop), required)
}
}
}
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 join_doc(lines: &[&str]) -> String {
lines
.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);
}
}