Skip to content
Draft
1 change: 1 addition & 0 deletions crates/common/tedge_config_macros/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
101 changes: 101 additions & 0 deletions crates/common/tedge_config_macros/examples/generic_mapper.rs
Original file line number Diff line number Diff line change
@@ -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<String>, C8yReadableKey)
// - WritableKey::MapperTyC8y(Option<String>, C8yWritableKey)
// - DtoKey::MapperTyC8y(Option<String>, 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<T> 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<Self::Item>, new_value: Self::Item) -> Option<Self::Item>;

fn remove(current_value: Option<Self::Item>, remove_value: Self::Item) -> Option<Self::Item>;
}

impl<T> AppendRemoveItem for T {
type Item = T;

fn append(_current_value: Option<Self::Item>, _new_value: Self::Item) -> Option<Self::Item> {
unimplemented!()
}

fn remove(_current_value: Option<Self::Item>, _remove_value: Self::Item) -> Option<Self::Item> {
unimplemented!()
}
}

define_tedge_config! {
device: {
#[tedge_config(rename = "type")]
ty: String,
},
#[tedge_config(multi)]
mapper: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't fully get what's the plan here.

So a mapper can be given a specific type, but what if want to run 2 mappers on my device.

  • Is this setting about a mapper or the mapper?
  • How this works in combination with profiles?

Copy link
Contributor Author

@jarhodes314 jarhodes314 Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've now attempted to clarify this in the PR description. It's about a mapper (#[tedge_config(multi)] is what dictates that, though now we've definitely called the feature in question "profiles", we probably ought to change the attribute to say something more like #[tedge_config(multi_profile)] instead.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If my understanding is correct we will then be able to configure:

A main c8y mapper:

$ tedge config set mapper.type c8y --profile default
$ tedge config set mapper.c8y.url "my.c8y.com" --profile default 

as well as a second c8y mapper:

$ tedge config set mapper.type c8y --profile fallback
$ tedge config set mapper.c8y.url "fallback.c8y.com" --profile fallback 

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If my understanding is correct

Yes, that is broadly correct, however instead of

$ tedge config set mapper.c8y.url "my.c8y.com" --profile default 

it would likely be:

$ tedge config set mapper.url "my.c8y.com" --profile default 

as the url is a common configuration to all mappers. The mapper.c8y. would be restricted just to the Cumulocity-specific configurations

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even though mapper.url would be the new "recommended" config, the old c8y.url setting would still be maintained for backward compatibility, right?

#[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);
}
147 changes: 125 additions & 22 deletions crates/common/tedge_config_macros/impl/src/dto.rs
Original file line number Diff line number Diff line change
@@ -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::<syn::Type>::new();
let mut sub_dtos = Vec::new();
let mut preserved_attrs: Vec<Vec<&syn::Attribute>> = 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>));
Expand All @@ -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)]
Expand All @@ -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)]
Expand Down Expand Up @@ -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)*
}
}
Expand All @@ -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::*;
Expand Down Expand Up @@ -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<MapperTypeDto>,
}
};

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()
}

Expand All @@ -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()
}
}
Loading
Loading