diff --git a/README.md b/README.md index 56de8b15..f6cb989a 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 - for example, parameters describing an AUTOSAR E2E protection scheme for specific CAN signals. `attribute_structs` lets you expose this metadata as typed constants in the generated message types. + +You can declare a struct in your own crate 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 00000000..7f7bbbd8 --- /dev/null +++ b/examples/attribute_structs.rs @@ -0,0 +1,68 @@ +//! Demonstrates [`Config::attribute_structs`]. +//! Emits constants for AUTOSAR E2E / SecOC-style metadata 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"); + + println!("{code}"); +} diff --git a/src/lib.rs b/src/lib.rs index fbaa3144..0c12a613 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,7 +24,7 @@ use can_dbc::MultiplexIndicator::{ MultiplexedSignal, Multiplexor, MultiplexorAndMultiplexedSignal, Plain, }; use can_dbc::ValueType::Signed; -use can_dbc::{Dbc, Message, MessageId, Signal, ValDescription, ValueDescription}; +use can_dbc::{AttributeValue, Dbc, Message, MessageId, Signal, ValDescription, ValueDescription}; use heck::ToSnakeCase; use quote::ToTokens; use typed_builder::TypedBuilder; @@ -33,8 +33,9 @@ pub use crate::feature_config::FeatureConfig; use crate::pad::PadAdapter; use crate::signal_type::{IntSize, ValType}; use crate::utils::{ - enum_name, enum_variant_name, multiplex_enum_name, multiplexed_enum_variant_name, - multiplexed_enum_variant_wrapper_name, MessageExt as _, SignalExt as _, + enum_name, enum_variant_name, is_screaming_snake_case, is_valid_ident, is_valid_type_path, + multiplex_enum_name, multiplexed_enum_variant_name, multiplexed_enum_variant_wrapper_name, + MessageExt as _, SignalExt as _, }; static ALLOW_DEADCODE: &str = "#[allow(dead_code)]"; @@ -95,11 +96,83 @@ 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. + /// 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 attributes and emits as an +/// associated constant in the generated message type. +#[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 +363,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 +489,170 @@ impl Config<'_> { Ok(()) } + /// Validate the [`AttributeStruct`] specs. + fn validate_attribute_structs(&self) -> Result<()> { + for spec in self.attribute_structs { + // Require SCREAMING_SNAKE_CASE to guarantee that the name never collides + // with a generated method (all `snake_case`). + ensure!( + is_screaming_snake_case(spec.const_name), + "attribute_structs: 'const_name' {:?} must be SCREAMING_SNAKE_CASE", + spec.const_name + ); + ensure!( + is_valid_type_path(spec.type_path), + "attribute_structs: 'type_path' {:?} for {:?} is not a valid Rust type path", + spec.type_path, + 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 + ); + + let message_scope = matches!(spec.scope, AttributeScope::Message); + let mut seen = BTreeSet::new(); + for field in spec.fields { + ensure!( + is_valid_ident(field.name), + "attribute_structs: field name {:?} in {:?} is not a valid Rust identifier", + field.name, + spec.const_name + ); + ensure!( + seen.insert(field.name), + "attribute_structs: duplicate field {:?} in {:?}", + field.name, + spec.const_name + ); + if message_scope { + 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 associated item already emitted for this message type so + // that colliding const names are rejected here instead of producing code + // that fails to build. Using a BTree to ensure deterministic output. + 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 dbc.message_attribute(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 dbc + .signal_attribute(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, @@ -1325,6 +1564,49 @@ fn message_ignored(message: &Message) -> bool { message.name == "VECTOR__INDEPENDENT_SIG_MSG" } +/// 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) => dbc.resolved_signal_attribute(msg.id, &s.name, name), + None => dbc.resolved_message_attribute(msg.id, name), + }?; + Some(attr_value_literal(value)) + } + FieldSource::MessageAttr(name) => { + let value = dbc.resolved_message_attribute(msg.id, 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:?}")), + } +} + +/// Byte index of a signal's start bit. +fn signal_start_byte(signal: &Signal) -> u64 { + signal.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/src/utils.rs b/src/utils.rs index c787c88d..d7b1cd11 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -89,3 +89,59 @@ pub fn enum_variant_name(x: &str) -> String { pub fn is_integer(val: f64) -> bool { val.fract().abs() < f64::EPSILON } + +/// Check whether `s` is usable as a Rust identifier (const or field name). +/// `syn` accepts exactly valid, non-keyword identifiers, so it also rejects +/// keywords, the bare wildcard `_`, and anything not identifier-shaped. +pub fn is_valid_ident(s: &str) -> bool { + syn::parse_str::(s).is_ok() +} + +/// Check whether `s` is valid with no lowercase letters. +pub fn is_screaming_snake_case(s: &str) -> bool { + is_valid_ident(s) && !s.bytes().any(|b| b.is_ascii_lowercase()) +} + +/// Check whether `s` is a usable Rust type path, e.g. `crate::Foo`. +/// `syn` validates the whole path (including generics), while we +/// reject a qualified self-type (`::X`) and a leading `::`, +/// since neither makes sense as an emitted constant's type. +pub fn is_valid_type_path(s: &str) -> bool { + matches!( + syn::parse_str::(s), + Ok(tp) if tp.qself.is_none() && tp.path.leading_colon.is_none() + ) +} + +#[cfg(test)] +mod tests { + use super::{is_screaming_snake_case, is_valid_ident, is_valid_type_path}; + + #[test] + fn idents() { + assert!(is_valid_ident("data_id")); + assert!(is_valid_ident("E2E")); + assert!(!is_valid_ident("1bad")); + assert!(!is_valid_ident("_")); + assert!(!is_valid_ident("type")); // keyword + assert!(!is_valid_ident("with-dash")); + } + + #[test] + fn screaming_snake_case() { + assert!(is_screaming_snake_case("E2E_DATA_ID")); + assert!(!is_screaming_snake_case("mixedCase")); + assert!(!is_screaming_snake_case("new")); + } + + #[test] + fn type_paths() { + assert!(is_valid_type_path("data_protection::E2EDataIdInfo")); + assert!(is_valid_type_path("crate::Foo")); + assert!(is_valid_type_path("my::Wrapper")); // generics now accepted + assert!(!is_valid_type_path("1bad::Type")); // digit-leading segment + assert!(!is_valid_type_path("")); // empty + assert!(!is_valid_type_path("::foo")); // leading `::` + assert!(!is_valid_type_path("::X")); // qualified self-type + } +} diff --git a/tests/attribute_structs.rs b/tests/attribute_structs.rs new file mode 100644 index 00000000..53be2b31 --- /dev/null +++ b/tests/attribute_structs.rs @@ -0,0 +1,594 @@ +#![cfg(feature = "std")] + +//! Tests for emitting user-defined structs populated from DBC attributes +//! (`Config::attribute_structs`). + +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; +"#; + +const FIXTURE: &str = "tests/fixtures/shared-test-files/dbc-cantools/attributes.dbc"; + +/// Signal-scoped +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"), + }, + ], +}; + +/// Message-scoped +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"), + }, + ], +}; + +/// Non-attribute sources +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"), + }, + ], +}; + +#[test] +fn default_emits_nothing() { + let out = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .build() + .generate() + .unwrap(); + assert!(!out.contains("E2EDataIdInfo"), "{out}"); +} + +#[test] +fn signal_scope_emits_typed_struct_with_derived_layout() { + let out = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[E2E]) + .build() + .generate() + .unwrap(); + 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}"); // start_bit 16 / 8 + assert!(out.contains("width_bit: 48"), "{out}"); + assert!(out.contains(r#"profile: "none""#), "{out}"); // BA_DEF_DEF_ default +} + +#[test] +fn big_endian_start_byte_uses_start_bit_over_eight() { + // BeSig is `23|8@0+` (Motorola): start byte is still start_bit / 8 == 2. + let out = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[E2E]) + .build() + .generate() + .unwrap(); + 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 = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[SEC_OC]) + .build() + .generate() + .unwrap(); + 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 = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[SEC_OC]) + .build() + .generate() + .unwrap(); + // The enum value is emitted as its index (1), not the label. + assert!(out.contains("sc_message: 1"), "{out}"); +} + +#[test] +fn unprotected_message_and_signal_get_no_const() { + let out = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[E2E, SEC_OC]) + .build() + .generate() + .unwrap(); + 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() { + // TestSig is `16|8@1+` on an 8-byte message. + let out = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[LAYOUT]) + .build() + .generate() + .unwrap(); + assert!( + out.contains("pub const TEST_SIG_LAYOUT: layout::SignalLayout"), + "{out}" + ); + assert!(out.contains("start_bit: 16"), "{out}"); + assert!(out.contains("bit_width: 8"), "{out}"); + assert!(out.contains("message_size: 8"), "{out}"); + assert!(out.contains("tx_offset: -7"), "{out}"); + assert!(out.contains("gain: 2.5"), "{out}"); + 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: &[AttributeField { + name: "x", + source: FieldSource::StartByte, + }], + }; + let err = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[bad]) + .build() + .generate() + .unwrap_err(); + assert!(format!("{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: &[AttributeField { + name: "x", + source: FieldSource::Attr("NoSuchAttr"), + }], + }; + let err = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[bad]) + .build() + .generate() + .unwrap_err(); + assert!(format!("{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", // collides with SEC_OC + scope: AttributeScope::Message, + require: "SCP_FreshnessValueId", + fields: &[AttributeField { + name: "freshness_id", + source: FieldSource::Attr("SCP_FreshnessValueId"), + }], + }; + let err = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[SEC_OC, dup]) + .build() + .generate() + .unwrap_err(); + assert!(format!("{err:#}").contains("collides"), "{err:#}"); +} + +#[test] +fn non_screaming_snake_case_const_name_is_rejected() { + for name in ["1bad", "new", "raw", "with-dash"] { + let bad = AttributeStruct { + type_path: "foo::Bar", + const_name: name, + scope: AttributeScope::Message, + require: "SCP_FreshnessValueId", + fields: &[AttributeField { + name: "x", + source: FieldSource::Attr("SCP_FreshnessValueId"), + }], + }; + let err = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[bad]) + .build() + .generate() + .unwrap_err(); + assert!( + format!("{err:#}").contains("SCREAMING_SNAKE_CASE"), + "{name}: {err:#}" + ); + } +} + +#[test] +fn const_name_colliding_with_reserved_const_is_rejected() { + let bad = AttributeStruct { + type_path: "foo::Bar", + const_name: "MESSAGE_ID", // Collides with the generated const MESSAGE_ID + scope: AttributeScope::Message, + require: "SCP_FreshnessValueId", + fields: &[AttributeField { + name: "x", + source: FieldSource::Attr("SCP_FreshnessValueId"), + }], + }; + let err = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[bad]) + .build() + .generate() + .unwrap_err(); + assert!(format!("{err:#}").contains("collides"), "{err:#}"); +} + +#[test] +fn invalid_type_path_is_rejected() { + let bad = AttributeStruct { + type_path: "1bad::Type", // not a valid type path + const_name: "OK", + scope: AttributeScope::Message, + require: "SCP_FreshnessValueId", + fields: &[AttributeField { + name: "x", + source: FieldSource::Attr("SCP_FreshnessValueId"), + }], + }; + let err = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[bad]) + .build() + .generate() + .unwrap_err(); + assert!( + format!("{err:#}").contains("not a valid Rust type path"), + "{err:#}" + ); +} + +#[test] +fn invalid_field_name_is_rejected() { + let bad = AttributeStruct { + type_path: "foo::Bar", + const_name: "OK", + scope: AttributeScope::Message, + require: "SCP_FreshnessValueId", + fields: &[AttributeField { + name: "1x", // not a valid identifier + source: FieldSource::Attr("SCP_FreshnessValueId"), + }], + }; + let err = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[bad]) + .build() + .generate() + .unwrap_err(); + assert!(format!("{err:#}").contains("field name"), "{err:#}"); +} + +#[test] +fn duplicate_field_names_are_rejected() { + let bad = AttributeStruct { + type_path: "foo::Bar", + const_name: "OK", + scope: AttributeScope::Message, + require: "SCP_FreshnessValueId", + fields: &[ + AttributeField { + name: "dup", + source: FieldSource::Attr("SCP_FreshnessValueId"), + }, + AttributeField { + name: "dup", + source: FieldSource::Int(1), + }, + ], + }; + let err = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[bad]) + .build() + .generate() + .unwrap_err(); + assert!(format!("{err:#}").contains("duplicate field"), "{err:#}"); +} + +#[test] +fn fixture_message_scope_resolves_hex_float_enum_and_size() { + let spec = AttributeStruct { + type_path: "MsgAttrs", + const_name: "ATTRS", + scope: AttributeScope::Message, + require: "TheHexAttribute", + fields: &[ + AttributeField { + name: "hex_attr", + source: FieldSource::Attr("TheHexAttribute"), + }, + AttributeField { + name: "float_attr", + source: FieldSource::Attr("TheFloatAttribute"), + }, + AttributeField { + name: "send_type", + source: FieldSource::Attr("GenMsgSendType"), + }, + AttributeField { + name: "size_bytes", + source: FieldSource::MessageSize, + }, + ], + }; + let bytes = std::fs::read(FIXTURE).unwrap(); + let dbc = can_dbc::decode_cp1252(&bytes).unwrap(); + let out = Config::builder() + .dbc_name("attributes.dbc") + .dbc_content(&dbc) + .attribute_structs(&[spec]) + .build() + .generate() + .unwrap(); + assert!(out.contains("hex_attr: 5"), "{out}"); + assert!(out.contains("float_attr: 58.7"), "{out}"); + assert!(out.contains("send_type: 0"), "{out}"); + assert!(out.contains("size_bytes: 8"), "{out}"); + assert_eq!(out.matches("pub const ATTRS").count(), 1, "{out}"); +} + +#[test] +fn fixture_signal_scope_resolves_string_enum_and_layout() { + let spec = AttributeStruct { + type_path: "SigAttrs", + const_name: "SIG", + scope: AttributeScope::Signal, + require: "TheSignalStringAttribute", + fields: &[ + AttributeField { + name: "label", + source: FieldSource::Attr("TheSignalStringAttribute"), + }, + AttributeField { + name: "send_type", + source: FieldSource::Attr("GenSigSendType"), + }, + AttributeField { + name: "start_byte", + source: FieldSource::StartByte, + }, + AttributeField { + name: "width", + source: FieldSource::BitWidth, + }, + ], + }; + let bytes = std::fs::read(FIXTURE).unwrap(); + let dbc = can_dbc::decode_cp1252(&bytes).unwrap(); + let out = Config::builder() + .dbc_name("attributes.dbc") + .dbc_content(&dbc) + .attribute_structs(&[spec]) + .build() + .generate() + .unwrap(); + assert!(out.contains(r#"label: "TestString""#), "{out}"); + assert!(out.contains("send_type: 1"), "{out}"); + assert!(out.contains("start_byte: 0"), "{out}"); + assert!(out.contains("width: 8"), "{out}"); + assert_eq!(out.matches("_SIG: SigAttrs").count(), 1, "{out}"); +} + +#[test] +fn fixture_absent_attribute_falls_back_to_default() { + // GenSigSendType is present on the signal in both messages. + let spec = AttributeStruct { + type_path: "SigAttrs", + const_name: "SIG2", + scope: AttributeScope::Signal, + require: "GenSigSendType", + fields: &[AttributeField { + name: "label", + source: FieldSource::Attr("TheSignalStringAttribute"), + }], + }; + let bytes = std::fs::read(FIXTURE).unwrap(); + let dbc = can_dbc::decode_cp1252(&bytes).unwrap(); + let out = Config::builder() + .dbc_name("attributes.dbc") + .dbc_content(&dbc) + .attribute_structs(&[spec]) + .build() + .generate() + .unwrap(); + assert_eq!(out.matches("pub const THE_SIGNAL_SIG2").count(), 2, "{out}"); + assert!(out.contains(r#"label: "TestString""#), "{out}"); + assert!(out.contains(r#"label: "100""#), "{out}"); // BA_DEF_DEF_ default +} + +#[test] +fn fixture_enum_attribute_is_index_not_label() { + let spec = AttributeStruct { + type_path: "SendTypeInfo", + const_name: "ST", + scope: AttributeScope::Message, + require: "GenMsgSendType", + fields: &[AttributeField { + name: "send_type", + source: FieldSource::Attr("GenMsgSendType"), + }], + }; + let bytes = std::fs::read(FIXTURE).unwrap(); + let dbc = can_dbc::decode_cp1252(&bytes).unwrap(); + let out = Config::builder() + .dbc_name("attributes.dbc") + .dbc_content(&dbc) + .attribute_structs(&[spec]) + .build() + .generate() + .unwrap(); + assert!(out.contains("send_type: 0"), "{out}"); + assert!(!out.contains("Cyclic"), "{out}"); +} + +#[test] +fn fixture_definition_without_default_is_an_error() { + let spec = AttributeStruct { + type_path: "Bad", + const_name: "BAD", + scope: AttributeScope::Message, + require: "TheHexAttribute", + fields: &[AttributeField { + name: "x", + source: FieldSource::Attr("TheUnusedAttributeDefinitionWithoutDefault"), + }], + }; + let bytes = std::fs::read(FIXTURE).unwrap(); + let dbc = can_dbc::decode_cp1252(&bytes).unwrap(); + let err = Config::builder() + .dbc_name("attributes.dbc") + .dbc_content(&dbc) + .attribute_structs(&[spec]) + .build() + .generate() + .unwrap_err(); + assert!(format!("{err:#}").contains("has no value"), "{err:#}"); +}