From 63114fd863cfceb10c5aef8a5a85ef8abeb27dd0 Mon Sep 17 00:00:00 2001 From: Roman Puls Date: Sun, 15 Mar 2026 00:37:04 +0100 Subject: [PATCH 1/3] Add #[cbor(text_keys)] for automatic string-keyed map encoding Build on top of the #[s("...")] string index support to add a struct/enum-level #[cbor(text_keys)] attribute that automatically uses field/variant names as string keys, removing the need to annotate every field individually. Features: - #[cbor(text_keys)] on structs: all fields auto-keyed by name - #[cbor(text_keys)] on enums: all variants auto-keyed by name - #[cbor(key = "...")] for custom wire key names (per field/variant) - text_keys implies map encoding automatically - Unit enum variants encode as naked strings ("Reset") - Non-unit enum variants encode as map(1) {"Name": {fields}} Also fixes: - Add missing `s` attribute to CborLen proc_macro_derive Validation: - text_keys on tuple/unit structs: compile error - text_keys + transparent / index_only: compile error - #[cbor(key = "...")] without text_keys: compile error - Duplicate key names: compile error Tests: 40 host tests + 36 STM32H563ZI (Cortex-M33) no_std tests Signed-off-by: Roman Puls --- minicbor-derive/src/attrs.rs | 58 ++- minicbor-derive/src/cbor_len.rs | 98 ++-- minicbor-derive/src/decode.rs | 62 ++- minicbor-derive/src/encode.rs | 201 ++++---- minicbor-derive/src/fields.rs | 22 +- minicbor-derive/src/lib.rs | 2 +- minicbor-derive/src/variants.rs | 20 +- minicbor-tests-nostd/.cargo/config.toml | 5 + minicbor-tests-nostd/Cargo.toml | 18 + minicbor-tests-nostd/memory.x | 5 + minicbor-tests-nostd/src/main.rs | 268 +++++++++++ minicbor-tests/src/lib.rs | 1 + minicbor-tests/src/text_keys.rs | 603 ++++++++++++++++++++++++ 13 files changed, 1230 insertions(+), 133 deletions(-) create mode 100644 minicbor-tests-nostd/.cargo/config.toml create mode 100644 minicbor-tests-nostd/Cargo.toml create mode 100644 minicbor-tests-nostd/memory.x create mode 100644 minicbor-tests-nostd/src/main.rs create mode 100644 minicbor-tests/src/text_keys.rs diff --git a/minicbor-derive/src/attrs.rs b/minicbor-derive/src/attrs.rs index 08cea13..435509a 100644 --- a/minicbor-derive/src/attrs.rs +++ b/minicbor-derive/src/attrs.rs @@ -47,7 +47,9 @@ pub enum Kind { Skip, SkipIf, Flat, - Default + Default, + TextKeys, + Key } #[derive(Debug, Clone)] @@ -68,7 +70,9 @@ enum Value { Skip(proc_macro2::Span), SkipIf(Option, proc_macro2::Span), Flat(proc_macro2::Span), - Default(proc_macro2::Span) + Default(proc_macro2::Span), + TextKeys(proc_macro2::Span), + Key(String, proc_macro2::Span) } #[derive(Debug, Copy, Clone)] @@ -138,6 +142,14 @@ impl Attributes { { return Err(syn::Error::new(*s, "flat enum does not support map encoding")) } + if let Some(Value::TextKeys(s)) = this.get(Kind::TextKeys) { + if this.contains_key(Kind::Transparent) { + return Err(syn::Error::new(*s, "`text_keys` and `transparent` are mutually exclusive")) + } + if this.contains_key(Kind::IndexOnly) { + return Err(syn::Error::new(*s, "`text_keys` and `index_only` are mutually exclusive")) + } + } // `skip_if` triggers the creation of a custom codec where `encode` and `decode` // correspond to the default routines, `is_nil` is defined via `skip_if`'s // predicate and `nil` points to an internal helper that matches the signature @@ -385,6 +397,11 @@ impl Attributes { attrs.try_insert(Kind::Skip, Value::Skip(meta.path.span()))? } else if meta.path.is_ident("flat") { attrs.try_insert(Kind::Flat, Value::Flat(meta.path.span()))? + } else if meta.path.is_ident("text_keys") { + attrs.try_insert(Kind::TextKeys, Value::TextKeys(meta.path.span()))? + } else if meta.path.is_ident("key") { + let s: LitStr = meta.value()?.parse()?; + attrs.try_insert(Kind::Key, Value::Key(s.value(), meta.path.span()))? } else if meta.path.is_ident("default") { attrs.try_insert(Kind::Default, Value::Default(meta.path.span()))? } else { @@ -456,6 +473,23 @@ impl Attributes { self.contains_key(Kind::Default) } + pub fn text_keys(&self) -> bool { + self.contains_key(Kind::TextKeys) + } + + /// Effective encoding: text_keys implies Map. + pub fn effective_encoding(&self) -> Encoding { + if self.text_keys() { + Encoding::Map + } else { + self.encoding().unwrap_or_default() + } + } + + pub fn key(&self) -> Option<&str> { + self.get(Kind::Key).and_then(|v| v.key()) + } + fn contains_key(&self, k: Kind) -> bool { self.attrs.contains_key(&k) } @@ -479,6 +513,7 @@ impl Attributes { | Kind::Transparent | Kind::ContextBound | Kind::Tag + | Kind::TextKeys => {} | Kind::Borrow | Kind::TypeParam @@ -493,6 +528,7 @@ impl Attributes { | Kind::SkipIf | Kind::Flat | Kind::Default + | Kind::Key => { let msg = format!("attribute is not supported on {}-level", self.level); return Err(syn::Error::new(val.span(), msg)) @@ -511,12 +547,14 @@ impl Attributes { | Kind::Skip | Kind::SkipIf | Kind::Default + | Kind::Key => {} | Kind::Encoding | Kind::IndexOnly | Kind::Transparent | Kind::ContextBound | Kind::Flat + | Kind::TextKeys => { let msg = format!("attribute is not supported on {}-level", self.level); return Err(syn::Error::new(val.span(), msg)) @@ -528,6 +566,7 @@ impl Attributes { | Kind::ContextBound | Kind::Tag | Kind::Flat + | Kind::TextKeys => {} | Kind::Borrow | Kind::TypeParam @@ -541,6 +580,7 @@ impl Attributes { | Kind::Skip | Kind::SkipIf | Kind::Default + | Kind::Key => { let msg = format!("attribute is not supported on {}-level", self.level); return Err(syn::Error::new(val.span(), msg)) @@ -550,6 +590,7 @@ impl Attributes { | Kind::Encoding | Kind::Index | Kind::Tag + | Kind::Key => {} | Kind::Borrow | Kind::TypeParam @@ -565,6 +606,7 @@ impl Attributes { | Kind::SkipIf | Kind::Flat | Kind::Default + | Kind::TextKeys => { let msg = format!("attribute is not supported on {}-level", self.level); return Err(syn::Error::new(val.span(), msg)) @@ -743,7 +785,9 @@ impl Value { Value::Skip(s) => *s, Value::SkipIf(_, s) => *s, Value::Flat(s) => *s, - Value::Default(s) => *s + Value::Default(s) => *s, + Value::TextKeys(s) => *s, + Value::Key(_, s) => *s } } @@ -810,6 +854,14 @@ impl Value { None } } + + fn key(&self) -> Option<&str> { + if let Value::Key(s, _) = self { + Some(s) + } else { + None + } + } } fn parse_i64_arg(a: &syn::Attribute) -> syn::Result { diff --git a/minicbor-derive/src/cbor_len.rs b/minicbor-derive/src/cbor_len.rs index a5dab30..9f2111e 100644 --- a/minicbor-derive/src/cbor_len.rs +++ b/minicbor-derive/src/cbor_len.rs @@ -61,7 +61,7 @@ fn on_struct(inp: &mut syn::DeriveInput) -> syn::Result for #name #typ_generics #where_clause { @@ -85,7 +85,7 @@ fn on_enum(inp: &mut syn::DeriveInput) -> syn::Result let name = &inp.ident; let enum_attrs = Attributes::try_from_iter(Level::Enum, inp.attrs.iter())?; - let enum_encoding = enum_attrs.encoding().unwrap_or_default(); + let enum_encoding = enum_attrs.effective_encoding(); let index_only = enum_attrs.index_only(); let flat = enum_attrs.flat(); let variants = Variants::try_from(name.span(), data.variants.iter(), &enum_attrs)?; @@ -101,47 +101,70 @@ fn on_enum(inp: &mut syn::DeriveInput) -> syn::Result let con = &var.ident; let encoding = attrs.encoding().unwrap_or(enum_encoding); let tag = on_tag(attrs); - let row = match &var.fields { - syn::Fields::Unit => if index_only { - quote! { + let row = if enum_attrs.text_keys() { + // text_keys enum: unit → just string len, non-unit → map(1) header + string key + fields + match &var.fields { + syn::Fields::Unit => quote! { #name::#con => { #idx.cbor_len(__ctx777) } + }, + syn::Fields::Named(_) => { + let steps = on_fields(&fields, false, encoding)?; + let idents = fields.fields().idents(); + // 1 byte for map(1) header + key str + tag + fields + quote! { + #name::#con{#(#idents,)* ..} => { 1 + #idx.cbor_len(__ctx777) + #tag + #(#steps)* } + } } - } else if flat { - quote! { - #name::#con => { 1 + #idx.cbor_len(__ctx777) } - } - } else { - quote! { - #name::#con => { 1 + #idx.cbor_len(__ctx777) + #tag + 1 } + syn::Fields::Unnamed(_) => { + let steps = on_fields(&fields, false, encoding)?; + let idents = fields.match_idents(); + quote! { + #name::#con(#(#idents,)*) => { 1 + #idx.cbor_len(__ctx777) + #tag + #(#steps)* } + } } } - syn::Fields::Named(f) if index_only => { - return Err(syn::Error::new(f.span(), "index_only enums must not have fields")) - } - syn::Fields::Named(_) => { - let steps = on_fields(&fields, false, encoding)?; - let idents = fields.fields().idents(); - match encoding { - Encoding::Map => quote! { - #name::#con{#(#idents,)* ..} => { 1 + #idx.cbor_len(__ctx777) + #tag + #(#steps)* } - }, - Encoding::Array if flat => quote! { - #name::#con{#(#idents,)* ..} => { #(#steps)* + #idx.cbor_len(__ctx777) } - }, - Encoding::Array => quote! { - #name::#con{#(#idents,)* ..} => { #(#steps)* + #tag + 1 + #idx.cbor_len(__ctx777) } + } else { + match &var.fields { + syn::Fields::Unit => if index_only { + quote! { + #name::#con => { #idx.cbor_len(__ctx777) } + } + } else if flat { + quote! { + #name::#con => { 1 + #idx.cbor_len(__ctx777) } + } + } else { + quote! { + #name::#con => { 1 + #idx.cbor_len(__ctx777) + #tag + 1 } } } - } - syn::Fields::Unnamed(f) if index_only => { - return Err(syn::Error::new(f.span(), "index_only enums must not have fields")) - } - syn::Fields::Unnamed(_) => { - let steps = on_fields(&fields, false, encoding)?; - let idents = fields.match_idents(); - match encoding { - Encoding::Map => quote! { - #name::#con(#(#idents,)*) => { 1 + #idx.cbor_len(__ctx777) + #tag + #(#steps)* } + syn::Fields::Named(f) if index_only => { + return Err(syn::Error::new(f.span(), "index_only enums must not have fields")) + } + syn::Fields::Named(_) => { + let steps = on_fields(&fields, false, encoding)?; + let idents = fields.fields().idents(); + match encoding { + Encoding::Map => quote! { + #name::#con{#(#idents,)* ..} => { 1 + #idx.cbor_len(__ctx777) + #tag + #(#steps)* } + }, + Encoding::Array if flat => quote! { + #name::#con{#(#idents,)* ..} => { #(#steps)* + #idx.cbor_len(__ctx777) } + }, + Encoding::Array => quote! { + #name::#con{#(#idents,)* ..} => { #(#steps)* + #tag + 1 + #idx.cbor_len(__ctx777) } + } + } + } + syn::Fields::Unnamed(f) if index_only => { + return Err(syn::Error::new(f.span(), "index_only enums must not have fields")) + } + syn::Fields::Unnamed(_) => { + let steps = on_fields(&fields, false, encoding)?; + let idents = fields.match_idents(); + match encoding { + Encoding::Map => quote! { + #name::#con(#(#idents,)*) => { 1 + #idx.cbor_len(__ctx777) + #tag + #(#steps)* } }, Encoding::Array if flat => quote! { #name::#con(#(#idents,)*) => { #(#steps)* + #idx.cbor_len(__ctx777) } @@ -149,6 +172,7 @@ fn on_enum(inp: &mut syn::DeriveInput) -> syn::Result Encoding::Array => quote! { #name::#con(#(#idents,)*) => { #(#steps)* + #tag + 1 + #idx.cbor_len(__ctx777) } } + } } } }; diff --git a/minicbor-derive/src/decode.rs b/minicbor-derive/src/decode.rs index 8af6e39..ef65996 100644 --- a/minicbor-derive/src/decode.rs +++ b/minicbor-derive/src/decode.rs @@ -86,7 +86,7 @@ fn on_struct(inp: &mut syn::DeriveInput) -> syn::Result syn::Result let name = &inp.ident; let enum_attrs = Attributes::try_from_iter(Level::Enum, inp.attrs.iter())?; - let enum_encoding = enum_attrs.encoding().unwrap_or_default(); + let enum_encoding = enum_attrs.effective_encoding(); let index_only = enum_attrs.index_only(); let flat = enum_attrs.flat(); let variants = Variants::try_from(name.span(), data.variants.iter(), &enum_attrs)?; @@ -161,6 +161,7 @@ fn on_enum(inp: &mut syn::DeriveInput) -> syn::Result let mut defaults = BTreeSet::new(); let mut default_attrs = Vec::new(); let mut lifetime = gen_lifetime(); + let text_keys = enum_attrs.text_keys(); let mut num_rows = Vec::new(); let mut str_rows = Vec::new(); for ((var, idx), attrs) in data.variants.iter().zip(variants.indices.iter()).zip(&variants.attrs) { @@ -169,7 +170,10 @@ fn on_enum(inp: &mut syn::DeriveInput) -> syn::Result let con = &var.ident; let tag = decode_tag(attrs); let row = if let syn::Fields::Unit = var.fields { - if index_only | flat { + if text_keys { + // text_keys: unit variant matched by string, no payload to skip + quote!(#idx => Ok(#name::#con),) + } else if index_only | flat { quote!(#idx => Ok(#name::#con),) } else { quote!(#idx => { @@ -265,7 +269,12 @@ fn on_enum(inp: &mut syn::DeriveInput) -> syn::Result let (_, typ_generics, where_clause) = inp.generics.split_for_impl(); - let check = if index_only { + let check = if text_keys { + quote! { + let __p777 = __d777.position(); + let __p778 = __d777.position(); + } + } else if index_only { quote! { let __p778 = __d777.position(); } @@ -292,7 +301,50 @@ fn on_enum(inp: &mut syn::DeriveInput) -> syn::Result let tag = decode_tag(&enum_attrs); - let match_fragement = if str_rows.is_empty() { + let match_fragement = if text_keys { + // text_keys enum: String → unit variant, Map(1) → non-unit variant + quote! { + match __d777.datatype()? { + minicbor::data::Type::String => { + match __d777.str()? { + #(#str_rows)* + s => Err(minicbor::__minicbor_cfg! { + 'std { + minicbor::decode::Error::unknown_variant_str(s.to_string()).at(__p778) + } + 'alloc { + minicbor::decode::Error::unknown_variant_str(s.to_string()).at(__p778) + } + 'otherwise { + minicbor::decode::Error::unknown_variant_str().at(__p778) + } + }) + } + } + minicbor::data::Type::Map => { + let __len777 = __d777.map()?.ok_or_else(|| minicbor::decode::Error::message("indefinite map not supported for text_keys enum").at(__p777))?; + if __len777 != 1 { + return Err(minicbor::decode::Error::message("expected map with 1 entry for text_keys enum variant").at(__p777)) + } + match __d777.str()? { + #(#str_rows)* + s => Err(minicbor::__minicbor_cfg! { + 'std { + minicbor::decode::Error::unknown_variant_str(s.to_string()).at(__p778) + } + 'alloc { + minicbor::decode::Error::unknown_variant_str(s.to_string()).at(__p778) + } + 'otherwise { + minicbor::decode::Error::unknown_variant_str().at(__p778) + } + }) + } + } + _ => Err(minicbor::decode::Error::message("expected string or map for text_keys enum").at(__p777)) + } + } + } else if str_rows.is_empty() { quote! { match __d777.i64()? { #(#num_rows)* diff --git a/minicbor-derive/src/encode.rs b/minicbor-derive/src/encode.rs index 6909199..0b965fd 100644 --- a/minicbor-derive/src/encode.rs +++ b/minicbor-derive/src/encode.rs @@ -34,7 +34,7 @@ fn on_struct(inp: &mut syn::DeriveInput) -> syn::Result syn::Result let name = &inp.ident; let enum_attrs = Attributes::try_from_iter(Level::Enum, inp.attrs.iter())?; - let enum_encoding = enum_attrs.encoding().unwrap_or_default(); + let enum_encoding = enum_attrs.effective_encoding(); let index_only = enum_attrs.index_only(); let flat = enum_attrs.flat(); let variants = Variants::try_from(name.span(), data.variants.iter(), &enum_attrs)?; @@ -94,6 +94,8 @@ fn on_enum(inp: &mut syn::DeriveInput) -> syn::Result let mut field_attrs = Vec::new(); let mut rows = Vec::new(); + let text_keys = enum_attrs.text_keys(); + for ((var, idx), attrs) in data.variants.iter().zip(variants.indices.iter()).zip(&variants.attrs) { let fields = Fields::try_from(var.ident.span(), var.fields.iter(), &[attrs, &enum_attrs])?; blacklist.merge(&fields, &inp.generics, Blacklist::full(Mode::Encode, &fields, &inp.generics)); @@ -101,102 +103,139 @@ fn on_enum(inp: &mut syn::DeriveInput) -> syn::Result let encoding = attrs.encoding().unwrap_or(enum_encoding); let tag = encode_tag(attrs); let fun = idx.to_method(); - let row = match &var.fields { - syn::Fields::Unit => match encoding { - Encoding::Array | Encoding::Map if index_only => quote! { - #name::#con => { - __e777.#fun(#idx)?; - Ok(()) - } - }, - Encoding::Array if flat => quote! { + let row = if text_keys { + // text_keys enum: unit → naked string, non-unit → map(1) { "Name": fields } + match &var.fields { + syn::Fields::Unit => quote! { #name::#con => { - __e777.array(1)?; - __e777.#fun(#idx)?; + __e777.str(#idx)?; Ok(()) } }, - Encoding::Array => quote! { - #name::#con => { - __e777.array(2)?; - __e777.#fun(#idx)?; - #tag - __e777.array(0)?; - Ok(()) + syn::Fields::Named(_) => { + let (tests, statements) = encode_fields(&fields, false, encoding, false)?; + let idents = fields.fields().idents(); + quote! { + #name::#con{#(#idents,)* ..} => { + #tests + __e777.map(1)?; + __e777.str(#idx)?; + #tag + #statements + } } - }, - Encoding::Map => quote! { - #name::#con => { - __e777.array(2)?; - __e777.#fun(#idx)?; - #tag - __e777.map(0)?; - Ok(()) + } + syn::Fields::Unnamed(_) => { + let (tests, statements) = encode_fields(&fields, false, encoding, false)?; + let idents = fields.match_idents(); + quote! { + #name::#con(#(#idents,)*) => { + #tests + __e777.map(1)?; + __e777.str(#idx)?; + #tag + #statements + } } } } - syn::Fields::Named(f) if index_only => { - return Err(syn::Error::new(f.span(), "index_only enums must not have fields")) - } - syn::Fields::Named(_) if flat => { - let (tests, statements) = encode_fields(&fields, false, encoding, true)?; - let idents = fields.fields().idents(); - quote! { - #name::#con{#(#idents,)* ..} => { - #tests - if let Some(__i777) = __max_index777 { - __e777.array(__i777 + 2)?; // max index + 1 + (1 for constructor index) - } else { + } else { + match &var.fields { + syn::Fields::Unit => match encoding { + Encoding::Array | Encoding::Map if index_only => quote! { + #name::#con => { + __e777.#fun(#idx)?; + Ok(()) + } + }, + Encoding::Array if flat => quote! { + #name::#con => { __e777.array(1)?; + __e777.#fun(#idx)?; + Ok(()) + } + }, + Encoding::Array => quote! { + #name::#con => { + __e777.array(2)?; + __e777.#fun(#idx)?; + #tag + __e777.array(0)?; + Ok(()) + } + }, + Encoding::Map => quote! { + #name::#con => { + __e777.array(2)?; + __e777.#fun(#idx)?; + #tag + __e777.map(0)?; + Ok(()) } - __e777.#fun(#idx)?; - #statements } } - } - syn::Fields::Named(_) => { - let (tests, statements) = encode_fields(&fields, false, encoding, false)?; - let idents = fields.fields().idents(); - quote! { - #name::#con{#(#idents,)* ..} => { - #tests - __e777.array(2)?; - __e777.#fun(#idx)?; - #tag - #statements + syn::Fields::Named(f) if index_only => { + return Err(syn::Error::new(f.span(), "index_only enums must not have fields")) + } + syn::Fields::Named(_) if flat => { + let (tests, statements) = encode_fields(&fields, false, encoding, true)?; + let idents = fields.fields().idents(); + quote! { + #name::#con{#(#idents,)* ..} => { + #tests + if let Some(__i777) = __max_index777 { + __e777.array(__i777 + 2)?; + } else { + __e777.array(1)?; + } + __e777.#fun(#idx)?; + #statements + } } } - } - syn::Fields::Unnamed(f) if index_only => { - return Err(syn::Error::new(f.span(), "index_only enums must not have fields")) - } - syn::Fields::Unnamed(_) if flat => { - let (tests, statements) = encode_fields(&fields, false, encoding, true)?; - let idents = fields.match_idents(); - quote! { - #name::#con(#(#idents,)*) => { - #tests - if let Some(__i777) = __max_index777 { - __e777.array(__i777 + 2)?; // max index + 1 + (1 for constructor index) - } else { - __e777.array(1)?; + syn::Fields::Named(_) => { + let (tests, statements) = encode_fields(&fields, false, encoding, false)?; + let idents = fields.fields().idents(); + quote! { + #name::#con{#(#idents,)* ..} => { + #tests + __e777.array(2)?; + __e777.#fun(#idx)?; + #tag + #statements } - __e777.#fun(#idx)?; - #statements } } - } - syn::Fields::Unnamed(_) => { - let (tests, statements) = encode_fields(&fields, false, encoding, false)?; - - let idents = fields.match_idents(); - quote! { - #name::#con(#(#idents,)*) => { - #tests - __e777.array(2)?; - __e777.#fun(#idx)?; - #tag - #statements + syn::Fields::Unnamed(f) if index_only => { + return Err(syn::Error::new(f.span(), "index_only enums must not have fields")) + } + syn::Fields::Unnamed(_) if flat => { + let (tests, statements) = encode_fields(&fields, false, encoding, true)?; + let idents = fields.match_idents(); + quote! { + #name::#con(#(#idents,)*) => { + #tests + if let Some(__i777) = __max_index777 { + __e777.array(__i777 + 2)?; + } else { + __e777.array(1)?; + } + __e777.#fun(#idx)?; + #statements + } + } + } + syn::Fields::Unnamed(_) => { + let (tests, statements) = encode_fields(&fields, false, encoding, false)?; + let idents = fields.match_idents(); + quote! { + #name::#con(#(#idents,)*) => { + #tests + __e777.array(2)?; + __e777.#fun(#idx)?; + #tag + #statements + } } } } diff --git a/minicbor-derive/src/fields.rs b/minicbor-derive/src/fields.rs index e911d99..cdf6d2b 100644 --- a/minicbor-derive/src/fields.rs +++ b/minicbor-derive/src/fields.rs @@ -1,6 +1,6 @@ use std::cmp::Ordering; -use crate::attrs::{Attributes, Idx, Kind, Level}; +use crate::attrs::{Attributes, Encoding, Idx, Kind, Level}; use crate::attrs::idx::{self, Index}; use proc_macro2::Span; use syn::{Ident, Type}; @@ -40,16 +40,34 @@ impl Fields { let mut skipped = Vec::new(); let mut has_str_index = false; - let encoding = parents.iter().find_map(|p| p.encoding()).unwrap_or_default(); + let text_keys = parents.iter().any(|p| p.text_keys()); + let encoding = if text_keys { + Encoding::Map + } else { + parents.iter().find_map(|p| p.encoding()).unwrap_or_default() + }; for (pos, f) in iter.into_iter().enumerate() { let attrs = Attributes::try_from_iter(Level::Field, &f.attrs)?; + if !text_keys && attrs.key().is_some() { + let s = f.ident.as_ref().map(|i| i.span()).unwrap_or_else(|| f.ty.span()); + return Err(syn::Error::new(s, "`#[cbor(key = \"...\")]` requires `#[cbor(text_keys)]` on the struct")) + } let index = if attrs.skip() { debug_assert!(attrs.index().is_none()); Index::Num(Idx::N(i64::MAX)) } else if let Some(i) = attrs.index() { debug_assert!(!attrs.skip()); i.clone() + } else if text_keys { + // Auto-assign string index from key attribute or field name + let key = attrs.key() + .map(|s| s.to_string()) + .or_else(|| f.ident.as_ref().map(|i| i.to_string())) + .ok_or_else(|| { + syn::Error::new(f.ty.span(), "`text_keys` requires named fields (not supported on tuple structs)") + })?; + Index::Str(key) } else if parents.last().map(|p| p.transparent()).unwrap_or(false) { Index::Num(Idx::N(i64::MAX)) } else { diff --git a/minicbor-derive/src/lib.rs b/minicbor-derive/src/lib.rs index a171029..6547316 100644 --- a/minicbor-derive/src/lib.rs +++ b/minicbor-derive/src/lib.rs @@ -568,7 +568,7 @@ enum Mode { /// Derive the `minicbor::CborLen` trait for a struct or enum. /// /// See the [crate] documentation for details. -#[proc_macro_derive(CborLen, attributes(n, b, cbor))] +#[proc_macro_derive(CborLen, attributes(n, b, s, cbor))] pub fn derive_cbor_len(input: proc_macro::TokenStream) -> proc_macro::TokenStream { cbor_len::derive_from(input) } diff --git a/minicbor-derive/src/variants.rs b/minicbor-derive/src/variants.rs index 1e7bb00..b78b317 100644 --- a/minicbor-derive/src/variants.rs +++ b/minicbor-derive/src/variants.rs @@ -18,13 +18,25 @@ impl Variants { let mut indices = Vec::new(); let mut attrs = Vec::new(); - let parent_encoding = parent.encoding().unwrap_or_default(); + let parent_encoding = parent.effective_encoding(); + + let text_keys = parent.text_keys(); for v in iter.into_iter() { let attr = Attributes::try_from_iter(Level::Variant, &v.attrs)?; - let idex = attr.index().ok_or_else(|| { - syn::Error::new(v.ident.span(), "missing `#[n(...)]`, `#[b(...)]`, or `#[s(...)]` attribute") - })?; + if !text_keys && attr.key().is_some() { + return Err(syn::Error::new(v.ident.span(), "`#[cbor(key = \"...\")]` requires `#[cbor(text_keys)]` on the enum")) + } + let idex = if let Some(i) = attr.index() { + i.clone() + } else if text_keys { + let key = attr.key() + .map(|s| s.to_string()) + .unwrap_or_else(|| v.ident.to_string()); + Index::Str(key) + } else { + return Err(syn::Error::new(v.ident.span(), "missing `#[n(...)]`, `#[b(...)]`, or `#[s(...)]` attribute")) + }; if idex.is_str() && attr.encoding().unwrap_or(parent_encoding).is_array() { let span = attr.span(Kind::Index).unwrap_or_else(|| v.ident.span()); return Err(syn::Error::new(span, "array encoding does not support constructors with string indices")) diff --git a/minicbor-tests-nostd/.cargo/config.toml b/minicbor-tests-nostd/.cargo/config.toml new file mode 100644 index 0000000..e558c65 --- /dev/null +++ b/minicbor-tests-nostd/.cargo/config.toml @@ -0,0 +1,5 @@ +[target.thumbv8m.main-none-eabihf] +rustflags = ["-C", "link-arg=-Tlink.x"] + +[build] +target = "thumbv8m.main-none-eabihf" diff --git a/minicbor-tests-nostd/Cargo.toml b/minicbor-tests-nostd/Cargo.toml new file mode 100644 index 0000000..caf8e9a --- /dev/null +++ b/minicbor-tests-nostd/Cargo.toml @@ -0,0 +1,18 @@ +[package] +publish = false +name = "minicbor-tests-nostd" +version = "0.1.0" +edition = "2021" + +[dependencies] +minicbor = { path = "../minicbor", features = ["derive"] } +cortex-m = { version = "0.7", features = ["critical-section-single-core"] } +cortex-m-rt = "0.7" +cortex-m-semihosting = "0.5" +panic-halt = "1" + +[profile.dev] +panic = "abort" + +[profile.release] +panic = "abort" diff --git a/minicbor-tests-nostd/memory.x b/minicbor-tests-nostd/memory.x new file mode 100644 index 0000000..3bc2594 --- /dev/null +++ b/minicbor-tests-nostd/memory.x @@ -0,0 +1,5 @@ +MEMORY +{ + FLASH : ORIGIN = 0x08000000, LENGTH = 2048K + RAM : ORIGIN = 0x20000000, LENGTH = 640K +} diff --git a/minicbor-tests-nostd/src/main.rs b/minicbor-tests-nostd/src/main.rs new file mode 100644 index 0000000..5efe2a9 --- /dev/null +++ b/minicbor-tests-nostd/src/main.rs @@ -0,0 +1,268 @@ +#![no_std] +#![no_main] + +use cortex_m_rt::entry; +use cortex_m_semihosting::{hprintln, debug}; +use minicbor::{Encode, Decode, Encoder, Decoder}; +use panic_halt as _; + +#[derive(Encode, Decode, PartialEq, Debug)] +#[cbor(text_keys)] +struct SensorData { + temperature: i16, + humidity: u8, + active: bool, +} + +#[derive(Encode, Decode, PartialEq, Debug)] +#[cbor(text_keys)] +struct WithRename { + #[cbor(key = "t")] + temperature: i16, +} + +#[derive(Encode, Decode, PartialEq, Debug)] +#[cbor(text_keys)] +struct WithOptional { + name: u8, + interval: Option, +} + +#[derive(Encode, Decode, PartialEq, Debug)] +#[cbor(text_keys)] +enum Command { + Reset, + SetInterval { #[s("ms")] ms: u32 }, +} + +#[derive(Encode, Decode, PartialEq, Debug)] +#[cbor(text_keys)] +struct WithSkip { + name: u8, + #[cbor(skip)] + cached: u32, + value: u16, +} + +#[derive(Encode, Decode, PartialEq, Debug)] +#[cbor(text_keys)] +struct WithArray { + label: u8, + readings: [u16; 3], +} + +#[derive(Encode, Decode, PartialEq, Debug)] +#[cbor(text_keys)] +struct Empty {} + +#[derive(Encode, Decode, PartialEq, Debug)] +#[cbor(text_keys)] +struct AllOpt { + a: Option, + b: Option, +} + +#[derive(Encode, Decode, PartialEq, Debug)] +#[cbor(text_keys)] +struct Generic { + value: T, + label: u8, +} + +fn assert(ok: bool, msg: &str) { + if ok { + hprintln!(" PASS: {}", msg); + } else { + hprintln!(" FAIL: {}", msg); + debug::exit(debug::EXIT_FAILURE); + } +} + +fn encode_to_buf>(val: &T, buf: &mut [u8]) -> usize { + let buf_len = buf.len(); + let mut e = Encoder::new(&mut *buf); + e.encode(val).unwrap(); + buf_len - e.writer().len() +} + +#[entry] +fn main() -> ! { + hprintln!("=== minicbor text_keys no_std tests on STM32H563ZI ==="); + + let mut buf = [0u8; 128]; + + // Test 1: Roundtrip SensorData + { + let val = SensorData { temperature: 23, humidity: 65, active: true }; + let len = encode_to_buf(&val, &mut buf); + let decoded: SensorData = minicbor::decode(&buf[..len]).unwrap(); + assert(val == decoded, "roundtrip SensorData"); + } + + // Test 2: Renamed keys + { + let val = WithRename { temperature: -5 }; + let len = encode_to_buf(&val, &mut buf); + assert(buf[0] == 0xa1, "rename: map(1)"); + assert(buf[1] == 0x61, "rename: text(1)"); + assert(buf[2] == b't', "rename: key 't'"); + let decoded: WithRename = minicbor::decode(&buf[..len]).unwrap(); + assert(val == decoded, "roundtrip WithRename"); + } + + // Test 3: Optional field absent + { + let val = WithOptional { name: 42, interval: None }; + let len = encode_to_buf(&val, &mut buf); + assert(buf[0] == 0xa1, "optional absent: map(1)"); + let decoded: WithOptional = minicbor::decode(&buf[..len]).unwrap(); + assert(val == decoded, "roundtrip WithOptional (None)"); + } + + // Test 4: Optional field present + { + let val = WithOptional { name: 42, interval: Some(1000) }; + let len = encode_to_buf(&val, &mut buf); + assert(buf[0] == 0xa2, "optional present: map(2)"); + let decoded: WithOptional = minicbor::decode(&buf[..len]).unwrap(); + assert(val == decoded, "roundtrip WithOptional (Some)"); + } + + // Test 5: Decode reordered keys + { + let buf_len = buf.len(); + let mut e = Encoder::new(&mut buf[..]); + e.map(3).unwrap() + .str("active").unwrap().bool(false).unwrap() + .str("humidity").unwrap().u8(99).unwrap() + .str("temperature").unwrap().i16(-10).unwrap(); + let len = buf_len - e.writer().len(); + let decoded: SensorData = minicbor::decode(&buf[..len]).unwrap(); + assert(decoded.temperature == -10, "reorder: temperature"); + assert(decoded.humidity == 99, "reorder: humidity"); + assert(!decoded.active, "reorder: active"); + } + + // Test 6: Unknown keys skipped + { + let buf_len = buf.len(); + let mut e = Encoder::new(&mut buf[..]); + e.map(4).unwrap() + .str("temperature").unwrap().i16(0).unwrap() + .str("extra").unwrap().str("ignored").unwrap() + .str("humidity").unwrap().u8(50).unwrap() + .str("active").unwrap().bool(true).unwrap(); + let len = buf_len - e.writer().len(); + let decoded: SensorData = minicbor::decode(&buf[..len]).unwrap(); + assert(decoded.temperature == 0, "unknown keys: temperature"); + assert(decoded.humidity == 50, "unknown keys: humidity"); + assert(decoded.active, "unknown keys: active"); + } + + // Test 7: Enum unit variant + { + let val = Command::Reset; + let len = encode_to_buf(&val, &mut buf); + let mut d = Decoder::new(&buf[..len]); + assert(d.str().unwrap() == "Reset", "enum unit: wire = \"Reset\""); + let decoded: Command = minicbor::decode(&buf[..len]).unwrap(); + assert(decoded == Command::Reset, "roundtrip enum unit"); + } + + // Test 8: Enum struct variant + { + let val = Command::SetInterval { ms: 500 }; + let len = encode_to_buf(&val, &mut buf); + let decoded: Command = minicbor::decode(&buf[..len]).unwrap(); + assert(decoded == Command::SetInterval { ms: 500 }, "roundtrip enum struct"); + } + + // Test 9: skip field + { + let val = WithSkip { name: 1, cached: 9999, value: 42 }; + let len = encode_to_buf(&val, &mut buf); + assert(buf[0] == 0xa2, "skip: map(2)"); + let decoded: WithSkip = minicbor::decode(&buf[..len]).unwrap(); + assert(decoded.name == 1, "skip: name"); + assert(decoded.cached == 0, "skip: cached == Default"); + assert(decoded.value == 42, "skip: value"); + } + + // Test 10: array field + { + let val = WithArray { label: 5, readings: [100, 200, 300] }; + let len = encode_to_buf(&val, &mut buf); + let decoded: WithArray = minicbor::decode(&buf[..len]).unwrap(); + assert(val == decoded, "roundtrip WithArray"); + } + + // Test 11: empty struct + { + let val = Empty {}; + let len = encode_to_buf(&val, &mut buf); + assert(buf[0] == 0xa0, "empty struct: map(0)"); + assert(len == 1, "empty struct: 1 byte"); + let decoded: Empty = minicbor::decode(&buf[..len]).unwrap(); + assert(val == decoded, "roundtrip Empty"); + } + + // Test 12: all optional, all None + { + let val = AllOpt { a: None, b: None }; + let len = encode_to_buf(&val, &mut buf); + assert(buf[0] == 0xa0, "all none: map(0)"); + let decoded: AllOpt = minicbor::decode(&buf[..len]).unwrap(); + assert(val == decoded, "roundtrip AllOpt (None)"); + } + + // Test 13: generic struct + { + let val: Generic = Generic { value: 1000, label: 5 }; + let len = encode_to_buf(&val, &mut buf); + let decoded: Generic = minicbor::decode(&buf[..len]).unwrap(); + assert(val == decoded, "roundtrip Generic"); + } + + // Test 14: encode buffer too small + { + let val = SensorData { temperature: 23, humidity: 65, active: true }; + let len = encode_to_buf(&val, &mut buf); + let mut small = [0u8; 1]; + assert(minicbor::encode(&val, &mut small[..]).is_err(), "buf too small: err"); + assert(minicbor::encode(&val, &mut buf[..len]).is_ok(), "buf exact: ok"); + if len > 0 { + assert(minicbor::encode(&val, &mut buf[..len - 1]).is_err(), "buf one short: err"); + } + } + + // Test 15: decode truncated + { + let val = SensorData { temperature: 23, humidity: 65, active: true }; + let len = encode_to_buf(&val, &mut buf); + let result: Result = minicbor::decode(&buf[..len - 1]); + assert(result.is_err(), "decode truncated: err"); + } + + // Test 16: decode non-string key + { + let buf_len = buf.len(); + let mut e = Encoder::new(&mut buf[..]); + e.map(1).unwrap().u32(0).unwrap().u8(1).unwrap(); + let len = buf_len - e.writer().len(); + let result: Result = minicbor::decode(&buf[..len]); + assert(result.is_err(), "non-string key: err"); + } + + // Test 17: text_keys implies map + { + let val = SensorData { temperature: 1, humidity: 2, active: true }; + let len = encode_to_buf(&val, &mut buf); + assert(buf[0] == 0xa3, "implies map: map(3)"); + let decoded: SensorData = minicbor::decode(&buf[..len]).unwrap(); + assert(val == decoded, "implies map: roundtrip"); + } + + hprintln!("=== ALL TESTS PASSED ==="); + debug::exit(debug::EXIT_SUCCESS); + loop {} +} diff --git a/minicbor-tests/src/lib.rs b/minicbor-tests/src/lib.rs index 7ef3b88..0b7e691 100644 --- a/minicbor-tests/src/lib.rs +++ b/minicbor-tests/src/lib.rs @@ -4,5 +4,6 @@ mod bytes; mod enums; mod structs; mod various; +mod text_keys; pub mod deriving; diff --git a/minicbor-tests/src/text_keys.rs b/minicbor-tests/src/text_keys.rs new file mode 100644 index 0000000..98de90c --- /dev/null +++ b/minicbor-tests/src/text_keys.rs @@ -0,0 +1,603 @@ +#![cfg(feature = "std")] + +#[cfg(test)] +mod tests { + use minicbor::{CborLen, Encode, Decode, Encoder, Decoder}; + + fn encode_to_buf>(val: &T, buf: &mut [u8]) -> usize { + let buf_len = buf.len(); + let mut e = Encoder::new(&mut *buf); + e.encode(val).unwrap(); + buf_len - e.writer().len() + } + + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + struct Simple { + x: u8, + y: u16, + } + + #[test] + fn roundtrip_simple() { + let val = Simple { x: 1, y: 300 }; + let mut buf = [0u8; 64]; + let len = encode_to_buf(&val, &mut buf); + let decoded: Simple = minicbor::decode(&buf[..len]).unwrap(); + assert_eq!(val, decoded); + assert_eq!(minicbor::len(&val), len); + } + + #[test] + fn decode_reordered_keys() { + let mut buf = [0u8; 64]; + let buf_len = buf.len(); + let mut e = Encoder::new(&mut buf[..]); + e.map(2).unwrap() + .str("y").unwrap().u16(300).unwrap() + .str("x").unwrap().u8(1).unwrap(); + let len = buf_len - e.writer().len(); + let decoded: Simple = minicbor::decode(&buf[..len]).unwrap(); + assert_eq!(decoded, Simple { x: 1, y: 300 }); + } + + #[test] + fn decode_unknown_keys_skipped() { + let mut buf = [0u8; 128]; + let buf_len = buf.len(); + let mut e = Encoder::new(&mut buf[..]); + e.map(3).unwrap() + .str("x").unwrap().u8(1).unwrap() + .str("unknown_field").unwrap().str("ignored").unwrap() + .str("y").unwrap().u16(300).unwrap(); + let len = buf_len - e.writer().len(); + let decoded: Simple = minicbor::decode(&buf[..len]).unwrap(); + assert_eq!(decoded, Simple { x: 1, y: 300 }); + } + + #[test] + fn renamed_keys() { + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + struct Renamed { + #[cbor(key = "t")] + temperature: i16, + } + let val = Renamed { temperature: -5 }; + let mut buf = [0u8; 32]; + let len = encode_to_buf(&val, &mut buf); + assert_eq!(buf[0], 0xa1); // map(1) + assert_eq!(buf[1], 0x61); // text(1) + assert_eq!(buf[2], b't'); + let decoded: Renamed = minicbor::decode(&buf[..len]).unwrap(); + assert_eq!(val, decoded); + assert_eq!(minicbor::len(&val), len); + } + + #[test] + fn optional_field_absent() { + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + struct WithOpt { + required: u8, + optional: Option, + } + let val = WithOpt { required: 42, optional: None }; + let mut buf = [0u8; 64]; + let len = encode_to_buf(&val, &mut buf); + assert_eq!(buf[0], 0xa1); // map(1) + let decoded: WithOpt = minicbor::decode(&buf[..len]).unwrap(); + assert_eq!(val, decoded); + assert_eq!(minicbor::len(&val), len); + } + + #[test] + fn optional_field_present() { + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + struct WithOpt { + required: u8, + optional: Option, + } + let val = WithOpt { required: 42, optional: Some(100) }; + let mut buf = [0u8; 64]; + let len = encode_to_buf(&val, &mut buf); + assert_eq!(buf[0], 0xa2); // map(2) + let decoded: WithOpt = minicbor::decode(&buf[..len]).unwrap(); + assert_eq!(val, decoded); + assert_eq!(minicbor::len(&val), len); + } + + #[test] + fn nested_text_keys() { + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + struct Inner { a: u8 } + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + struct Outer { inner: Inner, b: u16 } + let val = Outer { inner: Inner { a: 1 }, b: 2 }; + let mut buf = [0u8; 64]; + let len = encode_to_buf(&val, &mut buf); + let decoded: Outer = minicbor::decode(&buf[..len]).unwrap(); + assert_eq!(val, decoded); + assert_eq!(minicbor::len(&val), len); + } + + #[test] + fn missing_required_field_errors() { + let mut buf = [0u8; 64]; + let buf_len = buf.len(); + let mut e = Encoder::new(&mut buf[..]); + e.map(1).unwrap().str("x").unwrap().u8(1).unwrap(); + let len = buf_len - e.writer().len(); + assert!(minicbor::decode::(&buf[..len]).is_err()); + } + + #[test] + fn enum_unit_variant() { + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + enum Command { Reset, Stop } + let val = Command::Reset; + let mut buf = [0u8; 32]; + let len = encode_to_buf(&val, &mut buf); + let decoded: Command = minicbor::decode(&buf[..len]).unwrap(); + assert_eq!(val, decoded); + assert_eq!(minicbor::len(&val), len); + } + + #[test] + fn enum_struct_variant() { + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + enum Command { + Reset, + SetInterval { #[s("ms")] ms: u32 }, + } + let val = Command::SetInterval { ms: 500 }; + let mut buf = [0u8; 64]; + let len = encode_to_buf(&val, &mut buf); + let decoded: Command = minicbor::decode(&buf[..len]).unwrap(); + assert_eq!(val, decoded); + assert_eq!(minicbor::len(&val), len); + } + + #[test] + fn skip_field() { + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + struct WithSkip { + name: u8, + #[cbor(skip)] + cached: u32, + value: u16, + } + let val = WithSkip { name: 1, cached: 9999, value: 42 }; + let mut buf = [0u8; 64]; + let len = encode_to_buf(&val, &mut buf); + assert_eq!(buf[0], 0xa2); // map(2) + let decoded: WithSkip = minicbor::decode(&buf[..len]).unwrap(); + assert_eq!(decoded.name, 1); + assert_eq!(decoded.cached, 0); + assert_eq!(decoded.value, 42); + assert_eq!(minicbor::len(&val), len); + } + + #[test] + fn vec_field() { + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + struct WithVec { label: u8, items: Vec } + let val = WithVec { label: 1, items: vec![10, 20, 30] }; + let mut buf = [0u8; 128]; + let len = encode_to_buf(&val, &mut buf); + let decoded: WithVec = minicbor::decode(&buf[..len]).unwrap(); + assert_eq!(val, decoded); + assert_eq!(minicbor::len(&val), len); + } + + #[test] + fn btreemap_field() { + use std::collections::BTreeMap; + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + struct WithMap { label: u8, data: BTreeMap } + let mut data = BTreeMap::new(); + data.insert(1, 100); + data.insert(2, 200); + let val = WithMap { label: 1, data }; + let mut buf = [0u8; 128]; + let len = encode_to_buf(&val, &mut buf); + let decoded: WithMap = minicbor::decode(&buf[..len]).unwrap(); + assert_eq!(val, decoded); + assert_eq!(minicbor::len(&val), len); + } + + #[test] + fn string_field() { + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + struct WithString { id: u8, name: String } + let val = WithString { id: 1, name: "hello".into() }; + let mut buf = [0u8; 64]; + let len = encode_to_buf(&val, &mut buf); + let decoded: WithString = minicbor::decode(&buf[..len]).unwrap(); + assert_eq!(val, decoded); + assert_eq!(minicbor::len(&val), len); + } + + #[test] + fn empty_struct() { + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + struct Empty {} + let val = Empty {}; + let mut buf = [0u8; 16]; + let len = encode_to_buf(&val, &mut buf); + assert_eq!(buf[0], 0xa0); // map(0) + assert_eq!(len, 1); + let decoded: Empty = minicbor::decode(&buf[..len]).unwrap(); + assert_eq!(val, decoded); + assert_eq!(minicbor::len(&val), len); + } + + #[test] + fn all_optional_all_none() { + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + struct AllOpt { a: Option, b: Option } + let val = AllOpt { a: None, b: None }; + let mut buf = [0u8; 16]; + let len = encode_to_buf(&val, &mut buf); + assert_eq!(buf[0], 0xa0); // map(0) + let decoded: AllOpt = minicbor::decode(&buf[..len]).unwrap(); + assert_eq!(val, decoded); + assert_eq!(minicbor::len(&val), len); + } + + #[test] + fn generic_struct() { + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + struct Wrapper { value: T, label: u8 } + let val: Wrapper = Wrapper { value: 1000, label: 5 }; + let mut buf = [0u8; 64]; + let len = encode_to_buf(&val, &mut buf); + let decoded: Wrapper = minicbor::decode(&buf[..len]).unwrap(); + assert_eq!(val, decoded); + assert_eq!(minicbor::len(&val), len); + } + + #[test] + fn encode_buffer_too_small() { + let val = Simple { x: 1, y: 300 }; + let expected_len = minicbor::len(&val); + for size in 0..expected_len { + let mut buf = vec![0u8; size]; + assert!(minicbor::encode(&val, buf.as_mut_slice()).is_err()); + } + let mut buf = vec![0u8; expected_len]; + minicbor::encode(&val, buf.as_mut_slice()).unwrap(); + } + + #[test] + fn decode_truncated() { + let val = Simple { x: 1, y: 300 }; + let mut buf = [0u8; 64]; + let len = encode_to_buf(&val, &mut buf); + for trunc in 1..len { + assert!(minicbor::decode::(&buf[..len - trunc]).is_err()); + } + } + + #[test] + fn decode_non_string_key_errors() { + let mut buf = [0u8; 64]; + let buf_len = buf.len(); + let mut e = Encoder::new(&mut buf[..]); + e.map(1).unwrap().u32(0).unwrap().u8(1).unwrap(); + let len = buf_len - e.writer().len(); + assert!(minicbor::decode::(&buf[..len]).is_err()); + } + + #[test] + fn text_keys_wire_format() { + let val = Simple { x: 42, y: 1000 }; + let mut buf = [0u8; 64]; + let len = encode_to_buf(&val, &mut buf); + let mut d = Decoder::new(&buf[..len]); + let n = d.map().unwrap().unwrap(); + assert_eq!(n, 2); + let mut keys = vec![]; + for _ in 0..n { + keys.push(d.str().unwrap().to_string()); + d.skip().unwrap(); + } + keys.sort(); + assert_eq!(keys, vec!["x", "y"]); + } + + // --- Missing from old test suite --- + + #[test] + fn borrowed_str_field() { + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + struct Borrowed<'a> { + #[cbor(n(0))] + name: &'a str, + value: u8, + } + let val = Borrowed { name: "hello", value: 42 }; + let mut buf = [0u8; 64]; + let len = encode_to_buf(&val, &mut buf); + let decoded: Borrowed = minicbor::decode(&buf[..len]).unwrap(); + assert_eq!(val, decoded); + assert_eq!(minicbor::len(&val), len); + } + + #[test] + fn btreemap_field_empty() { + use std::collections::BTreeMap; + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + struct WithMap { label: u8, data: BTreeMap } + let val = WithMap { label: 7, data: BTreeMap::new() }; + let mut buf = [0u8; 64]; + let len = encode_to_buf(&val, &mut buf); + let decoded: WithMap = minicbor::decode(&buf[..len]).unwrap(); + assert_eq!(val, decoded); + assert_eq!(minicbor::len(&val), len); + } + + #[test] + fn cbor_len_simple() { + let val = Simple { x: 1, y: 300 }; + let mut buf = [0u8; 64]; + let len = encode_to_buf(&val, &mut buf); + assert_eq!(minicbor::len(&val), len); + } + + #[test] + fn cbor_len_renamed() { + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + struct Renamed { #[cbor(key = "t")] temperature: i16 } + let val = Renamed { temperature: -5 }; + let mut buf = [0u8; 32]; + let len = encode_to_buf(&val, &mut buf); + assert_eq!(minicbor::len(&val), len); + } + + #[test] + fn cbor_len_optional_absent() { + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + struct Opt { required: u8, optional: Option } + let val = Opt { required: 42, optional: None }; + let mut buf = [0u8; 64]; + let len = encode_to_buf(&val, &mut buf); + assert_eq!(minicbor::len(&val), len); + } + + #[test] + fn cbor_len_optional_present() { + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + struct Opt { required: u8, optional: Option } + let val = Opt { required: 42, optional: Some(1000) }; + let mut buf = [0u8; 64]; + let len = encode_to_buf(&val, &mut buf); + assert_eq!(minicbor::len(&val), len); + } + + #[test] + fn cbor_len_nested() { + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + struct Inner { a: u8 } + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + struct Outer { inner: Inner, b: u16 } + let val = Outer { inner: Inner { a: 1 }, b: 2 }; + let mut buf = [0u8; 64]; + let len = encode_to_buf(&val, &mut buf); + assert_eq!(minicbor::len(&val), len); + } + + #[test] + fn cbor_len_enum_unit() { + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + enum Cmd { Reset, Stop } + let val = Cmd::Reset; + let mut buf = [0u8; 32]; + let len = encode_to_buf(&val, &mut buf); + assert_eq!(minicbor::len(&val), len); + } + + #[test] + fn cbor_len_enum_struct() { + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + enum Cmd { Reset, SetInterval { #[s("ms")] ms: u32 } } + let val = Cmd::SetInterval { ms: 500 }; + let mut buf = [0u8; 64]; + let len = encode_to_buf(&val, &mut buf); + assert_eq!(minicbor::len(&val), len); + } + + #[test] + fn decode_indefinite_map_works() { + // The str-index codebase supports indefinite-length maps + let buf = [ + 0xbf, // begin indefinite map + 0x61, b'x', 0x01, // "x": 1 + 0x61, b'y', 0x19, 0x01, 0x2c, // "y": 300 + 0xff, // break + ]; + let decoded: Simple = minicbor::decode(&buf).unwrap(); + assert_eq!(decoded, Simple { x: 1, y: 300 }); + } + + #[test] + fn encode_buffer_too_small_with_optional() { + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + struct Opt { a: u8, b: Option } + let val = Opt { a: 1, b: Some(1000) }; + let expected_len = minicbor::len(&val); + let mut buf = vec![0u8; expected_len - 1]; + assert!(minicbor::encode(&val, buf.as_mut_slice()).is_err()); + let mut buf = vec![0u8; expected_len]; + minicbor::encode(&val, buf.as_mut_slice()).unwrap(); + + let val_none = Opt { a: 1, b: None }; + let expected_len_none = minicbor::len(&val_none); + assert!(expected_len_none < expected_len); + let mut buf = vec![0u8; expected_len_none - 1]; + assert!(minicbor::encode(&val_none, buf.as_mut_slice()).is_err()); + let mut buf = vec![0u8; expected_len_none]; + minicbor::encode(&val_none, buf.as_mut_slice()).unwrap(); + } + + #[test] + fn enum_tuple_variant() { + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + enum Value { + None, + Int(#[s("v")] u32), + Pair(#[s("a")] u8, #[s("b")] u16), + } + let val = Value::None; + let mut buf = [0u8; 64]; + let len = encode_to_buf(&val, &mut buf); + assert_eq!(minicbor::decode::(&buf[..len]).unwrap(), val); + + let val = Value::Int(42); + let len = encode_to_buf(&val, &mut buf); + assert_eq!(minicbor::decode::(&buf[..len]).unwrap(), val); + assert_eq!(minicbor::len(&val), len); + + let val = Value::Pair(1, 300); + let len = encode_to_buf(&val, &mut buf); + assert_eq!(minicbor::decode::(&buf[..len]).unwrap(), val); + assert_eq!(minicbor::len(&val), len); + } + + #[test] + fn generic_struct_with_vec() { + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + struct Collection { items: Vec, count: u8 } + let val: Collection = Collection { items: vec![1, 2, 3], count: 3 }; + let mut buf = [0u8; 128]; + let len = encode_to_buf(&val, &mut buf); + let decoded: Collection = minicbor::decode(&buf[..len]).unwrap(); + assert_eq!(val, decoded); + assert_eq!(minicbor::len(&val), len); + } + + #[test] + fn hashmap_field_roundtrip() { + use std::collections::HashMap; + #[derive(Encode, Decode, PartialEq, Debug)] + #[cbor(text_keys)] + struct WithHashMap { label: u8, data: HashMap } + let mut data = HashMap::new(); + data.insert(10, 1000); + data.insert(20, 2000); + let val = WithHashMap { label: 3, data }; + let mut buf = [0u8; 128]; + let len = encode_to_buf(&val, &mut buf); + let decoded: WithHashMap = minicbor::decode(&buf[..len]).unwrap(); + assert_eq!(val, decoded); + } + + #[test] + fn skip_field_only_skipped_remain() { + #[derive(Encode, Decode, PartialEq, Debug)] + #[cbor(text_keys)] + struct AllButOne { + keep: u8, + #[cbor(skip)] + drop1: u16, + #[cbor(skip)] + drop2: bool, + } + let val = AllButOne { keep: 7, drop1: 1000, drop2: true }; + let mut buf = [0u8; 64]; + let len = encode_to_buf(&val, &mut buf); + assert_eq!(buf[0], 0xa1); // map(1) + let decoded: AllButOne = minicbor::decode(&buf[..len]).unwrap(); + assert_eq!(decoded.keep, 7); + assert_eq!(decoded.drop1, 0); + assert_eq!(decoded.drop2, false); + } + + #[test] + fn skip_with_vec() { + #[derive(Encode, Decode, PartialEq, Debug)] + #[cbor(text_keys)] + struct Mixed { + items: Vec, + #[cbor(skip)] + cache: Vec, + count: u16, + } + let val = Mixed { items: vec![1, 2, 3], cache: vec![99, 98], count: 42 }; + let mut buf = [0u8; 128]; + let len = encode_to_buf(&val, &mut buf); + let decoded: Mixed = minicbor::decode(&buf[..len]).unwrap(); + assert_eq!(decoded.items, vec![1, 2, 3]); + assert_eq!(decoded.cache, Vec::::new()); + assert_eq!(decoded.count, 42); + } + + #[test] + fn text_keys_implies_map() { + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + struct ImpliedMap { a: u8, b: u16 } + let val = ImpliedMap { a: 1, b: 2 }; + let mut buf = [0u8; 64]; + let len = encode_to_buf(&val, &mut buf); + assert_eq!(buf[0], 0xa2); // map(2), not array + let decoded: ImpliedMap = minicbor::decode(&buf[..len]).unwrap(); + assert_eq!(val, decoded); + } + + #[test] + fn vec_field_empty() { + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + struct WithVec { label: u8, items: Vec } + let val = WithVec { label: 5, items: vec![] }; + let mut buf = [0u8; 64]; + let len = encode_to_buf(&val, &mut buf); + let decoded: WithVec = minicbor::decode(&buf[..len]).unwrap(); + assert_eq!(val, decoded); + assert_eq!(minicbor::len(&val), len); + } + + #[test] + fn vec_of_text_keys_structs() { + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + struct Item { id: u8, value: u16 } + #[derive(Encode, Decode, CborLen, PartialEq, Debug)] + #[cbor(text_keys)] + struct Container { name: u8, items: Vec } + let val = Container { + name: 1, + items: vec![Item { id: 0, value: 100 }, Item { id: 1, value: 200 }], + }; + let mut buf = [0u8; 256]; + let len = encode_to_buf(&val, &mut buf); + let decoded: Container = minicbor::decode(&buf[..len]).unwrap(); + assert_eq!(val, decoded); + assert_eq!(minicbor::len(&val), len); + } +} From 918adb9bc3ee0aff0e128c34636d5d1d33f67441 Mon Sep 17 00:00:00 2001 From: Roman Puls Date: Sun, 15 Mar 2026 09:11:49 +0100 Subject: [PATCH 2/3] Add ENHANCEMENTS.md documenting text_keys and string index features Signed-off-by: Roman Puls --- ENHANCEMENTS.md | 201 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 ENHANCEMENTS.md diff --git a/ENHANCEMENTS.md b/ENHANCEMENTS.md new file mode 100644 index 0000000..d55b3e0 --- /dev/null +++ b/ENHANCEMENTS.md @@ -0,0 +1,201 @@ +# String-Keyed CBOR Maps + +This branch adds two complementary features for encoding structs and enums +as CBOR maps with human-readable string keys. + +It builds on the `#[s("...")]` string index support introduced by +@twittner in [PR #53](https://github.com/twittner/minicbor/pull/53) +and extends it with a convenience macro that automatically derives +string keys from field and variant names. + +## `#[s("...")]` — Per-field string index + +Annotate individual fields or variants with a string index, just like +`#[n(0)]` assigns a numeric index: + +```rust +use minicbor::{Encode, Decode}; + +#[derive(Encode, Decode)] +#[cbor(map)] +struct SensorData { + #[s("temperature")] + temperature: i16, + #[s("humidity")] + humidity: u8, + #[n(2)] + flags: u8, // numeric and string indices can coexist +} +``` + +Wire format (CBOR diagnostic notation): + +``` +{"temperature": 23, "humidity": 65, 2: 7} +``` + +## `#[cbor(text_keys)]` — Automatic string keys from field names + +Instead of annotating every field, apply `text_keys` at the struct or +enum level. Each field is automatically keyed by its Rust identifier: + +```rust +#[derive(Encode, Decode)] +#[cbor(text_keys)] +struct SensorData { + temperature: i16, + humidity: u8, + active: bool, +} +``` + +Wire format: + +``` +{"temperature": 23, "humidity": 65, "active": true} +``` + +No `#[cbor(map)]` is needed — `text_keys` implies map encoding. + +### Renaming keys + +Override individual key names with `#[cbor(key = "...")]`: + +```rust +#[derive(Encode, Decode)] +#[cbor(text_keys)] +struct SensorData { + #[cbor(key = "t")] + temperature: i16, + #[cbor(key = "h")] + humidity: u8, + active: bool, +} +``` + +Wire format: + +``` +{"t": 23, "h": 65, "active": true} +``` + +### Optional fields + +`Option` fields that are `None` are omitted from the map entirely: + +```rust +#[derive(Encode, Decode)] +#[cbor(text_keys)] +struct Config { + name: u8, + #[cbor(key = "interval_ms")] + interval: Option, +} + +// Config { name: 1, interval: None } encodes as: {"name": 1} +// Config { name: 1, interval: Some(500) } encodes as: {"name": 1, "interval_ms": 500} +``` + +### Skipping fields + +Fields annotated with `#[cbor(skip)]` are excluded from encoding and +initialized with `Default::default()` on decode: + +```rust +#[derive(Encode, Decode)] +#[cbor(text_keys)] +struct State { + value: u16, + #[cbor(skip)] + cached: u32, // not encoded, defaults to 0 on decode +} +``` + +### Nested structs + +Inner structs with `text_keys` produce nested string-keyed maps: + +```rust +#[derive(Encode, Decode)] +#[cbor(text_keys)] +struct Inner { a: u8 } + +#[derive(Encode, Decode)] +#[cbor(text_keys)] +struct Outer { + inner: Inner, + b: u16, +} + +// Outer { inner: Inner { a: 1 }, b: 2 } +// encodes as: {"inner": {"a": 1}, "b": 2} +``` + +### Enums + +Unit variants encode as a plain string. Non-unit variants encode as a +single-entry map wrapping the variant's fields: + +```rust +#[derive(Encode, Decode)] +#[cbor(text_keys)] +enum Command { + Reset, + SetInterval { #[s("ms")] ms: u32 }, +} + +// Command::Reset → "Reset" +// Command::SetInterval { ms: 500 } → {"SetInterval": {"ms": 500}} +``` + +### Generic structs + +Works with generics — the derived bounds are added automatically: + +```rust +#[derive(Encode, Decode)] +#[cbor(text_keys)] +struct Wrapper { + value: T, + label: u8, +} +``` + +### Collection fields + +Standard collection types work as field values: + +```rust +use std::collections::BTreeMap; + +#[derive(Encode, Decode)] +#[cbor(text_keys)] +struct DataSet { + readings: Vec, + metadata: BTreeMap, + name: String, +} +``` + +## Decode behavior + +- **Unknown keys** are silently skipped (forward compatible) +- **Missing optional fields** default to `None` (backward compatible) +- **Missing required fields** produce a decode error +- **Key order** does not matter — the decoder handles any order +- Both definite and indefinite-length maps are supported + +## Compile-time validation + +The following invalid usages produce compile errors: + +- `#[cbor(text_keys)]` on a tuple struct (fields have no names) +- `#[cbor(text_keys)]` combined with `transparent` or `index_only` +- `#[cbor(key = "...")]` without `text_keys` on the struct/enum +- Two fields with the same key name + +## `no_std` / `no_alloc` + +Both features are fully compatible with `no_std` and `no_alloc` targets. +String keys are encoded directly by the `Encoder::str()` method and decoded +zero-copy via `Decoder::str()` — no heap allocation is needed at any point. From 0f0848c058564845fd03df27724a612325a1b5d8 Mon Sep 17 00:00:00 2001 From: Roman Puls Date: Sun, 15 Mar 2026 09:30:28 +0100 Subject: [PATCH 3/3] Clean up ENHANCEMENTS.md, add issue #52 reference Signed-off-by: Roman Puls --- ENHANCEMENTS.md | 82 +++++++++++++++++++++++-------------------------- 1 file changed, 39 insertions(+), 43 deletions(-) diff --git a/ENHANCEMENTS.md b/ENHANCEMENTS.md index d55b3e0..ae0c3ce 100644 --- a/ENHANCEMENTS.md +++ b/ENHANCEMENTS.md @@ -1,17 +1,15 @@ # String-Keyed CBOR Maps -This branch adds two complementary features for encoding structs and enums -as CBOR maps with human-readable string keys. +This branch adds two features for encoding structs and enums as CBOR maps +with string keys instead of integer indices. -It builds on the `#[s("...")]` string index support introduced by -@twittner in [PR #53](https://github.com/twittner/minicbor/pull/53) -and extends it with a convenience macro that automatically derives -string keys from field and variant names. +Based on [issue #52](https://github.com/twittner/minicbor/issues/52) and +the `#[s("...")]` string index support from @twittner's +[PR #53](https://github.com/twittner/minicbor/pull/53). -## `#[s("...")]` — Per-field string index +## `#[s("...")]` per-field string index -Annotate individual fields or variants with a string index, just like -`#[n(0)]` assigns a numeric index: +Works like `#[n(0)]`, but uses a string key: ```rust use minicbor::{Encode, Decode}; @@ -34,10 +32,10 @@ Wire format (CBOR diagnostic notation): {"temperature": 23, "humidity": 65, 2: 7} ``` -## `#[cbor(text_keys)]` — Automatic string keys from field names +## `#[cbor(text_keys)]` for automatic string keys -Instead of annotating every field, apply `text_keys` at the struct or -enum level. Each field is automatically keyed by its Rust identifier: +Applies string keys to all fields automatically, using their Rust +identifiers. No per-field annotations needed: ```rust #[derive(Encode, Decode)] @@ -55,11 +53,11 @@ Wire format: {"temperature": 23, "humidity": 65, "active": true} ``` -No `#[cbor(map)]` is needed — `text_keys` implies map encoding. +`text_keys` implies map encoding, so `#[cbor(map)]` is not required. ### Renaming keys -Override individual key names with `#[cbor(key = "...")]`: +Use `#[cbor(key = "...")]` to override individual key names: ```rust #[derive(Encode, Decode)] @@ -81,7 +79,7 @@ Wire format: ### Optional fields -`Option` fields that are `None` are omitted from the map entirely: +`Option` fields that are `None` are omitted from the map: ```rust #[derive(Encode, Decode)] @@ -92,14 +90,14 @@ struct Config { interval: Option, } -// Config { name: 1, interval: None } encodes as: {"name": 1} -// Config { name: 1, interval: Some(500) } encodes as: {"name": 1, "interval_ms": 500} +// Config { name: 1, interval: None } encodes as {"name": 1} +// Config { name: 1, interval: Some(500) } encodes as {"name": 1, "interval_ms": 500} ``` ### Skipping fields -Fields annotated with `#[cbor(skip)]` are excluded from encoding and -initialized with `Default::default()` on decode: +`#[cbor(skip)]` excludes fields from encoding. They get `Default::default()` +on decode: ```rust #[derive(Encode, Decode)] @@ -113,8 +111,6 @@ struct State { ### Nested structs -Inner structs with `text_keys` produce nested string-keyed maps: - ```rust #[derive(Encode, Decode)] #[cbor(text_keys)] @@ -128,13 +124,13 @@ struct Outer { } // Outer { inner: Inner { a: 1 }, b: 2 } -// encodes as: {"inner": {"a": 1}, "b": 2} +// encodes as {"inner": {"a": 1}, "b": 2} ``` ### Enums -Unit variants encode as a plain string. Non-unit variants encode as a -single-entry map wrapping the variant's fields: +Unit variants encode as a plain string, non-unit variants as a +single-entry map: ```rust #[derive(Encode, Decode)] @@ -144,13 +140,13 @@ enum Command { SetInterval { #[s("ms")] ms: u32 }, } -// Command::Reset → "Reset" -// Command::SetInterval { ms: 500 } → {"SetInterval": {"ms": 500}} +// Command::Reset encodes as "Reset" +// Command::SetInterval { ms: 500 } encodes as {"SetInterval": {"ms": 500}} ``` -### Generic structs +### Generics -Works with generics — the derived bounds are added automatically: +Trait bounds are derived automatically: ```rust #[derive(Encode, Decode)] @@ -161,7 +157,7 @@ struct Wrapper { } ``` -### Collection fields +### Collections Standard collection types work as field values: @@ -179,23 +175,23 @@ struct DataSet { ## Decode behavior -- **Unknown keys** are silently skipped (forward compatible) -- **Missing optional fields** default to `None` (backward compatible) -- **Missing required fields** produce a decode error -- **Key order** does not matter — the decoder handles any order -- Both definite and indefinite-length maps are supported +- Unknown keys are skipped (forward compatible) +- Missing optional fields default to `None` (backward compatible) +- Missing required fields produce a decode error +- Key order does not matter +- Definite and indefinite-length maps are both supported -## Compile-time validation +## Compile-time checks -The following invalid usages produce compile errors: +The following produce compile errors: -- `#[cbor(text_keys)]` on a tuple struct (fields have no names) -- `#[cbor(text_keys)]` combined with `transparent` or `index_only` -- `#[cbor(key = "...")]` without `text_keys` on the struct/enum +- `text_keys` on a tuple struct (fields have no names) +- `text_keys` combined with `transparent` or `index_only` +- `key = "..."` without `text_keys` on the struct or enum - Two fields with the same key name -## `no_std` / `no_alloc` +## `no_std` and `no_alloc` -Both features are fully compatible with `no_std` and `no_alloc` targets. -String keys are encoded directly by the `Encoder::str()` method and decoded -zero-copy via `Decoder::str()` — no heap allocation is needed at any point. +Both features work on `no_std` and `no_alloc` targets. +`Encoder::str()` writes directly, `Decoder::str()` borrows from the +input buffer. No heap allocation anywhere.