feat(config): add validated profile core

This commit is contained in:
Onur Özkan committed 2026-08-09 22:06:10 +03:00
1 parent 0a75860841
commit 6c5e061359
36 files changed
+3343

No files matched your search

+187
View File
@@ -0,0 +1,187 @@
use proc_macro2::TokenStream;
use quote::quote;
use syn::Result;
use crate::parse::{Check, Model};
pub(crate) fn expand(model: Model) -> Result<TokenStream> {
let Model {
ident,
generics,
crate_path,
post_validate,
fields,
} = model;
let (impl_generics, type_generics, where_clause) = generics.split_for_impl();
let mut checks = Vec::new();
let mut descriptors = Vec::new();
for field in fields {
let FieldMetadata {
descriptor,
checks: field_checks,
} = expand_field(&crate_path, &field)?;
if let Some(descriptor) = descriptor {
descriptors.push(descriptor);
}
checks.extend(field_checks);
}
let post_validate = post_validate.map(|hook| quote! { #hook(self)?; });
Ok(quote! {
impl #impl_generics #crate_path::ValidateFields for #ident #type_generics #where_clause {
fn validate_fields(&self) -> Result<(), #crate_path::ConfigViolation> {
#(#checks)*
#post_validate
Ok(())
}
}
impl #impl_generics #ident #type_generics #where_clause {
/// Field metadata generated from the same attributes as validation.
pub const DESCRIPTORS: &'static [#crate_path::FieldDescriptor] = &[
#(#descriptors),*
];
}
})
}
struct FieldMetadata {
descriptor: Option<TokenStream>,
checks: Vec<TokenStream>,
}
fn expand_field(crate_path: &syn::Path, field: &crate::parse::FieldModel) -> Result<FieldMetadata> {
let mut checks = Vec::new();
let Some(id) = field.id else {
return Ok(FieldMetadata {
descriptor: None,
checks,
});
};
let minimum = field.checks.iter().find_map(|check| match check {
Check::Minimum(value) => Some(value),
_ => None,
});
let maximum = field.checks.iter().find_map(|check| match check {
Check::Maximum(value) => Some(value),
_ => None,
});
for check in &field.checks {
checks.push(expand_check(crate_path, &field.ident, id, check.clone()));
}
let key = field.display_key.as_ref().expect("id fields have a key");
let unit = field.unit.as_ref().expect("id fields have a unit");
let default = field.default.as_ref().expect("id fields have a default");
let (kind, value) = config_value_tokens(crate_path, &field.ty)?;
let minimum = option_value_tokens(value.clone(), minimum);
let maximum = option_value_tokens(value.clone(), maximum);
Ok(FieldMetadata {
descriptor: Some(quote! {
#crate_path::FieldDescriptor {
id: #crate_path::FieldId::new(#id),
display_key: #key,
kind: #kind,
minimum: #minimum,
maximum: #maximum,
default: #value(#default),
unit: #unit,
apply_policy: #crate_path::ApplyPolicy::RestartRequired,
}
}),
checks,
})
}
fn option_value_tokens(value: TokenStream, expression: Option<&syn::Expr>) -> TokenStream {
match expression {
Some(expression) => quote! { Some(#value(#expression)) },
None => quote! { None },
}
}
fn config_value_tokens(
crate_path: &syn::Path,
ty: &syn::Type,
) -> Result<(TokenStream, TokenStream)> {
let syn::Type::Path(path) = ty else {
return Err(syn::Error::new_spanned(
ty,
"configuration metadata requires a scalar type",
));
};
let Some(segment) = path.path.segments.last() else {
return Err(syn::Error::new_spanned(
ty,
"configuration metadata requires a scalar type",
));
};
let (kind, value) = match segment.ident.to_string().as_str() {
"bool" => (
quote! { #crate_path::ValueKind::Bool },
quote! { #crate_path::ConfigValue::Bool },
),
"i32" => (
quote! { #crate_path::ValueKind::I32 },
quote! { #crate_path::ConfigValue::I32 },
),
"u32" => (
quote! { #crate_path::ValueKind::U32 },
quote! { #crate_path::ConfigValue::U32 },
),
"u64" => (
quote! { #crate_path::ValueKind::U64 },
quote! { #crate_path::ConfigValue::U64 },
),
"f32" => (
quote! { #crate_path::ValueKind::F32 },
quote! { #crate_path::ConfigValue::F32 },
),
"f64" => (
quote! { #crate_path::ValueKind::F64 },
quote! { #crate_path::ConfigValue::F64 },
),
_ => {
return Err(syn::Error::new_spanned(
ty,
"configuration metadata supports bool, i32, u32, u64, f32, and f64",
));
}
};
Ok((kind, value))
}
fn expand_check(crate_path: &syn::Path, field: &syn::Ident, id: u16, check: Check) -> TokenStream {
match check {
Check::Finite => quote! {
if !self.#field.is_finite() {
return Err(#crate_path::ConfigViolation::field(
#crate_path::FieldId::new(#id),
#crate_path::ValidationRule::NonFinite,
));
}
},
Check::Nonzero => quote! {
if self.#field == 0 {
return Err(#crate_path::ConfigViolation::field(
#crate_path::FieldId::new(#id),
#crate_path::ValidationRule::Zero,
));
}
},
Check::Minimum(minimum) => quote! {
if self.#field < (#minimum) {
return Err(#crate_path::ConfigViolation::field(
#crate_path::FieldId::new(#id),
#crate_path::ValidationRule::BelowMinimum,
));
}
},
Check::Maximum(maximum) => quote! {
if self.#field > (#maximum) {
return Err(#crate_path::ConfigViolation::field(
#crate_path::FieldId::new(#id),
#crate_path::ValidationRule::AboveMaximum,
));
}
},
}
}
+17
View File
@@ -0,0 +1,17 @@
//! Procedural validation derive for Kaptan configuration records.
mod expand;
mod parse;
use proc_macro::TokenStream;
use syn::parse_macro_input;
/// Implements `kaptan_config::ValidateFields` from `#[config(...)]` helpers.
#[proc_macro_derive(ConfigValidate, attributes(config))]
pub fn derive_config_validate(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as syn::DeriveInput);
match parse::Model::parse(input).and_then(expand::expand) {
Ok(tokens) => tokens.into(),
Err(error) => error.into_compile_error().into(),
}
}
+372
View File
@@ -0,0 +1,372 @@
use syn::{
Attribute, Data, DeriveInput, Expr, Field, Fields, Ident, Lit, Path, Result, Type, UnOp,
spanned::Spanned,
};
pub(crate) struct Model {
pub(crate) ident: Ident,
pub(crate) generics: syn::Generics,
pub(crate) crate_path: Path,
pub(crate) post_validate: Option<Path>,
pub(crate) fields: Vec<FieldModel>,
}
pub(crate) struct FieldModel {
pub(crate) ident: Ident,
pub(crate) ty: Type,
pub(crate) id: Option<u16>,
pub(crate) display_key: Option<syn::LitStr>,
pub(crate) unit: Option<syn::LitStr>,
pub(crate) default: Option<Expr>,
pub(crate) checks: Vec<Check>,
}
#[derive(Clone)]
pub(crate) enum Check {
Finite,
Nonzero,
Minimum(Expr),
Maximum(Expr),
}
impl Model {
pub(crate) fn parse(input: DeriveInput) -> Result<Self> {
let (crate_path, post_validate) = parse_container_attributes(&input.attrs)?;
let Data::Struct(data) = input.data else {
return Err(syn::Error::new(
input.ident.span(),
"ConfigValidate can only be derived for structs",
));
};
let Fields::Named(fields) = data.fields else {
return Err(syn::Error::new(
input.ident.span(),
"ConfigValidate requires named struct fields",
));
};
let mut models = Vec::with_capacity(fields.named.len());
let mut ids = Vec::new();
for field in fields.named {
let model = parse_field(field)?;
if let Some(id) = model.id {
if ids.contains(&id) {
return Err(syn::Error::new(
model.ident.span(),
"duplicate configuration field id",
));
}
ids.push(id);
}
models.push(model);
}
for field in &models {
if field.id.is_some() {
if field.display_key.is_none() {
return Err(syn::Error::new(
field.ident.span(),
"configuration fields require config(key = \"...\")",
));
}
if field.unit.is_none() {
return Err(syn::Error::new(
field.ident.span(),
"configuration fields require config(unit = \"...\")",
));
}
if field.default.is_none() {
return Err(syn::Error::new(
field.ident.span(),
"configuration fields require config(default = ...)",
));
}
}
}
Ok(Self {
ident: input.ident,
generics: input.generics,
crate_path,
post_validate,
fields: models,
})
}
}
fn parse_container_attributes(attributes: &[Attribute]) -> Result<(Path, Option<Path>)> {
let mut crate_path = syn::parse_quote!(::kaptan_config);
let mut has_crate_path = false;
let mut post_validate = None;
for attribute in attributes
.iter()
.filter(|attribute| attribute.path().is_ident("config"))
{
attribute.parse_nested_meta(|meta| {
if meta.path.is_ident("crate") {
if has_crate_path {
return Err(meta.error("duplicate crate path override"));
}
crate_path = meta.value()?.parse()?;
has_crate_path = true;
return Ok(());
}
if meta.path.is_ident("post") {
if post_validate.is_some() {
return Err(meta.error("duplicate post validation hook"));
}
post_validate = Some(meta.value()?.parse()?);
return Ok(());
}
Err(meta.error("struct config options are crate = path and post = path"))
})?;
}
Ok((crate_path, post_validate))
}
fn parse_field(field: Field) -> Result<FieldModel> {
let ident = field
.ident
.clone()
.ok_or_else(|| syn::Error::new(field.span(), "configuration fields must be named"))?;
let mut checks = Vec::new();
let mut id = None;
let mut display_key = None;
let mut unit = None;
let mut default = None;
let mut finite = false;
let mut nonzero = false;
let mut range_minimum = false;
let mut range_maximum = false;
for attribute in field
.attrs
.iter()
.filter(|attribute| attribute.path().is_ident("config"))
{
attribute.parse_nested_meta(|meta| {
if meta.path.is_ident("id") {
if id.is_some() {
return Err(meta.error("duplicate configuration field id"));
}
let literal: syn::LitInt = meta.value()?.parse()?;
let parsed = literal.base10_parse::<u16>()?;
if parsed == 0 || parsed == u16::MAX {
return Err(meta.error("configuration field id is reserved"));
}
id = Some(parsed);
return Ok(());
}
if meta.path.is_ident("key") {
if display_key.is_some() {
return Err(meta.error("duplicate configuration display key"));
}
display_key = Some(meta.value()?.parse()?);
return Ok(());
}
if meta.path.is_ident("unit") {
if unit.is_some() {
return Err(meta.error("duplicate configuration unit"));
}
unit = Some(meta.value()?.parse()?);
return Ok(());
}
if meta.path.is_ident("default") {
if default.is_some() {
return Err(meta.error("duplicate configuration default"));
}
let expression = meta.value()?.parse()?;
validate_default_literal(&expression, &field.ty)?;
default = Some(expression);
return Ok(());
}
if meta.path.is_ident("finite") {
if finite {
return Err(meta.error("duplicate finite validation rule"));
}
require_float(&field.ty, &meta.path)?;
checks.push(Check::Finite);
finite = true;
return Ok(());
}
if meta.path.is_ident("nonzero") {
if nonzero {
return Err(meta.error("duplicate nonzero validation rule"));
}
require_numeric(&field.ty, &meta.path)?;
checks.push(Check::Nonzero);
nonzero = true;
return Ok(());
}
if meta.path.is_ident("range") {
require_numeric(&field.ty, &meta.path)?;
let (minimum, maximum) = parse_range(meta)?;
if minimum.is_none() && maximum.is_none() {
return Err(syn::Error::new(
field.span(),
"range requires min and/or max",
));
}
if let Some(minimum) = minimum {
if range_minimum {
return Err(syn::Error::new(field.span(), "duplicate range minimum"));
}
checks.push(Check::Minimum(minimum));
range_minimum = true;
}
if let Some(maximum) = maximum {
if range_maximum {
return Err(syn::Error::new(field.span(), "duplicate range maximum"));
}
checks.push(Check::Maximum(maximum));
range_maximum = true;
}
return Ok(());
}
Err(meta.error(
"supported field config rules are id, key, unit, default, finite, nonzero, and range",
))
})?;
}
if !checks.is_empty() && id.is_none() {
return Err(syn::Error::new(
ident.span(),
"validated configuration fields require config(id = N)",
));
}
Ok(FieldModel {
ident,
ty: field.ty,
id,
display_key,
unit,
default,
checks,
})
}
fn parse_range(meta: syn::meta::ParseNestedMeta<'_>) -> Result<(Option<Expr>, Option<Expr>)> {
let mut minimum = None;
let mut maximum = None;
meta.parse_nested_meta(|nested| {
if nested.path.is_ident("min") {
if minimum.is_some() {
return Err(nested.error("duplicate range minimum"));
}
let expression = nested.value()?.parse()?;
validate_numeric_literal(&expression)?;
minimum = Some(expression);
return Ok(());
}
if nested.path.is_ident("max") {
if maximum.is_some() {
return Err(nested.error("duplicate range maximum"));
}
let expression = nested.value()?.parse()?;
validate_numeric_literal(&expression)?;
maximum = Some(expression);
return Ok(());
}
Err(nested.error("range accepts only min and max"))
})?;
Ok((minimum, maximum))
}
fn validate_numeric_literal(expression: &Expr) -> Result<()> {
match expression {
Expr::Lit(expression) if matches!(expression.lit, Lit::Int(_) | Lit::Float(_)) => Ok(()),
Expr::Unary(expression)
if matches!(expression.op, UnOp::Neg(_))
&& matches!(
expression.expr.as_ref(),
Expr::Lit(literal) if matches!(literal.lit, Lit::Int(_) | Lit::Float(_))
) =>
{
Ok(())
}
_ => Err(syn::Error::new(
expression.span(),
"range bounds must be numeric literals",
)),
}
}
fn validate_default_literal(expression: &Expr, ty: &Type) -> Result<()> {
if scalar_type_name(ty) == Some("bool") {
return match expression {
Expr::Lit(expression) if matches!(expression.lit, Lit::Bool(_)) => Ok(()),
_ => Err(syn::Error::new(
expression.span(),
"bool configuration defaults must be bool literals",
)),
};
}
validate_numeric_literal(expression)
}
fn require_float(ty: &Type, span: &impl Spanned) -> Result<()> {
if scalar_type_name(ty).is_some_and(|name| name == "f32" || name == "f64") {
Ok(())
} else {
Err(syn::Error::new(
span.span(),
"finite requires an f32 or f64 field",
))
}
}
fn require_numeric(ty: &Type, span: &impl Spanned) -> Result<()> {
if scalar_type_name(ty).is_some_and(|name| {
matches!(
name,
"f32"
| "f64"
| "i8"
| "i16"
| "i32"
| "i64"
| "i128"
| "isize"
| "u8"
| "u16"
| "u32"
| "u64"
| "u128"
| "usize"
)
}) {
Ok(())
} else {
Err(syn::Error::new(
span.span(),
"nonzero and range require a primitive numeric field",
))
}
}
fn scalar_type_name(ty: &Type) -> Option<&str> {
let Type::Path(path) = ty else {
return None;
};
if path.qself.is_some() || path.path.segments.len() != 1 {
return None;
}
let segment = path.path.segments.first()?;
if !matches!(segment.arguments, syn::PathArguments::None) {
return None;
}
Some(match &segment.ident {
ident if ident == "bool" => "bool",
ident if ident == "f32" => "f32",
ident if ident == "f64" => "f64",
ident if ident == "i8" => "i8",
ident if ident == "i16" => "i16",
ident if ident == "i32" => "i32",
ident if ident == "i64" => "i64",
ident if ident == "i128" => "i128",
ident if ident == "isize" => "isize",
ident if ident == "u8" => "u8",
ident if ident == "u16" => "u16",
ident if ident == "u32" => "u32",
ident if ident == "u64" => "u64",
ident if ident == "u128" => "u128",
ident if ident == "usize" => "usize",
_ => return None,
})
}