diff --git a/libs/kaptan-config-derive/src/expand.rs b/libs/kaptan-config-derive/src/expand.rs index 1cf5452..9330d8a 100644 --- a/libs/kaptan-config-derive/src/expand.rs +++ b/libs/kaptan-config-derive/src/expand.rs @@ -2,7 +2,7 @@ use proc_macro2::TokenStream; use quote::quote; use syn::Result; -use crate::parse::{Check, Model}; +use crate::parse::{Check, Model, ModelKind}; pub(crate) fn expand(model: Model) -> Result { let Model { @@ -10,26 +10,77 @@ pub(crate) fn expand(model: Model) -> Result { generics, crate_path, post_validate, + kind, fields, } = 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, + fields: Vec, +) -> Result { 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 mut defaults = Vec::new(); + let mut reads = Vec::new(); + let mut writes = Vec::new(); + for field in &fields { let FieldMetadata { descriptor, checks: field_checks, - } = expand_field(&crate_path, &field)?; + } = expand_field(&crate_path, field)?; if let Some(descriptor) = descriptor { descriptors.push(descriptor); } 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) = + ::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)?; }); Ok(quote! { impl #impl_generics #crate_path::ValidateFields for #ident #type_generics #where_clause { fn validate_fields(&self) -> Result<(), #crate_path::ConfigViolation> { - #(#checks)* + ::validate_local_fields(self)?; #post_validate Ok(()) } @@ -41,6 +92,133 @@ pub(crate) fn expand(model: Model) -> Result { #(#descriptors),* ]; } + + impl #impl_generics #crate_path::ConfigSection for #ident #type_generics #where_clause { + const FIELD_COUNT: usize = Self::DESCRIPTORS.len(); + + fn default_section() -> Result { + let section = Self { + #(#defaults),* + }; + ::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, + fields: Vec, +) -> Result { + 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 { + let aggregate = Self { + #(#defaults),* + }; + ::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)) + } + } }) } diff --git a/libs/kaptan-config-derive/src/parse.rs b/libs/kaptan-config-derive/src/parse.rs index 3c301ba..6f1b8d5 100644 --- a/libs/kaptan-config-derive/src/parse.rs +++ b/libs/kaptan-config-derive/src/parse.rs @@ -8,9 +8,16 @@ pub(crate) struct Model { pub(crate) generics: syn::Generics, pub(crate) crate_path: Path, pub(crate) post_validate: Option, + pub(crate) kind: ModelKind, pub(crate) fields: Vec, } +#[derive(Clone, Copy, Eq, PartialEq)] +pub(crate) enum ModelKind { + Scalars, + Sections, +} + pub(crate) struct FieldModel { pub(crate) ident: Ident, pub(crate) ty: Type, @@ -19,6 +26,7 @@ pub(crate) struct FieldModel { pub(crate) unit: Option, pub(crate) default: Option, pub(crate) checks: Vec, + pub(crate) section: bool, } #[derive(Clone)] @@ -44,6 +52,12 @@ impl Model { "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 ids = Vec::new(); for field in fields.named { @@ -59,8 +73,22 @@ impl Model { } models.push(model); } - for field in &models { - if field.id.is_some() { + 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 { if field.display_key.is_none() { return Err(syn::Error::new( field.ident.span(), @@ -79,6 +107,7 @@ impl Model { "configuration fields require config(default = ...)", )); } + validate_static_constraints(field)?; } } Ok(Self { @@ -86,6 +115,7 @@ impl Model { generics: input.generics, crate_path, post_validate, + kind, fields: models, }) } @@ -135,12 +165,35 @@ fn parse_field(field: Field) -> Result { let mut nonzero = false; let mut range_minimum = false; let mut range_maximum = false; + let mut section = false; for attribute in field .attrs .iter() .filter(|attribute| attribute.path().is_ident("config")) { 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 id.is_some() { return Err(meta.error("duplicate configuration field id")); @@ -224,12 +277,24 @@ fn parse_field(field: Field) -> Result { )) })?; } - if !checks.is_empty() && id.is_none() { + if !section && !checks.is_empty() && id.is_none() { return Err(syn::Error::new( ident.span(), "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 { ident, ty: field.ty, @@ -238,6 +303,7 @@ fn parse_field(field: Field) -> Result { unit, default, checks, + section, }) } @@ -300,6 +366,276 @@ fn validate_default_literal(expression: &Expr, ty: &Type) -> Result<()> { 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 { + 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 { + 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::() + .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 { + 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 { + 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::() + .map_err(|error| syn::Error::new(value.span(), error))?, + Lit::Float(value) => value + .base10_parse::() + .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<()> { if scalar_type_name(ty).is_some_and(|name| name == "f32" || name == "f64") { Ok(()) diff --git a/libs/kaptan-config-derive/tests/pass/aggregate_sections.rs b/libs/kaptan-config-derive/tests/pass/aggregate_sections.rs new file mode 100644 index 0000000..f318a55 --- /dev/null +++ b/libs/kaptan-config-derive/tests/pass/aggregate_sections.rs @@ -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))); +} diff --git a/libs/kaptan-config-derive/tests/section.rs b/libs/kaptan-config-derive/tests/section.rs new file mode 100644 index 0000000..bfc4d35 --- /dev/null +++ b/libs/kaptan-config-derive/tests/section.rs @@ -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(); +} diff --git a/libs/kaptan-config-derive/tests/ui/default_below_minimum.rs b/libs/kaptan-config-derive/tests/ui/default_below_minimum.rs new file mode 100644 index 0000000..14006ac --- /dev/null +++ b/libs/kaptan-config-derive/tests/ui/default_below_minimum.rs @@ -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() {} diff --git a/libs/kaptan-config-derive/tests/ui/default_below_minimum.stderr b/libs/kaptan-config-derive/tests/ui/default_below_minimum.stderr new file mode 100644 index 0000000..de700e1 --- /dev/null +++ b/libs/kaptan-config-derive/tests/ui/default_below_minimum.stderr @@ -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))] + | ^ diff --git a/libs/kaptan-config-derive/tests/ui/default_violates_finite.rs b/libs/kaptan-config-derive/tests/ui/default_violates_finite.rs new file mode 100644 index 0000000..b09ab85 --- /dev/null +++ b/libs/kaptan-config-derive/tests/ui/default_violates_finite.rs @@ -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() {} diff --git a/libs/kaptan-config-derive/tests/ui/default_violates_finite.stderr b/libs/kaptan-config-derive/tests/ui/default_violates_finite.stderr new file mode 100644 index 0000000..7629742 --- /dev/null +++ b/libs/kaptan-config-derive/tests/ui/default_violates_finite.stderr @@ -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)] + | ^^^^^ diff --git a/libs/kaptan-config-derive/tests/ui/default_violates_nonzero.rs b/libs/kaptan-config-derive/tests/ui/default_violates_nonzero.rs new file mode 100644 index 0000000..235f682 --- /dev/null +++ b/libs/kaptan-config-derive/tests/ui/default_violates_nonzero.rs @@ -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() {} diff --git a/libs/kaptan-config-derive/tests/ui/default_violates_nonzero.stderr b/libs/kaptan-config-derive/tests/ui/default_violates_nonzero.stderr new file mode 100644 index 0000000..a274a22 --- /dev/null +++ b/libs/kaptan-config-derive/tests/ui/default_violates_nonzero.stderr @@ -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)] + | ^ diff --git a/libs/kaptan-config-derive/tests/ui/empty.rs b/libs/kaptan-config-derive/tests/ui/empty.rs new file mode 100644 index 0000000..a6fe384 --- /dev/null +++ b/libs/kaptan-config-derive/tests/ui/empty.rs @@ -0,0 +1,6 @@ +use kaptan_config_derive::ConfigValidate; + +#[derive(ConfigValidate)] +struct Empty {} + +fn main() {} diff --git a/libs/kaptan-config-derive/tests/ui/empty.stderr b/libs/kaptan-config-derive/tests/ui/empty.stderr new file mode 100644 index 0000000..2d04d41 --- /dev/null +++ b/libs/kaptan-config-derive/tests/ui/empty.stderr @@ -0,0 +1,5 @@ +error: ConfigValidate requires at least one configured field or section + --> tests/ui/empty.rs:4:8 + | +4 | struct Empty {} + | ^^^^^ diff --git a/libs/kaptan-config-derive/tests/ui/mixed_section_scalar.rs b/libs/kaptan-config-derive/tests/ui/mixed_section_scalar.rs new file mode 100644 index 0000000..5a81b85 --- /dev/null +++ b/libs/kaptan-config-derive/tests/ui/mixed_section_scalar.rs @@ -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() {} diff --git a/libs/kaptan-config-derive/tests/ui/mixed_section_scalar.stderr b/libs/kaptan-config-derive/tests/ui/mixed_section_scalar.stderr new file mode 100644 index 0000000..6374074 --- /dev/null +++ b/libs/kaptan-config-derive/tests/ui/mixed_section_scalar.stderr @@ -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, + | ^^^^^ diff --git a/libs/kaptan-config-derive/tests/ui/range_minimum_exceeds_maximum.rs b/libs/kaptan-config-derive/tests/ui/range_minimum_exceeds_maximum.rs new file mode 100644 index 0000000..94f0239 --- /dev/null +++ b/libs/kaptan-config-derive/tests/ui/range_minimum_exceeds_maximum.rs @@ -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() {} diff --git a/libs/kaptan-config-derive/tests/ui/range_minimum_exceeds_maximum.stderr b/libs/kaptan-config-derive/tests/ui/range_minimum_exceeds_maximum.stderr new file mode 100644 index 0000000..b96879c --- /dev/null +++ b/libs/kaptan-config-derive/tests/ui/range_minimum_exceeds_maximum.stderr @@ -0,0 +1,5 @@ +error: configuration range minimum exceeds maximum + --> tests/ui/range_minimum_exceeds_maximum.rs:6:5 + | +6 | value: u32, + | ^^^^^ diff --git a/libs/kaptan-config/src/lib.rs b/libs/kaptan-config/src/lib.rs index c92e370..03b66c5 100644 --- a/libs/kaptan-config/src/lib.rs +++ b/libs/kaptan-config/src/lib.rs @@ -22,4 +22,4 @@ pub use field::{ pub use kaptan_config_derive::ConfigValidate; pub use lifecycle::{BeginError, CommitError, ConfigManager, ConfigState, SetError, StartupError}; pub use storage::{Generation, ProfileStore}; -pub use validation::ValidateFields; +pub use validation::{ConfigAggregate, ConfigSection, ValidateFields}; diff --git a/libs/kaptan-config/src/validation.rs b/libs/kaptan-config/src/validation.rs index 89e66ca..e7ee338 100644 --- a/libs/kaptan-config/src/validation.rs +++ b/libs/kaptan-config/src/validation.rs @@ -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. /// @@ -11,3 +11,53 @@ pub trait ValidateFields { /// Rejects the first violated scalar or declared post-validation rule. 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 + where + Self: Sized; + + /// Returns one scalar by stable field identity. + fn field_value(&self, id: FieldId) -> Option; + + /// 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 + where + Self: Sized; + + /// Returns one nested scalar by stable field identity. + fn field_value(&self, id: FieldId) -> Option; + + /// 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>; +} diff --git a/libs/kaptan-flight-config/src/cbor.rs b/libs/kaptan-flight-config/src/cbor.rs index a09ba1e..51f3a16 100644 --- a/libs/kaptan-flight-config/src/cbor.rs +++ b/libs/kaptan-flight-config/src/cbor.rs @@ -26,6 +26,19 @@ pub enum CborError { 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 { + /// 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), +} + /// Decodes exactly one bounded current record and validates it against immutable /// board capabilities before returning a candidate to its caller. pub fn decode_candidate( @@ -37,31 +50,50 @@ pub fn decode_candidate( } let candidate = decode_current(bytes)?; candidate - .validate_against(capabilities) + .validate(capabilities) .map_err(CborError::InvalidProfile)?; 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( candidate: CandidateProfile, + capabilities: &VehicleCapabilities, writer: W, -) -> Result<(), minicbor::encode::Error> +) -> Result<(), CborEncodeError> where W: Write, { + candidate + .validate(capabilities) + .map_err(CborEncodeError::InvalidProfile)?; let mut encoder = Encoder::new(writer); - encoder.map(CandidateProfile::FIELD_COUNT as u64 + 2)?; - encoder.u16(FORMAT_KEY)?.u16(FORMAT_VERSION)?; - encoder.u16(SCHEMA_KEY)?.bytes(&SCHEMA_ID)?; + encoder + .map(CandidateProfile::FIELD_COUNT as u64 + 2) + .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() { - encoder.u16(descriptor.id.get())?; + encoder + .u16(descriptor.id.get()) + .map_err(CborEncodeError::Encode)?; encode_value( &mut encoder, candidate .field_value(descriptor.id) .expect("descriptor field"), - )?; + ) + .map_err(CborEncodeError::Encode)?; } Ok(()) } @@ -178,7 +210,7 @@ mod tests { fn round_trip_and_trailing_rejection_are_exact() { let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]); 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 bytes = writer.into_inner(); 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] fn rejects_wrong_format_and_schema_before_candidate_conversion() { 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( format_version: u16, schema_id: [u8; 16], diff --git a/libs/kaptan-flight-config/src/lib.rs b/libs/kaptan-flight-config/src/lib.rs index f08c334..6189e66 100644 --- a/libs/kaptan-flight-config/src/lib.rs +++ b/libs/kaptan-flight-config/src/lib.rs @@ -5,7 +5,9 @@ pub mod cbor; 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}; /// Exact identifier of the one unreleased Quad-X profile schema. diff --git a/libs/kaptan-flight-config/src/profile/candidate.rs b/libs/kaptan-flight-config/src/profile/candidate.rs index 91df666..b04f64a 100644 --- a/libs/kaptan-flight-config/src/profile/candidate.rs +++ b/libs/kaptan-flight-config/src/profile/candidate.rs @@ -1,8 +1,8 @@ //! Private vehicle candidate and whole-profile validation boundary. use kaptan_config::{ - ConfigModel, ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError, - ValidationRule, + ConfigAggregate, ConfigModel, ConfigValidate, ConfigValue, ConfigViolation, FieldDescriptor, + FieldId, FieldWriteError, ValidateFields, ValidationRule, }; use super::{ @@ -24,41 +24,39 @@ pub const FORMAT_VERSION: u16 = 1; /// /// Fields are private outside this crate. Operators, CBOR import, and MAVLink /// transport all use the same descriptor/mutation and full-validation path. -#[derive(Clone, Copy)] +#[derive(Clone, Copy, ConfigValidate)] pub struct CandidateProfile { + #[config(section)] pub(super) safety: SafetyCandidate, + #[config(section)] pub(super) timing: TimingCandidate, + #[config(section)] pub(super) aiding: AidingCandidate, + #[config(section)] pub(super) control: ControlCandidate, + #[config(section)] pub(super) thrust: ThrustCandidate, + #[config(section)] pub(super) rate_pid: PidCandidate, + #[config(section)] pub(super) actuator_limits: ActuatorLimitsCandidate, + #[config(section)] pub(super) observability: ObservabilityCandidate, } impl Default for CandidateProfile { fn default() -> Self { - registry::default_candidate() + ::default_aggregate() + .expect("compiled configuration defaults must validate") } } 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. pub const fn descriptors() -> &'static [FieldDescriptor; Self::FIELD_COUNT] { registry::descriptors() } - /// Validates this candidate against immutable board capabilities. - pub fn validate_against( - &self, - capabilities: &VehicleCapabilities, - ) -> Result { - self.validate_complete(capabilities) - } - fn from_active(active: &VehicleProfile) -> Self { let safety = active.safety(); let timing = active.timing(); @@ -118,19 +116,8 @@ impl CandidateProfile { &self, capabilities: &VehicleCapabilities, ) -> Result { - if capabilities.actuator_count() != 4 { - return Err(ConfigViolation::global( - 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()?; + self.validate_fields()?; + self.validate_capabilities(capabilities)?; Ok(VehicleProfile { safety: SafetySettings { prearm_dwell_microseconds: self.safety.prearm_dwell_microseconds, @@ -179,6 +166,18 @@ impl CandidateProfile { 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 { @@ -191,19 +190,19 @@ impl ConfigModel for CandidateProfile { } 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 { - registry::field_value(self, id) + ::field_value(self, id) } fn active_field_value(active: &Self::Active, id: FieldId) -> Option { - registry::field_value(&Self::from_active(active), id) + ::field_value(&Self::from_active(active), id) } fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> { - registry::set_field(self, id, value) + ::set_field(self, id, value) } fn validate(&self, context: &Self::Context) -> Result { @@ -213,13 +212,21 @@ impl ConfigModel for CandidateProfile { #[cfg(test)] mod tests { - use kaptan_config::{ConfigModel, ConfigValue, FieldWriteError, ValidationRule}; + use kaptan_config::{ConfigModel, ConfigValue, FieldId, FieldWriteError, ValidationRule}; use super::{CandidateProfile, VehicleCapabilities}; use crate::profile::ids; 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] fn every_descriptor_has_a_readable_default_value() { let candidate = CandidateProfile::default(); @@ -293,13 +300,19 @@ mod tests { fn observability_filter_is_persisted_restart_only_and_cannot_hide_errors() { let mut candidate = CandidateProfile::default(); candidate - .set_field(ids::OBSERVABILITY_MAXIMUM_LOG_LEVEL, ConfigValue::U32(3)) + .set_field( + descriptor_id("observability.maximum_log_level"), + ConfigValue::U32(3), + ) .unwrap(); let active = candidate.validate(&CAPABILITIES).unwrap(); assert_eq!(active.observability().maximum_log_level(), 3); assert_eq!(active.observability().maximum_log_level(), 3); candidate - .set_field(ids::OBSERVABILITY_MAXIMUM_LOG_LEVEL, ConfigValue::U32(5)) + .set_field( + descriptor_id("observability.maximum_log_level"), + ConfigValue::U32(5), + ) .unwrap(); assert_eq!( candidate @@ -311,7 +324,7 @@ mod tests { ); assert!(matches!( candidate.set_field( - ids::OBSERVABILITY_MAXIMUM_LOG_LEVEL, + descriptor_id("observability.maximum_log_level"), ConfigValue::U32(2), ), Err(FieldWriteError::Violation(issue)) @@ -324,7 +337,10 @@ mod tests { for raw in [3, 4, 5, 6, 7] { let mut candidate = CandidateProfile::default(); 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"); assert_eq!( candidate diff --git a/libs/kaptan-flight-config/src/profile/control.rs b/libs/kaptan-flight-config/src/profile/control.rs index c08b19f..b46b079 100644 --- a/libs/kaptan-flight-config/src/profile/control.rs +++ b/libs/kaptan-flight-config/src/profile/control.rs @@ -1,4 +1,4 @@ -use kaptan_config::{ConfigValidate, ConfigViolation, ValidateFields, ValidationRule}; +use kaptan_config::{ConfigValidate, ConfigViolation, ValidationRule}; use super::ids; @@ -24,12 +24,6 @@ pub(super) struct AidingCandidate { 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)] pub(super) struct ControlCandidate { #[config( @@ -78,13 +72,8 @@ pub(super) struct ControlCandidate { )] pub(super) maximum_downward_acceleration_mps2: f32, } -impl ControlCandidate { - pub(super) fn validate(self) -> Result<(), ConfigViolation> { - self.validate_fields() - } -} - #[derive(Clone, Copy, ConfigValidate)] +#[config(post = Self::validate_thrust)] pub(super) struct ThrustCandidate { #[config( id = 13, @@ -115,8 +104,7 @@ pub(super) struct ThrustCandidate { pub(super) maximum_tilt_tangent: f32, } impl ThrustCandidate { - pub(super) fn validate(self) -> Result<(), ConfigViolation> { - self.validate_fields()?; + fn validate_thrust(&self) -> Result<(), ConfigViolation> { if self.maximum_collective_thrust_newtons <= self.mass_kg * 9.806_65 { return Err(ConfigViolation::related( ids::MAXIMUM_COLLECTIVE_THRUST_NEWTONS, @@ -129,6 +117,7 @@ impl ThrustCandidate { } #[derive(Clone, Copy, ConfigValidate)] +#[config(post = Self::validate_pid)] pub(super) struct PidCandidate { #[config( id = 16, @@ -177,8 +166,7 @@ pub(super) struct PidCandidate { pub(super) integral_limit: f32, } impl PidCandidate { - pub(super) fn validate(self) -> Result<(), ConfigViolation> { - self.validate_fields()?; + fn validate_pid(&self) -> Result<(), ConfigViolation> { if self.integral_limit > self.output_limit { return Err(ConfigViolation::related( ids::RATE_PID_INTEGRAL_LIMIT, @@ -191,6 +179,7 @@ impl PidCandidate { } #[derive(Clone, Copy, ConfigValidate)] +#[config(post = Self::validate_actuator_limits)] pub(super) struct ActuatorLimitsCandidate { #[config( id = 21, @@ -221,8 +210,7 @@ pub(super) struct ActuatorLimitsCandidate { pub(super) slew_per_second: f32, } impl ActuatorLimitsCandidate { - pub(super) fn validate(self) -> Result<(), ConfigViolation> { - self.validate_fields()?; + fn validate_actuator_limits(&self) -> Result<(), ConfigViolation> { if self.minimum >= self.maximum { return Err(ConfigViolation::related( ids::ACTUATOR_MINIMUM, diff --git a/libs/kaptan-flight-config/src/profile/ids.rs b/libs/kaptan-flight-config/src/profile/ids.rs index dc75c73..f5d6004 100644 --- a/libs/kaptan-flight-config/src/profile/ids.rs +++ b/libs/kaptan-flight-config/src/profile/ids.rs @@ -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; -// These symbolic names are projections of derive-generated metadata. No field -// identifier is handwritten outside the field declaration itself. -pub(super) const PREARM_DWELL_MICROSECONDS: FieldId = SafetyCandidate::DESCRIPTORS[0].id; +use super::{ + control::{ActuatorLimitsCandidate, PidCandidate, ThrustCandidate}, + timing::TimingCandidate, +}; + 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 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 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_INTEGRAL_LIMIT: FieldId = PidCandidate::DESCRIPTORS[4].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_SLEW_PER_SECOND: FieldId = ActuatorLimitsCandidate::DESCRIPTORS[2].id; -pub(super) const OBSERVABILITY_MAXIMUM_LOG_LEVEL: FieldId = - ObservabilityCandidate::DESCRIPTORS[0].id; diff --git a/libs/kaptan-flight-config/src/profile/observability.rs b/libs/kaptan-flight-config/src/profile/observability.rs index d57f385..3c85779 100644 --- a/libs/kaptan-flight-config/src/profile/observability.rs +++ b/libs/kaptan-flight-config/src/profile/observability.rs @@ -1,6 +1,6 @@ //! 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 /// detail; error-level records are never suppressible configuration fields. @@ -16,12 +16,6 @@ pub(super) struct ObservabilityCandidate { 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. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct ObservabilitySettings { diff --git a/libs/kaptan-flight-config/src/profile/registry.rs b/libs/kaptan-flight-config/src/profile/registry.rs index 551c099..b61131b 100644 --- a/libs/kaptan-flight-config/src/profile/registry.rs +++ b/libs/kaptan-flight-config/src/profile/registry.rs @@ -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::{ - ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError, ValidationRule, - ValueKind, -}; +use kaptan_config::FieldDescriptor; use super::{ candidate::CandidateProfile, control::{ ActuatorLimitsCandidate, AidingCandidate, ControlCandidate, PidCandidate, ThrustCandidate, }, - ids, observability::ObservabilityCandidate, safety::SafetyCandidate, 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 -/// parameter. It requires a separate physical-airframe safety workflow. -pub(super) const FIELD_COUNT: usize = SafetyCandidate::DESCRIPTORS.len() - + TimingCandidate::DESCRIPTORS.len() - + AidingCandidate::DESCRIPTORS.len() - + ControlCandidate::DESCRIPTORS.len() - + ThrustCandidate::DESCRIPTORS.len() - + PidCandidate::DESCRIPTORS.len() - + ActuatorLimitsCandidate::DESCRIPTORS.len() - + ObservabilityCandidate::DESCRIPTORS.len(); -/// The registry is an ordered view of metadata generated from field attributes. -/// It owns no IDs, bounds, defaults, or display text. +/// Allocation topology remains immutable board capability data, not an +/// operator parameter. +pub(super) const FIELD_COUNT: usize = CandidateProfile::FIELD_COUNT; + +/// Ordered view of metadata generated from field attributes. +/// +/// This has no independently-maintained defaults, bounds, values, or setter +/// logic. The length is tied to `CandidateProfile::FIELD_COUNT` as a +/// compile-time completeness guard. const FIELDS: [FieldDescriptor; FIELD_COUNT] = [ SafetyCandidate::DESCRIPTORS[0], TimingCandidate::DESCRIPTORS[0], @@ -77,332 +75,6 @@ pub(super) const fn descriptors() -> &'static [FieldDescriptor; FIELD_COUNT] { &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> { FIELDS.get(usize::from(index)) } - -pub(super) fn field_value(candidate: &CandidateProfile, id: FieldId) -> Option { - 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 { - 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 { - 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 { - 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) -} diff --git a/libs/kaptan-flight-config/src/profile/safety.rs b/libs/kaptan-flight-config/src/profile/safety.rs index ccfdbde..5974f3b 100644 --- a/libs/kaptan-flight-config/src/profile/safety.rs +++ b/libs/kaptan-flight-config/src/profile/safety.rs @@ -1,4 +1,4 @@ -use kaptan_config::{ConfigValidate, ConfigViolation, ValidateFields}; +use kaptan_config::ConfigValidate; #[derive(Clone, Copy, ConfigValidate)] pub(super) struct SafetyCandidate { @@ -12,9 +12,3 @@ pub(super) struct SafetyCandidate { )] pub(super) prearm_dwell_microseconds: u64, } - -impl SafetyCandidate { - pub(super) fn validate(self) -> Result<(), ConfigViolation> { - self.validate_fields() - } -} diff --git a/libs/kaptan-flight-config/src/profile/timing.rs b/libs/kaptan-flight-config/src/profile/timing.rs index 271bb14..e97909d 100644 --- a/libs/kaptan-flight-config/src/profile/timing.rs +++ b/libs/kaptan-flight-config/src/profile/timing.rs @@ -1,8 +1,9 @@ -use kaptan_config::{ConfigValidate, ConfigViolation, ValidateFields, ValidationRule}; +use kaptan_config::{ConfigValidate, ConfigViolation, ValidationRule}; use super::ids; #[derive(Clone, Copy, ConfigValidate)] +#[config(post = Self::validate_timing)] pub(super) struct TimingCandidate { #[config( id = 2, @@ -43,8 +44,7 @@ pub(super) struct TimingCandidate { } impl TimingCandidate { - pub(super) fn validate(self) -> Result<(), ConfigViolation> { - self.validate_fields()?; + fn validate_timing(&self) -> Result<(), ConfigViolation> { if self.maximum_imu_interval_microseconds > self.imu_freshness_microseconds { return Err(ConfigViolation::related( ids::MAXIMUM_IMU_INTERVAL_MICROSECONDS, diff --git a/tools/sitl/src/config/store.rs b/tools/sitl/src/config/store.rs index fe05a0d..b79cd94 100644 --- a/tools/sitl/src/config/store.rs +++ b/tools/sitl/src/config/store.rs @@ -8,8 +8,8 @@ use std::{ use kaptan_config::{Generation, ProfileStore}; use kaptan_flight_config::{ - CandidateProfile, CborError, MAXIMUM_CANDIDATE_BYTES, VehicleProfile, decode_candidate, - encode_candidate, + CandidateProfile, CborEncodeError, CborError, MAXIMUM_CANDIDATE_BYTES, VehicleProfile, + decode_candidate, encode_candidate, }; use kaptan_log::KaptanError; @@ -134,12 +134,13 @@ fn write_candidate( candidate: CandidateProfile, generation: Generation, ) -> 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]); - 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 bytes = writer.into_inner(); let mut envelope = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES + 64]); diff --git a/tools/sitl/src/config/tests.rs b/tools/sitl/src/config/tests.rs index bae4f22..591cd09 100644 --- a/tools/sitl/src/config/tests.rs +++ b/tools/sitl/src/config/tests.rs @@ -1,6 +1,6 @@ use std::fs; -use kaptan_config::{Generation, ProfileStore}; +use kaptan_config::{ConfigModel, Generation, ProfileStore}; use kaptan_config_mavlink::{ Authorized, ConfigCommand, ResultKind, ServiceResponse, SnapshotImporter, }; @@ -87,7 +87,7 @@ fn host_store_round_trips_a_complete_validated_snapshot() { Err(CborError::Malformed) )); let (_, candidate) = store.load().unwrap().expect("persisted candidate"); - assert!(candidate.validate_against(&CAPABILITIES).is_ok()); + assert!(candidate.validate(&CAPABILITIES).is_ok()); assert_eq!( 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() { let candidate = CandidateProfile::default(); 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 bytes = writer.into_inner(); let imported = SitlSnapshotImporter .decode(&bytes[..length]) .expect("valid current candidate import"); - assert!(imported.validate_against(&CAPABILITIES).is_ok()); + assert!(imported.validate(&CAPABILITIES).is_ok()); } #[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]); - encode_candidate(CandidateProfile::default(), &mut writer).unwrap(); + encode_candidate(CandidateProfile::default(), &CAPABILITIES, &mut writer).unwrap(); let length = writer.position(); assert!(length <= 192); let bytes = writer.into_inner();