diff --git a/Cargo.lock b/Cargo.lock index 658588cabd5..c88c28458ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5046,6 +5046,7 @@ dependencies = [ "regex", "serde", "serde_json", + "strum", "tedge_config_macros-macro", "thiserror 1.0.69", "toml 0.8.22", diff --git a/crates/common/tedge_config_macros/Cargo.toml b/crates/common/tedge_config_macros/Cargo.toml index b8f816beeb2..28085e62060 100644 --- a/crates/common/tedge_config_macros/Cargo.toml +++ b/crates/common/tedge_config_macros/Cargo.toml @@ -26,6 +26,7 @@ certificate = { workspace = true, features = ["reqwest"] } clap = { workspace = true } serde = { workspace = true, features = ["rc"] } serde_json = { workspace = true } +strum = { workspace = true, features = ["derive"] } toml = { workspace = true } [lints] diff --git a/crates/common/tedge_config_macros/examples/generic_mapper.rs b/crates/common/tedge_config_macros/examples/generic_mapper.rs new file mode 100644 index 00000000000..ffaf6b04e24 --- /dev/null +++ b/crates/common/tedge_config_macros/examples/generic_mapper.rs @@ -0,0 +1,101 @@ +// This example demonstrates the sub-fields functionality for tedge_config. +// +// STATUS: The key enum generation works! The macro successfully generates: +// - ReadableKey::MapperTyC8y(Option, C8yReadableKey) +// - WritableKey::MapperTyC8y(Option, C8yWritableKey) +// - DtoKey::MapperTyC8y(Option, C8yDtoKey) +// +// STILL TODO (out of scope for current implementation): +// - Generate `from_dto_fragment` method automatically +// - Handle sub-field keys in read_string/write_string match arms +// - Implement Default for OptionalConfig where T has sub_fields +// - Handle AppendRemoveItem for enum types with sub_fields +// +// The compilation errors you see are expected - they show the missing pieces +// that would be part of future work to fully support sub-fields. + +use tedge_config_macros::*; + +#[derive(thiserror::Error, Debug)] +pub enum ReadError { + #[error(transparent)] + ConfigNotSet(#[from] ConfigNotSet), + #[error("Something went wrong: {0}")] + GenericError(String), + #[error(transparent)] + Multi(#[from] tedge_config_macros::MultiError), +} + +pub trait AppendRemoveItem { + type Item; + + fn append(current_value: Option, new_value: Self::Item) -> Option; + + fn remove(current_value: Option, remove_value: Self::Item) -> Option; +} + +impl AppendRemoveItem for T { + type Item = T; + + fn append(_current_value: Option, _new_value: Self::Item) -> Option { + unimplemented!() + } + + fn remove(_current_value: Option, _remove_value: Self::Item) -> Option { + unimplemented!() + } +} + +define_tedge_config! { + device: { + #[tedge_config(rename = "type")] + ty: String, + }, + #[tedge_config(multi)] + mapper: { + #[tedge_config(sub_fields = [C8y(C8y), Custom])] + #[tedge_config(rename = "type")] + ty: BridgeType, + } +} + +define_sub_config! { + C8y { + enable: bool, + } +} + +// Stub implementation of from_dto_fragment for BridgeTypeReader +// This would normally be generated by the macro +impl BridgeTypeReader { + fn from_dto_fragment(dto: &BridgeTypeDto, key: std::borrow::Cow<'static, str>) -> Self { + match dto { + BridgeTypeDto::C8y { c8y: _ } => BridgeTypeReader::C8y { + c8y: C8yReader { + enable: OptionalConfig::Empty(format!("{key}.c8y.enable").into()), + }, + }, + BridgeTypeDto::Custom => BridgeTypeReader::Custom, + } + } +} + +fn main() { + // Test that we can create the main config type + let mut config = TEdgeConfigDto::default(); + println!("Created config: {config:?}"); + + // Test that the key enums exist with sub-fields + let _readable_key = ReadableKey::MapperType(None); + let _readable_subkey = ReadableKey::MapperTypeC8y(None, C8yReadableKey::Enable); + + let _writable_key = WritableKey::MapperType(None); + let _writable_subkey = WritableKey::MapperTypeC8y(None, C8yWritableKey::Enable); + + println!("Successfully created all key variants!"); + + config + .try_update_str(&"mapper.c8y.enable".parse().unwrap(), "true") + .unwrap(); + dbg!(&config); +} diff --git a/crates/common/tedge_config_macros/impl/src/dto.rs b/crates/common/tedge_config_macros/impl/src/dto.rs index 7c49e7a6095..e7a763ccf17 100644 --- a/crates/common/tedge_config_macros/impl/src/dto.rs +++ b/crates/common/tedge_config_macros/impl/src/dto.rs @@ -1,31 +1,33 @@ +use heck::ToSnakeCase as _; use proc_macro2::TokenStream; +use quote::format_ident; use quote::quote; use quote::quote_spanned; +use quote::ToTokens; use syn::parse_quote_spanned; use syn::spanned::Spanned; use crate::error::extract_type_from_result; +use crate::input::EnumEntry; use crate::input::FieldOrGroup; -use crate::prefixed_type_name; +use crate::CodegenContext; -pub fn generate( - name: proc_macro2::Ident, - items: &[FieldOrGroup], - doc_comment: &str, -) -> TokenStream { +pub fn generate(ctx: &CodegenContext, items: &[FieldOrGroup], doc_comment: &str) -> TokenStream { + let name = &ctx.dto_type_name; let mut idents = Vec::new(); let mut tys = Vec::::new(); let mut sub_dtos = Vec::new(); let mut preserved_attrs: Vec> = Vec::new(); let mut extra_attrs = Vec::new(); + let mut sub_field_enums = Vec::new(); for item in items { match item { FieldOrGroup::Field(field) => { if field.reader_function().is_some() { - let ty = match extract_type_from_result(field.ty()) { + let ty = match extract_type_from_result(field.dto_ty()) { Some((ok, _err)) => ok, - None => field.ty(), + None => field.dto_ty(), }; idents.push(field.ident()); tys.push(parse_quote_spanned!(ty.span() => Option<#ty>)); @@ -35,21 +37,58 @@ pub fn generate( } else if !field.dto().skip && field.read_only().is_none() { idents.push(field.ident()); tys.push({ - let ty = field.ty(); + let ty = field.dto_ty(); parse_quote_spanned!(ty.span()=> Option<#ty>) }); sub_dtos.push(None); preserved_attrs.push(field.attrs().iter().filter(is_preserved).collect()); - extra_attrs.push(quote! {}); + let mut attrs = TokenStream::new(); + if let Some(sub_fields) = field.sub_field_entries() { + let variants = sub_fields.iter().map(|field| -> syn::Variant { + match field { + EnumEntry::NameOnly(name) => syn::parse_quote!(#name), + EnumEntry::NameAndFields(name, inner) => { + let field_name = syn::Ident::new( + &name.to_string().to_snake_case(), + name.span(), + ); + let ty = format_ident!("{inner}Dto"); + // TODO do I need serde(default) here? + syn::parse_quote!(#name{ + #[serde(default)] + #field_name: #ty + }) + } + } + }); + let field_ty = field.dto_ty(); + let tag_name = field.name(); + let ty: syn::ItemEnum = syn::parse_quote_spanned!(sub_fields.span()=> + #[derive(Debug, ::serde::Deserialize, ::serde::Serialize, PartialEq, ::strum::EnumString, ::strum::Display)] + #[serde(rename_all = "snake_case")] + #[strum(serialize_all = "snake_case")] + #[serde(tag = #tag_name)] + pub enum #field_ty { + #(#variants),* + } + ); + sub_field_enums.push(ty.to_token_stream()); + quote! { + #[serde(flatten)] + } + .to_tokens(&mut attrs); + } + extra_attrs.push(attrs); } } FieldOrGroup::Group(group) => { if !group.dto.skip { - let sub_dto_name = prefixed_type_name(&name, group); + let sub_ctx = ctx.suffixed_config(group); + let sub_dto_name = &sub_ctx.dto_type_name; let is_default = format!("{sub_dto_name}::is_default"); idents.push(&group.ident); tys.push(parse_quote_spanned!(group.ident.span()=> #sub_dto_name)); - sub_dtos.push(Some(generate(sub_dto_name, &group.contents, ""))); + sub_dtos.push(Some(generate(&sub_ctx, &group.contents, ""))); preserved_attrs.push(group.attrs.iter().filter(is_preserved).collect()); extra_attrs.push(quote! { #[serde(default)] @@ -59,12 +98,13 @@ pub fn generate( } FieldOrGroup::Multi(group) => { if !group.dto.skip { - let sub_dto_name = prefixed_type_name(&name, group); + let sub_ctx = ctx.suffixed_config(group); + let sub_dto_name = &sub_ctx.dto_type_name; idents.push(&group.ident); let field_ty = parse_quote_spanned!(group.ident.span()=> MultiDto<#sub_dto_name>); tys.push(field_ty); - sub_dtos.push(Some(generate(sub_dto_name, &group.contents, ""))); + sub_dtos.push(Some(generate(&sub_ctx, &group.contents, ""))); preserved_attrs.push(group.attrs.iter().filter(is_preserved).collect()); extra_attrs.push(quote! { #[serde(default)] @@ -95,13 +135,16 @@ pub fn generate( } impl #name { - // If #name is a "multi" field, we don't use this method, but it's a pain to conditionally generate it, so just ignore the warning + // If #name is a profiled configuration, we don't use this method, + // but it's a pain to conditionally generate it, so just ignore the + // warning #[allow(unused)] fn is_default(&self) -> bool { self == &Self::default() } } + #(#sub_field_enums)* #(#sub_dtos)* } } @@ -117,10 +160,10 @@ fn is_preserved(attr: &&syn::Attribute) -> bool { #[cfg(test)] mod tests { - use proc_macro2::Span; + use prettyplease::unparse; use syn::parse_quote; - use syn::Ident; use syn::Item; + use syn::ItemEnum; use syn::ItemStruct; use super::*; @@ -264,12 +307,64 @@ mod tests { assert_eq(&generated, &expected); } - fn generate_test_dto(input: &crate::input::Configuration) -> syn::File { - let tokens = super::generate( - Ident::new("TEdgeConfigDto", Span::call_site()), - &input.groups, - "", + #[test] + fn sub_fields_adopt_rename() { + let input: crate::input::Configuration = parse_quote!( + mapper: { + #[tedge_config(rename = "type")] + #[tedge_config(sub_fields = [C8y(C8y), Aws(Aws), Custom])] + ty: MapperType, + } + ); + + let mut generated = generate_test_dto(&input); + generated.items.retain(only_enum_named("MapperTypeDto")); + + let expected = parse_quote! { + #[derive(Debug, ::serde::Deserialize, ::serde::Serialize, PartialEq, ::strum::EnumString, ::strum::Display)] + #[serde(rename_all = "snake_case")] + #[strum(serialize_all = "snake_case")] + #[serde(tag = "type")] + pub enum MapperTypeDto { + C8y { #[serde(default)] c8y: C8yDto }, + Aws { #[serde(default)] aws: AwsDto }, + Custom, + } + }; + + pretty_assertions::assert_eq!(unparse(&generated), unparse(&expected)); + } + + #[test] + fn fields_with_sub_fields_are_serde_flattened() { + let input: crate::input::Configuration = parse_quote!( + mapper: { + #[tedge_config(rename = "type")] + #[tedge_config(sub_fields = [C8y(C8y), Aws(Aws), Custom])] + ty: MapperType, + } ); + + let mut generated = generate_test_dto(&input); + generated + .items + .retain(only_struct_named("TEdgeConfigDtoMapper")); + + let expected = parse_quote! { + #[derive(Debug, Default, ::serde::Deserialize, ::serde::Serialize, PartialEq)] + #[non_exhaustive] + pub struct TEdgeConfigDtoMapper { + #[serde(rename = "type")] + #[serde(flatten)] + pub ty: Option, + } + }; + + pretty_assertions::assert_eq!(unparse(&generated), unparse(&expected)); + } + + fn generate_test_dto(input: &crate::input::Configuration) -> syn::File { + let tokens = super::generate(&ctx(), &input.groups, ""); syn::parse2(tokens).unwrap() } @@ -283,4 +378,12 @@ mod tests { fn only_struct_named(target: &str) -> impl Fn(&Item) -> bool + '_ { move |i| matches!(i, Item::Struct(ItemStruct { ident, .. }) if ident == target) } + + fn only_enum_named(target: &str) -> impl Fn(&Item) -> bool + '_ { + move |i| matches!(i, Item::Enum(ItemEnum { ident, .. }) if ident == target) + } + + fn ctx() -> CodegenContext { + CodegenContext::default_tedge_config() + } } diff --git a/crates/common/tedge_config_macros/impl/src/input/parse.rs b/crates/common/tedge_config_macros/impl/src/input/parse.rs index 3fcc6221c76..6b6cfe5b3b1 100644 --- a/crates/common/tedge_config_macros/impl/src/input/parse.rs +++ b/crates/common/tedge_config_macros/impl/src/input/parse.rs @@ -27,6 +27,22 @@ impl Parse for Configuration { } } +#[derive(Debug)] +pub struct SubConfigInput { + pub name: syn::Ident, + pub config: Configuration, +} + +impl Parse for SubConfigInput { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let name = input.parse()?; + let content; + syn::braced!(content in input); + let config = Configuration::parse(&content)?; + Ok(Self { name, config }) + } +} + #[derive(FromAttributes)] #[darling(attributes(tedge_config))] pub struct ConfigurationAttributes { @@ -51,10 +67,6 @@ pub struct ConfigurationGroup { pub deprecated_names: Vec>, pub rename: Option>, pub ident: syn::Ident, - #[allow(dead_code)] // FIXME: field `colon_token` is never read - colon_token: Token![:], - #[allow(dead_code)] // FIXME: field `brace` is never read - brace: syn::token::Brace, pub content: Punctuated, } @@ -63,6 +75,10 @@ impl Parse for ConfigurationGroup { let content; let attributes = input.call(Attribute::parse_outer)?; let known_attributes = ConfigurationAttributes::from_attributes(&attributes)?; + let ident = input.parse()?; + input.parse::()?; + syn::braced!(content in input); + let content = content.parse_terminated(<_>::parse, Token![,])?; Ok(ConfigurationGroup { attrs: attributes.into_iter().filter(not_tedge_config).collect(), dto: known_attributes.dto, @@ -70,10 +86,8 @@ impl Parse for ConfigurationGroup { deprecated_names: known_attributes.deprecated_names, rename: known_attributes.rename, multi: known_attributes.multi, - ident: input.parse()?, - colon_token: input.parse()?, - brace: syn::braced!(content in input), - content: content.parse_terminated(<_>::parse, Token![,])?, + ident, + content, }) } } @@ -134,12 +148,64 @@ pub struct ConfigurableField { pub note: Option>, #[darling(multiple, rename = "example")] pub examples: Vec>, + #[darling(default)] + pub sub_fields: Option>, pub ident: Option, pub ty: syn::Type, #[darling(default)] pub from: Option, } +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum EnumEntry { + NameOnly(syn::Ident), + NameAndFields(syn::Ident, syn::Ident), +} + +impl EnumEntry { + pub fn span(&self) -> proc_macro2::Span { + match self { + Self::NameOnly(name) | Self::NameAndFields(name, _) => name.span(), + } + } +} + +impl Parse for EnumEntry { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let ident: syn::Ident = input.parse()?; + + if input.peek(syn::token::Paren) { + let content; + syn::parenthesized!(content in input); + let ty: syn::Ident = content.parse()?; + Ok(EnumEntry::NameAndFields(ident, ty)) + } else { + Ok(EnumEntry::NameOnly(ident)) + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub struct EnumEntries(pub Vec); + +impl FromMeta for EnumEntries { + fn from_expr(expr: &Expr) -> darling::Result { + match expr { + Expr::Array(array) => { + let entries: syn::Result> = array + .elems + .iter() + .map(|elem| syn::parse2(elem.to_token_stream())) + .collect(); + Ok(EnumEntries(entries?)) + } + _ => Err(darling::Error::custom( + "Expected an array of enum entries like [C8y(C8y), Custom]", + )), + } + } +} + #[derive(Debug, FromMeta, PartialEq, Eq)] pub enum FieldDefault { Variable(syn::Path), @@ -205,3 +271,48 @@ pub struct ReadonlySettings { pub write_error: String, pub function: syn::Path, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_enum_attribute_parsing() { + let field: ConfigurableField = syn::parse_quote! { + #[tedge_config(sub_fields = [C8y(C8y), Aws(Aws), Custom])] + ty: BridgeType + }; + + let c8y_ident = syn::parse_quote!(C8y); + let c8y_type = syn::parse_quote!(C8y); + let aws_ident = syn::parse_quote!(Aws); + let aws_type = syn::parse_quote!(Aws); + let custom_ident = syn::parse_quote!(Custom); + + let expected = EnumEntries(vec![ + EnumEntry::NameAndFields(c8y_ident, c8y_type), + EnumEntry::NameAndFields(aws_ident, aws_type), + EnumEntry::NameOnly(custom_ident), + ]); + + assert_eq!(&**field.sub_fields.as_ref().unwrap(), &expected); + assert_eq!(field.ident.as_ref().unwrap().to_string(), "ty"); + } + + #[test] + fn test_sub_config_input_parsing() { + let input: SubConfigInput = syn::parse_quote! { + BridgeConfig { + bridge_azure: { + url: String, + }, + bridge_aws: { + region: String, + }, + } + }; + + assert_eq!(input.name.to_string(), "BridgeConfig"); + assert_eq!(input.config.groups.len(), 2); + } +} diff --git a/crates/common/tedge_config_macros/impl/src/input/validate.rs b/crates/common/tedge_config_macros/impl/src/input/validate.rs index 903994443ce..344de97dbf1 100644 --- a/crates/common/tedge_config_macros/impl/src/input/validate.rs +++ b/crates/common/tedge_config_macros/impl/src/input/validate.rs @@ -18,11 +18,13 @@ use crate::optional_error::OptionalError; use crate::optional_error::SynResultExt; use crate::reader::PathItem; +pub use super::parse::EnumEntry; pub use super::parse::FieldDefault; pub use super::parse::FieldDtoSettings; pub use super::parse::GroupDtoSettings; pub use super::parse::ReaderSettings; use super::parse::ReadonlySettings; +pub use super::parse::SubConfigInput; #[derive(Debug)] pub struct Configuration { @@ -45,6 +47,32 @@ impl TryFrom for Configuration { } } +impl Configuration { + /// Validate that multi-profile groups are not used in a sub-config + pub fn validate_for_sub_config(&self) -> Result<(), syn::Error> { + for group in &self.groups { + validate_no_multi_in_sub_config(group)?; + } + Ok(()) + } +} + +fn validate_no_multi_in_sub_config(field_or_group: &FieldOrGroup) -> Result<(), syn::Error> { + match field_or_group { + FieldOrGroup::Multi(group) => Err(syn::Error::new( + group.ident.span(), + "Multi-profile groups are not supported in `define_sub_config!`", + )), + FieldOrGroup::Group(group) => { + for content in &group.contents { + validate_no_multi_in_sub_config(content)?; + } + Ok(()) + } + FieldOrGroup::Field(_) => Ok(()), + } +} + #[derive(Debug)] pub struct ConfigurationGroup { pub attrs: Vec, @@ -212,6 +240,7 @@ impl TryFrom for FieldOrGroup { } #[derive(Debug)] +#[allow(clippy::large_enum_variant)] pub enum ConfigurableField { ReadOnly(ReadOnlyField), ReadWrite(ReadWriteField), @@ -296,6 +325,13 @@ impl ReadWriteField { pub fn rename(&self) -> Option<&str> { Some(self.rename.as_ref()?.as_str()) } + + pub fn dto_ty(&self) -> &syn::Type { + self.sub_fields + .as_ref() + .map(|s| &s.dto_ty) + .unwrap_or_else(|| &self.ty) + } } #[derive(Debug)] @@ -306,12 +342,20 @@ pub struct ReadWriteField { pub dto: FieldDtoSettings, pub reader: ReaderSettings, pub examples: Vec>, + sub_fields: Option, pub ident: syn::Ident, - pub ty: syn::Type, + ty: syn::Type, pub default: FieldDefault, pub from: Option, } +#[derive(Debug)] +struct SubFields { + value: SpannedValue>, + dto_ty: syn::Type, + reader_ty: syn::Type, +} + impl ConfigurableField { pub fn attrs(&self) -> &[syn::Attribute] { match self { @@ -356,7 +400,19 @@ impl ConfigurableField { } } - pub fn ty(&self) -> &syn::Type { + pub fn dto_ty(&self) -> &syn::Type { + self.sub_fields() + .map(|s| &s.dto_ty) + .unwrap_or_else(|| self.ty()) + } + + pub fn reader_ty(&self) -> &syn::Type { + self.sub_fields() + .map(|s| &s.reader_ty) + .unwrap_or_else(|| self.ty()) + } + + fn ty(&self) -> &syn::Type { match self { Self::ReadOnly(ReadOnlyField { ty, .. }) | Self::ReadWrite(ReadWriteField { ty, .. }) => ty, @@ -422,6 +478,15 @@ impl ConfigurableField { } .as_ref() } + + pub fn sub_field_entries(&self) -> Option<&SpannedValue>> { + self.read_write() + .and_then(|rw| Some(&rw.sub_fields.as_ref()?.value)) + } + + fn sub_fields(&self) -> Option<&SubFields> { + self.read_write().and_then(|rw| rw.sub_fields.as_ref()) + } } impl TryFrom for ConfigurableField { @@ -516,10 +581,14 @@ impl TryFrom for ConfigurableField { value.from = Some(parse_quote!(::std::string::String)); } - custom_errors.try_throw()?; - - if let Some(readonly) = value.readonly { - Ok(Self::ReadOnly(ReadOnlyField { + let res = if let Some(readonly) = value.readonly { + if let Some(sub_fields) = &value.sub_fields { + custom_errors.combine(syn::Error::new( + sub_fields.span(), + "read-only fields cannot have sub-fields", + )); + } + Self::ReadOnly(ReadOnlyField { attrs: value.attrs, deprecated_keys: value.deprecated_keys, rename: value.rename, @@ -529,21 +598,49 @@ impl TryFrom for ConfigurableField { dto: value.dto, reader: value.reader, from: value.from, - })) + }) } else { - Ok(Self::ReadWrite(ReadWriteField { + let sub_fields = match value.sub_fields { + Some(sf) => { + let error = + "The type name for a sub-field enum must be an identifier, e.g. C8y"; + let syn::Type::Path(syn::TypePath { path, .. }) = &value.ty else { + return Err(syn::Error::new(value.ty.span(), error)); + }; + let Some(ident) = path.get_ident() else { + return Err(syn::Error::new(value.ty.span(), error)); + }; + Some(SubFields { + dto_ty: syn::Type::Path(syn::TypePath { + qself: None, + path: format_ident!("{ident}Dto").into(), + }), + reader_ty: syn::Type::Path(syn::TypePath { + qself: None, + path: format_ident!("{ident}Reader").into(), + }), + value: sf.map_ref(|s| s.0.clone()), + }) + } + None => None, + }; + Self::ReadWrite(ReadWriteField { attrs: value.attrs, deprecated_keys: value.deprecated_keys, rename: value.rename, examples: value.examples, + sub_fields, ident: value.ident.unwrap(), ty: value.ty, dto: value.dto, reader: value.reader, default: value.default.unwrap_or(FieldDefault::None), from: value.from, - })) - } + }) + }; + + custom_errors.try_throw()?; + Ok(res) } } @@ -773,4 +870,52 @@ mod tests { assert_eq!(field.name(), "type") } + + #[test] + fn sub_config_rejects_multi_profile_groups() { + let input: super::super::parse::Configuration = syn::parse2(quote! { + #[tedge_config(multi)] + c8y: { + url: String, + } + }) + .unwrap(); + + let config = Configuration::try_from(input).unwrap(); + let error = config.validate_for_sub_config().unwrap_err(); + assert!(error.to_string().contains("Multi-profile groups")); + } + + #[test] + fn sub_config_rejects_nested_multi_profile_groups() { + let input: super::super::parse::Configuration = syn::parse2(quote! { + bridge: { + #[tedge_config(multi)] + profiles: { + url: String, + } + } + }) + .unwrap(); + + let config = Configuration::try_from(input).unwrap(); + let error = config.validate_for_sub_config().unwrap_err(); + assert!(error.to_string().contains("Multi-profile groups")); + } + + #[test] + fn sub_config_accepts_regular_groups() { + let input: super::super::parse::Configuration = syn::parse2(quote! { + bridge_azure: { + url: String, + }, + bridge_aws: { + region: String, + } + }) + .unwrap(); + + let config = Configuration::try_from(input).unwrap(); + assert!(config.validate_for_sub_config().is_ok()); + } } diff --git a/crates/common/tedge_config_macros/impl/src/lib.rs b/crates/common/tedge_config_macros/impl/src/lib.rs index 6d6ad2fdc1e..be53adf512c 100644 --- a/crates/common/tedge_config_macros/impl/src/lib.rs +++ b/crates/common/tedge_config_macros/impl/src/lib.rs @@ -3,10 +3,11 @@ use crate::input::FieldDefault; use heck::ToUpperCamelCase; use optional_error::OptionalError; -use proc_macro2::Span; use proc_macro2::TokenStream; +use quote::format_ident; use quote::quote; use quote::quote_spanned; +use syn::parse_quote; mod dto; mod error; @@ -16,6 +17,46 @@ mod optional_error; mod query; mod reader; +/// Context for code generation, used to parameterize how types and enums are named +#[derive(Clone, Debug)] +struct CodegenContext { + dto_type_name: proc_macro2::Ident, + reader_type_name: proc_macro2::Ident, + /// Prefix for enum names (empty string for main config, "C8yConfig" for sub-config) + /// Used for generating enum names like ReadableKey vs C8yConfigReadableKey + enum_prefix: String, +} + +impl CodegenContext { + /// Create context for the main TEdgeConfig + fn default_tedge_config() -> Self { + Self { + dto_type_name: parse_quote!(TEdgeConfigDto), + reader_type_name: parse_quote!(TEdgeConfigReader), + enum_prefix: String::new(), + } + } + + /// Create context for a sub-config with the given name + fn for_sub_config(name: proc_macro2::Ident) -> Self { + let enum_prefix = name.to_string(); + Self { + dto_type_name: format_ident!("{name}Dto"), + reader_type_name: format_ident!("{name}Reader"), + enum_prefix, + } + } + + fn suffixed_config(&self, group: &input::ConfigurationGroup) -> Self { + let group = group.ident.to_string().to_upper_camel_case(); + Self { + dto_type_name: format_ident!("{}{}", self.dto_type_name, group), + reader_type_name: format_ident!("{}{}", self.reader_type_name, group), + enum_prefix: self.enum_prefix.clone(), + } + } +} + #[doc(hidden)] pub fn generate_configuration(tokens: TokenStream) -> Result { let input: input::Configuration = syn::parse2(tokens)?; @@ -42,7 +83,7 @@ pub fn generate_configuration(tokens: TokenStream) -> Result Result Result>(); - let reader_name = proc_macro2::Ident::new("TEdgeConfigReader", Span::call_site()); + let ctx = CodegenContext::default_tedge_config(); + let reader_name = &ctx.reader_type_name; let dto_doc_comment = format!( "A data-transfer object, designed for reading and writing to `tedge.toml` @@ -107,11 +149,7 @@ pub fn generate_configuration(tokens: TokenStream) -> Result Result { + let parse_input: input::SubConfigInput = syn::parse2(tokens)?; + + // Parse and validate the configuration + let validated_config: input::Configuration = parse_input.config.try_into()?; + + // Validate that multi-profile groups are not used in sub-configs + validated_config.validate_for_sub_config()?; + + // Create context for this sub-config + let ctx = CodegenContext::for_sub_config(parse_input.name); + + let fields_with_keys = validated_config + .groups + .iter() + .flat_map(|group| match group { + input::FieldOrGroup::Group(group) => unfold_group(Vec::new(), group), + input::FieldOrGroup::Multi(_) => { + unreachable!("Multi-profile groups are disallowed for sub-configs in validation.rs") + } + input::FieldOrGroup::Field(field) => { + // Top-level fields are allowed in sub-configs + // Treat them as if they're directly at the top level + vec![(vec![], field)] + } + }) + .collect::>(); + + let example_tests = fields_with_keys + .iter() + .filter_map(|(key, field)| Some((key, field.read_write()?))) + .flat_map(|(key, field)| { + let ty = field.from.as_ref().unwrap_or(field.dto_ty()); + field.examples.iter().enumerate().map(move |(n, example)| { + let name = quote::format_ident!( + "example_value_can_be_deserialized_for_{}_example_{n}", + key.join("_").replace('-', "_") + ); + let span = example.span(); + let example = example.as_ref(); + let expect_message = format!( + "Example value {example:?} for '{}' could not be deserialized", + key.join(".") + ); + quote_spanned! {span=> + #[test] + fn #name() { + #example.parse::<#ty>().expect(#expect_message); + } + } + }) + }) + .collect::>(); + + let fromstr_default_tests = fields_with_keys + .iter() + .filter_map(|(key, field)| Some((key, field.read_write()?))) + .filter_map(|(key, field)| { + let ty = field.from.as_ref().unwrap_or(field.dto_ty()); + if let FieldDefault::FromStr(default) = &field.default { + let name = quote::format_ident!( + "default_value_can_be_deserialized_for_{}", + key.join("_").replace('-', "_") + ); + let span = default.span(); + let expect_message = format!( + "Default value {default:?} for '{}' could not be deserialized", + key.join("."), + ); + Some(quote_spanned! {span=> + #[test] + fn #name() { + #default.parse::<#ty>().expect(#expect_message); + } + }) + } else { + None + } + }) + .collect::>(); + + let reader_name = &ctx.reader_type_name; + let dto_doc_comment = format!( + "A data-transfer object, designed for reading and writing to the `{reader_name}` configuration\n\ + All the configurations inside this are optional to represent whether \ + the value is or isn't configured. Any defaults are \ + populated when this is converted to [{reader_name}] (via \ + [from_dto]({reader_name}::from_dto)).\n\ + For simplicity when using this struct, only the fields are optional. \ + Any configuration groups are always present. Groups that have no value set \ + will be omitted in the serialized output to avoid polluting the configuration file.", + ); + + let dto = dto::generate(&ctx, &validated_config.groups, &dto_doc_comment); + + let reader_doc_comment = + "A struct to read configured values from, designed to be accessed only \ + via an immutable borrow\n\ + The configurations inside this struct are optional only if the field \ + does not have a default value configured.\n\ + Where fields are optional, they are stored using [OptionalConfig] to \ + produce a descriptive error message that directs the user to set the \ + relevant key."; + let reader = reader::try_generate(&ctx, &validated_config.groups, reader_doc_comment)?; + + let enums = query::generate_writable_keys(&ctx, &validated_config.groups); Ok(quote! { #(#example_tests)* @@ -168,17 +323,6 @@ fn unfold_group( output } -fn prefixed_type_name( - start: &proc_macro2::Ident, - group: &input::ConfigurationGroup, -) -> proc_macro2::Ident { - quote::format_ident!( - "{start}{}", - group.ident.to_string().to_upper_camel_case(), - span = group.ident.span() - ) -} - #[cfg(test)] mod tests { use super::*; @@ -262,4 +406,34 @@ mod tests { "Unexpected expression, `default(value = ...)` expects a literal.\n\ Perhaps you want to use `#[tedge_config(default(variable = \"Ipv4Addr::LOCALHOST\"))]`?"); } + + #[test] + fn sub_config_generates_code() { + assert!(generate_sub_configuration(quote! { + BridgeConfig { + bridge_azure: { + url: String, + }, + bridge_aws: { + region: String, + } + } + }) + .is_ok()); + } + + #[test] + fn sub_config_rejects_multi_profile_groups() { + let error = generate_sub_configuration(quote! { + BridgeConfig { + #[tedge_config(multi)] + profiles: { + url: String, + } + } + }) + .unwrap_err(); + + assert!(error.to_string().contains("Multi-profile groups")); + } } diff --git a/crates/common/tedge_config_macros/impl/src/query.rs b/crates/common/tedge_config_macros/impl/src/query.rs index 697a6321527..e55e2c12d66 100644 --- a/crates/common/tedge_config_macros/impl/src/query.rs +++ b/crates/common/tedge_config_macros/impl/src/query.rs @@ -1,14 +1,17 @@ use crate::error::extract_type_from_result; use crate::input::ConfigurableField; +use crate::input::EnumEntry; use crate::input::FieldOrGroup; use crate::namegen::IdGenerator; use crate::namegen::SequentialIdGenerator; use crate::namegen::UnderscoreIdGenerator; +use crate::CodegenContext; use heck::ToSnekCase; use heck::ToUpperCamelCase; use itertools::Itertools; use proc_macro2::Span; use proc_macro2::TokenStream; +use quote::format_ident; use quote::quote; use quote::quote_spanned; use std::borrow::Cow; @@ -18,50 +21,114 @@ use syn::parse_quote; use syn::parse_quote_spanned; use syn::spanned::Spanned; -pub fn generate_writable_keys(items: &[FieldOrGroup]) -> TokenStream { +/// Context for code generation containing all namespaced type and function names +#[derive(Clone)] +struct GenerationContext { + readable_key_name: syn::Ident, + readonly_key_name: syn::Ident, + writable_key_name: syn::Ident, + dto_key_name: syn::Ident, + write_error_name: syn::Ident, + parse_key_error_name: syn::Ident, + dto_name: syn::Ident, + reader_name: syn::Ident, +} + +impl From<&CodegenContext> for GenerationContext { + fn from(ctx: &CodegenContext) -> Self { + GenerationContext { + readable_key_name: format_ident!("{}ReadableKey", ctx.enum_prefix), + readonly_key_name: format_ident!("{}ReadOnlyKey", ctx.enum_prefix), + writable_key_name: format_ident!("{}WritableKey", ctx.enum_prefix), + dto_key_name: format_ident!("{}DtoKey", ctx.enum_prefix), + write_error_name: format_ident!("{}WriteError", ctx.enum_prefix), + parse_key_error_name: format_ident!("{}ParseKeyError", ctx.enum_prefix), + dto_name: ctx.dto_type_name.clone(), + reader_name: ctx.reader_type_name.clone(), + } + } +} + +#[derive(Clone, Copy)] +enum FilterRule { + ReadOnly, + ReadWrite, + None, +} + +impl FilterRule { + fn matches(self, segments: &VecDeque<&FieldOrGroup>) -> bool { + match self { + Self::ReadOnly => !is_read_write(segments), + Self::ReadWrite => is_read_write(segments), + Self::None => true, + } + } +} + +pub fn generate_writable_keys(ctx: &CodegenContext, items: &[FieldOrGroup]) -> TokenStream { let dto_paths = configuration_paths_from(items, Mode::Dto); let mut reader_paths = configuration_paths_from(items, Mode::Reader); - let (readonly_destr, write_error): (Vec<_>, Vec<_>) = reader_paths - .iter() - .filter_map(|field| { - let configuration = enum_variant(field); - Some(( - configuration.match_shape, - field - .back()? - .field()? - .read_only()? - .readonly - .write_error - .as_str(), - )) - }) - .multiunzip(); - let readable_args = configuration_strings(reader_paths.iter()); - let readonly_args = - configuration_strings(reader_paths.iter().filter(|path| !is_read_write(path))); - let writable_args = - configuration_strings(reader_paths.iter().filter(|path| is_read_write(path))); - let dto_args = configuration_strings(dto_paths.iter()); - let readable_keys = keys_enum(parse_quote!(ReadableKey), &readable_args, "read from"); + let gen_ctx = GenerationContext::from(ctx); + let readable_args = configuration_strings( + reader_paths.iter(), + FilterRule::None, + &gen_ctx.readable_key_name, + ); + let readonly_args = configuration_strings( + reader_paths.iter(), + FilterRule::ReadOnly, + &gen_ctx.readonly_key_name, + ); + let writable_args = configuration_strings( + reader_paths.iter(), + FilterRule::ReadWrite, + &gen_ctx.writable_key_name, + ); + let dto_args = configuration_strings(dto_paths.iter(), FilterRule::None, &gen_ctx.dto_key_name); + let readable_keys = keys_enum(&gen_ctx.readable_key_name, &readable_args, "read from"); let readonly_keys = keys_enum( - parse_quote!(ReadOnlyKey), + &gen_ctx.readonly_key_name, &readonly_args, "read from, but not written to,", ); - let writable_keys = keys_enum(parse_quote!(WritableKey), &writable_args, "written to"); - let dto_keys = keys_enum(parse_quote!(DtoKey), &dto_args, "written to"); - let fromstr_readable = generate_fromstr_readable(parse_quote!(ReadableKey), &readable_args); - let fromstr_readonly = generate_fromstr_readable(parse_quote!(ReadOnlyKey), &readonly_args); - let fromstr_writable = generate_fromstr_writable(parse_quote!(WritableKey), &writable_args); - let fromstr_dto = generate_fromstr_writable(parse_quote!(DtoKey), &dto_args); - let read_string = generate_string_readers(&reader_paths); + let write_error_branches: Vec = readonly_args + .1 + .iter() + .map(|key| { + if let Some(error) = &key.write_error { + let pattern = &key.match_shape; + parse_quote!( + Self::#pattern => #error + ) + } else if key.sub_field_info.is_some() { + let pattern = &key.match_read_write; + parse_quote!( + #[allow(unused)] + Self::#pattern => sub_key.write_error() + ) + } else { + unreachable!() + } + }) + .collect(); + let writable_keys = keys_enum(&gen_ctx.writable_key_name, &writable_args, "written to"); + let dto_keys = keys_enum(&gen_ctx.dto_key_name, &dto_args, "written to"); + let fromstr_readable = + generate_fromstr_readable(&gen_ctx.readable_key_name, &readable_args, &gen_ctx); + let fromstr_readonly = + generate_fromstr_readable(&gen_ctx.readonly_key_name, &readonly_args, &gen_ctx); + let fromstr_writable = + generate_fromstr_writable(&gen_ctx.writable_key_name, &writable_args, &gen_ctx); + let fromstr_dto = generate_fromstr_writable(&gen_ctx.dto_key_name, &dto_args, &gen_ctx); + let read_string = generate_string_readers(&reader_paths, &gen_ctx); let write_string = generate_string_writers( &reader_paths .iter() .filter(|path| is_read_write(path)) .cloned() .collect::>(), + &gen_ctx, ); let reader_paths_vec = reader_paths @@ -69,15 +136,17 @@ pub fn generate_writable_keys(items: &[FieldOrGroup]) -> TokenStream { .map(|vd| &*vd.make_contiguous()) .collect::>(); let readable_keys_iter = key_iterators( - parse_quote!(TEdgeConfigReader), - parse_quote!(ReadableKey), + &ctx.reader_type_name, + &gen_ctx.readable_key_name, + &parse_quote_spanned!(ctx.reader_type_name.span()=> readable_keys), &reader_paths_vec, "", &[], ); let readonly_keys_iter = key_iterators( - parse_quote!(TEdgeConfigReader), - parse_quote!(ReadOnlyKey), + &ctx.reader_type_name, + &gen_ctx.readonly_key_name, + &parse_quote_spanned!(ctx.reader_type_name.span()=> readonly_keys), &reader_paths_vec .iter() .copied() @@ -87,8 +156,9 @@ pub fn generate_writable_keys(items: &[FieldOrGroup]) -> TokenStream { &[], ); let writable_keys_iter = key_iterators( - parse_quote!(TEdgeConfigReader), - parse_quote!(WritableKey), + &ctx.reader_type_name, + &gen_ctx.writable_key_name, + &parse_quote_spanned!(ctx.reader_type_name.span()=> writable_keys), &reader_paths_vec .iter() .copied() @@ -106,6 +176,99 @@ pub fn generate_writable_keys(items: &[FieldOrGroup]) -> TokenStream { .is_empty() .then(|| parse_quote!(_ => unreachable!("ReadOnlyKey is uninhabited"))); + // Extract generation context fields for use in quote! block + let write_error_name = &gen_ctx.write_error_name; + let writable_key_name = &gen_ctx.writable_key_name; + let readonly_key_name = &gen_ctx.readonly_key_name; + let parse_key_error_name = &gen_ctx.parse_key_error_name; + let reader_type_name = &ctx.reader_type_name; + let readable_key_name = &gen_ctx.readable_key_name; + let utility_functions = if ctx.enum_prefix.is_empty() { + quote! { + fn replace_aliases(key: String) -> String { + use ::once_cell::sync::Lazy; + use ::std::borrow::Cow; + use ::std::collections::HashMap; + use ::doku::*; + + static ALIASES: Lazy, Cow<'static, str>>> = Lazy::new(|| { + let ty = #reader_type_name::ty(); + let TypeKind::Struct { fields, transparent: false } = ty.kind else { panic!("Expected struct but got {:?}", ty.kind) }; + let Fields::Named { fields } = fields else { panic!("Expected named fields but got {:?}", fields)}; + let mut aliases = struct_field_aliases(None, &fields); + #( + if let Some(alias) = aliases.insert(Cow::Borrowed(#static_alias), #readable_key_name::#iter_updated.to_cow_str()) { + panic!("Duplicate configuration alias for '{}'. It maps to both '{}' and '{}'. Perhaps you provided an incorrect `deprecated_key` for one of these configurations?", #static_alias, alias, #readable_key_name::#iter_updated.to_cow_str()); + } + )* + aliases + }); + + ALIASES + .get(&Cow::Borrowed(key.as_str())) + .map(|c| c.clone().into_owned()) + .unwrap_or(key) + } + + fn warn_about_deprecated_key(deprecated_key: String, updated_key: &'static str) { + use ::once_cell::sync::Lazy; + use ::std::sync::Mutex; + use ::std::collections::HashSet; + + static WARNINGS: Lazy>> = Lazy::new(<_>::default); + + let warning = format!("The key '{}' is deprecated. Use '{}' instead.", deprecated_key, updated_key); + if WARNINGS.lock().unwrap().insert(deprecated_key) { + ::tracing::warn!("{}", warning); + } + } + } + } else { + quote! {} + }; + + let write_error = if ctx.enum_prefix.is_empty() { + quote! { + #[derive(::thiserror::Error, Debug)] + /// An error encountered when writing to a configuration value from a + /// string + pub enum #write_error_name { + #[error("Failed to parse input")] + ParseValue(#[from] Box), + #[error(transparent)] + Multi(#[from] MultiError), + #[error("Setting {target} requires {parent} to be set to {parent_expected}, but it is currently set to {parent_actual}")] + SuperFieldWrongValue { + target: #writable_key_name, + parent: #writable_key_name, + parent_expected: String, + parent_actual: String, + }, + } + } + } else { + quote! { + #[derive(::thiserror::Error, Debug)] + /// An error encountered when writing to a configuration value from a + /// string + pub enum #write_error_name { + #[error("Failed to parse input")] + ParseValue(#[from] Box), + #[error(transparent)] + Multi(#[from] MultiError), + } + + impl From<#write_error_name> for WriteError { + fn from(inner: #write_error_name) -> WriteError { + match inner { + #write_error_name::ParseValue(e) => WriteError::ParseValue(e), + #write_error_name::Multi(e) => WriteError::Multi(e), + } + } + } + } + }; + quote! { #readable_keys #readonly_keys @@ -120,21 +283,12 @@ pub fn generate_writable_keys(items: &[FieldOrGroup]) -> TokenStream { #readable_keys_iter #readonly_keys_iter #writable_keys_iter + #write_error - #[derive(::thiserror::Error, Debug)] - /// An error encountered when writing to a configuration value from a - /// string - pub enum WriteError { - #[error("Failed to parse input")] - ParseValue(#[from] Box), - #[error(transparent)] - Multi(#[from] MultiError), - } - - impl ReadOnlyKey { + impl #readonly_key_name { fn write_error(&self) -> &'static str { match self { - #(Self::#readonly_destr => #write_error,)* + #(#write_error_branches,)* #fallback_branch } } @@ -142,67 +296,243 @@ pub fn generate_writable_keys(items: &[FieldOrGroup]) -> TokenStream { #[derive(Debug, ::thiserror::Error)] /// An error encountered when parsing a configuration key from a string - pub enum ParseKeyError { + pub enum #parse_key_error_name { #[error("{}", .0.write_error())] - ReadOnly(ReadOnlyKey), + ReadOnly(#readonly_key_name), #[error("Unknown key: '{0}'")] Unrecognised(String), } - fn replace_aliases(key: String) -> String { - use ::once_cell::sync::Lazy; - use ::std::borrow::Cow; - use ::std::collections::HashMap; - use ::doku::*; - - static ALIASES: Lazy, Cow<'static, str>>> = Lazy::new(|| { - let ty = TEdgeConfigReader::ty(); - let TypeKind::Struct { fields, transparent: false } = ty.kind else { panic!("Expected struct but got {:?}", ty.kind) }; - let Fields::Named { fields } = fields else { panic!("Expected named fields but got {:?}", fields)}; - let mut aliases = struct_field_aliases(None, &fields); - #( - if let Some(alias) = aliases.insert(Cow::Borrowed(#static_alias), ReadableKey::#iter_updated.to_cow_str()) { - panic!("Duplicate configuration alias for '{}'. It maps to both '{}' and '{}'. Perhaps you provided an incorrect `deprecated_key` for one of these configurations?", #static_alias, alias, ReadableKey::#iter_updated.to_cow_str()); + #utility_functions + } +} + +fn sub_field_enum_variant( + parent_segments: &VecDeque<&FieldOrGroup>, + sub_field_variant: &syn::Ident, + sub_field_type: &syn::Ident, + key_type_suffix: &syn::Ident, +) -> ConfigurationKey { + let base_ident = ident_for(parent_segments); + let combined_ident = format_ident!("{base_ident}{sub_field_variant}"); + + let parent_multi_count = parent_segments + .iter() + .filter(|fog| matches!(fog, FieldOrGroup::Multi(_))) + .count(); + + let parent_opt_strs = + std::iter::repeat_n::(parse_quote!(Option), parent_multi_count); + let sub_field_key_ty_ident = format_ident!("{sub_field_type}{key_type_suffix}"); + let sub_field_key_ty: syn::Type = parse_quote!(#sub_field_key_ty_ident); + + let mut all_types: Vec = parent_opt_strs.collect(); + all_types.push(sub_field_key_ty); + + let enum_variant = + parse_quote_spanned!(combined_ident.span()=> #combined_ident(#(#all_types),*)); + + let parent_field_names = SequentialIdGenerator::default() + .take(parent_multi_count) + .collect::>(); + let sub_key_ident = syn::Ident::new("sub_key", sub_field_variant.span()); + let mut all_field_names = parent_field_names.clone(); + all_field_names.push(sub_key_ident); + + let match_read_write = + parse_quote_spanned!(combined_ident.span()=> #combined_ident(#(#all_field_names),*)); + + let all_underscores = UnderscoreIdGenerator.take(parent_multi_count + 1); + let match_shape = + parse_quote_spanned!(combined_ident.span()=> #combined_ident(#(#all_underscores),*)); + + // Generate formatters for sub-field keys + // Sub-field keys generate a match arm that handles all profiles at once + let sub_key_name = syn::Ident::new("sub_key", sub_field_variant.span()); + let sub_field_variant_snake = sub_field_variant.to_string().to_lowercase(); + + // Extract parent segments without the field + let parent_segments_without_field: Vec<&FieldOrGroup> = parent_segments + .iter() + .copied() + .take(parent_segments.len().saturating_sub(1)) + .collect(); + + let formatters = if parent_multi_count > 0 { + // For multi-field parents, generate a single formatter that handles all profiles + let pattern = parse_quote_spanned!(combined_ident.span()=> #combined_ident(#(#parent_field_names),*, #sub_key_name)); + + // TODO this vec![].join thing that's going on feels pretty complicated + let mut multi_field_idx = 0; + let base_segments: Vec = parent_segments_without_field + .iter() + .map(|segment| match segment { + FieldOrGroup::Multi(m) => { + let field_name = &parent_field_names[multi_field_idx]; + let m_name = m.ident.to_string(); + multi_field_idx += 1; + let profile_fmt = format!("{m_name}.profiles.{{}}"); + quote! { + if let Some(profile) = #field_name { + format!(#profile_fmt, profile) + } else { + #m_name.to_string() + } } - )* - aliases - }); + } + FieldOrGroup::Group(g) => { + let g_name = g.name().to_string(); + quote! { #g_name.to_string() } + } + FieldOrGroup::Field(f) => { + let f_name = f.name().to_string(); + quote! { #f_name.to_string() } + } + }) + .collect(); - ALIASES - .get(&Cow::Borrowed(key.as_str())) - .map(|c| c.clone().into_owned()) - .unwrap_or(key) - } + let base_expr = quote! { + { + vec![#(#base_segments),*].join(".") + } + }; - fn warn_about_deprecated_key(deprecated_key: String, updated_key: &'static str) { - use ::once_cell::sync::Lazy; - use ::std::sync::Mutex; - use ::std::collections::HashSet; + vec![( + pattern, + parse_quote!(::std::borrow::Cow::Owned( + format!("{}.{}.{}", #base_expr, #sub_field_variant_snake, #sub_key_name.to_cow_str()) + )), + )] + } else { + // Non-multi parents: just use parent path directly + let parent_path_str = parent_segments_without_field + .iter() + .map(|fog| fog.name()) + .collect::>() + .join("."); + + vec![( + parse_quote_spanned!(combined_ident.span()=> #combined_ident(#sub_key_name)), + parse_quote!(::std::borrow::Cow::Owned( + format!("{}.{}.{}", #parent_path_str, #sub_field_variant_snake, #sub_key_name.to_cow_str()) + )), + )] + }; - static WARNINGS: Lazy>> = Lazy::new(<_>::default); + // Generate regex parser for sub-field keys with profiles + let regex_parser = if parent_multi_count > 0 { + // Build a regex pattern for sub-field keys with profiles + // Must properly handle multi-fields in the parent path, not just assume first segment is multi - let warning = format!("The key '{}' is deprecated. Use '{}' instead.", deprecated_key, updated_key); - if WARNINGS.lock().unwrap().insert(deprecated_key) { - ::tracing::warn!("{}", warning); + // Build the pattern for the parent path + let mut pattern_parts = Vec::new(); + for segment in &parent_segments_without_field { + match segment { + FieldOrGroup::Multi(m) => { + pattern_parts.push(format!("{}(?:[\\._]profiles[\\._]([^\\.]+))?", m.name())); + } + FieldOrGroup::Group(g) => { + pattern_parts.push(g.name().to_string()); + } + FieldOrGroup::Field(f) => { + pattern_parts.push(f.name().to_string()); + } } } + let parent_pattern = pattern_parts.join("[\\._]"); + + // Sub-field keys are flat at the parent level, append the sub-field variant + let pattern = format!( + "^{}[\\._]{}[\\._](.+)$", + parent_pattern, + sub_field_variant.to_string().to_lowercase() + ); + + let pattern_lit = syn::LitStr::new(&pattern, sub_field_variant.span()); + let regex_if: syn::ExprIf = parse_quote! { + if let Some(captures) = ::regex::Regex::new(#pattern_lit).unwrap().captures(value) { + // Placeholder - will be filled in by generate_fromstr + } + }; + + Some(regex_if) + } else { + None + }; + + ConfigurationKey { + enum_variant, + iter_field: parse_quote!(unreachable!("sub-field keys are not iterable")), + match_shape, + match_read_write, + regex_parser, + field_names: all_field_names, + formatters, + insert_profiles: vec![], + doc_comment: None, + sub_field_info: Some(SubFieldInfo { + type_name: sub_field_type.clone(), + }), + write_error: None, } } fn configuration_strings<'a>( variants: impl Iterator>, + filter_rule: FilterRule, + key_type_suffix: &syn::Ident, ) -> (Vec, Vec) { variants - .map(|segments| { + .flat_map(|segments| { let configuration_key = enum_variant(segments); - ( - segments - .iter() - .map(|variant| variant.name()) - .collect::>() - .join("."), - configuration_key, - ) + let base_string = segments + .iter() + .map(|variant| variant.name()) + .collect::>() + .join("."); + + let mut results = if filter_rule.matches(segments) { + vec![(base_string.clone(), configuration_key)] + } else { + vec![] + }; + + if let Some(FieldOrGroup::Field(field)) = segments.back() { + if let Some(sub_fields) = field.sub_field_entries() { + // For sub-fields, use the path without the parent field name + // e.g., mapper.type -> mapper, or mapper.config.type -> mapper.config + let parent_path = segments + .iter() + .take(segments.len() - 1) + .map(|variant| variant.name()) + .collect::>() + .join("."); + + for entry in sub_fields.iter() { + if let EnumEntry::NameAndFields(variant_name, type_name) = entry { + // Sub-field keys are flat at the parent level, not nested under the field + let sub_config_str = if parent_path.is_empty() { + variant_name.to_string().to_snek_case() + } else { + format!( + "{}.{}", + parent_path, + variant_name.to_string().to_snek_case() + ) + }; + let sub_config_key = sub_field_enum_variant( + segments, + variant_name, + type_name, + key_type_suffix, + ); + results.push((sub_config_str, sub_config_key)); + } + } + } + } + + results }) .unzip() } @@ -227,19 +557,83 @@ fn deprecated_keys<'a>( } fn generate_fromstr( - type_name: syn::Ident, + type_name: &syn::Ident, (configuration_string, configuration_key): &(Vec, Vec), error_case: syn::Arm, + gen_ctx: &GenerationContext, ) -> TokenStream { - let simplified_configuration_string = configuration_string + // Separate regular keys from sub-field keys + let (regular_strings, regular_keys): (Vec<_>, Vec<_>) = configuration_string + .iter() + .zip(configuration_key.iter()) + .filter(|(_, k)| k.sub_field_info.is_none()) + .unzip(); + + let sub_fields = configuration_string + .iter() + .zip(configuration_key.iter()) + .filter_map(|(s, k)| Some((s, k, k.sub_field_info.as_ref()?))) + .collect::>(); + + let simplified_configuration_string = regular_strings .iter() - .map(|s| s.replace('.', "_")) - .zip(configuration_key.iter().map(|k| &k.enum_variant)) - .map(|(s, v)| quote_spanned!(v.span()=> #s)); - let iter_variant = configuration_key.iter().map(|k| &k.iter_field); + .map(|s| (s.replace('.', "_"), s)) + .map(|(s, _)| quote_spanned!(Span::call_site()=> #s)); + + let iter_variant = regular_keys.iter().map(|k| &k.iter_field); + + let main_parse_err = &gen_ctx.parse_key_error_name; + let readonly_key_name = &gen_ctx.readonly_key_name; + + // Generate sub-field match cases with prefix matching + let sub_field_match_cases = + sub_fields + .iter() + .map(|(config_str, config_key, sub_field_info)| { + let enum_variant = &config_key.enum_variant; + + // Construct sub-field type name from identifier and type name (e.g. C8y + ReadableKey) + let sub_field_type_name = + format_ident!("{}{}", &sub_field_info.type_name, type_name); + + let pattern_str = format!("{}_", config_str.replace('.', "_")); + let variant_ident = &enum_variant.ident; + let parent_field_count = config_key.field_names.len() - 1; // All except the last (sub_key) + let prefix_str = format!("{}.", config_str); + let sub_field_parse_err = format_ident!("{}{}", sub_field_info.type_name, main_parse_err); + let unrecognised_sub_key_fmt = format!("{prefix_str}{{sub_key}}"); + + // For simple pattern matching (non-profiled), use None for each multi-field + let match_args = if parent_field_count == 0 { + quote!(sub_key) + } else { + // Generate None for each parent field + let nones = std::iter::repeat_n(quote!(None), parent_field_count); + quote!(#(#nones),*, sub_key) + }; + + let res: syn::Arm = parse_quote_spanned!(enum_variant.span()=> + key if key.starts_with(#pattern_str) => { + let sub_key_str = value.strip_prefix(#prefix_str).unwrap_or(value); + let sub_key: #sub_field_type_name = sub_key_str.parse().map_err(|err| match err { + #sub_field_parse_err::ReadOnly(sub_key) => { + #main_parse_err::ReadOnly(#readonly_key_name::#variant_ident(#match_args)) + } + #sub_field_parse_err::Unrecognised(sub_key) => { + #main_parse_err::Unrecognised(format!(#unrecognised_sub_key_fmt)) + } + })?; + return Ok(Self::#variant_ident(#match_args)) + } + ); + res + }); + let regex_patterns = configuration_key .iter() + // Exclude sub-field keys - they're handled separately + .filter(|c| c.sub_field_info.is_none()) .filter_map(|c| Some((c.regex_parser.clone()?, c))) .map(|(mut r, c)| { let match_read_write = &c.match_read_write; @@ -258,24 +652,86 @@ fn generate_fromstr( r }); + // Generate regex patterns for sub-field keys with profiles + let sub_field_regex_patterns: Vec<_> = sub_fields + .iter() + .filter_map(|(config_str, c, s)| { + // Only generate if there's a regex_parser (i.e., has profile fields) + c.regex_parser.clone().map(|r| (r, *config_str, c, *s)) + }) + .map(|(mut r, config_str, key, sub_field_info)| { + let variant_ident = &key.enum_variant.ident; + // Construct sub-field type name from identifier and type name (e.g. C8y + ReadableKey) + let sub_field_type_name = format_ident!("{}{}", sub_field_info.type_name, type_name); + + let all_field_names = &key.field_names; + let parent_fields = &all_field_names[..all_field_names.len() - 1]; + // The sub_key is captured after all parent field captures + // Parent fields are at indices 1, 2, ..., parent_fields.len() + // Sub_key is at index parent_fields.len() + 1 + let sub_key_capture_idx = parent_fields.len() + 1; + let sub_field_parse_err = + format_ident!("{}{}", sub_field_info.type_name, main_parse_err); + let unrecognised_sub_key_fmt = format!("{config_str}.{{sub_key}}"); + + // Generate assignments only for parent fields (not the sub_key) + let own_branches = parent_fields + .iter() + .enumerate() + .map::(|(n, id)| { + let n = n + 1; + parse_quote! { + let #id = captures.get(#n).map(|re_match| re_match.as_str().to_owned()); + } + }); + + // For sub-keys, we need to parse the remainder + r.then_branch = parse_quote!({ + #(#own_branches)* + let sub_key_str = captures.get(#sub_key_capture_idx) + .map(|re_match| re_match.as_str()) + .unwrap_or(""); + let sub_key: #sub_field_type_name = sub_key_str.parse().map_err({ + #(let #parent_fields = #parent_fields.clone();)* + |err| match err { + #sub_field_parse_err::ReadOnly(sub_key) => { + #main_parse_err::ReadOnly(#readonly_key_name::#variant_ident(#(#parent_fields),*, sub_key)) + } + #sub_field_parse_err::Unrecognised(sub_key) => { + #main_parse_err::Unrecognised(format!(#unrecognised_sub_key_fmt)) + } + } + })?; + return Ok(Self::#variant_ident(#(#parent_fields),*, sub_key)); + }); + r + }) + .collect(); + + let all_regex_patterns = regex_patterns.chain(sub_field_regex_patterns); + let parse_key_error = &gen_ctx.parse_key_error_name; + quote! { impl ::std::str::FromStr for #type_name { - type Err = ParseKeyError; + type Err = #parse_key_error; fn from_str(value: &str) -> Result { // If we get an unreachable pattern, it means we have the same key twice #[deny(unreachable_patterns)] let res = match replace_aliases(value.to_owned()).replace(".", "_").as_str() { #( #simplified_configuration_string => { - if value != #configuration_string { - warn_about_deprecated_key(value.to_owned(), #configuration_string); + if value != #regular_strings { + warn_about_deprecated_key(value.to_owned(), #regular_strings); } return Ok(Self::#iter_variant) }, )* + #( + #sub_field_match_cases, + )* #error_case }; - #(#regex_patterns;)* + #(#all_regex_patterns;)* res } } @@ -283,46 +739,52 @@ fn generate_fromstr( } fn generate_fromstr_readable( - type_name: syn::Ident, + type_name: &syn::Ident, fields: &(Vec, Vec), + gen_ctx: &GenerationContext, ) -> TokenStream { + let parse_key_error_name = &gen_ctx.parse_key_error_name; generate_fromstr( type_name, fields, - parse_quote! { _ => Err(ParseKeyError::Unrecognised(value.to_owned())) }, + parse_quote! { _ => Err(#parse_key_error_name::Unrecognised(value.to_owned())) }, + gen_ctx, ) } // TODO test the error messages actually appear fn generate_fromstr_writable( - type_name: syn::Ident, + type_name: &syn::Ident, fields: &(Vec, Vec), + gen_ctx: &GenerationContext, ) -> TokenStream { + let GenerationContext { + readonly_key_name, + parse_key_error_name, + .. + } = gen_ctx; generate_fromstr( type_name, fields, parse_quote! { - _ => if let Ok(key) = ::from_str(value) { - Err(ParseKeyError::ReadOnly(key)) + _ => if let Ok(key) = <#readonly_key_name as ::std::str::FromStr>::from_str(value) { + Err(#parse_key_error_name::ReadOnly(key)) } else { - Err(ParseKeyError::Unrecognised(value.to_owned())) + Err(#parse_key_error_name::Unrecognised(value.to_owned())) }, }, + gen_ctx, ) } fn key_iterators( - reader_ty: syn::Ident, - type_name: syn::Ident, + reader_ty: &syn::Ident, + type_name: &syn::Ident, + function_name: &syn::Ident, fields: &[&[&FieldOrGroup]], prefix: &str, args: &[syn::Ident], ) -> TokenStream { - let mut function_name = type_name.to_string().to_snek_case(); - // Pluralise the name - function_name += "s"; - let function_name = syn::Ident::new(&function_name, type_name.span()); - let mut stmts: Vec = Vec::new(); let mut exprs: VecDeque = VecDeque::new(); let mut complete_fields: Vec = Vec::new(); @@ -375,8 +837,9 @@ fn key_iterators( let mut args = args.to_owned(); args.push(m.ident.clone()); global.push(key_iterators( - sub_type_name, - type_name.clone(), + &sub_type_name, + type_name, + function_name, &remaining_fields, &prefix, &args, @@ -389,8 +852,9 @@ fn key_iterators( let prefix = format!("{prefix}{upper_ident}"); let remaining_fields = fields.iter().map(|fs| &fs[1..]).collect::>(); global.push(key_iterators( - sub_type_name, - type_name.clone(), + &sub_type_name, + type_name, + function_name, &remaining_fields, &prefix, args, @@ -402,23 +866,53 @@ fn key_iterators( } Some(FieldOrGroup::Field(f)) => { let ident = f.ident(); - let field_name = syn::Ident::new( - &format!( - "{}{}", - prefix, - f.rename() - .map(<_>::to_upper_camel_case) - .unwrap_or_else(|| ident.to_string().to_upper_camel_case()) - ), - ident.span(), + let field_name = format_ident!( + "{}{}", + prefix, + f.name().to_upper_camel_case(), + span = ident.span(), ); - let args = match args.len() { + let arg_tokens = match args.len() { 0 => TokenStream::new(), _ => { quote!((#(#args.clone()),*)) } }; - complete_fields.push(parse_quote!(#type_name::#field_name #args)) + complete_fields + .push(parse_quote_spanned!(ident.span()=> #type_name::#field_name #arg_tokens)); + if let Some(entries) = f.sub_field_entries() { + exprs.push_back(parse_quote_spanned!(ident.span()=> { + #(let #args = #args.clone();)* + self.#ident.or_none().into_iter().flat_map(move |#ident| #ident.#function_name(#(#args.clone()),*)) + })); + let arms = entries.iter().map::(|entry| + match entry { + EnumEntry::NameAndFields(name, _inner) => { + let field_name = format_ident!("{field_name}{name}"); + let sub_field_name = name.to_string().to_snek_case(); + let sub_field_name = format_ident!("{}", sub_field_name, span = name.span()); + parse_quote!(Self::#name { #sub_field_name } => #sub_field_name. + #function_name() + .map(|inner_key| #type_name::#field_name(#(#args.clone(),)* inner_key)) + .collect(), + ) + } + EnumEntry::NameOnly(name) => { + parse_quote!(Self::#name => Vec::new(),) + } + } + ); + let impl_for = f.reader_ty(); + global.push(quote! { + impl #impl_for { + pub fn #function_name(&self #(, #args: Option)*) -> Vec<#type_name> { + match self { + #(#arms)* + } + } + } + }) + } } None => panic!("Expected FieldOrGroup list te be nonempty"), }; @@ -435,13 +929,13 @@ fn key_iterators( } let exprs = exprs.into_iter().enumerate().map(|(i, expr)| { if i > 0 { - parse_quote!(chain(#expr)) + parse_quote_spanned!(function_name.span()=> chain(#expr)) } else { expr } }); - quote! { + quote_spanned! {function_name.span()=> impl #reader_ty { pub fn #function_name(&self #(, #args: Option)*) -> impl Iterator + '_ { #(#stmts)* @@ -454,7 +948,7 @@ fn key_iterators( } fn keys_enum( - type_name: syn::Ident, + type_name: &syn::Ident, (configuration_string, configuration_key): &(Vec, Vec), doc_fragment: &'static str, ) -> TokenStream { @@ -487,9 +981,18 @@ fn keys_enum( .iter() .flat_map(|k| k.formatters.clone()) .unzip(); + let iter_field: Vec<_> = configuration_key .iter() - .map(|k| k.iter_field.clone()) + // Exclude sub-field keys: they aren't (statically) iterable + // TODO make the keys iterable + .filter_map(|k| { + if k.sub_field_info.is_none() { + Some(k.iter_field.clone()) + } else { + None + } + }) .collect(); let uninhabited_catch_all = configuration_key .is_empty() @@ -512,7 +1015,11 @@ fn keys_enum( None => quote!(None), }); - let max_profile_count = configuration_key.iter().map(|k| k.field_names.len()).max(); + let max_profile_count = configuration_key + .iter() + .filter(|k| !k.insert_profiles.is_empty()) + .map(|k| k.field_names.len()) + .max(); let try_with_profile_impl = match max_profile_count { Some(1) => quote! { @@ -574,6 +1081,9 @@ fn keys_enum( #try_with_profile_impl + // TODO: Replace VALUES with a mechanism that supports all keys, including sub-fields + // Currently sub-fields are excluded because the available sub-field keys are generated + // by a separate macro invocation, so we can't know them statically here const VALUES: &'static [Self] = &[ #(Self::#iter_field),* ]; @@ -666,48 +1176,119 @@ fn generate_multi_dto_cleanup(fields: &VecDeque<&FieldOrGroup>) -> Vec]) -> TokenStream { +fn generate_read_arm_for_field( + path: &VecDeque<&FieldOrGroup>, + configuration_key: ConfigurationKey, + gen_ctx: &GenerationContext, +) -> syn::Arm { + let field = path + .back() + .expect("Path must have a back as it is nonempty") + .field() + .expect("Back of path is guaranteed to be a field"); + let segments = generate_field_accessor(path, "try_get", true); + let to_string = quote_spanned!(field.reader_ty().span()=> .to_string()); + let match_variant = configuration_key.match_read_write; + let readable_key_name = &gen_ctx.readable_key_name; + + if field.read_only().is_some() || field.reader_function().is_some() { + if extract_type_from_result(field.reader_ty()).is_some() { + parse_quote! { + #readable_key_name::#match_variant => Ok(self.#(#segments).*()?#to_string), + } + } else { + parse_quote! { + #readable_key_name::#match_variant => Ok(self.#(#segments).*()#to_string), + } + } + } else if field.has_guaranteed_default() { + parse_quote! { + #readable_key_name::#match_variant => Ok(self.#(#segments).*#to_string), + } + } else { + parse_quote! { + #readable_key_name::#match_variant => Ok(self.#(#segments).*.or_config_not_set()?#to_string), + } + } +} + +fn generate_read_arms_for_sub_fields(path: &VecDeque<&FieldOrGroup>) -> Vec { + let Some(field) = path.back().and_then(|f| f.field()) else { + return vec![]; + }; + + let Some(sub_fields) = field.sub_field_entries() else { + return vec![]; + }; + + let parent_segments = generate_field_accessor(path, "try_get", true).collect::>(); + let field_ty = field.reader_ty(); + let base_ident = ident_for(path); + let parent_multi_count = path + .iter() + .filter(|fog| matches!(fog, FieldOrGroup::Multi(_))) + .count(); + + sub_fields + .iter() + .filter_map(|entry| { + let EnumEntry::NameAndFields(variant_name, _type_name) = entry else { + return None; + }; + + let combined_ident = format_ident!("{base_ident}{variant_name}"); + let variant_field_name = syn::Ident::new( + &variant_name.to_string().to_snek_case(), + variant_name.span(), + ); + let parent_field_names = SequentialIdGenerator::default() + .take(parent_multi_count) + .collect::>(); + let sub_key_ident = syn::Ident::new("sub_key", variant_name.span()); + let error_msg = format!( + "Attempted to read {} sub-field from non-{} variant", + variant_name, variant_name + ); + + let arm: syn::Arm = parse_quote! { + ReadableKey::#combined_ident(#(#parent_field_names,)* #sub_key_ident) => { + let mapper_ty = &self.#(#parent_segments).*.or_config_not_set()?; + match mapper_ty { + #field_ty::#variant_name { #variant_field_name } => { + #variant_field_name.read_string(#sub_key_ident) + } + _ => unreachable!(#error_msg), + } + } + }; + Some(arm) + }) + .collect() +} + +fn generate_string_readers( + paths: &[VecDeque<&FieldOrGroup>], + gen_ctx: &GenerationContext, +) -> TokenStream { let enum_variants = paths.iter().map(enum_variant); let arms = paths .iter() .zip(enum_variants) - .map(|(path, configuration_key)| -> syn::Arm { - let field = path - .back() - .expect("Path must have a back as it is nonempty") - .field() - .expect("Back of path is guaranteed to be a field"); - let segments = generate_field_accessor(path, "try_get", true); - let mut parent_segments = generate_field_accessor(path, "try_get", true).collect::>(); - parent_segments.remove(parent_segments.len() - 1); - let to_string = quote_spanned!(field.ty().span()=> .to_string()); - let match_variant = configuration_key.match_read_write; - if field.read_only().is_some() || field.reader_function().is_some() { - if extract_type_from_result(field.ty()).is_some() { - parse_quote! { - ReadableKey::#match_variant => Ok(self.#(#segments).*()?#to_string), - } - } else { - parse_quote! { - ReadableKey::#match_variant => Ok(self.#(#segments).*()#to_string), - } - } - } else if field.has_guaranteed_default() { - parse_quote! { - ReadableKey::#match_variant => Ok(self.#(#segments).*#to_string), - } - } else { - parse_quote! { - ReadableKey::#match_variant => Ok(self.#(#segments).*.or_config_not_set()?#to_string), - } - } + .flat_map(|(path, configuration_key)| { + let main_arm = generate_read_arm_for_field(path, configuration_key, gen_ctx); + let sub_field_arms = generate_read_arms_for_sub_fields(path); + std::iter::once(main_arm).chain(sub_field_arms) }); + let fallback_branch: Option = paths .is_empty() .then(|| parse_quote!(_ => unreachable!("ReadableKey is uninhabited"))); + let reader_name = &gen_ctx.reader_name; + let readable_key_name = &gen_ctx.readable_key_name; + quote! { - impl TEdgeConfigReader { - pub fn read_string(&self, key: &ReadableKey) -> Result { + impl #reader_name { + pub fn read_string(&self, key: &#readable_key_name) -> Result { match key { #(#arms)* #fallback_branch @@ -717,41 +1298,234 @@ fn generate_string_readers(paths: &[VecDeque<&FieldOrGroup>]) -> TokenStream { } } -fn generate_string_writers(paths: &[VecDeque<&FieldOrGroup>]) -> TokenStream { - let enum_variants = paths.iter().map(enum_variant); - type Arms = ( - Vec, - Vec, - Vec, - Vec, - Vec, - ); - let (update_arms, copy_arms, unset_arms, append_arms, remove_arms): Arms = paths +fn generate_write_arms_for_sub_fields( + path: &VecDeque<&FieldOrGroup>, +) -> Vec<(syn::Arm, syn::Arm, syn::Arm, syn::Arm, syn::Arm)> { + let Some(field) = path.back().and_then(|f| f.field()) else { + return vec![]; + }; + + let Some(sub_fields) = field.sub_field_entries() else { + return vec![]; + }; + + let mut parent_segments = + generate_field_accessor(path, "try_get_mut", false).collect::>(); + // Remove the last segment (the field itself) to get just the parent + parent_segments.pop(); + let reader_segments = generate_field_accessor(path, "try_get", true).collect::>(); + + let field_dto_ty = field.dto_ty(); + let field_reader_ty = field.reader_ty(); + let field_name = field.ident(); + let base_ident = ident_for(path); + let parent_multi_count = path .iter() - .zip(enum_variants) - .map(|(path, configuration_key)| { - let read_segments = generate_field_accessor(path, "try_get", true); - let write_segments = generate_field_accessor(path, "try_get_mut", false).collect::>(); - let cleanup_stmts = generate_multi_dto_cleanup(path); - let field = path - .iter() - .filter_map(|thing| thing.field()) - .next() - .unwrap(); - let match_variant = configuration_key.match_read_write; + .filter(|fog| matches!(fog, FieldOrGroup::Multi(_))) + .count(); - let ty = if field.reader_function().is_some() { - extract_type_from_result(field.ty()).map(|tys| tys.0).unwrap_or(field.ty()) + sub_fields + .iter() + .filter_map(|entry| { + let EnumEntry::NameAndFields(variant_name, type_name) = entry else { + return None; + }; + + let combined_ident = format_ident!("{base_ident}{variant_name}"); + let variant_field_name = syn::Ident::new(&variant_name.to_string().to_snek_case(), variant_name.span()); + let variant_reader_field_name = format_ident!("{variant_field_name}_reader"); + let parent_field_names = SequentialIdGenerator::default().take(parent_multi_count).collect::>(); + let sub_key_ident = syn::Ident::new("sub_key", variant_name.span()); + let variant_name_str = variant_name.to_string().to_snek_case(); + let dto_type_ident = format_ident!("{}Dto", type_name); + let reader_type_ident = format_ident!("{}Reader", type_name); + + // Get the parent variable name from the path + let parent_var_name = if parent_segments.is_empty() { + // If there are no parent segments, we're working on self directly + syn::Ident::new("self_root", field_name.span()) } else { - field.ty() + // Take the ident from the last element in the path (before the field itself) + path.iter() + .rev() + .nth(1) + .expect("Parent must exist since parent_segments is not empty") + .ident() + .clone() }; - let parse_as = field.from().unwrap_or(field.ty()); - let parse = quote_spanned! {parse_as.span()=> parse::<#parse_as>() }; - let convert_to_field_ty = quote_spanned! {ty.span()=> map(<#ty>::from)}; + // Generate the field variable name as {parent}_{field} + let field_var_name = format_ident!("{}_{}", parent_var_name, field_name); + + let update_arm: syn::Arm = parse_quote_spanned! {entry.span()=> + WritableKey::#combined_ident(#(#parent_field_names,)* #sub_key_ident) => { + let #parent_var_name = self.#(#parent_segments).*; + let #field_var_name = #parent_var_name.#field_name.get_or_insert_with(|| #field_dto_ty::#variant_name { #variant_field_name: #dto_type_ident::default() }); + if let #field_dto_ty::#variant_name { #variant_field_name } = #field_var_name { + #variant_field_name.try_update_str(#sub_key_ident, value)?; + } else { + return Err(WriteError::SuperFieldWrongValue { + target: key.clone(), + parent: WritableKey::#base_ident(#(#parent_field_names.clone()),*), + parent_expected: #variant_name_str.to_string(), + parent_actual: #field_var_name.to_string(), + }); + } + } + }; + + let other_parent_var_name = format_ident!("other_{}", parent_var_name); + let other_field_var_name = format_ident!("other_{}", field_var_name); + let other_variant_field_name = format_ident!("other_{}", variant_field_name); + let reader_var_name = format_ident!("{}_reader", field_var_name); + + let take_value_arm: syn::Arm = parse_quote! { + WritableKey::#combined_ident(#(#parent_field_names,)* #sub_key_ident) => { + let #parent_var_name = self.#(#parent_segments).*; + let #field_var_name = #parent_var_name.#field_name.get_or_insert_with(|| #field_dto_ty::#variant_name { #variant_field_name: #dto_type_ident::default() }); + if let #field_dto_ty::#variant_name { #variant_field_name } = #field_var_name { + let #other_parent_var_name = other.#(#parent_segments).*; + let #other_field_var_name = &mut #other_parent_var_name.#field_name; + if let Some(#field_dto_ty::#variant_name { #variant_field_name: ref mut #other_variant_field_name }) = #other_field_var_name { + #variant_field_name.take_value_from(#other_variant_field_name, #sub_key_ident)?; + } + } else { + return Err(WriteError::SuperFieldWrongValue { + target: key.clone(), + parent: WritableKey::#base_ident(#(#parent_field_names.clone()),*), + parent_expected: #variant_name_str.to_string(), + parent_actual: #field_var_name.to_string(), + }); + } + } + }; + + let unset_arm: syn::Arm = parse_quote! { + WritableKey::#combined_ident(#(#parent_field_names,)* #sub_key_ident) => { + let #parent_var_name = self.#(#parent_segments).*; + let #field_var_name = #parent_var_name.#field_name.get_or_insert_with(|| #field_dto_ty::#variant_name { #variant_field_name: #dto_type_ident::default() }); + if let #field_dto_ty::#variant_name { #variant_field_name } = #field_var_name { + #variant_field_name.try_unset_key(#sub_key_ident)?; + } else { + return Err(WriteError::SuperFieldWrongValue { + target: key.clone(), + parent: WritableKey::#base_ident(#(#parent_field_names.clone()),*), + parent_expected: #variant_name_str.to_string(), + parent_actual: #field_var_name.to_string(), + }); + } + } + }; + + let append_arm: syn::Arm = parse_quote! { + WritableKey::#combined_ident(#(#parent_field_names,)* #sub_key_ident) => { + let #parent_var_name = self.#(#parent_segments).*; + let #field_var_name = #parent_var_name.#field_name.get_or_insert_with(|| #field_dto_ty::#variant_name { #variant_field_name: #dto_type_ident::default() }); + let #reader_var_name = reader.#(#reader_segments).*.or_none().map(::std::borrow::Cow::Borrowed).unwrap_or_else(|| { + ::std::borrow::Cow::Owned(#field_reader_ty::#variant_name { + #variant_field_name: #reader_type_ident::from_dto( + &#dto_type_ident::default(), + &TEdgeConfigLocation::default(), + ) + }) + }); + if let #field_dto_ty::#variant_name { #variant_field_name } = #field_var_name { + if let #field_reader_ty::#variant_name { #variant_field_name: #variant_reader_field_name } = #reader_var_name.as_ref() { + #variant_field_name.try_append_str(#variant_reader_field_name, #sub_key_ident, value)?; + } else { + unreachable!("Shape of reader should match shape of DTO") + } + } else { + return Err(WriteError::SuperFieldWrongValue { + target: key.clone(), + parent: WritableKey::#base_ident(#(#parent_field_names.clone()),*), + parent_expected: #variant_name_str.to_string(), + parent_actual: #field_var_name.to_string(), + }); + } + } + }; + + let remove_arm: syn::Arm = parse_quote! { + WritableKey::#combined_ident(#(#parent_field_names,)* #sub_key_ident) => { + let #parent_var_name = self.#(#parent_segments).*; + let #field_var_name = #parent_var_name.#field_name.get_or_insert_with(|| #field_dto_ty::#variant_name { #variant_field_name: #dto_type_ident::default() }); + let #reader_var_name = reader.#(#reader_segments).*.or_none().map(::std::borrow::Cow::Borrowed).unwrap_or_else(|| { + ::std::borrow::Cow::Owned(#field_reader_ty::#variant_name { + #variant_field_name: #reader_type_ident::from_dto( + &#dto_type_ident::default(), + &TEdgeConfigLocation::default(), + ) + }) + }); + if let #field_dto_ty::#variant_name { #variant_field_name } = #field_var_name { + if let #field_reader_ty::#variant_name { #variant_field_name: #variant_reader_field_name } = #reader_var_name.as_ref() { + #variant_field_name.try_remove_str(#variant_reader_field_name, #sub_key_ident, value)?; + } else { + unreachable!("Shape of reader should match shape of DTO") + } + } else { + return Err(WriteError::SuperFieldWrongValue { + target: key.clone(), + parent: WritableKey::#base_ident(#(#parent_field_names.clone()),*), + parent_expected: #variant_name_str.to_string(), + parent_actual: #field_var_name.to_string(), + }); + } + } + }; + + Some((update_arm, take_value_arm, unset_arm, append_arm, remove_arm)) + }) + .collect() +} + +fn generate_string_writers( + paths: &[VecDeque<&FieldOrGroup>], + gen_ctx: &GenerationContext, +) -> TokenStream { + let writable_key_name = &gen_ctx.writable_key_name; + let dto_name = &gen_ctx.dto_name; + let reader_name = &gen_ctx.reader_name; + let write_error_name = &gen_ctx.write_error_name; + let enum_variants = paths.iter().map(enum_variant); + type Arms = ( + Vec, + Vec, + Vec, + Vec, + Vec, + ); + let (update_arms, take_value_arms, unset_arms, append_arms, remove_arms): Arms = paths + .iter() + .zip(enum_variants) + .flat_map(|(path, configuration_key)| { + let read_segments = generate_field_accessor(path, "try_get", true); + let write_segments = generate_field_accessor(path, "try_get_mut", false).collect::>(); + let cleanup_stmts = generate_multi_dto_cleanup(path); + let field = path + .iter() + .filter_map(|thing| thing.field()) + .next() + .unwrap(); + let match_variant = configuration_key.match_read_write; + + let ty = if field.reader_function().is_some() { + extract_type_from_result(field.dto_ty()).map(|tys| tys.0).unwrap_or(field.dto_ty()) + } else { + field.dto_ty() + }; + + let parse_as = field.from().unwrap_or(field.dto_ty()); + let parse = quote_spanned! {parse_as.span()=> parse::<#parse_as>() }; + let convert_to_field_ty = quote_spanned! {ty.span()=> map(<#ty>::from)}; - let current_value = if field.read_only().is_some() || field.reader_function().is_some() { - if extract_type_from_result(field.ty()).is_some() { + // For fields with sub-fields, get current value from self (Dto) instead of reader, + // since the reader type is different from the dto type for sub-fields + let current_value = if field.sub_field_entries().is_some() { + quote_spanned! {ty.span()=> self.#(#write_segments).*.take()} + } else if field.read_only().is_some() || field.reader_function().is_some() { + if extract_type_from_result(field.reader_ty()).is_some() { quote_spanned! {ty.span()=> reader.#(#read_segments).*().ok().cloned()} } else { quote_spanned! {ty.span()=> Some(reader.#(#read_segments).*())} @@ -762,42 +1536,45 @@ fn generate_string_writers(paths: &[VecDeque<&FieldOrGroup>]) -> TokenStream { quote_spanned! {ty.span()=> reader.#(#read_segments).*.or_none().cloned()} }; - ( + let main_arms = ( parse_quote_spanned! {ty.span()=> #[allow(clippy::useless_conversion)] - WritableKey::#match_variant => self.#(#write_segments).* = Some(value + #writable_key_name::#match_variant => self.#(#write_segments).* = Some(value .#parse .#convert_to_field_ty - .map_err(|e| WriteError::ParseValue(Box::new(e)))?), + .map_err(|e| #write_error_name::ParseValue(Box::new(e)))?), }, parse_quote_spanned! {ty.span()=> - WritableKey::#match_variant => self.#(#write_segments).* = other.#(#write_segments).*.take(), + #writable_key_name::#match_variant => self.#(#write_segments).* = other.#(#write_segments).*.take(), }, parse_quote_spanned! {ty.span()=> - WritableKey::#match_variant => { + #writable_key_name::#match_variant => { self.#(#write_segments).* = None; #(#cleanup_stmts)* }, }, parse_quote_spanned! {ty.span()=> #[allow(clippy::useless_conversion)] - WritableKey::#match_variant => self.#(#write_segments).* = <#ty as AppendRemoveItem>::append( + #writable_key_name::#match_variant => self.#(#write_segments).* = <#ty as AppendRemoveItem>::append( #current_value, value .#parse .#convert_to_field_ty - .map_err(|e| WriteError::ParseValue(Box::new(e)))?), + .map_err(|e| #write_error_name::ParseValue(Box::new(e)))?), }, parse_quote_spanned! {ty.span()=> #[allow(clippy::useless_conversion)] - WritableKey::#match_variant => self.#(#write_segments).* = <#ty as AppendRemoveItem>::remove( + #writable_key_name::#match_variant => self.#(#write_segments).* = <#ty as AppendRemoveItem>::remove( #current_value, value .#parse .#convert_to_field_ty - .map_err(|e| WriteError::ParseValue(Box::new(e)))?), + .map_err(|e| #write_error_name::ParseValue(Box::new(e)))?), }, - ) + ); + + let sub_field_arms = generate_write_arms_for_sub_fields(path); + std::iter::once(main_arms).chain(sub_field_arms) }) .multiunzip(); let fallback_branch: Option = update_arms @@ -805,8 +1582,8 @@ fn generate_string_writers(paths: &[VecDeque<&FieldOrGroup>]) -> TokenStream { .then(|| parse_quote!(_ => unreachable!("WritableKey is uninhabited"))); quote! { - impl TEdgeConfigDto { - pub fn try_update_str(&mut self, key: &WritableKey, value: &str) -> Result<(), WriteError> { + impl #dto_name { + pub fn try_update_str(&mut self, key: &#writable_key_name, value: &str) -> Result<(), #write_error_name> { match key { #(#update_arms)* #fallback_branch @@ -814,15 +1591,15 @@ fn generate_string_writers(paths: &[VecDeque<&FieldOrGroup>]) -> TokenStream { Ok(()) } - pub(crate) fn take_value_from(&mut self, other: &mut TEdgeConfigDto, key: &WritableKey) -> Result<(), WriteError> { + pub(crate) fn take_value_from(&mut self, other: &mut #dto_name, key: &#writable_key_name) -> Result<(), #write_error_name> { match key { - #(#copy_arms)* + #(#take_value_arms)* #fallback_branch }; Ok(()) } - pub fn try_unset_key(&mut self, key: &WritableKey) -> Result<(), WriteError> { + pub fn try_unset_key(&mut self, key: &#writable_key_name) -> Result<(), #write_error_name> { match key { #(#unset_arms)* #fallback_branch @@ -830,7 +1607,7 @@ fn generate_string_writers(paths: &[VecDeque<&FieldOrGroup>]) -> TokenStream { Ok(()) } - pub fn try_append_str(&mut self, reader: &TEdgeConfigReader, key: &WritableKey, value: &str) -> Result<(), WriteError> { + pub fn try_append_str(&mut self, reader: &#reader_name, key: &#writable_key_name, value: &str) -> Result<(), #write_error_name> { match key { #(#append_arms)* #fallback_branch @@ -838,7 +1615,7 @@ fn generate_string_writers(paths: &[VecDeque<&FieldOrGroup>]) -> TokenStream { Ok(()) } - pub fn try_remove_str(&mut self, reader: &TEdgeConfigReader, key: &WritableKey, value: &str) -> Result<(), WriteError> { + pub fn try_remove_str(&mut self, reader: &#reader_name, key: &#writable_key_name, value: &str) -> Result<(), #write_error_name> { match key { #(#remove_arms)* #fallback_branch @@ -849,6 +1626,12 @@ fn generate_string_writers(paths: &[VecDeque<&FieldOrGroup>]) -> TokenStream { } } +/// Metadata for tracking sub-field information +#[derive(Clone, Debug)] +struct SubFieldInfo { + type_name: syn::Ident, +} + /// A configuration key that is stored in an enum variant /// /// The macro generates e.g. `ReadableKey` to list the variants @@ -886,6 +1669,9 @@ struct ConfigurationKey { insert_profiles: Vec<(syn::Pat, syn::Expr)>, doc_comment: Option, + /// If this is a sub-field key, contains metadata about the sub-field + sub_field_info: Option, + write_error: Option, } fn ident_for(segments: &VecDeque<&FieldOrGroup>) -> syn::Ident { @@ -924,7 +1710,7 @@ fn enum_variant(segments: &VecDeque<&FieldOrGroup>) -> ConfigurationKey { .iter() .map(|fog| match fog { FieldOrGroup::Multi(m) => { - format!("{}(?:[\\._]profiles[\\._]([^\\.]+))?", m.ident) + format!("{}(?:[\\._]profiles[\\._]([^\\.]+))?", m.name()) } FieldOrGroup::Field(f) => f.name().to_string(), FieldOrGroup::Group(g) => g.name().to_string(), @@ -958,8 +1744,8 @@ fn enum_variant(segments: &VecDeque<&FieldOrGroup>) -> ConfigurationKey { format!("{}.profiles.{{{}}}", m.ident, binding) } } - FieldOrGroup::Group(g) => g.ident.to_string(), - FieldOrGroup::Field(f) => f.ident().to_string(), + FieldOrGroup::Group(g) => g.name().to_string(), + FieldOrGroup::Field(f) => f.name().to_string(), }) .interleave(std::iter::repeat_n(".".to_owned(), segments.len() - 1)) .collect::(); @@ -1001,6 +1787,18 @@ fn enum_variant(segments: &VecDeque<&FieldOrGroup>) -> ConfigurationKey { formatters, insert_profiles, doc_comment: segments.iter().last().unwrap().doc(), + sub_field_info: None, + write_error: (|| { + Some( + segments + .back()? + .field()? + .read_only()? + .readonly + .write_error + .clone(), + ) + })(), } } else { ConfigurationKey { @@ -1015,7 +1813,19 @@ fn enum_variant(segments: &VecDeque<&FieldOrGroup>) -> ConfigurationKey { parse_quote!(::std::borrow::Cow::Borrowed(#key_str)), )], insert_profiles: vec![], - doc_comment: segments.iter().last().unwrap().doc(), + doc_comment: segments.back().unwrap().doc(), + sub_field_info: None, + write_error: (|| { + Some( + segments + .back()? + .field()? + .read_only()? + .readonly + .write_error + .clone(), + ) + })(), } } } @@ -1071,13 +1881,14 @@ fn is_read_write(path: &VecDeque<&FieldOrGroup>) -> bool { #[cfg(test)] mod tests { use super::*; + use prettyplease::unparse; use syn::ImplItem; use syn::ItemImpl; use test_case::test_case; #[test] fn output_parses() { - syn::parse2::(generate_writable_keys(&[])).unwrap(); + syn::parse2::(generate_writable_keys(&ctx(), &[])).unwrap(); } #[test] @@ -1088,7 +1899,7 @@ mod tests { url: String } }; - syn::parse2::(generate_writable_keys(&input.groups)).unwrap(); + syn::parse2::(generate_writable_keys(&ctx(), &input.groups)).unwrap(); } #[test] @@ -1098,12 +1909,14 @@ mod tests { url: String, } ); + let gen_ctx = gen_ctx(); let paths = configuration_paths_from(&input.groups, Mode::Reader); - let c = configuration_strings(paths.iter()); + let c = configuration_strings(paths.iter(), FilterRule::None, &gen_ctx.readable_key_name); let generated = generate_fromstr( - syn::Ident::new("ReadableKey", Span::call_site()), + &gen_ctx.readable_key_name, &c, parse_quote!(_ => unimplemented!("just a test, no error handling")), + &gen_ctx, ); let expected = parse_quote!( impl ::std::str::FromStr for ReadableKey { @@ -1135,6 +1948,14 @@ mod tests { /// and that the regex itself functions as intended const C8Y_URL_REGEX: &str = "^c8y(?:[\\._]profiles[\\._]([^\\.]+))?[\\._]url$"; + /// The regex generated for `mapper.ty` with multi-field profiles + const MAPPER_TY_REGEX: &str = "^mapper(?:[\\._]profiles[\\._]([^\\.]+))?[\\._]type$"; + + /// The regex generated for `mapper.c8y.*` (sub-field with profiles) + /// Captures: (1) profile name, (2) remainder after the sub-field prefix + const MAPPER_TY_C8Y_REGEX: &str = + "^mapper(?:[\\._]profiles[\\._]([^\\.]+))?[\\._]c8y[\\._](.+)$"; + #[test_case("c8y.url", None; "with no profile specified")] #[test_case("c8y.profiles.name.url", Some("name"); "with profile toml syntax")] #[test_case("c8y_profiles_name_url", Some("name"); "with environment variable profile")] @@ -1155,6 +1976,32 @@ mod tests { assert!(re.captures(input).is_none()); } + #[test_case("mapper.c8y.instance", (None, "instance"); "with no profile")] + #[test_case("mapper.profiles.myprofile.c8y.instance", (Some("myprofile"), "instance"); "with profile toml syntax")] + #[test_case("mapper_profiles_myprofile_c8y_instance", (Some("myprofile"), "instance"); "with environment variable syntax")] + fn sub_field_regex_matches(input: &str, (profile, remainder): (Option<&str>, &str)) { + let re = regex::Regex::new(MAPPER_TY_C8Y_REGEX).unwrap(); + let captures = re.captures(input).unwrap(); + assert_eq!( + captures.get(1).map(|s| s.as_str()), + profile, + "Profile capture should match" + ); + assert_eq!( + captures.get(2).map(|s| s.as_str()), + Some(remainder), + "Remainder capture should match" + ); + } + + #[test_case("mapper.type.custom.field"; "with custom sub-field instead of c8y")] + #[test_case("mapper.type"; "with no sub-field")] + #[test_case("mapper.c8y"; "with sub-field but no remainder")] + fn sub_field_regex_fails(input: &str) { + let re = regex::Regex::new(MAPPER_TY_C8Y_REGEX).unwrap(); + assert!(re.captures(input).is_none()); + } + #[test] fn from_str_generates_regex_matches_for_multi_fields() { let input: crate::input::Configuration = parse_quote!( @@ -1163,12 +2010,14 @@ mod tests { url: String, } ); + let gen_ctx = gen_ctx(); let paths = configuration_paths_from(&input.groups, Mode::Reader); - let c = configuration_strings(paths.iter()); + let c = configuration_strings(paths.iter(), FilterRule::None, &gen_ctx.readable_key_name); let generated = generate_fromstr( - syn::Ident::new("ReadableKey", Span::call_site()), + &gen_ctx.readable_key_name, &c, parse_quote!(_ => unimplemented!("just a test, no error handling")), + &gen_ctx, ); let expected = parse_quote!( impl ::std::str::FromStr for ReadableKey { @@ -1213,8 +2062,9 @@ mod tests { let mut paths = configuration_paths_from(&input.groups, Mode::Reader); let paths = paths.iter_mut().map(|vd| &*vd.make_contiguous()); let generated = key_iterators( - parse_quote!(TEdgeConfigReader), - parse_quote!(ReadableKey), + &parse_quote!(TEdgeConfigReader), + &parse_quote!(ReadableKey), + &parse_quote!(readable_keys), &paths.collect::>(), "", &[], @@ -1279,8 +2129,9 @@ mod tests { let mut paths = configuration_paths_from(&input.groups, Mode::Reader); let paths = paths.iter_mut().map(|vd| &*vd.make_contiguous()); let generated = key_iterators( - parse_quote!(TEdgeConfigReader), - parse_quote!(ReadableKey), + &parse_quote!(TEdgeConfigReader), + &parse_quote!(ReadableKey), + &parse_quote!(readable_keys), &paths.collect::>(), "", &[], @@ -1305,11 +2156,110 @@ mod tests { ); } + #[test] + fn iteration_of_sub_fields_recurses_to_sub_config() { + let input: crate::input::Configuration = parse_quote!( + mapper: { + enable: bool, + + #[tedge_config(rename = "type")] + #[tedge_config(sub_fields = [C8y(C8y), Az(Az), Custom])] + ty: MapperType, + + url: String, + } + ); + let mut paths = configuration_paths_from(&input.groups, Mode::Reader); + let paths = paths.iter_mut().map(|vd| &*vd.make_contiguous()); + let generated = key_iterators( + &parse_quote!(TEdgeConfigReader), + &parse_quote!(ReadableKey), + &parse_quote!(readable_keys), + &paths.collect::>(), + "", + &[], + ); + let expected = parse_quote! { + impl TEdgeConfigReader { + pub fn readable_keys(&self) -> impl Iterator + '_ { + self.mapper.readable_keys() + } + } + + impl TEdgeConfigReaderMapper { + pub fn readable_keys(&self) -> impl Iterator + '_ { + [ReadableKey::MapperEnable, ReadableKey::MapperType, ReadableKey::MapperUrl] + .into_iter() + .chain({ + self.ty.or_none().into_iter().flat_map(move |ty| ty.readable_keys()) + }) + } + } + + impl MapperTypeReader { + pub fn readable_keys(&self) -> Vec { + match self { + Self::C8y { c8y } => c8y.readable_keys().map(|inner_key| ReadableKey::MapperTypeC8y(inner_key)).collect(), + Self::Az { az } => az.readable_keys().map(|inner_key| ReadableKey::MapperTypeAz(inner_key)).collect(), + Self::Custom => Vec::new(), + } + } + } + }; + + pretty_assertions::assert_eq!( + prettyplease::unparse(&syn::parse2(generated).unwrap()), + prettyplease::unparse(&expected) + ); + } + + #[test] + fn iteration_of_multi_profile_sub_fields() { + let input: crate::input::Configuration = parse_quote!( + #[tedge_config(multi)] + mapper: { + #[tedge_config(rename = "type")] + #[tedge_config(sub_fields = [C8y(C8y), Az(Az), Custom])] + ty: MapperType, + } + ); + let mut paths = configuration_paths_from(&input.groups, Mode::Reader); + let paths = paths.iter_mut().map(|vd| &*vd.make_contiguous()); + let generated = key_iterators( + &parse_quote!(TEdgeConfigReader), + &parse_quote!(ReadableKey), + &parse_quote!(readable_keys), + &paths.collect::>(), + "", + &[], + ); + let mut actual: syn::File = syn::parse2(generated).unwrap(); + actual.items.retain(|item| matches!(item, syn::Item::Impl(syn::ItemImpl { self_ty, .. }) if **self_ty == parse_quote!(TEdgeConfigReaderMapper))); + let expected = parse_quote! { + impl TEdgeConfigReaderMapper { + pub fn readable_keys(&self, mapper: Option) -> impl Iterator + '_ { + [ReadableKey::MapperType(mapper.clone())] + .into_iter() + .chain({ + let mapper = mapper.clone(); + self.ty.or_none().into_iter().flat_map(move |ty| ty.readable_keys(mapper.clone())) + }) + } + } + }; + + pretty_assertions::assert_eq!( + prettyplease::unparse(&actual), + prettyplease::unparse(&expected) + ); + } + #[test] fn iteration_of_empty_field_enum_is_an_empty_iterator() { let generated = key_iterators( - parse_quote!(TEdgeConfigReader), - parse_quote!(ReadableKey), + &parse_quote!(TEdgeConfigReader), + &parse_quote!(ReadableKey), + &parse_quote!(readable_keys), &[], "", &[], @@ -1341,8 +2291,9 @@ mod tests { let mut paths = configuration_paths_from(&input.groups, Mode::Reader); let paths = paths.iter_mut().map(|vd| &*vd.make_contiguous()); let generated = key_iterators( - parse_quote!(TEdgeConfigReader), - parse_quote!(ReadableKey), + &parse_quote!(TEdgeConfigReader), + &parse_quote!(ReadableKey), + &parse_quote!(readable_keys), &paths.collect::>(), "", &[], @@ -1392,7 +2343,8 @@ mod tests { } ); let paths = configuration_paths_from(&input.groups, Mode::Reader); - let config_keys = configuration_strings(paths.iter()); + let config_keys = + configuration_strings(paths.iter(), FilterRule::None, &gen_ctx().readable_key_name); let impl_block = retain_fn(keys_enum_impl_block(&config_keys), "to_cow_str"); let expected = parse_quote! { @@ -1424,7 +2376,8 @@ mod tests { } ); let paths = configuration_paths_from(&input.groups, Mode::Reader); - let config_keys = configuration_strings(paths.iter()); + let config_keys = + configuration_strings(paths.iter(), FilterRule::None, &gen_ctx().readable_key_name); let impl_block = retain_fn(keys_enum_impl_block(&config_keys), "to_cow_str"); let expected = parse_quote! { @@ -1449,10 +2402,13 @@ mod tests { #[tedge_config(multi)] c8y: { url: String, + #[tedge_config(rename = "type")] + ty: String, } ); let paths = configuration_paths_from(&input.groups, Mode::Reader); - let config_keys = configuration_strings(paths.iter()); + let config_keys = + configuration_strings(paths.iter(), FilterRule::None, &gen_ctx().readable_key_name); let impl_block = retain_fn(keys_enum_impl_block(&config_keys), "to_cow_str"); let expected = parse_quote! { @@ -1461,6 +2417,8 @@ mod tests { match self { Self::C8yUrl(None) => ::std::borrow::Cow::Borrowed("c8y.url"), Self::C8yUrl(Some(key0)) => ::std::borrow::Cow::Owned(format!("c8y.profiles.{key0}.url")), + Self::C8yType(None) => ::std::borrow::Cow::Borrowed("c8y.type"), + Self::C8yType(Some(key0)) => ::std::borrow::Cow::Owned(format!("c8y.profiles.{key0}.type")), } } } @@ -1484,7 +2442,8 @@ mod tests { } ); let paths = configuration_paths_from(&input.groups, Mode::Reader); - let config_keys = configuration_strings(paths.iter()); + let config_keys = + configuration_strings(paths.iter(), FilterRule::None, &gen_ctx().readable_key_name); let impl_block = retain_fn(keys_enum_impl_block(&config_keys), "to_cow_str"); let expected = parse_quote! { @@ -1522,8 +2481,10 @@ mod tests { enable: bool, }, ); + let gen_ctx = gen_ctx(); let paths = configuration_paths_from(&input.groups, Mode::Reader); - let config_keys = configuration_strings(paths.iter()); + let config_keys = + configuration_strings(paths.iter(), FilterRule::None, &gen_ctx.readable_key_name); let impl_block = retain_fn(keys_enum_impl_block(&config_keys), "try_with_profile"); let expected = parse_quote! { @@ -1564,7 +2525,7 @@ mod tests { }, ); let paths = configuration_paths_from(&input.groups, Mode::Reader); - let writers = generate_string_writers(&paths); + let writers = generate_string_writers(&paths, &gen_ctx()); let impl_dto_block = syn::parse2(writers).unwrap(); let impl_dto_block = retain_fn(impl_dto_block, "try_unset_key"); @@ -1609,7 +2570,7 @@ mod tests { } ); let paths = configuration_paths_from(&input.groups, Mode::Reader); - let writers = generate_string_writers(&paths); + let writers = generate_string_writers(&paths, &gen_ctx()); let impl_dto_block = syn::parse2(writers).unwrap(); let impl_dto_block = retain_fn(impl_dto_block, "try_append_str"); @@ -1652,9 +2613,11 @@ mod tests { ty: String, }, ); + let gen_ctx = gen_ctx(); let dto_paths = configuration_paths_from(&input.groups, Mode::Dto); - let dto_keys = configuration_strings(dto_paths.iter()); - let writers = generate_fromstr_writable(parse_quote!(DtoKey), &dto_keys); + let dto_keys = + configuration_strings(dto_paths.iter(), FilterRule::None, &gen_ctx.dto_key_name); + let writers = generate_fromstr_writable(&parse_quote!(DtoKey), &dto_keys, &gen_ctx); let impl_dto_block = syn::parse2(writers).unwrap(); let expected = parse_quote! { @@ -1704,48 +2667,935 @@ mod tests { ); } - fn keys_enum_impl_block(config_keys: &(Vec, Vec)) -> ItemImpl { - let generated = keys_enum(parse_quote!(ReadableKey), config_keys, "DOC FRAGMENT"); - let generated_file: syn::File = syn::parse2(generated).unwrap(); - let mut impl_block = generated_file + #[test] + fn writable_keys_includes_ability_to_set_sub_fields() { + let input: crate::input::Configuration = parse_quote!( + #[tedge_config(multi)] + mapper: { + #[tedge_config(sub_fields = [C8y(C8y), Az(Az), Custom])] + #[tedge_config(rename = "type")] + ty: MapperType, + }, + ); + let writers = generate_writable_keys(&ctx(), &input.groups); + let mut actual: syn::File = syn::parse2(writers).unwrap(); + actual.items.retain(|item| matches!(item, syn::Item::Enum(syn::ItemEnum { ident, .. }) if !ident.to_string().ends_with("Error"))); + actual.items.iter_mut().for_each(|item| { + if let syn::Item::Enum(enumm) = item { + enumm.attrs.retain(|attr| !is_doc_comment(attr)); + for variant in &mut enumm.variants { + variant.attrs.retain(|attr| !is_doc_comment(attr)); + } + } + }); + + let expected = parse_quote! { + #[derive(Clone, Debug, PartialEq, Eq)] + #[non_exhaustive] + #[allow(unused)] + pub enum ReadableKey { + MapperType(Option), + MapperTypeC8y(Option, C8yReadableKey), + MapperTypeAz(Option, AzReadableKey), + } + + #[derive(Clone, Debug, PartialEq, Eq)] + #[non_exhaustive] + #[allow(unused)] + pub enum ReadOnlyKey { + MapperTypeC8y(Option, C8yReadOnlyKey), + MapperTypeAz(Option, AzReadOnlyKey), + } + + #[derive(Clone, Debug, PartialEq, Eq)] + #[non_exhaustive] + #[allow(unused)] + pub enum WritableKey { + MapperType(Option), + MapperTypeC8y(Option, C8yWritableKey), + MapperTypeAz(Option, AzWritableKey), + } + + #[derive(Clone, Debug, PartialEq, Eq)] + #[non_exhaustive] + #[allow(unused)] + pub enum DtoKey { + MapperType(Option), + MapperTypeC8y(Option, C8yDtoKey), + MapperTypeAz(Option, AzDtoKey), + } + }; + + pretty_assertions::assert_eq!(unparse(&actual), unparse(&expected)); + } + + #[test] + fn write_error_method_recurses_to_sub_fields() { + let input: crate::input::Configuration = parse_quote!( + device: { + #[tedge_config(readonly(write_error = "An example error message", function = "device_id"))] + id: String + }, + #[tedge_config(multi)] + mapper: { + #[tedge_config(sub_fields = [C8y(C8y), Az(Az), Custom])] + #[tedge_config(rename = "type")] + ty: MapperType, + }, + ); + let actual = generate_writable_keys(&ctx(), &input.groups); + let mut actual: syn::File = syn::parse2(actual).unwrap(); + actual.items = actual .items .into_iter() - .find_map(|item| { - if let syn::Item::Impl(r#impl @ ItemImpl { trait_: None, .. }) = item { - Some(r#impl) + .filter_map(|mut item| { + if let syn::Item::Impl(i) = &mut item { + i.items.retain( + |item| matches!(item, syn::ImplItem::Fn(f) if f.sig.ident == "write_error"), + ); + if !i.items.is_empty() { + Some(item) + } else { + None + } } else { None } }) - .expect("Should generate an impl block for ReadableKey"); + .collect(); + let expected: syn::File = parse_quote!( + impl ReadOnlyKey { + fn write_error(&self) -> &'static str { + match self { + Self::DeviceId => "An example error message", + #[allow(unused)] + Self::MapperTypeC8y(key0, sub_key) => sub_key.write_error(), + #[allow(unused)] + Self::MapperTypeAz(key0, sub_key) => sub_key.write_error(), + } + } + } + ); - // Remove doc comments from items - for item in &mut impl_block.items { - if let syn::ImplItem::Fn(f) = item { - f.attrs.retain(|f| *f.path().get_ident().unwrap() != "doc"); + pretty_assertions::assert_eq!(unparse(&actual), unparse(&expected)); + } + + #[test] + fn sub_fields_dont_trigger_nested_profiles_error() { + let input: crate::input::Configuration = parse_quote!( + #[tedge_config(multi)] + mapper: { + #[tedge_config(sub_fields = [C8y(C8y), Az(Az), Custom])] + #[tedge_config(rename = "type")] + ty: MapperType, + }, + ); + let gen_ctx = gen_ctx(); + let paths = configuration_paths_from(&input.groups, Mode::Reader); + let config_keys = + configuration_strings(paths.iter(), FilterRule::None, &gen_ctx.readable_key_name); + let impl_block = retain_fn(keys_enum_impl_block(&config_keys), "try_with_profile"); + + let expected = parse_quote! { + impl ReadableKey { + pub fn try_with_profile(self, profile: ProfileName) -> ::anyhow::Result { + match self { + Self::MapperType(None) => Ok(Self::MapperType(Some(profile.into()))), + c @ Self::MapperType(Some(_)) => ::anyhow::bail!("Multiple profiles selected from the arguments {c} and --profile {profile}"), + other => ::anyhow::bail!("You've supplied a profile, but {other} is not a profiled configuration"), + } + } } - } + }; - impl_block + pretty_assertions::assert_eq!( + prettyplease::unparse(&parse_quote!(#impl_block)), + prettyplease::unparse(&expected) + ); } - fn retain_fn(mut impl_block: ItemImpl, fn_name: &str) -> ItemImpl { - let ident = syn::Ident::new(fn_name, Span::call_site()); - let all_fn_names: Vec<_> = impl_block - .items - .iter() - .filter_map(|i| match i { - ImplItem::Fn(f) => Some(f.sig.ident.clone()), - _ => None, - }) - .collect(); - impl_block - .items - .retain(|i| matches!(i, ImplItem::Fn(f) if f.sig.ident == ident)); - assert!( - !impl_block.items.is_empty(), - "{ident:?} did not appear in methods. The valid method names are {all_fn_names:?}" + #[test] + fn read_string_handles_sub_field_keys() { + let input: crate::input::Configuration = parse_quote!( + #[tedge_config(multi)] + mapper: { + #[tedge_config(sub_fields = [C8y(C8y), Custom])] + #[tedge_config(rename = "type")] + ty: MapperType, + }, ); - impl_block + + let paths = configuration_paths_from(&input.groups, Mode::Reader); + let readers = generate_string_readers(&paths, &gen_ctx()); + let impl_block: syn::ItemImpl = syn::parse2(readers).unwrap(); + let actual = retain_fn(impl_block, "read_string"); + + let expected = parse_quote! { + impl TEdgeConfigReader { + pub fn read_string(&self, key: &ReadableKey) -> Result { + match key { + ReadableKey::MapperType(key0) => Ok(self.mapper.try_get(key0.as_deref())?.ty.or_config_not_set()?.to_string()), + ReadableKey::MapperTypeC8y(key0, sub_key) => { + let mapper_ty = &self.mapper.try_get(key0.as_deref())?.ty.or_config_not_set()?; + match mapper_ty { + MapperTypeReader::C8y { c8y } => { + c8y.read_string(sub_key) + } + _ => unreachable!("Attempted to read C8y sub-field from non-C8y variant"), + } + } + } + } + } + }; + + pretty_assertions::assert_eq!( + prettyplease::unparse(&parse_quote!(#actual)), + prettyplease::unparse(&expected) + ); + } + + #[test] + fn write_string_handles_sub_field_keys() { + let input: crate::input::Configuration = parse_quote!( + #[tedge_config(multi)] + mapper: { + #[tedge_config(sub_fields = [C8y(C8y), Custom])] + ty: MapperType, + }, + ); + + let paths = configuration_paths_from(&input.groups, Mode::Reader); + let writers = generate_string_writers(&paths, &gen_ctx()); + let impl_block: syn::ItemImpl = syn::parse2(writers).unwrap(); + let actual = retain_fn(impl_block, "try_update_str"); + + let expected = parse_quote! { + impl TEdgeConfigDto { + pub fn try_update_str(&mut self, key: &WritableKey, value: &str) -> Result<(), WriteError> { + match key { + #[allow(clippy::useless_conversion)] + WritableKey::MapperTy(key0) => self.mapper.try_get_mut(key0.as_deref(), "mapper")?.ty = Some(value.parse::().map(::from).map_err(|e| WriteError::ParseValue(Box::new(e)))?), + WritableKey::MapperTyC8y(key0, sub_key) => { + let mapper = self.mapper.try_get_mut(key0.as_deref(), "mapper")?; + let mapper_ty = mapper.ty.get_or_insert_with(|| MapperTypeDto::C8y { c8y: C8yDto::default() }); + if let MapperTypeDto::C8y { c8y } = mapper_ty { + c8y.try_update_str(sub_key, value)?; + } else { + return Err(WriteError::SuperFieldWrongValue { + target: key.clone(), + parent: WritableKey::MapperTy(key0.clone()), + parent_expected: "c8y".to_string(), + parent_actual: mapper_ty.to_string(), + }); + } + } + }; + Ok(()) + } + } + }; + + pretty_assertions::assert_eq!( + prettyplease::unparse(&parse_quote!(#actual)), + prettyplease::unparse(&expected) + ); + } + + #[test] + fn take_value_from_handles_sub_field_keys() { + let input: crate::input::Configuration = parse_quote!( + #[tedge_config(multi)] + mapper: { + #[tedge_config(sub_fields = [C8y(C8y), Custom])] + ty: MapperType, + }, + ); + + let paths = configuration_paths_from(&input.groups, Mode::Reader); + let writers = generate_string_writers(&paths, &gen_ctx()); + let impl_block: syn::ItemImpl = syn::parse2(writers).unwrap(); + let actual = retain_fn(impl_block, "take_value_from"); + + let expected = parse_quote! { + impl TEdgeConfigDto { + pub(crate) fn take_value_from(&mut self, other: &mut TEdgeConfigDto, key: &WritableKey) -> Result<(), WriteError> { + match key { + WritableKey::MapperTy(key0) => { + self.mapper.try_get_mut(key0.as_deref(), "mapper")?.ty = other + .mapper + .try_get_mut(key0.as_deref(), "mapper")? + .ty + .take(); + } + WritableKey::MapperTyC8y(key0, sub_key) => { + let mapper = self.mapper.try_get_mut(key0.as_deref(), "mapper")?; + let mapper_ty = mapper.ty.get_or_insert_with(|| MapperTypeDto::C8y { c8y: C8yDto::default() }); + if let MapperTypeDto::C8y { c8y } = mapper_ty { + let other_mapper = other.mapper.try_get_mut(key0.as_deref(), "mapper")?; + let other_mapper_ty = &mut other_mapper.ty; + if let Some(MapperTypeDto::C8y { c8y: ref mut other_c8y }) = other_mapper_ty { + c8y.take_value_from(other_c8y, sub_key)?; + } + } else { + return Err(WriteError::SuperFieldWrongValue { + target: key.clone(), + parent: WritableKey::MapperTy(key0.clone()), + parent_expected: "c8y".to_string(), + parent_actual: mapper_ty.to_string(), + }); + } + } + }; + Ok(()) + } + } + }; + + pretty_assertions::assert_eq!( + prettyplease::unparse(&parse_quote!(#actual)), + prettyplease::unparse(&expected) + ); + } + + #[test] + fn try_unset_key_handles_sub_field_keys() { + let input: crate::input::Configuration = parse_quote!( + #[tedge_config(multi)] + mapper: { + #[tedge_config(sub_fields = [C8y(C8y), Custom])] + ty: MapperType, + }, + ); + + let paths = configuration_paths_from(&input.groups, Mode::Reader); + let writers = generate_string_writers(&paths, &gen_ctx()); + let impl_block: syn::ItemImpl = syn::parse2(writers).unwrap(); + let actual = retain_fn(impl_block, "try_unset_key"); + + let expected = parse_quote! { + impl TEdgeConfigDto { + pub fn try_unset_key(&mut self, key: &WritableKey) -> Result<(), WriteError> { + match key { + WritableKey::MapperTy(key0) => { + self.mapper.try_get_mut(key0.as_deref(), "mapper")?.ty = None; + self.mapper.remove_if_empty(key0.as_deref()); + } + WritableKey::MapperTyC8y(key0, sub_key) => { + let mapper = self.mapper.try_get_mut(key0.as_deref(), "mapper")?; + let mapper_ty = mapper.ty.get_or_insert_with(|| MapperTypeDto::C8y { c8y: C8yDto::default() }); + if let MapperTypeDto::C8y { c8y } = mapper_ty { + c8y.try_unset_key(sub_key)?; + } else { + return Err(WriteError::SuperFieldWrongValue { + target: key.clone(), + parent: WritableKey::MapperTy(key0.clone()), + parent_expected: "c8y".to_string(), + parent_actual: mapper_ty.to_string(), + }); + } + } + }; + Ok(()) + } + } + }; + + pretty_assertions::assert_eq!( + prettyplease::unparse(&parse_quote!(#actual)), + prettyplease::unparse(&expected) + ); + } + + #[test] + fn try_append_str_handles_sub_field_keys() { + let input: crate::input::Configuration = parse_quote!( + #[tedge_config(multi)] + mapper: { + #[tedge_config(sub_fields = [C8y(C8y), Custom])] + ty: MapperType, + }, + ); + let gen_ctx = gen_ctx(); + + let paths = configuration_paths_from(&input.groups, Mode::Reader); + let writers = generate_string_writers(&paths, &gen_ctx); + let impl_block: syn::ItemImpl = syn::parse2(writers).unwrap(); + let actual = retain_fn(impl_block, "try_append_str"); + + let expected = parse_quote! { + impl TEdgeConfigDto { + pub fn try_append_str(&mut self, reader: &TEdgeConfigReader, key: &WritableKey, value: &str) -> Result<(), WriteError> { + match key { + #[allow(clippy::useless_conversion)] + WritableKey::MapperTy(key0) => { + self.mapper.try_get_mut(key0.as_deref(), "mapper")?.ty = ::append( + self.mapper.try_get_mut(key0.as_deref(), "mapper")?.ty.take(), + value + .parse::() + .map(::from) + .map_err(|e| WriteError::ParseValue(Box::new(e)))?, + ); + } + WritableKey::MapperTyC8y(key0, sub_key) => { + let mapper = self.mapper.try_get_mut(key0.as_deref(), "mapper")?; + let mapper_ty = mapper.ty.get_or_insert_with(|| MapperTypeDto::C8y { c8y: C8yDto::default() }); + let mapper_ty_reader = reader + .mapper + .try_get(key0.as_deref())? + .ty + .or_none() + .map(::std::borrow::Cow::Borrowed) + .unwrap_or_else(|| { + ::std::borrow::Cow::Owned(MapperTypeReader::C8y { + c8y: C8yReader::from_dto(&C8yDto::default(), &TEdgeConfigLocation::default()), + }) + }); + if let MapperTypeDto::C8y { c8y } = mapper_ty { + if let MapperTypeReader::C8y { c8y: c8y_reader } = mapper_ty_reader.as_ref() { + c8y.try_append_str(c8y_reader, sub_key, value)?; + } else { + unreachable!("Shape of reader should match shape of DTO") + } + } else { + return Err(WriteError::SuperFieldWrongValue { + target: key.clone(), + parent: WritableKey::MapperTy(key0.clone()), + parent_expected: "c8y".to_string(), + parent_actual: mapper_ty.to_string(), + }); + } + } + }; + Ok(()) + } + } + }; + + pretty_assertions::assert_eq!( + prettyplease::unparse(&parse_quote!(#actual)), + prettyplease::unparse(&expected) + ); + } + + #[test] + fn try_remove_str_handles_sub_field_keys() { + let input: crate::input::Configuration = parse_quote!( + #[tedge_config(multi)] + mapper: { + #[tedge_config(sub_fields = [C8y(C8y), Custom])] + #[tedge_config(rename = "type")] + ty: MapperType, + }, + ); + + let paths = configuration_paths_from(&input.groups, Mode::Reader); + let writers = generate_string_writers(&paths, &gen_ctx()); + let impl_block: syn::ItemImpl = syn::parse2(writers).unwrap(); + let actual = retain_fn(impl_block, "try_remove_str"); + + let expected = parse_quote! { + impl TEdgeConfigDto { + pub fn try_remove_str(&mut self, reader: &TEdgeConfigReader, key: &WritableKey, value: &str) -> Result<(), WriteError> { + match key { + #[allow(clippy::useless_conversion)] + WritableKey::MapperType(key0) => { + self.mapper.try_get_mut(key0.as_deref(), "mapper")?.ty = ::remove( + self.mapper.try_get_mut(key0.as_deref(), "mapper")?.ty.take(), + value + .parse::() + .map(::from) + .map_err(|e| WriteError::ParseValue(Box::new(e)))?, + ); + } + WritableKey::MapperTypeC8y(key0, sub_key) => { + let mapper = self.mapper.try_get_mut(key0.as_deref(), "mapper")?; + let mapper_ty = mapper.ty.get_or_insert_with(|| MapperTypeDto::C8y { c8y: C8yDto::default() }); + let mapper_ty_reader = reader + .mapper + .try_get(key0.as_deref())? + .ty + .or_none() + .map(::std::borrow::Cow::Borrowed) + .unwrap_or_else(|| { + ::std::borrow::Cow::Owned(MapperTypeReader::C8y { + c8y: C8yReader::from_dto(&C8yDto::default(), &TEdgeConfigLocation::default()), + }) + }); + if let MapperTypeDto::C8y { c8y } = mapper_ty { + if let MapperTypeReader::C8y { c8y: c8y_reader } = mapper_ty_reader.as_ref() { + c8y.try_remove_str(c8y_reader, sub_key, value)?; + } else { + unreachable!("Shape of reader should match shape of DTO") + } + } else { + return Err(WriteError::SuperFieldWrongValue { + target: key.clone(), + parent: WritableKey::MapperType(key0.clone()), + parent_expected: "c8y".to_string(), + parent_actual: mapper_ty.to_string(), + }); + } + } + }; + Ok(()) + } + } + }; + + pretty_assertions::assert_eq!( + prettyplease::unparse(&parse_quote!(#actual)), + prettyplease::unparse(&expected) + ); + } + + #[test] + fn sub_field_keys_are_excluded_from_values_array() { + // Regression test: ensure sub-field keys don't cause "Self is only available in impls" error + let input: crate::input::Configuration = parse_quote!( + #[tedge_config(multi)] + mapper: { + #[tedge_config(sub_fields = [C8y(C8y), Custom])] + ty: MapperType, + }, + ); + let gen_ctx = gen_ctx(); + + let paths = configuration_paths_from(&input.groups, Mode::Reader); + let config_keys = + configuration_strings(paths.iter(), FilterRule::None, &gen_ctx.readable_key_name); + let generated = keys_enum(&parse_quote!(ReadableKey), &config_keys, "read from"); + + // Should parse successfully without "Self is only available" errors + let generated_file: syn::File = syn::parse2(generated).unwrap(); + + // Find the VALUES constant + let impl_block = generated_file + .items + .iter() + .find_map(|item| { + if let syn::Item::Impl(r#impl @ syn::ItemImpl { trait_: None, .. }) = item { + Some(r#impl) + } else { + None + } + }) + .expect("Should have impl block"); + + let values_const = impl_block + .items + .iter() + .find_map(|item| { + if let syn::ImplItem::Const(c) = item { + if c.ident == "VALUES" { + return Some(c); + } + } + None + }) + .expect("Should have VALUES const"); + + // The VALUES array should only contain MapperType(None), not the sub-field variants + let values_str = quote!(#values_const).to_string(); + assert!( + values_str.contains("MapperTy"), + "Should contain MapperTy (rename applied), got: {}", + values_str + ); + assert!( + !values_str.contains("MapperTyC8y"), + "Should not contain sub-field key MapperTyC8y" + ); + assert!( + !values_str.contains("unreachable"), + "Should not contain unreachable! macro calls" + ); + } + + #[test] + fn sub_field_keys_are_excluded_from_fromstr() { + // Regression test: ensure sub-field keys don't cause "Self is only available" error in FromStr + let input: crate::input::Configuration = parse_quote!( + #[tedge_config(multi)] + mapper: { + #[tedge_config(sub_fields = [C8y(C8y), Custom])] + ty: MapperType, + }, + ); + let gen_ctx = gen_ctx(); + + let paths = configuration_paths_from(&input.groups, Mode::Reader); + let config_keys = + configuration_strings(paths.iter(), FilterRule::None, &gen_ctx.readable_key_name); + let fromstr_impl = + generate_fromstr_readable(&gen_ctx.readable_key_name.clone(), &config_keys, &gen_ctx); + + // Should parse successfully without "Self is only available" errors + let _: syn::File = + syn::parse2(fromstr_impl).expect("FromStr impl should parse without errors"); + } + + #[test] + fn fromstr_parses_sub_field_keys() { + // Test that FromStr properly parses sub-field keys + let input: crate::input::Configuration = parse_quote!( + #[tedge_config(multi)] + mapper: { + #[tedge_config(sub_fields = [C8y(C8y)])] + #[tedge_config(rename = "type")] + ty: MapperType, + }, + ); + let gen_ctx = gen_ctx(); + + let paths = configuration_paths_from(&input.groups, Mode::Reader); + let config_keys = + configuration_strings(paths.iter(), FilterRule::None, &gen_ctx.readable_key_name); + let generated = generate_fromstr( + &gen_ctx.readable_key_name, + &config_keys, + parse_quote!(_ => unimplemented!("just a test, no error handling")), + &gen_ctx, + ); + + let expected = parse_quote!( + impl ::std::str::FromStr for ReadableKey { + type Err = ParseKeyError; + fn from_str(value: &str) -> Result { + #[deny(unreachable_patterns)] + let res = match replace_aliases(value.to_owned()).replace(".", "_").as_str() { + "mapper_type" => { + if value != "mapper.type" { + warn_about_deprecated_key(value.to_owned(), "mapper.type"); + } + return Ok(Self::MapperType(None)); + }, + key if key.starts_with("mapper_c8y_") => { + // Sub-field keys start with the prefix and parse the remainder with the sub-key type + let sub_key_str = value.strip_prefix("mapper.c8y.").unwrap_or(value); + let sub_key: C8yReadableKey = sub_key_str.parse().map_err(|err| match err { + C8yParseKeyError::ReadOnly(sub_key) => ParseKeyError::ReadOnly(ReadOnlyKey::MapperTypeC8y(None, sub_key)), + C8yParseKeyError::Unrecognised(sub_key) => ParseKeyError::Unrecognised(format!("mapper.c8y.{sub_key}")), + })?; + return Ok(Self::MapperTypeC8y(None, sub_key)); + }, + _ => unimplemented!("just a test, no error handling"), + }; + if let Some(captures) = ::regex::Regex::new(#MAPPER_TY_REGEX).unwrap().captures(value) { + let key0 = captures.get(1usize).map(|re_match| re_match.as_str().to_owned()); + return Ok(Self::MapperType(key0)); + }; + if let Some(captures) = ::regex::Regex::new(#MAPPER_TY_C8Y_REGEX).unwrap().captures(value) { + let key0 = captures.get(1usize).map(|re_match| re_match.as_str().to_owned()); + let sub_key_str = captures.get(2usize).map(|re_match| re_match.as_str()).unwrap_or(""); + let sub_key: C8yReadableKey = sub_key_str.parse().map_err({ + let key0 = key0.clone(); + |err| match err { + C8yParseKeyError::ReadOnly(sub_key) => ParseKeyError::ReadOnly(ReadOnlyKey::MapperTypeC8y(key0, sub_key)), + C8yParseKeyError::Unrecognised(sub_key) => ParseKeyError::Unrecognised(format!("mapper.c8y.{sub_key}")), + } + })?; + return Ok(Self::MapperTypeC8y(key0, sub_key)); + }; + res + } + } + ); + + pretty_assertions::assert_eq!( + prettyplease::unparse(&syn::parse2(generated).unwrap()), + prettyplease::unparse(&expected) + ); + } + + #[test] + fn fromstr_parses_sub_field_keys_with_intermediary_group() { + // Test that sub-field keys with an intermediary group still use correct capture indices + let input: crate::input::Configuration = parse_quote!( + #[tedge_config(multi)] + mapper: { + config: { + #[tedge_config(sub_fields = [C8y(C8y)])] + ty: MapperType, + } + }, + ); + let gen_ctx = &gen_ctx(); + + let paths = configuration_paths_from(&input.groups, Mode::Reader); + let config_keys = + configuration_strings(paths.iter(), FilterRule::None, &gen_ctx.readable_key_name); + let generated = generate_fromstr( + &parse_quote!(ReadableKey), + &config_keys, + parse_quote!(_ => unimplemented!("just a test, no error handling")), + gen_ctx, + ); + + let generated_code = prettyplease::unparse(&syn::parse2(generated).unwrap()); + + // Find all capture.get(...) calls to see what indices are being used + // Note: The pattern must account for potential whitespace/newlines between 'captures' and '.get' + let capture_pattern = + regex::Regex::new(r"captures\s*\.get\s*\(\s*(\d+)\s*usize\s*\)").unwrap(); + let capture_indices: Vec = capture_pattern + .captures_iter(&generated_code) + .filter_map(|cap| cap.get(1).and_then(|m| m.as_str().parse().ok())) + .collect(); + + // We should have exactly three capture groups: 1 for profile on mapper.config.ty, 1 for profile on mapper.config.ty.c8y.*, 2 for sub-key remainder + if capture_indices != vec![1, 1, 2] { + eprintln!("Expected [1, 1, 2] but found {:?}", capture_indices); + eprintln!("Generated code:\n{}", generated_code); + panic!( + "Capture indices mismatch: expected [1, 1, 2], got {:?}", + capture_indices + ); + } + } + + #[test] + fn sub_field_variants_in_writable_key_use_writable_subkey_type() { + // Test that WritableKey sub-field enum variants use WritableKey sub-field types, + // not ReadableKey sub-field types + let input: crate::input::Configuration = parse_quote!( + #[tedge_config(multi)] + mapper: { + config: { + #[tedge_config(sub_fields = [C8y(C8y)])] + ty: MapperType, + } + }, + ); + + let generated = generate_writable_keys(&ctx(), &input.groups); + let generated_code = prettyplease::unparse(&syn::parse2(generated).unwrap()); + + // Extract the WritableKey enum specifically + let start_idx = generated_code + .find("pub enum WritableKey") + .expect("WritableKey enum not found"); + let end_idx = generated_code[start_idx..] + .find("}\n") + .expect("End of WritableKey enum not found") + + start_idx; + let writable_key_enum = &generated_code[start_idx..end_idx]; + + // Should contain C8yWritableKey, not C8yReadableKey + assert!( + writable_key_enum.contains("C8yWritableKey"), + "WritableKey should use C8yWritableKey. Found:\n{}", + writable_key_enum + ); + + assert!( + !writable_key_enum.contains("C8yReadableKey"), + "WritableKey should NOT use C8yReadableKey. Found:\n{}", + writable_key_enum + ); + } + + #[test] + fn sub_field_append_remove_uses_dto_type_not_reader_type() { + // Regression test: ensure try_append_str and try_remove_str use the Dto type + // for current_value when the field has sub-fields, not the Reader type. + // Also tests that sub-field arms properly delegate to the sub-field DTO methods. + let input: crate::input::Configuration = parse_quote!( + #[tedge_config(multi)] + mapper: { + #[tedge_config(sub_fields = [C8y(C8y), Custom])] + ty: MapperType, + }, + ); + + let paths = configuration_paths_from(&input.groups, Mode::Reader); + let writers = generate_string_writers(&paths, &gen_ctx()); + + let generated_file: syn::File = syn::parse2(writers).unwrap(); + + // Find try_append_str method + let try_append_method = generated_file + .items + .iter() + .find_map(|item| { + if let syn::Item::Impl(impl_block) = item { + impl_block.items.iter().find_map(|impl_item| { + if let syn::ImplItem::Fn(method) = impl_item { + if method.sig.ident == "try_append_str" { + return Some(method.clone()); + } + } + None + }) + } else { + None + } + }) + .expect("Should have try_append_str method"); + + let expected: syn::File = parse_quote! { + pub fn try_append_str( + &mut self, + reader: &TEdgeConfigReader, + key: &WritableKey, + value: &str, + ) -> Result<(), WriteError> { + match key { + #[allow(clippy::useless_conversion)] + WritableKey::MapperTy(key0) => { + self.mapper.try_get_mut(key0.as_deref(), "mapper")?.ty = ::append( + self.mapper.try_get_mut(key0.as_deref(), "mapper")?.ty.take(), + value + .parse::() + .map(::from) + .map_err(|e| WriteError::ParseValue(Box::new(e)))?, + ); + } + WritableKey::MapperTyC8y(key0, sub_key) => { + let mapper = self.mapper.try_get_mut(key0.as_deref(), "mapper")?; + let mapper_ty = mapper.ty.get_or_insert_with(|| MapperTypeDto::C8y { c8y: C8yDto::default() }); + let mapper_ty_reader = reader + .mapper + .try_get(key0.as_deref())? + .ty + .or_none() + .map(::std::borrow::Cow::Borrowed) + .unwrap_or_else(|| { + ::std::borrow::Cow::Owned(MapperTypeReader::C8y { + c8y: C8yReader::from_dto(&C8yDto::default(), &TEdgeConfigLocation::default()), + }) + }); + if let MapperTypeDto::C8y { c8y } = mapper_ty { + if let MapperTypeReader::C8y { c8y: c8y_reader } = mapper_ty_reader.as_ref() { + c8y.try_append_str(c8y_reader, sub_key, value)?; + } else { + unreachable!("Shape of reader should match shape of DTO") + } + } else { + return Err(WriteError::SuperFieldWrongValue { + target: key.clone(), + parent: WritableKey::MapperTy(key0.clone()), + parent_expected: "c8y".to_string(), + parent_actual: mapper_ty.to_string(), + }); + } + } + }; + Ok(()) + } + }; + + pretty_assertions::assert_eq!( + prettyplease::unparse(&parse_quote!(#try_append_method)), + prettyplease::unparse(&expected) + ); + } + + #[test] + fn to_cow_str_handles_sub_field_keys() { + // Test that to_cow_str generates match arms for sub-field keys + let input: crate::input::Configuration = parse_quote!( + #[tedge_config(multi)] + mapper: { + #[tedge_config(sub_fields = [C8y(C8y), Custom])] + ty: MapperType, + }, + ); + let gen_ctx = gen_ctx(); + + let paths = configuration_paths_from(&input.groups, Mode::Reader); + let config_keys = + configuration_strings(paths.iter(), FilterRule::None, &gen_ctx.readable_key_name); + let impl_block = keys_enum_impl_block(&config_keys); + let actual = retain_fn(impl_block, "to_cow_str"); + + let expected = parse_quote! { + impl ReadableKey { + pub fn to_cow_str(&self) -> ::std::borrow::Cow<'static, str> { + match self { + Self::MapperTy(None) => ::std::borrow::Cow::Borrowed("mapper.ty"), + Self::MapperTy(Some(key0)) => { + ::std::borrow::Cow::Owned(format!("mapper.profiles.{key0}.ty")) + } + Self::MapperTyC8y(key0, sub_key) => { + ::std::borrow::Cow::Owned(format!("{}.{}.{}", { + vec![if let Some(profile) = key0 { + format!("mapper.profiles.{}", profile) + } else { + "mapper".to_string() + }].join(".") + }, "c8y", sub_key.to_cow_str())) + } + } + } + } + }; + + pretty_assertions::assert_eq!( + prettyplease::unparse(&parse_quote!(#actual)), + prettyplease::unparse(&expected) + ); + } + + fn keys_enum_impl_block(config_keys: &(Vec, Vec)) -> ItemImpl { + let generated = keys_enum(&parse_quote!(ReadableKey), config_keys, "DOC FRAGMENT"); + let generated_file: syn::File = syn::parse2(generated).unwrap(); + let mut impl_block = generated_file + .items + .into_iter() + .find_map(|item| { + if let syn::Item::Impl(r#impl @ ItemImpl { trait_: None, .. }) = item { + Some(r#impl) + } else { + None + } + }) + .expect("Should generate an impl block for ReadableKey"); + + // Remove doc comments from items + for item in &mut impl_block.items { + if let syn::ImplItem::Fn(f) = item { + f.attrs.retain(|f| *f.path().get_ident().unwrap() != "doc"); + } + } + + impl_block + } + + fn retain_fn(mut impl_block: ItemImpl, fn_name: &str) -> ItemImpl { + let ident = syn::Ident::new(fn_name, Span::call_site()); + let all_fn_names: Vec<_> = impl_block + .items + .iter() + .filter_map(|i| match i { + ImplItem::Fn(f) => Some(f.sig.ident.clone()), + _ => None, + }) + .collect(); + impl_block + .items + .retain(|i| matches!(i, ImplItem::Fn(f) if f.sig.ident == ident)); + assert!( + !impl_block.items.is_empty(), + "{ident:?} did not appear in methods. The valid method names are {all_fn_names:?}" + ); + impl_block + } + + fn is_doc_comment(attr: &syn::Attribute) -> bool { + match &attr.meta { + syn::Meta::NameValue(nv) => { + nv.path.get_ident().map(<_>::to_string) == Some("doc".into()) + } + _ => false, + } + } + + fn ctx() -> CodegenContext { + CodegenContext::default_tedge_config() + } + + fn gen_ctx() -> GenerationContext { + GenerationContext::from(&ctx()) } } diff --git a/crates/common/tedge_config_macros/impl/src/reader.rs b/crates/common/tedge_config_macros/impl/src/reader.rs index 7676690c260..b53a610df93 100644 --- a/crates/common/tedge_config_macros/impl/src/reader.rs +++ b/crates/common/tedge_config_macros/impl/src/reader.rs @@ -5,11 +5,14 @@ use std::iter::once; use heck::ToPascalCase; +use heck::ToSnakeCase as _; use itertools::Itertools; use proc_macro2::Span; use proc_macro2::TokenStream; +use quote::format_ident; use quote::quote; use quote::quote_spanned; +use quote::ToTokens as _; use syn::parse_quote; use syn::parse_quote_spanned; use syn::punctuated::Punctuated; @@ -18,20 +21,21 @@ use syn::Token; use crate::error::extract_type_from_result; use crate::input::ConfigurableField; +use crate::input::EnumEntry; use crate::input::FieldDefault; use crate::input::FieldOrGroup; use crate::namegen::IdGenerator; use crate::namegen::SequentialIdGenerator; use crate::optional_error::OptionalError; -use crate::prefixed_type_name; +use crate::CodegenContext; pub fn try_generate( - root_name: proc_macro2::Ident, + ctx: &CodegenContext, items: &[FieldOrGroup], doc_comment: &str, ) -> syn::Result { - let structs = generate_structs(&root_name, items, Vec::new(), doc_comment)?; - let conversions = generate_conversions(&root_name, items, vec![], items)?; + let structs = generate_structs(ctx, items, Vec::new(), doc_comment)?; + let conversions = generate_conversions(ctx, items, vec![], items, &ctx.dto_type_name)?; Ok(quote! { #structs #conversions @@ -39,46 +43,77 @@ pub fn try_generate( } fn generate_structs( - name: &proc_macro2::Ident, + ctx: &CodegenContext, items: &[FieldOrGroup], parents: Vec, doc_comment: &str, ) -> syn::Result { + let name = &ctx.reader_type_name; let mut idents = Vec::new(); let mut tys = Vec::::new(); let mut sub_readers = Vec::new(); let mut attrs: Vec> = Vec::new(); let mut lazy_readers = Vec::new(); let mut vis: Vec = Vec::new(); + let mut sub_field_enums = Vec::new(); for item in items { match item { FieldOrGroup::Field(field) => { - let ty = field.ty(); + let ty = field.reader_ty(); attrs.push(field.attrs().to_vec()); idents.push(field.ident()); if let Some((function, rw_field)) = field.reader_function() { let name = rw_field.lazy_reader_name(&parents); let parent_ty = rw_field.parent_name(&parents); tys.push(parse_quote_spanned!(ty.span()=> #name)); - let dto_ty: syn::Type = match extract_type_from_result(&rw_field.ty) { + let reader_ty: syn::Type = match extract_type_from_result(field.reader_ty()) { Some((ok, _err)) => parse_quote!(OptionalConfig<#ok>), None => { - let ty = &rw_field.ty; + let ty = &field.reader_ty(); parse_quote!(OptionalConfig<#ty>) } }; lazy_readers.push(( name, - &rw_field.ty, + field.reader_ty(), function, parent_ty, rw_field.ident.clone(), - dto_ty.clone(), + reader_ty.clone(), visibility(field), )); vis.push(parse_quote!()); } else if field.is_optional() { + if let Some(sub_fields) = field.sub_field_entries() { + let flatten = syn::parse_quote_spanned!(ty.span()=> #[serde(flatten)]); + attrs.last_mut().unwrap().push(flatten); + let variants = sub_fields.iter().map(|field| -> syn::Variant { + match field { + EnumEntry::NameOnly(name) => syn::parse_quote!(#name), + EnumEntry::NameAndFields(name, inner) => { + let field_name = syn::Ident::new( + &name.to_string().to_snake_case(), + name.span(), + ); + let inner = format_ident!("{inner}Reader"); + syn::parse_quote!(#name{ #field_name: #inner }) + } + } + }); + let field_ty = field.reader_ty(); + let tag_name = field.name(); + let ty: syn::ItemEnum = syn::parse_quote_spanned!(sub_fields.span()=> + #[derive(Debug, Clone, ::serde::Serialize, ::strum::Display, ::doku::Document)] + #[serde(rename_all = "snake_case")] + #[strum(serialize_all = "snake_case")] + #[serde(tag = #tag_name)] + pub enum #field_ty { + #(#variants),* + } + ); + sub_field_enums.push(ty.to_token_stream()); + } tys.push(parse_quote_spanned!(ty.span()=> OptionalConfig<#ty>)); vis.push(match field.reader().private { true => parse_quote!(), @@ -108,14 +143,15 @@ fn generate_structs( sub_readers.push(None); } FieldOrGroup::Multi(group) if !group.reader.skip => { - let sub_reader_name = prefixed_type_name(name, group); + let sub_ctx = ctx.suffixed_config(group); + let sub_reader_name = &sub_ctx.reader_type_name; idents.push(&group.ident); tys.push(parse_quote_spanned!(group.ident.span()=> MultiReader<#sub_reader_name>)); let mut parents = parents.clone(); parents.push(PathItem::Static(group.ident.clone(), item.name().into())); parents.push(PathItem::Dynamic(group.ident.span())); sub_readers.push(Some(generate_structs( - &sub_reader_name, + &sub_ctx, &group.contents, parents, "", @@ -127,13 +163,14 @@ fn generate_structs( }); } FieldOrGroup::Group(group) if !group.reader.skip => { - let sub_reader_name = prefixed_type_name(name, group); + let sub_ctx = ctx.suffixed_config(group); + let sub_reader_name = &sub_ctx.reader_type_name; idents.push(&group.ident); tys.push(parse_quote_spanned!(group.ident.span()=> #sub_reader_name)); let mut parents = parents.clone(); parents.push(PathItem::Static(group.ident.clone(), item.name().into())); sub_readers.push(Some(generate_structs( - &sub_reader_name, + &sub_ctx, &group.contents, parents, "", @@ -210,6 +247,7 @@ fn generate_structs( #lazy_reader_impls )* + #(#sub_field_enums)* #(#sub_readers)* }) } @@ -315,12 +353,15 @@ fn read_field(parents: &[PathItem]) -> impl Iterator + '_ { } fn reader_value_for_field<'a>( + ctx: &CodegenContext, field: &'a ConfigurableField, parents: &[PathItem], root_fields: &[FieldOrGroup], + root_dto_name: &syn::Ident, mut observed_keys: Vec<&'a Punctuated>, ) -> syn::Result { let name = field.ident(); + let readable_key_name = format_ident!("{}ReadableKey", ctx.enum_prefix); Ok(match field { ConfigurableField::ReadWrite(rw_field) => { let mut ident: String = parents @@ -338,18 +379,27 @@ fn reader_value_for_field<'a>( args }); let key: syn::Expr = if args.is_empty() { - parse_quote!(ReadableKey::#ident.to_cow_str()) + parse_quote!(#readable_key_name::#ident.to_cow_str()) } else { - parse_quote!(ReadableKey::#ident(#(#args.map(<_>::to_owned)),*).to_cow_str()) + parse_quote!(#readable_key_name::#ident(#(#args.map(<_>::to_owned)),*).to_cow_str()) }; let read_path = read_field(parents); let value = match &rw_field.default { - FieldDefault::None => quote_spanned! {rw_field.ident.span()=> - match &dto.#(#read_path).*.#name { - None => OptionalConfig::Empty(#key), - Some(value) => OptionalConfig::Present { value: value.clone(), key: #key }, + FieldDefault::None => { + let span = rw_field.ident.span(); + let ty = field.reader_ty(); + let value: syn::Expr = if field.sub_field_entries().is_some() { + parse_quote_spanned!(span=> #ty::from_dto_fragment(value, #key)) + } else { + parse_quote_spanned!(span=> value.clone()) + }; + quote_spanned! {span=> + match &dto.#(#read_path.)*#name { + None => OptionalConfig::Empty(#key), + Some(value) => OptionalConfig::Present { value: #value, key: #key }, + } } - }, + } FieldDefault::FromKey(key) if observed_keys.contains(&key) => { let string_paths = observed_keys .iter() @@ -376,9 +426,11 @@ fn reader_value_for_field<'a>( FieldDefault::FromKey(default_key) | FieldDefault::FromOptionalKey(default_key) => { observed_keys.push(default_key); let default = reader_value_for_field( + ctx, find_field(root_fields, default_key)?, &parents_for(default_key, parents, root_fields)?, root_fields, + root_dto_name, observed_keys, )?; @@ -400,32 +452,32 @@ fn reader_value_for_field<'a>( }; quote_spanned! {name.span()=> - match &dto.#(#read_path).*.#name { + match &dto.#(#read_path.)*#name { Some(value) => #value, None => #default, } } } FieldDefault::Function(function) => quote_spanned! {function.span()=> - match &dto.#(#read_path).*.#name { - None => TEdgeConfigDefault::::call(#function, dto, location), + match &dto.#(#read_path.)*#name { + None => TEdgeConfigDefault::<#root_dto_name, _>::call(#function, dto, location), Some(value) => value.clone(), } }, FieldDefault::Value(default) => quote_spanned! {name.span()=> - match &dto.#(#read_path).*.#name { + match &dto.#(#read_path.)*#name { None => #default.into(), Some(value) => value.clone(), } }, FieldDefault::Variable(default) => quote_spanned! {name.span()=> - match &dto.#(#read_path).*.#name { + match &dto.#(#read_path.)*#name { None => #default.into(), Some(value) => value.clone(), } }, FieldDefault::FromStr(default) => quote_spanned! {name.span()=> - match &dto.#(#read_path).*.#name { + match &dto.#(#read_path.)*#name { None => #default.parse().unwrap(), Some(value) => value.clone(), } @@ -529,11 +581,13 @@ fn parents_for( /// Generate the conversion methods from DTOs to Readers fn generate_conversions( - name: &proc_macro2::Ident, + ctx: &CodegenContext, items: &[FieldOrGroup], parents: Vec, root_fields: &[FieldOrGroup], + root_dto_name: &syn::Ident, ) -> syn::Result { + let name = &ctx.reader_type_name; let mut field_conversions = Vec::new(); let mut rest = Vec::new(); let mut id_gen = SequentialIdGenerator::default(); @@ -552,11 +606,19 @@ fn generate_conversions( match item { FieldOrGroup::Field(field) => { let name = field.ident(); - let value = reader_value_for_field(field, &parents, root_fields, Vec::new())?; + let value = reader_value_for_field( + ctx, + field, + &parents, + root_fields, + root_dto_name, + Vec::new(), + )?; field_conversions.push(quote_spanned!(name.span()=> #name: #value)); } FieldOrGroup::Group(group) if !group.reader.skip => { - let sub_reader_name = prefixed_type_name(name, group); + let sub_ctx = ctx.suffixed_config(group); + let sub_reader_name = &sub_ctx.reader_type_name; let name = &group.ident; let mut parents = parents.clone(); @@ -575,12 +637,18 @@ fn generate_conversions( field_conversions.push( quote_spanned!(name.span()=> #name: #sub_reader_name::from_dto(dto, location, #(#extra_call_args),*)), ); - let sub_conversions = - generate_conversions(&sub_reader_name, &group.contents, parents, root_fields)?; + let sub_conversions = generate_conversions( + &sub_ctx, + &group.contents, + parents, + root_fields, + root_dto_name, + )?; rest.push(sub_conversions); } FieldOrGroup::Multi(group) if !group.reader.skip => { - let sub_reader_name = prefixed_type_name(name, group); + let sub_ctx = ctx.suffixed_config(group); + let sub_reader_name = &sub_ctx.reader_type_name; let name = &group.ident; let new_arg = PathItem::Dynamic(group.ident.span()); @@ -611,8 +679,13 @@ fn generate_conversions( let new_arg2 = extra_call_args.last().unwrap().clone(); field_conversions.push(quote_spanned!(name.span()=> #name: dto.#(#read_path).*.map_keys(|#new_arg2| #sub_reader_name::from_dto(dto, location, #(#extra_call_args),*), #parent_key))); parents.push(new_arg); - let sub_conversions = - generate_conversions(&sub_reader_name, &group.contents, parents, root_fields)?; + let sub_conversions = generate_conversions( + &sub_ctx, + &group.contents, + parents, + root_fields, + root_dto_name, + )?; rest.push(sub_conversions); } FieldOrGroup::Group(_) | FieldOrGroup::Multi(_) => { @@ -626,7 +699,7 @@ fn generate_conversions( #[allow(unused, clippy::clone_on_copy, clippy::useless_conversion)] #[automatically_derived] /// Converts the provided [TEdgeConfigDto] into a reader - pub(crate) fn from_dto(dto: &TEdgeConfigDto, location: &TEdgeConfigLocation, #(#extra_args,)*) -> Self { + pub(crate) fn from_dto(dto: &#root_dto_name, location: &TEdgeConfigLocation, #(#extra_args,)*) -> Self { Self { #(#field_conversions),* } @@ -640,8 +713,10 @@ fn generate_conversions( #[cfg(test)] mod tests { use super::*; + use prettyplease::unparse; use syn::parse_quote; use syn::Item; + use syn::ItemEnum; use syn::ItemImpl; use syn::ItemStruct; @@ -661,12 +736,14 @@ mod tests { }; let http = m.contents[1].field().unwrap(); let actual = reader_value_for_field( + &ctx(), http, &[ PathItem::Static(parse_quote!(c8y), "c8y".into()), PathItem::Dynamic(Span::call_site()), ], &input.groups, + &parse_quote!(TEdgeConfigDto), vec![], ) .unwrap(); @@ -721,9 +798,11 @@ mod tests { }; let az_url = g.contents[0].field().unwrap(); let error = reader_value_for_field( + &ctx(), az_url, &[PathItem::Static(parse_quote!(az), "az".into())], &input.groups, + &parse_quote!(TEdgeConfigDto), vec![], ) .unwrap_err(); @@ -742,10 +821,11 @@ mod tests { }, ); let actual = generate_conversions( - &parse_quote!(TEdgeConfigReader), + &ctx(), &input.groups, Vec::new(), &input.groups, + &parse_quote!(TEdgeConfigDto), ) .unwrap(); let file: syn::File = syn::parse2(actual).unwrap(); @@ -794,13 +874,7 @@ mod tests { id: String, }, ); - let actual = generate_structs( - &parse_quote!(TEdgeConfigReader), - &input.groups, - Vec::new(), - "", - ) - .unwrap(); + let actual = generate_structs(&ctx(), &input.groups, Vec::new(), "").unwrap(); let file: syn::File = syn::parse2(actual).unwrap(); let expected = parse_quote! { @@ -841,13 +915,7 @@ mod tests { id: String, }, ); - let actual = generate_structs( - &parse_quote!(TEdgeConfigReader), - &input.groups, - Vec::new(), - "", - ) - .unwrap(); + let actual = generate_structs(&ctx(), &input.groups, Vec::new(), "").unwrap(); let mut file: syn::File = syn::parse2(actual).unwrap(); let target: syn::Type = parse_quote!(TEdgeConfigReaderDevice); file.items @@ -880,13 +948,7 @@ mod tests { optional: String, }, ); - let actual = generate_structs( - &parse_quote!(TEdgeConfigReader), - &input.groups, - Vec::new(), - "", - ) - .unwrap(); + let actual = generate_structs(&ctx(), &input.groups, Vec::new(), "").unwrap(); let mut file: syn::File = syn::parse2(actual).unwrap(); file.items.retain(|s| matches!(s, Item::Struct(ItemStruct { ident, ..}) if ident == "TEdgeConfigReaderTest")); @@ -907,6 +969,63 @@ mod tests { ) } + #[test] + fn sub_field_enum_adopts_rename_from_original_field() { + let input: crate::input::Configuration = parse_quote!( + mapper: { + #[tedge_config(rename = "type")] + #[tedge_config(sub_fields = [C8y(C8y), Aws(Aws), Custom])] + ty: MapperType, + } + ); + + let generated = generate_structs(&ctx(), &input.groups, Vec::new(), "").unwrap(); + let mut actual: syn::File = syn::parse2(generated).unwrap(); + actual.items.retain( + |s| matches!(s, Item::Enum(ItemEnum { ident, ..}) if ident == "MapperTypeReader"), + ); + + let expected = parse_quote! { + #[derive(Debug, Clone, ::serde::Serialize, ::strum::Display, ::doku::Document)] + #[serde(rename_all = "snake_case")] + #[strum(serialize_all = "snake_case")] + #[serde(tag = "type")] + pub enum MapperTypeReader { + C8y { c8y: C8yReader }, + Aws { aws: AwsReader }, + Custom, + } + }; + + pretty_assertions::assert_eq!(unparse(&actual), unparse(&expected)); + } + + #[test] + fn sub_fields_use_the_reader_variant_in_struct() { + let input: crate::input::Configuration = parse_quote!( + mapper: { + #[tedge_config(rename = "type")] + #[tedge_config(sub_fields = [C8y(C8y), Aws(Aws), Az(Az), Custom])] + ty: MapperType, + }, + ); + + let expected = parse_quote! { + #[derive(::doku::Document, ::serde::Serialize, Debug, Clone)] + #[non_exhaustive] + pub struct TEdgeConfigReaderMapper { + #[serde(rename = "type")] + #[serde(flatten)] + pub ty: OptionalConfig, + } + }; + + let actual = generate_structs(&ctx(), &input.groups, Vec::new(), "").unwrap(); + let mut file: syn::File = syn::parse2(actual).unwrap(); + file.items.retain(|s| matches!(s, Item::Struct(ItemStruct { ident, ..}) if ident == "TEdgeConfigReaderMapper")); + pretty_assertions::assert_eq!(unparse(&file), unparse(&expected)) + } + #[test] fn default_values_do_stuff() { let input: crate::input::Configuration = parse_quote!( @@ -917,10 +1036,11 @@ mod tests { }, ); let actual = generate_conversions( - &parse_quote!(TEdgeConfigReader), + &ctx(), &input.groups, Vec::new(), &input.groups, + &parse_quote!(TEdgeConfigDto), ) .unwrap(); let file: syn::File = syn::parse2(actual).unwrap(); @@ -976,10 +1096,7 @@ mod tests { } }; - pretty_assertions::assert_eq!( - prettyplease::unparse(&file), - prettyplease::unparse(&expected) - ) + pretty_assertions::assert_eq!(unparse(&file), unparse(&expected)) } #[test] @@ -998,10 +1115,11 @@ mod tests { }, ); let actual = generate_conversions( - &parse_quote!(TEdgeConfigReader), + &ctx(), &input.groups, Vec::new(), &input.groups, + &parse_quote!(TEdgeConfigDto), ) .unwrap(); let mut file: syn::File = syn::parse2(actual).unwrap(); @@ -1055,4 +1173,145 @@ mod tests { prettyplease::unparse(&expected) ) } + + #[test] + fn sub_fields_call_from_dto_fragment_on_inner_value() { + // Regression test: ensure from_dto_fragment is called on the unwrapped value, + // not on Option + let input: crate::input::Configuration = parse_quote!( + #[tedge_config(multi)] + mapper: { + #[tedge_config(sub_fields = [C8y(C8y), Custom])] + ty: MapperType, + }, + ); + + let actual = generate_conversions( + &ctx(), + &input.groups, + Vec::new(), + &input.groups, + &parse_quote!(TEdgeConfigDto), + ) + .unwrap(); + let mut file: syn::File = syn::parse2(actual).unwrap(); + let target: syn::Type = parse_quote!(TEdgeConfigReaderMapper); + file.items + .retain(|i| matches!(i, Item::Impl(ItemImpl { self_ty, ..}) if **self_ty == target)); + + let expected = parse_quote! { + impl TEdgeConfigReaderMapper { + #[allow(unused, clippy::clone_on_copy, clippy::useless_conversion)] + #[automatically_derived] + /// Converts the provided [TEdgeConfigDto] into a reader + pub(crate) fn from_dto( + dto: &TEdgeConfigDto, + location: &TEdgeConfigLocation, + key0: Option<&str>, + ) -> Self { + Self { + ty: match &dto.mapper.try_get(key0, "mapper").unwrap().ty { + None => OptionalConfig::Empty(ReadableKey::MapperTy(key0.map(<_>::to_owned)).to_cow_str()), + Some(value) => OptionalConfig::Present { + value: MapperTypeReader::from_dto_fragment(value, ReadableKey::MapperTy(key0.map(<_>::to_owned)).to_cow_str()), + key: ReadableKey::MapperTy(key0.map(<_>::to_owned)).to_cow_str(), + }, + }, + } + } + } + }; + + pretty_assertions::assert_eq!(unparse(&file), unparse(&expected)) + } + + #[test] + fn top_level_fields_on_sub_configs_are_handled_correctly() { + let input: crate::input::SubConfigInput = parse_quote! { + C8y { + enable_feature: bool, + } + }; + let config: crate::input::Configuration = input.config.try_into().unwrap(); + let ctx = CodegenContext::for_sub_config(parse_quote!(C8y)); + + let actual = generate_conversions( + &ctx, + &config.groups, + Vec::new(), + &config.groups, + &parse_quote!(TEdgeConfigDto), + ) + .unwrap(); + let actual = syn::parse2(actual).unwrap(); + let expected: syn::File = parse_quote! { + impl C8yReader { + #[allow(unused,clippy::clone_on_copy,clippy::useless_conversion)] + #[automatically_derived] + #[doc = r" Converts the provided [TEdgeConfigDto] into a reader"] + pub(crate)fn from_dto(dto: &TEdgeConfigDto, location: &TEdgeConfigLocation,) -> Self { + Self { + enable_feature: match &dto.enable_feature { + None => OptionalConfig::Empty(C8yReadableKey::EnableFeature.to_cow_str()), + Some(value) => OptionalConfig::Present { + value: value.clone(), + key: C8yReadableKey::EnableFeature.to_cow_str() + }, + } + } + } + } + }; + + pretty_assertions::assert_eq!( + prettyplease::unparse(&actual), + prettyplease::unparse(&expected) + ) + } + + #[test] + fn default_functions_on_sub_configs_use_correct_dto_type() { + let input: crate::input::SubConfigInput = parse_quote! { + C8y { + #[tedge_config(default(function = "default_enable_feature"))] + enable_feature: bool, + } + }; + let config: crate::input::Configuration = input.config.try_into().unwrap(); + let ctx = CodegenContext::for_sub_config(parse_quote!(C8y)); + + let actual = generate_conversions( + &ctx, + &config.groups, + Vec::new(), + &config.groups, + &parse_quote!(C8yDto), + ) + .unwrap(); + let actual = syn::parse2(actual).unwrap(); + let expected: syn::File = parse_quote! { + impl C8yReader { + #[allow(unused,clippy::clone_on_copy,clippy::useless_conversion)] + #[automatically_derived] + #[doc = r" Converts the provided [TEdgeConfigDto] into a reader"] + pub(crate)fn from_dto(dto: &C8yDto, location: &TEdgeConfigLocation,) -> Self { + Self { + enable_feature: match &dto.enable_feature { + None => TEdgeConfigDefault::::call(default_enable_feature, dto, location), + Some(value) => value.clone(), + } + } + } + } + }; + + pretty_assertions::assert_eq!( + prettyplease::unparse(&actual), + prettyplease::unparse(&expected) + ) + } + + fn ctx() -> CodegenContext { + CodegenContext::default_tedge_config() + } } diff --git a/crates/common/tedge_config_macros/macro/src/lib.rs b/crates/common/tedge_config_macros/macro/src/lib.rs index e74340ffcdc..dd9400391ab 100644 --- a/crates/common/tedge_config_macros/macro/src/lib.rs +++ b/crates/common/tedge_config_macros/macro/src/lib.rs @@ -13,3 +13,13 @@ pub fn define_tedge_config(item: TokenStream) -> TokenStream { Err(err) => TokenStream::from(err.to_compile_error()), } } + +#[proc_macro] +pub fn define_sub_config(item: TokenStream) -> TokenStream { + let item = parse_macro_input!(item as proc_macro2::TokenStream); + + match tedge_config_macros_impl::generate_sub_configuration(item) { + Ok(tokens) => tokens.into(), + Err(err) => TokenStream::from(err.to_compile_error()), + } +} diff --git a/crates/common/tedge_config_macros/src/lib.rs b/crates/common/tedge_config_macros/src/lib.rs index 9fff212874e..18e40963fdc 100644 --- a/crates/common/tedge_config_macros/src/lib.rs +++ b/crates/common/tedge_config_macros/src/lib.rs @@ -1,3 +1,4 @@ +pub use tedge_config_macros_macro::define_sub_config; #[doc(inline)] #[doc = include_str!("define_tedge_config_docs.md")] pub use tedge_config_macros_macro::define_tedge_config;