diff --git a/README.md b/README.md index 11e0a59..fec82fe 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,47 @@ Config::builder() .check_ranges(FeatureConfig::Never); ``` +### Attribute-driven constants + +Many DBCs contain additional metadata in `BA_` attributes, e.g. parameters describing an AUTOSAR E2E protection scheme. `attribute_structs` lets you expose these as typed constants in the generated message types. + +You declare a struct that your own crate owns and map each field to a DBC attribute, derived signal, or a literal: + +```rust,no_run +use dbc_codegen::{AttributeField, AttributeScope, AttributeStruct, Config, FieldSource}; + +// `data_protection::E2EDataIdInfo { data_id, start_byte, width_bit }` is a type defined in your crate. +let e2e = AttributeStruct { + type_path: "data_protection::E2EDataIdInfo", + const_name: "E2E", + scope: AttributeScope::Signal, // One const per matching signal + require: "E2EDataId", // Only signals carrying this attribute + fields: &[ + AttributeField { name: "data_id", source: FieldSource::Attr("E2EDataId") }, + AttributeField { name: "start_byte", source: FieldSource::StartByte }, + AttributeField { name: "width_bit", source: FieldSource::Attr("E2EDataLength") }, + ], +}; + +let dbc_file = ""; +Config::builder() + .dbc_name("example.dbc") + .dbc_content(dbc_file) + .attribute_structs(&[e2e]) + .build() + .generate() + .unwrap(); +``` + +For every signal that carries an `E2EDataId` attribute, this generates: + +```rust,ignore +impl SomeMessage { + pub const SOME_SIGNAL_E2E: data_protection::E2EDataIdInfo = + data_protection::E2EDataIdInfo { data_id: 373, start_byte: 0, width_bit: 48 }; +} +``` + ### `no_std` The generated code is `no_std` compatible, unless you enable `impl_error`. diff --git a/examples/attribute_structs.rs b/examples/attribute_structs.rs new file mode 100644 index 0000000..a0881eb --- /dev/null +++ b/examples/attribute_structs.rs @@ -0,0 +1,82 @@ +//! Demonstrates [`Config::attribute_structs`]. +//! Emits constants for AUTOSAR E2E / SecOC-style metdata from DBC 'BA_' attributes. + +use dbc_codegen::{AttributeField, AttributeScope, AttributeStruct, Config, FieldSource}; + +const DBC: &str = r#"VERSION "" + +NS_ : + +BS_: + +BU_: ECU1 + +BO_ 256 Protected: 8 ECU1 + SG_ Speed_CRC : 16|8@1+ (1,0) [0|255] "" ECU1 + +BA_DEF_ BO_ "SCP_FreshnessValueId" INT 0 65535; +BA_DEF_ SG_ "E2EDataId" INT 0 65535; +BA_DEF_ SG_ "E2EDataLength" INT 0 65535; +BA_DEF_ SG_ "E2EProfile" STRING ; +BA_DEF_DEF_ "SCP_FreshnessValueId" 0; +BA_DEF_DEF_ "E2EDataId" 0; +BA_DEF_DEF_ "E2EDataLength" 0; +BA_DEF_DEF_ "E2EProfile" "none"; +BA_ "SCP_FreshnessValueId" BO_ 256 1002; +BA_ "E2EDataId" SG_ 256 Speed_CRC 373; +BA_ "E2EDataLength" SG_ 256 Speed_CRC 48; +BA_ "E2EProfile" SG_ 256 Speed_CRC "P01"; +"#; + +fn field(name: &'static str, source: FieldSource<'static>) -> AttributeField<'static> { + AttributeField { name, source } +} + +fn main() { + let e2e = AttributeStruct { + type_path: "data_protection::E2EDataIdInfo", + const_name: "E2E", + scope: AttributeScope::Signal, + require: "E2EDataId", + fields: &[ + field("data_id", FieldSource::Attr("E2EDataId")), + field("start_byte", FieldSource::StartByte), + field("width_bit", FieldSource::Attr("E2EDataLength")), + field("profile", FieldSource::Attr("E2EProfile")), + ], + }; + let secoc = AttributeStruct { + type_path: "data_protection::SecOcInfo", + const_name: "SEC_OC", + scope: AttributeScope::Message, + require: "SCP_FreshnessValueId", + fields: &[field( + "freshness_id", + FieldSource::Attr("SCP_FreshnessValueId"), + )], + }; + + let code = Config::builder() + .dbc_name("example") + .dbc_content(DBC) + .attribute_structs(&[e2e, secoc]) + .build() + .generate() + .expect("generate"); + + // Print just the associated consts to keep the output readable. + for line in code.lines() { + let t = line.trim_start(); + if t.starts_with("pub const") + || t.starts_with("data_id") + || t.starts_with("start_byte") + || t.starts_with("width_bit") + || t.starts_with("profile") + || t.starts_with("freshness_id") + || t.contains("E2EDataIdInfo {") + || t.contains("SecOcInfo {") + { + println!("{line}"); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 23440f4..fddea3c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,7 +24,9 @@ use can_dbc::MultiplexIndicator::{ MultiplexedSignal, Multiplexor, MultiplexorAndMultiplexedSignal, Plain, }; use can_dbc::ValueType::Signed; -use can_dbc::{Dbc, Message, MessageId, Signal, Transmitter, ValDescription, ValueDescription}; +use can_dbc::{ + AttributeValue, Dbc, Message, MessageId, Signal, Transmitter, ValDescription, ValueDescription, +}; use heck::ToSnakeCase; use quote::ToTokens; use typed_builder::TypedBuilder; @@ -95,11 +97,113 @@ pub struct Config<'a> { /// Optional: Allow dead code in the generated module. Default: `false`. #[builder(default)] pub allow_dead_code: bool, + + /// Optional: User-defined structs populated from DBC attributes and message layout. + /// These are emitted as associated constants in the generated message types. + /// Default: empty. + #[builder(default)] + pub attribute_structs: &'a [AttributeStruct<'a>], +} + +/// A user-defined struct that [`Config`] fills from DBC data and emits as an +/// associated constant in the generated message type. +/// +/// # Example +/// +/// Given a consumer type `data_protection::E2EDataIdInfo { data_id, start_byte, +/// width_bit }`, this declaration: +/// +/// ``` +/// use dbc_codegen::{AttributeField, AttributeScope, AttributeStruct, FieldSource}; +/// +/// const E2E: AttributeStruct = AttributeStruct { +/// type_path: "data_protection::E2EDataIdInfo", +/// const_name: "E2E", +/// scope: AttributeScope::Signal, +/// require: "E2EDataId", +/// fields: &[ +/// AttributeField { name: "data_id", source: FieldSource::Attr("E2EDataId") }, +/// AttributeField { name: "start_byte", source: FieldSource::StartByte }, +/// AttributeField { name: "width_bit", source: FieldSource::Attr("E2EDataLength") }, +/// ], +/// }; +/// ``` +/// +/// emits, for every signal that carries an `E2EDataId` attribute: +/// +/// ```ignore +/// impl SomeMessage { +/// pub const SOME_SIGNAL_E2E: data_protection::E2EDataIdInfo = +/// data_protection::E2EDataIdInfo { data_id: 373, start_byte: 0, width_bit: 48 }; +/// } +/// ``` +#[derive(Debug, Clone)] +pub struct AttributeStruct<'a> { + /// Fully-qualified path of the target Rust type. + pub type_path: &'a str, + + /// Base name of the emitted associated constant. + pub const_name: &'a str, + + /// Whether one constant is emitted per message or per matching signal. + pub scope: AttributeScope, + + /// Only emit the constant when this attribute has an assigned value on the + /// scoped object (the message for [`AttributeScope::Message`] or the signal + /// for [`AttributeScope::Signal`]). + pub require: &'a str, + + /// The struct fields, written in this order. + pub fields: &'a [AttributeField<'a>], +} + +/// Whether an [`AttributeStruct`] is emitted once per message or once per signal. +#[derive(Debug, Clone, Copy)] +#[non_exhaustive] +pub enum AttributeScope { + /// One const per message (BO_ attribute). + Message, + /// One const per signal (SG_ attribute). + Signal, +} + +/// One field of an [`AttributeStruct`] and where its value comes from. +#[derive(Debug, Clone)] +pub struct AttributeField<'a> { + /// The target struct field name. + pub name: &'a str, + /// Where the field's value comes from. + pub source: FieldSource<'a>, +} + +/// Source of a generated [`AttributeField`] value. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum FieldSource<'a> { + /// Value of an attribute on the scoped object: a signal-level attribute in + /// [`AttributeScope::Signal`] or a message-level attribute in + /// [`AttributeScope::Message`]. + Attr(&'a str), + /// Value of a message-level attribute. + MessageAttr(&'a str), + /// The signal's raw DBC start bit. [`AttributeScope::Signal`] only. + StartBit, + /// The signal's start byte. [`AttributeScope::Signal`] only. + StartByte, + /// The signal's width in bits. [`AttributeScope::Signal`] only. + BitWidth, + /// The message size in bytes. + MessageSize, + /// A literal integer. + Int(i64), + /// A literal string. + Str(&'a str), } impl Config<'_> { /// Write Rust structs matching DBC input description to `out` buffer fn codegen(&self, out: impl Write) -> Result<()> { + self.validate_attribute_structs()?; let dbc = Dbc::try_from(self.dbc_content).map_err(|e| { let msg = "Could not parse dbc file"; if self.debug_prints { @@ -290,6 +394,8 @@ impl Config<'_> { } writeln!(w)?; + self.render_attribute_structs(&mut w, msg, dbc)?; + writeln!(w, "/// Construct new {} from values", msg.name)?; let args = msg .signals @@ -414,6 +520,148 @@ impl Config<'_> { Ok(()) } + /// Validate the [`AttributeStruct`] specs. + fn validate_attribute_structs(&self) -> Result<()> { + for spec in self.attribute_structs { + ensure!( + !spec.const_name.is_empty(), + "attribute_structs: 'const_name' must not be empty" + ); + ensure!( + !spec.type_path.is_empty(), + "attribute_structs: 'type_path' must not be empty for '{}'", + spec.const_name + ); + ensure!( + !spec.require.is_empty(), + "attribute_structs: 'require' must not be empty for '{}'", + spec.const_name + ); + ensure!( + !spec.fields.is_empty(), + "attribute_structs: '{}' declares no fields", + spec.const_name + ); + if matches!(spec.scope, AttributeScope::Message) { + for field in spec.fields { + ensure!( + !matches!( + field.source, + FieldSource::StartBit | FieldSource::StartByte | FieldSource::BitWidth + ), + "attribute_structs: field '{}' of '{}' uses a signal-only source \ + (StartBit/StartByte/BitWidth) but the struct has message scope", + field.name, + spec.const_name + ); + } + } + } + Ok(()) + } + + /// Emit the configured [`AttributeStruct`]s as associated constants. + fn render_attribute_structs(&self, w: &mut impl Write, msg: &Message, dbc: &Dbc) -> Result<()> { + if self.attribute_structs.is_empty() { + return Ok(()); + } + + // Track every const name already emitted for this message type so that + // a duplicate is rejected here instead of producing code that fails to + // compile. + let mut used: BTreeSet = BTreeSet::new(); + used.insert("MESSAGE_ID".to_string()); + for signal in &msg.signals { + if ValType::from_signal(signal) != ValType::Bool { + let sig = signal.field_name().to_uppercase(); + used.insert(format!("{sig}_MIN")); + used.insert(format!("{sig}_MAX")); + } + } + + for spec in self.attribute_structs { + match spec.scope { + AttributeScope::Message => { + if message_attr(dbc, msg.id, spec.require).is_none() { + continue; + } + Self::render_attribute_struct( + w, + spec, + spec.const_name, + msg, + dbc, + None, + &mut used, + )?; + } + AttributeScope::Signal => { + for signal in &msg.signals { + if signal_attr(dbc, msg.id, &signal.name, spec.require).is_none() { + continue; + } + let name = + format!("{}_{}", signal.field_name().to_uppercase(), spec.const_name); + Self::render_attribute_struct( + w, + spec, + &name, + msg, + dbc, + Some(signal), + &mut used, + )?; + } + } + } + } + Ok(()) + } + + /// Emit a single struct constant. + fn render_attribute_struct( + w: &mut impl Write, + spec: &AttributeStruct<'_>, + const_name: &str, + msg: &Message, + dbc: &Dbc, + signal: Option<&Signal>, + used: &mut BTreeSet, + ) -> Result<()> { + ensure!( + used.insert(const_name.to_string()), + "attribute_structs: generated const '{const_name}' on message '{}' collides with \ + another const. Use a distinct 'const_name'.", + msg.name + ); + + let mut fields = Vec::with_capacity(spec.fields.len()); + for field in spec.fields { + let lit = resolve_field_source(&field.source, msg, dbc, signal).ok_or_else(|| { + anyhow!( + "attribute_structs: const '{const_name}' field '{}' has no value in the DBC \ + and no default (source: {:?})", + field.name, + field.source + ) + })?; + fields.push((field.name, lit)); + } + + let ty = spec.type_path; + writeln!(w, "/// Generated from DBC attributes '{}'", spec.require)?; + writeln!(w, "pub const {const_name}: {ty} = {ty} {{")?; + { + let mut w = PadAdapter::wrap(w); + for (name, lit) in fields { + writeln!(w, "{name}: {lit},")?; + } + } + writeln!(w, "}};")?; + writeln!(w)?; + Ok(()) + } + fn render_signal( &self, w: &mut impl Write, @@ -1322,6 +1570,82 @@ fn message_ignored(message: &Message) -> bool { message.name == "VECTOR__INDEPENDENT_SIG_MSG" } +/// Look up an assigned message-level (`BO_`) attribute value. +fn message_attr<'a>(dbc: &'a Dbc, id: MessageId, name: &str) -> Option<&'a AttributeValue> { + dbc.attribute_values_message + .iter() + .find(|a| a.message_id == id && a.name == name) + .map(|a| &a.value) +} + +/// Look up an assigned signal-level (`SG_`) attribute value. +fn signal_attr<'a>( + dbc: &'a Dbc, + id: MessageId, + signal: &str, + name: &str, +) -> Option<&'a AttributeValue> { + dbc.attribute_values_signal + .iter() + .find(|a| a.message_id == id && a.signal_name == signal && a.name == name) + .map(|a| &a.value) +} + +/// Look up an attribute's default value (`BA_DEF_DEF_`). +fn attr_default<'a>(dbc: &'a Dbc, name: &str) -> Option<&'a AttributeValue> { + dbc.attribute_defaults + .iter() + .find(|d| d.name == name) + .map(|d| &d.value) +} + +/// Resolve a [`FieldSource`] to a Rust literal. +fn resolve_field_source( + source: &FieldSource<'_>, + msg: &Message, + dbc: &Dbc, + signal: Option<&Signal>, +) -> Option { + match source { + FieldSource::Attr(name) => { + let value = match signal { + Some(s) => signal_attr(dbc, msg.id, &s.name, name), + None => message_attr(dbc, msg.id, name), + } + .or_else(|| attr_default(dbc, name))?; + Some(attr_value_literal(value)) + } + FieldSource::MessageAttr(name) => { + let value = message_attr(dbc, msg.id, name).or_else(|| attr_default(dbc, name))?; + Some(attr_value_literal(value)) + } + FieldSource::StartBit => signal.map(|s| s.start_bit.to_string()), + FieldSource::StartByte => signal.map(|s| signal_start_byte(s).to_string()), + FieldSource::BitWidth => signal.map(|s| s.size.to_string()), + FieldSource::MessageSize => Some(msg.size.to_string()), + FieldSource::Int(v) => Some(v.to_string()), + FieldSource::Str(v) => Some(format!("{v:?}")), + } +} + +/// Frame-relative byte index of a signal's start bit. +fn signal_start_byte(signal: &Signal) -> u64 { + let start_bit = match signal.byte_order { + LittleEndian | BigEndian => signal.start_bit, + }; + start_bit.checked_div(8).unwrap_or(0) +} + +/// Render an attribute value as an un-suffixed Rust literal. +fn attr_value_literal(value: &AttributeValue) -> String { + match value { + AttributeValue::Uint(v) => v.to_string(), + AttributeValue::Int(v) => v.to_string(), + AttributeValue::Double(v) => format!("{v:?}"), + AttributeValue::String(v) => format!("{v:?}"), + } +} + impl Config<'_> { /// Generate Rust structs matching DBC input description and return as String pub fn generate(self) -> Result { diff --git a/tests/attribute_structs.rs b/tests/attribute_structs.rs new file mode 100644 index 0000000..1405024 --- /dev/null +++ b/tests/attribute_structs.rs @@ -0,0 +1,257 @@ +#![cfg(feature = "std")] + +//! Tests for emitting user-defined structs populated from DBC attributes. + +use dbc_codegen::{AttributeField, AttributeScope, AttributeStruct, Config, FieldSource}; + +const DBC: &str = r#"VERSION "" + +NS_ : + +BS_: + +BU_: ECU1 + +BO_ 256 Protected: 8 ECU1 + SG_ TestSig : 16|8@1+ (1,0) [0|255] "" ECU1 + +BO_ 257 Plain: 8 ECU1 + SG_ OtherSig : 0|8@1+ (1,0) [0|255] "" ECU1 + +BO_ 258 BeProtected: 8 ECU1 + SG_ BeSig : 23|8@0+ (1,0) [0|255] "" ECU1 + +BA_DEF_ BO_ "SC_Message" ENUM "0","1","2"; +BA_DEF_ BO_ "SCP_FreshnessValueId" INT 0 65535; +BA_DEF_ BO_ "TxOffset" INT -128 127; +BA_DEF_ BO_ "Gain" FLOAT 0 10; +BA_DEF_ SG_ "E2EDataId" INT 0 65535; +BA_DEF_ SG_ "E2EDataLength" INT 0 65535; +BA_DEF_ SG_ "E2EProfile" STRING ; +BA_DEF_DEF_ "SC_Message" "0"; +BA_DEF_DEF_ "SCP_FreshnessValueId" 0; +BA_DEF_DEF_ "TxOffset" 0; +BA_DEF_DEF_ "Gain" 0; +BA_DEF_DEF_ "E2EDataId" 0; +BA_DEF_DEF_ "E2EDataLength" 0; +BA_DEF_DEF_ "E2EProfile" "none"; +BA_ "SC_Message" BO_ 256 1; +BA_ "SCP_FreshnessValueId" BO_ 256 1002; +BA_ "TxOffset" BO_ 256 -7; +BA_ "Gain" BO_ 256 2.5; +BA_ "E2EDataId" SG_ 256 TestSig 373; +BA_ "E2EDataLength" SG_ 256 TestSig 48; +BA_ "E2EDataId" SG_ 258 BeSig 500; +BA_ "E2EDataLength" SG_ 258 BeSig 16; +"#; + +fn field(name: &'static str, source: FieldSource<'static>) -> AttributeField<'static> { + AttributeField { name, source } +} + +const E2E: AttributeStruct = AttributeStruct { + type_path: "data_protection::E2EDataIdInfo", + const_name: "E2E", + scope: AttributeScope::Signal, + require: "E2EDataId", + fields: &[ + AttributeField { + name: "data_id", + source: FieldSource::Attr("E2EDataId"), + }, + AttributeField { + name: "start_byte", + source: FieldSource::StartByte, + }, + AttributeField { + name: "width_bit", + source: FieldSource::Attr("E2EDataLength"), + }, + AttributeField { + name: "profile", + source: FieldSource::Attr("E2EProfile"), + }, + ], +}; + +const SEC_OC: AttributeStruct = AttributeStruct { + type_path: "data_protection::SecOcInfo", + const_name: "SEC_OC", + scope: AttributeScope::Message, + require: "SCP_FreshnessValueId", + fields: &[ + AttributeField { + name: "freshness_id", + source: FieldSource::Attr("SCP_FreshnessValueId"), + }, + AttributeField { + name: "sc_message", + source: FieldSource::Attr("SC_Message"), + }, + ], +}; + +const LAYOUT: AttributeStruct = AttributeStruct { + type_path: "layout::SignalLayout", + const_name: "LAYOUT", + scope: AttributeScope::Signal, + require: "E2EDataId", + fields: &[ + AttributeField { + name: "start_bit", + source: FieldSource::StartBit, + }, + AttributeField { + name: "bit_width", + source: FieldSource::BitWidth, + }, + AttributeField { + name: "message_size", + source: FieldSource::MessageSize, + }, + AttributeField { + name: "tx_offset", + source: FieldSource::MessageAttr("TxOffset"), + }, + AttributeField { + name: "gain", + source: FieldSource::MessageAttr("Gain"), + }, + AttributeField { + name: "version", + source: FieldSource::Int(42), + }, + AttributeField { + name: "label", + source: FieldSource::Str("hello"), + }, + ], +}; + +fn try_generate(specs: &[AttributeStruct<'_>]) -> anyhow::Result { + Config::builder() + .dbc_name("attribute_structs_test") + .dbc_content(DBC) + .attribute_structs(specs) + .build() + .generate() +} + +fn generate(specs: &[AttributeStruct<'_>]) -> String { + try_generate(specs).expect("generate") +} + +#[test] +fn default_emits_nothing() { + let out = generate(&[]); + assert!(!out.contains("E2EDataIdInfo"), "{out}"); +} + +#[test] +fn signal_scope_emits_typed_struct_with_derived_layout() { + let out = generate(&[E2E]); + assert!( + out.contains("pub const TEST_SIG_E2E: data_protection::E2EDataIdInfo"), + "{out}" + ); + assert!(out.contains("data_id: 373"), "{out}"); + assert!(out.contains("start_byte: 2"), "{out}"); + assert!(out.contains("width_bit: 48"), "{out}"); + assert!(out.contains(r#"profile: "none""#), "{out}"); +} + +#[test] +fn big_endian_start_byte_uses_start_bit_over_eight() { + let out = generate(&[E2E]); + assert!( + out.contains("pub const BE_SIG_E2E: data_protection::E2EDataIdInfo"), + "{out}" + ); + assert!(out.contains("data_id: 500"), "{out}"); + assert!(out.contains("start_byte: 2"), "{out}"); +} + +#[test] +fn message_scope_emits_once_per_protected_message() { + let out = generate(&[SEC_OC]); + assert!( + out.contains("pub const SEC_OC: data_protection::SecOcInfo"), + "{out}" + ); + assert!(out.contains("freshness_id: 1002"), "{out}"); +} + +#[test] +fn enum_attribute_emitted_as_integer_index() { + let out = generate(&[SEC_OC]); + assert!(out.contains("sc_message: 1"), "{out}"); +} + +#[test] +fn unprotected_message_and_signal_get_no_const() { + let out = generate(&[E2E, SEC_OC]); + assert!(!out.contains("OTHER_SIG_E2E"), "{out}"); + assert_eq!(out.matches("pub const SEC_OC").count(), 1, "{out}"); +} + +#[test] +fn layout_and_literal_sources_resolve() { + let out = generate(&[LAYOUT]); + assert!( + out.contains("pub const TEST_SIG_LAYOUT: layout::SignalLayout"), + "{out}" + ); + // TestSig is `16|8@1+` on an 8-byte message. + assert!(out.contains("start_bit: 16"), "{out}"); + assert!(out.contains("bit_width: 8"), "{out}"); + assert!(out.contains("message_size: 8"), "{out}"); + // Message-level attributes pulled into a signal-scoped struct, covering the + // signed-integer and floating-point attribute-value shapes. + assert!(out.contains("tx_offset: -7"), "{out}"); + assert!(out.contains("gain: 2.5"), "{out}"); + // Literal sources. + assert!(out.contains("version: 42"), "{out}"); + assert!(out.contains(r#"label: "hello""#), "{out}"); +} + +#[test] +fn message_scope_with_signal_source_is_rejected() { + let bad = AttributeStruct { + type_path: "foo::Bar", + const_name: "BAD", + scope: AttributeScope::Message, + require: "SCP_FreshnessValueId", + fields: &[field("x", FieldSource::StartByte)], + }; + let err = format!("{:#}", try_generate(&[bad]).unwrap_err()); + assert!(err.contains("signal-only source"), "{err}"); +} + +#[test] +fn missing_field_value_is_an_error() { + let bad = AttributeStruct { + type_path: "foo::Bar", + const_name: "BAD", + scope: AttributeScope::Signal, + require: "E2EDataId", + fields: &[field("x", FieldSource::Attr("NoSuchAttr"))], + }; + let err = format!("{:#}", try_generate(&[bad]).unwrap_err()); + assert!(err.contains("has no value"), "{err}"); +} + +#[test] +fn duplicate_const_name_is_an_error() { + let dup = AttributeStruct { + type_path: "foo::Bar", + const_name: "SEC_OC", + scope: AttributeScope::Message, + require: "SCP_FreshnessValueId", + fields: &[field( + "freshness_id", + FieldSource::Attr("SCP_FreshnessValueId"), + )], + }; + let err = format!("{:#}", try_generate(&[SEC_OC, dup]).unwrap_err()); + assert!(err.contains("collides"), "{err}"); +}