diff --git a/README.md b/README.md index 8efe03c..62e0305 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ let e2e = AttributeStruct { AttributeField { name: "start_byte", source: FieldSource::StartByte }, AttributeField { name: "width_bit", source: FieldSource::Attr("E2EDataLength") }, ], + for_node: None, }; let dbc_file = ""; @@ -153,6 +154,77 @@ impl SomeMessage { } ``` +DBCs can also attach attributes to relations between a node and a signal (`BA_DEF_REL_ BU_SG_REL_` / `BA_REL_`) or a node and a message (`BA_DEF_REL_ BU_BO_REL_` / `BA_REL_`), for example, a signal timeout. `AttributeScope::NodeSignal` emits one const per receiving node of a matching signal, and `AttributeScope::NodeMessage` emits one const per node related to a matching message. `FieldSource::NodeName` exposes the related node's name and is only valid with these two scopes: + +```rust,no_run +use dbc_codegen::{AttributeField, AttributeScope, AttributeStruct, Config, FieldSource}; + +// `relation::SigTimeoutInfo { node, timeout_ms }` is a type defined in your crate. +let sig_timeout = AttributeStruct { + type_path: "relation::SigTimeoutInfo", + const_name: "SIG_TIMEOUT", + scope: AttributeScope::NodeSignal, // One const per receiving node of a matching signal + require: "GenSigTimeoutTime", // Only nodes carrying this relation attribute + fields: &[ + AttributeField { name: "node", source: FieldSource::NodeName }, + AttributeField { name: "timeout_ms", source: FieldSource::Attr("GenSigTimeoutTime") }, + ], + for_node: None, +}; + +let dbc_file = ""; +Config::builder() + .dbc_name("example.dbc") + .dbc_content(dbc_file) + .attribute_structs(&[sig_timeout]) + .build() + .generate() + .unwrap(); +``` + +For every signal and receiving node pair that carries a `GenSigTimeoutTime` relation attribute, this generates: + +```rust,ignore +impl SomeMessage { + pub const SOME_SIGNAL_ECU2_SIG_TIMEOUT: relation::SigTimeoutInfo = + relation::SigTimeoutInfo { node: "ECU2", timeout_ms: 60 }; +} +``` + +If you're generating code for a single node/ECU, you can set `for_node` to the node name to generates constants only for relation attributes that are relevant to the specific node. This also removes the node name from the constant names, making the generated code node/ECU name-agnostic. + +```rust,no_run +use dbc_codegen::{AttributeField, AttributeScope, AttributeStruct, Config, FieldSource}; + +let sig_timeout_for_ecu2 = AttributeStruct { + type_path: "relation::SigTimeoutInfo", + const_name: "SIG_TIMEOUT", + scope: AttributeScope::NodeSignal, + require: "GenSigTimeoutTime", + fields: &[ + AttributeField { name: "node", source: FieldSource::NodeName }, + AttributeField { name: "timeout_ms", source: FieldSource::Attr("GenSigTimeoutTime") }, + ], + for_node: Some("ECU2"), +}; + +let dbc_file = ""; +Config::builder() + .dbc_name("example.dbc") + .dbc_content(dbc_file) + .attribute_structs(&[sig_timeout_for_ecu2]) + .build() + .generate() + .unwrap(); +``` + +```rust,ignore +impl SomeMessage { + pub const SOME_SIGNAL_SIG_TIMEOUT: relation::SigTimeoutInfo = + relation::SigTimeoutInfo { node: "ECU2", timeout_ms: 60 }; +} +``` + ### `no_std` The generated code is `no_std` compatible. diff --git a/examples/attribute_structs.rs b/examples/attribute_structs.rs index 7f7bbbd..a6f6db6 100644 --- a/examples/attribute_structs.rs +++ b/examples/attribute_structs.rs @@ -44,6 +44,7 @@ fn main() { field("width_bit", FieldSource::Attr("E2EDataLength")), field("profile", FieldSource::Attr("E2EProfile")), ], + for_node: None, }; let secoc = AttributeStruct { type_path: "data_protection::SecOcInfo", @@ -54,6 +55,7 @@ fn main() { "freshness_id", FieldSource::Attr("SCP_FreshnessValueId"), )], + for_node: None, }; let code = Config::builder() diff --git a/src/lib.rs b/src/lib.rs index 07daa56..6e2cff2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,7 +24,10 @@ use can_dbc::MultiplexIndicator::{ MultiplexedSignal, Multiplexor, MultiplexorAndMultiplexedSignal, Plain, }; use can_dbc::ValueType::Signed; -use can_dbc::{AttributeValue, Dbc, Message, MessageId, Signal, ValDescription, ValueDescription}; +use can_dbc::{ + AttributeValue, AttributeValueForRelationType, Dbc, Message, MessageId, Signal, ValDescription, + ValueDescription, +}; use heck::ToSnakeCase; use quote::ToTokens; use typed_builder::TypedBuilder; @@ -35,7 +38,7 @@ use crate::signal_type::{IntSize, ValType}; use crate::utils::{ 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 _, + node_field_name, MessageExt as _, SignalExt as _, }; static ALLOW_DEADCODE: &str = "#[allow(dead_code)]"; @@ -126,16 +129,26 @@ pub struct AttributeStruct<'a> { /// The struct fields, written in this order. pub fields: &'a [AttributeField<'a>], + + /// Restrict [`AttributeScope::NodeSignal`]/[`AttributeScope::NodeMessage`] + /// emission to this one node and remove the node name from the generated + /// const. Set to `None` to keep the default behavior (one constant per + /// matching node). + pub for_node: Option<&'a str>, } /// 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). + /// One const per message (`BO_` attribute). Message, - /// One const per signal (SG_ attribute). + /// One const per signal (`SG_` attribute). Signal, + /// One const per node related to a message (`BU_BO_REL_` relation attribute). + NodeMessage, + /// One const per receiving node of a signal (`BU_SG_REL_` relation attribute). + NodeSignal, } /// One field of an [`AttributeStruct`] and where its value comes from. @@ -169,6 +182,17 @@ pub enum FieldSource<'a> { Int(i64), /// A literal string. Str(&'a str), + /// The name of the related node. + /// [`AttributeScope::NodeSignal`] and [`AttributeScope::NodeMessage`] only. + NodeName, +} + +/// Message/signal/node context used to resolve an [`AttributeStruct`]'s fields. +struct AttrTarget<'a> { + msg: &'a Message, + dbc: &'a Dbc, + signal: Option<&'a Signal>, + node: Option<&'a str>, } impl Config<'_> { @@ -531,7 +555,27 @@ impl Config<'_> { spec.const_name ); - let message_scope = matches!(spec.scope, AttributeScope::Message); + let has_signal = matches!( + spec.scope, + AttributeScope::Signal | AttributeScope::NodeSignal + ); + let has_node = matches!( + spec.scope, + AttributeScope::NodeSignal | AttributeScope::NodeMessage + ); + if let Some(for_node) = spec.for_node { + ensure!( + !for_node.is_empty(), + "attribute_structs: 'for_node' must not be empty for {:?}", + spec.const_name + ); + ensure!( + has_node, + "attribute_structs: {:?} sets 'for_node' but its scope has no associated \ + node (scope must be NodeSignal or NodeMessage)", + spec.const_name + ); + } let mut seen = BTreeSet::new(); for field in spec.fields { ensure!( @@ -546,14 +590,23 @@ impl Config<'_> { field.name, spec.const_name ); - if message_scope { + if !has_signal { 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", + (StartBit/StartByte/BitWidth) but the struct has no associated signal", + field.name, + spec.const_name + ); + } + if !has_node { + ensure!( + !matches!(field.source, FieldSource::NodeName), + "attribute_structs: field {:?} of {:?} uses NodeName but the struct has \ + no associated node (scope must be NodeSignal or NodeMessage)", field.name, spec.const_name ); @@ -592,9 +645,12 @@ impl Config<'_> { w, spec, spec.const_name, - msg, - dbc, - None, + &AttrTarget { + msg, + dbc, + signal: None, + node: None, + }, &mut used, )?; } @@ -612,9 +668,91 @@ impl Config<'_> { w, spec, &name, - msg, - dbc, - Some(signal), + &AttrTarget { + msg, + dbc, + signal: Some(signal), + node: None, + }, + &mut used, + )?; + } + } + AttributeScope::NodeSignal => { + for signal in &msg.signals { + for node in &signal.receivers { + if spec.for_node.is_some_and(|target| node != target) { + continue; + } + if node_relation_attribute( + dbc, + node, + msg.id, + Some(&signal.name), + spec.require, + ) + .is_none() + { + continue; + } + let name = if spec.for_node.is_some() { + format!( + "{}_{}", + signal.field_name().to_uppercase(), + spec.const_name + ) + } else { + let node_ident = node_field_name(node).to_uppercase(); + format!( + "{}_{node_ident}_{}", + signal.field_name().to_uppercase(), + spec.const_name + ) + }; + Self::render_attribute_struct( + w, + spec, + &name, + &AttrTarget { + msg, + dbc, + signal: Some(signal), + node: Some(node), + }, + &mut used, + )?; + } + } + } + AttributeScope::NodeMessage => { + for node in &dbc.nodes { + if spec.for_node.is_some_and(|target| node.0 != target) { + continue; + } + if node_relation_attribute(dbc, &node.0, msg.id, None, spec.require) + .is_none() + { + continue; + } + let name = if spec.for_node.is_some() { + spec.const_name.to_string() + } else { + format!( + "{}_{}", + node_field_name(&node.0).to_uppercase(), + spec.const_name + ) + }; + Self::render_attribute_struct( + w, + spec, + &name, + &AttrTarget { + msg, + dbc, + signal: None, + node: Some(&node.0), + }, &mut used, )?; } @@ -629,21 +767,19 @@ impl Config<'_> { w: &mut impl Write, spec: &AttributeStruct<'_>, const_name: &str, - msg: &Message, - dbc: &Dbc, - signal: Option<&Signal>, + target: &AttrTarget<'_>, 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 + target.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(|| { + let lit = resolve_field_source(&field.source, target).ok_or_else(|| { anyhow!( "attribute_structs: const '{const_name}' field {:?} has no value in the DBC \ and no default (source: {:?})", @@ -1636,16 +1772,21 @@ fn message_ignored(message: &Message) -> bool { } /// Resolve a [`FieldSource`] to a Rust literal. -fn resolve_field_source( - source: &FieldSource<'_>, - msg: &Message, - dbc: &Dbc, - signal: Option<&Signal>, -) -> Option { +fn resolve_field_source(source: &FieldSource<'_>, target: &AttrTarget<'_>) -> Option { + let AttrTarget { + msg, + dbc, + signal, + node, + } = *target; match source { - FieldSource::Attr(name) => match signal { - Some(s) => dbc.resolved_signal_attribute(msg.id, &s.name, name), - None => dbc.resolved_message_attribute(msg.id, name), + FieldSource::Attr(name) => match (signal, node) { + (Some(s), None) => dbc.resolved_signal_attribute(msg.id, &s.name, name), + (None, None) => dbc.resolved_message_attribute(msg.id, name), + (Some(s), Some(n)) => { + resolved_node_relation_attribute(dbc, n, msg.id, Some(&s.name), name) + } + (None, Some(n)) => resolved_node_relation_attribute(dbc, n, msg.id, None, name), } .map(attr_value_literal), FieldSource::MessageAttr(name) => dbc @@ -1657,9 +1798,64 @@ fn resolve_field_source( FieldSource::MessageSize => Some(msg.size.to_string()), FieldSource::Int(v) => Some(v.to_string()), FieldSource::Str(v) => Some(format!("{v:?}")), + FieldSource::NodeName => node.map(|n| format!("{n:?}")), } } +/// Lookup an assigned node relation attribute value. This can be either +/// node-to-signal (`BA_REL_ ... BU_SG_REL_`, `signal_name: Some`) or +/// node-to-message (`BA_REL_ ... BU_BO_REL_`, `signal_name: None`). +fn node_relation_attribute<'a>( + dbc: &'a Dbc, + node_name: &str, + message_id: MessageId, + signal_name: Option<&str>, + name: &str, +) -> Option<&'a AttributeValue> { + dbc.relation_attribute_values.iter().find_map(|rel| { + if rel.name != name { + return None; + } + match (&rel.details, signal_name) { + ( + AttributeValueForRelationType::NodeToSignal { + node_name: n, + message_id: mid, + signal_name: sig, + value, + }, + Some(want_sig), + ) if n == node_name && *mid == message_id && sig == want_sig => Some(value), + ( + AttributeValueForRelationType::NodeToMessage { + node_name: n, + message_id: mid, + value, + }, + None, + ) if n == node_name && *mid == message_id => Some(value), + _ => None, + } + }) +} + +/// Lookup a node relation attribute value. +/// Uses the default (`BA_DEF_DEF_REL_`) if a value is not assigned. +fn resolved_node_relation_attribute<'a>( + dbc: &'a Dbc, + node_name: &str, + message_id: MessageId, + signal_name: Option<&str>, + name: &str, +) -> Option<&'a AttributeValue> { + node_relation_attribute(dbc, node_name, message_id, signal_name, name).or_else(|| { + dbc.relation_attribute_defaults + .iter() + .find(|d| d.name == name) + .map(|d| &d.value) + }) +} + /// Byte index of a signal's start bit. fn signal_start_byte(signal: &Signal) -> u64 { signal.start_bit.checked_div(8).unwrap_or(0) diff --git a/src/utils.rs b/src/utils.rs index d7b1cd1..74db563 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -69,6 +69,10 @@ impl MessageExt for Message { } } +pub fn node_field_name(name: &str) -> String { + sanitize_name(name, "x", ToSnakeCase::to_snake_case) +} + pub fn sanitize_name(x: &str, prefix: &str, to_case: fn(&str) -> String) -> String { if keywords::is_keyword(x) || !x.starts_with(|c: char| c.is_ascii_alphabetic()) { format!("{prefix}{}", to_case(x)) diff --git a/tests/attribute_structs.rs b/tests/attribute_structs.rs index 53be2b3..9e8774a 100644 --- a/tests/attribute_structs.rs +++ b/tests/attribute_structs.rs @@ -11,7 +11,7 @@ NS_ : BS_: -BU_: ECU1 +BU_: ECU1 ECU2 BO_ 256 Protected: 8 ECU1 SG_ TestSig : 16|8@1+ (1,0) [0|255] "" ECU1 @@ -22,6 +22,9 @@ BO_ 257 Plain: 8 ECU1 BO_ 258 BeProtected: 8 ECU1 SG_ BeSig : 23|8@0+ (1,0) [0|255] "" ECU1 +BO_ 259 Related: 8 ECU1 + SG_ RelSig : 0|8@1+ (1,0) [0|255] "" ECU2 + BA_DEF_ BO_ "SC_Message" ENUM "0","1","2"; BA_DEF_ BO_ "SCP_FreshnessValueId" INT 0 65535; BA_DEF_ BO_ "TxOffset" INT -128 127; @@ -44,11 +47,20 @@ BA_ "E2EDataId" SG_ 256 TestSig 373; BA_ "E2EDataLength" SG_ 256 TestSig 48; BA_ "E2EDataId" SG_ 258 BeSig 500; BA_ "E2EDataLength" SG_ 258 BeSig 16; +BA_DEF_REL_ BU_SG_REL_ "GenSigTimeoutTime" INT 0 65535; +BA_DEF_REL_ BU_SG_REL_ "GenSigFirstTimeoutTime" INT 0 65535; +BA_DEF_DEF_REL_ "GenSigTimeoutTime" 0; +BA_DEF_DEF_REL_ "GenSigFirstTimeoutTime" 240; +BA_REL_ "GenSigTimeoutTime" BU_SG_REL_ ECU2 SG_ 259 RelSig 60; +BA_DEF_REL_ BU_BO_REL_ "MsgProject" ENUM "A","B","C"; +BA_DEF_DEF_REL_ "MsgProject" "A"; +BA_REL_ "MsgProject" BU_BO_REL_ ECU1 259 0; +BA_REL_ "MsgProject" BU_BO_REL_ ECU2 259 1; "#; const FIXTURE: &str = "tests/fixtures/shared-test-files/dbc-cantools/attributes.dbc"; -/// Signal-scoped +/// Signal-scoped attributes const E2E: AttributeStruct = AttributeStruct { type_path: "data_protection::E2EDataIdInfo", const_name: "E2E", @@ -72,9 +84,10 @@ const E2E: AttributeStruct = AttributeStruct { source: FieldSource::Attr("E2EProfile"), }, ], + for_node: None, }; -/// Message-scoped +/// Message-scoped attributes const SEC_OC: AttributeStruct = AttributeStruct { type_path: "data_protection::SecOcInfo", const_name: "SEC_OC", @@ -90,6 +103,7 @@ const SEC_OC: AttributeStruct = AttributeStruct { source: FieldSource::Attr("SC_Message"), }, ], + for_node: None, }; /// Non-attribute sources @@ -128,6 +142,65 @@ const LAYOUT: AttributeStruct = AttributeStruct { source: FieldSource::Str("hello"), }, ], + for_node: None, +}; + +/// Node-signal relation attributes +const SIG_TIMEOUT: AttributeStruct = AttributeStruct { + type_path: "relation::SigTimeoutInfo", + const_name: "SIG_TIMEOUT", + scope: AttributeScope::NodeSignal, + require: "GenSigTimeoutTime", + fields: &[ + AttributeField { + name: "node", + source: FieldSource::NodeName, + }, + AttributeField { + name: "timeout_ms", + source: FieldSource::Attr("GenSigTimeoutTime"), + }, + AttributeField { + name: "first_timeout_ms", + source: FieldSource::Attr("GenSigFirstTimeoutTime"), + }, + AttributeField { + name: "start_byte", + source: FieldSource::StartByte, + }, + ], + for_node: None, +}; + +/// Node-message relation attributes +const MSG_PROJECT: AttributeStruct = AttributeStruct { + type_path: "relation::MsgProjectInfo", + const_name: "MSG_PROJECT", + scope: AttributeScope::NodeMessage, + require: "MsgProject", + fields: &[ + AttributeField { + name: "node", + source: FieldSource::NodeName, + }, + AttributeField { + name: "project", + source: FieldSource::Attr("MsgProject"), + }, + ], + for_node: None, +}; + +/// Node-signal relation attributes, filtered to one node +const SIG_TIMEOUT_FOR_ECU2: AttributeStruct = AttributeStruct { + for_node: Some("ECU2"), + ..SIG_TIMEOUT +}; + +/// Node-message relation attributes, filtered to one node +const MSG_PROJECT_FOR_ECU1: AttributeStruct = AttributeStruct { + for_node: Some("ECU1"), + ..MSG_PROJECT }; #[test] @@ -254,6 +327,7 @@ fn message_scope_with_signal_source_is_rejected() { name: "x", source: FieldSource::StartByte, }], + for_node: None, }; let err = Config::builder() .dbc_name("test") @@ -276,6 +350,7 @@ fn missing_field_value_is_an_error() { name: "x", source: FieldSource::Attr("NoSuchAttr"), }], + for_node: None, }; let err = Config::builder() .dbc_name("test") @@ -298,6 +373,7 @@ fn duplicate_const_name_is_an_error() { name: "freshness_id", source: FieldSource::Attr("SCP_FreshnessValueId"), }], + for_node: None, }; let err = Config::builder() .dbc_name("test") @@ -321,6 +397,7 @@ fn non_screaming_snake_case_const_name_is_rejected() { name: "x", source: FieldSource::Attr("SCP_FreshnessValueId"), }], + for_node: None, }; let err = Config::builder() .dbc_name("test") @@ -347,6 +424,7 @@ fn const_name_colliding_with_reserved_const_is_rejected() { name: "x", source: FieldSource::Attr("SCP_FreshnessValueId"), }], + for_node: None, }; let err = Config::builder() .dbc_name("test") @@ -369,6 +447,7 @@ fn invalid_type_path_is_rejected() { name: "x", source: FieldSource::Attr("SCP_FreshnessValueId"), }], + for_node: None, }; let err = Config::builder() .dbc_name("test") @@ -394,6 +473,7 @@ fn invalid_field_name_is_rejected() { name: "1x", // not a valid identifier source: FieldSource::Attr("SCP_FreshnessValueId"), }], + for_node: None, }; let err = Config::builder() .dbc_name("test") @@ -422,6 +502,7 @@ fn duplicate_field_names_are_rejected() { source: FieldSource::Int(1), }, ], + for_node: None, }; let err = Config::builder() .dbc_name("test") @@ -458,6 +539,7 @@ fn fixture_message_scope_resolves_hex_float_enum_and_size() { source: FieldSource::MessageSize, }, ], + for_node: None, }; let bytes = std::fs::read(FIXTURE).unwrap(); let dbc = can_dbc::decode_cp1252(&bytes).unwrap(); @@ -500,6 +582,7 @@ fn fixture_signal_scope_resolves_string_enum_and_layout() { source: FieldSource::BitWidth, }, ], + for_node: None, }; let bytes = std::fs::read(FIXTURE).unwrap(); let dbc = can_dbc::decode_cp1252(&bytes).unwrap(); @@ -529,6 +612,7 @@ fn fixture_absent_attribute_falls_back_to_default() { name: "label", source: FieldSource::Attr("TheSignalStringAttribute"), }], + for_node: None, }; let bytes = std::fs::read(FIXTURE).unwrap(); let dbc = can_dbc::decode_cp1252(&bytes).unwrap(); @@ -555,6 +639,7 @@ fn fixture_enum_attribute_is_index_not_label() { name: "send_type", source: FieldSource::Attr("GenMsgSendType"), }], + for_node: None, }; let bytes = std::fs::read(FIXTURE).unwrap(); let dbc = can_dbc::decode_cp1252(&bytes).unwrap(); @@ -580,6 +665,7 @@ fn fixture_definition_without_default_is_an_error() { name: "x", source: FieldSource::Attr("TheUnusedAttributeDefinitionWithoutDefault"), }], + for_node: None, }; let bytes = std::fs::read(FIXTURE).unwrap(); let dbc = can_dbc::decode_cp1252(&bytes).unwrap(); @@ -592,3 +678,186 @@ fn fixture_definition_without_default_is_an_error() { .unwrap_err(); assert!(format!("{err:#}").contains("has no value"), "{err:#}"); } + +#[test] +fn node_signal_scope_emits_const_per_receiving_node() { + let out = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[SIG_TIMEOUT]) + .build() + .generate() + .unwrap(); + assert!( + out.contains("pub const REL_SIG_ECU2_SIG_TIMEOUT: relation::SigTimeoutInfo"), + "{out}" + ); + assert!(out.contains(r#"node: "ECU2""#), "{out}"); + assert!(out.contains("timeout_ms: 60"), "{out}"); + assert!(out.contains("first_timeout_ms: 240"), "{out}"); // BA_DEF_DEF_REL_ default + assert!(out.contains("start_byte: 0"), "{out}"); +} + +#[test] +fn node_message_scope_emits_const_per_related_node() { + let out = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[MSG_PROJECT]) + .build() + .generate() + .unwrap(); + assert!( + out.contains("pub const ECU1_MSG_PROJECT: relation::MsgProjectInfo"), + "{out}" + ); + assert!( + out.contains("pub const ECU2_MSG_PROJECT: relation::MsgProjectInfo"), + "{out}" + ); + assert!(out.contains(r#"node: "ECU1""#), "{out}"); + assert!(out.contains(r#"node: "ECU2""#), "{out}"); + // The enum value is emitted as its index (0 for "A", 1 for "B"), not the label. + assert!(out.contains("project: 0"), "{out}"); + assert!(out.contains("project: 1"), "{out}"); +} + +#[test] +fn node_signal_scope_with_for_node_drops_node_suffix() { + let out = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[SIG_TIMEOUT_FOR_ECU2]) + .build() + .generate() + .unwrap(); + assert!( + out.contains("pub const REL_SIG_SIG_TIMEOUT: relation::SigTimeoutInfo"), + "{out}" + ); + assert!(!out.contains("ECU2_SIG_TIMEOUT"), "{out}"); + assert!(out.contains(r#"node: "ECU2""#), "{out}"); + assert!(out.contains("timeout_ms: 60"), "{out}"); + assert_eq!( + out.matches("pub const REL_SIG_SIG_TIMEOUT").count(), + 1, + "{out}" + ); +} + +#[test] +fn node_message_scope_with_for_node_drops_node_suffix() { + let out = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[MSG_PROJECT_FOR_ECU1]) + .build() + .generate() + .unwrap(); + assert!( + out.contains("pub const MSG_PROJECT: relation::MsgProjectInfo"), + "{out}" + ); + assert!(!out.contains("ECU1_MSG_PROJECT"), "{out}"); + assert!(!out.contains("ECU2_MSG_PROJECT"), "{out}"); + assert!(out.contains(r#"node: "ECU1""#), "{out}"); + assert!(out.contains("project: 0"), "{out}"); + assert_eq!(out.matches("pub const MSG_PROJECT").count(), 1, "{out}"); +} + +#[test] +fn for_node_targeting_absent_node_emits_nothing() { + let spec = AttributeStruct { + for_node: Some("ECU1"), // ECU1 is not a receiver of RelSig and has no GenSigTimeoutTime + ..SIG_TIMEOUT + }; + let out = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[spec]) + .build() + .generate() + .unwrap(); + assert!(!out.contains("SIG_TIMEOUT"), "{out}"); +} + +#[test] +fn for_node_with_message_scope_is_rejected() { + let bad = AttributeStruct { + for_node: Some("ECU1"), + ..SEC_OC + }; + let err = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[bad]) + .build() + .generate() + .unwrap_err(); + assert!(format!("{err:#}").contains("no associated node"), "{err:#}"); +} + +#[test] +fn empty_for_node_is_rejected() { + let bad = AttributeStruct { + for_node: Some(""), + ..SIG_TIMEOUT + }; + let err = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[bad]) + .build() + .generate() + .unwrap_err(); + assert!( + format!("{err:#}").contains("'for_node' must not be empty"), + "{err:#}" + ); +} + +#[test] +fn node_name_source_is_rejected_outside_node_scopes() { + let bad = AttributeStruct { + type_path: "foo::Bar", + const_name: "BAD", + scope: AttributeScope::Signal, + require: "E2EDataId", + fields: &[AttributeField { + name: "node", + source: FieldSource::NodeName, + }], + for_node: None, + }; + let err = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[bad]) + .build() + .generate() + .unwrap_err(); + assert!(format!("{err:#}").contains("NodeName"), "{err:#}"); +} + +#[test] +fn node_message_scope_with_signal_source_is_rejected() { + let bad = AttributeStruct { + type_path: "foo::Bar", + const_name: "BAD", + scope: AttributeScope::NodeMessage, + require: "MsgProject", + fields: &[AttributeField { + name: "x", + source: FieldSource::StartByte, + }], + for_node: None, + }; + 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:#}"); +}