refactor(config): generate scalar validation boundaries
This commit is contained in:
1 parent
afafd126d7
commit
8134235f48
29 files changed
+1007
-479
No files matched your search
@@ -2,7 +2,7 @@ use proc_macro2::TokenStream;
|
|||||||
use quote::quote;
|
use quote::quote;
|
||||||
use syn::Result;
|
use syn::Result;
|
||||||
|
|
||||||
use crate::parse::{Check, Model};
|
use crate::parse::{Check, Model, ModelKind};
|
||||||
|
|
||||||
pub(crate) fn expand(model: Model) -> Result<TokenStream> {
|
pub(crate) fn expand(model: Model) -> Result<TokenStream> {
|
||||||
let Model {
|
let Model {
|
||||||
@@ -10,26 +10,77 @@ pub(crate) fn expand(model: Model) -> Result<TokenStream> {
|
|||||||
generics,
|
generics,
|
||||||
crate_path,
|
crate_path,
|
||||||
post_validate,
|
post_validate,
|
||||||
|
kind,
|
||||||
fields,
|
fields,
|
||||||
} = model;
|
} = model;
|
||||||
|
match kind {
|
||||||
|
ModelKind::Scalars => expand_scalars(ident, generics, crate_path, post_validate, fields),
|
||||||
|
ModelKind::Sections => expand_sections(ident, generics, crate_path, post_validate, fields),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand_scalars(
|
||||||
|
ident: syn::Ident,
|
||||||
|
generics: syn::Generics,
|
||||||
|
crate_path: syn::Path,
|
||||||
|
post_validate: Option<syn::Path>,
|
||||||
|
fields: Vec<crate::parse::FieldModel>,
|
||||||
|
) -> Result<TokenStream> {
|
||||||
let (impl_generics, type_generics, where_clause) = generics.split_for_impl();
|
let (impl_generics, type_generics, where_clause) = generics.split_for_impl();
|
||||||
let mut checks = Vec::new();
|
let mut checks = Vec::new();
|
||||||
let mut descriptors = Vec::new();
|
let mut descriptors = Vec::new();
|
||||||
for field in fields {
|
let mut defaults = Vec::new();
|
||||||
|
let mut reads = Vec::new();
|
||||||
|
let mut writes = Vec::new();
|
||||||
|
for field in &fields {
|
||||||
let FieldMetadata {
|
let FieldMetadata {
|
||||||
descriptor,
|
descriptor,
|
||||||
checks: field_checks,
|
checks: field_checks,
|
||||||
} = expand_field(&crate_path, &field)?;
|
} = expand_field(&crate_path, field)?;
|
||||||
if let Some(descriptor) = descriptor {
|
if let Some(descriptor) = descriptor {
|
||||||
descriptors.push(descriptor);
|
descriptors.push(descriptor);
|
||||||
}
|
}
|
||||||
checks.extend(field_checks);
|
checks.extend(field_checks);
|
||||||
|
let id = field.id.expect("ConfigValidate requires configured fields");
|
||||||
|
let default = field
|
||||||
|
.default
|
||||||
|
.as_ref()
|
||||||
|
.expect("ConfigValidate requires defaults");
|
||||||
|
let (kind, value) = config_value_tokens(&crate_path, &field.ty)?;
|
||||||
|
let field_ident = &field.ident;
|
||||||
|
defaults.push(quote! { #field_ident: #default });
|
||||||
|
reads.push(quote! {
|
||||||
|
#id => Some(#value(self.#field_ident)),
|
||||||
|
});
|
||||||
|
writes.push(quote! {
|
||||||
|
#id => {
|
||||||
|
let next = match value {
|
||||||
|
#value(next) => next,
|
||||||
|
actual => {
|
||||||
|
return Err(#crate_path::FieldWriteError::WrongType {
|
||||||
|
field: #crate_path::FieldId::new(#id),
|
||||||
|
expected: #kind,
|
||||||
|
actual: actual.kind(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let previous = self.#field_ident;
|
||||||
|
self.#field_ident = next;
|
||||||
|
if let Err(violation) =
|
||||||
|
<Self as #crate_path::ConfigSection>::validate_local_fields(self)
|
||||||
|
{
|
||||||
|
self.#field_ident = previous;
|
||||||
|
return Err(#crate_path::FieldWriteError::Violation(violation));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
let post_validate = post_validate.map(|hook| quote! { #hook(self)?; });
|
let post_validate = post_validate.map(|hook| quote! { #hook(self)?; });
|
||||||
Ok(quote! {
|
Ok(quote! {
|
||||||
impl #impl_generics #crate_path::ValidateFields for #ident #type_generics #where_clause {
|
impl #impl_generics #crate_path::ValidateFields for #ident #type_generics #where_clause {
|
||||||
fn validate_fields(&self) -> Result<(), #crate_path::ConfigViolation> {
|
fn validate_fields(&self) -> Result<(), #crate_path::ConfigViolation> {
|
||||||
#(#checks)*
|
<Self as #crate_path::ConfigSection>::validate_local_fields(self)?;
|
||||||
#post_validate
|
#post_validate
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -41,6 +92,133 @@ pub(crate) fn expand(model: Model) -> Result<TokenStream> {
|
|||||||
#(#descriptors),*
|
#(#descriptors),*
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl #impl_generics #crate_path::ConfigSection for #ident #type_generics #where_clause {
|
||||||
|
const FIELD_COUNT: usize = Self::DESCRIPTORS.len();
|
||||||
|
|
||||||
|
fn default_section() -> Result<Self, #crate_path::ConfigViolation> {
|
||||||
|
let section = Self {
|
||||||
|
#(#defaults),*
|
||||||
|
};
|
||||||
|
<Self as #crate_path::ValidateFields>::validate_fields(§ion)?;
|
||||||
|
Ok(section)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_value(&self, id: #crate_path::FieldId) -> Option<#crate_path::ConfigValue> {
|
||||||
|
match id.get() {
|
||||||
|
#(#reads)*
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_field(
|
||||||
|
&mut self,
|
||||||
|
id: #crate_path::FieldId,
|
||||||
|
value: #crate_path::ConfigValue,
|
||||||
|
) -> Result<(), #crate_path::FieldWriteError> {
|
||||||
|
match id.get() {
|
||||||
|
#(#writes,)*
|
||||||
|
_ => Err(#crate_path::FieldWriteError::UnknownField(id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_local_fields(&self) -> Result<(), #crate_path::ConfigViolation> {
|
||||||
|
#(#checks)*
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand_sections(
|
||||||
|
ident: syn::Ident,
|
||||||
|
generics: syn::Generics,
|
||||||
|
crate_path: syn::Path,
|
||||||
|
post_validate: Option<syn::Path>,
|
||||||
|
fields: Vec<crate::parse::FieldModel>,
|
||||||
|
) -> Result<TokenStream> {
|
||||||
|
let (impl_generics, type_generics, where_clause) = generics.split_for_impl();
|
||||||
|
let validations = fields.iter().map(|field| {
|
||||||
|
let field_ident = &field.ident;
|
||||||
|
let ty = &field.ty;
|
||||||
|
quote! {
|
||||||
|
<#ty as #crate_path::ValidateFields>::validate_fields(&self.#field_ident)?;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let defaults = fields.iter().map(|field| {
|
||||||
|
let field_ident = &field.ident;
|
||||||
|
let ty = &field.ty;
|
||||||
|
quote! {
|
||||||
|
#field_ident: <#ty as #crate_path::ConfigSection>::default_section()?
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let reads = fields.iter().map(|field| {
|
||||||
|
let field_ident = &field.ident;
|
||||||
|
let ty = &field.ty;
|
||||||
|
quote! {
|
||||||
|
if let Some(value) = <#ty as #crate_path::ConfigSection>::field_value(
|
||||||
|
&self.#field_ident,
|
||||||
|
id,
|
||||||
|
) {
|
||||||
|
return Some(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let writes = fields.iter().map(|field| {
|
||||||
|
let field_ident = &field.ident;
|
||||||
|
let ty = &field.ty;
|
||||||
|
quote! {
|
||||||
|
if <#ty as #crate_path::ConfigSection>::field_value(&self.#field_ident, id).is_some() {
|
||||||
|
return <#ty as #crate_path::ConfigSection>::set_field(
|
||||||
|
&mut self.#field_ident,
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let counts = fields.iter().map(|field| {
|
||||||
|
let ty = &field.ty;
|
||||||
|
quote! { <#ty as #crate_path::ConfigSection>::FIELD_COUNT }
|
||||||
|
});
|
||||||
|
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> {
|
||||||
|
#(#validations)*
|
||||||
|
#post_validate
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl #impl_generics #ident #type_generics #where_clause {
|
||||||
|
/// Number of fields aggregated from derived nested sections.
|
||||||
|
pub const FIELD_COUNT: usize = 0 #(+ #counts)*;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl #impl_generics #crate_path::ConfigAggregate for #ident #type_generics #where_clause {
|
||||||
|
fn default_aggregate() -> Result<Self, #crate_path::ConfigViolation> {
|
||||||
|
let aggregate = Self {
|
||||||
|
#(#defaults),*
|
||||||
|
};
|
||||||
|
<Self as #crate_path::ValidateFields>::validate_fields(&aggregate)?;
|
||||||
|
Ok(aggregate)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_value(&self, id: #crate_path::FieldId) -> Option<#crate_path::ConfigValue> {
|
||||||
|
#(#reads)*
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_field(
|
||||||
|
&mut self,
|
||||||
|
id: #crate_path::FieldId,
|
||||||
|
value: #crate_path::ConfigValue,
|
||||||
|
) -> Result<(), #crate_path::FieldWriteError> {
|
||||||
|
#(#writes)*
|
||||||
|
Err(#crate_path::FieldWriteError::UnknownField(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,16 @@ pub(crate) struct Model {
|
|||||||
pub(crate) generics: syn::Generics,
|
pub(crate) generics: syn::Generics,
|
||||||
pub(crate) crate_path: Path,
|
pub(crate) crate_path: Path,
|
||||||
pub(crate) post_validate: Option<Path>,
|
pub(crate) post_validate: Option<Path>,
|
||||||
|
pub(crate) kind: ModelKind,
|
||||||
pub(crate) fields: Vec<FieldModel>,
|
pub(crate) fields: Vec<FieldModel>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||||
|
pub(crate) enum ModelKind {
|
||||||
|
Scalars,
|
||||||
|
Sections,
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) struct FieldModel {
|
pub(crate) struct FieldModel {
|
||||||
pub(crate) ident: Ident,
|
pub(crate) ident: Ident,
|
||||||
pub(crate) ty: Type,
|
pub(crate) ty: Type,
|
||||||
@@ -19,6 +26,7 @@ pub(crate) struct FieldModel {
|
|||||||
pub(crate) unit: Option<syn::LitStr>,
|
pub(crate) unit: Option<syn::LitStr>,
|
||||||
pub(crate) default: Option<Expr>,
|
pub(crate) default: Option<Expr>,
|
||||||
pub(crate) checks: Vec<Check>,
|
pub(crate) checks: Vec<Check>,
|
||||||
|
pub(crate) section: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -44,6 +52,12 @@ impl Model {
|
|||||||
"ConfigValidate requires named struct fields",
|
"ConfigValidate requires named struct fields",
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
if fields.named.is_empty() {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
input.ident.span(),
|
||||||
|
"ConfigValidate requires at least one configured field or section",
|
||||||
|
));
|
||||||
|
}
|
||||||
let mut models = Vec::with_capacity(fields.named.len());
|
let mut models = Vec::with_capacity(fields.named.len());
|
||||||
let mut ids = Vec::new();
|
let mut ids = Vec::new();
|
||||||
for field in fields.named {
|
for field in fields.named {
|
||||||
@@ -59,8 +73,22 @@ impl Model {
|
|||||||
}
|
}
|
||||||
models.push(model);
|
models.push(model);
|
||||||
}
|
}
|
||||||
|
let kind = if models.iter().all(|field| field.section) {
|
||||||
|
ModelKind::Sections
|
||||||
|
} else if models.iter().all(|field| field.id.is_some()) {
|
||||||
|
ModelKind::Scalars
|
||||||
|
} else {
|
||||||
|
let field = models
|
||||||
|
.iter()
|
||||||
|
.find(|field| field.section || field.id.is_none())
|
||||||
|
.expect("mixed configuration model has one invalid field");
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
field.ident.span(),
|
||||||
|
"ConfigValidate records must contain either scalar config fields or config(section) fields exclusively",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if kind == ModelKind::Scalars {
|
||||||
for field in &models {
|
for field in &models {
|
||||||
if field.id.is_some() {
|
|
||||||
if field.display_key.is_none() {
|
if field.display_key.is_none() {
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
field.ident.span(),
|
field.ident.span(),
|
||||||
@@ -79,6 +107,7 @@ impl Model {
|
|||||||
"configuration fields require config(default = ...)",
|
"configuration fields require config(default = ...)",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
validate_static_constraints(field)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -86,6 +115,7 @@ impl Model {
|
|||||||
generics: input.generics,
|
generics: input.generics,
|
||||||
crate_path,
|
crate_path,
|
||||||
post_validate,
|
post_validate,
|
||||||
|
kind,
|
||||||
fields: models,
|
fields: models,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -135,12 +165,35 @@ fn parse_field(field: Field) -> Result<FieldModel> {
|
|||||||
let mut nonzero = false;
|
let mut nonzero = false;
|
||||||
let mut range_minimum = false;
|
let mut range_minimum = false;
|
||||||
let mut range_maximum = false;
|
let mut range_maximum = false;
|
||||||
|
let mut section = false;
|
||||||
for attribute in field
|
for attribute in field
|
||||||
.attrs
|
.attrs
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|attribute| attribute.path().is_ident("config"))
|
.filter(|attribute| attribute.path().is_ident("config"))
|
||||||
{
|
{
|
||||||
attribute.parse_nested_meta(|meta| {
|
attribute.parse_nested_meta(|meta| {
|
||||||
|
if section && !meta.path.is_ident("section") {
|
||||||
|
return Err(meta.error(
|
||||||
|
"config(section) cannot be combined with scalar id, metadata, or validation rules",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if meta.path.is_ident("section") {
|
||||||
|
if section {
|
||||||
|
return Err(meta.error("duplicate config(section)"));
|
||||||
|
}
|
||||||
|
if id.is_some()
|
||||||
|
|| display_key.is_some()
|
||||||
|
|| unit.is_some()
|
||||||
|
|| default.is_some()
|
||||||
|
|| !checks.is_empty()
|
||||||
|
{
|
||||||
|
return Err(meta.error(
|
||||||
|
"config(section) cannot be combined with scalar id, metadata, or validation rules",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
section = true;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
if meta.path.is_ident("id") {
|
if meta.path.is_ident("id") {
|
||||||
if id.is_some() {
|
if id.is_some() {
|
||||||
return Err(meta.error("duplicate configuration field id"));
|
return Err(meta.error("duplicate configuration field id"));
|
||||||
@@ -224,12 +277,24 @@ fn parse_field(field: Field) -> Result<FieldModel> {
|
|||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
if !checks.is_empty() && id.is_none() {
|
if !section && !checks.is_empty() && id.is_none() {
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
ident.span(),
|
ident.span(),
|
||||||
"validated configuration fields require config(id = N)",
|
"validated configuration fields require config(id = N)",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if section
|
||||||
|
&& (id.is_some()
|
||||||
|
|| display_key.is_some()
|
||||||
|
|| unit.is_some()
|
||||||
|
|| default.is_some()
|
||||||
|
|| !checks.is_empty())
|
||||||
|
{
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
ident.span(),
|
||||||
|
"config(section) cannot be combined with scalar id, metadata, or validation rules",
|
||||||
|
));
|
||||||
|
}
|
||||||
Ok(FieldModel {
|
Ok(FieldModel {
|
||||||
ident,
|
ident,
|
||||||
ty: field.ty,
|
ty: field.ty,
|
||||||
@@ -238,6 +303,7 @@ fn parse_field(field: Field) -> Result<FieldModel> {
|
|||||||
unit,
|
unit,
|
||||||
default,
|
default,
|
||||||
checks,
|
checks,
|
||||||
|
section,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,6 +366,276 @@ fn validate_default_literal(expression: &Expr, ty: &Type) -> Result<()> {
|
|||||||
validate_numeric_literal(expression)
|
validate_numeric_literal(expression)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_static_constraints(field: &FieldModel) -> Result<()> {
|
||||||
|
let ty = scalar_type_name(&field.ty).ok_or_else(|| {
|
||||||
|
syn::Error::new(
|
||||||
|
field.ty.span(),
|
||||||
|
"configuration metadata requires a supported scalar type",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if ty == "bool" {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let default = scalar_literal(
|
||||||
|
field.default.as_ref().expect("scalar fields have defaults"),
|
||||||
|
ty,
|
||||||
|
)?;
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
let minimum = minimum.map(|value| scalar_literal(value, ty)).transpose()?;
|
||||||
|
let maximum = maximum.map(|value| scalar_literal(value, ty)).transpose()?;
|
||||||
|
if minimum.is_some_and(|value| !value.is_finite())
|
||||||
|
|| maximum.is_some_and(|value| !value.is_finite())
|
||||||
|
{
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
field.ident.span(),
|
||||||
|
"configuration range bounds must be finite",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let (Some(minimum), Some(maximum)) = (minimum, maximum)
|
||||||
|
&& compare_scalar(minimum, maximum).is_gt()
|
||||||
|
{
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
field.ident.span(),
|
||||||
|
"configuration range minimum exceeds maximum",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if field
|
||||||
|
.checks
|
||||||
|
.iter()
|
||||||
|
.any(|check| matches!(check, Check::Finite))
|
||||||
|
&& !default.is_finite()
|
||||||
|
{
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
field
|
||||||
|
.default
|
||||||
|
.as_ref()
|
||||||
|
.expect("scalar fields have defaults")
|
||||||
|
.span(),
|
||||||
|
"configuration default violates finite",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !default.is_finite() {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
field
|
||||||
|
.default
|
||||||
|
.as_ref()
|
||||||
|
.expect("scalar fields have defaults")
|
||||||
|
.span(),
|
||||||
|
"configuration default is not finite for its field type",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if field
|
||||||
|
.checks
|
||||||
|
.iter()
|
||||||
|
.any(|check| matches!(check, Check::Nonzero))
|
||||||
|
&& default.is_zero()
|
||||||
|
{
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
field
|
||||||
|
.default
|
||||||
|
.as_ref()
|
||||||
|
.expect("scalar fields have defaults")
|
||||||
|
.span(),
|
||||||
|
"configuration default violates nonzero",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(minimum) = minimum
|
||||||
|
&& compare_scalar(default, minimum).is_lt()
|
||||||
|
{
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
field
|
||||||
|
.default
|
||||||
|
.as_ref()
|
||||||
|
.expect("scalar fields have defaults")
|
||||||
|
.span(),
|
||||||
|
"configuration default is below its declared minimum",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(maximum) = maximum
|
||||||
|
&& compare_scalar(default, maximum).is_gt()
|
||||||
|
{
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
field
|
||||||
|
.default
|
||||||
|
.as_ref()
|
||||||
|
.expect("scalar fields have defaults")
|
||||||
|
.span(),
|
||||||
|
"configuration default is above its declared maximum",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum ScalarLiteral {
|
||||||
|
Signed(i128),
|
||||||
|
Unsigned(u128),
|
||||||
|
Float(f64),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScalarLiteral {
|
||||||
|
fn is_finite(self) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::Signed(_) | Self::Unsigned(_) => true,
|
||||||
|
Self::Float(value) => value.is_finite(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_zero(self) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::Signed(value) => value == 0,
|
||||||
|
Self::Unsigned(value) => value == 0,
|
||||||
|
Self::Float(value) => value == 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scalar_literal(expression: &Expr, ty: &str) -> Result<ScalarLiteral> {
|
||||||
|
match ty {
|
||||||
|
"i32" => {
|
||||||
|
let value = signed_literal(expression)?;
|
||||||
|
i32::try_from(value).map_err(|_| {
|
||||||
|
syn::Error::new(
|
||||||
|
expression.span(),
|
||||||
|
"configuration literal is outside i32 range",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(ScalarLiteral::Signed(value))
|
||||||
|
}
|
||||||
|
"u32" => {
|
||||||
|
let value = unsigned_literal(expression)?;
|
||||||
|
u32::try_from(value).map_err(|_| {
|
||||||
|
syn::Error::new(
|
||||||
|
expression.span(),
|
||||||
|
"configuration literal is outside u32 range",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(ScalarLiteral::Unsigned(value))
|
||||||
|
}
|
||||||
|
"u64" => {
|
||||||
|
let value = unsigned_literal(expression)?;
|
||||||
|
u64::try_from(value).map_err(|_| {
|
||||||
|
syn::Error::new(
|
||||||
|
expression.span(),
|
||||||
|
"configuration literal is outside u64 range",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(ScalarLiteral::Unsigned(value))
|
||||||
|
}
|
||||||
|
"f32" => Ok(ScalarLiteral::Float(f64::from(
|
||||||
|
float_literal(expression)? as f32
|
||||||
|
))),
|
||||||
|
"f64" => float_literal(expression).map(ScalarLiteral::Float),
|
||||||
|
_ => Err(syn::Error::new(
|
||||||
|
expression.span(),
|
||||||
|
"configuration metadata supports bool, i32, u32, u64, f32, and f64",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signed_literal(expression: &Expr) -> Result<i128> {
|
||||||
|
match expression {
|
||||||
|
Expr::Lit(expression) => match &expression.lit {
|
||||||
|
Lit::Int(value) => value.base10_parse(),
|
||||||
|
_ => Err(syn::Error::new(
|
||||||
|
expression.span(),
|
||||||
|
"expected an integer literal",
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
Expr::Unary(expression) if matches!(expression.op, UnOp::Neg(_)) => {
|
||||||
|
let Expr::Lit(value) = expression.expr.as_ref() else {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
expression.span(),
|
||||||
|
"expected an integer literal",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let Lit::Int(value) = &value.lit else {
|
||||||
|
return Err(syn::Error::new(value.span(), "expected an integer literal"));
|
||||||
|
};
|
||||||
|
value
|
||||||
|
.base10_parse::<i128>()
|
||||||
|
.map(|value| -value)
|
||||||
|
.map_err(|error| syn::Error::new(value.span(), error))
|
||||||
|
}
|
||||||
|
_ => Err(syn::Error::new(
|
||||||
|
expression.span(),
|
||||||
|
"expected an integer literal",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unsigned_literal(expression: &Expr) -> Result<u128> {
|
||||||
|
match expression {
|
||||||
|
Expr::Lit(expression) => match &expression.lit {
|
||||||
|
Lit::Int(value) => value
|
||||||
|
.base10_parse()
|
||||||
|
.map_err(|error| syn::Error::new(value.span(), error)),
|
||||||
|
_ => Err(syn::Error::new(
|
||||||
|
expression.span(),
|
||||||
|
"expected an unsigned integer literal",
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
_ => Err(syn::Error::new(
|
||||||
|
expression.span(),
|
||||||
|
"expected an unsigned integer literal",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn float_literal(expression: &Expr) -> Result<f64> {
|
||||||
|
let (negative, literal) = match expression {
|
||||||
|
Expr::Lit(expression) => (false, &expression.lit),
|
||||||
|
Expr::Unary(expression) if matches!(expression.op, UnOp::Neg(_)) => {
|
||||||
|
let Expr::Lit(value) = expression.expr.as_ref() else {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
expression.span(),
|
||||||
|
"expected a float literal",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
(true, &value.lit)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
expression.span(),
|
||||||
|
"expected a float literal",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let value = match literal {
|
||||||
|
Lit::Int(value) => value
|
||||||
|
.base10_parse::<f64>()
|
||||||
|
.map_err(|error| syn::Error::new(value.span(), error))?,
|
||||||
|
Lit::Float(value) => value
|
||||||
|
.base10_parse::<f64>()
|
||||||
|
.map_err(|error| syn::Error::new(value.span(), error))?,
|
||||||
|
_ => {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
expression.span(),
|
||||||
|
"expected a float literal",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(if negative { -value } else { value })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compare_scalar(left: ScalarLiteral, right: ScalarLiteral) -> core::cmp::Ordering {
|
||||||
|
match (left, right) {
|
||||||
|
(ScalarLiteral::Signed(left), ScalarLiteral::Signed(right)) => left.cmp(&right),
|
||||||
|
(ScalarLiteral::Unsigned(left), ScalarLiteral::Unsigned(right)) => left.cmp(&right),
|
||||||
|
(ScalarLiteral::Float(left), ScalarLiteral::Float(right)) => {
|
||||||
|
left.partial_cmp(&right).expect("finite literals compare")
|
||||||
|
}
|
||||||
|
_ => unreachable!("field literals share their scalar type"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn require_float(ty: &Type, span: &impl Spanned) -> Result<()> {
|
fn require_float(ty: &Type, span: &impl Spanned) -> Result<()> {
|
||||||
if scalar_type_name(ty).is_some_and(|name| name == "f32" || name == "f64") {
|
if scalar_type_name(ty).is_some_and(|name| name == "f32" || name == "f64") {
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
use kaptan_config::{ConfigAggregate, ConfigSection, ConfigViolation, ValidateFields};
|
||||||
|
use kaptan_config_derive::ConfigValidate;
|
||||||
|
|
||||||
|
#[derive(ConfigValidate)]
|
||||||
|
struct Child {
|
||||||
|
#[config(id = 1, key = "child.value", unit = "count", default = 1, range(min = 0, max = 2))]
|
||||||
|
value: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(ConfigValidate)]
|
||||||
|
#[config(post = Self::validate_complete)]
|
||||||
|
struct Root {
|
||||||
|
#[config(section)]
|
||||||
|
child: Child,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Root {
|
||||||
|
fn validate_complete(&self) -> Result<(), ConfigViolation> {
|
||||||
|
self.child.validate_fields()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let root = Root::default_aggregate().unwrap();
|
||||||
|
assert_eq!(Root::FIELD_COUNT, 1);
|
||||||
|
assert_eq!(root.child.field_value(kaptan_config::FieldId::new(1)), Some(kaptan_config::ConfigValue::U32(1)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
use kaptan_config::{
|
||||||
|
ConfigAggregate, ConfigSection, ConfigValue, ConfigViolation, FieldId, FieldWriteError,
|
||||||
|
ValidateFields, ValidationRule,
|
||||||
|
};
|
||||||
|
use kaptan_config_derive::ConfigValidate;
|
||||||
|
|
||||||
|
#[derive(ConfigValidate)]
|
||||||
|
struct LocalSection {
|
||||||
|
#[config(
|
||||||
|
id = 1,
|
||||||
|
key = "local.first",
|
||||||
|
unit = "count",
|
||||||
|
default = 2,
|
||||||
|
range(min = 1, max = 9)
|
||||||
|
)]
|
||||||
|
first: u32,
|
||||||
|
#[config(
|
||||||
|
id = 2,
|
||||||
|
key = "local.second",
|
||||||
|
unit = "count",
|
||||||
|
default = 4,
|
||||||
|
range(min = 1, max = 9)
|
||||||
|
)]
|
||||||
|
second: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(ConfigValidate)]
|
||||||
|
#[config(post = Self::validate_order)]
|
||||||
|
struct OrderedSection {
|
||||||
|
#[config(
|
||||||
|
id = 3,
|
||||||
|
key = "ordered.minimum",
|
||||||
|
unit = "count",
|
||||||
|
default = 1,
|
||||||
|
range(min = 0, max = 9)
|
||||||
|
)]
|
||||||
|
minimum: u32,
|
||||||
|
#[config(
|
||||||
|
id = 4,
|
||||||
|
key = "ordered.maximum",
|
||||||
|
unit = "count",
|
||||||
|
default = 2,
|
||||||
|
range(min = 0, max = 9)
|
||||||
|
)]
|
||||||
|
maximum: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrderedSection {
|
||||||
|
fn validate_order(&self) -> Result<(), ConfigViolation> {
|
||||||
|
if self.minimum > self.maximum {
|
||||||
|
return Err(ConfigViolation::related(
|
||||||
|
FieldId::new(3),
|
||||||
|
FieldId::new(4),
|
||||||
|
ValidationRule::Inconsistent,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(ConfigValidate)]
|
||||||
|
struct Aggregate {
|
||||||
|
#[config(section)]
|
||||||
|
local: LocalSection,
|
||||||
|
#[config(section)]
|
||||||
|
ordered: OrderedSection,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_section_owns_every_declared_scalar_path() {
|
||||||
|
let mut section = LocalSection::default_section().unwrap();
|
||||||
|
assert_eq!(LocalSection::FIELD_COUNT, 2);
|
||||||
|
assert_eq!(
|
||||||
|
section.field_value(FieldId::new(2)),
|
||||||
|
Some(ConfigValue::U32(4))
|
||||||
|
);
|
||||||
|
section
|
||||||
|
.set_field(FieldId::new(2), ConfigValue::U32(8))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
section.field_value(FieldId::new(2)),
|
||||||
|
Some(ConfigValue::U32(8))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
section.set_field(FieldId::new(2), ConfigValue::U32(0)),
|
||||||
|
Err(FieldWriteError::Violation(ConfigViolation::field(
|
||||||
|
FieldId::new(2),
|
||||||
|
ValidationRule::BelowMinimum,
|
||||||
|
)))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
section.field_value(FieldId::new(2)),
|
||||||
|
Some(ConfigValue::U32(8))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn aggregate_routes_writes_and_defers_declared_post_validation() {
|
||||||
|
let mut aggregate = Aggregate::default_aggregate().unwrap();
|
||||||
|
assert_eq!(Aggregate::FIELD_COUNT, 4);
|
||||||
|
assert_eq!(
|
||||||
|
aggregate.field_value(FieldId::new(2)),
|
||||||
|
Some(ConfigValue::U32(4))
|
||||||
|
);
|
||||||
|
aggregate
|
||||||
|
.set_field(FieldId::new(3), ConfigValue::U32(3))
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
aggregate.validate_fields(),
|
||||||
|
Err(issue)
|
||||||
|
if issue.primary() == Some(FieldId::new(3))
|
||||||
|
&& issue.related_field() == Some(FieldId::new(4))
|
||||||
|
&& issue.rule() == ValidationRule::Inconsistent
|
||||||
|
));
|
||||||
|
aggregate
|
||||||
|
.set_field(FieldId::new(4), ConfigValue::U32(3))
|
||||||
|
.unwrap();
|
||||||
|
aggregate.validate_fields().unwrap();
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
use kaptan_config_derive::ConfigValidate;
|
||||||
|
|
||||||
|
#[derive(ConfigValidate)]
|
||||||
|
struct DefaultBelowMinimum {
|
||||||
|
#[config(id = 1, key = "value", unit = "count", default = 1, range(min = 2, max = 3))]
|
||||||
|
value: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: configuration default is below its declared minimum
|
||||||
|
--> tests/ui/default_below_minimum.rs:5:63
|
||||||
|
|
|
||||||
|
5 | #[config(id = 1, key = "value", unit = "count", default = 1, range(min = 2, max = 3))]
|
||||||
|
| ^
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
use kaptan_config_derive::ConfigValidate;
|
||||||
|
|
||||||
|
#[derive(ConfigValidate)]
|
||||||
|
struct NonFiniteDefault {
|
||||||
|
#[config(id = 1, key = "value", unit = "ratio", default = 1e100, finite)]
|
||||||
|
value: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: configuration default violates finite
|
||||||
|
--> tests/ui/default_violates_finite.rs:5:63
|
||||||
|
|
|
||||||
|
5 | #[config(id = 1, key = "value", unit = "ratio", default = 1e100, finite)]
|
||||||
|
| ^^^^^
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
use kaptan_config_derive::ConfigValidate;
|
||||||
|
|
||||||
|
#[derive(ConfigValidate)]
|
||||||
|
struct ZeroDefault {
|
||||||
|
#[config(id = 1, key = "value", unit = "count", default = 0, nonzero)]
|
||||||
|
value: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: configuration default violates nonzero
|
||||||
|
--> tests/ui/default_violates_nonzero.rs:5:63
|
||||||
|
|
|
||||||
|
5 | #[config(id = 1, key = "value", unit = "count", default = 0, nonzero)]
|
||||||
|
| ^
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
use kaptan_config_derive::ConfigValidate;
|
||||||
|
|
||||||
|
#[derive(ConfigValidate)]
|
||||||
|
struct Empty {}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: ConfigValidate requires at least one configured field or section
|
||||||
|
--> tests/ui/empty.rs:4:8
|
||||||
|
|
|
||||||
|
4 | struct Empty {}
|
||||||
|
| ^^^^^
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
use kaptan_config_derive::ConfigValidate;
|
||||||
|
|
||||||
|
#[derive(ConfigValidate)]
|
||||||
|
struct MixedSectionAndScalar {
|
||||||
|
#[config(section)]
|
||||||
|
child: u32,
|
||||||
|
#[config(id = 1, key = "scalar", unit = "count", default = 1, range(min = 0, max = 2))]
|
||||||
|
scalar: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: ConfigValidate records must contain either scalar config fields or config(section) fields exclusively
|
||||||
|
--> tests/ui/mixed_section_scalar.rs:6:5
|
||||||
|
|
|
||||||
|
6 | child: u32,
|
||||||
|
| ^^^^^
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
use kaptan_config_derive::ConfigValidate;
|
||||||
|
|
||||||
|
#[derive(ConfigValidate)]
|
||||||
|
struct InvalidRange {
|
||||||
|
#[config(id = 1, key = "value", unit = "count", default = 2, range(min = 3, max = 1))]
|
||||||
|
value: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: configuration range minimum exceeds maximum
|
||||||
|
--> tests/ui/range_minimum_exceeds_maximum.rs:6:5
|
||||||
|
|
|
||||||
|
6 | value: u32,
|
||||||
|
| ^^^^^
|
||||||
@@ -22,4 +22,4 @@ pub use field::{
|
|||||||
pub use kaptan_config_derive::ConfigValidate;
|
pub use kaptan_config_derive::ConfigValidate;
|
||||||
pub use lifecycle::{BeginError, CommitError, ConfigManager, ConfigState, SetError, StartupError};
|
pub use lifecycle::{BeginError, CommitError, ConfigManager, ConfigState, SetError, StartupError};
|
||||||
pub use storage::{Generation, ProfileStore};
|
pub use storage::{Generation, ProfileStore};
|
||||||
pub use validation::ValidateFields;
|
pub use validation::{ConfigAggregate, ConfigSection, ValidateFields};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Local scalar validation implemented by the configuration derive macro.
|
//! Local scalar configuration sections implemented by the derive macro.
|
||||||
|
|
||||||
use crate::ConfigViolation;
|
use crate::{ConfigValue, ConfigViolation, FieldId, FieldWriteError};
|
||||||
|
|
||||||
/// Validates local scalar invariants of one record.
|
/// Validates local scalar invariants of one record.
|
||||||
///
|
///
|
||||||
@@ -11,3 +11,53 @@ pub trait ValidateFields {
|
|||||||
/// Rejects the first violated scalar or declared post-validation rule.
|
/// Rejects the first violated scalar or declared post-validation rule.
|
||||||
fn validate_fields(&self) -> Result<(), ConfigViolation>;
|
fn validate_fields(&self) -> Result<(), ConfigViolation>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A complete scalar configuration section generated from `#[config(...)]`
|
||||||
|
/// field attributes.
|
||||||
|
///
|
||||||
|
/// Its metadata, default construction, reads, local writes, and scalar
|
||||||
|
/// validation are all produced from the same field declarations. A section
|
||||||
|
/// setter never commits a locally invalid value. Cross-section and capability
|
||||||
|
/// invariants deliberately remain the aggregate candidate's responsibility.
|
||||||
|
pub trait ConfigSection: ValidateFields {
|
||||||
|
/// Number of scalar descriptors generated for this section.
|
||||||
|
const FIELD_COUNT: usize;
|
||||||
|
|
||||||
|
/// Builds the section from derive-generated defaults after validating it.
|
||||||
|
fn default_section() -> Result<Self, ConfigViolation>
|
||||||
|
where
|
||||||
|
Self: Sized;
|
||||||
|
|
||||||
|
/// Returns one scalar by stable field identity.
|
||||||
|
fn field_value(&self, id: FieldId) -> Option<ConfigValue>;
|
||||||
|
|
||||||
|
/// Applies one scalar only when this section owns the ID and its local
|
||||||
|
/// generated validation accepts the new value.
|
||||||
|
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError>;
|
||||||
|
|
||||||
|
/// Validates only generated scalar invariants. This intentionally omits
|
||||||
|
/// a derive `post` hook so staged multi-field edits can remain temporarily
|
||||||
|
/// cross-field-invalid until aggregate validation/commit.
|
||||||
|
fn validate_local_fields(&self) -> Result<(), ConfigViolation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An ordered aggregate of generated configuration sections.
|
||||||
|
///
|
||||||
|
/// The derive owns routing/default construction so adding a field to an
|
||||||
|
/// existing section cannot silently bypass local validation. The aggregate
|
||||||
|
/// owner still defines capability checks and immutable active construction.
|
||||||
|
pub trait ConfigAggregate: ValidateFields {
|
||||||
|
/// Builds and fully validates all nested sections from their derived
|
||||||
|
/// defaults.
|
||||||
|
fn default_aggregate() -> Result<Self, ConfigViolation>
|
||||||
|
where
|
||||||
|
Self: Sized;
|
||||||
|
|
||||||
|
/// Returns one nested scalar by stable field identity.
|
||||||
|
fn field_value(&self, id: FieldId) -> Option<ConfigValue>;
|
||||||
|
|
||||||
|
/// Routes one scalar write to its owning generated section. The setter
|
||||||
|
/// applies local validation only, so a transaction may temporarily hold a
|
||||||
|
/// cross-field-invalid multi-edit candidate.
|
||||||
|
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError>;
|
||||||
|
}
|
||||||
@@ -26,6 +26,19 @@ pub enum CborError {
|
|||||||
InvalidProfile(ConfigViolation),
|
InvalidProfile(ConfigViolation),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Failure while encoding one complete candidate snapshot.
|
||||||
|
///
|
||||||
|
/// The candidate is validated before the first CBOR item is written, so a
|
||||||
|
/// caller can never export a locally valid but relationally invalid edit.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum CborEncodeError<E> {
|
||||||
|
/// The complete candidate did not validate against immutable board
|
||||||
|
/// capabilities.
|
||||||
|
InvalidProfile(ConfigViolation),
|
||||||
|
/// The caller-owned CBOR writer rejected the encoded snapshot.
|
||||||
|
Encode(minicbor::encode::Error<E>),
|
||||||
|
}
|
||||||
|
|
||||||
/// Decodes exactly one bounded current record and validates it against immutable
|
/// Decodes exactly one bounded current record and validates it against immutable
|
||||||
/// board capabilities before returning a candidate to its caller.
|
/// board capabilities before returning a candidate to its caller.
|
||||||
pub fn decode_candidate(
|
pub fn decode_candidate(
|
||||||
@@ -37,31 +50,50 @@ pub fn decode_candidate(
|
|||||||
}
|
}
|
||||||
let candidate = decode_current(bytes)?;
|
let candidate = decode_current(bytes)?;
|
||||||
candidate
|
candidate
|
||||||
.validate_against(capabilities)
|
.validate(capabilities)
|
||||||
.map_err(CborError::InvalidProfile)?;
|
.map_err(CborError::InvalidProfile)?;
|
||||||
Ok(candidate)
|
Ok(candidate)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encodes one complete current snapshot to a caller-owned CBOR writer.
|
/// Validates and encodes one complete current snapshot to a caller-owned CBOR
|
||||||
|
/// writer.
|
||||||
|
///
|
||||||
|
/// Validation happens before creating the encoder, ensuring a failed
|
||||||
|
/// cross-field or capability check emits no partial CBOR record.
|
||||||
pub fn encode_candidate<W>(
|
pub fn encode_candidate<W>(
|
||||||
candidate: CandidateProfile,
|
candidate: CandidateProfile,
|
||||||
|
capabilities: &VehicleCapabilities,
|
||||||
writer: W,
|
writer: W,
|
||||||
) -> Result<(), minicbor::encode::Error<W::Error>>
|
) -> Result<(), CborEncodeError<W::Error>>
|
||||||
where
|
where
|
||||||
W: Write,
|
W: Write,
|
||||||
{
|
{
|
||||||
|
candidate
|
||||||
|
.validate(capabilities)
|
||||||
|
.map_err(CborEncodeError::InvalidProfile)?;
|
||||||
let mut encoder = Encoder::new(writer);
|
let mut encoder = Encoder::new(writer);
|
||||||
encoder.map(CandidateProfile::FIELD_COUNT as u64 + 2)?;
|
encoder
|
||||||
encoder.u16(FORMAT_KEY)?.u16(FORMAT_VERSION)?;
|
.map(CandidateProfile::FIELD_COUNT as u64 + 2)
|
||||||
encoder.u16(SCHEMA_KEY)?.bytes(&SCHEMA_ID)?;
|
.map_err(CborEncodeError::Encode)?;
|
||||||
|
encoder
|
||||||
|
.u16(FORMAT_KEY)
|
||||||
|
.and_then(|encoder| encoder.u16(FORMAT_VERSION))
|
||||||
|
.map_err(CborEncodeError::Encode)?;
|
||||||
|
encoder
|
||||||
|
.u16(SCHEMA_KEY)
|
||||||
|
.and_then(|encoder| encoder.bytes(&SCHEMA_ID))
|
||||||
|
.map_err(CborEncodeError::Encode)?;
|
||||||
for descriptor in CandidateProfile::descriptors() {
|
for descriptor in CandidateProfile::descriptors() {
|
||||||
encoder.u16(descriptor.id.get())?;
|
encoder
|
||||||
|
.u16(descriptor.id.get())
|
||||||
|
.map_err(CborEncodeError::Encode)?;
|
||||||
encode_value(
|
encode_value(
|
||||||
&mut encoder,
|
&mut encoder,
|
||||||
candidate
|
candidate
|
||||||
.field_value(descriptor.id)
|
.field_value(descriptor.id)
|
||||||
.expect("descriptor field"),
|
.expect("descriptor field"),
|
||||||
)?;
|
)
|
||||||
|
.map_err(CborEncodeError::Encode)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -178,7 +210,7 @@ mod tests {
|
|||||||
fn round_trip_and_trailing_rejection_are_exact() {
|
fn round_trip_and_trailing_rejection_are_exact() {
|
||||||
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
|
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
|
||||||
let capabilities = VehicleCapabilities::quad_x();
|
let capabilities = VehicleCapabilities::quad_x();
|
||||||
encode_candidate(CandidateProfile::default(), &mut writer).unwrap();
|
encode_candidate(CandidateProfile::default(), &capabilities, &mut writer).unwrap();
|
||||||
let length = writer.position();
|
let length = writer.position();
|
||||||
let bytes = writer.into_inner();
|
let bytes = writer.into_inner();
|
||||||
assert!(decode_candidate(&bytes[..length], &capabilities).is_ok());
|
assert!(decode_candidate(&bytes[..length], &capabilities).is_ok());
|
||||||
@@ -190,6 +222,23 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encoding_rejects_a_cross_field_invalid_transaction_before_writing() {
|
||||||
|
let capabilities = VehicleCapabilities::quad_x();
|
||||||
|
let mut candidate = CandidateProfile::default();
|
||||||
|
candidate
|
||||||
|
.set_field(kaptan_config::FieldId::new(2), ConfigValue::U64(50_001))
|
||||||
|
.expect("the scalar is locally valid");
|
||||||
|
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
encode_candidate(candidate, &capabilities, &mut writer),
|
||||||
|
Err(CborEncodeError::InvalidProfile(violation))
|
||||||
|
if violation.rule() == ValidationRule::Inconsistent
|
||||||
|
));
|
||||||
|
assert_eq!(writer.position(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_wrong_format_and_schema_before_candidate_conversion() {
|
fn rejects_wrong_format_and_schema_before_candidate_conversion() {
|
||||||
let capabilities = VehicleCapabilities::quad_x();
|
let capabilities = VehicleCapabilities::quad_x();
|
||||||
@@ -259,6 +308,46 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cbor_scalar_write_preserves_the_generated_field_and_rule() {
|
||||||
|
let capabilities = VehicleCapabilities::quad_x();
|
||||||
|
let mass = CandidateProfile::descriptors()
|
||||||
|
.iter()
|
||||||
|
.find(|descriptor| descriptor.display_key == "thrust.mass_kg")
|
||||||
|
.expect("declared mass descriptor");
|
||||||
|
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
|
||||||
|
let mut encoder = Encoder::new(&mut writer);
|
||||||
|
encoder
|
||||||
|
.map(CandidateProfile::FIELD_COUNT as u64 + 2)
|
||||||
|
.unwrap();
|
||||||
|
encoder
|
||||||
|
.u16(FORMAT_KEY)
|
||||||
|
.unwrap()
|
||||||
|
.u16(FORMAT_VERSION)
|
||||||
|
.unwrap();
|
||||||
|
encoder.u16(SCHEMA_KEY).unwrap().bytes(&SCHEMA_ID).unwrap();
|
||||||
|
let candidate = CandidateProfile::default();
|
||||||
|
for descriptor in CandidateProfile::descriptors() {
|
||||||
|
encoder.u16(descriptor.id.get()).unwrap();
|
||||||
|
let value = if descriptor.id == mass.id {
|
||||||
|
ConfigValue::F32(0.009)
|
||||||
|
} else {
|
||||||
|
candidate.field_value(descriptor.id).unwrap()
|
||||||
|
};
|
||||||
|
encode_value(&mut encoder, value).unwrap();
|
||||||
|
}
|
||||||
|
let length = writer.position();
|
||||||
|
let bytes = writer.into_inner();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
decode_candidate(&bytes[..length], &capabilities),
|
||||||
|
Err(CborError::InvalidProfile(issue))
|
||||||
|
if issue.primary() == Some(mass.id)
|
||||||
|
&& issue.related_field().is_none()
|
||||||
|
&& issue.rule() == ValidationRule::BelowMinimum
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
fn encoded_test_record<F>(
|
fn encoded_test_record<F>(
|
||||||
format_version: u16,
|
format_version: u16,
|
||||||
schema_id: [u8; 16],
|
schema_id: [u8; 16],
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
pub mod cbor;
|
pub mod cbor;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
|
|
||||||
pub use cbor::{CborError, MAXIMUM_CANDIDATE_BYTES, decode_candidate, encode_candidate};
|
pub use cbor::{
|
||||||
|
CborEncodeError, CborError, MAXIMUM_CANDIDATE_BYTES, decode_candidate, encode_candidate,
|
||||||
|
};
|
||||||
pub use profile::{CandidateProfile, FORMAT_VERSION, VehicleCapabilities, VehicleProfile};
|
pub use profile::{CandidateProfile, FORMAT_VERSION, VehicleCapabilities, VehicleProfile};
|
||||||
|
|
||||||
/// Exact identifier of the one unreleased Quad-X profile schema.
|
/// Exact identifier of the one unreleased Quad-X profile schema.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! Private vehicle candidate and whole-profile validation boundary.
|
//! Private vehicle candidate and whole-profile validation boundary.
|
||||||
|
|
||||||
use kaptan_config::{
|
use kaptan_config::{
|
||||||
ConfigModel, ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError,
|
ConfigAggregate, ConfigModel, ConfigValidate, ConfigValue, ConfigViolation, FieldDescriptor,
|
||||||
ValidationRule,
|
FieldId, FieldWriteError, ValidateFields, ValidationRule,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
@@ -24,41 +24,39 @@ pub const FORMAT_VERSION: u16 = 1;
|
|||||||
///
|
///
|
||||||
/// Fields are private outside this crate. Operators, CBOR import, and MAVLink
|
/// Fields are private outside this crate. Operators, CBOR import, and MAVLink
|
||||||
/// transport all use the same descriptor/mutation and full-validation path.
|
/// transport all use the same descriptor/mutation and full-validation path.
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy, ConfigValidate)]
|
||||||
pub struct CandidateProfile {
|
pub struct CandidateProfile {
|
||||||
|
#[config(section)]
|
||||||
pub(super) safety: SafetyCandidate,
|
pub(super) safety: SafetyCandidate,
|
||||||
|
#[config(section)]
|
||||||
pub(super) timing: TimingCandidate,
|
pub(super) timing: TimingCandidate,
|
||||||
|
#[config(section)]
|
||||||
pub(super) aiding: AidingCandidate,
|
pub(super) aiding: AidingCandidate,
|
||||||
|
#[config(section)]
|
||||||
pub(super) control: ControlCandidate,
|
pub(super) control: ControlCandidate,
|
||||||
|
#[config(section)]
|
||||||
pub(super) thrust: ThrustCandidate,
|
pub(super) thrust: ThrustCandidate,
|
||||||
|
#[config(section)]
|
||||||
pub(super) rate_pid: PidCandidate,
|
pub(super) rate_pid: PidCandidate,
|
||||||
|
#[config(section)]
|
||||||
pub(super) actuator_limits: ActuatorLimitsCandidate,
|
pub(super) actuator_limits: ActuatorLimitsCandidate,
|
||||||
|
#[config(section)]
|
||||||
pub(super) observability: ObservabilityCandidate,
|
pub(super) observability: ObservabilityCandidate,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for CandidateProfile {
|
impl Default for CandidateProfile {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
registry::default_candidate()
|
<Self as ConfigAggregate>::default_aggregate()
|
||||||
|
.expect("compiled configuration defaults must validate")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CandidateProfile {
|
impl CandidateProfile {
|
||||||
/// Number of operator-editable fields in the derived descriptor registry.
|
|
||||||
pub const FIELD_COUNT: usize = registry::FIELD_COUNT;
|
|
||||||
|
|
||||||
/// Returns the complete transport-neutral descriptor registry.
|
/// Returns the complete transport-neutral descriptor registry.
|
||||||
pub const fn descriptors() -> &'static [FieldDescriptor; Self::FIELD_COUNT] {
|
pub const fn descriptors() -> &'static [FieldDescriptor; Self::FIELD_COUNT] {
|
||||||
registry::descriptors()
|
registry::descriptors()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates this candidate against immutable board capabilities.
|
|
||||||
pub fn validate_against(
|
|
||||||
&self,
|
|
||||||
capabilities: &VehicleCapabilities,
|
|
||||||
) -> Result<VehicleProfile, ConfigViolation> {
|
|
||||||
self.validate_complete(capabilities)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_active(active: &VehicleProfile) -> Self {
|
fn from_active(active: &VehicleProfile) -> Self {
|
||||||
let safety = active.safety();
|
let safety = active.safety();
|
||||||
let timing = active.timing();
|
let timing = active.timing();
|
||||||
@@ -118,19 +116,8 @@ impl CandidateProfile {
|
|||||||
&self,
|
&self,
|
||||||
capabilities: &VehicleCapabilities,
|
capabilities: &VehicleCapabilities,
|
||||||
) -> Result<VehicleProfile, ConfigViolation> {
|
) -> Result<VehicleProfile, ConfigViolation> {
|
||||||
if capabilities.actuator_count() != 4 {
|
self.validate_fields()?;
|
||||||
return Err(ConfigViolation::global(
|
self.validate_capabilities(capabilities)?;
|
||||||
ValidationRule::UnsupportedCapability,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
self.safety.validate()?;
|
|
||||||
self.timing.validate()?;
|
|
||||||
self.aiding.validate()?;
|
|
||||||
self.control.validate()?;
|
|
||||||
self.thrust.validate()?;
|
|
||||||
self.rate_pid.validate()?;
|
|
||||||
self.actuator_limits.validate()?;
|
|
||||||
self.observability.validate()?;
|
|
||||||
Ok(VehicleProfile {
|
Ok(VehicleProfile {
|
||||||
safety: SafetySettings {
|
safety: SafetySettings {
|
||||||
prearm_dwell_microseconds: self.safety.prearm_dwell_microseconds,
|
prearm_dwell_microseconds: self.safety.prearm_dwell_microseconds,
|
||||||
@@ -179,6 +166,18 @@ impl CandidateProfile {
|
|||||||
mixer: capabilities.mixer(),
|
mixer: capabilities.mixer(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_capabilities(
|
||||||
|
&self,
|
||||||
|
capabilities: &VehicleCapabilities,
|
||||||
|
) -> Result<(), ConfigViolation> {
|
||||||
|
if capabilities.actuator_count() != 4 {
|
||||||
|
return Err(ConfigViolation::global(
|
||||||
|
ValidationRule::UnsupportedCapability,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConfigModel for CandidateProfile {
|
impl ConfigModel for CandidateProfile {
|
||||||
@@ -191,19 +190,19 @@ impl ConfigModel for CandidateProfile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn field_count() -> u16 {
|
fn field_count() -> u16 {
|
||||||
u16::try_from(registry::FIELD_COUNT).expect("field registry exceeds the protocol limit")
|
u16::try_from(Self::FIELD_COUNT).expect("field registry exceeds the protocol limit")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn field_value(&self, id: FieldId) -> Option<ConfigValue> {
|
fn field_value(&self, id: FieldId) -> Option<ConfigValue> {
|
||||||
registry::field_value(self, id)
|
<Self as ConfigAggregate>::field_value(self, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn active_field_value(active: &Self::Active, id: FieldId) -> Option<ConfigValue> {
|
fn active_field_value(active: &Self::Active, id: FieldId) -> Option<ConfigValue> {
|
||||||
registry::field_value(&Self::from_active(active), id)
|
<Self as ConfigAggregate>::field_value(&Self::from_active(active), id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> {
|
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> {
|
||||||
registry::set_field(self, id, value)
|
<Self as ConfigAggregate>::set_field(self, id, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate(&self, context: &Self::Context) -> Result<Self::Active, ConfigViolation> {
|
fn validate(&self, context: &Self::Context) -> Result<Self::Active, ConfigViolation> {
|
||||||
@@ -213,13 +212,21 @@ impl ConfigModel for CandidateProfile {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use kaptan_config::{ConfigModel, ConfigValue, FieldWriteError, ValidationRule};
|
use kaptan_config::{ConfigModel, ConfigValue, FieldId, FieldWriteError, ValidationRule};
|
||||||
|
|
||||||
use super::{CandidateProfile, VehicleCapabilities};
|
use super::{CandidateProfile, VehicleCapabilities};
|
||||||
use crate::profile::ids;
|
use crate::profile::ids;
|
||||||
|
|
||||||
const CAPABILITIES: VehicleCapabilities = VehicleCapabilities::quad_x();
|
const CAPABILITIES: VehicleCapabilities = VehicleCapabilities::quad_x();
|
||||||
|
|
||||||
|
fn descriptor_id(display_key: &str) -> FieldId {
|
||||||
|
CandidateProfile::descriptors()
|
||||||
|
.iter()
|
||||||
|
.find(|descriptor| descriptor.display_key == display_key)
|
||||||
|
.expect("declared field descriptor")
|
||||||
|
.id
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_descriptor_has_a_readable_default_value() {
|
fn every_descriptor_has_a_readable_default_value() {
|
||||||
let candidate = CandidateProfile::default();
|
let candidate = CandidateProfile::default();
|
||||||
@@ -293,13 +300,19 @@ mod tests {
|
|||||||
fn observability_filter_is_persisted_restart_only_and_cannot_hide_errors() {
|
fn observability_filter_is_persisted_restart_only_and_cannot_hide_errors() {
|
||||||
let mut candidate = CandidateProfile::default();
|
let mut candidate = CandidateProfile::default();
|
||||||
candidate
|
candidate
|
||||||
.set_field(ids::OBSERVABILITY_MAXIMUM_LOG_LEVEL, ConfigValue::U32(3))
|
.set_field(
|
||||||
|
descriptor_id("observability.maximum_log_level"),
|
||||||
|
ConfigValue::U32(3),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let active = candidate.validate(&CAPABILITIES).unwrap();
|
let active = candidate.validate(&CAPABILITIES).unwrap();
|
||||||
assert_eq!(active.observability().maximum_log_level(), 3);
|
assert_eq!(active.observability().maximum_log_level(), 3);
|
||||||
assert_eq!(active.observability().maximum_log_level(), 3);
|
assert_eq!(active.observability().maximum_log_level(), 3);
|
||||||
candidate
|
candidate
|
||||||
.set_field(ids::OBSERVABILITY_MAXIMUM_LOG_LEVEL, ConfigValue::U32(5))
|
.set_field(
|
||||||
|
descriptor_id("observability.maximum_log_level"),
|
||||||
|
ConfigValue::U32(5),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
candidate
|
candidate
|
||||||
@@ -311,7 +324,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
candidate.set_field(
|
candidate.set_field(
|
||||||
ids::OBSERVABILITY_MAXIMUM_LOG_LEVEL,
|
descriptor_id("observability.maximum_log_level"),
|
||||||
ConfigValue::U32(2),
|
ConfigValue::U32(2),
|
||||||
),
|
),
|
||||||
Err(FieldWriteError::Violation(issue))
|
Err(FieldWriteError::Violation(issue))
|
||||||
@@ -324,7 +337,10 @@ mod tests {
|
|||||||
for raw in [3, 4, 5, 6, 7] {
|
for raw in [3, 4, 5, 6, 7] {
|
||||||
let mut candidate = CandidateProfile::default();
|
let mut candidate = CandidateProfile::default();
|
||||||
candidate
|
candidate
|
||||||
.set_field(ids::OBSERVABILITY_MAXIMUM_LOG_LEVEL, ConfigValue::U32(raw))
|
.set_field(
|
||||||
|
descriptor_id("observability.maximum_log_level"),
|
||||||
|
ConfigValue::U32(raw),
|
||||||
|
)
|
||||||
.expect("descriptor range accepts the stable severity");
|
.expect("descriptor range accepts the stable severity");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
candidate
|
candidate
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use kaptan_config::{ConfigValidate, ConfigViolation, ValidateFields, ValidationRule};
|
use kaptan_config::{ConfigValidate, ConfigViolation, ValidationRule};
|
||||||
|
|
||||||
use super::ids;
|
use super::ids;
|
||||||
|
|
||||||
@@ -24,12 +24,6 @@ pub(super) struct AidingCandidate {
|
|||||||
pub(super) velocity_variance_m2_per_second_squared: f32,
|
pub(super) velocity_variance_m2_per_second_squared: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AidingCandidate {
|
|
||||||
pub(super) fn validate(self) -> Result<(), ConfigViolation> {
|
|
||||||
self.validate_fields()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, ConfigValidate)]
|
#[derive(Clone, Copy, ConfigValidate)]
|
||||||
pub(super) struct ControlCandidate {
|
pub(super) struct ControlCandidate {
|
||||||
#[config(
|
#[config(
|
||||||
@@ -78,13 +72,8 @@ pub(super) struct ControlCandidate {
|
|||||||
)]
|
)]
|
||||||
pub(super) maximum_downward_acceleration_mps2: f32,
|
pub(super) maximum_downward_acceleration_mps2: f32,
|
||||||
}
|
}
|
||||||
impl ControlCandidate {
|
|
||||||
pub(super) fn validate(self) -> Result<(), ConfigViolation> {
|
|
||||||
self.validate_fields()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, ConfigValidate)]
|
#[derive(Clone, Copy, ConfigValidate)]
|
||||||
|
#[config(post = Self::validate_thrust)]
|
||||||
pub(super) struct ThrustCandidate {
|
pub(super) struct ThrustCandidate {
|
||||||
#[config(
|
#[config(
|
||||||
id = 13,
|
id = 13,
|
||||||
@@ -115,8 +104,7 @@ pub(super) struct ThrustCandidate {
|
|||||||
pub(super) maximum_tilt_tangent: f32,
|
pub(super) maximum_tilt_tangent: f32,
|
||||||
}
|
}
|
||||||
impl ThrustCandidate {
|
impl ThrustCandidate {
|
||||||
pub(super) fn validate(self) -> Result<(), ConfigViolation> {
|
fn validate_thrust(&self) -> Result<(), ConfigViolation> {
|
||||||
self.validate_fields()?;
|
|
||||||
if self.maximum_collective_thrust_newtons <= self.mass_kg * 9.806_65 {
|
if self.maximum_collective_thrust_newtons <= self.mass_kg * 9.806_65 {
|
||||||
return Err(ConfigViolation::related(
|
return Err(ConfigViolation::related(
|
||||||
ids::MAXIMUM_COLLECTIVE_THRUST_NEWTONS,
|
ids::MAXIMUM_COLLECTIVE_THRUST_NEWTONS,
|
||||||
@@ -129,6 +117,7 @@ impl ThrustCandidate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, ConfigValidate)]
|
#[derive(Clone, Copy, ConfigValidate)]
|
||||||
|
#[config(post = Self::validate_pid)]
|
||||||
pub(super) struct PidCandidate {
|
pub(super) struct PidCandidate {
|
||||||
#[config(
|
#[config(
|
||||||
id = 16,
|
id = 16,
|
||||||
@@ -177,8 +166,7 @@ pub(super) struct PidCandidate {
|
|||||||
pub(super) integral_limit: f32,
|
pub(super) integral_limit: f32,
|
||||||
}
|
}
|
||||||
impl PidCandidate {
|
impl PidCandidate {
|
||||||
pub(super) fn validate(self) -> Result<(), ConfigViolation> {
|
fn validate_pid(&self) -> Result<(), ConfigViolation> {
|
||||||
self.validate_fields()?;
|
|
||||||
if self.integral_limit > self.output_limit {
|
if self.integral_limit > self.output_limit {
|
||||||
return Err(ConfigViolation::related(
|
return Err(ConfigViolation::related(
|
||||||
ids::RATE_PID_INTEGRAL_LIMIT,
|
ids::RATE_PID_INTEGRAL_LIMIT,
|
||||||
@@ -191,6 +179,7 @@ impl PidCandidate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, ConfigValidate)]
|
#[derive(Clone, Copy, ConfigValidate)]
|
||||||
|
#[config(post = Self::validate_actuator_limits)]
|
||||||
pub(super) struct ActuatorLimitsCandidate {
|
pub(super) struct ActuatorLimitsCandidate {
|
||||||
#[config(
|
#[config(
|
||||||
id = 21,
|
id = 21,
|
||||||
@@ -221,8 +210,7 @@ pub(super) struct ActuatorLimitsCandidate {
|
|||||||
pub(super) slew_per_second: f32,
|
pub(super) slew_per_second: f32,
|
||||||
}
|
}
|
||||||
impl ActuatorLimitsCandidate {
|
impl ActuatorLimitsCandidate {
|
||||||
pub(super) fn validate(self) -> Result<(), ConfigViolation> {
|
fn validate_actuator_limits(&self) -> Result<(), ConfigViolation> {
|
||||||
self.validate_fields()?;
|
|
||||||
if self.minimum >= self.maximum {
|
if self.minimum >= self.maximum {
|
||||||
return Err(ConfigViolation::related(
|
return Err(ConfigViolation::related(
|
||||||
ids::ACTUATOR_MINIMUM,
|
ids::ACTUATOR_MINIMUM,
|
||||||
|
|||||||
@@ -1,41 +1,20 @@
|
|||||||
//! Stable field identities for the current vehicle configuration schema.
|
//! Symbolic projections used by relation checks and focused tests.
|
||||||
|
//!
|
||||||
|
//! These constants never define an ID. Each projects the metadata generated
|
||||||
|
//! from its owning `#[config(id = ...)]` declaration.
|
||||||
|
|
||||||
use super::{
|
|
||||||
control::{
|
|
||||||
ActuatorLimitsCandidate, AidingCandidate, ControlCandidate, PidCandidate, ThrustCandidate,
|
|
||||||
},
|
|
||||||
observability::ObservabilityCandidate,
|
|
||||||
safety::SafetyCandidate,
|
|
||||||
timing::TimingCandidate,
|
|
||||||
};
|
|
||||||
use kaptan_config::FieldId;
|
use kaptan_config::FieldId;
|
||||||
|
|
||||||
// These symbolic names are projections of derive-generated metadata. No field
|
use super::{
|
||||||
// identifier is handwritten outside the field declaration itself.
|
control::{ActuatorLimitsCandidate, PidCandidate, ThrustCandidate},
|
||||||
pub(super) const PREARM_DWELL_MICROSECONDS: FieldId = SafetyCandidate::DESCRIPTORS[0].id;
|
timing::TimingCandidate,
|
||||||
|
};
|
||||||
|
|
||||||
pub(super) const MAXIMUM_IMU_INTERVAL_MICROSECONDS: FieldId = TimingCandidate::DESCRIPTORS[0].id;
|
pub(super) const MAXIMUM_IMU_INTERVAL_MICROSECONDS: FieldId = TimingCandidate::DESCRIPTORS[0].id;
|
||||||
pub(super) const IMU_FRESHNESS_MICROSECONDS: FieldId = TimingCandidate::DESCRIPTORS[1].id;
|
pub(super) const IMU_FRESHNESS_MICROSECONDS: FieldId = TimingCandidate::DESCRIPTORS[1].id;
|
||||||
pub(super) const LOCAL_STATE_FRESHNESS_MICROSECONDS: FieldId = TimingCandidate::DESCRIPTORS[2].id;
|
|
||||||
pub(super) const COMMAND_LINK_FRESHNESS_MICROSECONDS: FieldId = TimingCandidate::DESCRIPTORS[3].id;
|
|
||||||
pub(super) const POSITION_VARIANCE_M2: FieldId = AidingCandidate::DESCRIPTORS[0].id;
|
|
||||||
pub(super) const VELOCITY_VARIANCE_M2_PER_SECOND_SQUARED: FieldId =
|
|
||||||
AidingCandidate::DESCRIPTORS[1].id;
|
|
||||||
pub(super) const POSITION_GAIN_PER_SECOND_SQUARED: FieldId = ControlCandidate::DESCRIPTORS[0].id;
|
|
||||||
pub(super) const VELOCITY_GAIN_PER_SECOND: FieldId = ControlCandidate::DESCRIPTORS[1].id;
|
|
||||||
pub(super) const MAXIMUM_HORIZONTAL_ACCELERATION_MPS2: FieldId =
|
|
||||||
ControlCandidate::DESCRIPTORS[2].id;
|
|
||||||
pub(super) const MAXIMUM_UPWARD_ACCELERATION_MPS2: FieldId = ControlCandidate::DESCRIPTORS[3].id;
|
|
||||||
pub(super) const MAXIMUM_DOWNWARD_ACCELERATION_MPS2: FieldId = ControlCandidate::DESCRIPTORS[4].id;
|
|
||||||
pub(super) const MASS_KG: FieldId = ThrustCandidate::DESCRIPTORS[0].id;
|
pub(super) const MASS_KG: FieldId = ThrustCandidate::DESCRIPTORS[0].id;
|
||||||
pub(super) const MAXIMUM_COLLECTIVE_THRUST_NEWTONS: FieldId = ThrustCandidate::DESCRIPTORS[1].id;
|
pub(super) const MAXIMUM_COLLECTIVE_THRUST_NEWTONS: FieldId = ThrustCandidate::DESCRIPTORS[1].id;
|
||||||
pub(super) const MAXIMUM_TILT_TANGENT: FieldId = ThrustCandidate::DESCRIPTORS[2].id;
|
|
||||||
pub(super) const RATE_PID_PROPORTIONAL: FieldId = PidCandidate::DESCRIPTORS[0].id;
|
|
||||||
pub(super) const RATE_PID_INTEGRAL: FieldId = PidCandidate::DESCRIPTORS[1].id;
|
|
||||||
pub(super) const RATE_PID_DERIVATIVE: FieldId = PidCandidate::DESCRIPTORS[2].id;
|
|
||||||
pub(super) const RATE_PID_OUTPUT_LIMIT: FieldId = PidCandidate::DESCRIPTORS[3].id;
|
pub(super) const RATE_PID_OUTPUT_LIMIT: FieldId = PidCandidate::DESCRIPTORS[3].id;
|
||||||
pub(super) const RATE_PID_INTEGRAL_LIMIT: FieldId = PidCandidate::DESCRIPTORS[4].id;
|
pub(super) const RATE_PID_INTEGRAL_LIMIT: FieldId = PidCandidate::DESCRIPTORS[4].id;
|
||||||
pub(super) const ACTUATOR_MINIMUM: FieldId = ActuatorLimitsCandidate::DESCRIPTORS[0].id;
|
pub(super) const ACTUATOR_MINIMUM: FieldId = ActuatorLimitsCandidate::DESCRIPTORS[0].id;
|
||||||
pub(super) const ACTUATOR_MAXIMUM: FieldId = ActuatorLimitsCandidate::DESCRIPTORS[1].id;
|
pub(super) const ACTUATOR_MAXIMUM: FieldId = ActuatorLimitsCandidate::DESCRIPTORS[1].id;
|
||||||
pub(super) const ACTUATOR_SLEW_PER_SECOND: FieldId = ActuatorLimitsCandidate::DESCRIPTORS[2].id;
|
|
||||||
pub(super) const OBSERVABILITY_MAXIMUM_LOG_LEVEL: FieldId =
|
|
||||||
ObservabilityCandidate::DESCRIPTORS[0].id;
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Persisted, restart-only runtime-observability policy.
|
//! Persisted, restart-only runtime-observability policy.
|
||||||
|
|
||||||
use kaptan_config::{ConfigValidate, ConfigViolation, ValidateFields};
|
use kaptan_config::ConfigValidate;
|
||||||
|
|
||||||
/// Candidate-only observability policy. It deliberately controls only optional
|
/// Candidate-only observability policy. It deliberately controls only optional
|
||||||
/// detail; error-level records are never suppressible configuration fields.
|
/// detail; error-level records are never suppressible configuration fields.
|
||||||
@@ -16,12 +16,6 @@ pub(super) struct ObservabilityCandidate {
|
|||||||
pub(super) maximum_log_level: u32,
|
pub(super) maximum_log_level: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ObservabilityCandidate {
|
|
||||||
pub(super) fn validate(self) -> Result<(), ConfigViolation> {
|
|
||||||
self.validate_fields()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Immutable boot-active best-effort observability policy.
|
/// Immutable boot-active best-effort observability policy.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub struct ObservabilitySettings {
|
pub struct ObservabilitySettings {
|
||||||
|
|||||||
@@ -1,35 +1,33 @@
|
|||||||
//! Current field registry and local candidate mutation mapping.
|
//! Aggregate descriptor registry for the generated vehicle sections.
|
||||||
|
//!
|
||||||
|
//! Every descriptor is generated from its owning scalar field. This ordered
|
||||||
|
//! projection is intentionally exhaustive: `FIELD_COUNT` comes from the root
|
||||||
|
//! aggregate, so adding an attributed field without extending this view fails
|
||||||
|
//! at compile time instead of silently omitting it from CBOR or transport.
|
||||||
|
|
||||||
use kaptan_config::{
|
use kaptan_config::FieldDescriptor;
|
||||||
ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError, ValidationRule,
|
|
||||||
ValueKind,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
candidate::CandidateProfile,
|
candidate::CandidateProfile,
|
||||||
control::{
|
control::{
|
||||||
ActuatorLimitsCandidate, AidingCandidate, ControlCandidate, PidCandidate, ThrustCandidate,
|
ActuatorLimitsCandidate, AidingCandidate, ControlCandidate, PidCandidate, ThrustCandidate,
|
||||||
},
|
},
|
||||||
ids,
|
|
||||||
observability::ObservabilityCandidate,
|
observability::ObservabilityCandidate,
|
||||||
safety::SafetyCandidate,
|
safety::SafetyCandidate,
|
||||||
timing::TimingCandidate,
|
timing::TimingCandidate,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Number of operator-editable fields in the current SITL Quad-X profile.
|
/// Number of operator-editable fields in the current vehicle profile.
|
||||||
///
|
///
|
||||||
/// Allocation topology is immutable board capability data, not an operator
|
/// Allocation topology remains immutable board capability data, not an
|
||||||
/// parameter. It requires a separate physical-airframe safety workflow.
|
/// operator parameter.
|
||||||
pub(super) const FIELD_COUNT: usize = SafetyCandidate::DESCRIPTORS.len()
|
pub(super) const FIELD_COUNT: usize = CandidateProfile::FIELD_COUNT;
|
||||||
+ TimingCandidate::DESCRIPTORS.len()
|
|
||||||
+ AidingCandidate::DESCRIPTORS.len()
|
/// Ordered view of metadata generated from field attributes.
|
||||||
+ ControlCandidate::DESCRIPTORS.len()
|
///
|
||||||
+ ThrustCandidate::DESCRIPTORS.len()
|
/// This has no independently-maintained defaults, bounds, values, or setter
|
||||||
+ PidCandidate::DESCRIPTORS.len()
|
/// logic. The length is tied to `CandidateProfile::FIELD_COUNT` as a
|
||||||
+ ActuatorLimitsCandidate::DESCRIPTORS.len()
|
/// compile-time completeness guard.
|
||||||
+ ObservabilityCandidate::DESCRIPTORS.len();
|
|
||||||
/// The registry is an ordered view of metadata generated from field attributes.
|
|
||||||
/// It owns no IDs, bounds, defaults, or display text.
|
|
||||||
const FIELDS: [FieldDescriptor; FIELD_COUNT] = [
|
const FIELDS: [FieldDescriptor; FIELD_COUNT] = [
|
||||||
SafetyCandidate::DESCRIPTORS[0],
|
SafetyCandidate::DESCRIPTORS[0],
|
||||||
TimingCandidate::DESCRIPTORS[0],
|
TimingCandidate::DESCRIPTORS[0],
|
||||||
@@ -77,332 +75,6 @@ pub(super) const fn descriptors() -> &'static [FieldDescriptor; FIELD_COUNT] {
|
|||||||
&FIELDS
|
&FIELDS
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Constructs the vehicle candidate entirely from derive-generated defaults.
|
|
||||||
pub(super) fn default_candidate() -> CandidateProfile {
|
|
||||||
CandidateProfile {
|
|
||||||
safety: SafetyCandidate {
|
|
||||||
prearm_dwell_microseconds: default_u64(ids::PREARM_DWELL_MICROSECONDS),
|
|
||||||
},
|
|
||||||
timing: TimingCandidate {
|
|
||||||
maximum_imu_interval_microseconds: default_u64(ids::MAXIMUM_IMU_INTERVAL_MICROSECONDS),
|
|
||||||
imu_freshness_microseconds: default_u64(ids::IMU_FRESHNESS_MICROSECONDS),
|
|
||||||
local_state_freshness_microseconds: default_u64(
|
|
||||||
ids::LOCAL_STATE_FRESHNESS_MICROSECONDS,
|
|
||||||
),
|
|
||||||
command_link_freshness_microseconds: default_u64(
|
|
||||||
ids::COMMAND_LINK_FRESHNESS_MICROSECONDS,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
aiding: AidingCandidate {
|
|
||||||
position_variance_m2: default_f32(ids::POSITION_VARIANCE_M2),
|
|
||||||
velocity_variance_m2_per_second_squared: default_f32(
|
|
||||||
ids::VELOCITY_VARIANCE_M2_PER_SECOND_SQUARED,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
control: ControlCandidate {
|
|
||||||
position_gain_per_second_squared: default_f32(ids::POSITION_GAIN_PER_SECOND_SQUARED),
|
|
||||||
velocity_gain_per_second: default_f32(ids::VELOCITY_GAIN_PER_SECOND),
|
|
||||||
maximum_horizontal_acceleration_mps2: default_f32(
|
|
||||||
ids::MAXIMUM_HORIZONTAL_ACCELERATION_MPS2,
|
|
||||||
),
|
|
||||||
maximum_upward_acceleration_mps2: default_f32(ids::MAXIMUM_UPWARD_ACCELERATION_MPS2),
|
|
||||||
maximum_downward_acceleration_mps2: default_f32(
|
|
||||||
ids::MAXIMUM_DOWNWARD_ACCELERATION_MPS2,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
thrust: ThrustCandidate {
|
|
||||||
mass_kg: default_f32(ids::MASS_KG),
|
|
||||||
maximum_collective_thrust_newtons: default_f32(ids::MAXIMUM_COLLECTIVE_THRUST_NEWTONS),
|
|
||||||
maximum_tilt_tangent: default_f32(ids::MAXIMUM_TILT_TANGENT),
|
|
||||||
},
|
|
||||||
rate_pid: PidCandidate {
|
|
||||||
proportional: default_f32(ids::RATE_PID_PROPORTIONAL),
|
|
||||||
integral: default_f32(ids::RATE_PID_INTEGRAL),
|
|
||||||
derivative: default_f32(ids::RATE_PID_DERIVATIVE),
|
|
||||||
output_limit: default_f32(ids::RATE_PID_OUTPUT_LIMIT),
|
|
||||||
integral_limit: default_f32(ids::RATE_PID_INTEGRAL_LIMIT),
|
|
||||||
},
|
|
||||||
actuator_limits: ActuatorLimitsCandidate {
|
|
||||||
minimum: default_f32(ids::ACTUATOR_MINIMUM),
|
|
||||||
maximum: default_f32(ids::ACTUATOR_MAXIMUM),
|
|
||||||
slew_per_second: default_f32(ids::ACTUATOR_SLEW_PER_SECOND),
|
|
||||||
},
|
|
||||||
observability: ObservabilityCandidate {
|
|
||||||
maximum_log_level: default_u32(ids::OBSERVABILITY_MAXIMUM_LOG_LEVEL),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn descriptor(index: u16) -> Option<&'static FieldDescriptor> {
|
pub(super) fn descriptor(index: u16) -> Option<&'static FieldDescriptor> {
|
||||||
FIELDS.get(usize::from(index))
|
FIELDS.get(usize::from(index))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn field_value(candidate: &CandidateProfile, id: FieldId) -> Option<ConfigValue> {
|
|
||||||
Some(match id {
|
|
||||||
ids::PREARM_DWELL_MICROSECONDS => {
|
|
||||||
ConfigValue::U64(candidate.safety.prearm_dwell_microseconds)
|
|
||||||
}
|
|
||||||
ids::MAXIMUM_IMU_INTERVAL_MICROSECONDS => {
|
|
||||||
ConfigValue::U64(candidate.timing.maximum_imu_interval_microseconds)
|
|
||||||
}
|
|
||||||
ids::IMU_FRESHNESS_MICROSECONDS => {
|
|
||||||
ConfigValue::U64(candidate.timing.imu_freshness_microseconds)
|
|
||||||
}
|
|
||||||
ids::LOCAL_STATE_FRESHNESS_MICROSECONDS => {
|
|
||||||
ConfigValue::U64(candidate.timing.local_state_freshness_microseconds)
|
|
||||||
}
|
|
||||||
ids::COMMAND_LINK_FRESHNESS_MICROSECONDS => {
|
|
||||||
ConfigValue::U64(candidate.timing.command_link_freshness_microseconds)
|
|
||||||
}
|
|
||||||
ids::POSITION_VARIANCE_M2 => ConfigValue::F32(candidate.aiding.position_variance_m2),
|
|
||||||
ids::VELOCITY_VARIANCE_M2_PER_SECOND_SQUARED => {
|
|
||||||
ConfigValue::F32(candidate.aiding.velocity_variance_m2_per_second_squared)
|
|
||||||
}
|
|
||||||
ids::POSITION_GAIN_PER_SECOND_SQUARED => {
|
|
||||||
ConfigValue::F32(candidate.control.position_gain_per_second_squared)
|
|
||||||
}
|
|
||||||
ids::VELOCITY_GAIN_PER_SECOND => {
|
|
||||||
ConfigValue::F32(candidate.control.velocity_gain_per_second)
|
|
||||||
}
|
|
||||||
ids::MAXIMUM_HORIZONTAL_ACCELERATION_MPS2 => {
|
|
||||||
ConfigValue::F32(candidate.control.maximum_horizontal_acceleration_mps2)
|
|
||||||
}
|
|
||||||
ids::MAXIMUM_UPWARD_ACCELERATION_MPS2 => {
|
|
||||||
ConfigValue::F32(candidate.control.maximum_upward_acceleration_mps2)
|
|
||||||
}
|
|
||||||
ids::MAXIMUM_DOWNWARD_ACCELERATION_MPS2 => {
|
|
||||||
ConfigValue::F32(candidate.control.maximum_downward_acceleration_mps2)
|
|
||||||
}
|
|
||||||
ids::MASS_KG => ConfigValue::F32(candidate.thrust.mass_kg),
|
|
||||||
ids::MAXIMUM_COLLECTIVE_THRUST_NEWTONS => {
|
|
||||||
ConfigValue::F32(candidate.thrust.maximum_collective_thrust_newtons)
|
|
||||||
}
|
|
||||||
ids::MAXIMUM_TILT_TANGENT => ConfigValue::F32(candidate.thrust.maximum_tilt_tangent),
|
|
||||||
ids::RATE_PID_PROPORTIONAL => ConfigValue::F32(candidate.rate_pid.proportional),
|
|
||||||
ids::RATE_PID_INTEGRAL => ConfigValue::F32(candidate.rate_pid.integral),
|
|
||||||
ids::RATE_PID_DERIVATIVE => ConfigValue::F32(candidate.rate_pid.derivative),
|
|
||||||
ids::RATE_PID_OUTPUT_LIMIT => ConfigValue::F32(candidate.rate_pid.output_limit),
|
|
||||||
ids::RATE_PID_INTEGRAL_LIMIT => ConfigValue::F32(candidate.rate_pid.integral_limit),
|
|
||||||
ids::ACTUATOR_MINIMUM => ConfigValue::F32(candidate.actuator_limits.minimum),
|
|
||||||
ids::ACTUATOR_MAXIMUM => ConfigValue::F32(candidate.actuator_limits.maximum),
|
|
||||||
ids::ACTUATOR_SLEW_PER_SECOND => {
|
|
||||||
ConfigValue::F32(candidate.actuator_limits.slew_per_second)
|
|
||||||
}
|
|
||||||
ids::OBSERVABILITY_MAXIMUM_LOG_LEVEL => {
|
|
||||||
ConfigValue::U32(candidate.observability.maximum_log_level)
|
|
||||||
}
|
|
||||||
_ => return None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn set_field(
|
|
||||||
candidate: &mut CandidateProfile,
|
|
||||||
id: FieldId,
|
|
||||||
value: ConfigValue,
|
|
||||||
) -> Result<(), FieldWriteError> {
|
|
||||||
let value = match descriptor_for(id) {
|
|
||||||
Some(descriptor) => match descriptor.kind {
|
|
||||||
ValueKind::U64 => ConfigValue::U64(validate_u64(id, value, descriptor)?),
|
|
||||||
ValueKind::U32 => ConfigValue::U32(validate_u32(id, value, descriptor)?),
|
|
||||||
ValueKind::F32 => ConfigValue::F32(validate_f32(id, value, descriptor)?),
|
|
||||||
kind => {
|
|
||||||
return Err(FieldWriteError::WrongType {
|
|
||||||
field: id,
|
|
||||||
expected: kind,
|
|
||||||
actual: value.kind(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
None => return Err(FieldWriteError::UnknownField(id)),
|
|
||||||
};
|
|
||||||
match value {
|
|
||||||
ConfigValue::U64(value) => match id {
|
|
||||||
ids::PREARM_DWELL_MICROSECONDS => candidate.safety.prearm_dwell_microseconds = value,
|
|
||||||
ids::MAXIMUM_IMU_INTERVAL_MICROSECONDS => {
|
|
||||||
candidate.timing.maximum_imu_interval_microseconds = value
|
|
||||||
}
|
|
||||||
ids::IMU_FRESHNESS_MICROSECONDS => candidate.timing.imu_freshness_microseconds = value,
|
|
||||||
ids::LOCAL_STATE_FRESHNESS_MICROSECONDS => {
|
|
||||||
candidate.timing.local_state_freshness_microseconds = value
|
|
||||||
}
|
|
||||||
ids::COMMAND_LINK_FRESHNESS_MICROSECONDS => {
|
|
||||||
candidate.timing.command_link_freshness_microseconds = value
|
|
||||||
}
|
|
||||||
_ => return Err(FieldWriteError::UnknownField(id)),
|
|
||||||
},
|
|
||||||
ConfigValue::U32(value) => match id {
|
|
||||||
ids::OBSERVABILITY_MAXIMUM_LOG_LEVEL => {
|
|
||||||
candidate.observability.maximum_log_level = value
|
|
||||||
}
|
|
||||||
_ => return Err(FieldWriteError::UnknownField(id)),
|
|
||||||
},
|
|
||||||
ConfigValue::F32(value) => match id {
|
|
||||||
ids::POSITION_VARIANCE_M2 => candidate.aiding.position_variance_m2 = value,
|
|
||||||
ids::VELOCITY_VARIANCE_M2_PER_SECOND_SQUARED => {
|
|
||||||
candidate.aiding.velocity_variance_m2_per_second_squared = value
|
|
||||||
}
|
|
||||||
ids::POSITION_GAIN_PER_SECOND_SQUARED => {
|
|
||||||
candidate.control.position_gain_per_second_squared = value
|
|
||||||
}
|
|
||||||
ids::VELOCITY_GAIN_PER_SECOND => candidate.control.velocity_gain_per_second = value,
|
|
||||||
ids::MAXIMUM_HORIZONTAL_ACCELERATION_MPS2 => {
|
|
||||||
candidate.control.maximum_horizontal_acceleration_mps2 = value
|
|
||||||
}
|
|
||||||
ids::MAXIMUM_UPWARD_ACCELERATION_MPS2 => {
|
|
||||||
candidate.control.maximum_upward_acceleration_mps2 = value
|
|
||||||
}
|
|
||||||
ids::MAXIMUM_DOWNWARD_ACCELERATION_MPS2 => {
|
|
||||||
candidate.control.maximum_downward_acceleration_mps2 = value
|
|
||||||
}
|
|
||||||
ids::MASS_KG => candidate.thrust.mass_kg = value,
|
|
||||||
ids::MAXIMUM_COLLECTIVE_THRUST_NEWTONS => {
|
|
||||||
candidate.thrust.maximum_collective_thrust_newtons = value
|
|
||||||
}
|
|
||||||
ids::MAXIMUM_TILT_TANGENT => candidate.thrust.maximum_tilt_tangent = value,
|
|
||||||
ids::RATE_PID_PROPORTIONAL => candidate.rate_pid.proportional = value,
|
|
||||||
ids::RATE_PID_INTEGRAL => candidate.rate_pid.integral = value,
|
|
||||||
ids::RATE_PID_DERIVATIVE => candidate.rate_pid.derivative = value,
|
|
||||||
ids::RATE_PID_OUTPUT_LIMIT => candidate.rate_pid.output_limit = value,
|
|
||||||
ids::RATE_PID_INTEGRAL_LIMIT => candidate.rate_pid.integral_limit = value,
|
|
||||||
ids::ACTUATOR_MINIMUM => candidate.actuator_limits.minimum = value,
|
|
||||||
ids::ACTUATOR_MAXIMUM => candidate.actuator_limits.maximum = value,
|
|
||||||
ids::ACTUATOR_SLEW_PER_SECOND => candidate.actuator_limits.slew_per_second = value,
|
|
||||||
_ => return Err(FieldWriteError::UnknownField(id)),
|
|
||||||
},
|
|
||||||
ConfigValue::Bool(_) | ConfigValue::I32(_) | ConfigValue::F64(_) => {
|
|
||||||
return Err(FieldWriteError::WrongType {
|
|
||||||
field: id,
|
|
||||||
expected: descriptor_for(id).expect("checked descriptor").kind,
|
|
||||||
actual: value.kind(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_u32(
|
|
||||||
id: FieldId,
|
|
||||||
value: ConfigValue,
|
|
||||||
descriptor: &FieldDescriptor,
|
|
||||||
) -> Result<u32, FieldWriteError> {
|
|
||||||
let ConfigValue::U32(value) = value else {
|
|
||||||
return Err(FieldWriteError::WrongType {
|
|
||||||
field: id,
|
|
||||||
expected: ValueKind::U32,
|
|
||||||
actual: value.kind(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
let Some(ConfigValue::U32(minimum)) = descriptor.minimum else {
|
|
||||||
unreachable!()
|
|
||||||
};
|
|
||||||
let Some(ConfigValue::U32(maximum)) = descriptor.maximum else {
|
|
||||||
unreachable!()
|
|
||||||
};
|
|
||||||
if value < minimum {
|
|
||||||
return Err(FieldWriteError::Violation(ConfigViolation::field(
|
|
||||||
id,
|
|
||||||
ValidationRule::BelowMinimum,
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
if value > maximum {
|
|
||||||
return Err(FieldWriteError::Violation(ConfigViolation::field(
|
|
||||||
id,
|
|
||||||
ValidationRule::AboveMaximum,
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn descriptor_for(id: FieldId) -> Option<&'static FieldDescriptor> {
|
|
||||||
FIELDS.iter().find(|descriptor| descriptor.id == id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_u32(id: FieldId) -> u32 {
|
|
||||||
match descriptor_for(id).expect("generated descriptor").default {
|
|
||||||
ConfigValue::U32(value) => value,
|
|
||||||
_ => unreachable!("generated descriptor type"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_u64(id: FieldId) -> u64 {
|
|
||||||
match descriptor_for(id).expect("generated descriptor").default {
|
|
||||||
ConfigValue::U64(value) => value,
|
|
||||||
_ => unreachable!("generated descriptor type"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_f32(id: FieldId) -> f32 {
|
|
||||||
match descriptor_for(id).expect("generated descriptor").default {
|
|
||||||
ConfigValue::F32(value) => value,
|
|
||||||
_ => unreachable!("generated descriptor type"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_u64(
|
|
||||||
id: FieldId,
|
|
||||||
value: ConfigValue,
|
|
||||||
descriptor: &FieldDescriptor,
|
|
||||||
) -> Result<u64, FieldWriteError> {
|
|
||||||
let ConfigValue::U64(value) = value else {
|
|
||||||
return Err(FieldWriteError::WrongType {
|
|
||||||
field: id,
|
|
||||||
expected: ValueKind::U64,
|
|
||||||
actual: value.kind(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
let Some(ConfigValue::U64(minimum)) = descriptor.minimum else {
|
|
||||||
unreachable!()
|
|
||||||
};
|
|
||||||
let Some(ConfigValue::U64(maximum)) = descriptor.maximum else {
|
|
||||||
unreachable!()
|
|
||||||
};
|
|
||||||
if value < minimum {
|
|
||||||
return Err(FieldWriteError::Violation(ConfigViolation::field(
|
|
||||||
id,
|
|
||||||
ValidationRule::BelowMinimum,
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
if value > maximum {
|
|
||||||
return Err(FieldWriteError::Violation(ConfigViolation::field(
|
|
||||||
id,
|
|
||||||
ValidationRule::AboveMaximum,
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_f32(
|
|
||||||
id: FieldId,
|
|
||||||
value: ConfigValue,
|
|
||||||
descriptor: &FieldDescriptor,
|
|
||||||
) -> Result<f32, FieldWriteError> {
|
|
||||||
let ConfigValue::F32(value) = value else {
|
|
||||||
return Err(FieldWriteError::WrongType {
|
|
||||||
field: id,
|
|
||||||
expected: ValueKind::F32,
|
|
||||||
actual: value.kind(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
if !value.is_finite() {
|
|
||||||
return Err(FieldWriteError::non_finite(id));
|
|
||||||
}
|
|
||||||
let Some(ConfigValue::F32(minimum)) = descriptor.minimum else {
|
|
||||||
unreachable!()
|
|
||||||
};
|
|
||||||
let Some(ConfigValue::F32(maximum)) = descriptor.maximum else {
|
|
||||||
unreachable!()
|
|
||||||
};
|
|
||||||
if value < minimum {
|
|
||||||
return Err(FieldWriteError::Violation(ConfigViolation::field(
|
|
||||||
id,
|
|
||||||
ValidationRule::BelowMinimum,
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
if value > maximum {
|
|
||||||
return Err(FieldWriteError::Violation(ConfigViolation::field(
|
|
||||||
id,
|
|
||||||
ValidationRule::AboveMaximum,
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(value)
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use kaptan_config::{ConfigValidate, ConfigViolation, ValidateFields};
|
use kaptan_config::ConfigValidate;
|
||||||
|
|
||||||
#[derive(Clone, Copy, ConfigValidate)]
|
#[derive(Clone, Copy, ConfigValidate)]
|
||||||
pub(super) struct SafetyCandidate {
|
pub(super) struct SafetyCandidate {
|
||||||
@@ -12,9 +12,3 @@ pub(super) struct SafetyCandidate {
|
|||||||
)]
|
)]
|
||||||
pub(super) prearm_dwell_microseconds: u64,
|
pub(super) prearm_dwell_microseconds: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SafetyCandidate {
|
|
||||||
pub(super) fn validate(self) -> Result<(), ConfigViolation> {
|
|
||||||
self.validate_fields()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
use kaptan_config::{ConfigValidate, ConfigViolation, ValidateFields, ValidationRule};
|
use kaptan_config::{ConfigValidate, ConfigViolation, ValidationRule};
|
||||||
|
|
||||||
use super::ids;
|
use super::ids;
|
||||||
|
|
||||||
#[derive(Clone, Copy, ConfigValidate)]
|
#[derive(Clone, Copy, ConfigValidate)]
|
||||||
|
#[config(post = Self::validate_timing)]
|
||||||
pub(super) struct TimingCandidate {
|
pub(super) struct TimingCandidate {
|
||||||
#[config(
|
#[config(
|
||||||
id = 2,
|
id = 2,
|
||||||
@@ -43,8 +44,7 @@ pub(super) struct TimingCandidate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TimingCandidate {
|
impl TimingCandidate {
|
||||||
pub(super) fn validate(self) -> Result<(), ConfigViolation> {
|
fn validate_timing(&self) -> Result<(), ConfigViolation> {
|
||||||
self.validate_fields()?;
|
|
||||||
if self.maximum_imu_interval_microseconds > self.imu_freshness_microseconds {
|
if self.maximum_imu_interval_microseconds > self.imu_freshness_microseconds {
|
||||||
return Err(ConfigViolation::related(
|
return Err(ConfigViolation::related(
|
||||||
ids::MAXIMUM_IMU_INTERVAL_MICROSECONDS,
|
ids::MAXIMUM_IMU_INTERVAL_MICROSECONDS,
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ use std::{
|
|||||||
|
|
||||||
use kaptan_config::{Generation, ProfileStore};
|
use kaptan_config::{Generation, ProfileStore};
|
||||||
use kaptan_flight_config::{
|
use kaptan_flight_config::{
|
||||||
CandidateProfile, CborError, MAXIMUM_CANDIDATE_BYTES, VehicleProfile, decode_candidate,
|
CandidateProfile, CborEncodeError, CborError, MAXIMUM_CANDIDATE_BYTES, VehicleProfile,
|
||||||
encode_candidate,
|
decode_candidate, encode_candidate,
|
||||||
};
|
};
|
||||||
use kaptan_log::KaptanError;
|
use kaptan_log::KaptanError;
|
||||||
|
|
||||||
@@ -134,12 +134,13 @@ fn write_candidate(
|
|||||||
candidate: CandidateProfile,
|
candidate: CandidateProfile,
|
||||||
generation: Generation,
|
generation: Generation,
|
||||||
) -> Result<(), FileStoreError> {
|
) -> Result<(), FileStoreError> {
|
||||||
candidate
|
|
||||||
.validate_against(&CAPABILITIES)
|
|
||||||
.map_err(CborError::InvalidProfile)
|
|
||||||
.map_err(FileStoreError::Cbor)?;
|
|
||||||
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
|
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
|
||||||
encode_candidate(candidate, &mut writer).map_err(|_| FileStoreError::Encode)?;
|
encode_candidate(candidate, &CAPABILITIES, &mut writer).map_err(|error| match error {
|
||||||
|
CborEncodeError::InvalidProfile(violation) => {
|
||||||
|
FileStoreError::Cbor(CborError::InvalidProfile(violation))
|
||||||
|
}
|
||||||
|
CborEncodeError::Encode(_) => FileStoreError::Encode,
|
||||||
|
})?;
|
||||||
let length = writer.position();
|
let length = writer.position();
|
||||||
let bytes = writer.into_inner();
|
let bytes = writer.into_inner();
|
||||||
let mut envelope = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES + 64]);
|
let mut envelope = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES + 64]);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
use kaptan_config::{Generation, ProfileStore};
|
use kaptan_config::{ConfigModel, Generation, ProfileStore};
|
||||||
use kaptan_config_mavlink::{
|
use kaptan_config_mavlink::{
|
||||||
Authorized, ConfigCommand, ResultKind, ServiceResponse, SnapshotImporter,
|
Authorized, ConfigCommand, ResultKind, ServiceResponse, SnapshotImporter,
|
||||||
};
|
};
|
||||||
@@ -87,7 +87,7 @@ fn host_store_round_trips_a_complete_validated_snapshot() {
|
|||||||
Err(CborError::Malformed)
|
Err(CborError::Malformed)
|
||||||
));
|
));
|
||||||
let (_, candidate) = store.load().unwrap().expect("persisted candidate");
|
let (_, candidate) = store.load().unwrap().expect("persisted candidate");
|
||||||
assert!(candidate.validate_against(&CAPABILITIES).is_ok());
|
assert!(candidate.validate(&CAPABILITIES).is_ok());
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store.persist(&CandidateProfile::default()).unwrap(),
|
store.persist(&CandidateProfile::default()).unwrap(),
|
||||||
@@ -111,13 +111,13 @@ fn host_store_round_trips_a_complete_validated_snapshot() {
|
|||||||
fn sitl_snapshot_importer_uses_the_real_schema_and_capabilities() {
|
fn sitl_snapshot_importer_uses_the_real_schema_and_capabilities() {
|
||||||
let candidate = CandidateProfile::default();
|
let candidate = CandidateProfile::default();
|
||||||
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
|
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
|
||||||
encode_candidate(candidate, &mut writer).unwrap();
|
encode_candidate(candidate, &CAPABILITIES, &mut writer).unwrap();
|
||||||
let length = writer.position();
|
let length = writer.position();
|
||||||
let bytes = writer.into_inner();
|
let bytes = writer.into_inner();
|
||||||
let imported = SitlSnapshotImporter
|
let imported = SitlSnapshotImporter
|
||||||
.decode(&bytes[..length])
|
.decode(&bytes[..length])
|
||||||
.expect("valid current candidate import");
|
.expect("valid current candidate import");
|
||||||
assert!(imported.validate_against(&CAPABILITIES).is_ok());
|
assert!(imported.validate(&CAPABILITIES).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -138,7 +138,7 @@ fn sitl_service_imports_a_real_cbor_candidate_only_into_its_transaction() {
|
|||||||
},
|
},
|
||||||
)));
|
)));
|
||||||
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
|
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
|
||||||
encode_candidate(CandidateProfile::default(), &mut writer).unwrap();
|
encode_candidate(CandidateProfile::default(), &CAPABILITIES, &mut writer).unwrap();
|
||||||
let length = writer.position();
|
let length = writer.position();
|
||||||
assert!(length <= 192);
|
assert!(length <= 192);
|
||||||
let bytes = writer.into_inner();
|
let bytes = writer.into_inner();
|
||||||
|
|||||||
Reference in new issue
Block a user