#![warn(missing_docs)]
#![deny(unsafe_code)]
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct EnvBinding {
pub prefix: Option<String>,
pub key: String,
pub separator: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MergeHint {
Replace,
DeepMerge,
Append,
Unique,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ValidationRule {
Min(i64),
Max(i64),
MinLen(usize),
MaxLen(usize),
Pattern(String),
Custom(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SchemaKind {
Struct,
Enum,
Bool,
Integer,
Float,
String,
Sequence,
Map,
Any,
}
impl SchemaKind {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Struct => "struct",
Self::Enum => "enum",
Self::Bool => "bool",
Self::Integer => "integer",
Self::Float => "float",
Self::String => "string",
Self::Sequence => "sequence",
Self::Map => "map",
Self::Any => "any",
}
}
}
impl std::fmt::Display for SchemaKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SchemaNode {
pub path: String,
pub name: String,
pub kind: SchemaKind,
pub rust_type: String,
pub required: bool,
pub nullable: bool,
pub default: Option<serde_json::Value>,
pub aliases: Vec<String>,
pub env: Option<EnvBinding>,
pub merge_hint: Option<MergeHint>,
pub docs: Option<String>,
pub validations: Vec<ValidationRule>,
pub children: BTreeMap<String, Self>,
}
impl SchemaNode {
#[must_use]
pub fn new(
path: impl Into<String>,
name: impl Into<String>,
kind: SchemaKind,
rust_type: impl Into<String>,
) -> Self {
Self {
path: path.into(),
name: name.into(),
kind,
rust_type: rust_type.into(),
required: false,
nullable: false,
default: None,
aliases: Vec::new(),
env: None,
merge_hint: None,
docs: None,
validations: Vec::new(),
children: BTreeMap::new(),
}
}
#[must_use]
pub const fn required(mut self, required: bool) -> Self {
self.required = required;
self
}
#[must_use]
pub const fn nullable(mut self, nullable: bool) -> Self {
self.nullable = nullable;
self
}
#[must_use]
pub fn default_value(mut self, default: serde_json::Value) -> Self {
self.default = Some(default);
self
}
#[must_use]
pub fn alias(mut self, alias: impl Into<String>) -> Self {
self.aliases.push(alias.into());
self
}
#[must_use]
pub fn env_binding(mut self, env: EnvBinding) -> Self {
self.env = Some(env);
self
}
#[must_use]
pub const fn merge_hint(mut self, merge_hint: MergeHint) -> Self {
self.merge_hint = Some(merge_hint);
self
}
#[must_use]
pub fn docs(mut self, docs: impl Into<String>) -> Self {
self.docs = Some(docs.into());
self
}
#[must_use]
pub fn validation(mut self, rule: ValidationRule) -> Self {
self.validations.push(rule);
self
}
#[must_use]
pub fn child(mut self, key: impl Into<String>, child: Self) -> Self {
self.children.insert(key.into(), child);
self
}
#[must_use]
pub fn find<'a>(&'a self, path: &str) -> Option<&'a Self> {
if path == self.path {
return Some(self);
}
if path == "/" {
return (self.path == "/").then_some(self);
}
let mut node = self;
for segment in path.trim_start_matches('/').split('/') {
if segment.is_empty() {
continue;
}
let decoded = segment.replace("~1", "/").replace("~0", "~");
node = node.children.get(&decoded)?;
}
Some(node)
}
}
pub trait ConfigSchema {
fn schema() -> SchemaNode;
}
#[cfg(test)]
mod tests {
use super::*;
struct AppConfig;
impl ConfigSchema for AppConfig {
fn schema() -> SchemaNode {
SchemaNode::new("/", "root", SchemaKind::Struct, "AppConfig").child(
"server",
SchemaNode::new("/server", "server", SchemaKind::Struct, "ServerConfig").child(
"port",
SchemaNode::new("/server/port", "port", SchemaKind::Integer, "u16")
.required(true),
),
)
}
}
#[test]
fn test_find_nested_node() {
let schema = AppConfig::schema();
let node = schema.find("/server/port").unwrap();
assert_eq!(node.path, "/server/port");
assert_eq!(node.kind, SchemaKind::Integer);
assert!(node.required);
}
#[test]
fn test_config_schema_trait() {
let schema = AppConfig::schema();
assert_eq!(schema.path, "/");
assert!(schema.find("/server").is_some());
}
#[test]
fn test_schema_node_preserves_metadata_contract() {
let node = SchemaNode::new("/database/url", "url", SchemaKind::String, "String")
.required(true)
.nullable(false)
.default_value(serde_json::json!("postgres://localhost"))
.alias("DATABASE_URL")
.env_binding(EnvBinding {
prefix: Some("APP".to_string()),
key: "DATABASE__URL".to_string(),
separator: Some("__".to_string()),
})
.merge_hint(MergeHint::Replace)
.docs("Database connection string")
.validation(ValidationRule::MinLen(1))
.validation(ValidationRule::Pattern("^postgres://".to_string()));
assert_eq!(node.path, "/database/url");
assert_eq!(node.kind, SchemaKind::String);
assert_eq!(
node.default,
Some(serde_json::json!("postgres://localhost"))
);
assert_eq!(node.aliases, vec!["DATABASE_URL"]);
assert_eq!(node.env.as_ref().unwrap().key, "DATABASE__URL");
assert_eq!(node.merge_hint, Some(MergeHint::Replace));
assert_eq!(node.docs.as_deref(), Some("Database connection string"));
assert_eq!(node.validations.len(), 2);
}
#[test]
fn test_find_root_and_missing_path() {
let schema = AppConfig::schema();
assert_eq!(schema.find("/").unwrap().name, "root");
assert!(schema.find("/server/host").is_none());
assert!(schema.find("/missing").is_none());
}
#[test]
fn test_find_decodes_json_pointer_segments() {
let schema = SchemaNode::new("/", "root", SchemaKind::Struct, "Root")
.child(
"a/b",
SchemaNode::new("/a~1b", "a/b", SchemaKind::String, "String"),
)
.child(
"tilde~key",
SchemaNode::new("/tilde~0key", "tilde~key", SchemaKind::String, "String"),
);
assert_eq!(schema.find("/a~1b").unwrap().name, "a/b");
assert_eq!(schema.find("/tilde~0key").unwrap().name, "tilde~key");
}
#[test]
fn test_schema_kind_display_names() {
assert_eq!(SchemaKind::Struct.as_str(), "struct");
assert_eq!(SchemaKind::Integer.to_string(), "integer");
assert_eq!(SchemaKind::Any.to_string(), "any");
}
#[test]
fn test_children_are_kept_in_deterministic_order() {
let schema = SchemaNode::new("/", "root", SchemaKind::Struct, "Root")
.child(
"zeta",
SchemaNode::new("/zeta", "zeta", SchemaKind::Bool, "bool"),
)
.child(
"alpha",
SchemaNode::new("/alpha", "alpha", SchemaKind::Bool, "bool"),
);
let keys: Vec<_> = schema.children.keys().cloned().collect();
assert_eq!(keys, vec!["alpha".to_string(), "zeta".to_string()]);
}
}