use toml::Value;
#[derive(Debug, Clone)]
pub struct Schema {
pub name: String,
pub doc: Vec<String>,
pub strict: Option<bool>,
pub fields: Vec<NamedField>,
}
impl Schema {
pub fn object(name: impl Into<String>) -> SchemaBuilder {
SchemaBuilder {
schema: Schema {
name: name.into(),
doc: Vec::new(),
strict: None,
fields: Vec::new(),
},
}
}
}
#[derive(Debug, Clone)]
pub struct SchemaBuilder {
schema: Schema,
}
impl SchemaBuilder {
pub fn doc(mut self, line: impl Into<String>) -> Self {
self.schema.doc.push(line.into());
self
}
pub fn strict(mut self, value: bool) -> Self {
self.schema.strict = Some(value);
self
}
pub fn field(mut self, name: impl Into<String>, field: FieldBuilder) -> Self {
let name = name.into();
validate_field_name(&self.schema, &name);
self.schema.fields.push(NamedField {
name,
field: Field::Leaf(field.build()),
});
self
}
pub fn nested(mut self, name: impl Into<String>, child: SchemaBuilder) -> Self {
let name = name.into();
validate_field_name(&self.schema, &name);
self.schema.fields.push(NamedField {
name,
field: Field::Nested(child.build()),
});
self
}
pub fn array_of(mut self, name: impl Into<String>, item: SchemaBuilder) -> Self {
let name = name.into();
validate_field_name(&self.schema, &name);
self.schema.fields.push(NamedField {
name,
field: Field::ArrayOf(item.build()),
});
self
}
pub fn build(self) -> Schema {
self.schema
}
}
#[derive(Debug, Clone)]
pub struct NamedField {
pub name: String,
pub field: Field,
}
#[derive(Debug, Clone)]
pub enum Field {
Leaf(Leaf),
Nested(Schema),
ArrayOf(Schema),
}
impl Field {
pub fn string() -> FieldBuilder {
FieldBuilder::new(LeafType::String)
}
pub fn integer() -> FieldBuilder {
FieldBuilder::new(LeafType::Integer)
}
pub fn float() -> FieldBuilder {
FieldBuilder::new(LeafType::Float)
}
pub fn boolean() -> FieldBuilder {
FieldBuilder::new(LeafType::Bool)
}
pub fn datetime() -> FieldBuilder {
FieldBuilder::new(LeafType::DateTime)
}
pub fn array_of_type(item: LeafType) -> FieldBuilder {
FieldBuilder::new(LeafType::Array(Box::new(item)))
}
pub fn map_of(value: LeafType) -> FieldBuilder {
FieldBuilder::new(LeafType::Map(Box::new(value)))
}
pub fn enum_of<V: Into<Value>, I: IntoIterator<Item = V>>(values: I) -> FieldBuilder {
let values: Vec<Value> = values.into_iter().map(Into::into).collect();
FieldBuilder::new(LeafType::Enum { values })
}
}
#[derive(Debug, Clone)]
pub struct Leaf {
pub doc: Vec<String>,
pub ty: LeafType,
pub default: Option<Value>,
pub optional: bool,
pub env: Option<String>,
}
#[derive(Debug, Clone)]
pub enum LeafType {
String,
Integer,
Float,
Bool,
DateTime,
Array(Box<LeafType>),
Map(Box<LeafType>),
Enum {
values: Vec<Value>,
},
}
impl LeafType {
pub(crate) fn name(&self) -> &'static str {
match self {
LeafType::String => "string",
LeafType::Integer => "integer",
LeafType::Float => "float",
LeafType::Bool => "bool",
LeafType::DateTime => "datetime",
LeafType::Array(_) => "array",
LeafType::Map(_) => "map",
LeafType::Enum { .. } => "enum",
}
}
pub(crate) fn check(&self, value: &Value) -> Result<(), String> {
match (self, value) {
(LeafType::String, Value::String(_)) => Ok(()),
(LeafType::Integer, Value::Integer(_)) => Ok(()),
(LeafType::Float, Value::Float(_)) => Ok(()),
(LeafType::Bool, Value::Boolean(_)) => Ok(()),
(LeafType::DateTime, Value::Datetime(_)) => Ok(()),
(LeafType::Array(elem), Value::Array(items)) => {
for (i, item) in items.iter().enumerate() {
elem.check(item).map_err(|e| format!("array[{i}]: {e}"))?;
}
Ok(())
}
(LeafType::Map(elem), Value::Table(table)) => {
for (k, v) in table {
elem.check(v).map_err(|e| format!("map[{k}]: {e}"))?;
}
Ok(())
}
(LeafType::Enum { values }, v) => {
if values.iter().any(|allowed| allowed == v) {
Ok(())
} else {
let listed = values
.iter()
.map(format_toml_value)
.collect::<Vec<_>>()
.join(" | ");
Err(format!(
"value {} is not in allowed set: {listed}",
format_toml_value(v)
))
}
}
(expected, got) => Err(format!(
"expected {}, got {}",
expected.name(),
value_type_name(got)
)),
}
}
}
#[derive(Debug, Clone)]
pub struct FieldBuilder {
leaf: Leaf,
}
impl FieldBuilder {
fn new(ty: LeafType) -> Self {
Self {
leaf: Leaf {
doc: Vec::new(),
ty,
default: None,
optional: false,
env: None,
},
}
}
pub fn doc(mut self, line: impl Into<String>) -> Self {
self.leaf.doc.push(line.into());
self
}
pub fn default<V: Into<Value>>(mut self, value: V) -> Self {
self.leaf.default = Some(value.into());
self
}
pub fn optional(mut self) -> Self {
self.leaf.optional = true;
self
}
pub fn env(mut self, name: impl Into<String>) -> Self {
self.leaf.env = Some(name.into());
self
}
pub(crate) fn build(self) -> Leaf {
self.leaf
}
}
fn validate_field_name(schema: &Schema, name: &str) {
assert!(!name.is_empty(), "clapfig: field name must not be empty");
assert!(
!name.contains('.'),
"clapfig: field name {name:?} contains '.', which conflicts with the dotted-path separator"
);
assert!(
!name.contains('['),
"clapfig: field name {name:?} contains '[', which conflicts with array-index syntax"
);
assert!(
!name.contains(']'),
"clapfig: field name {name:?} contains ']', which conflicts with array-index syntax"
);
assert!(
!schema.fields.iter().any(|f| f.name == name),
"clapfig: duplicate field name {name:?} on schema {:?}",
schema.name
);
}
fn format_toml_value(v: &Value) -> String {
match v {
Value::String(s) => format!("\"{s}\""),
Value::Integer(i) => i.to_string(),
Value::Float(f) => f.to_string(),
Value::Boolean(b) => b.to_string(),
Value::Datetime(d) => d.to_string(),
Value::Array(_) => "<array>".into(),
Value::Table(_) => "<table>".into(),
}
}
fn value_type_name(v: &Value) -> &'static str {
match v {
Value::String(_) => "string",
Value::Integer(_) => "integer",
Value::Float(_) => "float",
Value::Boolean(_) => "bool",
Value::Datetime(_) => "datetime",
Value::Array(_) => "array",
Value::Table(_) => "table",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builder_builds_a_simple_schema() {
let s = Schema::object("App")
.doc("Top-level config")
.field("host", Field::string().default("localhost"))
.field("port", Field::integer().default(8080i64))
.build();
assert_eq!(s.name, "App");
assert_eq!(s.doc, vec!["Top-level config".to_string()]);
assert_eq!(s.fields.len(), 2);
assert!(matches!(s.fields[0].field, Field::Leaf(_)));
}
#[test]
fn builder_handles_nested_schemas() {
let s = Schema::object("Root")
.nested(
"db",
Schema::object("Db").field("url", Field::string().optional()),
)
.build();
match &s.fields[0].field {
Field::Nested(inner) => {
assert_eq!(inner.name, "Db");
assert_eq!(inner.fields.len(), 1);
}
other => panic!("expected Nested, got {other:?}"),
}
}
#[test]
fn builder_handles_strict_override() {
let s = Schema::object("Top").strict(false).build();
assert_eq!(s.strict, Some(false));
}
#[test]
fn enum_of_collects_values() {
let f = Field::enum_of(["debug", "info"]).build();
match &f.ty {
LeafType::Enum { values } => {
assert_eq!(values.len(), 2);
assert_eq!(values[0], Value::String("debug".into()));
}
other => panic!("expected Enum, got {other:?}"),
}
}
#[test]
fn leaf_type_check_accepts_matching_primitives() {
assert!(LeafType::String.check(&Value::String("x".into())).is_ok());
assert!(LeafType::Integer.check(&Value::Integer(1)).is_ok());
assert!(LeafType::Bool.check(&Value::Boolean(true)).is_ok());
}
#[test]
fn leaf_type_check_rejects_mismatched_type() {
let err = LeafType::Integer
.check(&Value::String("nope".into()))
.unwrap_err();
assert!(err.contains("expected integer"));
assert!(err.contains("got string"));
}
#[test]
fn leaf_type_check_enum_accepts_known_value() {
let e = LeafType::Enum {
values: vec!["info".into(), "warn".into()],
};
assert!(e.check(&Value::String("info".into())).is_ok());
}
#[test]
fn leaf_type_check_enum_rejects_unknown_value() {
let e = LeafType::Enum {
values: vec!["info".into(), "warn".into()],
};
let err = e.check(&Value::String("garbage".into())).unwrap_err();
assert!(err.contains("not in allowed set"));
assert!(err.contains("\"info\""));
assert!(err.contains("\"warn\""));
}
#[test]
fn leaf_type_check_array_recurses() {
let arr = LeafType::Array(Box::new(LeafType::Integer));
let good = Value::Array(vec![Value::Integer(1), Value::Integer(2)]);
assert!(arr.check(&good).is_ok());
let bad = Value::Array(vec![Value::Integer(1), Value::String("oops".into())]);
let err = arr.check(&bad).unwrap_err();
assert!(err.contains("array[1]"));
assert!(err.contains("expected integer"));
}
#[test]
#[should_panic(expected = "contains '.'")]
fn field_name_with_dot_panics() {
let _ = Schema::object("Top").field("a.b", Field::string()).build();
}
#[test]
#[should_panic(expected = "contains '['")]
fn field_name_with_open_bracket_panics() {
let _ = Schema::object("Top").field("a[0]", Field::string()).build();
}
#[test]
#[should_panic(expected = "must not be empty")]
fn empty_field_name_panics() {
let _ = Schema::object("Top").field("", Field::string()).build();
}
#[test]
#[should_panic(expected = "duplicate field name")]
fn duplicate_field_name_panics() {
let _ = Schema::object("Top")
.field("a", Field::string())
.field("a", Field::integer())
.build();
}
#[test]
fn nested_and_array_of_share_the_same_validation() {
let _ = Schema::object("Top")
.nested("a", Schema::object("A"))
.array_of("b", Schema::object("B"))
.build();
let result = std::panic::catch_unwind(|| {
Schema::object("Top")
.field("a", Field::string())
.nested("a", Schema::object("Dup")) .build()
});
assert!(result.is_err(), "duplicate across leaf/nested must panic");
}
#[test]
fn leaf_type_check_map_recurses() {
let map = LeafType::Map(Box::new(LeafType::Integer));
let mut t = toml::map::Map::new();
t.insert("a".into(), Value::Integer(1));
assert!(map.check(&Value::Table(t.clone())).is_ok());
t.insert("b".into(), Value::String("oops".into()));
let err = map.check(&Value::Table(t)).unwrap_err();
assert!(err.contains("map[b]"));
}
}